Создаем современный ретро компьютер на Arduino
Всем привет! В этой публикации я расскажу про свой опыт создания небольшой вычислительной системы, проблемах связанных с этим и способах их решения, но обо всем по порядку.
Меня зовут Евгений, я студент 4 курса МатМеха УрГУ (урфу). Примерно на первом курсе я понял, что меня завораживают старые компьютеры и старые технологии. Примерно тогда же я купил себе советский клон ZX Spectrum`а — Урал-8/64K.
ZX Spectrum, для тех, кто в танке, это совершенно легендарный компьютер, его знают и любят все, кто даже далек от этой всей компьютерной темы. Об этом, так же, говорит и наличие современной разработки компьютерных игр для него. В общем, много чего можно рассказать о нем, но не будем сильно отвлекаться. Так вот, купил я себе такой, поигрался, попрограммировал, мне понравилась его простота, какое понимание он дает о своем устройстве тем, кто впервые с ним работает.
Идея
Немного раньше я делал небольшие проекты на Arduino и думал над каким то достаточно большим проектом, но сильно не хотел делать еще одну »умную поилку для собаки», которых в интернете наплодили достаточно, чтобы потерять всякий интерес к этому, не то, чтобы он до этого был большой. Я подумал, мне нравится Spectrum, так почему бы не сделать его эмулятор, софтверных эмуляторов полно, да и физических тоже хватает, но я решил посмотреть что есть и сделать свое. Я погуглил и нашел статью, которая и толкнула меня к первым шагам.
Что мне не понравилось в уже готовых аналогичных проектах, чтобы просто их реализовать:
Абсолютно нечитаемый код на 2000+ строк кода в одном файле с кучей комментариев — иногда не только пояснительных, но и кода…
Мощные платы — в моей задумке было использовать простенькую Arduino Nano, а точно не Mega!
Много плат — опять же хотелось использовать всего одну простую плату и вместить на нее все необходимое.
Необходимость обновления прошивки при появлении новых программ для устройства — убивает внутреннюю flash память устройства, нужно что то другое.
Начинаем разбор
Посмотрев на различные проекты и доступное оборудование, я решил сделать симулятор не ZX Spectrum, а полностью придуманного вычислителя — со своей системой инструкций, способом коммуникации с оборудованием, и языком ассемблера, соответственно! Приступим к описанию того, как строилась система. Но прежде рекомендую ознакомиться с инструкциями процессора — это основной репозиторий проекта, дальше информация оттуда будет упоминаться.
Одну из проблем, которые я пытался решить — это износ внутренней flash памяти, но оперативной памяти мало для хранения программ написанных для нашего компьютера — её всего 2 кб! Так что я решил использовать SD карту. Изначально я думал хранить на карточке файловую систему и в ней файлы и открывать нужную программу, выгружая её в оперативку. Однако для такой схемы нужна библиотека SD, использование которой не оставит от этой самой оперативки ничего. Я подумал — если понадобится ФС, напишем ее поддержку уже на нашем коде, а не будем занимать память Arduino для этого. Вполне хорошей альтернативой для работы с картой послужила библиотека sd_raw, которая предоставляет доступ к любому байту на карте на чтение и запись туда сырых данных, т.е. просто набора байт. В качестве основного устройства вывода текста я взял черно-белый дисплей Nokia 5110 — точное попадание в ретро стиль, как мне кажется. В качестве клавиатуры, понятно, возьмем PS/2. Остались свободные пины — заткнем пьезо-пищалкой. Мой хороший знакомый нарисовал и напечатал мне корпус, как многие заметили по картинке выше, похожий на корпус Macintosh.
Instruction Set Architecture
Ок, поговорили как он выглядит, что внутри, теперь углубимся в систему команд… Она строилась таким образом, чтобы пользователь не писал кучу лишнего кода, чтобы проинициализировать какое то устройство, как делал я, чтобы завести мышку в x86, используя только запись в порты.
Т.е. на манипуляции с устройствами выделены некоторые инструкции, такие как:
gkey — получает сканкод нажатой клавиши, если ее не было, то 0.
line x1, y1, x2, y2 — рисует линию по указанным координатам на экране.
play label — играет музыку по смещению label в памяти.
…
Как же будут устроены обработчики этих инструкций и их опкоды? Я решил этот вопрос очень просто — создал массив функций, теперь в основном цикле работы процессора байт, на который указывает указатель инструкции (IP) — будет восприниматься как номер функции в этом массиве. Если же у инструкции есть какие то аргументы, например операнды инструкции сложения add r0, r0, то соответствующая функция сама об этом знает и дочитывает их. Кстати говоря, поскольку регистров 16, то эту пару из инструкции выше можно упаковать в 1 байт, чем я и воспользовался, чтобы сократить код.
Регистры!
С ISA разобрались, немного о регистрах, их 2 набора по 16 штук — для целых чисел и чисел с плавающей запятой, все 32-битные. Есть инструкции кастинга из одного типа в другой, при этом сами регистры при программировании ничем не отличаются, инструкция сама понимает из какого именно регистра читать/писать (хотя можно сделать псевдонимы, чтобы не путаться). Кстати о плавающих числах, тут нам можно работать с плавающими числами как с целыми в плане использования их в инструкциях, не нужно использовать регистровый стек и обращаться по ссылке в память для получения константы, как это есть в x87. Однако вводя эти числа в свою систему, я не знал как безболезненно преобразовывать их, т.е. без битого разбора числа в формате IEEE754. Оказывается такой способ есть и он очень прост, нам не потребуются никакие логические операции в огромном количестве, а только 2 строчки:
unsigned int x = 0x3f322e3f;
float y = *(float*)&x;
Тут мы просто записали в некоторую область памяти целое число (4 байта), изначально смотрим на них именно так, потом после взятия указателя на эту область (целое число), производим каст его к указателю на вещественное число и разыменовываем. Вся основная работа происходит во второй строчке и читать ее нужно справа налево. В итоге в переменной y окажется число, байтовое представление которого в формате IEEE754 равно числу в переменной x.
Пройдемся по устройствам…
Дисплей — с ним особых проблем не возникало, открыл datasheet, написал библиотеку и все работает, возникла только одна проблема с тем, что необходимо сохранять содержимое буфера экрана (508 байт) в какой-то памяти для изменения отдельных пикселей. Хорошо бы можно было в оперативке, но места мало — примерно столько же ест массив инструкций и столько же библиотека sd_raw, в совокупности с прочими расходами на регистры и другие переменные и массивы. Это оставляет около 150–200 байт на локальные переменные, чего может оказаться мало. Но быстро стало ясно, что инструкции никто не будет менять (хотя идея интересная — налету подменять инструкции процессора), поэтому было решено перенести их во внутреннюю flash память, используя ключевое слово PROGMEM в Arduino IDE, которое позволяет сохранять константы любых типов в неизменяемую память, освобождая оперативную. Таким образом решилась проблема нехватки памяти для экрана.
Клавиатура — тут интереснее то, что происходило в программном эмуляторе нашего девайса, но об этом позже. В остальном, я пока что с реальной клавиатурой разбираюсь — там нужно выбрать хорошую (для наших целей) таблицу сканкодов (их 3 в PS/2) и понять как легко транслировать их в символы ASCII, или хотя бы как это делает DOS.
Карта памяти — в какой то момент мне пришло осознание, что даже, если у нас регистры 32-битные и карта не меньше 4 Гб, но мы все равно не можем адресовать больше 2 Гб — это связано с тем, какую плату картридера я установил в наш вычислитель — она не поддерживает карты большего объема. Это налагает ряд проблем — теперь мы можем использовать только карты на которых указан объем 2Гб, они всегда на самом деле меньше и даже так — они различаются по объему. Но так как раньше и IP и SP — специальные регистры были по умолчанию установлены в 0, то теперь для адекватного использования стека (который растет вниз) нужно знать верхнюю границу памяти. И очень кстати в библиотеке имелась функция для чтения заводской информации, а помимо производителя там было поле capacity (емкость). Вот именно в это значение мы и устанавливаем теперь SP перед началом основного цикла процессора.
Пьезо-пищалка — здесь было совершено большое открытие для меня — что delay в Arduino не такой уж блокирующий, как нам все говорят. Остановимся поподробнее.
Начну с того, какая задача стояла. У нас есть набор пар частот и задержек, мы хотим перебирать их и проигрывать на пищалке функцией tone, которая как раз принимает пин, частоту и задержку, пищит с нужными параметрами и отключается по прохождении задержки. Перебирать нужно не абы как, а когда исполнится инструкция play, которая раньше упоминалась. Она укажет, где лежат эти ноты и запустит проигрывание, но стандартная функция tone не умеет по циклу ходить и изымать частоты с задержками. Значит нужно как-то детектировать то, что текущая нота должна уже закончиться и пора бы включить следующую. Самым простым решением будет в основном цикле процессора следить за этим с помощью millis, которая возвращает время прошедшее с запуска контроллера в миллисекундах. Но самое простое — не самое эффективное, у нас инструкции не имеют фиксированного времени исполнения, та же delay может занимать достаточно длительное время, не давая переключить ноту. Дальнейшим решением для меня было — покопаться в исходниках функции tone и создать аналог, который принимает обработчик завершения тона, обычно это была функция, которая отключает таймер, но теперь мы ее подменяем и вместо отключения мы включаем новый тон. После того, как проигрывание завершается нужно позвать noTone, чтобы вызвать правильный обработчик и подменить его обратно. Вроде все хорошо, все работает! Однако не совсем… Я загрузил следующий код:
#define N 39
int i = 0;
int frequences[N] = {
392, 392, 392, 311, 466, 392, 311, 466, 392,
587, 587, 587, 622, 466, 369, 311, 466, 392,
784, 392, 392, 784, 739, 698, 659, 622, 659,
415, 554, 523, 493, 466, 440, 466,
311, 369, 311, 466, 392
};
int durations[N] = {
350, 350, 350, 250, 100, 350, 250, 100, 700,
350, 350, 350, 250, 100, 350, 250, 100, 700,
350, 250, 100, 350, 250, 100, 100, 100, 450,
150, 350, 250, 100, 100, 100, 450,
150, 350, 250, 100, 750
};
void executor(){
tone(2, frequences[i], durations[i]);
i = (i+1) % N;
}
void setup() {
tone(executor);
delay(10750);
noTone(2);
}
void loop() {}
Оказалось, что проиграв одну ноту, он выключал проигрывание. Почему же… Обратите внимание на delay, если зайти в её исходники, то мы замечаем функцию, которая вызывается перед циклом задержки — yield, я мало чего про нее нашел, но, как я понял, это макрос, в который мы оборачиваем код и как-то можем параллельно его исполнять с основным кодом, если кто знает точно — поделитесь. Я сам попробовал решить эту проблему и у меня получилось! Покажу на примере. У нас есть такие строчки в функции noTone —
Первая строка тела записывает логический 0 в указанный выход, а вторая подменяет обратно обработчик. Что будет если их поменять? Все сломается, конечно. Вспомним, что у нас delay не совсем блокирует, а именно, код будет исполняться до »digitalWrite», перед чем заблокируется. Если строчки будут в другом порядке, то вот эта замена приведет к тому что у нас старый обработчик вернется и будет отключать таймер, на котором работает пищалка. Таким образом, экспериментально проверено, что delay блокирует код, если он изменяет состояние пинов, иначе он исполняется. Каким образом это происходит — на это у меня ответов пока что нет.
Прочий софт
Вот такие были пироги при работе с периферией нашего компьютера. Помимо его самого, для него я сделал транслятор ассемблера в машинные коды, чтобы писать начальные программы было удобнее — работает на Python. На нем начал реализовывать оболочку, которая умеет разбирать FAT32, ходить по ее директориям и исполнять файлы. Так же для удобства отладки был написан эмулятор девайса — он использует общий код с самим компом. Рассмотрим подробнее оболочку и эмулятор.
Оболочка
Компьютер начинает исполнение инструкций с 0 байта. На карте памяти было решено создать файловую систему FAT32 и использовать ее резервные секторы для нашего загрузчика. Как правило, начало самого первого сектора содержит служебную информацию, но самые первые байты не критичны к изменению, а именно нам надо изменить первые 5 байт на инструкцию перехода к первому байту загрузчика. Ок, мы попали в загрузочную секцию, разобрали все байты, рассчитали константы в удобном виде. Сохранили это все, теперь, чтобы не пересчитывать это при каждом старте и не перезаписывать подменим еще раз инструкцию перехода на блок непосредственной загрузки. Можно видеть это в этом коде:
movw r0, [0xe]
shl r0, 9
mov [first_fat], r0
movb r1, [0xd]
shl r1, 9
mov [cluster_size], r1
movb r1, [0x10] ;number of fats
movw r2, [0x24] ;sector per fat
shl r2, 9
mov [fat_size], r2
mul r1, r2
add r0, r1
mov [data_area], r0
mov r0, start_main
mov [1], r0
include "main.asm"
Про FAT ничего не буду рассказывать, можно посмотреть какие там есть поля, как строятся файлы и про терминологию в этом видео:
У нас в программе есть поле »текущая директория», она обозначает номер первого кластера директории. Таким образом если мы в какой то директории начали работу при выключении компьютера, то при включении мы в ней и начнем работу. Выбирать файл в директории можно клавишами стрелками вверх-вниз, исполнять по Enter, при этом если это директория, то она становится в качестве текущей. Кстати говоря, под исполнение нужно выделить место. Поскольку доступно максимум 2 Гб, то 1 нижний гигабайт мы отдадим файловой системе, а старшую область под исполнение. Поскольку экран у нас может вмещать 6 строк текста по 14 символов, то мы можем отображать имя и тип файла (файл или директория) в каждой строке, а между строками перемещаться, используя скользящее окно.
Эмулятор
Наконец, немного про эмулятор. Чтобы все работало так же как на реальном компе, понятно, что нужно реализовать соответствующим образом библиотеки работы с устройствами. Карта памяти эмулируется, как не сложно догадаться — файлом.
Экран изначально был приложением на SFML, но это влияло на работу с клавиатурой, создавая две проблемы. Во-первых, нужно понимать, что SFML это библиотека для игр, поэтому там не получить никаких нормальных сканкодов, значит надо их получать откуда-то еще, но если фокус на окне SFML, то оно получает все события клавиатуры, поэтому приходится переключать фокус на консоль, но так чтобы и окно было видно. Во-вторых, SFML это графическая библиотека, т.е. в текстовой консоли (одной из тех, что доступны по ALT-CTRL-Fx) ее нельзя использовать для рисования чего-то на экране.
Хотя хотелось бы перейти в текстовую консоль, поскольку есть способ получения совсем хороших сканкодов — прямо таких, какие приходят от клавиатуры. Это показывает утилита showkey, но работает она только в текстовой консоли, так как графика в Linux для обработки хоткеев всегда читает /dev/console. Суть этого метода — перейти в неканонический вид консоли, где мы будем получать сырые сканкоды. Я просто нашел исходники утилиты showkey и подправил их для своих нужд, заодно научился компоновать программу на С++ с функциями на С — это нужно поскольку showkey написана на С, а весь проект мы пишем на плюсах. Ну, а для отрисовки экрана мы воспользуемся популярной библиотекой для консольной графики — ncurses. Чтобы все отображалось хорошо, нужно настроить консоль так, чтобы размер шрифта вместо 8×16 был 8×8 — красиво, как пиксели на экране.
Заключение
Вот, вроде бы все основное рассказал. Вообще говоря, там еще много всего можно рассказать, с чем сталкивался по пути, но это, скорее, просто занимательные мелочи, чем что то важное. Столько удачных костылей было вставлено — о некоторых рассказал, о других молчу…
Сейчас делаю сетевой интерфейс для передачи по встроенному UART — нашел способ без дополнительных проводов, только UART и диоды, собрать общую шину из таких компьютеров и передавать между ними данные. Так же думаю как сделать транслятор из, скажем, wav в описанный формат музыкальных файлов. Еще поглядываю в сторону нормального компилятора на основе LLVM, но пока только мысленно, потому что итак есть чем заняться с этим проектом.
Спасибо за внимание, присоединяйтесь к проекту. Оставлю ссылки.
Основной репозиторий, программная оболочка, транслятор ассемблера.