Предисловие
В жизни многих программистов наступает момент, когда хочется понять как же работает процессор на самом деле, а не в абстрактных схемах высокоуровневых компонентов. У меня возник такой вопрос некоторое время назад, но все материалы которые я находил по этой теме либо были очень специализированными, требующими хорошего понимания электротехники и опыта работы со схемами дискретной логики, либо общие описания, пропускающие многие этапы, и оставляющие лишь смутное представление о том как же всё-таки тысячи транзисторов должны превратиться в работающий процессор.
Для этого я решил написать статью собирающую мой опыт попыток разобраться в этом вопросе, понятным языком, в то же время не пропуская ничего, чтобы после прочтения читатель мог воссоздать процессор из простейших элементов.
В данной статьей мы пройдем путь создания процессора от единичного транзистора до работающего 8-битного процессора, и напишем свой ассемблер для него.
Дисклеймер
Данная статья не является заменой профильной литературы, и опускает многие ограничения физической реализации схем и компонентов, так же практически все компоненты в этой статье спроектированы мной и соответственно не являются оптимальными или эталонными, и сделаны для обучающих целей.
Проектирование производилось в программе с открытым исходным кодом Logisim-evolution версии 3.80, большинство скриншотов сделаны там же.
Транзистор
Транзистор - простейший логический компонент любой схемы, который управляет большим током с помощью малого тока. Его можно вообразить как заслонку открывающую поток воды при активации, или кнопку при зажатии которой ток начинает проходить по схеме. Эта кнопка называется затвор транзистора, а точки входа и выхода тока в транзистор называются исток и сток, соответственно. Само это "зажатие", заключается в подаче небольшого тока или напряжения на затвор транзистора, после чего ток начинает течь от истока к стоку.
Существует два основных вида транзисторов: биполярные и полевые. Основное отличие заключается в том что биполярные транзисторы управляются током, в то время как полевые управляются напряжением, создающим электрическое поле.
В современной микроэлектронике наиболее часто используются полевые транзисторы со структурой Metal-Oxide-Semiconductor, которые сокращают до MOSFET, где FET - field-effect transistor.
Существует два логических типа полевых транзисторов: n-тип, который пропускает ток при подаче напряжения на затвор, и p-тип, который, наоборот, перестаёт пропускать ток при подаче напряжения на затвор (с физической точки зрения более корректно сказать N-тип начинает пропускать ток при подаче положительного напряжения относительно истока, а P-тип пропускает ток при подаче отрицательного напряжения относительно истока).
Значение в проводе обозначается на схеме цветом, яркий зеленый это 1, темный зеленый это 0. Элемент похожий на зиг-заг на выходе с подписанным 0 это pull-down резистор, нужный чтобы подтягивать неопределенные значения к нулю. Более подробно можете почитать об этом здесь, но для нас сейчас это не очень важно.
Рассмотрим работу инвертора со схемы выше. Эта схема состоит из двух транзисторов разных типов, источника напряжения, земли, входа и выхода с pull-down резистором. Если на вход подается 1, то pmos транзистор будет "выключен", а nmos транзистор "включен", соответственно ток не сможет попасть на выход (обозначенный out), на выходе будет 0. Если же на вход подается 0, то pmos транзистор будет включен, а nmos выключен, соответственно ток сможет "пройти" через pmos, и попасть на out, из-за чего там будет 1.
Такое сочетание pmos и nmos транзисторов называется CMOS логикой и используется в большинстве современных схем высокой интеграции.
NAND
Познакомившись с транзистором и рассмотрев простейшую схему с ним, можно перейти к созданию главного элемента логики, на основе которого и будет создан наш процессор.
Логический вентиль - элемент схемы выполняющий одну элементарную логическую функцию, преобразуя входной сигнал в выходной по определенному закону. К примеру, в предыдущем разделе мы создали логический вентиль "НЕ", преобразующий 0 в 1, а 1 в 0.
Двумя самыми важными вентилями являются NAND и NOR, так как это универсальные вентили из каждого из которых можно получить все остальные, соответственно всего лишь из NAND вентилей можно построить логическую схему любой сложности.
NAND это вентиль выполняющий функцию И-НЕ, то есть отрицание "И", соответственно если "И" выдает единицу только если оба аргумента равны 1, то И-НЕ будет давать 0 только когда оба аргумента равны 1.
Таблица истинности NAND:
A |
B |
Out |
0 |
0 |
1 |
0 |
1 |
1 |
1 |
0 |
1 |
1 |
1 |
0 |
Спроектируем и изучим NAND вентиль с помощью CMOS логики.
Принцип работы этой схемы достаточно прост - так как 0 будет только если на входе две единицы, то подключив оба входа к двум разным nmos транзисторам связанных последовательно можно обеспечить "проход" земли только если оба nmos включены, то есть если на входе 1 и 1, а подключив параллельно pmos, мы достигаем того что ток от +V будет идти во всех случаях кроме того что оба pmos выключены, то есть кроме случая когда на входе 1 и 1.
Так как в дальнейшем схемы будут усложняться, готовые схемы будут упаковываться в такие абстракции, для упрощения восприятия. Питание +V, и земля GND опущены и подразумеваются в дальнейшем для каждого вентиля идентичные.
Соответственно две синие точки на этом обозначении это входы, красная - выход.
Значит, на красном будет 0, только если будет 1 на двух синих.
Другие вентили
Остальные вентили получаются элементарными преобразованиями вентиля NAND c помощью инвертора, далее приведена их схема.
Подробное описание их работы приводить не буду, дабы не раздувать объем статьи, достаточно легко проверить их работоспособность на бумаге.
Сложение
После создания основных вентилей можно приступить к созданию первого компонента процессора - сумматора. Сумматор является схемой складывающей два числа, которая будет использоваться при создании арифметико-логического устройства и для создания счетчика операций.
Как сложить два двоичных числа?
Принцип сложения двоичных чисел идентичен сложению десятичных - сложить разряд и перенести, если есть переполнение. К примеру, сложим два однобитных числа:
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.
В данной схеме на выход carry подается результат ИЛИ от флагов Сarry полусумматора, так как невозможна ситуация при которых оба этих флага равны единице, ведь если Carry первого полусумматора равен 1, то его результат должен быть равен 0, соответственно на втором полусумматоре переполнения быть не может.
Теперь можно с легкостью сложить числа любой длины, последовательно складывая все его разряды, и передавая carry на вход следующему. К примеру приведу 4-битный сумматор, но его легко расширить до любой битности просто добавляя дополнительные сумматоры.
Как вычесть два числа?
Для представления отрицательных чисел в современных компьютерах используется метод "дополнительного кода" или "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 бит как обычное беззнаковое число.
Преимущество такого метода представления чисел в том что мы можем использовать наш обычный сумматор для сложения чисел со знаками.
Для того чтобы получить число со знаком минус из существующего, достаточно применить побитовое НЕТ и добавить 1. К примеру возьмем число 0b0001, чтобы из него получить -1, применим побитовое НЕТ и получим 0b1110, прибавим 1, получим 0b1111, что равняется -8 + 4 + 2 + 1 = -1.
Мультиплексор и декодер
На данный момент наш "процессор" может только лишь сложить два числа, которые ему подадут на вход. Не слишком похоже на настоящий процессор. Для того чтобы добавить вариативность в схему необходимо создать компоненты с возможностью передачи сигнала с одной линии на другую с помощью управляющего сигнала. Одним из таких компонентов является мультиплексор. Мультиплексор передает на выход один из входящих сигналов, выбор которого обусловлен управляющим сигналом. Простейший случай мультиплексора - "2 в 1", в котором один из двух входов передается на выход в зависимости от управляющего сигнала. То есть если на входе 1 будет 0, а на входе 2 будет 1, то при управляющем сигнале равным 0 будет выбран вход 1, соответственно значение будет 0, а при управляющем сигнале 1 будет выбран вход 2, соответственно значение будет 1.
Рекомендую воссоздавать схемы по ходу статьи, и тестировать самостоятельно, так как сложность будет постепенно повышаться, и на бумаге станет невозможно проверять проверять различные сценарии.
Данный мультиплексор можно легко (хоть и не оптимально) масштабировать, используя мультиплексоры меньшей размерности. Для масштабирования по количеству бит можно применять однобитный мультиплексор для каждого из входных бит.
Также для управления схемой нам понадобится декодер. Декодер это компонент который принимает на вход управляющий сигнал, и отправляет единицу по одной из выходной линий, определяемой управляющим сигналом. К примеру однобитный декодер принимает на вход 1 или 0, и отправляет 1 по выходной линии №1 или №2 соответственно.
В данной схеме используется компонент Controlled buffer, который включает или отключает передачу сигнала через себя, в зависимости от сигнала Enable. Функционально он работает как транзистор, но с логическими сигналами. Также используются pull-down резисторы на выходе из декодера, так как при выключении controlled buffer(или любого другого разрыва логической цепи) на выходе будет не логический 0, а undefined. Подробнее можно прочитать здесь https://ru.wikipedia.org/wiki/Подтягивающий_резистор.
Этот компонент работает так, что первый бит подается на выбор обоим однобитным декодером, а второй бит управляет тем с какого декодера будем брать результат.
ALU
Теперь у нас есть все нужные компоненты для создания "сердца" будущего процессора - Арифметико-логического устройства. АЛУ - это компонент который будет выполнять команды арифметики и логики, которые будут декодироваться из инструкций. То есть к примеру на входе есть два аргумента, и команда сложить, тогда АЛУ соответственно сложит эти два числа. Наш АЛУ будет будет 8-битным, и выполнять 4 возможные команды - сложение, вычитание, XOR, NAND. Эти четыре команды можно закодировать 2-битовым числом.
Соответственно для того чтобы получить результат одной из этих операций логично воспользоваться мультиплексором 4в1. Можно подать оба аргумента на вход всем 4 компонентам, и выбрать результат в зависимости от кода операции полученного на входе.
По схеме видно что если на входе стоять флаг 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-триггера два выхода, второй из которых возвращает предыдущее сохраненное значение, и обозначается как Q-Черта, но в данном проекте это не будет применяться, поэтому не буду подробно описывать.
Для того чтобы было возможно практично использовать схемы с секвенциальной (От англ. sequential, последовательная) логикой необходим общий источник тактовой частоты (а rs-триггер это первый компонент с секвенциальной/последовательной, а не комбинаторной логикой, это такой тип логики в которой выход схемы зависит не только от входных данных, но и от внутреннего состояния системы, к примеру выход rs-триггера при 1 и 1 на входе зависит от того какой бит записан, а не только от входных данных).
Для того чтобы использовать тактовую частоту требуется изменить схему триггера таким образом чтобы он мог изменять сохраненные данные только если линия тактовой частоты (далее по тексту и в изображениях может быть использован термин clock) активна. Для этого добавим в схему 2 NAND вентиля перед входом в основной триггер таким образом чтобы на вход триггера шел 0 только если Clock активен. Такая схема будет называться gated s-r latch, или синхронный rs-триггер.
Теперь мы можем заменить второй вход на инверсию первого входа. Тогда при подаче тактового сигнала данная схема просто запомнит логическое значение на входе без необходимости особого сочетания входных битов. Остается добавить вход "Write", чтобы бит записывался только если подается этот флаг, так как тактовый сигнал является общим для всех компонентов процессора, и информация будет перезаписываться на каждом такте без добавления этого входа. Чтобы добавить этот вход достаточно поставить логический "И" между "Write" и "Clock", тогда данные будут записываться только когда Write активен. Таким образом мы создали однобитный регистр.
Банк регистров
Теперь можно объединить несколько регистров в систему, из которой можно читать данные на выходную линию из определенного регистра и записывать данные из входной линии в определенный регистр. Для того чтобы выбрать из какого регистра читать данные, или в какой писать будем использовать трехбитное число, что означает возможность использования максимум 2^3 = 8 регистров.
Чтобы создать банк регистров, сперва подключим 8 регистров параллельно, так, чтобы каждый вход данных регистра соединялся со входом данных компонента, а каждый выход данных регистра соединялся с мультиплексором 8к1. Также подключим каждый регистр к шине тактовой частоты. Должен получится подобный результат:
Теперь добавим трехбитовый вход WriteReg, который будет указывать в какой регистр будет производится запись данных, этот вход будет идти на вход трехбитному декодеру, каждый выход из которого будет подключен ко входу Write соответствующего регистра. Таким образом получив на входе, к примеру 3 (0b011), декодер даст на линии Write третьего регистра логическую единицу, и третий регистр соответственно запишет входные данные. Также добавим однобитный вход Write подключенный ко входу Enable декодера, который будет обозначать записывает ли данные банк на этом такте.
Повторим процесс для чтения из регистра, добавив входы ReadReg, Read, и подключив к мультиплексору с прошлого шага.
Также добавим прямые выходы данных с первого и второго регистра на выход из компонента, так как эти два регистра будут использоваться для подачи аргументов в АЛУ.
Резюмируя, данная схема получает на вход данные, флаг того пишет ли в регистр, флаг читает ли из регистра, адрес (номер) регистра для чтения и адрес регистра для записи, и выдаёт данные из запрошенного регистра (если есть флаг чтения), а также всегда выдаёт данные из первого и второго регистра на выход.
Счётчик
Наш процессор будет построен по гарвардской архитектуре. Гарвардская архитектура процессора это такой подход к хранению информации, при котором программный код и данные хранятся раздельно, в отличии от архитектуры фон Неймана, в котором единое хранилище информации. Главное практическое отличие этих архитектур заключается в невозможности изменения исполняемого кода в чистой гарвардской архитектуре, что обуславливает её использование в микроконтроллерах, которые прошивают перед вводом в эксплуатацию, и тот же код без изменений выполняется всё время работы устройства. В персональных компьютерах применение такой архитектуры затруднительно, так как, установка внешних программ была бы крайне сложной, и общее количество компонентов бы увеличивалось за счет разделения оперативной памяти.
Для простоты понимания в этой статье будет применяться гарвардская архитектура, но в следующей статье из цикла данный процессор будет глубоко переработан и переведён на архитектуру фон Неймана.
В качестве места хранения исполняемой программы будет использоваться ПЗУ (ROM). ПЗУ - постоянное запоминающее устройство, простейший случай которого это матрица транзисторов, в которых некоторые транзисторы могли "выжечь" для получения логической единицы, а выборка по адресу осуществляется сочетанием декодеров и мультиплексоров. Современные ПЗУ можно стирать и программировать многократно, но основное применение остается прежним - прошивки микроконтроллеров, хранение программ не требующих обычно обновлений, например BIOS компьютера.
Я не буду проектировать собственный модуль ROM, и воспользуюсь готовым модулем из Logisim на 256 8-битных слов, так как это бы раздуло и так большой объем этой статьи, а сама эта задача тривиальна и репетативна.
Что же такое исполняемый код нашего процессора?
Исполняемый код в этом случае представляет собой набор 8-битных слов расположенных подряд, где каждое слово является инструкцией. Формат инструкции будет рассмотрен в следующем разделе, сейчас достаточно понимать что это просто байты в памяти подряд.
Соответственно, чтобы исполнять инструкции, нужно каждый цикл исполнения доставать из памяти по одному байту, и увеличивать адрес, указывающий на то, откуда берется этот байт.
Для этого потребуется модуль ROM, регистр, где будет хранится адрес текущей инструкции, и сумматор, для увеличения адреса.
Также необходимо добавить возможность замены данных хранящихся в регистре, из внешнего входа. Это позволит выбирать какую инструкцию выполнять следующей, что позволит в дальнейшем реализовать условные ветвления, циклы, и любую другую нелинейность программы.
Для этого достаточно добавить вход данных адреса, флаг записи, и направлять данные через мультиплексор, выбирая откуда берем с помощью флага записи.
Декодер инструкций
Рассмотрим формат инструкций. Каждая инструкция будет фиксированной длины в 8 бит. Первые два бита обозначают тип производимой операции, где:
00 - Выполнение операции с помощью АЛУ
01 - Копирование данных из регистра в регистр
10 - Выполнение условного перехода
11 - Работа с оперативной памятью
На первом этапе рассмотрим операции с АЛУ. Этот тип операции берет значения регистров 1 и 2, и записывает их в регистр 0. Чтобы процессор выполнил эту операцию необходимо: Подать на линию включения АЛУ логическую единицу, указать банку регистров что будет запись, подать банку регистров адрес записи (так как мы пишем в нулевой регистр, подаём 0). Разработаем для этого схему, которая будет выполнять эти действия, которая будет называться декодером инструкций.
Эта схема при подаче на вход 0, разбивает 8-битное число на отдельные биты и отправляет первые два в декодер, который определяет что сейчас выполняется, и так как подается ноль выполняется вышеописанный процесс с АЛУ.
Следующие 2 бита инструкций выделим на код операции в АЛУ. Это то число, которое мы добавляли для выбора совершаемой операции в логическом устройстве. Эти два бита можно отправить сразу же на выход, для передачи в АЛУ.
Последние 4 бита необходимы для констант. Константа - это число которое будем добавлять дополнительно к результату АЛУ, чтобы из кода программы можно было вносить какие-либо числа в регистры. К примеру, инструкция 0b 0000 1111 будет выполнятся как сложение регистров 1 и 2, затем сложение получившегося числа с 15 (0b1111). Таким образом можно внести в программу число, если регистр 1 и 2 пусты, и это число будет в регистре 0.
Теперь мы можем перейти к реализации второго типа операции - копирования. Для этого 6 бит оставшиеся в инструкции после определения типа разделим на два 3-битных значения, Source и Destination. Эти два значения будут определять пару регистров откуда и куда будет передаваться значение. Значит, при выполнении копирования мы должны указать банку регистров что мы будем читать, писать, и откуда/куда. Для этого добавим соответствующие выходы, Read и ReadAddr, и добавим ИЛИ перед выходом Write, чтобы он был активен и при этом типе операции. Подведём выход декодера к Read, чтобы он также активировался, и разведем по три бита по адресам регистров.
Интеграция
Теперь у нас готовы все компоненты для первичной проверки работоспособности процессора, и пришло время объединить всё ранее созданное в прототип процессора.
Все выходы были описаны в разделах соответствующих компонентов, единственное что здесь добавлено это мультиплексор куда на вход приходит выход из АЛУ, и выход из банка регистров, он нужен для того, чтобы выбрать данные откуда будут браться для записи в регистр.
Как это работает?
Изначально из компонента с счётчиком программ (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.
Этот компонент lполучает на вход значение регистра 0 и флаг активации, и если блок активен, и регистр 0 равен нулю, то он даёт сигнал JMP.
Также доработаем схему декодера
Теперь если тип операции 2 (0b10), ставим флаг Conditional, также отправляем младшие 6 бит как адрес прыжка на выход из компонента.
Подключим блок переходов к общей схеме.
Теперь, если из ПЗУ достается инструкция условного перехода, блок 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 обнуляется, чтобы условия перехода выполнялось.
Заключение
В этой статье я постарался пройти путь от транзистора до простейшего процессора, описав возможные схемы всех компонентов. Данная архитектура крайне не практична в реализации и программировании, в ней нет обработки прерываний, обработки стека вызовов, и внешних выходов, но в ней есть все базовые принципы, на основе которых можно создавать системы гораздо большей сложности.
В следующей статье я опишу создание практичного процессора на основе архитектуры RISC-V, с обработкой внешних прерываний, а также создадим видеоадаптер, для того чтобы запустить и поиграть в змейку на этой системе.
Комментарии (64)
SIISII
27.07.2024 13:23+3Ну, вообще-то, даже полевых транзисторов отнюдь не два типа, а ведь есть ещё и биполярные, на которых строились все машины средней и высокой производительности вплоть до конца 1980-х. Понятно, что описывать их все в подобной статье смысла нет, но хотя б упомянуть, что мир этим не исчерпывается, стоило бы.
Реализация АЛУ тоже... скажем так, типична для ПЛИС, но не шибко эффективна: много лишней логики получается. Можно сравнить, например, с устройством классического АЛУ SN74181, а заодно разобраться в ограничениях по скорости из-за переносов и как они могут быть смягчены в реальных схемах.
aprolad Автор
27.07.2024 13:23+6Отразил в статье что есть разные типы транзисторов, было слишком упрощенно, спасибо
nikolz
27.07.2024 13:23+2Принцип сложения двоичных чисел идентичен сложению десятичных - сложить разряд и перенести, если есть переполнение.
Можно сказать иначе: Сложение чисел реализуется через сложение цифр и знака переноса.
Как следствие, простейшее АЛУ - это одноразрядные сумматор,сдвигатель,инвертор .
Armmaster
27.07.2024 13:23+5Строго говоря все современные процессоры с кэш-памятью можно отнести к
гарвардской архитектуре, так как у них разделяется кэш команд и кэш
данныхНет, это не так. Организация кэшей бывает разная, но как правило на нижних уровнях работают уже unified кэши, общие для инструкций и данных. И в любом случае, любая организация кэшей не отменяет того факта, что пространство памяти общее.
SIISII
27.07.2024 13:23+4А ещё гарвардская архитектура предполагает не просто отдельные шины для доступа к коду и данным, а принципиально разные адресные пространства -- т.е. численно одинаковый адрес означает разные ячейки памяти в зависимости от того, относится он к коду или данным. Соответственно, данные в области кода либо не могут располагаться вовсе, либо для доступа к ним нужны специальные команды, а не обычные, оперирующие с данными в памяти данных. Современный пример -- архитектура AVR8. Но подавляющее количество архитектур -- фон-неймановские, где нет принципиального отделения пространств кода и данных (даже если в физической реализации они на некотором уровне разнесены, как это происходит с кэшами первого уровня современных процов).
Вообще, очень много проблем возникает из-за неряшливого обращения с терминами, и "архитектура" здесь одна из наиболее пострадавших. Вон, кажись, вчера мелькала статейка про то ли интеловские, то ли АМДшные процы, где архитектуру использовали вместо микроархитектуры. Так и с гарвардской/фон-неймановской получилось: смотрят на некий простой формальный признак, а не на суть этих понятий.
aprolad Автор
27.07.2024 13:23+1А в чём отличие архитектуры от микроархитектуры?
Микроархитектура это конкретная реализация архитектуры? То есть, примеру, x86 это архитектура, а intel 286 это микроархитектура имплементирующая x86?SIISII
27.07.2024 13:23+3В первом приближении -- да. Впервые разделение на архитектуру (представление машины с точки зрения программиста) и реализацию ввела IBM в Системе 360, но микроархитектуру тогда ещё не придумали, т.е. не отделили её от физической реализации. Микроархитектура -- это, скорей, нечто промежуточное между архитектурой и физическим воплощением: логическое внутреннее устройство процессора, но без жёсткой привязки к "железу". Скажем, можно взять какой-нибудь интеловский проц (официально они называют современную архитектуру Intel 64 -- не брать же название AMD64 :) , а её 32-разрядного предка -- IA-32, хотя в "разговорной речи" обычно все говорят об x86) какой-нибудь определённой микроархитектуры, скажем, Sandy Bridge, но реализовать его по иным технологическим нормам (не 22 нм или сколько там было в нём, а, скажем, 7 нм). Понятно, что физически реализация уже будет другой, но если в "схемы" (реально -- в исходные тексты на Верилоге или ещё каком языке описания аппаратуры) никаких изменений не вносили, то микроархитектура останется прежней (а архитектура -- и подавно).
aprolad Автор
27.07.2024 13:23Спасибо за разъяснения. Кстати, не подскажите, в реальном проектировании микроархитектур используется Verilog или VHDL, или там уже что-то другое, а эти языки больше для FPGA?
Armmaster
27.07.2024 13:23+1Проектировать микроархитектуру можно на чём угодно, хоть на chisel. Verilog, VHDL, Chisel и т.д. - это всего лишь языки описания аппаратуры, разной степени абстракции. На практике, почти вся индустриальная разработка сидит на Verilog/SystemVerilog. В академии больше любят VHDL.
SIISII
27.07.2024 13:23+1Ну, скажем, Cortex-M1 и Cortex-M3 написаны на Верилоге (исходники есть). Вряд ли другие ядра они писали на чём-то другом.
gleb_l
27.07.2024 13:23Архитектура - то, что доступно с уровня машинных команд. Микроархитектура - соответственно, с уровня микрокоманд (если он есть), или с уровня реализации выполнения машинных команд средствами комбинационной логики.
Armmaster
27.07.2024 13:23Да, смесь архитектуры/микроархитектуры это вообще беда нашей индустрии. Вот казалось бы, а за пределами узкого круга специалистов вообще мало кто понимает, что это разные вещи. И что архитектура вообще вещь достаточно тривиальная, а вот микроархитектура сложнее на порядки
omxela
27.07.2024 13:23+2Спасибо за статью, включу в свою библиотеку. Мне, как программисту, в своё время сильно прояснила дело книжка Чарлза Гилмора "Введение в микропроцессорную технику" (Мир, 1984). На понятных структурных схемах по шагам показано, как всё движется в типичных случаях.
SlFed
27.07.2024 13:23+3Само это "зажатие", заключается в подаче небольшого тока на затвор транзистора, после чего ток начинает течь от истока к стоку.
Полевые транзисторы управляются не током, а напряжением. О чем вы пишете в следующем абзаце. Поэтому тут надо поправить на подачу небольшого напряжения на затвор транзистора
karavan_750
27.07.2024 13:23Существует два основных вида транзисторов: биполярные и полевые. Основное отличие заключается в том что биполярные транзисторы управляются током, в то время как полевые управляются напряжением, создающим электрическое поле.
Существует два логических типа полевых транзисторов: n-тип, который пропускает ток при подаче тока на затвор
Нет ли во второй цитате описки относительно "подачи тока на затвор" вместо "подачи напряжения на затвор"?
Схема логического инвертора в двух состояниях
На картинке выше вроде как в правой части на выходе должна быть "1"
P.S.: Давно мечтаю освоить радиоэлектронику, чтение схем, но в каждую попытку освоения натыкаюсь на такие вот необъяснимые коллизии в описании и освоение откладывается до каких-нибудь иных времен.
aprolad Автор
27.07.2024 13:23Исправил, спасибо
А в схеме в правой части действительно 1, '0' там обозначает что резистор подтягивает выход к нулю, а цвет провода обозначает значение. Упустил момент с объяснением обозначения и предназначения резисторов и выходов с неопределенным значением.SIISII
27.07.2024 13:23Кстати, с биполярными транзисторами всё хитрее. Вроде бы, на уровне физики они тоже управляются напряжением, но для практических расчётов удобнее считать, что током, почему так и считают. Впрочем, в этом вопросе не поклянусь, сам разбирался, как оно работает, ещё школьником, а это было 100500 лет назад :)
checkpoint
27.07.2024 13:23Вентиль NOT (НЕ) это тот же NAND с объединенными входами.
Простыми словами принцип работы можно объяснить таким образом: стандартное состояние триггер принимает при получении на вход 1 и 1, в этом состоянии он возвращает свой сохраненный бит,
Для RS-триггера две единицы на входах (S и R) является запрещенной комбинацией. Режим хранения это два нуля.
Схема RS-триггера в статье не верная - сигнал сброса (R) должен находиться вверху напротив неинвертирующего выхода.
Про RS-триггер на русском.
checkpoint
27.07.2024 13:23rs-триггер это первый компонент с последовательной, а не комбинаторной логикой,
RS-триггер это всё еще комбинационная асинхронная схема, где Вы в ней увидели сигнал тактирования ? Чтобы его сделать синхронным (получить D-триггер) требуется на входе еще три элемента.
Регистр содержит два последовательных D-триггера и обозначется (ТТ в русскоязычной литературе). Делается это для того, чтобы избавиться от случайных изменений выхода в процессе записи (в течении одного такта), т.е. запись происходит по переднему фронту в первый триггер, а по спаду - даннное перекладывается из первого триггера во второй.
aprolad Автор
27.07.2024 13:23Логика не должна быть синхронной, чтобы быть секвенциальной, а RS-триггер определенно является секвенциальным, так как его выход не является только функцией от текущих входов. Возможно мне следует заменить термин последовательный на секвенциальный, если он обозначает нечто иное.
checkpoint
27.07.2024 13:23При разработке цифровых схем последовательностная логика всегда синхронная, RS-триггеры почти не используются, поэтому когда говорят "последовательностная логика" всегда где-то рядом есть сигнал тактирования.
aprolad Автор
27.07.2024 13:23Простите, я Вас не очень понимаю, по вашему мнению RS-триггер является элементом комбинационной логики, то есть состояние его выходов однозначно определяется набором входных сигналов? Возможно есть третье определение логики подходящее, по определению из вики это асинхронная секвенциональная логика, но в любом случае она не может быть комбинационной, из-за наличия внутреннего состояния.
SIISII
27.07.2024 13:23Нет, RS-триггер, в отличие от действительно комбинационной логики, запоминает своё состояние. Да, оно устанавливается определённой комбинацией на его входах, но в дальнейшем поддерживается триггером самостоятельно, пока он не будет переключён в другое состояние. Ну а комбинационная логика ничего в принципе не хранит -- в том и различие.
Сейчас RS-триггеры действительно почти не используются, как не используются и защёлки -- везде сплошь флип-флопы, а вот "в древности" -- строго наоборот: защёлки и RS устроены существенно проще, а когда тебе каждый триггер надо собирать на логических элементах, ты начинаешь заботиться о количестве корпусов микросхем :) Ну и, кроме того, в крупных схемах с протяжёнными связями возникают проблемы с разводкой синхронизации, и там нередко проще и целесообразнее сделать многофазную синхронизацию и использовать защёлки, а не делать однофазную и использовать флип-флопы.
В ПЛИС использовать можно и то, и другое, но, во-первых, флип-флопы у тебя "бесплатны" (нет смысла экономить: они всё равно реализованы), а во-вторых, ПО не умеет правильно считать задержки и прочая при использовании защёлок, поэтому обеспечение правильной синхронизации становится задачей разработчика (с флип-флопами считать намного проще из-за того, что они меняют своё состояние лишь в момент фронта/спада тактового импульса).
checkpoint
27.07.2024 13:23Вопрос скорее в терминологии. По той же ссылки написано:
Секвенциальная логика — это логика памяти цифровых устройств. Название «секвенциальная» восходит к англ. sequential. Соответствующая логика может именоваться также как последовательностная, хотя последний термин по преимуществу употребляется в связи с логическими автоматами.
То есть "последовательностная" логика употребляется при работе с автоматами и предполагает синхронизацию. В случае RS-триггера правильный термин это "секвенциальная".
aprolad Автор
27.07.2024 13:23+1Сделал в тексте два варианта секвенциональная/последовательная, так как первый вариант используется в вики, а по второму выдаёт больше ссылок по триггерам, и чаще так переводится.
aprolad Автор
27.07.2024 13:23Активный режим триггера для схемы на И-НЕ является 0, так что при подаче 0 0 будет неопределенное состояние, также порядок входов приведенном вами изображении для триггера на ИЛИ-НЕ, вот картинка из статьи по ссылке для И-НЕ. Постараюсь более явно выразить в статье на основе какого вентиля построен триггер и в чем их отличие.
checkpoint
27.07.2024 13:23Для элементов И-НЕ всё верно. Извините, глаз замылился к трем часам ночи. :)
Но Вы не упомянули про запрещенное состояние.
checkpoint
27.07.2024 13:23+2Приведенная схема полного сумматора является самой неэффективной - увеличение разрядности сумматора в двое влечет удвоение времени задержки, что уменьшает тактовую частоту процессора в двое. Существуют более сложные схемы с распространением переноса наперед (carry-lookahead adder, carry-skip adde и т.д.)
SIISII
27.07.2024 13:23Ну, на ПЛИС примерно такое обычно и синтезируется: несколько отдельных блоков для сложения, И, ИЛИ и т.п., а на выходе -- мультиплексор для выбора результата. Реализовать ручками ускоренный перенос можно, но он будет, скорей всего, работать медленнее последовательного, т.к. для последовательного переноса у ПЛИСины имеется специально для этого выделенная связь и всё такое прочее, максимально оптимизированные по скорости, а ускоренный придётся делать обычной логикой и разводить обычной медленной коммутацией через 100500 невидимых мультиплексоров. Вот в заказной микросхеме или на рассыпухе -- другое дело.
checkpoint
27.07.2024 13:23Если говорить про ПЛИСы, то все зависит от разрядности сумматора и обычно такие схемы синтезируются в готовые блоки АЛУ. Я же пишу про теоретический аспект. :)
checkpoint
27.07.2024 13:23А что если в качестве сигнала Conditional использовать флаг Carry от АЛУ и выкинуть блок сравнения вместе с его инструкцией ?
aprolad Автор
27.07.2024 13:23Да, так и правда значительно эффективнее с точки зрения транзисторного бюджета, и эстетичнее с точки зрения технического дизайна, но я хотел выделить этот тип операций в отдельный блок, для лучшего восприятия что именно происходит в момент сравнения. Если использовать флаг переполнения, можно было бы задействовать последний слот в двух-битовом значении как операцию с загрузкой константы в определенный регистр, что определенно упростило бы программирование. Хотелось иметь инструкцию типа "JMP (адрес)", чтобы было проще её понять, ведь она фундамент любого ветвления.
checkpoint
27.07.2024 13:23С моей точки зрения гораздо интереснее является дизайн вычислителя в котором все операции выполнятся через АЛУ, в том числе пересылка регистр-регистр. Посмотрите на TD4 CPU, если вдруг не сталкивались еще.
SIISII
27.07.2024 13:23Это, кстати, не всегда эффективно. Скажем, одной из причин, хотя и далеко не основной, очень значительного (в 4-6 раз) повышения производительности процессора ЕС-1022 по сравнению с ЕС-1020 является то, что в нём есть два параллельно работающих пути данных: через АЛУ и прямая передача мимо АЛУ, что позволяет в одной микрокоманде совмещать часто используемые пересылки с вычислениями, а не разносить их по разным тактам. Хотя, понятно, по количеству логики такое устройство затратнее.
checkpoint
27.07.2024 13:23Разумеется. А еще АЛУ может быть несколько штук, они могут быть разных видов, и т.д. Но мы же говоим про обучающий минималистичный вариант вычислителя.
Кстати не нашел у автора про реализацию сдвигателя. Как же без SLL и SRL ? :)
SIISII
27.07.2024 13:23Ну, сдвиг влево можно реализовать сложением числа с самим собой :) Например, именно так поступают в секционных АЛУ К589ИК02, в девичестве i3002. Но вот сдвиг вправо таки нужен в явном виде.
Xambey97
27.07.2024 13:23+1Аж олдскулы свело бывшего студента вычислетеля, спасибо за статью. Годное объяснение
gleb_l
27.07.2024 13:23+2Существует два логических типа полевых транзисторов: n-тип, который пропускает ток при подаче напряжения на затвор, и p-тип, который, наоборот, перестаёт пропускать ток при подаче напряжения на затвор.
Нет! P-тип - не реле с нормально-замкнутыми контактами. Он тоже пропускает ток про подаче напряжения на затвор, но не просто «напряжения», а относительно истока, и это напряжение отрицательное. Чтобы его использовать в положительной логике, его исток цепляют к плюсу, и управляют им подтягиванием затвора к нулю.
Dyzzet
27.07.2024 13:23+2NAND и XOR [...] — это универсальные вентили, из каждого из которых можно получить все остальные, соответственно, всего лишь из NAND-вентилей можно построить логическую схему любой сложности
XOR является функцией, сохраняющей 0, и заодно линейной функцией. То есть система из одной этой функции критерию Поста не удовлетворяет. А вот система из одной функции NAND — удовлетворяет.
aprolad Автор
27.07.2024 13:23Возможно построить NAND из 4 XOR вентилей, так что должно быть возможно построить из XOR всё что возможно построить из NAND.
SlFed
27.07.2024 13:23Функция XOR позволяет реализовать любую схему при добавлении к ней константы 1. Тогда получается полный базис.
Подробнее - см. Полином Жегалкина
SIISII
27.07.2024 13:23+1Цитирую Вику:
Этому требованию отвечает, в частности, система функций ⟨∧,⊕,1⟩ (конъюнкция, сложение по модулю два, константа 1). На её основе и строятся полиномы Жегалкина.
Как видим, эти полиномы содержат функции AND и XOR, а не одну только XOR. Речь же о возможности создания любой логики на одном типе логических элементов.
mozg37
27.07.2024 13:23Делать на фпга проц - зачем? Если можно сразу всю логику изделия зашить.
checkpoint
27.07.2024 13:23+3Чтобы программировать. Программная логика может быть куда более витиеватой, а реализация этой же логики в "железе" может легко упереться в ограничения по ресурсам. Хорошим решением является комбинирование софт-ядра и специализированной аппаратуры. ПО на ядре выполняет управляющую функцию, оперативно снабжает специализированную аппаратуру входными данными, которая, в свою очередь, решает поставленную задачу.
SIISII
27.07.2024 13:23+1А ещё можно сделать ради собственного удовольствия :) Ну или как прототип будущей заказной микросхемы. Или как железный эмулятор древнего компа -- но с последним не всегда получится в лоб. Скажем, проц от СМ-1420 для симуляции в какой-нить там Квесте перевести на Верилог можно, а вот для синтеза в ПЛИС без существенных переделок -- фигвам: там и двунаправленные шины с открытым коллектором или с тремя состояниями, и куча одновибраторов, длительность импульсов которых задаётся RC-цепочками... И несколько ошибок, и реальный проц работает только потому, что в схемотехнике ТТЛ они оказались некритичными :)
iboltaev
27.07.2024 13:23+2Есть книжка Харрис и Харрис, там это все тоже очень хорошо и доступно излагается. Очень рекомендую!
KiV66
27.07.2024 13:23+2Здесь уже делали замечание по этому утверждению:
Существует два логических типа полевых транзисторов: n-тип, который пропускает ток при подаче напряжения на затвор, и p-тип, который, наоборот, перестаёт пропускать ток при подаче напряжения на затвор.
Немного уточню.
Вообще-то есть, как минимум, ЧЕТЫРЕ типа полевых транзисторов (на самом деле больше). Главная ошибка в том, что и P и N -канальные транзисторы при подаче напряжения на затвор начинают пропускать ток! Рзница только в полярности подаваемого на затвор относительно истока напряжения.
А вот полевые транзисторы со встроенным каналом (depletion mode MOSFET) - проводят ток при нулевом напряжении на затворе. Причём как P, так N. Вот такие транзисторы:
перестаёт пропускать ток при подаче напряжения на затвор
Но они весьма специфичны и не используются в цифровых системах.
Поэтому правильнее будет написать примерно так:
N-тип, который начинает пропускать ток при подаче положительного напряжения относительно истока и P-тип, который пропускает ток при подаче отрицательного напряжения относительно истока.
aprolad Автор
27.07.2024 13:23+1Да, Вы правы, транзистор не является просто стандартно открытым/закрытым реле, но несколько человек, практически незнакомых с электроникой, на которых я проверял доступность текста, с трудом могли себе представить механику работы, и аналогия с водой была наиболее эффективна, а в её рамках достаточно сложно концептуализировать отрицательное напряжение.
Добавил Вашу версию текста в скобках как более корректную.
volkanin
Посмотрите https://www.nand2tetris.org
kasato4kin
Есть ещё очень занимательная игрушка Turing Complete, где аналогично шаг за шагом строишь на логических элементах компьютер с нуля. Очень классные головоломки. Есть над чем поскрипеть мозгами.
aprolad Автор
Эта игра во многом и вдохновила на написание этой статьи, определенно лучшая игра на эту тему. Также могу посоветовать MHRD, там также создание процессора, но с помощью упрощённой версии Verilog, помогает разобраться как спроектировать что-нибудь без визула, только текстом.
kasato4kin
Удалось собрать все ачивки и пройти до конца?
aprolad Автор
Прошёл все кроме некоторых assembly challenges, немного наскучило писать программы на асме, увидел что в игре есть экспорт в Верилог и попробовал синтезировать в Quartus, но оказалось что игра не лучший софт для проектирования микросхем, поэтому решил перейти в более специализированные программы.