5.1 Linux操作系统加载

PC上电或复位后,处理器跳转到BIOS,开始执行BIOS。BIOS首先进行加电自检,初始化相关硬件,然后加载MBR中的程序到内存0x7c00处并跳转到该地址处,接着由MBR中的程序完成操作系统的加载工作。通常,MBR中的程序也被称为Bootloader。当然,鉴于现代操作系统的复杂性,Bootloader已远远不止一个扇区大小。这一节,我们就以一个具体的Bootloader——GRUB为例,探讨操作系统的加载过程。为简单起见,我们只讨论典型的从硬盘加载操作系统的过程,所以后续的讨论全部是针对从硬盘启动的情况。

PC上硬盘的传统分区方式是MBR分区方案。但是MBR最大能表示的分区大小为2TB。因此,随着硬盘容量的不断扩大,为了突破MBR分区方式的一些限制,20世纪90年代Intel提出了GPT分区方案。对于不同的分区方式,加载操作系统的方式还是有些许不同的。也是为了简单起见,我们结合现在依然广泛使用的传统的MBR分区方案进行讨论。

5.1.1 GRUB映像构成

对于仅有512字节大小的MBR,又要留给分区表64字节,在这么小的一个空间,已经很难容纳加载一个现代操作系统的代码。于是GRUB采取了分阶段的策略,MBR中仅存放GRUB的第一阶段的代码,MBR中的代码负责把GRUB的其余部分载入内存。

但是GRUB分成几段合适呢?要回答这个问题我们还得从DOS谈起。

DOS的系统映像是不能跨柱面存放的,所以在DOS时代,磁盘的第一个分区索性并没有紧接在MBR的后面,而是直接从下一个柱面的边界开始。而且,按照柱面对齐,对系统的性能有很大好处,这对于现代操作系统同样适用。于是,在MBR与第一个分区之间,就出现了一块空闲区域。从那时起,这种分区方式成为了一个约定俗成,基本上所有的分区工具都把这种分区方式保留了下来。如果硬盘是MBR分区方案,用分区工具fdisk就可以看到这一点,以笔者的机器为例:

5.1 Linux操作系统加载 - 图1

根据fdisk的输出可见,每个磁道划分为63个扇区。硬盘的第一个分区起始于第63个扇区(从0开始计数)。也就是说,对于第0个磁道,除了MBR占据的一个分区,其余62个分区是空闲的。

于是,GRUB的开发人员就打算把GRUB“嵌入”到这个空闲区域,这样做的好处就是相对来说比较安全。因为某些文件系统的一些特性或者一些修复文件系统的操作,有可能导致文件系统中的文件所在的扇区发生改变。因此,单纯依靠扇区定位文件是有一定的风险的。而对于GRUB来说,在其初始阶段,由于尚未加载文件系统的驱动,因此,它恰恰需要通过BIOS以扇区的方式访问GRUB的后续的阶段。但是,一旦GRUB嵌入到这个不属于任何分区的特殊区域,则将不再受文件系统的影响。当然将GRUB嵌入到这个区域也不是必须的,但是因为这个相对安全的原因,GRUB的开发人员推荐将GRUB嵌入到这个区域。

但是这个区域的大小是有限的,通常,一个扇区512字节,一个柱面最多包含63个扇区。因此,除去MBR,这个区域的大小是62个扇区,即31KB。因此,嵌入到这里的GRUB的映像最大不能超过31KB。为了控制嵌入到这个区域中的映像的尺寸不超过31KB,GRUB采用了模块化的设计方案。

GRUB在嵌入的映像中包含硬件及文件系统的驱动,因此,一旦嵌入的映像载入内存,GRUB即可访问文件系统。其他模块完全可以存储在文件系统上,通过文件系统的接口访问这些模块,避开了因为如修复文件系统而引起文件所在扇区的变化而带来的风险。另外也可以很好地控制嵌入到空闲扇区的映像的尺寸。

由上述内容可知,GRUB将映像分为三个部分:MBR中的boot.img、嵌入空闲扇区的core.img以及存储在文件系统中的模块。这三个部分也对应着GRUB执行的三个阶段。在MBR分区模式下,以嵌入方式安装的GRUB的各个部分在硬盘上的分布如图5-1所示。

5.1 Linux操作系统加载 - 图2

图 5-1 在MBR分区模式下以嵌入方式安装的GRUB

boot.img以及core.img分别以读写磁盘扇区的方式访问,它们不属于任何一个硬盘分区,所以不会受到文件系统的影响。第三阶段的这些模块是存储在文件系统上的,虽然文件所在的扇区可能会变动,但是GRUB不再通过扇区访问而是通过文件系统访问。

1.MBR映像

boot.img主要功能是将core.img中的第一个扇区载入内存。为什么只是加载core.img的第一个扇区而不是加载整个core.img呢?答案还是因为那可怜的区区512字节,除去64字节的分区域表信息以及最后的2字节的引导标识,还要给BIOS保留一段参数空间,boot.img中可用的空间已经被瓜分得所剩无几。因此,索性boot.img中仅记录core.img的第一个扇区号,并仅将这个扇区号对应的扇区中的内容加载入内存,core.img其余部分的加载留给core.img的第一个扇区的代码去考虑吧。

boot.img对应的源文件是boot.S,其中保存core.img的第一个扇区的位置如下:

5.1 Linux操作系统加载 - 图3

boot.S中标号kernel_sector所在处,即boot.img中偏移GRUB_BOOT_MACHINE_KERNEL_SECTOR,即92字节(0x5c)处,记录的就是core.img第一个扇区在硬盘上所在的扇区号。后面讨论GRUB安装时,我们会看到,在安装GRUB时,GRUB的安装程序将根据core.img的第一个扇区占据的实际硬盘扇区号修改这里。事实上,如果GRUB采用的是嵌入模式,那么这里的扇区就应该是1,即紧接在MBR后面的一个扇区。

由于程序大小被限制在可怜的一个扇区内,不能奢望在这么小的程序内实现硬盘以及文件系统的驱动,所以,boot.img只能利用BIOS提供的中断向量为0x13的基于扇区的磁盘读写服务。以支持LBA模式的硬盘为例,读取扇区的代码如下:

5.1 Linux操作系统加载 - 图4

boot.img按照BIOS服务的要求,设置相应的寄存器,调用BIOS服务。BIOS负责将地址kernel_sector处指示的扇区号所在扇区的内容载入内存。boot.img最后把读入的扇区内容移动到符号kernel_address处指示的地址,并跳转到那里执行。符号kernel_address处的值为宏GRUB_BOOT_MACHINE_KERNEL_ADDR,如下代码:

5.1 Linux操作系统加载 - 图5

这个宏的值是0x8000,也就是说,GRUB第二阶段映像被移动到了这里,并且从这里继续执行。后面在讨论GRUB启动时,读者会看到,链接器给core.img的最初512字节分配的地址,也确实是从0x8000开始的。

另外,读者并不会在boot.S中看到关于分区表的部分,因为在安装GRUB时,安装程序负责将分区表写到boot.img中。

2.GRUB核心映像

core.img包括多个映像和模块,以从硬盘启动为例,core.img包含的内容如图5-2所示。

5.1 Linux操作系统加载 - 图6

图 5-2 core.img构成示意图

图5-2中diskboot.img占据core.img中的第一个扇区,它就是boot.img加载的core.img的所谓的第一个扇区。diskboot.img用来加载core.img中除diskboot.img外的其余部分,与boot.S的实现本质上并无不同,也是借助BIOS的中断服务。只不过boot.img加载一个扇区进入内存,而diskboot.img加载多个扇区进入内存而已。

与boot.img类似,diskboot.img也需要知道core.img的后续部分所在的扇区。显然,只有在将GRUB安装到磁盘时,才能知道core.img实际所占据的扇区。因此,在安装时,GRUB的安装程序会将core.img占据的扇区号写入diskboot.img中。相关代码如下:

5.1 Linux操作系统加载 - 图7

diskboot.img的最后12字节记录的是一个blocklist,每个blocklist代表一个连续的扇区,其对应的C语言的结构体如下:

5.1 Linux操作系统加载 - 图8

其中start代表这个连续的扇区的起始扇区,len表示扇区的数量,segment表示扇区加载到内存的段地址。

在diskboot.S中,注意标号blocklist_default_seg处的宏GRUB_BOOT_MACHINE_KERNEL_SEG,这是一个类似带参数的宏,对于使用x86架构的PC,MACHINE最后会被替换为"I386_PC",展开后为GRUB_BOOT_I386_PC_KERNEL_SEG:

5.1 Linux操作系统加载 - 图9

也就是说,diskboot.img将core.img中除diskboot.img外的部分加载到内存的段地址为0x820。在diskboot.img进行加载时,将段内偏移设置为了0,所以最终core.img的其余部分被加载到了从内存地址0x8200开始的地方。在前面讨论boot.img时,我们看到,boot.img将diskboot.img加载到了0x8000处。也就是说,diskboot.img正好占据了一个扇区(0x200字节)。

事实上,对于MBR分区方案,如果采用了嵌入式的安装方式,那么只要有一个blocklist就足够了。当使用非嵌入式的安装方式时,core.img可能被分块存储在磁盘上,因此,diskboot.img中可能存在多个blocklist,每一个blocklist代表一段连续的扇区。第一个blocklist位于diskboot.img的最后,每增加一个blockllist,向着diskboot.img开始的方向延伸。

为了控制core.img的体积,GRUB将core.img进行了压缩。显然diskboot.img是不能压缩的,因为boot.img中没有任何解压代码。因此,GRUB只将core.img中的kernel.img和模块进行了压缩。对于基于x86架构的PC,GRUB默认使用的是lzma压缩算法。当然安装GRUB前创建core.img时,用户也可通过命令行参数指定压缩算法,但是从2.0版本的代码来看,对于x86架构来说,只能使用lzma压缩算法。

既然有压缩部分,就要有负责解压的部分。GRUB将lzma算法的解压缩代码编译为lzma_decompress.img,连接在diskboot.img的后面。diskboot.img将core.img加载进内存后,将跳转到lzma_decompress.img,执行其中代码解压缩core.img后面的压缩部分。下面的代码就是diskboot.img加载完core.img后进行的跳转:

5.1 Linux操作系统加载 - 图10

宏GRUB_BOOT_MACHINE_KERNEL_ADDR定义如下:

5.1 Linux操作系统加载 - 图11

刚刚我们已经看到了,宏GRUB_BOOT_MACHINE_KERNEL_SEG值为0x800,于是左移4位后,宏GRUB_BOOT_MACHINE_KERNEL_ADDR的值为0x8000。可见,加载完core.img剩余部分后,diskboot.img跳转到了地址0x8000+0x200处,正是lzma_decompress.img。lzma_decompress.img解压后面的压缩的映像,最终跳转到kernel.img。

根据其名字我们就可以猜到了,kernel.img是GRUB的核心代码了。其中包括为底层具体的磁盘驱动以及文件系统驱动提供公共的服务层。kernel.img的主入口函数是grub_main。lzma_decompress.img解压后正是跳转到这个函数,从某种意义上讲,这里才是GRUB的真正开始。

5.1 Linux操作系统加载 - 图12

鉴于嵌入区域的尺寸有限,因此只有最关键的模块才能包含到core.img中,随着core.img一起嵌入到MBR后面的空闲扇区。那么哪些模块是关键模块呢?只有core.img支持文件系统,它才可以读入其他模块。所以,这就是磁盘驱动模块biosdisk.mod、MBR分区模式模块part_msdos.mod以及文件系统的驱动模块ext2.mod(虽然其名字为ext2,但是这个模块支持EXT系列文件系统)包含到core.img中的原因,它们的目的是驱动文件系统。

这几个模块虽然已经被diskboot.img加载进了内存,但是显然只是将它们简单地“放到”内存中还是不够的,因为这些模块就相当于目标文件,指令和数据地址都是从0开始分配的,比如以笔者机器上的GRUB的模块ext2.mod为例:

5.1 Linux操作系统加载 - 图13

所以需要为它们进行重定位,还是以这个模块为例,我们可以看到其有大量需要重定位的符号:

5.1 Linux操作系统加载 - 图14

这就是函数grub_main调用grub_load_modules的目的。这个函数执行完毕后,GRUB就支持文件系统了,访问磁盘上的文件时,无须再依靠原始的BIOS使用扇区的方式读写文件了。后续无论是读取GRUB的其他模块还是加载内核,都是通过文件系统的接口。