手上有几批 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 秒是个心理阈值空闲常驻内存≤ 20MBfree -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里我最终用的是这样一行consolettyS0,115200 root/dev/mmcblk0p3 rootfstypesquashfs ro rootwait quiet loglevel3rootwait这个参数不能省eMMC 的枚举是异步的不加的话内核可能在存储设备还没就绪时就去挂载根文件系统直接 panic。quiet和loglevel3是给量产版本用的开发阶段会去掉因为内核启动日志是排查问题的第一手材料。还有一个细节bootcmd里不要写死绝对地址。我见过有人把内核加载地址和入口地址写死在环境变量里换一个内核版本编译出来体积变了加载地址没变结果内核把自己覆盖了。正确的做法是让内核加载偏移和分区偏移都从同一份分区描述里生成或者至少在使用bootz时明确区分加载地址和入口地址。3.2 内核配置的裁剪顺序有讲究内核裁剪我按四轮走每轮编译一次看体积变化这样能清楚知道哪一刀砍掉了多少。轮次主要动作zImage 体积说明起点板子厂商的默认配置4.2MB兼容性优先什么都开第一轮关掉调试相关DEBUG_*、FTRACE、KPROBES、LOCKDEP3.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 ARCHarm CROSS_COMPILEarm-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/inittabinit 进程的配置规定启动时执行什么/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,size16m 0 0 tmpfs /run tmpfs defaults,size8m 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的使用——如果不加waitrcS会在服务还没起来时就返回某些依赖服务的逻辑会失败。还有一个细节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_offset0x0 uboot_size0x100000 kernel_offset0x100000 kernel_size0x800000 dtb_offset0x900000 dtb_size0x100000 rootfs_offset0xA00000 rootfs_size0x1F000000 data_offset0x1FA00000打包脚本读这个文件生成镜像烧录脚本读它生成 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: cant 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 各 8MBU-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.time1两个参数就能拿到每个初始化函数的耗时用户态用date %s%N在关键节点打点。# 内核参数里加这两个 printk.time1 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 里的东西被覆盖了恢复起来要拆机接调试口。这个习惯帮我省过至少两次麻烦。