Linux 内核引导:从保护模式切换到 64 位长模式(head_64.S 深度解析)

原创2026-09-26 20:19:081,733 阅读

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.S
  • head_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 位模式需要完成四步:

  1. 启用 PAE;
  2. 建立页表,并把顶级页表的地址放入 cr3 寄存器;
  3. 启用 EFER.LME;
  4. 启用分页。

上一步已经通过设置 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 的全部关键步骤:

  1. 检查 KEEP_SEGMENTS 并按需重载 ds/es/ss 段寄存器;
  2. 通过 call/pop 技巧获取 startup_32 的真实物理地址(可用 gdb 在 0x100022 断点验证为 0x100000);
  3. 基于 .bss 中的 boot_stack_end 建立栈,并调用 verify_cpu 用 cpuid 确认长模式与 SSE 支持;
  4. 依据 CONFIG_RELOCATABLE 与 LOAD_PHYSICAL_ADDR 计算内核解压重定位地址;
  5. 更新 GDT(CS.L=1、CS.D=0)并 lgdt 载入,启用 PAE;
  6. 建立映射 4GB 内存的初期 4 级页表(PML4 + PDP + 4 个页目录),将 PML4 地址写入 cr3;
  7. 设置 EFER.LME、压入 __KERNEL_CS 与 startup_64 地址,置位 cr0 的 PG|PE 后用 lret 进入 64 位长模式。

关于分页机制更完整的理论(4 级页表层次、线性地址位拆分、页表项标志位、内核/用户空间虚拟内存布局),可继续阅读仓库中的 Theory/linux-theory-1.md;引导流程的整体脉络可从 Booting/README.md 概览。

登录后查看全文
linux-insides-zh