做嵌入式Linux系统移植的朋友应该都有这种经历:U-Boot起来了、内核也打印到Freeing unused kernel memory,结果最后一步却卡在根文件系统上。屏幕上要么是Kernel panic - not syncing: No init found,要么是串口shell死也不出来,连个登录提示符都看不到。我这个系列做到第二十一期,前面已经带着大家把引导程序、内核、BusyBox编译这些大头都过了一遍,今天的主题还是继续填坑:在Ubuntu20.04开发环境下,把手头这块ARM开发板的BusyBox根文件系统从“能跑”完善到“能用”,再打磨到“好用”。
这套完善工作做下来,你会发现根文件系统并不是把BusyBox编完复制过去就结束了。一个能真正启动到shell、能挂网卡、能跑应用程序的根文件系统,需要解决目录结构、设备节点、init流程、动态库依赖、账号配置这一连串问题。每一步都有坑,但每一步也都有固定的排查套路。这篇文章我会按照我实际调试的顺序,把完善BusyBox根文件系统的全过程掰开揉碎讲清楚,包含可直接抄作业的脚本和配置,以及Ubuntu20.04环境下特有的编译和调试注意事项,适合正在做系统移植、刚把BusyBox跑起来但不知道怎么往下走的人。
1. 为什么BusyBox根文件系统需要“完善”
1.1 BusyBox到底给了我们什么
BusyBox本质上是一个“命令集合体”。它把几百个常用Linux命令整合进了一个可执行文件里,通过符号链接或者调用参数来区分到底执行哪个命令。比如你在目标板上敲ls,实际上执行的是/bin/busybox这个程序,BusyBox根据argv[0]判断出你要的是ls。这种设计大幅节省了Flash空间,非常适合嵌入式设备。
但是BusyBox本身只解决“命令从哪来”的问题。它不负责创建/dev/console设备节点,不负责挂载/proc和/sys文件系统,不负责配置网卡IP,也不负责告诉你它编译时依赖了哪个版本的glibc。举个不太恰当但贴切的例子:BusyBox像是一套多功能工具箱,箱子本身倒是挺全,但你的毛坯房里电没通、水没接、门锁还是坏的,工具再全也住不进去。这个“毛坯房装修”的过程,就是本篇要讲的根文件系统完善工作。
1.2 完善工作的优先级规划
我在做完第一批BusyBox构建实验后,给自己定了一个完善顺序,这个顺序后来成了我做其他板子的标准流程:
第一优先级是让系统能启动。具体来说就是目录结构齐全、/dev/console设备节点存在、/etc/inittab正确、init进程能顺利执行rcS脚本。这个阶段的目标是上电能到shell提示符。
第二优先级是让系统能用起来。网络要通,动态库要补齐,root能登录,主机名和DNS要设置好,应用程序依赖的库文件不能报错。
第三优先级才是让系统好用。环境变量、PATH路径、快捷脚本、开机自启动服务这些,都是在前面两步稳定之后才去碰的。
很多人一上来就折腾/etc/profile和自启动脚本,结果设备根本没到那个阶段就panic了,排查半天还以为是脚本写错,其实是设备节点没创建。所以我强烈建议按“先启动、再功能、后体验”的顺序来,一步一验证,别跳步。
2. 基础目录与设备节点方案
2.1 标准目录一次建齐
BusyBox构建完成后,你通常会得到一个包含bin、sbin、usr等目录的rootfs雏形。但它的目录不一定齐全,尤其是dev、proc、sys、tmp这些运行时目录,往往需要手动补上。我在Ubuntu20.04的开发机上是通过下面的命令一次建齐整套目录的:
sudo mkdir -p rootfs/{bin,sbin,usr/bin,usr/sbin,lib,etc/init.d,dev,proc,sys,tmp,var,root,home,mnt,opt,run}这里每个目录都是有讲究的,不是随便建的。dev是设备节点所在目录,内核和用户空间通过它访问硬件;proc和sys是两个虚拟文件系统,分别提供进程和内核设备模型的信息,很多命令如ps、mount、mdev都强依赖它们;tmp是临时文件目录,通常挂tmpfs用内存承载;var放日志和锁文件;root是root用户的home目录;mnt是给调试挂载NFS之类用的临时挂载点。
搞完目录之后,我还会顺手把usr目录下的内容做个整理。BusyBox的rootfs里usr/bin和usr/sbin一般直接复制工具即可,如果是为了省空间,也可以把usr/bin和usr/sbin做成指向bin和sbin的符号链接。不过这部分不影响启动,可以放到后面再优化。
2.2 设备节点:静态mknod还是动态mdev
设备节点这块是新手最容易懵的地方。有些教程让你用mknod手工创建一堆设备文件,有些教程则说只要配置mdev就能自动管理。我的建议是两者结合,分阶段处理。
起步阶段用静态mknod创建最关键的几个节点就够了:
sudo mknod -m 660 rootfs/dev/console c 5 1 sudo mknod -m 666 rootfs/dev/null c 1 3 sudo mknod -m 666 rootfs/dev/zero c 1 5这其中有console和null是必须的。console是内核启动早期和串口交互的基础,内核在init进程跑起来之前就要往console上打印信息;null是很多命令的垃圾桶,没有它某些命令会直接报错。zero、random、urandom这些也可以顺手建上,成本很低。
等系统能启动到shell之后,再引入mdev做动态设备管理。mdev是BusyBox自带的轻量级设备管理工具,它通过扫描sysfs来创建设备节点,比手工mknod省心太多。配置方式是在/etc/init.d/rcS里加上:
echo /sbin/mdev > /proc/sys/kernel/hotplug mdev -s其中第一行告诉内核,当热插拔事件发生时调用/sbin/mdev来处理,第二行让mdev立即扫描一次/sys目录,把当前存在的所有设备节点都补建出来。需要说明的是,mdev依赖内核的sysfs和uevent机制,编译内核时要确认CONFIG_SYSFS已经打开。
静态和动态方案的取舍也很直接:静态mknod优点是简单直接、不依赖内核配置,缺点是设备一多就没法维护,外设插拔也不会自动生成节点;动态mdev优点是省心、全自动,缺点是依赖sysfs、配置错了排查起来更麻烦。实际产品里绝大多数用mdev或者devtmpfs,只有极简教学环境才还在用全套静态节点。
3. init初始化流程与启动脚本
3.1 内核交接给用户空间的流程
内核启动到最后,会去文件系统里找init进程来执行。这个交接过程是有固定搜索顺序的:内核先尝试/sbin/init,找不到再试/bin/init,然后是/etc/init,最后是/bin/sh。如果这些都没有,内核就直接panic,打印出Kernel panic - not syncing: No working init found。
这句报错我实在见过太多次,几乎每个自己动手做rootfs的人都绕不过去。原因无非两种:一是init路径不对,二是文件系统里确实没有可执行的init。BusyBox编译安装后,/sbin/init通常是busybox的符号链接,所以只要BusyBox本体没问题、目录结构正确,这一步一般能过。如果实在找不到init,也可以在内核启动参数里手动指定init=/linuxrc或者init=/bin/sh来排查问题。这个参数在调试阶段非常好用,能让你绕过init直接进shell看环境。
内核把控制权交给init之后,BusyBox init会读取/etc/inittab这个配置文件,按里面的定义来启动各个进程。换句话说,inittab就是用户空间的“开机启动项”,它的写法直接决定你的串口有没有shell、rcS跑不跑。
3.2 /etc/inittab动作与写法
BusyBox的inittab格式和桌面Linux的systemd配置完全不同,它沿用了经典的SysV init风格,每行格式是:
id:runlevel:action:process在BusyBox里,id用来标识控制台设备,一般留空;runlevel可以忽略;action是触发条件;process是要执行的命令。重点就在action上,常用动作整理成了一张表:
| action | 触发时机 | 典型用途 |
|---|---|---|
| sysinit | init启动后最先执行 | 执行/etc/init.d/rcS |
| respawn | 进程结束后立即重新启动 | 始终保持某个后台进程存活 |
| askfirst | 进程启动前在控制台询问“Press enter to continue” | 给串口终端分配交互shell |
| wait | 启动进程并等待其结束 | 执行一次性初始化任务 |
| once | 只执行一次,不等待 | 执行一次性服务 |
| ctrlaltdel | 收到Ctrl+Alt+Del组合键时 | 触发重启 |
| shutdown | 系统关机时 | 卸载文件系统 |
我实际用来跑通系统的inittab非常简单,就四行:
::sysinit:/etc/init.d/rcS console::askfirst:-/bin/sh ::ctrlaltdel:/sbin/reboot ::shutdown:/bin/umount -a -r第一行是初始化入口,第二行是串口交互shell,第三行为快捷键重启兜底,第四行关机前卸载文件系统。这里的conssole对应设备节点/dev/console,也可以改成ttyS0或者ttymxc0这类具体串口名。很多人以为inittab内容越多越好,其实不是,刚开始越精简越好排查。
有个细节值得注意:第二行askfirst前的减号“-/bin/sh”表示让shell成为登录shell,会去读取/etc/profile里的环境变量。如果你不写这个减号,登录后PATH可能都是空的,命令都敲不了。
3.3 rcS脚本内容逐行讲解
rcS是整个根文件系统的初始化入口,位于/etc/init.d/rcS。它没有固定的标准写法,但有几个步骤几乎是所有嵌入式Linux的标配。我给出一个验证过能用的版本,并解释每行的作用:
#!/bin/sh mount -t proc proc /proc mount -t sysfs sysfs /sys mount -t tmpfs tmpfs /tmp mkdir -p /dev/pts mount -t devpts devpts /dev/pts echo /sbin/mdev > /proc/sys/kernel/hotplug mdev -s hostname -F /etc/hostname ifconfig lo up ifconfig eth0 up udhcpc -i eth0第一步挂proc和sysfs,这是内核与用户空间交互的基础。mount -a依赖/etc/fstab,但我在rcS里直接写了mount命令而不是用mount -a,跳过fstab可以减少一层配置。第二步挂tmpfs到/tmp,让临时文件落在内存里,避免频繁读写Flash。第三步挂devpts,这是pty伪终端需要的文件系统,虽然很多场景用不到,但挂上没坏处。第四步是mdev,前面已经解释过。第五步设置主机名。第六步配置回环设备和以太网卡,并用udhcpc自动获取IP。
这个脚本里每一条都有可能是坑。比如mount -t proc这一步如果失败,ps命令会看到“can't open /proc”之类的报错;mdev不执行,/dev下除了手动创建的几个节点外空空如也;ifconfig那两行如果网卡名字不叫eth0,会报错但不影响启动,这种情况用ip addr看下实际网卡名改一下就行。
写完rcS后记得加执行权限,命令是chmod +x /etc/init.d/rcS。另外文件换行符必须是LF,如果在Windows上编辑过变成CRLF,启动时会报“not found”但文件明明存在,这个坑我踩过不止一次。
4. 用户侧配置:账号、网络与动态库
4.1 从“能启动”到“能登录”
系统能起来并进入shell之后,接下来要处理的是用户、密码和环境变量。BusyBox验证登录时读取的是/etc/passwd、/etc/shadow和/etc/group这三个文件。我第一次做的时候以为没有密码文件也能用,结果login命令一直报错。
一个最小可用的passwd文件可以是这样:
root:x:0:0:root:/root:/bin/shshadow文件不设密码的情况下可以写成:
root::0:0:99999:7:::group文件写一行root组:
root:x:0:这三个文件我通常直接echo重定向进去,不用额外工具。注意passwd字段里的x表示密码存储在shadow文件里,这是标准的shadow机制。如果需要设置root密码,可以在目标板启动后用passwd命令修改,或者Ubuntu20.04上借助交叉环境的工具直接生成加密串。
环境变量方面,/etc/profile是每次登录shell时读取的配置。我一般会设置PATH和PS1:
export PATH=/bin:/sbin:/usr/bin:/usr/sbin export PS1='[\u@\h]:\w$ 'PATH设置很关键。如果用默认值,有些命令可能不在当前路径里,登录后敲命令会提示not found,而实际上命令就在/bin下。PS1是命令提示符格式,\u表示当前用户,\h表示主机名,\w是当前路径,调整成自己习惯的格式就行。
4.2 网络配置
网络这块在嵌入式rootfs里有几条路可以走。最简单粗暴的是在rcS里用ifconfig手动配置固定IP:
ifconfig lo 127.0.0.1 up ifconfig eth0 192.168.1.100 netmask 255.255.255.0 up route add default gw 192.168.1.1如果要动态获取IP,就用BusyBox自带的udhcpc,也就是我前面的rcS里那种写法:
udhcpc -i eth0udhcpc运行后会自动发起DHCP请求,拿到IP后通过脚本刷新网络配置。它有对应的脚本通常在/etc/udhcpc/default.script里,如果这个脚本缺失,udhcpc可能能拿到地址但无法正确配置网卡。
DNS配置在/etc/resolv.conf文件里,格式很简单:
nameserver 8.8.8.8 nameserver 114.114.114.114没有这个文件,ping域名会报“bad address”,但ping IP是通的。主机名相关的还有/etc/hostname和/etc/hosts两个文件,hostname里放一个纯名字比如“arm-dev”,hosts里可以放本机的hostname映射,方便网络邻居识别。我在rcS里用hostname -F /etc/hostname来加载主机名,这样比硬编码在ifconfig环境里更规范。
4.3 动态库依赖处理
动态库缺失是BusyBox根文件系统“完善”阶段的头号杀手。我遇到过的情况是:BusyBox编译时依赖的glibc版本和目标板上的库版本不一致,或者干脆没拷贝库文件,结果上电后整个系统根本起不来。
处理依赖的第一步是确认BusyBox的二进制格式和依赖项。在Ubuntu20.04的终端里用file命令看格式,再用交叉编译工具链自带的readelf查看依赖:
file rootfs/bin/busybox readelf -d rootfs/bin/busybox | grep NEEDED readelf -l rootfs/bin/busybox | grep interpreadelf -d命令会列出这个可执行文件依赖了哪些共享库,通常是libc.so.6、libm.so.6这几项。readelf -l里的interp一行标明动态链接器的路径,一般是/lib/ld-linux.so.3或者/lib/ld-linux-armhf.so.3。如果这个路径不对,运行时会报“No such file or directory”,而这句报错其实指的是动态链接器不存在,不是库文件本身的问题。
接下来从交叉编译工具链的sysroot目录把依赖的库拷贝到rootfs/lib下:
cp -a /opt/toolchain/sysroot/lib/libc.so.6 rootfs/lib/ cp -a /opt/toolchain/sysroot/lib/libm.so.6 rootfs/lib/ cp -a /opt/toolchain/sysroot/lib/ld-linux*.so.* rootfs/lib/这里我特别强调cp -a保留符号链接属性。glibc的库文件往往带有版本号后缀,同时存在libc.so.6这样的软链接指向真实文件。如果你用普通的cp拷贝,软链接会变成普通文件,运行时同样加载失败。拷贝完成后可以顺手用工具链的strip命令瘦身,把调试符号去掉,体积能缩小一半以上。但要注意,strip操作针对的是目标文件,不是开发机上的x86文件,别搞混了。
还有个大坑是glibc版本与内核版本的兼容性。新工具链编出来的BusyBox如果用了较高版本的glibc,拷到老内核的设备上运行时会报“FATAL: kernel too old”。这个问题我在Ubuntu20.04上做某块老平台时碰到过一次,解决办法要么换低版本工具链重新编译,要么直接在BusyBox配置里打开静态编译选项CONFIG_STATIC=y,静态链接之后就不依赖目标板上的动态库了,省去库管理的麻烦。当然静态编译会增加可执行文件体积,对于Flash资源极其紧张的场景要权衡一下。
5. 常见问题排查实录
5.1 启动失败的三个典型场景
做这个第二十一期过程中,我把自己和身边人踩过的问题汇总了一下,频率最高的三个场景很有代表性。
第一个是Kernel panic - not syncing: No init found。排查顺序非常固定:先确认rootfs里是否有/sbin/init或/bin/init;再确认init文件是否有执行权限;然后确认kernel启动参数里的root=路径是否正确指向了rootfs分区;最后如果Rootfs是烧写在SD卡或Flash上,还要确认驱动是否把存储设备识别出来了。这个报错还有一个变种,就是文件系统挂载成功但init脚本权限不对,内核会报“can't run '/etc/init.d/rcS': Permission denied”,这时候chmod +x就能解决问题。
第二个是串口完全没有输出或者直接卡死在Starting kernel。这个其实不完全是rootfs的锅,更多是启动参数和console设备没配对。但只要涉及串口shell没出来,我会按这样的顺序查:内核启动参数有没有配置console=ttyS0,115200;/dev/console节点是否存在;/etc/inittab里askfirst对应的设备名是不是当前串口;最后才是波特率设置是否正确。千万别一上来就去改inittab。
第三个是文件系统变成只读。BusyBox的rootfs如果用ramdisk方式启动,默认是只读的,需要重新挂载为读写:
mount -o remount,rw /这块我特别想强调:很多初学者在只读文件系统下改了/etc/inittab或rcS,发现重启后修改没了,还以为是自己没保存好,实际上就是没做remount。
5.2 动态库与文件权限相关坑
动态库类的报错有个非常有迷惑性的现象:运行busybox时提示/bin/sh: not found,而你明明在/bin下看到了sh这个软链接。这个“not found”的真实原因是动态链接器路径不存在,不是sh本身缺失。用readelf -l查看interpreter路径,然后把对应的ld-linux库拷到指定位置,问题就会消失。这个坑如果不理解原理,翻遍bin目录都找不到头绪。
还有一个坑是库文件拷贝对了但却是x86的。我第一次做时直接从开发机的/lib目录拷贝.so文件,结果目标板启动后libc相关程序全部段错误。后来才意识到,开发机Ubuntu20.04的glibc是x86架构,ARM板根本执行不了。所以拷贝库文件必须用交叉编译工具链sysroot下的对应架构库,不能图省事从系统目录直接cp。
再就是上电后root登录不了。排查顺序是:/etc/passwd和/etc/shadow是否存在;root用户UID是不是0;shadow里密码字段格式是否正确;如果之前设置了登录shell验证,还要确认/bin/sh是否存在且可执行。这套排查逻辑基本能把登录问题覆盖完。
5.3 Ubuntu20.04开发环境下的经验总结
Ubuntu20.04相比老版本Ubuntu,在交叉编译工具链方面最大的变化是系统自带的gcc版本较新,新版的glibc编译产物在旧内核上跑容易触发kernel too old。这个现象我做实验时反复出现过,最终给出的建议如下表:
| 现象 | 直接原因 | 解决办法 |
|---|---|---|
| No init found | rootfs里没有/sbin/init或bin/init | 检查BusyBox安装结果和权限 |
| No working init found | init文件存在但无法执行 | 检查动态链接器路径和libc依赖 |
| 串口无shell | console设备未创建或inittab设备名不匹配 | 先用mknod建console节点再调inittab |
| mount: proc already mounted | /proc已被内核自动挂载或重复挂载 | 注释rcS里的重复挂载行 |
| FATAL: kernel too old | glibc版本高于内核兼容范围 | 换旧工具链或静态编译BusyBox |
| No such file or directory | 动态链接器ld-linux不存在 | readelf查看interp路径并补齐 |
| can't run rcS: Permission denied | 脚本没有执行权限或换行符错误 | chmod +x并确认LF换行 |
调试手段上,我强烈推荐在Ubuntu20.04开发机上配置NFS根文件系统。把rootfs目录通过NFS共享给开发板,内核用root=/dev/nfs nfsroot=192.168.1.100:/path这样的参数启动,这样所有文件和脚本的修改都能即时生效,不用反复烧写SD卡,调试效率能提升一个量级。我后期几乎所有的配置调整都是在NFS启动模式下完成的,确认稳定后再打包烧进板载存储。
我在这个系列里踩得最深、耽误时间最长的坑,几乎都集中在“文件存在但执行不了”这一类问题上。动态链接器路径不对、库里混了x86版本、脚本换行符是CRLF、权限位没设置——这些问题从现象上看全是not found、Permission denied这类模糊提示,很容易让人以为操作错了方向。实际上它们的排查手段都很固定,就是把readelf、file、ls -l这几个工具用熟,先确认文件的架构和依赖,再确认权限和格式,基本能解决八成的问题。我建议你手头常备一张最小rootfs的备份,每次做大改动之前先复制一份,保证随时能回退到能启动的状态。接下来再做系统移植,思路就很清晰了:先把启动流程跑通,再逐个功能验证,最后才是优化体积、精简启动时间这些进阶活儿。