Симулятор микропроцессоров: запускаем BASIC и не только

—

от автора

Привет, Хабр!

Это уже третья часть моего цикла статей о проекте симулятора микропроцессоров на C++. В первой части я рассказывал о том, с чего всё началось и как в самом базовом представлении устроен проект на примере MOS6502. Там мы рассмотрели ассемблер этого микропроцессора, доступные в нем режимы адресации и общий подход к разработке. Во второй части я рассказывал о дальнейшем развитии проекта, о процессорах Intel 8080 и Intel 8086, о сегментах памяти, а также про то, как эволюционировал проект, с какими трудностями я сталкивался в процессе его развития и как решал возникающие проблемы.

Со времени написания последней статьи я продолжал работу над I8086 и практически закончил его реализацию, но было бы странно приходить с материалом типа “я добавил поддержку еще одного процессора”. Я решил основательно подготовиться к этой публикации.

В комментариях к первой статье пользователь @SIISII написал следующее:

Ну, сделали выполнение команд, и что дальше? Даже не поиграешь в какую-нибудь древнюю игрушку для Эпла-2, Денди или нашего Агата, которые как раз на 6502 были 🙂

Я также упоминал этот комментарий в предыдущей статье и тогда в качестве извинений расписал принцип работы системы ввода-вывода. Тем не менее никакого рабочего примера я так и не показал, и мне очень долго не давал покоя этот недочет. Действительно, зачем нужен проект симулятора, который ничего, кроме инструкций, симулировать не в состоянии? Более того, с самого начала работы над проектом у меня была конкретная цель — запустить интерпретатор языка Basic, а воз и ныне там. Я решил, что так дело не пойдет, и всерьез взялся за эту задачку.

Сначала немного о терминологии

Я часто использую термин “симулятор”, но это слишком общее понятие, которое к тому же не дает никакой конкретики. Прежде чем продолжить, давайте определимся с терминологией.

Если давать проекту более строгое определение, то правильнее называть его timing-accurate instruction set simulator:

  • Instruction set simulator значит, что симулятор исполняет команды и воспроизводит эффекты их исполнения: изменяет значения регистров, флаги статуса, содержимое памяти и состояние портов ввода-вывода. Внутренние детали реализации процессора опускаются: ни устройство АЛУ, ни сдвиговые регистры и триггеры, ни тем более конвейеры и кэши не моделируются.

  • Timing-accurate значит, что симулятор воспроизводит задержки настоящего процессора — он знает, сколько тактов занимает каждая инструкция, но сами такты не моделирует. В этом его отличие от cycle-accurate (потактовых) симуляторов, которые воспроизводят состояние процессора на каждом такте: каждую внутреннюю передачу данных, каждую операцию на шине. Потактовый симулятор максимально точен, но за эту точность приходится платить скоростью исполнения и сложностью реализации. Timing-accurate симулятор заметно быстрее и проще, и его точности мне вполне достаточно.

Что это даёт на практике? Как уже было сказано выше, симулятор исполняет инструкции на уровне их наблюдаемых эффектов. Что же касается временны́х характеристик, то после выполнения каждой инструкции внедряется искусственная задержка, которая компенсирует слишком высокую скорость исполнения инструкции на современном железе. Для этого определяется, сколько виртуальных тактов заняло исполнение инструкции, вычисляется, сколько времени это должно было занять на настоящем процессоре, затем полученное значение сравнивается с реальным временем исполнения и вычисляется величина необходимой задержки. Так средняя скорость выполнения примерно совпадает со скоростью симулируемого процессора:

// задаем длительность одного такта на частоте 5 МГцconstexpr double one_tick_duration = 1.0f / 5000000;...do {// обнуляем счетчик цикловcycles = 0;// засекаем время до исполнения шагаbegin = std::chrono::steady_clock::now();// счетчик циклов изменяется в процессе исполнения шага в зависимости от операцийdecodeSuccess = Step();// засекаем время после исполнения шагаend = std::chrono::steady_clock::now();// вычисляем реальную длительность шагаduration = std::chrono::duration_cast<std::chrono::microseconds>(end - begin).count();// вычисляем, сколько времени должен был длиться шаг// на частоте симулируемого микропроцессораsleep_needed = cycles * one_tick_duration * 1000000;// если время симуляции шага оказалось меньше ожидаемого, усыпляем// поток на время, равное разнице фактической и теоретической длительностейif (duration < sleep_needed) {std::chrono::microseconds real_duration(long(sleep_needed - duration));std::this_thread::sleep_for(real_duration);}// прибавляем количество циклов за этот шаг к общему счетчику цикловtotal_cycles += cycles;} while (decodeSuccess && !stop_requested);

Теперь можно вернуться к системе ввода-вывода.

Разбираем I/O

Задумка в реализации системы ввода-вывода простая:

template<typename BusWidth>class IO_Device {public:    virtual BYTE Read(BusWidth address) = 0;    virtual void Write(BusWidth address, BYTE value) = 0;};

Это интерфейс, в котором есть всего два метода: записать байт по адресу и прочитать байт по адресу.

Любое устройство, в том числе реализация RAM, построено на базе этого интерфейса:

template<typename BusWidth>class Memory : public IO_Device<BusWidth> {...BYTE Read(WORD address) override { ... }void Write(WORD address, BYTE value) override { ... }...}class Keyboard : public IO_Device<WORD> {...BYTE Read(WORD address) override { ... }void Write(WORD address, BYTE value) override { ... }...}class TTY : public IO_Device<WORD> {...BYTE Read(WORD address) override { ... }void Write(WORD address, BYTE value) override { ... }...}

Из второй части статьи мы знаем, что в проекте реализованы два вида I/O:

  • Memory-mapped I/O, при котором устройство занимает диапазон общего адресного пространства, и запись по этому адресу отправляет данные в устройство, а не в память (как это устроено в MOS6502).

  • Port-mapped I/O, при котором устройство подключается к выделенному порту ввода-вывода процессора и не мешает доступу к памяти (как это устроено в I8080).

На самом деле, это достаточно грубое объяснение, и в нём есть некоторые неточности, но в целом этого будет достаточно.

И в том и в другом случае мы используем класс Bus, который реализует примитивный interval_map: структуру данных, которая сопоставляет целый непрерывный диапазон ключей с одним конкретным значением. В нашем случае это сопоставление непрерывного диапазона адресов с одним конкретным устройством.

Пример memory-mapped I/O для MOS6502:

...Bus<WORD> bus; // шина адресовMemory<WORD> mem(64);Keyboard kbd;TTY tty;// память занимает адресное пространство 0x0000-0xFFFFbus.Attach(&mem, 0x0000, 0xFFFF);// клавиатура вырезает из общего адресного пространства диапазон KEYBOARD_DATA-KEYBOARD_STATUSbus.Attach(&kbd, Keyboard::KEYBOARD_DATA, Keyboard::KEYBOARD_STATUS);// терминал вырезает из общего адресного пространства диапазон TTY_OUTPUT-TTY_OUTPUT (один адрес)bus.Attach(&tty, TTY::TTY_OUTPUT, TTY::TTY_OUTPUT);// назначаем шину адресов процессораcpu.SetBusInstance(&bus);...

Пример port-mapped I/O для I8080:

...Bus<WORD> bus; // шина адресовBus<WORD> portBus; // шина портовMemory<WORD> mem(64);Keyboard kbd;TTY tty;// память занимает всё адресное пространство 0x0000-0xFFFFbus.Attach(&mem, 0x0000, 0xFFFF);  // клавиатура занимает диапазон портов KEYBOARD_DATA-KEYBOARD_STATUSportBus.Attach(&kbd, Keyboard::KEYBOARD_DATA, Keyboard::KEYBOARD_STATUS);  // терминал занимает диапазон портов TTY_OUTPUT-TTY_OUTPUT (один порт)portBus.Attach(&tty, TTY::TTY_OUTPUT, TTY::TTY_OUTPUT);// назначаем шину адресов процессораcpu.SetBusInstance(&bus);  // назначаем шину портов процессораcpu.SetPortBusInstance(&portBus);...

С чего начать?

Самый простой вариант интерактивной программы для MOS6502 — это Wozmon — монитор, написанный Стивом Возняком для Apple I. Под монитором здесь понимается программа, которая позволяет читать и менять содержимое памяти, а также запускать программы по адресу (если они были записаны в память).

Синтаксис у Wozmon простой:

  • <адрес> — вывести содержимое по адресу,

  • <начало>.<конец> — вывести содержимое по диапазону адресов,

  • <адрес>:<последовательность байт> — записать последовательность байтов в память, начиная с указанного адреса,

  • <адрес> R — запустить программу, которая начинается с указанного адреса.

Здесь важно понимать, что это уже не “голый” микропроцессор, а полноценная компьютерная система с периферийными устройствами. В Apple I в качестве таких устройств были клавиатура и порт для подключения телевизора, а значит, нам необходимо добавить в проект два новых модуля: эмулятор клавиатуры и эмулятор терминала.

Я уже упоминал эту программу во второй части и тогда я написал следующее:

Не с первого раза, но мне удалось заставить программу работать. Почему эксперимент был успешным лишь частично? Потому что где-то, видимо в самой программе (точнее в том варианте, что я нашел), была ошибка, и при указании любого интервала, даже одиночного адреса, программа выдавала на экран всю память, начиная с указанного диапазона и до конца (0xFFFF).

Вообще-то, я ошибся, но прежде чем объяснить суть моей ошибки (а она была очень забавная), я расскажу, что и как именно я запускал.

Вариантов программы Wozmon в интернете достаточно, я выбрал эту. Но это текст программы, а не готовый исполняемый файл, поэтому для начала нужно перевести код в набор машинных инструкций, пригодных для запуска. Инструментов для этого много: есть и онлайн трансляторы, например mass::werk, и полноценные тулчейны вроде cc65.

Для работы с Wozmon я выбрал vasm. Это современный инструмент, который активно разрабатывается и поддерживает много разных платформ. Он позволяет использовать разные синтаксисы, а также поддерживает много форматов вывода готового файла.

vasm6502_oldstyle -Fbin -dotdir wozmon.asm -o wozmon.binvasm 2.0a (c) in 2002-2024 Volker Barthelmannvasm 6502 cpu backend 1.0a (c) 2002,2006,2008-2012,2014-2024 Frank Willevasm oldstyle syntax module 0.20a (c) 2002-2024 Frank Willevasm binary output module 2.3c (c) 2002-2024 Volker Barthelmann and Frank Willeorg0001:ff00(acrwx1):            256 bytes

Для запуска нужно лишь записать полученные 256 байт в конец памяти, начиная с адреса FF00, так как это стартовый адрес для Wozmon:

const std::filesystem::path filePath = projectRoot / "wozmon.bin";cpu.LoadROM(filePath.c_str(), mem, 0xFF00);

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

В коде Wozmon для этого используются три адреса:

  • 0xD012 — для вывода в терминал (write-only),

  • 0xD010 — для получения символа с клавиатуры (read-only),

  • 0xD011 — для получения статуса клавиатуры (read-only).

Класс терминала наследуется от ранее упомянутого интерфейса IO_Device и реализует метод Write, который выводит полученный символ на экран, используя стандартную функцию putchar. Здесь также нужно учитывать некоторые особенности трактовки символов в программе: вместо \r нужно вывести \n, а вместо 0x5F нужно сдвинуть каретку влево и стереть символ под ней:

class TTY : public IO_Device<WORD> {  public:      static constexpr WORD TTY_OUTPUT = 0xD012;        TTY() = default;        BYTE Read(WORD address) override {          return 0x00;      }        void Write(WORD address, BYTE value) override {          char c = static_cast<char>(value & 0x7F);          if (c == '\r') {              putchar('\n');          } else if (c == 0x5F) {        // '_' — Wozmon's backspace echo              putchar('\b');             // move cursor left              putchar(' ');              // overwrite with space              putchar('\b');             // move cursor left again          } else if (c >= 0x20 && c < 0x7F) {              putchar(c);          }          fflush(stdout);      }  };

Эмулятор экрана работает синхронно. Как только в коде вызывается инструкция STA DSP (записать значение регистра A в адрес DSP, он же DISPLAY), происходит вызов Bus->Write(address, value), который синхронно вызывает TTY->Write(address, value), и мы видим символ на экране.

С клавиатурой дела обстоят чуть сложнее, ведь она, в отличие от терминала, работает асинхронно. В Wozmon система обработки ввода основана не на прерываниях, а на поллинге (регулярном опросе) состояния входа клавиатуры. Символы считываются по одному, а готовность проверяется чтением порта состояния. После нескольких итераций я получил такую реализацию клавиатуры:

class Keyboard : public IO_Device<WORD> {  public:      static constexpr WORD KEYBOARD_DATA = 0xD010;      static constexpr WORD KEYBOARD_STATUS = 0xD011;        Keyboard() = default;        void set_input(const char newInput) {          std::lock_guard lock(input_mtx);          input = newInput;          input_ready = true;      }        BYTE Read(WORD address) override {          switch (address) {              case KEYBOARD_STATUS: {                  std::lock_guard lock(input_mtx);                // значение 0x80 используется как маска готовности символа                return input_ready ? 0x80 : 0x00;            }              case KEYBOARD_DATA: {                  std::lock_guard lock(input_mtx);                  input_ready = false;                  // значение 0x80 используется как маска готовности символа                return input | 0x80;              }              default:                  return 0x00;          }      }        void Write(WORD address, BYTE value) override {}    private:      char input = '\0';      bool input_ready = false;      std::mutex input_mtx;  };

Снаружи, в main.cpp, обработчик ввода запускается в отдельном потоке, чтобы не блокировать работу всей программы:

void keyboard_thread(Keyboard *kbd) {      while (g_running) {          if (char input = get_single_key()) {              if (input == 'q') {                  g_running = false;                  break;              }              kbd->set_input(input);          } else {              g_running = false;              break;          }      }  }...Bus<WORD> bus;  Keyboard kbd;...std::thread kb_thread(keyboard_thread, &kbd);...

Функция get_single_key — это обёртка для ввода, которая отключает echo в терминале, а также производит маппинг клавиш:

char get_single_key() {  #ifdef _WIN32      return _getch();  #else      char ch = 0;      struct termios old_opts, new_opts;      tcgetattr(STDIN_FILENO, &old_opts);      new_opts = old_opts;      new_opts.c_lflag &= ~(ICANON | ECHO);      tcsetattr(STDIN_FILENO, TCSANOW, &new_opts);      read(STDIN_FILENO, &ch, 1);      tcsetattr(STDIN_FILENO, TCSANOW, &old_opts);      if (ch == 127)          // real backspace          ch = (char) 0xDF;   // backspace for Wozmon      else if (ch == 10)      // real enter          ch = (char) 0x8D;   // enter for Wozmon      return ch;  #endif  }

Если вдруг вы знаете более простой способ отключить echo при вводе в терминале без использования termios — напишите в комментариях.

Ну вот, собственно, и всё. Можно приступать к запуску! Хотя подождите, в программе же была ошибка… Или нет?

Об “ошибке” в программе

Как я уже сказал, ранее я сослался на некую ошибку в коде, из-за которой программа работала некорректно. Работать она должна вот так:

8000.80FF                           <-- вывести диапазон 0x8000 -> 0x80FF8000: 00 00 00 00 00 00 00 008008: 00 00 00 00 00 00 00 008010: 00 00 00 00 00 00 00 00...80F0: 00 00 00 00 00 00 00 0080F8: 00 00 00 00 00 00 00 00

А работало так:

8000.80FF                           <-- вывести диапазон 0x8000 -> 0x80FF8000: 00 00 00 00 00 00 00 008008: 00 00 00 00 00 00 00 008010: 00 00 00 00 00 00 00 00...FFF0: 12 D0 30 FB 8D 12 D0 60FFF8: 00 00 00 0F 00 FF 00 00       <-- получаем вывод до конца всего адресного пространства

Долгое время я не мог понять, в чем дело, и списал такое поведение на неполадку в коде Wozmon, однако проблема оказалась на моей стороне, и заключалась она в банальной ошибке в реализации инструкции SBC (Subtract with Carry). Я некорректно определял значение флага Carry после выполнения операции вычитания, а в коде Wozmon именно этот флаг отвечал за выход из цикла чтения адресов.

Хотя мой код и покрыт тестами, их разработка — дело очень тонкое. Тесты для SBC всегда были зеленые, но, видимо, я подобрал такие тестовые сценарии или вовсе выстраивал логику теста на основе того, какие значения должны получиться. TDD это сложно, особенно когда у тебя не так много реальных примеров.

Первый запуск

Теперь, когда корень проблемы устранён, можно запустить Wozmon и немного поиграться с ним, хотя возможностей у него, честно говоря, не особо много.

Первое и основное его назначение — просмотр памяти, поэтому попросим Wozmon вывести самого себя:

\FF00.FFFFFF00: D8 58 A0 7F 8C 12 D0 A9FF08: A7 8D 11 D0 8D 13 D0 C9FF10: DF F0 13 C9 9B F0 03 C8FF18: 10 0F A9 DC 20 EF FF A9FF20: 8D 20 EF FF A0 01 88 30FF28: F6 AD 11 D0 10 FB AD 10FF30: D0 99 00 02 20 EF FF C9FF38: 8D D0 D4 A0 FF A9 00 AAFF40: 0A 85 2B C8 B9 00 02 C9FF48: 8D F0 D4 C9 AE 90 F4 F0FF50: F0 C9 BA F0 EB C9 D2 F0FF58: 3B 86 28 86 29 84 2A B9FF60: 00 02 49 B0 C9 0A 90 06FF68: 69 88 C9 FA 90 11 0A 0AFF70: 0A 0A A2 04 0A 26 28 26FF78: 29 CA D0 F8 C8 D0 E0 C4FF80: 2A F0 97 24 2B 50 10 A5FF88: 28 81 26 E6 26 D0 B5 E6FF90: 27 4C 44 FF 6C 24 00 30FF98: 2B A2 02 B5 27 95 25 95FFA0: 23 CA D0 F7 D0 14 A9 8DFFA8: 20 EF FF A5 25 20 DC FFFFB0: A5 24 20 DC FF A9 BA 20FFB8: EF FF A9 A0 20 EF FF A1FFC0: 24 20 DC FF 86 2B A5 24FFC8: C5 28 A5 25 E5 29 B0 C1FFD0: E6 24 D0 02 E6 25 A5 24FFD8: 29 07 10 C8 48 4A 4A 4AFFE0: 4A 20 E5 FF 68 29 0F 09FFE8: B0 C9 BA 90 02 69 06 2CFFF0: 12 D0 30 FB 8D 12 D0 60FFF8: 00 00 00 0F 00 FF 00 00

Также мы можем записать что-нибудь в память:

\8000.8007                      <-- запрашиваем диапазон 8000-80078000: FF FF FF FF FF FF FF FF  --> получаем значения памяти в данном диапазоне8000:10 20 30 40 50 60 70 80   <-- записываем последовательность данных, начиная с адреса 80008000: FF                       --> echo значение начального адреса8000.8007                      <-- снова запрашиваем диапазон 8000-80078000: 10 20 30 40 50 60 70 80  --> видим обновленные данные

А еще мы можем вручную записать туда программу и запустить ее. Например, запишем следующее:

LDA #$42   ; записать 42 в регистр АSTA $00    ; сохранить значение регистра А в ячейке памяти с адресом 00JMP $FF00  ; вернуться к Wozmon           ; здесь JMP, а не RTS, потому что Wozmon не сохраняет адрес вызова на стеке

Сделать это можно таким образом:

\0000                         <-- проверяем ячейку с адресом 00000000: FF8000: A9 42 85 00 4C 00 FF   <-- кодировка кода в шестнадцатиричном формате8000: FF8000 R                       <-- команда "запусти программу по адресу 8000"8000: A9\                    --> слэш — эхо-символ готовности Wozmon, значит он перезапустился0000                         <-- снова проверяем ячейку с адресом 00000000: 42                     --> видим обновленное значение

Повышаем ставки

Как только я получил работающий вариант программы Wozmon, передо мной открылось большое поле для экспериментов.

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

Например, есть Microsoft BASIC for 6502 Microprocessor, но сначала я совсем не понял, что это за синтаксис, и ни vasm, ни cc65 его обработать не смогли. Позже я выяснил, что это был MACRO-10 ассемблер, который работал под PDP-10, поэтому получить исполняемый файл из этого кода я никак не мог.

Позже я нашел адаптированную версию Microsoft BASIC, но проще от этого не стало, так как Microsoft 6502 BASIC был предназначен для целых компьютерных систем, а не для голого микропроцессора, как в моем случае. Например, если собирать эту версию BASIC под систему Apple II, то предполагается наличие Apple II Monitor, который, в общем-то, похож на Wozmon, но всё же более продвинутый, а значит, нужно искать код для него. Короче, я решил поискать что-то более доступное.

В конечном счёте свой выбор я остановил на EhBASIC2.2, и это не старая разработка, а вполне современный интерпретатор, насколько это возможно сказать для программы, которая последний раз обновлялась аж в 2005 году. Написан он, к слову, тоже для симулятора, но и запуск на реальном микропроцессоре вроде как тоже поддерживается.

В этот раз вместо vasm я решил использовать cc65, и здесь также не обошлось без некоторых доработок. Важным отличием cc65 от vasm является то, что это тулчейн, а поэтому состоит не из одиночного транслятора, а из цепочки инструментов, среди которых есть линкер. Линкеру для корректной работы необходима “карта” памяти — описание секций и сегментов кода.

Для тех, кто знаком с объектными файлами — это те же секции .rodata, .text, .bss и остальные.

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

Для начала опишем эту самую карту памяти:

# ehbasic.cfgMEMORY {      ZP:       start = $0000, size = $0100, type = rw, fill = yes;      RAM:      start = $0100, size = $BF00, type = rw, fill = yes;    BASIC:    start = $C000, size = $3F80, type = ro, fill = yes;    MONITOR:  start = $FF80, size = $007A, type = ro, fill = yes;    VECT:     start = $FFFA, size = $06, type = ro, fill = yes;}    SEGMENTS {      ZEROPAGE: load = ZP, type = zp;      CODE:     load = BASIC, type = ro;    MONITOR:  load = MONITOR, type = ro;    VECTORS:  load = VECT, type = ro;}

Здесь имеется два блока:

  • секции памяти (MEMORY), т.е. именованные отрезки адресного пространства, и их свойства: адрес начала, размер, доступ и заполнение (заливка на весь размер секции),

  • сегменты памяти (SEGMENTS), т.е. именованные группы данных/кода, привязанные к секциям.

Более подробно про сегменты и секции можно почитать в официальном руководстве

После формирования конфигурации памяти вызовем пару утилит из комплекта тулчейна:

# вызываем ассемблер ca65 для получения объектного файла из исходниковca65 --feature labels_without_colons -I $CC65DIR/include -o ehbasic.o min_mon.asm# вызываем линкер ld65 для получения бинарного файла из объектного файла и конфигурации памятиld65 -C ehbasic.cfg -o ehbasic.bin ehbasic.o

Реализация клавиатуры и монитора здесь точно такая же, как и в Wozmon, с отличием лишь в маппинге служебных символов (Escape, Enter и Backspace), ну и обработку ввода q я, конечно же, убрал, потому что в BASIC символ ‘q’ вполне валидный. Меня в целом устраивает то, как выглядит реализация класса Keyboard, но пока приходится таскать с собой get_single_key:

char get_single_key() {  ...    if (ch == 127)          // real backspace          ch = (char) 0x08;   // backspace for EhBASIC      else if (ch == 27)      // real esc          ch = (char) 0x03;   // ctrl-c for EhBASIC      else if (ch == 10)      // real enter          ch = (char) 0x8D;   // enter for EhBASIC      return ch;}    class Keyboard : public IO_Device<WORD> {  public:      static constexpr WORD KEYBOARD_DATA = 0xD010;    // класс тот же, убрали порт статуса      ...      FORCE_INLINE BYTE Read(WORD address) override {          if (address == KEYBOARD_DATA) {              std::lock_guard lock(input_mtx);              // в EhBASIC уже нет KEYBOARD_STATUS            // состояние нажатия клавиши накладывается маской 0x80            if (input_ready) {                  input_ready = false;                  return input | 0x80;              }                return 0x00;        }          return 0x00;      }        ...};

В реализации терминала мне и вовсе пришлось лишь добавить обработку сдвига каретки, так как все остальные символы выводились корректно и без дополнительных правок:

class TTY : public IO_Device<WORD> {  public:      static constexpr WORD TTY_OUTPUT = 0xD012;        ...      FORCE_INLINE void Write(WORD address, BYTE value) override {          char c = static_cast<char>(value & 0x7F);          if (c == 0x08) // EhBASIC backspace echo          {              putchar('\b');             // move cursor left              putchar(' ');              // overwrite with space          }          putchar(c);          fflush(stdout);      }  };

После всех операций наконец можно запустить этот, пусть и не винтажный, но настоящий BASIC:

./6502-eh-basic6502 EhBASIC [C]old/[W]arm ?              <-- нажимаем C, он же Cold BootMemory size ? 9000                        <-- задаем доступный интерпретатору объем памяти7975 Bytes free                           --> получаем, сколько осталось под наши нуждыEnhanced BASIC 2.22Ready                                     --> видим приветственное сообщение

Ну и напишем пару программ:

Ready10 INPUT "HELLO! WHAT'S YOUR NAME";NAME$20 PRINT "HELLO,";NAME$RUNHELLO! WHAT'S YOUR NAME? DIMAHELLO,DIMAReady10 INPUT "ENTER YOU NUMBER";NUM20 TWICED = NUM * 230 SQUARED = NUM * NUM40 PRINT "YOUR NUMBER:";NUM50 PRINT "TWICED NUMBER:";TWICED60 PRINT "SQUARED NUMBER:";SQUAREDRUNENTER YOU NUMBER? 25YOUR NUMBER: 25TWICED NUMBER: 50SQUARED NUMBER: 625

Я провел несколько приятных вечеров, играясь с EhBASIC, и решил двигаться дальше. Конечно, остались нерешенные вопросы, например, я так и не понял, как именно должен работать Warm Boot или как можно организовать сохранение и загрузку программ, ведь EhBASIC поддерживает эту возможность. Так или иначе, для себя я закрыл эту главу. Пришло время бросить себе новый вызов.

Может, хватит про 6502?

Ну действительно, сколько можно? Проектов с симулятором именно этого микропроцессора полно, и не хочется быть “еще одним”, тем более что у меня есть I8080, поэтому я решил посмотреть, что же в интернете есть для него.

Статья на Википедии в первом же абзаце раздела Commercial Impact содержит упоминание операционной системы CP/M. Звучит как достойная задачка!

CP/M — это одна из первых коммерческих операционных систем, построенных на основе монолитного ядра. Она поддерживала множество популярных микропроцессоров того времени: Intel 8080, Intel 8085, Zilog Z80, Zilog Z8000, Intel 8086 и Motorola 68000.

Поиск исходников CP/M не составил труда. Буквально вторая ссылка в поисковике ведет на неофициальный архив, в котором есть не только разные версии ОС, но и множество утилит из стандартной поставки, например, ED (текстовый редактор), PIP (утилита для копирования файлов) или STAT (утилита для вывода статистики по свободному месту). Спустя некоторое время я нашел настоящее сокровище — репозиторий cpm_compilers, в котором можно найти инструменты для разработки на любой вкус: Forth, Basic, Fortran, PASCAL и даже несколько компиляторов языка Си. Но прежде чем копаться в этом богатстве, мне нужно было запустить саму ОС, а это не так уж просто.

ОС разделена на три части:

  • CCP (Console Command Processor) — подсистема обработки команд от пользователя,

  • BDOS (Basic Disk Operating System) — подсистема работы с файлами и дисками,

  • BIOS (Basic Input Output System) — подсистема работы с устройствами ввода/вывода.

Я взял ОС версии 2.2, а в архиве с ней имелся скелет BIOS, который можно было взять за основу. BIOS содержал заготовки служебных функций, в которых было зарезервировано место под реализацию. Мне оставалось лишь дописать код в пустые места:

conin:  ;console character into register A      in 0          ;keyboard status      ani    01h    ;character ready?      jz conin      ;loop until ready      in 1          ;keyboard data      ani    7fh    ;strip parity      ret;conout: ;console character output from register C      mov    a,c      out    2      ;TTY output      ret;

Я не буду вдаваться в подробности работы непосредственно с кодом, потому что всё это не сильно отличается от того, что мы уже видели в Wozmon и EhBASIC: такой же обработчик клавиатуры, такой же обработчик интерфейса вывода, и точно так же нужно учитывать особенные символы на ввод и на вывод. Что было для меня принципиально новым, так это то, что CP/M работает с полноценным приводом на четыре дискеты, и это значило, что мне нужно разработать еще одно I/O устройство.

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

Шуршим дискетами

Я взял за основу восьмидюймовую дискету. Она состоит из 77 треков по 26 секторов на трек, по 128 байтов на сектор, что в совокупности дает нам 256 кБ памяти, а в качестве реального хранилища под дискетой лежит обычный файл. Для простоты восприятия я не буду подробно расписывать ход разработки устройства, в статье и так уже слишком много кода. Пробежимся по ключевым моментам, а потом посмотрим, что из этого вышло.

В моем варианте исполнения (вероятно не самом полном) мы можем прочитать с устройства статус последней операции, и статус диска. Дисков, напомню, может быть четыре.

BYTE Read(WORD address) override {    std::lock_guard lock(disk_mtx);      BYTE status = 0xFF;      if (address == CMD_PORT) {        status = lastStatus;      } else if (address == DRIVE_SELECT_PORT) {          BYTE available = drives[selectedDrive].image.is_open() ? 0 : 1;        status = available;      } else {}    return status;  }

Для чтения самих данных используется механизм DMA (англ. Direct Memory Access — прямой доступ к памяти). Для этого операционная система записывает в устройство параметры, а затем привод сам пишет данные напрямую в выделенную ему область памяти. Для того чтобы лучше понимать то, что происходит внутри, я добавил логирование:

--> write DRIVE_SELECT_PORT drive=0               < выбираем диск<-- drive 0 availability: 0                       < проверяем доступность--> write TRACK_PORT 2                            < выбираем трек--> write SECTOR_PORT 1                           < выбираем сектор--> write DMA_LOW_PORT ef                         < выставляем младший байт адреса DMA--> write DMA_HIGH_PORT f3                        < выставляем старший байт адреса DMA--> write CMD_PORT address 1                      < записываем команду| CMD_READ drive=0 128 bytes with offset 6656:    < команда на чтение...                                               < DMA пишет данные из дискеты в память

С записью данных всё то же самое:

--> write TRACK_PORT 2                            < выбираем трек--> write SECTOR_PORT 4                           < выбираем сектор--> write DMA_LOW_PORT ef                         < выставляем младший байт адреса DMA--> write DMA_HIGH_PORT f3                        < выставляем старший байт адреса DMA--> write CMD_PORT address 2                      < записываем команду| CMD_WRITE drive=1 128 bytes with offset 7040:   < команда на запись...                                               < DMA читает данные из памяти в дискету

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

В виде схемы это выглядит так:

Дисковод, как и клавиатура и терминал, работает через port based I/O, соответственно, устройство имеет ряд портов, на которые производится запись:

void Write(WORD address, BYTE value) override {    std::lock_guard lock(disk_mtx);      switch (address) {          case TRACK_PORT:            currentTrack = value;              break;          case SECTOR_PORT:            currentSector = value;              break;          case DMA_LOW_PORT:            dmaAddress = (dmaAddress & 0xFF00) | value;              break;          case DMA_HIGH_PORT:            dmaAddress = (dmaAddress & 0x00FF) | (static_cast<WORD>(value) << 8);              break;          case DRIVE_SELECT_PORT:            if (value < MAX_DRIVES)                  selectedDrive = value;              break;          case CMD_PORT:            executeCommand(value);              break;      }  }

После того, как я таки реализовал код дисковода, ничего не заработало. Я вновь просмотрел выполненные инструкции и выяснил, что для запуска необходимо, чтобы на дискете тоже имелся код CP/M. Это нужно, чтобы система смогла совершить Warm Boot — перезагрузку без перезапуска всего оборудования. Пришлось дописать функционал, который создавал бы подготовленный “образ” дискеты:

00000000  e5 e5 e5 e5 e5 e5 e5 e5  e5 e5 e5 e5 e5 e5 e5 e5  |................|*00000080  c3 5c df c3 58 df 7f 00  43 6f 70 79 72 69 67 68  |.\..X...Copyrigh|00000090  74 20 31 39 37 39 20 28  63 29 20 62 79 20 44 69  |t 1979 (c) by Di|000000a0  67 69 74 61 6c 20 52 65  73 65 61 72 63 68 20 20  |gital Research  |000000b0  20 20 20 20 00 00 00 00  00 00 00 00 00 00 00 00  |    ............|000000c0  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|*00000100  00 00 00 00 00 00 00 00  08 dc 00 00 5f 0e 02 c3  |............_...|00000110  05 00 c5 cd 8c dc c1 c9  3e 0d cd 92 dc 3e 0a c3  |........>....>..|00000120  92 dc 3e 20 c3 92 dc c5  cd 98 dc e1 7e b7 c8 23  |..> ........~..#|00000130  e5 cd 8c dc e1 c3 ac dc  0e 0d c3 05 00 5f 0e 0e  |............._..|00000140  c3 05 00 cd 05 00 32 ee  e3 3c c9 0e 0f c3 c3 dc  |......2..<......|...

Но даже после этого почему-то ничего не работало. Я изучал логи, вчитывался в тысячи строк информации о выполненных инструкциях, но не понимал, что могло пойти не так. Ни одной зацепки.

Длинная лирическая пауза…

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

trans:  ;   sector translate vector  db 1,7,13,19  ;sectors 1,2,3,4      db 25,5,11,17 ;sectors 5,6,7,8      db 23,3,9,15  ;sectors 9,10,11,12      db 21,2,8,14  ;sectors 13,14,15,16      db 20,26,6,12 ;sectors 17,18,19,20      db 18,24,4,10 ;sectors 21,22,23,24      db 16,22      ;sectors 25,26  ;

Заменив эту таблицу на таблицу последовательных чисел, я снова попытался запустить CP/M:

a>

Итак, система запустилась. Не передать словами мой восторг, когда я увидел заветные символы на экране. Не описать, сколько еще мелких (и не очень) проблем мне пришлось преодолеть, прежде чем добиться исправного состояния. И всё же оно заработало!

По умолчанию нам доступен следующий набор команд:

  • DIR — вывести список файлов на активном диске,

  • ERA — удалить указанный файл,

  • REN — переименовать существующий файл на диске,

  • SAVE — сохранить указанное количество страниц транзитной памяти в файл,

  • TYPE — вывести текст ASCII файла,

  • USER — сменить активного пользователя.

Насколько я понял, save играет роль чего-то вроде core dump, т.к. транзитная память — это такая область памяти, куда загружается код программы для ее выполнения внутри операционной системы.

a> dirNo file

Что ж, теперь нам нужна какая-то более интерактивная среда для экспериментов, и для этого нужно записать на дискету парочку программ. Предстояло решить две задачи:

  • как записать программу на дискету,

  • куда именно записать программу, чтобы ОС ее увидела.

На CP/M-совместимой дискете имеется своя файловая система, и в ней есть область, которая играет роль каталога. Соответственно, нужно прописать в каталог название файла и информацию о нем. Для своих опытов я взял текстовый редактор ED1. После того, как я дописал возможность копирования файлов в образ дискеты, получилось следующее:

...00001a00  00 45 44 31 20 20 20 20  20 43 4f 4d 00 00 00 34  |.ED1     COM...4|00001a10  02 03 04 05 06 07 08 00  00 00 00 00 00 00 00 00  |................|...

Разметка здесь такая:

  1. Идентификатор пользователя, который создал файл.

  2. Имя файла (не более восьми символов).

  3. Расширение файла (не более трёх символов).

  4. Номер начального блока (экстента).

  5. Ноль (зарезервировано).

  6. Ноль (зарезервировано).

  7. Количество занимаемых блоков размером 128 байт.

  8. Массив указателей на блоки данных, из которых состоит файл.

Создание образа диска и копирование в него программ выглядит так:

./i8080-cpm --create-image image.img --inject ED1.COM

После запуска симулятора я мог ввести команду DIR и увидеть содержимое каталога:

a>dirA: ED1      COM

Теперь можно попробовать что-нибудь написать. Как оказалось, строчный редактор — это даже сложнее, чем vim. Если кому-то интересно, то ed есть под Linux и входит в стандартную поставку любого дистрибутива.

a>ed1 test.txtNEW FILE     : *i                          <-- команда начала ввода    1:  hello    2:  this is ed    3:  running in cp/m    4:                             <-- ESC — конец ввода     : *#b                         <-- возврат в начало файла    1: *#a                         <-- загрузить весь файл в буфер    1: *#t                         <-- вывести содержимое буфера    1:  hello    2:  this is ed    3:  running in cp/m    1: *e                          <-- сохранить и выйтиa>

Теперь выведем содержимое диска А:

a>dirA: ED1      COM : TEST     TXT

Содержимое файла, который играет роль дискеты, также обновилось:

...00001a00  00 45 44 31 20 20 20 20  20 43 4f 4d 00 00 00 34  |.ED1     COM...4|00001a10  02 03 04 05 06 07 08 00  00 00 00 00 00 00 00 00  |................|00001a20  00 54 45 53 54 20 20 20  20 54 58 54 00 00 00 01  |.TEST    TXT....|...00003e00  68 65 6c 6c 6f 0d 0a 74  68 69 73 20 69 73 20 65  |hello..this is e|00003e10  64 0d 0a 72 75 6e 6e 69  6e 67 20 69 6e 20 63 70  |d..running in cp|00003e20  2f 6d 0d 0a 1a 1a 1a 1a  1a 1a 1a 1a 1a 1a 1a 1a  |/m..............|...

После того, как была налажена стабильная работа ОС и на дискеты можно было записывать программы, я начал экспериментировать с различными языками программирования.

Пробуем софт

BDS C

Он же BD Software C Compiler. В минимальном жизнеспособном варианте состоит из следующих файлов:

  • CC.COM — первый проход компилятора, который преобразует C-код в промежуточное представление,

  • CC2.COM — второй проход компилятора, который преобразует промежуточное представление в переносимый объектный код в формате CRL (C Relocatable Library),

  • C.CCC — оверлей для разделяемого кода компилятора (используется CC и CC2),

  • CLINK.COM — линкер,

  • CLIB.COM — менеджер библиотек, необходимый для получения CRL файла,

  • DEFF.CRL — набор стандартной библиотеки,

  • DEFF2.CRL — дополнение к стандартной библиотеке.

После загрузки всех перечисленных файлов на дискету мы можем попробовать написать код, используя ED:

a>dirA: CC       COM : CC2      COM : CLIB     COM : CLINK    COMA: DEFF     CRL : DEFF2    CRL : C        CCC : ED1      COMa>ed1 main.cNEW FILE     : *i    1:  int main() {    2:      printf("hello from bdsc!");    3:      return 0;    4:  }    5:     : *ea>cc main.cBD Software C Compiler v1.60  (part I)  37K elbowroomBD Software C Compiler v1.60 (part II)  34K to sparea>dirA: CC       COM : CC2      COM : CLIB     COM : CLINK    COMA: DEFF     CRL : DEFF2    CRL : C        CCC : ED1      COMA: MAIN     C   : MAIN     BAK : MAIN     CRLa>clink main.crlBD Software C Linker   v1.60Last code address: 0E4EExternals start at 0E4F, occupy 0000 bytes, last byte at 0E4FTop of memory: E405Stack space: D5B7Writing output...  45K link space remaininga>dirA: CC       COM : CC2      COM : CLIB     COM : CLINK    COMA: DEFF     CRL : DEFF2    CRL : C        CCC : ED1      COMA: MAIN     C   : MAIN     BAK : MAIN     CRL : MAIN     COMa>mainhello from bdsc!

В целом выглядит не так уж и сложно, но это всё-таки не ANSI C и даже не K&R C, поэтому здесь даже тип float — это стороннее расширение в виде макросов. Надо изучать.

NEVADA BASIC

Менее популярный по сравнению с MICROSOFT BASIC, но и менее дорогой, он был разработан в начале 1980-х. Запишем NVBASIC версии 2.5 на дискету и попробуем написать пару строк:

a> nvbasicNVBASIC 2.5 (2) (19JUL84) Kaypro 2, 4 or 10Copyright (C) 1983 by Ellis Computing, Inc.Modified by Ian D. Kettleborough*****    All Rights Reserved    ***** 8 Digit Precision VersionFirst protected memory address (hex) is E405Delete matrix operations? (Y,N)n29433 bytes of free memoryReady10 print "HELLO"run 10HELLOReady10 for i = 1 to 520    print "Iteration number: "; i30 next irunIteration number:  1Iteration number:  2Iteration number:  3Iteration number:  4Iteration number:  5Readybyea>

Oxford PASCAL 2.1

Состоит из:

  • PAS.COM — PASCAL-компилятор (генерирует .OBJ-файлы),

  • LINK.COM — линкер для объединения нескольких .OBJ-файлов,

  • LOCATE.COM — преобразует .OBJ-файлы в исполняемый .COM-файл,

  • RUN.COM — запускает .OBJ-файл,

  • PASSYS.LIB — библиотека рантайма,

  • PASERROR.MSG — файл текстовых ошибок компилятора.

a>dirA: PAS      COM : PASSYS   LIB : PASERROR MSG : LOCATE   COMA: LINK     COM : PROGRAM  PAS : RUN      COMa>type program.pasprogram hello(output);beginwriteln('hello world');end.a>pas a:programOxford Pascal compiler - version 2.1       (c) Copyright 1985.HELLOprogram       0   000B0 error(s)Compilation complete.a>run programhello worlda>locate a:programa>programhello world

Supersoft ADA 1.20a

Ранняя версия языка ADA, реализующая ограниченный набор возможностей. Состоит из двух исполняемых файлов, которые играют роль двухпроходного компилятора, а также файла KAPSE (Kernel Ada Program Support Environment), который реализует рантайм.

Про ADA я совсем ничего не знаю, поэтому для эксперимента взял ни много ни мало целую игру STARTREK, которая была в комплекте поставки:

a>dirA: STARTREK ADA : ADA      COM : ADA2     COM : KAPSEa>ada startrekAda Compiler V1.20a S#00000000Copyright (c) 1982Supersoft Inc. and Maranatha Software Systems............................Ada Phase II............................0 syntax error(s)Ada 8080/8085 code generatorCompilation completea>startrekPlease wait while I create the universe(Even God took seven days...)26      unit hit from Klingon at 7,1 (157). . . . . . . .. . . . . . . .     Stardate=3421* . . . E . . .     Condition: Red. . . . . . . .     Quadrant=7,1. . . . . . . .     Sector=3,5. . . . . . . .     Energy=3974K . . . . . . .. . . . . . . .     Klingons left=129Command? 3Impulse (I) or Warp (W) power? iNew x co-ordinate: 2New y co-ordinate: 2. . . . . . . .. E . . . . . .     Stardate=3421* . . . . . . .     Condition: Red. . . . . . . .     Quadrant=7,1. . . . . . . .     Sector=2,2. . . . . . . .     Energy=3943K . . . . . . .. . . . . . . .     Klingons left=129

И в этот самый момент я понял, что цель моего долгого путешествия достигнута.

Заключение

Я работал над CP/M достаточно долго, и основная часть трудозатрат ушла именно на то, чтобы заставить работать саму операционную систему. Как только основной функционал и инфраструктура были проработаны, проблем уже почти не было. Мне нужно было лишь создать образ дискеты с нужными файлами и проверить, запускаются ли они. У этого есть и обратная сторона: если программа не запускалась, то у меня не было абсолютно никакой возможности узнать причину.

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

  • разработка эмулятора дисковода и поддержка нескольких дискет,

  • проработка механизма внедрения файлов в образ дискеты, а также работа с multi-extent файлами (программы размером более 16 кБ),

  • подход к отладке и логированию.

Также остался ряд нерешенных вопросов. Вот небольшой список known issues:

  • работают не все multi-extent приложения. Например, я не смог запустить Borland Pascal, Microsoft BASIC (не везет мне с ним) и компилятор Forth. В то же время другие multi-extent программы вроде Nevada BASIC и Oxford Pascal запускаются без проблем;

  • не работает текстовый редактор ED, хотя ED1 вполне работоспособен. Судя по информации в интернете, ED — это пропатченная версия редактора, в то время как ED1 — оригинал, так что в целом здесь всё корректно;

  • незначительная проблема состоит в том, что после вызова любой утилиты система возвращает управление с активным диском A, хотя это может быть и стандартным поведением — здесь я не уверен;

  • я до сих пор наверняка не знаю, нужно ли записывать CP/M на дискету, и если нужно, то нужно ли его изначально загружать в память, или загружать только код BIOS. Ответа на этот вопрос я так и не смог найти, поэтому сейчас я делаю двойную работу: изначально загружаю в память код ОС, который считывает сам себя из дискеты.

Что ж, это был действительно долгий путь. Я с большим интересом работал над CP/M и другими интерактивными программами, и это было гораздо увлекательнее, чем улучшать поддержку процессора и покрывать инструкции тестами. Запуск реальных, а не синтетических программ помог гораздо быстрее обнаружить недоработки и ошибки, допущенные в процессе разработки.

Я с немалым интересом провел несколько вечеров за CP/M и как будто бы даже почувствовал то, что чувствовали люди, впервые работающие с подобной машиной. Казалось бы, мой компьютер в тысячи и сотни тысяч раз производительнее, чем эта система, но в этом всё равно был дух чего-то неизвестного и непостижимого. Ты запускаешь ED, вбиваешь строка за строкой текст, сохраняешь файл, и у тебя есть возможность прочитать его. Это удивительно, и, кажется, поэтому существуют комьюнити поклонников ретро-компьютеров.

Я не знаю, что я буду делать дальше. Опробовав запуск целой операционной системы, я подумал, что можно было бы запустить что-то еще, например Unix System 6, тем более, что он поддерживался I8086, разработку которого я вот-вот закончу. Может быть, я решу добавить поддержку Z80 и собрать ZX Spectrum. А может быть, сверну в сторону инфраструктуры и займусь отладчиком и графическим интерфейсом. Я пока не определился, но одно могу сказать точно — первоначальная цель этого проекта достигнута. Я хотел запустить BASIC, и я это сделал!

Проект по-прежнему доступен по ссылке. Если вам есть что обсудить или предложить — обязательно пишите в Telegram или на почту.

ссылка на оригинал статьи https://habr.com/ru/articles/1088258/