编译内核时那种“敲一条命令、刷一屏日志、等一壶水烧开”的节奏,到了Rootfs阶段会突然变得安静很多——因为创建目录结构看起来太简单了,简单到容易让人轻视。但恰恰是这一步的草率,会让后面init进程起不来、busybox找不到库、设备节点全是乱码的问题,以“连环爆炸”的方式找上门。
我手上的这块ARM Cortex-A系列开发板,从交叉工具链搭建、内核配置到设备树适配,前几篇文章已经处理得七七八八,现在到了整个移植方案里最关键也最容易膨胀的一步:从零创建Rootfs目录结构。这篇是这个移植系列的第4篇,核心不是“背一遍Linux目录结构大全”,也不是贴一份Mkdir命令让你抄作业,而是把每一步选择背后的原因讲清楚——为什么嵌入式Rootfs要做裁剪?为什么/dev/console这个节点必须手工补?为什么init进程会反复重启?搞懂这些,你才算真正会搭Linux文件系统的骨架。
这篇内容适合正在做系统移植、刚把kernel跑起来却卡在“找不到根文件系统”这一步、或者想彻底搞懂FHS目录设计逻辑的朋友。我不讲玄乎的理论,只讲我在这块板子上从零验证过、能开机进shell的完整流程。
1. 开工前先决定三件事:存储介质、工具链路径、BusyBox形态
1.1 存储介质决定你在哪里建目录:NFS调试与镜像烧写的差异
目录结构本身,和最终承载它的Flash介质(eMMC、NAND、SD卡)没有强关联,但这决定了你的创建路径和验证方式。我在调试早期几乎全部使用NFS挂载Rootfs——就是把开发机的某个目录通过NFS共享给开发板,内核启动后通过网络把这块目录当作根文件系统挂载。
选NFS起步不是图省事,而是因为嵌入式开发初期,Rootfs内容一天要改几十次。每次编译完busybox或者改完inittab,直接改开发机上的文件就能生效,不用反复烧写Flash,这对排查启动问题来说效率是碾压级的。等你把Rootfs内容调稳定了,再考虑打成ext4镜像或者烧进eMMC。
所以我的建议是:第一步目录创建,就在开发机的/opt/rootfs(或者你习惯的任意路径)下完成。后续要在板子上跑,要么用NFS挂载这个目录,要么把它打包烧录。验证方式不同,但目录结构本身是同一套。
1.2 交叉编译环境变量:一条命令必须能完整导出的前提
目录结构创建本身不是编译动作,但后面拷贝busybox、配置init脚本时需要频繁使用交叉工具链,所以开工前先把环境变量捋顺是必须的。我通常会在当前终端里这样导出:
export ARCH=arm export CROSS_COMPILE=arm-linux-gnueabihf- export ROOTFS=/opt/rootfs export PATH=/opt/arm-linux-gnueabihf/bin:$PATH这几个变量的含义不复杂:ARCH指定内核和系统相关代码按ARM架构处理,CROSS_COMPILE告诉构建系统用哪个前缀的交叉编译器去拿gcc、ld、strip等工具,ROOTFS就是我们这次要创建的Rootfs目录的根。
实操时容易踩坑的是,有人在新终端里直接接着上一步操作,发现CROSS_COMPILE是空白的,然后所有编译出来的东西全都错乱。所以每次重新打开终端,记得先确认这四行环境变量被正确导出。
1.3 BusyBox的编译形态:静态链接是省心,动态链接是修行
Rootfs的“肌肉”靠的是busybox——这个工具集把ls、cat、mount、sh等上百个常用命令打包进一个可执行文件,极大节省嵌入式系统的存储空间。在编译busybox之前,你需要做一个影响后续所有操作的决策:静态链接还是动态链接。
选择静态链接(CONFIG_STATIC=y),busybox内嵌了所有它依赖的运行库,编译出来的/bin/busybox拷贝到Rootfs里就能直接执行,不依赖/lib下的任何.so文件。这对新手非常友好,因为少了一半“库找不到”的坑。
选择动态链接,busybox本身很小,但它依赖的外部库(比如libc)必须完整放到/lib或者/usr/lib里,否则执行时报错busybox: not found,实际上文件明明就躺在那里。动态链接能省存储空间,但调试成本更高。
我的建议是,第一次搭建Rootfs骨架时先用静态链接,把系统跑通、理解了动态链接的机制以后,再切换成动态链接做裁剪。这篇后续的验证步骤,我会按静态链接来演示。
2. 按下mkdir之前,先在脑子里画一张“嵌入式文件系统骨架图”
2.1 从FHS到嵌入式裁剪:这十二个目录缺一不可
Linux桌面上根目录下可能有二十多个目录,但嵌入式Rootfs不是照搬桌面版的目录列表。FHS(文件系统层次标准)给了我们一套通用规则,嵌入式系统在遵循这个规则的前提下,可以砍掉一堆在板子上用不到的目录。
先看核心骨架,我用表格把每个目录的角色和“缺了会怎样”列清楚:
| 目录 | 核心职责 | 缺失时的典型症状 |
|---|---|---|
| /bin | 所有用户可执行的基本命令(busybox主程序在这) | 无法执行任何shell命令 |
| /sbin | 系统管理员使用的系统管理命令 | 无法挂载文件系统、无法重启 |
| /etc | 配置文件和启动脚本 | init找不到配置,无限重启 |
| /lib | 运行库和内核模块 | 动态链接的程序无法启动 |
| /dev | 设备节点文件 | 无法访问串口、无法交互 |
| /proc | 内核暴露的进程和系统信息(虚拟) | 很多工具报错,无法查看进程 |
| /sys | 内核设备和驱动模型(虚拟) | 无法管理设备,udev工作异常 |
| /tmp | 临时文件 | 部分程序运行异常 |
| /var | 日志、锁文件、运行时数据 | 无法记录日志,部分服务起不来 |
| /home | 普通用户的家目录 | 登录后无家目录,影响用户体验 |
| /root | root用户的家目录 | root环境不完整 |
| /mnt | 手动挂载外部设备的挂载点 | 通常无立即影响,但会带来麻烦 |
| /opt | 可选应用软件的安装目录 | 某些用户程序无法安装 |
| /usr | 系统应用程序和库的二级树 | 按静态busybox来说影响不大,但按约定必须有 |
如果只保留最精简的集合,/home、/opt、/root都可以临时不建。但如果是在做正式移植、希望后续扩展,我建议一次把上面列的全建出来,因为某些软件在安装时如果检测到目录缺失,会直接报错中断,到时候再倒回头补目录就闹心了。
2.2 可裁剪目录与两个“虚拟硬骨头”:/proc和/sys的来龙去脉
嵌入式Rootfs通常可以砍掉哪些目录?/boot(启动引导文件,嵌入式启动由bootloader直接引导内核)、/media(桌面版自动挂载用的)、/srv(服务数据目录,板子上几乎不需要)、/lost+found(恢复用,等你格式化成ext系文件系统时自动生成)。
但有两个目录必须存在,而且它们不是真实存储在Flash上的,是内核在运行时动态生成的虚拟文件系统:/proc和/sys。启动过程中init进程或初始化脚本会执行mount -t proc proc /proc和mount -t sysfs sysfs /sys,把内核内存里的信息“挂”到这两个挂载点上。
如果你没建这两个目录,或者建了但没在系统启动时挂载它们,表现是:ps命令看不到进程列表、mount命令报错、部分设备驱动无法通过sysfs接口配置。它们的本质是内核与用户空间之间的桥梁,没有它们,Rootfs骨架等于缺了神经。
3. 一行行敲目录:创建命令与逐目录拆解
3.1 目录创建命令的实际操作序列
环境变量导出之后,创建目录本身的命令非常短,但顺序和参数有讲究。我不建议用一条巨长的命令解决所有问题,那样一旦某个目录名拼错,排查起来反而浪费时间。分步骤来:
# Rootfs根目录 mkdir -p $ROOTFS # 一级目录 mkdir -p $ROOTFS/bin mkdir -p $ROOTFS/sbin mkdir -p $ROOTFS/etc mkdir -p $ROOTFS/var mkdir -p $ROOTFS/lib mkdir -p $ROOTFS/usr mkdir -p $ROOTFS/dev mkdir -p $ROOTFS/proc mkdir -p $ROOTFS/sys mkdir -p $ROOTFS/tmp mkdir -p $ROOTFS/home mkdir -p $ROOTFS/root mkdir -p $ROOTFS/mnt mkdir -p $ROOTFS/opt然后创建二级目录,尤其是/usr和/var下那套结构,很多移植新手会忽略:
mkdir -p $ROOTFS/usr/bin mkdir -p $ROOTFS/usr/sbin mkdir -p $ROOTFS/usr/lib mkdir -p $ROOTFS/usr/share mkdir -p $ROOTFS/usr/include mkdir -p $ROOTFS/var/log mkdir -p $ROOTFS/var/run mkdir -p $ROOTFS/var/cache mkdir -p $ROOTFS/var/lib mkdir -p $ROOTFS/etc/init.d这里特别强调一下/etc/init.d,大部分嵌入式系统的启动脚本,比如rcS,都是放在这个目录里的。如果你的init框架是busybox init,它默认会去/etc/init.d/rcS里找启动脚本。目录不提前建好,后面写脚本就处处被动。
mkdir -p这个参数的作用是:如果父目录不存在,自动先创建父目录,再创建子目录;如果目录已经存在,它不会报错。所以就算你操作不熟练,多敲几遍也不会产生破坏性后果。
3.2 每个目录在嵌入式系统里的真实角色
很多教程把目录结构表格往上一贴就算完事了,但没告诉你每个目录在板子上具体怎么用,所以我挨个拆解一下。
/bin放的是所有用户(包括普通用户)都能用的基础命令。在嵌入式环境里,busybox通常被拷贝到/bin/busybox,然后通过软链接机制让/bin/ls、/bin/cat这些命令“指向”同一个可执行文件。所以你会在Rootfs里看到/bin/ls -> busybox这样的软链接。
/sbin放的是系统管理员用的命令,比如mount、reboot、poweroff、ifconfig这种。区分/bin和/sbin的意义在于,普通用户不需要也不应该去执行系统管理命令,权限隔离从目录层面就开始了。但在嵌入式系统里这个区分没有桌面版那么严格,busybox本身也不在乎你是放在bin还是sbin里,只是约定俗成而已。
/etc是配置文件的总仓。内核启动init进程后,init会去/etc/inittab找启动配置;/etc/init.d/rcS是系统初始化的脚本入口;/etc/fstab定义文件系统的挂载表;/etc/passwd和/etc/shadow负责用户管理。如果你后面想让网络接口在开机时自动获取IP,就得在/etc/init.d/rcS里写udhcpc命令,或者放一个/etc/network/interfaces配置文件。可以说,/etc是Rootfs骨架里最繁忙的“关节”。
/lib是共享库的家。如果你用的是动态链接的busybox,那么ld-linux-armhf.so.3、libc.so.6这一堆文件必须放在这个目录里;如果用的是静态链接,/lib也建议放内核模块目录/lib/modules,否则后续加载驱动会傻眼。
/dev是设备节点目录。最早期嵌入式系统没有udev/devtmpfs,所有的设备节点都得手工用mknod创建(稍后专门讲);现代系统都会用devtmpfs,在内核启动时自动生成大部分节点,但像/dev/console这样的关键节点还需要手工确认。/dev缺失,你连串口终端都拿不到。
/proc和/sys前面讲过了,虚拟文件系统挂载点。ps命令读取的是/proc下的进程信息,lsusb会去看/sys下的设备树。启动脚本里没有mount -t proc和mount -t sysfs这两条命令,系统状态信息就是不完整的。
/tmp是临时目录,特别要注意权限,通常要给chmod 1777,保证任意用户都能往里面写临时文件,又不能删别人的东西(粘滞位)。/home和/root是用户家目录,虽然嵌入式系统多半以root用户直接登录,但没家目录会导致部分软件运行时出现奇怪的行为。/mnt是手动挂载U盘、TF卡的通用挂载点,/opt是安装第三方软件的约定位置。/var里存的是会变的数据——日志、锁文件、进程PID文件。
3.3 目录权限:最容易忽略却最容易出事的细节
目录建完只是第一步,权限不对等于白建。我在实际移植时吃过一次大亏:/tmp目录权限是755,结果某个程序要往/tmp写临时文件直接报错Permission denied,排查了半天才意识到是权限问题。
嵌入式Rootfs目录权限的基准建议如下:
chmod 755 $ROOTFS/bin chmod 755 $ROOTFS/sbin chmod 755 $ROOTFS/etc chmod 755 $ROOTFS/lib chmod 1777 $ROOTFS/tmp chmod 755 $ROOTFS/var chmod 755 $ROOTFS/home chmod 700 $ROOTFS/root chmod 755 $ROOTFS/mnt chmod 755 $ROOTFS/opt chmod 755 $ROOTFS/usr解释一下数字权限:755表示所有者可读可写可执行,组和其他人可读可执行,这是系统目录的通用配置;1777是在普通用户的rwx基础上给目录加了粘滞位,保证/tmp谁都能写但谁都不能删别人的文件;700的/root保证只有root自己能访问家目录。/dev、/proc、/sys这三个虚拟目录的权限,系统启动时挂载虚拟文件系统时会自动覆盖,不用手工设置。
/etc目录下那些配置文件权限也要注意:/etc/shadow(存放加密密码)和/etc/passwd,前者必须是root可读写、其他用户零权限,后者是644即可。如果你用默认的Shadow功能却把权限放得太开,一些安全检测工具会报警。
4. 骨架的关节:设备节点、inittab与rcS,缺一个系统都不转
4.1 mknod手工创建设备节点的原理与实操
创建完目录,接下来是让“骨骼”能传递信号的“关节”——设备节点。设备节点本质上是文件,但它不是普通文件,而是用户空间访问设备驱动的入口。应用层操作/dev/ttyS0、/dev/fb0时,内核根据这个文件的类型(字符设备还是块设备)、主设备号和次设备号,找到对应的驱动。
mknod命令格式是:
mknod [/路径/设备名] [c或b] [主设备号] [次设备号]关键的设备节点,我列一份建议清单:
| 设备节点 | 类型 | 主号 | 次号 | 作用 |
|---|---|---|---|---|
| /dev/console | c | 5 | 1 | 系统控制台,内核日志输出 |
| /dev/null | c | 1 | 3 | 空设备,丢弃写入 |
| /dev/zero | c | 1 | 5 | 零数据源 |
| /dev/random | c | 1 | 8 | 随机数发生器 |
| /dev/urandom | c | 1 | 9 | 随机数发生器(非阻塞) |
| /dev/ttyS0 | c | 4 | 64 | 第一个串口设备 |
| /dev/ttyS1 | c | 4 | 65 | 第二个串口设备 |
| /dev/ram0 | b | 1 | 0 | 块设备ramdisk |
实际操作命令:
mknod -m 600 $ROOTFS/dev/console c 5 1 mknod -m 666 $ROOTFS/dev/null c 1 3 mknod -m 666 $ROOTFS/dev/zero c 1 5 mknod -m 666 $ROOTFS/dev/random c 1 8 mknod -m 666 $ROOTFS/dev/urandom c 1 9 mknod -m 600 $ROOTFS/dev/ttyS0 c 4 64-m参数直接设置节点权限。console用600是因为它只能由root操作,权限放开反而可能被人窃听内核日志;null、zero这些必须是666,因为任何用户都可能往里面倾倒数据。
这里要解释一个现代内核的变化:如果你的内核配置了CONFIG_DEVTMPFS(绝大多数现代ARM内核默认开启),那么系统启动时内核会自动挂载devtmpfs并填充大部分设备节点,你手工mknod的命令似乎多余。但实践中我仍然保留手工创建的原因在于:/dev/console必须在Rootfs里静态存在,因为内核在进程PID 1(init)启动之前,需要先打开console设备用于输出,而这时候devtmpfs可能还没就绪。最保险的做法是两者都做:目录里有手工创建的console节点,同时依靠devtmpfs自动管理其他节点。
4.2 init进程依赖的/etc/inittab骨架:少了它会无限重启
如果整个Rootfs建完缺了/etc/inittab,你会在串口上看到什么?最常见的现象是内核启动一切正常,但一旦切到用户空间,系统反复执行/sbin/init然后崩溃、然后重新启动,形成一个“启动循环”。
原因在于:内核启动流程的最后一步,会尝试启动PID 1进程。busybox编译好后,/sbin/init会变成最终执行的第一个用户态程序。busybox init默认读取/etc/inittab文件,如果找不到,它的默认行为会退化成“自动挂载一些文件系统后直接执行sh”,但很多情况下这个默认路径并不符合你要的系统配置,最终导致init失败。正确的做法是提供一个明确、完整的最小inittab。
我给出的最小可用版本:
::sysinit:/etc/init.d/rcS console::askfirst:-/bin/sh ::restart:/sbin/init ::shutdown:/bin/umount -a -r逐行解释:第一行::sysinit:/etc/init.d/rcS,表示在系统初始化阶段执行rcS脚本;第二行console::askfirst:-/bin/sh,作用于/dev/console,askfirst的意思是控制台等待用户按回车才启动shell,这样避免重启后日志瞬间刷屏看都看不清;第三行::restart:/sbin/init,当init进程被信号通知重启时,重新执行init;第四行::shutdown:/bin/umount -a -r,关机时把文件系统解挂。
这个写法是busybox inittab的经典标准格式,字段依次是:id:runlevel:action:process。嵌入式系统runlevel基本用不到,都留空;action字段是核心,常见取值包括sysinit、askfirst、respawn、restart、shutdown。
4.3 rcS脚本:开机自动挂载、初始化网络、加载模块
/etc/init.d/rcS是系统初始化的“总调度”。我在板子上用的最小rcS如下:
#!/bin/sh # 挂载虚拟文件系统 mount -t proc none /proc mount -t sysfs none /sys mount -t devtmpfs none /dev # 让控制台目录权限正确 mkdir -p /dev/pts mount -t devpts none /dev/pts # 配置loopback回环接口 ifconfig lo up # 配置网络地址(示例:开发板固定IP) # ifconfig eth0 192.168.1.100 netmask 255.255.255.0 up # route add default gw 192.168.1.1 echo "rootfs init done"写完rcS后,必须给它加执行权限:
chmod +x $ROOTFS/etc/init.d/rcS很多移植新手在这里卡住:rcS写得滚瓜烂熟,但忘了加执行权限,系统启动时/bin/sh执行它失败,然后静默跳过,结果网卡没起来、文件系统没挂载,一脸懵。用ls -l $ROOTFS/etc/init.d/rcS看一眼,如果没有x权限就补上。
我在rcS里特别加了mkdir -p /dev/pts和挂载devpts这一步。原因在于,如果你的系统后续要跑telnetd、sshd这些需要伪终端的服务,/dev/pts不存在或者没挂载devpts,程序会直接报错。现在留着这行,后面就是顺手的事。
5. 骨架有没有“活着”?通过chroot在开发机上做最小验证
5.1 Chroot验证:把Rootfs骨架先“试穿”一下
目录建好、设备节点创建完毕、inittab和rcS配置完成,这时候如果直接烧到板子上调试,成本还是有点高。更聪明的方式是在开发机上用chroot命令“试穿”这套Rootfs,先验证骨架能不能立得住。
chroot的原理简单说,就是把当前进程的根目录切换到你指定的目录,让这个目录里的命令、库、脚本像在一个独立的系统里运行。操作如下:
sudo chroot $ROOTFS /bin/busybox sh进入chroot环境后,你会看到一个shell提示符。这时候可以执行基本的命令看看有没有报错,比如:
/bin/busybox --list ls / mount如果busybox是静态编译的,这一步非常顺利;如果是动态编译,这里就会报busybox: not found或类似的加载错误,因为你还没把/lib下的运行库拷贝进去。
不过要提醒一点,chroot环境里的/host文件系统是隔离的,网络相关的配置、设备节点都需要额外处理,所以在开发机上验证只能验证最基本的“命令能不能跑、目录结构是不是完整”,它替代不了真机烧录调试。
5.2 四大典型报错与排查路径:从开发机到板子的过渡
验证过程中你会遇到几类报错,我逐个说排查思路。
第一类是“无法执行二进制文件”。chroot后输入./bin/busybox,报cannot execute binary file或者Exec format error。原因几乎可以锁定在交叉编译架构不对,要么是工具链选错了目标架构(比如你拿x86工具链编了个arm的根文件系统),要么是用错了CROSS_COMPILE。检查方法很简单,在开发板上执行file /bin/busybox(普通Linux系统也带file命令),看输出里是ARM还是x86-64。
第二类是动态链接库找不到。报错/bin/sh: not found。文件明明在/bin/sh,却说不存在,其实是因为动态链接器路径不对。用readelf -l $ROOTFS/bin/busybox查看interpreter段指向的路径,比如/lib/ld-linux-armhf.so.3,然后检查这个文件是否真的存在于/lib。在没有这个库文件的情况下,内核会报告“无法加载”,但用户看到的是“not found”这种误导性信息。
第三类是权限不够。执行某个脚本时提示Permission denied,但ls -l一看权限没问题。这种现象通常是因为文件系统挂载选项包含noexec,或者你chroot时没有带--userspec参数导致当前用户映射问题。前期调试我直接用root操作chroot,能避开大部分权限干扰。
第四类是mount命令报错Operation not permitted。在chroot环境下这是必然的,因为你没有CAP_SYS_ADMIN能力。解决方案是在真机上测试mount相关逻辑,或者用sudo chroot并用--mount相关参数挂载。这不是Rootfs本身的问题。
5.3 真机启动前,拷贝与打包的最后一公里
框架在开发机上验证通过后,就要把它推向真机。如果是NFS启动方式,把$ROOTFS目录导出的NFS路径配置正确,内核启动参数里加上root=/dev/nfs nfsroot=服务器IP:/opt/rootfs ip=dhcp即可。
如果是烧录方式,以ext4镜像为例:
dd if=/dev/zero of=rootfs.img bs=1M count=64 mkfs.ext4 rootfs.img mkdir -p /mnt/rootfs sudo mount -o loop rootfs.img /mnt/rootfs sudo cp -a $ROOTFS/* /mnt/rootfs/ sudo umount /mnt/rootfs这一步有个细节必须注意:拷贝时用cp -a保留属主、权限和软链接,如果图省事用了cp -r,软链接会变成普通文件,设备节点的属性也会丢。我在开发板上见过用户把/bin/ls软链接拷丢了,执行命令时报诡异错误。用ls -R检查一遍目录里关键软链接的状态,再烧录,能省掉一次串口刷屏的排查。
6. 我在这轮移植中保留的三个习惯
目录结构创建是Rootfs系列里最不该出错的一环,但恰恰是这一环容易因马虎翻车。我在这次移植中养成了三个习惯,分享给正在做同样事情的朋友。
每个大阶段结束后,立刻拍快照。创建完目录、配置完rcS、移植完busybox,我会各打包一次,例如tar -czf rootfs_stage1.tar.gz -C /opt rootfs。这个快照不是浪费空间,而是让后续任何时候改坏了Rootfs都能秒级回滚,不用从头再来。
所有关键文件权限确认,靠脚本而不是靠肉眼。我写了一个简单的shell函数,每次都执行一遍关键目录和文件的权限检查,输出到屏幕上,确认无误才打包。人类用肉眼扫权限是不可靠的,串口报错时最急的时候最容易看漏。
内核启动参数里的console=必须和inittab里的控制台节点一致。我在这块板子上一开始指定了console=ttyS0,115200,但inittab里用的是console节点,结果日志刷到一半就没了。后来统一了控制台定义,启动信息从头看到尾,排错效率高了很多。
最后再补一个容易被忽略的点:串口波特率。开发板上UART参数是115200,但你开发机终端软件设置成9600去连,看到的全是乱码,这不是Rootfs的问题,却会被误判成设备节点创建失败。先检查终端波特率,再检查Rootfs,顺序别搞反。
这套骨架稳定跑起来以后,后面的工作就有了牢固的着力点——往Rootfs里添加应用程序、做动态链接裁剪、加图形界面,都只是在已经验证过的骨骼上继续长肌肉。骨架搭得规整,后面的每一步都清爽。