3. 内核解压与重定位:vmlinuz与zImage的区别、自解压代码(head.S)的执行流程

好,咱们接着往下走。上一章我们把BootLoader的工作聊完了,现在内核镜像已经被加载到了内存里。但这时候你看到的那个文件——不管是叫vmlinuz还是zImage——其实都还不是可以直接运行的内核。说白了,它是一个压缩包,外面裹了一层自解压代码。

我记得刚入行那会儿,有同事直接拿vmlinuz去反汇编,结果发现入口点根本不是start_kernel,而是一堆看不懂的汇编。后来才明白,那其实是解压程序的入口。嗯,今天我们就来扒开这层外衣。

3.1 vmlinuz vs zImage:到底差在哪?

这两个名字,你肯定都见过。很多初学者容易搞混,我简单梳理一下。

特性 vmlinuz zImage
全称 Virtual Memory LINUX (压缩) zImage (压缩内核镜像)
压缩方式 通常为gzip,也可能是bz2、lzma等 gzip
加载地址 通常加载到高端内存(>1MB) 加载到低端内存(<1MB)
解压后大小 可大于1MB,适合大内核 解压后必须小于1MB
适用场景 现代x86、ARM等主流架构 早期x86、小内存系统
文件头 包含完整的ELF头或自解压头 简单的自解压头

说白了,zImage是vmlinuz的前身。早期内核小,压缩后能塞进640KB以下的常规内存,所以叫“z”Image(z表示压缩)。后来内核越来越大,1MB都不够用了,就搞出了vmlinuz,可以加载到任意位置。

核心区别一句话:zImage是“小内核专用”,vmlinuz是“大内核通用”。现在你看到的几乎所有发行版内核,都是vmlinuz。

我个人习惯在调试嵌入式系统时,如果内存紧张(比如只有4MB),会特意编一个zImage。但说实话,现在这种场景越来越少了。

3.2 自解压代码的执行流程:head.S 干了什么?

不管是vmlinuz还是zImage,它们的开头都有一段自解压代码。对于x86架构,这段代码在 arch/x86/boot/compressed/head.S 里。ARM架构也有类似的,叫 arch/arm/boot/compressed/head.S。我们今天以x86为例,因为它的逻辑最清晰。

你想想看,CPU拿到vmlinuz后,第一条指令执行的是哪?就是head.S的入口。它要完成的任务其实就三件:

  1. 设置基本运行环境(栈、段寄存器等)
  2. 解压内核(调用decompress_kernel)
  3. 重定位并跳转(把解压后的内核挪到正确位置,然后跳过去)

我画了一张流程图,帮你理清head.S的执行脉络:

head.S 自解压执行流程图 入口:startup_32 设置栈、段寄存器、GDT 检测CPU模式(32位/64位) 调用 decompress_kernel() 重定位:将解压后内核移到最终位置 跳转到解压后的内核入口 注:32位入口为 startup_32,64位入口为 startup_64,流程类似但寄存器设置不同

3.3 关键代码段解析

我们来看一段head.S的核心片段(简化版,x86 32位):

# arch/x86/boot/compressed/head.S

    .code32
    .text

startup_32:
    # 1. 设置栈
    leal    (ebx - startup_32)(%ebp), %esp
    
    # 2. 检查是否支持长模式(64位)
    movl    $0x80000001, %eax
    cpuid
    testl   $0x20000000, %edx
    jnz     .Llong_mode_supported
    
    # 3. 解压内核
    # 参数:输入地址、输出地址、长度等
    pushl   %ebp            # 保存ebp
    call    decompress_kernel
    popl    %ebp
    
    # 4. 重定位
    # 计算解压后的内核应该放在哪
    movl    %ebp, %ebx
    addl    $(z_output_len - _end), %ebx
    
    # 5. 跳转到解压后的内核
    leal    relocated(%ebx), %eax
    jmp     *%eax

这段代码看起来不长,但每一步都有讲究。我挑几个重点说说:

3.4 避坑指南:我踩过的几个坑

坑一:栈设置不对,解压直接崩

我曾经在移植内核到一个新的x86板子时,发现解压到一半就挂了。查了两天才发现,是head.S里栈指针没对准。因为自解压代码运行的时候,原来的栈可能已经被覆盖了。所以leal (ebx - startup_32)(%ebp), %esp这行,必须确保ebx指向正确的基址。

坑二:重定位时地址重叠

解压后的内核如果和压缩镜像有重叠,那解压过程中就会把自己覆盖掉。嗯,这听起来像是个死循环。内核的做法是:如果检测到重叠,就先把压缩镜像挪到安全位置,再解压。这个逻辑在decompress_kernel里处理,但如果你自己写BootLoader,一定要留够空间。

警告:vmlinuz的加载地址和解压后的内核地址不能重叠!通常建议解压后的内核地址比压缩镜像高至少2MB。否则你会看到“Data abort”或“General protection fault”。

3.5 解压算法的选择

内核支持多种压缩算法:gzip、bzip2、lzma、xz、lzo、lz4。你可能会问,哪个最好?

我个人的经验是:

  • gzip:兼容性最好,几乎所有架构都支持。但压缩率一般。
  • lzma:压缩率最高,但解压慢。适合存储空间紧张的系统。
  • lz4:解压极快,但压缩率低。适合启动速度要求高的场景。

我记得有一次做车载系统,要求冷启动必须在2秒内进入用户态。我们把压缩算法从gzip换成lz4,解压时间从800ms降到了200ms。虽然镜像大了30%,但启动快了,值!

小技巧:在配置内核时,CONFIG_KERNEL_GZIPCONFIG_KERNEL_LZ4等选项就是用来选压缩算法的。如果你不确定,就选gzip,不会错。

3.6 重定位的细节

解压完成后,内核镜像其实还在一个“临时位置”。head.S的最后一步,就是把它挪到最终运行地址。这个地址通常由BootLoader通过内核启动参数告诉内核,或者由内核自己计算。

对于x86,最终地址通常是物理地址0x100000(1MB)或更高。对于ARM,则取决于平台的内存映射。

重定位的代码其实就是一个memcpy,但要注意:如果源和目标有重叠,必须用memmove。head.S里用的是rep movsb指令,配合方向标志位,可以正确处理重叠情况。

嗯,这里有个细节:重定位之后,原来的压缩镜像就可以被覆盖了。所以解压后的内核会占据最终位置,而压缩镜像所在的内存会被释放,留给后续的内核使用。

3.7 总结一下

vmlinuz和zImage的区别,说白了就是“能不能装下大内核”。head.S的自解压流程,就是“设置环境→解压→重定位→跳转”四步走。每一步都有坑,但只要你理解了内存布局和地址关系,就不容易出问题。

我个人建议,如果你在做内核移植,第一步就是先确认压缩镜像的加载地址和解压后的目标地址。把这两个地址搞清楚,head.S的执行流程就成功了一半。


公众号:蓝海资料掘金营,微信deep3321