
Это самый длинный пост всей серии, потому что он посвящён главной части этого проекта — всё вращается вокруг CPU.
Почему бы просто не взять готовый CPU?
Кто-то может заявить: зачем заморачиваться проектированием собственного CPU? Есть куча маленьких хорошо задокументированных процессоров и дешёвых микроконтроллеров, способных исполнять прошивку калькулятора. Zilog Z80 не так сложно реализовать на FPGA, и я в этом уже убедился (проект A-Z80, находящийся у меня на GitHub). Подойдёт и 6502. Маленький встраиваемый RISC тоже прекрасно справится с этой работой.
Отвечу честно: это было бы не так интересно, потому что подобное уже много раз делали. Но есть и другие (более удобные для меня) причины.
Наш калькулятор построен на BCD (двоично-десятичном коде),в котором каждый десятичный разряд хранится в отдельном 4-битном полубайте (ниббле). Это правильный выбор для калькулятора, и он определяет всё дальнейшее. Z80 (и другие стандартные CPU) работает на уровне байтов. Для индексации регистра мантиссы из 16 нибблов с ориентированным на байты процессором пришлось бы постоянно жонглировать сдвигами, масками и двумя нибблами на байт. На каждом шаге режимы адресации вступают в конфликт со схемой данных.
Нам же нужен процессор, в котором 4 бита будут естественной единицей данных, где память адресуема по нибблам и где режимы адресации позволяют тривиально обходить мантиссу разряд за разрядом. Всего этого нет ни в одном CPU общего назначения, поэтому мы спроектируем собственный.
В 1984 году HP пришла к тому же выводу, выпустив процессор Saturn, который затем использовали в производстве HP-71B, а позже и серий HP-28 и HP-48. Регистры Saturn имеют ширину 64 бита (16 нибблов), операции работают с выбираемыми пользователем полями этих регистров (один, два ниббла, весь регистр и так далее), а всё кодирование команд целиком построено на доступе с полубайтовой дробностью. Эта архитектура применялась в самых мощных калькуляторах HP почти двадцать лет. Это самый совершенный BCD-процессор в мире, и изучение его набора команд перед проектированием собственного CPU оказалось крайне полезным (я выбирал, что копировать, а что преднамеренно делать иначе).

Ограничения, от которых зависит всё
Прежде, чем приступать к черчению блоков команд, я создал список того, в чём должен быть хорош CPU:
Операции с нибблами. АЛУ должен нативно работать с 4-битными значениями. Сложение, вычитание, сравнение: всё должно выполняться с нибблами; команды коррекции BCD (DAA и DAS) на каждом шаге должны обеспечивать соответствие результатов десятичному диапазону. Регистры общего назначения тоже должны иметь полубайтовую ширину (4 бит каждый); они получаются очень узкими, но это кажется логичным соответствием остальной архитектуре: машина, построенная на основе десятичных разрядов, должна иметь регистры того же размера, что и десятичный разряд.
Простое декодирование. Я хотел, чтобы логика аппаратного декодирования была простой и систематичной, то есть один класс операндов всегда должен занимать одни и те же битовые поля. Если классу команд требуется непосредственный операнд или индекс регистра общего назначения в качестве операнда назначения, то он всегда должен находить его в фиксированных слотах (например, bits[3:0] или [7:4]). Команды, имеющие схожие структуры, должны иметь и одинаковые правила декодирования. Это ещё и сильно упростило написание ассемблера.
Ширина адресов. Адресное пространство конечно, и мне нужно было спрогнозировать, какой объём мне понадобится. В этой реализации я тесно привязал его к ширинам команд, сделав шириной 12 бит.
Компактные команды. Я остановился на 12-битных командах фиксированной длины. Это довольно необычная длина, но зато она равна точно трём нибблам, что удобно соотносится с нашей общей ориентированностью на нибблы. 8-битная команда слишком бы нас ограничивала; 16-бит казались слишком щедрой длиной для такого набора команд.
Выбор 12 бит имел исторический прецедент, о котором стоит упомянуть: в миникомпьютере PDP-8 (1965 год) тоже использовались 12-битные команды и 12-битное адресное пространство размером 4096 слов. Команда Кена Олсена из DEC выбрала 12 бит по схожим причинам: достаточное пространство опкодов, достаточное покрытие адресов, отсутствие лишних трат. PDP-8 продавался десятками тысяч устройств и повлиял на целое поколение архитекторов компьютерных систем.
Гарвардская модель памяти. Адресные пространства команд и данных полностью разделены. Это был преднамеренный выбор с целью максимизации площади, на которой они могут разрастаться независимо друг от друга: код можно расширять до полных 4096 12-битных слов команд без конкуренции с пространством данных, а шина данных — это узкий 4-битный путь, подстроенный под ширину данных, а не под команды.
Большое количество регистров. Так как кодировка команд разбита на поля шириной в ниббл, индексы регистров естественным образом умещаются в 4 бита, что даёт нам 16 возможных регистров общего назначения (R0–R15). Кажется, что это много, и я не был уверен, что 8 регистров хватит, но понимал, что 16 регистров могут быть перебором. Вместо того, чтобы выбрать что-то конкретное, я создал параметр SystemVerilog: архитектура поддерживает или 8, или 16 регистров общего назначения; выбор можно сделать на этапе синтеза; разница в логических элементах составляет всего около 3%. Я начал писать микрокод с 8 регистрами и внимательно следил, не исчерпаются ли они. Этого так и не произошло. Восьми регистров оказалось вполне достаточно, поэтому 16 я так никогда и не включал. На случай, если параметр кому-то понадобится, я его оставил. Единственный недостаток (или цена) заключается в том, что при 8 регистрах мы впустую тратим один бит кодировки команды.
В результате получилась архитектура загрузки-хранения с гарвардской памятью (отдельные шины команд и данных), ROM 12-битных команд и пространством данных шириной 4 бита; для всего этого можно выполнять адресацию до 4096 слов.
Набор команд
Имея в голове приблизительную картину того, что хочу создать, я начал набрасывать схему опкодов. Основными источниками вдохновения для придумывания имён команд, стандартов флагов и общей структуры набора стали Z80 (годы любительской работы), ARM и x86 (профессиональная деятельность). Когда ты одновременно и архитектор, и единственный программист, знакомые паттерны снижают количество ошибок. Но такое дублирование ролей даёт и другие выгоды. У тебя есть потрясающая свобода: не нужна никакая обратная совместимость, защита установленной базы, никаких комитетов, утверждающих новый опкод. Если в архитектуре набора команд нужна команда, ты просто её добавляешь. Если команда оказывается бесполезной, сразу её удаляешь. Команды разработчиков коммерческих CPU (наподобие тех, которую увековечил Трейси Киддер в книге The Soul of a New Machine, где проектировщики оборудования и разработчики ПО были отдельными племенами, которые едва общались друг с другом) никогда не обладали подобной гибкостью. С другой стороны, у тебя есть опасное «слепое пятно»: ты меньше всего подходишь на роль человека, выявляющего неудобные команды, потому что ты сам спроектировал их и твоя ментальная модель кода естественным образом основывается на них. У проектировщиков первых калькуляторов HP имелась та же проблема. Команды, создававшие чипы серии Woodstock в начале 1970-х, одновременно разрабатывали набор команд и писали весь микрокод; это задокументировано в HP Journal той эпохи: в выпуске за ноябрь 1975 года говорится о том, что множество улучшений, внесённых в набор команд Woodstock, было вызвано трудностями, обнаруженными уже на поздних этапах процесса микропрограммирования (на бумаге всё выглядело хорошо, но на практике усложняло жизнь программиста). Они исправили всё в следующей версии чипа. Я мог исправлять всё в следующем коммите.
Получившийся набор команд можно приблизительно разбить на следующие группы:
Сохранение/загрузка:
LDM,STM,LDI(загрузка непосредственного значения),LDX/STX(индексированная, для обхода массивов регистров), плюс двухрегистровый индексированный вариантLDX2/STX2для доступа к 2D-массиву мантиссАЛУ: 14 операций:
ADD,ADC,SUB,SBC,AND,OR,XOR,CMP,BIT(битовый тест),INC,DEC,DECA(декремент, селективные флаги),BCPL(9’s complement, для вычитания BCD) иBSHR(BCD-сдвиг вправо, деление на 2).DAAиDAS(коррекция разряда BCD) и отдельные команды, не входящие в группу опкодов АЛУУмножение:
MULперемножает два ниббла (R0 × R1) и возвращает двухниббловый результат в {R1, R0}, используя таблицу поиска в ROM, а не аппаратный умножительПоток управления:
JMP/JC/JNC,CALL/CALLC,RET/RETC,BRA/BRACдля коротких ветвлений,HALT/HALTCКопирование и сравнение регистров:
MOVдля копирования между регистрами,CMPXдля сравнения любого регистра с непосредственным значениемМанипуляции с флагами:
SETF,CLRF,INVF(установка, сброс, инвертирование любого из 16 флаговых битов по индексу),PUSHF/POPF,FLGETВвод-вывод:
LCDWC(запись управляющего слова в ЖК-модуль),LCDWD(запись ASCII-строки),LCDWR(запись значения регистра в виде шестнадцатеричного разряда)Указатель стека и адреса:
PUSH/POPдля стека данных,ASTORE/ALOADдля последовательного массового сохранения/восстановления регистров по указателю адреса,APLDR/APSTRдля загрузки и сохранения самого указателя адреса
Полная таблица кодировок команд, включая все группы опкодов, условные флаги и эффекты флагов АЛУ, находится в CPU ISA Reference в папке docs/ репозитория. Ниже представлены три её основные части:
Спецификация архитектуры CPU
Набор команд
Мнемоника |
Опкод |
Описание |
|---|---|---|
Разное и системные | ||
NOP |
0000 0000 0000 |
Отсутствие операции. |
MUL |
0000 0000 0001 |
BCD-умножение {R1,R0} = R1 × R0 |
DAS |
0000 0000 0010 |
Десятичная коррекция R0 (после вычитания), если установлен флаг B |
DAA |
0000 0000 0011 |
Десятичная коррекция R0 (после сложения), если установлен флаг B |
POPF |
0000 0000 0100 |
Извлечение из стека флагов АЛУ |
PUSHF |
0000 0000 0101 |
Запись в стек флагов АЛУ |
APLDR |
0000 0000 0110 |
Загрузка указателя адреса из {R2,R1,R0} |
APSTR |
0000 0000 0111 |
Сохранение указателя адреса в {R2,R1,R0} |
FLGET |
0000 0000 1000 |
Чтение условного флага, индексированного по R0, и соответствующая установка CF |
0000 0000 1001 |
(не используется) |
|
0000 0000 101- |
(не используется) |
|
0000 0000 11– |
(не используется) |
|
Манипуляции с флагами | ||
INVF |
0000 0001 cccc |
Инвертирование выбранного флагового бита (z, c, b, a; или <0,15>) |
CLRF |
0000 0010 cccc |
Сброс выбранного флагового бита |
SETF |
0000 0011 cccc |
Установка выбранного флагового бита |
EI |
0000 0010 1111 |
Включение прерываний — псевдоним для |
DI |
0000 0011 1111 |
Отключение прерываний — псевдоним для |
Останов и ввод-вывод | ||
HALTC |
0000 010n cccc |
Условный останов (n=0: if cond=1; n=1: if cond=0; или всегда, когда n=1, c=15) |
HALTNC |
0000 0101 cccc |
Псевдоним для останова с инвертированным условием |
HALT |
0000 0101 1111 |
Всегда останов |
LCDWR |
0000 0110 rrrr |
Запись регистра в виде шестнадцатеричного разряда в ЖК-модуль (опрос ЖК-модуля) |
0000 0111 —- |
(не используется) |
|
Возврат и стек | ||
RETC |
0000 100n cccc |
Условный возврат (n=0: if cond=1; n=1: if cond=0) |
RETNC |
0000 1001 cccc |
Возврат с инвертированным условием |
RET |
0000 1001 1111 |
Безусловный возврат |
RETI |
0000 1000 1111 |
Возврат из прерывания; сбрасывает FLAG_IRQ_DIS |
POP |
0000 1100 qqqq |
Извлечение из стека R0–Rq; инкремент указателя стека |
PUSH |
0000 1101 qqqq |
Запись в стек R0–Rq; декремент указателя стека |
ALOAD |
0000 1110 qqqq |
Загрузка R0–Rq из памяти данных; increment address pointer |
ASTORE |
0000 1111 qqqq |
Сохранение R0–Rq в память данных; инкремент указателя данных |
АЛУ — Регистровые операнды | ||
CMP |
0001 0000 rrrr |
Сравнение регистра (reg) с R0; Установка CF, если R0<reg, ZF в случае равенства. Без фиксации результата. |
ADD |
0001 0001 rrrr |
R0 = R0 + reg |
ADC |
0001 0010 rrrr |
R0 = R0 + reg + carry (перенос) |
SUB |
0001 0011 rrrr |
R0 = R0 – reg |
SBC |
0001 0100 rrrr |
R0 = R0 – reg – carry |
AND |
0001 0101 rrrr |
R0 = R0 & reg |
OR |
0001 0110 rrrr |
R0 = R0 | reg |
XOR |
0001 0111 rrrr |
R0 = R0 ^ reg |
0001 1000 —- |
(нераспределённое АЛУ — без фиксации результата) |
|
INC |
0001 1001 rrrr |
Инкремент любого регистра |
DEC |
0001 1010 rrrr |
Декремент любого регистра |
DECA |
0001 1011 rrrr |
Декремент; устанавливает только AF и ZF |
BCPL |
0001 1100 rrrr |
BCD complement: CF, reg = 9 – reg + CF |
BSHR |
0001 1101 rrrr |
Сдвиг вправо с коррекцией BCD: reg = reg/2 + (CF ? 5 : 0) |
0001 111- —- |
(нераспределённое АЛУ) |
|
АЛУ — Непосредственные операнды | ||
CMPI |
0010 0000 iiii |
Сравнение R0 с непосредственным значением (immediate); R0 не меняется. Без фиксации результата. |
ADDI |
0010 0001 iiii |
R0 = R0 + immediate |
ADCI |
0010 0010 iiii |
R0 = R0 + immediate + carry |
SUBI |
0010 0011 iiii |
R0 = R0 – immediate |
SBCI |
0010 0100 iiii |
R0 = R0 – immediate – carry |
ANDI |
0010 0101 iiii |
R0 = R0 & immediate |
ORI |
0010 0110 iiii |
R0 = R0 | immediate |
XORI |
0010 0111 iiii |
R0 = R0 ^ immediate |
BIT |
0010 1000 00tt |
Тестирование бита tt регистра R0; задаёт CF, если bit=1. Без фиксации результата. |
0010 1001–111- —- |
(неиспользуемые псевдонимы / нераспределённое АЛУ) |
|
Загрузка, копирование и ЖК-модуль | ||
LDI |
0011 iiii rrrr |
Загрузка непосредственного значения в регистр |
LCDWC |
0100 iiii rrrr |
Запись 8-битного управляющего слова в ЖК-модуль (старшие 4 бита= команда, младшие 4 бита= регистр) |
LCDWD |
0101 iiii iiii |
Запись 8-битных ASCII-данных в ЖК-модуль |
MOV |
0110 pppp rrrr |
Копирование регистр p → регистр r |
CMPX |
0111 rrrr iiii |
Сравнение любого регистра с промежуточным значением; если reg<imm, устанавливается CF |
Ветвление (относительно счётчика команд) | ||
BRAC |
10nc csss ssss |
Условное относительное ветвление (смещение на ±64/63) |
BRA |
1011 1sss ssss |
Безусловное относительное ветвление |
Переходы и вызовы (двухсловные, далее следует адрес) | ||
JC |
1100 000n cccc |
Условный переход (n=0: if cond=1; n=1: if cond=0) |
JNC |
1100 0001 cccc |
Переход с инвертированным условием |
JMP |
1100 0001 1111 |
Безусловный переход |
1100 001-–1— —- |
(не распределено) |
|
KEYCALL |
1101 0000 0000 |
Обработчик ключа вызова, индексируемый по key_code CPU; далее следует адрес таблицы диспетчеризации |
TBLCALL |
1101 0000 0001 |
Обработчик вызова, индексируемый по R0; далее следует адрес таблицы диспетчеризации |
1101 0000 001-–1— |
(не распределено) |
|
CALLC |
1101 001n cccc |
Условный вызов (n=0: if cond=1; n=1: if cond=0); далее следует адрес |
CALLNC |
1101 0011 cccc |
Вызов с инвертированным условием |
CALL |
1101 0011 1111 |
Безусловный вызов |
1101 01–1— —- |
(не распределено) |
|
Доступ к памяти (двухсловный) | ||
LDM |
1110 0000 rrrr |
Загрузка регистра из памяти; далее следует адрес |
STM |
1110 0001 rrrr |
Сохранение регистра в память; далее следует адрес |
LDX |
1110 0010 rrrr |
Загрузка из базового адреса + индексированного смещения; слово 2: base(11:4) | index-reg(3:0) |
STX |
1110 0011 rrrr |
Сохранение в базовый адрес + индексированное смещение |
LDX2 |
1110 0100 rrrr |
Загрузка из базового + двух индексных регистров; word 2: base(11:8) | idx2(7:4) | idx1(3:0) |
STX2 |
1110 0101 rrrr |
Сохранение в базовый + два индексных регистра |
1110 011- —- |
(зарезервировано — паттерн декодера в Verilog) |
|
LDAP |
1110 1000 0000 |
Загрузка указателя адреса с непосредственным значение; далее следует адрес |
1110 1000 0001–11 … |
(не распределено) |
|
CALLI |
1111 qqqq rrrr |
Загрузка r4=qqqq, r3=rrrr в качестве аргументов, затем вызывается подпрограмма; далее следует адрес |
Решение с таблицей ROM для одноразрядного умножения оказалось простым и эффективным. В первом HP-35 (1972 год) не было аппаратного умножителя и таблицы поиска: он выполнял BCD-умножение посредством итеративного сдвига и сложения в микрокоде, благодаря чему количество чипов ограничивалось пятью интегральными схемами (два процессорных чипа плюс три ROM), но процесс был медленным. Билл Хьюлетт сказал разработчикам HP-35, что калькулятор обязательно должен умещаться в карман рубашки, поэтому они считали каждый транзистор.
Одна команда, добавленная на последних этапах процесса разработки, оказалась важнее, чем ожидалось: CALLI (вызов с неявной передачей аргумента). После завершения написания микрокода я провёл анализ частоты использования функций (в продакшен-микрокоде содержится 2604 команд, не считая тестов) и выяснил, что ldi и call составляют 28% всего кода. Такого высокого показателя я не ожидал. Паттерн просматривался чётко: почти перед каждым вызовом call шли команды ldi, записывающие аргументы в R4 и R3. Как только замечаешь этот паттерн, он становится очевидным. Добавление команды CALLI снизило общий размер кода с 3451 слов (84% из 4096 доступных) до 3265 слов (79%), позволив сэкономить 186 слов (5,3%). Подобные открытия меня искренне радуют: это похоже на то, как вы случайно находите деньги, оставшиеся в куртке с прошлой зимы.

Я смог обнаружить этот (и некоторые другие) возможности оптимизации потому, что писал микрокод, строго следуя одинаковым паттернам: всегда использовал одно и то же множество регистров для передачи аргументов подпрограммам, одинаковые паттерны там, где повторялись последовательности кода и так далее; по сути, я писал очень «скучный», структурированный код, не пытаясь особо умничать — наверно, именно поэтому всё почти всегда работало с первой попытки.
Джон Кок из IBM Research в середине 70-х показал, что приблизительно 20% команд в типичной программе занимают примерно 80% времени исполнения. Его открытие стало одним из основ движения RISC: если в среде исполнения доминирует лишь несколько команд, то можно оптимизировать их и упростить всё остальное. Дэвид Паттерсон из Беркли позже придумал название RISC и опубликовал в 1982 году процессор Berkeley RISC-I, у которого была всего 31 команда в 44 тысячах транзисторов; при этом в ключевых бенчмарках она демонстрировала производительность, сравнимую с машинами класса VAX. Его вывод был таким же, как и в случае с оптимизацией CALLI: надо измерять то, что исполняется на самом деле, а потом улучшать это.
В версии 2025 года я добавил ещё множество команд, возникших благодаря тому же процессу отслеживания паттернов. TBLCALL занимается диспетчеризацией функций скриптинга: на основании базового адреса (второе слово) и индекса в регистре R0 она вычисляет место перехода как base + R0, а затем посредине конвейера превращает себя в безусловный JMP (удобный трюк, позволяющий избежать дополнительного цикла получения команды). DECA — это таргетированная команда АЛУ, выполняющая декремент регистра и обновляющая только ZF и AF, оставляя CF и BF неприкосновенными для объединения цепочек внутренних арифметических операций. Флаг AF устанавливается, если значение до декремента было ненулевым, и сбрасывается, если было равно нулю, благодаря чему DECA становится подходящим инструментом для счётчиков цикла, которым нужно проверять «был ли я уже равен нулю», а не «произошло ли у меня только что отрицательное переполнение?»
Другие изменения, связанные с добавлением в CPU прерываний, будут описаны в посте 9.
Стоит отметить подробность реализации, связанную с кодированием условий. Каждая команда, поддерживающая условие, имеет 4-битное поле условия в битах [3:0], позволяя выбирать один из 16 возможных битов условий. Первые четыре — это стандартные флаги АЛУ (Z, C, B и A). Оставшиеся двенадцать — это программные флаги общего назначения; все их можно устанавливать, сбрасывать и инвертировать командами из одного слова. Бит 4 поля условия обращает выбранное условие, поэтому кодировка для «если флаг условия 1 равен нулю, делай это» выглядит, как 0b1_0001.
Условные и безусловные команды имеют одинаковый паттерн кодирования — мы проверяем особый случай, в котором флаг условия номер 15 с установленным битом обращения обрабатывается, «как всегда», что позволяет изящным образом избавиться от необходимости в отдельном пространстве безусловных команд.
Кодирование работает так:
Условие |
Кодирование (n + флаг) |
Meaning |
|---|---|---|
|
|
Переход, если установлен флаг нуля |
|
|
Переход, если сброшен флаг нуля |
|
|
Переход, если установлен флаг переноса |
|
|
Переход, если установлен программный флаг 7 |
|
|
Переход, если сброшен программный флаг 7 |
|
|
Всегда (особое значение из всех единиц) |
Ассемблер также принимает описательные псевдонимы: eq для установленного флага нуля, ne для сброшенного флага нуля, lt для установленного флага переноса и ge для сброшенного флага переноса.
Для команд ветвления (BRA/BRAC) поле условия имеет ширину всего 3 бита (выбор выполняется только из четырёх флагов АЛУ), а особый случай {1,1,1} кодирует безусловное ветвление.
Переходам и вызовам нужен полный 12-битный целевой адрес, который задаётся во втором слове команды. Для дальних переходов это работает нормально, но стоит двух слов на каждое ветвление. В случае коротких условных ветвлений, которые постоянно встречаются в коротких циклах, трата двух слов оказывается излишней, но одного слова (12-битного) недостаточно для добавления адреса всего пространства. Компромиссом стала команда BRA: в одно 12-битное слово закодировано 7-битное смещение со знаком (позволяющая достигать от -64 до +63 слов в каждом направлении) и укороченное множество условий, охватывающее только четыре флага АЛУ плюс бит обращения. Этого оказалось достаточно для всех ветвлений внутренних циклов, а а также для многих других близких переходов при продуманном структурировании кода. В этом помогает и ассемблер: он определяет, когда цель перехода достаточно близка для использования BRA, и рекомендует использовать укороченную форму.
АЛУ и BCD-арифметика
АЛУ имеет ширину 4 бита и реализует 14 операций. Большинство из них простые; самые интересные — это команды поддержки BCD.
После прибавления ниббла результат может быть в интервале от 10 до 15 (допустимо в шестнадцатеричном виде, но не в BCD). Команда DAA (десятичная коррекция после сложения) проверяет это и преобразует значение в интервал 0–9, также устанавливая флаг переноса для следующего разряда. DAS выполняет эквивалентную операцию после вычитания, прибавляя 10. Эти две команды обеспечивают возможность работы алгоритмов последовательного по нибблам сложения и вычитания BCD. DAA и DAS могут показаться вам знакомыми, и это не совпадение: они позаимствованы напрямую из процессора 8086, где выполняли ту же задачу.
Z80 объединил обе коррекции в одну команду DAA, считывающую флаг N (устанавливаемый предшествующим вычитанием) для выбора коррекции после сложения или вычитания. До него 8080 обрабатывал только сложение. 8086 разделил их на две отдельные команды (DAA и DAS), как и в моей архитектуре. (Иногда приходится соглашаться с Intel.)
BSHR (сдвиг вправо с коррекцией BCD) делит разряд на 2 и позволяет образовывать цепочки (в микрокоде) между разрядами при помощи флага переноса. Это истинный десятичный сдвиг, его формула выглядит так: x / 2 + (CF_in ? 5 : 0). Если предыдущий разряд нечётный, его оставшаяся половина (5) передаётся вниз как перенос и прибавляется к текущему разряду. Перенос — это младший бит разряда, передаваемый следующему разряду в цикле. Последний перенос сообщает нам, есть ли у общего числа остаток.
Эта команда функционально идентична микропримитивам SRB (Shift Right BCD) из архитектуры Saturn Hewlett-Packard и специализированных программируемых логических матриц BCD серий Texas Instruments TMS1100 и Hitachi HMCS40.
Структура памяти
Процессор имеет два независимых адресных пространства (гарвардская архитектура):
Пространство команд: адреса шириной 12 бит, слова команд шириной 12 бит (до 4096 команд)
Пространство данных: адреса шириной 12 бит, нибблы данных шириной 4 бита (до 4096 адресов)
Пространство адресов данных калькулятора имеет следующую структуру:
Пространство адресов данных
Диапазон адресов |
Размер |
Область |
Содержимое |
|---|---|---|---|
ОЗУ |
|||
|
256 |
Регистровый файл |
16 регистров × 16-ниббловая мантисса: X, Y, Z, T, LASTX, R (результат), S0–S4 (временная память), 5 статистических накопителей |
|
32 |
Экспоненты |
16 регистров × 2-ниббловая экспонента (верхняя часть в |
|
16 |
Записи знаков |
16 регистров × 1-ниббловый знак (биты: знак мантиссы, знак экспоненты, валидность) |
|
16 |
Системные переменные |
Формат отображения, состояние сдвига, количество разрядов, код ошибки, разряд защиты, бит фиксации и так далее. |
|
202 |
Пользовательская память |
Регистры STO/RCL 0–9 (мантисса, экспонента, знак для каждой) |
|
246 |
Свободно |
Свободно для использования в будущем |
|
256 |
Стек данных |
Разрастается вниз от |
ROM |
|||
|
512 |
ROM констант |
До 32 полных 16-ниббловых констант: π, e, ln(10), таблицы CORDIC/log |
I/O |
|||
|
1 |
STRAPS / LED |
Чтение: 4 аппаратных конфигурационных бита. Запись: 4 светодиода на передней панели |
|
1 |
SYSCTL |
Управление системой (бит 0: включение принтера) |
|
1 |
PRNG |
Чтение: случайный ниббл из регистра сдвига с линейной обратной связью Галуа |
|
1 |
KEY_READY |
Чтение: флаг готовности клавиш (бит 0). Чтение: сброс флага готовности клавиш |
ROM |
|||
|
2,048 |
ROM скриптинга |
Упакованные 4-битные токены для интерпретатора скриптинга |
0x000–0x3FF — это ОЗУ, содержащая всё, с чем напрямую работает микрокод. Первый блок (0x000–0x0FF) — это регистровый файл: четыре регистра стека RPN X, Y, Z и T, каждый из которых занимает 16 нибблов мантиссы, за которыми следует LASTX, регистр временных данных RESULT, пять регистров временных данных (S0–S4) и пять регистров статистического накопителя (n, среднее, скользящее стандартное отклонение, ΣX, ΣX²). Выше ниш все экспоненты хранятся отдельно в компактном блоке по адресу 0x100: по два ниббла на регистр, 16 регистров один за другим. За ними следуют записи знаков по адресу 0x120: по одному нибблу каждая, с отдельными битами под знак мантиссы, знак экспоненты и флаг валидности. С адреса 0x130 начинаются системные переменные (формат отображения, состояние сдвига, количество разрядов, код ошибки и прочие.
0x300–0x3FF — это стек данных. Указатель стека инициализируется на вершине ОЗУ и растёт вниз. Защитное пороговое значение SP_GUARD установлено равным 0x300: любая запись в стек, которая опускает указатель стека ниже этого адреса, приводит к немедленному сбою CPU ещё до выполнения записи. Слишком большое количество извлечений из стека возвращает указатель обратно к нулю, который так же меньше защитного значения, поэтому это тоже приводит к сбою. На практике, это позволило обнаружить множество багов микрокода, на локализацию которых в противном случае потребовалось гораздо больше усилий.
0x400–0x5FF — это ROM констант: 512 нибблов блоковой памяти, содержащей до 32 полных 16-ниббловых мантисс. Именно здесь хранятся число пи, e, таблицы поиска CORDIC и логарифмов. Доступ к ним добавляет один такт задержек чтения, что согласуется с таймингом ОЗУ.
0x600–0x7FF — это MMIO. Запись в 0x600 позволяет управлять тремя светодиодами; чтение из него возвращает четыре аппаратных конфигурационных бита (на данный момент я использую эти биты, чтобы сообщать о том, подключен ли дисплей к симуляции). 0x601 — это регистр SYSCTL (бит 0 связывает принтер с шиной ЖК-модуля). 0x602 считывает свежий ниббл из аппаратного генератора псевдослучайных чисел, основанного на регистре сдвига с линейной обратной связью Галуа. 0x603 считывает состояние клавиатуры (флаг готовности клавиш), а при записи сбрасывает это флаг. Сам код клавиши передаётся в CPU через выделенный порт ввода, используемый командой KEYCALL.
0x800–0xFFF — это ROM скриптинга: 2048 нибблов упакованных 4-битных токенов для интерпретатора скриптинга.
Пространство команд совершенно отделено от пространства данных: полные 4096 × 12-битных слов ROM микрокода, никак не конфликтующие с описанным выше.
Итеративный цикл
Логично предположить, что сначала проектируется весь ЦПУ, затем пишется ассемблер, затем микрокод. Но у меня всё было иначе.
Реальный процесс представлял собой взаимосвязанный цикл, по одной команде за раз: я добавлял команду в RTL, добавлял в ассемблер правило её кодирования, собирал программу и писал тест. Затем прогонял тест через Verilator (который компилирует Verilog в потактово-точную модель на C++), проверял, что команда выполняется корректно и не ничему не мешает. И только после прохождения теста я двигался дальше.

Это был единственный разумный способ работы. Если бы я попытался сначала полностью разработать оборудование и тестировать его целиком, то отладка бы превратилась в кошмар. В моём цикле проблемы отлавливались на ранних этапах, ещё до того, как их оказывалось сложно изолировать.
test_self_check.asm — это первая линия защиты. Этот тестовый код выполняет каждую команду, проверяет её результат и отправляет HALT, если результат не соответствует спецификациям и/или ожиданиям. HALT вызывает сбой, при котором выводится адрес сбоя, что упрощает быстрые проверки и прогоны выявления регрессий.
Создав практичное множество базовых команд, я приступил к написанию микрокода одной из функций калькулятора. И именно на этом этапе я получил реальную обратную связь от архитектуры CPU. При написании реального кода быстро становилось ясно, правильно ли реализовано множество команд. Например, на этом этапе можно обратиться к чему-то, чего ещё нет. Или найти повторяющийся везде паттерн и понять, что это должно быть одной командой, а не тремя. Вы обнаруживаете, что две команды, которые вы считали отдельными, можно объединить в одну с дополнительным битом кодирования, что и упрощает логику декодирования, и позволяет использовать команду по-новому.
Иногда я полностью удалял команду. При проектировании CPU часто испытываешь соблазн писать команды, которые кажутся изящными, но редко пригождаются на практике. Они занимают место кодирования и повышают сложность декодирования, почти не обеспечивая никакого выигрыша. Требуется дисциплина, чтобы избавляться от них. На определённом этапе я отказался от BRANC и TEST, осознав, что оставшиеся условные механизмы полностью покрывают сценарии их использования, не требуя при этом лишних опкодов.
Параллельно всему этому эволюционировала и внутренняя архитектура калькулятора: расположение переменных в памяти, структура регистров, выбор пространства временных данных для каждого алгоритма. Эти решения часто влияют и на разработку команд. Например, режимы адресации LDX2 и STX2 окончательно сформировались после того, как структура 16-битных регистров мантиссы выстроилась в матрицу, которую можно адресовать просто с помощью соседствующих друг с другом 4-битных индексов.
Сам ассемблер — это состоящий из двух проходов скрипт на Python 3 (casm.py) размером меньше 700 строк; он поддерживает предварительные ссылки, условную сборку, многоуровневые включения файлов, локальные метки внутри подпрограмм, вычисление выражений и множество других псевдодиректив (PROC, EQU, DEFINE), которые намеренно позаимствованы из MASM и TASM; частично это вызвано тем, что я осваивал ассемблер в этих инструментах, частично — тем, что они уже стали устоявшимся стандартом.
Этот итеративный цикличный процесс больше походил на лепку, чем на разработку. Я начинаю с грубой формы, и каждый на каждом проходе выясняю, от чего нужно избавиться, а что требует дополнительного труда. Возникший набор команд непохож на тот, который я изначально проектировал на бумаге. Он стал даже лучше.
Самое странное в проектировании собственной архитектуры набора команд
Есть что-то странное с философской точки зрения в написании кода для спроектированного тобой процессора. Ты полностью знаешь его внутренности: каждое состояние в конвейере исполнения, каждый путь в логике декодирования. Тем не менее, когда приступаешь к написанию микрокода, то осознаёшь, что совершенно не знаешь процессор. Не знаешь его личности. Не знаешь, какую последовательность команд естественно будет в нём использовать, какие режимы адресации будут неудобны на практике, что ты забыл и какие пограничные случаи обернутся проблемами.
Начинаешь и по-другому думать о коде, который пишешь. При работе со стандартным CPU мы сначала оптимизируем корректность кода, потом его производительность. В этом же случае начинаешь беспокоиться о чём-то более фундаментальном: выбрал ли я для себя подходящие инструменты? Каждая точка неэффективности в микрокоде — потенциальный симптом недостающей команды или ошибочной архитектуры. Каждое место, где приходится использовать обходное решение, становится намёком на дефицит чего-то в архитектуре наборы команд.
Чем больше микрокода пишешь, тем большему учишься, но внесение изменений становится всё сложнее и монотоннее. В конечном итоге, я очень доволен своим набором команд и общими характеристиками CPU. Он оказался идеально подходящим для своей задачи.
В следующем посте мы поговорим о том, что происходит, когда реально приступаешь к написанию микрокода для этой архитектуры набора команд и обнаруживаешь, в каких конкретно местах архитектуры ты немного ошибся.
Исходники CPU и ассемблера можно найти в репозитории FPGA-Calculator. Документ со спецификацией CPU лежит в папке docs.