5.2.3 重定位

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

5.2.3 重定位 - 图1

5.2.3 重定位 - 图2

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

5.2.3 重定位 - 图3

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

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

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

5.2.3 重定位 - 图4

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

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的第二个操作数:

5.2.3 重定位 - 图5

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

5.2.3 重定位 - 图6

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

5.2.3 重定位 - 图7

我们来分析这个表达式,寄存器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.2.3 重定位 - 图8

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

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

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