我从一个实际项目说起吧。早些年我帮客户做一款工业网关,用的主控是ARM Cortex-A7,内存只有64MB,Flash 256MB。系统跑起来总共占用不到15MB的空间,启动时间控制在5秒以内,这就是BusyBox的功劳。当时团队里有个刚从桌面Linux转过来的同事,第一反应是想在板子上跑Ubuntu的core版本,我直接劝退了。倒不是说Ubuntu不好,而是对这个场景来说,一套完整的GNU工具链和systemd体系带来的收益远小于开销。而BusyBox这一个程序,几百个命令,占空间小、行为可预期、定制灵活,很多时候它才是嵌入式Linux里最合适的那个“操作系统外壳”。
这篇博文我会把BusyBox的原理拆开讲清楚,再带大家从零到一,用它手工搭一套根文件系统。内容包括:BusyBox的applet工作方式、编译配置的关键选项、根文件系统的目录骨架、init启动流程、集成Dropbear实现SSH登录、用NFS挂载根文件系统调试,以及我在实际项目中踩过的各种坑。
1. 从“跑不动的Linux”说起:BusyBox到底解决什么问题
1.1 一套完整的GNU工具链在嵌入式板子上有多贵
你在PC上敲ls、cp、grep的时候,背后对应的是coreutils、procps、grep这些独立软件包里的程序。每个程序是独立的ELF可执行文件,自己带着一堆动态库依赖和配置文件。在x86服务器上这无所谓,硬盘按TB算,内存按GB算。但放到嵌入式板子上,情况就完全不同了。
拿我那块64MB内存的板子来说。一个完整的coreutils包装上去,大概需要几十MB的空间,而grep、sed、awk这些再来一圈,Flash空间就快见底了。更重要的是,这些程序在启动阶段逐个加载,每个都要经过动态链接器解析、映射、初始化,消耗的时间和内存积少成多,对启动速度的影响非常明显。
BusyBox的做法完全不同。它把几百个命令的代码逻辑塞进了一个可执行文件里,在系统里你只看到一个/bin/busybox,ls、cp、cat这些命令都是指向它的符号链接。当你在终端敲ls的时候,内核加载的是同一个busybox程序,只是启动参数不同而已。
1.2 BusyBox能干什么:一张命令清单看清边界
BusyBox能提供的命令数量与配置选项有关,完整的配置打开后大约有400多个applet。我这里整理了一份常用的命令清单,方便新手对照理解它的能力边界。
| 分类 | 常用命令 |
|---|---|
| 文件操作 | ls, cp, mv, rm, mkdir, touch, cat, tail, head, find, tar, gzip |
| 文本处理 | grep, sed, awk, cut, sort, uniq, wc, tr, vi |
| 进程管理 | ps, top, kill, killall, nice, renice, nohup, pgrep |
| 网络工具 | ifconfig, route, ping, wget, telnetd, netstat, nc, udhcpc, httpd |
| 系统管理 | mount, umount, dmesg, free, df, du, sync, mdev, init, reboot, poweroff |
| shell环境 | ash, sh, msh |
| 其他实用 | crond, ntpd, syslogd, klogd, watchdog, fdisk, mkfs.vfat, hwclock |
这份清单覆盖了嵌入式Linux日常开发和运维的绝大部分需求。有一些命令我是建议额外使用完整版工具的,比如dd、parted这些涉及数据恢复和分区操作的,BusyBox的实现虽然能用,但容错性比GNU原版弱一些。我在实际项目中如果设备上空间允许,通常会单独放一个完整的e2fsprogs的e2fsck进去,用于文件系统修复。
1.3 不是只有小系统才用BusyBox
很多人的印象是BusyBox只给单片机级别的设备用,其实不然。Docker容器里的大量精简镜像,底层就是BusyBox。安卓系统的调试模式里也有它的身影。工业路由器、智能汽车的车机系统、甚至一些桌面Linux的initramfs阶段,都会用BusyBox作为过渡工具。
原因很简单,initramfs要在极小的内存环境里完成挂载真正根文件系统的任务,它需要一套完整的、依赖极少的基本工具。BusyBox静态编译之后,对系统几乎零依赖,扔进去就能用。这种“一个二进制打天下”的特性,让它在很多对体积和依赖敏感的场合成了首选。
2. 瘦身成功的秘密:applet机制与编译选型的底层逻辑
2.1 从main函数到符号链接:BusyBox的设计哲学
BusyBox的源码根目录下有个文件叫libbb/appletlib.c,里面藏着一个非常精巧的分发机制。当内核执行busybox的时候,main函数第一件事就是检查argv[0],也就是调用这个程序时使用的名字。如果是ls,就走ls的applet_main;如果是cp,就走cp的applet_main。这就是符号链接能工作的根本原因。
源码中每个applet都有一个类似applet_main的入口点,它们被统一注册在applet表里。编译的时候通过CONFIG_FEATURE_SH_STANDALONE这类选项,还可以让BusyBox里的shell在找不到外部命令时回退到内置applet,实现“无外部依赖”运行。
有个细节值得注意:BusyBox并不只靠符号链接来触发。当argv[0]显示是busybox本身的时候,它会把第一个参数当作要执行的applet名,比如busybox ls -l的效果和ls -l一样。这在一些特殊情况很好用——比如某个脚本里硬编码了/bin/busybox cat /etc/passwd,即使cat的符号链接坏了,命令依然能执行。
2.2 动态链接还是静态链接:一个影响一生的决定
编译BusyBox时,最重要的一个选择是CONFIG_STATIC是否打开。这个选项直接决定了最终生成的二进制文件是否依赖动态库。
| 对比项 | 动态链接(默认) | 静态链接 |
|---|---|---|
| 二进制体积 | 约800KB-1MB | 约1.5MB-2.5MB |
| 运行依赖 | 需要glibc/musl等C库 | 无任何外部依赖 |
| 调试便利性 | 可用gdb正常调试 | 需注意strip后符号丢失 |
| 适用场景 | Flash充足、需要节省内存 | initramfs、极小系统、chroot环境 |
我个人的建议是,如果不是空间特别紧张,尽量使用静态链接。原因很实在:嵌入式系统里动态库版本一旦升级,很容易出现busybox与新libc兼容性出问题的情况。静态链接的busybox像一块砖头一样扎实,扔到哪个板子上都能跑,少了很多“幽灵式”的依赖困扰。
有一个坑必须提醒:如果你的系统里所有用户程序都依赖glibc,而busybox用了musl静态编译,那么busybox调用外部程序时,glibc的程序依然会正常执行,这不冲突。但反过来,如果你用busybox的shell写脚本调用外部工具,外部工具的依赖项得自己保证。所以静态编译busybox本质上不解决整个系统的依赖问题,它只解决busybox自身的依赖问题。
2.3 要不要开启的隐藏配置项
打开make menuconfig之后,密密麻麻的配置项容易让人眼花缭乱。我梳理了几个实际项目中最容易出问题的选项,做一个说明。
CONFIG_FEATURE_TOP_SMP_CPU:top命令显示多核CPU使用率。默认不开启时,多核设备上的CPU使用率显示可能不准。CONFIG_FEATURE_MOUNT_HELPERS:挂载时启用辅助工具。对NFS、FUSE这类文件系统挂载有影响,建议开启。CONFIG_FEATURE_VI_REGEX_SEARCH:vi编辑器支持正则搜索。不开启的话,vi的搜索功能会弱很多。对在板子上临时改文件来说,这个功能还挺重要的。CONFIG_ASH_JOB_CONTROL:ash shell的工作控制。关闭后,你在终端里按Ctrl+C可能会失灵,任务前后台切换也有问题。这个选项默认打开,建议保持。CONFIG_FEATURE_EDITING_SAVEHISTORY:保存shell历史记录。在调试时很实用,能减少重复输入命令的烦恼。
还有个值得一提的坑:有些开发板厂商会给BusyBox打一堆自己的补丁,然后在配置里开一些奇奇怪怪的选项。交叉编译环境的CONFIG_CROSS_COMPILER_PREFIX要填对,不然后面链接阶段会莫名其妙报错。这个错误信息通常还不直观,我第一次遇到的时候查了半天,最后发现是工具链前缀写错了。
3. 交叉编译BusyBox:一套能用的配置和避坑记录
3.1 用Buildroot还是纯手动:两条路线怎么选
搭建BusyBox环境有两条常用路线。如果你还在项目初期,对工具链、内核、根文件系统的版本搭配没有把握,建议直接上Buildroot。它把工具链、内核、BusyBox、整个用户空间全打包管理,版本之间的兼容性被严格验证过,出问题概率低很多。
如果你是想深入理解BusyBox本身,或者你的工具链是芯片厂商定制的、无法用Buildroot统一管理,那就手动来。手动编译的流程也不复杂,基本步骤就是交叉编译工具链准备好,解压BusyBox源码,配置,编译,安装到一个临时目录,再把临时目录里的内容整合进根文件系统。
我个人的经验是:第一次玩BusyBox,不建议直接上Buildroot,最好是手动编译一遍。原因在于Buildroot帮你屏蔽了太多细节,你最后发现自己只是敲了个make,整个过程黑盒化非常严重。而手动编译一次,你能清晰感知到交叉编译器、sysroot、链接过程分别做了什么,这对接下来的根文件系统调试非常有帮助。
3.2 手动编译的完整流程
以下命令基于x86主机,目标平台是ARM Cortex-A7,使用通用的arm-linux-gnueabihf-工具链。实际操作时,工具链前缀换成你板子对应的名字即可。
# 解压源码 tar -xvf busybox-1.36.1.tar.bz2 cd busybox-1.36.1 # 先生成默认配置,再在menuconfig中调整 make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- defconfig make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- menuconfig进入menuconfig之后,我通常会检查这几处:
Settings -> Build static binary (no shared libs):选中,除非你有特殊理由用动态链接。Settings -> Cross compiler prefix:填arm-linux-gnueabihf-。Settings -> Destination path for 'make install':先空着或者填一个临时目录,之后手动拷贝。Busybox Settings -> Busybox Library Tuning:按需微调。Networking Utilities:勾选你需要的网络工具,比如udhcpc。
# 编译并安装到临时目录 make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- -j8 make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- install CONFIG_PREFIX=/tmp/rootfs # 确认生成的关键文件 ls -l /tmp/rootfs/bin/busybox file /tmp/rootfs/bin/busyboxfile命令的输出里应该能看到statically linked,说明静态编译生效了。与此同时,/tmp/rootfs下会有bin、sbin、usr等目录,里面全部是指向busybox的符号链接。
3.3 编译阶段最容易迷惑的三类报错
第一类是找不到头文件,比如stdio.h: No such file or directory。这是交叉编译器没有安装到系统路径,或者CONFIG_CROSS_COMPILER_PREFIX填错,导致编译器直接退化成宿主编译。第二类是链接阶段报cannot find -lc,说明你的工具链缺少libc库文件,安装完整工具链或者配置好sysroot路径就可以了。第三类是编译过程中突然出现与时间相关的警告,clock_gettime相关符号找不到,这多发生在musl工具链和某些老版本BusyBox搭配的时候,升级BusyBox版本或工具链版本基本能解决。
如果你用芯片厂商提供的工具链,可能还带有一个独立的sysroot路径。在Makefile或者环境变量里加上CFLAGS指向sysroot,具体方法各家不一样。我的习惯是写进Makefile的EXTRA_CFLAGS里,避免每次make clean后重配。
4. 用BusyBox手工搭建根文件系统:从目录骨架到启动成功
4.1 根文件系统的目录骨架到底怎么搭
有一句话要刻在脑子里:根文件系统不是为busybox准备的,是为整个Linux系统准备的。busybox只是其中的一部分。最终设备上完整的根文件系统至少要包含这些目录:
| 目录 | 用途说明 |
|---|---|
| /bin | 基本用户命令 |
| /sbin | 系统管理命令 |
| /usr/bin | 扩展的用户命令 |
| /usr/sbin | 扩展的系统命令 |
| /etc | 配置文件 |
| /lib | 动态库和内核模块 |
| /dev | 设备节点 |
| /proc | proc虚拟文件系统挂载点 |
| /sys | sysfs虚拟文件系统挂载点 |
| /tmp | 临时文件 |
| /var | 运行时数据 |
| /root | root用户的home目录 |
| /home | 普通用户home目录 |
| /mnt | 手动挂载点 |
| /opt | 可选的附加软件目录 |
有一个细节我强烈建议新手注意:在做成镜像之前,先把这些目录在PC上建好,再逐一核对。很多根文件系统起不来的问题,不是配置错了,而是缺了某个目录。比如/var下缺了/var/log,syslogd会启动失败;/tmp没有可写权限,很多临时文件操作会报错。
ROOTFS=/tmp/rootfs mkdir -p $ROOTFS/{bin,sbin,etc,lib,usr,proc,sys,tmp,var,root,home,mnt,opt,dev} chmod 1777 $ROOTFS/tmp/tmp设置1777权限属于强烈的推荐操作。不设的话,普通用户进程往/tmp写文件时会以Permission denied告终。
4.2 busybox --install与符号链接:手动建还是自动建
前面make install已经生成了一套符号链接。但如果你拿到的是一个现成的busybox二进制而没有那套链接,在设备上可以直接运行:
/bin/busybox --install -s-s参数表示用符号链接方式安装,不消耗额外Flash空间。不带-s的话,busybox会把每个applet的硬链接创建出来,或者直接复制一份完整busybox二进制——这显然更浪费空间。
这个命令一般在最早期的手工搭阶段非常有用。你甚至可以在设备启动后,在rootfs挂载阶段调用它重建全套命令。但要注意,在chroot或系统尚未完全启动时,--install的目标目录必须存在,并且busybox自身要能被找到。有一种鸡生蛋蛋生鸡的场景:你想用ls查看/bin目录,但/bin/ls这个符号链接还没建——这时候直接/bin/busybox ls /bin就够了。
4.3 设备节点:最容易被“失踪”的基础设施
设备节点的处理,是老一代嵌入式工程师和新手之间一个很重要的认知分水岭。如果你习惯桌面Linux,你几乎感知不到设备节点的存在,因为udev自动帮你创建了。但在一个精简的嵌入式系统里,udev是一个比较重的组件,很多时候我们用mdev或者干脆手动创建关键节点。
至少这四个设备节点是内核启动后立即需要的:
mknod -m 600 $ROOTFS/dev/console c 5 1 mknod -m 666 $ROOTFS/dev/null c 1 3 mknod -m 666 $ROOTFS/dev/ttyS0 c 4 64 mknod -m 666 $ROOTFS/dev/zero c 1 5/dev/console是内核打印信息的输出目标,权限设置成600是常见做法。/dev/null和/dev/zero的作用不用多说,很多命令和脚本依赖它们。/dev/ttyS0是串口控制台设备节点,如果你的板子串口对应的是ttyS0就用这个。
如果你还需要访问SD卡、USB设备,可以提前放好mmcblk0、sda等节点,也可以依赖后面的mdev自动创建。不过从调试的角度看,我建议在根文件系统里提前把SD卡节点建好,因为mdev需要内核事件触发,如果开机阶段还没有uevent,设备节点不会凭空出现。
4.4 内核启动参数的传递:从uImage到root=的完整链路
在引导加载U-Boot的阶段,内核启动参数一般由环境变量bootargs传递。典型的bootargs长这样:
console=ttyS0,115200 root=/dev/mmcblk0p2 rootwait rwconsole=ttyS0,115200告诉内核串口控制台用哪个设备、什么波特率。root=/dev/mmcblk0p2表示根文件系统在SD卡的第二分区。rootwait表示等待设备节点出现,如果省掉这个参数,早期启动阶段内核可能因为找不到根设备直接kernel panic。rw表示以读写方式挂载根文件系统,这在调试时非常有用。
我见过不少人卡在“内核启动到一半panic”的状态,最后发现是bootargs里的console参数和实际板子的串口编号对不上。比如高通的板子可能是ttyMSM0,全志的板子可能是ttyS0,这个不能一概而论,得看内核里的串口驱动注册名。如果内核本来就支持多个串口,可以先用earlyprintk把早期日志打到所有串口上,一次就能定位到底该用哪个别名。
5. 启动流程的完整解剖:inittab、rcS与控制台的那点事
5.1 BusyBox init和SysVinit的异同
当内核启动并挂载好根文件系统后,它会执行/sbin/init。在BusyBox体系里,init就是busybox的符号链接之一。它完成三件主要的事情:读取/etc/inittab、执行系统初始化脚本、为每个控制台启动登录shell。
BusyBox init的语义是SysVinit的一个子集,但有很多细节差异。最典型的是对/etc/inittab的解析方式。SysVinit里可以配置各种复杂的runlevel,BusyBox init简化了很多,重点支持以下几种action:
| action | 触发时机 |
|---|---|
| sysinit | 系统启动时最先执行 |
| respawn | 进程结束后自动重启 |
| askfirst | 终端上第一次按键后再启动进程 |
| wait | 启动后等待进程结束 |
| once | 启动一次,进程结束后不重启 |
| ctrlaltdel | Ctrl+Alt+Delete触发 |
| shutdown | 系统关机时执行 |
5.2 一份可以直接抄的inittab
我们项目里用的一份最简inittab长这样:
::sysinit:/etc/init.d/rcS console::askfirst:-/bin/sh ::restart:/sbin/init ::ctrlaltdel:/sbin/reboot ::shutdown:/bin/umount -a -r解释一下每一行的意思。第一行,启动时执行/etc/init.d/rcS脚本,这是整个用户空间初始化的起点。第二行,在console这个终端上等待用户按一次键,然后启动一个login shell。-前缀表示login shell,它会执行完profile相关的初始化再交给你交互提示符。第三行是init进程自己重启时的入口。第四行和第五行分别是热键重启和关机时卸载文件系统。
有个非常常见的坑:如果你在inittab的第一行写的是console::sysinit:/etc/init.d/rcS,而你的板子的确存在/dev/console节点,那么rcS执行的stdin/stdout会指向console,shell的输出确实能看到。但如果你不指定设备或者指定了不存在的设备,rcS里的所有输出会淹没在/var/log/messages里,你盯着串口终端可能什么都不显示,还以为系统卡死了。
“can't access tty; job control turned off”这句警告,为什么会出现?因为它试图为当前shell分配一个控制终端,却找不到可用的tty。于是shell只能以“无工作控制”模式运行,Ctrl+C和前后台任务切换都会失灵。在开发早期,如果板子只有一个串口,简单粗暴的做法就是inittab里把console那个条目配上并保证/dev/console存在,基本就不会有这个警告了。
5.3 rcS脚本里到底该放什么
/etc/init.d/rcS是整个用户空间初始化的入口。它通常是一个shell脚本,按顺序做几件事:挂载虚拟文件系统、配置主机名、启动mdev、初始化网络。
#!/bin/sh # 挂载基础虚拟文件系统 mount -t proc proc /proc mount -t sysfs sysfs /sys mount -t tmpfs tmpfs /tmp # 设置主机名 echo "mytestboard" > /etc/hostname hostname -F /etc/hostname # 初始化设备管理 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注意mount -t tmpfs tmpfs /tmp,这个挂在前面搭建骨架时提过。tmpfs是基于内存的文件系统,数据重启即失,但对嵌入式系统来说,把临时文件放内存里效率高而且不伤Flash寿命,是常规操作。
这里有一个我要重点提醒的坑:mdev -s必须在/sys挂载成功之后再跑,否则-s扫描不到sysfs里的设备信息,设备节点就创建不全。有些板子内核里没有CONFIG_UEVENT_HELPER,那么写/proc/sys/kernel/hotplug这一步可能没有效果,这种时候mdev -s就是创建设备节点的主力,即使热插拔事件辅助失效,冷插拔设备的节点也能靠-s补上。
5.4 一个真实的启动debug案例
我记得有次调试一块板子,内核启动完到用户空间之后,串口上只有init started之后就彻底静默了。当时的直觉是rcS卡住了,于是我改了bootargs,在内核启动参数里加init=/bin/sh,直接绕过init跳过inittab进shell。进去之后手动挂载/proc、/sys,一步步排查,最终发现是rcS里有个mount NFS的语句,服务器没响应,mount命令卡在TCP重传上。
这个经验很直接:当用户空间启动卡住时,第一件事情不是猜,而是用init=/bin/sh绕过整个init体系,手工逐条执行脚本命令。这让你能在最小环境里快速定位是哪一步在阻塞。
6. 给BusyBox配上SSH:集成Dropbear的关键细节
6.1 为什么是Dropbear而不是OpenSSH
嵌入式系统上做远程登录,Dropbear和OpenSSH都可以,但我实际项目里几乎全部用Dropbear。原因有四条。第一,Dropbear的二进制体积大约是OpenSSH的三分之一左右。第二,它会话建立速度更快,握手时间在嵌入式设备上感知明显。第三,配置项精简,适合在固定环境里使用。第四,它的授权协议是MIT,对商用产品比较友好。
Dropbear包含两部分:dropbear是服务端,dropbearkey是密钥生成工具。在嵌入式系统里,一套最简单的部署策略是:在主机上生成host key,然后拷贝到开发板的/etc/dropbear目录,之后启动dropbear进程即可。
6.2 静态编译与libc依赖问题
如果你在主机的交叉编译环境里编译Dropbear,最稳妥的方式还是静态链接。注意,Dropbear默认是链接动态库的,配置时加--enable-static可以对可执行文件启用静态链接。此外,还需要把--disable-zlib打开,因为zlib的依赖对嵌入式设备来说也是一种负担,而且Dropbear在无zlib情况下能正常使用。
一个比较阴间的坑是:如果你的整个系统都是glibc环境,而Dropbear用了musl静态编译,那么在上层应用通过PAM或者nsswitch查询用户信息的时候,可能会遇到读取不了shadow密码的问题。Dropbear默认不依赖PAM,默认用/etc/passwd和/etc/shadow验证,所以纯静态编译一般问题不大。但假如你启用了--enable-pam,就不得不考虑和系统的PAM模块以及动态库之间的互动,这时候静态编译反而不一定是好选择。
6.3 密钥、首次启动与开机自启
生成host key的命令:
dropbearkey -t ed25519 -f /etc/dropbear/dropbear_ed25519_host_keyed25519的密钥比RSA小,生成速度也快,适合嵌入式。生成一次以后,这个密钥文件要保留好。设备重启后如果密钥变了,客户端的known_hosts会提示host key不匹配,影响使用体验。
在rcS中集成启动逻辑:
# 在rcS脚本中添加 mkdir -p /etc/dropbear if [ ! -f /etc/dropbear/dropbear_ed25519_host_key ]; then /usr/sbin/dropbearkey -t ed25519 -f /etc/dropbear/dropbear_ed25519_host_key fi /usr/sbin/dropbear -R-R参数表示如果没有指定的host key,就自动生成一个随机的临时密钥。这个选项对调试很方便,因为省去了首次启动前手工生成密钥的操作。但生产环境建议还是用持久化的host key,不然每次重启客户端都要确认指纹变化。
6.4 用scp传文件的小细节
Dropbear提供了dbclient,但它和OpenSSH的scp不直接兼容。跨端传文件我一般用两种方案。方案一:在PC端装一个pscp(PuTTY工具集),或者直接用scp -O命令,但可能需要在PC端安装dropbear兼容工具。方案二:在板子上跑scp命令,如果BusyBox自带的scpapplet和另一端Dropbear服务端配合有问题,就改用busybox nc手动传。
我的实测结果是:PC端用OpenSSH的scp往Dropbear服务端传,偶尔能通偶尔卡住,跟Dropbear版本和算法协商有关。最省事的其实是直接在PC上执行dropbear的dbclient来连板子,或者用sftp的替代工具。过程有点绕,但胜在稳定。
7. NFS挂载根文件系统:调试效率翻倍的工作笔记
7.1 为什么NFS根文件系统是嵌入式开发者的加速器
嵌入式开发中反复烧写Flash是个非常折磨人的过程。每改一个小脚本,都要重新制作镜像、烧录、重启,一次循环下来十分钟没了。NFS根文件系统的思路是:板子通过以太网,把PC上某个目录当作根文件系统挂载起来。这样你在PC上改代码、改配置,板子重启后直接看到最新状态,整个过程省掉了烧写的等待。
我自己的项目里,一旦板子和PC的以太网都能通,调试阶段几乎都会把NFS根文件系统作为默认启动方式。只有到了发布阶段,才切回Flash或者SD卡上的正式文件系统。
7.2 服务器端配置的完整步骤
主机PC上安装NFS服务:
sudo apt install nfs-kernel-server sudo mkdir -p /srv/nfs/rootfs # 将制作好的根文件系统内容拷贝到该目录 sudo cp -a /tmp/rootfs/* /srv/nfs/rootfs/修改/etc/exports:
/srv/nfs/rootfs 192.168.1.0/24(rw,sync,no_subtree_check,no_root_squash)关键参数解释一下:
rw:板子可读写挂载。sync:同步写,保证每次写操作都直接落到主机磁盘,避免掉电丢数据。如果调试时不追求极限性能,还是开着sync安心。no_root_squash:允许板子上的root用户以root权限访问NFS文件,否则默认会映射为nobody用户,板子上很多操作会报Permission denied。
sudo exportfs -ra sudo systemctl restart nfs-kernel-serverexportfs -ra的作用是重新导出共享目录,让配置立即生效。
有一种很常见的报错是mount.nfs: Operation not permitted,多半是no_root_squash没写对,或者NFS版本协商有问题。建议在exports里强制指定版本,比如加上vers=3,可以避开一些较新的NFSv4权限模型导致的困惑。
7.3 板子端的内核命令行配置
板子的bootargs需要指定根文件系统走NFS:
console=ttyS0,115200 root=/dev/nfs nfsroot=192.168.1.100:/srv/nfs/rootfs,v3,tcp rw ip=192.168.1.50:192.168.1.100::255.255.255.0::eth0:off其中root=/dev/nfs是固定写法,告诉内核根文件系统由NFS提供。nfsroot后面跟的是服务器IP、共享目录、NFS版本和传输协议。ip=后面的格式是:板子IP:服务器IP:网关:子网掩码::网卡名:off。
关于ip=参数,有一个坑是格式里的::eth0:off,最后的off表示不自动配置IP。如果你省掉整个ip=参数,内核会尝试用DHCP自动获取IP,如果局域网里没有DHCP服务器,就会卡在“Waiting for root device”上。
7.4 sync、VFS和掉电安全的那点事
NFS根文件系统调试最频繁出现的问题之一就是:板子运行着的某些程序一直往根文件系统写数据,而此时如果板子掉电,数据可能丢失。
VFS(虚拟文件系统层)位于具体文件系统之上,负责把应用的write请求缓存到Page Cache里,然后再由pdflush/ writeback线程择机刷入实际存储介质。这种缓冲机制对性能很有帮助,但也意味着掉电瞬间数据不一定落盘。你调用sync命令能强制刷盘,但你的应用如果不主动sync,内核的数据要等一段时间才会真正写下去。
在NFS调试时,如果你发现板子上跑了一个应用,改完配置文件后立即断电,再上电后配置还是旧的,那多半是Page Cache里的数据没来得及刷到主机。这不一定非得改程序,你可以调整内核参数:
echo 1 > /proc/sys/vm/dirty_background_ratio echo 2 > /proc/sys/vm/dirty_ratio echo 100 > /proc/sys/vm/dirty_writeback_centisecsdirty_writeback_centisecs单位是百分之一秒,100表示每1秒唤醒一次writeback线程。这个值越小,数据驻留内存的时间越短,掉电丢失的风险越低,但写性能会下降。实际情况中,嵌入式设备掉电频繁的场景,建议把dirty_writeback_centisecs调到50(0.5秒)。
7.5 NFS根文件系统的权限问题
NFS挂载后,板子上root用户的uid是0,PC端共享目录里的文件如果属于另一个用户,权限映射就可能会出现奇怪的现象。
例如,PC端某个目录的所有者是uid 1000,板子上root用户访问这个目录,由于no_root_squash的存在,它能读能写,但创建的新文件owner会变成uid 1000对应的用户。这在实际调试中很吓人——明明我是root,创建出来的文件却属于别人。解决办法是在PC端把目录所有者也改成root,或者调整共享参数加入all_squash加上anonuid、anongid的映射。
8. 从实战收尾:版本差异、Android对比与断舍离经验
8.1 老版本BusyBox在新时代的兼容性问题
项目中如果你拿到的是开发板厂商提供的旧源码包,BusyBox版本还停留在1.22甚至更老,这时候最好看一下里面的补丁历史。老版本的BusyBox,比如1.22,在某些新内核对mount的解析支持有问题,或者对新的文件系统类型如f2fs支持不完整。
比较典型的是busybox v1.22.1在较新内核上,ps命令读取/proc信息时可能会显示不完整,某些内核版本下还会出现process information unavailable之类的输出。如果遇到这种情况,优先考虑把BusyBox单独升级到比较新的稳定版,比如1.36.x系列,而不是去修它。
旧版BusyBox另一个常见问题是对mdev的固件加载支持不完善。很多设备需要内核加载固件时,hotplug事件触发/sbin/mdev,但老版本mdev对firmware请求处理有bug,固件永远加载不进去,设备初始化失败。新版本在这方面修复很多。
8.2 Android的根文件系统为什么“不用”BusyBox了
顺着热词里提到Android的根文件系统,这里多说一句。Android早期确实使用过BusyBox的方案,但后来它构建了一个自己专门的操作系统用户空间层,底层启动文件系统由system/core里的init、toolbox按需构建。Android 10之后,toolbox逐渐被toybox取代。toybox的思路和BusyBox很像,但它的代码更专注于Android环境下的命令实现,且因为许可协议是BSD风格,对于Google的AOSP整合更友好。
对我们做嵌入式Linux的人有什么启发?我觉得最重要的是:BusyBox不是唯一的applet聚合工具,也不是永远的最佳选择。如果你的产品对许可合规有特别要求,或者你需要命令行为与Android生态高度一致,可以考虑toybox。但就通用嵌入式Linux开发调试来说,BusyBox依旧是最成熟、资料最多、社区经验最充沛的方案。
8.3 我踩过几次坑之后养成的使用习惯
构建根文件系统,我的固定流程是先跑make defconfig再手动开CONFIG_STATIC和几个必要的feature,而不是直接从别人的配置文件开始。原因是你不知道别人的配置里开了哪些私有patch,出了问题无从排查。从最保守配置逐步开,每开一项就做一次验证,有问题能准确定位到是哪个特性引起的。
编译阶段我的习惯是把CONFIG_PREFIX指到一个干净的临时目录,这一点已经强调过。注意顺序:make clean之后重新编译,一定要把目标目录清空,否则旧符号链接残留会导致奇怪的问题。我遇到过在目标目录里运行ls命令报“No such file or directory”,排查半天才发现是上次残留的符号链接指向了一个不存在的位置。
设备节点方面,我的习惯是手动创建console、null、zero、ttyS0四个节点,其余的交给mdev。但USB设备这种需要枚举才能确定的节点,光靠提前创建没用,还是要跑mdev -s或者依赖内核的uevent回调。很多人说“为什么我的U盘插上去没有/dev/sda”,十有八九是mdev的hotplug链路没有配置好。
还有一个小经验,在脚本调试阶段,把/etc/init.d/rcS里的命令逐条加上echo "=== running xxx ==="这类日志输出,能大大缩短定位时间。脚本一旦多了,没有进度提示的启动过程非常煎熬。这些日志最后在一两轮调试后可以删掉,但调试阶段它们价值巨大。
8.4 最后的建议:给新手的启动路径
如果你完全没接触过BusyBox,我给的建议路径是这样的:先在一台x86虚拟机上手动编译并运行一个基于BusyBox的最小系统,用qemu-system-x86_64跑起来。这一步没有硬件调试的干扰,能让你把Linux启动本质——内核加载、根文件系统挂载、init执行、shell交互——整条链路彻底理解清楚。
然后再上到ARM板子,这时候你已经知道哪些步骤属于通用Linux,哪些属于板子特有逻辑,问题定位会快非常多。一味拿开发板从头试错,很容易把“我网络没配好”和“我根文件系统有问题”混在一起,排查起来非常痛苦。
我个人在实际操作中最深的感受是:BusyBox这个工具,看起来只是一个“小型命令集合”,但只要深挖到它的init机制、applet实现、编译方式、和内核的配合关系,你几乎能把整个嵌入式Linux系统的启动链路完整串一遍。这只瑞士军刀,真正值钱的地方不是它有多锋利,而是你拿着它,能把一台“裸奔”的Linux设备从零“盘活”。