手上有几批 ARM 开发板,原厂配的镜像解压出来接近 3GB,根文件系统里塞了桌面环境、两套调试工具和一堆用不上的驱动,上电到能登录要四十多秒,内存还没跑业务就被吃掉一百多兆。这种情况下继续在原厂镜像上做减法,改一处崩一处,最后往往连自己都不知道改过什么。所以这次干脆从零构建一套轻量级 Linux 发行版,目标很明确:镜像压到 16MB 以内,空闲内存占用 20MB 以下,从上电到业务进程跑起来控制在 5 秒内。整个过程踩了不少坑,包括工具链版本对不上导致板子起来直接 data abort、根文件系统分区算错 4MB 导致首次烧录白干、BusyBox 编译成动态链接结果找不到解释器等等。下面把这些过程完整记一遍,包括每个决策背后的取舍逻辑和实测数据,手上同样有 ARM 板子想做裁剪的朋友可以照着走一遍。
1. 先把"轻量"量化:没有指标就没法做裁剪
动手之前必须先定验收标准,否则"轻量"会变成一个越调越模糊的目标。我见过太多人一开始喊着要精简,改到一半发现某功能没了又加回去,最后体积不减反增,因为反复加减导致内核配置里残留了大量"看起来无害"的选项。定指标这件事本身花不了多少时间,但能让后面每一刀裁剪都有依据。
1.1 从板子的资源底账开始
先把手上的板子参数列清楚。这批板子是双核 Cortex-A7,主频 800MHz,内存 256MB DDR3,存储是一颗 4GB 的 eMMC,另外有 SD 卡槽、一个百兆网口、两个串口、一路 USB Host。这个配置不算低,但也不宽裕,尤其内存只有 256MB,跑起来之后留给应用的空间需要仔细算。
资源底账里最容易忽略的是 flash 容量和分区对齐。eMMC 标称 4GB,实际可用扇区数会因为坏块管理和预留区域少掉一部分,我实际读到的容量是 3.64GB 左右。如果按标称值去算分区大小,最后一个分区大概率会在烧录时越界报错,或者烧进去之后挂载失败。所以第一步操作是把板子接到调试串口,用cat /proc/partitions或者直接读 eMMC 的 CSD 寄存器把真实扇区数拿下来,记在本子上。
# 在已有系统的板子上读真实容量(单位:1K 块) cat /proc/partitions # 或者从主机侧读 SD 卡的扇区数 sudo blockdev --getsz /dev/sdX内存这边也要实测,不能只看芯片手册。DDR 有一部分会被保留给显示控制器或其他硬件单元,free -m看到的总量通常比标称少 10MB 到 30MB 不等。我这块板子标称 256MB,实际可用 243MB。
1.2 三个硬指标怎么定
指标定得太松没意义,定得太死会导致后期维护成本爆炸。我最后定的是三条:
| 指标 | 目标值 | 测量方式 | 为什么定这个数 |
|---|---|---|---|
| 完整烧录镜像 | ≤ 16MB | 打包脚本输出文件大小 | 16MB 的镜像通过百兆网口传输只要 2 秒左右,现场升级体验可以接受 |
| 上电到业务进程就绪 | ≤ 5s | 串口打点 + 内核 timestamp | 业务要求设备断电恢复后快速可用,5 秒是个心理阈值 |
| 空闲常驻内存 | ≤ 20MB | free -m观察 used 字段 | 留给应用 200MB 以上,跑数据采集和本地缓存够用 |
这三条里最有争议的是镜像体积。有人会说 16MB 太激进,ext4 根文件系统光元数据就要占不少。这里的关键选择是根文件系统改用只读的 squashfs 压缩镜像,配合 tmpfs 和 overlay 存放运行时数据。squashfs 对文本和二进制混合内容通常能压到原体积的三分之一甚至更低,一个 40MB 的 ext4 根目录压出来大概 13MB 到 15MB。这个选择后面会展开讲。
顺带说一句,指标里我只写了体积、时间、内存三项,没有写"启动进程数"或者"服务数量",因为那些是手段不是目标。手段层面的东西一旦写进验收标准,很容易演变成为了凑数字而关掉本来该有的东西,比如把日志服务砍了,结果现场出问题无从查起。
1.3 第一版翻车的直接原因
我的第一版其实做出来了,镜像 14.8MB,看起来很美好。烧进去之后系统起不来,串口停在VFS: Cannot open root device。查了半天发现是分区表的问题:我给 U-Boot 分了 1MB,内核加设备树分了 8MB,剩下的全给根文件系统。但 8MB 这个数字是拍脑袋定的,实际编译出来的 zImage 是 3.2MB,设备树 28KB,中间留了 4MB 多的空洞。与此同时根文件系统分区起始地址被算到了 9MB 偏移,而我烧录镜像的时候是从 1MB 偏移开始连续写入内核和根文件系统,两边差了 4MB,内核读到的根文件系统起始位置上是空的。
这个坑的教训很直接:分区布局必须用脚本统一管理,起始地址、大小、烧录偏移三个数字只能有一个来源。我后来把分区定义写成了一个单一的分区描述文件,打包脚本、烧录脚本、U-Boot 的 bootargs 全部从它生成,从那以后没再出过类似问题。
2. 构建框架与工具链:三条路的成本对照
轻量级发行版的构建有三条主流路线,手工从零搭、用自动化构建框架、以及基于某个成熟发行版做裁剪。这三条路没有绝对优劣,关键看你后续要维护多久、团队里有几个人熟悉这套东西。
2.1 手工搭、构建框架、裁剪发行版的实际差异
| 路线 | 首次上手成本 | 后续维护成本 | 可复现性 | 适合场景 |
|---|---|---|---|---|
| 纯手工(脚本 + Makefile) | 高,每个组件都要自己写编译规则 | 中,改动直观但要自己管依赖 | 取决于脚本质量 | 组件少、结构固定、追求极致体积 |
| 自动化构建框架 | 中,需要学配置语法 | 低,依赖和编译规则框架管 | 高,配置文件即全部 | 组件较多、需要多板型复用 |
| 裁剪成熟发行版 | 低,装完就能跑 | 高,升级和删包容易互相牵扯 | 中,依赖包管理器状态 | 原型验证、对体积不敏感 |
我最终选的是"自动化构建框架 + 手工补关键组件"的混合方式。原因很实际:整套系统里 90% 的东西(工具链、内核、BusyBox、常用库)用框架生成省时间,但真正决定体积和启动速度的几个点——内核配置、init 流程、根文件系统布局——必须手工控制,框架的默认配置为了通用性会带上一堆我根本不需要的东西。
这里要特别提醒一句:不要一上来就在框架里做深度定制。框架的价值在于开箱出一版能跑的镜像,先跑通、先看到串口有输出,再回头一层层剥。我第一周就犯了这个错,直接改框架的包配置想一步到位,结果编译报错的信息被框架的日志层级吞掉,排查了半天才发现是自己关掉了一个必需的依赖包。
2.2 交叉工具链的版本对齐,这是最容易埋雷的地方
工具链版本不对导致的故障,症状往往和原因离得很远。我遇到过最典型的一次:板子启动到用户态,执行第一个程序就报Illegal instruction。查了两天,最后发现是工具链的浮点 ABI 和内核配置不匹配——工具链默认走 hard-float,但内核配置里浮点单元相关的选项没开全。
工具链选型要盯住四个东西:
- 目标架构三元组:Cortex-A7 带 NEON 和 VFPv4,用
arm-linux-gnueabihf-前缀。注意hf后缀代表 hard-float ABI,一旦选定,整条链路(内核、BusyBox、所有第三方库)都必须保持一致,混用 soft-float 编译出来的库会导致链接期或运行期直接崩。 - C 库版本:工具链自带的 libc 版本决定了根文件系统里要放哪个版本的动态链接器,也决定了你能用哪些新接口。我习惯把 libc 版本固定在和内核头文件同一时期发布的版本上,避免出现头文件声明了新接口但运行时库没有的情况。
- 内核头文件版本:这个必须小于等于实际运行的内核版本。头文件比内核新会出现编译通过但运行时报
ENOSYS的情况。 - GCC 版本:不必追新,但要注意某些版本组合会生成依赖新指令集的代码。
我最后用的是 8.3 版本的 GCC、2.28 的 libc、4.19 的内核头文件,配 4.19 的内核。这个组合不算新,但稳定,而且和板子厂商提供的 BSP 内核版本一致,省掉了很多设备驱动适配的麻烦。
2.3 装完工具链先做三行验证
工具链装好后别急着编译整个系统,先做最小验证。我习惯写一个三行的测试程序,编译后看三件事:能不能编过、链接出来的 ELF 头对不对、能不能在目标板上跑起来。
# 第一行:编译一个最小程序 arm-linux-gnueabihf-gcc -o hello hello.c # 第二行:看 ELF 头和动态段 arm-linux-gnueabihf-readelf -h hello arm-linux-gnueabihf-readelf -d hello | head -20 # 第三行:看它依赖的解释器路径 # 输出里会有类似 /lib/ld-linux-armhf.so.3 的条目第二步的输出里要重点看Flags字段,正常应该出现Version5 EABI, hard-float ABI。如果这里是soft-float ABI而你的板子期望 hard-float,那后面所有的麻烦都从这里开始。第三步输出里的解释器路径要和根文件系统里实际放的位置一致,这一点在做精简根文件系统时特别容易出错——很多人把动态链接器放到/lib/下,但工具链编译出来的程序期望的是/lib/ld-linux-armhf.so.3,名字差一个字符都跑不起来。
3. U-Boot 与内核裁剪:每一 KB 都要问来路
引导加载器和内核是整个系统里最容易失控的两块。U-Boot 的配置项有上千个,内核的配置项超过一万五千个,默认配置为了兼容各种硬件会把能开的都开上。裁剪的思路不是一个个关,而是反过来:先从一个极小的配置起步,缺什么补什么。
3.1 引导加载器只做三件事
U-Boot 在这套系统里的职责被我压到最小:初始化 DDR 和存储、把内核和设备树搬到内存、跳过去执行。网络启动、USB 支持、文件系统驱动、命令行工具这些在量产固件里全部不需要,只在开发阶段临时开。
关闭这些选项之后 U-Boot 的体积从 480KB 降到 210KB 左右。这个数字看着不大,但它影响的是启动时间——U-Boot 自己在启动阶段做的探测越少,从复位到内核开始执行的时间越短。实测下来精简版本比原版快了约 300ms。
启动参数这块有几个地方值得细说。bootargs里我最终用的是这样一行:
console=ttyS0,115200 root=/dev/mmcblk0p3 rootfstype=squashfs ro rootwait quiet loglevel=3rootwait这个参数不能省,eMMC 的枚举是异步的,不加的话内核可能在存储设备还没就绪时就去挂载根文件系统,直接 panic。quiet和loglevel=3是给量产版本用的,开发阶段会去掉,因为内核启动日志是排查问题的第一手材料。
还有一个细节:bootcmd里不要写死绝对地址。我见过有人把内核加载地址和入口地址写死在环境变量里,换一个内核版本编译出来体积变了,加载地址没变,结果内核把自己覆盖了。正确的做法是让内核加载偏移和分区偏移都从同一份分区描述里生成,或者至少在使用bootz时明确区分加载地址和入口地址。
3.2 内核配置的裁剪顺序有讲究
内核裁剪我按四轮走,每轮编译一次看体积变化,这样能清楚知道哪一刀砍掉了多少。
| 轮次 | 主要动作 | zImage 体积 | 说明 |
|---|---|---|---|
| 起点 | 板子厂商的默认配置 | 4.2MB | 兼容性优先,什么都开 |
| 第一轮 | 关掉调试相关(DEBUG_*、FTRACE、KPROBES、LOCKDEP) | 3.5MB | 收益最大,风险最低 |
| 第二轮 | 关掉不用的文件系统(BTRFS、XFS、NFS、FUSE 等) | 3.0MB | 只留 squashfs、ext4、vfat、proc、sysfs、tmpfs |
| 第三轮 | 关掉不用的网络协议栈和驱动 | 2.7MB | 只留 IPv4、TCP、UDP,以太网只留实际那一颗 |
| 第四轮 | 关掉模块支持,需要的东西全部内建 | 2.6MB | 少一个模块加载环节,启动更快 |
第一轮的收益最大是有原因的:调试相关的选项会引入大量额外的代码路径和数据结构,而且它们往往是默认开的。关掉之后不仅体积降,运行时内存占用也会降,因为很多调试基础设施会在启动时分配缓冲区。
第四轮关掉CONFIG_MODULES是一个需要谨慎的决定。关掉之后所有驱动必须内建,好处是没有模块加载的开销,启动路径更短;坏处是以后加一个新驱动必须重新编译内核。对于固件形态的产品这是划算的,但如果你的板子还在频繁换外设,建议保留模块支持,只把常用的内建。
这里补充一个实测数据:关掉模块支持后,内核启动日志里少了几十条模块加载和符号解析的记录,从内核开始执行到挂载根文件系统的时间从 820ms 降到 690ms。这个提升不算巨大,但对于 5 秒总预算来说是白捡的。
3.3 设备树也值得看一眼
设备树容易被当成"厂商给的文件,不用动"。实际上厂商的设备树经常包含一堆板上根本没有的节点,比如预留的摄像头接口、没用到的第二个网口、默认启用的音频编解码器。这些节点即使硬件不存在,内核在解析时也会走一遍初始化和探测流程,有些驱动还会因为探测失败反复重试,拖慢启动。
我的做法是把设备树里实际用到的节点列一遍,然后逐条比对。删掉一个没用的 I2C 从设备节点、删掉音频节点、删掉第二个以太网控制器,设备树源文件从 1200 多行降到 700 多行,编译出来的 dtb 从 42KB 降到 28KB。
设备树的调试靠串口。earlycon参数能让内核在串口驱动初始化之前就开始输出,配合earlyprintk可以看到内核最早的启动阶段。我用这招定位过一次问题:某个外设的时钟节点引用了不存在的父时钟,内核在时钟框架初始化时直接卡住,没有任何日志,加了earlycon之后才看到卡在哪一行。
# 编译设备树 make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- dtbs # 反编译已有的 dtb 看内容 arm-linux-gnueabihf-dtc -I dtb -O dts board.dtb -o board.dts反编译这一步很值得做,因为厂商提供的 dts 源文件经常和实际编译进固件的 dtb 不一致——有可能是用另一份配置编的,也有可能是后来改过没同步。以 dtb 为准反编译回来,才是板子真正运行的那份配置。
4. 根文件系统:从一个空目录到能登录
根文件系统是整套系统里工作量最大、也最能体现"轻量"的部分。从空目录开始搭的好处是每一个文件的存在都有理由,坏处是要自己处理一堆平时被发行版隐藏掉的细节。
4.1 BusyBox 静态还是动态
BusyBox 提供了一百多个常用命令的精简实现,是轻量根文件系统的核心。编译时第一个决策是静态链接还是动态链接。
静态链接的优点是省心,编译出来的单个二进制放到任何地方都能跑,不用担心找不到库。缺点是体积大,一个全功能静态 BusyBox 大概 1.8MB。动态链接的版本大概 900KB,但需要配上 libc 和几个依赖库,加起来 1.3MB 左右。
我最后选的是静态链接,理由不是体积,而是可靠性和调试便利。系统启动早期如果动态链接器出问题,你连一个能用的 shell 都拿不到,只能干瞪眼。静态 BusyBox 可以保证无论库环境多乱,/bin/sh一定能起来。多出来的 500KB 在 16MB 的预算里可以接受。
applet 的取舍要按实际需要来。BusyBox 默认配置里开了很多平时用不到的命令,比如各种压缩工具、网络工具、编辑器。我的做法是先全开编一版,然后看根文件系统里的脚本实际调用了哪些命令,把没被引用的全关掉。
# 找出 rcS 和各个 init 脚本里用到的命令 grep -ohE '\b[a-z][a-z0-9_-]{1,15}\b' /etc/init.d/* /etc/rc.local | sort -u这个过程能砍掉大概三分之一的 applet。要注意几个隐蔽的依赖:sh内部会调用test、[、echo;某些脚本用sed处理配置;mdev依赖cat和echo。砍之前一定要在真机上跑一遍完整启动流程,别在主机上凭感觉判断。
4.2 /etc 下到底需要哪些文件
一个能正常启动、能登录、能跑服务的根文件系统,/etc下最少需要这些:
/etc/inittab:init 进程的配置,规定启动时执行什么/etc/init.d/rcS:启动脚本主体/etc/fstab:挂载表,虽然很多东西可以写死在 rcS 里,但保留 fstab 更清晰/etc/passwd、/etc/group:至少要有一个 root 条目,否则login和su会出问题/etc/profile:登录时设置环境变量,主要是 PATH/etc/hostname、/etc/hosts:有些程序启动时会去解析本机名,缺了会卡几秒/etc/mdev.conf:设备节点的权限规则,如果用了 mdev 做热插拔
/etc/fstab的内容我写得很克制,只挂载真正需要的:
proc /proc proc defaults 0 0 sysfs /sys sysfs defaults 0 0 tmpfs /tmp tmpfs defaults,size=16m 0 0 tmpfs /run tmpfs defaults,size=8m 0 0/dev用 devtmpfs 由内核自动挂载,不需要写进 fstab,但需要在内核配置里开启CONFIG_DEVTMPFS_MOUNT,否则启动后/dev是空的,串口都登不进去。这个选项我第一次就漏了,症状是启动日志一切正常,但最后停在Please press Enter to activate this console,按了没反应。
4.3 init 流程的设计决定了启动速度
BusyBox 的 init 支持两种模式,一种是读/etc/inittab,另一种是直接执行/etc/init.d/rcS。我用的是后者,因为配置更简单,启动路径更短。
rcS的结构我分成三段:必须先做的、可以并行的、可以延后的。
#!/bin/sh # 第一阶段:挂载和基础环境,必须串行 mount -a hostname -F /etc/hostname mkdir -p /dev/pts /dev/shm mount -t devpts devpts /dev/pts mount -t tmpfs tmpfs /dev/shm # 第二阶段:设备节点和内核参数 echo /sbin/mdev > /proc/sys/kernel/hotplug mdev -s sysctl -p /etc/sysctl.conf 2>/dev/null # 第三阶段:并行拉起业务服务 /etc/init.d/network start & /etc/init.d/collector start & wait第三阶段的并行化收益很明显。两个服务的初始化各自要 400ms 左右,串行就是 800ms,并行之后总共 450ms。这里要注意wait的使用——如果不加wait,rcS会在服务还没起来时就返回,某些依赖服务的逻辑会失败。
还有一个细节:mdev -s会扫描/sys下的所有设备节点并创建对应的/dev条目,这个过程在设备多的板子上要花 200ms 到 400ms。如果/dev已经用 devtmpfs 挂载了,mdev -s其实可以省略,只保留热插拔那一行。
4.4 只读根文件系统加 overlay
这是整套方案里我认为收益最大的一个设计。根文件系统用 squashfs,只读挂载;运行时需要写入的目录(/tmp、/run、/var、/etc的改动)用 tmpfs 或者 overlay 覆盖。
这样做有三个好处。第一,体积小,squashfs 的压缩率比 ext4 高得多。第二,掉电安全,只读分区不会因为异常断电产生文件系统损坏。第三,升级简单,整个根文件系统就是一个镜像文件,换掉就行,不需要在设备上做增量更新。
代价是任何对根文件系统的修改都不会持久化。如果业务需要保存配置,就要额外分一个可写分区,把配置目录挂上去。
# /etc/fstab 里加一行可写数据分区 /dev/mmcblk0p4 /data ext4 defaults,noatime 0 0noatime这个挂载参数值得加,它关掉了文件访问时间的更新,能显著减少写入次数,对 eMMC 寿命有好处。实测下来加上之后,空闲状态下的写入从每分钟十几次降到几乎为零。
overlay 的配置稍微麻烦一点,需要在 init 阶段用mount -t overlay把只读的下层和可写的上层叠起来。我这套系统里用得不多,只在需要保留/etc修改的场景开,多数情况下直接用 tmpfs 覆盖就够了。
5. 镜像打包与烧录:分区布局决定后期维护成本
前面所有工作最后都要落到一个镜像文件上。这一步看起来只是把几个文件拼起来,实际上分区布局的设计会直接影响后面几年的维护体验。
5.1 分区表的三个约束
设计分区表时有三个互相牵制的约束:U-Boot 必须从存储的固定偏移开始(通常是扇区 0 之后的一段保留区),内核的加载偏移必须和 U-Boot 的bootcmd一致,根文件系统的起始必须和bootargs里的分区号一致。任何一个数字改动,其他两个都要同步。
我的做法是写一个分区描述文件,用简单的键值格式:
uboot_offset=0x0 uboot_size=0x100000 kernel_offset=0x100000 kernel_size=0x800000 dtb_offset=0x900000 dtb_size=0x100000 rootfs_offset=0xA00000 rootfs_size=0x1F000000 data_offset=0x1FA00000打包脚本读这个文件生成镜像,烧录脚本读它生成 dd 命令,U-Boot 的环境变量也由它生成。改一个地方就够,不会出现三处不一致。
要注意的是 U-Boot 的实际大小可能超过预留区。我预留了 1MB,实际编译出来 210KB,看起来余量很大。但如果后面开了更多功能,比如加了网络启动支持,很容易就顶到 1MB。所以打包脚本里必须加一个检查:如果 U-Boot 二进制大小超过uboot_size,直接报错终止,不要等到烧录之后才发现。
5.2 首次上电排查的完整链路
烧录完成第一次上电,如果串口没有任何输出,排查顺序是有讲究的,不要乱试。我按下面这条路走过几次,基本能快速定位。
第一步,确认串口本身通了。把板子的 TX 接到 USB 转串口模块的 RX,用minicom或者picocom打开,波特率 115200、8N1。有几次我以为板子没起来,结果是串口线接反了或者波特率配错了。
第二步,看有没有 U-Boot 的打印。如果完全没有,说明 U-Boot 没被正确加载。原因可能是烧录偏移错了、存储介质选错了、或者启动模式跳线没设对。很多板子有从 SD 卡启动和从 eMMC 启动的跳线或拨码开关,默认可能不是你以为的那个。
第三步,如果 U-Boot 有打印但停在某个位置,通常是 DDR 初始化失败或者设备树加载失败。U-Boot 的日志会明确指出。
第四步,如果内核开始执行但很快挂掉,看最后几行。Unable to mount root fs是分区或文件系统类型问题;Kernel panic - not syncing: No init found是根文件系统里的 init 路径不对;data abort是内存或指令集问题,多半和工具链或者 DDR 配置有关。
我踩过最隐蔽的一次是:U-Boot 和内核都正常,根文件系统挂载成功,但 init 进程启动后立刻退出,串口反复刷init: can't open /dev/console。查了很久才明白,根文件系统里没有创建/dev/console这个节点。因为/dev是 devtmpfs,理论上内核会自动创建,但前提是内核配置里CONFIG_DEVTMPFS_MOUNT开启,而我这一版恰好关掉了。解决方式是在制作根文件系统时手工mknod一个 console 节点,或者把那个内核选项加回来。
# 在制作根文件系统时手工创建 console 和 null sudo mknod -m 622 rootfs/dev/console c 5 1 sudo mknod -m 666 rootfs/dev/null c 1 3这两行现在成了我的标准动作,无论内核怎么配,先手工建好,省得反复折腾。
5.3 给升级留一条后路
固件做出来只是开始,后面一定会有升级需求。分区布局阶段就要想清楚升级怎么做。
我的方案是双内核分区加单一根文件系统分区。内核分区 A 和 B 各 8MB,U-Boot 的环境变量里记录当前从哪个分区启动。升级时把新内核写到另一个分区,改一下启动变量,重启就能切换。出问题就把变量改回去。
根文件系统因为是整块 squashfs 镜像,升级就是重写整个分区。这要求根文件系统分区在不破坏数据分区的前提下可以安全重写,所以数据分区必须独立,配置和业务数据都放在那里。
预留的另一个东西是恢复模式。U-Boot 里配一个按键检测或者串口命令,能在根文件系统损坏时从 SD 卡或者网络加载一个最小系统,用来修复 eMMC。这个功能平时用不上,但现场设备变砖的时候能救命。
6. 最后一轮压榨:体积与启动耗时的量化优化
前面的工作做完,镜像大概在 18MB 左右,启动 6 秒多,离目标还差一点。这一轮优化靠的不是大开大合地砍功能,而是逐项测量、逐项优化。
6.1 依赖分析和动态库裁剪
如果系统里有动态链接的程序,一定会有一批库被带进根文件系统。有些库只被一个程序用到,有些库的某个版本同时被多个程序引用,还有些库引用了根本不会执行的代码路径。
先用readelf把所有二进制的依赖关系理清楚:
# 列出根文件系统里所有 ELF 文件的动态依赖 find rootfs -type f -exec file {} \; | grep ELF | cut -d: -f1 | while read f; do echo "== $f" arm-linux-gnueabihf-readelf -d "$f" 2>/dev/null | grep NEEDED done输出整理成一张表之后,能明显看出哪些库是必需的。我这一版里最后只保留了三个动态库:libc、libm、libgcc_s。libstdc++ 因为有一个 C++ 写的采集程序需要,也被保留下来,但它有 1.2MB,我试过用静态链接这个程序来省掉它,结果静态版本的程序本身又大了 800KB,权衡之后还是保留动态库更划算。
strip 是必做的。编译出来的二进制默认带符号表和调试信息,一个几 MB 的程序 strip 之后可能只剩几百 KB。
find rootfs -type f -exec arm-linux-gnueabihf-strip {} \; 2>/dev/null要注意的是,strip 之后就无法用 gdb 调试了。所以我的打包流程分两套:开发版本不 strip,方便现场调试;发布版本 strip,追求体积。两套镜像用同一个打包脚本,一个参数切换。
6.2 启动耗时到底花在哪
优化启动时间的前提是知道时间花在哪。内核侧用initcall_debug和printk.time=1两个参数就能拿到每个初始化函数的耗时,用户态用date +%s%N在关键节点打点。
# 内核参数里加这两个 printk.time=1 initcall_debug输出会非常长,但 grep 一下就能找到耗时最长的几个初始化函数。我这块板子上排名靠前的是存储控制器初始化、网络 PHY 协商、以及某个 USB 控制器探测。前两个没法省,第三个把 USB Host 的驱动从内建改成按需加载之后,启动时间少了 400ms。
用户态的打点我写成一个小函数放在 rcS 里:
mark() { echo "$(date +%s%N) $1" >> /tmp/boot.log } mark start # ... 各阶段操作 mark network_done最后算差值。实测下来用户态启动的大头是mdev -s和网络服务初始化。mdev -s前面说过可以通过 devtmpfs 省掉,网络服务初始化慢是因为它在等 DHCP 超时——改成静态 IP 之后从 3 秒降到 200ms。
6.3 一些长期维护的经验
这套系统跑了大半年,中间改过几次,有几个经验值得记下来。
一是保留一份"最大配置"的分支。裁剪的过程是不可逆的,很多东西砍掉之后再加回来要重新查资料。我在仓库里留了一个分支,保留所有功能和调试工具,出问题的时候切过去复现,定位之后再回到精简分支改。
二是把内核配置和根文件系统的制作过程都纳入版本管理。内核.config文件、根文件系统的制作脚本、分区描述文件,全部进仓库。我吃过亏,有一次在本地调了半天内核配置,忘了保存.config,重新编译之后行为变了,找了两天才发现是配置文件没提交。
三是每次改动都记录体积和启动时间的变化。我在仓库根目录放了一个metrics.md,每完成一个优化就追加一行,写清楚改了什么、镜像大了还是小了、启动快了还是慢了。这个记录在后期做取舍的时候非常有用,比如当有人要求加一个功能时,你能立刻说出它大概会让镜像增加多少、启动慢多少。
四是别把启动时间压到极限。我一开始把目标定在 3 秒,压到 4.2 秒的时候发现再往下压就要砍掉一些错误处理和日志,系统变得很难排查问题。最后把目标放宽到 5 秒,保留了必要的日志和自检,实际 4.6 秒,留了一点余量。设备跑在现场,可维护性比省下的那零点几秒重要得多。
最后分享一个我在实际项目里养成的小习惯:每次改动分区布局或者内核配置之后,不要直接烧到主力板上,先在一张备用 SD 卡上烧一遍,用它启动验证。SD 卡烧坏了换一张就行,eMMC 里的东西被覆盖了恢复起来要拆机接调试口。这个习惯帮我省过至少两次麻烦。