万书网 > 文学作品 > 深度探索Linux操作系统 > 5.2 解压内核

5.2 解压内核



根据构建内核时的分析,我们知道,内核的保护模式部分包括非压缩部分以及压缩部分,压缩部分才是内核正常运转时的部分,而非压缩部分只是一个过客,其主要作用是解压内核的压缩部分,解压完成后,非压缩部分也将退出历史舞台。

内核的解压缩过程几经演进,现在的解压过程不再是首先将内核解压到另外的位置,然后再合并到最终的目的地址。而是采用了所谓的就地解压(in-place  decompression)方法,内核解压时并不需要解压到另外的位置,从而避免覆盖其他部分的数据。

以不可重定位的内核的解压过程为例,其解压过程如图5-7所示。

图 5-7 不可重定位内核的就地解压过程

对于不可重定位内核,最终解压后的的内核的起始位置是内核编译时设定的加载地址,即LOAD_PHYSICAL_ADDR。虽然解压的方式是就地解压,但是为了安全起见,解压过程所需要的内存空间并不完全等于解压后内核占据的空间,而是还预留有那么一点点安全空间。所以这个解压所需的空间,即图中标明的"In-place  decompress  buffer"的长度是解压后内核的长度z_output_len加上这个预留的安全空间。

为了确保在解压时,读取的位置永远在写入的位置的前面。内核首先移动到这个解压空间的最后。那么内核如何才能确保移动到这个空间的最后呢?内核只需从LOAD_PHYSICAL_ADDR向后移动z_extract_offset,就确保了内核映像移动到了这个解压空间的最后。

那么z_extract_offset以及图5-7中的几个数据,包括解压后内核的长度z_output_len等数据,都是从哪里获取的呢?这些数据当然是压缩内核的时候最清楚了,因此这些早已在内核编译时,进行压缩时就已经计算好了,定义在内核映像中:



piggy.S中定义的解压内核时需要的变量包括:

1)z_input_len,压缩内核的长度,即vmlinux.bin.gz的长度。

2)z_output_len,内核解压缩后的长度。

3)z_extract_offset,进行就地解压前,相对于解压后的位置,内核映像需要向后移动一段距离,为解压留出空间,避免解压的内核覆盖了压缩的内核,z_extract_offset就是这个偏移的大小。

4)z_extract_offset_negative,这个是z_extract_offset的负数,是为了编程方便定义的。

5)input_data,标识内核映像中,压缩部分的起始位置。

在解压缩后,非压缩部分根据需要可能还要对内核进行重定位符号,然后跳转到解压后的内核的入口startup_32。接下来,我们就具体讨论一下这个过程。



5.2.1 移动内核映像


1.确定源地址

前面讨论GRUB时,我们看到虽然GRUB按照引导协议的规定,将内核保护模式部分加载的地址,即code32_start写入了引导参数中,但是只有内核的“真正”部分投入运行时,内核才复制GRUB保存在低端内存的引导参数。也就是说,这个临时的负责解压部分,并没有复制GRUB保存的引导参数,因此,内核还需要自己计算映像被加载的地址。内核使用下面的代码获得当前被Bootloader加载的地址:



这里首先解释一下代码片段中的1f。1后面的f表示的是forward,即以该条指令为参照,继续向前来寻找1这个标号;如果1的后缀是b,则意义正好与此相反。

call指令执行时,首先会将该调用返回后执行的下一条指令的地址压栈,这里就是标号1标识的指令运行时的地址。执行了call指令后,程序跳转到标号1所在的代码行处执行,标号1所在行的代码将栈顶的内容弹出到寄存器ebp中。而此时栈顶的内容恰恰是执行call调用前CPU压入的标号1处的指令的地址,也就是说,寄存器ebp中保存的就是标号1标识的指令在运行时所在的地址。接下来,减去标号1这行代码相对于程序开头处的偏移,即$1b,就获得了函数startup_32的运行时地址。而函数startup_32就是内核开头,换句话说,就是获得了内核保护模式部分被GRUB实际加载的地址。

这段代码执行后,寄存器ebp中保存的就是内核的加载地址。

2.确定目的地址

内核映像移动的目的地址针对内核是否支持重定位需要区别计算。

(1)内核被编译为可重定位

如果内核被编译为可重定位,理论上内核映像被加载的地址就可以作为最终的解压目的地址。但是出于效率角度的考虑,所以还要进一步检查Bootloader加载的地址是否符合内核的对齐要求。如果不符合要求,那么还要按照内核的对齐要求修正一下这个地址,然后再将其作为最终的内核解压的目的地址。代码如下所示:



在上面代码中,#if代码块的目的就是进行对齐修订。修订后的地址,也就是解压内核的目的地址,保存在寄存器ebx中。

然后,将内核解压后的地址再加上偏移z_extract_offset,最后寄存器ebx中保存的即是内核映像在解压前应该移动到的位置。

如果需要配置一个可重定位的内核,则可按如下步骤进行:

1)执行make  menuconfig,出现如图5-8所示的界面。

图 5-8 配置可重定位内核(1)

2)在图5-8中,选择菜单项"Processor  type  and  features",出现如图5-9所示的界面。

图 5-9 配置可重定位内核(2)

3)在图5-9中,选择"Build  a  relocatable  kernel"。

在内核3.7.4版本中默认对齐为0x1000000,当然也可以通过配置内核进行修改。

(2)内核不支持重定位

如果内核没有被编译为可重定位,那么表明内核不允许将其加载到其他位置,必须要加载到LOAD_PHYSICAL_ADDR,因此内核的解压目的地址就是LOAD_PHYSICAL_ADDR。代码如下:



然后,将解压的目的地址偏移z_extract_offset,最后,寄存器ebx中保存的即是内核映像在解压前应该移动到的位置。

变量LOAD_PHYSICAL_ADDR是内核编译时指定的加载地址。其定义如下:



可见,LOAD_PHYSICAL_ADDR就是内核配置选项CONFIG_PHYSICAL_START按照内核的对齐要求修订后的值。

由这里可见,即使内核不允许重定位,那么事实上最后内核解压后的地址也是符合内核的对齐要求的,因为这里已经对编译时指定的加载地址进行了对齐处理。

内核3.7.4版本的默认加载地址是0x1000000,用户可以通过配置指定内核加载的物理地址,步骤如下:

1)执行make  menuconfig,出现如图5-10所示的界面。

图 5-10 更改内核加载地址(1)

2)在图5-10中,选择菜单项"Processor  type  and  features",出现如图5-11所示的界面。

图 5-11 更改内核加载地址(2)

3)设置内核加载地址依赖于CONFIG_EXPERT或者CONFIG_CRASH_DUMP,所以如果要修改内核依赖地址,必须选择这两项中的一项。可以在图5-11中选中"kernel  crash  dumps",即CONFIG_CRASH_DUMP,在该配置项的下面即可出现修改内核加载地址的配置项"Physical  address  where  the  kernel  is  loaded",修改这一项的值即可达到修改内核加载地址的目的。

3.移动内核映像

源地址和目的地址确定后,内核映像就开始了移动过程,代码如下:



在上述代码中,符号_bss在链接vmlinux.bin时定义在bss段的开头。BSS段被链接在vmlinux.bin的最后,而它在内核映像文件中并不占据空间,因此,符号_bss的地址就是内核保护模式的末尾。

寄存器esi是保存移动指令的源地址的,第2行代码就是设置这个寄存器的值,表达式:



展开后如下:



而寄存器ebp中保存的值是内核加载的地址,所以"%ebp+_bss"即为内核的末尾地址,-4的目的当然是为第一次复制留出4字节的空间。

类似的,第3行代码是设置保存移动指令的目的地址的寄存器edi。因为寄存器ebx中保存移动后的内核地址,所以edi中的值最后设置为移动后的内核的末尾地址,并为第一次复制留出4字节的空间。

同理,读者回忆一下vmlinu.bin的构建,链接脚本指定将函数startup_32链接在vmlinux.bin的最开头,因此,在第4行代码中,"_bss-startup_32"就是内核以字节为单位的长度了。因为,一次复制4字节,所以代表移动次数的寄存器ecx需要右移两位(即除以4)。

第6行代码中,指令std的目的是表示每移动一次,esi和edi分别减一(4字节),也就是说复制是从内核尾部向着头部的方向复制。

内核移动结束后,显然需要重新装载指令指针EIP的值,跳转到移动后的内核中继续执行。这里是通过jmp指令修改指令指针的,即第12行代码,这条语句的目的是跳转到移动后的内核映像中标号relocated处继续执行。



5.2.2 解压

完成内核移动后,下一步就要开始解压内核了,代码如下:



我们看到,在head_32.S中,调用函数decompress_kernel解压内核,见第10行代码。decompress_kernel是用C语言编写的,其函数定义在misc.c中:



我们结合函数decompress_kernel来看看文件head_32.S中的几个关键参数:

1)函数decompress_kernel的最后一个参数output是内核解压的目的地址,这个应该是第一个压栈的参数,见head_32.S代码第1~3行。在前面讨论移动内核时,我们看到,寄存器ebx中保存的是内核移动后的地址,而根据piggy.S,z_extract_offset_negative就是z_extract_offset的负数,所以表达式



相当于



参照图5-7,显然,这就是内核解压的目的地址。

2)函数decompress_kernel的参数input_len表示压缩的内核映像的长度,这个变量对应piggy.S中定义的z_input_len,这是第2个需要压栈的参数,见head_32.S代码片段第4行。

3)函数decompress_kernel的参数input_data表示压缩的内核映像的开头,对应piggy.S中定义的input_data,这是第3个需要压栈的参数,见head_32.S代码片段第5行。

具体解压算法,我们不再分析,读者如果有兴趣,可自行阅读代码。



5.2.3 重定位

根据下面的内核链接脚本片段可见,内核中指令和数据的运行时地址是假定内核被解压到物理地址LOAD_PHYSICAL_ADDR处而分配的,我们称这个假定地址为理论加载地址。



如果内核被配置为可重定位的,那么尽管内核在引导协议中会将希望加载的地址(pref_address)设置为LOAD_PHYSICAL_ADDR,如下代码:



但是内核并不能确保Bootloader将内核一定加载到内核建议的LOAD_PHYSICAL_ADDR。如果Bootloader实际加载地址与理论加载地址不同,那么内核需要进行重定位。

对于可重定位内核,内核自身包含一个工具relocs。在编译内核的最后,relocs将vmlinux中需要重定位的符号导出,写入vmlinux.relocs,然后build将其链接在内核的最后。简单来讲,vmlinux.relocs就是一个数组,每一个元素记录的就是一个需要修订的位置。

head_32.S中重定位的代码片段如下:



该代码片段执行的主要操作如下:

1)首先需要判断内核是否需要重定位,见代码第5~7行。在前面为解压缩函数准备参数时,寄存器ebp中记录了内核解压后的地址,所以这两行代码的目的就是比较内核解压后的地址与内核理论加载地址LOAD_PHYSICAL_ADDR。如果相同,那么无须进行重定位,直接跳到标号2处,也就是跳过了标号1和标号2之间的重定位代码。

2)如果需要重定位,那么首先需要找到重定位表vmlinux.relocs,见第3行代码。在编译时,内核构建脚本将重定位表链接在映像的最后,而z_output_len代表内核解压后的长度,因此,%ebp+z_output_len指向的就是重定位表的末尾。

3)找到重定位表后,就可以进行重定位了,代码第9~14行从后向前遍历重定位表,逐项进行修订。其中第9~12行代码判断重定位是否已经完成,重定位表以0开头,所以,当某一项的值为0时,就说明已经到了重定位表的表头,所有需要重定位的条目已经完成。具体的修订算法非常直接,就是在每个修订的位置,加上内核实际加载的地址与理论加载地址的差值,见第13行代码。但是这行指令的操作数使用了相对复杂一点的寻址方式,而且两次出现了寄存器ebx,所以容易让人困惑。

首先来看一下寄存器ebx的值。事实上,在执行第6行代码时,内核实际的加载地址与理论加载地址的差值已经被保存到了寄存器ebx中。也就是说,用来修订的差值已经准备就绪。那么显然,第13行代码中指令addl的第二个操作数:



就是需要修订的位置,我们将其展开:



为了看得更清楚一点,我们换个写法:



我们来分析这个表达式,寄存器ecx就像一个局部变量,临时存储重定位表中每一项,即需要重定位的位置,那么为什么要从ecx中刨除__PAGE_OFFSET呢?这个问题的根源就在于重定位表vmlinux.relocs中记录的修订位置使用的是虚拟地址。

内核为了占据3GB以上的进程地址空间,所以在编译时,链接器为每个符号的地址增加了3GB的偏移,也就是这里的__PAGE_OFFSET。在内核运行时,页式映射会将这个逻辑上的偏移消除,将符号映射到真正的物理地址。

但此时的麻烦是,CPU尚未开启页式映射,而且GRUB将CPU设置工作在平坦内存模型下,段基址都为0,虚拟地址经过MMU映射后,将原封不动地转换为物理地址。举个例子,假设内核最终加载到了16MB处,那么内核开头的虚拟地址是0xc1000000,假设这里需要修订,那么重定位表中记录的修订位置是0xc1000000。当进行重定位时,如果不做任何处理,这个地址经过MMU转换后的物理地址依然为0xc1000000,显然多了3GB的偏移。因此,重定位代码需要事先将这个偏移消除掉。这就是在寄存器ecx的基础上再减去__PAGE_OFFSET的原因。

但是仅仅减去__PAGE_OFFSET还不够,不仅修订位置处的内容需要进行修订,修订位置本身也发生了变化,如图5-12所示,上面的虚线表示的是内核理论加载的地址,下面的实线表示的是内核实际加载的地址。

图 5-12 重定位位置的变化

因此,修订位置本身也需要进行修订。修订位置本身的偏移就是内核理论加载位置和实际加载位置的差值,而这个差值已经保存在寄存器ebx中,这就是为什么修订位置除了减去__PAGE_OFFSET外,又加上ebx的原因。

重定位完成之后,跳转到解压后的内核起始地址处继续执行,见重定位代码片段中的第18行。