过渡到 64 位模式
切换到64位模式
这是 内核引导过程 的第四部分,我们将会看到在保护模式中的最初几步,比如确认CPU是否支持长模式,SSE和分页以及页表的初始化,在这部分的最后我们还将讨论如何切换到长模式。
注意:这部分将会有大量的汇编代码,如果你不熟悉汇编,建议你找本书参考一下。
在前一章节,我们停在了跳转到位于 arch/x86/boot/pmjump.S 的 32 位入口点这一步:
jmpl *%eax回忆一下, eax 寄存器包含了 32 位入口点的地址。我们可以在 x86 linux 内核引导协议 中找到相关内容:
When using bzImage, the protected-mode kernel was relocated to 0x100000当使用 bzImage 时,保护模式下的内核被重定位至 0x100000让我们检查一下 32 位入口点的寄存器值来确保这是对的:
eax 0x100000 1048576
ecx 0x0 0
edx 0x0 0
ebx 0x0 0
esp 0x1ff5c 0x1ff5c
ebp 0x0 0x0
esi 0x14470 83056
edi 0x0 0
eip 0x100000 0x100000
eflags 0x46 [ PF ZF ]
cs 0x10 16
ss 0x18 24
ds 0x18 24
es 0x18 24
fs 0x18 24
gs 0x18 24我们在这里可以看到 cs 寄存器包含了 - 0x10 (回忆前一章节,这代表了全局描述符表中的第二个索引项), eip 寄存器的值是 0x100000,并且包括代码段在内的所有内存段的基地址都为0。所以我们可以得到物理地址: 0:0x100000 或者 0x100000,这和协议规定的一样。现在让我们从 32 位入口点开始。
32 位入口点
我们可以在汇编源码 arch/x86/boot/compressed/head_64.S 中找到 32 位入口点的定义。
首先,为什么目录名叫做 被压缩的 (compressed) ?实际上 bzimage 是由 vmlinux + 头文件 + 内核启动代码 被 gzip 压缩之后获得的。我们在前几个章节已经看到了启动内核的代码。所以, head_64.S 的主要目的就是做好进入长模式的准备之后进入长模式,进入以后再解压内核。在这一章节,我们将会看到直到内核解压缩之前的所有步骤。
在 arch/x86/boot/compressed 目录下有两个文件:
但是,你可能还记得我们这本书只和 x86_64 有关,所以我们只会关注 head_64.S ;在我们这里 head_32.S 没有被用到。让我们看一下 arch/x86/boot/compressed/Makefile。在那里我们可以看到以下目标:
注意 $(obj)/head_$(BITS).o 。这意味着我们将会选择基于 $(BITS) 所设置的文件执行链接操作,即 head_32.o 或者 head_64.o。$(BITS) 在 arch/x86/Makefile 之中根据 .config 文件另外定义:
现在我们知道从哪里开始了,那就来吧。
必要时重新加载内存段寄存器
正如上面阐述的,我们先从 arch/x86/boot/compressed/head_64.S 这个汇编文件开始。首先我们看到了在 startup_32 之前的特殊段属性定义:
这个 __HEAD 是一个定义在头文件 include/linux/init.h 中的宏,展开后就是下面这个段的定义:
其拥有 .head.text 的命名和 ax 标记。在这里,这些标记告诉我们这个段是可执行的或者换种说法,包含了代码。我们可以在 arch/x86/boot/compressed/vmlinux.lds.S 这个链接脚本里找到这个段的定义:
如果你不熟悉 GNU LD 这个链接脚本语言的语法,你可以在这个文档中找到更多信息。简单来说,这个 . 符号是一个链接器的特殊变量 - 位置计数器。其被赋值为相对于该段的偏移。在这里,我们将位置计数器赋值为0,这意味着我们的代码被链接到内存的 0 偏移处。此外,我们可以从注释里找到更多信息:
好了,现在我们知道我们在哪里了,接下来就是深入 startup_32 函数的最佳时机。
在 startup_32 函数的开始,我们可以看到 cld 指令将标志寄存器的 DF (方向标志)位清空。当方向标志被清空,所有的串操作指令像stos, scas等等将会增加索引寄存器 esi 或者 edi 的值。我们需要清空方向标志是因为接下来我们会使用汇编的串操作指令来做为页表腾出空间等工作。
在我们清空 DF 标志后,下一步就是从内核加载头中的 loadflags 字段来检查 KEEP_SEGMENTS 标志。你是否还记得在本书的最初一节,我们已经看到过 loadflags 。在那里我们检查了 CAN_USE_HEAP 标记以使用堆。现在我们需要检查 KEEP_SEGMENTS 标记。这些标记在 linux 的引导协议文档中有描述:
所以,如果 KEEP_SEGMENTS 位在 loadflags 中没有被设置,我们需要重置 ds , ss 和 es 段寄存器到一个基地址为 0 的普通段中。如下:
记住 __BOOT_DS 是 0x18 (位于全局描述符表中数据段的索引)。如果设置了 KEEP_SEGMENTS ,我们就跳转到最近的 1f 标签,或者当没有 1f 标签,则用 __BOOT_DS 更新段寄存器。这非常简单,但是这是一个有趣的操作。如果你已经读了前一章节,你或许还记得我们在 arch/x86/boot/pmjump.S 中切换到保护模式的时候已经更新了这些段寄存器。那么为什么我们还要去关心这些段寄存器的值呢?答案很简单,Linux 内核也有32位的引导协议,如果一个引导程序之前使用32位协议引导内核,那么在 startup_32 之前的代码就会被忽略。在这种情况下 startup_32 将会变成引导程序之后的第一个入口点,不保证段寄存器会不会处于未知状态。
在我们检查了 KEEP_SEGMENTS 标记并且给段寄存器设置了正确的值之后,下一步就是计算我们代码的加载和编译运行之间的位置偏差了。记住 setup.ld.S 包含了以下定义:在 .head.text 段的开始 . = 0 。这意味着这一段代码被编译成从 0 地址运行。我们可以在 objdump 工具的输出中看到:
objdump 工具告诉我们 startup_32 的地址是 0 。但实际上并不是。我们当前的目标是获知我们实际上在哪里。在长模式下,这非常简单,因为其支持 rip 相对寻址,但是我们当前处于保护模式下。我们将会使用一个常用的方法来确定 startup_32 的地址。我们需要定义一个标签并且跳转到它,然后把栈顶抛出到一个寄存器中:
在这之后,那个寄存器将会包含标签的地址,让我们看看在 Linux 内核中类似的寻找 startup_32 地址的代码:
回忆前一节, esi 寄存器包含了 boot_params 结构的地址,这个结构在我们切换到保护模式之前已经被填充了。bootparams 这个结构体包含了一个特殊的字段 scratch ,其偏移量为 0x1e4 。这个 4 字节的区域将会成为 call 指令的临时栈。我们把 scratch 的地址加 4 存入 esp 寄存器。我们之所以在 BP_scratch 基础上加 4 是因为,如之前所说的,这将成为一个临时的栈,而在 x86_64 架构下,栈是自顶向下生长的。所以我们的栈指针就会指向栈顶。接下来我们就可以看到我上面描述的过程。我们跳转到 1f 标签并且把该标签的地址放入 ebp 寄存器,因为在执行 call 指令之后我们把返回地址放到了栈顶。那么,目前我们拥有 1f 标签的地址,也能够很容易得到 startup_32 的地址。我们只需要把我们从栈里得到的地址减去标签的地址:
startup_32 被链接为在 0x0 地址运行,这意味着 1f 的地址为 0x0 + 1f 的偏移量 。实际上偏移量大概是 0x22 字节。 ebp 寄存器包含了 1f 标签的实际物理地址。所以如果我们从 ebp 中减去 1f ,我们就会得到 startup_32 的实际物理地址。Linux 内核的引导协议描述了保护模式下的内核基地址是 0x100000 。我们可以用 gdb 来验证。让我们启动调试器并且在 1f 的地址 0x100022 添加断点。如果这是正确的,我们将会看到在 ebp 寄存器中值为 0x100022 :
如果我们执行下一条指令 subl $1b, %ebp ,我们将会看到:
好了,那是对的。startup_32 的地址是 0x100000 。在我们知道了 startup_32 的地址之后,我们可以开始准备切换到长模式了。我们的下一个目标是建立栈并且确认 CPU 对长模式和 SSE 的支持。
栈的建立和 CPU 的确认
如果不知道 startup_32 标签的地址,我们就无法建立栈。我们可以把栈看作是一个数组,并且栈指针寄存器 esp 必须指向数组的底部。当然我们可以在自己的代码里定义一个数组,但是我们需要知道其真实地址来正确配置栈指针。让我们看一下代码:
boots_stack_end 标签被定义在同一个汇编文件 arch/x86/boot/compressed/head_64.S 中,位于 .bss 段:
首先,我们把 boot_stack_end 放到 eax 寄存器中。那么 eax 寄存器将包含 boot_stack_end 链接后的地址或者说 0x0 + boot_stack_end 。为了得到 boot_stack_end 的实际地址,我们需要加上 startup_32 的实际地址。回忆一下,前面我们找到了这个地址并且把它存到了 ebp 寄存器中。最后,eax 寄存器将会包含 boot_stack_end 的实际地址,我们只需要将其加到栈指针上。
在外面建立了栈之后,下一步是 CPU 的确认。既然我们将要切换到 长模式 ,我们需要检查 CPU 是否支持 长模式 和 SSE。我们将会在跳转到 verify_cpu 函数之后执行:
这个函数定义在 arch/x86/kernel/verify_cpu.S 中,只是包含了几个对 cpuid 指令的调用。该指令用于获取处理器的信息。在我们的情况下,它检查了对 长模式 和 SSE 的支持,通过 eax 寄存器返回0表示成功,1表示失败。
如果 eax 的值不是 0 ,我们就跳转到 no_longmode 标签,用 hlt 指令停止 CPU ,期间不会发生硬件中断:
如果 eax 的值为0,万事大吉,我们可以继续。
计算重定位地址
下一步是在必要的时候计算解压缩之后的地址。首先,我们需要知道内核重定位的意义。我们已经知道 Linux 内核的32位入口点地址位于 0x100000 。但是那是一个32位的入口。默认的内核基地址由内核配置项 CONFIG_PHYSICAL_START 的值所确定,其默认值为 0x1000000 或 16 MB 。这里的主要问题是如果内核崩溃了,内核开发者需要一个配置于不同地址加载的 救援内核 来进行 kdump。Linux 内核提供了特殊的配置选项以解决此问题 - CONFIG_RELOCATABLE 。我们可以在内核文档中找到:
简单来说,这意味着相同配置下的 Linux 内核可以从不同地址被启动。这是通过将程序以 位置无关代码 的形式编译来达到的。如果我们参考 /arch/x86/boot/compressed/Makefile,我们将会看到解压器的确是用 -fPIC 标记编译的:
当我们使用位置无关代码时,一段代码的地址是由一个控制地址加上程序计数器计算得到的。我们可以从任意一个地址加载使用这种方式寻址的代码。这就是为什么我们需要获得 startup_32 的实际地址。现在让我们回到 Linux 内核代码。我们目前的目标是计算出内核解压的地址。这个地址的计算取决于内核配置项 CONFIG_RELOCATABLE 。让我们看代码:
记住 ebp 寄存器的值就是 startup_32 标签的物理地址。如果在内核配置中 CONFIG_RELOCATABLE 内核配置项开启,我们就把这个地址放到 ebx 寄存器中,对齐到 2M 的整数倍 ,然后和 LOAD_PHYSICAL_ADDR 的值比较。 LOAD_PHYSICAL_ADDR 宏定义在头文件 arch/x86/include/asm/boot.h 中,如下:
我们可以看到该宏只是展开成对齐的 CONFIG_PHYSICAL_ALIGN 值,其表示了内核加载位置的物理地址。在比较了 LOAD_PHYSICAL_ADDR 和 ebx 的值之后,我们给 startup_32 加上偏移来获得解压内核镜像的地址。如果 CONFIG_RELOCATABLE 选项在内核配置时没有开启,我们就直接将默认的地址加上 z_extract_offset 。
在前面的操作之后,ebp 包含了我们加载时的地址,ebx 被设为内核解压缩的目标地址。
进入长模式前的准备工作
在我们得到了重定位内核镜像的基地址之后,我们需要做切换到64位模式之前的最后准备。首先,我们需要更新全局描述符表:
在这里我们把 ebp 寄存器加上 gdt 的偏移存到 eax 寄存器。接下来我们把这个地址放到 ebp 加上 gdt+2 偏移的位置上,并且用 lgdt 指令载入 全局描述符表 。为了理解这个神奇的 gdt 偏移量,我们需要关注 全局描述符表 的定义。我们可以在同一个源文件中找到其定义:
我们可以看到其位于 .data 段,并且包含了5个描述符: null 、内核代码段、内核数据段和其他两个任务描述符。我们已经在上一章节载入了 全局描述符表 ,和我们现在做的差不多,但是将描述符改为 CS.L = 1 CS.D = 0 从而在 64 位模式下执行。我们可以看到, gdt 的定义从两个字节开始: gdt_end - gdt ,代表了 gdt 表的最后一个字节,或者说表的范围。接下来的4个字节包含了 gdt 的基地址。记住 全局描述符表 保存在 48位 GDTR-全局描述符表寄存器 中,由两个部分组成:
全局描述符表的大小 (16位)
全局描述符表的基址 (32位)
所以,我们把 gdt 的地址放到 eax 寄存器,然后存到 .long gdt 或者 gdt+2。现在我们已经建立了 GDTR 寄存器的结构,并且可以用 lgdt 指令载入 全局描述符表 了。
在我们载入 全局描述符表 之后,我们必须启用 PAE 模式。方法是将 cr4 寄存器的值传入 eax ,将第5位置1,然后再写回 cr4 。
现在我们已经接近完成进入64位模式前的所有准备工作了。最后一步是建立页表,但是在此之前,这里有一些关于长模式的知识。
长模式
长模式是 x86_64 系列处理器的原生模式。首先让我们看一看 x86_64 和 x86 的一些区别。
64位 模式提供了一些新特性,比如:
从
r8到r158个新的通用寄存器,并且所有通用寄存器都是64位的了。64位指令指针 -
RIP;新的操作模式 - 长模式;
64位地址和操作数;
RIP 相对寻址 (我们将会在接下来的章节看到一个例子).
长模式是一个传统保护模式的扩展,其由两个子模式构成:
64位模式
兼容模式
为了切换到 64位 模式,我们需要完成以下操作:
启用 PAE;
建立页表并且将顶级页表的地址放入
cr3寄存器;启用
EFER.LME;启用分页;
我们已经通过设置 cr4 控制寄存器中的 PAE 位启动 PAE 了。在下一个段落,我们就要建立页表的结构了。
初期页表初始化
现在,我们已经知道了在进入 64位 模式之前,我们需要先建立页表,那么就让我们看看如何建立初期的 4G 启动页表。
注意:我不会在这里解释虚拟内存的理论,如果你想知道更多,查看本节最后的链接
Linux 内核使用 4级 页表,通常我们会建立6个页表:
1 个
PML4或称为4级页映射表,包含 1 个项;1 个
PDP或称为页目录指针表,包含 4 个项;4 个 页目录表,一共包含
2048个项;
让我们看看其实现方式。首先我们在内存中为页表清理一块缓存。每个表都是 4096 字节,所以我们需要 24 KB 的空间:
我们把和 ebx 相关的 pgtable 的地址放到 edi 寄存器中,清空 eax 寄存器,并将 ecx 赋值为 6144 。 rep stosl 指令将会把 eax 的值写到 edi 指向的地址,然后给 edi 加 4 , ecx 减 4 ,重复直到 ecx 小于等于 0 。所以我们才把 6144 赋值给 ecx 。
pgtable 定义在 arch/x86/boot/compressed/head_64.S 的最后:
我们可以看到,其位于 .pgtable 段,大小为 24KB 。
在我们为 pgtable 分配了空间之后,我们可以开始构建顶级页表 - PML4 :
还是在这里,我们把和 ebx 相关的,或者说和 startup_32 相关的 pgtable 的地址放到 edi 寄存器。接下来我们把相对此地址偏移 0x1007 的地址放到 eax 寄存器中。 0x1007 是 PML4 的大小 4096 加上 7 。这里的 7 代表了 PML4 的项标记。在我们这里,这些标记是 PRESENT+RW+USER 。在最后我们把第一个 PDP(页目录指针) 项的地址写到 PML4 中。
在接下来的一步,我们将会在 页目录指针(PDP) 表(3级页表)建立 4 个带有 PRESENT+RW+USE 标记的 Page Directory (2级页表) 项:
我们把 3 级页目录指针表的基地址(从 pgtable 表偏移 4096 或者 0x1000 )放到 edi ,把第一个 2 级页目录指针表的首项的地址放到 eax 寄存器。把 4 赋值给 ecx 寄存器,其将会作为接下来循环的计数器,然后将第一个页目录指针项写到 edi 指向的地址。之后, edi 将会包含带有标记 0x7 的第一个页目录指针项的地址。接下来我们就计算后面的几个页目录指针项的地址,每个占 8 字节,把地址赋值给 eax ,然后回到循环开头将其写入 edi 所在地址。建立页表结构的最后一步就是建立 2048 个 2MB 页的页表项。
在这里我们做的几乎和上面一样,所有的表项都带着标记 - $0x00000183 - PRESENT + WRITE + MBZ 。最后我们将会拥有 2048 个 2MB 大的页,或者说:
一个 4G 页表。我们刚刚完成我们的初期页表结构,其映射了 4G 大小的内存,现在我们可以把高级页表 PML4 的地址放到 cr3 寄存器中了:
这样就全部结束了。所有的准备工作都已经完成,我们可以开始看如何切换到长模式了。
切换到长模式
首先我们需要设置 MSR 中的 EFER.LME 标记为 0xC0000080 :
在这里我们把 MSR_EFER 标记(在 arch/x86/include/uapi/asm/msr-index.h 中定义)放到 ecx 寄存器中,然后调用 rdmsr 指令读取 MSR 寄存器。在 rdmsr 执行之后,我们将会获得 edx:eax 中的结果值,其取决于 ecx 的值。我们通过 btsl 指令检查 EFER_LME 位,并且通过 wrmsr 指令将 eax 的数据写入 MSR 寄存器。
下一步我们将内核段代码地址入栈(我们在 GDT 中定义了),然后将 startup_64 的地址导入 eax 。
在这之后我们把这个地址入栈然后通过设置 cr0 寄存器中的 PG 和 PE 启用分页:
然后执行:
指令。记住前一步我们已经将 startup_64 函数的地址入栈,在 lret 指令之后,CPU 取出了其地址跳转到那里。
这些步骤之后我们最后来到了64位模式:
就是这样!
总结
这是 linux 内核启动流程的第4部分。如果你有任何的问题或者建议,你可以留言,也可以直接发消息给我 twitter 或者创建一个 issue。
下一节我们将会看到内核解压缩流程和其他更多。
如果你发现文中描述有任何问题,请提交一个 PR 到 linux-insides-zh 。
相关链接
Last updated