本文不是教程
本文记录的是一次从零拼 RootFS 的过程。
它不保证路线最优,不保证命令可复制,不保证作者精神状态稳定。
如果你需要稳定方案,请使用 Alpine。 如果你需要痛苦,请继续阅读。
嗨呀,暂时回来写点东西了。
很多人问过我:为什么不直接上 Alpine 这种现成的极简发行版?或者老老实实跟着 LFS 教程走?
答案很简单:我想体验那种“除了我自己,没有任何人知道这个系统为什么能跑起来”的掌控感。
当然,这种掌控感通常在构建的前 48 小时里就会被 undefined reference 和 segmentation fault 击得粉碎。构建一个 RootFS 本质上是一场关于“依赖地狱”的考古挖掘,你以为自己在搭房子,实际上在给一个极其挑剔的编译器写情书,祈祷它这次能正确理解你的 --sysroot。
这不是一篇严谨的教程,更像是一次技术考古的杂记。我想从零拼一个能启动、能登录的 Linux 系统,不用现成发行版,而是一块砖一块砖地捡起那些年头久远、各有脾气的零件。下面我就好好聊聊这些零件背后的历史、它们之间的路线争议,以及我在拼凑时撞上的几堵墙。
出发前的“三块地”
先给宿主系统划出三块实验田:
mkdir rootfs source workspace
source 放源码包,workspace 放解压后的构建目录,rootfs 则是最终要打包进目标系统的成品。 这件事本身毫无戏剧性,但它至少能避免一种经典惨剧:你以为自己在安装到目标系统,实际上正在污染宿主机。
内核:用户态接口不能随便动
先从内核开始。拖一个 7.x:
wget https://cdn.kernel.org/pub/linux/kernel/v7.x/linux-7.0.10.tar.xz
内核本身当然一直在变,但它承载的很多用户态接口能一直追溯到 1990 年代。我们要做的第一件事不是编译完整内核,而是导出用户态需要的“契约”——头文件:
export SYSROOT=$HOME/Projects/lfz/rootfs
cd workspace/linux-7.0.10
make defconfig
make -j18
make headers_install INSTALL_HDR_PATH=$SYSROOT/usr
make headers_install 导出的不是内核源码,而是 Linux 对用户空间暴露的 syscall 常量、结构体定义。这套头文件是 libc 与内核对话的合同。
这里有一个 Linux 世界非常朴素、但又非常硬的规矩:不要破坏用户空间。内核内部可以大改,调度器可以换,文件系统可以长出新的能力,但已经暴露给用户态的 ABI 不能随便变。否则十年前编译的程序今天突然跑不起来,用户不会说“内核开发者有架构洁癖”,用户只会说“你把我的系统弄炸了”。
所以第一步不是把内核源码塞进 rootfs,而是拿到这份边界。libc 后面会基于它提供更友好的 C 接口,用户程序也不应该直接依赖内核内部头文件。
受难记录:headers
我以为:编完内核就可以开始搞用户态。
实际上:用户态首先需要的是 UAPI headers。
判词:不是所有头文件都能 include。内核内部头文件尤其不能。
musl:不是更小的 glibc
Libc 是用户态程序的根基。主流选择是 glibc,可我挑了 musl。
glibc 是 GNU/Linux 世界里的主流 libc,功能完整,兼容性强,也承载了大量历史行为。它不是不好,它是太像“默认世界”了:很多软件没有显式说自己依赖 glibc,但配置脚本、宏判断、链接参数里到处都是对 glibc 世界的默认假设。
musl 不是“更小的 glibc”。它更像是从 C/POSIX 标准出发,重新回答“一个 libc 到底应该提供什么”。它代码量更小,静态链接友好,运行时依赖少,对小系统和容器镜像很有吸引力。代价也很明显:当软件偷偷依赖 glibc 扩展、或者依赖某些历史兼容行为时,musl 往往不会假装没看见。
这就是我想要的东西:第一轮 RootFS 不需要一整套发行版遗产,只需要一套边界清楚的 C 运行时。
cd ../musl-1.2.6
./configure --prefix=/usr
make -j18
make DESTDIR=$SYSROOT install
这里有个很妙的 DESTDIR 技巧:--prefix=/usr 是目标系统中的理想位置,而 DESTDIR=$SYSROOT 则是宿主机上的安装根。安装完后,rootfs/usr/lib 里便有了 musl 的共享库,rootfs/usr/bin/musl-clang 则是一个包装脚本,后续编译时直接用它,编译器就会自动使用 musl 的头文件和库,不用你每次手写 --target 和 --sysroot。
受难记录:musl
我以为:有了 clang,再加一个 --sysroot 就结束了。
实际上:crt objects、默认库路径、动态链接器路径,一个都不能少。
判词:sysroot 不是目录名,是世界观。
toybox:不是 BusyBox 青春版
有了 libc,系统还是不能用。你需要 ls、mkdir、mount 这些基本命令。传统上这活儿是 BusyBox 的天下,我偏选了 toybox。
toybox 的作者 Rob Landley 曾是 BusyBox 的维护者之一,后来重新做了一套许可证更宽松的基础命令集合。它和 Android 用户态关系很深,Android 从老的 toolbox 迁移到 toybox 后,toybox 就不再只是“名字听起来很玩具”的项目,而是一个真实系统里用过的用户态工具集。
这也是它吸引我的地方:一个二进制里塞进大量基础命令,适合最小 rootfs。听起来很美好。注意,是“听起来”。
export CC=$SYSROOT/usr/bin/musl-clang
cd ../toybox-0.8.13
make defconfig
./configure
make -j18
make install DESTDIR=$SYSROOT
然而,在这里我必须给所有试图走这条路的后辈敲响警钟:千万不要被 toybox 的组件清单给骗了。
我天真地以为 toybox 是一个可以完全替代 BusyBox 的全能工具箱。可当我试图调用它的 sh、awk,甚至是 getty 和 init 时,我才发现自己掉进了一个巨大的陷阱——这些组件处于一种极其诡异的“搁置状态”,实际上它们要么是空壳,要么是个半成品。
这就很符合软件工程的基本规律:列表里有,不代表能用;能编过,不代表能承担系统启动链路;命令叫 init,不代表它真的应该当 PID 1。不是每一种牛奶都叫特仑苏
受难记录:toybox
我以为:一个 toybox 解决所有基础命令。
实际上:ls 很好,sh/awk/getty/init 另说。
判词:又不是不能用,但不要让半成品守国门。
被迫开启的支线任务
mksh:shell 也是系统体验
有了命令,还得有一个能交互的 shell。选 shell 这事基本上决定了你在系统里的存活体验。
mksh 是 MirBSD Korn Shell,是 Korn Shell(ksh)这一脉的实现。Korn Shell 当年本来就是对 Bourne shell 的扩展:保留 shell 脚本的基本语法,同时补上更好的交互体验和脚本能力。mksh 又继承了 pdksh 的路线,主打可移植、轻量、够用。
这类东西很容易被低估。因为 shell 看起来只是一个命令提示符,但当系统还没有包管理器、没有编辑器、没有完整用户态时,它就是你和系统之间最主要的交互界面。一个不顺手的 shell,会让每一次调试都变成额外惩罚。
cd ../mksh
export CC=$SYSROOT/usr/bin/musl-clang
chmod +x Build.sh
./Build.sh
mkdir -p $SYSROOT/bin $SYSROOT/etc
install -c -s -m 555 mksh $SYSROOT/bin/mksh
echo /bin/mksh >> $SYSROOT/etc/shells
现在 shell 有了,但系统总得有个 init 来拉起万物吧。于是我走进了 init 选择的修罗场。
寻找一个能用的 init:地狱难度副本
dinit,缺席的 C++ 运行时
我第一个看上的是 dinit——一个年轻的 service manager,C++ 编写,设计简洁,文档也很通透。
它吸引我的点很直接:不像传统 init scripts 那样把控制流散在一堆 shell 文件里,也不像 systemd 那样一上来就接管半个用户空间。dinit 的目标比较克制:启动服务、监督服务、处理依赖。
但现实立刻给了我一大盆冷水:我天真地以为只要给 CXX 变量赋值,编译器就能在我那个简陋的 sysroot 里凭空生出一整套 C++ 标准库。
尝试过几个 hack:
export CXX="clang++ --sysroot=$SYSROOT -stdlib=libc++"
编译器确实找到了,但目标 sysroot 里就是没有 libc++abi 和 libunwind 等运行时实体。clang++ 只是编译器,不是 C++ 运行时生成器。
受难记录:dinit
我以为:CXX=clang++ --sysroot=$SYSROOT。
实际上:sysroot 里没有 libc++,所以这只是把错误写得更有仪式感。
判词:不是 dinit 不行,是我这个 rootfs 暂时还没有资格邀请 C++。
OpenRC,隐式的“发行版假设”
放弃 dinit 后,我转向 OpenRC。它在 Gentoo 和 Alpine 上被调教得极好,依赖驱动,通过 shell 脚本定义服务。
它的历史位置也很清楚:OpenRC 属于传统 Unix init/service 管理方式的现代化延续。它不想把所有东西都塞进一个巨大守护进程里,而是保留 service scripts、runlevel、依赖关系这些概念。对于 Gentoo、Alpine 这种已经有完整发行版约定的系统,它很好用。
但很快我就撞上了 OpenRC 的“发行版假设”。我用 Meson 构建它时,由于没有完整定义交叉编译的 cross-file,Meson 会自然地按宿主环境做 sanity check。更麻烦的是,OpenRC 期待一套相对完整的 /etc、/run、/lib、/sbin 和 service scripts 组织方式。
这不是 OpenRC 的错。它不是为“一个刚刚有 libc 和 shell 的目录”设计的,它默认你已经有一个发行版形状的用户空间。
受难记录:OpenRC
我以为:OpenRC 很轻,所以适合最小 rootfs。
实际上:轻不等于没有环境假设。
判词:它适合管理系统,而我当时还没有系统。
s6:把 init 拆成很多小问题
两次碰壁后,我开始翻阅 skarnet 的工具链:skalibs、execline、s6、mdevd。
s6 的思路和 OpenRC、dinit 都不一样。它更接近 daemontools/runit 那一路:把服务监督、一次性初始化、日志、ready notification、进程状态这些东西拆成小工具。它不是一个“安装后立刻拥有完整发行版体验”的 init,而是一套可以拼出启动链路的积木。
这很适合当前阶段。因为我的目标不是立刻得到一个漂亮的服务管理界面,而是看清楚系统从 PID 1 到 login prompt 的每一步。
当然,skarnet 生态也有自己的脾气。s6 依赖 execline,execline 又依赖 skalibs,mdevd 也在同一个生态里。每个包都很小,但你必须认真处理 --with-lib、安装路径、链接路径。
./configure --prefix=$SYSROOT/usr --libdir=$SYSROOT/lib --with-lib=$SYSROOT/lib
make -j18 && make install
受难记录:s6
我以为:模块化会让我少操心。
实际上:模块化会把每一个依赖关系摊开给你看。
判词:透明是真的透明,累也是真的累。
getty:系统启动以后,人怎么进去
最后还缺一道门:登录。内核跑完初始化,s6 拉起了服务,得有人在一个 tty 上等着用户敲回车。这就是 getty。
这个环节很容易被忽略。很多教程写到 init 能起来就开始庆祝,但如果没有 getty,你只是拥有了一个会启动的系统,不一定拥有一个能登录进去的系统。
我首先试图信任 toybox。但正如前文所说,toybox 的 getty 不适合承担这条启动链路。然后我翻了一圈更小的实现,最后选择了 mingetty。它的定位很窄:在 tty 上等待输入,然后把控制权交给登录程序或 shell。对第一轮 rootfs 来说,窄反而是优点。
从 SourceForge 的数字仓库里拖出这个古董
wget https://sourceforge.net/projects/mingetty/files/mingetty-1.0.tar.gz
tar xvf mingetty-1.0.tar.gz
cd mingetty-1.0
make -j18
make install DESTDIR=$SYSROOT
受难记录:getty
我以为:init 起了,系统就算能用了。
实际上:没有 getty,用户进不去。
判词:能启动和能登录,是两个里程碑。
启动,但是没有原神
有了内核、libc、shell、基础命令、init 生态、设备管理,以及那个古董 getty,剩下的工作就是给 s6 写一份极简的启动脚本,然后用工具把整个 rootfs 打成 initramfs 镜像。启动时在内核参数里甩上一句 init=/sbin/s6-svscan,启动序列便会依次牵出 mdevd、getty,以及任何你需要的服务。
当屏幕终于打印出那个朴素而又神圣的 login: 提示符,我敲下 root,mksh 还了我一个安静的 # 时,第一阶段就算结束了。系统很小,很静,甚至简陋得让强迫症犯难,但它的每一个零件都是自己挑的,每一个选型背后都是一段技术路线的取舍,和无数次编译失败后的“我知道为什么了”。
尾声:关于 init 江湖的恩仇与碎碎念
这次构建经历让我反复想起 OpenRC 和 systemd 的那场大分裂。
OpenRC 代表了传统 init 脚本的现代化延续,坚持脚本的可读性和服务的彼此解耦。而 systemd 则是另一条路:它试图一次性解决 Linux 用户空间那令人绝望的基础设施碎片化,将 udev、journald、logind 等统统整合进一个庞然大物。批评者骂 systemd 违背了 Unix 的“做一件事并做好”原则,Poettering 却毫不客气地回怼:那种原则在现实场景下根本不实用,用户需要的只是“能工作”。
在这次从零构建里,我最终没有用 systemd,也没有用 OpenRC,而是退回了更底层的 s6。不是因为哪种哲学更高明,而是因为在这个没有发行版精心包裹的环境里,我必须亲眼看到每一个 init 的链接过程,看清它对 sysroot 的每一个隐式假设。
当我终于见到那个闪烁的 login: 提示符时,涌上心头的并不是巨大的成就感,而是一种“终于熬出头了”的虚脱。构建一个系统最迷人的地方,从来不在于最后的那个 OK,而在于过程中那些我知道为什么这里会报错的瞬间。
阶段判定
内核:能启动。
libc:能链接。
用户态:能敲命令。
init:能拉服务。
getty:能登录。
结论:它还不是发行版,但它已经不是一堆文件了。
感觉可能没有下期了
如果没活了,下期就要编译 llvm 和 mesa 了,可能就要和《冰菓》一样鸽子了,别问,问就是还在编译…