前言

最近粗略拜读了朋友推荐的《Linux 内核设计的艺术》这本书。不过相比于 Linux 内核设计的艺术,我从这本书中获得的最大启发其实是 Linux 启动的流程。尽管这本书中的 Linux 版本比较古早,和现行的 Linux 发行版的启动方式大相径庭,不过也从一个更底层的角度向我展示了整个操作系统的引导流程,不管是 I/O 子系统还是中断子系统,也让我对内核的工作方式和存在结构有了全新的认识。遗憾的是,我的这些问题,如果早点读到《操作系统真象还原》这本书的话,可能能得到更好的解答(当然,《操作系统真象还原》这本书讲的并不是 Linux,而是 x86 体系上的通用操作系统概念)。下文的讲述将可能引用《操作系统真象还原》这本书的内容,简洁起见(省得打字),下面将《操作系统真象还原》简称为《真象》,而《Linux 内核设计的艺术》简称为《艺术》。

第一行指令

在先前的计算机组成课中,我了解到了指令在系统中是如何流转的,但这种就是一个比较底层的视角,我还是没能计算机组成与操作系统这两门课很好地结合到一起。这本书与其他讲述 Linux 内核的书籍不同之处在于,不是一上来就基于代码模块对整个 Linux 内核进行分块的剖析,而是从按下开机键上电到 Linux 启动完毕的流程娓娓道来,这正是我所欠缺的,将操作系统与计算机组成统一起来的视角。

指令从何而来?

计算机的心脏,CPU,从上电那一刻起就在不停地搏动。那么第一下搏动从何而来呢?每条指令需要在 CPU 上执行,才能产生效果,因此触发第一条指令执行的,必定不是某一条指令,而是只有不需要前置条件,上电即可触发的硬件。在 Intel 8086 CPU 架构中,上电之后 CPU 的 CS:IP 会被指向地址0xFFFF0,这个地址下会有一条指令,跳转到 BIOS 程序的真正入口点。那么问题又来了,这第一条指令又是从哪里来的呢?

这个点在《真象》里面是这么解释的。在 I/O 子系统中,我们曾经学过一种 CPU 的 I/O 方式,即内存映射 IO,内存映射 I/O 通过将外设于一块特别的内存区域绑定到一块,这样指令可以直接通过访问那块区域的内存来读写外设上面的数据。在 CPU 能够访问到的地址空间里面,我们可以将一部分地址空间用于 I/O,剩下的内存地址空间再绑定到物理内存上面。因为 BIOS 是计算机上面执行的第一行指令,所以 BIOS 必定是由硬件加载的,而这里的硬件,就是 ROM。ROM 也是一块内存,不过这块内存是非易失性的,这块内存的地址空间被硬件映射到内存地址空间0xF0000~0xFFFFF处,因此,当我们访问这块地址的时候,实际上我们是在访问 ROM 中的内容,就不会有加载的问题。

BIOS 的工作是什么?

当 BIOS 的第一行指令开始上 CPU 执行后,BIOS 的指令就会被源源不断地读进 CPU 并执行了。现在 CPU 正处于一个叫做实模式的状态下。为什么会是这个模式,以及实模式和保护模式的区别将在后续的内容中讲到,这里只需要注意,实模式下的访存方式,和我们在计算机组成里面学到的访存机制基本一致,是直接将 CS:IP 的值组装成地址之后通过地址总线访问,不需要额外的转换工作。Intel 8086 留给实模式的地址线只有 20 根,所以只能够访问到 1MB 的内存。随着 BIOS 的指令执行,它会检查所有外设的状态信息,如果没有检测到错误,那么下一步,就要开始准备中断向量表和中断服务程序,为下面的操作系统引导铺路。

BIOS 会在内存的最开始位置的 1KB 地址空间中,即0x00000~0x003FF中构建中断向量表(每个中断向量 4 字节,2 个字节是 CS,两个字节是 IP,共 256 个中断向量),接着是 256 字节的 BIOS 数据区(),再然后的约 57KB 之后(0x0E05B)是一些与中断向量表相应的中断服务程序(值得一提的是,这个地址正是《真象》提及的0xFFFF0的代码所跳转的位置)。构建好这些内容以后,就可以开始真正的 boot 操作了,这也是 BIOS 的最后一项工作,将 Bootloader 载入进内存。《艺术》研究的 Linux 0.11 版本将操作系统的内核代码分为三部分加载,第一部分就是加载 bootsect。bootsect 是 Linux 0.11 的引导程序,位于启动盘的第一个扇区。在《真象》里面,这个扇区的内容被称为 MBR,也就是大名鼎鼎的主引导记录。需要注意的是,主引导记录所存在的扇区里面最后两个字节必须是 0x55 和 0xaa,这两个魔数标识了这个扇区存在可执行的程序。这个扇区会被 BIOS 加载到内存以0x07C00为起点的位置。具体为什么会是这个地址,可以参考《真象》作者在书中的猜测与解释。MBR 是第一个从磁盘中获取的程序,也就是说其是第一个与我们的操作系统发生关联的程序,Linux 0.11 中的 bootsect 就是如此,只不过叫法不太一样。BIOS 将 MBR 载入内存之后,便利用一条跳转指令跳转到 MBR 所处的内存区域,开始执行 MBR 中的内容。

操作系统的接力赛

bootsect (MBR) 负责载入后续程序

MBR 主要负责后续程序的载入工作,通常 MBR 不会直接载入内核,而是先载入一个内核载入器,再由内核载入器陆续载入真正的内核部分。Linux 0.11 的 bootsect 也是如此。开始执行以后,bootsect 首先需要对内存进行规划,设置后续内容需要加载到的位置等。这里不赘述具体的内存分配规则,具体的内存分配可以看《艺术》书中第 8 页的描述。

内存区域规划完成之后,就可以开始执行具体的逻辑了。而 bootsect 所执行的第一个逻辑是将其从0x07C00的位置复制到内存0x90000的位置。为什么需要进行这个转移?因为其实 bootsect 被加载到的位置0x07C00其实是 BIOS 选择的地址,而并非是 bootsect 所希望的地址,因此 bootsect 需要将自己复制到内存中靠后的部分来将前面的内存另作它用。

完成复制之后,就可以开始将后续的区块载入进内存了。这个载入动作还是需要利用到先前的 BIOS 构建的中断向量表以及中断服务程序(0x13号中断)。bootsect 利用这个中断服务程序将磁盘第二到第五的个扇区的内容载入进内存的0x90200处,紧跟着之前复制的 bootsect(512 字节,换算到 16 进制正好是 0x200)后面。

第二批代码已经载入了,接下去就是载入第三批代码。为什么第二批和第三批要分别进行载入?因为第三批程序所要加载到的位置和前面的第二批程序是不一样的,第三批程序加载到位于内存0x10000起的位置,一共 240 个扇区。载入完之后 bootsect 的任务就已经完成了,需要跳转到加载到第二批代码 setup 程序中进行执行。

Setup 进行初始化工作

setup 开始执行以后,首先要利用 BIOS 提供的终端服务程序来从设备上面提取内核所需的数据,并将这些数据存放到内存的0x90000~0x901FD处。注意这里的数据存放已经覆盖了 bootsect 的代码。到此为止,操作系统内核程序的加载工作已经完成,系统将通过这些已经被载入到内存中的代码实现从实模式到保护模式的转换,包括打开寻址空间,打开保护模式,建立保护模式下的中断响应机制等与保护模式配套的相关工作,建立内存的分页机制等,最终进入 Linux 系统的 main 函数中。

在重建中断之前,setup 需要先清除现有的中断机制。在中断机制的重建过程中,系统必须关闭对中断的响应,否则当中断到来的时候,系统会跳到一个先前指定的中断服务函数处执行,但是此时这个位置的中断服务函数已经被清除了,执行该位置的指令可能引起意料之外的事情发生,因此必须禁止系统对中断进行响应,直到新的中断响应机制建立完成。

重建中断响应机制

在关闭中断以后,setup 程序会首先将位于0x10000处的内核程序复制到内存的起始位置0x00000处。这个动作不仅废除了 BIOS 的中断向量表,回收了地址空间,也使得内核代码占据了物理内存中的最开始部分。接下来,setup 要预先定义的信息初始化中断描述符表寄存器(IDTR)和全局描述符寄存器表(GDTR)。GDT 和 IDT 的具体作用可以看书上的解释,这里不方便长篇赘述。简而言之,GDT 用来索引所有进程的局部描述符表(LDT)和任务状态段(TSS),辅助完成进程中各段的寻址、现场保护与恢复,GDTR 寄存器存放的即是该表的地址。IDT 则是存放保护模式下中断服务程序的入口地址,类似实模式下的中断向量表,IDTR 也存放 IDT 的地址。

有了 IDT,就意味着不必将中断服务程序的入口地址放置在内存起始位置,可以由操作系统的设计者灵活安排。setup 此时建立好了 IDT,不过表里面还是空的,并没有内容。这影响不大,毕竟我们现在还处于关中断的状态下,还不需要中断服务程序的服务。

完成了 GDTR 与 IDTR 的设置之后,setup 就会打开 A20 地址线,开启 32 位的寻址空间。为什么要有实模式?实模式和保护模式的区别在哪里?这一点在《真象》的第 3、4、5 章有详细的描述,这里很难以简短的话说清楚,故不再赘述。开完地址线,紧接着就要重新构建新的中断响应机制。因为现在新的中断向量表 IDT 已经确定好地址了,剩下的就是往中断向量表里面填入中断服务函数,具体的中断服务号设置可以参考《艺术》第 24 页的内容。

进入保护模式

将新的中断服务函数地址写入到新的中断向量表以后,setup 就会把 CR0 寄存器的 PE (Protected Mode Enable) 标志置为 1,表明当前 CPU 工作在保护模式下。在这里,进入保护模式所需要的三个前提(1. 打开 A20 地址线;2. 加载 GDT;3. 将 CR0 的 PE 位置 1)的工作均已完成,CPU 已经运行在保护模式下了。setup 的工作就是为系统能够在保护模式下面运行打好基础。不过 setup 完成的工作还不够,后续的工作将交给 head.s 程序来继续完成。注意这时候系统的中断向量表尚未初始化完成,系统仍然处于关中断状态。

head.s 是进入 Linux 内核的 main 函数之前执行的最后一个程序,在此之前的 bootsect (MBR) 是由 BIOS 加载的并执行的,负责载入 setup、head.s 和内核代码,setup 是由 bootsect 加载并执行的,负责准备保护模式下的运行环境,head.s 则是负责创建用于内核分页机制的一系列数据结构,最后跳转到 main 函数开始执行。由于先前 setup 在实模式下预先构建好了 GDT 表,位于保护模式下的系统能够正确地使用段选择符进行寻址。然后 head.s 开始设置各类寄存器的数据,使其指向正确的内存位置。完成设置以后,head.s 就准备开始重建保护模式下的中断服务体系。

head.s 重建保护模式下地中断服务体系的第一步就是先把 IDT 的内容进行初始化,指向一个预先定义好的ignore_int函数。最后 head.s 需要废除当前的 GDT 并在内核中的新位置重新构建 GDT。为什么都已经在 setup 里面构建好了 GDT 却又要在 head 废除掉重新构建呢?这是因为 setup 在构建 GDT 时用的是 setup 模块设计代码时的数据,而之后 setup 所处的内存地址会被新的内存地址划分下的缓冲区覆盖,为了保护 GDT,只能将其挪到一个绝对安全的地方,也就是当前正在执行的 head.s 所处的内存位置。而因为要覆盖 head.s 的区域则必须等待 head.s 执行完毕后才能进行,所以复制 GDT 的任务就理所当然地交给了 head.s 进行。在重设了 GDT 后,一系列寄存器也需要跟着 GDT 进行重新设置,包括各个段寄存器等等。为了后续能够跳转到 main 函数执行,head.s 将 main 函数的地址压入栈中,方便之后通过 ret 指令直接跳转到 main 函数执行。

建立分页机制

在压栈完成之后,head.s 将开始创建分页机制,将内存起始区域的 5 页内容全部清空,在这里建立一个页目录表和 4 个页表,并按内存地址从高到低填写页表内容。页表内容填写完成之后,将页目录表基址寄存器 CR3 指向页目录表,并将 CR0 中的分页机制开关 PG 置位,启用分页寻址模式,内核的分页机制就构建完毕了。对于内核来说,虚拟地址到实际物理地址时一一对应的,可以非常直观地看到虚拟地址实际对应的物理地址,进而可以在编程的时候避免转换工作。最后,head 程序通过 ret 指令跳转到 main 函数开始执行。至于为什么使用 ret 指令跳转到 main 函数执行,感兴趣的小伙伴也可以看看《艺术》书中第 42 页的解释。到此为止,Linux 加载的第一阶段完成,完成了所有的前期准备,终于进入了万众瞩目的 main 函数开始执行。

Linux 启动概览,来自低并发编程的微信公众号文章

参考

  1. 《Linux 内核设计的艺术》
  2. 《操作系统真象还原》
  3. 低并发编程
  4. BIOS 从 FFFF0H 处开始执行指令的理解