4、内核解压与初始化:从vmlinuz到start_kernel

好,我们继续往下走。上一章我们聊了引导加载程序如何把内核镜像从磁盘捞进内存,但那时候的内核还是压缩过的——说白了就是个“压缩包”。今天这一节,我们就来拆这个包,看看它怎么把自己解压开,然后一步步走到 C 语言的入口 start_kernel。

我个人习惯把这段过程叫做“内核的自我觉醒”。你想想看,一个被压缩的二进制文件,没有操作系统帮它,它得自己把自己解压到正确的位置,建立页表,开启 MMU,最后跳进 C 代码。这中间任何一个环节出错,机器连个错误日志都来不及打印就挂了。我在项目中遇到过好几次解压地址算错导致内核启动卡死的 case,排查起来特别痛苦。

4.1 vmlinuz 的构成:你看到的不是真正的内核

我们先搞清楚一件事:vmlinuz 到底是什么?它其实是一个“套娃”结构。

组成部分 说明
头部(Header) 包含引导协议版本、加载地址、入口偏移等元数据
解压例程(Decompressor) 一段自包含的汇编 + C 代码,负责解压真正的内核
压缩后的内核(vmlinux.bin.gz / .xz / .zst) 真正的内核镜像,经过 gzip / xz / zstd 压缩
尾随信息 可能包含 CRC 校验、签名等

所以,当引导程序把 vmlinuz 加载到内存后,CPU 首先执行的是那个解压例程,而不是内核本身。这个解压例程是编译时由 arch/x86/boot/compressed/ 下的代码生成的,它和真正的内核是两个独立的程序。

关键点:解压例程和内核是两个不同的 ELF 对象,链接时被拼接到同一个文件中。解压例程的入口在 arch/x86/boot/compressed/head_64.S(x86_64 架构)。

4.2 解压前的准备工作:建立“临时窝点”

解压例程开始执行时,CPU 还处于实模式或保护模式早期,页表可能还没开,或者只开了恒等映射。这时候解压例程需要做几件要紧事:

  1. 保存引导信息:从 boot_params 结构体中提取内存布局、命令行参数等。
  2. 选择解压位置:计算一个不会覆盖自身代码的目标地址。这步很讲究——如果解压目标地址和当前代码重叠,解压到一半就把自己覆盖了,那就崩了。
  3. 建立临时页表:为了开启 MMU 并访问高地址内存(比如 64 位模式下的 4GB 以上区域),需要建立至少一个 2MB 页的恒等映射。
  4. 切换到 64 位长模式(x86_64):设置 CR0、CR3、EFER 等寄存器,完成模式切换。

我曾经在调试一个 UEFI 引导的板子时,发现解压例程在建立页表时把 CR3 写错了值,导致 MMU 开启后 CPU 立即 triple fault。嗯,这种问题连串口都来不及输出,只能靠 JTAG 抓取指令流来定位。

小技巧:如果你在调试早期启动问题,可以在解压例程的 head_64.S 中插入 outb 指令往串口写字符。这是最原始的“printf 调试法”,但非常有效。

4.3 解压过程:从 .gz 到 ELF

准备工作做完后,解压例程会调用一个 C 函数——通常是 decompress_kernel()。这个函数会根据内核编译时选择的压缩算法,调用相应的解压函数。

// arch/x86/boot/compressed/misc.c(简化示意)
asmlinkage __visible void *decompress_kernel(void *rmode, memptr heap,
                                             unsigned char *input_data,
                                             unsigned long input_len,
                                             unsigned char *output_data,
                                             unsigned long output_len)
{
    // 选择解压算法
    switch (compression_type) {
    case COMPRESS_GZIP:
        __gunzip(input_data, input_len, output_data, output_len, ...);
        break;
    case COMPRESS_XZ:
        __xz(input_data, input_len, output_data, output_len, ...);
        break;
    case COMPRESS_ZSTD:
        __zstd(input_data, input_len, output_data, output_len, ...);
        break;
    }
    return output_data;
}

解压完成后,内存中就有了一个完整的 vmlinux ELF 镜像。但这时候还不能直接跳过去——因为 ELF 文件里的段地址是链接时确定的,而解压后的实际地址可能和链接地址不一致,需要做重定位。

注意:解压后的内核如果开启了 KASLR(内核地址空间布局随机化),解压例程还会在允许的地址范围内随机选择一个基址,然后调整所有符号的偏移。这个随机化是在解压完成后、跳转到内核之前完成的。

4.4 重定位与跳转:最后一公里

解压例程的最后一步是执行重定位。对于 x86_64 架构,内核是位置无关的(PIE),但需要修正 GOT(全局偏移表)和 PLT(过程链接表)中的地址。这部分工作由 relocate_kernel() 完成。

重定位完成后,解压例程会执行一条跳转指令,把控制权交给解压后的内核入口——也就是 startup_64(x86_64 架构)。这个入口位于 arch/x86/kernel/head_64.S 中,它才是真正的内核代码起点。

// arch/x86/boot/compressed/head_64.S(片段)
    /*
     * 跳转到解压后的内核入口
     * rdi 中保存了 boot_params 的地址
     */
    movq    %rax, %rsi        # rax 是解压后的内核入口地址
    movq    %rbp, %rdi        # rbp 中保存了 boot_params
    call    *%rsi

从这一刻起,内核开始以高权限模式运行,页表已经建立,MMU 已经开启,CPU 处于 64 位长模式。接下来要进入的是 C 语言的世界。

4.5 进入 start_kernel:C 语言的起点

startup_64 汇编代码会做最后一点架构相关的初始化——比如设置内核栈指针、清零 BSS 段、设置 IDT(中断描述符表)的早期入口。然后它调用 x86_64_start_kernel(),后者再调用 start_kernel()。

start_kernel() 定义在 init/main.c 中,它是内核初始化流程的总控函数。从它开始,内核进入了一个完全由 C 语言主导的世界。

// init/main.c(简化)
asmlinkage __visible void __init start_kernel(void)
{
    char *command_line;
    char *after_dashes;

    // 架构相关初始化
    setup_arch(&command_line);

    // 日志系统
    setup_log_buf(0);
    printk(KERN_NOTICE "Kernel command line: %s\n", boot_command_line);

    // 内存管理初始化
    mm_init();

    // 调度器初始化
    sched_init();

    // 中断系统
    early_irq_init();
    init_IRQ();

    // 定时器
    init_timers();

    // 控制台
    console_init();

    // ... 后续还有大量初始化步骤
    // 最终调用 rest_init() 创建 init 进程
}

你看,从 start_kernel 开始,内核才真正有了“意识”——它开始打印日志、管理内存、调度任务。而在此之前的所有工作,都是为这一刻铺路。

一句话总结:从 vmlinuz 到 start_kernel,内核完成了“自我解压 → 重定位 → 架构初始化 → 进入 C 世界”的蜕变。这个过程虽然只有几毫秒,但却是整个系统启动中最脆弱、也最关键的阶段。

4.6 整体流程可视化

下面这张图把整个流程串起来了。我建议你把它保存下来,以后排查启动问题时可以对照着看。

从 vmlinuz 到 start_kernel 核心流程 vmlinuz 镜像 head_64.S 解压例程入口 保存引导信息 / 建立临时页表 decompress_kernel() relocate_kernel() startup_64(内核入口) start_kernel() 关键说明 • 解压例程与内核是独立编译的 • 解压目标地址不能覆盖自身 • KASLR 在重定位阶段生效 常见坑点 • 页表建立错误 → triple fault • 解压地址重叠 → 代码自毁 • boot_params 传递错误 → 内核无法获取 cmdline

4.7 几个值得注意的细节

  • 压缩算法的选择:从内核 5.x 开始,默认压缩算法逐渐从 gzip 转向 zstd。zstd 的解压速度比 gzip 快 3-5 倍,但压缩率略低。对于启动时间敏感的嵌入式系统,我建议用 zstd。
  • 解压内存的分配:解压例程需要一块临时内存来存放解压后的内核。这块内存通常来自引导程序报告的 E820 内存映射中的可用区域。如果这块区域被其他固件占用,解压就会失败。
  • 早期串口输出:在 start_kernel 之前,printk 还不可用。如果你想在解压阶段输出调试信息,只能用汇编级别的串口操作——直接写 0x3f8 端口(COM1)。

调试建议:如果你需要调试解压阶段的问题,可以在内核编译时打开 CONFIG_EARLY_PRINTK 和 CONFIG_DEBUG_BOOT_PARAMS。这两个选项会在解压例程中插入额外的日志输出,帮助你定位问题。

好了,这一节的内容就到这里。从 vmlinuz 到 start_kernel 的这段旅程,虽然只有短短几毫秒,但每一步都踩在刀刃上。理解了它,你就掌握了内核启动的第一个关键转折点。