实模式C语言MBR程序

x86体系的计算机,经典的启动模式是BIOS先初始化硬件,然后把启动盘的MBR加载到0x7c00处并跳转执行。这时候,系统还处于16位实模式的状态,有非常多的文章和书籍介绍如何编写16位实模式汇编代码并且转入32位保护模式,此时也就顺理成章地可以使用C语言。但是,如果是否可以直接在实模式下使用C语言呢?

利用gcc的16位代码生成功能,答案是肯定的,以下以一个HelloWorld程序为例进行讲解。

1. 代码

代码中打印单个字符的功能因为涉及到BIOS调用,需要以汇编的形式实现,其余部分都通过C语言来完成。

/* mbr.c */

// 即使裸机运行,stdint.h中定义的类型或宏仍然可以使用,因为这些内容只作用于编译期
#include <stdint.h>

const char str[] = "Hello, world!";
void printChar(uint8_t row, uint8_t column, char ch, uint8_t color);

// 程序入口函数
void main()
{
// 打印15行的HelloWorld
for (uint8_t row = 0; row < 15; row++) {
// 背景为黑色,前景色逐行改变
uint8_t color = 0x01 + row;
for (uint8_t column = 0; column < sizeof(str) - 1; ++column) {
printChar(row, column, str[column], color);
}
}
// 其实更好的死循环方式是hlt指令,这里就不计较那么多了
while (1);
}

// 在屏幕指定的行列位置打印一个字符
// 输入参数:行位置,列位置,字符,颜色属性
// 颜色属性参见 https://en.wikipedia.org/wiki/BIOS_color_attributes
void printChar(uint8_t row, uint8_t column, char ch, uint8_t color)
{
/*
* 整个内联汇编分两步,先设置光标位置,后在光标位置输出字符。
* 设置光标位置:AH=02h, BH = 页号, DH = 行, DL = 列
* 输出字符:AH=09h, AL = 字符, BH = 页号, BL = 颜色, CX = 重复次数
*/
__asm__ __volatile__ (
"mov $0x02, %%ah\n\t"
"int $0x10\n\t"
"mov $0x09, %%ah\n\t"
"mov %0, %%al\n\t"
"int $0x10\n\t"
:
: "m"(ch),
"b"(((uint16_t) 0 << 8) | color),
"c"((uint16_t) 1),
"d"(((uint16_t) row << 8) | column)
: "ax");
}

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位调用约定,有很多内存空间是浪费了。