万书网 > 文学作品 > 深度探索Linux操作系统 > 第5章 从内核空间到用户空间

第5章 从内核空间到用户空间



前面,我们从无到有,编译了内核,构建了initramfs和一个基本的根文件系统,成功启动了用户空间的第一个进程。虽然我们只是迈出了一小步,但是这是关键的一步。在此基础上,我们可以放开手脚,去探索曾经的遥不可及。但是,我们也才刚刚破冰,学而不思则罔,因此,在继续构建一个完整的操作系统之前,我们先来更深入的探索一下这一切是如何发生的。

曾经不止一次,笔者在各个技术文章、书籍、甚至顶尖高校的讲义中,都看到类似的论述:内核首先进入实模式,然后从实模式跳入保护模式,事实果真如此吗?在这一章中,我们首先从Linux操作系统的加载谈起。

对于普通程序,它们运行在操作系统已经为其准备好的环境中,操作系统则没有这么幸运,其运行在裸机上。操作系统需要在裸机上自己引导自己,而且还要为运行进程搭建好环境。因此,本章的5.2节和5.3节将讨论内核是如何自解压以及如何初始化的。

操作系统最终的目的之一是承载进程。因此,在本章的最后,我们讨论了进程的加载和运行。提及进程的加载和运行,我们几乎将所有的关注都放在了内核上,却往往忽略了另外一个为进程辅以建立运行环境的重要角色:动态链接器。在进程加载中,相当一部分烦琐而又重要的工作由动态链接器完成。因此,除了讨论进程在内核中的加载过程外,我们也深入探讨了进程在用户空间的加载和链接过程。



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就可以看到这一点,以笔者的机器为例:



根据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 在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的第一个扇区的位置如下:



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模式的硬盘为例,读取扇区的代码如下:



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



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

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

2.GRUB核心映像

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

图 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中。相关代码如下:



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



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

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



也就是说,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后进行的跳转:



宏GRUB_BOOT_MACHINE_KERNEL_ADDR定义如下:



刚刚我们已经看到了,宏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的真正开始。



鉴于嵌入区域的尺寸有限,因此只有最关键的模块才能包含到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为例:



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



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



5.1.2 安装GRUB

通常,在安装操作系统的最后,操作系统安装程序将会为用户安装GRUB。当然,有时我们也会手动安装GRUB。但是都是通过GRUB提供的工具,执行的命令如下:



事实上,在这个安装命令的背后,GRUB的安装过程分为两个阶段:第一阶段是创建core.img,GRUB为此提供的工具是grub-mkimage;第二阶段是安装boot.img及core.img到硬盘,GRUB提供的工具是grub-setup。为了方便,GRUB将这两个过程封装到脚本grub-install中。

在创建core.img时,grub-mkimage需要获取需要加入core.img的模块,GRUB也提供了相应的工具grub-probe。grub-install利用这个工具根据内核映像所在的介质,自动探测所需要的模块,并将它们传给grub-mkimage。以笔者机器为例,使用grub-probe探测磁盘分区方式和文件系统的的方法如下:



1.创建映像

grub-install首先调用grub-mkimage创建core.img,我们结合其源代码来讨论core.img的创建过程。



根据上面的代码可见,创建core.img的主要过程如下:

1)generate_image读取kernel.img到内存,见代码第7~9行。

2)除了kernel.img外,还要将一些模块合并到core.img中。传递给函数generate_image的参数mods是一个数组,其中记录的是每个要合并到core.img中的模块。但是这些模块可能还依赖其他模块,所以代码第4~5行是检查这些模块的依赖模块,并将这些模块记录到链表path_list中。然后,第11~16行的代码将这些模块全部加载到kernel.img的后面。

3)至此,kernel.img和各个模块组成的core.img组装完成。为了在62个扇区中容纳下core.img,所以代码第18~19行压缩core.img。对于基于IA32架构的PC,GRUB使用的默认压缩方法是LZMA。

4)既然压缩了core.img,那么就得有人来负责解压缩。如同内核采用的方法,GRUB也在压缩的core.img前面附加了一段未经压缩的指令,见代码第21~37行。如果core.img使用的是LZMA压缩方法,则generate_image读取lzma_decompress.img,将其附加到core.img的前面。

5)如果core.img是为基于IA32的PC创建的,那么在core.img的最前面还要附加一个diskboot.img,代码第43~66就是准备diskboot.img。因为diskboot.img负责将core.img中除diskboot.img的部分读入内存,因此,diskboot.img需要知道core.img所在的扇区信息。因为这里已经确定了core.img占用的大小,所以,generate_image将core.img占用的扇区数写入了diskboot.img最后的blocklist中,这就是代码第53~59行的目的。准备好diskboot.img后,generate_image将其写入了在磁盘上保存core.img的文件,见代码第61~62行,其中out就是对应的文件core.img。

6)在将diskboot.img写入磁盘文件core.img后,generate_image将内存中的core.img的其他部分,包括lzma_decompress.img、kernel.img以及需要合并到core.img中的模块,也写入磁盘上的文件core.img中,紧接在diskboot.img之后。至此,core.img映像创建完毕。

2.安装映像

创建完core.img映像后,grub-install将调用grub-setup将core.img(包括boot.img)安装到硬盘。下面我们结合grub-setup的代码来讨论这一过程。



根据上述代码可见,安装GRUB的主要过程如下:

1)grub-setup首先读取boot.img和core.img到内存,见代码第4~6行。

2)boot.img的安装位置是固定的,即MBR,但是grub-setup需要确定core.img安装的扇区。对于不同的情况,确定的方法是不同的。代码第8~10行是针对多个磁盘组成的逻辑盘的情况。否则依次尝试使用具体分区方案以及文件系统提供的embed函数,见代码第11~16行。代表MBR分区方案的对象是msdos,其中提供了获取安装GRUB所在的扇区函数pc_partition_map_embed,该函数将计算core.img安装的扇区,并将结果保存到数组sectors中。变量nsec记录的是core.img占用扇区数。

3)在确定了core.img的安装扇区后,显然要将diskboot.img中的blocklist填充上,代码第18~20行就是在做这件事。

4)一旦确定了core.img安装的扇区,grub-setup还要修订boot.img。虽然对嵌入式安装而言,diskboot.img就安装在第2个扇区,但是GRUB不能进行这样的假设,因为还有可能使用非嵌入的安装方式。因此,grub-setup需要设置boot.img中diskboot.img所在的扇区,见代码第22行。其中所谓的first_sector就是core.img的第1个扇区,即diskboot.img占据的硬盘扇区。

5)映像准备好后,如果采用嵌入式安装,那么需要将core.img嵌入MBR后面的空闲扇区,即数组sectors中记录的扇区,见代码第24~27行。对于嵌入式安装,因为是嵌入在一块连续的扇区,所以diskboot.img中只需要记录一个blocklist,如图5-3所示。

图 5-3 嵌入式安装GRUB示意图

6)最后,grub-setup将boot.img写入MBR,见代码第29~30行。

为简单起见,上面代码中略去了非嵌入式安装的情况。在非嵌入式安装情况下,GRUB不需要将保存在文件系统中的core.img写入到MBR后面空闲的扇区中,而只需要将文件系统中的core.img所在的扇区写入disboot.img的blocklist,将diskboot.img所在的扇区写入boot.img即可。因为core.img很有可能不是连续存储在硬盘上的,所以diskboot.img中需要记录多个blocklist,这就是diskboot.img后面预留了多个blocklist空间的原因,如图5-4所示。

图 5-4 非嵌入式安装GRUB示意图



5.1.3 GRUB启动过程

我们知道,在PC启动时,BIOS会将MBR中的程序加载到内存的0x7c00处,并跳转到那里开始执行。对于GRUB来说,对应MBR中的映像是boot.img,在编译boot.img时,编译脚本确实也是指导链接器从0x7c00开始为其指令和数据分配地址的,如下面编译脚本片段中使用黑体标识的部分:



读者可能会注意到脚本中映像的后缀是"image",而不是"img",这是因为编译脚本最后会将ELF格式的boot.image转换为裸二进制格式,并命名为boot.img。其余映像也是如此处理。

当跳转到0x7c00后,GRUB开始执行,其启动过程大体如图5-5所示。

图 5-5 GRUB启动过程

(1)boot.img加载diskboot.img

boot.img使用BIOS中断号为0x13的基于扇区的磁盘读写服务加载diskboot.img。GRUB使用从0x70000开始处的一段内存作为BIOS读缓存,所以BIOS首先将diskboot.img读到内存0x70000处,然后boot.img再将其移动到内存0x8000处。根据下面脚本片段中黑色标识的部分可见,链接器确实是从0x8000为diskboot.img分配地址的:



boot.img将diskboot  image加载完成后,跳转到diskboot中的第一条指令处继续执行。

(2)diskboot.img加载core.img

与boot.img类似,diskboot.img使用BIOS中断号为0x13的基于扇区的磁盘读写服务加载core.img。BIOS将lore.img读到缓存0x70000处,然后diskboot.img将其移动到0x8200处,最后跳转到0x8200处开始执行lzma_decompress.img。

(3)core.img自解压

在讨论GRUB创建映像时,我们看到,连接在core.img前端的lzma_decompress.img并没有被压缩,而这段没有被压缩的部分的作用就是负责解压core.img其余压缩的部分。我们来看看构建lzma_decompress.img的脚本片段:



core.img被diskboot.img加载到0x8200处,lzma_decompress.img作为core.img的开头,其地址应该从0x8200分配,根据上面的编译脚本,即可见这一点。

从脚本可见,lzma_decompress.img的开头是startup_raw.S,其正是解压core.img的地方,见下面的代码:



其中,_LzmaDecodeA就是进行解压的函数,其实现在文件lzma_decode.S中,所以startup_raw.S将这个文件包含进来。lzma_decompress.img将core.img解压到内存GRUB_MEMORY_MACHINE_DECOMPRESSION_ADDR处,定义如下:



因为低端内存捉襟见肘,所以GRUB使用从1MB开始的空间作为解压的缓冲区。

GRUB之所以能访问1MB以上的内存地址,是因为开启了CPU的保护模式。但是GRUB并没有启用分页,而是采用了段式寻址,而且还采用了特殊的平坦内存模型(flat  model),即段基址为0。平坦内存模型的寻址比较简单,某种意义上就是短路了CPU的段机制,对于未开启分页的平坦内存模型,偏移地址就是最后的物理地址。GRUB中使用了平坦内存模型的GDT的设置如下:



core.img解压完成后,lzma_decompress.img将跳转到解压的core.img处继续执行。根据前面讨论的core.img的构成,core.img的压缩部分包括kernel.img和必要的模块,所以经过这次跳转后,GRUB跳转到了映像kernel.img的开头。

(4)kernel.img将自己复制回0x9000

因为Linux内核和initramfs可能被加载到内存从1MB开始的任何地方,所以GRUB要给它们指路。为此,GRUB虽然使用了1MB以上的区域作为解压使用的缓冲区,但是解压后要移动回1MB以下的区域。

我们看一下kernel.img的构建脚本:




根据kernel.img的编译脚本可见,kernel.img的开头是startup.S,移动kernel.img的代码就在这个文件中:



根据startup.S的代码可见:

1)startup.S调用x86的指令movsb移动映像。

2)寄存器esi中的值是移动的源地址。在解压core.img时,解压后的core.img的地址,即1MB,已经保存在寄存器esi中了。

3)寄存器edi的值是移动的目的地址。在代码中,寄存器edi的值被设置为符号_start的地址,这个符号地址是多少呢?注意编译脚本中传给链接器的参数kernel_exec_LDFLAGS,其请求链接器从0x9000开始为kernel.img分配地址,而_start恰位于kernel.img的开头,所以符号_start的地址是0x9000。因此,kernel.img就是将自身移动到0x9000处。

4)寄存器ecx的值是移动的字节数。从代码中计算ecx的值来看,startup.S只移动从_edata到_start的这段指令和数据,而_edata是链接器定义的代表kernel.img的数据段结束的位置,也就是说,startup.S只是将kernel.img移动到了0x9000处,并没有移动模块。在讨论core.img的构成时,我们已经谈到模块需要重定位,所以不能简单地进行移动。

5)在移动完kernel.img后,startup.S再次使用跳转指令jmp跳转到了移动后的位置继续执行,并转入了GRUB真正的核心部分,即C语言写的函数grub_main处。



函数grub_main调用函数grub_load_modules装配模块,然后调用grub_load_normal_mode加载normal模块,这个模块拉开了加载Linux内核和initramfs的大幕。



5.1.4 加载内核和initramfs

normal模块读取并解析GRUB配置文件grub.cfg,然后根据grub.cfg中的具体命令,加载相应的模块。命令和模块的关系记录在文件command.lst中,通常GRUB将该文件被安装在/boot/grub/i386-pc目录下,normal模块加载时将加载这个文件。command.lst包括两列,第一列是命令,第二列是该命令所在的模块:



以下面的GRUB配置文件中的片段为例:



当normal模块遇到命令如"linux"、"initrd"时,将到文件command.lst中查找这些命令所在的模块。根据command.lst可知,命令"linux"、"initrd"都在模块linux中,因此,normal模块将加载linux模块。然后,调用linux模块中的命令"linux"、"initrd",完成Linux内核以及initramfs的加载。本节中,我们通过分析这两个命令对应的回调函数,来探讨Linux内核和initramfs的加载。

1.引导协议

Bootloader负责加载内核,显然Bootloader和内核之间需要分享一些数据。典型的比如Bootloader需要知道内核的保护模式部分希望加载到什么位置?内核是不是可重定位内核?从内核的角度,则需要清楚Bootloader将initramfs加载到了内存的什么位置、initramfs的尺寸是多少等。

因此,内核和引导程序之间需要有个约定,这个约定称为引导协议(boot  protocol),也称为16位引导协议(16-bit  boot  protocol)。该协议约定了Bootloader和内核之间分享的数据存储的位置、大小以及哪些由内核提供给Bootloader,哪些由Bootloader提供给内核等。

在进入保护模式后,内核将不会再切换到实模式,而硬件相关的参数必须在实模式下借助BIOS中断获取。为此,在早期的内核中,内核中包含了一部分实模式代码,即setup.bin,其主要功能之一就是为保护模式部分的代码获取硬件的信息,也就是内核中所说的零页(zero-page)中规定的信息。

随着新的BIOS标准,如EFI、LinuxBIOS等的出现,出现了32位引导协议(32-bit  boot  protocol)。在32位引导协议下,除了传统的16位协议,Bootloader取代内核中实模式部分负责收集硬件信息(即零页信息)的功能。而且Bootloader会将CPU切换为保护模式,在内核和initramfs加载完成后,Bootloader不再跳转到内核实模式部分,而是直接跳转到内核的保护模式部分。

下面我们就来看看在32位引导协议下,内核和Bootloader之间是如何分享信息的。

(1)内核向Bootloader传递信息

内核中引导协议的相关部分在文件arch/x86/boot/header.S中:



上面列出了几个典型的信息,其中ramdisk_image和ramdisk_size是由Bootloader负责填充的,告诉内核initramfs被加载到了内存的什么位置,占据多大空间。而kernel_alignment、relocatable_kernel、pref_address则由内核负责填充,告知Bootloader内核加载的对齐要求、内核是否是可以重定位的以及内核希望的加载地址等信息。

引导协议规定,协议数据从内核映像的偏移0x1F1处开始,所以在header.S中使用汇编伪指令.section".header"指示编译器将引导数据所在的段定义为".header",并在setup.bin的链接脚本中将此段安排在内核映像偏移0x1F1处:



setup.ld指示链接器将段".header"链接在地址497处,其十六进制即0x1F1,这恰是引导协议约定的位置。GRUB加载内核时,将首先从setup.bin中读取引导协议相关的信息。

对于零页中规定的信息,并不需要从内核传递给Bootloader,所以setup.bin中定义的依然是传统的16位引导数据。

(2)Bootloader向内核传递信息

Bootloader向内核传递的信息,要比内核向Bootloader的传递复杂一些。因为除了传统的16位引导信息外,还需要向内核传递零页信息。Bootloader和内核均为此定义了一个数据结构,通常将这个结构体称为引导参数(boot  parameters)。GRUB中的定义如下:



在结构体linux_kernel_params中,从偏移0x1F1处,即成员setup_sects处,保存的传统的16位引导协议的数据。除此之外,结构体linux_kernel_params中保存就是零页信息了,如显示相关的信息、内存相关的信息等。

GRUB在启动内核前,将创建一个结构体linux_kernel_params类型的变量linux_params,首先从setup.bin中读取16位的引导数据到这个变量中,代码如下:



grub_cmd_linux是模块linux中的命令linux的回调函数,跟在命令linux后的第一个参数就是内核映像文件,因此,这里的argv[0]就是内核映像文件。函数grub_cmd_linux从内核映像的开头读取了结构体linux_kernel_header大小的一块数据,结构体linux_kernel_header定义的就是传统的16位的引导数据:



然后,grub_cmd_linux将其复制到变量linux_params中。

GRUB除了使用从内核中读取的信息,比如内核希望加载的地址,也将实际加载的情况(比如initramfs加载的位置)填充到变量linux_params中。另外,GRUB还要按照32位启动协议规定,检测零页定义的信息,填充到变量linux_params中。比如在上面列出的函数grub_cmd_linux的代码片段中设置linux_kernel_params中的ext_mem和alt_mem等。再举一个典型的例子,在引导Linux内核前,GRUB会将探测到的内存的信息记录在变量linux_params中:



在内核中,引导信息定义的数据结构如下:



其中,结构体setup_header中记录的就是传统的16位引导信息,结构体boot_params对应于GRUB中的结构体linux_kernel_params,即记录的是传统的16位的引导信息和零页信息。在初始化时,内核会将GRUB准备好的这些信息复制到内核的地址空间中,代码如下:



内核重复调用汇编指令movsl进行复制。复制的源寄存器esi在GRUB中启动内核前设置指向linux_kernel_params对象,目的地址是内核中定义的结构体boot_params类型的变量boot_params。

如果用户通过GRUB向内核传递了参数,即我们所说的grub.cfg中的命令行参数,则GRUB将这些参数保存在一块内存中,并设置引导参数结构体中的字段cmd_line_ptr指向这块内存,内核也要将这些参数从GRUB复制到内核。

2.加载内核及initramfs

理解了引导协议后,接下来看看GRUB是如何加载内核以及initramfs到内存的。

模块linux初始化时注册了两个命令,一个是命令linux,另外一个是initrd。命令linux的作用是加载Linux内核,其对应的回调函数是grub_cmd_linux;命令initrd的作用是加载initramfs,其对应的回调函数是grub_cmd_initrd,代码如下:



(1)加载内核

前面提到过,在32位引导协议下,内核的实模式部分已经退化为仅负责承载引导协议,其功能部分已经被GRUB取代了,所以实模式部分无须再加载到内存了。GRUB只需将内核的保护模式加载到内存即可,相关代码如下:



在讨论函数grub_cmd_linux前,首先解释代码中出现的两个容易混淆的变量——prot_mode_mem和prot_mode_target,见代码第1行和第2行。这两个变量很容易让人困惑,但是仔细观察它们的变量类型可以发现,prot_mode_mem是指向一块内存的指针,所以读写内存操作应该使用这个指针。而prot_mode_target记录的仅仅是一个内存地址,典型的用作跳转指令的操作数。比如跳转到内核的保护模式部分时,指令jmp后接的操作数是内存地址,而不应使用指向内存的指针。

函数grub_cmd_linux执行的主要操作如下:

1)既然准备将内核映像加载到内存,函数grub_cmd_linux首先确定内核希望加载的地址,见代码第15~22行。以大于0x020a版本的引导协议为例,如果内核支持重定位,那么GRUB将从引导协议中读取的pref_address作为内核加载的位置。否则,将内核加载到位置GRUB_LINUX_BZIMAGE_ADDR,该宏在GRUB中定义为1MB:



2)确定了加载地址后,grub_cmd_linux调用函数allocate_pages为内核映像分配内存,见代码第24~25行。同时,函数allocate_pages设置指针prot_mode_mem指向为内核分配的内存,而将该内存的地址记录在变量prot_mode_target中。

3)函数grub_cmd_linux也将内核加载的物理地址,即变量prot_mode_target的值,记录在引导参数的成员code32_start中,见代码第27~28行。后面启动内核时的跳转命令将以code32_start记录的物理地址作为操作数。这里,GRUB的开发者们应该是考虑了某些特殊情况,因为针对我们的具体情况,setup.bin中的code32_start定义为1MB:



而宏GRUB_LINUX_BZIMAGE_ADDR也是1MB,所以lh.code32_start和GRUB_LINUX_BZIMAGE_ADDR是相互抵消的,变量code32_start的值就是prot_mode_target。

4)准备好了内核映像加载的内存后,下面就要准备从硬盘将内核映像读入内存。因为实模式部分不需要加载,所以读取前需要将映像文件的指针定位到保护模式。显然只有内核知道自己的实模式部分有多大,因此,GRUB需要从承载引导协议的setup.bin中读取实模式部分的尺寸。

最初,内核为了支持从软盘启动,实模式部分分为bootsect以及setup两部分。后来引导统一由Bootloader来负责,因此,内核从2.6版本开始把bootsect.S文件和setup.S文件合成为一个文件——header.S文件。但是为了向后兼容,引导协议中记录setup部分大小的成员setup_sectors依旧被内核设置为实模式部分的尺寸再减去一个扇区,代码如下所示:



代码中第9行就是读取setup部分占据的扇区数,第11行是将扇区转换为字节。

5)获取了实模式部分的大小后,下面就要将内核映像文件定位到保护模式开始的地方,第30行代码就是做这件事。其中GRUB_DISK_SECTOR_SIZE就是在setup的基础上再增加一个扇区的bootsect。

6)在最后读取之前,还有一件事要做,那就是确定保护模式部分的尺寸。一旦确定了实模式部分的大小,保护模式的尺寸就非常容易计算了,即整个映像的尺寸减去实模式的尺寸(包括setup部分和bootsect部分),就是保护模式的大小,见代码第12~13行。

7)一切就绪,第33行代码加载内核。此时,GRUB已经加载了驱动硬盘和文件系统的模块,所以,GRUB不再是通过BIOS中断,而是通过自身的文件系统驱动提供的接口grub_file_read读取的内核映像。

(2)加载initramfs

与加载内核类似,命令initrd对应的回调函数grub_cmd_initrd首先确定initramfs的加载地址,为initramfs分配好内存。然后调用GRUB中的文件系统驱动提供的接口grub_file_read从硬盘加载initramfs。相关代码如下:



函数grub_cmd_initrd执行的主要操作如下:

1)grub_cmd_initrd首先要确定initramfs加载的位置,见代码第4~13行。从引导协议0x0203版本开始,内核定义了加载initramfs的上限,以Linux内核3.7.4版本为例,其规定的initramfs的上限为0x7fffffff:



如果引导协议小于这个版本,则GRUB只需自己作主即可,GRUB将initramfs加载的上限设置为GRUB_LINUX_INITRD_MAX_ADDRESS:



而对于下限,内核没有要求。但是根据代码可见,GRUB将initramfs加载在内核映像之后。

2)函数grub_cmd_initrd调用函数grub_relocator_alloc_chunk_align在这个范围内找一个合适的位置,见代码第18~20行。根据传给函数grub_relocator_alloc_chunk_align的参数GRUB_RELOCATOR_PREFERENCE_HIGH可见,GRUB采用的策略是尽可能的将initramfs加载到高地址处。

为initramfs分配完内存之后,grub_cmd_initrd将指针initrd_mem指向为加载initramfs分配的内存,并将这块内存的物理地址记录到变量initrd_mem_target中,见代码第22~23行。这两个变量与前面讨论加载内核时见到的变量prot_mode_mem和prot_mode_target类似。最后这个内存地址是要分享给内核的,当然不能将GRUB中的一个内存指针传递给内核了。

3)确定了地址,并分配了内存后,函数grub_cmd_initrd调用grub_file_read将initramfs加载到内存initrd_mem处。这里GRUB考虑了可能存在多个initrd的情况,所以有个for循环。

4)最后,GRUB将initramfs的尺寸、加载的位置记录到引导参数中,供内核寻找initramfs时使用,见代码第33~34行。这里,我们看到,传递给内核的initramfs的加载地址就是前面分配的内存的物理地址initrd_mem_target。

3.将控制权交给内核

在加载完内核映像和initramfs后,GRUB完成了其作为操作系统加载器的使命,其将跳转到加载的内核映像,将控制权交给内核。相关代码如下:



基本上,GRUB是运行在保护模式的,只有在使用BIOS时才切换到实模式,所以记录引导参数的全局变量linux_params的位置是随机的。因此,在启动内核前,GRUB在传统的低端内存中申请了一块区域,将引导参数放置到传统的实模式占据的位置。函数grub_linux_boot中指向这块内存的指针是real_mode_mem,并将这块内存的物理地址记录在变量real_mode_target。最终,在跳转到内核之前,GRUB会将real_mode_target记录到寄存器esi中,内核启动后,将从寄存器esi记录的这个地址复制引导参数。

第6行代码就是将变量linux_params的值复制到这块内存区域。

第10行设置了指令指针的地址为code32_start,我们在讨论加载内核映像时已经见到了code32_start,其就是内核保护模式加载的地址。最后,函数grub_relocator32_boot将调用函数grub_relocator32_start跳转到内核的保护模式处,代码如下:



上面代码片段中第5行装载gdt寄存器,gdt的内容在第12~24行代码中定义。根据gdt的定义可见,gdt中定义了两个段,一个是代码段,另外一个是数据段。这两个段的基址都是0,段的长度是32位CPU线性地址空间的范围,即4GB。这两个段的唯一区别是代码段是只读的,而数据段具有读写权限。

继续向下看第7行代码,其中有一个字节的"0xea",这个正是x86指令集中的长跳转指令之一的操作码,如表5-1所示。

根据表5-1可见,指令jmp的操作数是48位的,前16位是代码段CS的内容,后32位是指令指针EIP的内容。

显然,上述代码片段中跟在0xea之后的第10行的word类型的变量就是CS段的内容,我们看到这2个字节处的宏CODE_SEGMENT在第1行代码处定义,值为0x10,展开二进制为:



在保护模式下,寄存器CS中保存的是段选择子(Segment  Selector),其格式如图5-6所示。

图 5-6 段选择子格式

参照图5-6,除去最后三位,那么CS段在GDT中的索引就是二进制的10,十进制的2。而GDT表的下表从0开始,所以第2项正好是代码段。

第9行的long类型的变量就是EIP的内容,这个值是在函数grub_relocator32_boot中填充的,代码如下:



看到"state.eip"是不是很熟悉?没错,它是在函数grub_linux_boot中设置的,值是code32_start,也就是内核32位保护模式开始的地方。由此可见,函数grub_relocator32_st