В голосовании к прошлой статье с небольшим отрывом победила видеокарта, но так как у нас тут не демократия, а конституционная монархия, про видеокарту будет следующая статья, а эта – про кодирование инструкций и их исполнение.
Флаги
Перед тем, как перейти к самим инструкциям, расскажу о флагах. В процессоре флаги – это однобитовые значения, которые изменяются в результате арифметических действий (или других событий) и используются для условных переходов или других условных операций. В моем процессоре четыре флага:
-
Z – ноль,
-
С – перенос,
-
S – знак,
-
O – переполнение или знаковый перенос.
Перенос и переполнение
При программировании на ассемблере нет знаковых или беззнаковых чисел: любое число можно рассматривать либо так, либо этак. Например, утверждения "регистр A содержит -1" и "регистр A содержит 255" значат одно и то же. Всё дело в интерпретации двоичного числа человеком. Рассмотрим пример: в восьмибитном регистре A находится двоичное значение 1000 0010, а в B – 0111 1101. Посмотрим на результаты сложения и вычитания этих чисел и их знаковых и беззнаковых интерпретаций.
Выражение |
Двочиный код |
Без знака |
Со знаком |
A |
|
130 |
-126 |
B |
|
126 |
126 |
A + B |
|
0 |
0 |
A - B |
|
4 |
4 |
В случае со сложением беззнаковый результат "неправильный": должно получиться 256, но из-за переполнения байта получается ноль. В случае с вычитанием наоборот: должно получиться -252, но такое число нельзя закодировать в 8 бит, поэтому получается 4. Для обнаружения этих ситуаций и нужны флаги С и O: С устанавливается в случае "неправильных" беззнаковых результатов, а O – в случае "неправильных" знаковых. Подробнее можно почитать тут.
Набор инструкций
В моем процессоре пять классов инструкций: арифметические, загрузка, сохранение, загрузка константы и переходы.
Код любой инструкции имеет размер один байт. Этот байт в начале каждого цикла загружается в регистр текущей инструкции IR. Таким образом процессор "знает", какую инструкцию он сейчас исполняет.
Блок-схема процессора
Арифметические инструкции
0CCCCIRR
Старший бит кода арифметической инструкции ноль. Он и определяет этот класс инструкций.
Следующие четыре бита – код арифметической операции, напрямую подаваемый на вход АЛУ. Всего 16 операций:
-
MOV
– просто копирование, -
ADD
– сложение, -
ADC
– сложение с переносом, -
SUB
– вычитание, -
SBB
– вычитание с переносом, -
INC
– инкремент, -
DEC
– декремент, -
OR
– побитовое ИЛИ, -
AND
– побитовое И, -
XOR
– побитовое исключающее ИЛИ, -
NOT
– побитовое отрицание, -
NEG
– смена знака числа, -
SHL
– сдвиг влево на один бит, -
SHR
– логический сдвиг вправо на один бит, -
SAR
– арифметический сдвиг вправо на один бит, -
EXP
– устанавливает регистр в 00 или FF в зависимости от флага переноса.
Дальше идет бит инверсии, определяющий порядок операндов. Как мы помним, из-за жесткой привязки выхода регистра A к первому входу АЛУ один из аргументов должен обязательно быть А. Этот бит и определяет, первый это аргумент (0) или второй (1).
Пояснение про порядок операндов
Если вы не знакомы с ассемблером (диалект Intel), то нужно помнить, что первый операнд инструкции – это то, куда записывается результат. Например:
sub b, a
Тут результат вычитания a из b будет записан в b. На си-подобном языке это было бы так:
b = b - a;
И последние два бита – это индекс регистра, используемого в качестве второго операнда.
-
01
– B, -
10
– PL, -
11
– PH.
Комбинация 00
должна соответствовать регистру A, но он не подключен к ведущей на второй вход АЛУ шине, поэтому при использовании этой комбинации на этой шине будет ноль благодаря подтягивающим резисторам. Таким образом возможна арифметика между A (фиксированным первым операндом) и нулем.
Для примера рассмотрим инструкцию ADD PL, A
. Битовое представление будет следующим:
-
старший бит всегда
0
, -
код операции ADD
1001
, -
бит инверсии установлен (
1
), так как А – второй аргумент, -
и код регистра PL –
10
.
Итого 01001110
или 0x4E
. На знакомой блок-схеме исполнение этой инструкции будет выглядеть так:
Красными стрелками обозначены сигналы, которые будут активны при исполнении этой инструкции. Звездочки показывают, какое устройство активно, т.е. определяет уровни сигналов, на конкретной шине. Теперь рассмотрим временную диаграмму исполнения этой инструкции:
Примечание для дотошных
На диаграмме все сигналы для простоты имеют активный уровень 1, хоть реально это может быть и не так, многие сигналы инверсны.
Самый верхний сигнал на диаграмме – тактовый сигнал. Следующий за ним сигнал cycle
– это просто тактовый сигнал с частотой в два раза ниже. Он определяет цикл исполнения инструкции: загрузка опкода или исполнение.
Всё начинается с того, что в IR еще находится код предыдущей инструкции (1), а на шине адреса уже адрес нашей инструкции. Сигнал доступа к памяти oe_mem
активен, поэтому память выставляет на шине данных значение по этому адресу: число 01001110
.
По нисходящему фронту тактового сигнала (2) значение с шины данных защелкивается в регистр IR, так как сигнал записи в этот регистр we_ir
активен.
Дальше (3) cycle
переключается в 1, что означает исполнение инструкции. Одновременно с ним переключаются необходимые для исполнения этой конкретной инструкции сигналы:
-
we_pl
указывает, что значение со внутренней шины должно быть записано в регистр PL; -
oe_pl_alu
– что выходной буфер PL должен включиться и подать напряжение на шину, ведущую в ALU; -
oe_alu
– что АЛУ должно активировать свой выходной буфер и подать результат на внутреннюю шину; -
we_flags
– что в регистр флагов тоже должно быть записано значение.
Сигнал we_ir
, наоборот, отключается, чтобы значение в IR сохранилось еще на такт. А благодаря активному сигналу inc_ip
по нисходящему фронту clk
счетчик инструкции инкрементируется (4).
Вся запись в регистры происходит по нисходящему фронту clk
. Таким образом, между восходящим фронтом (3), когда управляющие сигналы переключаются, и нисходящим (4) значения на всех шинах успевают установиться, и в регистры попадают правильные значения.
Когда cycle
возвращается в ноль (5), инструкцию можно считать выполненной. На шине данных уже находится опкод следующей инструкции, который будет загружен в IR в момент времени (6).
Итого, арифметическая инструкция занимает два такта.
Инструкции INC, DEC, NOT, NEG, SHL, SHR, SAR, EXP принимают один аргумент, хоть и кодируются так же, как и остальные. Можно считать, что второй аргумент они просто игнорируют.
Еще один момент: MOV
– это тоже "арифметическая" инструкция, поэтому она должна менять флаги (we_flags
активен). Это очень неудобно при программировании, поэтому в случае этой конкретной операции we_flags
остается неактивным, флаги сохраняются.
Загрузка константы
110xxxRR СССССССС
Здесь первые три бита опкода должны быть 110
, а последние два кодируют регистр. Иксами помечены биты, значение которых неважно. Сразу за опкодом в памяти следует константа, которую надо загрузить. На ассемблере эта инструкция обозначается LDI
.
Пример: LDI B, 0x5A
Код инструкции: 11000001 01011010
Здесь вступает в игру новый сигнал – supercycle
, который является замедленным в два раза cycle
. Благодаря ему исполнение инструкции LDI занимает четыре такта. IP инкрементируется два раза (4 и 8). В середине (6), когда на шине данных константа для загрузки, сигналом oe_d_di
активируется буфер, соединяющий внешнюю шину данных со внутренней, и значение с этой шины защелкивается в регистр B благодаря активному we_b
.
Загрузка из памяти
100xxxRR
Последние два бита так же, как и в LDI, кодируют регистр, в который будет загружено значение. Как вы помните, адрес памяти, откуда будет взят байт, хранится в PH:PL.
Пример: LD A
Код инструкции: 10000000
Здесь по уже знакомому сигналу cycle
активируется сигнал addr_p
(3), по которому на шину адреса выводится значение из PH:PL вместо IP. Как и в LDI, значение с внешней шины данных передается на внутреннюю, а с нее по нисходящему фронту clk
(4) защелкивается в нужный регистр. Инструкция исполняется за два такта.
Сохранение в память
101xxxxR
Здесь под код регистра отводится только один бит (A или B), потому что сохранение PL или PH не имеет смысла: в них хранится адрес той ячейки, куда сохраняем!
Пример: ST B
Код инструкции: 10100001
На этой диаграмме шина D показана иначе, чем раньше: тут показано то значение, которое устанавливает на нее процессор. По умолчанию шина находится в плавающем состоянии, но по сигналу oe_b_d
(3-5) на нее подается значение из регистра B. Так как в это же время сигнал oe_mem
становится неактивным, конфликта на шине не будет: память ее "отпускает". По нисходящему фронту we_mem
(4) значение с шины данных записывается в память по адресу из P, который, как и в случае с LD, устанавливается на шине с помощью сигнала addr_dp
. Сигнал we_cycle
– сдвинутый на 90º cycle
– нужен для удобства получения сигнала we_mem
, который занимает половину периода clk
.
Эти тайминги работали отлично, пока я не добавил переключение банков и не стал запускать программы из ОЗУ. Тогда возникла проблема: так как сигнал we_mem
устанавливается слишком быстро (3), а на шине еще старый адрес, данные в памяти портились. Чтобы это исправить, я напаял на плате модуля управления схему небольшой задержки сигнала на RC-цепочке. Получились такие тайминги:
Теперь адрес успевает установиться до того, как будет запрошена запись.
Схема для задержки сигнала
Здесь сигналы we_mem_orig
и we_mem
инверсны (активный ноль).
Переходы
111xEIFF
Этот код кодирует сразу безусловные переходы, условные переходы и NOP ("безусловные непереходы").
Два бита FF
кодируют флаг для проверки (Z, C, S, O).
Бит I
определяет инверсию результата проверки.
Бит E
определяет, будут ли вообще проверяться флаги. Если он единица, то флаги не проверяются, а результат проверки считается единицей. Поэтому, если выставлены одновременно биты E
и I
, то переход не произойдет (NOP), а если E
выставлен, а I
сброшен, то произойдет безусловно (JMP).
Примеры:
-
NOP
11111111
-
JMP
11101000
-
JC
11100001
-
JNZ
11100100
Диаграмма выглядит так же, как и остальные, кроме сигнала swap_p
, который устанавливается в единицу, если должен быть выполнен переход. Если swap_p
выставлен в единицу, по нисходящему фронту clk
(4), переключается флаг, определяющий, какой из физических регистров в блоке P – это IP, а какой PH:PL.
Реализация
Вот и все инструкции. Имея временные диаграммы, несложно нарисовать логические схемы, которые должны выдавать все управляющие сигналы. Эти схемы реализованы на синей плате, а управляющие сигналы наглядно выведены на светодиоды.
Когда я только начинал делать процессор, я хотел сделать по-другому: чтобы быстрее получить что-то работающее, использовать ПЗУ с таблицей значений вместо дискретной логики. Оказалось, что так не получится: при переключении адресов ПЗУ выдает неопределенные значения, случайные всплески сигналов, поэтому тот вариант оказался совершенно неработоспособным. Процессор вел себя случайным и непредсказуемым образом. С АЛУ, однако, такой подход прошел, потому что значения с выхода АЛУ не используются как управляющие сигналы. Моя первая версия АЛУ была на двух микросхемах ПЗУ, но потом я его тоже переделал.
Программирование
У меня используется классическая (для C или C++) модель сборки. Компилятор выдает ассемблерный код, ассемблер делает из него объектные файлы, линкер собирает их в бинарный файл для прошивки в ПЗУ или для записи на SD-карту. Всё это управляется системой сборки SCons – ее очень просто настроить под себя и использовать нестандартные компиляторы.
Линкер оптимизирующий: умеет выкидывать неиспользуемые секции (аналог --gc-sections
в GNU ld) и перемешивать их так, чтобы максимально заполнять пустые места, остающиеся от выравнивания.
Компилятор (и язык) я назвал Natrix (латинское название ужа), но по сути это урезанный Си: без неявных кастов, с ограничениями по вызову функций, без конструкций вроде union
и switch
. Главная проблема для компилятора – это отсутствие аппаратного стека, которую я решаю так: все локальные переменные в функциях выделяются статически, адрес возврата тоже кладется в статическую (уникальную для функции) переменную. Временные переменные для вычисления выражений тоже статические, но общие для всех функций. Аргументы и возвращаемое значение – тоже статические переменные. Такое обилие статических переменных – не проблема, так как нехватки ОЗУ никогда не наблюдается: намного раньше заканчивается память для кода (ха-ха).
Чтобы всё-таки можно было использовать рекурсию, используется программный стек. При компиляции строится граф вызовов, и если обнаружены циклы, то перед этими рекурсивными вызовами все статические переменные вызывающей функции кладутся на стек, а потом восстанавливаются. Это очень дорого, но рекурсия работает.
Умножение, деление и сдвиг на переменную программные и заменяются на вызовы встроенных функций. Простое умножение (переменной-байта на константу) и сдвиги на константу встраивается.
Пример сгенерированного кода: сдвиг 32-битного числа
u32 x;
x >>= 10u8;
; (u32, 1, __cc_loc_main_x) = shr (u32, 1, __cc_loc_main_x), 10
ldi pl, lo(__cc_loc_main_x + 1)
ldi ph, hi(__cc_loc_main_x + 1)
ld b
inc pl
ld a
shr b
shr b
shl a
shl a
shl a
shl a
shl a
shl a
or b, a
ldi pl, lo(__cc_loc_main_x + 0)
st b
ldi pl, lo(__cc_loc_main_x + 2)
ld b
inc pl
ld a
shr b
shr b
shl a
shl a
shl a
shl a
shl a
shl a
or b, a
ldi pl, lo(__cc_loc_main_x + 1)
st b
ldi pl, lo(__cc_loc_main_x + 3)
ld b
shr b
shr b
dec pl
st b
mov a, 0
inc pl
st a
Еще пример: умножение байта на константу
u8 x, y;
x = y * 5u8;
; __cc_loc_main_x = (u8, 1, __cc_loc_main_y) * 5 (byte)
ldi pl, lo(__cc_loc_main_y + 0)
ldi ph, hi(__cc_loc_main_y + 0)
ld a
mov b, a
shl a
shl a
add b, a
mov a, b
ldi pl, lo(__cc_loc_main_x)
ldi ph, hi(__cc_loc_main_x)
st a
Всё это (компилятор, ассемблер и линкер) написано на питоне, потому что я хотел как можно скорее получить работающий результат. И если для ассемблера и линкера это оправдано из-за простоты, а в ассемблере еще и дает преимущество в виде простого вычисления константных выражений с помощью eval
, то писать компиляторы на питоне я никому не посоветую. Да, первый результат получился быстро, но отлавливать ошибки и поддерживать код очень сложно.
Первый эмулятор тоже был на питоне, но потом я его переписал на Rust, что ускорило его раз в пять.
Примечание
Временные диаграммы нарисованы в wavedrom, а блок-схемы в Inkscape. Электрические схемы – скриншоты из KiCAD.
Автор:
ynoxinul