
Это самый длинный пост всей серии, потому что он посвящён главной части этого проекта — всё вращается вокруг 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.
