Как стать автором
Обновить

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

Время на прочтение 9 мин
Количество просмотров 10K

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

Флаги

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

  • 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), то нужно помнить, что первый операнд инструкции – это то, куда записывается результат. Например:

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

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

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

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

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

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

100xxxRR

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

Пример: LD A

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

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

Активные сигналы в момент времени (4)
Активные сигналы в момент времени (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.

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

Эти тайминги работали отлично, пока я не добавил переключение банков и не стал запускать программы из ОЗУ. Тогда возникла проблема: так как сигнал 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.

Переключение селектора 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.

Теги:
Хабы:
+79
Комментарии 30
Комментарии Комментарии 30

Публикации

Истории

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн