如何通过GCC编译器编译DOS COM文件

文章于2014年12月9日发布
2018年更新:RenéRebe根据本文制作了一个有趣的视频第2部分

上周末我参加了Ludum Dare#31 。 但是甚至在会议宣布之前,由于我最近的爱好,我想在DOS下制作一款老式游戏。 目标平台是DOSBox。 尽管所有现代x86处理器都与旧的处理器(最多16位8086)完全向后兼容,但这是运行DOS应用程序的最实用方法。

我在会议上成功创建并展示了DOS Defender游戏。 该程序在32位80386的实模式下工作。所有资源都内置在可执行的COM文件中,没有任何外部依赖关系,因此整个游戏都打包为10 KB二进制文件。



您需要操纵杆或游戏板才能玩。 为了演示起见,我在Ludum Dare的发行版中包含了鼠标支持,但是由于效果不佳而将其删除。

技术上最有趣的部分是, 无需 DOS开发工具即可创建游戏 ! 我只使用了常规的Linux C编译器(gcc)。 实际上,您甚至无法为DOS构建DOS Defender。 我仅将DOS视为嵌入式平台,这是DOS至今仍然存在的唯一形式。 与DOSBox和DOSEMU一起使用,这是一组相当方便的工具。

如果您只对开发的实际部分感兴趣,请转到“在GCC上作弊”部分,我们将在此使用GCC Linux编写DOS COM程序“ Hello,World”。

寻找合适的工具


当我开始这个项目时,我没有想到GCC。 实际上,当我发现Debian的bcc软件包(Bruce的C编译器)时,我就这样走了,该软件包收集了8086的16位二进制文​​件。该软件包用于编译x86引导加载程序和其他内容,但是bcc也可用于编译DOS COM文件。 它使我感兴趣。

供参考:1978年发布了Intel 8086 16位微处理器。 它没有现代处理器的怪异功能:没有内存保护,没有浮点指令以及只有1 MB的可寻址RAM。 四十年前,所有现代x86台式机和笔记本电脑仍可以假装为8086的16位处理器,但寻址方式和所有功能都相同。 这是一个相当向后的兼容性。 这种功能称为实模式 。 这是所有x86计算机引导的模式。 现代操作系统通过虚拟寻址和安全的多任务处理立即切换到保护模式 。 DOS没有这样做。

不幸的是,bcc不是ANSI C编译器,它支持K&R C的子集以及内置的x86汇编代码。 与其他8086 C编译器不同,它没有“远”或“长”指针的概念,因此需要内置的汇编代码来访问其他内存段 (VGA,时钟等)。 注意:这些“长指针” 8086的剩余部分仍保留在Win32 API中: LPSTRLPWORDLPDWORD等。该内置汇编程序甚至与内置汇编程序GCC并没有紧密的比较。 在汇编器中,您需要从堆栈中手动加载变量,并且由于bcc支持两种不同的调用约定,因此代码中的变量应根据一种或另一种约定进行硬编码。

考虑到这些限制,我决定寻找替代方案。

DJGPP


DJGPP -DOS下的GCC端口。 一个非常令人印象深刻的项目,可以在DOS下传输几乎整个POSIX。 在DJGPP上制作了许多DOS移植程序。 但是他只为保护模式创建32位程序。 如果在保护模式下,您需要使用硬件(例如VGA),则程序会向DOS保护模式接口 (DPMI)的服务发出请求。 如果我选择了DJGPP,则不可能将自己局限于单个独立的二进制文件,因为我必须拥有DPMI服务器。 对DPMI的要求也会降低性能。

至少可以说,很难获得DJGPP的必要工具。 幸运的是,我找到了一个有用的build-djgpp项目,该项目可以至少在Linux上运行所有内容。

要么是一个严重的错误,要么是官方DJGPP二进制文件再次病毒感染 ,但是当我在DOSBox中启动程序时,错误“ Not COFF:检查病毒”不断出现。 为了进一步验证病毒不在我自己的计算机上,我在Raspberry Pi上设置了DJGPP环境,该环境充当无尘室。 此基于ARM的设备不能感染x86病毒。 仍然出现相同的问题,并且机器之间的所有二进制哈希都是相同的,所以这不是我的错。

因此,鉴于此和DPMI问题,我开始进一步寻找。

愚弄gcc


我最终决定解决的问题是“欺骗” GCC以实模式构建DOS COM文件的棘手技巧。 这个技巧最多可以达到80386(通常是您所需要的)。 80386处理器于1985年推出,成为第一个32位x86微处理器。 即使在x86-64环境下,GCC仍然遵守这套说明。 不幸的是,GCC无法以任何方式生成16位代码,因此我不得不放弃最初为8086制作游戏的目标。 但是,这并不重要,因为目标DOSBox平台本质上是80386仿真器。

从理论上讲,该技巧也应在MinGW编译器中起作用,但是存在一个长期存在的错误,导致其无法正常工作(“无法对非PE输出文件执行PE操作”)。 但是,可以绕过它,而我自己做的:您应该删除OUTPUT_FORMAT指令,并添加一个额外的objcopy步骤( objcopy -O binary )。

在DOS上的Hello World


为了演示,我们将在Linux上使用GCC创建DOS COM程序“ Hello,World”。

这种方法有一个主要的重大障碍: 将没有标准库 。 这就像从头开始编写操作系统,但DOS提供了一些服务。 那意味着没有printf()之类的东西。 相反,我们要求DOS将字符串打印到控制台。 创建DOS请求需要中断,这意味着内联汇编代码!

DOS有9个中断:0x20、0x21、0x22、0x23、0x24、0x25、0x26、0x27、0x2F。 让我们感兴趣的最重要的东西是0x21,函数0x09(打印一行)。 在DOS和BIOS之间, 有数千种以此模式命名的功能 。 我将不尝试解释x86汇编程序,但简而言之,功能编号卡在了ah寄存器中-并触发了0x21中断。 函数0x09还带有一个参数-指向要打印的行的指针,该指针在dxds寄存器中传递。

这是GCC内联汇编程序的print()函数。 传递给此函数的行必须以$字符结尾。 怎么了 因为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 KB,而DOS只是将COM文件下载到0x0100段地址并执行。 没有标题,只有干净的二进制文件。 由于COM程序原则上不能有很大的大小,因此不应该出现实际的布局(独立式),因此整个程序被编译为单个翻译单元。 这将是一个带有一堆参数的GCC调用。

编译器选项


这是主要的编译器选项。

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

由于未使用标准库,因此gnu99和c99之间的唯一区别是禁用的trigraph(应如此),并且内置汇编程序可以asm代替__asm__编写。 这不是牛顿的垃圾箱。 该项目将与GCC紧密相关,以至于我仍然不关心GCC的扩展。

-Os选项尽可能减少编译结果。 因此,该程序将运行得更快。 注意DOSBox的重要性,因为默认仿真器像80年代的机器一样运行缓慢。 我想适应这个限制。 如果优化器引起问题,请临时-O0以确定您的错误或优化器在此处。

如您所见,优化器不了解该程序将在具有相应寻址限制的实模式下运行。 它执行各种无效的优化,破坏您完全有效的程序。 这不是GCC错误,因为我们自己在这里做疯狂的事情。 我不得不重做几次代码,以防止优化程序破坏程序。 例如,我们必须避免从函数返回复杂的结构,因为它们有时会充满垃圾。 真正的危险是,将来的GCC版本将变得更加智能,并且将破坏更多的代码。 这是你朋友volatile

下一个参数是-nostdlib ,因为我们将无法甚至静态地链接到任何有效的库。

参数-m32-march=i386编译器发出代码80386。如果我为现代计算机编写了引导加载程序,则80686的视线也将是正常的,但DOSBox为80386。

-ffreestanding变量要求GCC不要发布访问内置标准库的帮助程序功能的代码。 有时,它会生成用于调用内置函数的代码,而不是实际工作的代码,尤其是使用数学运算符时。 我遇到了密件抄送的主要问题之一,该行为无法禁用。 编写引导加载程序和OS内核时,最常使用此选项。 现在是dos dos .com文件。

链接器选项


-Wl用于将参数传递给链接器( ld )。 我们之所以需要这样做,是因为我们在一次致电GCC的过程中做了一切。

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

--nmagic禁用节页面对齐。 首先,我们不需要它。 其次,它浪费了宝贵的空间。 在我的测试中,这似乎不是必需的措施,但以防万一,我保留了此选项。

--script参数指示我们要使用特殊的链接描述文件 。 这使您可以准确地放置程序的各个部分( textdatabssrodata )。 这是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符号标记二进制文件的_heap 。 当我们完成“ Hello,World”后,在编写sbrk()时,这sbrk()用场。 我指示将_heap与4个字节对齐。

快完成了

程序启动


链接程序通常知道我们的入口点( main )并为我们设置了入口点。 但是,由于我们要求“二进制”问题,因此我们必须自己弄清楚。 如果第一个运行print()函数,则程序将从其启动,这是错误的。 该程序需要一个小标题才能开始。

链接描述文件中有一个用于此类情况的STARTUP选项,但为简单起见,我们将在程序中直接实现它。 通常,将这些东西称为crt0.oBoot.o ,以防您在某个地方Boot.o它们。 我们的代码必须从内置的汇编程序开始,然后再包含任何包含物等。 DOS将为我们完成大部分安装,我们只需要转到入口点即可。

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

.code16gcc告诉汇编程序我们将在实模式下工作,这样它将进行正确的配置。 尽管有名称,但它不会产生16位代码! 首先, dosmain我们先前编写的dosmain函数。 然后,他使用0x4C函数(“返回代码结尾”)告诉DOS,我们将退出代码传递给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 -std=gnu99 -Os -nostdlib -m32 -march=i386 -ffreestanding \ -o hello.com -Wl,--nmagic,--script=com.ld hello.c 

以及他在DOSBox中的测试:



然后,如果您想要精美的图形,唯一的问题是调用中断并写入VGA内存 。 如果要声音,请使用PC扬声器中断。 我还没有弄清楚如何调用Sound Blaster。 从那一刻起,DOS Defender就长大了。

内存分配


要涵盖另一个主题,请记住_heap吗? 我们可以使用它来实现sbrk()并在程序的主要部分中动态分配内存。 这是一个实模式,没有虚拟内存,因此我们可以写入我们可以随时访问的任何内存。 有些区域是为设备保留的(例如,较低和较高的内存)。 因此,没有真正需要使用sbrk(),但是尝试很有趣。

像在x86上一样,程序和分区位于较低的内存(在这种情况下为0x0100),而堆栈在较高的内存(在我们的情况下为0xffff区域)中。 在类Unix系统上, malloc()返回的malloc()来自两个地方: sbrk()mmap()sbrk()作用是在程序/数据段的上方分配内存,将其“向上”递增到堆栈。 每次对sbrk()调用都会增加此空间(或使其完全相同)。 该内存将由malloc()等管理。

这是在COM程序中实现sbrk()的方法。 请注意,您需要定义自己的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并根据需要_heap进行递增。 稍微聪明一点的sbrk()也要小心对齐。

DOS Defender的创建过程中发生了一件有趣的事情。 我(错误地)认为sbrk()中的内存sbrk()重置。 所以是在第一场比赛之后。 但是,DOS不会在程序之间重置此内存。 当我再次开始游戏时, 它会一直停在我停止的地方 ,因为将具有相同内容的相同数据结构加载到位。 很酷的巧合! 这是使该嵌入式平台变得有趣的部分原因。

Source: https://habr.com/ru/post/zh-CN412867/


All Articles