Ещё один простой процессор на verilog

    В статье описан очередной примитивный процессор и ассемблер для него.
    Вместо обычных 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.

    Share post

    Similar posts

    Comments 14

      0
      Спасибо, отличная статья!
      … и начинать пилить бэкенд для llvm.
      Мне вот этот этап всегда казался самым сложным.
        0
        Я вот тоже совсем не уверен что удастся осилить.
        0
        Сомнительная простота с двухпортовым ОЗУ и Lua. На мой взгляд, лучше все же начать изучение с действительно простых примеров, например MCPU. В качестве ассемблера там используется Symbolic Macro Assembly Language
          +2
          В любой FPGA найти двух портовую память как раз не проблема, куда проще чем приделывать любую обычную память к мелкой CPLD снаружи, так что с простотой там тоже не всё так однозначно, и в качестве ассемблера там тоже есть такой же велосипед, только страшненький и на питоне jeelabs.org/2017/11/tfoc---a-minimal-computer
          Для того чтобы допилить SMLA для данного процессора двадцатью строчками думаю не обойтись. Ну и с Lua, как мне кажется, получилось красиво, особенно то, что сама программа на ассемблере остаётся валидным кодом на Lua, со всеми её плюшками для препроцессора/макросов забесплатно.
          Цель же была не в изучении, для этого есть MIPS и RISC-V, а в велосипедостроении.
          В очередной раз просто по другому упаковывать различные операции в биты команд не так интересно, таких процессоров действительно полно.
          Тут же полагаю основная фича в простой расширяемости, а если максимально упаковать пяток инструкций в минимальное количество бит в инструкции, получив железный аналог brainfuck, добавить туда что-то будет сложно. Но за это, правда, приходится платить размером инструкций/кода особенно для разрядностей больше 8.
          0
          А сколько получается в LUTs?
            +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 % )
              0
              Шикарно! ) Можно попросить попробовать переключить проект на микросхему MAX240? Я, правда, не помню, двухпортовая ли там память.
                +1
                Насколько помню — нету там памяти, только флэша немного.
                  +2
                  Причём пользовательская флэш у epm240 — последовательная, с максимальной частотой 8МГц, то есть ROM из неё конечно можно сделать, но с частотой выборки 16ти битных слов 512кГц, а двухпортовой «памяти» данных на триггерах получится байт 20.
                  То есть упихать-то можно конечно, но что-то совсем уж какой-то «троллейбус из буханки белого (или черного) хлеба.jpg» получается.
                    +1
                    Насчёт 20 байт памяти это я погорячился, если убрать регистр вычитания, чтение PC и порта, т.е. assign data = data_r; и оставить только 8 байт памяти, получается
                    Device EPM240T100C5
                    Total logic elements 236 / 240 ( 98 % )
                    почему-то не очень хорошо получается память из логики в CPLD, да и «параллельный» флэш из последовательного UFM тоже не бесплатно.
                      0
                      Он же вместо памяти делает регистры, если её нет. А если нет двухпортовой памяти — делает два регистра? )
                        +1
                        Да вроде как у D триггера и так есть вход D и выход Q, поэтому для двухпортовой памяти у которой один порт на чтение, а другой на запись ничего больше и не надо.
                0
                .
                  0
                  Просто оставлю это тут: How To Programming in Houdini from XAPKOHHEH on Vimeo.

                  Only users with full accounts can post comments. Log in, please.