Компьютер на логических микросхемах: исполнение инструкций

В голосовании к прошлой статье с небольшим отрывом победила видеокарта, но так как у нас тут не демократия, а конституционная монархия, про видеокарту будет следующая статья, а эта — про кодирование инструкций и их исполнение.

Флаги

Перед тем, как перейти к самим инструкциям, расскажу о флагах. В процессоре флаги — это однобитовые значения, которые изменяются в результате арифметических действий (или других событий) и используются для условных переходов или других условных операций. В моем процессоре четыре флага:

Перенос и переполнение

При программировании на ассемблере нет знаковых или беззнаковых чисел: любое число можно рассматривать либо так, либо этак. Например, утверждения »регистр 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. Таким образом процессор «знает», какую инструкцию он сейчас исполняет.

Блок-схема процессора

6456fb1907486c16acd46a52fb3d050c.svg

Арифметические инструкции

0CCCCIRR

Старший бит кода арифметической инструкции ноль. Он и определяет этот класс инструкций.

Следующие четыре бита — код арифметической операции, напрямую подаваемый на вход АЛУ. Всего 16 операций:

  • MOV — просто копирование,

  • ADD — сложение,

  • ADC — сложение с переносом,

  • SUB — вычитание,

  • SBB — вычитание с переносом,

  • INC — инкремент,

  • DEC — декремент,

  • OR — побитовое ИЛИ,

  • AND — побитовое И,

  • XOR — побитовое исключающее ИЛИ,

  • NOT — побитовое отрицание,

  • NEG — смена знака числа,

  • SHL — сдвиг влево на один бит,

  • SHR — логический сдвиг вправо на один бит,

  • SAR — арифметический сдвиг вправо на один бит,

  • EXP — устанавливает регистр в 00 или FF в зависимости от флага переноса.

Дальше идет бит инверсии, определяющий порядок операндов. Как мы помним, из-за жесткой привязки выхода регистра A к первому входу АЛУ один из аргументов должен обязательно быть А. Этот бит и определяет, первый это аргумент (0) или второй (1).

Пояснение про порядок операндов

Если вы не знакомы с ассемблером (диалект Intel), то нужно помнить, что первый операнд инструкции — это то, куда записывается результат. Например:

sub b, a

Тут результат вычитания a из b будет записан в b. На си-подобном языке это было бы так:

b = b - a;

И последние два бита — это индекс регистра, используемого в качестве второго операнда.

  • 01 — B,

  • 10 — PL,

  • 11 — PH.

Комбинация 00 должна соответствовать регистру A, но он не подключен к ведущей на второй вход АЛУ шине, поэтому при использовании этой комбинации на этой шине будет ноль благодаря подтягивающим резисторам. Таким образом возможна арифметика между A (фиксированным первым операндом) и нулем.

Для примера рассмотрим инструкцию ADD PL, A. Битовое представление будет следующим:

  • старший бит всегда 0,

  • код операции ADD 1001,

  • бит инверсии установлен (1), так как А — второй аргумент,

  • и код регистра PL — 10.

Итого 01001110 или 0x4E. На знакомой блок-схеме исполнение этой инструкции будет выглядеть так:

15565e55d1d5ea3abad4b3a5bc2c5c3d.svg

Красными стрелками обозначены сигналы, которые будут активны при исполнении этой инструкции. Звездочки показывают, какое устройство активно, т.е. определяет уровни сигналов, на конкретной шине. Теперь рассмотрим временную диаграмму исполнения этой инструкции:

1a6f0b7818508ad99d30d098db0270aa.svgПримечание для дотошных

На диаграмме все сигналы для простоты имеют активный уровень 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

ac459d9c7b3eb52bb82ead9ce714d125.svg

Здесь вступает в игру новый сигнал — supercycle, который является замедленным в два раза cycle. Благодаря ему исполнение инструкции LDI занимает четыре такта. IP инкрементируется два раза (4 и 8). В середине (6), когда на шине данных константа для загрузки, сигналом oe_d_di активируется буфер, соединяющий внешнюю шину данных со внутренней, и значение с этой шины защелкивается в регистр B благодаря активному we_b.

Активные сигналы в момент времени (6)Активные сигналы в момент времени (6)

Загрузка из памяти

100xxxRR

Последние два бита так же, как и в LDI, кодируют регистр, в который будет загружено значение. Как вы помните, адрес памяти, откуда будет взят байт, хранится в PH: PL.

Пример: LD A

Код инструкции: 10000000

7fddbd0efa06a97d3aaaa2e1eb3bd0d9.svg

Здесь по уже знакомому сигналу cycle активируется сигнал addr_p (3), по которому на шину адреса выводится значение из PH: PL вместо IP. Как и в LDI, значение с внешней шины данных передается на внутреннюю, а с нее по нисходящему фронту clk (4) защелкивается в нужный регистр. Инструкция исполняется за два такта.

Активные сигналы в момент времени (4)Активные сигналы в момент времени (4)

Сохранение в память

101xxxxR

Здесь под код регистра отводится только один бит (A или B), потому что сохранение PL или PH не имеет смысла: в них хранится адрес той ячейки, куда сохраняем!

Пример: ST B

Код инструкции: 10100001

5d7704b5c82fdf5338504f5ce422bdd3.svg

На этой диаграмме шина D показана иначе, чем раньше: тут показано то значение, которое устанавливает на нее процессор. По умолчанию шина находится в плавающем состоянии, но по сигналу oe_b_d (3–5) на нее подается значение из регистра B. Так как в это же время сигнал oe_mem становится неактивным, конфликта на шине не будет: память ее «отпускает». По нисходящему фронту we_mem (4) значение с шины данных записывается в память по адресу из P, который, как и в случае с LD, устанавливается на шине с помощью сигнала addr_dp. Сигнал we_cycle — сдвинутый на 90º cycle — нужен для удобства получения сигнала we_mem, который занимает половину периода clk.

Активные сигналы в момент времени (4)Активные сигналы в момент времени (4)

Эти тайминги работали отлично, пока я не добавил переключение банков и не стал запускать программы из ОЗУ. Тогда возникла проблема: так как сигнал we_mem устанавливается слишком быстро (3), а на шине еще старый адрес, данные в памяти портились. Чтобы это исправить, я напаял на плате модуля управления схему небольшой задержки сигнала на RC-цепочке. Получились такие тайминги:

38345e5caf5535a52688311f6647a328.svg

Теперь адрес успевает установиться до того, как будет запрошена запись.

Схема для задержки сигнала

image-loader.svg

Здесь сигналы 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

1fc11e4003403a1c2d76be5fcfb627be.svg

Диаграмма выглядит так же, как и остальные, кроме сигнала swap_p, который устанавливается в единицу, если должен быть выполнен переход. Если swap_p выставлен в единицу, по нисходящему фронту clk (4), переключается флаг, определяющий, какой из физических регистров в блоке P — это IP, а какой PH: PL.

Переключение селектора PПереключение селектора P

Реализация

Вот и все инструкции. Имея временные диаграммы, несложно нарисовать логические схемы, которые должны выдавать все управляющие сигналы. Эти схемы реализованы на синей плате, а управляющие сигналы наглядно выведены на светодиоды.

Модуль управленияМодуль управления

Когда я только начинал делать процессор, я хотел сделать по-другому: чтобы быстрее получить что-то работающее, использовать ПЗУ с таблицей значений вместо дискретной логики. Оказалось, что так не получится: при переключении адресов ПЗУ выдает неопределенные значения, случайные всплески сигналов, поэтому тот вариант оказался совершенно неработоспособным. Процессор вел себя случайным и непредсказуемым образом. С АЛУ, однако, такой подход прошел, потому что значения с выхода АЛУ не используются как управляющие сигналы. Моя первая версия АЛУ была на двух микросхемах ПЗУ, но потом я его тоже переделал.

Программирование

У меня используется классическая (для C или C++) модель сборки. Компилятор выдает ассемблерный код, ассемблер делает из него объектные файлы, линкер собирает их в бинарный файл для прошивки в ПЗУ или для записи на SD-карту. Всё это управляется системой сборки SCons — ее очень просто настроить под себя и использовать нестандартные компиляторы.

Линкер оптимизирующий: умеет выкидывать неиспользуемые секции (аналог --gc-sections в GNU ld) и перемешивать их так, чтобы максимально заполнять пустые места, остающиеся от выравнивания.

Компилятор (и язык) я назвал Natrix (латинское название ужа), но по сути это урезанный Си: без неявных кастов, с ограничениями по вызову функций, без конструкций вроде union и switch. Главная проблема для компилятора — это отсутствие аппаратного стека, которую я решаю так: все локальные переменные в функциях выделяются статически, адрес возврата тоже кладется в статическую (уникальную для функции) переменную. Временные переменные для вычисления выражений тоже статические, но общие для всех функций. Аргументы и возвращаемое значение — тоже статические переменные. Такое обилие статических переменных — не проблема, так как нехватки ОЗУ никогда не наблюдается: намного раньше заканчивается память для кода (ха-ха).

Чтобы всё-таки можно было использовать рекурсию, используется программный стек. При компиляции строится граф вызовов, и если обнаружены циклы, то перед этими рекурсивными вызовами все статические переменные вызывающей функции кладутся на стек, а потом восстанавливаются. Это очень дорого, но рекурсия работает.

Умножение, деление и сдвиг на переменную программные и заменяются на вызовы встроенных функций. Простое умножение (переменной-байта на константу) и сдвиги на константу встраивается.

Пример сгенерированного кода: сдвиг 32-битного числа

u32 x;
x >>= 10u8; 
  ; (u32, 1, __cc_loc_main_x) = shr (u32, 1, __cc_loc_main_x), 10
	ldi pl, lo(__cc_loc_main_x + 1)
	ldi ph, hi(__cc_loc_main_x + 1)
	ld b
	inc pl
	ld a
	shr b
	shr b
	shl a
	shl a
	shl a
	shl a
	shl a
	shl a
	or b, a
	ldi pl, lo(__cc_loc_main_x + 0)
	st b
	ldi pl, lo(__cc_loc_main_x + 2)
	ld b
	inc pl
	ld a
	shr b
	shr b
	shl a
	shl a
	shl a
	shl a
	shl a
	shl a
	or b, a
	ldi pl, lo(__cc_loc_main_x + 1)
	st b
	ldi pl, lo(__cc_loc_main_x + 3)
	ld b
	shr b
	shr b
	dec pl
	st b
	mov a, 0
	inc pl
	st a

Еще пример: умножение байта на константу

u8 x, y;
x = y * 5u8;
	; __cc_loc_main_x = (u8, 1, __cc_loc_main_y) * 5 (byte)
	ldi pl, lo(__cc_loc_main_y + 0)
	ldi ph, hi(__cc_loc_main_y + 0)
	ld a
	mov b, a
	shl a
	shl a
	add b, a
	mov a, b
	ldi pl, lo(__cc_loc_main_x)
	ldi ph, hi(__cc_loc_main_x)
	st a

Всё это (компилятор, ассемблер и линкер) написано на питоне, потому что я хотел как можно скорее получить работающий результат. И если для ассемблера и линкера это оправдано из-за простоты, а в ассемблере еще и дает преимущество в виде простого вычисления константных выражений с помощью eval, то писать компиляторы на питоне я никому не посоветую. Да, первый результат получился быстро, но отлавливать ошибки и поддерживать код очень сложно.

Первый эмулятор тоже был на питоне, но потом я его переписал на Rust, что ускорило его раз в пять.

Множество Мандельброта (скриншот из эмулятора)Множество Мандельброта (скриншот из эмулятора)

Примечание

Временные диаграммы нарисованы в wavedrom, а блок-схемы в Inkscape. Электрические схемы — скриншоты из KiCAD.

© Habrahabr.ru