Linux 内核引导:从保护模式切换到 64 位长模式(head_64.S 深度解析)
Linux 内核引导:从保护模式切换到 64 位长模式(head_64.S 深度解析)
在 Linux 内核引导过程中,startup_32 是 32 位保护模式下的解压器入口,它负责完成段寄存器重载、地址重定位计算、CPU 能力确认、初期页表建立,以及最终通过 lret 切换到 64 位长模式并进入 startup_64 的全部准备工作。本文以 Booting/linux-bootstrap-4.md 为主线,逐行拆解 arch/x86/boot/compressed/head_64.S 中的核心汇编代码,说明保护模式下内核入口地址、重定位机制、GDT 更新、PAE 启用与 4GB 初期页表的结构细节,让读者完整掌握「保护模式 → 长模式」这条引导路径的每一步原理与可验证依据。
从保护模式跳转:32 位入口点在哪里
在前一章中,引导流程停在了 arch/x86/boot/pmjump.S 中的 32 位入口点跳转指令:
jmpl *%eax
回忆一下,eax 寄存器中保存的是 32 位入口点的地址。根据 x86 Linux 内核引导协议,使用 bzImage 时,保护模式下的内核被重定位至 0x100000:
When using bzImage, the protected-mode kernel was relocated to 0x100000
当使用 bzImage 时,保护模式下的内核被重定位至 0x100000
跳转之前各寄存器的实测值可以验证这一点:
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:回忆前一章可知,这是全局描述符表(GDT)中的第二个索引项,指向内核代码段;eip = 0x100000:指令指针恰好等于协议规定的内核加载地址。
由于代码段、数据段等所有内存段的基地址都为 0,实际物理地址为 0:0x100000,即 0x100000,与引导协议一致。
32 位入口点 startup_32 与 head_64.S
32 位入口点的定义位于汇编源码 arch/x86/boot/compressed/head_64.S:
__HEAD
.code32
ENTRY(startup_32)
....
....
....
ENDPROC(startup_32)
为什么目录叫 compressed(被压缩的)
bzImage 实际上由 vmlinux + 头文件 + 内核启动代码 经 gzip 压缩后得到,前几个章节已经看到了启动代码的部分。head_64.S 的主要目的就是:做好进入长模式的所有准备 → 进入长模式 → 再解压内核。本文覆盖的是从 startup_32 到内核解压缩之前的所有步骤。
为什么只关心 head_64.S
在 arch/x86/boot/compressed 目录下有两个汇编文件:
head_32.Shead_64.S
本系列聚焦 x86_64,因此只分析 head_64.S。选择哪一个文件由构建系统决定。查看 arch/x86/boot/compressed/Makefile 中的目标定义:
vmlinux-objs-y := $(obj)/vmlinux.lds $(obj)/head_$(BITS).o $(obj)/misc.o \
$(obj)/string.o $(obj)/cmdline.o \
$(obj)/piggy.o $(obj)/cpuflags.o
注意 $(obj)/head_$(BITS).o:链接时根据 $(BITS) 的值选择 head_32.o 或 head_64.o。$(BITS) 在 arch/x86/Makefile 中根据 .config 定义:
ifeq ($(CONFIG_X86_32),y)
BITS := 32
...
...
else
BITS := 64
...
...
endif
即:配置了 64 位内核(CONFIG_X86_32 不选)时,BITS := 64,链接 head_64.o。
必要时重新加载内存段寄存器
startup_32 的开头是一个特殊的段属性定义:
__HEAD
.code32
ENTRY(startup_32)
__HEAD 是定义在头文件 include/linux/init.h 中的宏,展开为:
#define __HEAD .section ".head.text","ax"
它创建了一个名为 .head.text、标记为 ax(可执行、含代码)的段。该段在链接脚本 arch/x86/boot/compressed/vmlinux.lds.S 中有明确定义:
SECTIONS
{
. = 0;
.head.text : {
_head = . ;
HEAD_TEXT
_ehead = . ;
}
这里的 . 是 GNU LD 链接器的特殊变量——位置计数器,此处被赋值为 0,意味着 .head.text 段内的代码被链接到内存 0 偏移处。源码注释也强调了这一点:
Be careful parts of head_64.S assume startup_32 is at address 0.
要小心, head_64.S 中一些部分假设 startup_32 位于地址 0。
startup_32 内部的第一条指令是 cld,它清除标志寄存器中的 DF(方向标志)位。当方向标志被清空后,所有串操作指令(如 stos、scas)会递增索引寄存器 esi 或 edi。清空方向标志是因为接下来要用串操作指令为页表腾出空间。
接下来,从内核加载头中的 loadflags 字段检查 KEEP_SEGMENTS 标志位。在本书最初一节中已经见过 loadflags(当时检查的是 CAN_USE_HEAP 以使用堆)。KEEP_SEGMENTS 位在 Linux 引导协议文档中的定义是:
Bit 6 (write): KEEP_SEGMENTS
Protocol: 2.07+
- If 0, reload the segment registers in the 32bit entry point.
- If 1, do not reload the segment registers in the 32bit entry point.
Assume that %cs %ds %ss %es are all set to flat segments with
a base of 0 (or the equivalent for their environment).
第 6 位 (写): KEEP_SEGMENTS
协议版本: 2.07+
- 为0,在32位入口点重载段寄存器
- 为1,不在32位入口点重载段寄存器。假设 %cs %ds %ss %es 都被设到基地址为0的普通段中(或者在他们的环境中等价的位置)。
如果 KEEP_SEGMENTS 位没有被设置,就需要把 ds、ss、es 重置到基地址为 0 的普通段中:
testb $(1 << 6), BP_loadflags(%esi)
jnz 1f
cli
movl $(__BOOT_DS), %eax
movl %eax, %ds
movl %eax, %es
movl %eax, %ss
__BOOT_DS 是 0x18(GDT 中数据段的索引)。如果设置了 KEEP_SEGMENTS,则直接跳转到 1f 标签,跳过重载。
为什么要再次关心段寄存器? 前一章在 arch/x86/boot/pmjump.S 中切换到保护模式时已经更新过这些段寄存器,但 Linux 内核还存在 32 位引导协议:如果引导程序用 32 位协议引导内核,startup_32 之前的代码会被忽略,startup_32 将成为引导程序之后的第一个入口点,此时段寄存器可能处于未知状态,必须重新设置。
计算 startup_32 的实际运行地址
检查完 KEEP_SEGMENTS 并设置好段寄存器后,下一步是计算代码被加载的地址与编译期链接地址之间的偏移。
如前所述,.head.text 段以 . = 0 开始,即这段代码被编译成从地址 0 运行。用 objdump 可以验证:
arch/x86/boot/compressed/vmlinux: file format elf64-x86-64
Disassembly of section .head.text:
0000000000000000 <startup_32>:
0: fc cld
1: f6 86 11 02 00 00 40 testb $0x40,0x211(%rsi)
objdump 告诉我们 startup_32 被链接在地址 0,但实际并非如此。在长模式下可以用 rip 相对寻址轻松获得真实地址,但当前处于保护模式,需要一种经典技巧:定义一个标签并 call 跳转到它,然后从栈顶 pop 出返回地址:
call label
label: pop %reg
执行之后,寄存器中就保存了标签的真实地址。Linux 内核中的对应代码是:
leal (BP_scratch+4)(%esi), %esp
call 1f
1: popl %ebp
subl $1b, %ebp
回忆前一节,esi 寄存器保存着 boot_params 结构的地址(切换到保护模式之前已填充)。boot_params 包含一个特殊字段 scratch,偏移量为 0x1e4,这 4 字节区域正好作为 call 指令的临时栈。把 scratch 地址加 4 存入 esp,是因为 x86_64 架构下栈自顶向下生长,BP_scratch+4 就是栈顶。
执行 call 1f 后,返回地址(1f 标签的地址)被压栈;popl %ebp 取出该地址。随后 subl $1b, %ebp 用标签地址减去自身的编译期偏移,得到 startup_32 的真实物理地址:
startup_32 (0x0) +-----------------------+
| |
| |
| |
| |
| |
| |
| |
| |
1f (0x0 + 1f offset) +-----------------------+ %ebp - 实际物理地址
| |
| |
+-----------------------+
startup_32 被链接在 0x0 运行,因此 1f 的编译期地址是 0x0 + 1f 的偏移量(实际约 0x22 字节),而 ebp 中保存的是 1f 的实际物理地址。两者相减即得 startup_32 的真实物理地址。
按 Linux 引导协议,保护模式下内核基地址为 0x100000,可以用 gdb 验证。在 1f 的地址 0x100022 处打断点:
$ gdb
(gdb)$ target remote :1234
Remote debugging using :1234
0x0000fff0 in ?? ()
(gdb)$ br *0x100022
Breakpoint 1 at 0x100022
(gdb)$ c
Continuing.
Breakpoint 1, 0x00100022 in ?? ()
(gdb)$ i r
eax 0x18 0x18
ecx 0x0 0x0
edx 0x0 0x0
ebx 0x0 0x0
esp 0x144a8 0x144a8
ebp 0x100021 0x100021
esi 0x142c0 0x142c0
edi 0x0 0x0
eip 0x100022 0x100022
eflags 0x46 [ PF ZF ]
cs 0x10 0x10
ss 0x18 0x18
ds 0x18 0x18
es 0x18 0x18
fs 0x18 0x18
gs 0x18 0x18
此时 ebp = 0x100021。执行下一条指令 subl $1b, %ebp 之后:
nexti
...
ebp 0x100000 0x100000
...
startup_32 的真实地址确实是 0x100000。至此可以开始准备切换到长模式:建立栈、确认 CPU 对长模式和 SSE 的支持。
建立栈与 CPU 确认
基于 boot_stack_end 建立栈
在知道 startup_32 的地址之前无法建立栈——栈可以看作一个数组,栈指针 esp 必须指向数组底部,而数组的真实地址必须先知道。代码如下:
movl $boot_stack_end, %eax
addl %ebp, %eax
movl %eax, %esp
boot_stack_end 定义在同一个汇编文件 arch/x86/boot/compressed/head_64.S 的 .bss 段:
.bss
.balign 4
boot_heap:
.fill BOOT_HEAP_SIZE, 1, 0
boot_stack:
.fill BOOT_STACK_SIZE, 1, 0
boot_stack_end:
先把 boot_stack_end 的链接地址(0x0 + boot_stack_end)装入 eax,再加上 ebp 中保存的 startup_32 实际地址,eax 就得到了 boot_stack_end 的真实物理地址,最后把它赋给栈指针 esp。
verify_cpu:确认长模式与 SSE
建立栈之后是 CPU 确认。因为将要切换到长模式,必须检查 CPU 是否支持长模式和 SSE:
call verify_cpu
testl %eax, %eax
jnz no_longmode
verify_cpu 定义在 arch/x86/kernel/verify_cpu.S,内部只包含若干 cpuid 指令调用。cpuid 用于获取处理器信息,这里用它检查长模式和 SSE 支持:eax 返回 0 表示成功,1 表示失败。
如果 eax 非 0,跳转到 no_longmode 标签,用 hlt 停止 CPU(期间不会发生硬件中断):
no_longmode:
1:
hlt
jmp 1b
如果 eax 为 0,则继续下一步。
计算重定位地址
下一步是在必要时计算内核解压后的地址。首先需要理解内核重定位的意义。
Linux 内核的 32 位入口地址是 0x100000,但默认内核基地址由配置项 CONFIG_PHYSICAL_START 决定,默认值为 0x1000000(16 MB)。主要问题是:如果内核崩溃,内核开发者需要一个加载到不同地址的"救援内核"来做 kdump。Linux 内核提供了 CONFIG_RELOCATABLE 配置选项解决此问题,内核文档说明如下:
This builds a kernel image that retains relocation information
so it can be loaded someplace besides the default 1MB.
Note: If CONFIG_RELOCATABLE=y, then the kernel runs from the address
it has been loaded at and the compile time physical address
(CONFIG_PHYSICAL_START) is used as the minimum location.
这建立了一个保留了重定向信息的内核镜像,这样就可以在默认的 1MB 位置之外加载了。
注意:如果 CONFIG_RELOCATABLE=y, 那么 内核将会从其被加载的位置运行,编译时的物理地址 (CONFIG_PHYSICAL_START) 将会被作为最低地址位置的限制。
简单来说,相同配置的内核可以从不同地址启动。其实现手段是把程序编译为位置无关代码(PIC)。参考 arch/x86/boot/compressed/Makefile,解压器确实用 -fPIC 编译:
KBUILD_CFLAGS += -fno-strict-aliasing -fPIC
使用位置无关代码时,一段代码的地址由「控制地址 + 程序计数器」计算得到,因此可以从任意地址加载。这就是为什么要先获得 startup_32 的实际地址。
现在的目标是计算内核解压的地址,该计算取决于 CONFIG_RELOCATABLE:
#ifdef CONFIG_RELOCATABLE
movl %ebp, %ebx
movl BP_kernel_alignment(%esi), %eax
decl %eax
addl %eax, %ebx
notl %eax
andl %eax, %ebx
cmpl $LOAD_PHYSICAL_ADDR, %ebx
jge 1f
#endif
movl $LOAD_PHYSICAL_ADDR, %ebx
1:
addl $z_extract_offset, %ebx
ebp 中保存的是 startup_32 的物理地址。若开启 CONFIG_RELOCATABLE,把该地址放入 ebx,按引导头 BP_kernel_alignment 指定的对齐粒度取整(decl/addl/notl/andl 组合实现向上对齐到 2 的幂边界),再与 LOAD_PHYSICAL_ADDR 比较,取较大者。
LOAD_PHYSICAL_ADDR 宏定义在头文件 arch/x86/include/asm/boot.h:
#define LOAD_PHYSICAL_ADDR ((CONFIG_PHYSICAL_START \
+ (CONFIG_PHYSICAL_ALIGN - 1)) \
& ~(CONFIG_PHYSICAL_ALIGN - 1))
它只是把 CONFIG_PHYSICAL_START 按 CONFIG_PHYSICAL_ALIGN 对齐,表示内核加载位置的物理地址。比较完成后,给 ebx 加上 z_extract_offset 得到解压内核镜像的目标地址。若未开启 CONFIG_RELOCATABLE,则直接用默认地址加 z_extract_offset。
经过上述操作后:ebp 保存加载时的实际地址,ebx 被设为内核解压的目标地址。
进入长模式前的准备:GDT 与 PAE
更新全局描述符表
得到重定位基地址后,先更新全局描述符表(GDT):
leal gdt(%ebp), %eax
movl %eax, gdt+2(%ebp)
lgdt gdt(%ebp)
先把 ebp + gdt 偏移 存入 eax,再把该地址写入 ebp + gdt+2 处,最后用 lgdt 载入 GDT。要理解这个 gdt+2 偏移,需要看 GDT 的定义(位于同一源文件的 .data 段):
.data
gdt:
.word gdt_end - gdt
.long gdt
.word 0
.quad 0x0000000000000000 /* NULL descriptor */
.quad 0x00af9a000000ffff /* __KERNEL_CS */
.quad 0x00cf92000000ffff /* __KERNEL_DS */
.quad 0x0080890000000000 /* TS descriptor */
.quad 0x0000000000000000 /* TS continued */
gdt_end:
该 GDT 包含 5 个描述符:NULL、内核代码段、内核数据段以及两个任务段描述符。与上一章载入的 GDT 相比,这里把描述符改为 CS.L = 1、CS.D = 0,以便在 64 位模式下执行。
GDT 的结构以两个字节开始:gdt_end - gdt,代表表的范围;接下来的 4 字节是 GDT 的基地址。GDT 保存在 48 位的 GDTR(全局描述符表寄存器)中,由两部分组成:
- 全局描述符表的大小(16 位);
- 全局描述符表的基址(32 位)。
所以代码把 gdt 的地址存入 eax 后写到 .long gdt(即 gdt+2)处,构造出 GDTR 的结构,再 lgdt 载入。
启用 PAE
载入 GDT 后必须启用 PAE(物理地址扩展)模式:把 cr4 的值读入 eax,将第 5 位置 1,再写回 cr4:
movl %cr4, %eax
orl $X86_CR4_PAE, %eax
movl %eax, %cr4
至此,进入 64 位模式前的准备工作只剩下建立页表。先补充一些长模式背景知识。
长模式:x86_64 的原生模式
长模式是 x86_64 系列处理器的原生模式。64 位模式相比 x86 提供了一些新特性:
- 从
r8到r15共 8 个新的通用寄存器,且所有通用寄存器都是 64 位的; - 64 位指令指针
RIP; - 新的操作模式——长模式;
- 64 位地址和操作数;
RIP相对寻址(后续章节会看到实例)。
长模式是传统保护模式的扩展,由两个子模式构成:
- 64 位模式;
- 兼容模式。
切换到 64 位模式需要完成四步:
- 启用 PAE;
- 建立页表,并把顶级页表的地址放入
cr3寄存器; - 启用
EFER.LME; - 启用分页。
上一步已经通过设置 cr4 中的 PAE 位完成了第 1 步,接下来建立页表结构。
初期页表初始化:映射 4GB 内存
Linux 内核使用 4 级页表。初期引导通常建立 6 个页表:
- 1 个
PML4(4 级页映射表),包含 1 个项; - 1 个
PDP(页目录指针表),包含 4 个项; - 4 个页目录表,共包含
2048个项。
分页机制的理论细节(4 级页表层次、线性地址拆分、cr3 结构、页表项标志位含义等)可参阅仓库中的 Theory/linux-theory-1.md。
为页表清空缓存区
每个表都是 4096 字节,6 个表共需 24 KB 空间。首先在内存中为页表清出一块区域:
leal pgtable(%ebx), %edi
xorl %eax, %eax
movl $((4096*6)/4), %ecx
rep stosl
把与 ebx 相对的 pgtable 地址放入 edi,清空 eax,ecx 赋值为 6144。rep stosl 把 eax 的值写入 edi 指向的地址,edi 加 4、ecx 减 4,重复直到 ecx 为 0,从而把 24 KB 全部清零。
pgtable 定义在 arch/x86/boot/compressed/head_64.S 末尾:
.section ".pgtable","a",@nobits
.balign 4096
pgtable:
.fill 6*4096, 1, 0
它位于 .pgtable 段,大小为 24KB。
建立 PML4 顶级页表
leal pgtable + 0(%ebx), %edi
leal 0x1007 (%edi), %eax
movl %eax, 0(%edi)
把 pgtable 的地址放入 edi,把相对此地址偏移 0x1007 的地址放入 eax。0x1007 是 PML4 的大小 4096 加 7,其中 7 代表 PML4 项的标记 PRESENT+RW+USER。最后把第一个 PDP 项的地址写入 PML4 的第 0 项。
建立 PDP 表:4 个页目录指针项
接下来在 3 级页表 PDP 中建立 4 个带有 PRESENT+RW+USE 标记的页目录项:
leal pgtable + 0x1000(%ebx), %edi
leal 0x1007(%edi), %eax
movl $4, %ecx
1: movl %eax, 0x00(%edi)
addl $0x00001000, %eax
addl $8, %edi
decl %ecx
jnz 1b
把 PDP 表的基地址(pgtable 偏移 0x1000)放入 edi,把第一个页目录表首项的地址放入 eax,ecx 设为 4 作为循环计数器。循环中把 eax 写入 edi,随后每个页目录项占 8 字节,eax 每次增加 0x1000(指向下一个 2 级页表),edi 每次加 8,循环 4 次。
建立 2048 个 2MB 页的页表项
最后一步是建立 2048 个 2MB 页的页表项:
leal pgtable + 0x2000(%ebx), %edi
movl $0x00000183, %eax
movl $2048, %ecx
1: movl %eax, 0(%edi)
addl $0x00200000, %eax
addl $8, %edi
decl %ecx
jnz 1b
每个表项的标记为 $0x00000183(PRESENT + WRITE + MBZ)。循环写入 2048 项,eax 每次增加 0x00200000(2MB)。最终得到:
>>> 2048 * 0x00200000
4294967296
即一个映射 4GB 内存的初期页表。
把 PML4 地址写入 cr3
页表结构建立完成后,把高级页表 PML4 的地址放入 cr3 寄存器:
leal pgtable(%ebx), %eax
movl %eax, %cr3
至此所有准备工作完成,可以开始切换到长模式了。
切换到长模式
设置 EFER.LME MSR
首先设置 MSR(模型专用寄存器)中的 EFER.LME 位,其地址为 0xC0000080:
movl $MSR_EFER, %ecx
rdmsr
btsl $_EFER_LME, %eax
wrmsr
MSR_EFER 标记定义在 arch/x86/include/uapi/asm/msr-index.h。把该值放入 ecx,调用 rdmsr 读取 MSR,结果以 edx:eax 返回(取决于 ecx 的值)。用 btsl 把 EFER_LME 位置 1,再用 wrmsr 把 eax 写回 MSR。
压栈内核代码段地址与 startup_64
把 GDT 中定义的内核代码段地址入栈,并把 startup_64 的地址装入 eax:
pushl $__KERNEL_CS
leal startup_64(%ebp), %eax
启用分页并 lret 跳转
把这个地址入栈,然后通过设置 cr0 寄存器的 PG(分页)和 PE(保护模式)位启用分页:
movl $(X86_CR0_PG | X86_CR0_PE), %eax
movl %eax, %cr0
随后执行:
lret
由于前面已经把 startup_64 的地址压栈,lret 执行后 CPU 取出该地址并跳转到那里。这些步骤之后,终于进入 64 位模式:
.code64
.org 0x200
ENTRY(startup_64)
....
....
....
startup_64 之后的流程(重置段寄存器、重新计算重定位地址、建立栈、调用解压函数等)见下一章。
总结
本章完整走过了从保护模式 32 位入口 startup_32 到长模式入口 startup_64 的全部关键步骤:
- 检查
KEEP_SEGMENTS并按需重载ds/es/ss段寄存器; - 通过
call/pop技巧获取startup_32的真实物理地址(可用 gdb 在0x100022断点验证为0x100000); - 基于
.bss中的boot_stack_end建立栈,并调用verify_cpu用cpuid确认长模式与 SSE 支持; - 依据
CONFIG_RELOCATABLE与LOAD_PHYSICAL_ADDR计算内核解压重定位地址; - 更新 GDT(
CS.L=1、CS.D=0)并lgdt载入,启用 PAE; - 建立映射 4GB 内存的初期 4 级页表(PML4 + PDP + 4 个页目录),将 PML4 地址写入
cr3; - 设置
EFER.LME、压入__KERNEL_CS与startup_64地址,置位cr0的PG|PE后用lret进入 64 位长模式。
关于分页机制更完整的理论(4 级页表层次、线性地址位拆分、页表项标志位、内核/用户空间虚拟内存布局),可继续阅读仓库中的 Theory/linux-theory-1.md;引导流程的整体脉络可从 Booting/README.md 概览。