В голосовании к прошлой статье с небольшим отрывом победила видеокарта, но так как у нас тут не демократия, а конституционная монархия, про видеокарту будет следующая статья, а эта – про кодирование инструкций и их исполнение.
Флаги
Перед тем, как перейти к самим инструкциям, расскажу о флагах. В процессоре флаги – это однобитовые значения, которые изменяются в результате арифметических действий (или других событий) и используются для условных переходов или других условных операций. В моем процессоре четыре флага:
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.
Комментарии (26)
staticmain
26.11.2021 12:04+1Оказалось, что так не получится: при переключении адресов ПЗУ выдает неопределенные значения
А какую модель вы использовали? Обычно для избежания такого эффекта на более-менее комплексных чипах есть ножка Latch, которая позволяет выдавать значения не постоянно, а только когда на ней логический 0 (или 1 в зависимости от модели).ynoxinul Автор
26.11.2021 12:08AT28C256, на ней такой ножки нет.
staticmain
26.11.2021 13:35Read
The AT28C256 is accessed like a Static RAM. When CE and OE are low and WE is high, the
data stored at the memory location determined by the address pins is asserted on the outputs.
The outputs are put in the high impedance state when either CE or OE is high. This dual-line
control gives designers flexibility in preventing bus contention in their system.
Если я правильно понял, то они предполагают что вы сначала установите CE/OE в low, потом выставите адрес, а затем выставите WE в high, чтобы промежуточное значение адреса не было использовано. У схемы есть Latch для Data, но он вместо одной ножки использует логическое open = (!CE && !OE && WE).
Опять же, я не сверхэксперт в этой области, но кажется, что документация говорит именно об этом.ynoxinul Автор
26.11.2021 13:57+1Смысл-то в том, чтобы предыдущие сигналы держались, а потом сразу в новые перещелкивались. Даже если CE/OE дергать, они будут через high-z переключаться, а не напрямую. Короче, ПЗУ – это не ПЛИС.
axe_chita
26.11.2021 18:20Продолжение это гуд, а поскольку проект уже доведен до логического конца, то то мы имеем шансы увидеть цикл статей о компьютере на логических микросхемах от «А» до «Я»
Подскажите, а команда вызова подпрограммы/процедуры в вашем процессоре есть?
Кстати NOP порадовал:) «безусловный непереход» это сильно. В 8080 NOP-ом была инструкция MOV A,A. А в 8086 NOP был XCHG EAX, EAX.tbl
26.11.2021 19:34+1в 8080 nop - это в первую очередь инструкция 0b0000000 из-за того, что при рестарте это число будет записано в IR с пустой внутренней шины данных, еще до зачитывания инструкции с входного буфера-защелки на внешней шине данных, находящейся по адресу 0x0000 (на которую указывает сброшенный PC). Т.е. после перезагрузки процессор выполнит NOP, а только затем уже инструкцию с адреса 0x0000. То, что NOP мнемонически кодируется в MOV A, A - это уже вынужденная мера, т.к. должны были исходить из того, что старший бит для MOV-операций - нулевой, но с другой стороны удобно - это и признак того, что операция выполняется не на ALU, а только на регистровых мультиплексорах/демультиплексорах, соответственно не нужны отдельные хаки для сохранения флагов результата арифметических операций.
Alex-111
26.11.2021 19:35Спасибо, очень круто! Интересно посмотреть схему формирования видео сигнала.
kuza2000
26.11.2021 22:10Проделанная работа вызывает восхищение!
Ох как стека-то не хватает, конечно.
Кстати, после изучения архитектуры, системы команд, и примеров кода мне одному brainfuck вспомнился?)))
vipassa
26.11.2021 22:41+1В детстве мечтал самостоятельно собрать компьютер из тех, что публиковались тогда в журналах. Делал подборки таких публикаций (к слову храню их до сих пор). Но прошли годы и оказалось, что легче купить компьютер, чем сделать самостоятельно. Но может быть, когда появятся такого вот плана конструкторы или ещё проще :), то куплю такой конструктор и уже со своим сыном его соберу.
Автору респект!
rus084
26.11.2021 23:11+1Круто!
А как себя поведет команда LD PL / LD PH?
Мне кажется ее поведение будет таким: на время выставления сигнала CYCLE содержимое шины данных будет дублироваться через регистр на 8 бит шины адреса и загрузить число по изначальному адресу регистра P в него-же не получится
ynoxinul Автор
26.11.2021 23:54Никакой проблемы с загрузкой PH/PL нет. Значение в регистр защелкивается по нисходящему фронту CLK, поэтому до этого фронта на шине будет нужный адрес.
rus084
27.11.2021 14:32А после фронта на шине установится уже другой адрес? (то значение что было защелкнуто в регистр по фронту)
буфер соединяющий шину данных и внутреннюю шину это просто буфер, или он защелкивается в момент активации?
ynoxinul Автор
27.11.2021 15:14Да, после фронта будет новый адрес, но это неважно: значение уже защелкнуто.
Шину данных со внутренней шиной соединяет обычный буфер 74HC244.
gleb_l
30.11.2021 00:35+1Компактно и эффективно закодировать инструкции - не менее важно, чем оптимально построить АЛУ. Уровня микрокоманд-то нет - все ручками! Особенно понравились MOV через АЛУ с подавлением строба флагов, JMP/NOP через инверсию проверки и автосохранение адреса следующей инструкции при переходе.
По поводу нуля на входе второго операнда АЛУ вместо того же аккумулятора - навскидку не факт, от чего больше пользы. ADD A, A эквивалентно SHL, XOR A, A в отличие от CLR A (у вас это MOV A, 0) даёт очистку с установкой флагов (это часто важно!), OR A, A ставит флаги по текущему содержимому (тоже очень нужно). Так что при прочих равных я бы выбрал версию системы команд аккумулятора с самим собой. Аппаратно наверное это потребовало бы ещё одного регистра-защелки с Z-состоянием, на второй вход АЛУ с выхода A, но это небольшая жертва.
Для унарных операций биты RR могут кодировать ее тип, таким образом можно уместить NOT, NEG, INC, DEC в один код CCCC, а освободившиеся использовать для будущих расширений - но это уже другой уровень сложности декодера.
ynoxinul Автор
30.11.2021 15:26Спасибо за отзыв!
XOR A, A в отличие от CLR A (у вас это MOV A, 0) даёт очистку с установкой флагов (это часто важно!), OR A, A ставит флаги по текущему содержимому (тоже очень нужно).
Очистка с установкой флагов будет
AND A, 0
;OR A, 0
эквивалентнаOR A, A
.Для унарных операций биты RR могут кодировать ее тип
Да, тут нужно усложнять и АЛУ, и декодер.
Dovgaluk
05.12.2021 15:53Какой командой процессора колобок ест чёрта?
У меня в компьютере тоже память команд очень быстро кончается. Жалею, что не сделал инструкцию проверки отдельных битов.
ynoxinul Автор
05.12.2021 22:33Не понял про колобка и черта :)
Dovgaluk
06.12.2021 08:50Шутка для олдфагов, даже не помню откуда она.
А про язык программирования будет публикация?
ynoxinul Автор
06.12.2021 12:38Я не знаю, что про язык программирования писать. Пока что мне не хочется, потому что гордиться особо нечем. Получился какой-то компилятор, который как-то компилирует. Если про него писать, это точно не будет пособием по написанию компиляторов, скорее, антипособием.
Dovgaluk
06.12.2021 13:38Компиляторы я писать умею :)
А вот про особенности синтаксиса интересно узнать.
MAXH0
Просто феноменально круто. Вообще, для меня, это что то похожее на "компьютер для попаданца" - с относительного нуля, но обладая необходимыми знаниями собрать компьютер.
ynoxinul Автор
Для попаданца в 1970-е :)
axe_chita
Цикл «Еще не поздно» Павла Дмитриева
Очень занимательное произведение :)