
В 1994 году у меня появился первый компьютер: Intel i486 DX2-66 с 4 МБ ОЗУ и жёстким диском на 512 МБ. На нём были установлены IBM OS/2 и Microsoft Windows 3.11. Следующие четыре года я апгрейдил эту машину каждые несколько месяцев, добавляя больше ОЗУ (до 16 МБ), привод CD-ROM и карту SoundBlaster. Так я научился апгрейдить эту машину, устанавливать новое ПО, а потом и писать ПО на BASIC. Но я ни разу не касался процесса запуска и тонкостей MS-DOS.
В 2026 году, 32 года спустя, я узнал из скриншотов DDX3216, что в Behringer использовался настоящий процессор 386. В моём мозгу сразу же активировались какие-то нейроны, и я начал размышлять о том, можно ли запускать на этом устройстве ПО или даже полнофункциональную операционную систему. Для этого мне нужно было разобраться, как запускается система x86, когда управление перехватывает DOS и что необходимо для попадания в оболочку.
Технические подробности о Behringer DDX3216

В DDX3216 используются следующие аппаратные компоненты:
Основной процессор: SoC AMD Elan SC300 386 (386SX с интегрированными UART, PCMCIA, GPIO и так далее)
Интегральные схемы 27C512 64k x 8-bit ROM (для BIOS)
8 модулей HYB5117400BJ60 ОЗУ 4M x 4bit, суммарно 16 МБ DRAM
1 модуль SRAM UM61256 (в качестве VideoRAM)
4 модуля интегральных флэш-схем 29C040-120 для основного ПО
4-битный ЖК-модуль на внутреннем ЖК-интерфейсе SC300 (с тремя контроллерами столбцов Toshiba T6A39 и одним контроллером строк T6A40)
Внешний UART Toshiba TLC16C552 (2 последовательных порта и 1 параллельный)
Разъём PCMCIA для подключения внешней CF-карты (с адаптером)
Бескорпусной Intel 82078 FDC (Floppy Disk Controller), подключённый к свободному 34-контактному разъёму

То есть, в конечном итоге, оборудование в связке AMD Elan SC300 довольно хорошее и должно быть совместимо с обычной системой x86. Давайте подробнее изучим систему x86.
Первые шаги в разработке собственного ПО для голого x86
Для большинства компьютеров можно скачать из Интернета готовую BIOS. Я поискал BIOS для AMD ELAN SC и обнаружил многообещающее устройство в Швейцарии: компания PC Engines разработала программы BIOS для AMD ELAN SC400 и 520, а также для некоторых других SoC-устройств. Я связался с главным разработчиком и поначалу он обнадёжил меня, что у него есть исходный код для SC300. Но спустя пару дней выяснилось, что сохранились только исходники начиная с модели SC400 и выше. Потом я попытался связаться с компанией General Software, продававшей Embedded BIOS с поддержкой SC300. Однако основанная в 1989 году General Software в 2008 году была приобретена Phoenix, поэтому мне пришлось связаться с одним из ответственных лиц в Phoenix (Германия). Он попытался найти информацию о совместимом с SC300 пакете BIOS, но через пару недель вынужден был признать, что это уже невозможно: 32 года — долгий срок.
Я был вынужден закатать рукава и приступить к чтению документации системы x86 и ведению заметок о программировании собственного BIOS для SC300. Даже у самых современных x86-совместимых CPU наподобие Intel Core i9 или AMDs Threadripper есть совместимый с 8086 процесс запуска. Сразу после выполнения сброса CPU выполняет переход к концу пространства памяти на адрес 0xFFF0 и ожидает найти там исполняемый код x86 — так называемый вектор сброса (reset vector). От этого вектора сброса мы должны выполнить переход к нужному коду, который должен исполняться следующим; он находится где-то в ROM программы BIOS.
Вот моя первая попытка реализации валидного вектора сброса x86:
reset_vector: nop // no-operation cli // отключение прерываний jmp start // переход к началу текущего сегмента // Заполнение до конца и добавление даты .zero (0x10 - (. - reset_vector) - 8) .ascii "06/04/26" // MM/DD/YY
Этот код отключает аппаратные прерывания, а затем переходит к другому коду в функции start. Исполняя эту команду перехода, CPU выходит из состояния запуска и входит в так называемый «реальный режим» (real mode), то есть исходный 16-битный режим 8086. Код вектора сброса помещается скриптом компоновщика в позицию 0xFFF0 готового ROM. Как можно увидеть из списка выше, в DDX3216 установлен чип 64k x 8bit ROM, поэтому код и данные можно хранить где-то в интервале от 0x0000 и 0xFFFF, а вектор сброса должен быть размещён по адресу 0xFFF0 для совместимости со спецификациями x86. Вот код скрипта компоновщика, сообщающий GCC, куда помещать код в готовом двоичном файле:
OUTPUT_FORMAT("elf32-i386") OUTPUT_ARCH(i386) ENTRY(reset_vector) MEMORY { ROM (rx) : ORIGIN = 0x0000, LENGTH = 64K } SECTIONS { .text : { __text_start = .; KEEP(*(.text)) *(.text.*) . = ALIGN(2); __text_end = .; } > ROM .reset 0xFFF0 : { KEEP(*(.reset)) } > ROM }
Скомпилированный двоичный файл выглядит так:
0000ffa0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 0000ffb0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 0000ffc0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 0000ffd0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 0000ffe0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 0000fff0 90 fa e9 0b 00 00 00 00 30 36 2f 30 34 2f 32 36 .úé.....06/04/26
Здесь мы видим по адресу 0xFFF0 команду 0x90, то есть nop (No Operation), команду 0xFA, то есть cli, отключающую все прерывания, за которой следует 0xE9, то есть команда перехода. 0x0B — это ближний адрес для команды перехода. «Ближний» означает, что этот адрес находится внутри текущего сегмента. Сегменты — это особая структура в системе x86: так как у нас есть только 16 бит, мы можем адресовать только 65535 байт. Для адресации большего количества адресов x86 использует сегменты по 64 килобайт. Проблема заключается в том, что для обеспечения обратной совместимости указатели адресов сегментов накладываются друг на друга на 16 байт. Это позволяет адресовать 0xFFFF сегментов каждые 16 байт, получив таким образом адресное пространство от 0x00000 до 0xFFFF0, то есть 1 МБ. Физический адрес вычисляется по следующему уравнению:
Physical Address = (SEGMENT << 4) + OFFSET // максимальный физический адрес Physical Address = (0xFFFF << 4) + 0x000F = 0xFFFFF = 1048575 = 1 MB
Так я наконец-то понял, почему DOS и игры для неё имели проблемы с основной памятью, которая должна была находиться в диапазоне 1 МБ. На самом деле, ограничения были ещё более серьёзными: можно было использовать в качестве основной памяти только первые 640 КБ, более высокие адреса зарезервированы под видеопамять, ROM расширений и саму BIOS. Из-за этого обычная система x86 в реальном режиме имела такую структуру памяти:
+--------------------------------------------------+ 0x100000 (1 МБ) | | | SYSTEM BIOS | ROM 64 КБ | | +--------------------------------------------------+ 0xF0000 (960 КБ) | | | Option ROM или свободная верхняя память | 160 КБ | | +--------------------------------------------------+ 0xC8000 (800 КБ) | Video BIOS | ROM 32 КБ +--------------------------------------------------+ 0xC0000 (768 KB) | Video RAM | ОЗУ 128 КБ +==================================================+ 0xA0000 (640 КБ) | | | | | | | | | ОСНОВНАЯ ПАМЯТЬ (ОЗУ) | | Свободное место для DOS, | около 605 КБ | программ, драйверов и т.д. | | | | | | | +--------------------------------------------------+ 0x07E00 | Загрузочный сектор (загружаемый | | с загрузочного устройства) | 512 байт +--------------------------------------------------+ 0x07C00 | Свободная память DOS / ядро DOS | ~29 КБ +--------------------------------------------------+ 0x00500 | BDA (BIOS Data Area) | 256 байт +--------------------------------------------------+ 0x00400 | IVT (таблицы векторов прерываний) | 1 КБ +--------------------------------------------------+ 0x00000
То есть вместо 640 КБ у нас есть только 605 КБ свободной ОЗУ для нашего кода, потому что часть памяти занимают IVT, BDA и загрузочный сектор. От 0x0500 до 0x7C00 у нас есть немного места для ядра, а от 0xC8000 до 0xF0000 есть 160 КБ верхней памяти, которую можно использовать в случае отсутствия Option ROM.
Поначалу я не понимал, как тестировать мой скомпилированный код. Да, чаще всего применяют EEPROM-программатор для записи образа BIOS на интегральную схему EEPROM, но из-за этого цикл разработки будет медленным. Я поискал решение получше и нашёл проекты PicoROM и OneROM. В обоих проектах используются контроллеры RaspberryPi Pico, в которых достаточно памяти для эмуляции интегральной схемы ROM. Я заказал оба устройства из США и Великобритании. Сначала я загрузил оригинальный ROM DDX3216, чтобы проверить беспроблемный запуск консоли аудиомикширования.


Оригинальное ПО загрузилось, и я удостоверился в работоспособности эмулятора ROM. Чтобы проверить работу моего кода, я хотел запустить внешний UART. Как показано на изображении, у DDX3216 есть 9-контактный разъём RS232:

Проблема в том, что компания Behringer для этого разъёма использовала не внутренний UART SC300, а внешнюю интегральную схему Toshiba TLC16C552. Внешний UART подключён к адресным линиям с SA0 по SA2 (обозначены синим кругом) и с линиями данных с D0 по D7. TLC16C552 состоит из двух последовательных портов и одного параллельного порта, а каждую функцию можно включить при помощи одного из трёх сигналов выбора чипа от CS0# до CS2# (зелёный круг). Эти сигналы выбора чипа подключены к каким-то логическим интегральным схемам и соединены с SA3, SA4, SA12, SA13, SA14 и SA15:

Изучив спецификации подключённых адресных линий, можно понять, что CS0# активируется, когда используется адрес ввода-вывода с 0x1000 по 0x1007, CS1# активируется, когда используется адрес ввода-вывода с 0x1008 по 0x100F, CS#2 — когда используются адреса ввода-вывода с 0x1010 по 0x1017. Прежде, чем мы сможем задействовать 9-контактный разъём UART, нам нужно гарантировать включение логической интегральной схемы IC110 для передачи сигналов TxD. В качестве этой схемы используется 74HCT125; для включения её вывода необходимо активировать сигнал RS232#. RS232# соединён с SLIN# схемы TLC16C552, соединённым с параллельным выводом. То есть сначала нам нужно запрограммировать параллельный порт, активировать SLIN#, а затем инициализировать последовательный вывод для отладки:
; подача тактового сигнала на внешний UART out 0x0022, 0xBA ; задание адреса конфигурации out 0x0023, 0b00001000 ; задание данных конфигурации ; программирование внешнего UART через операции записи ввода-вывода ; сначала интерфейс параллельного порта по базовому адресу 0x1010 out 0x1012, 0x00001000 ; включение SLIN# = RS232# через параллельный порт ; теперь интерфейс последовательного порта по базовому адресу 0x1000 out 0x1003, 0x80 ; включение доступа к регистрам-защёлкам делителя out 0x1000, 0x5D ; установка делителя скорости передачи (LSB) out 0x1001, 0x00 ; установка делителя скорости передачи (MSB) out 0x1003, 0x03 ; сброс бита DLAB и задание режима 8N1 out 0x1001, 0x00 ; отключение всех прерываний out 0x1002, 0x00 ; отключение FIFO out 0x1004, 0x03 ; задание DTR и RTS
Этого должно быть достаточно для включения внешнего UART и передачи сигналов TxD через 9-контактный разъём на обратной стороне устройства. Из Programmers Reference Manual я узнал, что для переключения SoC в рабочее состояние самые важные регистры нужно устанавливать после сброса запуска, поэтому запрограммировал ещё примерно двадцать регистров конфигурации ELAN SC300, чтобы SoC работала на частоте 33 МГц.
Для вывода отладочного сообщения через терминал я отправлял символы на последовательный приёмопередатчик:
out 0x1000, 'A' ; вывод символа через RS232
Потратив пару часов на чтение руководства и спецификаций других интегральных устройств, я смог загрузить свой образ ROM и успешно получил на подключённом компьютере символы на скорости 9600 бод:


Запуск ЖК-дисплея и проблема с сегментами
Спустя какое-то время мне удалось инициализировать стек и перейти к моей основной функции на C. После этого было довольно просто инициализировать другие компоненты, например, дисплей, подключённый напрямую к 4-битному интерфейсу ЖК-дисплея AMD Elan SC300. Этот интерфейс реализует совместимую с CGA/HGA видеокарту. SC300 использует сегмент памяти 0xB800 для хранения символов при работе с текстовым режимом. В этом режиме SC300 ожидает по два байта на каждый отображаемый символ: первый байт — это отображаемый символ, а второй содержит байт атрибута. Этот байт атрибута содержит контрастность и цвет символа. Вроде здесь никаких проблем, но... мы не можем выполнять запись напрямую в сегмент 0xB800. BIOS работает в сегменте 0xF000. Вместе со смещением мы можем обеспечивать доступ ко всему 16-битному диапазону ROM, то есть ко всем 64 КБ. Но при попытке записи в сегмент 0xB800 доступ к памяти завершается сбоем.
Система x86 использует множество регистров для доступа к данным или коду. В реальном режиме есть CS (CodeSegment) и DS (DataSegment) для доступа к коду и переменным в определённом сегменте. Пока оба сегмента привязаны к 0xF000. Но есть и другие регистры, например, ES. Можно записать в него нужный целевой сегмент и скопировать данные. Чтобы реализовать это, я использую встроенный ассемблерный код: «значение» записывается в ES, «смещением» становится регистр BX, после чего «значение» записывается по нужному адресу:
static inline void writeFarByte(uint16_t segment, uint16_t offset, uint8_t value) { __asm__ __volatile__( "pushw %%es\n\t" "movw %w0, %%es\n\t" "movb %b2, %%es:(%%bx)\n\t" "popw %%es\n\t" : : "r"(segment), "b"(offset), "q"(value) : "memory" ); }
Мы можем и считывать переменные:
static inline uint8_t readFarByte(uint16_t segment, uint16_t offset) { uint8_t value; __asm__ __volatile__( "pushw %%es\n\t" "movw %w1, %%es\n\t" "movb %%es:(%%bx), %b0\n\t" "popw %%es\n\t" : "=q"(value) : "r"(segment), "b"(offset) : "memory" ); return value; }
Благодаря двум этим функциям с ассемблерным кодом у меня должно получиться записывать символы на дисплей. Но оказалось, что пока нет. SC300 имеет поддержку нескольких шрифтов ЖК-дисплея, но у него нет ROM со шрифтами. Поэтому мне пришлось побайтово реализовать полную таблицу ASCII, потому что SC300 требует символов размером 8×8 пикселей. Итого получается 8 байт на один символ. Суммарно мой готовый файл заголовка шрифтов занимает примерно 22 КБ, но, к счастью, с этой тупой задачей смог справиться ИИ. Google Gemini сгенерировал красивый шрифт для моего BIOS. На уровне отдельных символов пришлось устранить некоторые проблемы с пикселями, но в целом шрифт был вполне рабочим с самого начала. Вот пример частей шрифта:
static const uint8_t bios_font_8x8[256][8] = { // ... ['a'] = {0x00, 0x00, 0x78, 0x0C, 0x7C, 0xCC, 0x76, 0x00}, ['b'] = {0xE0, 0x60, 0x7C, 0x66, 0x66, 0x66, 0xDC, 0x00}, ['c'] = {0x00, 0x00, 0x78, 0xCC, 0xC0, 0xCC, 0x78, 0x00}, ['d'] = {0x1C, 0x0C, 0x7C, 0xCC, 0xCC, 0xCC, 0x76, 0x00}, ['e'] = {0x00, 0x00, 0x78, 0xCC, 0xFC, 0xC0, 0x78, 0x00}, ['f'] = {0x38, 0x6C, 0x60, 0xF0, 0x60, 0x60, 0xF0, 0x00}, ['g'] = {0x00, 0x00, 0x76, 0xCC, 0xCC, 0x7C, 0x0C, 0xF8}, ['h'] = {0xE0, 0x60, 0x6C, 0x76, 0x66, 0x66, 0xE6, 0x00}, ['i'] = {0x18, 0x00, 0x38, 0x18, 0x18, 0x18, 0x3C, 0x00}, ['j'] = {0x06, 0x00, 0x16, 0x06, 0x06, 0x06, 0x66, 0x3C}, ['k'] = {0xE0, 0x60, 0x6C, 0x78, 0x70, 0x78, 0x6C, 0xC6}, ['l'] = {0x38, 0x18, 0x18, 0x18, 0x18, 0x18, 0x3C, 0x00}, ['m'] = {0x00, 0x00, 0xEC, 0xFE, 0xD6, 0xD6, 0xC6, 0x00}, // .... }
Реализация полнофункционального BIOS x86 для SC300
Я потратил ещё немного времени на инициализацию других компонентов SC300, например, таблицы векторов прерываний (IVT), BIOS Data Area (BDA), внутреннего контроллера клавиатуры, таймера и интерфейса карты CF. Первые два компонента — это просто область в основной памяти. Взглянув на структуру памяти, вы сможете найти обе эти части внизу:
+--------------------------------------------------+ 0x07E00 | Загрузочный сектор (загружаемый | | с загрузочного устройства) | 512 байт +--------------------------------------------------+ 0x07C00 | Свободная память DOS / ядро DOS | ~29 КБ +--------------------------------------------------+ 0x00500 | BDA (BIOS Data Area) | 256 байт +--------------------------------------------------+ 0x00400 | IVT (таблицы векторов прерываний) | 1 КБ +--------------------------------------------------+ 0x00000
Область IVT считывается процессором x86, чтобы получить указатель соответствующей функции обработки прерывания при создании запроса прерывания оборудованием или ПО. То есть это просто список 16-битных указателей, за которым следует ещё одно 16-битное значение, содержащее адрес сегмента, на который указывает 16-битный указатель. Если вкратце, мы храним до 256 32-битных (примерно) адресов для каждого прерывания.
BDA можно сравнить с пространством конфигурации. Здесь BIOS хранит такие данные, как базовые адреса COM-портов, LPT-портов, общий размер основной памяти, положения курсоров, видеорежимы, такты таймеров, данные клавиатуры и так далее. DOS использует часть 16-битных значений для взаимодействия с BIOS наряду с функциями прерываний.
Из Programmers Reference Manual я узнал, что интегральная схема внутреннего таймера типа 8254 применяется в качестве эталонной интегральной схемы таймера x86. Интегральная схема таймера имеет тактовую частоту 1,1892 МГц, в отличие от более распространённого стандарта AT 1,19318 МГц. Для достижения стандартного прерывания по таймеру в 18,207 Гц мне пришлось сконфигурировать счётчик таймера на 0xFF23, получив в результате счётчик на 18,20715 Гц, что достаточно близко.
В SC300 используется стандартный контроллер клавиатуры XT, получающий 8-битные данные от внешней клавиатуры XT, поэтому мне пришлось создать адаптер, преобразующий данные моей клавиатуры AT/PS2 в XT.
Ещё одним сложным аспектом стал интерфейс карты CF, на реализацию которого мне понадобилось много времени. У SC300 есть два нативных разъёма для карт PCMCIA. То есть теоретически, к нему можно подключить две карты CF или другие карты PCMCIA, но со внешним разъёмом карты соединён только порт A. К счастью, на eBay всё ещё можно найти кучу разных адаптеров PCMCIA, поэтому я приобрёл PCMCIA-2-CF-Card-Adapter. Карты CF — это, по сути, накопители IDE со стандартным интерфейсом команд ATA, а PCMCIA напрямую соединяется с картой CF без дополнительной электроники. Всё это здорово, но размер моей карты CF больше 500 МБ. Из истории с сегментами я уже понял что в реальном режиме можно адресовать только до 1 МБ. Как же мне считывать данные с карты CF без этих ограничений? Оказалось, что у SC300 есть мощный блок распределения памяти, а то и два. В SC300 они называются MMS (MemoryMappingSystem) A и B.
MMSA способен распределять 8 страниц по 64 КБ по адресам от 0xD0000 до 0xEFFFF, а MMSB — только 4 страницы по 64 КБ от 0xA0000 до 0xAFFFF. На изображении ниже показано потенциальное распределение различных частей системы. DRAM, PCMCIA CardA или B, ROM BIOS или ROM DOS можно распределить в эти области по 64 КБ. ROM DOS означает одну из четырёх интегральных флэш-схем, перечисленных в первой таблице. Сюда помещается исходная прошивка, поэтому я пока не буду использовать эти интегральные схемы. А вот MMS в таком распределении можно использовать для проверки памяти модулей DRAM верхних 15 МБ.

Впрочем, здесь нужно признаться, что чтение и запись с карты CF можно выполнять без MMS, поскольку команды ATA поддерживают LBA-доступ или, как минимум, доступ с использованием CHS, позволяющий адресовать цилиндры, головки и секторы по 512 байт на сектор. То есть суммарно мы сможем получать доступ примерно к 8 ГБ:
C * H * S * 512 байт = x 1024 * 255 * 63 * 512 байт = 8422686720 байт = 8032,50 МБ
Однако проблема в том, что моя карта CF пока не запускается в режиме TrueIDE и доступ к ней нельзя получать через команды ATA. Это связано с аппаратной конфигурацией разъёма карты CF, где важный контакт изначально установлен в режим PCMCIA-памяти. То есть нам нужно инициализировать карту CF, при помощи MMS получить доступ к так называемой CIS (Card Information Structure), а затем переключить карту CF из режима PCMCIA-памяти в режим TrueIDE:
// Записываем в REGA# LOW для доступа к памяти атрибутов outb(REGA_BASE, 0x01); // Задаём COR, чтобы переключиться из режима распределения памяти в режим распределения ввода-вывода // Мы используем режим опроса (0x1F0...0x1F7 / 0x3F6...0x3F7) // b7=SoftReset, b6=Interrupt-Mode, b5=CardReset, b4..0=ConfigIndex writeFarByte(PCMCIA_BASE, 0x0200, 0b00000010); // Записываем в REGA# HIGH для доступа к общей памяти outb(REGA_BASE, 0x00); // Выполняем сброс карты CF через регистр IDE outb(IDE_DEV_CTRL, 0x06); // Программный сброс карты CF через регистр IDE for (volatile uint16_t i = 0; i < 10000; i++); outb(IDE_DEV_CTRL, 0x02); // SRST=0, nIEN=1 (IRQ остаётся отключенным) for (volatile uint16_t i = 0; i < 10000; i++); // Выбор привода 0 (master) outb(IDE_DRIVE_HEAD, 0xA0); for (volatile uint32_t i = 0; i < 10000; i++);
Теперь мы можем использовать обычные команды ATA для адресов ввода-вывода с 0x1F0 по 0x1F7 или с 0x3F6 по 0x3F7. Ниже приведён пример моей функции чтения всего сектора (512 байт) в ОЗУ. Для простоты я не использую CHS-адресацию, а преобразую CHS в LBA и вызываю эту функцию чтения. Да, это не самый быстрый способ, но мне просто хотелось получать удовольствие, а не максимально оптимизировать свой код:
uint8_t ide_read_sector(uint32_t lba, uint16_t dest_seg, uint16_t offset) { // Ожидаем готовности привода if (!ide_wait_ready()) { return 0xAA; // ERROR: Drive not ready } // Задаём адрес LBA и отправляем команду outb(IDE_SECT_COUNT, 1); // 0x1F2 outb(IDE_LBA_LOW, (uint8_t)( lba & 0xFF)); // 0x1F3 outb(IDE_LBA_MID, (uint8_t)((lba >> 8) & 0xFF)); // 0x1F4 outb(IDE_LBA_HIGH, (uint8_t)((lba >> 16) & 0xFF)); // 0x1F5 outb(IDE_DRIVE_HEAD, 0xE0 | ((lba >> 24) & 0x0F)); // 0x1F6 outb(IDE_COMMAND, IDE_CMD_READ); // 0x1F7 // Ждём готовности данных (DRQ установлен) if (!ide_wait_drq()) { return 0xBB; // ERROR: DRQ timeout } // Считанные 512 байт данных (один полный сектор) // доставляются по адресу ввода-вывода 0x1F0 (регистр IDE_DATA) for (uint16_t i = 0; i < 512; i++) { uint8_t data = inb(IDE_DATA); writeFarByte(dest_seg, offset + i, data); } return 0x00; // ошибок нет }
После создания этой функции у нас есть большинство функций для взаимодействия с основными компонентами. Наконец-то при одной из моих попыток запустить машину я увидел такой экран:

Для этого мне понадобилось примерно две с половиной недели, но не благодаря исключительно чтению технической документации системы x86 — частично мне помогал ИИ. Наверно, это подходящий момент для того, чтобы рассказать, как я пользуюсь ИИ: я никогда не использовал ИИ-агентов для управления своим кодом, мне кажется небезопасным терять контроль над ним. В своём университете я пользуюсь системой Google Gemini и ChatAI (чаще всего работаю с Claude Sonnet 4.6). ИИ не пишет и не модифицирует мой код, я задаю ему конкретные вопросы. Например, я загрузил в ChatAI Programmers Reference Manuel системы AMD Elan SC300 и попросил помочь с инициализацией моих конкретных чипов DRAM. Я сверил установленные регистры с руководством, но с 95% регистров было всё в порядке. Затем я реализовал этот проверенный код в своём проекте. Благодаря такой методике даже сложные задачи можно решить достаточно быстро.
Функции прерываний и попытка запуска MS-DOS 6.22
Моя конечная цель заключалась в запуске DOS, но прежде предстоял большой объём работы. DOS взаимодействует с BIOS крайне специфичным образом: частично DOS взаимодействует с BIOS Data Area, но чаще всего работа с BIOS выполняется при помощи прерываний. Вот список самых важных прерываний:
INT 0x08: прерывание таймера INT 0x09: прерывание внешней клавиатуры INT 0x0C: прерывание внешнего UART INT 0x10: прерывание видео INT 0x11: список оборудования INT 0x12: размер памяти INT 0x13: прерывание диска INT 0x14: прерывание UART INT 0x15: многофункциональное прерывание INT 0x16: прерывание клавиатуры INT 0x17: прерывание параллельного порта INT 0x19: прерывание запуска INT 0x1A: прерывание RTC
Если DOS хочет отобразить данные на ЖК-дисплее, то не вызывает конкретно функции ЖК-модуля, а просто записывает в регистр AX значение 0x0Ecc; cc содержит отображаемый символ. BIOS нужно взаимодействовать с интерфейсом ЖК-модуля, но DOS просто общается с прерыванием 0x10. Аналогично и для данных с диска: если DOS нужно считать определённый сектор с диска, она вызывает прерывание 0x13 с какими-то данными в регистрах AX, BX и так далее. Для чтения в AX должно быть записано 0x02ss, где ss — это количество считываемых секторов. Регистр BX содержит смещение с целевым сегментом, а регистр ES — целевой сегмент. CX и DX содержат конкретный цилиндр, головку и сектор диска. Мой BIOS преобразует этот CHS-адрес в 48-битный LBA-адрес и считывает конкретный сектор.
Для реализации некоторых из функций прерываний потребовалось довольно много времени, но в конечном итоге всё было готово к попытке запуска MS-DOS 6.22: я знал, что DOS довольно капризна, когда дело касается загрузочных секторов и данных в первом секторе, поэтому запустил свой старый компьютер 486 с загрузочным диском DOS 6.22. При помощи fdisk я разбил карту CF на разделы и выполнил format c:, чтобы отформатировать её в FAT16. Затем я перенёс программные файлы на карту CF. При этом в самое начало карты CF скопировался IO.SYS, а также MSDOS.SYS и COMMAND.COM.
Перед запуском DOS требуется выполнить ещё несколько операций:
Этап 1: BIOS загружает Master Boot Record (MBR) выбранного диска в ОЗУ по адресу 0x7C00 (суммарно 512 байт) Этап 2: MBR считывает таблицу разделов, выбирает активный раздел и загружает первый сектор в ОЗУ по адресу 0x7C00 В случае MS-DOS файл IO.SYS должен быть первым(!) файлом в корневой папке Этап 3: Первые три сектора IO.SYS загружает в ОЗУ и система переходит к IO.SYS Этап 4: IO.SYS через INT12h запрашивает основную память и загружает другие секторы в сегмент 0x0000:0x0500, а позже последний сегмент в конец основной памяти Этап 5: IO.SYS загружает MSDOS.SYS (60-80 секторов) Этап 6: MSDOS.SYS загружает COMMAND.COM и запускает оболочку
Мне нужно было скопировать загрузочный сектор (MBR) в сегмент 0x7C00, который предназначен для загрузочного сектора, а затем выполнить дальний переход к этому адресу:
void boot_dos() { uint8_t status = 0xFF; uint8_t retries = 3; // чтение загрузочного сектора while (retries-- > 0) { if (ide_read_bootsector() == 0x00) { status = 0; // успешно break; } // ошибка чтения -> повтор } if (status != 0) { // ERROR: read после трёх повторных попыток return; } // проверка сигнатуры загрузки в конце MBR uint16_t signature = readFarWord(BASE_SEG, 0x7C00 + 510); if (signature != 0xAA55) { // ERROR: нет валидного магического слова return; } // похоже, загрузочный сектор в порядке -> переход к загрузочному сектору launch_bootsector(); }
Функция launch_bootsector() написана на языке ассемблера; она просто сбрасывает сегментные регистры, задаёт начальный стек прямо под загрузочным сектором, выбирает загрузочный диск при помощи регистра DX и выполняет дальний переход к началу загрузочного сектора:
launch_bootsector: cli ; отключение прерываний ; сброс всех сегментов на 0x0000 xor ax, ax ; присвоение ax значения 0x0000 mov ds, ax mov es, ax ; задание указателя стека для инициализации DOS ; прямо под загрузочным сектором по адресу 0x0000:0x7C00 mov ss, ax ; присвоение сегменту стека значения 0x0000 mov sp, 0x7C00 ; запись загрузочного диска в DL mov dl, 0x80 sti ; включение прерываний ; дальний переход к загрузочному сектору jmp 0x0000:0x7C00
После компиляции и загрузки всего этого я подключил карту CF и запустил систему... но ничего не произошло. Приступив к отладке вызовов прерываний через интерфейс UART, я выяснил, что в некоторых моих функциях прерываний имелись проблемы с ошибочными кодами функций и ответами DOS. Я исправил эти прерывания, а потом запустился снова. ЖК-дисплей по-прежнему не отображал никакой информации DOS, но вызывалось гораздо больше INT 0x13. Впрочем, система внезапно снова вылетела.
Я вывел через интерфейс UART указатель стека и обнаружил, что стек опустился очень низко, хотя я запустил его прямо под 0x7C00. Причина этого заключается в том, что стек растёт сверху вниз, но когда указатель стека становится слишком низким, это означает наличие проблем в стеке. Очевидно, DOS или изменяла порядок стека, или использовала огромный объём стека (оказалось, что для получения больше свободной ОЗУ DOS перемещает стек в нижнюю часть памяти). Я потратил несколько часов на переупорядочивание своей модели памяти и выделил отдельный стек BIOS в начале основной памяти исключительно под вызовы прерываний BIOS. Это позволило решить проблему и запуск DOS продвинулся гораздо дальше. Как видно на следующем изображении, экран показывает сообщение «Starting MS-DOS…», за которым следует ещё несколько вызовов прерываний:

После текста «Starting MS-DOS…» вызывается прерывание 0x15, за которым следует INT 0x1A (таймер реального времени). Точки обозначают вызовы INT 0x13 (чтение с диска), но система зависает после последнего вызова INT 0x15 с AX = 0x4101, не обозначающего обычный вызов функции. То есть DOS, похоже, сдаётся после того, как пытается прочитать какие-то секторы. Я много дней потратил на решение этой проблемы, но в конечном итоге поднял руки вверх… Я был так близок к оболочке DOS. Судя по изученному исходному коду MS-DOS 4.0, файлы IO.SYS и MSDOS.SYS загружались успешно, поскольку уже вызывалось прерывание RTC. То есть система зависала где-то между MSDOS.SYS и COMMAND.COM. Даже после исследования опубликованного Microsoft исходного кода MS-DOS 4.0 мне так и не удалось найти виновника, поэтому я временно отказался от запуска MS-DOS 6.22.
Успешный запуск FreeDOS v1.4
MS-DOS 6.22 не запускалась, поэтому я скачал самую свежую версию FreeDOS, а именно 1.4. Затем я запустил QEMU и создал небольшое виртуальное окружение для установки FreeDOS:
rem создание нового виртуального образа для FreeDOS qemu-img create -f raw freedos.img 100M rem Запуск системы с FreeDOS LiveCD rem Виртуальный диск с 203 цилиндрами, 16 головками и 63 секторами qemu-system-i386 -machine isapc -cpu 486 -m 8 -device isa-vga,vgamem_mb=1 -rtc base=localtime -drive file=freedos.img,format=raw,if=none,id=d1 -device ide-hd,drive=d1,cyls=203,heads=16,secs=63 -cdrom FD14LIVE.iso -boot d rem Альтернатива: rem Запуск системы с загрузочным гибким диском MS-DOS 6.22 rem Виртуальный диск с 203 цилиндрами, 16 головками и 63 секторами qemu-system-i386 -machine isapc -cpu 486 -m 8 -device isa-vga,vgamem_mb=1 -rtc base=localtime -drive file=freedos.img,format=raw,if=none,id=d1 -device ide-hd,drive=d1,cyls=203,heads=16,secs=63 -fda dos622.img -boot a
После процесса установки я при помощи Rufus посекторно скопировал виртуальный образ на реальную карту CF. Затем подключил её к консоли аудиомикшера Behringer DDX3216 и запустил систему:

Ура, хотя бы FreeDOS справилась с запуском. Я потратил три недели на попытки создания самодельной BIOS, которая будет достаточно совместимой с реальным ПО x86, чтобы запустить реальную операционную систему. Мне ещё осталось реализовать немного кода для прерывания 0x16 (доступ к клавиатуре) и другие мелочи, но в целом система вполне работает.
Другое внутреннее оборудование и следующие шаги
Многие компоненты DDX3216 основаны строго на логических интегральных схемах. Например, все светодиоды управляются простым сдвиговым регистром, подключённым к интерфейсу ввода-вывода SC300. Сначала сигналы управления подаются на IC5A и IC6A; IC5A управляет сигналами VULTCH, LSSELR, UCSELR и SPTESR, а IC6A управляет сигналами VUSELW, LSSELW, UCSELW, LSLTCH, FLSET1, FLSET0. Адресные биты SA12..15 SC300 используются на шине ввода-вывода, образуя адресное пространство от 0x1000 до 0xF000. 0x3000 позволяет VUSELW выполнять запись, а VULTCH выполнять чтение шины ввода-вывода. То есть светодиоды 1 и 9 аудиоканала 1-4 управляются битом 0 адреса 0x3000, светодиоды 8 и 16 канала 1-4 — битом 7 адреса 0x3000. К девятому биту предыдущего сдвигового регистра подключено ещё четыре сдвиговых регистра, чтобы биты сдвигались и через различные логические интегральные схемы.
Кроме того, для ограничения максимального тока контактов VCC или GND драйверов светодиодов чётные и нечётные светодиоды подключены к GND и VCC попеременно:

То есть нам нужно передавать 0 и 1 в зависимости от выбранного в сдвиговом регистре светодиода. Показанный ниже код устанавливает для всех светодиодов индикаторов уровня всех каналов сигнал HIGH:
bool even = false; for (uint8_t i = 0; i < (8 * 5); i++) { if (even) { outb(0x3000, 0b11111111); }else{ outb(0x3000, 0b00000000); } inb(0x3000); even = !even; }
Этот код работает благодаря тому. что у нас есть пять конкатенированных 8-битных сдвиговых регистров с попеременными светодиодами на каждом выводе. Каждый бит показанного выше байта связан с конкретным светодиодом. Вот полный список светодиодов индикаторов уровней:
// DL30..37 outb(0x3000, 0b00000000); inb(0x3000); // led 1, Left led 2, Left led 3, Left outb(0x3000, 0b11111111); inb(0x3000); // led 9, Left led 10, Left led 11, Left outb(0x3000, 0b00000000); inb(0x3000); // led 1, Right led 2, Right led 3, Right outb(0x3000, 0b11111111); inb(0x3000); // led 9, Right led 10, Right led 11, Right outb(0x3000, 0b00000000); inb(0x3000); // LED segment outb(0x3000, 0b00000000); inb(0x3000); // LED segment outb(0x3000, 0b00000000); inb(0x3000); // LED segment outb(0x3000, 0b00000000); inb(0x3000); // свободно // DL20..27 outb(0x3000, 0b00000000); inb(0x3000); // led 1, ch 13 led 2, ch 13 led 3, ch 13 outb(0x3000, 0b11111111); inb(0x3000); // led 9, ch 13 led 10, ch 13 led 11, ch 13 outb(0x3000, 0b00000000); inb(0x3000); // led 1, ch 14 led 2, ch 14 led 3, ch 14 outb(0x3000, 0b11111111); inb(0x3000); // led 9, ch 14 led 10, ch 14 led 11, ch 14 outb(0x3000, 0b00000000); inb(0x3000); // led 1, ch 15 led 2, ch 15 led 3, ch 15 outb(0x3000, 0b11111111); inb(0x3000); // led 9, ch 15 led 10, ch 15 led 11, ch 15 outb(0x3000, 0b00000000); inb(0x3000); // led 1, ch 16 led 2, ch 16 led 3, ch 16 outb(0x3000, 0b11111111); inb(0x3000); // led 9, ch 16 led 10, ch 16 led 11, ch 16 // DL10..17 outb(0x3000, 0b00000000); inb(0x3000); // led 1, ch 9 led 2, ch 9 led 3, ch 9 outb(0x3000, 0b11111111); inb(0x3000); // led 9, ch 9 led 10, ch 9 led 11, ch 9 outb(0x3000, 0b00000000); inb(0x3000); // led 1, ch 10 led 2, ch 10 led 3, ch 10 outb(0x3000, 0b11111111); inb(0x3000); // led 9, ch 10 led 10, ch 10 led 11, ch 10 outb(0x3000, 0b00000000); inb(0x3000); // led 1, ch 11 led 2, ch 11 led 3, ch 11 outb(0x3000, 0b11111111); inb(0x3000); // led 9, ch 11 led 10, ch 11 led 11, ch 11 outb(0x3000, 0b00000000); inb(0x3000); // led 1, ch 12 led 2, ch 12 led 3, ch 12 outb(0x3000, 0b11111111); inb(0x3000); // led 9, ch 12 led 10, ch 12 led 11, ch 12 // DL00..07 outb(0x3000, 0b00000000); inb(0x3000); // led 1, ch 5 led 2, ch 5 led 3, ch 5 outb(0x3000, 0b11111111); inb(0x3000); // led 9, ch 5 led 10, ch 5 led 11, ch 5 outb(0x3000, 0b00000000); inb(0x3000); // led 1, ch 6 led 2, ch 6 led 3, ch 6 outb(0x3000, 0b11111111); inb(0x3000); // led 9, ch 6 led 10, ch 6 led 11, ch 6 outb(0x3000, 0b00000000); inb(0x3000); // led 1, ch 7 led 2, ch 7 led 3, ch 7 outb(0x3000, 0b11111111); inb(0x3000); // led 9, ch 7 led 10, ch 7 led 11, ch 7 outb(0x3000, 0b00000000); inb(0x3000); // led 1, ch 8 led 2, ch 8 led 3, ch 8 outb(0x3000, 0b11111111); inb(0x3000); // led 9, ch 8 led 10, ch 8 led 11, ch 8 // DL0..7 outb(0x3000, 0b00000000); inb(0x3000); // led 1, ch 1 led 2, ch 1 led 3, ch 1 outb(0x3000, 0b11111111); inb(0x3000); // led 9, ch 1 led 10, ch 1 led 11, ch 1 outb(0x3000, 0b00000000); inb(0x3000); // led 1, ch 2 led 2, ch 2 led 3, ch 2 outb(0x3000, 0b11111111); inb(0x3000); // led 9, ch 2 led 10, ch 2 led 11, ch 2 outb(0x3000, 0b00000000); inb(0x3000); // led 1, ch 3 led 2, ch 3 led 3, ch 3 outb(0x3000, 0b11111111); inb(0x3000); // led 9, ch 3 led 10, ch 3 led 11, ch 3 outb(0x3000, 0b00000000); inb(0x3000); // led 1, ch 4 led 2, ch 4 led 3, ch 4 outb(0x3000, 0b11111111); inb(0x3000); // led 9, ch 4 led 10, ch 4 led 11, ch 4
Итак, в дальнейшем можно было бы написать функцию, изменяющую состояние светодиодов в зависимости от части ОЗУ, содержащей состояние светодиода, аналогично буферу пикселей. Также можно запрограммировать ползунки и вращающиеся ручки.
Так как в DDX3216 есть множество микроконтроллеров PIC16 с проприетарными прошивками, нам, вероятно, не удастся заставить работать все детали. Набравшись опыта в работе с Behringer X32 и DSP 21379, я думал о том, чтобы попробовать управлять DSP AnalogDevices SHARC, но четыре DSP SHARC подключены к проприетарному логическому устройству, похожему на FPGA, только программируемому во включённом состоянии. Без конкретной информации об этом устройстве подключиться к DSP будет довольно сложно.

Поэтому я думаю продолжить эксперименты с FreeDOS, подключить клавиатурный переходник AT-XT и, возможно, реализовать графический видеорежим для тестирования на этом устройстве Windows 2.0 или 3.0, но это уже тогда, когда у меня будет много времени.
Полный исходный код можно найти на GitHub: https://github.com/xn--nding-jua/DDX3216. В нём я подготовил несколько самодельных программ для загрузочного сектора, способных переключаться в защищённый режим, а также использовать плоские 32-битные указатели, что сильно упрощает адресацию всей памяти. Но с этими загрузочными секторами вам не удастся запустить DOS, потому что она требует реального режима CPU x86.
Комментарии (4)

dlinyj
26.06.2026 10:08Спасибо за перевод. Очень крутая статья. Я рад, что узнал об эмуляторах ПЗУ, ценная информация. Ещё бы им добавить вывод отладочной инфы - по какому адресу находиться сейчас чтение, что позволило бы дебажить материнские платы. В любом случае это круто!

Javian
26.06.2026 10:08Действительно интересный проект, который раньше не попадался. Отпадает необходимость программировать микросхемы УФ ПЗУ. Было бы ещё интересно, для полного комплекта, этим Пико считывать ПЗУ.

dlinyj
26.06.2026 10:08Да меня не особо обламывает стирать УФ ПЗУ и читать их программатором. Но это реально прикольно и удобнее.
woodiron
Если говорить в общем о Беринжере, то не знаю, как в мире - но в РФ у него было достаточно стрёмная репутация. Прекрасно оформленная полиграфия, хорошие на бумаге параметры, но на практике всё-таки дешёвое по цене и по звуку оборудование. Но когда Беринжер купил Мидас и выпустил цифровой микшер X32, это был успешный успех, по тем временам просто убойная вещь по отношению цена/качество.