Всем привет! В этой публикации я расскажу про свой опыт создания небольшой вычислительной системы, проблемах связанных с этим и способах их решения, но обо всем по порядку.

Меня зовут Евгений, я студент 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.

Пройдемся по устройствам...

  1. Дисплей - с ним особых проблем не возникало, открыл datasheet, написал библиотеку и все работает, возникла только одна проблема с тем, что необходимо сохранять содержимое буфера экрана (508 байт) в какой-то памяти для изменения отдельных пикселей. Хорошо бы можно было в оперативке, но места мало - примерно столько же ест массив инструкций и столько же библиотека sd_raw, в совокупности с прочими расходами на регистры и другие переменные и массивы. Это оставляет около 150-200 байт на локальные переменные, чего может оказаться мало. Но быстро стало ясно, что инструкции никто не будет менять (хотя идея интересная - налету подменять инструкции процессора), поэтому было решено перенести их во внутреннюю flash память, используя ключевое слово PROGMEM в Arduino IDE, которое позволяет сохранять константы любых типов в неизменяемую память, освобождая оперативную. Таким образом решилась проблема нехватки памяти для экрана.

  2. Клавиатура - тут интереснее то, что происходило в программном эмуляторе нашего девайса, но об этом позже. В остальном, я пока что с реальной клавиатурой разбираюсь - там нужно выбрать хорошую (для наших целей) таблицу сканкодов (их 3 в PS/2) и понять как легко транслировать их в символы ASCII, или хотя бы как это делает DOS.

  3. Карта памяти - в какой то момент мне пришло осознание, что даже, если у нас регистры 32-битные и карта не меньше 4 Гб, но мы все равно не можем адресовать больше 2 Гб - это связано с тем, какую плату картридера я установил в наш вычислитель - она не поддерживает карты большего объема. Это налагает ряд проблем - теперь мы можем использовать только карты на которых указан объем 2Гб, они всегда на самом деле меньше и даже так - они различаются по объему. Но так как раньше и IP и SP - специальные регистры были по умолчанию установлены в 0, то теперь для адекватного использования стека (который растет вниз) нужно знать верхнюю границу памяти. И очень кстати в библиотеке имелась функция для чтения заводской информации, а помимо производителя там было поле capacity (емкость). Вот именно в это значение мы и устанавливаем теперь SP перед началом основного цикла процессора.

  4. Пьезо-пищалка - здесь было совершено большое открытие для меня - что 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. Чтобы все отображалось хорошо, нужно настроить консоль так, чтобы размер шрифта вместо 8x16 был 8x8 - красиво, как пиксели на экране.

Заключение

Вот, вроде бы все основное рассказал. Вообще говоря, там еще много всего можно рассказать, с чем сталкивался по пути, но это, скорее, просто занимательные мелочи, чем что то важное. Столько удачных костылей было вставлено - о некоторых рассказал, о других молчу...

Сейчас делаю сетевой интерфейс для передачи по встроенному UART - нашел способ без дополнительных проводов, только UART и диоды, собрать общую шину из таких компьютеров и передавать между ними данные. Так же думаю как сделать транслятор из, скажем, wav в описанный формат музыкальных файлов. Еще поглядываю в сторону нормального компилятора на основе LLVM, но пока только мысленно, потому что итак есть чем заняться с этим проектом.

Спасибо за внимание, присоединяйтесь к проекту. Оставлю ссылки.

Основной репозиторий, программная оболочка, транслятор ассемблера.

Комментарии (20)


  1. forthuser
    02.11.2021 11:23
    +3

    По моему, со словом Современный в загoловке статьи Вы немного погорячились. :)

    P.S. Fungus: the Befunge CPU
    image


  1. Javian
    02.11.2021 11:25

    точное попадание в ретро стиль

    Еще бы цвет и цвет бордера. И вместо SD карты могло быть лучше использовать что-то вроде 25Q128.


  1. ECRV
    02.11.2021 12:16
    +3

    Это оставляет около 150-200 байт на локальные переменные, чего может оказаться мало.

    ZX имел в разы или в десятки раз большее количество оперативной памяти чем вся 328я атмега, получается что людям остается только 200 или 700 байт оперативной памяти, когда даже на сам экран нужно 508 байт. Звучит как очень мало.

    С учетом того что вы пишете свой компилятор и процессор, можно подрубить к ардуине внешнюю оперативку и транслировать переменные программ в нее



    1. PapaKarlo787 Автор
      02.11.2021 20:17

      Людям остаётся все 2 гб, что есть на флешке :) то, что остаётся от оперативки это нужно для дальнейшего расширения и локальных переменных инструкций, пользователям все равно должно быть на это, их это не касается при работе с устройством, только разработчиков касается


      1. ECRV
        02.11.2021 20:51
        +1

        У вас 2ГБ флеш памяти, у дешевых флешек около 1000 циклов перезаписи. У оперативки должно быть ... много, очень много. Флешку вы убьете очень быстро, используя ее как оперативную память, а писать программы без оперативки очень грустно


  1. shuhray
    02.11.2021 14:04

    И приделать к нему ножки, будет робот из "Звёздных войн"


  1. hecategram
    02.11.2021 16:37
    +3

    Лучше взять за основу плату Wemos D1 с ESP8266 на борту. Будет вай фай встроенный. Плюс памяти у нее больше на борту др 4 мегабайтов. Клавиатуру можно взять от тв боксов и через блютус подключить ее. Да и экран лучше взять современный цветной. Там даже проводов будет меньше для работы, чем у нокиа экрана.


    1. Javian
      02.11.2021 16:44
      +1

      Тогда уж так https://habr.com/ru/post/412325/


    1. PapaKarlo787 Автор
      02.11.2021 20:20
      +1

      Смотря какая цель :) я писал об этом, может быть в будущем, если уже точно ничего не выжать из данной платы будет, то можно перейти на что мощнее


  1. iShrimp
    02.11.2021 17:18
    +1

    Респект автору за энтузиазм, интересный проект, мне он напомнил программируемый калькулятор на ATtiny85, там похожим образом сделан интерпретатор байт-кода. Не было ли желания приблизить свой язык программирования к AVR-овскому ассемблеру, чтобы максимальное количество простых инструкций транслировались сразу в машинный код (а сложные - в вызовы функций)?

    P.s. при всём уважении к 8-битным процессорам, сейчас рулят 32-битные, как справедливо заметили выше. За чуть большую цену можно собрать гораздо более производительный мини-компьютер на ESP или Blue pill...


    1. Ringenium
      09.11.2021 08:11

      До кризиса полупроводников цена на те же 103-тие была ниже ардуинки)

      Но тут же видимо интерес именно из меги выжать максимум, хотя если из меги то наверное лучше с ней и работать, а не с ардуиной)

      Короче, автору в любом случае респект, полезным делом занят.


  1. Viktordp
    02.11.2021 19:45
    +1

    А почему Вы применяете Arduino AtMega?

    Ведь сейчас полно Arduin 32 разрядных с 32к и более на борту.


    1. PapaKarlo787 Автор
      02.11.2021 19:46

      Интересно использовать минимальное количество ресурсов во всех смыслах


  1. unsignedchar
    03.11.2021 10:49
    +1

    мы замечаем функцию, которая вызывается перед циклом задержки — yield, я мало чего про нее нашел, но, как я понял, это макрос, в который мы оборачиваем код и как-то можем параллельно его исполнять с основным кодом, если кто знает точно — поделитесь.


    Можно считать, что это такая очень lite реализация кооперативной многозадачности. Внутри yield вызывается код из ядра arduino. Поддержка сети (если есть) например. Так то поток выполнения один (функция loop), и в нем по очереди выполняется то код, написанный вами, то служебный ардуиновский.


  1. napa3um
    03.11.2021 22:01

    Это не ассемблер, это сисколы операционной системы, больше похоже на бэйсик, как раз в том же ZX была подобная организация, там даже арифметика вся была в системных вызовах (просто переход по адресу), ввиду отсутствия в процессоре умножения/деления/плавучки/тригонометрии. Ну а эмуляция RAM флешкой... Ну прям самый ленивый рецепт ленивых голубцов, какой я видел, на совершенно рандомных ограничениях - сюда только 3Д-очки прикрутить для максимального дисбаланса аппаратной конфигурации, ейбогу :3.


    1. PapaKarlo787 Автор
      04.11.2021 16:24

      На счет ассемблера, тогда в x86 тоже нет ассемблера :) А если серьезно, то это называется микрокод - одна инструкция состоит из нескольких машинных инструкций, а не сисколы. Про x86 упомянул потому что сама по себе это CISC архитектура, но на самом деле у нее RISC ядро и микрокод для обратной совместимости. На счет флешки, что бы вы предложили? Пользовательские программы где то нужно хранить, самое оптимальное как по мне это как раз на флешке. Если добавим еще какие-нибудь устройства для временного хранения пользовательских программ, то:
      1) больше памяти потратим на управление - ее не так уж и много изначально

      2) больше пинов займем, их тоже не особо в избытке

      Если у вас есть мысли по решению этих проблем, то я был бы рад, если бы вы ими поделились :)


      1. unsignedchar
        05.11.2021 10:08
        +1

        Подключайте по SPI, 4 ноги всего. Кстати, обычную microsd карту можно использовать. И SPI RAM бывает.


      1. napa3um
        05.11.2021 19:09

        Микрокод процессора и системные процедуры в RAM/ROM вообще не одно и то же. И ассемблер перпендикулярное им явление. И решать искусственные проблемы можно самым изощрённым способом, ваш тоже рабочий, очевидно. Моя несколько негативная оценка этого решения была чисто эстетической, никаких объективных критериев тут нет :3


  1. Krionix
    15.11.2021 13:40
    +1

    Уважение автору за подобный проект! Просто поразительно, что среди современных студентов еще встречаются подобные энтузиасты. Оптимальные технические решения обязательно придут в процессе работы, главное - не забрасывать.

    Флеш, определенно, будет слабым местом, интенсивные циклы перезаписи убьют память достаточно быстро, в будущем лучше изменить архитектуру.