Привет, Хабр. Два года назад, как раз перед началом пандемии, я затеял большой проект: построить компьютер, используя только простые логические микросхемы 74 серии и микросхемы памяти. В этой статье я бы хотел кратко рассказать о том, что получилось, и более подробно об основной части – процессоре.
На сегодняшний день можно сказать, что у меня получился полноценный компьютер: на нем можно играть, можно читать и редактировать текстовые файлы на SD-карте, можно считать и даже строить графики. Нельзя только выходить в интернет.
Технические характеристики компьютера получились следующие:
Процессор: 8 бит, 4 регистра, очень урезанный набор инструкций, тактовая частота 1.5 МГц;
Память: 32 кБ ПЗУ и 52 кБ ОЗУ;
Видеокарта: текстовый режим 80x30, 16 цветов (как в CGA), подключение к VGA-монитору;
Внешний накопитель - SD-карта с файловой системой FAT16;
Разъем PS/2 для подключения клавиатуры.
Процессор
В этом разделе я попытаюсь показать ход моих мыслей при проектировании процессора и покажу, что из этого вышло.
Мне хотелось, чтобы получился более-менее полноценный процессор, программировать который было бы не слишком большой болью. То есть, должны быть полновесные 8 бит и небольшой, но не слишком урезанный набор арифметики: обязательно должны быть простые действия вроде сложения-вычитания с переносом и без и все логические операции, но умножение – это уже слишком. Стеком и прерываниями тоже можно смело пожертвовать.
С такими требованиями к арифметике АЛУ легко сделать асинхронным: при подаче значений на входы на выходе сразу появится результат. Чтобы не было слишком много проводов, один вход АЛУ можно привязать к одному конкретному регистру, который обычно называют аккумулятором.
Следующий вопрос – как сделать переходы. Чтобы процессор выполнил инструкцию jmp label
(переход на заданный в инструкции адрес), нужно сначала загрузить адрес в какой-то регистр, а потом уже оттуда передать его в IP. Загружать напрямую в IP нельзя: адрес состоит из двух байт, и когда будет загружен первый байт, мы не сможем загрузить второй, потому что в IP будет уже наполовину новый адрес.
С доступом к памяти та же история: в x86, например, можно сделать так: mov ax, [label]
. Здесь, чтобы загрузить из памяти значение по закодированному в инструкции адресу, этот адрес тоже нужно сначала поместить в невидимый регистр.
Раз для адресации нужен отдельный регистр, почему бы не сделать его доступным программисту? Тогда можно будет явно загружать туда значения и выполнять с ними арифметику, а потом использовать их в качестве адреса перехода и операций с памятью. Назовем этот регистр P. Так как адрес 16-битный, а данные 8-битные, разделим P на две части: PL и PH.
Итак, минимум нужно три регистра, доступных программисту: аккумулятор A для фиксированного подключения к одному из входов АЛУ и пара PL/PH для адресации. Кодировать три регистра в инструкции неудобно: нужно два бита, остается одна неиспользуемая комбинация, поэтому добавим еще один регистр B.
Из-за того, что адрес нужно загружать в P явно, для операций с памятью и перехода потребуется больше одной инструкции. Например, переход:
ldi pl, lo(label) ; загрузка младшего байта адреса в PL
ldi ph, hi(label) ; загрузка старшего байта в PH
jmp ; собственно переход - инструкция без аргументов!
Заметим, что у нас появилось два 16-битных регистра: указатель инструкции IP и указатель адреса P, причем из P нужно уметь передавать значение в IP. Для передачи значения не обязательно копировать его: можно добавить флаг, определяющий, какой из физических регистров будет действовать как IP, а какой как P. При исполнении инструкции перехода этот флаг будет переключаться, и с точки зрения программиста окажется так, что после перехода в P будет адрес возврата! Таким образом получится сделать вызовы функций без использования стека: достаточно будет в начале функции сохранить значение из P, а при возврате считать его и выполнить переход.
Как выглядят пролог и эпилог функции
function:
mov a, ph ; арифметика (включая mov) возможна только между A и другим регистром
mov b, a
mov a, pl
ldi ph, hi(ret_addr)
ldi pl, lo(ret_addr)
st a ; сначала сохраняем младший байт
inc pl ; ret_addr выровнен, поэтому переполнения через 256 не случится
st b
; ... тут сам код функции
ldi ph, hi(ret_addr)
ldi pl, lo(ret_addr)
ld a
inc pl
ld ph ; старший байт можно загрузить сразу в PH
mov pl, a
jmp ; возврат из функции
; в секции данных:
.align 2
ret_addr: res 2 ; резервируем два байта для адреса возврата
Теперь, когда регистры определены, можно нарисовать общую схему процессора.
Здесь мы видим регистры A и B, блок регистров P, содержащий в себе две пары регистров: PL/PH и IP, регистр текущей инструкции IR, регистр флагов и АЛУ (блок в форме надкушенной трапеции).
Для мультиплексирования сигналов на шинах я использую логические сигналы с тремя состояниями. В каждый момент времени на конкретной шине активно только одно устройство, определяющее уровни сигналов, остальные же находятся в состоянии высокого сопротивления.
Красная шина на схеме – это внешняя шина данных, ведущая к памяти и перефирийным устройствам. Данные с нее могут быть напрямую загружены в регистр инструкции IR или через буфер (треугольник под IR на схеме) переданы на внутреннюю шину процессора (зеленая), ведущую на входы всех регистов. АЛУ также выводит свой результат на зеленую шину.
Розовая шина ведет на второй вход АЛУ. Если ни одно из устройств, подключенных к ней, не активно, на этой шине будет ноль благодаря подтягивающим резисторам. Это позволяет использовать ноль вместо регистра в качестве операнда арифметичских инструкций. Например, так: adc a, 0
.
И, наконец, голубая шина, ведущая от блока P наружу – шина адреса. На ней процессор выставляет адрес памяти, чтобы записать или считать данные.
У регистров A и B по два выхода: на внешнюю шину данных и на АЛУ. Таким образом эти регистры могут участвовать в арифметике и быть загруженными в память. Регистры PL и PH не могут быть загружены в память напрямую: это не имеет смысла, ведь они хранят адрес операции с памятью.
Конечно, почти все блоки на этой схеме – это не отдельные микросхемы. Например, для регистра B нужно три микросхемы: собственно восьмибитный регистр 74HC273 и два выходных буфера 74HC244. Для каждой пары регистров из P нужно восемь микросхем: четыре четырехбитных счетчика 74HC161 и четыре буфера 74HC244.
Адресное пространство
![](https://habrastorage.org/getpro/habr/upload_files/e23/341/89f/e2334189faceeda2b1b47b4cd2e63413.png)
Как вы могли заметить, процессор адресует максимум 216 Байт = 64 кБ, но памяти на самом деле больше: 32 кБ ПЗУ и 52 кБ ОЗУ. Такое возможно с помощью переключения банков: по умолчанию в нижние 32 кБ отображается ПЗУ, но если записать нужный бит в регистр конфигурации памяти, можно отобразить туда дополнительную оперативку. Это позволяет делать довольно сложные приложения: из-за крайне низкой плотности кода 32 кБ едва хватает на драйвер файловой системы, поэтому без переключения банков текстовый редактор, например, ну никак не получилось бы написать. А так можно загрузить приложение с SD-карты в нижнюю часть ОЗУ и использовать функции работы с файловой системой из ПЗУ как системные вызовы.
На старшие сегменты адресного пространства отображены видеопамять и регистры периферийных устройств (клавиатуры и SD-карты), а также регистр конфигурации памяти. Видеопамять организована в два отдельных сегмента для цвета и для текста, в отличие от CGA, где цвета перемежаются с символами. Такая организация проще: чтобы вывести строку, можно просто побайтово скопировать ее. Или, например, можно легко очистить часть экрана, оставив информацию о цвете.
Процесс разработки
Для разработки я использовал только свободное ПО (кроме текстового редактора). После определения общей структуры модуля я рисовал желаемые тайминги сигналов и по ним описывал модели и тесты на языке Verilog, которые запускал и проверял с помощью Icarus Verilog и GTKWave. Потом по списку микросхем 7400 серии я выбирал подходящие и смотрел, есть ли они в продаже. Когда микросхемы были выбраны, я переделывал код с использованием моделей конкретных микросхем. Одновременно я рисовал схему в KiCAD. Таким образом получалось полное соответствие между схемой и моделью и можно было быть уверенным (почти), что всё заработает в железе.
Такой подход оправдал себя: в платах почти не было логических ошибок. Возникали другие непредвиденные проблемы: например, благодаря этому проекту я узнал про наводки между соседними дорожками и про отражение высокочастотных сигналов и про то, что будет, если этого не учитывать. Также я узнал, что более быстрая серия микросхем не значит лучшая.
Заключение
Этот пост получился уже довольно длинным, а я многого не рассказал: про видеокарту, про АЛУ, про кодирование инструкций и ассемблер, про общение с PS/2 и SD-картой, а также про программную часть этого карантинного проекта. Если будет интересно, напишу еще посты, а пока можете посмотреть репозиторий.
UPD следующий пост про кодирование и исполнение инструкций
UPD пост про АЛУ