В статье описан очередной примитивный процессор и ассемблер для него.
Вместо обычных 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
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"
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
})
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 (или онлайн) получим программу для процессора в машинных кодах.
0100
0701
0501
0101
0307
0703
0503
01FB
0303
0103
0203
010D
0001
010D
0001
Для симуляции сделаем простой тестбенч, который лишь отпускает ресет и дергает клоки.
`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)
sashz
17.12.2018 12:21Сомнительная простота с двухпортовым ОЗУ и Lua. На мой взгляд, лучше все же начать изучение с действительно простых примеров, например MCPU. В качестве ассемблера там используется Symbolic Macro Assembly Language
pvvv Автор
17.12.2018 13:21В любой FPGA найти двух портовую память как раз не проблема, куда проще чем приделывать любую обычную память к мелкой CPLD снаружи, так что с простотой там тоже не всё так однозначно, и в качестве ассемблера там тоже есть такой же велосипед, только страшненький и на питоне jeelabs.org/2017/11/tfoc---a-minimal-computer
Для того чтобы допилить SMLA для данного процессора двадцатью строчками думаю не обойтись. Ну и с Lua, как мне кажется, получилось красиво, особенно то, что сама программа на ассемблере остаётся валидным кодом на Lua, со всеми её плюшками для препроцессора/макросов забесплатно.
Цель же была не в изучении, для этого есть MIPS и RISC-V, а в велосипедостроении.
В очередной раз просто по другому упаковывать различные операции в биты команд не так интересно, таких процессоров действительно полно.
Тут же полагаю основная фича в простой расширяемости, а если максимально упаковать пяток инструкций в минимальное количество бит в инструкции, получив железный аналог brainfuck, добавить туда что-то будет сложно. Но за это, правда, приходится платить размером инструкций/кода особенно для разрядностей больше 8.
UA3MQJ
17.12.2018 18:14А сколько получается в LUTs?
pvvv Автор
17.12.2018 22:30+1Quartus 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 % )UA3MQJ
17.12.2018 23:40Шикарно! ) Можно попросить попробовать переключить проект на микросхему MAX240? Я, правда, не помню, двухпортовая ли там память.
pvvv Автор
18.12.2018 09:11Причём пользовательская флэш у epm240 — последовательная, с максимальной частотой 8МГц, то есть ROM из неё конечно можно сделать, но с частотой выборки 16ти битных слов 512кГц, а двухпортовой «памяти» данных на триггерах получится байт 20.
То есть упихать-то можно конечно, но что-то совсем уж какой-то «троллейбус из буханки белого (или черного) хлеба.jpg» получается.
pvvv Автор
18.12.2018 11:49+1Насчёт 20 байт памяти это я погорячился, если убрать регистр вычитания, чтение PC и порта, т.е. assign data = data_r; и оставить только 8 байт памяти, получается
Device EPM240T100C5
Total logic elements 236 / 240 ( 98 % )
почему-то не очень хорошо получается память из логики в CPLD, да и «параллельный» флэш из последовательного UFM тоже не бесплатно.UA3MQJ
18.12.2018 11:56Он же вместо памяти делает регистры, если её нет. А если нет двухпортовой памяти — делает два регистра? )
pvvv Автор
18.12.2018 14:28+1Да вроде как у D триггера и так есть вход D и выход Q, поэтому для двухпортовой памяти у которой один порт на чтение, а другой на запись ничего больше и не надо.
UA3MQJ
Спасибо, отличная статья!
Мне вот этот этап всегда казался самым сложным.pvvv Автор
Я вот тоже совсем не уверен что удастся осилить.