Pull to refresh

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

Reading time 7 min
Views 13K

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


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


https://github.com/pavel212/cpu


Для начала опишем 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.

Tags:
Hubs:
+22
Comments 14
Comments Comments 14

Articles