BusyBox实战:从原理到构建嵌入式Linux根文件系统
2026/9/7 12:43:34 网站建设 项目流程

1. 先搞清楚它凭什么被称为“瑞士军刀”

做嵌入式Linux开发的人,十有八九都见过这样一个场景:板子启动后进入shell,你敲ls、cat、mount、ifconfig、ps,这些命令统统能用,但一看/bin目录,里面只有一个孤零零的busybox文件,剩下的全是软链接。第一次见的人多少会愣一下,还以为文件系统坏了。我当时也干过这种事,后来才明白,这不是文件系统坏,而是BusyBox天生就是这种设计。

BusyBox的核心思路,是用一个二进制文件提供上百个常用Unix命令的实现。它最初由Bruce Perens在1996年为Debian安装程序设计,后来一路在嵌入式领域扎根,几乎所有基于Linux的路由器、机顶盒、工业控制板、物联网设备里都能看到它的身影。之所以叫“瑞士军刀”,是因为它把ls、cp、mv、sh、init、telnetd、httpd这些原本各自独立的工具,全部折叠进了一个可执行文件里。这种设计对嵌入式Linux来说意义重大:闪存空间通常只有几MB,一个动态链接的BusyBox二进制体积能压到几百KB,换来的却是几十上百个可用命令,这笔账怎么算都划算。

这篇文章不会只停留在“BusyBox很牛”这个层面,我想带着大家从内到外拆一遍:它的applet机制到底怎么工作、构建和裁剪要动哪些配置、如何基于它从零做出一个能启动、能挂NFS、能远程登录的根文件系统。内容会尽量贴近我实际调板子时的操作路径,不是照抄官方文档,而是把容易踩坑的地方一并讲明白。适合正在学嵌入式Linux、准备自己动手构建根文件系统的朋友,也适合那些明明能跑起内核、却在init阶段一头雾水的开发者。

在往下看之前,请先建立一个认知:BusyBox不是简单的“命令合集”,它是一个可以深度定制的运行时环境。你完全可以通过menuconfig像裁剪内核一样裁剪它,也可以在它的基础上添加第三方程序,比如Docker里那些极简镜像、路由器固件里的Web管理界面,底层都有类似的设计逻辑。理解了BusyBox,你对整个嵌入式Linux用户空间的启动链路,就相当于拿到了第一把钥匙。

2. 从原理入手:一个二进制如何变出几百个命令

2.1 applet表、符号链接与main函数的“分身术”

很多第一次接触BusyBox源码的人,都会对它的目录结构产生极大兴趣。看第一眼感觉这似乎是一个完整的Linux用户态工具集,实际上它的源码确实包含了ls、cat、sed、awk等命令的独立实现文件,但它们大部分没有单独的main函数入口,而是被统一编译进了一个静态库,再由最顶层的main函数根据传入的argv[0]来分发。

关键机制叫“applet表”。在BusyBox源码里,有一张静态表,里面记录了每个命令的名字、ID、是否启用、对应的main函数指针。当你在shell里输入ls,内核执行的不是busybox这个文件里某个叫ls的函数,而是通过软链接、硬链接或者shell的内建方式调起busybox,然后BusyBox拿到argv[0],在applet表里找到ls对应的条目,把控制权转交给这个条目的执行函数。

这里的实现细节有两个。一个是“all_applets”数组,它由include/applet_tables.h生成,根据menuconfig的选择动态决定哪些applet会被编译进去。另一个是main函数顶部有一段dispatch逻辑:

const struct applet *applet = find_applet_by_name(argv[0]); if (applet && applet->main) exit(applet->main(argc, argv));

代码本身不复杂,但这里非常考究。如果直接比较字符串,效率会低,所以BusyBox使用了基于哈希的查找表,把命令名映射到索引。还有些特殊applet,比如[这种符号,在正常的文件名规则里很难处理,它也单独做了兼容。

这种设计的核心收益在于:所有命令共享同一段基础运行库代码、相同的启动逻辑、相同的链接配置。如果每个命令都编译成独立二进制,那么单是动态链接库的结构、libc的引用和符号解析,就会消耗掉大量存储空间。而合并成一个二进制后,代码段可以共享,空闲的applet不会占用空间,只生成一些表项而已。

2.2 静态链接和动态链接的选择,为什么这么重要

接下来一个绕不开的问题是:BusyBox的二进制到底该静态链接还是动态链接?这个选择直接决定你根文件系统里的lib目录要装多少东西。

静态链接的意思是把所有用到的C库代码都打进busybox可执行文件里,这样它在运行时完全不依赖目标板上的动态链接库。好处是部署极其简单,一个busybox文件拷过去就完事;坏处是体积变大,而且如果要升级C库,必须重新编译整个busybox。动态链接则相反,busybox本体很小,但系统里必须具备对应版本的libc.so和动态链接器ld-linux.so,如果版本对不上,跑起来就是各种段错误或者加载失败。

我在实际项目里的习惯是:如果是初始开发调试阶段,用动态链接会更灵活,因为板上通常还有glibc或其他C库,包管理器也能装上依赖;但到了量产固化阶段,为了稳定和简化升级,我几乎都会改成静态编译。尤其是一些极小内存的MCU+Linux方案,静态BusyBox配合一个自制的轻量级libc(比如musl),整个用户空间可以压到1MB以内,这对产品固化非常有利。

还有一个容易忽略的细节:BusyBox的构建配置里,“Settings -> Build Options”可以指定交叉编译器的前缀,比如arm-linux-gnueabihf-。这里的交叉编译器选择要和你编译内核用的编译器保持一致。我之前有一次就是用不同版本的编译器分别编译内核和BusyBox,结果板子上跑的时候,ls看起来正常,但mount挂载时出现奇怪的参数传递错误,查了一整天才发现是工具链版本引起的ABI不兼容。如果想让经验更稳,直接使用同一套交叉编译工具链,最好连版本都不换。

3. 实战前的全局规划:目标板、编译环境与目录骨架

3.1 明确你的启动方式和存储介质

做根文件系统,第一步不是马上敲make命令,而是先想清楚几个问题:你的内核从哪启动、文件系统最终放在哪、用什么方式挂载。这些听起来像废话,但方向错了,后面会白忙一场。

常见启动方式有三种:

  • 内核在SD卡/emmc上,根文件系统也放在同一块存储介质的分区里,通过root=/dev/mmcblk0p2这类参数传给内核。
  • 内核在NAND闪存上,根文件系统做成JFFS2/UBIFS镜像,挂载后进入用户空间。
  • 开发调试阶段,板子通过PXE或者U-Boot的tftp方式加载内核,根文件系统通过NFS从宿主机挂载,板子上不放任何用户空间数据。

第三种方式在日常开发中很常见,因为它能快速迭代,编译完新文件系统直接改宿主机目录,板子重启就能看到变化。我之前调驱动时,就是让开发板通过NFS挂载宿主机上的一个目录,里面装着一份手工构建的BusyBox根文件系统,省去了频繁烧写存储介质的痛苦。但要注意,NFS挂载依赖网络,U-Boot和内核的网络配置必须稳定,否则启动调优时网线一松会非常难受。

不论选哪种,根文件系统本身的目录结构要提前设计好。传统Linux根文件系统至少有/bin、/sbin、/usr、/etc、/proc、/sys、/dev、/tmp、/var、/mnt等目录。这些目录不一定都需要很大的内容,但目录本身得存在,否则有些程序会拒绝工作。我在构建最小系统时,最开始只建了/bin、/sbin、/etc、/dev、/proc、/sys和/app,后面跑业务程序时才发现缺/var/run,导致进程PID文件无处可写,又回头补了一次。提前设计完目录再动手,能省不少来回。

3.2 交叉编译工具链和BusyBox的版本选择

工具链选型,我建议优先考虑目标板官方BSP自带的工具链。如果板子用的是Buildroot或者Yocto构建出来的系统,也可以直接从它们的toolchain目录里取出交叉编译器。自己手工下载Linaro GCC也可以,但要确认目标板的内核和Bootloader使用的是同一套ABI。比如ARMv7板子,有的用hard-float,有的用softfp,混用的话运行概率性崩溃。

BusyBox版本这块,我经常看到有人在网上问“哪个版本稳定”。我的经验是别追太新的版本,也别用太老的。太新的版本可能调整了配置项或者依赖新内核特性,太老的版本则可能在比较新的内核上出现兼容性问题。比如一些基于CentOS改造的发行版,自带的busybox版本可能还停留在1.22.x,但如果你想在Ubuntu主机上重新编译,就会遇到一些Kbuild配置的差异。建议直接去busybox.net下载最近一两年的stable版本,比如1.36.x系列,功能和稳定性都比较均衡。

除此之外,版本号还会影响你的内核启动参数。早期BusyBox的init程序对控制台设备名、环境变量的处理,和现在的实现有细微差异。如果内核启动参数里没有配好console参数,你会看到内核启动日志完了之后一片空白。那不是系统挂了,而是init进程没找到合适的控制台把shell输出送出来。关于这个细节,后面专门讲init时再说。

4. 构建根文件系统的核心步骤:从编译到落地

4.1 下载源码、配置menuconfig并完成编译

我用一个具体例子带大家走一遍。假设目标平台是ARM Cortex-A7,交叉编译前缀是arm-linux-gnueabihf-,宿主机是Ubuntu 22.04。下面是完整命令流,其中每步的目的我都会点一下。

# 1. 下载并解压源码 wget https://busybox.net/downloads/busybox-1.36.0.tar.bz2 tar -xjf busybox-1.36.0.tar.bz2 cd busybox-1.36.0 # 2. 设置环境变量,告诉编译系统使用哪个编译器 export ARCH=arm export CROSS_COMPILE=arm-linux-gnueabihf- # 3. 生成默认配置,然后进入菜单定制 make defconfig make menuconfig

进入menuconfig之后,重点调整几个位置:

  • Settings -> Build Options -> Build static binary(no shared libs):量产推荐选中,开发可以考虑不选。
  • Settings -> Busybox Installation Prefix:设置make install的安装目录,我常用./_install,方便后面做文件系统根目录。
  • Coreutils -> 按需选择:如果你写脚本用到了特定命令,要确认对应的applet已启用。一般默认配置已经足够,但如果你要裁剪空间,可以在这里关掉不用的命令。
  • Networking -> 勾选需要的网络工具:比如ifconfig、ping、route、telnetd等,要远程调试就务必勾上。

配置完后执行:

make -j$(nproc) make install

如果一切顺利,_install目录下会出现bin、sbin目录,以及busybox可执行文件,bin下还有一堆指向../bin/busybox的软链接。如果你配置的是动态链接,就会遇到ldd提示依赖libc.so.6、ld-linux-armhf.so.3等。那么这些库文件就得从交叉工具链的sysroot目录里拷贝过来,后面会说具体路径。

4.2 完善根文件系统目录、设备节点和关键动态库

编译完BusyBox只是第一步。要让这个根文件系统真正启动,还得补全很多周边内容。

先搭目录骨架:

cd _install mkdir -p proc sys dev etc/init.d tmp var/mnt root home

然后是设备节点。如果内核启动时使用devtmpfs,那/dev目录理论上可以由内核自动生成。但为了保险,至少得手动创建两个节点:

sudo mknod -m 666 dev/null c 1 3 sudo mknod -m 666 dev/console c 5 1

这两个设备节点极其重要。/dev/null没有的话,很多程序重定向时会直接报错;/dev/console没有或者属性不对,内核把控制台交给init后,shell的输入输出可能送不到串口终端上。内核日志里如果看到Kernel panic - not syncing: Attempted to kill init!,有不少情况就是/dev/console出了问题。

动态链接的BusyBox需要复制动态库。可以用交叉编译器定位sysroot:

arm-linux-gnueabihf-gcc -print-sysroot

假设输出是/opt/toolchain/arm-linux-gnueabihf/libc,接着把libc.so.6、ld-linux-armhf.so.3(名称因架构不同而异)拷到根文件系统合适的位置。可以建一个lib目录,也可以放在/lib/arm-linux-gnueabihf下,具体看动态链接器的搜索路径。最笨也最稳的方法是先用ldd看看busybox依赖哪些库:

arm-linux-gnueabihf-readelf -d busybox | grep NEEDED

把列出的库逐一复制过去,同时复制符号链接本身,而不仅仅是文件内容。很多刚起步的朋友就是漏了这个,结果把动态库复制成了普通文件,符号链接失效,加载器根本找不到。

4.3 制作镜像文件或配置NFS根目录

有了_install目录内容,接下来有两种落地方式。

第一种是用NFS直接调试。把整个_install目录设为NFS导出目录,在宿主机/etc/exports写入类似:

/home/user/rootfs *(rw,sync,no_root_squash,no_subtree_check)

然后重启NFS服务。开发板内核启动参数加上:

root=/dev/nfs nfsroot=192.168.1.100:/home/user/rootfs,v3,tcp ip=dhcp

注意,如果你在U-Boot环境变量里配置,还要记得bootargs中要保留console=ttyS0,115200这种参数。

第二种是做磁盘镜像。最常见的做法是建一个raw镜像文件,格式化成ext4,挂载后把文件系统内容拷贝进去:

dd if=/dev/zero of=rootfs.img bs=1M count=64 mkfs.ext4 -d _install rootfs.img

这里-d参数会把指定目录内容直接写入镜像,非常方便。做完之后把镜像放到SD卡分区或emmc对应分区里,内核参数指定root=/dev/mmcblk0p2 rootwait,就能启动了。

两种方式对比下来,开发阶段用NFS,固化阶段做镜像,是嵌入式Linux项目里最常见也最顺手的操作路径。

5. init进程与rcS脚本:系统启动后真正的“第一现场”

5.1 内核最后的动作,就是唤醒init

每次看到板子在串口输出“Freeing unused kernel memory”之后,我就知道马上要看到用户空间的第一行日志了。内核在完成硬件初始化、挂载好根文件系统后,会按照init=这个内核参数去执行第一个用户空间进程。如果没显式指定,它会在/bin/init、/sbin/init、/etc/init这几个候选路径里找。绝大多数根文件系统中,/linuxrc或/init这样的路径也会被特殊处理,但实践中我们用的还是/sbin/init。

BusyBox的init和标准SysVinit的机制类似,但精简了不少。它会读取/etc/inittab文件,里面定义了系统默认运行级别、需要在哪些运行级别启动哪些程序、哪些程序需要被init托管(比如重新生成shell)、哪些是“等待”型任务或“响应Ctrl+Alt+Del”的操作。在BusyBox里,即使没有inittab文件,init也能用一套默认规则跑起来,最常见的效果就是启动后直接给你一个可用的shell。所以如果你看到“Please press Enter to activate this console”,就是inittab里配置了::respawn:/sbin/getty -L ttyS0 115200 vt100这类行,但按回车才能激活登录提示。有些朋友以为板子卡住了,其实只是没按回车。

我个人的习惯是把默认规则显式写进inittab,养成可复制的习惯。一个典型的BusyBox inittab是这样的:

::sysinit:/etc/init.d/rcS ttyS0::respawn:/sbin/getty -L ttyS0 115200 vt100 ::ctrlaltdel:/sbin/reboot ::shutdown:/bin/umount -a -r

第一行表示系统启动时第一个要执行的脚本是/etc/init.d/rcS;第二行表示在串口ttyS0上启动一个getty进程,如果它挂了init会反复拉起;最后两行分别处理Ctrl+Alt+Del和关机时的卸载操作。

5.2 我踩过的rcS脚本坑:环境变量挂载顺序和执行权限

rcS是整个启动过程中最能体现项目风格的文件。很多人喜欢在里面把mount、ip配置、启动业务程序一股脑写进去,但很容易踩到几个坑。

第一个坑是挂载顺序。/proc/sys必须最先挂载,因为后续很多命令依赖sysfs和procfs提供的信息。比如没有挂载/proc,ps会显示不出任何进程,卡顿一个样;没有挂载/sys,部分设备节点的动态生成就会出问题。我编写rcS通常按这个顺序:

#!/bin/sh mount -t proc none /proc mount -t sysfs none /sys mount -t devtmpfs devtmpfs /dev mkdir -p /dev/pts mount -t devpts devpts /dev/pts ifconfig eth0 up udhcpc -i eth0 echo "Starting rcS done"

第二个坑是执行权限。rcS脚本如果没有可执行权限,系统启动时init会尝试调用脚本解释器,但实际执行的是/bin/sh /etc/init.d/rcS。如果/bin/sh因为软链接失效而缺失,你会看到什么都起不来。所以我建议手头保留一个绝对路径下的dash或bash,并确保/bin/sh -> /bin/busybox的软链接存在。

第三个坑是环境变量。BusyBox init在启动rcS时,不会像普通登录shell那样自动加载完整的PATH。如果你的rcS里调用了一个放得比较深的可执行文件,最好用绝对路径。我遇到过好几个项目,业务程序明明编译好了,放在/opt/myapp,结果rcS里写myapp,启动时报not found,改成/opt/myapp后一切正常。

6. 把根文件系统做成“可远控的设备”:网络和SSH集成

6.1 集成Dropbear,让板子可以被远程登录

嵌入式设备上装OpenSSH太重,最常用的替代方案是Dropbear。它专门为小内存、低存储环境打造,单个二进制也不大,却能提供SSH服务端和客户端功能。配合BusyBox,就是一套完整的远程管理方案。

我推荐用Dropbear的过程也比较固定:先在宿主机交叉编译,再把二进制和依赖库放到根文件系统里。假设Dropbear源码根目录为dropbear-2022.83

./configure --host=arm-linux-gnueabihf --disable-zlib make PROGRAMS="dropbear dropbearkey"

--disable-zlib是为了省空间,如果不需要压缩传输可以关掉。编译完后生成dropbear和dropbearkey,把它们拷到根文件系统/usr/sbin目录,并创建必要的运行目录:

mkdir -p /etc/dropbear

然后生成主机密钥:

dropbearkey -t rsa -f /etc/dropbear/dropbear_rsa_host_key

在inittab里加一行,让dropbear在系统启动后作为守护进程运行:

::respawn:/usr/sbin/dropbear -R -I 1200

-I参数是空闲超时,防止空闲连接占用过多资源。重新启动后,理论上就能从宿主机用ssh root@板子IP登录了。如果遇到连不上,先检查根文件系统里有没有/lib/libc.so.6这类依赖库,以及dropbear可执行文件是否在动态链接时缺了符号。也可以用/usr/sbin/dropbear -E -F前台调试模式看看输出。

6.2 为什么/proc、/sys和/dev的挂载顺序决定生死

这个点的坑我在前面提到过,但它值得单独拿出来强调。NFS挂载或从SD卡启动时,很多人以为只要root参数指向正确,系统就能顺利用起来,但其实很多服务都要靠挂载虚拟文件系统才能正常工作。

/proc是进程信息虚拟文件系统,里面包含系统内存、CPU、中断等状态。许多BusyBox applet,比如ps、free、top,都是通过读取/proc来获取数据的。如果/proc没挂载,ps会报错或显示空列表,free看到的也全是零。/sys则用于导出内核设备和驱动的信息。现代的udev/mdev设备管理都要依赖sysfs。BusyBox自带的mdev是设备节点自动管理方案之一,它监听内核uevent,在/dev下动态创建设备节点。如果/sys没挂载,mdev就收不到事件,设备节点自然不会出现。

/dev挂载顺序也很讲究。如果你先启动了一个业务进程,它打开某个外部串口设备文件,但/dev还没有初始化好,打开就会失败。所以rcS里我会把proc、sys、dev的挂载放在一切业务启动之前,且devtmpfs挂载完毕后还要额外执行一次mdev -s,用来扫描已经存在的设备节点并补全。这段顺序,在很多Bootloader引导脚本异常、串口无输出的排查中,是第一个要怀疑的地方。

7. 体积与性能:当“能用”还不够时,如何继续压榨

7.1 从静态编译到strip,再到移除无用符号

如果一个BusyBox静态编译出来的二进制都嫌大,通常第一件事就是先strip一把。strip会把可执行文件的符号表和调试信息去掉,体积能缩小两三成。这个动作我在交叉编译后经常做:

arm-linux-gnueabihf-strip busybox

但是strip不是万能的。如果将来想用gdb定位问题,保留带符号的版本会方便很多。最稳妥的做法是保留一个带符号的副本用于调试,量产固件里只放strip过的版本。

比strip更进一步的裁剪方式是重新审视menuconfig中的配置。BusyBox默认配置里其实开了非常多applet,有的项目根本不需要比如su、mount工具里的某些变体。把它们关掉,可以明显减少最终二进制的大小。我碰到过一个路由器项目,通过逐个选项裁剪,BusyBox从1.1MB降到了700KB左右。对于那些只有2MB flash的产品来说,这个空间的节省是非常可观的。

如果你的根文件系统里还引入了其他程序,可以用readelf -Sobjdump检查代码段、数据段占用,看看有没有不必要的字符串或全局变量。但一般不要过度优化,稳定性和可维护性永远在空间之前。做一个极端精简的BusyBox系统并不难,难的是精简完之后还能快速排查问题。

7.2 从BusyBox向Buildroot和Yocto延伸

手工构建BusyBox根文件系统是理解嵌入式Linux的极佳路径,但在实际产品开发中,我更推荐把它作为入门或定制手段。工程化落地时,Buildroot和Yocto这类构建系统会让过程更可复现、更好维护。

Buildroot选择的默认init系统可以是BusyBox init,也可以切到systemd。它会自动处理交叉编译工具链、内核、根文件系统镜像的完整流水线。你写一个.config,指定目标平台架构、交叉工具链、软件包列表,一条make命令就能产出可烧写的image。它的底层仍然会调用BusyBox,但很多外部工具比如Dropbear、ntpclient、iptables,都被集成了进来。

Yocto则更强调灵活性和元数据的模块化。它适合大规模、多平台复用的嵌入式Linux发行版。如果你的公司一直在各种平台上做不同产品,Yocto的学习曲线虽然陡峭,但一旦搭好比Buildroot更抗变更。

从学习角度上,我不建议直接跳到Buildroot或者Yocto。先用BusyBox手工做一遍根文件系统,你会对每个环节有真实的肌肉记忆。之后再看Buildroot的makefile,会发现它不过就是把我们刚才做的事情自动化了。到那时,工具的“魔法”感消失,剩下的就是工程化选择问题。

8. 最后分享两个小技巧,以及从这套实践里沉淀下来的体会

写到这里,主体内容已经覆盖了BusyBox的核心机制、根文件系统的构建、启动过程、远程管理和体积优化。最后我想补充两个我在实际项目里一再用到、但很少写进文档的小技巧。

第一个技巧是善用busybox --list。进入一个正在跑BusyBox的设备后,敲busybox --list能列出当前编译配置里启用的所有applet,排错时先查这个列表,比逐个试命令快得多。比如我在一个现场设备上发现没有ps命令,最开始以为是文件系统损坏,后来用busybox --list | grep ps才发现只是编译时把ps裁剪掉了。这种定位方式能省下不少无谓的折腾。

第二个技巧是做好BusyBox根文件系统的“版本基底”管理。我会把一份刚构建完、能正常启动的最小根文件系统目录打成一个tar包,作为后续所有定制的基线。每次做新项目,不再从零开始,而是解压这个基线,再按需添加服务。多年实践下来,这套做法比每次重新配置、重新编译靠谱得多,也能避免环境差异带来的隐性bug。

总的来说,我在嵌入式Linux这条路上绕了不少弯路,但BusyBox始终是我最信任的“老伙计”。它看起来简单,背后却藏着UNIX哲学里最经典的组件化思想:一个程序,做一件事,但组合起来能覆盖整个用户空间。希望这篇从原理到实战的内容,能帮你少走一些我当年走过的坑,在你自己构建根文件系统时,把思路理得更顺。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询