☰
Linux内核initramfs生成工具gen_init_cpio源码解析与AI辅助阅读
2026/9/26 6:28:11 网站建设 项目流程

从月初开始追一个 initramfs 生成异常的问题,内核构建日志里反复出现usr/gen_init_cpio.c这个小工具,我发现自己对它的认识几乎为零:只知道它能把一个“文件描述列表”打成 cpio 归档,但具体怎么打、为什么内核要自己维护一套而不是直接调系统的 cpio 命令,完全说不清。那阵子我正好在试 DeepSeek 辅助读内核源码,就顺手把这个文件整个丢给 AI 做精读,再一行行对着源码核验。

这一读收获很大。gen_init_cpio.c应该是内核构建链里最容易被忽略的“小角色”之一,但它的实现里藏着 newc 格式、设备号编码、硬链接复用、对齐填充等一堆扎实细节。我这次把完整的分析过程、验证方法和踩过的坑都整理成文,既是一次源码阅读记录,也算是给想用 AI 帮忙啃内核源码的人一个参考样例。

1. 从 initramfs 构建链理解 gen_init_cpio 的定位

1.1 构建链里的“最后一步”

Linux 内核把 initramfs 直接链接进内核镜像的机制,很多人只知道一个大概:内核里有个initramfs功能,编译时会把一个 cpio 格式的归档塞进.init.ramfs段,启动早期由内核自己解包并挂载为 rootfs。但这段归档从哪来?答案就在usr/目录。

在内核 6.19 的构建体系里,usr/目录下有一套专门生成 initramfs 的工具链。顶层 Makefile 会调用usr/gen_initramfs.sh脚本,这个脚本负责搜集要打进 initramfs 的文件清单,并生成一个文本描述文件。这个文本文件随后被喂给usr/gen_init_cpio——也就是由gen_init_cpio.c编译出来的可执行程序——最终转换成真正的二进制 cpio 归档。

所以gen_init_cpio.c承担的是 initramfs 构建链里“最后一步”的工作:把可读的、面向构建系统的文本描述,翻译成内核启动时能直接识别的二进制格式。

1.2 为什么内核非要自带一个 cpio 生成器

这是我最先问自己的问题:Linux 系统里明明有标准的cpio命令,为什么内核要费劲自己写一个生成器?

翻阅源码和构建脚本后,原因其实很清楚:

  • 内核构建环境可能没有 cpio 工具。交叉编译、最小化构建环境下,不能假设宿主机装了 GNU cpio。
  • 内核解包 initramfs 只支持newc格式,普通 cpio 命令输出的格式默认不一定是 newc,参数写错就会生成内核查不了的结构。
  • initramfs 的字段有一些内核特有要求,比如设备号需要用内核自己定义的方式编码,硬链接复用同一个 inode 编号等。用系统 cpio 很难精确控制这些字段。

与其依赖外部工具,不如在源码树里放一个几十 KB 的 C 程序自己干。这很符合内核“自举优先、减少外部依赖”的风格。

2. 我是怎么用 DeepSeek 做代码精读的

2.1 第一条提示词:先让它拆骨架

我没有一上来就要求 AI 逐行解释,而是先把完整源码贴进对话,让它给出“文件级”的骨架:主要函数列表、每个函数的大致职责、函数之间的调用关系。

这里有个经验:让 AI 读代码时,第一轮问“是什么”,不要问“为什么”。比如我先问的是:

请分析usr/gen_init_cpio.c,列出所有静态函数和 main 函数的职责、它们之间的调用关系,以及文件数据结构的整体流转。

DeepSeek 很快给出了一个调用链条:main -> parse -> cpio_mkdir / cpio_mkfile / cpio_mknod / cpio_mkslink / cpio_mkpipe -> cpio_trailer。这个骨架和我在源码里看到的基本一致,直接帮我节省了找入口函数的几分钟。

2.2 追问函数实现时,怎么问才不跑偏

骨架确认后,我针对每个函数单独追问。这个环节,提示词要具体到“字段”和“函数名”,不能泛泛问。如果只是问“cpio_mkfile 是怎么工作的?”,AI 容易给出笼统描述,甚至会混入其他项目里的印象。

我实际用的提问方式类似:

在usr/gen_init_cpio.c的cpio_mkfile函数里,newc 头部写入了哪些字段?namesize是否包含结尾的\0?文件内容写入后如何做 4 字节对齐?

把问题拆到这种粒度,AI 回答时就会对应到具体代码分支,我再逐个去源码里验证。

2.3 必须警惕的“一本正经胡说”

AI 读代码的坑也不少。最典型的问题是,它会“脑补”一些看起来对、但跟实际源码不符的细节。

最明显的一次,我问它 newc 头部里namesize的计算逻辑,它回答“通常不包含字符串结尾的 NUL,因为很多实现不会把终止符算进去”。但我在gen_init_cpio.c里核对的结果是strlen(name) + 1,结尾的\0明确包含在内。这个偏差会影响整个解析器如何读取文件名长度,性质很严重。

后来我又让 AI 解释device number的编码,它试图用通用的 Linux 设备号格式直接解释,结果和内核new_encode_dev的语义对不上。

所以我的原则是:AI 可以用来搭骨架、找线索、催生问题,但最终每一个结论都必须回到源码和官方文档验证。后文讲到的所有细节,都是手动逐行核对过的。

3. gen_init_cpio.c 核心逻辑拆解

3.1 入口 main:逐行读取与多级分发

这个程序的入口main做的事情非常线性:打开输入文件(或标准输入),用一个循环逐行调用解析函数,每一行对应 cpio 归档中的一条记录。

输入文件的每一行有固定语法,行首关键字决定记录类型。简单归纳如下表格:

记录类型语法示例生成目标
dirdir /bin 755 0 0目录
filefile /init /path/to/file 755 0 0普通文件
nodnod /dev/console 666 0 0 c 5 1字符/块设备节点
slinkslink /bin/sh busybox 777 0 0符号链接
pipepipe /pipe 644 0 0FIFO

解析器忽略空行和#开头注释。每个类型的分发逻辑就是一组字符串匹配与sscanf,并没有复杂的语法分析逻辑。main在解析完所有条目后,会调用cpio_trailer()写入归档结束标记。

3.2 头部生成:newc 格式的 110 字节

CPIO newc 格式的核心是每个文件记录前面的头部。所有字段都是固定 8 位 ASCII 十六进制,只有 magic 是 6 字节字符串,所以整个头固定 110 字节。

真实源码里的写入逻辑可以浓缩为这样一段等价代码:

static void write_cpio_header(const char *name, unsigned int ino, unsigned int mode, unsigned int uid, unsigned int gid, unsigned int nlink, unsigned int mtime, unsigned int filesize, unsigned int devmajor, unsigned int devminor, unsigned int rdevmajor, unsigned int rdevminor) { unsigned int namesize = strlen(name) + 1; fprintf(out, "%s%08X%08X%08X%08X%08X%08X%08X%08X%08X%08X%08X%08X%08X", "070701", ino, mode, uid, gid, nlink, mtime, filesize, devmajor, devminor, rdevmajor, rdevminor, namesize, 0); fwrite(name, namesize, 1, out); pad_align(namesize); }

注意字段顺序:magic - ino - mode - uid - gid - nlink - mtime - filesize - devmajor - devminor - rdevmajor - rdevminor - namesize - check。check字段在 newc 格式里通常是 0,gen_init_cpio 没有启用 CRC 校验,所以直接写 0。

3.3 文件、目录、设备节点与符号链接的落盘逻辑

不同记录类型的差别主要在头部之后的载荷:

  • dir:写完头部和目录名后,没有额外数据。
  • file:写完头部后,紧接着从源文件读取内容并fwrite到输出,文件内容也要做 4 字节对齐。
  • nod:头部里mode会带上设备文件类型位(S_IFCHR或S_IFBLK),rdevmajor/rdevminor记录设备号,不需要额外载荷。
  • slink:符号链接的“内容”就是链接目标字符串,源码里把目标字符串作为数据写入。
  • pipe:FIFO 模式,也没有额外载荷。

这里的关键点在于,不同文件类型对mode字段的构造方式不同。设备和 FIFO 需要在普通权限位上叠加S_IFCHR、S_IFBLK、S_IFIFO等文件类型位。文件读取后会调用stat获取源文件内容大小,再填进头部的filesize。

3.4 inode 与硬链接:比想象中简单

gen_init_cpio.c维护一个全局的ino计数器,每生成一条新记录就把当前编号填进头部,然后递增。它不需要猜测磁盘上真实 inode,因为 cpio 解包时只要求同一个归档内硬链接条目 inode 相同即可。

真正处理硬链接的地方在cpio_mkfile附近。如果同一个location被多次file指令引用,解析层会为它分配同一个ino,并增加该 inode 的nlink。后面再次遇到时,头部nlink更新,但不再重复写入文件内容,filesize置为 0,成为一个“只有头部没有数据”的硬链接条目。

这个机制值得圈出来:newc 格式的硬链接,靠的是多个条目共享ino、nlink大于 1,且除第一个条目外数据长度为零。和真实文件系统里的 inode 硬链接是一个思路,只不过是“扁平化”到归档里的。

4. 这些实现细节,盲读代码很容易漏掉

4.1 4 字节对齐是硬约束

newc 格式规定,文件名之后、文件数据之后,都要填充到 4 字节边界。填充用什么值?源码里用的是\0。

这个要求并不只是“为了对齐而对齐”。内核在解包 initramfs 时,会按照头部声明的namesize和filesize跳过数据块,并期望下一条记录在 4 字节对齐位置开始。如果不做填充,内核读到的下一个 magic 就是错位的,直接导致解包失败。

实现上这个填充逻辑非常直白:

static void pad_align(unsigned int len) { unsigned int pad = (4 - (len % 4)) % 4; while (pad--) fputc(0, out); }

读代码时觉得这是小事,实际排查我看到过很多人都栽在“cpio 文件为什么内核不认”上,最后发现就是手动生成归档时没做 4 字节对齐。

4.2 设备号要过一道编码

nod指令里给出的是maj和min两个十进制数字,但在 cpio 头部写入设备号时,gen_init_cpio.c并没有简单地把 major 放到一个字节、minor 放到另一个字节。它调用的是内核提供的设备号编码函数,把构造出来的dev_t转成 cpio 字段里能放的 32 位整数。

这一层为什么不能省?因为内核内部表示设备号并不是简单的major * 256 + minor。不同架构、不同内核对次设备号位数的定义不同。new_encode_dev这类接口会按内核当前使用的编码规则做转换,保证解包时内核能正确解析回原来的设备节点。

如果盲读代码不理解这层,自己写一个替代工具时很容易直接major << 8 | minor,做出来的 initramfs 在特定内核版本上会出现设备号错乱。

4.3 结尾的 TRAILER!!! 不是装饰

cpio 归档的结束标记是一条文件名为TRAILER!!!的特殊记录。内核解包 parse 完这条记录后才知道归档结束。

gen_init_cpio.c里的cpio_trailer()会为这个特殊 name 写一条头部记录,nlink为 1,其他字段基本为空。如果生成工具漏掉这条,内核在解包时会尝试继续读下一块数据,轻则告警,重则挂载 rootfs 失败。

4.4 目录顺序与父目录问题

config文件里行的先后顺序直接影响 cpio 归档中条目的顺序。如果先写/bin/busybox的file条目,后写/bin的dir条目,内核查到/bin/busybox时父目录/bin可能还不存在。

实际测试中我发现,标准流程里的gen_initramfs.sh会先把所有需要创建的目录聚在一起,再输出文件、设备节点等内容。这其实是为了兼容内核解包逻辑中“父目录必须存在”的要求。

自己手写 initramfs_list 时最容易忽略的就是这点。我建议按“先 dir,再 slink/nod/pipe,最后 file”的顺序组织描述文件,避免踩坑。

5. 亲手跑一遍:编译、生成、解包验证

理论再多,不如亲手跑一遍。我在 6.19 源码树里做了个最小实验,全程可以复现。

5.1 在 6.19 源码树里编译这个小工具

单独编译gen_init_cpio.c不难,但它在完整源码树里依赖内核头文件里的new_encode_dev等定义,所以最稳妥的方式是借助内核自身的 Makefile:

make usr/gen_init_cpio

编译完成后,当前目录会出现usr/gen_init_cpio可执行文件。如果只想单独编译,也可以直接用 gcc 手动指定内核头文件路径,但没必要折腾。

5.2 写一个最小 initramfs_list

我在临时目录里写了下面这个minimal_list:

dir /bin 755 0 0 dir /dev 755 0 0 file /init /home/user/minimal-init 755 0 0 nod /dev/console 666 0 0 c 5 1 slink /bin/sh /init 777 0 0

/home/user/minimal-init是一个简单的#!/bin/sh脚本,内容可以是echo "hello initramfs"。

然后生成归档:

./usr/gen_init_cpio minimal_list > minimal.cpio

这里重点关注二进制输出。可以用xxd minimal.cpio | head -20查看头部,能看到文档里描述的070701开头和一连串 ASCII 十六进制字段。

5.3 解包验证与预期结果

验证生成结果是否合法,最直接的方式是用系统 cpio 解包:

mkdir -p out cd out cpio -t < ../minimal.cpio

正常输出应该完整列出:

. bin bin/sh dev dev/console init

再用cpio -id < ../minimal.cpio实际解包,然后ls -lR查看权限,会发现/dev/console按配置变成了字符设备节点,/bin/sh是符号链接,/init是可执行脚本。这说明 gen_init_cpio 生成的权限、类型都正确。

如果我把nod行里的设备类型改成错误的格式,比如漏掉c参数,gen_init_cpio 会直接报错退出。这种“白盒生成”方式比起手写二进制 cpio 要可读得多,也更容易排查。

6. 用 AI 啃内核源码的几条个人心得

6.1 AI 适合搭骨架,不适合背细节

这次用 DeepSeek 分析gen_init_cpio.c,我最深的体会就是:AI 的强项在于快速把整个文件的结构和调用链抽出来,相当于给我画了一张“地图”;但地图上每一个具体标记点,还需要自己亲脚下到现场核对。

特别是对于内核代码,宏定义、架构分支、历史兼容逻辑太多,AI 经常会把别的版本或者别的项目里的实现混进来。我的做法是:让 AI 给我线索,让我有地方下嘴;但最终写进文章、写进结论的每一个数值和逻辑,都手动打开代码验证一遍。

6.2 一个问到底的循环:从入口到文档

如果把 AI 当成“高级搜索框”也不够,要形成一个问题循环。我先问整体,再问单个函数,再问格式规范,再回到源码逐行核对。每一步产出的疑问,比如“偏移量 110 字节到底是怎么算的”“ namesize 为什么不等于文件名字面长度”,最终都指向了内核文档和 CPIO 规范。

对新读者来说,这个循环可能比答案本身更重要。因为它会逼着你去读Documentation/earlycpio.rst、去读init/initramfs.c,才能真正把一个点吃透。

6.3 把 gen_init_cpio 当作读内核源码的起点

如果你也是第一次认真读内核源码,我不建议从 VFS、进程调度那类大部头开始。像usr/gen_init_cpio.c这种几十 KB、逻辑集中、外部依赖少的文件,反而是很好的起点。

读完这个文件后,你已经掌握了 cpio newc 格式、initramfs 的构建位置、内核解包的前置规则。接下来再去看init/initramfs.c的解包实现,会顺畅很多;去看gen_initramfs.sh也看得懂了。

我下一步的计划是带着同样的问题意识,继续把usr/gen_initramfs.sh从头到尾过一遍,这次依然会让 DeepSeek 先帮我拆骨架,再逐段核对构建脚本里那些看起来莫名其妙的排序和过滤逻辑。内核源码里这种“小而完整”的工具很多,对新手来说,它们比宏大主题友好得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询