- BrainTools - https://www.braintools.ru -

Это самый длинный пост всей серии, потому что он посвящён главной части этого проекта — всё вращается вокруг CPU.
Кто-то может заявить: зачем заморачиваться проектированием собственного CPU? Есть куча маленьких хорошо задокументированных процессоров и дешёвых микроконтроллеров, способных исполнять прошивку калькулятора. Zilog Z80 не так сложно реализовать на FPGA, и я в этом уже убедился (проект A-Z80 [2], находящийся у меня на GitHub). Подойдёт и 6502. Маленький встраиваемый RISC тоже прекрасно справится с этой работой.
Отвечу честно: это было бы не так интересно, потому что подобное уже много раз делали. Но есть и другие (более удобные для меня) причины.
Наш калькулятор построен на BCD (двоично-десятичном коде),в котором каждый десятичный разряд хранится в отдельном 4-битном полубайте (ниббле). Это правильный выбор для калькулятора, и он определяет всё дальнейшее. Z80 (и другие стандартные CPU) работает на уровне байтов. Для индексации регистра мантиссы из 16 нибблов с ориентированным на байты процессором пришлось бы постоянно жонглировать сдвигами, масками и двумя нибблами на байт. На каждом шаге режимы адресации вступают в конфликт [3] со схемой данных.
Нам же нужен процессор, в котором 4 бита будут естественной единицей данных, где память [4] адресуема по нибблам и где режимы адресации позволяют тривиально обходить мантиссу разряд за разрядом. Всего этого нет ни в одном CPU общего назначения, поэтому мы спроектируем собственный.
В 1984 году HP пришла к тому же выводу, выпустив процессор Saturn [5], который затем использовали в производстве HP-71B, а позже и серий HP-28 и HP-48. Регистры Saturn имеют ширину 64 бита (16 нибблов), операции работают с выбираемыми пользователем полями этих регистров (один, два ниббла, весь регистр и так далее), а всё кодирование команд целиком построено на доступе с полубайтовой дробностью. Эта архитектура применялась в самых мощных калькуляторах HP почти двадцать лет. Это самый совершенный BCD-процессор в мире, и изучение его набора команд перед проектированием собственного CPU оказалось крайне полезным (я выбирал, что копировать, а что преднамеренно делать иначе).
Прежде, чем приступать к черчению блоков команд, я создал список того, в чём должен быть хорош CPU:
Операции с нибблами. АЛУ должен нативно работать с 4-битными значениями. Сложение, вычитание, сравнение: всё должно выполняться с нибблами; команды коррекции BCD (DAA и DAS) на каждом шаге должны обеспечивать соответствие результатов десятичному диапазону. Регистры общего назначения тоже должны иметь полубайтовую ширину (4 бит каждый); они получаются очень узкими, но это кажется логичным соответствием остальной архитектуре: машина, построенная на основе десятичных разрядов, должна иметь регистры того же размера, что и десятичный разряд.
Простое декодирование. Я хотел, чтобы логика [6] аппаратного декодирования была простой и систематичной, то есть один класс операндов всегда должен занимать одни и те же битовые поля. Если классу команд требуется непосредственный операнд или индекс регистра общего назначения в качестве операнда назначения, то он всегда должен находить его в фиксированных слотах (например, bits[3:0] или [7:4]). Команды, имеющие схожие структуры, должны иметь и одинаковые правила декодирования. Это ещё и сильно упростило написание ассемблера.
Ширина адресов. Адресное пространство конечно, и мне нужно было спрогнозировать, какой объём мне понадобится. В этой реализации я тесно привязал его к ширинам команд, сделав шириной 12 бит.
Компактные команды. Я остановился на 12-битных командах фиксированной длины. Это довольно необычная длина, но зато она равна точно трём нибблам, что удобно соотносится с нашей общей ориентированностью на нибблы. 8-битная команда слишком бы нас ограничивала; 16-бит казались слишком щедрой длиной для такого набора команд.
Выбор 12 бит имел исторический прецедент, о котором стоит упомянуть: в миникомпьютере PDP-8 [7] (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 [8], где проектировщики оборудования и разработчики ПО были отдельными племенами, которые едва общались друг с другом) никогда не обладали подобной гибкостью. С другой стороны, у тебя есть опасное «слепое пятно»: ты меньше всего подходишь на роль человека, выявляющего неудобные команды, потому что ты сам спроектировал их и твоя ментальная модель кода естественным образом основывается на них. У проектировщиков первых калькуляторов HP имелась та же проблема. Команды, создававшие чипы серии Woodstock [9] в начале 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 [10] в папке docs/ репозитория. Ниже представлены три её основные части:
|
Мнемоника |
Опкод |
Описание |
|---|---|---|
|
Разное и системные |
||
|
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 [11] (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, и рекомендует использовать укороченную форму.
АЛУ имеет ширину 4 бита и реализует 14 операций. Большинство из них простые; самые интересные — это команды поддержки BCD.
После прибавления ниббла результат может быть в интервале от 10 до 15 (допустимо в шестнадцатеричном виде, но не в BCD). Команда DAA (десятичная коррекция после сложения) проверяет это и преобразует значение в интервал 0–9, также устанавливая флаг переноса для следующего разряда. DAS выполняет эквивалентную операцию после вычитания, прибавляя 10. Эти две команды обеспечивают возможность работы алгоритмов последовательного по нибблам сложения и вычитания BCD. DAA и DAS могут показаться вам знакомыми, и это не совпадение: они позаимствованы напрямую из процессора 8086, где выполняли ту же задачу.
Z80 объединил обе коррекции в одну команду DAA [12], считывающую флаг 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 |
Системные переменные |
Формат отображения, состояние сдвига, количество разрядов, код ошибки [13], разряд защиты, бит фиксации и так далее. |
|
|
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; частично это вызвано тем, что я осваивал ассемблер в этих инструментах, частично — тем, что они уже стали устоявшимся стандартом.
Этот итеративный цикличный процесс больше походил на лепку, чем на разработку. Я начинаю с грубой формы, и каждый на каждом проходе выясняю, от чего нужно избавиться, а что требует дополнительного труда. Возникший набор команд непохож на тот, который я изначально проектировал на бумаге. Он стал даже лучше.
Есть что-то странное с философской точки зрения [14] в написании кода для спроектированного тобой процессора. Ты полностью знаешь его внутренности: каждое состояние в конвейере исполнения, каждый путь в логике декодирования. Тем не менее, когда приступаешь к написанию микрокода, то осознаёшь, что совершенно не знаешь процессор. Не знаешь его личности. Не знаешь, какую последовательность команд естественно будет в нём использовать, какие режимы адресации будут неудобны на практике, что ты забыл и какие пограничные случаи обернутся проблемами.
Начинаешь и по-другому думать о коде, который пишешь. При работе со стандартным CPU мы сначала оптимизируем корректность кода, потом его производительность. В этом же случае начинаешь беспокоиться о чём-то более фундаментальном: выбрал ли я для себя подходящие инструменты? Каждая точка неэффективности в микрокоде — потенциальный симптом недостающей команды или ошибочной архитектуры. Каждое место, где приходится использовать обходное решение, становится намёком на дефицит чего-то в архитектуре наборы команд.
Чем больше микрокода пишешь, тем большему учишься, но внесение изменений становится всё сложнее и монотоннее. В конечном итоге, я очень доволен своим набором команд и общими характеристиками CPU. Он оказался идеально подходящим для своей задачи.
В следующем посте мы поговорим о том, что происходит, когда реально приступаешь к написанию микрокода для этой архитектуры набора команд и обнаруживаешь, в каких конкретно местах архитектуры ты немного ошибся.
Исходники CPU и ассемблера можно найти в репозитории FPGA-Calculator [15]. Документ со спецификацией CPU лежит в папке docs.
Автор: PatientZero
Источник [16]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/32445
URLs in this post:
[1] ← Четвёртая и пятая части: https://habr.com/ru/articles/1037312/
[2] A-Z80: https://github.com/gdevic/A-Z80
[3] конфликт: http://www.braintools.ru/article/7708
[4] память: http://www.braintools.ru/article/4140
[5] процессор Saturn: https://en.wikipedia.org/wiki/HP_Saturn
[6] логика: http://www.braintools.ru/article/7640
[7] миникомпьютере PDP-8: https://en.wikipedia.org/wiki/PDP-8
[8] The Soul of a New Machine: https://en.wikipedia.org/wiki/The_Soul_of_a_New_Machine
[9] чипы серии Woodstock: https://www.hpmuseum.org/journals/woodb.htm
[10] CPU ISA Reference: https://github.com/gdevic/FPGA-Calculator/blob/main/docs/cpu-isa.md
[11] первом HP-35: https://en.wikipedia.org/wiki/HP-35
[12] Z80 объединил обе коррекции в одну команду DAA: https://www.zilog.com/docs/z80/um0080.pdf
[13] ошибки: http://www.braintools.ru/article/4192
[14] зрения: http://www.braintools.ru/article/6238
[15] репозитории FPGA-Calculator: https://github.com/gdevic/FPGA-Calculator
[16] Источник: https://habr.com/ru/articles/1037474/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1037474
Нажмите здесь для печати.