Статья опубликована 9 декабря 2014 года
Обновление от 2018 года: ReneRebe сделал на базе этой статьи интересное видео (часть 2)
В минувшие выходные я участвовал в Ludum Dare #31. Но даже до объявления тем конференции из-за своего недавнего увлечения я хотел сделать олдскульную игру под DOS. Целевой платформой выбрана DOSBox. Это самый практичный способ запуска DOS-приложений несмотря на то, что все современные процессоры x86 полностью обратно совместимы со старыми, вплоть до 16-битного 8086.
Я успешно создал и показал на конференции игру DOS Defender. Программа работает в реальном режиме 32-битного 80386. Все ресурсы встроены в исполняемый COM-файл, никаких внешних зависимостей, так что игра целиком упакована в бинарник 10 килобайт.
LPSTR
, LPWORD
, LPDWORD
и др. Тот встроенный ассемблер даже близко не сравнится со встроенным ассемблером GCC. На ассемблере нужно вручную загружать переменные из стека, а поскольку bcc поддерживает два разных соглашения о вызовах, то переменные в коде следует жёстко закодировать в соответствии с одним или другим соглашением.OUTPUT_FORMAT
и добавить дополнительный шаг objcopy
(objcopy -O binary
).printf()
и тому подобного. Вместо этого мы попросим DOS вывести строку в консоль. Создать запрос к DOS требует запуска прерывания, что означает встроенный ассемблерный код!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.string
, когда-либо читалась. Вероятно, массив, который поддерживает строку, тоже придётся объявить volatile
. Всё это предвещает неизбежное: любые действия в такой среде превращаются в бесконечную борьбу с оптимизатором. Не все из этих битв можно выиграть.main()
, потому что у MinGW есть забавные идеи, как обрабатывать конкретно такие символы, даже если его просят не делать этого.int dosmain(void)
{
print("Hello, World!\n$");
return 0;
}
-std=gnu99 -Os -nostdlib -m32 -march=i386 -ffreestanding
asm
вместо __asm__
. Это не бином Ньютона. Проект будет настолько тесно связан с GCC, что я всё равно не озабочен расширениями GCC.-Os
насколько возможно уменьшает результат компиляции. Так и программа будет работать быстрее. Это важно с прицелом на DOSBox, потому что эмулятор по умолчанию работает медленно как машина 80-х. Я хочу вписаться в это ограничение. Если оптимизатор вызывает проблемы, то временно поставим -O0
, чтобы определить, тут ваша ошибка или оптимизатора.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!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
, потому что не имеет входов и выходов.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
_heap
? Можем использовать его для реализации sbrk()
и динамического выделения памяти в основном разделе программы. Это реальный режим и нет виртуальной памяти, поэтому можем писать в любую память, к которой мы можем обратиться в любой момент. Некоторые участки зарезервированы (например, нижняя и верхняя память) для оборудования. Так что реальной нужды в использовании sbrk() нет, но интересно попробовать.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()
также будет осторожен с выравниванием.sbrk()
обнулилась. Так было после первой игры. Однако DOS не обнуляет эту память между программами. Когда я снова запустил игру, она продолжилась точно там, где остановилась, потому что те же структуры данных с тем же содержимым были загружены на свои места. Довольно прикольное совпадение! Это часть того, что делает забавной эту встроенную платформу.
К сожалению, не доступен сервер mySQL