系列目录

实现 Boot Loader

接上一篇准备工作,从这篇开始我们将进入 boot loader 的编写。网上有一些类似的教程可能跳过了这个阶段,直接为你准备好了 boot loader,从而你可以直接开始 kernel 的编写,例如之前推荐的 JamesM's kernel development tutorials 就是这样的。不过我还是强烈建议将 boot loader 也自己实现了,尤其是对初学者,原因如下:

  • 它并不困难,相比于后面的 kernel;
  • 有助于你快速提高汇编能力,这在后面的 C 语言 kernel 编写、调试中仍然很重要;
  • boot 裸机运行阶段的的编程有助于你建立起对磁盘、内存、指令和 data 之间的加载、映射关系的正确认识,为后面的内核、可执行程序的加载,以及虚拟内存的建立做好准备,尤其是如果你感觉对这一块比较模糊的话;
  • boot 阶段其实会初步搭建起 segment 以及虚拟内存的框架,为后续 kernel 编写打下基础;

开机进入 BIOS

这是一个经典的问题,就是计算机主板开机上电后到启动,发生了什么?

首先我们需要知道开机后 CPU 和内存所处的状态,开机后 CPU 初始模式是实模式,地址宽度为 20 位,即最大地址空间 1MB。这 1MB 空间的划分是固定的,每一块都有规定的用途的,被映射到不同的设备上:

BIOS 的工作

我们来看一下开机后发生的事情:

  1. 开机后 CPU 的指令寄存器 ip 被强置为地址 0xFFFF0,这一地址被映射到 BIOS 固件上的代码,这就是计算机开机后的第一条指令的地址;
  2. CPU 开始执行 BIOS 上的代码,这一部分主要是硬件输入输出设备相关的检查,以及建立一个最初的中断向量表,目前不必深究;
  3. BIOS 代码最后阶段的工作,就是检查启动盘上的 mbr 分区,所谓 mbr 分区就是磁盘上的第一个 512B 内容,又叫引导分区;BIOS 会对这 512B 做一个检查:它的最后2个字节必须是两个 magic number:0x550xaa,否则它就不是一个合法的启动盘;
  4. 检查通过后,BIOS 将这 512B 加载到内存 0x7C00 处,到 0x7E00 为止,然后指令跳转到 0x7C00 开始执行;至此 BIOS 退出舞台;

将上面那张表格画成图,去掉干扰项,只留下我们关心的部分:

  • 黄色部分是加载到内存的 mbr,起始地址 0x7C00;
  • 白色部分是我们后面可以自由使用的内存空间;
  • 斜线阴影部分为 BIOS 代码;

图中标出了 BIOS 的主要工作流程,从地址 0xFFFF0 开始,经过一系列代码执行,最终校验并读取磁盘第一个 512B 扇区,加载到黄色部分即为 mbr,地址为 0x7C00,然后指令跳转过去,进入 mbr 的执行;

mbr 的工作

那么 mbr 需要做什么事情?因为 mbr 大小被限制在了 512B,你不可能在里面放很多代码和数据,所以它最重要的工作只有一个:

  • 将后面的 loader 部分从磁盘加载到内存,并跳转到 loader 继续执行;

内存布局规划

所以我们需要规划一下整个 boot load 阶段的内存布局,这里我们直接给出磁盘以及内存的全貌:

我们目前重点关注内存 1MB 以下部分的内容,不同部分用了不同的颜色标识出来:

  • 黄色:mbr
  • 蓝色:loader
  • 白色:可自由使用

经过 BIOS 的工作,现在指令已经来到了 mbr 部分,它需要将蓝色部分的 loader 从磁盘上加载到内存,地址就定为 0x8000,注意这个地址可以自由指定的,只要在图中白色区域内,并且空间足够用即可。我们的 loader 部分也不会很大,按照比较富余地估计,4KB 足以。

mbr 代码

先给出我项目里的代码,路径为 src/boot/mbr.S,供你参考。

首先关注开始部分:

SECTION mbr vstart=MBR_BASE_ADDR

MBR_BASE_ADDR 定义在了 boot.inc 中,为 0x7C00,这表示了整个 mbr 里的内容都是从 0x7C00 开始编址,包括代码和数据。这一点非常重要,因为我们已经提前知道了 BIOS 会将 mbr 加载到这个位置,所以整个 mbr 里的内容的编址必须从这里开始,这样 BIOS 在跳转到 mbr 的第一条指令后,后续对 mbr 中代码和数据的访问才能正确寻址。

mbr 的入口我标记为了 mbr_entry,后面我定义了几个函数,后面的讲解我们不妨用 C 语言给它做注释:

void init_segments();

这里初始化了几个 segment 寄存器,初始值均为 0,这表示 flat mode 的段内存分布方式,现在你也不必深究。另外我还将 stack 移动到了 0x7B00 的位置,这只是自由发挥,完全不是必须的。

接下来加载 loader:

void load_loader_img();
// 这个函数的汇编代码直接使用寄存器传参。
void read_disk(short load_mem_addr,
               short load_start_sector,
               short sectors_num);

这里就是 mbr 最主要的工作,把 loader 从磁盘上加载进来到内存 0x8000 的位置,
read_disk 三个参数传参分别为:

// loader 加载地址为 0x8000;
short load_mem_addr = LOADER_BASE_ADDR;
// loader 镜像在磁盘上起始位置为第 2 个sector,紧接着 mbr 之后;
short load_start_sector = 1;
// loader 大小为 8 个 sectors,共 4KB;
short sectors_num = 8;

read_disk 函数涉及到了读取磁盘,需要用到一堆 CPU 控制磁盘的端口和中断功能,你需要查阅文档使用,冗长繁杂,我是照搬了《操作系统真相还原》一书第三章的内容。你其实也不必深究,拿来用就可以,只需要知道它做了什么工作即可。

加载完 loader 之后,就可以跳转到 loader 地址 0x8000 执行:

jmp LOADER_BASE_ADDR

整个从 BIOS -> mbr -> loader 的指令运行跳转流程如下图所示。loader 部分用浅蓝色阴影标出,因为它实际上目前没有有效数据,等待我们后续将它实现并加载入内存:

最后还有个关键的小东西:
这一通代码下来,所用的空间还远未到 512B,我们将剩余的空间全部用 0 填充(其实随便填什么都行,反正执行不到),最后在 512B 的末尾处写上 0x550xaa 两个 magic number:

times 510-($-$$) db 0
db 0x55, 0xaa

至此 mbr 便编码完成了,非常短小简单。接下来我们需要将它编译并且制作成启动镜像,加载到 Bochs 里运行。

运行 mbr

首先你需要制作一个磁盘镜像文件,这里又用到了 Bochs 自带的 bximage 这个命令行工具:

>> bximage -hd -mode="flat" -size=3 -q scroll.img 1>/dev/null

它其实就是产生了一个 3MB 的写满了 0 的文件,3MB 的大小的磁盘对于我们的项目已经足够容纳 mbr,boot,kernel 以及其它用户程序等所有数据。bximage 的打印日志还会告诉你,应该给配置文件 bochsrc.txt 里的磁盘设置什么参数,很方便。

接下来使用 nasm 编译 mbr.S:

nasm -o mbr mbr.S

然后你就得到一个 512B 大小的 mbr 文件。接下来将它刻写进磁盘镜像文件,这里用到了 dd 这个命令:

dd if=mbr of=scroll.img bs=512 count=1 seek=0 conv=notrunc

注意到这里把 mbr 写到了磁盘镜像文件的第一个扇区(512B)。


现在我们得到一个这样的磁盘镜像文件:

然后你就可以把磁盘镜像文件加载到 Bochs 里运行了,和之前一样:

bochs -f bochsrc.txt

不过在此之前,mbr 最好先做一个小小的改动。因为此时我们镜像里还没有任何 loader 内容,加载完的 loader 其实全是 0,这不是可以执行的代码,因此 mbr 的最后一条指令 jmp LOADER_BASE_ADDR 之后 CPU 就会挂掉,所以你可以在这条指令之前加一句 jmp $,这相当于是死循环 while (true) {},让程序悬停在这里,你就可以暂停 Bochs 然后看它是不是停在这条指令了,如果是的话,说明 mbr 的运行已经成功了。

总结

mbr 短小精悍,本身没有太多难点在里面,不过完事开头难,作为整个内核镜像的开篇,我们需要开始提前对整个内存的布局进行谋划。如果是对汇编,指令,内存等在裸机上运行的原理还不太熟悉的同学,mbr 也是一个非常好的练手机会,建议你多对照着反编译后 mbr 代码,以及 Bochs 调试,能快速地帮助你建立相关的认知。

03-05 23:58