1. 从一个尴尬的启动日志说起
第一次在自制的板子上跑通内核之后,我盯着串口终端里最后一行Kernel panic - not syncing: No working init found愣了很久。内核明明已经跑起来了,CPU、内存、串口驱动都正常,为什么就是进不了系统?后来才想明白,内核只是"发动机",它需要一套完整的"操作系统骨架"才能把控制权交出去——这套骨架就是根文件系统。没有它,内核找不到/sbin/init,找不到/dev/console,找不到任何可以执行的用户态程序,只能选择崩溃。
根文件系统(rootfs)是嵌入式Linux里最容易被低估的一环。很多人把精力全砸在内核裁剪和驱动移植上,等到要打包镜像的时候才发现,一个能用的根文件系统比想象中复杂得多:目录结构要符合FHS规范,设备节点要齐全,初始化脚本要能跑,动态库要匹配,权限要正确。而BusyBox恰好是解决这个问题的利器——它把上百个常用命令塞进一个几百KB的可执行文件里,通过符号链接的方式"伪装"成一个个独立命令,极大压缩了根文件系统的体积。
这篇内容适合正在做嵌入式Linux移植的开发者、想理解Linux启动流程的学生,以及需要为定制硬件构建最小系统的工程师。我会从根文件系统的本质讲起,把BusyBox的编译配置、目录骨架搭建、设备节点创建、初始化脚本编写、库依赖处理这些环节全部拆开,配上我实际踩过的坑和验证过的参数。读完你应该能独立做出一个能启动、能登录、能跑自己程序的根文件系统。
2. 根文件系统到底在启动流程里扮演什么角色
2.1 内核交接控制权的那一刻发生了什么
理解根文件系统的价值,得先搞清楚内核启动的最后阶段。内核完成自身初始化后,会执行一个叫init的用户态程序,这个程序必须存在于根文件系统里。内核查找 init 的顺序通常是/sbin/init、/etc/init、/bin/init、/bin/sh,如果这些路径全部找不到,就会抛出那个经典的 panic。
在挂载根文件系统之前,内核需要一个临时的过渡环境,这就是initramfs或者initrd的作用。但对于大多数资源受限的嵌入式设备,我们直接把根文件系统做成initramfs内嵌进内核镜像,或者做成独立的jffs2、ubifs、ext4分区。前者省事,后者灵活。选择哪种方式,取决于你的存储介质和是否需要运行时写入。
根文件系统提供的核心能力包括:提供 init 进程、提供 shell 和基础命令、提供设备节点、提供动态库、提供配置文件、提供挂载点目录。这六件事缺一不可,少任何一个都会导致启动失败或者功能残缺。
2.2 为什么不用发行版的根文件系统
有人会问,直接把桌面发行版的根目录拷过来不行吗?理论上可以,但实际会带来三个致命问题。第一是体积,一个精简的桌面发行版根目录动辄几百MB到几个GB,而嵌入式设备的Flash可能只有64MB甚至更小。第二是依赖,发行版的命令依赖大量动态库和配置文件,裁剪起来比重建还麻烦。第三是启动速度,发行版的init系统(如systemd)启动流程冗长,不适合需要秒级启动的嵌入式场景。
BusyBox的思路完全不同。它把ls、cp、mv、cat、ifconfig、mount等命令全部编译进一个二进制文件,运行时根据argv[0](也就是调用它的名字)来决定执行哪个功能。这意味着你只需要一个几百KB的文件,加上一堆符号链接,就能获得一个功能相当完整的命令行环境。这种设计对Flash空间极其友好,也是它成为嵌入式领域事实标准的原因。
2.3 一个合格根文件系统的最小清单
在动手之前,先明确目标。一个能正常启动并允许登录的最小根文件系统,至少需要以下内容:
| 类别 | 必需内容 | 作用 |
|---|---|---|
| 目录结构 | /bin /sbin /etc /dev /proc /sys /lib /usr /tmp /root /var | 符合FHS,满足程序运行路径需求 |
| 可执行文件 | busybox及其符号链接 | 提供init和所有基础命令 |
| 设备节点 | console、null、tty、zero | 内核和用户态通信的基础 |
| 配置文件 | /etc/inittab、/etc/init.d/rcS、/etc/fstab | 控制启动流程和挂载行为 |
| 动态库 | ld-linux.so、libc.so等 | 如果BusyBox是动态编译的 |
| 挂载点 | /proc /sys /dev/pts /tmp | 供内核虚拟文件系统挂载 |
这份清单是我反复验证过的底线。少了/proc挂载点,ps命令就没法工作;少了/dev/console,init 就没法输出日志;少了/etc/inittab,BusyBox的init就不知道该启动什么。
3. BusyBox的编译配置:别急着make,先想清楚这几个选项
3.1 静态编译还是动态编译,这是个战略选择
BusyBox的编译方式直接决定了根文件系统的复杂度和体积。静态编译会把所有依赖库打进BusyBox二进制文件,生成的文件通常在1MB到2MB之间,但好处是根文件系统里不需要再放任何动态库,部署极其简单。动态编译生成的BusyBox只有几百KB,但你必须把对应的libc.so、ld-linux.so等库文件也放进/lib目录,还要保证版本匹配。
我的建议是:调试阶段用静态编译,量产阶段根据Flash容量决定。静态编译能帮你排除掉大量"库找不到"的干扰,让你快速验证根文件系统骨架是否正确。等骨架跑通了,再切换到动态编译去优化体积。切换的时候只需要重新配置BusyBox并重新打包根文件系统,工作量不大。
配置静态编译的命令是make menuconfig之后进入Settings菜单,选中Build static binary (no shared libs)。注意,如果你的交叉编译工具链本身不支持静态链接(某些精简工具链会裁掉静态库),这一步会报错,那就只能走动态编译路线。
3.2 交叉编译工具链的匹配问题
BusyBox的编译依赖交叉编译工具链,而工具链的libc类型(glibc、uclibc、musl)必须和内核以及后续要运行的程序保持一致。我遇到过最坑的情况是:BusyBox用glibc工具链编译,但板子上跑的是musl环境,结果BusyBox一启动就报No such file or directory,实际上文件明明存在——这是动态链接器路径不匹配导致的。
配置交叉编译时,在make menuconfig的Settings里设置Cross compiler prefix,比如arm-linux-gnueabihf-。这个前缀必须和你的工具链实际前缀完全一致,多一个字符少一个字符都不行。设置完之后,可以用make -n看一下实际调用的编译命令,确认前缀被正确应用了。
还有一个细节:CONFIG_PREFIX这个配置项决定了make install时BusyBox安装到哪个目录。默认是./_install,我习惯改成绝对路径,避免因为工作目录变化导致安装位置错乱。
3.3 那些必须开启和必须关闭的配置项
BusyBox的配置项有几百个,全开会让体积爆炸,全关又会导致功能缺失。以下是我总结的关键配置清单:
必须开启的:
CONFIG_STATIC(如果选择静态编译)CONFIG_INIT(提供init进程,这是启动的核心)CONFIG_FEATURE_USE_INITTAB(让init读取/etc/inittab)CONFIG_MOUNT、CONFIG_UMOUNT(挂载文件系统)CONFIG_MKNOD(创建设备节点,虽然可以在宿主机创建,但板子上有会更方便)CONFIG_SH_IS_ASH或CONFIG_SH_IS_HUSH(提供shell)CONFIG_IFCONFIG、CONFIG_IP(网络配置)CONFIG_LS、CONFIG_CAT、CONFIG_CP、CONFIG_MV、CONFIG_RM(基础文件操作)
可以关闭的:
- 所有和
System Logging相关但你不用的选项 CONFIG_DEBUG系列(量产时关闭,调试时开启)- 各种不常用的网络工具(如
telnet、ftp,除非确实需要) CONFIG_FEATURE_*里那些锦上添花的功能
配置完成后,执行make编译,再执行make install。安装目录里会出现bin、sbin、usr三个子目录,里面是BusyBox本体和一堆符号链接。这些就是根文件系统的核心内容。
4. 手工搭建目录骨架与设备节点
4.1 目录结构的创建顺序有讲究
创建根文件系统目录时,顺序其实有讲究。我习惯先建顶层目录,再建子目录,最后处理权限。顶层目录包括:
mkdir -p rootfs/{bin,sbin,etc,dev,proc,sys,lib,usr,tmp,root,var,mnt} mkdir -p rootfs/usr/{bin,sbin,lib} mkdir -p rootfs/etc/init.d mkdir -p rootfs/dev/pts mkdir -p rootfs/lib/modules这里有几个容易忽略的点。/dev/pts是给伪终端用的,如果你要支持多个SSH会话或者telnet登录,这个目录必须存在。/lib/modules是给内核模块用的,即使你现在不加载模块,留着也不占空间。/var目录下通常还需要log、run、tmp等子目录,但BusyBox的init不一定需要,可以按需创建。
权限方面,/tmp和/var/tmp必须是1777(粘滞位),否则普通用户无法写入。/root应该是700,只允许root访问。这些权限在打包镜像时会被保留,所以要在宿主机上就设置好。
4.2 设备节点的两种创建方式
设备节点是根文件系统里最特殊的存在。传统方式是用mknod命令手动创建,需要知道主设备号和次设备号。比如:
sudo mknod rootfs/dev/console c 5 1 sudo mknod rootfs/dev/null c 1 3 sudo mknod rootfs/dev/zero c 1 5 sudo mknod rootfs/dev/tty c 5 0 sudo mknod rootfs/dev/ttyS0 c 4 64这些数字不是随便写的。console的主设备号5、次设备号1是Linux内核固定的约定;null是1和3;zero是1和5。ttyS0对应第一个串口,主设备号4、次设备号64。如果你用的是其他串口,次设备号要相应调整。
但手动创建有个问题:权限和属主容易搞错。console和tty通常需要root:tty属主,权限620或600。更现代的做法是在init脚本里用mdev自动创建设备节点。BusyBox自带的mdev可以监听内核的uevent,自动在/dev下创建对应的节点。配置方法是在/etc/init.d/rcS里加入:
echo /sbin/mdev > /proc/sys/kernel/hotplug mdev -s第一行让内核把热插拔事件交给mdev处理,第二行在启动时扫描/sys并创建所有已存在设备的节点。这种方式比手动mknod灵活得多,尤其是当你的板子有USB、SD卡等热插拔设备时。
4.3 动态库的拷贝不能靠猜
如果你选择了动态编译BusyBox,就必须把依赖的库文件拷贝到根文件系统的/lib目录。问题是,怎么知道BusyBox依赖哪些库?
用readelf -d busybox可以列出所有NEEDED项,这些就是直接依赖。但直接依赖的库本身可能还有依赖,所以要用ldd递归查看。交叉编译环境下,ldd不能直接用(因为它是针对目标架构的),需要用工具链自带的ldd或者readelf手动追踪。
我通常的做法是:
arm-linux-gnueabihf-readelf -d busybox | grep NEEDED然后对每个依赖库重复这个过程,直到没有新的依赖出现。常见的依赖包括libc.so.6、libm.so.6、libresolv.so.2、ld-linux-armhf.so.3。注意ld-linux这个动态链接器必须放在/lib目录下,而且路径要和编译时指定的解释器路径一致。可以用readelf -l busybox | grep interpreter查看解释器路径。
拷贝库文件时,要注意符号链接。比如libc.so.6通常是一个指向libc-2.xx.so的符号链接,两个文件都要拷贝,否则运行时会报找不到库。我吃过这个亏:只拷了libc.so.6这个链接文件,结果板子启动时报libc.so.6: version not found,排查了半天才发现是链接目标缺失。
5. 初始化脚本:让系统真正"活"起来
5.1 inittab的格式与BusyBox的特殊约定
BusyBox的init进程会读取/etc/inittab文件来决定启动行为。这个文件的格式和传统SysV init的inittab类似,但BusyBox做了简化。每一行的格式是:
<id>:<runlevels>:<action>:<process>id是标识符,runlevels在BusyBox里基本被忽略(可以留空),action是动作类型,process是要执行的命令。最常用的action有:
sysinit:系统初始化时执行,只执行一次respawn:进程退出后自动重启askfirst:启动一个交互式shell,等待用户按回车once:执行一次,不重启shutdown:关机时执行
一个典型的最小inittab长这样:
::sysinit:/etc/init.d/rcS ::askfirst:-/bin/sh ::ctrlaltdel:/sbin/reboot ::shutdown:/bin/umount -a -r第一行在系统启动时执行rcS脚本,第二行启动一个shell并等待用户输入,第三行定义Ctrl+Alt+Del的行为,第四行在关机时卸载所有文件系统。注意askfirst前面的-号,它表示这是一个登录shell,会读取/etc/profile等配置文件。
5.2 rcS脚本里该放什么不该放什么
/etc/init.d/rcS是系统启动时执行的第一个脚本,它的内容决定了系统的初始化行为。我见过有人把所有能想到的命令都塞进rcS,结果启动慢不说,还容易出错。正确的做法是只放必要的挂载和配置命令。
一个经过验证的rcS脚本:
#!/bin/sh # 挂载虚拟文件系统 mount -t proc none /proc mount -t sysfs none /sys mount -t tmpfs none /tmp mount -t devpts none /dev/pts # 设置主机名 hostname myboard # 启动mdev自动创建设备节点 echo /sbin/mdev > /proc/sys/kernel/hotplug mdev -s # 配置网络(可选) ifconfig lo 127.0.0.1 up ifconfig eth0 192.168.1.100 netmask 255.255.255.0 up # 设置环境变量 export PATH=/bin:/sbin:/usr/bin:/usr/sbin这里有几个细节值得说。mount -t proc none /proc里的none是设备名,对于虚拟文件系统来说这个参数没有实际意义,但必须提供。mdev -s要在挂载/sys之后执行,因为它依赖/sys里的设备信息。网络配置如果不需要可以注释掉,但lo接口建议始终启用,很多程序依赖它。
脚本开头必须是#!/bin/sh,而且这个脚本要有可执行权限(chmod +x)。我遇到过因为忘记加执行权限导致rcS不执行的情况,排查时一度怀疑是inittab写错了。
5.3 登录shell与用户环境
askfirst:-/bin/sh这行配置会让系统启动后显示 "Please press Enter to activate this console",用户按回车后进入shell。这个shell是root权限的,没有密码验证。如果你需要密码登录,得用login命令配合/etc/passwd和/etc/shadow。
BusyBox的login需要CONFIG_LOGIN配置项开启。/etc/passwd的格式和桌面Linux一样:
root:x:0:0:root:/root:/bin/sh/etc/shadow里的密码可以用mkpasswd生成,或者直接用明文(不推荐)。如果只是调试用,可以在inittab里直接用askfirst:-/bin/sh,省去登录环节。
环境变量方面,/etc/profile会在登录shell启动时被读取。可以在这里设置PATH、PS1(命令提示符)、LD_LIBRARY_PATH等。一个实用的profile:
export PATH=/bin:/sbin:/usr/bin:/usr/sbin export PS1='[\u@\h \W]# ' export LD_LIBRARY_PATH=/lib:/usr/libPS1里的\u是用户名,\h是主机名,\W是当前目录名。设置好之后,命令行提示符会显示成[root@myboard /]#这种形式,比默认的#友好得多。
6. 打包与烧录:从目录到可启动镜像
6.1 initramfs方式的打包细节
把根文件系统目录打包成initramfs是最简单的方式,因为不需要额外的存储分区。内核在启动时会自动解压initramfs到内存中作为根文件系统。打包命令是:
cd rootfs find . | cpio -o -H newc | gzip > ../rootfs.cpio.gz这里用的是newc格式,这是现代内核推荐的cpio格式。find .会列出所有文件,包括隐藏文件,确保没有遗漏。打包完成后,在内核配置里指定CONFIG_INITRAMFS_SOURCE指向这个文件,重新编译内核即可。
但initramfs有个限制:它是只读的,运行时无法写入。如果你的程序需要写文件,得在rcS里挂载一个tmpfs到可写目录,或者使用overlayfs。另外,initramfs会占用内存,根文件系统越大,内核启动后可用内存越少。对于内存紧张的设备,这种方式要谨慎。
6.2 独立分区的制作与烧录
如果Flash空间充足,更推荐把根文件系统做成独立分区。常见的格式有jffs2(适合NOR Flash)、ubifs(适合NAND Flash)、ext4(适合eMMC/SD卡)。制作jffs2镜像用mkfs.jffs2:
mkfs.jffs2 -r rootfs -o rootfs.jffs2 -e 0x20000 -n-e 0x20000指定擦除块大小为128KB,这个值必须和你的Flash实际擦除块大小一致,否则镜像无法正常挂载。-n表示不添加cleanmarker,某些Flash需要这个选项。
ubifs的制作更复杂一些,需要先用mkfs.ubifs生成UBIFS镜像,再用ubinize打包成UBI镜像。参数包括页大小、擦除块大小、最小I/O大小等,这些都要从Flash数据手册里查。搞错任何一个参数都会导致挂载失败。
烧录方式取决于你的板子。串口烧录、TFTP网络烧录、USB烧录、SD卡烧录都有可能。烧录工具也五花八门,具体操作参考板子厂商的文档。我唯一想提醒的是:烧录根文件系统之前,先确认内核的启动参数(bootargs)里root=指向了正确的分区,否则内核会去错误的位置找根文件系统。
6.3 启动失败的常见症状与排查顺序
根文件系统启动失败的症状通常很明确,按以下顺序排查效率最高:
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
| No working init found | init不存在或不可执行 | 检查/sbin/init是否存在,权限是否为755 |
| Unable to open initial console | /dev/console缺失 | 检查设备节点是否创建,主次设备号是否正确 |
| Kernel panic - not syncing | 根文件系统挂载失败 | 检查bootargs里的root=参数,检查分区格式 |
| 卡在Starting kernel... | 内核参数错误或镜像损坏 | 检查内核镜像完整性,检查bootargs |
| 启动后无shell | inittab配置错误 | 检查inittab格式,检查rcS执行权限 |
我遇到最多的是第一和第二种。No working init found通常是BusyBox安装时没有创建/sbin/init符号链接,或者链接指向了错误的位置。Unable to open initial console则是/dev/console没创建,或者创建了但权限不对。这两个问题在手工搭建根文件系统时几乎必然遇到一次,属于"必修课"。
7. 几个让我印象深刻的坑与对应的解法
7.1 动态库版本不匹配导致的"幽灵错误"
有一次我用工具链A编译了BusyBox,用工具链B编译了一个测试程序,两个工具链的glibc版本不同。BusyBox能正常启动,但测试程序一运行就报symbol not found。原因是测试程序依赖的某个符号在板子上的glibc版本里不存在。
这个问题的隐蔽性在于:BusyBox本身不报错,只有特定程序会出问题。排查方法是把出问题的程序和它依赖的库都拷到板子上,用LD_DEBUG=libs ./program查看库加载过程,或者用readelf -s对比符号表。根本解法是统一工具链,所有用户态程序都用同一个工具链编译。
7.2 文件系统权限在打包时丢失
在宿主机上设置好的权限,打包成cpio或jffs2镜像后可能会丢失,尤其是当打包工具以非root身份运行时。我遇到过/dev/console打包后变成普通文件的情况,导致启动时找不到控制台。
解法是打包时用sudo或者fakeroot。fakeroot的好处是不需要真正的root权限,但能模拟出root拥有的文件属主和权限。用法是fakeroot -- sh -c "find . | cpio -o -H newc | gzip > ../rootfs.cpio.gz"。这样打包出来的镜像里,文件属主和权限都是正确的。
7.3 串口控制台的波特率与内核参数不一致
这个坑和根文件系统本身无关,但症状很像根文件系统问题。内核的bootargs里通常有console=ttyS0,115200,如果这个波特率和实际串口配置不一致,你会看到乱码或者什么都看不到。更隐蔽的是,如果console=指定的设备名和实际串口设备名不匹配(比如内核里是ttyS1但你写了ttyS0),控制台就没有输出,看起来像是根文件系统没启动。
排查方法是先确认内核启动时串口有没有输出。如果内核日志能正常打印,说明串口配置没问题,问题在根文件系统;如果内核日志都是乱码或空白,先解决串口问题。
7.4 忘记挂载/proc导致ps命令失效
ps命令依赖/proc文件系统。如果rcS里忘记挂载/proc,ps会返回空结果或者报错。这个问题的迷惑性在于,系统看起来是正常的,shell能登录,命令能执行,只有ps、top这类依赖proc的命令不正常。
解法很简单,在rcS里加上mount -t proc none /proc。但要注意挂载顺序:/proc必须在其他依赖它的操作之前挂载。我习惯把proc和sys的挂载放在rcS的最前面。
8. 从最小系统到实用系统的扩展思路
最小根文件系统跑通之后,下一步通常是按需扩展。扩展的方向取决于你的应用场景。如果要做网络设备,需要加入iptables、dnsmasq、dropbear(SSH服务)等;如果要做数据采集,需要加入串口通信工具、数据库客户端;如果要做人机交互,需要加入Qt库和图形程序。
扩展时有一个原则:每加一个功能,先确认它依赖什么,再把依赖补齐。比如加dropbear,它依赖libcrypto、libz,还要生成主机密钥,还要配置/etc/dropbear目录。这些细节在桌面环境里通常由包管理器自动处理,但在嵌入式环境里必须手动完成。
另一个建议是保持根文件系统的可维护性。把自定义的启动脚本、配置文件、应用程序分门别类放在/etc/init.d、/etc/config、/usr/local/bin等目录下,不要和BusyBox的文件混在一起。这样后续升级BusyBox或者排查问题时,能快速定位到自己的修改。
我个人在实际操作中的体会是,根文件系统的搭建过程本质上是在理解Linux系统的运行机制。每解决一个启动问题,你对内核、init、文件系统、设备模型的理解就深一层。BusyBox只是一个工具,真正有价值的是它背后那套"最小可用系统"的设计哲学——用最少的资源,提供最核心的能力。这套思路不仅适用于嵌入式Linux,在任何资源受限的系统设计里都用得上。