3. 内核启动入口:head.S与启动汇编、从实模式到保护模式、页表初始化

本章深入剖析Linux内核启动的早期阶段,从CPU加电复位后的第一条指令开始,直至开启分页机制、进入保护模式。这一过程是内核启动的基石,也是理解CPU热插拔与SMP协同的前提——每个CPU核心在启动时都会经历类似的初始化路径。

3.1 启动流程总览:从BIOS到内核入口

当x86系统上电或复位时,CPU处于实模式(Real Mode),CS:IP强制指向0xFFFF:0xFFF0(即物理地址0xFFFFFFF0),该地址映射到BIOS ROM。BIOS完成硬件自检(POST)后,加载引导扇区(MBR/GPT)到内存0x7C00处,并将控制权交给引导程序(如GRUB2)。

引导程序负责加载内核映像(bzImage)到内存,并跳转到内核的启动扇区(arch/x86/boot/header.S中的start_of_setup)。随后,内核经历以下关键阶段:

阶段 代码位置 CPU模式 核心任务
1. 启动扇区 arch/x86/boot/header.S 实模式 设置堆栈、解析引导参数、切换到保护模式前的准备
2. 保护模式切换 arch/x86/boot/pmjump.S 实模式→保护模式 加载GDT、设置CR0.PE位、远跳转刷新流水线
3. 32位启动内核 arch/x86/boot/compressed/head_32.S 保护模式(32位) 解压内核、设置页表、开启分页
4. 64位内核入口 arch/x86/kernel/head_64.S 长模式(64位) 初始化页表、设置GS/FS段、跳转到start_kernel
关键概念:实模式下CPU只能访问1MB内存(20位地址线),且段寄存器左移4位+偏移形成地址。保护模式引入段选择子与描述符表,支持4GB(32位)或更大(PAE/长模式)地址空间。页表初始化是开启虚拟内存管理的第一步。

3.2 实模式下的启动汇编:header.S与setup代码

内核映像bzImage由两部分组成:setup代码(实模式部分)和压缩的内核(保护模式部分)。setup代码位于映像开头,由引导程序加载到物理地址0x10000(64KB)处,随后跳转到start_of_setup。

实模式汇编的关键操作包括:

  • 设置堆栈:将SS:SP指向一个安全的临时堆栈区域,避免覆盖关键数据。
  • 解析引导参数:从boot_params结构体中读取内存大小、视频模式、命令行参数等。
  • 检测硬件:通过BIOS中断(如int 0x15)获取内存映射(E820)、APM/ACPI信息。
  • 切换到保护模式:关闭中断(CLI)、加载全局描述符表(GDT)、设置CR0寄存器的PE位。
; arch/x86/boot/pmjump.S 关键片段
.code16
protected_mode_jump:
    movl    %edx, %esi          ; 保存32位入口地址到esi
    xorl    %ebx, %ebx
    movl    %cr0, %eax
    orb     $1, %al             ; 设置CR0.PE位,开启保护模式
    movl    %eax, %cr0
    ; 远跳转刷新指令流水线,加载CS段选择子
    ljmpl   $__BOOT_CS, $in_pm32
.code32
in_pm32:
    movl    %esi, %eax
    jmp     *%eax               ; 跳转到32位内核入口
注意:从实模式切换到保护模式时,必须使用远跳转(ljmp)来刷新CPU的指令预取队列,否则后续指令可能仍按实模式解码,导致异常。这是x86架构的一个经典陷阱。

3.3 保护模式切换:GDT与段机制

保护模式的核心是段描述符表(GDT/LDT)。内核在实模式下预先构建一个临时的GDT,包含必要的代码段、数据段描述符。每个描述符指定段的基地址、限长、访问权限和类型。

临时GDT的结构如下:

索引 段选择子 用途 基地址 限长 DPL
0 0x00 空描述符(必须) — — —
1 __BOOT_CS (0x10) 32位代码段 0x00000000 0xFFFFFFFF 0 (内核)
2 __BOOT_DS (0x18) 32位数据段 0x00000000 0xFFFFFFFF 0 (内核)

切换完成后,CPU使用段选择子访问内存,逻辑地址通过段描述符转换为线性地址。此时尚未开启分页,线性地址直接对应物理地址。

为什么使用平坦模型?内核将所有段的基地址设为0,限长设为4GB,实现“平坦”地址空间。这样,逻辑地址与线性地址相等,简化了内存管理。后续分页机制将接管线性地址到物理地址的映射。

3.4 页表初始化:从临时页表到内核页表

进入保护模式后,内核立即着手初始化页表,开启分页机制。对于32位内核,使用两级页表(页目录+页表);对于64位内核,使用四级或五级页表(PML4→PDPT→PD→PT)。

页表初始化的核心目标:

  • 恒等映射:将内核所在的物理地址区域映射到相同的虚拟地址,确保开启分页后指令指针不失效。
  • 高地址映射:将内核映射到虚拟地址空间的高端(如0xC0000000以上),为后续进程地址空间布局做准备。
  • 覆盖关键区域:确保页表本身、GDT、IDT、堆栈等关键数据结构在分页后仍可访问。
; arch/x86/kernel/head_64.S 中页表初始化片段
__startup_64:
    ; 计算页表在内存中的位置(对齐到4KB)
    leaq    _text(%rip), %rdi
    andq    $~0xFFF, %rdi
    ; 清零页表区域
    movl    $512, %ecx          ; 512个PML4E
    xorl    %eax, %eax
    rep stosq
    ; 设置PML4E指向PDPT
    leaq    level2_kernel_pgt(%rip), %rax
    orq     $0x07, %rax         ; Present + R/W + User
    movq    %rax, level1_kernel_pgt(%rip)
    ; ... 后续设置PDPT、PD、PT条目
页表标志位:每个页表条目(PTE)的低12位包含属性标志,如Present(位0)、R/W(位1)、User/Supervisor(位2)、PWT/PCD(位3-4)、Accessed(位5)、Dirty(位6)等。内核初始化时通常设置Present+R/W,确保内核自身可读写。

3.5 启动阶段页表结构图

下图展示了64位内核在启动初期建立的临时页表结构,采用恒等映射与高地址映射并存的策略:

启动阶段页表结构(64位长模式) PML4 1个条目 指向 PDPT 4个条目 指向 页目录 (PD) 512个条目 指向 页表 (PT) 512个条目 映射策略 • 恒等映射:虚拟地址 0x00000000~0x1000000 映射到物理地址 0x00000000~0x1000000 • 高地址映射:虚拟地址 0xFFFFFFFF80000000~ 映射到物理地址 0x00000000~ • 页表自身映射:将PML4/PDPT/PD/PT所在物理页映射到虚拟地址空间,便于递归访问 物理内存布局(启动阶段) 0x00000000 0x00100000 0x01000000 0x02000000 0x10000000+ 实模式中断向量表 内核setup代码 压缩内核映像 页表/堆栈 动态分配区域

3.6 从实模式到长模式的完整路径

对于64位内核,CPU模式切换并非直接从实模式跳到长模式,而是经过一个中间步骤:先切换到保护模式(32位),再通过设置EFER寄存器的LME位和CR4的PAE位,最后开启CR0.PG位进入长模式。

  1. 实模式:16位,20位地址线,1MB地址空间,无保护。
  2. 保护模式(32位):32位,段保护,4GB地址空间,可开启分页。
  3. 长模式(64位):64位,必须启用PAE页表,支持64位虚拟地址,兼容32位子模式。
关键寄存器设置:
  • CR0.PE:开启保护模式(位0)
  • CR4.PAE:开启物理地址扩展(位5),长模式必须
  • EFER.LME:开启长模式(位8)
  • CR0.PG:开启分页(位31),必须在设置完页表后置位
设置顺序:CR4.PAE → EFER.LME → CR0.PG,且CR0.PG必须在最后一步置位,否则CPU会触发异常。

3.7 启动汇编中的关键数据结构

在head.S和head_64.S中,内核定义了几个关键的数据结构,用于页表初始化和早期内存管理:

符号名称 类型 用途 对齐
level1_kernel_pgt PML4E数组 顶级页表(PML4),包含指向PDPT的条目 4KB
level2_kernel_pgt PDPTE数组 页目录指针表,指向页目录 4KB
level3_kernel_pgt PDE数组 页目录,包含2MB大页或指向页表的条目 4KB
level4_kernel_pgt PTE数组 页表(仅当使用4KB页时存在) 4KB
initial_code 指针 存储start_kernel的入口地址 8字节
initial_stack 字节数组 早期内核堆栈(通常为16KB) 16字节
; arch/x86/kernel/head_64.S 中数据段定义
.section .data
.align 4
.globl initial_code
initial_code:
    .quad   x86_64_start_kernel   ; 指向C语言入口

.align 16
.globl initial_stack
initial_stack:
    .fill   4096, 1, 0            ; 16KB堆栈空间
    .quad   0                     ; 堆栈顶标记

.align 4096
.globl level1_kernel_pgt
level1_kernel_pgt:
    .quad   level2_kernel_pgt + 0x07  ; Present + R/W + User
    .fill   511, 8, 0                 ; 其余条目清零

3.8 常见问题与调试技巧

常见陷阱:
  • 页表未对齐:页表基地址必须4KB对齐,否则CPU在加载CR3时会触发通用保护异常(#GP)。
  • GDT描述符错误:代码段描述符的S位(描述符类型)必须为1(代码/数据段),DPL必须与当前CPL匹配,否则远跳转会失败。
  • 缓存一致性问题:修改页表后,必须使用INVLPG指令或重新加载CR3来刷新TLB,否则CPU可能使用过期的缓存映射。
  • 栈指针无效:在切换模式时,如果SS:SP指向的地址在分页后不可访问,会导致双重异常(#DF)。
调试建议:使用QEMU + GDB可以单步跟踪启动汇编。在head.S的入口处设置断点,观察CR0/CR3/EFER寄存器的变化,以及页表内容是否正确。对于页表问题,可以打印CR3指向的物理内存内容,验证PML4E/PDPTE/PDE/PTE的标志位和地址字段。

至此,内核完成了从实模式到保护模式、再到长模式的切换,并建立了基础的页表映射。CPU已经准备好跳转到C语言编写的start_kernel函数,开启内核初始化的下一阶段——在这里,CPU热插拔与SMP启动的协同机制将逐步展开。