Linux内核分析之基础知识-01

韩乔落

2.1 启动流程概览

当用户按下计算机电源键的那一刻,一场从硬件固件到操作系统内核的接力赛便悄然拉开序幕。在 x86_64 平台上,Linux 7.0 内核的启动过程涉及多个执行阶段,跨越 16 位实模式、32 位保护模式与 64 位长模式,最终将控制权交由架构无关的通用初始化代码。本节将对这一完整链路进行全景式梳理。

2.1.1 完整启动序列

以下是 x86_64 平台从加电到内核运行的完整流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
加电 (Power-On)


BIOS/UEFI 固件 ─── POST(加电自检)、硬件枚举


引导加载程序(GRUB2) ─── 读取磁盘,加载 bzImage 到内存


实模式设置代码 (arch/x86/boot/)
│ ─── 16 位实模式,收集硬件信息
▼ 建立 E820 内存映射
保护模式转换 (arch/x86/boot/pm.c, pmjump.S)
│ ─── 切换至 32 位保护模式

压缩内核入口 (arch/x86/boot/compressed/head_64.S)
│ ─── 解压前准备,切换至 64 位长模式

内核解压 (arch/x86/boot/compressed/misc.c)
│ ─── 解压 vmlinux 到目标地址

内核主体入口 (arch/x86/kernel/head_64.S)
│ ─── 64 位入口点 startup_64

早期 C 初始化 (arch/x86/kernel/head64.c)
│ ─── 初始化早期页表、控制台

start_kernel() (init/main.c)
│ ─── 架构无关的通用初始化

内核运行,挂载根文件系统,启动 init 进程

每个阶段承担的职责截然不同,运行于不同的 CPU 模式与地址空间中,下面逐一展开。

BIOS/UEFI 固件阶段

计算机加电后,CPU 从固定的复位向量(reset vector)开始执行。对于传统 BIOS 系统,该地址位于 0xFFFFFFF0,指向固件 ROM 中的代码。固件首先执行 POST(Power-On Self-Test),检测并初始化处理器、内存、显卡等核心硬件。UEFI 固件在此基础上还建立了自己的驱动模型与服务协议。

固件完成自检后,根据启动顺序定位引导设备。BIOS 会读取磁盘的第一个扇区(MBR,Master Boot Record)到内存地址 0x07C0:0000(即线性地址 0x7C00),并将控制权转交给该处代码。UEFI 则通过文件系统直接加载 EFI 系统分区上的引导程序(如 \EFI\BOOT\BOOTX64.EFI)。

引导加载程序阶段

Linux 通常使用 GRUB2 作为引导加载程序。GRUB2 的核心任务是:

  1. 读取磁盘上的内核映像文件(如 /boot/vmlinuz-7.0.0
  2. 将内核的 setup 部分加载到实模式地址(通常为 0x9000:0000
  3. 将压缩的内核载荷加载到 0x100000(1MB 标记处)
  4. 可选地加载初始内存盘(initramfs)
  5. 准备引导参数(boot_params),包括 E820 内存映射、命令行参数等
  6. 跳转到 setup 代码的入口点执行

arch/x86/boot/header.S 中,内核映像的前 512 字节包含了引导参数块(setup header),其中定义了引导加载程序需要填充的各种字段。GRUB2 会将硬件信息写入 struct boot_params(定义于 arch/x86/include/uapi/asm/bootparam.h),供后续内核代码使用。

实模式设置代码

内核映像中的 setup 代码位于 arch/x86/boot/ 目录下,以 16 位实模式编译。入口点为 arch/x86/boot/header.S 中定义的 _start 标号。这段代码的主要职责包括:

  • 调用 BIOS 中断查询显示器模式、APM 信息、EDD(Enhanced Disk Drive)数据
  • 验证内核映像的完整性(通过校验和)
  • 为保护模式切换做最后的准备
  • 启用 A20 地址线,确保可访问 1MB 以上的内存

这些操作的入口在 arch/x86/boot/main.c 中的 go_to_protected_mode() 函数处达到终点,该函数调用 arch/x86/boot/pm.c 中的逻辑完成模式切换。

保护模式转换

arch/x86/boot/pm.c 中的 go_to_protected_mode() 函数执行以下关键操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* arch/x86/boot/pm.c */
void go_to_protected_mode(void)
{
/* 关闭中断 */
cli();

/* 启用 A20 地址线 */
if (enable_a20()) { ... }

/* 设置 IDT 与 GDT */
setup_idt();
setup_gdt();

/* 通过汇编代码实际切换到保护模式 */
protected_mode_jump(boot_params.hdr.code32_start,
(u32)&boot_params + (ds() << 4));
}

protected_mode_jump 定义于 arch/x86/boot/pmjump.S,使用一条远跳转(ljmp)指令将 CPU 切换至 32 位保护模式,并跳转到 code32_start 所指向的地址——即 0x100000(1MB)处,那里是压缩内核载荷的入口。

压缩内核入口与解压

arch/x86/boot/compressed/head_64.S 中的代码以 32 位保护模式开始执行。其入口点 startup_32 负责:

  1. 设置基本的 32 位执行环境(段寄存器、栈)
  2. 启用分页机制(使用临时页表)
  3. 如果 CPU 支持 64 位长模式,切换至长模式
  4. 跳转到 64 位入口 startup_64

进入 64 位模式后,代码调用 arch/x86/boot/compressed/misc.c 中的 extract_kernel() 函数完成内核解压。该函数支持多种压缩算法:

1
2
3
4
5
6
7
8
/* arch/x86/boot/compressed/misc.c */
asmlinkage __visible void *extract_kernel(void *rmode, unsigned char *output)
{
/* ... */
__decompress(input_data, input_len, NULL, NULL, output, output_len,
NULL, error);
/* ... */
}

支持的压缩格式包括 gzip(.gz)、LZMA(.lzma)、XZ(.xz)、LZ4(.lz4)和 Zstandard(.zst),由内核编译配置 CONFIG_KERNEL_GZIPCONFIG_KERNEL_XZ 等选项决定。

解压完成后,代码跳转到解压后的内核主体入口——arch/x86/kernel/head_64.S 中的 startup_64

内核主体入口

arch/x86/kernel/head_64.S 是真正的 64 位内核代码入口。startup_64 标号处执行的任务包括:

  • 初始化内核的早期页表(从物理地址映射到 __START_KERNEL_map0xffffffff80000000
  • 设置全局描述符表(GDT)
  • 加载临时 IDT
  • 检测 CPU 特性(通过 verify_cpu
  • 为每个 CPU 初始化 per-CPU 数据区

随后,代码跳转到 arch/x86/kernel/head64.c 中的 C 函数 x86_64_start_kernel()x86_64_start_reservations(),进入早期 C 语言初始化阶段。

早期 C 初始化与 start_kernel()

arch/x86/kernel/head64.c 中的 x86_64_start_kernel() 负责在架构相关的最后准备工作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* arch/x86/kernel/head64.c */
asmlinkage __visible void __init x86_64_start_kernel(char *real_mode_data)
{
/* 重置 IDT,处理早期异常 */
idt_setup_early_handler();

/* 初始化早期页表 */
reset_early_page_tables();

/* 清空内核映像的 BSS 段 */
clear_bss();

/* ... */
}

当所有架构相关的初始化完成后,最终调用 init/main.c 中的 start_kernel() 函数,这是架构无关的通用初始化入口。start_kernel() 完成剩余的子系统初始化,最终调用 rest_init() 创建内核线程,启动用户空间的 init 进程(PID 1)。

2.1.2 各阶段内存布局

启动过程中,内核映像的不同部分被加载到内存中的特定位置。以下是各阶段的关键内存地址:

1
2
3
4
5
6
7
8
9
10
11
物理地址                          内容
─────────────────────────────────────────────────────────
0x00007C00 MBR/引导扇区加载地址(仅 BIOS 传统启动)
0x00090000 实模式 setup 代码加载地址(boot_params)
0x00100000 压缩内核加载地址 (code32_start, 1MB 标记)
0x01000000+ 解压后的内核映像(由 extract_kernel 解压至此)

虚拟地址 内容
─────────────────────────────────────────────────────────
0xffffffff80000000 __START_KERNEL_map — 内核映像映射起始
0xffffffff81000000 _text — 内核代码段虚拟地址

需要特别说明的是,0x07C0:0000 是 BIOS 传统启动规范定义的引导扇区加载地址。现代引导加载程序(GRUB2)并不使用这一地址执行引导扇区代码,而是直接将内核的 setup 部分加载到 0x9000:0000 附近。0x9000:0000 这一地址的选择源于历史兼容性——早期 Linux 使用 0x90000 存放实模式数据,且该地址在常规内存(conventional memory)范围内,不会与 BIOS 数据区或引导加载程序自身发生冲突。

code32_start 定义在引导参数头中(arch/x86/boot/header.S),默认值为 0x100000,即 1MB 地址标记。这是 x86 架构的”基地本内存”(base conventional memory)上限,也是保护模式下的经典加载地址。

内核虚拟空间的布局由 __START_KERNEL_map0xffffffff80000000)定义。在 64 位长模式下,内核映像被映射到这一地址开始的虚拟空间中,直接映射区域(direct mapping)则从 0xffff888000000000 开始,映射全部物理内存。

2.1.3 关键源码目录与职责

启动流程涉及的内核源码分布在多个目录中,各目录负责不同的启动阶段:

目录 模式 职责
arch/x86/boot/ 16 位实模式 实模式设置代码,收集硬件信息,A20 使能,向保护模式切换
arch/x86/boot/compressed/ 32→64 位 压缩内核的入口代码,模式切换(保护模式→长模式),内核解压
arch/x86/kernel/ 64 位 内核主体入口(head_64.S),早期 C 初始化(head64.c),CPU 与平台初始化
arch/x86/realmode/ 16 位实模式 应用处理器(AP)启动跳板代码,用于 SMP 系统中唤醒从处理器
drivers/firmware/efi/libstub/ 混合 EFI 桩代码,支持内核作为 EFI 应用程序直接被 UEFI 固件加载
init/ 64 位 架构无关的通用初始化(main.c 中的 start_kernel()),子系统初始化

这些目录在编译时分别构建,最终组装为 bzImage 格式的可引导内核映像。

2.1.4 bzImage 格式详解

Linux x86 内核的最终产物是 bzImage(big zImage),这是一个自包含的可引导映像,其内部结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
bzImage 结构
┌─────────────────────────────────┐
│ PE/COFF 头部(UEFI 兼容) │ ← 使内核可作为 EFI 应用程序执行
├─────────────────────────────────┤
│ 实模式 setup 代码 │ ← arch/x86/boot/ 编译产物
│ (16 位实模式二进制) │ 包含 header.S, main.c, pm.c 等
│ 包含引导参数头 (setup header) │
├─────────────────────────────────┤
│ 压缩载荷 │ ← vmlinux.bin 经压缩后得到
│ (gzip / lzma / xz / lz4 / zstd)│
│ 内含完整的 vmlinux 内核映像 │
└─────────────────────────────────┘

PE/COFF 头部:由 arch/x86/boot/header.S 中的 EFI stub 填充,使 bzImage 文件同时满足 PE 可执行文件格式的要求。这使得 UEFI 固件可以直接加载内核,无需传统引导加载程序作为中介。该头部位于文件偏移量 0 处,但实际有效内容从第 512 字节开始。

实模式 setup 代码:由 arch/x86/boot/ 目录下的源码编译生成,以 16 位实模式二进制形式存在。包含引导参数头(setup header),其中定义了引导协议版本、所需内存大小、命令行偏移等字段。引导加载程序通过读取这些字段来了解如何正确加载内核。

压缩载荷:这是 vmlinux(链接后的完整内核 ELF 映像)经过 objcopy 转换为裸二进制(vmlinux.bin),再经压缩算法处理后得到的。默认使用 gzip 压缩,但可通过 CONFIG_KERNEL_GZIPCONFIG_KERNEL_BZIP2CONFIG_KERNEL_LZMACONFIG_KERNEL_XZCONFIG_KERNEL_LZ4CONFIG_KERNEL_ZSTD 等配置项选择其他算法。

构建过程大致为:

1
2
3
4
5
vmlinux (ELF)
→ objcopy → vmlinux.bin (裸二进制)
→ gzip/xz/lz4/zstd → vmlinux.bin.gz (压缩载荷)
+ arch/x86/boot/ setup.o (实模式代码)
→ arch/x86/boot/tools/build.c → bzImage

arch/x86/boot/tools/build.c 是最终的组装工具,它将 setup 二进制与压缩载荷拼接,并计算校验和、填充 PE/COFF 头部。

2.1.5 BIOS 传统启动与 UEFI 启动对比

现代 x86_64 系统存在两种固件接口:传统 BIOS(Legacy BIOS)与 UEFI(Unified Extensible Firmware Interface)。Linux 内核在两种环境下均可启动,但路径存在显著差异。

传统 BIOS 引导路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
BIOS POST
└→ Bootloader (GRUB2 / LILO)
└→ 实模式设置代码 (arch/x86/boot/)
├→ header.S: start_of_setup
├→ main.c: main() — 实模式 C 初始化
├→ pm.c: go_to_protected_mode()
└→ pmjump.S: protected_mode_jump()
└→ 压缩内核入口 (arch/x86/boot/compressed/)
├→ head_64.S: startup_32 → startup_64
├→ misc.c: extract_kernel() — 解压
└→ 解压后的内核入口 (arch/x86/kernel/)
├→ head_64.S: startup_64
├→ head64.c: x86_64_start_kernel()
└→ init/main.c: start_kernel()

UEFI 直接引导路径

1
2
3
4
5
6
UEFI 固件
└→ EFI Stub (drivers/firmware/efi/libstub/)
├→ x86-stub.c: efi_pe_entry() → efi_stub_entry()
├→ 解压内核: efi_decompress_kernel()
└→ enter_kernel() → startup_64
└→ 解压后的内核入口 (同上)

辅助处理器 (AP) 启动路径

1
2
3
4
5
6
7
BSP 发送 SIPI
└→ AP 从实模式开始
└→ arch/x86/realmode/rm/trampoline_64.S
├→ trampoline_start (16位实模式)
├→ startup_32 (32位保护模式)
├→ startup_64 (64位长模式)
└→ secondary_startup_64 (arch/x86/kernel/head_64.S)

关键数据结构

在引导过程中,最重要的数据结构是 boot_params,它在引导的各个阶段之间传递信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// arch/x86/include/uapi/asm/bootparam.h
struct boot_params {
struct screen_info screen_info; // 显示信息
struct apm_bios_info apm_bios_info; // APM BIOS 信息
__u64 tboot_addr; // Trusted Boot 地址
struct ist_info ist_info; // Intel SpeedStep 信息
__u8 hd0_info[16]; // 硬盘 0 信息
__u8 hd1_info[16]; // 硬盘 1 信息
struct sys_desc_table sys_desc_table; // 系统描述表
struct olpc_ofw_header olpc_ofw_header;
__u32 ext_ramdisk_image; // initrd 高 32 位地址
__u32 ext_ramdisk_size; // initrd 高 32 位大小
__u32 ext_cmd_line_ptr; // 命令行高 32 位指针
struct boot_params_header hdr; // Setup Header —— 核心!
__u8 _pad9[128]; // 填充
__u8 edid_info[128]; // EDID 信息
struct efi_info efi_info; // EFI 信息
__u8 alt_mem_k[32]; // 替代内存大小
__u8 scratch[112]; // 临时空间
__u8 e820_entries; // E820 内存映射条目数
__u8 eddbuf_entries; // EDD 条目数
__u8 edd_mbr_sig_buf_entries; // EDD MBR 签名条目数
struct boot_e820_entry e820_table[E820_MAX_ENTRIES_ZEROPAGE];
__u8 _pad8[48]; // 填充
struct eddbuf eddbuf[EDDMAXNR]; // EDD 数据
__u8 _pad4[208]; // 填充
__u8 edd_mbr_sig_buffer[EDD_MBR_SIG_MAX]; // EDD MBR 签名
};

BIOS 传统启动路径

  1. 固件执行 POST,读取 MBR 到 0x7C00,执行 MBR 中的引导代码
  2. GRUB2 的 boot.img(MBR 第一阶段)加载 core.img(第二阶段)
  3. core.img 读取文件系统,加载 bzImage
  4. 将 setup 代码加载到实模式地址,压缩载荷加载到 0x100000
  5. 通过 16 位实模式代码 → 保护模式 → 长模式的链式切换进入内核

BIOS 启动使用中断调用(int 0x10int 0x13int 0x15 等)获取硬件信息。整个启动过程必须经过 16 位实模式阶段。

UEFI 启动路径

  1. 固件执行初始化,建立 EFI 驱动环境与服务
  2. 固件的引导管理器定位 EFI 系统分区上的引导程序(GRUB2 的 grubx64.efi
  3. GRUB2 通过 EFI_BLOCK_IO_PROTOCOL 等协议读取磁盘
  4. GRUB2 加载 bzImage(利用 EFI 服务进行内存分配与磁盘 I/O)
  5. 内核的 EFI stub(drivers/firmware/efi/libstub/)在引导加载程序之前被执行
  6. EFI stub 利用固件提供的 EFI 服务获取内存映射、命令行参数
  7. 内核直接以 64 位模式启动,跳过 16 位实模式阶段
  8. EFI stub 调用 efi_exit_boot_services() 退出固件服务,进入内核

UEFI 路径的关键优势在于:内核可以通过 EFI stub 直接作为 EFI 应用程序被固件加载,完全绕过引导加载程序。在此模式下,bzImage 文件的 PE/COFF 头部发挥作用,UEFI 固件将其视为合法的可执行文件。

两种路径在内核代码层面的汇合点位于压缩内核入口。BIOS 路径经过完整的实模式 → 保护模式 → 长模式链路,而 UEFI 路径则由 EFI stub 完成大部分准备工作后直接进入 64 位代码。无论走哪条路径,最终都会到达 arch/x86/kernel/head_64.S 中的 startup_64,进入统一的内核初始化流程。


本章后续各节将依次深入每个启动阶段的实现细节,从实模式 setup 代码开始,逐步追踪内核的启动全过程。

2.2 固件阶段:BIOS 与 UEFI

当用户按下电源键的那一刻,x86_64 处理器的引脚接收到电源良好(Power Good)信号,硬件复位逻辑被触发。此时,整个系统处于一个定义明确的初始状态,控制权首先交由主板固件(Firmware)接管。固件负责完成硬件初始化、自检以及引导设备的定位,为后续操作系统的加载奠定基础。在现代 x86_64 平台上,固件主要分为两种类型:传统的 BIOS(Basic Input/Output System)和现代的 UEFI(Unified Extensible Firmware Interface)。它们在工作模式、引导方式和功能丰富程度上存在根本性差异。

2.2.1 x86 处理器的复位状态

在深入讨论固件流程之前,有必要先了解 x86_64 处理器上电复位后的精确状态。理解这一状态对于把握后续引导过程至关重要。

处理器复位后,所有通用寄存器被清零(EAXEBXECXEDXESIEDIEBP 均为 0x00000000),但 CSEIP 是唯一的例外。控制寄存器 CR0PE(Protection Enable,位 0)位为 0PG(Paging Enable,位 31)位也为 0,这意味着处理器既未开启保护模式,也未启用分页机制。扩展功能寄存器 EFER(Extended Feature Enable Register)中的 LME(Long Mode Enable)和 LMA(Long Mode Active)均为 0,说明长模式尚未激活。所有段寄存器(DSESFSGSSS)均被初始化为 0

1
2
3
4
5
6
复位后的关键寄存器状态:
CS = 0xFFFF (唯一非零段寄存器)
EIP = 0x0000FFF0 (复位向量)
CR0 = 0x60000010 (PE=0, PG=0)
EFER= 0x00000000 (LME=0, LMA=0)
DS = ES = FS = GS = SS = 0x0000

简而言之,处理器处于实模式(Real Mode),地址空间被限制在 20 位,即最大可寻址 1MB 的内存空间。段寄存器 CS 被硬件设置为 0xFFFFEIP 被设置为 0x0000FFF0,通过实模式的地址转换规则——将段寄存器左移 4 位再加上偏移量——得到线性地址:

1
线性地址 = CS × 16 + EIP = 0xFFFF × 16 + 0xFFF0 = 0xFFFFF + 0xFFF0 = 0x000FFFF0

这个地址 0xFFFFFFF0 被称为复位向量(Reset Vector),它位于 4GB 地址空间的顶端附近。主板设计者会在该地址处放置一条跳转指令(通常位于 ROM/Flash 芯片中),跳转到固件的实际入口点。这正是整个启动链的起点。

2.2.2 BIOS POST:上电自检

对于采用传统 BIOS 固件的系统,处理器完成复位跳转后,BIOS 固件开始执行**上电自检(Power-On Self-Test,POST)**流程。POST 是 BIOS 的核心初始化阶段,其主要职责包括:

硬件初始化与检测。 BIOS 首先对 CPU 进行基本初始化,然后检测并初始化系统内存。经典的 POST 过程会对内存执行写入-读取-校验操作,确认可用内存容量。接下来,BIOS 会枚举并初始化各类系统总线(PCI、ISA 等)上的设备,包括显卡、存储控制器、键盘控制器等基本外设。在这一过程中,用户通常会看到屏幕上显示的 CPU 型号、内存容量等信息。

设备枚举与资源分配。 BIOS 通过扫描总线上的设备标识,逐一为它们分配 I/O 端口、中断请求线(IRQ)和内存映射区域等系统资源。这一过程确保各设备之间不会发生资源冲突。

引导设备定位。 POST 完成后,BIOS 根据用户在 CMOS 中配置的启动顺序(Boot Sequence)逐一检查候选引导设备。对于每个候选设备,BIOS 读取其第一个扇区(共 512 字节),检查该扇区的末尾两个字节是否为引导签名 0x55AA。如果签名有效,则将该扇区视为主引导记录(Master Boot Record,MBR),并将其整个 512 字节内容加载到内存地址 0x7C00 处:

1
2
3
4
5
6
; BIOS 加载 MBR 后的处理器状态
; CS:IP 跳转至 MBR 入口
CS:IP → 0x0000:0x7C00 ; 段地址:偏移地址,对应线性地址 0x7C00
; MBR 最后两字节:
; 偏移 510: 0x55
; 偏移 511: 0xAA

随后,BIOS 通过一条远跳转指令将控制权移交给 0x7C00 处的 MBR 代码。至此,BIOS 的使命基本完成(但它提供的中断服务例程,如 INT 0x13 磁盘读写服务,在后续的引导加载器阶段仍然可用)。0x7C00 这个地址并非随意选取,它源于最早的 IBM PC 5150 设计——该机型拥有 32KB 内存,0x7C00 正好留下 32KB - 512 字节 - 1KB 的空间给 MBR 代码及其栈空间,是当时工程权衡的结果。

2.2.3 UEFI 启动:现代化的固件接口

与传统 BIOS 不同,UEFI(统一可扩展固件接口)代表了固件技术的根本性变革。UEFI 的一个关键特征是:固件从一开始就运行在 64 位长模式(Long Mode)下。这意味着 UEFI 完全跳过了实模式这一历史遗留物,无需经历从 16 位实模式到 32 位保护模式再到 64 位长模式的繁琐切换过程。

UEFI 启动管理器。 UEFI 固件内置了功能完备的启动管理器(Boot Manager)。启动管理器读取 NVRAM 中存储的引导变量(如 BootOrderBoot0000 等),按照预定顺序查找并加载 EFI 应用程序。每个引导项指向一个位于 EFI 系统分区(ESP,通常为 FAT32 格式)上的 .efi 可执行文件。

PE/COFF 可执行格式。 UEFI 直接将 EFI 应用程序作为 PE/COFF(Portable Executable / Common Object File Format) 格式的可执行文件加载和运行。这与 BIOS 只能加载 512 字节 MBR 的局限形成了鲜明对比。UEFI 的引导加载器(如 GRUB 的 grubx64.efi)或操作系统内核本身都可以作为 EFI 应用程序被直接加载。

EFI Stub 机制。 Linux 内核在 arch/x86/boot/header.S 中巧妙地为 bzImage 嵌入了一个 PE/COFF 头部。这意味着 Linux 内核镜像本身就符合 EFI 应用程序的格式规范,可以被 UEFI 固件直接识别和加载,无需借助任何第三方引导加载器。当 UEFI 加载此镜像时,它直接调用 efi_pe_entry() 函数,该函数是内核在 EFI 环境下的入口点:

1
2
3
; arch/x86/boot/header.S 中的 PE/COFF 头部
; 使 bzImage 可被 UEFI 直接识别为合法的 EFI 应用程序
; 入口点:efi_pe_entry() (arch/x86/boot/compressed/efi_stub_64.S)

这一机制被称为 EFI Stub,它使得 Linux 内核可以在没有 GRUB 或 systemd-boot 等中间引导加载器的情况下,由 UEFI 固件直接引导启动。

UEFI 启动服务。 UEFI 为运行中的 EFI 应用程序提供了丰富的启动服务(Boot Services),包括但不限于:内存分配(EFI_BOOT_SERVICES.AllocatePool())、设备访问(通过块 I/O 协议、简单文件系统协议等)、控制台输入输出(EFI_BOOT_SERVICES.*() 系列函数)。这些服务在操作系统调用 ExitBootServices() 之前一直可用,为内核的早期初始化提供了标准化的硬件访问接口。

2.2.4 两条路径的交汇点

BIOS 和 UEFI 代表了两条截然不同的引导路径,但它们最终都将控制权交给 Linux 内核:

1
2
3
4
5
6
7
8
BIOS 路径:
CPU 复位(0xFFFFFFF0) → BIOS POST → 加载 MBR 至 0x7C00
→ 引导加载器(如 GRUB Stage 1) → 加载内核镜像 → 跳转至内核入口

UEFI 路径:
UEFI 固件(64位长模式) → UEFI 启动管理器
→ 加载 EFI 应用程序(引导加载器或内核 EFI Stub)
→ 调用 efi_pe_entry() → 内核接管

在 BIOS 路径中,固件仅负责加载第一个扇区的 512 字节代码,后续的内核加载工作完全由引导加载器(如 GRUB)完成。而在 UEFI 路径中,固件本身具备直接加载和执行复杂 EFI 应用程序的能力,内核可以通过 EFI Stub 被固件直接加载,大幅简化了引导链。

无论经过哪条路径,当内核的入口点获得控制权时,系统都必须面对一个共同的任务:在已有的固件环境基础上,建立操作系统自己的内存管理、中断处理和设备驱动体系。这正是后续章节将要详细展开的内容。

2.3 引导加载程序与 Linux Boot Protocol

在内核获得 CPU 控制权之前,引导加载程序(Bootloader)承担着至关重要的职责:将内核映像从存储设备加载到内存中,并按照内核预期的格式设置各类启动参数。Linux 内核为此定义了一套严格的 Boot Protocol(启动协议),所有主流引导加载程序(GRUB2、systemd-boot、syslinux 等)都必须遵循该协议。本节将以 GRUB2 为例,深入剖析 Boot Protocol 的设计、bzImage 的内部结构,以及引导加载程序与内核之间的握手过程。


2.3.1 Linux Boot Protocol 概述

Linux Boot Protocol 在内核源码树 Documentation/x86/boot.rst 中定义。在 Linux 7.0 中,协议版本为 0x020f(即 2.15 版)。该协议规定了引导加载程序必须如何解析内核映像头部、如何加载内核各部分到内存、以及如何构造启动参数结构体。

Boot Protocol 的核心是 setup header(setup 头),它嵌入在 bzImage 映像的固定偏移处。引导加载程序通过读取这些头部字段来获知内核的加载需求。

setup_header 结构体

struct setup_header 定义在 arch/x86/include/uapi/asm/bootparam.h 中,它在 bzImage 中的起始偏移为 0x1F1。以下是该结构体中最关键的字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
struct setup_header {
__u8 setup_sects; /* 偏移 0x1F1: 实模式 setup 代码的扇区数 */
__u16 root_flags; /* 偏移 0x1F2 */
__u32 syssize; /* 偏移 0x1F4: 内核有效载荷大小(16 字节单位) */
__u16 ram_size; /* 偏移 0x1F8 */
__u16 vid_mode; /* 偏移 0x1FA */
__u16 root_dev; /* 偏移 0x1FC */
__u16 boot_flag; /* 偏移 0x1FE: 必须为 0xAA55 */
__u16 jump; /* 偏移 0x200: 跳转指令 */
__u32 header; /* 偏移 0x202: 魔数 "HdrS" (0x53726448) */
__u16 version; /* 偏移 0x206: Boot Protocol 版本号 */
__u32 realmode_swtch; /* 偏移 0x208 */
__u16 start_sys_seg; /* 偏移 0x20C */
__u16 kernel_version; /* 偏移 0x20E: 内核版本字符串偏移 */
__u8 type_of_loader; /* 偏移 0x210: 引导加载程序标识 */
__u8 loadflags; /* 偏移 0x211: 加载标志位 */
__u16 setup_move_size; /* 偏移 0x212 */
__u32 code32_start; /* 偏移 0x214: 保护模式入口点地址 */
__u32 ramdisk_image; /* 偏移 0x218: initrd 加载地址 */
__u32 ramdisk_size; /* 偏移 0x21C: initrd 大小 */
__u32 bootsect_kludge; /* 偏移 0x220 */
__u16 heap_end_ptr; /* 偏移 0x224 */
__u8 ext_loader_ver; /* 偏移 0x226 */
__u8 ext_loader_type; /* 偏移 0x227 */
__u32 cmd_line_ptr; /* 偏移 0x228: 内核命令行指针 */
__u32 initrd_addr_max; /* 偏移 0x22C: initrd 最大地址 */
__u32 kernel_alignment; /* 偏移 0x230: 内核加载地址对齐要求 */
__u8 relocatable_kernel; /* 偏移 0x234: 内核是否可重定位 */
__u8 min_alignment; /* 偏移 0x235 */
__u16 xloadflags; /* 偏移 0x236 */
__u32 cmdline_size; /* 偏移 0x238: 命令行最大长度 */
__u32 hardware_subarch; /* 偏移 0x23C */
__u64 hardware_subarch_data;/* 偏移 0x240 */
__u32 payload_offset; /* 偏移 0x248: 压缩有效载荷偏移 */
__u32 payload_length; /* 偏移 0x24C: 压缩有效载荷长度 */
__u64 setup_data; /* 偏移 0x250 */
__u64 pref_address; /* 偏移 0x258: 可重定位内核的首选地址 */
__u32 init_size; /* 偏移 0x260: 内核运行时所需内存大小 */
__u32 handover_offset; /* 偏移 0x264: EFI handover 入口偏移 */
__u32 kernel_info_offset; /* 偏移 0x268: kernel_info 偏移 */
} __attribute__((packed));

魔数签名验证

引导加载程序首先在 bzImage 的偏移 0x202 处查找 4 字节魔数。该魔数为字符串 “HdrS”,对应十六进制值 0x53726448(小端序存储)。只有检测到该魔数,引导加载程序才会按照 Boot Protocol 的规范继续解析后续字段:

1
2
3
4
5
6
7
8
9
10
11
/* 引导加载程序的典型检查逻辑 */
if (header->header != 0x53726448) { /* "HdrS" */
/* 非标准 Boot Protocol 映像,可能是旧格式 */
return -EINVAL;
}

/* 检查 Boot Protocol 版本 */
if (header->version < 0x0208) {
/* 协议版本过低,缺少必要字段 */
return -EINVAL;
}

loadflags 标志位详解

偏移 0x211 处的 loadflags 字段包含多个关键标志位:

名称 含义
0 LOADED_HIGH 内核是否加载到高地址(bzImage 必须置位)
1 KASLR_FLAG 内核是否支持 KASLR(内核地址随机化)
4 LOAD_HIGH 已废弃
5 QUIET_FLAG 静默启动模式
7 CAN_USE_HEAP 表示 setup 代码可以使用堆空间

其中 LOADED_HIGH(bit 0)对 bzImage 格式的内核始终为 1,表示内核的实模式部分加载到高地址(0x90000 段),而非传统的 0x10000 段。CAN_USE_HEAP(bit 7)告诉引导加载程序需要为 setup 代码分配堆空间,堆的结束地址由 heap_end_ptr 字段指定。

关键地址字段

  • code32_start(偏移 0x214):保护模式下的内核入口点地址。默认值为 0x100000(1MB),这是 Legacy BIOS 环境下内核压缩有效载荷的标准加载地址。引导加载程序将压缩内核加载到此地址后,由 setup 代码完成解压跳转。
  • pref_address(偏移 0x258):对于标记为 relocatable_kernel 的内核,此字段给出内核最期望被加载到的物理地址,通常为 0x1000000(16MB)或更高,以便为 DMA 等低地址需求预留空间。
  • kernel_alignment(偏移 0x230):内核加载地址必须满足的对齐约束,通常为 2MB(对应大页大小)。
  • initrd_addr_max(偏移 0x22C):initrd/initramfs 可以加载的最大物理地址。超过此地址的 initrd 将无法被内核正确访问。

2.3.2 bzImage 的内部结构

bzImage 是 x86_64 Linux 内核的标准映像格式。尽管名称中带有 “bz”,它并非使用 bzip2 压缩,而是 historically 继承自 “big zImage”(突破了旧 zImage 格式的大小限制)。bzImage 的内部结构如下:

1
2
3
4
5
6
7
8
9
10
11
偏移        内容                      说明
──────────────────────────────────────────────────────────
0x000 Boot Sector (512B) 传统 MBR 引导扇区(已基本废弃)
0x200 Setup Code 实模式 setup 代码(包含 setup_header)
└ 0x1F1 setup_header 开始 引导加载程序解析的核心头部
└ 0x202 "HdrS" 魔数 Boot Protocol 签名
└ 变长 实模式代码与数据 含视频模式设置、BIOS 调用等
──────────────────────────────────────────────────────────
setup 之后 Payload Header 压缩有效载荷头部(含解压例程)
Compressed Kernel 经 gzip/lzma/xz/zstd 压缩的内核
└ startup_32 / startup_64 解压后的 32/64 位入口点

更精确地,用 setup_sects 字段可以计算出 setup 代码的大小:

1
setup 大小 = (setup_sects + 1) * 512 字节

压缩有效载荷的位置由 payload_offsetpayload_length 精确定位:

1
2
3
/* 在 bzImage 中定位压缩有效载荷 */
void *payload = (void *)image_base + (setup_sects + 1) * 512 + payload_offset;
size_t payload_len = payload_length;

值得注意的是,现代 bzImage 的开头还可能包含一个 PE/COFF 头部,这是为了满足 UEFI 固件对可执行文件格式的要求。该头部由内核构建脚本 arch/x86/boot/header.S 生成,使 bzImage 同时可以作为 EFI 应用程序被 UEFI 固件直接加载。


2.3.3 GRUB2 的内核加载流程

GRUB2(GRand Unified Bootloader version 2)是大多数 Linux 发行版的默认引导加载程序。其 Linux 内核加载逻辑集中在 grub-core/loader/i386/linux.c 文件中,核心函数为 grub_cmd_linux()(对应 GRUB 配置中的 linux 命令)。

第一步:读取并验证 setup header

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* 简化的 GRUB2 内核加载逻辑 */
grub_err_t grub_cmd_linux(grub_command_t cmd, int argc, char *argv[])
{
struct linux_kernel_header lh; /* 对应 setup_header */

/* 读取 bzImage 的 setup header(前 4096 字节即可) */
grub_file_read(file, &lh, sizeof(lh));

/* 验证魔数 */
if (lh.header != GRUB_LINUX_MAGIC_SIGNATURE) {
return grub_error(GRUB_ERR_BAD_OS, "invalid magic number");
}

/* 检查是否为 bzImage(必须加载到高地址) */
if (!(lh.loadflags & GRUB_LINUX_FLAG_LOADED_HIGH)) {
return grub_error(GRUB_ERR_BAD_OS, "zImage not supported");
}
}

第二步:加载内核各部分到内存

GRUB2 将 bzImage 的不同部分加载到内存中的不同位置:

1
2
3
4
5
6
7
8
9
10
/*
* 内存布局概览:
*
* 0x00000 - 0x07FFF : BIOS / EBDA 区域(避免使用)
* 0x08000 - 0x08FFF : boot_params 结构体(4096 字节)
* 0x09000 - 0x0902FF : 实模式 setup 代码的头部(被 GRUB 覆盖)
* 0x10000+ : 实模式 setup 代码
* 0x100000+ : 压缩内核(32 位入口点)
* < initrd_addr_max : initrd / initramfs
*/

GRUB2 为 setup 代码和压缩内核分别分配内存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* 加载实模式 setup 代码 */
grub_linux_real_addr = (grub_addr_t)grub_mmap_alloc(
real_size, /* setup_sects * 512 */
0x10000, /* 对齐到 64KB */
GRUB_MEMORY_AVAILABLE
);

/* 加载压缩内核到 code32_start 或更高地址 */
grub_linux_prot_addr = lh.code32_start; /* 默认 0x100000 */
if (lh.relocatable_kernel) {
/* 可重定位内核:在满足对齐要求的范围内寻找合适地址 */
grub_linux_prot_addr = find_aligned_addr(
lh.kernel_alignment,
lh.pref_address,
lh.init_size
);
}

第三步:构造 boot_params 结构体

struct boot_params(定义在 arch/x86/include/uapi/asm/bootparam.h)是引导加载程序传递给内核的核心数据结构,大小恰好为 4096 字节(一页)。它的第一个成员就是 setup_header,后续还包含 EDD 信息、EFI 信息、视频模式信息等:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
struct boot_params {
struct screen_info screen_info; /* 偏移 0x000: 显示信息 */
struct apm_bios_info apm_bios_info; /* 偏移 0x040 */
__u8 _pad4[4]; /* 偏移 0x054 */
__u64 tboot_shared_addr; /* 偏移 0x058 */
struct ist_info ist_info; /* 偏移 0x060 */
__u64 acpi_rsdp_addr; /* 偏移 0x070: RSDP 地址 */
__u8 _pad8[8]; /* 偏移 0x078 */
__u8 hd0_info[16]; /* 偏移 0x080 */
__u8 hd1_info[16]; /* 偏移 0x090 */
struct sys_desc_table sys_desc_table; /* 偏移 0x0A0 */
__u8 olpc_ofw_header[128]; /* 偏移 0x0B0 */
__u32 ext_ramdisk_image; /* 偏移 0x130 */
__u32 ext_ramdisk_size; /* 偏移 0x134 */
__u32 ext_cmd_line_ptr; /* 偏移 0x138 */
__u8 _pad4_1[112]; /* 偏移 0x13C */
__u64 cc_blob_address; /* 偏移 0x1AC: AMD SEV/SNP 信息 */
struct edid_info edid_info; /* 偏移 0x1C0 */
struct efi_info efi_info; /* 偏移 0x1C8: EFI 系统表信息 */
__u32 alt_mem_k; /* 偏移 0x1E0 */
__u32 scratch; /* 偏移 0x1E4 */
__u8 e820_entries; /* 偏移 0x1E8 */
__u8 eddbuf_entries; /* 偏移 0x1E9 */
__u8 edd_mbr_sig_buf_entries; /* 偏移 0x1EA */
__u8 kbd_status; /* 偏移 0x1EB */
__u8 secure_boot; /* 偏移 0x1EC */
__u8 _pad5[17]; /* 偏移 0x1ED */
struct setup_header hdr; /* 偏移 0x1F1: setup_header! */
__u8 _pad6[0x29d - 0x1f1 - sizeof(struct setup_header)];
struct e820_entry e820_table[E820_MAX_ENTRIES_ZEROPAGE]; /* 0x2D0 */
__u8 _pad8_1[48]; /* 偏移 0xCD0 */
struct edd_info eddbuf[EDDMAXNR]; /* 偏移 0xD00 */
__u8 _pad9[276]; /* 偏移 0xECC */
} __attribute__((packed));

GRUB2 在跳转到内核之前必须完成以下 boot_params 字段的设置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/* GRUB2 填充 boot_params 的关键步骤 */
struct boot_params *params;

params = (struct boot_params *)boot_params_addr;
memset(params, 0, sizeof(*params));

/* 复制 setup_header(从 bzImage 中读取的原始数据) */
memcpy(&params->hdr, &lh, sizeof(lh));

/* 设置内核命令行 */
params->hdr.cmd_line_ptr = (__u32)cmdline_addr;
params->hdr.cmdline_size = cmdline_len + 1;

/* 设置 initrd 地址和大小 */
params->hdr.ramdisk_image = (__u32)initrd_addr;
params->hdr.ramdisk_size = (__u32)initrd_size;
/* 对于 64 位地址,使用扩展字段 */
params->ext_ramdisk_image = (__u32)(initrd_addr >> 32);
params->ext_ramdisk_size = (__u32)(initrd_size >> 32);

/* 填充 E820 内存映射表 */
for (i = 0; i < e820_entries; i++) {
params->e820_table[i].addr = mmap[i].addr;
params->e820_table[i].size = mmap[i].size;
params->e820_table[i].type = mmap[i].type;
}
params->e820_entries = e820_entries;

/* 如果是 UEFI 启动,设置 EFI 信息 */
params->efi_info.efi_systab = (grub_uint32_t)(grub_addr_t)system_table;
params->efi_info.efi_memdesc_size = desc_size;
params->efi_info.efi_memdesc_version = desc_version;
params->efi_info.efi_memmap = (grub_uint32_t)(grub_addr_t)mmap_key;
params->efi_info.efi_memmap_size = mmap_size;

/* 标识引导加载程序类型 */
params->hdr.type_of_loader = 0x72; /* GRUB2 的 loader ID */
params->hdr.loadflags |= GRUB_LINUX_FLAG_CAN_USE_HEAP;
params->hdr.heap_end_ptr = params->hdr.setup_move_size - 0x200;

第四步:跳转到内核入口

最终,GRUB2 将 CPU 控制权交给内核。在传统 BIOS 模式下,跳转流程如下:

1
2
3
4
5
6
GRUB2 执行跳转前设置:
- ESI = &boot_params(boot_params 结构体的物理地址)
- DS = ES = SS = 实模式段地址
- CS:IP = setup 代码入口点(16 位实模式)

随后执行 far jump 进入内核实模式 setup 代码

2.3.4 入口点详解:16 位、32 位与 64 位

Linux 内核支持多种入口点,对应不同的启动路径:

16 位实模式入口(Legacy BIOS)

这是传统的入口点,位于 arch/x86/boot/header.S 中的 _start 标号。在此路径下,CPU 首先以 16 位实模式执行 setup 代码,然后由 setup 代码自行切换到保护模式,最终跳转到压缩内核的 32 位入口。整个流程为:

1
2
3
4
5
6
7
GRUB2 (16-bit real mode)
→ setup code (arch/x86/boot/header.S: _start)
→ arch/x86/boot/main.c: go_to_protected_mode()
→ arch/x86/boot/pm.c: protected_mode_jump()
→ 压缩内核的 startup_32
→ 解压内核
→ startup_64(长模式)

32 位保护模式入口

引导加载程序也可以直接将 CPU 切换到 32 位保护模式后跳转到内核。此时跳转目标为 code32_start 指向的地址(即压缩内核的起始位置)。这种方式跳过了内核实模式 setup 代码的 16 位阶段。某些嵌入式场景或定制的引导加载程序使用此路径。

64 位 EFI Handover 入口

在 UEFI 环境下,GRUB2 可以使用 EFI Handover Protocol。此协议在 setup header 的 handover_offset 字段中指定了一个 64 位入口点,该入口点位于压缩内核偏移 0x200 处的 startup_64。GRUB2 在 UEFI 模式下的跳转约定如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
/* EFI Handover Protocol 跳转约定 */
typedef void (*efi_kernel_entry_t)(
struct boot_params *boot_params, /* RDI (x86_64 SysV ABI) */
void *efi_system_table /* RSI */
);

/* 计算入口地址 */
efi_kernel_entry_t entry = (efi_kernel_entry_t)(
(void *)kernel_load_addr + handover_offset
);

/* 直接跳转,CPU 已经处于 64 位长模式 */
entry(boot_params, efi_system_table);

这种方式完全绕过了 16 位实模式代码,因为 UEFI 固件在调用 GRUB2 时就已经将 CPU 置于 64 位长模式。内核的 startup_64 例程(位于 arch/x86/boot/compressed/head_64.S)会直接从长模式开始执行,完成内核自解压后跳转到解压后的主内核入口。


2.3.5 initrd / initramfs 的加载

除了内核本身,引导加载程序还负责将初始内存盘(initrd 或 initramfs)加载到内存中。其关键步骤为:

  1. GRUB2 读取 initrd 命令指定的文件(通常为 /boot/initramfs-<version>.img)。
  2. 在物理内存中分配一块区域,确保其起始地址低于 initrd_addr_max(通常为 0x7FFFFFFF,即 2GB 以下)。
  3. 将 initrd 文件内容加载到该区域。
  4. boot_params 中设置 ramdisk_image(及其 64 位扩展 ext_ramdisk_image)和 ramdisk_size(及其扩展 ext_ramdisk_size)字段。

内核启动后,在早期初始化阶段会读取这些字段来定位 initrd,并将其中的内容解压为 rootfs。如果 initrd 是 cpio 格式(即 initramfs),内核会直接将其解压为 tmpfs;如果是传统的文件系统映像格式,则通过 initrd 驱动以块设备方式挂载。


2.4 实模式设置代码(Real-Mode Setup)

当引导装载程序(Bootloader)将内核镜像加载到内存后,CPU 依然处于实模式(Real Mode)下运行。Linux 内核的第一段代码位于 arch/x86/boot/ 目录中,负责在实模式下完成硬件探测、内存检测、视频模式设置等一系列初始化工作,最终将 CPU 切换到保护模式(Protected Mode)。本章将深入分析这段实模式设置代码的完整执行流程。

2.4.1 入口点:header.S

内核实模式代码的入口点定义在 arch/x86/boot/header.S 中。这个文件同时充当引导装载程序与内核之间的协议接口——文件头部包含了一系列魔数(Magic Number)和结构化的头部信息,供 GRUB、SYSLINUX 等引导装载程序识别和解析。

PE/COFF 混合头部与 MZ 签名

当内核配置了 CONFIG_EFI_STUB 选项时(现代发行版通常启用),内核镜像本身可以被 UEFI 固件直接作为 EFI 应用程序加载。为此,header.S 的起始偏移 0 处放置了一个 DOS/PE 可执行文件的经典魔数 "MZ"(0x4D5A),这正是 EFI 混合镜像(EFI Hybrid Stub)的标记。紧随其后的是一段 PE/COFF 头部占位区域,使得整个内核镜像同时满足 EFI 可执行文件格式和传统 Linux 启动协议的双重需求。

_start 与短跳转

真正的实模式入口点 _start 位于偏移 512 字节(0x200)处。引导装载程序在加载内核后,会跳转到这个位置开始执行。这里的第一条指令是一个短跳转:

1
2
3
4
    .globl _start
_start:
.byte 0xeb /* 短跳转操作码 */
.byte start_of_setup - 1f /* 跳转偏移量 */

反汇编后可以看到经典的 EB 4C 字节序列——一个 2 字节的短跳转指令,越过中间的头部信息字段,跳转到 start_of_setup 标签处。头部信息字段中包含了启动协议版本、内核版本字符串、命令行指针、初始化 RAM 盘(initrd)信息等,这些字段由引导装载程序在加载内核时填写。

start_of_setup:段寄存器初始化

start_of_setup 是实模式汇编初始化的核心部分,依次完成以下工作:

1
2
3
4
5
6
7
8
9
10
11
12
start_of_setup:
/* 强制将 DS 段寄存器设置为 CS 的值 */
movw %ds, %ax
cmpw %ax, %cs:move_self+2
je 1f
movw %cs, %ax
movw %ax, %ds
1:
/* 将所有段寄存器统一设置为 DS */
movw %ds, %ax
movw %ax, %es
movw %ax, %ss

这段代码首先确保 DS 寄存器与 CS 寄存器指向同一个段。在某些引导场景下,DS 和 CS 可能不一致,这会导致后续的内存访问出错。然后将 ES 和 SS 也统一设置为与 DS 相同的值,建立起一致的段寄存器环境。接着设置栈指针 SP,使其指向一个安全的内存区域:

1
movw    $0x4000 - 12, %sp      /* 栈顶设置在 0x4000 - 12 */

验证 setup 签名

段寄存器初始化完成后,代码会验证偏移 0x202 处是否存在启动协议签名 "HdrS"(0x48647253):

1
2
cmpl    $0x53726448, setup_sig  /* 检查 "HdrS" 签名(小端序) */
jne setup_bad /* 签名不匹配则报错 */

这个签名是 Linux 启动协议的核心标识。如果签名不匹配,说明引导装载程序传递的启动参数格式有误,内核将跳转到错误处理代码,在屏幕上显示 “No setup signature found…” 信息后挂起系统。

清零 BSS 段

签名验证通过后,代码将 BSS(Block Started by Symbol)段清零。BSS 段是未初始化的全局变量和静态变量存储的区域,C 语言标准要求这些变量初始值为零:

1
2
3
4
5
6
7
/* 清零 BSS 段:从 __bss_start 到 _end */
movw $__bss_start, %di
movw %ds, %ax
movw %ax, %es
xorw %ax, %ax
movw $_end - $__bss_start, %cx
rep; stosb

这段代码使用 rep stosb 指令将 BSS 段的每一个字节都设置为 0,确保 C 代码运行时所有未初始化变量都是零值。

跳转到 C 代码

BSS 段清零完成后,汇编代码通过一条远调用指令跳转到 C 语言的入口函数 main()

1
calll   main                   /* 调用 C 入口函数 */

至此,执行流程从汇编语言过渡到 C 语言,进入更加结构化的初始化阶段。

2.4.2 main.c —— 实模式 C 入口

arch/x86/boot/main.c 中的 main() 函数是实模式下所有 C 语言初始化代码的总调度器。它按照严格的顺序调用各个子系统的初始化函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void main(void)
{
init_default_io_ops(); /* 初始化默认 I/O 操作函数 */
copy_boot_params(); /* 复制启动参数到 boot_params 结构 */
console_init(); /* 初始化早期控制台(串口输出) */
init_heap(); /* 初始化堆内存管理器 */
validate_cpu(); /* 验证 CPU 是否支持长模式(64位) */
set_bios_mode(); /* 通知 BIOS 即将进入长模式 */
detect_memory(); /* 通过 BIOS 中断检测物理内存布局 */
keyboard_init(); /* 初始化键盘(设置重复速率) */
query_ist(); /* 查询 Intel SpeedStep 信息 */
set_video(); /* 设置视频模式 */
go_to_protected_mode(); /* 切换到保护模式! */
}

每个函数的调用顺序都经过精心安排。例如,copy_boot_params() 必须在所有其他函数之前调用,因为后续几乎所有初始化操作都需要读取 boot_params 中的信息;console_init() 必须尽早执行,以便后续代码可以通过串口输出调试信息;validate_cpu()set_bios_mode()detect_memory() 之前执行,确保 CPU 具备运行 64 位内核的能力。

值得注意的是,这个 main() 函数不需要返回。如果由于某种原因函数返回了,调用它的汇编代码会进入一个无限循环,因为此时系统已经处于不可恢复的状态。

2.4.3 copy_boot_params():启动参数复制

copy_boot_params() 函数负责将引导装载程序通过启动协议传递的参数统一整理到内核的 boot_params 结构中:

1
2
3
4
5
6
7
8
9
10
11
static void copy_boot_params(void)
{
struct old_cmdline {
u16 cl_magic, cl_offset;
};
const struct old_cmdline * const oldcmd =
(const struct old_cmdline *)__STACK_SIZE;

/* 将 hdr(setup header)复制到 boot_params.hdr */
memcpy(&boot_params.hdr, &hdr, sizeof(hdr));
}

boot_params 是一个 4096 字节(一页)大小的结构体,按照 16 字节边界对齐。它是实模式代码与后续保护模式/长模式代码之间传递信息的核心数据结构,包含了内存映射表、视频模式信息、命令行参数、EFI 相关数据等几乎所有启动阶段收集到的硬件信息。

该函数还处理旧版命令行协议的兼容性问题。如果检测到旧式命令行魔数 0xA33F,会将命令行指针转换为现代格式存储到 boot_params.hdr.cmd_line_ptr 中,确保即使使用旧版引导装载程序也能正确获取内核启动命令行。

2.4.4 detect_memory():物理内存检测

arch/x86/boot/memory.c 中的 detect_memory() 函数是实模式下最关键的初始化之一。它通过三种不同的 BIOS 中断调用方法来探测物理内存布局,从最新最详细的方法开始尝试,逐步降级到最古老的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int detect_memory(void)
{
int err = -1;

if (detect_memory_e820() > 0) {
err = 0;
}
if (!err && detect_memory_e801() > 0) {
err = 0;
}
if (!err && detect_memory_88() > 0) {
err = 0;
}
return err;
}

INT 0x15/E820:详细内存映射

detect_memory_e820() 是首选的内存检测方法,它通过 BIOS 中断 INT 0x15, AX=0xE820 获取系统完整的物理内存映射。每次调用返回一个内存区域描述符,包含三个关键字段:

  • 基地址(base):该内存区域的起始物理地址
  • 长度(length):该内存区域的大小
  • 类型(type):该内存区域的用途分类

内存类型包括:

类型值 名称 含义
1 AddressRangeMemory 可用 RAM
2 AddressRangeReserved 保留区域(不可使用)
3 AddressRangeACPI ACPI 表数据(可回收)
4 AddressRangeNVS ACPI NVS 区域(必须保留)
5 AddressRangeUnusable 不可用区域(存在但损坏)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static int detect_memory_e820(void)
{
int count = 0;
struct biosregs ireg, oreg;
struct e820entry *desc = boot_params.e820_table;
static u32 desc_idx;

initregs(&ireg);
ireg.ax = 0xe820;
ireg.cx = sizeof(*desc);
ireg.edx = SMAP;
ireg.di = (size_t)desc;

do {
intcall(0x15, &ireg, &oreg);
ireg.ebx = oreg.ebx; /* 用于下一次迭代的 continuation 值 */
} while (ireg.ebx && count < ARRAY_SIZE(boot_params.e820_table));
}

每次调用 INT 0x15 后,BIOS 返回一个 EBX 延续值。当 EBX 为 0 时表示所有内存区域已枚举完毕。返回的内存条目被存储在 boot_params.e820_table[] 数组中,供后续保护模式和长模式下的内存管理子系统使用。E820 方法最多可以记录 128 个内存区域描述符。

INT 0x15/E801 和 INT 0x15/88:降级方案

如果 E820 调用失败,内核会尝试 INT 0x15, AX=0xE801 方法。这是一种较老的接口,返回 1MB 以上到 16MB 之间的内存大小(以 KB 为单位)以及 16MB 以上的内存大小(以 64KB 为块单位)。

最古老的方法是 INT 0x15, AH=0x88,它仅返回 1MB 以上的连续可用内存大小(以 KB 为单位),功能非常有限。在现代系统上通常不会使用这两种降级方案,但保留它们是为了兼容极老的硬件。

2.4.5 控制台初始化

console_init() 函数负责建立实模式下的早期控制台输出能力。在调试场景中,这是内核最早能够输出文字信息的通道:

1
2
3
4
5
6
7
void console_init(void)
{
init_serial(); /* 初始化串口 */
if (cmdline_find_option("console", ...) == 0) {
/* 解析 console= 参数,配置串口波特率等 */
}
}

串口(Serial Port)是实模式下最可靠的调试输出通道。默认使用 COM1 端口(I/O 端口地址 0x3F8),波特率通常设置为 115200。当内核编译时启用了调试选项,控制台初始化完成后会通过串口输出 "early console in setup code\n" 消息,确认早期控制台已经就绪。

串口输出的实现基于直接对 I/O 端口的操作,不依赖任何驱动程序。发送一个字节只需等待串口发送缓冲区为空(检查 Line Status Register 的 THRE 位),然后将字节写入 Transmit Holding Register。这种极简的实现保证了在实模式早期阶段就能正常工作。

2.4.6 CPU 验证:长模式检测

validate_cpu() 函数确保当前 CPU 支持 64 位长模式(Long Mode)。对于 x86_64 架构的内核而言,这是运行的必要条件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int validate_cpu(void)
{
u32 *err = (u32 *)boot_params.e820_table;
int cpu_level;

cpu_level = check_cpu(-1, err, err);

if (cpu_level < 64) {
printf("This kernel requires an %s CPU, ", "x86-64");
printf("but only detected an %s CPU.\n", cpu_name(cpu_level));
return -1;
}
return 0;
}

实际的 CPU 特性检测在 arch/x86/boot/cpucheck.c 中的 check_cpu() 函数里完成。该函数通过 CPUID 指令逐步检查 CPU 支持的特性:

  1. 首先调用 CPUID 基础功能(EAX=0x01),检查是否有扩展特性支持
  2. 然后调用 CPUID 扩展功能(EAX=0x80000000),获取扩展功能的最大支持值
  3. 如果扩展功能支持到 0x80000001 及以上,调用 CPUID EAX=0x80000001 获取扩展特性标志
  4. 检查 EDX 寄存器的第 29 位——即 LM(Long Mode)位。该位为 1 表示 CPU 支持 64 位长模式

如果 CPU 不支持长模式,内核会在屏幕上打印明确的错误信息并中止启动。这意味着用户试图在不支持 64 位的旧处理器(如早期的 Pentium 4 或更老的 CPU)上运行 x86_64 内核时,会得到清晰的提示而非难以理解的崩溃。

2.5 从实模式到保护模式:跨越 16 位到 32 位的鸿沟

当引导加载器(如 GRUB)将内核映像加载到内存后,CPU 仍然运行在实模式(Real Mode)下——这是 8086 时代遗留的 16 位寻址模式,地址空间仅有 1MB。现代内核必须尽早切换到保护模式(Protected Mode),以获得 32 位寻址能力和硬件级内存保护。Linux 7.0 内核中,这一关键转换由 arch/x86/boot/pm.c 中的 go_to_protected_mode() 函数完成。

2.5.1 go_to_protected_mode():模式切换的总指挥

该函数是实模式到保护模式切换的入口,按照严格的顺序执行六个步骤:

1
2
3
4
5
6
7
8
9
10
11
12
// arch/x86/boot/pm.c
void go_to_protected_mode(void)
{
realmode_switch_hook(); // 关闭中断(CLI + 禁用 NMI)
enable_a20(); // 启用 A20 地址线,突破 1MB 限制
reset_coprocessor(); // 复位数学协处理器
mask_all_interrupts(); // 屏蔽所有 PIC 中断
setup_idt(); // 加载空 IDT
setup_gdt(); // 设置最小化 GDT
protected_mode_jump(boot_params.hdr.code32_start,
(u32)&boot_params + (ds() << 4));
}

这个顺序至关重要。首先必须确保中断被完全禁用,因为切换过程中 CPU 处于一个脆弱的中间状态——段寄存器仍然是实模式的值,而 IDT 还没有为保护模式做好准备。任何在此期间触发的中断都会导致不可预测的行为,最可能的后果是三重故障(Triple Fault)和系统复位。

2.5.2 A20 地址线的启用

为什么需要 A20?

在 8086 处理器上,地址线只有 20 根(A0-A19),最大寻址空间为 1MB。当程序访问超过 0xFFFFF(1MB)的地址时,地址会自动回绕(wrap around)到 0。例如,0xFFFF:0x0010 在 8086 上实际访问的是 0x00000000,而不是 0x100010

80286 引入了 24 根地址线,寻址空间扩展到 16MB。但为了保持与 8086 的向后兼容,A20 地址线默认被禁用,以模拟地址回绕行为。进入保护模式后,内核需要访问 1MB 以上的内存,因此必须先启用 A20 地址线。

三种启用方法的尝试顺序

enable_a20() 定义在 arch/x86/boot/a20.c 中,按可靠性从高到低的顺序依次尝试三种方法:

方法一:快速 A20(Fast A20)

通过系统控制端口 0x92 的第 1 位来启用 A20:

1
2
3
4
5
6
7
// arch/x86/boot/a20.c - 简化逻辑
static int enable_a20_fast(void)
{
u8 port92 = inb(0x92);
outb(port92 | 0x02, 0x92); // 设置 bit 1
// 验证 A20 是否确实启用...
}

端口 0x92 是 IBM 在 AT 兼容机上引入的快速 A20 控制方法,直接操作硬件,速度最快。这是首选方案。

方法二:键盘控制器(KBC)

如果快速 A20 不可用,则通过 8042 键盘控制器来控制 A20。键盘控制器的端口 0x64 用于发送命令,端口 0x60 用于读写数据:

1
2
3
4
// 通过键盘控制器启用 A20 的典型流程
outb(0xD1, 0x64); // 命令:写输出端口
// 等待键盘控制器就绪...
outb(0xDF, 0x60); // 数据:设置 P2 输出端口的 bit 1(A20 使能)

命令 0xD1 告诉键盘控制器下一个写入 0x60 端口的数据应该作为输出端口 P2 的值。0xDF(二进制 11011111)将 A20 门控位置 1,同时保持其他位不变。这个方法比较慢,因为键盘控制器需要时间处理命令。

方法三:BIOS 中断

最后的方法是调用 BIOS 中断 INT 0x15,入口参数 AX = 0x2401

1
2
3
4
5
// 通过 BIOS 调用启用 A20
struct biosregs ireg;
initregs(&ireg);
ireg.ax = 0x2401;
intcall(0x15, &ireg, NULL);

每种方法尝试后都会通过检查地址回绕是否消除来验证 A20 是否真正被启用。如果三种方法都失败,内核将执行 die("A20 gate not responding, ...") 并死机。

2.5.3 GDT 的设置

全局描述符表(Global Descriptor Table,GDT)是保护模式内存管理的核心数据结构。在进入保护模式之前,必须至少设置一个可用的 GDT。

setup_gdt()pm.c 中定义了一个最小化的引导 GDT:

1
2
3
4
5
6
// arch/x86/boot/pm.c
static const u64 boot_gdt[] __attribute__((aligned(16))) = {
[GDT_ENTRY_BOOT_CS] = GDT_ENTRY(DESC_CODE32, 0, 0xfffff),
[GDT_ENTRY_BOOT_DS] = GDT_ENTRY(DESC_DATA32, 0, 0fffff),
[GDT_ENTRY_BOOT_TSS] = GDT_ENTRY(DESC_TSS32, 4096, 103),
};

这个引导 GDT 只包含三个段描述符:

  • 代码段(CS):基址为 0,限长为 4GB(0xfffff 个 4KB 页 = 4GB),32 位可执行段。选择子值为 __BOOT_CS
  • 数据段(DS):同样基址为 0,限长 4GB,32 位可读写数据段。选择子值为 __BOOT_DS
  • 任务状态段(TSS):仅用于 Intel VT(虚拟化技术)兼容性。在某些 Intel VT 场景下,硬件要求 TR 寄存器指向一个有效的 TSS 段,否则会触发 VM-Entry 失败。这里的 TSS 段基址为 4096,限长为 103 字节(TSS32 的标准大小)。

这是一种平坦内存模型(Flat Memory Model)——所有段的基址都从 0 开始,限长覆盖整个 4GB 地址空间,段级保护在此阶段不发挥作用。真正的内存保护要等到分页(Paging)机制启用后才由页表实现。

setup_gdt() 函数最终通过 lgdt 指令将这个表加载到 GDTR 寄存器:

1
2
3
4
5
6
7
8
static void setup_gdt(void)
{
static struct desc_ptr gdt_desc = {
.size = sizeof(boot_gdt) - 1,
.address = (u32)&boot_gdt,
};
asm volatile("lgdt %0" : : "m" (gdt_desc));
}

2.5.4 protected_mode_jump():最后的跃迁

protected_mode_jump() 定义在 arch/x86/boot/pmjump.S 中,是一段关键的汇编代码。它虽然以 .code16 指令开始(因为此时仍处于实模式),但它的使命是完成到 32 位保护模式的切换:

1
2
3
4
5
6
7
8
9
10
11
12
13
# arch/x86/boot/pmjump.S
.code16
GLOBAL(protected_mode_jump)
movl %edx, %esi # 保存 boot_params 指针到 ESI

# 设置 CR0.PE 位以启用保护模式
movl %cr0, %eax
orl $X86_CR0_PE, %eax
movl %eax, %cr0

# 远跳转刷新预取队列,正式进入 32 位保护模式
ljmpl $__BOOT_CS, $1f
ENDPROC(protected_mode_jump)

CR0 寄存器的变化

CR0(Control Register 0)是 x86 架构的关键控制寄存器。在实模式下,它的值通常为 0x00000010,其中只有 ET 位(Extension Type,bit 4)被设置,表示存在 80287 数学协处理器。

设置 PE(Protection Enable,bit 0)位后:

1
2
切换前: CR0 = 0x00000010  (ET=1, PE=0)  — 实模式
切换后: CR0 = 0x00000011 (ET=1, PE=1) — 保护模式

仅仅设置 CR0.PE = 1 在技术上已经让 CPU 进入保护模式,但由于 80386 流水线中可能仍然缓存着实模式的指令,必须执行一条远跳转(ljmpl)指令来清空预取队列(Prefetch Queue),确保后续指令按保护模式的规则解码和执行。

进入 32 位世界

远跳转之后,代码进入 .code32 部分,此时 CPU 已经运行在 32 位保护模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
.code32
1: # .Lin_pm32 标签
movl $__BOOT_DS, %eax
movl %eax, %ds
movl %eax, %es
movl %eax, %fs
movl %eax, %gs
movl %eax, %ss

# 设置 TR 寄存器(Intel VT 兼容性)
movw $__BOOT_TSS, %ax
ltr %ax

# 清除通用寄存器
xorl %eax, %eax
xorl %ebx, %ebx
xorl %ecx, %ecx
xorl %edx, %edx
xorl %edi, %edi

# 跳转到压缩内核入口点
jmpl *%eax # eax = code32_start = 0x100000

所有数据段寄存器(DS、ES、FS、GS、SS)被统一设置为 __BOOT_DS,指向引导 GDT 中的平坦数据段。然后通过 ltr 指令加载 TR(Task Register)为 __BOOT_TSS,满足 Intel VT 的硬件要求。通用寄存器被清零,防止实模式下的残留值造成干扰。

最后,代码跳转到 code32_start 所指向的地址——默认值是 0x100000(1MB 处),即压缩内核(compressed kernel)的入口点。从这里开始,执行流程进入 arch/x86/boot/compressed/head_64.S,继续向 64 位长模式迈进。

2.5.5 为什么中断必须被禁用

在整个模式切换过程中,中断被彻底禁用,原因如下:

第一,realmode_switch_hook() 通过 cli 指令清除 IF 标志位,禁用可屏蔽硬件中断,并通过向端口 0x70(CMOS/RTC 地址端口)写入 0x80 来同时禁用 NMI(Non-Maskable Interrupt,不可屏蔽中断)。

第二,mask_all_interrupts() 向 PIC(可编程中断控制器)的从片端口 0xA1 和主片端口 0x21 写入 0xFF,屏蔽所有外部中断请求线。

第三,setup_idt() 加载的是一个空 IDT(所有 256 个表项均为 0)——没有定义任何中断处理程序。

这意味着在模式切换期间,如果任何中断(包括 NMI)被触发,CPU 会尝试在 IDT 中查找对应的处理程序,但空 IDT 中没有任何有效条目,将立即产生双重故障(Double Fault),继而可能触发三重故障(Triple Fault),最终导致 CPU 执行硬件复位。因此,通过 CLI + 禁用 NMI + 屏蔽 PIC 三重保险,内核确保在模式切换的整个过程中没有任何中断能够打断这一关键流程。

2.6 从保护模式到长模式——进入 64 位世界

在上一节中,实模式的引导代码已经通过 pmjump.S 完成了从实模式到 32 位保护模式的跳转。然而,Linux 内核是一个 64 位操作系统,它需要运行在 x86_64 的长模式(Long Mode)下。从保护模式切换到长模式,是内核启动过程中至关重要的一个步骤,它发生在压缩内核解压器(compressed kernel decompressor)中,具体实现在 arch/x86/boot/compressed/head_64.S 文件中。

这个过渡过程并不简单——x86 架构要求在启用长模式之前必须先建立页表并启用分页机制。这意味着我们还没有进入 64 位模式,就必须用 32 位代码手动构建完整的四级页表结构。整个过程如同在旧世界中为新世界打下地基。

2.6.1 startup_32:保护模式中的最后准备

startup_32 位于压缩内核的偏移 0x000 处。此时 CPU 运行在 32 位保护模式下,刚刚从 pmjump.S 跳转而来。该函数的职责是为进入长模式做好一切准备工作。

基本环境初始化

1
2
3
startup_32:
cli
cld

首先执行 cli 指令关闭中断。在保护模式下,我们尚未建立完整的中断描述符表(IDT),任何硬件中断都可能导致不可预测的行为。随后的 cld 指令清除方向标志位(DF),确保后续所有字符串操作(如 rep movsb)按正向递增地址方向执行。

计算加载偏移量

压缩内核可以被加载到内存中的任意位置,因此代码必须动态计算实际运行地址与编译时链接地址之间的差值——即加载偏移量(load delta)。这里使用了一个经典的”call/pop”技巧:

1
2
3
4
    leal (BP_scratch+4)(%esi), %esp
call 1f
1: popl %ebp
subl $rva(1b), %ebp /* %ebp = 加载偏移量 */

这段代码的原理十分巧妙:call 1f 指令会将下一条指令(即标签 1: 处)的运行时实际地址压入栈中,随后 popl %ebp 将其弹出并保存到 %ebprva(1b) 是编译时计算出的标签 1 的相对虚拟地址。两者之差便是加载偏移量,后续所有对绝对地址的引用都需要加上这个偏移量进行修正。%esi 寄存器此时指向 boot_params 结构,BP_scratch 是其中一块临时暂存区域,此处借用其作为栈空间。

加载新的 GDT

在进入长模式之前,必须加载包含 64 位代码段和数据段描述符的全新全局描述符表(GDT):

1
lgdt gdt_desc(%ebp)

新的 GDT 中包含了 64 位模式所需的代码段描述符(__KERNEL_CS)和数据段描述符(__BOOT_DS)。加载完成后,还需要刷新所有段寄存器:

1
2
3
4
5
6
movl $__BOOT_DS, %eax
movl %eax, %ds
movl %eax, %es
movl %eax, %fs
movl %eax, %gs
movl %eax, %ss

这一步确保所有段寄存器都指向新的 64 位数据段描述符,避免残留的保护模式段选择子引起问题。

CPU 长模式能力检测

并非所有 x86 CPU 都支持长模式。在继续之前必须验证当前 CPU 是否具备这项能力:

1
call verify_cpu

verify_cpu() 函数通过 cpuid 指令检测 CPU 是否支持长模式(CPUID 扩展功能位 LM)以及必要的扩展特性(如 NX 位、SSE 等)。如果不支持,函数会返回错误码并中止启动。这个检测非常关键——在 AMD SEV(Secure Encrypted Virtualization)环境中,CPU 可能还受到额外的特性限制。

计算重定位目标地址

压缩内核在解压之前可能需要将自身移动到一个安全的位置,以避免解压过程中覆盖自身数据:

1
/* 计算重定位目标地址,存入 %ebx */

重定位目标地址保存在 %ebx 寄存器中,后续页表的构建和内核的搬移都依赖这个地址。

启用 PAE

物理地址扩展(PAE,Physical Address Extension)是启用 64 位页表的先决条件:

1
2
3
movl %cr4, %eax
btsl $X86_CR4_PAE_BIT, %eax
movl %eax, %cr4

btsl 指令将 CR4 寄存器中的 PAE 位(第 5 位)置为 1。启用 PAE 后,页表项从 32 位扩展到 64 位,使得页表能够寻址超过 4GB 的物理内存,这也正是长模式所要求的页表格式。

2.6.2 构建身份映射页表

进入长模式的硬性前提是启用分页,而启用分页必须有可用的页表。代码在这里构建了一套简单的身份映射(identity mapping)页表,将虚拟地址直接映射到相同的物理地址,确保开启分页后当前代码仍能正常执行。

页表被放置在重定位目标区域末尾的 pgtable 区域中。x86_64 使用四级页表结构,但对于启动阶段的身份映射,只需要极少的页表项即可覆盖前 4GB 物理地址空间。

四级页表结构

1
2
3
4
5
/*
* Level 4 (PML4): 1 个表项,指向 Level 3
* Level 3 (PDPTR): 4 个表项,每个指向一个 Level 2 页目录
* Level 2 (PD): 2048 个表项,每个映射 2MB 页 → 总计 4GB
*/

具体来说:

  • PML4(Page Map Level 4):顶级页表,只有一个有效表项,指向唯一的 PDPTR 页。
  • PDPTR(Page Directory Pointer Table):包含 4 个有效表项,每个表项指向一个页目录(PD),每个 PD 覆盖 1GB 地址空间。
  • PD(Page Directory):总共 4 个页目录,每个包含 512 个表项,每个表项映射一个 2MB 大页。4 个 PD 共 2048 个表项,合计映射 4GB 物理内存。

页表项中使用的标志位包括:

标志位 含义
Present(位 0) 页表项有效
Read/Write(位 1) 可读写
Page Size(位 7) 使用大页映射(2MB)

通过使用 2MB 大页(而非普通的 4KB 页),启动代码可以用最少量的页表项覆盖足够的地址空间。这在启动早期尤为重要——我们没有足够的内存来构建完整的细粒度页表。

AMD SEV 的加密掩码

对于运行在 AMD SEV 环境中的系统,物理地址可能需要附加一个加密掩码(encryption mask):

1
/* AMD SEV: 将加密掩码添加到页表项中 */

在 SEV 模式下,内存加密是透明的,但页表项中的物理地址需要包含加密位(C-bit),以确保 CPU 正确地进行内存加密/解密。这个掩码在构建页表时被加入到每一个页表项中。

2.6.3 启用长模式

页表就绪后,下一步是正式启用长模式。x86 架构通过 IA32_EFER MSR(Model Specific Register)中的 LME 位来控制长模式的使能:

1
2
3
4
movl $MSR_EFER, %ecx
rdmsr
btsl $_EFER_LME, %eax /* 设置 LME 位(Long Mode Enable) */
wrmsr

rdmsr 指令读取 IA32_EFER MSR 的当前值(结果存入 EDX:EAX),btsl_EFER_LME 位(通常是第 8 位)置为 1,然后 wrmsr 将修改后的值写回。此时长模式已被”请求”启用,但尚未真正生效——x86 架构规定,LME 位只有在启用分页(CR0.PG)之后才会真正激活。

这是一个精妙的设计:长模式的激活被分为两步——先设置 LME 位,再启用分页。这种两步机制确保了在设置 LME 和实际进入长模式之间,软件有机会完成页表等所有准备工作,避免中途出现不一致状态。

2.6.4 启用分页并跳转到 64 位代码

万事俱备,现在执行最后三个关键步骤:加载页表基址到 CR3、启用分页、以及通过远跳转进入 64 位代码。

1
2
3
4
5
6
7
8
9
10
11
12
/* 将页表基址加载到 CR3 */
leal rva(pgtable)(%ebx), %eax
movl %eax, %cr3

/* 启用分页(CR0.PG)—— 激活长模式 */
movl $CR0_STATE, %eax /* CR0 中 PG、PE、ET 位已置位 */
movl %eax, %cr0

/* 通过远返回跳转到 64 位代码 */
pushl $__KERNEL_CS
pushl %eax /* startup_64 的地址 */
lret /* 跳转到 64 位 startup_64 */

逐条分析:

  1. 加载 CR3CR3 寄存器指向当前页表的物理基地址。通过将 pgtable 的地址(加上加载偏移量)写入 CR3,CPU 就知道去哪里查找页表了。

  2. 设置 CR0CR0_STATE 是一个预定义的常量,其中包含 PG(Paging,位 31)、PE(Protection Enable,位 0)和 ET(Extension Type,位 4)等关键位。将此值写入 CR0 时,PG 位的置位会触发分页机制的启用。由于之前已经设置了 EFER.LME 位,此时分页的启用水到渠成地将 CPU 推入长模式。从这一刻起,CPU 已经运行在 64 位长模式下了。

  3. 远跳转lret(远返回)指令在这里被巧妙地当作”远跳转”来使用。通过事先将 64 位代码段选择子 __KERNEL_CSstartup_64 的地址压入栈中,lret 从栈中弹出这两个值并加载到 CS:RIP 中,完成了从 32 位代码段到 64 位代码段的切换。CPU 在执行 lret 时发现新的 CS 选择子指向 64 位代码段描述符,于是自动切换到 64 位执行模式。

这一系列操作必须在极短的时间内连续完成。从 CR0.PG 被置位的那一刻起,CPU 已经处于长模式中,但当前执行的仍然是 32 位代码。这是一个极其脆弱的过渡状态——下一条指令必须是那个跳转到 64 位段的远跳转。

2.6.5 startup_64:进入 64 位长模式

startup_64 位于压缩内核的偏移 0x200 处。当执行流到达这里时,CPU 已经正式运行在 64 位长模式下:

1
2
3
    .code64
.org 0x200
startup_64:

清理段寄存器与地址计算

1
2
3
4
5
6
xorl %eax, %eax
movl %eax, %ds
movl %eax, %es
movl %eax, %fs
movl %eax, %gs
movl %eax, %ss

在长模式下,段寄存器(除 CS 外)的基址和限长被忽略,但必须清零以避免遗留的保护模式设置引发问题。代码将所有数据段寄存器置零,确保后续所有内存访问完全依赖平坦(flat)的线性地址空间。

计算解压目标地址与设置栈

接下来计算内核解压的目标地址,并设置一个可用的栈:

1
2
/* 计算解压目标地址 */
/* 设置栈指针指向 boot_stack_end */

栈被设置为 boot_stack_end,这是为解压器预留的栈空间的顶端。有了可用的栈,后续的 C 函数调用才成为可能。

早期 IDT 与 SEV 处理

1
call load_stage1_idt

load_stage1_idt 加载一个最基本的 64 位中断描述符表。这个早期 IDT 只处理最关键的中断(例如 #VC 异常,即 AMD SEV-ES 的虚拟化通信异常)。在 SEV-ES 环境中,某些 CPU 指令会触发 VMGEXIT,需要通过 #VC 异常处理器来与虚拟机管理器(hypervisor)进行通信。在启用完整的 IDT 之前,这个早期 IDT 确保此类关键异常不会导致系统崩溃。

五级页表配置

1
call configure_5level_paging

如果系统支持五级页表(5-level paging,LA57),configure_5level_paging() 会进行相应配置。五级页表将虚拟地址空间从 128PB 扩展到 128EB,但并非所有 CPU 都支持该特性。这个函数会通过 CPUID 指令检测 CPU 是否支持 LA57,并在支持的情况下进行配置。关于五级页表切换的详细机制,将在下一小节深入讨论。

搬移压缩内核

在解压之前,压缩内核需要被复制到一个安全的位置。这是因为内核解压是”就地”(in-place)展开的——解压后的内核会覆盖原始的压缩数据。如果压缩内核的当前位置与解压目标区域重叠,解压过程就会破坏尚未读取的压缩数据:

1
/* 将压缩内核复制到安全位置以便就地解压 */

搬移完成后,代码跳转到 .Lrelocated 标签处继续执行,那里是解压器的核心逻辑所在——调用 extract_kernel() 函数将压缩的内核镜像解压到最终的运行位置。

2.6.6 五级页表切换的精妙机制

五级页表(5-level paging)的配置是一个特别值得深入分析的过程,其实现位于 arch/x86/boot/compressed/pgtable_64.c 中。

x86 架构有一个严格的限制:CR4.LA57(五级页表使能位)不能在长模式下修改。这意味着要从四级页表切换到五级页表,必须暂时离开长模式,回到 32 位保护模式进行操作。整个过程需要使用一个精心设计的”跳板”(trampoline)机制:

  1. 准备跳板代码:在低地址内存(物理地址低于 4GB)中设置一小段 32 位代码和相应的页表。之所以必须使用低地址内存,是因为 32 位代码无法访问 4GB 以上的地址空间。

  2. 退出长模式:先关闭分页(清除 CR0.PG),这会让 CPU 从长模式退回到保护模式。

  3. 修改 CR4.LA57:在 32 位保护模式下,安全地设置或清除 CR4.LA57 位。

  4. 重建页表:根据新的分页级别(四级或五级)重新构建页表结构。五级页表需要额外的一层页表——PML5(Page Map Level 5),位于 PML4 之上。

  5. 重新进入长模式:重新启用 PAE、设置 EFER.LME、加载新的 CR3、启用分页,再次回到 64 位长模式。

这个”长模式 → 保护模式 → 修改配置 → 长模式”的往返过程,是 x86 架构向后兼容性带来的复杂性代价。整个跳板代码必须极其小心地处理每一个寄存器和 CPU 状态,因为在模式切换的过程中,任何疏忽都可能导致系统直接重启。

这种设计体现了 x86 架构演进的一个核心理念:新特性总是在保留旧模式的基础上添加,而模式之间的切换必须通过一系列严格定义的步骤来完成。从保护模式到长模式的过渡,正是这一理念的集中体现。

2.7 内核解压与重定位

在上一节中,startup_64 完成了压缩内核的内存拷贝工作,将自身从加载地址搬移到安全的目标位置。接下来,引导流程进入 .Lrelocated 标签,开始执行内核解压的核心过程。本节将详细剖析从 BSS 清零、压缩负载解压、ELF 段重定位到最终跳转至解压后内核入口的完整链路。

2.7.1 .Lrelocated:准备解压环境

startup_64 中的内存拷贝完成后,代码跳转到 .Lrelocated 标签。此时压缩内核已经被复制到目标位置,处理器运行在 64 位长模式下,但尚未建立完整的 C 语言运行环境。.Lrelocated 需要完成两项关键的准备工作:清零 BSS 段和设置调用约定,然后才能跳转到 C 函数 extract_kernel()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.Lrelocated:
xorl %eax, %eax
xorl %ebx, %ebx
xorl %ecx, %ecx
xorl %edx, %edx
/* 清零 BSS 段 */
leal _bss(%rbx), %rdi
leal _ebss(%rbx), %rcx
subl %edi, %ecx
shrl $3, %ecx
rep stosq
/* 调用 extract_kernel 进行解压 */
call extract_kernel
/* 跳转至解压后内核的入口点 */
jmp *%rax

BSS 段清零是建立 C 运行时的前提条件。C 语言标准规定所有未初始化的全局变量和静态变量必须被初始化为零。BSS(Block Started by Symbol)段正是这些变量的存放位置。在固件和引导加载程序的环境中,内存内容是不可预测的,因此必须由引导代码显式清零。代码通过 _bss_ebss 符号计算出 BSS 段的大小,再除以 8(因为使用 stosq 指令每次写入 8 字节),然后用 rep stosq 指令批量清零。这一操作确保了解压代码中所有全局变量(如调试标志、解压状态等)从已知的零值状态开始工作。

寄存器清零操作将 %eax%ebx%ecx%edx 全部置零,这既是为了满足调用约定的干净状态要求,也是为了避免残留值干扰后续的 C 函数执行。

在 BSS 段清零完成后,栈和参数已经由前序代码设置完毕,代码通过 call extract_kernel 进入 C 语言世界。extract_kernel() 的返回值存放在 %rax 中,即解压后内核的入口地址。最后,jmp *%rax 将控制权转交给解压后的真正内核。

2.7.2 extract_kernel():解压总入口

extract_kernel() 定义在 arch/x86/boot/compressed/misc.c 中,是整个内核解压过程的核心调度函数。其函数签名如下:

1
2
void *extract_kernel(void *rmode, unsigned char *input_data,
unsigned char *output, unsigned long output_len)

四个参数的含义分别是:

  • rmode:指向 boot_params 结构的指针,其中包含引导加载程序传递的各类参数,包括内存映射、视频模式、命令行等信息。
  • input_data:指向压缩内核负载的起始地址,即经引导加载程序加载的压缩数据。
  • output:指向输出缓冲区的起始地址,解压后的数据将写入此处。
  • output_len:输出缓冲区的大小,用于边界检查,防止解压越界。

extract_kernel() 的执行过程可以分为以下几个步骤:

第一步:初始化控制台。 调用 console_init() 初始化调试输出通道。在早期引导阶段,内核可以通过 VGA 文本模式或串口输出调试信息。通过 earlyprintk 内核命令行参数可以启用这一功能,这对于调试解压过程中的问题至关重要。

第二步:检测运行环境。 现代 x86 系统可能运行在 TDX(Trust Domain Extensions)或 SEV(Secure Encrypted Virtualization)等 confidential computing 环境中。这些环境对内存访问、加密和完整性检查有特殊要求,解压代码需要尽早识别并采取相应的处理措施。

第三步:选择并执行解压器。 根据内核编译时选择的压缩格式,调用相应的 __decompress() 函数对压缩负载进行解压。

第四步:解析 ELF 并重定位。 调用 parse_elf() 处理解压后的 ELF 二进制,将各段搬移到正确的虚拟地址。

第五步:返回入口地址。 将解压后内核的入口点地址返回给汇编调用者。

2.7.3 支持的压缩格式

Linux 内核在编译时通过配置选项选择压缩格式。misc.c 通过条件编译将选定格式的解压器代码直接包含进来:

配置选项 压缩格式 特点
CONFIG_KERNEL_GZIP Gzip 历史最悠久,兼容性最好,压缩率中等
CONFIG_KERNEL_BZIP2 Bzip2 压缩率高于 Gzip,但解压速度较慢
CONFIG_KERNEL_LZMA LZMA 高压缩比,但解压时内存占用较大
CONFIG_KERNEL_XZ XZ 基于 LZMA2,压缩率优秀,是现代发行版的默认选择
CONFIG_KERNEL_LZO LZO 解压速度极快,压缩率较低,适合嵌入式场景
CONFIG_KERNEL_LZ4 LZ4 解压速度最快的选项,适合对启动时间有严格要求的环境
CONFIG_KERNEL_ZSTD ZSTD 压缩率和解压速度的优良平衡,较新的选项

misc.c 中可以看到类似以下的条件编译结构:

1
2
3
4
5
6
7
8
#ifdef CONFIG_KERNEL_GZIP
#include "../../../../lib/decompress_inflate.c"
#endif

#ifdef CONFIG_KERNEL_XZ
#include "../../../../lib/decompress_unxz.c"
#endif
/* 其他格式类似 */

这种直接 #include .c 文件的方式虽然不符合常规的 C 编程规范,但在内核引导环境中是必要的:引导阶段的内核运行在高度受限的环境中,无法依赖常规的内核基础设施,因此需要将解压器代码静态链接到解压存根中。每个解压器都实现了统一的 __decompress() 接口,使得 misc.c 可以用相同的方式调用不同格式的解压器。

2.7.4 原地解压策略

内核解压面临一个特殊的挑战:解压后的数据通常比压缩数据大两到三倍,而解压过程需要在有限的内存空间内同时容纳压缩数据和正在生成的解压数据。为此,内核采用了一种巧妙的”原地解压”(in-place decompression)策略。

具体做法是:在解压开始之前,先将压缩内核数据拷贝到输出缓冲区的尾部。然后解压器从缓冲区尾部的压缩数据开始读取,将解压结果从缓冲区头部向前写入。只要解压器处理的输出速率高于输入速率(这对所有上述压缩格式都成立),已读过的压缩数据就会被新写入的解压数据覆盖,两者不会发生冲突。

这种滑动窗口的方法避免了分配额外的临时缓冲区,使得内核可以在非常紧凑的内存空间内完成解压。这对于早期引导阶段尤为重要,因为此时可用的内存资源极为有限。

2.7.5 parse_elf():ELF 段解析与重定位

解压后的内核是一个 ELF(Executable and Linkable Format)格式的二进制文件。parse_elf() 函数负责解析这个 ELF 文件,将其中的可加载段搬移到正确的内存位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static void parse_elf(void *output)
{
const Elf64_Ehdr *ehdr = (Elf64_Ehdr *)output;
const Elf64_Phdr *phdrs, *phdr;
int i;

/* 验证 ELF 魔数 */
/* 遍历程序头表 */
phdrs = (void *)ehdr + ehdr->e_phoff;
for (i = 0; i < ehdr->e_phnum; i++) {
phdr = &phdrs[i];
if (phdr->p_type != PT_LOAD)
continue;
/* 将 LOAD 段拷贝到目标虚拟地址 */
memcpy((void *)phdr->p_paddr,
output + phdr->p_offset,
phdr->p_filesz);
/* 清零 BSS 部分(p_memsz > p_filesz 的部分) */
if (phdr->p_memsz > phdr->p_filesz)
memset((void *)phdr->p_paddr + phdr->p_filesz, 0,
phdr->p_memsz - phdr->p_filesz);
}
}

ELF 文件由 ELF 头(Elf64_Ehdr)和若干程序头(Elf64_Phdr)描述。每个类型为 PT_LOAD 的程序头代表一个需要加载到内存中的段。对于可重定位内核(CONFIG_RELOCATABLE=y),parse_elf() 还需要处理重定位表。内核链接时,所有绝对地址引用被记录在 .rela.dyn 重定位段中。parse_elf() 遍历这些重定位条目,根据内核实际加载地址与链接地址的偏差,修正所有绝对地址引用。这正是”可重定位内核”名称的由来——它可以在物理内存的任意合法位置运行,而不仅限于链接时指定的固定地址。

2.7.6 KASLR:内核地址空间布局随机化

KASLR(Kernel Address Space Layout Randomization)是现代 Linux 内核的重要安全特性,由 arch/x86/boot/compressed/kaslr.c 实现。当 CONFIG_RANDOMIZE_BASE 启用时,内核在每次启动时会被加载到一个随机的物理地址,从而增加内核漏洞利用的难度。

熵源收集是 KASLR 的核心环节。kaslr.c 通过多种途径获取随机性:

  • RDTSC 指令:读取处理器时间戳计数器,提供基于时序的熵。
  • RDRAND 指令:读取硬件随机数生成器,提供高质量的随机位。
  • EFI RNG 协议:在 UEFI 环境中,通过 EFI 随机数生成协议获取随机种子。

合法位置的筛选需要遍历系统内存映射。KASLR 使用 E820 内存映射(传统 BIOS 环境)或 EFI 内存映射(UEFI 环境)来识别可用的物理内存区域。内核必须避开以下类型的内存区域:

  • 保留区域(reserved)
  • ACPI 数据区
  • 不可用或损坏的内存
  • 已被引导加载程序或其他组件占用的区域

内核必须对齐到 2MB 边界(因为使用大页映射),因此随机地址的最低 21 位为零。在 64 位系统上,KASLR 的物理地址随机化范围可达数 GB,提供了可观的安全增益。

值得注意的是,KASLR 与可重定位内核(CONFIG_RELOCATABLE)紧密耦合。只有可重定位内核才能在不同地址运行,因此 KASLR 依赖于 CONFIG_RELOCATABLE=y。KASLR 选定目标地址后,内核解压和重定位过程会将内核放置到该地址,parse_elf() 中的重定位逻辑则修正所有地址引用,使其指向新的基地址。

2.7.7 解压完成:跳转至真正的内核

extract_kernel() 完成所有解压、ELF 解析和重定位工作后,它返回解压后内核的入口地址。在 head_64.S 中,jmp *%rax 指令将控制权跳转到这个地址。

这个入口地址指向的是 arch/x86/kernel/head_64.S 中的 startup_64 标签——注意,这是真正内核startup_64,而非压缩存根中的同名标签。两个 startup_64 位于不同的文件中,服务于不同的阶段:前者在解压后的正式内核中,负责建立早期页表、初始化每 CPU 数据区、设置中断描述符表等核心体系结构初始化工作;后者(即我们一直在讨论的)在压缩存根中,只负责内存搬移和解压。

至此,内核完成了从压缩状态到可执行状态的转变。压缩存根的使命已经结束,后续的启动流程将在解压后的正式内核代码中继续展开——进入真正的体系结构初始化阶段。

2.8 内核真正的入口:head_64.S

当 decompressor(解压器)完成内核映像的解压、建立好最基本的身份映射(identity-mapped)页表并将控制权移交之后,x86_64 架构的 Linux 内核终于要进入它”真正的”入口点了——那就是 arch/x86/kernel/head_64.S 中定义的 startup_64。在这里,CPU 已经处于 64 位长模式(Long Mode),拥有一个可以工作的栈和一组临时页表,但内核自身的运行环境尚未完全就绪。head_64.S 的使命就是在最底层完成一系列关键的引导初始化工作:设置 GDT 和 IDT、激活 SME/SEV 内存加密、修正页表、切换到内核虚拟地址空间、配置各种 CPU 控制寄存器,最终跳入第一个 C 语言函数。

2.8.1 startup_64:BSP 的起点

startup_64 是引导处理器(Bootstrap Processor,BSP)进入内核的第一个执行点。此时,CPU 已经运行在 64 位长模式下,但使用的仍然是 decompressor 所建立的临时身份映射页表,段寄存器也并非指向内核的代码段和数据段。

保存 boot_params 并建立栈

1
2
startup_64:
movq %rsi, %r15 /* 将 boot_params 的物理地址保存到 %r15 */

进入 startup_64 时,%RSI 寄存器持有 boot_params 结构的物理地址——这是内核从 bootloader 那里继承来的全部引导信息。由于后续代码会频繁使用 %RSI,必须先将其安全地保存到被调用者保存寄存器 %R15 中。这一约定贯穿整个早期启动过程:%R15 始终持有 boot_params 指针,直到被传递给 C 代码。

接下来设置内核栈:

1
leaq    __top_init_kernel_stack(%rip), %rsp

此处使用 RIP 相对寻址获取 __top_init_kernel_stack 的地址,将其作为栈指针。由于当前仍然运行在身份映射的物理地址空间中,这个栈地址必须能够通过当前的页表访问。__top_init_kernel_stack 定义在内核链接脚本中,位于内核映像的 BSS 段末尾附近,是初始内核栈的顶端。

清零 GSBASE 并加载内核 GDT/IDT

1
2
3
4
movl    $MSR_GS_BASE, %ecx
xorl %eax, %eax
xorl %edx, %edx
wrmsr /* 通过 MSR 将 GSBASE 清零 */

在长模式下,%GS 段寄存器的基址通过 MSR_GS_BASE(0xC0000101)来设置。此时 GSBASE 的值是未定义的,必须先将其清零,以避免后续任何对 GS 段的访问引发异常。

1
call    __pi_startup_64_setup_gdt_idt

__pi_startup_64_setup_gdt_idt 负责加载内核自己的 GDT(全局描述符表)和 IDT(中断描述符表)。前缀 __pi_ 表示该函数属于”位置无关”(Position Independent)代码,可以在物理地址空间中直接运行。GDT 的加载涉及将 GDT 的基址和界限写入 GDTR,IDT 同理写入 IDTR。此时 IDT 中的处理程序还是早期异常处理程序(early exception handler),仅供启动阶段捕获致命错误。

切换到内核代码段

加载完 GDT 之后,CS 段寄存器仍然指向旧的代码段选择子。为了让 CS 指向内核的 __KERNEL_CS,必须执行一次远跳转(far jump)或远返回(far return):

1
2
3
4
5
    pushq   $__KERNEL_CS           /* 将内核代码段选择子压栈 */
leaq .Lon_kernel_cs(%rip), %rax
pushq %rax /* 将返回地址压栈 */
lretq /* 远返回,CS = __KERNEL_CS */
.Lon_kernel_cs:

lretq 指令从栈上弹出返回地址和新的 CS 值,完成段寄存器的切换。此后代码运行在 __KERNEL_CS(通常为 0x10)所描述的 64 位代码段中。

2.8.2 SME/SEV 内存加密激活

AMD 的安全内存加密(Secure Memory Encryption,SME)和安全加密虚拟化(Secure Encrypted Virtualization,SEV)技术允许对物理内存进行透明加密。如果内核配置了 CONFIG_AMD_MEM_ENCRYPT,则需要在此处尽早激活加密功能:

1
2
3
4
#ifdef CONFIG_AMD_MEM_ENCRYPT
movq %r15, %rdi /* 传入 boot_params 指针 */
call __pi_sme_enable /* 启用内存加密 */
#endif

__pi_sme_enable 会检查 BIOS 提供的加密信息,确定是否需要启用 SME 以及加密的物理位掩码(encryption mask)。这个掩码将被后续的页表修正代码使用,以确保页表项中的物理地址包含加密位。SME 的激活必须在页表修正之前完成,因为一旦加密启用,所有内存访问都会受到加密位的影响。

2.8.3 CPU 特性验证

1
call    verify_cpu

verify_cpu 检查当前 CPU 是否满足内核运行的最低要求。对于 x86_64,最关键的检查项包括:

  • NX 位(No-eXecute)支持:64 位内核依赖 NX 位来实现内存保护,防止在数据页上执行代码。
  • 长模式(Long Mode)支持:确认 CPU 确实能够运行 64 位代码。
  • 其他必要的 CPU 特性:如合适的 CPUID 叶片等。

如果 CPU 不满足要求,verify_cpu 会将错误码设置到 %eax 中并触发异常,直接终止启动过程。

2.8.4 物理-虚拟地址偏移计算

由于内核被链接到特定的虚拟地址(__START_KERNEL_map,通常为 0xffffffff80000000),但当前运行在物理地址空间中,代码需要计算出物理地址到虚拟地址的偏移量:

1
2
3
leaq    common_startup_64(%rip), %rdi   /* RIP 相对寻址得到虚拟地址 */
subq .Lcommon_startup_64(%rip), %rdi /* 减去链接时的地址 */
/* 结果:phys_to_virt 偏移量存于 %rdi */

这条指令利用了 RIP 相对寻址的特性。leaq common_startup_64(%rip), %rdi 计算出 common_startup_64 在当前运行时的实际地址(物理地址),而 .Lcommon_startup_64 中存储的是 common_startup_64 的链接地址(虚拟地址)。两者之差即为物理地址到虚拟地址的偏移量,这个值在后续的页表修正中至关重要。

2.8.5 页表修正

1
2
movq    %r15, %rsi              /* 传入 boot_params 指针 */
call __pi___startup_64 /* 修正页表 */

__pi___startup_64 是早期启动中最关键的函数之一,它承担以下职责:

  1. 修正页表项中的物理地址。内核页表在编译时使用的是链接地址,而实际运行时内核可能被加载到不同的物理地址(KASLR 随机化)。该函数遍历所有早期页表项,将它们修正为正确的物理地址。

  2. 处理 SME 加密。如果 SME 处于激活状态,需要将加密掩码(encryption mask)应用到所有页表项的物理地址部分。加密掩码通常是将第 46 位(或更高位,取决于配置)置位。

  3. 加密内核映像。在 SME 模式下,内核映像本身也需要被加密,__pi___startup_64 会调用相应的例程完成对内核 .text.data 等段的加密处理。

  4. 返回 SME 加密掩码。该函数的返回值(%RAX)是 SME 加密掩码。如果 SME 未启用,返回值为 0;如果启用,则为一个非零的位掩码,后续代码会将其加到 CR3 的值上。

2.8.6 切换到内核页表

页表修正完成后,内核已经拥有了一套正确映射的页表。接下来要做的是将控制寄存器 CR3 切换到新的页表,从而完成从物理地址空间到虚拟地址空间的跳转:

1
2
3
4
leaq    early_top_pgt(%rip), %rcx
addq %rcx, %rax /* CR3 = early_top_pgt 物理地址 + SME 掩码 */
movq %rax, %cr3 /* 加载新页表! */
jmp *.Lcommon_startup_64(%rip) /* 跳转到虚拟地址 */

这是启动过程中一个极其重要的时刻。movq %rax, %cr3 指令执行之后,地址空间的映射规则发生了根本性的改变——从身份映射切换到了内核的虚拟地址映射。紧接着的 jmp 指令使用 RIP 相对寻址获取目标地址,这个地址已经是虚拟地址了。从此刻起,代码运行在内核的虚拟地址空间中。

值得注意的是 CR3 值中加入了 SME 加密掩码(%RAX 中包含 __pi___startup_64 的返回值)。这是 AMD SME 的一个关键机制:加密掩码的高位比特告诉内存控制器该页表所映射的内存区域是否需要加密。

2.8.7 secondary_startup_64:应用处理器的入口

secondary_startup_64 是应用处理器(Application Processor,AP)被唤醒后执行的入口点。AP 通过 start_ip(16 位实模式)-> trampoline(32 位兼容模式)-> secondary_startup_64(64 位长模式)这条路径被引导进入内核。

1
2
3
4
5
secondary_startup_64:
/*
* AP 不持有 boot_params,%R15 必须清零
*/
xorl %r15d, %r15d

与 BSP 不同,AP 没有 boot_params 结构可以传递,因此 %R15 被清零。

AP 的初始化路径与 BSP 类似,但有关键区别:

  • 使用 init_top_pgt 而非 early_top_pgt。AP 启动时内核已经完成了完整的页表构建,因此 AP 直接加载最终的内核页表 init_top_pgt 到 CR3。
  • 无需页表修正和 SME 激活。这些工作已经由 BSP 在启动早期完成。
  • 设置每 CPU(per-CPU)栈和 GDT。每个 AP 都有自己的内核栈和 GDT 副本。

完成这些设置后,AP 也落入 common_startup_64,与 BSP 共享后续的初始化逻辑。

2.8.8 common_startup_64:通用启动路径

common_startup_64 是 BSP 和所有 AP 汇聚的共同执行路径。它完成控制寄存器的最终配置,然后跳入 C 代码。

配置 CR4

1
2
3
common_startup_64:
movl $CR4_PAE | CR4_PGE | CR4_MCE, %edx
movq %rdx, %cr4

CR4 的配置启用了几个关键的 CPU 特性:

  • PAE(Physical Address Extension):虽然在 64 位模式下 PAE 是隐式启用的,但显式设置确保了一致性。
  • PGE(Page Global Enable):启用全局页(Global Pages),允许 TLB 项在 CR3 切换时不被刷掉,提升上下文切换性能。
  • MCE(Machine Check Exception):启用机器检查异常,允许 CPU 报告硬件错误。

设置 EFER 模型特定寄存器

1
2
3
4
movl    $MSR_EFER, %ecx
rdmsr
orl $EFER_SCE, %eax
wrmsr

EFER(Extended Feature Enable Register)的配置中,最关键的是启用 SCE(System Call Extensions),即 syscall/sysret 指令支持。这是 64 位 Linux 内核系统调用的快速路径。NX(No-eXecute)位通常已经在 decompressor 阶段被设置,但此处确保它保持启用状态。

设置 CR0

1
2
movl    $CR0_STATE, %eax
movq %rax, %cr0

CR0_STATE 包含了 CR0 所需的标准标志位组合,包括保护模式使能(PE)、分页使能(PG)、写保护(WP)、数值错误(NE)等。

清空标志寄存器并跳入 C 代码

1
2
3
4
5
6
pushq   $0
popfq /* 清空 EFLAGS */
movq initial_code(%rip), %rax
pushq $0
pushq %rax
lretq /* 跳转到 x86_64_start_kernel */

pushq $0; popfq 将 EFLAGS 清零,确保没有任何残留的标志位影响后续执行。initial_code 是一个全局变量,存储着第一个 C 函数的地址。对于 BSP,这个值是 x86_64_start_kernel;对于 AP,则是 start_secondary

lretq 完成最终的远跳转。此时 CS 被设置为 0(即 __KERNEL_CS 通过后续机制生效),而 RIP 则指向目标 C 函数。从这里开始,内核进入了 C 语言的世界。

2.8.9 早期页表结构

head_64.S 中定义了几个关键的页表数据结构,它们构成了早期启动阶段的地址映射骨架:

  • early_top_pgt:顶层页表(PML4),用于内核启动早期的地址映射。它在编译时被静态初始化,运行时由 __pi___startup_64 修正物理地址。
  • init_top_pgt:最终的内核顶层页表。AP 启动时直接使用此页表。在内核初始化完成后,它成为系统运行时的主页表。
  • level3_kernel_pgt:三级页表(PDPT),负责映射内核的 text 和 data 区域(从 __START_KERNEL_map 开始的 1GB 虚拟地址空间)。
  • level2_kernel_pgt:二级页表(PD),使用 2MB 大页(2MB pages)映射内核映像。每个 PD 项指向一个 2MB 的物理页,若干个这样的项覆盖了整个内核映像。
  • level2_fixmap_pgt:fixmap 区域的二级页表。fixmap 是内核在启动早期用于映射特定物理地址的固定虚拟地址区域,此时还没有动态内存分配器。

这些页表构成了一个四级页表层级:PML4 -> PDPT -> PD -> 2MB Page。使用 2MB 大页而非 4KB 普通页,可以在 TLB(Translation Lookaside Buffer)中用更少的条目覆盖更大的地址范围,提升早期启动阶段的访存性能。

2.8.10 早期异常处理

在完整的 IDT 初始化之前(即在 idt_setup_early_handler 被调用之前),内核使用一组简化的早期异常处理程序来捕获致命错误。这些处理程序定义在 early_idt_handler_array 中:

1
2
3
4
5
/* early_idt_handler_array: 256 个条目,每个 8 字节 */
.macro idtentry early_handler
pushq $vector_number /* 压入向量号 */
jmp early_idt_handler_common
.endm

每个异常向量对应一段 8 字节的代码,其核心逻辑是:

  1. 将异常向量号压入栈中。
  2. 跳转到公共处理函数,由后者调用 C 函数 do_early_exception

缺页异常的特殊处理

缺页异常(#PF,向量号 14)在早期启动阶段有特殊意义。由于内核此时使用的是静态页表,只有部分虚拟地址被映射。当代码访问了一个尚未映射的虚拟地址时,会触发缺页异常。早期缺页处理程序会调用 __early_make_pgtable,该函数动态地构建新的页表项,将所需的物理页映射到触发异常的虚拟地址上。这种机制保证了内核在启动早期即使页表不完整,也能按需建立映射。

SEV-ES 和 TDX 的特殊处理

在 AMD SEV-ES(Secure Encrypted Virtualization - Encrypted State)环境下,某些异常由 hypervisor 代理处理,内核通过 #VC(VMM Communication exception,向量号 29)接收通知。早期 #VC 处理程序需要使用 GHCB(Guest Hypervisor Communication Block)与 hypervisor 通信,完成必要的处理。

类似地,Intel TDX(Trust Domain Extensions)环境下,内核通过 #VE(Virtualization Exception,向量号 20)接收虚拟化相关的通知。这些处理程序的早期版本功能有限,主要用于确保启动过程不会因为缺少处理程序而发生三重故障(Triple Fault)。


startup_64x86_64_start_kernelhead_64.S 用数百行汇编代码完成了从裸硬件环境到 C 语言运行环境的过渡。这是内核启动过程中最精密、最脆弱的阶段之一——任何页表错误、寄存器遗漏或者时序问题都可能导致三重故障,令系统瞬间重启且没有任何错误信息。理解这段代码,是理解 Linux 内核在 x86_64 上如何从”无”到”有”建立运行环境的关键。

2.9 早期 C 初始化:head64.c 中的第一个 C 函数

当执行流从 head64.S 的汇编代码跳转到第一个 C 函数时,内核启动过程迈出了至关重要的一步。此时,CPU 已处于 64 位长模式,分页机制已启用,但系统仍处于极其原始的状态——没有调度器、没有中断处理、没有进程概念,只有引导处理器(BSP)在单线程地执行初始化代码。本节深入分析 arch/x86/kernel/head64.c 中的早期 C 初始化流程。

2.9.1 x86_64_start_kernel()——内核中的第一个 C 函数

1
void __init x86_64_start_kernel(char *real_mode_data)

这是整个 Linux 内核中最早执行的 C 函数。它由 common_startup_64(位于 head64.S)通过 initial_code 指针间接调用。real_mode_data 参数指向引导加载器传递的实模式数据,其中包含 boot_params 结构和内核命令行。

编译时布局校验

函数首先执行一系列 BUILD_BUG_ON 静态断言,用于在编译期验证内核映像的内存布局假设。这些检查确保关键常量(如页表对齐、内核映像大小等)在编译时就被验证正确,而非在运行时才暴露问题。这是内核开发中防御性编程的典型实践。

CR4 影子寄存器初始化

1
cr4_init_shadow();

CR4 控制寄存器包含大量控制位(如页大小扩展、SSE 支持、SMEP/SMAP 等)。cr4_init_shadow() 将当前 CR4 的值保存到每 CPU 变量的影子副本中,以便后续内核代码快速查询 CR4 状态,避免频繁执行昂贵的 mov cr4 指令。

重置早期页表

1
reset_early_page_tables();

此函数清除早期页表中除内核映射之外的所有条目。在引导汇编阶段建立的临时页表仅用于过渡,此时需要一个干净的状态。reset_early_page_tables() 遍历顶级页表(early_top_pgt),将除内核映像映射之外的所有条目清零,为后续动态页表构建做准备。

五级分页配置

1
2
3
4
if (__pgtable_l5_enabled) {
pgdir_shift = 48;
ptrs_per_p4d = 512;
}

x86_64 架构传统上使用四级页表(PGD→P4D→PUD→PMD→PT),可寻址 48 位虚拟地址空间(256 TB)。五级分页(5-level paging,LA57)将可寻址空间扩展到 57 位(128 PB)。此处根据硬件支持和固件配置,设置 pgdir_shift(页全局目录的移位值)和 ptrs_per_p4d(P4D 表的条目数)。在四级分页模式下,P4D 层被折叠(folded),ptrs_per_p4d 为 1。

清零 BSS 段

1
clear_bss();

BSS(Block Started by Symbol)段是未初始化全局变量和静态变量存储的区域。C 语言标准要求这些变量初始值为零,但在内核引导早期,没有标准库来完成这个工作。clear_bss() 将 BSS 段和 brk 区域全部清零,确保内核代码中所有未初始化变量的值为 0,而非随机内存内容。

KASAN 早期初始化

1
kasan_early_init();

如果内核编译时启用了 KASAN(Kernel Address SANitizer),此函数设置 KASAN 的影子内存区域。KASAN 是一种强大的内存错误检测工具,通过影子内存跟踪每个内存字节的可访问性。在早期引导阶段建立影子映射,使得后续初始化代码也能受益于 KASAN 的检测能力。

刷新全局 TLB

1
__flush_tlb_all();

在页表大幅修改之后,必须刷新 TLB(Translation Lookaside Buffer)以确保后续内存访问使用新的页表映射。__flush_tlb_all() 通过写入 CR4 或使用 invlpg 指令使所有 TLB 条目失效。

安装早期 IDT 处理程序

1
idt_setup_early_handlers();

此函数注册最基本的异常处理程序。此时安装的 IDT 条目数量有限,仅涵盖关键的异常类型(如页错误、一般保护错误等)。在完整的中断子系统初始化之前,这些早期处理程序提供了最低限度的异常捕获能力。其中最重要的是页错误处理程序——它将触发按需页表的构建。

TDX 来宾检测

1
initialize_tdx();

Intel TDX(Trust Domain Extensions)是一种机密计算技术。如果内核运行在 TDX 来宾环境中,需要尽早进行检测和初始化,因为 TDX 环境下某些操作需要通过特殊接口与主机通信。initialize_tdx() 检测 TDX 环境,设置必要的标志位。

复制引导数据

1
copy_bootdata(real_mode_data);

此函数将引导加载器传递的 boot_params 结构和内核命令行从实模式数据区域复制到内核的内部数据结构中。boot_params 包含丰富的系统信息:内存映射、视频模式、ACPI 表位置等。复制过程确保这些数据在后续初始化中可用,即使原始的实模式数据区域后来被释放或覆盖。

早期微码加载

1
load_ucore();

CPU 微码(microcode)更新可以修复处理器硬件缺陷。在极早期加载微码,确保后续初始化代码运行在修正后的处理器行为之上。某些微码更新对内核正确性至关重要,因此必须在启动过程中尽早执行。

2.9.2 x86_64_start_reservations()——最后的架构特定准备

1
void __init x86_64_start_reservations(char *real_mode_data)

此函数在 x86_64_start_kernel() 完成后调用。它的主要职责是为实模式跳板代码(real-mode trampoline)保留内存。跳板代码用于将 secondary CPU 从实模式唤醒到长模式,需要一块低地址物理内存。

完成内存预留后,该函数调用 start_kernel()——这是整个内核启动过程中最重要的转折点:

1
start_kernel();

x86_64_start_kernel()start_kernel() 之前的最后一个架构特定函数。一旦进入 start_kernel(),内核便进入了架构无关的通用初始化流程——调度器、内存管理、驱动模型、文件系统等核心子系统将在那里被逐一构建。

2.9.3 按需构建早期页表:__early_make_pgtable

在内核引导早期,并非所有内存映射都已建立。当代码访问一个尚未映射的地址时,会触发页错误(Page Fault)。此时,早期异常处理程序会调用 __early_make_pgtable() 动态构建页表。

1
static bool __init __early_make_pgtable(unsigned long paddr, pmdval_t pmd_val)

该函数的工作流程如下:

  1. 计算虚拟地址对应的页表索引:从虚拟地址中提取 PGD、P4D、PUD、PMD 各级索引
  2. 逐级遍历页表
    • 检查 PGD 条目是否存在,若不存在则从 early_dynamic_pgts[] 池中分配一个新页表页
    • 同理处理 P4D 和 PUD 层级
    • 在 PMD 层级直接写入映射条目
  3. 使用 2MB 大页:早期映射使用 PMD 级别的大页(2MB),而非 4KB 普通页。这减少了页表层级(无需 PT 层),简化了早期映射逻辑,同时提高 TLB 效率

页表页从 early_dynamic_pgts[] 数组中分配,这是一个预分配的静态数组:

1
2
3
static pte_t early_dynamic_pgts[EARLY_DYNAMIC_PAGE_TABLES][PTRS_PER_PTE]
__initdata __aligned(PAGE_SIZE);
static unsigned int next_early_pgt __initdata;

EARLY_DYNAMIC_PAGE_TABLES 定义为 64,即最多 64 个动态页表页。next_early_pgt 是一个简单的分配指针,每次分配一个页表页时递增。这是一个极其简单的分配器——没有回收机制,但足够满足早期引导的需求。

2.9.4 do_early_exception()——早期异常分派

1
2
3
4
5
6
7
8
9
10
11
void __init do_early_exception(struct pt_regs *regs, int trapnr)
{
if (trapnr == X86_TRAP_PF && early_make_pgtable(native_read_cr2()))
return;
if (IS_ENABLED(CONFIG_AMD_MEM_ENCRYPT) &&
trapnr == X86_TRAP_VC && handle_vc_boot_ghcb(regs))
return;
if (trapnr == X86_TRAP_VE && tdx_early_handle_ve(regs))
return;
early_fixup_exception(regs, trapnr);
}

这是早期 IDT 异常的统一入口,按优先级处理三类场景:

  1. 页错误(#PF):读取 CR2 获取引发缺页的线性地址,调用 early_make_pgtable() 尝试构建页表映射。如果成功,正常返回,被中断的指令将重试并成功访问。

  2. SEV-ES 虚拟化异常(#VC):AMD SEV-ES(Secure Encrypted Virtualization - Encrypted State)环境下,某些指令的执行会触发 #VC 异常。handle_vc_boot_ghcb() 通过 GHCB(Guest Hypervisor Communication Block)与宿主机通信来处理这些异常。

  3. TDX 虚拟化异常(#VE):Intel TDX 环境下,#VE 异常由 tdx_early_handle_ve() 处理,通过 TDX 模块调用完成必要的虚拟化操作。

  4. 致命异常:如果以上处理程序均无法处理该异常,early_fixup_exception() 被调用,通常会导致早期恐慌(early panic)并终止引导过程。

2.9.5 虚拟内存布局基础变量

head64.c 中定义了三个关键的虚拟内存布局变量:

1
2
3
unsigned long page_offset_base = __PAGE_OFFSET_BASE_L4;  /* 0xffff888000000000 */
unsigned long vmalloc_base = __VMALLOC_BASE_L4; /* 0xffffc90000000000 */
unsigned long vmemmap_base = __VMEMMAP_BASE_L4; /* 0xffffea0000000000 */

这三个变量定义了 x86_64 虚拟地址空间的核心布局:

  • page_offset_base:直接物理内存映射区域的起始地址。内核通过这个区域直接访问所有物理内存(减去一个偏移量)。
  • vmalloc_base:vmalloc 虚拟地址空间的起始地址。用于非连续物理页的虚拟地址连续映射。
  • vmemmap_base:虚拟内存映射(struct page 数组)的起始地址。每个物理页对应一个 struct page 结构。

在五级分页模式下,这些基地址会不同,以利用更广阔的虚拟地址空间。这些变量在后续的内存管理初始化中将被正式使用。

2.9.6 关键转折:从架构相关到架构无关

x86_64_start_kernel() 的执行标志着 x86_64 架构特定引导代码的终结。在此之前的启动状态可以总结为:

  • 单 CPU 执行:仅有 BSP 在运行,其他 CPU 尚未被唤醒
  • 无调度器:没有进程概念,没有上下文切换
  • 无中断子系统:仅有最低限度的异常处理
  • 最小页表:仅映射了内核映像和少量必要的物理内存
  • 恒等映射:部分物理内存同时通过恒等映射和内核映射访问

进入 start_kernel() 后,内核将开始构建所有通用子系统。x86_64 架构相关的功能(如 SMP 启动、中断控制器、特定驱动等)将通过后续的回调函数和条件编译逐步集成。这种架构代码与通用代码的清晰分界,是 Linux 内核可移植性的核心设计之一。2.10 UEFI 直接引导路径

在传统的 BIOS 引导流程中,内核映像必须经过实模式启动代码、A20 门控、从实模式到保护模式再到长模式的漫长切换过程。然而,当系统通过 UEFI 固件直接引导 Linux 内核时,这一切都被彻底改写了。UEFI stub 机制使得 Linux 内核自身就是一个合法的 EFI 应用程序,可以被 UEFI 固件直接加载和执行,完全绕过了实模式的遗留世界。

2.10.1 bzImage 中的 PE/COFF 头部

UEFI 固件能够直接加载 Linux 内核的关键在于 bzImage 映像中嵌入了一个合法的 PE/COFF 头部。这个头部在 arch/x86/boot/header.S 中定义,通过精心构造使得同一个 bzImage 文件既兼容传统的 BIOS 引导方式,又满足 UEFI 固件对可执行映像格式的要求。

在传统引导流程中,引导扇区的前两个字节是 jmp 指令(0xEB),随后是引导参数。而对于 UEFI 固件而言,偏移量 0x80 处放置了一个标准的 PE/COFF 签名 “PE\0\0”,UEFI 固件的加载器会识别这个签名并按照 PE/COFF 格式解析映像。头部中指定的入口点指向 EFI stub 的 efi_pe_entry() 函数,而非传统的实模式入口 start_of_setup

这意味着同一个 bzImage 文件可以被 UEFI 固件当作普通 EFI 应用程序加载运行,UEFI 固件根本不知道它加载的其实是一个 Linux 内核。

2.10.2 efi_pe_entry() 到 efi_stub_entry()

当 UEFI 固件加载 bzImage 并跳转到入口点时,执行进入 efi_pe_entry()。这个函数定义在 drivers/firmware/efi/libstub/x86-stub.c 中,是 UEFI 直接引导的起点。

此时的执行环境与传统的实模式环境截然不同:处理器已经运行在 64 位长模式下,UEFI 启动服务(Boot Services)依然可用,内存通过 EFI 的分配接口管理,中断由 UEFI 固件控制。整个环境是一个完整的 EFI 应用程序运行环境。

efi_pe_entry() 的执行步骤如下:

a) 获取 EFI 系统表和映像句柄。 UEFI 固件在调用入口点时,通过寄存器传递两个关键参数:EFI Image Handle(在 %rcx 中)和 EFI System Table 指针(在 %rdx 中)。Image Handle 标识当前加载的 EFI 映像实例,System Table 则提供了对全部 EFI 运行时服务和启动服务的访问入口。

b) 检查 EFI 版本兼容性。 EFI stub 验证当前固件的 EFI 规范版本是否满足最低要求。如果不兼容,立即终止引导并报告错误。

c) 分配 boot_params 结构。 EFI stub 通过 EFI 内存分配接口分配一个 4096 字节的页面,将其清零后作为 boot_params 结构体使用。这个结构体在整个内核引导过程中扮演着核心数据容器的角色,与 BIOS 引导路径中由实模式代码填充的 boot_params 完全等价。

d) 建立 EFI 内存映射。 EFI stub 调用 get_memory_map() 获取当前 EFI 内存映射,将相关信息写入 boot_params 结构。这包括可用内存区域的地址、大小和类型,后续内核的内存子系统将依赖这些信息进行物理内存管理。

e) 处理内核命令行。 EFI stub 从 EFI 系统表的 LoadOptions 字段中提取内核命令行参数,或者从 EFI 配置表中获取。命令行被复制到 boot_params 中指定的位置。

f) 加载 initrd。 这是 EFI stub 中最关键的步骤之一。initrd 的加载有两条路径:一是通过 EFI_LOAD_FILE2_PROTOCOL,由支持该协议的引导加载器(如 systemd-boot)提供 initrd 数据;二是通过命令行中的 initrd= 参数,由 EFI stub 自身从文件系统中读取 initrd 文件。前者是推荐的现代方式,后者是兼容方案。无论哪种方式,initrd 的地址和大小都被记录在 boot_paramshdr.ramdisk_imagehdr.ramdisk_size 字段中。

g) 调用 efi_decompress_kernel() 完成所有准备工作后,控制流转入内核解压缩阶段。

2.10.3 efi_decompress_kernel() —— 在 EFI 环境中解压内核

efi_decompress_kernel() 函数负责为解压后的内核分配内存并执行解压缩操作。由于此时仍处于 EFI 启动服务环境中,内存分配必须使用 EFI 的原生接口:allocate_pool()allocate_pages()

函数首先计算解压后内核所需的最大内存空间。然后,如果启用了 KASLR(内核地址空间随机化),会调用 kaslr.c 中的 EFI 专用随机化逻辑选择一个随机的物理地址作为内核加载位置。KASLR 在 EFI 环境中的实现利用 EFI 内存映射来确定可用的物理地址范围,并在其中随机选取一个满足对齐要求的地址。这种随机化是内核安全性的基本防线之一,使得每次引导时内核在物理内存中的位置都不同。

确定目标地址后,函数调用内核内嵌的解压缩例程将压缩的内核映像解压到目标位置。解压完成后,函数返回解压后内核的入口点地址,即目标地址加上固定偏移量(对应 startup_64 的位置)。

2.10.4 enter_kernel() —— 跳入内核世界

解压完成后,EFI stub 通过 enter_kernel() 函数执行最后的跳转。这个函数极其简洁,是一段内联汇编:

1
2
3
4
static void enter_kernel(unsigned long kernel_addr, struct boot_params *boot_params)
{
asm volatile("jmp *%0" :: "a"(kernel_addr), "S"(boot_params));
}

这段代码的语义十分明确:将 boot_params 的物理地址加载到 %rsi 寄存器,将目标内核地址加载到 %rax,然后通过间接跳转跳转到内核入口。%rsi 作为第二个参数传递(x86_64 调用约定中第二参数使用 %rsi),这恰好与内核启动代码期望接收 boot_params 指针的寄存器一致。

需要特别注意的是,此处的跳转目标是压缩内核(compressed kernel)中的 startup_64,位于 arch/x86/boot/compressed/head_64.S 中偏移 0x200 处,而非内核主体(kernel proper)的 startup_64。这意味着 EFI stub 跳转后,仍然要经过解压缩器的执行路径:startup_64(解压阶段)→ extract_kernel() → 内核主体的 startup_64。虽然内核映像已经被解压,但解压缩器代码仍会运行,只是它会检测到内核已经处于解压状态,直接跳转到最终的内核入口。

2.10.5 EFI stub 与传统引导路径对比

两种引导路径的差异深刻地体现了从遗留 BIOS 到 UEFI 的架构演进:

传统 BIOS 路径的完整链条是:BIOS 固件 → GRUB 引导加载器 → 实模式 setup 代码(start_of_setup)→ A20 门控开启 → 从实模式切换到保护模式 → 从保护模式切换到长模式 → 内核解压缩器 → 内核主体。整个过程中,处理器经历了从 16 位实模式到 32 位保护模式再到 64 位长模式的完整穿越。

UEFI stub 路径则截然不同:UEFI 固件 → EFI stub(efi_pe_entry,已在 64 位模式)→ 内核解压缩器 → 内核主体。处理器自始至终运行在 64 位长模式下。

EFI stub 路径完全跳过了传统路径中的以下环节:

  • 实模式 setup 代码:所有对硬件的检测和初始化都由 UEFI 固件在更早的阶段完成。
  • A20 门控:这是 80286 时代的遗留机制,在 UEFI 环境中毫无意义。
  • 保护模式过渡:UEFI 固件已经将处理器置于 64 位长模式,不需要任何模式切换。

但 EFI stub 路径仍然运行解压缩器。这是因为 bzImage 中的内核映像是压缩存储的,即使在 EFI 环境中也需要解压缩步骤。区别在于解压缩器从一开始就运行在 64 位模式下,而非像传统路径那样需要先经历模式切换。

2.10.6 EFI Handover Protocol(遗留协议,已弃用)

除了 UEFI 固件直接加载内核的”纯”EFI stub 路径外,历史上还存在一种被称为 EFI Handover Protocol 的中间方案。在这种方案中,引导加载器(如 GRUB)通过 UEFI 启动,但不是让 UEFI 固件直接加载内核,而是 GRUB 自身加载内核映像后,调用内核映像中导出的 efi32_stub_entryefi64_stub_entry 入口点。

这种设计允许 GRUB 在调用内核之前进行更多的准备工作,例如加载 initrd、设置命令行参数等。然而,这种协议引入了引导加载器与内核之间的紧密耦合,增加了维护复杂性和安全隐患。该协议已被标记为弃用(deprecated),新系统应当使用 UEFI 固件直接加载内核或使用支持 EFI_LOAD_FILE2_PROTOCOL 的现代引导加载器。

2.10.7 EFI 安全启动

UEFI 安全启动(Secure Boot)是 UEFI 规范中的重要安全特性,它通过数字签名验证机制确保只有经过授权的 EFI 应用程序才能被执行。

在安全启动流程中,UEFI 固件在加载 bzImage 时会验证其 PE/COFF 签名。如果签名有效且信任链完整,固件允许执行 EFI stub;否则拒绝加载。对于 Linux 发行版,通常使用 Shim 引导加载器作为信任链的桥梁:Shim 由 Microsoft 签名(因此被大多数 UEFI 固件信任),Shim 再验证并加载 Linux 内核。

内核通过 lockdown 机制配合安全启动。当检测到安全启动启用时,内核会自动进入 lockdown 模式,限制用户空间的某些操作(如直接访问 /dev/mem、加载未签名的内核模块等),防止已运行的操作系统绕过安全启动的保护意图。这形成了一个从固件到内核的完整信任链。

2.10.8 关键源文件索引

EFI stub 的实现分布在 drivers/firmware/efi/libstub/ 目录下的若干文件中,各司其职:

  • x86-stub.c:x86 架构专用的 EFI stub 实现,包含 efi_pe_entry()efi_stub_entry()efi_decompress_kernel()enter_kernel() 等核心函数。
  • efi-stub.c:架构无关的 EFI stub 主体逻辑,处理命令行解析、initrd 加载等通用任务。
  • efi-stub-helper.c:EFI stub 的辅助函数库,提供内存分配、控制台输出、内存映射获取等基础设施。
  • efistub.h:EFI stub 使用的类型定义和常量声明,定义了与 EFI 固件交互所需的数据结构。
  • kaslr.c:EFI 环境下的 KASLR 实现,利用 EFI 内存映射进行物理地址随机化。

这些文件共同构成了一个完整且自包含的 EFI 引导子系统,使得 Linux 内核能够在不依赖任何传统引导加载器的情况下,被现代 UEFI 固件直接加载和启动。这是 Linux 内核对现代固件架构的深度适配,也是引导流程从 16 位实模式时代迈向 64 位原生环境的重要里程碑。

2.11 多处理器启动:AP 跳板代码

2.11.1 多处理器启动概述

在现代 x86_64 系统中,通常存在多个物理处理器核心。系统启动时并非所有核心同时开始执行,而是遵循一种”主从”式的启动协议:

  • BSP(Bootstrap Processor,引导处理器):由硬件固件(BIOS/UEFI)选定的一个 CPU,负责执行前面章节描述的整个启动流程——从固件加载 bootloader,到进入内核并完成初始化。BSP 是系统中第一个开始执行指令的处理器。
  • AP(Application Processor,应用处理器):系统中除 BSP 之外的所有其他 CPU 核心。AP 在上电后处于停滞状态,等待 BSP 将其唤醒。

系统中的 CPU 拓扑信息由固件通过两种机制之一提供给内核:

  1. Intel MP Protocol(多处理器规范):较早期的机制,通过浮动指针结构和 MP 配置表描述系统中所有处理器、总线、中断等信息。
  2. ACPI MADT(Multiple APIC Descriptor Table):现代系统普遍采用的机制,在 ACPI 表中以条目形式列出每个 CPU 的 APIC ID、拓扑关系等。

内核在 BSP 启动流程中解析这些表,确定系统中有多少个 AP 需要被唤醒,然后逐个向它们发送 SIPI(Start-up Inter-Processor Interrupt,启动处理器间中断) 来完成启动。

2.11.2 SIPI 机制

SIPI 是 x86 架构提供的一种特殊中断,用于唤醒处于等待状态的 AP。其工作流程如下:

BSP 通过 Local APIC 的 ICR(Interrupt Command Register,中断命令寄存器)发送 SIPI。 ICR 的格式规定了目标 APIC ID、中断类型(此处为 Start-Up)以及一个向量号 VV。关键的约束在于:

  • AP 被唤醒后的起始执行地址必须是页对齐的(4KB 对齐)。
  • 该地址必须位于物理内存前 1MB 以内(实模式可寻址范围)。
  • 地址的计算方式为 0x000VV000,其中 VV 是 SIPI 中指定的向量号。
  • AP 被唤醒后,处理器处于实模式,初始的 CS:IP 被设置为 0xVV00:0x0000,对应的线性地址恰好是 0x000VV000

例如,若向量号为 0x09,则 AP 从物理地址 0x00009000 开始执行,CS = 0x0900,IP = 0x0000

内核在启动过程中通过 reserve_real_mode() 函数在低端内存(1MB 以下)预留一个物理页,用作 AP 的跳板代码(trampoline code)存放位置。这个跳板页的物理地址就是 SIPI 的目标地址。

2.11.3 跳板代码的准备

跳板代码的准备在 arch/x86/realmode/init.c 中完成,核心函数是 init_real_mode()

1
2
3
4
5
static int __init init_real_mode(void)
{
// ...
}
early_initcall(init_real_mode);

该函数通过 early_initcall 注册,在内核早期初始化阶段被调用,完成以下工作:

内存分配与代码复制

首先调用 reserve_real_mode() 在物理内存 1MB 以下分配一个对齐的物理页。然后将预编译好的跳板代码二进制 blob(trampoline_64.S 编译后的结果)复制到该物理页。由于跳板代码中包含需要重定位的物理地址引用,还需要对其应用重定位修正,使所有绝对地址引用指向正确的物理位置。

设置 trampoline_header

跳板代码的头部结构(trampoline_header)中存储了若干关键参数,由 BSP 在启动 AP 之前填充:

  • tr_efer:BSP 的 EFER MSR 值。包含 NX(No-Execute)位和 SCE(System Call Extensions)位等控制标志。AP 需要这些信息来正确配置扩展功能使能寄存器。
  • tr_cr4:BSP 的 CR4 值。包含 PAE(Physical Address Extension)、PGE(Page Global Enable)、LA57(5 级分页)等关键控制位。
  • tr_start:指向 secondary_startup_64 的指针,这是 AP 进入长模式后要跳转到的内核虚拟地址。
  • tr_flags:标志位。若系统启用了 AMD SME(Secure Memory Encryption),则设置 TH_FLAGS_SME_ACTIVE_BIT

设置 trampoline_pgd

跳板页表(trampoline_pgd)是 AP 从实模式/保护模式过渡到长模式期间使用的临时页表。它只包含两项映射:

  1. 恒等映射(Identity Mapping):将跳板代码所在的低物理地址映射到相同的虚拟地址。这在启用分页的瞬间至关重要,因为处理器此刻仍在低地址执行,需要恒等映射来保证指令流的连续性。
  2. 内核映射(Kernel Mapping):指向 init_top_pgt(内核的顶层页表),使得 AP 在进入长模式后可以访问内核空间的全部映射。

2.11.4 AP 启动流程:trampoline_64.S

AP 被 SIPI 唤醒后,从实模式开始执行跳板代码。整个流程经历三个阶段:16 位实模式 -> 32 位保护模式 -> 64 位长模式。代码位于 arch/x86/realmode/rm/trampoline_64.S

第一阶段:trampoline_start(16 位实模式)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
trampoline_start:
cli
wbinvd // 回写并使缓存无效
LJMPW_RM(1f) // 规范化 CS:IP
1:
// 设置段寄存器 DS = ES = SS = CS
movw %cs, %ax
movw %ax, %ds
movw %ax, %es
movw %ax, %ss

LOCK_AND_LOAD_REALMODE_ESP // 获取自旋锁并设置栈指针
call verify_cpu // 检查处理器是否支持长模式
jnz no_longmode // 若不支持则停机

lidtl tr_idt // 加载空 IDT(禁用中断)
lgdtl tr_gdt // 加载 GDT(含 KERNEL32_CS, KERNEL_CS, KERNEL_DS)

movl $(CR0_STATE & ~X86_CR0_PG), %eax
movl %eax, %cr0 // CR0.PE = 1(启用保护模式),PG = 0
ljmpl $__KERNEL32_CS, $pa_startup_32 // 远跳转到 32 位保护模式代码

AP 苏醒后的第一条指令便是 cli(禁用中断),因为实模式下中断处理程序尚未就绪。wbinvd 将缓存数据回写到内存并清空缓存,确保与 BSP 之间的内存一致性。

LJMPW_RM(1f) 是一个远跳转宏,用于规范化 CS:IP 的值——由于 SIPI 唤醒时 CS 和 IP 的组合可能产生非标准的线性地址,远跳转使 CS 被重新加载为正确的段基址。

LOCK_AND_LOAD_REALMODE_ESP 宏试图获取一个名为 tr_lock 的自旋锁。这个锁的作用是串行化多个 AP 的启动过程——如果系统中有多个 AP 同时被 SIPI 唤醒,它们需要依次使用跳板代码和实模式栈,避免竞态条件。

verify_cpu 函数检查当前处理器是否支持长模式(通过 CPUID 指令查询)。若不支持,AP 直接执行 hlt 进入停机状态。

GDT 中预先配置了三个段描述符:__KERNEL32_CS(32 位代码段,用于保护模式跳转)、__KERNEL_CS(64 位代码段)、__KERNEL_DS(数据段)。最后通过设置 CR0.PE = 1 进入保护模式,并远跳转到 32 位代码段。

第二阶段:startup_32(32 位保护模式)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
startup_32:
movl $__KERNEL_DS, %eax
movl %eax, %ds
movl %eax, %es
movl %eax, %ss // 加载平坦数据段

// 处理 AMD SME:若 TH_FLAGS_SME_ACTIVE_BIT 置位则启用内存加密
btl $TH_FLAGS_SME_ACTIVE_BIT, pa_tr_flags
jnc .Lno_sme
// ... SME 相关处理 ...

movl pa_tr_cr4, %eax
movl %eax, %cr4 // 启用 PAE(以及可能的其他 CR4 特性)

movl $pa_trampoline_pgd, %eax
movl %eax, %cr3 // 加载跳板页表

// 设置 EFER MSR(从 tr_efer 读取 BSP 的 EFER 值)
movl $MSR_EFER, %ecx
movl pa_tr_efer, %eax
movl pa_tr_efer + 4, %edx
wrmsr

movl $CR0_STATE, %eax // CR0.PE = 1, CR0.PG = 1
movl %eax, %cr0 // 启用分页 → 激活长模式
ljmpl $__KERNEL_CS, $pa_startup_64 // 远跳转到 64 位代码

进入 32 位保护模式后,首先加载平坦段选择子,使所有段寄存器指向基址为 0 的数据段。接下来依次完成进入长模式所需的三个前提条件:

  1. 启用 PAE:通过将 BSP 的 CR4 值写入 CR4 寄存器,启用 PAE(Physical Address Extension),这是 64 位分页所必需的。
  2. 加载页表:将 trampoline_pgd 的物理地址加载到 CR3。此时恒等映射确保当前正在执行的 32 位代码在启用分页后仍然可被访问。
  3. 设置 EFER.LME:将 BSP 的 EFER 值写入 MSR_EFER,其中的 LME(Long Mode Enable)位为进入长模式做好准备。

最后,将 CR0.PG 置 1 启用分页。这一刻是关键的转折点——处理器同时检测到 EFER.LME = 1 和 CR0.PG = 1,正式激活长模式。随后通过远跳转进入 64 位代码段。

第三阶段:startup_64(64 位长模式)

1
2
startup_64:
jmpq *tr_start(%rip) // 跳转到 secondary_startup_64!

长模式激活后,startup_64 仅做一件事:通过 tr_start 中存储的地址,间接跳转到内核主体中的 secondary_startup_64 函数。至此,跳板代码的使命已经完成——AP 成功从实模式过渡到了 64 位长模式,并即将进入内核的正常初始化流程。

2.11.5 secondary_startup_64:进入内核

secondary_startup_64 定义在 arch/x86/kernel/head_64.S 中,是 AP 正式进入内核世界的入口:

1
2
3
4
5
secondary_startup_64:
// 将 init_top_pgt(最终内核页表)加载到 CR3
movq %rsi, %cr3 // 或者通过其他方式获取 init_top_pgt 地址
// ... 加载 per-CPU GDT、栈、CR4、EFER、CR0 ...
// 跳转到 SMP 启动的 C 代码

AP 在此执行的核心操作包括:

  1. 加载最终页表:将 init_top_pgt 的物理地址写入 CR3,替换掉临时的 trampoline_pgd。此后 AP 可以访问完整的内核虚拟地址空间。
  2. 设置 per-CPU 数据:每个 CPU 都有自己独立的 GDT、栈和 per-CPU 数据区域。
  3. 配置控制寄存器:根据当前 CPU 的特性设置 CR4、EFER 和 CR0。
  4. 跳转到 C 代码:最终跳转到 SMP 启动的 C 语言函数(而非 BSP 使用的 x86_64_start_kernel),AP 从此进入调度器子系统,变为可用的 CPU 资源。

当所有 AP 都完成这一流程后,系统中的所有处理器核心都进入就绪状态,可以并行执行任务。

2.11.6 跳板锁机制

当系统中存在多个 AP 且 BSP 几乎同时向它们发送 SIPI 时,这些 AP 会在非常接近的时刻苏醒并开始执行跳板代码。然而,跳板代码位于一个共享的物理页中,且实模式栈空间有限。为避免并发访问导致的竞态条件,内核使用了一个自旋锁 tr_lock

  • 每个 AP 在执行跳板代码的实模式部分之前,必须先获取 tr_lock
  • 获取锁后才能设置栈指针并继续执行后续代码。
  • 当 AP 完成模式切换并跳转到 secondary_startup_64 之后,锁被释放(或在适当的位置释放)。
  • 下一个等待中的 AP 获取锁,开始自己的启动流程。

这种串行化机制确保了即使在拥有大量核心的服务器系统上,多个 AP 的启动也不会相互干扰。

2.11.7 4 级与 5 级分页的处理

x86_64 架构支持两种分页模式:传统的 4 级分页(48 位虚拟地址,256TB 空间)和较新的 5 级分页(57 位虚拟地址,128PB 空间)。5 级分页通过 CR4.LA57 位控制,但有一个关键约束:LA57 位只能在长模式之外修改(即不能在 64 位模式下切换)。

跳板代码中为此提供了额外的入口点:

  • trampoline_start64:64 位直接入口,用于 BIOS 到内核的过渡场景。
  • pa_trampoline_compat:负责在 4 级和 5 级分页之间进行切换。它通过暂时退出长模式来修改 CR4.LA57,然后再重新进入长模式。这个过程本质上是一次模式往返操作:长模式 -> 退出长模式 -> 修改 CR4.LA57 -> 重新进入长模式。

这种设计使得内核能够在运行时动态适配不同的分页级别,而不依赖于固件的预先配置。

2.11.8 ACPI S3 恢复

跳板代码的用途不仅限于 AP 启动。在 ACPI S3(Suspend-to-RAM,挂起到内存) 恢复场景中,同样需要使用跳板代码:

当系统从 S3 睡眠状态恢复时,固件恢复基本的硬件电源状态后,将控制权交还给内核。然而,此时的 BSP 处于实模式状态(因为固件运行在实模式或类似的低特权环境中),需要重新走一遍从实模式到长模式的过渡过程。这正是跳板代码所完成的工作。

内核在挂起时将跳板代码的物理地址保存在 resume_trampoline_addr 变量中,并将其传递给 ACPI 固件。恢复时,BSP 从这个跳板地址开始执行,经过与 AP 启动类似的实模式 -> 保护模式 -> 长模式过渡流程,最终回到内核的恢复点继续执行。

这种复用设计使得内核无需维护两套模式切换代码,减少了代码冗余和维护成本。跳板代码作为一段精心编写的、位置无关的底层过渡代码,同时服务于 AP 启动和 S3 恢复两大场景,是 x86_64 内核启动架构中不可或缺的关键组件。

  • Title: Linux内核分析之基础知识-01
  • Author: 韩乔落
  • Created at : 2026-05-28 15:09:45
  • Updated at : 2026-05-28 18:40:03
  • Link: https://jelasin.github.io/2026/05/28/Linux内核分析之基础知识-01/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments
On this page
Linux内核分析之基础知识-01