Linux 内核引导过程(四):切换到 64 位长模式 —— startup_32 与启动页表初始化深度解析
Linux 内核引导过程(四):切换到 64 位长模式 —— startup_32 与启动页表初始化深度解析
本文是 Linux 内核揭秘(linux-insides-zh)引导过程系列的第四部分,聚焦于内核从保护模式 32 位入口点
startup_32切换到 64 位长模式(long mode)的完整路径。你将跟随汇编级代码逐步理解:如何确认 CPU 对长模式与 SSE 的支持、如何计算内核实际加载地址、如何建立映射 4G 内存的启动页表,以及如何通过EFER.LME与lret最终抵达 64 位入口点startup_64。读完本文,你将具备独立跟踪内核早期启动汇编代码(arch/x86/boot/compressed/head_64.S)的能力。
在前一章节中,我们完成了显示模式初始化、A20 地址线使能、IDT/GDT 设置,并最终通过 jmpl *%eax 从实模式跳入了保护模式。本文将从这一步继续:保护模式的 32 位入口点位于 0x100000,接下来内核要在 startup_32 中完成长模式切换前的全部准备工作。
从 32 位入口点开始:bzImage 与 compressed 目录
回忆引导协议(x86 内核引导协议文档)中的规定:
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(回忆上一章,这对应全局描述符表中的第二个索引项,即 __BOOT_CS),eip 为 0x100000,且所有内存段基地址均为 0。因此物理地址为 0:0x100000 = 0x100000,与引导协议一致。
32 位入口点 startup_32 定义在 Linux 内核源码的 arch/x86/boot/compressed/head_64.S:
__HEAD
.code32
ENTRY(startup_32)
....
....
....
ENDPROC(startup_32)
为什么目录名叫 "compressed"?
bzImage 实际上是由 vmlinux + 头部 + 内核启动代码 经 gzip 压缩后得到的。head_64.S 的核心使命是:做好进入长模式的全部准备 → 进入长模式 → 解压内核。本文覆盖的正是"内核解压之前"的所有步骤。
在 arch/x86/boot/compressed 目录下存在两个文件:
head_32.Shead_64.S
由于本系列聚焦 x86_64,只关注 head_64.S(head_32.S 在 x86_64 构建下不会被用到)。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 位内核构建时 BITS := 64,链接 head_64.o。
__HEAD 段与"链接到地址 0"
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 链接器脚本的特殊变量——位置计数器(location counter),赋值为 0 意味着这段代码被链接到内存地址 0 处。链接脚本注释也明确警告:
Be careful parts of head_64.S assume startup_32 is at address 0.
即 head_64.S 中部分代码假设 startup_32 位于地址 0。
必要时重新加载段寄存器:KEEP_SEGMENTS 检查
startup_32 开头第一条指令是 cld,清空标志寄存器中的 DF(方向标志)。方向标志被清空后,所有串操作指令(如 stos、scas)执行时会递增索引寄存器 esi/edi。之所以需要清空方向标志,是因为稍后我们将使用串操作指令为页表"腾出空间"。
随后从内核引导头(boot header)的 loadflags 字段检查 KEEP_SEGMENTS 标志。回顾第一部分 linux-bootstrap-1.md 中我们检查过 CAN_USE_HEAP 以使用堆;现在检查的是 KEEP_SEGMENTS。x86 引导协议文档对其描述如下:
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 位为 0 时,需要在 32 位入口点重载段寄存器;为 1 时则假设 %cs %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 标签跳过重载;否则用 __BOOT_DS 更新 ds、es、ss。
为什么要再次关心段寄存器? 虽然我们在 linux-bootstrap-3.md 的 pmjump.S 切换到保护模式时已经更新过这些寄存器,但 Linux 内核同时存在 32 位引导协议:若引导程序用 32 位协议引导内核,startup_32 之前的代码(包括 pmjump.S 中更新段寄存器的部分)会被完全忽略,此时 startup_32 成为引导程序之后的第一个入口点,段寄存器的值不保证处于已知状态。因此必须在此显式重载。
计算 startup_32 的实际地址:call/pop 技巧与 gdb 验证
由于链接脚本将 .head.text 的起始位置计数器设为 0,startup_32 被编译为从地址 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,但实际加载地址并非 0。在长模式下可以用 rip 相对寻址轻松获知真实地址,但我们此刻还处于保护模式,没有 rip 相对寻址能力。内核采用的经典技巧是 call/pop:
call label
label: pop %reg
执行后该寄存器将包含 label 标签的实际运行地址。Linux 内核中的对应代码(head_64.S):
leal (BP_scratch+4)(%esi), %esp
call 1f
1: popl %ebp
subl $1b, %ebp
esi 寄存器保存着 boot_params 结构的地址(在切换到保护模式前已填充)。boot_params 结构体(定义于 arch/x86/include/uapi/asm/bootparam.h)包含一个特殊的 scratch 字段,偏移 0x1e4,这个 4 字节区域将作为 call 指令的临时栈。将 BP_scratch + 4 存入 esp,是因为栈在 x86 上是向下生长的,+4 后栈指针指向栈顶。
随后 call 1f 将返回地址(即 1f 标签的实际地址)压栈,popl %ebp 取出它。此时 ebp = 1f 的实际物理地址。由于 startup_32 链接在 0x0,1f 的链接地址是 0x0 + 1f 的偏移量(约 0x22 字节):
startup_32 (0x0) +-----------------------+
| |
| |
| |
| |
1f (0x0 + 1f offset) +-----------------------+ %ebp - 实际物理地址
| |
+-----------------------+
执行 subl $1b, %ebp 后,ebp = 实际物理地址 - 1f 偏移量 = startup_32 的实际物理地址。引导协议规定保护模式内核基地址为 0x100000,可用 gdb 验证:在 1f 地址 0x100022 下断点,应看到 ebp = 0x100021(popl 尚未执行时栈顶是返回地址):
$ 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
执行 nexti 跨过 subl $1b, %ebp 之后:
nexti
...
ebp 0x100000 0x100000
...
确认无误:startup_32 的实际地址是 0x100000。有了这个地址,我们才能建立栈、确认 CPU 能力。
建立栈与 CPU 确认
建立启动栈
栈可视为一个数组,栈指针 esp 必须指向数组底部;而数组的真实地址需要 startup_32 的实际地址才能换算。代码如下:
movl $boot_stack_end, %eax
addl %ebp, %eax
movl %eax, %esp
boot_stack_end 标签定义在同一文件 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 实际地址相加,即得到 boot_stack_end 的真实物理地址,最后写入 esp 完成栈指针配置。.fill 是 GNU 汇编指令,BOOT_HEAP_SIZE / BOOT_STACK_SIZE 为预定义尺寸常量。
verify_cpu:检查长模式与 SSE
切换到长模式前必须确认 CPU 支持长模式和 SSE。内核通过调用 verify_cpu 完成检查:
call verify_cpu
testl %eax, %eax
jnz no_longmode
verify_cpu 定义于 arch/x86/kernel/verify_cpu.S,本质是几次 cpuid 指令调用——cpuid 用于获取处理器信息。此处检查长模式与 SSE 支持:eax 返回 0 表示成功,非 0 表示失败。若失败则跳转到 no_longmode,用 hlt 停止 CPU(hlt 期间不发生硬件中断):
no_longmode:
1:
hlt
jmp 1b
eax 为 0 则继续后续流程。
计算重定位地址:CONFIG_RELOCATABLE 与 LOAD_PHYSICAL_ADDR
下一步是计算内核解压的目标地址。先理解重定位的意义: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.
即 CONFIG_RELOCATABLE=y 时,内核从其被加载的位置运行,编译时的物理地址 CONFIG_PHYSICAL_START 作为最低位置限制。这要求内核以**位置无关代码(PIC)**方式编译——arch/x86/boot/compressed/Makefile 中的确如此:
KBUILD_CFLAGS += -fno-strict-aliasing -fPIC
位置无关代码的地址由"某个基准地址 + 程序计数器"计算得出,因此可从任意地址加载。这就是为什么必须事先求出 startup_32 的实际地址。计算解压地址的代码:
#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 序列即"加到对齐值-1 后取反相与"的经典向上取整),再与 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_ALIGN 对齐的 CONFIG_PHYSICAL_START,即内核加载位置的物理地址。比较之后,再加上 z_extract_offset 偏移得到内核解压镜像的地址;若未开启 CONFIG_RELOCATABLE,则直接用默认地址加 z_extract_offset。
此时寄存器分工明确:ebp 保存加载地址,ebx 设为内核解压的目标地址。
进入长模式前的准备工作:GDT 更新与 PAE
更新全局描述符表(GDT)
重定位基地址就绪后,进入 64 位模式前的最后准备开始。首先更新 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
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 位于 .data 段,包含 5 个描述符:NULL、内核代码段(__KERNEL_CS)、内核数据段(__KERNEL_DS)以及两个任务(TSS)描述符。与 linux-bootstrap-3.md 中保护模式 GDT 的区别在于:这里的代码段描述符 CS.L = 1、CS.D = 0,以便在 64 位模式下执行。
GDT 定义前两个字节是 gdt_end - gdt(表的大小,即最后一个字节的偏移),接下来 4 字节是 gdt 的基地址。GDTR 寄存器是 48 位的,由两部分组成:
- GDT 大小(16 位)
- GDT 基址(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 位模式前的准备工作几乎完成,剩下的关键一步是建立页表。
长模式(Long Mode)基础
长模式是 x86_64 系列处理器的原生模式。与 32 位 x86 相比,64 位模式带来以下新特性:
- 新增
r8~r15共 8 个通用寄存器,且所有通用寄存器均扩展为 64 位; - 64 位指令指针
RIP; - 新的操作模式——长模式;
- 64 位地址与 64 位操作数;
- RIP 相对寻址(下一章将看到实例)。
长模式是传统保护模式的扩展,由两个子模式构成:
- 64 位模式(64-bit mode)
- 兼容模式(compatibility mode)
切换到 64 位模式需要依次完成:
- 启用 PAE(已在
cr4中置位); - 建立页表,并将顶级页表地址写入
cr3; - 启用
EFER.LME; - 启用分页。
前两步进行中,接下来就是页表结构的建立。
初期页表初始化:映射 4G 内存的启动页表
在进入 64 位模式之前必须建立页表。Linux 内核使用 4 级页表,此处通常建立 6 个页表:
- 1 个
PML4(4 级页映射表),含 1 个项; - 1 个
PDP(页目录指针表),含 4 个项; - 4 个页目录表,共含 2048 个项。
关于分页的理论基础可延伸阅读本仓库的 Theory/linux-theory-1.md(分页章节)。
清理页表空间
每个表 4096 字节,6 个表共需 24 KB 空间,先清零:
leal pgtable(%ebx), %edi
xorl %eax, %eax
movl $((4096*6)/4), %ecx
rep stosl
edi 指向与 ebx 相对的 pgtable 地址;eax 清零;ecx = 6144。rep stosl 把 eax(0)写入 edi 指向地址,edi += 4、ecx -= 1,重复至 ecx = 0,共写入 6144 * 4 = 24576 字节 = 24 KB。
pgtable 定义于 head_64.S 末尾:
.section ".pgtable","a",@nobits
.balign 4096
pgtable:
.fill 6*4096, 1, 0
位于 .pgtable 段,大小 24 KB。
建立顶级页表 PML4
leal pgtable + 0(%ebx), %edi
leal 0x1007 (%edi), %eax
movl %eax, 0(%edi)
edi 指向 pgtable(相对 startup_32/ebx)。eax = edi + 0x1007,其中 0x1000 = 4096 是 PML4 自身大小,7 是 PML4 表项的标志位——此处标志为 PRESENT + RW + USER。最后把第一个 PDP(页目录指针)表项地址写入 PML4 的第 0 项。
建立页目录指针表 PDP(3 级页表)
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
edi 指向 PDP 基地址(pgtable + 0x1000);eax 为第一个页目录项地址(带标志 0x7);ecx = 4 作为循环计数器。每次循环把当前页目录项地址写入 edi,随后 eax += 0x1000(跳到下一个 4 KB 对齐的页目录表)、edi += 8(跳到下一个 8 字节表项),直到 4 个项写完。这样 PDP 的 4 个项分别指向 4 个页目录表。
建立 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
edi 指向第一个页目录表(pgtable + 0x2000);eax = 0x183,即标志 PRESENT + WRITE + MBZ;ecx = 2048。循环写入 2048 个页表项,每项指向一个 2 MB 页(eax += 0x00200000),最终覆盖:
>>> 2048 * 0x00200000
4294967296
即 4 GB 的地址空间。整个初期页表结构就绪后,把顶级页表 PML4 的地址写入 cr3:
leal pgtable(%ebx), %eax
movl %eax, %cr3
至此所有准备工作完成,可以正式切换到长模式了。
切换到长模式:EFER.LME、启用分页与 lret
首先设置 MSR(Model Specific Register,模型专属寄存器)中的 EFER.LME 标志,MSR 地址为 0xC0000080:
movl $MSR_EFER, %ecx
rdmsr
btsl $_EFER_LME, %eax
wrmsr
MSR_EFER 定义于 arch/x86/include/uapi/asm/msr-index.h。流程:把 MSR_EFER 编号放入 ecx → rdmsr 读取 MSR(结果在 edx:eax,取决于 ecx)→ btsl 置位 EFER_LME 位 → wrmsr 写回。
接着把内核代码段选择子与 startup_64 地址依次压栈:
pushl $__KERNEL_CS
leal startup_64(%ebp), %eax
随后把 startup_64 的地址也压栈,并通过设置 cr0 的 PG 与 PE 位启用分页与保护:
movl $(X86_CR0_PG | X86_CR0_PE), %eax
movl %eax, %cr0
最后执行远返回指令:
lret
lret(far return)从栈中弹出两个值:返回地址(startup_64 的实际地址)与段选择子(__KERNEL_CS),CPU 据此跳转,同时切换到 64 位模式下的新代码段。
此后便进入 64 位模式,代码段切换为 .code64,抵达 64 位入口点:
.code64
.org 0x200
ENTRY(startup_64)
....
....
....
至此,内核成功从 32 位保护模式切换到了 64 位长模式。
总结
本文完整走完了 startup_32 的使命:重载段寄存器、借助 call/pop 技巧求出内核真实加载地址(0x100000)、建立启动栈、通过 verify_cpu 验证长模式与 SSE 支持、按 CONFIG_RELOCATABLE 计算解压重定位地址、更新 GDT 并启用 PAE、建立映射 4G 内存的 6 个启动页表、设置 EFER.LME 与 cr0.PG 并最终以 lret 跳入 64 位入口点 startup_64。
关键结论回顾:
bzImage的保护模式入口被重定位到0x100000,startup_32链接地址为 0,运行地址由call/pop + subl技巧求出;- 内核以
-fPIC编译为位置无关代码,CONFIG_RELOCATABLE=y时可在CONFIG_PHYSICAL_START之上任意对齐地址加载; - 启动页表结构为 1 个 PML4 + 1 个 PDP(4 项)+ 4 个页目录表(2048 项),以 2MB 大页映射 4G 地址空间;
- 长模式切换四步:PAE → 页表(写入 cr3)→ EFER.LME → cr0 分页,最后
lret进入.code64的startup_64。
下一部分将看到内核解压流程:Linux 内核引导过程(五):内核解压。本文开头提到的寄存器状态与 gdb 调试过程,也可结合 Booting/linux-bootstrap-1.md(引导加载程序到内核)与 Booting/linux-bootstrap-3.md(保护模式切换)按序阅读,形成完整引导链路。