x86体系的计算机,经典的启动模式是BIOS先初始化硬件,然后把启动盘的MBR加载到0x7c00处并跳转执行。这时候,系统还处于16位实模式的状态,有非常多的文章和书籍介绍如何编写16位实模式汇编代码并且转入32位保护模式,此时也就顺理成章地可以使用C语言。但是,如果是否可以直接在实模式下使用C语言呢?
利用gcc的16位代码生成功能,答案是肯定的,以下以一个HelloWorld程序为例进行讲解。
1. 代码
代码中打印单个字符的功能因为涉及到BIOS调用,需要以汇编的形式实现,其余部分都通过C语言来完成。
/* mbr.c */ |
2. 运行
2.1. 编译
gcc -m16 -ffreestanding -fno-asynchronous-unwind-tables -fno-pic -c mbr.c |
-m16
最为关键,用来告诉编译器生成16位模式的代码-ffreestanding
告诉编译器,不要假定标准标准库的存在,编译成独立的程序-fno-asynchronous-unwind-tables
防止生成eh_frame这类有关异常处理的section-fno-pic
不生成位置无关代码,也就禁用掉了GOT表
2.2. 链接
gcc -m16 -nostdlib -static -e main -Wl,-Ttext=0x7c00,-build-id=none -o mbr.elf mbr.o |
-m16
依然要告诉连接器生成16位代码-nostdlib
禁用标准库-static
生成静态代码-e main
代码入口为函数main-Wl,-Ttext=0x7c00,-build-id=none
链接.text节到0x7c00处,这x86启动代码被加载的内存地址;此外,不需要build-id
2.3. 检测elf生成
启动代码不同一般程序,必须小心检查各类section的生成情况,加载位置,实际上,更加合理的方法是编写linker script链接脚本,而不是如下的手工检查。
objdump -h mbr.elf |
mbr.elf: 文件格式 elf32-i386
节:
Idx Name Size VMA LMA File off Algn
0 .text 000000fa 00007c00 00007c00 00000c00 2**0
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .rodata 0000000e 00007cfc 00007cfc 00000cfc 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .comment 0000002a 00000000 00000000 00000d0a 2**0
CONTENTS, READONLY
此中,只有两个需要ALLOC
的段,.text
代码段和.rodata
只读数据段,分别对应了程序可执行代码和常量字符串。
.text
段从0x7c00开始,大小为0xfa.text
和.rodata
之间有两个字节空隙,这是gcc为了数据对齐自动添加的.rodata
从0x7cfc开始,大小为0xe(即为”Hello, world!”字符串的数据长度).comment
不需要考虑
经过检查,.rodata
结束位置距离.text
开始位置只有266字节,程序大小合适。
另外如果gcc版本不同,这个过程中也许会发现有其他的需要ALLOC
的段,这样的可执行代码是有问题的,需要调整gcc编译链接选项。
2.4. 提取纯二进制文件
objcopy -O binary -j .text -j .rodata mbr.elf mbr.bin |
经确认,mbr.bin文件大小为266字节无误。
2.5. 添加MBR启动标记
这一步要做的工作就是把代码扩展到一个扇区大小(512字节)并且在尾部设置0x55aa启动标志。使用dd命令可以定位写入到扇区尾部。
printf '\x55\xaa' | dd of=mbr.bin seek=510 bs=1 |
记录了2+0 的读入
记录了2+0 的写出
2 bytes copied, 3.0829e-05 s, 64.9 kB/s
2.6. qemu运行
qemu-system-x86_64 -hda mbr.bin |
3. 附注
但值得注意的是,由于gcc生来就是为了编写32/64位代码,对16代码生成只是做了一个拓展兼容,所以,其函数调用约定依然采32位代码的约定。比如上述代码中的函数void printChar(uint8_t row, uint8_t column, char ch, uint8_t color);
:
一般容易想当然以为,既然是16位模式,是调用者应该依次压入4个2字节的参数入栈,然后call printChar
(即16位的call),被调用者执行完成后采用ret
返回;
但是就像前面提到的,gcc的原生的支持就是32位的,所以事实上调用者往栈中押入了4个32位的参数,然后calll printChar
执行32位的长调用,被调用者返回时候也是对应使用retl
,所以说,这里全部使用的是32位调用约定,有很多内存空间是浪费了。