从月初开始追一个 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 格式里通常是 0gen_init_cpio 没有启用 CRC 校验所以直接写 0。3.3 文件、目录、设备节点与符号链接的落盘逻辑不同记录类型的差别主要在头部之后的载荷dir写完头部和目录名后没有额外数据。file写完头部后紧接着从源文件读取内容并fwrite到输出文件内容也要做 4 字节对齐。nod头部里mode会带上设备文件类型位S_IFCHR或S_IFBLKrdevmajor/rdevminor记录设备号不需要额外载荷。slink符号链接的“内容”就是链接目标字符串源码里把目标字符串作为数据写入。pipeFIFO 模式也没有额外载荷。这里的关键点在于不同文件类型对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等定义所以最稳妥的方式是借助内核自身的 Makefilemake usr/gen_init_cpio编译完成后当前目录会出现usr/gen_init_cpio可执行文件。如果只想单独编译也可以直接用 gcc 手动指定内核头文件路径但没必要折腾。5.2 写一个最小 initramfs_list我在临时目录里写了下面这个minimal_listdir /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 先帮我拆骨架再逐段核对构建脚本里那些看起来莫名其妙的排序和过滤逻辑。内核源码里这种“小而完整”的工具很多对新手来说它们比宏大主题友好得多。
