Linux 内核引导过程(四):切换到 64 位长模式 —— startup_32 与启动页表初始化深度解析

原创2026-10-03 18:53:121,677 阅读
文章标签:文档教程操作系统知识库

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

  1. 启用 PAE(已在 cr4 中置位);
  2. 建立页表,并将顶级页表地址写入 cr3;
  3. 启用 EFER.LME;
  4. 启用分页。

前两步进行中,接下来就是页表结构的建立。

初期页表初始化:映射 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(保护模式切换)按序阅读,形成完整引导链路。

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