Статья опубликована 9 декабря 2014 года
Обновление от 2018 года: ReneRebe сделал на базе этой статьи интересное видео (часть 2)

В минувшие выходные я участвовал в Ludum Dare #31. Но даже до объявления тем конференции из-за своего недавнего увлечения я хотел сделать олдскульную игру под DOS. Целевой платформой выбрана DOSBox. Это самый практичный способ запуска DOS-приложений несмотря на то, что все современные процессоры x86 полностью обратно совместимы со старыми, вплоть до 16-битного 8086.

Я успешно создал и показал на конференции игру DOS Defender. Программа работает в реальном режиме 32-битного 80386. Все ресурсы встроены в исполняемый COM-файл, никаких внешних зависимостей, так что игра целиком упакована в бинарник 10 килобайт.



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

Наиболее технически интересная часть заключается в том, что для создания игры не понадобились никакие инструменты разработки DOS! Я использовал только обычный компилятор Linux C (gcc). В реальности даже нельзя собрать DOS Defender под DOS. Я рассматриваю DOS только как встроенную платформу, что и есть единственная форма, в которой DOS всё ещё существует сегодня. Вместе с DOSBox и DOSEMU это довольно удобный набор инструментов.

Если вас интересует только практическая часть разработки, перейдите к разделу «Обманываем GCC», где мы напишем DOS COM программу “Hello, World” с GCC Linux.

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


Когда я начал этот проект, то не думал о GCC. В реальности я пошёл по этому пути, когда обнаружил пакет bcc (Bruce’s C Compiler) для Debian, который собирает 16-битные бинарники для 8086. Его держат для компиляции загрузчиков x86 и прочего, но bcc также можно использовать для компиляции DOS COM файлов. Это меня заинтересовало.

Для справки: 16-битный микропроцессор Intel 8086 вышел в 1978 году. У него не было никаких причудливых функций современных процессоров: ни защиты памяти, ни инструкций с плавающей запятой и только 1 МБ адресуемой RAM. Все современные десктопы и ноутбуки x86 всё ещё могут притвориться этим 16-битным процессором 8086 сорокалетней давности, с такой же ограниченной адресацией и всё такое. Это нехилая обратная совместимость. Такая функция называется реальным режимом. Это режим, в котором загружаются все компьютеры x86. Современные ОС сразу переключаются в защищённый режим с виртуальной адресацией и безопасной многозадачностью. DOS так не поступал.

К сожалению, bcc — не компилятор ANSI C. Он поддерживает подмножество K&R C, а также встроенный ассемблерный код x86. В отличие от других компиляторов 8086 C, у него нет понятия «дальних» или «длинных» указателей, поэтому для доступа к другим сегментам памяти (VGA, тактовые импульсы и т. д.) необходим встроенный ассемблерный код. Примечание: остатки этих «длинных указателей» 8086 до сих сохранились в Win32 API: LPSTR, LPWORD, LPDWORD и др. Тот встроенный ассемблер даже близко не сравнится со встроенным ассемблером GCC. На ассемблере нужно вручную загружать переменные из стека, а поскольку bcc поддерживает два разных соглашения о вызовах, то переменные в коде следует жёстко закодировать в соответствии с одним или другим соглашением.

Учитывая такие ограничения, я решил искать альтернативы.

DJGPP


DJGPP — порт GCC под DOS. Реально очень впечатляющий проект, который переносит под DOS почти весь POSIX. Многие портированные под DOS программы сделаны на DJGPP. Но он создаёт только 32-битные программы для защищённого режима. Если в защищённом режиме нужно работать с аппаратным обеспечением (например, VGA), то программа делает запросы к сервису интерфейса защищённого режима DOS (DPMI). Если бы я взял DJGPP, то не смог бы ограничиться единственным автономным бинарником, потому что пришлось бы поиметь и сервер DPMI. Производительность тоже страдает от запросов к DPMI.

Получить необходимые инструментальные средства для DJGPP сложно, мягко говоря. К счастью, я нашел полезный проект build-djgpp, который всё запускает, по крайней мере, на Linux.

Либо там серьёзная ошибка, либо официальные бинарники DJGPP опять заразились вирусом, но при при запуске моих программ в DOSBox постоянно возникала ошибка “Not COFF: check for viruses”. Для дополнительной проверки, что вирусы не на моей собственной машине, я настроил среду для DJGPP на своём Raspberry Pi, который действует как чистая комната. Это устройство на базе ARM невозможно заразить вирусом x86. И всё равно возникала та же проблема, и все двоичные хэши совпадали между машинами, так что это не моя вина.

Так что учитывая это и проблему DPMI, я начал искать дальше.

Обманываем GCC


На чём я в итоге остановился — так это на хитром трюке по «обману» GCC для сборки DOS COM-файлов реального режима. Трюк работает до 80386 (что обычно и нужно). Процессор 80386 выпущен в 1985 году и стал первым 32-битным x86 микропроцессором. GCC по-прежнему придерживается этого набора инструкций, даже в среде x86-64. К сожалению, GCC никак не может производить 16-битный код, так что от изначальной цели сделать игру для 8086 пришлось отказаться. Впрочем, это не имеет значения, потому что целевая платформа DOSBox по сути является эмулятором 80386.

В теории трюк должен работать и в компиляторе MinGW, но там есть давняя ошибка, которая мешает ему работать правильно (“cannot perform PE operations on non PE output file”). Впрочем, её можно обойти, и я делал это сам: следует удалить директиву OUTPUT_FORMAT и добавить дополнительный шаг objcopy (objcopy -O binary).

Hello World в DOS


Для демонстрации создадим досовскую COM-программу “Hello, World” с помощью GCC на Linux.

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

В DOS девять прерываний: 0x20, 0x21, 0x22, 0x23, 0x24, 0х25, 0x26, 0x27, 0x2F. Самое главное, которое нас интересует, это 0x21, функция 0x09 (вывести строку). Между DOS и BIOS есть тысячи функций, названных по такому шаблону. Я не собираюсь пытаться объяснить ассемблер x86, но вкратце номер функции забивается в регистр ah — и прерывание 0x21 срабатывает. Функция 0x09 также принимает аргумент — указатель на строку для печати, который передается в регистрах dx и ds.

Вот функция print() встроенного ассемблера GCC. Строки, передаваемые этой функции, должны заканчиваться символом $. Почему? Потому что DOS.

static void print(char *string)
{
    asm volatile ("mov   $0x09, %%ah\n"
                  "int   $0x21\n"
                  : /* no output */
                  : "d"(string)
                  : "ah");
}

Код объявлен volatile, поскольку у него побочный эффект (печать строки). Для GCC ассемблерный код непрозрачен, и оптимизатор полагается на ограничения выхода/входа/клоббера (последние три строки). Для таких DOS-программ любой встроенный ассемблер будет с побочными эффектами. Это потому что он пишется не для оптимизации, а для доступа к аппаратным ресурсам и DOS — вещей, недоступных простому C.

Нужно также позаботиться о вызывающем операторе, потому что GCC не знает, что память, на которую указывает string, когда-либо читалась. Вероятно, массив, который поддерживает строку, тоже придётся объявить volatile. Всё это предвещает неизбежное: любые действия в такой среде превращаются в бесконечную борьбу с оптимизатором. Не все из этих битв можно выиграть.

Теперь к основной функции. Её название по идее не важно, но я избегаю называть её main(), потому что у MinGW есть забавные идеи, как обрабатывать конкретно такие символы, даже если его просят не делать этого.

int dosmain(void)
{
    print("Hello, World!\n$");
    return 0;
}

COM-файлы ограничены размером 65279 байт. Это связано с тем, что сегмент памяти x86 составляет 64 КБ, а DOS просто загружает COM-файлы в адрес 0x0100 сегмента и выполняет. Заголовков нет, только чистый бинарник. Поскольку программа COM в принципе не может иметь значительный размер, то не должно происходить и никакой реальной компоновки (freestanding), вся вещь компилируется как одна единица трансляции. Это будет один вызов GCC с кучей параметров.

Параметры компилятора


Вот основные параметры компилятора.

-std=gnu99 -Os -nostdlib -m32 -march=i386 -ffreestanding

Поскольку стандартные библиотеки не используются, то единственное различие между gnu99 и c99 заключается в отключенных триграфах (как и должно быть), и встроенный ассемблер можно записать как asm вместо __asm__. Это не бином Ньютона. Проект будет настолько тесно связан с GCC, что я всё равно не озабочен расширениями GCC.

Параметр -Os насколько возможно уменьшает результат компиляции. Так и программа будет работать быстрее. Это важно с прицелом на DOSBox, потому что эмулятор по умолчанию работает медленно как машина 80-х. Я хочу вписаться в это ограничение. Если оптимизатор вызывает проблемы, то временно поставим -O0, чтобы определить, тут ваша ошибка или оптимизатора.

Как видите, оптимизатор не понимает, что программа будет работать в реальном режиме с соответствующими ограничениями адресации. Он выполняет всевозможные невалидные оптимизации, которые ломают ваши совершенно валидные программы. Это не баг GCC, ведь мы сами тут делаем сумасшедшие вещи. Мне пришлось несколько раз переделывать код, чтобы помешать оптимизатору сломать программу. Например, пришлось избегать возврата сложных структур из функций, потому что они иногда заполнялись мусором. Настоящая опасность в том, что будущая версия GCC станет ещё умнее и будет ломать ещё больше кода. Здесь ваш друг volatile.

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

Параметры -m32-march=i386 командуют компилятору выдавать код 80386. Если бы я писал загрузчик для современного компьютера, то прицел на 80686 тоже был бы нормальный, но DOSBox — это 80386.

Аргумент -ffreestanding требует, чтобы GCC не выдавал код, который обращается к функциям хелпера встроенной стандартной библиотеки. Иногда он вместо реально рабочего кода выдаёт код для вызова встроенной функции, особенно с математическими операторами. У меня это была одна из основных проблем с bcc, где такое поведение невозможно отключить. Такой параметр чаще всего используется при написании загрузчиков и ядер ОС. А теперь и досовских COM-файлов.

Параметры компоновщика


Параметр -Wl используется для передачи аргументов компоновщику (ld). Нам это нужно, поскольку мы всё делаем за один вызов GCC.

-Wl,--nmagic,--script=com.ld

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

Параметр --script указывает, что мы хотим использовать особый скрипт компоновщика. Это позволяет точно разместить разделы (text, data, bss, rodata) нашей программы. Вот скрипт com.ld.

OUTPUT_FORMAT(binary)
SECTIONS
{
    . = 0x0100;
    .text :
    {
        *(.text);
    }
    .data :
    {
        *(.data);
        *(.bss);
        *(.rodata);
    }
    _heap = ALIGN(4);
}

OUTPUT_FORMAT(binary) говорит не помещать это в файл ELF (или PE и т. д.). Компоновщик должен просто сбросить чистый код. COM-файл — это просто чистый код, то есть мы даём команду компоновщику создать файл COM!

Я говорил, что COM-файлы загружаются в адрес 0x0100. Четвёртая строка смещает туда бинарник. Первый байт COM-файла по-прежнему остаётся первым байтом кода, но будет запускаться с этого смещения в памяти.

Далее следуют все разделы: text (программа), data (статичные данные), bss (данные с нулевой инициализацией), rodata (строки). Наконец, я отмечаю конец двоичного файла символом _heap. Это пригодится позже при написании sbrk(), когда мы закончим с “Hello, World”. Я указал выровнять _heap по 4 байтам.

Почти закончили.

Запуск программы


Компоновщик обычно знает нашу точку входа (main) и настраивает её для нас. Но поскольку мы запросили «двоичную» выдачу, то придётся разбираться самим. Если первой запустится функция print(), то выполнение программы начнётся с неё, что неправильно. Программе нужен небольшой заголовок для начала работы.

В скрипте компоновщика для таких вещей есть опция STARTUP, но мы для простоты внедрим её прямо в программу. Обычно подобные штуки называются crt0.o или Boot.o, на случай, если вы где-то на них наткнётесь. Наш код обязан начинаться с этого встроенного ассемблера, перед любыми включениями и тому подобным. DOS сделает за нас бoльшую часть установки, нам просто нужно перейти к точке входа.

asm (".code16gcc\n"
     "call  dosmain\n"
     "mov   $0x4C, %ah\n"
     "int   $0x21\n");

.code16gcc сообщает ассемблеру, что мы собираемся работать в реальном режиме, так что он сделает правильную настройку. Несмотря на название, это не выдаст 16-битный код! Сначала вызывается функция dosmain, которую мы написали ранее. Затем он сообщает DOS с помощью функции 0x4C («закончить с кодом возврата»), что мы закончили, передавая код выхода в 1-байтовый регистр al (уже установленный функцией dosmain). Этот встроенный ассемблер автоматически volatile, потому что не имеет входов и выходов.

Всё вместе


Вот вся программа на C.

asm (".code16gcc\n"
     "call  dosmain\n"
     "mov   $0x4C,%ah\n"
     "int   $0x21\n");

static void print(char *string)
{
    asm volatile ("mov   $0x09, %%ah\n"
                  "int   $0x21\n"
                  : /* no output */
                  : "d"(string)
                  : "ah");
}

int dosmain(void)
{
    print("Hello, World!\n$");
    return 0;
}

Не буду повторять com.ld. Вот вызов GCC.

gcc -std=gnu99 -Os -nostdlib -m32 -march=i386 -ffreestanding     -o hello.com -Wl,--nmagic,--script=com.ld hello.c

И его тестирование в DOSBox:



Тут если вы хотите красивой графики, то вопрос всего лишь в вызове прерывания и записи в память VGA. Если хотите звука, используйте прерывание PC Speaker. Я ещё не разобрался, как вызвать Sound Blaster. Именно с этого момента вырос DOS Defender.

Выделение памяти


Чтобы покрыть ещё одну тему, помните тот _heap? Можем использовать его для реализации sbrk() и динамического выделения памяти в основном разделе программы. Это реальный режим и нет виртуальной памяти, поэтому можем писать в любую память, к которой мы можем обратиться в любой момент. Некоторые участки зарезервированы (например, нижняя и верхняя память) для оборудования. Так что реальной нужды в использовании sbrk() нет, но интересно попробовать.

Как обычно на x86, ваша программа и разделы находятся в нижней памяти (0x0100 в данном случае), а стек — в верхней (в нашем случае в районе 0xffff). В Unix-подобных системах память, возвращаемая malloc(), поступает из двух мест: sbrk() и mmap(). Что делает sbrk(), так это выделяет память чуть выше сегментов программы/данных, приращивая её «вверх» навстречу стеку. Каждый вызов sbrk() будет увеличивать это пространство (или оставлять его точно таким же). Данная память будет управляться malloc() и подобными.

Вот как можно реализовать sbrk() в программе COM. Обратите внимание, что нужно определить собственный size_t, потому что у нас нет стандартной библиотеки.

typedef unsigned short  size_t;

extern char _heap;
static char *hbreak = &_heap;

static void *sbrk(size_t size)
{
    char *ptr = hbreak;
    hbreak += size;
    return ptr;
}

Он просто устанавливает указатель на _heap и увеличивает его по мере необходимости. Немного более умный sbrk() также будет осторожен с выравниванием.

В процессе создания DOS Defender произошла интересная вещь. Я (неправильно) посчитал, что память от моего sbrk() обнулилась. Так было после первой игры. Однако DOS не обнуляет эту память между программами. Когда я снова запустил игру, она продолжилась точно там, где остановилась, потому что те же структуры данных с тем же содержимым были загружены на свои места. Довольно прикольное совпадение! Это часть того, что делает забавной эту встроенную платформу.

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


  1. mistergrim
    31.05.2018 20:26
    -2

    Не вижу смысла в таком олдскуле, если ему всё равно для запуска нужен минимум 386-й.


    1. mistergrim
      01.06.2018 11:00

      Вижу, что меня не совсем поняли, поясню: человек поставил себе задачу (COM-файл, 16-битный 8086-ассемблер), а инструмент для неё не подошёл. Вместо того, чтобы подобрать подходящий инструмент, он подогнал задачу под инструмент.


  1. rogoz
    31.05.2018 21:44
    +1

    Начиная с вроде как 4.9.0 вместо -m32 можно писать -m16 и выкидывать .code16gcc, компилятор добавит сам где нужно.


  1. vitalyvitaly
    31.05.2018 22:56

    Компилятор Digital Mars до сих пор имеет 16-разрядные DOS и OS/2-режимы сборки.


    1. mistergrim
      31.05.2018 23:13

      Первое, что очевидным образом приходит в голову — Open Watcom С.


  1. SlimShaggy
    31.05.2018 23:19
    +2

    А зачем такие извращения, что мешало в том же досбоксе запустить какой-нибудь Borland C?


    1. VolCh
      01.06.2018 08:27

      Так не разделяются целевая и инструментальная платформа.


    1. 0o0
      01.06.2018 08:59

      qview тогда уж открыть и фигачить сразу на асм'е


    1. MinamotoSoft
      01.06.2018 10:40

      это же «иностранцы» — у него нет лицензии на борланд :)


      1. SlimShaggy
        01.06.2018 22:07

        А, точно! У меня даже мысли не возникло, что он мог быть платным


  1. Prototik
    01.06.2018 02:16

    Когда я снова запустил игру, она продолжилась точно там, где остановилась

    Напоминает очень полезную программку GO для DOS, размером… в 0 байт. Позволяла открыть предыдущее приложение без потери его состояния :)


    1. immaculate
      01.06.2018 04:12

      А мне вспоминаются компьютеры Yamaha MSX, в которых иногда после перезагрузки сохранялось старое содержимое памяти. Я уже не помню, как именно это проявлялось. По-моему, например, после перезагрузки из встроенного Paint, можно было в программе на BASIC считать и показать последнюю редактируемую картинку. Иногда игры продолжались после перезагрузки.


      1. AKudinov
        01.06.2018 08:17

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


      1. mistergrim
        01.06.2018 11:23

        Вспомнилось https://habr.com/post/389001/


    1. JC_IIB
      01.06.2018 11:25

      Любопытно, а это вообще как?


      1. VolCh
        01.06.2018 11:53

        COM файлы просто загружались по адресу 0х0100 и на него передавалось управление. При нулевой длине ничего не загружалось, а управление передавалось. Ну а при выходе из программы память не очищалась.


      1. Prototik
        01.06.2018 13:07

        На хабре была статейка: https://habr.com/post/147075/


  1. woopler
    01.06.2018 09:45

    это что же должно быть в этой игре, чтобы она весила 10кб? эх… прошли те времена, когда шикарные демки (для DOS, COM-файл) умещались в 256 байт.


    1. Ernest88
      01.06.2018 12:11

      Были же соревнования ассемблерщиков, которые делали классные анимации ограничивая себя СОМ файлом.