x86实模式到保护模式及Linux启动协议的演变

larmbr | 2013-09-28 | Tags: Linux内核, x86, 实模式, 保护模式, 内核启动

操作系统的启动,英文称为bootstrap,又有汉译为“自举”,这一个自指涉的词汇生动地描述了操作系统启动前所遇的矛盾:

  • 加电瞬间所有硬件处于一种随机状态,如何完成第一条指令的加载,如何了解整机的硬件状态,以便完成后续的操作系统启动。

  • 即使第一条指令成功运行,如何把操作系统从外存加载到内存,以便执行一开始的初始化行为。

这两个问题都有了成熟的解决机制,为操作系统的启动铺设好了真正环境,分别是采取BIOS[1]和引导程序的解决方案。本文将从操作系统角度, 详述x86平台上与Linux内核启动过程悉悉相关的从实模式(virtual mode)保护模式(proctected mode)的演变过程, 以及x86平台上的Linux内核启动协议的变化。

洪荒时代: 实模式

从实模式到保护模式的变化,不仅是Intel处理器的一部发家史,更是30年来PC处理器的发展史。

Intel处理器从4004的开基立业,到8008的初露锋芒,再到8080的引起哄动,标记着PC时代的曙光已经到来。1978年推出首枚16位处理器8086; 同年,又趁热打铁地推出性能更出色的8088处理器。该处理器的特点是把地址线扩展到到20条,由此可以寻址1M字节的内存空间(220=1M)。出于向后兼容的要求,该处理器的设计别出心裁,引入了很多人耳熟能详的“段”的概念,即把20位的物理地址划分为称为16位的段:16位的段内偏移。还引入了段寄存器,用以配合完成段基址 << 4 + 段内偏移到20位物理地址转换过程, 如图。

  段基址: 0x1F21 << 4 + 段内偏移: 0x1027 = 物理地址: 0x 2 0 2 3 7

    0x 1 F 2 1 0
  + 0x   1 0 2 7
  --------------
    0x 2 0 2 3 7

这种模式就叫做实模式(real mode), 它有以下几个问题:

  • 显然, 这种寻址方法的最大寻址值是(0xFFFF : 0xFFFF =)0x10FFEF, 这比20位地址总线能寻址的1M字节范围还大0xFFEF字节。该CPU采取的方法是Wrap Around**, 即对1M取模, 因此, 多出的0x100000 - 0x10FFEF实际是映射到0x0 - 0xFFEF。

  • 从地址构造模式看, 可以发现段基址有个特征,其低4位全为0,也就是说每个段的起始地址一定是16的整数倍, 这反映了这个名字的由来, 物理地址空间已经被人为地划分为64k(216)为大小的区间。但注意, 这只是一个人为的划分, 换句话说, 没有任何硬件机制的隔离, 一个进程只要修改段基址寄存器的内容, 就可以随意访问其他段的内容! 这是一个重大的安全漏洞。

  • 这种地址构造模式还有另一个问题, 也就是一个物理地址可以有多种表示方法。注意最终的物理地址中间12位是段基址和段内偏移重合的, 所以, 理论上最多可以有212种组合方式可以构造出同一个物理地址!

文明时代: 保护模式

保护模式的滥觞: 80286

1982年, 伴随着Intel处理器史上最后一枚16位处理器80286的发布, 一种新的模式被引入了, 即为保护模式(proctected mode)。这一处理器地址总线宽度提高到了24位, 这意味着可以寻址(224=)16M字节的空间。不过前面说它仍然是16位的处理器, 因为它向后兼容, 仍然实现了前述的段寻址模式: 16位的段基址 : 16位的段内偏移。不同的是, 这枚CPU能够对内存及一些其他外围设备做硬件级的保护, 限制了一些非法地址的访问, 从而实现了硬件机制上的保护, 这也是这种名字的由来。

该处理器的更大意义是, 它出于向后兼容的需要, 开启了操作系统启动时从实模式保护模式切换这一传统, 一直沿续至今。前面说过实模式的第一个问题, 就是地址的回绕(wrap around), 在80286的引入后, 更增添了许多麻烦: 现在已经能寻址最多16M字节的空间了, 不存在超过1M要回绕这一问题了。但是, 在机器刚开始启动的时候, 仍处于实模式, 只能访问1M字节的空间, 意味着此时A20 - A23这4根地址线是用不到的。于是, Intel采用了一种叫A20 Gate的解决方案:

  • 在系统开机时, CPU复位, 这A20-A23置为0, 即A20 Gate被禁用,则对0x100000-0x10FFEF之间的地址进行寻址时,还是取模(回绕),从0开始访问内存。

  • 在进入保护模式后, 24根地址线全部启用, 即A20 Gate被开启,则对0x100000-0x10FFEF之间的地址进行寻址时,访问实际的地址对应的物理内存。

保护模式的辉煌: 32位处理器时代

1985年,Intel推出了里程碑式的80386处理器, 也开启了一个称为i386x86的光辉时代。这枚处理器不仅是首枚32位处理器,还引入分页模式,而分页模式是现代操作系统实现虚拟内存的先决条件。

同样为了向后兼容,80386保留了段机制, 但在保护模式下, 此时的16位段寄存器[2]存放的不再是段基址,而是一个13位的索引值,指向一张叫全局描述符表(GDT)局部描述符表(LDT)的某一项,表项长8字节, 包含着32位的段基址, 段长度, 段的访问权限之类的信息。前述的16位寄存器除了13位索引值外,剩下的两位(0-1)表示请求权限,一位(2)表示高13位是GDT还是LDT的索引值。此时的段寄存器被称为段选择子(selector)。 于是, 使用兼容的段基址和1段内偏移的方式, 通过一个间接的GDT表或LDT表, 从中取出段基址, 再加上段内偏移, 形成一个32位的地址。

  • 如果分布模式有启用, 这个地址就叫做线性地址(linear address), 要通过分页单元进一步处理, 形成最终的物理地址。

  • 如果分布模式没启用, 这个地址就是最终的物理地址。

Linux内核启动协议

Linux在X86平台上的启动协议随着内核的发展及硬件的发展,已经变得很复杂(详见内核源代码树Documentation/x86/boot.txt)。总的来说,启动协议分为旧内核(zImage)时代大内核(bzImage)时代(从协议2.0开始,已经发展到2.10)。接着将简要介绍这两种协议。

旧内核(zImage)时代

在旧内核时代(内核版本< 1.3.73),Linux内核映像还较小(zImage)。此时典型的物理内存布局如图1所示:

          |                        |
  0A000   +------------------------+
          |  Reserved for BIOS     |  Do not use.  Reserved for BIOS EBDA.
 09A000   +------------------------+
          |  Command line          |
          |  Stack/heap            |  For use by the kernel real-mode code.
 098000   +------------------------+
          |  Kernel setup          |  The kernel real-mode code.
 090200   +------------------------+
          |  Kernel boot sector    |  The kernel legacy boot sector.
 090000   +------------------------+
          |  Protected-mode kernel |  The bulk of the kernel image.
 001000   +------------------------+
          |  Boot loader           |  <- Boot sector entry point 0000:7C00
 001000   +------------------------+
          |  Reserved for MBR/BIOS |
 000800   +------------------------+
          |  Typically used by MBR |
 000600   +------------------------+
          |  BIOS use only         |
 000000   +------------------------+
图1: 旧内核时代物理内存布局

从图中看出,最高内存是0x0A000,即640K。这个数字历史缘于上世纪80年代,当时IBM所推出的第一台PC机采用的是前述的Intel 16位8088芯片,可供寻址的物理内存总共为1MB。这1MB中的低640KB供DOS以及应用程序使用,而高端的384KB则被留作它用,其中低端的640KB被称为常规内存(normal memory),高端的384KB则被称为保留内存(reserved memory),这两种不同的内存类型通过物理地址0xA0000得以分隔,此后这个分界线便被确定下来并沿用至今。

下面将结合linux-0.1.1版本的启动代码及上图,简述旧内核(zImage)时代启动协议。

  • 从0x00000至0x01000这4k空间主要是保留给BIOS使用,存放加电自检期间检查到的系统硬件配置。比如BIOS在初始化硬件时,会把中断向量初始化放在地址0开始的物理内存处。

  • 接下来从0x01000开始,就是可以存放引导程序的地方。引导程序负责把内核映像加载到内存,再将控制权交给内核。BIOS在完成其加电自检(POST), 检测外围设备等工作后, 就把磁盘设备上的MBR(Master Boot Record)加载到物理地址0x07c00处(而不是恰好在0x01000)。其实MBR正是存放着引导程序。MBR块是硬盘第一个扇区,它的大小只有512字节。如此小的引导程序能把内核映像加载到内存吗? 在Linux初期,内核映像(zImage,有别于后来的大内核bImage)是很小的, 比如0.1.1版本内核的大小才196KB。所以,512byte的引导程序是可以把内核映像加载到内存的,这部分代码对应于之前的/boot/boot_sect.S这个文件,从名字可以看出,boot sector意即启动扇区,正是指MBR块。它在编译后大小刚好为512字节,能放在MBR块处。

  • 从图1中可以看出0x90000-0x90200处标明为内核启动扇区,为何此处又有一个大小为0x200(刚好512字节)的启动扇区块? 其实,前述的boot_sect.S文件,不仅做的是加载内核映像,它的第一步动作是把自身从0x7c00复制到0x9000处, 再加载实模式内核模块kernel setup, 最后才加载保护模式映像模块system。至于为何要移动自身这一步动作,是因为后续的加载setup模块动作加载到0x90200,需要用到跳转指令,而实模式下一个段大小64k, 所以boot_sect.S必须把自身移动到距离加载setup模块一个段距离内。

  • 接下来可以看到从0x90200处开始是kernel setup区域。boot_sect.S移动自身到0x9000后,会继而从硬盘紧接着放MBR块的kernel setup模块(占4扇区)加载到0x90200处。这个setup模块是内核实模式代码,由/boot/setup.S文件编译而成。它的作用是把BIOS加电自检时保存在最开始(0x0处)的硬件信息复制到一个安全的地方(0x9000处,没错,将会覆盖boot_sect块,但此时boot_sect已经执行完毕,没有用处了), 以备内核接着使用。

  • 接着kernel setup模块会把保护模式内核映像(由boot_sect.S加载的system模块,加载在0x1000处,从图中亦可印证)整体移动到物理地址0x00000处。此外,为了为接下来的system模块在保护模式下运行,还要进行临时设置中断描述符表(IDT)和全局描述符表(GDT)的操作。从图中可看到,从0x98000开始开辟作为实模式下的setup模块使用的栈空间, 以作为之后C语言接手系统后, 各个函数的活动空间。

  • 最后, 系统的控制交割给保护模式下的system模块,由它完成更多的系统初始化功能,此处不再赘述。

以上整个过程如图2所描述。

图片

图2: 旧内核时代启动过程内核在内存中的位置变化

大内核(bzImage)时代

随着内核的发展,内核体积也越来越庞大,进入了大内核时代。像旧时代靠内核自身的512字节的引导程序已经无法完成如此复杂的功能,需要引入专门的引导程序。同时,启动协议也相应地进入了2.0时代。众所周知的引导程序有LInux LOader(LILO)及广泛使用的Grand Unified Bootloader(GRUB)。下面举例GRUB说明一下引导程序的加载内核的过程。

  • 首先执行基本的引导装载过程,该程序通常位于主引导记录(MBR)中,大小为512字节,由BIOS将其装入RAM中物理地址0x00007c00处,这就替代了旧内核时代内核自身的引程序,它的任务是建立实模式栈并利用BIOS指令将第二引导加载过程装入内存。

  • 第二引导加载过程(又称次引导过程)随后从磁盘读取可用操作系统的映射表,并提供给用户一个提示符,当用户选择需要被载入的操作系统,或是经过一段时间的延迟自动选择一个默认值之后,次引导过程便将相应分区下的内核映像以及initrd装载到内存中,而前述的映射表是GRUB通过读取/boot/grub/grub.conf文件中所设置的内容生成的。另外次引导过程还包括对特定文件系统(如ext2,ext3等)的支持以及对内核启动代码的初始化等职责,这就决定了次引导过程将占用较大的存储空间——连续多个扇区,从而无法装进单个扇区中,因此GRUB通常将该过程放在特定的文件系统中(通常是boot所在的根分区)。

  • 次引导过程拷贝到内存的目标中包括一个名为initrd的文件,该文件的全称为boot loader initialized RAM disk,即bootloader初始化内存盘。因为在Linux内核的启动过程中将会加载位于硬盘上的根文件系统,与硬盘相应的设备驱动程序又被存储在该文件系统中,这就导出一个矛盾:加载根文件系统需要使用设备驱动程序,而设备驱动程序在根文件系统加载之前又无法载入内存运行。当然解决该矛盾最简单的方法便是将设备驱动程序编译进内核,然而如今的Linux内核支持多种硬件架构,因此根文件系统可能被存储在IDE、SCSI、SATA、U盘等多种介质中,如果将所有的硬件驱动均编译进内核无疑将使得内核臃肿不堪,引入initrd正是为了解决如上问题,它主要用于实现一些模块的加载以及文件系统的安装等功能。在次引导过程完成相应文件的加载之后将会执行一个长跳转指令,该指令跳过实模式内核代码的前512个字节,也即跳到由前述链接脚本所指定的执行入口_start处开始执行,而所跳过的512字节正是我们之前剖析的Linux内核自带的引导程序,整个的衔接过程可谓天衣无缝。而内核自带的boot_sect模块已经失去了它的作用,所以,从2.6.24版本的内核开始,已经把boot_sect.Ssetup.S文件合并成为一个header.S文件。

此时的使用2.0启动协议的bzImage内核的内存空间布局如图3所示。

          ~                        ~
          |  Protected-mode kernel |
   100000 +------------------------+
          |  I/O memory hole       |
   0A0000 +------------------------+
          |  Reserved for BIOS     |   Leave as much as possible unused
          ~                        ~
          |  Command line          |   (Can also be below the X+10000 mark)
  X+10000 +------------------------+
          |  Stack/heap            |   For use by the kernel real-mode code.
  X+08000 +------------------------+
          |  Kernel setup          |   The kernel real-mode code.
          |  Kernel boot sector    |   The kernel legacy boot sector.
        X +------------------------+
          |  Boot loader           |   <- Boot sector entry point 0000:7C00
   001000 +------------------------+
          |  Reserved for MBR/BIOS |
   000800 +------------------------+
          |  Typically used by MBR |
   000600 +------------------------+
          |  BIOS use only         |
   000000 +------------------------+

   ... where the address X is as low as the design of the boot loader permits.
图3: 大内核时代物理内存布局

从图3可以看出,此时的内存大小也跟随着CPU寻址空间的扩展而增加到1M以上,但从0xA0000~0x100000-1范围内的物理内存通常保存BIOS例程,并且映射ISA图形卡上的内部内存。这个区域就是IBM兼容PC上从640KB到1MB之间的著名的洞——图1中的I/O memory hole:物理地址存在但被保留,不能由操作系统使用。

此外,我们发现内核实模式启动扇区起始地址被定义为一个未知数X, 并且指出X的取值应是引导程序所允许的尽量低的地址值。从X开始,实模式的boot sector和setup代码,加上实模式所用作栈或堆的空间,不能超过0x9A000。因为一些新的BIOS需要使用从起始地址0x9A000开始的额外内存空间,该内存空间即扩展的BIOS数据区EBDA(Extended BIOS Data Area)。因此引导程序需要利用BIOS的”INT 12h”例程来探测该BIOS所允许的最低内存地址是多少,保证实模式内核代码空间不超过这个点,以免数据被EBDA数据区覆盖。

最后,保护模式内核代码被加载到0x100000(即1M)处,而不是旧内核时代加载在0x10000处。这是大内核(bzImage)和小内核(zImage)的显著区别。

* * * * * * 全文完 * * * * * *

[1]: 现在已经有由Intel开始研发, 其后由一个专门的论坛研发维护的新标准UEFI, 用于取代老旧的BIOS。

[2]: 其实段选择器还有32位隐藏的部分, 存放着段基址, 在第一次访问描述符表后会把当前段基址存放于此处, 用于加快访问, 所以它实际上是48位, 详见Intel文档。

分享到 --

本文内容遵从CC版权协议,转载请注明出自larmbr.com