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