写在最前面的话
这份文档包括了两类经验:
- 一类是系统重建过程中的工程经验
- 一类是RISC-V LFS / systemd 启动排障中的技术经验
如果只用一句话概括这次最大的收获,那就是:
真正困难的,从来不是把某个包“编过去”,而是把工具链、运行时、init、systemd、rootfs 和最终交付物这一整条链同时闭合。
1. 我重新理解了“系统构建”这件事
一开始很容易把这个任务理解成:
- 下载源码
- 按顺序编译
- 遇错修错
- 最后能进系统就算完成
但真正做下来以后,我更清楚地意识到,RISC-V From Scratch 这种题目的核心并不是“会编译很多包”,而是能不能同时维护下面这些层次之间的契约:
- 宿主环境
- 交叉工具链
- 最终 rootfs
- 动态链接器与运行时库
- 内核与
init systemd和最小用户态- QEMU 启动验证
- 可交付的镜像、校验值和说明文档
任意一层没有闭合,最后都可能表现成一句非常短的错误日志。
这让我真正理解了一个工程事实:
“能编出来”只说明你解决了构建问题;“能启动、能验证、能交付”才说明你解决了系统问题。
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. 启动问题必须按链路拆开看
这次真正提升效率的,不是某一个命令,而是把启动过程拆成清晰的层次:
- OpenSBI / 固件
- Linux 内核
- 块设备识别
- 根文件系统挂载
/sbin/init- 动态加载器
- 基础共享库
systemd私有共享库systemd模式判断- 最基本的登录和用户态服务
这意味着每次排障都应该回答下面这些问题:
- 根文件系统到底挂没挂成功?
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 AR、RANLIB、OBJCOPY、STRIP同样是工具链的一部分
很多时候,真正的问题并不是编译器错了,而是后处理工具偷偷落回了宿主版本。
学到的经验是:
交叉工具链必须整套看,不能只盯着 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 setup或configure阶段暴露 - 能在配置阶段解决的依赖,不要拖到运行时才发现
也就是说,配置阶段失败并不可怕,运行时失败才更昂贵。
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.solibc.so.6ld-linux-riscv64-lp64d.so.1
这三者角色完全不同:
- 一个更偏链接期入口
- 一个是运行期真实 SONAME
- 一个是 ELF interpreter
学到的经验是:
动态链接问题一定要分清“链接时名字”、“运行时名字”和“解释器路径”,不能混着理解。
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/initreadelf -l /sbin/init- 实际启动后的行为
6. 交叉构建里,最怕的不是编不过,而是“编出来了但装错了”
这次一个非常典型的教训是:
- 构建目录里有正确的 RISC-V
systemd - 但装进 rootfs 的主程序却带着不合理的运行时信息
这让我深刻记住:
构建产物、安装结果和镜像内最终运行文件,是三层不同对象,不能默认它们天然一致。
真正可靠的做法应该是分别验证:
- build dir 里生成了什么
$CLFS里实际装了什么riscv-lfs.img里最终跑的是什么
7. 宿主污染是交叉构建里最阴险的问题之一
这次排障过程中,一个非常重要的警报信号就是:
- 文件表面是目标架构 ELF
- 但里面带着不该出现的宿主路径或宿主假设
这让我以后会把下面几件事当成常规动作:
filereadelf -dstrings- 必要时用
qemu-riscv64-static -L $CLFS单独试跑目标程序
不要等到整机启动时,才第一次验证运行时自洽性。
8. 重建比修补更重要的,不是“快”,而是“可信”
这次还有一个非常工程化的教训:
- 当
glibc动态链接链路、systemd布局、目标 rootfs 状态已经被多轮修补搞乱以后 - 继续补,短期看似省事
- 但从长期看,系统会越来越不可解释
所以重建真正带来的价值,不只是“清空重来”,而是:
- 恢复 ABI 契约的可信性
- 恢复目录布局的可信性
- 恢复最终交付物的可验证性
学到的经验是:
一旦运行时基础层已经不可信,重建往往不是退步,而是最便宜的工程决策。
9. 最小可启动系统,不等于完整发行版
这次还有一个很深的体会: “最小可启动”是一个很明确的工程目标,不应该和“完整用户空间”混为一谈。
一个最小可启动系统至少要保证:
- 根文件系统可挂载
/sbin/init可执行- 动态链接链闭合
systemd能认出 system mode- 最基础的登录链路闭合
而它不一定一开始就需要:
- 完整桌面栈
- 所有附加功能
- 所有可选组件
这件事也直接影响后续策略:
- 先把最小启动链闭合
- 再补正式发行版该有的用户态契约
这样远比一开始什么都想要更稳。
10. 现代上游项目不是“给个 CC 就能交叉编译”
这次引入像 systemd、fastfetch 这类现代项目时,我重新理解了一个事实:
- 现代项目默认会探测很多功能
- 它们往往会连出一整串可选依赖
- 如果不主动裁掉无关能力,就会被依赖链拖住
所以真正的交叉构建经验不是:
- 给一个
CC=...
而是:
- 明确工具链
- 明确 sysroot
- 明确要保留的功能
- 明确要裁掉的可选项
学到的经验是:
现代软件的交叉构建,更像“配置产品功能边界”,而不只是“切换编译器”。
11. 可复现脚本比一次性手工修复更有价值
如果只是本机上临时修到能启动,短期当然更快。 但一旦涉及:
- rootfs 打包
- Image 验证
- SHA256
- 报告文档
- 重新生成和交付
手工修补就会迅速失去可控性。
所以这次我更加确定:
真正有价值的不是“手工修好一次”,而是把关键步骤沉淀成可以重复执行的流程。
这包括:
- 构建脚本
- 打包脚本
- 镜像同步步骤
- 启动验证命令
- 交付物生成过程
12. 最终交付物不应该是源码树,而应该是别人能直接验证的资产
这次让我更加明确地理解了“交付”的标准。
真正有价值的结果,不只是本地有一个目录能启动,而是应该能拿出一组别人也能复现的资产:
- 一个可发布的
rootfs压缩包 - 一个对应的
Image - 一个 SHA256 校验值
- 一份清晰的运行说明
- 一次从交付物本身出发的真实验证
也就是说,验收对象应该是:
- 从 release 资产出发能不能启动
而不是:
- 在开发机源码树里临时能不能跑
13. 这次真正沉淀下来的方法论
如果以后再遇到类似问题,我会优先按下面这个顺序排:
第一步:确认问题是不是已经进入用户态
看日志里有没有:
VFS: Mounted rootRun /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/init、systemd,一直到 rootfs 打包、镜像验证和最终交付,这整条链只要有一环没有闭合,最后都会体现在一个非常短的错误消息里。
而当我开始用“链路”和“契约”去理解这些错误时,原本模糊的“系统起不来”,就变成了一组可以逐层定位、逐层修复、最终还能沉淀成可复现流程的工程问题。