写在最前面的话

我不擅长写文档。以下内容主要由 ChatGPT 生成,包含初级构建和再构建过程中产生的一些问题,我的感想以这样的提示框方式补充。


这份文档包括了两类经验:

  • 一类是系统重建过程中的工程经验
  • 一类是RISC-V LFS / systemd 启动排障中的技术经验

如果只用一句话概括这次最大的收获,那就是:

真正困难的,从来不是把某个包“编过去”,而是把工具链、运行时、initsystemd、rootfs 和最终交付物这一整条链同时闭合。

1. 我重新理解了“系统构建”这件事

一开始很容易把这个任务理解成:

  • 下载源码
  • 按顺序编译
  • 遇错修错
  • 最后能进系统就算完成

然后编译完所有东西后就发现进不去系统了

但真正做下来以后,我更清楚地意识到,RISC-V From Scratch 这种题目的核心并不是“会编译很多包”,而是能不能同时维护下面这些层次之间的契约:

  1. 宿主环境
  2. 交叉工具链
  3. 最终 rootfs
  4. 动态链接器与运行时库
  5. 内核与 init
  6. systemd 和最小用户态
  7. QEMU 启动验证
  8. 可交付的镜像、校验值和说明文档

任意一层没有闭合,最后都可能表现成一句非常短的错误日志。

这让我真正理解了一个工程事实:

“能编出来”只说明你解决了构建问题;“能启动、能验证、能交付”才说明你解决了系统问题。

2. “Kernel panic” 往往只是结果,不是根因

这次最典型、也最有迷惑性的日志是:

Run /sbin/init as init process
Can't run system mode unless PID 1.
Kernel panic - not syncing: Attempted to kill init!

最以为是启动方式或者参数有问题,但仔细查看后发现很多依赖是宿主机的。

这类报错很容易让人第一反应去怀疑:

  • 内核配置不对
  • QEMU 参数错了
  • RISC-V 平台启动方式有问题

但真正应该意识到的是:

  • 一旦日志已经出现 Run /sbin/init as init process
  • 内核已经把控制权交给了用户态
  • 后面的 panic 本质上就是“PID 1 没活下去”

也就是说,这种 panic 的真实含义通常是:

  • 根文件系统已经挂载成功
  • init 已经开始执行
  • 但最早期用户态链路没有闭合

所以看到 panic 时,最先要查的不是“内核为什么 panic”,而是:

  • /sbin/init 指向的到底是什么
  • 动态加载器是否存在
  • libc.so / libc.so.6 是否完整
  • systemd 自身依赖是否闭合
  • 程序是否因为参数、入口名或模式判断失败而退出

这次排障让我建立了一个非常实用的判断原则:

如果日志已经走到 Run /sbin/init as init process,后面就应该优先按用户态问题来查,而不是继续盲猜内核。

3. 启动问题必须按链路拆开看

这次真正提升效率的,不是某一个命令,而是把启动过程拆成清晰的层次:

  1. OpenSBI / 固件
  2. Linux 内核
  3. 块设备识别
  4. 根文件系统挂载
  5. /sbin/init
  6. 动态加载器
  7. 基础共享库
  8. systemd 私有共享库
  9. systemd 模式判断
  10. 最基本的登录和用户态服务

这意味着每次排障都应该回答下面这些问题:

  • 根文件系统到底挂没挂成功?
  • init 有没有被执行?
  • 它死在 execve() 之前还是之后?
  • 它是缺库死掉,还是逻辑判断失败?
  • 现在看到的错误来自内核、loader,还是 systemd 本身?

这套拆分方式最大的价值,在于把模糊的“系统起不来”变成了可验证的问题序列。

一定要分开考虑,不然都看不出哪里炸了。

4. 真实踩过的坑,以及每个坑教会我的事

很多问题都是宿主污染导致的次生问题。

4.1 FATAL ERROR: attr/error_context.h does not exist.

这是 acl 构建阶段遇到的典型依赖错误。它表面上像是 acl 包自身有问题,但本质上是:

  • attr 没有先装好
  • 头文件没有正确进入 $CLFS/usr/include

这让我真正记住了:

  • 交叉构建里,前置头文件的安装顺序极其重要
  • “configure 能跑”不代表依赖真的满足了
  • 遇到 *.h does not exist 这类错误时,优先检查前置包是否真的进入 sysroot

首次构建没注意构建顺序产生了很多这样的报错。

学到的经验是:

在交叉环境里,包与包之间最先暴露出来的,往往不是链接问题,而是 sysroot 中头文件和 .pc 文件是否齐全。

4.2 qemu-riscv64: Could not open '/lib/ld-linux-riscv64-lp64d.so.1'

这是 libcap 构建阶段暴露出来的问题。根因是:

  • 构建系统编出了一个目标架构程序
  • 然后立刻试图在宿主环境中执行它
  • 但运行时既没有正确的目标 loader,也没有正确的 sysroot 映射

这让我明白:

  • 交叉构建里,最危险的一类包就是“编完目标程序后立刻执行目标程序”的包
  • 此时必须清楚区分“宿主执行环境”和“目标执行环境”
  • qemu-riscv64-static -L $CLFS 不是可选技巧,而经常是必要条件

4.3 objcopy: Unable to recognise the format of the input file 'empty'

这个错误的价值在于,它提醒我:

  • 交叉构建不是只看 CC
  • ARRANLIBOBJCOPYSTRIP 同样是工具链的一部分

很多时候,真正的问题并不是编译器错了,而是后处理工具偷偷落回了宿主版本。

学到的经验是:

交叉工具链必须整套看,不能只盯着 gcc

回落宿主版本是真的坑。

4.4 Unknown options: "doc"

这是 systemd 的 Meson 配置阶段出现的错误。它没有教会我某个单独的参数,而是教会我:

  • 构建参数会随版本变化
  • 文档和博客里的旧选项不一定还能用
  • 看到“Unknown options”时,正确做法不是继续瞎试,而是回到当前版本的配置语义

学到的经验是:

构建错误里,越“明确指出不认识哪个参数”的报错,越应该优先相信它本身,而不是继续猜别的依赖。

4.5 Pkg-config for machine host machine not found

这个问题非常关键,因为它明确暴露了一个交叉构建里经常被忽略的事实:

  • 构建阶段要运行的工具,必须是宿主机可执行的
  • 目标系统里的 pkgconf 是装给目标系统用的,不是拿来给 Meson 配置阶段直接跑的

这让我更清楚地分开了两类东西:

  • 宿主侧构建工具
  • 目标侧库和头文件

学到的经验是:

不要把“装进目标系统的工具”和“用于宿主构建流程的工具”混为一谈。

4.6 C shared or static library 'crypt' not found

这类错误反而是比较“健康”的,因为它明确告诉你:

  • 缺的是什么
  • 需要补哪个包

从这个错误中我学到的是:

  • 最好让依赖问题在 meson setupconfigure 阶段暴露
  • 能在配置阶段解决的依赖,不要拖到运行时才发现

也就是说,配置阶段失败并不可怕,运行时失败才更昂贵。

4.7 static assertion failed: "__NR_xxx == systemd_NR_xxx"

这是整个 systemd 阶段最复杂的兼容性问题之一。它提醒我:

  • 有些错误不一定意味着“目标系统真的错了”
  • 也可能是上游对交叉编译环境做了很强的前提假设

这个坑让我明白:

  • 交叉环境与上游默认假设之间的冲突,往往需要最小 patch 来跨过去
  • 不是所有问题都适合“纯配置解决”

学到的经验是:

当明确识别出是交叉环境兼容性问题时,小而可控的 patch 往往比硬耗更工程化。

4.8 libc.so, needed by .../lib/libmount.so.1, not found

这是一个特别容易误判的错误。因为表面上看:

  • libc.so.6 明明存在

但链接器仍然会抱怨 libc.so 找不到。它真正教会我的是:

  • libc.so
  • libc.so.6
  • ld-linux-riscv64-lp64d.so.1

这三者角色完全不同:

  • 一个更偏链接期入口
  • 一个是运行期真实 SONAME
  • 一个是 ELF interpreter

学到的经验是:

动态链接问题一定要分清“链接时名字”、“运行时名字”和“解释器路径”,不能混着理解。

首次编译这里直接链接过去了,然后 boom!

4.9 Some ROM regions are overlapping

这是把整个 $CLFS 都打进 initramfs 之后出现的问题。根因非常典型:

  • sources/
  • cross-tools/
  • tools/

这些完全不属于最终 rootfs 的内容也塞进去了,导致 initramfs 巨大到和 FDT 地址空间重叠。

这件事让我彻底记住:

构建目录不是 rootfs,源码目录更不是 rootfs。

学到的经验是:

  • sources/ 绝对不该进最终系统
  • cross-tools/ 绝对不该进最终系统
  • 临时工具和构建残留都应该和交付物分离

4.10 /sbin/init: error while loading shared libraries: libc.so: cannot open shared object file

这是第一次非常明确地告诉我:

  • 根文件系统已经挂载成功
  • 内核已经开始执行 init
  • 问题已经进入用户态运行时阶段

从这里我真正学到的是:

一旦报错明确落在 shared libraries,就应该果断停止怀疑内核,把排障重心切换到 loader、库和目录布局。

4.11 Explicit --user argument required to run as user manager.

这个错误的难点在于,它让问题从“文件缺失”升级成了“程序行为语义错误”:

  • 二进制存在
  • 依赖也大体齐了
  • 程序也已经被执行

systemd 却没有把自己认成 system manager。

这让我真正意识到:

/sbin/init 不只是一个路径入口,它还是一个运行语义入口。

也就是说,后面必须继续关心:

  • argv[0] 是什么
  • 是否显式走了 --system
  • 程序是如何判断自己运行模式的

4.12 Can't run system mode unless PID 1.

这是整个排障里最有代表性的一个错误。它说明:

  • 程序已经能加载
  • 依赖基本闭合
  • 问题收缩到:它在自己的判断里不认为自己是一个合格的 system-mode PID 1

这让我学到一个非常重要的经验:

“被内核当作 PID 1 启动”和“程序自己认定自己处于 system mode”不是一回事。

对于 systemd 这种程序,还必须进一步确认:

  • 它如何读取 PID
  • 它如何读取入口名
  • 它如何解析参数

4.13 No working init found.

这类日志说明内核已经开始进入 fallback 搜索:

Run /sbin/init as init process
Run /etc/init as init process
Run /bin/init as init process
Run /bin/sh as init process
Kernel panic - not syncing: No working init found.

这并不意味着“系统完全坏了”,而是意味着:

  • 内核已经试过了多个标准 init 入口
  • 但它们都不可执行或执行失败

学到的经验是:

  • No working init found 这种日志一定要优先查 /sbin/init 的类型和可执行性
  • 不要把它当成“玄学启动失败”

5. /sbin/init 不是一个“只要指过去就行”的位置

这次让我彻底改变看法的一个点,就是 /sbin/init

过去很容易把它当成一个机械的符号链接入口,但这次真正学到的是,它实际上是整个系统最敏感的位置之一。你必须确认:

  • 它是不是 ELF
  • 它是不是脚本
  • 内核能不能把它当作 PID 1 执行
  • 它最终 exec 的目标是谁
  • 它有没有依赖 argv[0]
  • 它有没有依赖显式参数

所以今后遇到类似问题时,ls -l /sbin/init 只能算第一步,后面至少还应该继续看:

  • file /sbin/init
  • readelf -l /sbin/init
  • 实际启动后的行为

6. 交叉构建里,最怕的不是编不过,而是“编出来了但装错了”

这也是初次构建过程中的一个大问题

这次一个非常典型的教训是:

  • 构建目录里有正确的 RISC-V systemd
  • 但装进 rootfs 的主程序却带着不合理的运行时信息

这让我深刻记住:

构建产物、安装结果和镜像内最终运行文件,是三层不同对象,不能默认它们天然一致。

真正可靠的做法应该是分别验证:

  • build dir 里生成了什么
  • $CLFS 里实际装了什么
  • riscv-lfs.img 里最终跑的是什么

7. 宿主污染是交叉构建里最阴险的问题之一

这次排障过程中,一个非常重要的警报信号就是:

  • 文件表面是目标架构 ELF
  • 但里面带着不该出现的宿主路径或宿主假设

这让我以后会把下面几件事当成常规动作:

  • file
  • readelf -d
  • strings
  • 必要时用 qemu-riscv64-static -L $CLFS 单独试跑目标程序

不要等到整机启动时,才第一次验证运行时自洽性。

8. 重建比修补更重要的,不是“快”,而是“可信”

初次构建开始时以为是小问题,改改就能运行了,然后发现越改问题越多

这次还有一个非常工程化的教训:

  • glibc 动态链接链路、systemd 布局、目标 rootfs 状态已经被多轮修补搞乱以后
  • 继续补,短期看似省事
  • 但从长期看,系统会越来越不可解释

所以重建真正带来的价值,不只是“清空重来”,而是:

  • 恢复 ABI 契约的可信性
  • 恢复目录布局的可信性
  • 恢复最终交付物的可验证性

学到的经验是:

一旦运行时基础层已经不可信,重建往往不是退步,而是最便宜的工程决策。

9. 最小可启动系统,不等于完整发行版

初次构建时装了很多非必要的软件,增加了调试难度也增加了错误发生的概率,应该先创建最小可启动系统再去逐步增加其他软件

这次还有一个很深的体会: “最小可启动”是一个很明确的工程目标,不应该和“完整用户空间”混为一谈。

一个最小可启动系统至少要保证:

  • 根文件系统可挂载
  • /sbin/init 可执行
  • 动态链接链闭合
  • systemd 能认出 system mode
  • 最基础的登录链路闭合

而它不一定一开始就需要:

  • 完整桌面栈
  • 所有附加功能
  • 所有可选组件

这件事也直接影响后续策略:

  • 先把最小启动链闭合
  • 再补正式发行版该有的用户态契约

这样远比一开始什么都想要更稳。

10. 现代上游项目不是“给个 CC 就能交叉编译”

这次引入像 systemdfastfetch 这类现代项目时,我重新理解了一个事实:

  • 现代项目默认会探测很多功能
  • 它们往往会连出一整串可选依赖
  • 如果不主动裁掉无关能力,就会被依赖链拖住

所以真正的交叉构建经验不是:

  • 给一个 CC=...

而是:

  • 明确工具链
  • 明确 sysroot
  • 明确要保留的功能
  • 明确要裁掉的可选项

学到的经验是:

现代软件的交叉构建,更像“配置产品功能边界”,而不只是“切换编译器”。

11. 可复现脚本比一次性手工修复更有价值

把构建脚本记录下来能节约出现错误后的重建时间,减少重复劳动

如果只是本机上临时修到能启动,短期当然更快。 但一旦涉及:

  • rootfs 打包
  • Image 验证
  • SHA256
  • 报告文档
  • 重新生成和交付

手工修补就会迅速失去可控性。

所以这次我更加确定:

真正有价值的不是“手工修好一次”,而是把关键步骤沉淀成可以重复执行的流程。

这包括:

  • 构建脚本
  • 打包脚本
  • 镜像同步步骤
  • 启动验证命令
  • 交付物生成过程

12. 最终交付物不应该是源码树,而应该是别人能直接验证的资产

这次让我更加明确地理解了“交付”的标准。

真正有价值的结果,不只是本地有一个目录能启动,而是应该能拿出一组别人也能复现的资产:

  • 一个可发布的 rootfs 压缩包
  • 一个对应的 Image
  • 一个 SHA256 校验值
  • 一份清晰的运行说明
  • 一次从交付物本身出发的真实验证

也就是说,验收对象应该是:

  • 从 release 资产出发能不能启动

而不是:

  • 在开发机源码树里临时能不能跑

13. 这次真正沉淀下来的方法论

如果以后再遇到类似问题,我会优先按下面这个顺序排:

第一步:确认问题是不是已经进入用户态

看日志里有没有:

  • VFS: Mounted root
  • Run /sbin/init as init process

只要有,就先别继续怀疑内核。

第二步:确认 init 死在哪一层

常见分法是:

  • 死在解释器层
  • 死在基础共享库层
  • 死在私有共享库层
  • 死在参数 / 入口名 / 模式判断层

第三步:把 build dir、rootfs、镜像三者分开验证

因为这三者不一定一致。

第四步:把“系统起不来”翻译成“哪一层契约没闭合”

例如:

  • No working init found不是“系统没救了”,而是“内核试过的 init 都不可执行”
  • Attempted to kill init不是“内核莫名其妙 panic”,而是“PID 1 死了”
  • Can't run system mode unless PID 1不是“systemd 完全坏了”,而是“它没有认出自己当前处于 system mode”

结语

如果要用一句话概括这次真正学到的东西,那就是:

系统构建的本质,不是把很多软件编出来,而是保证每一层之间的契约都成立。

从交叉工具链、动态链接器、共享库、/sbin/initsystemd,一直到 rootfs 打包、镜像验证和最终交付,这整条链只要有一环没有闭合,最后都会体现在一个非常短的错误消息里。

而当我开始用“链路”和“契约”去理解这些错误时,原本模糊的“系统起不来”,就变成了一组可以逐层定位、逐层修复、最终还能沉淀成可复现流程的工程问题。

最后修改:2026 年 03 月 20 日
如果你觉得内容对你有帮助,欢迎请我喝一杯咖啡☕,你的支持是我持续创作的动力