В этой статье я расскажу, как самому написать процессор на VHDL. Кода будет не очень много (я, по крайней мере, надеюсь на это). Полный код выложен на гитхабе, и там же, можно посмотреть несколько итераций написания.
Процессор попадает под класс soft-процессоров.
Архитектура
Прежде всего, необходимо выбрать архитектуру процессора. Я буду использовать архитектуру RISC для процессора и гарвардскую архитектуру организации памяти.
Процессор будет без конвейера с двумя состояниями:
- Выборка команды и операндов
- Исполнение команды и сохранение результата
Так как пишем forth-процессор, то он будет стековым. Это позволит уменьшить разрядность команды, т.к. в ней не нужно будет хранить индексы регистров, с которыми проводятся вычисления. Для операций процессору будут доступны два верхних числа стека.
Стек данных и стек возвратов будут отдельные.
В ПЛИС существует блочная память с конфигурацией 18 бит * 1024 ячейки. Ориентируясь на неё выбираю разрядность команды в 9 бит (в один блок памяти уместится 2048 команд).
Разрядность памяти данных пусть будет «стандартной» в 32 бита.
«Общение» с периферийными устройствами реализую с помощью шины.
Схема всего этого безобразия получится примерно следующая.
Система команд
С архитектурой определились, теперь «попробуем со всем этим взлететь». Теперь необходимо придумать систему команд.
Все команды процессора можно разделить на несколько групп:
- Загрузка литерала (чисел) на стек
- Переходы (условный переход, вызов подпрограммы, возврат)
- Обращение к памяти данных (чтение и запись)
- Обращение к шине (по смыслу то же самое, что и обращение к памяти).
- Команды АЛУ.
- Прочие команды.
Итак, у нас есть 9 разрядов команды, в которые нам и нужно уложиться.
Загрузка литералов
Разрядность команды меньше разрядности данных, поэтому нужно придумать механизм загрузки чисел.
Я выбрал следующий формат команды для загрузки литералов на стек:
Мнемоника | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|---|---|
LIT | 1 | LIT |
Старший, 8 бит команды будет признаком загрузки числа. Остальные 8 бит – непосредственно число, загружаемое на стек.
Но разрядность данных 32 бита, а загрузить пока что можно только 8 бит.
Условимся, что если идет несколько команд LIT подряд, то это считается загрузкой одного числа. Первая команда загружается число на стек (знакорасширяя его), каждая последующая модифицирует верхнее число на стеке, сдвигая его на 8 бит влево и вписывая в младшую часть значение из команды. Таким образом, можно загрузить число любой разрядности последовательностью нескольких команд LIT.
Для разделения нескольких чисел можно использовать любую команду (например, NOP).
Группировка команд
Я решил разбить все остальные команды на группы для удобства декодирования. Группировать будем по тому, как они влияют на стек.
Мнемоника | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|---|---|
LIT | 0 | Группа | Команда |
Группы команд:
Группа | Берет со стека | Кладет на стек | Пример |
---|---|---|---|
0 | 0 | 0 | NOP |
1 | 0 | 1 | DEPTH |
2 | 1 | 0 | DROP |
3 | 1 | 1 | DUP, @ |
4 | 2 | 0 | !, OUTPORT |
5 | 2 | 1 | Арифметика (+, -, AND) |
Переходы:
Мнемоника | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|---|---|
JMP | 0 | 2 | 0 | ||||||
CALL | 0 | 2 | 1 | ||||||
IF | 0 | 4 | 0 | ||||||
RET | 0 | 0 | 1 |
Команды JMP и CALL берут адрес со стека и переходят по нему (call дополнительно кладет адрес возврата на соответствующий стек).
Команда IF берет адрес перехода (верхнее число на стеке) и признак перехода (следующее число). Если признак равен нулю, то осуществляется переход по адресу.
Команда RET работает со стеком возвратов, забирая верхнее число и переходя по нему.
Если команда не является переходом, то счетчик команд увеличивается на единицу.
Таблица команд
Для описания команд используется стековая нотация, выглядящая следующим образом:
<Состояние стека до выполнения слова> — <состояние стека после выполнения
слова>
Вершина стека находится справа, т.е. запись 2 3 — 5 означает, что до выполнения слова
на вершине стека находилось число 3, а под ним число 2; после выполнения эти числа
оказались удалены, а на вершине вместо них оказалось число 5.
Пример:
DUP (a — a a)
DROP (a b — a)
Возьмем минимальный набор команд, с которым можно будет хоть что-то сделать.
HL | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|---|
0 | NOP | RET | ||||||||
1 | TEMP> | DEPTH | RDEPTH | DUP | OVER | |||||
2 | JMP | CALL | DROP | |||||||
3 | @ | INPORT | NOT | SHL | SHR | SHRA | ||||
4 | IF | ! | OUTPORT | |||||||
5 | NIP | + | - | AND | OR | XOR | = | > | < | * |
Команда | Стековая нотация | Описание |
---|---|---|
NOP | No operation. Один процессорный такт ожидания | |
DEPTH | — D | Помещение на стек количества чисел на стеке данных до выполнения этого слова |
RDEPTH | — D | Помещение на стек количества чисел на стеке возвратов до выполнения этого слова |
DUP | A — A A | Дублирование верхнего числа |
OVER | A B — A B A | Копирование на вершину второго сверху числа |
DROP | A — | Удаление верхнего числа |
@ | A — D | Чтение памяти данных по адресу A |
INPORT | A — D | Чтение данных с шины по адресу A |
NOT | A — 0|-1 | Логическое НЕ верхнего числа (0 заменяется на -1, любое другое число заменяется на 0) |
SHL | A — B | Сдвиг верхнего числа на 1 разряд влево |
SHR | A — B | Сдвиг верхнего числа на 1 разряд вправо |
SHRA | A — B | Арифметический сдвиг верхнего числа на 1 разряд вправо (знак числа сохраняется) |
! | D A — | Запись данных D по адресу A в память данных |
OUTPORT | D A — | Запись данных D по адресу A в «шину» (на один такт будет выставлен сигнал iowr, периферия должна «поймать» свой адрес с высоким уровнем этого сигнала) |
NIP | A B — B | Удаление второго сверху числа со стека (число сохраняется в регистр TempReg) |
TEMP> | — A | Извлечение содержимого регистра TempReg |
+ | A B — A+B | Сложение верхних чисел на стеке |
- | A B — A-B | Вычитание из второго сверху числа верхнего числа |
AND | A B — A and B | Побитовый AND над верхними числами |
OR | A B — A or B | Побитовый OR над верхними числами |
XOR | A B — A xor B | Побитовый XOR над верхними числами |
= | A B — 0|-1 | Проверка равенства верхних чисел. Если числа равны, оставляет на стеке -1, иначе 0 |
> | A B — 0|-1 | Сравнение верхних чисел. Если A > B, оставляет на стеке -1, иначе 0. Сравнение с учетом знака |
< | A B — 0|-1 | Сравнение верхних чисел. Если A < B, оставляет на стеке -1, иначе 0. Сравнение с учетом знака |
* | A B — A*B | Умножение верхних чисел |
На стек за один процессорный такт можно записать 1 число; в форте есть команда SWAP, которая меняет местами 2 верхних числа на стеке. Для её реализации нужно 2 команды. Первая команда — NIP (a b — b), удаляет второе сверху число «a» и сохраняет его во временном регистре, а вторая команда TEMP> (-- a) извлекает это число из временного регистра и кладет на вершину стека.
Приступаем к кодированию
Реализация памяти.
Память кода и данных реализована через шаблон:
process(clk)
if rising_edge(clk) then
if WeA = '1' then
Ram(AddrA) <= DinA;
end if;
DoutA <= Ram(AddrA);
DoutB <= Ram(AddrB);
end if;
end process;
Ram – это сигнал, объявленный следующим образом:
subtype RamSignal is std_logic_vector(RamWidth-1 downto 0);
type TRam is array(0 to RamSize-1) of RamSignal;
signal Ram: TRam;
Память можно инициализировать следующим образом:
signal Ram: TRam :=
(0 => conv_std_logic_vector(0, RamWidth),
1 => conv_std_logic_vector(1, RamWidth),
2 => conv_std_logic_vector(2, RamWidth),
-- ...
others => (others => '0'));
Стеки реализованы через похожий шаблон
process(clk)
if rising_edge(clk) then
if WeA = '1' then
Stack(AddrA) <= DinA;
DoutA <= DinA;
else
DoutA <= Stack(AddrA);
end if;
DoutB <= Stack(AddrB);
end if;
end process;
Отличие от шаблона памяти только в том, что она «пробрасывает» записываемое значение на выход. С предыдущим шаблоном записанное значение было бы получено на следующем, после записи, такте.
Синтезатор автоматически распознаёт эти шаблоны и генерирует соответствующие блоки памяти. Это видно в отчете. Например, для стека данных он выглядит следующим образом:
-----------------------------------------------------------------------
| ram_type | Distributed | |
-----------------------------------------------------------------------
| Port A |
| aspect ratio | 16-word x 32-bit | |
| clkA | connected to signal <clk> | rise |
| weA | connected to signal <DSWeA> | high |
| addrA | connected to signal <DSAddrA> | |
| diA | connected to signal <DSDinA> | |
| doA | connected to internal node | |
-----------------------------------------------------------------------
| Port B |
| aspect ratio | 16-word x 32-bit | |
| addrB | connected to signal <DSAddrB> | |
| doB | connected to internal node | |
-----------------------------------------------------------------------
Думаю, нет смысла приводить полный код реализации памяти, он, по сути, шаблонный.
Основной цикл работы процессора – на первом такте делается выборка команды, на втором – исполнение. Чтобы определить, на каком такте находится процессор, сделан сигнал fetching.
process(clk)
begin
if rising_edge(clk) then
if reset = '1' then
-- обнуление сигналов
ip <= (others => '0');
fetching <= '1';
else
if fetching = '1' then
fetching <= '0';
else
fetching <= '1';
-- исполнение команды, формирование адреса для выборки
end if;
end if;
end if;
end process;
Самый простой вариант декодирования и исполнения команды – это большой «case» по всем вариантам. Для простоты написания лучше разделить его на несколько составляющих.
В этом проекте я разбил его на 3 части:
- кейс, который будет отвечать за формирование адреса стека данных, и формировать сигнал записи;
- кейс исполнения команды ;
- кейс формирования нового счетчика команд (ip).
-- Data stack addr and we
case conv_integer(cmd(8 downto 4)) is
when 16 to 31 => -- LIT
if PrevCmdIsLIT = '0' then
DSAddrA <= DSAddrA + 1;
end if;
DSWeA <= '1';
when 0 => -- group 0; pop 0; push 0
null;
when 1 => -- group 1; pop 0; push 1;
DSAddrA <= DSAddrA + 1;
DSWeA <= '1';
when 2 => -- group 2; pop 1; push 0;
DSAddrA <= DSAddrA - 1;
when 3 => -- group 3; pop 1; push 1;
DSWeA <= '1';
when 4 => -- group 4; pop 2; push 0;
DSAddrA <= DSAddrA - 2;
when 5 => -- group 5; pop 2; push 1;
DSAddrA <= DSAddrA - 1;
DSWeA <= '1';
when others => null;
end case;
Выборка идет по части команды, младшие 4 бита не используются.
Расписаны все заявленные группы команд. Изменять этот кейс нужно будет только при появлении новой группы команд.
Следующий кейс будет отвечать за исполнение команды. В нем формируются данные для стека данных (простите за тавтологию), сигнал iowr для команды OUTPORT и т.д.
-- Data stack value
case conv_integer(cmd) is
when 256 to 511 => -- LIT
if PrevCmdIsLIT = '1' then
DSDinA <= DSDoutA(DataWidth - 9 downto 0) & Cmd(7 downto 0);
else
DSDinA <= sxt(Cmd(7 downto 0), DataWidth);
end if;
when cmdPLUS =>
DSDinA <= DSDoutA + DSDoutB;
when others => null;
end case;
Пока реализовано только 2 команды. Загрузка чисел на стек и сложение двух верхних чисел на стеке. Этого хватит для «тестирования идеи», и, если эти 2 команды заработают, большинство остальных будет реализовано «по шаблону» без особых проблем.
И последний кейс – формирование следующего адреса для счетчика команд:
-- New ip and ret stack;
case conv_integer(cmd) is
when cmdJMP => -- jmp
ip <= DSDoutA(ip'range);
when cmdIF => -- if
if conv_integer(DSDoutB) = 0 then
ip <= DSDoutA(ip'range);
else
ip <= ip + 1;
end if;
when cmdCALL => -- call
RSAddrA <= RSAddrA + 1;
RSDinA <= ip + 1;
RSWeA <= '1';
ip <= DSDoutA(ip'range);
when cmdRET => -- ret
RSAddrA <= RSAddrA - 1;
ip <= RSDoutA(ip'range);
when others => ip <= ip + 1;
end case;
Реализованы базовые команды переходов. Адрес перехода берется со стека.
Тестирование
Прежде чем двигаться дальше, желательно оттестировать уже написанный код. Я создал TestBench, в я вписал только выдачу сигнала сброса на процессор в первые 100 ns.
Память кода инициализировал следующим образом:
signal CodeMemory: TCodeMemory := (
0 => "000000000", -- lit tests
1 => "100000000",
2 => "100000001",
3 => "100000010",
4 => "000000000",
5 => "100001111",
6 => "000000000",
7 => "100010000",
8 => "100001000",
9 => conv_std_logic_vector(cmdPLUS, CodeWidth),
10 => conv_std_logic_vector(cmdPLUS, CodeWidth),
11 => conv_std_logic_vector(cmdDROP, CodeWidth),
12 => "100010011",
13 => conv_std_logic_vector(cmdJMP, CodeWidth), -- jmp to 19
14 => "100000010",
15 => "000000000",
16 => "100000010",
17 => conv_std_logic_vector(cmdPLUS, CodeWidth),
18 => conv_std_logic_vector(cmdRET, CodeWidth), -- ret
19 => "100001110",
20 => conv_std_logic_vector(cmdCALL, CodeWidth), -- call to 14
21 => "111111111",
others => (others => '0')
);
Вначале, кладется несколько чисел, тестируется операция сложения и стек очищается командой DROP. Дальше тестируется переход, вызов подпрограммы и возврат.
Результат моделирования показан на следующих картинках (кликабельно):
Разбор загрузки чисел
На рисунке показано выполнение команды Lit 0. После снятия сигнала сброса счетчик команд равняется нулю (ip = 0) и процессору сказано, что он находится на фазе выборке команды (fetching = '1'). На первом такте совершается выборка. Первая команда NOP, которая ничего, кроме увеличения счетчика команд не делает (впрочем, любая неизвестная команда увеличит счетчик команд, и также, может что-то сделать со стеком данных, в зависимости от той группы, в которой она находится).
Команда #1 – это загрузка числа 0 на стек. На такте исполнения выставляются 3 сигнала: адрес стека данных увеличивается на 1, выставляются данные и выставляется сигнал разрешения записи.
На следующем такте выборки в стек по адресу «1» записывается значение «0». Значение, также, сразу «пробрасывается» на выход (чтобы следующая команда оперировала уже новым значением). Сигнал разрешения записи снимается.
Команда #2 – это тоже команда загрузки числа на стек. Т.к. она идет следом за командой LIT, то новое число на стек не будет загружено, а модифицируется верхнее. Оно сдвигается на 8 бит влево, в младшую часть пишется значение из команды (которое 0x01).
Команда #3 выполняет те же самые операции, что и команда #2. Число на стеке, после её работы равняется 0x0102.
Заключение
Первые команды протестированы. Практически шаблонно пишутся все оставшиеся команды («рисуем кружочки, рисуем остальную сову»).
Целью статьи было показать, что процессор можно написать самому, и, я надеюсь, у меня это получилось хоть в какой-то мере. Следующим шагом будет написание загрузчика и кросс-компилятора, если хабрасообществу будет интересна эта статья.
Проект на гитхабе: github.com/whiteTigr/vhdl_cpu
Код процессора: github.com/whiteTigr/vhdl_cpu/blob/master/cpu.vhd
Код testbench'а (хотя в нем практически ничего нет): github.com/whiteTigr/vhdl_cpu/blob/master/cpu_tb.vhd
Автор: whiteTigr