В статье описан очередной примитивный процессор и ассемблер для него.
Вместо обычных RISC/СISC, процессор не обладает набором инструкций как таковым, есть только единственная инструкция копирования.


Подобные процессоры есть у Maxim серия MAXQ.


Для начала опишем ROM, память программ


module rom1r(addr_r, data_r);
  parameter ADDR_WIDTH = 8;
  parameter DATA_WIDTH = 8;
  input [ADDR_WIDTH - 1 : 0] addr_r;
  output [DATA_WIDTH - 1 : 0] data_r;
  reg [DATA_WIDTH - 1 : 0] mem [0 : (1<<ADDR_WIDTH) - 1];
  initial $readmemh("rom.txt", mem, 0, (1<<ADDR_WIDTH) - 1);
  assign data_r = mem[addr_r];
endmodule

двухпортовую RAM для памяти данных


module ram1r1w(clk_wr, addr_w, data_w, addr_r, data_r);
  parameter ADDR_WIDTH = 8;
  parameter DATA_WIDTH = 8;
  input clk_wr;
  input [ADDR_WIDTH - 1 : 0] addr_r, addr_w;
  output [DATA_WIDTH - 1 : 0] data_r;
  input [DATA_WIDTH - 1 : 0] data_w;
  reg [DATA_WIDTH - 1 : 0] mem [0 : (1<<ADDR_WIDTH) - 1];
  assign data_r = mem[addr_r];
  always @ (posedge clk_wr) mem[addr_w] <= data_w;
endmodule

и сам процессор


module cpu(clk, reset, port);
  parameter WIDTH = 8;
  parameter RAM_SIZE = WIDTH;
  parameter ROM_SIZE = WIDTH;
  input clk, reset;
  output [WIDTH-1 : 0] port;

Ему, как минимум, нужен регистр счётчика команд, а также один вспомогательный регистр, ну и регистр IO порта, чтобы было что показать наружу из нашего процессора.


  reg [WIDTH-1 : 0] reg_pc;
  reg [WIDTH-1 : 0] reg_reg;
  reg [WIDTH-1 : 0] reg_port;
  assign port = reg_port;

Счётчик команд и будет адресом для памяти программ.


  wire [WIDTH-1 : 0] addr_w, addr_r, data_r, data_w, data;
  rom1r rom (reg_pc, {addr_w, addr_r});
  defparam rom.ADDR_WIDTH = ROM_SIZE;
  defparam rom.DATA_WIDTH = RAM_SIZE * 2;

Память программ удвоенной ширины содержит два адреса: куда и откуда скопировать данные в двухпортовой памяти данных.


  ram1r1w ram (clk, addr_w, data_w, addr_r, data_r);
  defparam ram.ADDR_WIDTH = RAM_SIZE;
  defparam ram.DATA_WIDTH = WIDTH;

Обозначим специальные адреса: счётчик команд, генератор констант, проверка на 0 (для условных переходов), операций сложения/вычитания и порт ввода-вывода, в данном случае пока только вывода.


  parameter PC  = 0;
  parameter CG  = 1;
  parameter TST = 2;
  parameter ADD = 3;
  parameter SUB = 4;
  parameter PORT = 5;

Шины данных двух портов памяти не просто соединены между собой, а через мультиплексоры, которые и будут заодно выполнять роль АЛУ.


Один мультиплексор — на шине данных порта чтения, чтобы вместо памяти по определённым адресам читать счётчик команд (для относительных переходов), IO, и т.д.


Второй — на шине данных порта записи, чтобы не только перекладывать данные в памяти, но ещё и при записи по определённым адресам изменять их.


  assign data   = (addr_r == PC)   ? reg_pc : 
                  (addr_r == PORT) ? reg_port : 
                   data_r;

  assign data_w = (addr_w == CG)  ? addr_r : 
                  (addr_w == TST) ? |data  : 
                  (addr_w == ADD) ? data + reg_reg : 
                  (addr_w == SUB) ? data - reg_reg : 
                   data;

Вспомогательный регистр reg_reg, который используется для арифметических действий не доступен напрямую, но в него копируется результат выполнения каждой инструкции.


Таким образом для сложения двух значений из памяти надо одно из них сначала прочитать куда угодно, например, скопировать самого в себя (и заодно в reg_reg), а следующая команда записи по адресу сумматора запишет туда уже сумму с предыдущим значением.


Генератор констант записывает в себя адрес, а не значение памяти по этому адресу.


Для безусловных переходов надо просто скопировать нужный адрес в reg_pc, а для условных переходов зарезервируем ещё один адрес TST, который превращает любое ненулевое значение в 1, и заодно увеличивает счётчик команд на 2 вместо 1 для пропуска следующей за ним команды, если результат не 0.


  always @ (posedge clk) begin
    if (reset) begin
      reg_pc <= 0;
    end else begin
      reg_reg <= data_w;
      if (addr_w == PC) begin
        reg_pc <= data_w; 
      end else begin
        reg_pc <= reg_pc + (((addr_w == TST) && data_w[0]) ? 2 : 1);
        case (addr_w)
          PORT: reg_port <= data_w;
        endcase
      end
    end
  end
endmodule

cpu.v
module rom1r(addr_r, data_r);
  parameter ADDR_WIDTH = 8;
  parameter DATA_WIDTH = 8;
  input [ADDR_WIDTH - 1 : 0] addr_r;
  output [DATA_WIDTH - 1 : 0] data_r;
  reg [DATA_WIDTH - 1 : 0] mem [0 : (1<<ADDR_WIDTH) - 1];
  initial $readmemh("rom.txt", mem, 0, (1<<ADDR_WIDTH) - 1);
  assign data_r = mem[addr_r];
endmodule

module ram1r1w(write, addr_w, data_w, addr_r, data_r);
  parameter ADDR_WIDTH = 8;
  parameter DATA_WIDTH = 8;
  input write;
  input [ADDR_WIDTH - 1 : 0] addr_r, addr_w;
  output [DATA_WIDTH - 1 : 0] data_r;
  input [DATA_WIDTH - 1 : 0] data_w;
  reg [DATA_WIDTH - 1 : 0] mem [0 : (1<<ADDR_WIDTH) - 1];
  assign data_r = mem[addr_r];
  always @ (posedge write) mem[addr_w] <= data_w;
endmodule

module cpu(clk, reset, port);
  parameter WIDTH = 8;
  parameter RAM_SIZE = 8;
  parameter ROM_SIZE = 8;

  parameter PC  = 0;
  parameter CG  = 1;
  parameter TST = 2;
  parameter ADD = 3;
  parameter SUB = 4;
  parameter PORT = 5;

  input clk, reset;
  output [WIDTH-1 : 0] port;

  wire [WIDTH-1 : 0] addr_r, addr_w, data_r, data_w, data;

  reg [WIDTH-1 : 0] reg_pc;
  reg [WIDTH-1 : 0] reg_reg;
  reg [WIDTH-1 : 0] reg_port;
  assign port = reg_port;

  rom1r rom(reg_pc, {addr_w, addr_r});
  defparam rom.ADDR_WIDTH = ROM_SIZE;
  defparam rom.DATA_WIDTH = RAM_SIZE * 2;

  ram1r1w ram (clk, addr_w, data_w, addr_r, data_r);
  defparam ram.ADDR_WIDTH = RAM_SIZE;
  defparam ram.DATA_WIDTH = WIDTH;

  assign data   = (addr_r == PC)   ? reg_pc : 
                  (addr_r == PORT) ? reg_port : 
                   data_r;

  assign data_w = (addr_w == CG)  ? addr_r : 
                  (addr_w == TST) ? |data  : 
                  (addr_w == ADD) ? data + reg_reg : 
                  (addr_w == SUB) ? data - reg_reg : 
                   data;

  always @ (posedge clk) begin
    if (reset) begin
      reg_pc <= 0;
    end else begin
      reg_reg <= data_w;
      if (addr_w == PC) begin
        reg_pc <= data_w; 
      end else begin
        reg_pc <= reg_pc + (((addr_w == TST) && data_w[0]) ? 2 : 1);
        case (addr_w)
          PORT: reg_port <= data_w;
        endcase
      end
    end
  end
endmodule

Вот собственно и весь процессор.


Assembler


Теперь напишем для него простую программу, которая просто выдаёт последовательно значения в порт, и останавливается на 5.


Писать ассемблер самому, даже такой простой (весь синтаксис A = B), было лень, поэтому вместо этого за основу был взят готовый язык Lua, который очень хорошо подходит для построения различных Domain Specific Language на его основе, заодно на халяву получим готовый Lua препроцессор.


Cначала объявление специальных адресов, запись в которые изменяет данные и переменная счётчика по адресу 7


require ("asm")

PC  = mem(0)
CG  = mem(1)
TST = mem(2)
ADD = mem(3)
SUB = mem(4)
PORT    = mem(5)

cnt = mem(7)

Вместо макросов можно использовать обычные функции Lua, правда из-за того что метатаблица окружения _G была изменена для отлавливания присваиваний (см. ниже), заодно отвалились и глобальные переменные: объявление нелокальной переменной some_variable = 0xAA наш ассемблер посчитает "своим" и попробует разобрать, вместо этого для объявлений глобальной переменной препроцессора придётся использовать rawset(_G, some_variable, 0xAA), который не трогает метаметоды.


function jmp(l)
  CG    = l
  PC    = CG
end

Метки будем обозначать словом label и строковыми константами, в Lua в случае единственного строкового аргумента у функции скобки можно опустить.


label "start"

Обнулим счётчик и регистр порта:


CG  = 0
cnt = CG
PORT    = CG

В цикле загружаем константу 1, добавляем её к переменной счётчика и показываем в порт:


label "loop"
CG  = 1
ADD = cnt       -- add = cnt + 1
cnt = ADD
PORT    = ADD

Добавляем недостающее до переполнения в 0 и, если там не ноль, переходим в начало, пропуская CG="exit", иначе заканчиваем в бесконечном цикле "exit".


CG  = -5
ADD = ADD       --add = add + 251
CG  = "loop"
TST = ADD       --skip "exit" if not 0
CG  = "exit"
PC  = CG

label "exit"
jmp "exit"

test.lua
require ("asm")

PC  = mem(0)
CG  = mem(1)
TST = mem(2)
ADD = mem(3)
SUB = mem(4)
PORT    = mem(5)

cnt = mem(7)

function jmp(l)
  CG    = l
  PC    = CG
end

label "start"
CG  = 0
cnt = CG
PORT    = CG

label "loop"
CG  = 1
ADD = cnt   -- add = cnt + 1
cnt = ADD
PORT    = ADD

CG  = -5
ADD = ADD       --add = add + 256 - 5
CG  = "loop"
TST = ADD       --skip "exit" if not 0
CG  = "exit"
PC  = CG

label "exit"
jmp "exit"

А теперь и собственно сам ассемблер asm.lua, как положено в 20 строк:


В функцию mem (для объявления специальных адресов) надо бы ещё добавить автоматическое присвоение очередного свободного адреса, если таковой не указан в качестве аргумента.
А для меток надо бы сделать проверку на повторное объявление существующей метки


local output = {}
local labels = {}
function mem(addr) return addr end
function label(name) labels[name] = #output end

В Lua нет метаметода для присвоения, но есть метаметоды для индексации существующих значений и для добавления новых, в том числе и для таблицы глобального окружения _G.
Так как __newindex срабатывает только для несуществующих в таблице значений, то вместо добавления новых элементов в _G, надо их куда-то перепрятать, не добавляя в _G, и, соответственно, достать оттуда, когда к ним обратились через __index.


Если имя уже существует, то добавляем данную инструкцию к остальным.


local g = {}
setmetatable(_G, {
  __index = function(t, k, v) return g[k] end,
  __newindex = function(t, k, v) 
    if g[k] then table.insert(output, {g[k], v})
    else g[k]=v end 
  end
})             

Ну и после выполнения программы ассемблера, когда сборщик мусора наконец придёт за массивом с нашей программой output, просто напечатаем её, заодно заменяя текстовые метки на правильные адреса.


setmetatable(output, {
  __gc = function(o)
    for i,v in ipairs(o) do 
      if type(v[2]) == "string" then v[2] = labels[v[2]] or print("error: ", v[2]) end
      print(string.format("%02X%02X", v[1] & 0xFF, v[2] & 0xFF)) 
    end 
  end
})

asm.lua
local output = {}
local labels = {}

function mem(addr) return addr end
function label(name) labels[name] = #output end

local g = {}
setmetatable(_G, {
  __index = function(t, k, v) return g[k] end,
  __newindex = function(t, k, v) 
    if g[k] then table.insert(output, {g[k], v})
    else g[k]=v end 
  end
}) 

setmetatable(output, {
  __gc = function(o)
    for i,v in ipairs(o) do 
      if type(v[2]) == "string" then v[2] = labels[v[2]] or print("error: ", v[2]) end
      print(string.format("%02X%02X", v[1] & 0xFF, v[2] & 0xFF))  --FIX for WIDTH > 8
    end 
  end
})

Запустив lua53 test.lua > rom.txt (или онлайн) получим программу для процессора в машинных кодах.


rom.txt
0100
0701
0501
0101
0307
0703
0503
01FB
0303
0103
0203
010D
0001
010D
0001

Для симуляции сделаем простой тестбенч, который лишь отпускает ресет и дергает клоки.


test.v
`include "cpu.v"

module test();
reg clk;
reg reset;
wire [7:0] port; 

cpu c(clk, reset, port);

initial
begin
  $dumpfile("test.vcd");
  reset <= 1;
  clk <= 0;
  #4 reset <= 0;
  #150 $finish;
end

always #1 clk <= !clk;

endmodule 

Просимулировав с помощью iverilog -o test.vvp test.v откроем получившийся test.vcd в GTKWave:

порт считает до пяти, а потом процессор зацикливается.


Теперь когда есть минимально рабочий процессор, в него, по мере надобности, можно добавлять остальную арифметику, логические операции, умножение, деление, плавающую запятую, тригонометрию, регистры для косвенного доступа к памяти, стэки, аппаратные циклы, различную периферию,… и начинать пилить бэкенд для llvm.

Комментарии (13)


  1. UA3MQJ
    17.12.2018 10:25

    Спасибо, отличная статья!

    … и начинать пилить бэкенд для llvm.
    Мне вот этот этап всегда казался самым сложным.


    1. pvvv Автор
      17.12.2018 10:38

      Я вот тоже совсем не уверен что удастся осилить.


  1. sashz
    17.12.2018 12:21

    Сомнительная простота с двухпортовым ОЗУ и Lua. На мой взгляд, лучше все же начать изучение с действительно простых примеров, например MCPU. В качестве ассемблера там используется Symbolic Macro Assembly Language


    1. pvvv Автор
      17.12.2018 13:21

      В любой FPGA найти двух портовую память как раз не проблема, куда проще чем приделывать любую обычную память к мелкой CPLD снаружи, так что с простотой там тоже не всё так однозначно, и в качестве ассемблера там тоже есть такой же велосипед, только страшненький и на питоне jeelabs.org/2017/11/tfoc---a-minimal-computer
      Для того чтобы допилить SMLA для данного процессора двадцатью строчками думаю не обойтись. Ну и с Lua, как мне кажется, получилось красиво, особенно то, что сама программа на ассемблере остаётся валидным кодом на Lua, со всеми её плюшками для препроцессора/макросов забесплатно.
      Цель же была не в изучении, для этого есть MIPS и RISC-V, а в велосипедостроении.
      В очередной раз просто по другому упаковывать различные операции в биты команд не так интересно, таких процессоров действительно полно.
      Тут же полагаю основная фича в простой расширяемости, а если максимально упаковать пяток инструкций в минимальное количество бит в инструкции, получив железный аналог brainfuck, добавить туда что-то будет сложно. Но за это, правда, приходится платить размером инструкций/кода особенно для разрядностей больше 8.


  1. UA3MQJ
    17.12.2018 18:14

    А сколько получается в LUTs?


    1. pvvv Автор
      17.12.2018 22:30
      +1

      Quartus Prime Version 18.1.0 Build 625 09/12/2018 SJ Lite Edition
      Revision Name cpu
      Top-level Entity Name cpu
      Family Cyclone 10 LP
      Device 10CL006YE144C8G
      Timing Models Final
      Total logic elements 91 / 6,272 ( 1 % )
      Total registers 24
      Total pins 10 / 89 ( 11 % )
      Total virtual pins 0
      Total memory bits 6,144 / 276,480 ( 2 % )
      Embedded Multiplier 9-bit elements 0 / 30 ( 0 % )
      Total PLLs 0 / 2 ( 0 % )


      1. UA3MQJ
        17.12.2018 23:40

        Шикарно! ) Можно попросить попробовать переключить проект на микросхему MAX240? Я, правда, не помню, двухпортовая ли там память.


        1. pvvv Автор
          17.12.2018 23:46
          +1

          Насколько помню — нету там памяти, только флэша немного.


        1. pvvv Автор
          18.12.2018 09:11

          Причём пользовательская флэш у epm240 — последовательная, с максимальной частотой 8МГц, то есть ROM из неё конечно можно сделать, но с частотой выборки 16ти битных слов 512кГц, а двухпортовой «памяти» данных на триггерах получится байт 20.
          То есть упихать-то можно конечно, но что-то совсем уж какой-то «троллейбус из буханки белого (или черного) хлеба.jpg» получается.


        1. pvvv Автор
          18.12.2018 11:49
          +1

          Насчёт 20 байт памяти это я погорячился, если убрать регистр вычитания, чтение PC и порта, т.е. assign data = data_r; и оставить только 8 байт памяти, получается
          Device EPM240T100C5
          Total logic elements 236 / 240 ( 98 % )
          почему-то не очень хорошо получается память из логики в CPLD, да и «параллельный» флэш из последовательного UFM тоже не бесплатно.


          1. UA3MQJ
            18.12.2018 11:56

            Он же вместо памяти делает регистры, если её нет. А если нет двухпортовой памяти — делает два регистра? )


            1. pvvv Автор
              18.12.2018 14:28
              +1

              Да вроде как у D триггера и так есть вход D и выход Q, поэтому для двухпортовой памяти у которой один порт на чтение, а другой на запись ничего больше и не надо.


  1. pvvv Автор
    17.12.2018 23:45

    .