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_start中的第7~10行代码,通过一个长跳转,GRUB将控制权交给了内核。