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的大幕。