Хабрахабр

[Перевод] Как собрать досовский COM-файл компилятором GCC

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Hello World в DOS

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

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

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

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

Её название по идее не важно, но я избегаю называть её 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, ведь мы сами тут делаем сумасшедшие вещи. Как видите, оптимизатор не понимает, что программа будет работать в реальном режиме с соответствующими ограничениями адресации. Например, пришлось избегать возврата сложных структур из функций, потому что они иногда заполнялись мусором. Мне пришлось несколько раз переделывать код, чтобы помешать оптимизатору сломать программу. Здесь ваш друг volatile. Настоящая опасность в том, что будущая версия GCC станет ещё умнее и будет ломать ещё больше кода.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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:

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

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

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

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

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 Defender произошла интересная вещь. Однако DOS не обнуляет эту память между программами. Так было после первой игры. Довольно прикольное совпадение! Когда я снова запустил игру, она продолжилась точно там, где остановилась, потому что те же структуры данных с тем же содержимым были загружены на свои места. Это часть того, что делает забавной эту встроенную платформу.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»