Предисловие
В жизни многих программистов наступает момент, когда хочется понять как же все таки работает процессор на самом деле, а не в абстрактных схемах высокоуровневых компонентов. У меня возник такой вопрос некоторое время назад, но все материалы которые я находил по этой теме либо были очень специализированными, требующими хорошего понимания электротехники и опыта работы со схемами дискретной логики, либо общие описания, пропускающие многие этапы, и оставляющие лишь смутное представление о том как же всё-таки тысячи транзисторов должны превратиться в работающий процессор.
Для этого я решил написать статью собирающую мой опыт попыток разобраться в этом вопросе, понятным языком, в то же время не пропуская ничего, чтобы после прочтения читатель мог воссоздать процессор из простейших элементов.
В данной статьей мы пройдем путь создания процессора от единичного транзистора до работающего 8-битного процессора, и напишем свой ассемблер для него.
Дисклеймер
Данная статья не является заменой профильной литературы, и опускает многие ограничения физической реализации схем и компонентов, так же практически все компоненты в этой статье спроектированы мной и соответственно не являются оптимальными или эталонными, и сделаны для обучающих целей.
Проектирование производилось в программе с открытым исходным кодом Logisim-evolution версии 3.80, большинство скриншотов сделаны там же.
Транзистор
Транзистор - простейший логический компонент любой схемы, который управляет большим током с помощью малого тока. Его можно вообразить как заслонку открывающую поток воды при активации, или кнопку при зажатии которой ток начинает проходить по схеме. Эта кнопка называется затвор транзистора, а точки входа и выхода тока в транзистор называются исток и сток, соответственно. Само это "зажатие", заключается в подаче небольшого тока или напряжения на затвор транзистора, после чего ток начинает течь от истока к стоку.
Существует два основных вида транзисторов: биполярные и полевые. Основное отличие заключается в том что биполярные транзисторы управляются током, в то время как полевые управляются напряжением, создающим электрическое поле.
В современной микроэлектронике наиболее часто используются полевые транзисторы со структурой Metal-Oxide-Semiconductor, которые сокращают до MOSFET, где FET - field-effect transistor.
Существует два логических типа полевых транзисторов: n-тип, который пропускает ток при подаче напряжения на затвор, и p-тип, который, наоборот, перестаёт пропускать ток при подаче напряжения на затвор.
![Обозначения транзистора которые будут применяться в дальнейшем Обозначения транзистора которые будут применяться в дальнейшем](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/4d4/4e6/2e7/4d44e62e7c05da804830ef09316be12a.png)
![Схема логического инвертора в двух состояниях, pmos и nmos это "p" и "n" транзисторы соответственно, далее по тексту будут обозначаться таким же образом Схема логического инвертора в двух состояниях, pmos и nmos это "p" и "n" транзисторы соответственно, далее по тексту будут обозначаться таким же образом](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/470/3f4/a2a/4703f4a2aa3cbadc5e6b76be00d47bd1.png)
Рассмотрим работу инвертора со схемы выше. Эта схема состоит из двух транзисторов разных типов, источника напряжения, земли, входа и выхода с pull-down резистором. Если на вход подается 1, то pmos транзистор будет "выключен", а nmos транзистор "включен", соответственно ток не сможет попасть на выход (обозначенный out), на выходе будет 0. Если же на вход подается 0, то pmos транзистор будет включен, а nmos выключен, соответственно ток сможет "пройти" через pmos, и попасть на out, из-за чего там будет 1.
Такое сочетание pmos и nmos транзисторов называется CMOS логикой и используется в большинстве современных схем высокой интеграции.
NAND
Познакомившись с транзистором и рассмотрев простейшую схему с ним, можно перейти к созданию главного элемента логики, на основе которого и будет создан наш процессор.
Логический вентиль - элемент схемы выполняющий одну элементарную логическую функцию, преобразуя входной сигнал в выходной по определенному закону. К примеру, в предыдущем разделе мы создали логический вентиль "НЕ", преобразующий 0 в 1, а 1 в 0.
Двумя самыми важными вентилями являются NAND и XOR, так как это универсальные вентили из каждого из которых можно получить все остальные, соответственно всего лишь из NAND вентилей можно построить логическую схему любой сложности.
NAND это вентиль выполняющий функцию И-НЕ, то есть отрицание "И", соответственно если "И" выдает единицу только если оба аргумента равны 1, то И-НЕ будет давать 0 только когда оба аргумента равны 1.
Таблица истинности NAND:
A | B | Out |
0 | 0 | 1 |
0 | 1 | 1 |
1 | 0 | 1 |
1 | 1 | 0 |
Спроектируем и изучим NAND вентиль с помощью CMOS логики.
![Схемы вентиля NAND в двух состояниях Схемы вентиля NAND в двух состояниях](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/4a9/0d1/a53/4a90d1a53f62559c15e464cd0b22cef9.png)
Принцип работы этой схемы достаточно прост - так как 0 будет только если на входе две единицы, то подключив оба входа к двум разным nmos транзисторам связанных последовательно можно обеспечить "проход" земли только если оба nmos включены, то есть если на входе 1 и 1, а подключив параллельно pmos, мы достигаем того что ток от +V будет идти во всех случаях кроме того что оба pmos выключены, то есть кроме случая когда на входе 1 и 1.
![Обозначение NAND на схеме Обозначение NAND на схеме](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/566/319/bb9/566319bb9e0ac259c754f1dfbeda8f84.png)
Так как в дальнейшем схемы будут усложняться, готовые схемы будут упаковываться в такие абстракции, для упрощения восприятия. Питание +V, и земля GND опущены и подразумеваются в дальнейшем для каждого вентиля идентичные.
Соответственно две синие точки на этом обозначении это входы, красная - выход.
Значит, на красном будет 0, только если будет 1 на двух синих.
Другие вентили
Остальные вентили получаются элементарными преобразованиями вентиля NAND c помощью инвертора, далее приведена их схема.
![В схеме XOR используются 2 вентиля AND, два инвертора, и один OR, полученные ранее В схеме XOR используются 2 вентиля AND, два инвертора, и один OR, полученные ранее](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/d69/791/e8a/d69791e8a62578fbe2c5774bf06c3bc2.png)
Подробное описание их работы приводить не буду, дабы не раздувать объем статьи, достаточно легко проверить их работоспособность на бумаге.
Сложение
После создания основных вентилей можно приступить к созданию первого компонента процессора - сумматора. Сумматор является схемой складывающей два числа, которая будет использоваться при создании арифметико-логического устройства и для создания счетчика операций.
Как сложить два двоичных числа?
Принцип сложения двоичных чисел идентичен сложению десятичных - сложить разряд и перенести, если есть переполнение. К примеру, сложим два однобитных числа:
Arg1 | Arg2 | Result | Carry |
0 | 0 | 0 | 0 |
0 | 1 | 1 | 0 |
1 | 0 | 1 | 0 |
1 | 1 | 0 | 1 |
Carry в данной таблице является флагом переноса (то есть флагом того что следующий разряд должен быть увеличен на 1, к примеру, складывая 1 + 1, мы получим result = 0, carry = 1, соответственно записываем 0, и переносим 1).
Из таблицы истинности можно увидеть что result равен результату функции XOR (потому что только если одна из цифр равна единице мы записываем её в результат), от аргументов, а carry результат функции AND. На основе этого легко составить схему полусумматора, которая будет приведена ниже.
Как сложить многобитные двоичные числа?
Для того чтобы сложить многобитные числа, необходимо сложить каждый разряд числа, и обработать флаг переноса для получаемый каждым разрядом от предыдущего.
Для этого создадим полный сумматор разряда, принимающий на вход флаг carry, означающий что к результату нужно добавить 1.
![Схема полусумматора Схема полусумматора](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/eeb/e57/a6b/eebe57a6bf7f72fb00110b17101f951f.png)
![Схема полного сумматора из двух полусумматоров Схема полного сумматора из двух полусумматоров](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/9a9/bb1/dd1/9a9bb1dd13de67c7a801dd2c0876e7c9.png)
В данной схеме на выход carry подается результат ИЛИ от флагов Сarry полусумматора, так как невозможна ситуация при которых оба этих флага равны единице, ведь если Carry первого полусумматора равен 1, то его результат должен быть равен 0, соответственно на втором полусумматоре переполнения быть не может.
Теперь можно с легкостью сложить числа любой длины, последовательно складывая все его разряды, и передавая carry на вход следующему. К примеру приведу 4-битный сумматор, но его легко расширить до любой битности просто добавляя дополнительные сумматоры.
![4-битный сумматор складывающий 5 и 4 4-битный сумматор складывающий 5 и 4](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/1b7/c45/426/1b7c45426f215e958fba6b00d911827c.png)
Как вычесть два числа?
Для представления отрицательных чисел в современных компьютерах используется метод "дополнительного кода" или "two's complement". Суть этого метода заключается в том, что старший бит в числе обозначает знак числа, таким образом, что, к примеру в 4-битном числе старший бит означает -8 если он равен 1, и означает 0, если равен 0.
Число в двоичном виде | Значение в десятичном виде со знаком | Вычисление |
0b1000 | -8 | -8 + 0 + 0 + 0 |
0b1001 | -7 | -8 + 0 + 0 + 1 |
0b1010 | -6 | -8 + 0 + 2 + 0 |
0b1111 | -1 | -8 + 4 + 2 + 1 |
То есть, если старший бит в числе равен 1, мы берем значение числа 0b1000 со знаком минус, а остальные складываем со знаком плюс к нему. Для 8-битного числа, к примеру, если старший бит равен 1, то мы к -128 прибавляем остальные 7 бит как обычное беззнаковое число.
Преимущество такого метода представления чисел в том что мы можем использовать наш обычный сумматор для сложения чисел со знаками.
![Сложение -8 и 3, советую проверить вручную для лучшего понимания Сложение -8 и 3, советую проверить вручную для лучшего понимания](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/851/8be/a6a/8518bea6a72bf2bb3daf09a2ab1bed26.png)
Для того чтобы получить число со знаком минус из существующего, достаточно применить побитовое НЕТ и добавить 1. К примеру возьмем число 0b0001, чтобы из него получить -1, применим побитовое НЕТ и получим 0b1110, прибавим 1, получим 0b1111, что равняется -8 + 4 + 2 + 1 = -1.
![На картинке изображено обозначение сумматора, схема берущая отрицательное число от данного и схема вычисляющая разность между чисел На картинке изображено обозначение сумматора, схема берущая отрицательное число от данного и схема вычисляющая разность между чисел](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/51e/49b/fe5/51e49bfe5e59e812f18cfcd339681b77.png)
Мультиплексор и декодер
На данный момент наш "процессор" может только лишь сложить два числа, которые ему подадут на вход. Не слишком похоже на настоящий процессор. Для того чтобы добавить вариативность в схему необходимо создать компоненты с возможностью передачи сигнала с одной линии на другую с помощью управляющего сигнала. Одним из таких компонентов является мультиплексор. Мультиплексор передает на выход один из входящих сигналов, выбор которого обусловлен управляющим сигналом. Простейший случай мультиплексора - "2 в 1", в котором один из двух входов передается на выход в зависимости от управляющего сигнала. То есть если на входе 1 будет 0, а на входе 2 будет 1, то при управляющем сигнале равным 0 будет выбран вход 1, соответственно значение будет 0, а при управляющем сигнале 1 будет выбран вход 2, соответственно значение будет 1.
![Схема 2в1 мультиплексора Схема 2в1 мультиплексора](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/fac/68d/dab/fac68ddabb12e3a839d48a7bcb7f9afe.png)
Рекомендую воссоздавать схемы по ходу статьи, и тестировать самостоятельно, так как сложность будет постепенно повышаться, и на бумаге станет невозможно проверять проверять различные сценарии.
Данный мультиплексор можно легко (хоть и не оптимально) масштабировать, используя мультиплексоры меньшей размерности. Для масштабирования по количеству бит можно применять однобитный мультиплексор для каждого из входных бит.
![Схема 4в1 мультиплексора Схема 4в1 мультиплексора](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/cc8/e7d/6d0/cc8e7d6d046dc32841c56c3f2b642981.png)
Также для управления схемой нам понадобится декодер. Декодер это компонент который принимает на вход управляющий сигнал, и отправляет единицу по одной из выходной линий, определяемой управляющим сигналом. К примеру однобитный декодер принимает на вход 1 или 0, и отправляет 1 по выходной линии №1 или №2 соответственно.
![Схема однобитного декодера Схема однобитного декодера](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/052/338/4ff/0523384ffb136d71f964a4c9a576700c.png)
В данной схеме используется компонент Controlled buffer, который включает или отключает передачу сигнала через себя, в зависимости от сигнала Enable. Функционально он работает как транзистор, но с логическими сигналами. Также используются pull-down резисторы на выходе из декодера, так как при выключении controlled buffer(или любого другого разрыва логической цепи) на выходе будет не логический 0, а undefined. Подробнее можно прочитать здесь https://ru.wikipedia.org/wiki/Подтягивающий_резистор.
![Схема 4-битного декодера, при подаче на вход 3 (0b11) Схема 4-битного декодера, при подаче на вход 3 (0b11)](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/5c4/bf5/002/5c4bf50026d0518b0b0d6a0b7dc2a407.png)
Этот компонент работает так, что первый бит подается на выбор обоим однобитным декодером, а второй бит управляет тем с какого декодера будем брать результат.
ALU
Теперь у нас есть все нужные компоненты для создания "сердца" будущего процессора - Арифметико-логического устройства. АЛУ - это компонент который будет выполнять команды арифметики и логики, которые будут декодироваться из инструкций. То есть к примеру на входе есть два аргумента, и команда сложить, тогда АЛУ соответственно сложит эти два числа. Наш АЛУ будет будет 8-битным, и выполнять 4 возможные команды - сложение, вычитание, XOR, NAND. Эти четыре команды можно закодировать 2-битовым числом.
Соответственно для того чтобы получить результат одной из этих операций логично воспользоваться мультиплексором 4в1. Можно подать оба аргумента на вход всем 4 компонентам, и выбрать результат в зависимости от кода операции полученного на входе.
![Схема арифметико-логического устройства Схема арифметико-логического устройства](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/364/a04/b08/364a04b0854fc275f8f003f9f585fe0e.png)
По схеме видно что если на входе стоять флаг Active (у мультиплексора из библиотеки logisim есть вход активации, который разрешает выход у мультиплексора), то данная схема вычислит результат всех четырех компонентов и отдаст один из результатов на выход в зависимости от значения OPCODE.
Код операции | Код операции двоичный | Действие |
0 | 00 | Сложение |
1 | 01 | Вычитание |
2 | 10 | XOR |
3 | 11 | NAND |
Память
Теперь нам необходим какой-нибудь метод получения аргументов для подачи в АЛУ, и место для сохранения результатов. Основополагающим блоком из которого состоит большинство современных схем памяти является RS-триггер (в английском языке используется термин SR-Latch). Это простая схема состоящая из двух NAND вентилей (также есть реализация из двух XOR вентилей), способная запоминать один бит информации. Лично у меня были трудности с пониманием механики работы до тех пор пока самостоятельно не попробовал переключать вентили в симуляторе. Простыми словами принцип работы можно объяснить таким образом: стандартное состояние триггер принимает при получении на вход 1 и 1, в этом состоянии он возвращает свой сохраненный бит, при изменении входа S на ноль сохраненное значение меняется на единицу, при изменении входа R на 0, сохраненное значение меняется на ноль, состояние когда на входе . К примеру чтобы изменить сохраненное значение с 0 на один требуется сначала изменить значение S на ноль, затем вернуть значение S в единицу. Этот процесс изображен на скриншоте.
При реализации этого элемента на XOR вентилях "стандартное" состояние будет при получении на вход 0 и 0, что важно помнить так как на разных ресурсах могу приводить разные схемы элемента, крайне схожие, но с разными таблицами истинности.
![Схема rs-триггера, в трех состояниях в процессе изменения сохраненного бита с единицы на ноль Схема rs-триггера, в трех состояниях в процессе изменения сохраненного бита с единицы на ноль](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/420/3c8/2ee/4203c82ee886b3823c19e05377ac1f31.png)
Строго говоря у rs-триггера два выхода, второй из которых возвращает предыдущее сохраненное значение, и обозначается как Q-Черта, но в данном проекте это не будет применяться, поэтому не буду подробно описывать.
Для того чтобы было возможно практично использовать схемы с последовательной логикой необходим общий источник тактовой частоты (а rs-триггер это первый компонент с последовательной, а не комбинаторной логикой, это такой тип логики в которой выход схемы зависит не только от входных данных, но и внутреннего состояния системы, к примеру выход rs-триггера при 1 и 1 на входе зависит от того какой бит записан, а не только от входных данных). Для того чтобы использовать тактовую частоту требуется изменить схему триггера таким образом чтобы он мог изменять сохраненные данные только если линия тактовой частоты (далее по тексту и в изображениях может быть использован термин clock) активна. Для этого добавим в схему 2 NAND вентиля перед входом в основной триггер таким образом чтобы на вход триггера шел 0 только если Clock активен. Такая схема будет называться gated s-r latch, или синхронный rs-триггер.
Теперь мы можем заменить второй вход на инверсию первого входа. Тогда при подаче тактового сигнала данная схема просто запомнит логическое значение на входе без необходимости особого сочетания входных битов. Остается добавить вход "Write", чтобы бит записывался только если подается этот флаг, так как тактовый сигнал является общим для всех компонентов процессора, и информация будет перезаписываться на каждом такте без добавления этого входа. Чтобы добавить этот вход достаточно поставить логический "И" между "Write" и "Clock", тогда данные будут записываться только когда Write активен. Таким образом мы создали однобитный регистр.
![Схема однобитного регистра Схема однобитного регистра](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/8c8/d58/a56/8c8d58a56152f5ccab6e884b55c36b2e.png)
Банк регистров
Теперь можно объединить несколько регистров в систему, из которой можно читать данные на выходную линию из определенного регистра и записывать данные из входной линии в определенный регистр. Для того чтобы выбрать из какого регистра читать данные, или в какой писать будем использовать трехбитное число, что означает возможность использования максимум 2^3 = 8 регистров.
Чтобы создать банк регистров, сперва подключим 8 регистров параллельно, так, чтобы каждый вход данных регистра соединялся со входом данных компонента, а каждый выход данных регистра соединялся с мультиплексором 8к1. Также подключим каждый регистр к шине тактовой частоты. Должен получится подобный результат:
![Первый этап создания банка регистров Первый этап создания банка регистров](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/48f/2b9/a9e/48f2b9a9e8e8b1bf68cc34111e7f6eca.png)
Теперь добавим трехбитовый вход WriteReg, который будет указывать в какой регистр будет производится запись данных, этот вход будет идти на вход трехбитному декодеру, каждый выход из которого будет подключен ко входу Write соответствующего регистра. Таким образом получив на входе, к примеру 3 (0b011), декодер даст на линии Write третьего регистра логическую единицу, и третий регистр соответственно запишет входные данные. Также добавим однобитный вход Write подключенный ко входу Enable декодера, который будет обозначать записывает ли данные банк на этом такте.
Повторим процесс для чтения из регистра, добавив входы ReadReg, Read, и подключив к мультиплексору с прошлого шага.
Также добавим прямые выходы данных с первого и второго регистра на выход из компонента, так как эти два регистра будут использоваться для подачи аргументов в АЛУ.
![Схема банка регистров Схема банка регистров](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/55c/ab9/827/55cab9827c0d88e2adf81252ffd5fe88.png)
Резюмируя, данная схема получает на вход данные, флаг того пишет ли в регистр, флаг читает ли из регистра, адрес (номер) регистра для чтения и адрес регистра для записи, и выдаёт данные из запрошенного регистра (если есть флаг чтения), а также всегда выдаёт данные из первого и второго регистра на выход.
Счётчик
Наш процессор будет построен по гарвардской архитектуре. Гарвардская архитектура процессора это такой подход к хранению информации, при котором программный код и данные хранятся раздельно, в отличии от архитектуры фон Неймана, в котором единое хранилище информации. Главное практическое отличие этих архитектур заключается в невозможности изменения исполняемого кода в чистой гарвардской архитектуре, что обуславливает её использование в микроконтроллерах, которые прошивают перед вводом в эксплуатацию, и тот же код без изменений выполняется всё время работы устройства. В персональных компьютерах применение такой архитектуры затруднительно, так как, установка внешних программ была бы крайне сложной, и общее количество компонентов бы увеличивалось за счет разделения оперативной памяти.
Для простоты понимания в этой статье будет применяться гарвардская архитектура, но в следующей статье из цикла данный процессор будет глубоко переработан и переведён на архитектуру фон Неймана.
В качестве места хранения исполняемой программы будет использоваться ПЗУ (ROM). ПЗУ - постоянное запоминающее устройство, простейший случай которого это матрица транзисторов, в которых некоторые транзисторы могли "выжечь" для получения логической единицы, а выборка по адресу осуществляется сочетанием декодеров и мультиплексоров. Современные ПЗУ можно стирать и программировать многократно, но основное применение остается прежним - прошивки микроконтроллеров, хранение программ не требующих обычно обновлений, например BIOS компьютера.
Я не буду проектировать собственный модуль ROM, и воспользуюсь готовым модулем из Logisim на 256 8-битных слов, так как это бы раздуло и так большой объем этой статьи, а сама эта задача тривиальна и репетативна.
Что же такое исполняемый код нашего процессора?
Исполняемый код в этом случае представляет собой набор 8-битных слов расположенных подряд, где каждое слово является инструкцией. Формат инструкции будет рассмотрен в следующем разделе, сейчас достаточно понимать что это просто байты в памяти подряд.
Соответственно, чтобы исполнять инструкции, нужно каждый цикл исполнения доставать из памяти по одному байту, и увеличивать адрес, указывающий на то, откуда берется этот байт.
Для этого потребуется модуль ROM, регистр, где будет хранится адрес текущей инструкции, и сумматор, для увеличения адреса.
![Отображение работы счетчика программ Отображение работы счетчика программ](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/08b/d72/864/08bd728649fdb68a853f4fde22d09a4d.gif)
Также необходимо добавить возможность замены данных хранящихся в регистре, из внешнего входа. Это позволит выбирать какую инструкцию выполнять следующей, что позволит в дальнейшем реализовать условные ветвления, циклы, и любую другую нелинейность программы.
Для этого достаточно добавить вход данных адреса, флаг записи, и направлять данные через мультиплексор, выбирая откуда берем с помощью флага записи.
![Доработанная схема счётчика программ Доработанная схема счётчика программ](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/795/086/407/79508640700d6d56c0e1fe7d4fd0c7e2.png)
Декодер инструкций
Рассмотрим формат инструкций. Каждая инструкция будет фиксированной длины в 8 бит. Первые два бита обозначают тип производимой операции, где:
00 - Выполнение операции с помощью АЛУ
01 - Копирование данных из регистра в регистр
10 - Выполнение условного перехода
11 - Работа с оперативной памятью
На первом этапе рассмотрим операции с АЛУ. Этот тип операции берет значения регистров 1 и 2, и записывает их в регистр 0. Чтобы процессор выполнил эту операцию необходимо: Подать на линию включения АЛУ логическую единицу, указать банку регистров что будет запись, подать банку регистров адрес записи (так как мы пишем в нулевой регистр, подаём 0). Разработаем для этого схему, которая будет выполнять эти действия, которая будет называться декодером инструкций.
![Предварительная схема декодера Предварительная схема декодера](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/569/80f/3f7/56980f3f7839a3e5357ce2dfbc34abde.png)
Эта схема при подаче на вход 0, разбивает 8-битное число на отдельные биты и отправляет первые два в декодер, который определяет что сейчас выполняется, и так как подается ноль выполняется вышеописанный процесс с АЛУ.
Следующие 2 бита инструкций выделим на код операции в АЛУ. Это то число, которое мы добавляли для выбора совершаемой операции в логическом устройстве. Эти два бита можно отправить сразу же на выход, для передачи в АЛУ.
Последние 4 бита необходимы для констант. Константа - это число которое будем добавлять дополнительно к результату АЛУ, чтобы из кода программы можно было вносить какие-либо числа в регистры. К примеру, инструкция 0b 0000 1111 будет выполнятся как сложение регистров 1 и 2, затем сложение получившегося числа с 15 (0b1111). Таким образом можно внести в программу число, если регистр 1 и 2 пусты, и это число будет в регистре 0.
![Декодер с логикой констант (Биты констант подключены не в том порядке, обнаружил при тестировании в дальнейшем) Декодер с логикой констант (Биты констант подключены не в том порядке, обнаружил при тестировании в дальнейшем)](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/79a/f1d/1e7/79af1d1e7d57bf3193ed15a6f5cc0869.png)
![](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/ee3/944/b7b/ee3944b7bc470833b6d6a98eb8d946b8.png)
![Доработанное АЛУ, после Const стоит расширитель битов, который просто дополняет нулями 4-битное число до 8-битного, чтобы сумматор мог с ним работать Доработанное АЛУ, после Const стоит расширитель битов, который просто дополняет нулями 4-битное число до 8-битного, чтобы сумматор мог с ним работать](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/ee3/944/b7b/ee3944b7bc470833b6d6a98eb8d946b8.png)
Теперь мы можем перейти к реализации второго типа операции - копирования. Для этого 6 бит оставшиеся в инструкции после определения типа разделим на два 3-битных значения, Source и Destination. Эти два значения будут определять пару регистров откуда и куда будет передаваться значение. Значит, при выполнении копирования мы должны указать банку регистров что мы будем читать, писать, и откуда/куда. Для этого добавим соответствующие выходы, Read и ReadAddr, и добавим ИЛИ перед выходом Write, чтобы он был активен и при этом типе операции. Подведём выход декодера к Read, чтобы он также активировался, и разведем по три бита по адресам регистров.
![Схема декодера с обработкой операции копирования Схема декодера с обработкой операции копирования](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/d66/31c/db9/d6631cdb9b89c40d13e21538383f7348.png)
Интеграция
Теперь у нас готовы все компоненты для первичной проверки работоспособности процессора, и пришло время объединить всё ранее созданное в прототип процессора.
![Общая схема прототипа Общая схема прототипа](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/149/707/df4/149707df41eab2198d4b7350513181b5.png)
Все выходы были описаны в разделах соответствующих компонентов, единственное что здесь добавлено это мультиплексор куда на вход приходит выход из АЛУ, и выход из банка регистров, он нужен для того, чтобы выбрать данные откуда будут браться для записи в регистр.
Как это работает?
Изначально из компонента с счётчиком программ (ProgramCounter) извлекается исполняемая инструкция, далее она отправляется в декодер, который устанавливает необходимые флаги, и если операция АЛУ, то результат АЛУ записывается в нулевой регистр, а если операция копирования, то запишется то, что прочитано из регистра.
Теперь можно продемонстрировать работу, для этого удобно написать программу, подставляющую вместо слов коды инструкций. Например, вместо ADD 5, нужно подставить "0b 0000 0101", так как тип такой операции кодируется 00, код операции также 00, и далее идет константное значение. Результатом такой инструкции будет запись 5 в регистр 0.
Также нужно добавить COPY x y, где x - исходный регистр, y - конечный регистр. Тогда команда COPY 0 4 скопирует значение из нулевого регистра в четвертый.
Реализовать такую программу крайне просто, необходимо лишь сложить составные части выражения со соответствующим сдвигом влево. К примеру, COPY 1 4 можно закодировать как 01 со сдвигом влево на 6 бит, так как копирование кодируется 01, плюс 001 со сдвигом влево 3, плюс 100 со сдвигом влево 0, то есть 01 001 100 -> 0100 1100 -> 0x4C.
Код и детали создания компилятора я постараюсь описать в следующей статье из цикла, на данный момент достаточно наивной линейной реализации, которую бы посоветовал создать самостоятельно.
Наконец-то мы можем сложить 2 + 3 на этом процессоре. Для этого напишем и скомпилируем (программой описанной выше) следующий код:
ADD 2 // Записываем в регистр 0 значение 2
COPY 0 3 // Копируем регистр 0 в регистр 3
ADD 3 // Записываем в регистр 0 значение 2
COPY 0 4 // Копируем регистр 0 в регистр 4
COPY 3 1 // Копируем значения в регистры-аргументы АЛУ
COPY 4 2 //
ADD 0 // Вычисляем значение
Да, 7 инструкций для выполнения такого простого действия. Это неизбежное следствие такой короткой длины инструкции, когда все возможные комбинации операций и аргументов умещаются в число до 256. И отсутствие флага ввода константы заставляет после каждого вычисления обнулять аргументы, если потребуется ввести какое-либо число в программу.
В дальнейшем, мы усовершенствуем процессор и приведем набор инструкций к архитектуре RISC-V, но для демонстрационных целей достаточно текущей архитектуры.
Условные переходы
Для того чтобы было возможно использовать условия (такие например как if), или циклы, требуется возможность изменять порядок выполнения команд, то есть, заменять содержимое регистра счетчика программ. К примеру, чтобы сделать цикл, нужно создать такую инструкцию, которая при исполнении будет заменять содержимое регистра счетчика меньшим значением, к примеру, на 5 меньше, тогда каждый раз доходя до этой инструкции значение будет заменяться, и эти 5 инструкций выполняться раз за разом. Так же, следует добавить условие выполнения этой инструкции, при невыполнении которого "прыжка" (замены адреса в регистре счетчика программ) не будет, это позволит избежать бесконечных циклов, так как до этого не было никакой возможности покинуть цикл.
Чтобы реализовать подобный функционал создадим новый компонент условных переходов Conditions.
![Схема блока условных переходов Схема блока условных переходов](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/edc/94d/5ae/edc94d5aeec4a01357900a4151d3e9fa.png)
Этот компонент lполучает на вход значение регистра 0 и флаг активации, и если блок активен, и регистр 0 равен нулю, то он даёт сигнал JMP.
Также доработаем схему декодера
![Схема декодера с логикой условных переходов Схема декодера с логикой условных переходов](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/e45/8b5/25c/e458b525c0c6ba1f9f98137aa27d5660.png)
Теперь если тип операции 2 (0b10), ставим флаг Conditional, также отправляем младшие 6 бит как адрес прыжка на выход из компонента.
Подключим блок переходов к общей схеме.
![Схема с блоком переходов Схема с блоком переходов](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/2cd/48d/0dd/2cd48d0ddf79108eaef0e733952a1c03.png)
Теперь, если из ПЗУ достается инструкция условного перехода, блок Conditions проверяет равен ли регистр 0 нулю, и если равен, то отправляет 1 на вход счетчика программ, после чего в регистр адреса команды запишется JmpAdress, то есть младшие 6 бит инструкции.
Оперативная память
Реализуем последний элемент необходимый для создания полноценного процессора. Оперативная память - компонент для хранения данных в процессе выполнения кода. Для того чтобы реализовать этот компонент нам потребуется выбрать один из регистров как регистр адреса, который будет указывать с какой ячейкой памяти будем работать. Так как наши регистры 8-битные максимальное количество байт которые мы можем адресовать - 2^8 = 256. Возьмем reg7 в качестве регистра адреса и подключим его выход данных ко входу адреса компонента RAM (оперативная память). Также нужно модифицировать декодер данных таким образом, что когда два бита типа операции равны 11 (00 - АЛУ, 01 - Копировать, 10 - условный переход) следующий бит (третий) будет определять читаем или записываем данные. Следующие три бита будут определять регистр с данными которого будем работать. Инструкция будет выглядеть следующим образом:
11 | 1 | 001 | 00 |
Тип операции(Операция с памятью) | Записать | В регистр 1 | Не используется |
Также необходимо добавить еще мультиплексор на входные данные в банк регистров, чтобы при загрузке из памяти выбиралась линия подключенная к оперативной памяти.
Картинки к изменениям приводить не буду, так как они небольшие и их много, а окончательную схему сможете найти по ссылке в конце статьи.
Использование
Осталось написать демонстрационную программу, чтобы показать возможности созданной схемы. В качестве этой программы я выбрал вычисление числа Фибоначчи с сохранением текущего вычисленного значения.
Код программы:
ADD 1
COPY 0 1
ADD 0 // Начало цикла
STORE 0 // Сохраняем в память
COPY 0 2
ADD 0
STORE 0 // Сохраняем в память
COPY 0 1
COPY 7 0 // Обнуление регистра 0 для того чтобы условие прыжка выполнялось
JUMP 2 // Переход к началу цикла
Логика этого кода заключается в том что после единоразовой загрузки единицы в регистр 1, вычисляется сумма, результат пишется в регистр 2, вычисляется сумма, результат записывается в регистр 1, прыжок в начало цикла. Также после каждого вычисления суммы результат записывается в память, а перед прыжком регистр 0 обнуляется, чтобы условия перехода выполнялось.
![Процесс работы программы Процесс работы программы](https://webcf.waybackmachine.org/web/20240728045358/https://habrastorage.org/getpro/habr/upload_files/356/9c1/42b/3569c142b9ea1b3ca317c3d5697165c2.gif)
Заключение
В этой статье я постарался пройти путь от транзистора до простейшего процессора, описав возможные схемы всех компонентов. Данная архитектура крайне не практична в реализации и программировании, в ней нет обработки прерываний, обработки стека вызовов, и внешних выходов, но в ней есть все базовые принципы, на основе которых можно создавать системы гораздо большей сложности.
В следующей статье я опишу создание практичного процессора на основе архитектуры RISC-V, с обработкой внешних прерываний, а также создадим видеоадаптер, для того чтобы запустить и поиграть в змейку на этой системе.