ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

vmlinux、zImage、uImage到底有什么区别?Linux内核镜像格式详解

2026/9/26 5:37:04 拓冰建站 浏览量
vmlinux、zImage、uImage到底有什么区别?Linux内核镜像格式详解 写这篇东西的起因很简单我当年第一次完整编译Linux内核在arch/arm/boot/目录下看到一堆vmlinux、zImage、uImage甚至Image文件时人都是懵的。这三个名字里都有内核尺寸差好几倍用file命令看格式也完全不一样到底该烧哪个进板子为什么网上教程一会儿让用zImage一会儿让用uImage后来自己翻了Kbuild的Makefile、啃了U-Boot的源码又把ARM自解压那套流程捋了一遍才算彻底想明白。这篇文章就把我踩过的坑和最终的理解一次性讲清楚内容围绕Linux内核镜像的三种典型形态展开适合搞嵌入式Linux移植、内核启动调试以及刚接触内核编译构建的读者。1. 先搞清楚一件事这三种文件根本不是同一层面的东西很多人把vmlinux、zImage、uImage想成三个不同版本的内核其实这是最大的误区。它们的关系更像是面粉、面团、包子——同一个内核源码在构建和打包的不同阶段产出的不同形态用途完全不一样。vmlinux是编译链接后生成的原始内核可执行文件ELF格式没有经过任何压缩里面带了完整的符号表和调试信息前提是开启了CONFIG_DEBUG_INFO。它是整个镜像链条的源头后面所有格式都是从它派生出来的。zImage是ARM 32位架构上传统的内核自解压镜像。它本质上是自解压代码 压缩后的vmlinux payload。Bootloader把它加载到内存后先执行那段解压代码把真正的内核解压到内存的指定位置然后跳转过去执行。之所以搞这么一出是因为早期的ARM板子内存小、存储介质慢压缩一下能省不少空间和时间。uImage则是U-Boot专属的内核镜像格式。它就是在zImage或者未压缩的Image前面加了一个64字节的U-Boot头里面记录了镜像的加载地址、入口地址、操作系统类型、压缩方式等信息。U-Boot通过这个头来判断我该把这个镜像放到哪个地址去执行。一句话总结文件本质是否压缩包含符号能否直接执行vmlinuxELF可执行文件否是调试时可以但极少直接用zImage自解压二进制包是payload部分否可以需bootloader引导uImageU-Boot包装格式取决于内部否可以只能由U-Boot引导弄清楚这个层面之后下面每一节分别展开讲包括它们各自为什么存在、内部怎么构造、实际项目里怎么选怎么用。2. vmlinux内核的本体但你可能永远用不到它2.1 静态链接的内核ELF地址是固定的vmlinux是一个静态链接的ELF文件所有代码和数据在链接阶段就被放到了固定的虚拟地址上。以ARM 32位为例内核链接地址通常由CONFIG_PAGE_OFFSET决定经典值是0xC0008000也就是内核虚拟地址空间的起点再加一个偏移。这不是随便定的它要和MMU页表设置、内核映射逻辑严格对齐乱了就直接起不来。我用readelf看过vmlinux的Program Header能看到它的text段被加载到0xC0008000data段紧随其后。这种布局和普通用户态程序完全不同用户态程序有动态链接器帮忙做重定位内核没有——它自己就是操作系统没人给它做重定位所以链接地址就是运行时的虚拟地址必须是编译期定死的。2.2 vmlinux的两个主要用途调试和生成其他格式你几乎不会把vmlinux直接烧进Flash原因很现实第一它太大。一个开了DEBUG_INFO的vmlinux动辄几十上百MB压缩成zImage可能就几MB。第二它的ELF格式带了一堆节区表、符号表这些信息运行的时候根本不需要还容易让Bootloader处理起来变复杂。第三链接地址是虚拟地址而Bootloader在跳转之前往往连MMU都没开直接执行一个按虚拟地址链接的ELF是不现实的。那vmlinux干嘛用调试。用GDB调试内核时vmlinux就是符号来源。比如你用QEMU调试ARM内核-kernel参数直接给vmlinuxGDB就能加载符号表对内核代码下断点、看变量非常方便。另外vmlinux也是生成zImage/uImage的源材料先objcopy成纯二进制再gzip压缩再和自解压代码拼在一起。2.3 符号表与System.map顺带说一个和vmlinux强相关的东西System.map。编译内核时顶层目录会生成System.map本质就是从vmlinux里提取的符号地址列表每行格式是地址 类型 符号名比如c0008000 T _stext c0009000 T stext这个文件不参与启动纯粹给调试和模块加载用。内核崩溃时打印的调用栈里那些十六进制地址就是要拿System.map来翻译成函数名的。我自己排查过oops信息没有System.map的时候只能看到一堆地址有了它就能定位到具体函数和偏移量效率完全不是一个级别。调试场景里还有一个细节要注意vmlinux里的符号地址是链接地址如果内核开启了CONFIG_RANDOMIZE_BASE内核地址随机化实际运行地址会被打乱符号表对不上这时候得通过kallsyms或者禁用随机化来处理。嵌入式调试经常为了省事直接关掉这个选项。3. zImage自解压镜像那一段解压代码的细节3.1 为什么需要解压这一步ARM 32位时代zImage是绝对的主流。原因很朴素早期NOR/NAND Flash读写慢容量也小压缩镜像能省空间、缩短烧录时间。而内核代码段本身有大量可压缩的内容尤其是字符串、switch跳转表之类gzip压完体积通常能缩小一半以上。Bootloader加载zImage之后它面临着当前运行地址和最终解压地址可能重叠的问题。比如U-Boot把zImage放到了0x30008000但内核最终要被解压到0x30008000就是同一个位置那解压过程就会把还没解压完的源数据覆盖掉。这个问题ARM的head.S里专门做了处理解压之前会计算源地址和目标地址区间如果重叠就把数据往高处搬搬到一个安全位置再解压。这就是为什么zImage开头那段代码叫decompress code它是整个镜像能否正确启动的关键。3.2 zImage内部长什么样zImage的构成分成两段。前面是arch/arm/boot/compressed/head.S编译出来的自解压代码后面是piggy数据——压缩后的vmlinux二进制。具体生成路径是这样的vmlinux先被objcopy去掉ELF头变成纯二进制就是arch/arm/boot/Image然后gzip压缩成piggy.gz再用汇编器把piggy.gz包成一个piggy.o文件最后和head.o、misc.o这些自解压代码目标文件链接得到zImage。用binwalk或者直接读文件头能看到zImage开头一段是ARM指令不是gzip头。你得跳过自解压代码在某个偏移量上才能找到gzip的魔数1f 8b。这个偏移不是固定的每次编译可能都不一样。我在实际排查中用过这样一个土办法来确认某个zImage到底压的是什么数据# 找到zImage里gzip流的偏移然后直接解压看看内容 # 先用binwalk定位或手动在hexdump里找 1f 8b dd ifzImage bs1 skipgzip偏移 ofpayload.gz gzip -dc payload.gz extracted.bin file extracted.bin如果extracted.bin显示是Linux kernel ARM boot executable zImage其实解出来还是ELF说明这确实是标准的内核payload流程。这个技巧在逆向、分析第三方内核镜像时很好使。3.3 zImage加载地址与text_offset的坑ARM平台上zImage不是随便放在哪个地址都能启动的。内核的Kconfig有一个CONFIG_AUTO_ZRELADDR选项很多平台会开启自动计算运行地址原理是根据当前PC值和内存起始地址推算让zImage在一定的地址范围内都能解压运行。但有些Bootloader不配合或者板子的内存映射比较特殊这时候就必须按平台规定的手册地址来加载。老一点的三星平台典型加载地址是0x30008000或0x20008000全志平台常见0x40008000之类。U-Boot的bootm命令加载zImage是先把zImage复制到加载地址然后跳到加载地址执行如果你的加载地址落在内存里的一个奇怪位置自解压代码算出来的zreladdr也会跟着变结果要么解压地址不对要么覆盖了U-Boot自身现象就是启动到一半卡死或者死循环。我调试过的板子就出过这种问题U-Boot里bootm 0x20008000内核起来后打印Uncompressing Linux... done然后没有任何输出直接挂掉。后来用串口加调试灯逐段排查发现是加载地址和DDR内存映射重叠zImage的解压代码把源数据覆盖了。解决方案是换一个安全的加载地址或者检查DTS里内存起始地址的配置确保两者不冲突。4. uImageU-Boot专用包装那64字节的讲究4.1 为什么U-Boot需要另外加个头部U-Boot启动内核时只给它一个裸的zImage它当然也能直接跳过去执行很多极简Bootloader就是这么干的。但U-Boot想做更多事情它想校验镜像完整性、想记录镜像的加载地址和入口地址、想区分这个镜像是什么CPU架构和什么操作系统甚至想做多内核镜像选择。这些信息放哪U-Boot的答案是加一个统一格式的头这就是uImage的来历。U-Boot源码里include/image.h定义了image_header_t结构体正好64字节。关键字段我列一下字段偏移意义ih_magic0固定魔数0x27051956ih_hcrc4头部校验和ih_time8时间戳ih_size12镜像数据大小ih_load16加载地址ih_ep20入口地址ih_dcrc24数据校验和ih_os28操作系统类型ih_arch29CPU架构ih_type30镜像类型kernel、ramdisk等ih_comp31压缩方式ih_name32名称字符串那个魔数0x27051956非常关键U-Boot的bootm命令第一件事就是校验这个值不对就直接报Bad Magic Number。如果你手动改过镜像头或者用错了mkimage参数第一个坑基本都在这。4.2 mkimage工具的使用与参数含义生成uImage的工具是mkimage它是U-Boot工具链的一部分。最经典的用法mkimage -A arm -O linux -T kernel -C gzip -a 0x30008000 -e 0x30008000 -n Linux 5.15 -d zImage uImage解释一下参数-A指定架构arm-O指定操作系统linux-T指定镜像类型kernel-C指定压缩方式gzip-a是U-Boot要把镜像加载到哪个地址-e是内核入口地址。对于zImage来说-a和-e通常是同一个值因为zImage的入口就是它自己被加载的位置。要注意的是mkimage这个工具相当古老很多发行版里默认带的版本可能不支持你手头U-Boot的新特性。比如新版U-Boot支持FIT image.itb文件格式更灵活能打包多个dtb和ramdisk。mkimage也支持生成FIT镜像。老式uImage虽然还在用但新项目里越来越多的方案转向FIT格式了。4.3 uImage和zImage的传家宝式转换网上有一大堆教程教你把zImage转成uImage本质上就是拿mkimage给zImage加个64字节的头。反过来把uImage的头去掉就得到zImage方法是用dd跳过前64字节dd ifuImage ofzImage.bin bs1 skip64实际操作中经常出现一个问题你自己转出来的uImageU-Boot加载后启动卡住。原因多半是mkimage命令里的-a和-e地址写错了。U-Boot的bootm流程是先把镜像数据从你指定的地址搬运到ih_load指定的地址然后跳到ih_ep执行。如果你给的-a是0x20008000但镜像实际是放在0x20008000执行的理论没问题但如果你写了-a 0x20008000 -e 0x30008000U-Boot会先去0x30008000找入口但镜像数据还被放在0x20008000入口处全是零或者垃圾指令自然起不来。尤其要注意zImage本身就带自解压逻辑它不在乎被放在哪个物理地址但uImage头的-a和-e必须和Bootloader实际放置镜像的位置保持逻辑一致。很多新手就是在这里被绕晕的。5. 生成链路全解从一件源码到三种镜像到底经过什么5.1 顶层Makefile与arch/*/boot下的产物编译内核时make默认会根据架构生成特定目标。ARM 32位上顶层目录看到的是vmlinuxarch/arm/boot/下会生成Image、zImage、uImage如果你配置了U-Boot目标。整个转换链路大概是vmlinux (ELF) - objcopy -O binary - arch/arm/boot/Image (纯二进制) - gzip - arch/arm/boot/compressed/piggy.gz - 与head.S等链接 - arch/arm/boot/zImage - mkimage加头 - arch/arm/boot/uImage对应的Makefile逻辑分散在arch/arm/boot/Makefile和arch/arm/boot/compressed/Makefile里。如果哪天你发现make uImage没有生成文件先别急着怀疑工具链直接看看这个目录下的Makefile是不是因为某些配置项短路了。我自己改过一段Makefile来自定义交叉编译时的压缩方式把gzip换成lzma。LZMA压缩率更高但解压速度慢、代码体积更大适合存储极小、CPU不差的场景。ARM的compressed Makefile里COMPRESSION相关的变量就是干这个的改它就能切换压缩算法。5.2 ARM64和x86的镜像形态差异到了ARM 64和x86上zImage和uImage这套玩法就变了。ARM64生成的镜像直接叫Image是一个未压缩的PE格式文件对你没看错内核和EFI有协议约定。U-Boot用booti命令加载Image而不是bootm。如果需要压缩生成Image.gzU-Boot支持在加载时解压但要看具体版本和配置。x86更简单编译完直接是bzImageBootloaderGRUB不需要什么解压辅助内核自己带着解压代码。vmlinux依然存在主要就是调试用。换句话说zImage/uImage这套是ARM 32位和U-Boot生态高度绑定的产物新架构上已经被更简洁的方案替代。理解这一点很重要因为很多为什么我照网上教程在ARM64上报错的问题答案就是那些教程写的都是ARM 32位U-Boot的旧玩法体系不匹配。5.3 符号表、调试信息与编译配置三种镜像形态差异背后还有一个隐藏话题编译配置。比如你调试内核时需要符号表# 开启调试信息和符号表相关的常用配置 CONFIG_DEBUG_INFOy CONFIG_DEBUG_INFO_DWARF4y CONFIG_KALLSYMSy CONFIG_KALLSYMS_ALLyKALLSYMS会把符号表编入内核镜像本身即使没有外部System.map也能从/proc/kallsyms读符号。但注意KALLSYMS_ALL开启后镜像会变大不少嵌入式产品一般会关掉只保留KALLSYMS的基础选项。调试场景里有个实用资料vmlinux的符号表可以帮助你直接在崩溃转储比如kdump的vmcore里解析调用栈。没有符号表的话crash工具解析出来的全是function0x??/0x??有vmlinux才能直接显示函数名和源码行号。所以我在排查内核crash问题时第一件事就是找一次编译留下的vmlinux和System.map要求它们和现场运行的内核版本完全一致差一个patch都不行。6. 这些镜像在真实场景里的选择与常见调试经验6.1 产品发布该选哪种镜像产品选型时主要看Bootloader能力和量产工具链。如果你的设备Bootloader是U-Boot且架构是ARM 32位那uImage几乎是最省事的选择因为U-Boot的bootm对它的支持最成熟从NAND、SD卡、网络tftp加载都有现成命令还能做CRC校验。如果Bootloader是自研的、代码在你手里建议直接上zImage甚至Image省掉U-Boot头这层Bootloader里跳转逻辑更简单。ARM64项目现在普遍用的是booti Image或Image.gzeMMC/NAND容量上去了不压缩的Image也能接受还能省去解压那几百毫秒。x86平台当然就是bzImage配合GRUB大家都很熟了。我维护过的产品里有两款板子至今还在用uImage原因就是产线烧录工具只认这个格式改格式牵动整个产线脚本和测试流程代价太大。技术和历史包袱的平衡是实际工程里躲不开的。6.2 调试内核崩溃时怎么用镜像里的符号信息前面说过一次崩溃现场需要vmlinux和System.map。我再细化一下完整流程。假设设备跑着Linux内核/proc/kallsyms没开或者被限制我们只拿到一份oops打印的原始寄存器值和调用栈地址。本地PC上准备好对应版本的vmlinux然后# 用gdb加载vmlinux把PC地址翻译成函数名和偏移 gdb vmlinux (gdb) info symbol 0xc0085c20这行命令会告诉你这个地址属于哪个函数以及相对函数开头的偏移量。接着用objdump反汇编vmlinux在偏移量附近看指令基本就能定位到崩溃点。更系统的做法是用crash工具分析vmcore前提是要有kdump配置。crash启动时加一个vmlinux参数它就能解析整个转储文件。我处理过一个kmalloc越界写导致的随机崩溃就是靠crash的kmem命令和vmlinux符号表一步步把释放对象的使用者找出来的。如果没有这个vmlinux那一堆十六进制地址基本等于天书。6.3 用户态与内核通信以及内核态调试的镜像配合热搜词里有一条Linux用户与内核通信这个问题和镜像调试其实有直接关系。用户态程序通过系统调用陷入内核glibc封装了接口普通开发者根本不需要关心内核镜像长什么样。但你一旦要调试为什么这个ioctl返回错误码或者为什么这个netlink消息没触发内核回调你就需要内核符号和调试手段。举例说明你想追踪某个系统调用的内核执行路径可以用perf、trace-cmd或者直接在内核函数上挂kprobe。kprobe按函数名注册比如# 在__x64_sys_openat上挂kprobe用tracefs echo p:myprobe __x64_sys_openat dfd%di filename%si flags%dx mode%cx /sys/kernel/debug/tracing/kprobe_events这种操作依赖kallsyms把函数名解析成地址如果镜像里符号表不全tracefs会报错找不到函数。所以做这类调试的内核编译时一定要开CONFIG_KALLSYMS_ALL。这个细节往往是别人的脚本能跑、我的脚本报错的根源。另外聊一下内核虚拟化场景。KVM调试中vmlinux符号表同样关键。看虚拟机内部内核崩溃时host上的trace-cmd要解析guest的符号信息需要guest的vmlinux作为辅助。perf kvm stat这类工具也需要符号表才能输出有意义的函数名。内核虚拟化听起来高深落到镜像层面其实还是调试符号、编译配置、版本匹配这三件事。6.4 网络加载内核镜像的常用姿势与常见翻车原因开发阶段最常干的事就是用TFTP从主机拉镜像到板子的DDR里跑。U-Boot命令一般是setenv serverip 192.168.1.10 setenv ipaddr 192.168.1.100 tftpboot 0x20008000 uImage bootm 0x20008000这个流程里最容易翻车的地方是误以为tftpboot的地址和uImage头里的-a地址必须完全一致。其实不一定。U-Boot tftpboot把文件放在0x20008000bootm拿到之后先检查魔数然后根据ih_load字段把数据再搬一次。如果你在mkimage里写-a 0x30008000U-Boot会从0x20008000把镜像搬到0x30008000再执行多一次拷贝慢一点但能跑。真正怕的是-a写的地址和加载地址在同一个区域且重叠错乱那就直接覆盖到一半起不来了。网络加载时还容易忽略一个事TFTP把文件放到某个地址前不会检查那块内存是不是已经被U-Boot自己占用。如果你把镜像tftp到U-Boot的堆栈区域镜像会覆盖掉U-Boot正在用的数据表现就是tftp显示传输完成然后bootm立刻死机。解决方法是选一个远离U-Boot镜像和堆栈的地址通常板子手册里都会给出推荐值。6.5 解开zImage里真正内核的方法最后补充一个实用的逆向小技巧怎么从一个zImage里提取出里面的vmlinux payload。前面提过用binwalk定位gzip流这里说说更精准的做法。arm的zImage里有一段解压函数会引用piggy_data的符号如果你有ELF版本的zImage有些构建会生成vmlinux格式的zImage可以直接用objdump找符号# 对带符号的zImage用nm找piggy_data nm zImage | grep piggy找到地址后从那个地址开始dump数据跳过gzip头再解压就得到纯二进制内核Image。这个方法在分析第三方固件时特别管用可以确认对方内核版本、编译选项甚至有没有打过私有patch。我拿到过一份厂商提供的zImage就是靠这个手段确认了它实际基于的内核版本然后才能匹配对应的调试符号和方法。至于uImage去掉64字节头再重复上面的流程即可。提醒一句mkimage加头时不会重算内部payload的校验所以直接dd去掉头部拿到的zImage一般和原始文件一致可以放心用。7. 关于调试环境与物理机内核调试的一点补充热搜词里还有一条两台实际物理机怎么调试Linux内核。这通常是做内核驱动开发或用KGDB做源码级调试的经典问题。方法是用串口或者网络把两台机器连起来目标机开KGDB主机跑gdb连接vmlinux和串口逻辑和单机调试类似。关键点有两个。第一目标机内核必须开CONFIG_KGDB、CONFIG_KGDB_SERIAL_CONSOLE等配置还要在内核启动参数里加kgdbocttyS0,115200 kgdbwait。第二gdb加载的vmlinux必须和目标机运行的内核是同一个版本、同一份配置编译出的否则符号对不上调试会得到错误结果。这个版本匹配的要求和我前面强调的vmlinux调试场景完全一致。物理机调试和虚拟机调试还有个区别物理机上如果内核死锁串口还通的话KGDB只有在你主动打断时才能接管如果整个内核完全死了往往只能靠硬件复位加大串口日志。所以日常排查普通问题时我更多用ftrace和trace-cmd而不是挂KGDB。KGDB更适合驱动开发时在某个函数里反复步进观察行为。根据我个人经验的话如果你想真正理解内核镜像这一块最推荐的做法不是光看这篇文章而是自己找一块便宜的ARM板完整走一遍编译内核、生成uImage、Tftp加载、启动、崩溃、拿vmlinux分析oops的闭环。这串动作做下来vmlinux、zImage、uImage在你心里就不再是三个抽象名词而是一条清晰流水线上的三个节点。等哪天你遇到内核起来了但跑飞了解压完成了但没输出这类疑难杂症你会感谢当初亲手折腾过这些镜像格式的自己。