只读分享解决方案文档原理方案设计📅 2026-10-05🔖 Rev 1.4⏳ 26天有效(至 2026-11-05)
🔧解决方案&应用市场分析 -> 原理方案设计 -> Linux_嵌入式系统开发指南

Linux_嵌入式系统开发指南

生成时间 2026-10-06 15:32:35 有效期至 2026-11-05 15:38:44(26天有效(至 2026-11-05))
同系列 · 原理方案设计

Linux 嵌入式系统开发指南

编制日期 2026-10-05 | 版本号 Rev 1.4

面向 MPU / Linux 平台的用户态系统层 · 覆盖交叉工具链、Bootloader 交接、根文件系统构建、init 与 systemd、进程与线程、信号与异步 IO、网络栈接入、调试可观测、实时性、安全加固、电源管理、OTA 与容器 · 与内核篇互补

一句话结论:嵌入式 Linux 的用户态系统层,真正决定产品体验的不是内核版本,而是启动链的每一段是否都被测量与控制过。工具链不一致会让同一份代码在不同机器上行为不同;根文件系统多塞一个库会让镜像胖30% 并且启动慢两秒;init 顺序错会让服务在依赖未就绪时反复重启;日志刷在错误的位置会让现场问题无法复现。本文把这些点逐条变成可测量、可判据化的工程项——启动多少秒、镜像多少兆、峰值多少内存、断电后能否恢复,每一项都有量化判据。

20
章 + 4 附录
从全景到清单
9
个启动阶段
从上电到首个应用
76
张图示
全部内联 SVG
10
道交付检查门
第 19 章
10
条最贵的教训
第 20 章

这份文档解决什么

本文是用户态系统层的专项指南:讲清嵌入式 Linux 从上电到应用运行之间的全部用户态环节——工具链与构建体系、Bootloader 交接、根文件系统怎么搭、init 用什么、服务怎么编排、进程与线程怎么组织、异步 IO 怎么写、网络怎么接、崩溃怎么抓、性能怎么测、安全怎么加固、电源怎么管、OTA 怎么做、容器能不能用。内核侧(内核配置、设备树、内核态驱动、内核调试)由《Linux 内核开发指南(BSP Kernel)》承接;存储软件栈(FTL、文件系统、掉电一致性)由《嵌入式文件系统开发指南》承接;具体 Flash 器件驱动由《SPI NOR / SPI NAND / PPI NAND / eMMC 驱动开发指南》系列承接。本文站在它们之上,只讲用户态。

路线 1 · 从零搭一套系统
手里只有芯片与SDK
第 2 章工具链 → 第 3 章 Bootloader → 第 4 章根文件系统 → 第 5 章 init → 附录 A 构建速查。
重点:sysroot、镜像格式、服务依赖
路线 2 · 启动太慢
要压到 3 秒内
第 15.2 节启动优化的六个手段 → 第 15.1 节启动分解 → 第 4.4 节镜像瘦身 → 附录 B 内核参数。
重点:分段计时、initramfs、按需加载
路线 3 · 现场偶发故障
偶发重启 / 偶发断网
第 9 章可观测 → 第 16 章可靠性 → 第 17 章排查手册 → 第 16.4 节区分掉电与复位。
重点:崩溃转储、异常标记、看门狗
路线 4 · 做 OTA
要能升级与回滚
第 13 章 OTA → 第 13.2 节升级包结构与验签范围 → 第 16.2 节看门狗 → 附录 A unit 模板。
重点:A/B、启动计数、回滚触发
路线 5 · 交付前检查
项目要量产了
第 19 章十道门 → 第 18 章测试 → 第 20 章十条教训 → 附录 C 命令速查。
重点:判据、可复现、命令手册

读者路径

路线 1(从零搭系统):第 1.2 节九个阶段 → 第 2.1 节工具链四段 → 第 3.3 节设备树交接 → 第 4.1 节内容清单 → 第 5.2 节 systemd 依赖 → 附录 A 目录布局。

路线 2(优化启动):第 15.1 节分段计时 → 第 15.2 节启动优化的六个手段 → 第 4.4 节镜像瘦身 → 附录 B 内核参数。

路线 3(排查线上问题):第 17 章排查手册 → 第 9.2 节崩溃转储 → 第 16.4 节异常复位区分 → 附录 C 命令速查。

路线 4(做实时性):第 10.1 节延迟来源 → 第 10.2 节 PREEMPT_RT → 第 10.3 节实时性验证 → 附录 B 调度参数。

版本基线与引用约定

  • 内核以 Linux 6.1 / 5.15 LTS 为基线,PREEMPT_RT特性以 6.1-rt 为参照;
  • 用户态以 glibc 2.36 / musl 1.2、systemd 252、BusyBox 1.36、Buildroot 2024.02、Yocto 4.0 为基线;
  • 命令示例在 MPU 平台可直接使用;MCU 平台若跑 uClinux / NuttX,本文概念仍适用,具体命令以该系统文档为准;
  • 内核态内容见《Linux 内核开发指南(BSP Kernel)》,存储栈见《嵌入式文件系统开发指南》,本文不重复。

1 · 用户态系统层全景

内核能跑起来只是起点。真正决定产品表现的是内核之下、内核之上的那一层:工具链怎么搭、Bootloader 交什么给内核、根文件系统里放了什么、服务按什么顺序起。这一章先把这条链路的全景摊开。

1.1 与内核篇的分工边界

两个文档讲的是同一条链路的上下两段,边界在「内核返回用户态」这一行环节本文(用户态)内核篇启动Bootloader 交接、initramfs、init 启动内核:解压内核 + 挂载 rootfs存储挂载参数、根文件系统布局、镜像瘦身内核:块设备与文件系统驱动设备接入网络配置、串口透传、业务逻辑内核:网卡驱动与协议栈实现进程线程模型、IPC、服务编排内核:调度器与信号机制调试崩溃回溯、性能剖析、日志体系内核:printk / ftrace / KASAN实时PREEMPT_RT 调优、优先级与亲和性内核:抢占模型与时钟安全账户权限、能力、网络暴露面内核:安全启动与命名空间电源休眠策略、唤醒源、运行时挂起内核:PM 框架与时钟树边界处最容易出现的误解「驱动 probe 失败」常被当成内核问题往内核篇找,但根因常常在用户态:设备树被覆盖、挂载点不存在、服务启动顺序错、权限不足。排查时先确认内核态与用户态的接缝(/dev、/sys、/proc)是否正常。
图 1 · 内核篇与系统篇的分工边界

两个文档的边界很清晰:内核篇讲内核返回用户态之前的事,本篇讲之后的事。但工程上出问题的地方,往往就在这条接缝上——设备树被应用层覆盖、挂载点不存在导致服务反复重启、内核已经 probe 成功但用户态找不到 /dev 节点。

排查前先确认接缝

遇到「设备不工作」类问题,第一步不是去翻内核代码,而是按顺序确认三个接缝:

① /proc/device-tree/ 里的设备树是不是应用预期的(有没有被覆盖);

② /dev/ 与 /sys/class/ 下的节点在不在、权限对不对;

③ dmesg 里有没有 probe 失败或 uevent 没发出。

三处都正常再看应用层,能省掉大量无效的内核侧排查。

1.2 从上电到第一个应用的九个阶段

1硬件复位SoC 内部 ROM 加载引导镜像头部2BootloaderU-Boot 初始化时钟/DDR/串口3读内核镜像从介质读 FIT/uImage校验完整性4内核启动解压内核建立页表5挂载 rootfs优先 initramfs否则真实分区6执行 init/sbin/init → systemd或 BusyBox init7挂载其余文件系统fstab 逐项挂载依赖就绪顺序8启动服务systemd 按依赖并行或按脚本顺序9首个应用开机自检完成进入业务主循环各阶段的责任归属与可测量指标阶段责任方该怎么测典型工具 / 读数1 ~ 2Bootloader / 硬件阶段 2 完成时打印一条时间戳串口首字节时间2 ~ 4Bootloader读镜像耗时(KB/s 可换算)load 阶段耗时4 ~ 5内核内核启动到 init 执行的时间printk 时间戳5 ~ 6initramfs过渡根的解压与切换耗时切换前后的时间差6 ~ 7initfstab 中每项挂载的耗时各挂载点时间戳8systemd服务从 start 到 active 的耗时systemd-analyze blame9应用自检完成到业务就绪应用侧日志时间戳
图 2 · 从上电到第一个应用的九个阶段

图 2 下半部分的「责任方」列是本章最有价值的部分:每一段耗时归属于不同的团队与不同的工具。启动优化之所以难,往往不是因为某段特别慢,而是因为没人知道自己负责的那段占了多少。

第一件该做的事:把启动时间分段测出来

在优化之前先做测量。方法很简单:在 Bootloader 入口、内核 start_kernel 之后、init 执行前后、以及每个服务启动完成时各打印一条带时间戳的日志。

分段之后你会发现,典型设备的启动时间分布往往是这样的:Bootloader 8%、内核 15%、initramfs 12%、systemd 拉起服务 55%、业务自检 10%。systemd 那55% 才是主战场,而它往往被忽略,因为内核和驱动都「看起来很快」。

1.3 用户态系统层的六个子系统

构建体系· 交叉工具链 / sysroot· Buildroot / Yocto· 镜像打包决定系统能否被复现启动与init· Bootloader 交接· /sbin/init· systemd / BusyBox init决定启动时间与依赖正确性运行时资源· 进程 / 线程模型· 内存分配· IPC决定并发能力与内存占用IO 与事件· 信号机制· epoll / io_uring· 文件描述符模型决定响应速度网络接入· Socket / netlink· DHCP / mDNS· 保活策略决定连通性与延迟运维与观测· 日志 / 崩溃转储· 性能剖析· OTA 与回滚决定问题能否被解决六个子系统的耦合点只有三个:Bootloader 交给内核的设备树、根文件系统里的挂载配置、以及内核暴露给用户态的 /dev 与 /sys。
图 3 · 用户态系统层的六个子系统与它们的主责工具

1.4 本文覆盖范围与前置约定

主题本文覆盖不在本文范围
工具链交叉编译、sysroot、glibc/musl 选型内核编译(见内核篇)
BootloaderU-Boot 环境变量、设备树交接、A/BU-Boot 移植的寄存器级细节
根文件系统内容清单、构建方式、镜像瘦身具体文件系统内部机制(见文件系统篇)
initsystemd 与 BusyBox init、依赖编排内核 initcall 机制
运行时进程、线程、内存、IPC、epoll内核调度器实现
网络Socket / netlink、DHCP / mDNS、保活网卡驱动与协议栈内核实现
可观测日志体系、崩溃转储、perf / eBPFftrace / KASAN(见内核篇)
OTAA/B、验签、回滚触发、数据迁移安全启动的硬件根信任
本文的三条前提约定

① 读者已有可启动的内核。本文不假设从零移植内核与BSP——那是《Linux 内核开发指南(BSP Kernel)》与芯片原厂 SDK 的范围。

② 读者有目标板。本文所有调优与验证都依赖真实硬件的时序、内存与温度,PC 上的仿真结论经常是错的。

③ 本文的判据都可量化。凡是「快」「稳定」「省电」这类形容词,本文都会给出对应的数字与测量方法;给不出数字的,说明它其实无法工程化。

2 · 交叉工具链与构建体系

工具链是所有后续工作的地基。它出问题时症状往往很奇怪(链接过但运行崩、同一份代码在不同机器上行为不同),所以必须在项目一开始就固定下来。

2.1 工具链的四段构成

Binutilsld / as / objcopy / objdump作用:链接脚本与入口地址段布局错会导致启动即挂GCC 前端cc1 / cc1plus作用:C/C++ 前端与内建函数版本与库不匹配会出现莫名 ABI 问题libcglibc / musl作用:系统调用封装与 C 库glibc 体积大;musl 小但功能少运行时crt1.o / crti.o /动态链接器作用:进程启动与共享库加载缺 crt 文件会报 `_start` 缺失版本必须四者同源发行版包(最省事)版本可能滞后但一致厂商 SDK(配套 BSP)与内核版本强绑定Bootlin 预编译可裁剪、支持自定义自建 LFS可控但维护成本极高最常见的翻车:用宿主机的 glibc 头文件编译,却在目标板运行 musl症状是运行时报错在 malloc / strlen 这类基础函数上。判定方法:ldd 看依赖的 libc 是否与 sysroot 一致。
图 4 · 交叉工具链的四段构成与各自的问题
环节命令前缀关键文件出问题时看
预处理$CC -E-include宏与头文件是否找到
编译$CC -c-mabi / -march指令集与 ABI 是否匹配
汇编$AS-x assembler内联汇编是否可编
链接$LD链接脚本 / crt*.o缺 _start、段布局、库顺序
后处理$OBJCOPY-O binary / elf32-little镜像格式与地址
运行$LD_SOld-linux-*.so缺解释器 / 库版本不匹配
最贵的一次踩坑:头文件与库来自不同 libc

典型场景:SDK 里带了交叉工具链,但业务代码的构建脚本里写了 -I/usr/include(宿主机路径),或者手动从另一套 SDK 拷了库文件。

症状:编译链接全过,运行时在 malloc / strlen / printf 上崩溃或输出乱码。原因是结构体布局与符号解析来自不同版本。

防线有三条:① 编译时用 -nostdinc 配合显式 -isystem,确保头文件只来自 sysroot;② 构建脚本里打印 $CC -print-sysroot 确认路径;③ 制品归档时记录 ldd 输出。

2.2 sysroot 与动态链接

sysroot 是「编译时看到的根文件系统」,它决定了头文件与库从哪来usr/include头文件编译期usr/lib库文件 .a / .so链接期与运行期lib / usr/lib64启动文件 crt1.o crti.o链接期lib/ld-linux-*.so动态链接器运行期第一个加载指定 sysroot 的三种方式--sysroot=<path>GCC 全部工具共用-isysroot <path>给头文件搜索用--with-sysroot编译期内置,SDK 常用静态链接 vs 动态链接静态链接-static运行时零依赖,镜像小(仅一个程序)动态链接默认多个程序共享一份 libc,镜像更小混用部分静态最难排查,需明确每个 .so 的来源验证 sysroot 正确的三条命令$CC -print-sysroot$CC -E -v x.c 2>&1 | grep searchfile ./app # 看是否静态
图 5 · sysroot 的目录结构与三类文件
# 一份可直接抄的交叉编译变量定义
TOOLCHAIN_PREFIX = /opt/toolchain/arm-linux-gnueabihf-
SYSROOT         = $(TOOLCHAIN_PREFIX)/sysroot

export CROSS_COMPILE = $(TOOLCHAIN_PREFIX)
export CC       = $(CROSS_COMPILE)gcc
export CXX      = $(CROSS_COMPILE)g++
export STRIP    = $(CROSS_COMPILE)strip
export OBJCOPY  = $(CROSS_COMPILE)objcopy
export LD       = $(CROSS_COMPILE)ld

# 关键:所有头文件与库都限定在 sysroot 内
export CFLAGS = --sysroot=$(SYSROOT) -march=armv7-a -mfpu=neon \
                -mfloat-abi=hard -O2 -g -Wall -Wextra
export LDFLAGS = --sysroot=$(SYSROOT) -Wl,-rpath-link=$(SYSROOT)/lib

# 静态构建(每个程序独立、无运行时依赖)
STATIC_FLAGS = -static

# 混合构建(共享 libc)
SHARED_FLAGS = -Wl,-rpath=/usr/lib

# 自检:构建第一步就打印 sysroot 与 libc 架构
build-check:
	@echo "SYSROOT = $(SYSROOT)"
	@$(CC) -print-sysroot
	@$(CC) -dumpmachine
	@echo 'int main(void){return 0;}' | $(CC) -x c - -o /tmp/t.bin \
	    && file /tmp/t.bin

2.3 Buildroot 与 Yocto 的对比

维度BuildrootYocto定位简单直接的构建系统完整的发行版框架元数据形式makefile + KconfigBitBake + layer 模型语言shell + makePython + BitBake构建速度快(2~ 10 分钟)慢(数小时,需缓存)定制粒度整包替换配置文件逐文件覆盖、增量构建镜像产物rootfs.tar / squashfs / ubifs同上,功能更全学习成本低,一天上手高,需理解 layer 与 bbclass适用阶段原型验证、小批量量产产品、多产品线体积控制天然小(只含你要的)需显式裁剪(toolschain)务实路径:先用 Buildroot 打通启动链路与BSP,量产前再评估是否迁Yocto(迁移成本主要是 layer 与配方,不是应用代码)。
图 6 · Buildroot 与 Yocto 的分工与选型
关注点BuildrootYocto
首次出镜像分钟级小时级
增量改一个包重新配置 + 构建,几分钟只重建该包,秒级
加一个驱动模块配置内核选项后重编写一个 .bbappend 即可
产物体积小(默认只含需要的)偏大(需 toolschain 裁剪)
多产品并行需维护多份 defconfiglayer 天然支持
合规与审计较简单有 SPDX / 组件清单
团队学习成本低高
选型建议:先Buildroot 后Yocto

绝大多数项目应该先用 Buildroot 打通。原因:它的反馈快,能让你把精力花在真正的难点(驱动对接、启动时间、服务依赖)上,而不是构建系统本身。

当出现下面任一情况时再评估迁Yocto:

· 同一 BSP 要支撑 5 个以上产品(型号与分支差异多);

· 需要 SPDX 清单或第三方审计;

· 需要长期维护并且有专职构建工程师;

· 需要极致的裁剪(Buildroot 加patch 成本已变高)。

迁移时最费时的不是应用代码,而是 layer 结构与自定义 recipe 的整理。

2.4 手工构建根文件系统的步骤

1建骨架目录bin sbin lib etc usr var tmpproc sys dev run2拷贝动态链接器从 sysroot 的 lib/ld-*3装最小组件BusyBox 或 glibc + shell4建设备节点dev/console dev/nulldev/ttyS0 …5写挂载表/etc/fstab:哪些挂哪些6建 init 脚本/etc/init.d/rcS 或 systemd7拷业务库与配置应用 + 证书 + 配置8打包成镜像cpio / tar / squashfs步骤 2 是新手最常漏的一步漏掉动态链接器的典型症状控制台报「no such file or directory」,但你确认文件明明在。这就是「缺解释器」的典型表现——内核需要的不是那个 ELF,而是 ELF 头部指定的动态链接器(如 /lib/ld-linux-armhf.so.3)。验证顺序:ldd 目标文件 → 看依赖的 loader 路径 → 确认该文件在镜像内且可执行
图 7 · 手工构建根文件系统的八步与依赖顺序

这八步存在的意义不是「替代 Buildroot」,而是让你在 Buildroot 出问题时知道它到底做了什么。很多现场问题(镜像里少了某个 so、服务启动了但依赖库没进去)只有手工打过一遍的人才能快速定位。

2.5 构建可复现性的工程要求

源码锁定 commit 而非 tag;tag 会被强制移动Git submodule 也要锁 commit最高优先级工具链锁定版本与编译选项;不用发行版滚动包SDK 里的 gcc 版本要写进文档高依赖包锁定源码 URL 与 sha256;不用「最新版」自建仓库保存所有 tarball高构建环境固定构建容器镜像;记录构建时间戳同一 tag 应产出同一 sha256中产物生成 sha256 清单并随制品归档清单要进版本库或制品库必须不可复现最典型的表现:同一个 commit 在两台机器上构建出的根文件系统 sha256 不同,且换一次就「碰巧好了」。
图 8 · 构建可复现性的五个层次与破坏点
破坏可复现的行为为什么危险对策
依赖 tag 而非 committag 可被强制移动锁 commit + 校验 sha256
下载「最新版」依赖包上游更新即行为变化自建 tarball 仓库
构建时联网下载外部不可用或被劫持预下载 + 离线构建
使用发行版滚动包工具链版本随系统升级变锁定 SDK 或自建工具链
在构建时嵌入时间戳同 commit 产出不同 sha256用 SOURCE_DATE_EPOCH
产物未归档无法回溯当时的环境归档制品 + 清单 + 环境信息
把「同 tag 同产物」作为验收项

最有效的一条工程要求是:同一 commit 在两台不同机器上构建,必须产出字节级相同的根文件系统。

验证方式很直接:构建两次,比对 sha256sum rootfs.tar。如果不同,用 diffoscope 或 tar -tvf 对比找出差异来源(通常就是时间戳、文件顺序、或某处随机 UUID)。

这条要求一旦落实,「这个版本怎么构建出来的」就不再是问题。

3 · Bootloader 与启动流程

Bootloader 是嵌入式系统里最容易被低估的一段代码。它只在上电后运行几百毫秒,却决定了后面所有环节的起点:内存对不对、设备树对不对、启动参数对不对、内核从哪来。这三个「对不对」任何一处出错,表现出来的都是「系统起不来」,而排查线索往往已经随串口日志一起消失了。

本章要解决的问题

① U-Boot 与 BootROM、SPL 的职责边界在哪里,什么时候必须拆成多级;
② 环境变量、bootargs、设备树三者如何共同决定内核看到的硬件;
③ A/B 分区怎么用启动计数实现「升级失败自动回滚」,以及它在什么情况下救不了场。

3.1 从上电到内核接管:五级引导链

① BootROM固化在 SoC 内的掩码 ROM,不可修改上电后 PC 指向它,只做一件事:找到并启动下一级② SPL / TPL最小化二级装载器,通常已搬进 DDR初始化 DDR 控制器,把 U-Boot 从存储搬进内存③ U-Boot Proper完整引导程序,带文件系统驱动与命令行初始化串口网口时钟,定位设备树与内核,加载并跳转④ Kernel ImagezImage / Image / FIT,含命令行或设备树解压自身,切换虚拟地址空间,跳转到 start_kernel⑤ init / 第一个应用systemd 或 BusyBox init,以及业务进程挂载根文件系统,执行初始化脚本,按依赖拉起业务每一级只做三件事:把下一级搬进内存、确定它在哪、跳转过去。级数越多,故障点越多——能合并的尽量合并。
图 9 · 从上电到内核接管:五级引导链与各自职责

五级不是必须的,但对绝大多数带 DDR 的 SoC 来说,BootROM 与 U-Boot 之间必须有 SPL,原因是 BootROM 体积被限制在几十 KB,它做不了「初始化复杂 DDR 控制器」这种需要几百行寄存器配置的事。

省掉 SPL 的代价

如果 SoC 的 BootROM 支持 XIP 读取 U-Boot(从 QSPI 内存直接执行),确实可以省掉 SPL。但一旦 U-Boot 需要 NOR 以外的存储格式、需要设备树、或者需要网络下载,就必须有独立的加载阶段。判断标准很简单:BootROM 只做「固定格式的复制」就够,还是需要「理解文件系统」?

3.2 环境变量与 bootargs:同一件事的两面

环境变量决定「怎么启动」,bootargs 决定「内核按什么假设启动」,两者是同一件事的两面ENV 块在存储上的物理布局crc1 (4B)数据区 (可变长)crc2 (4B)头尾两份 CRC 全部通过才被认为有效;任一份不匹配则整块作废,U-Boot 回落到内置默认值。这就是「改了环境变量重启后配置丢了」的直接原因。最常改的六个变量bootargs传给内核的启动命令行bootcmdU-Boot 阶段执行的命令bootdelay倒计时秒数,设 0 免按键等待ethaddr以太网 MAC,量产常由 eFuse 覆写bootpart从哪个分区读内核upgrade_availableOTA 标志位,配合 A/B 使用参数含义取值要点与常见坑root=根设备位置优先用 root=PARTUUID=十六进制串,不要用 /dev/mmcblk0p2 这种按序编号的名字rootfstype=根文件系统类型ext4 / squashfs / erofs / ubifs,必须与实际镜像一致,否则挂载失败console=控制台ttyS0,115200n8;追加 earlycon 可让内核更早打出日志init=第一个用户态程序默认 /sbin/init。systemd 场景一般不写,U-Boot 直接跳内核ip=内核级静态地址ip=10.0.0.8::10.0.0.1:255.255.255.0::eth0:off,省去用户态配置步骤mem=可用内存范围mem=512M@0x80000000,dtb 声明之外的内存对内核不可见mtdparts=MTD 分区表现代设备树已用 mtd 分区节点声明,此参数仅作兼容保留androidboot.Android 专用参数非 Android 系统不要照抄这套参数,会引入无意义的初始化行为
图 10 · U-Boot 环境变量与 bootargs 的参数交接

环境变量与 bootargs 最大的工程陷阱是它们的持久化方式。环境变量存在存储分区里,带着两段 CRC;bootargs 通常直接写在 U-Boot 源码的默认配置或 defconfig 里。前者可能被意外覆盖,后者不会——两者的行为差异决定了备份策略。

# U-Boot 中查看与修改环境变量
printenv                # 打印全部
printenv bootargs       # 只看一条
setenv bootargs 'console=ttyS0,115200 root=PARTUUID=1234-5678 rootfstype=ext4 rw'
saveenv                 # 写入存储(需要先 protect off 某些平台)
saveenv -f               # 强制保存,跳过 CRC 检查
量产设备上慎用 saveenv

U-Boot 的 saveenv 会在存储上执行「擦除 + 写入」,这本身会消耗 NOR 寿命。更严重的是:某些平台的 ENV 与固件同分区,一次 saveenv 可能连带破坏相邻的启动配置。量产阶段应当把关键配置固化到 defconfig,只保留调试用的 ENV 分区。

3.3 设备树在引导阶段的处理

路径一:原样传递(最常用)U-Boot 从存储读出 dtb 放进内存,bootm 时连同镜像一起交给内核,全程不修改。代价与边界:前提是同一份设备树精确描述当前板卡的每一个硬件。板卡有差异时只能出多份 dtb。路径二:fdtfixup 就地修改U-Boot 在内存里改 /chosen 的 bootargs、/memory、以及网口与存储的 MAC/容量字段。代价与边界:好处是同一份 dtb 适配多块板;风险是改坏了没有任何编译期检查,现场才发现。路径三:dtb 覆盖文件合并把易变部分拆成独立 .dtbo,启动时用 fdtoverlay 合并到基础 dtb 上。代价与边界:适合大批量小差异场景;代价是启动多一步,且覆盖内容与基础 dtb 的关系需要自己维护。经验:优先用「多份 dtb + 板型选择」而不是「运行时改写」,前者的问题在编译期就暴露,后者要到现场。
图 11 · 设备树在引导阶段的处理:三条路径与各自代价
处理路径改动发生时机谁能看到改动适用场景
原样传递U-Boot 内部不产生只有内核单板卡、硬件固定
fdtfixupU-Boot 内存中的 dtb 被改写内核看到的已是改后版本同型号多板卡、MAC 差异化
dtbo 覆盖启动时由 fdtoverlay 合并内核看到合并后的结果大批量小差异、模组化设计

3.4 A/B 分区与自动回滚

bootloader双备份boot-args只读A 根当前运行B 根OTA 目标data配置与校准cache可清空① 启动前Bootloader 读取 boot-count未达上限则自增并写入② 拉起新系统从另一分区加载镜像把该分区标记为 pending③ 应用自检业务就绪后写 good 事件boot-count 清零④ 超时回滚未收到 good 事件跳回旧分区并自减计数机制作用工程要点boot-count剩余尝试次数初值设为「最大重试次数 + 1」,不要给太大,否则回滚迟迟不触发bootlimit超过即强制回滚U-Boot 的 bootcount 与 systemd-boot 的 boot-limit 语义不同,需确认实际生效者升级后未确认最危险的窗口新系统必须能独立起来并主动上报 good,不能依赖旧系统的残留状态确认时机业务自检通过之后内核起来不算成功,应用关键线程起来才算回滚代价多花一次启动时间现场表现是「升级后要重启两次」,必须在产品说明里写清数据兼容A/B 共用 data 分区数据结构升级需保证新版本能读旧版本数据,或在升级前主动迁移
图 12 · A/B 分区方案与启动计数回滚状态机
回滚机制真正的价值不在 OTA 本身

启动计数 + 自动回滚最大的价值其实是防砖:无论是 OTA 升级出错、现场断电,还是用户自己刷了不匹配的固件,设备都能自己恢复到上一版。对无法上门维护的设备来说,这一条比「升级包小 10%」重要得多。

但它也有明确的失效边界,必须在设计阶段就想清楚:

失效场景为什么回滚救不了应对措施
A 与 B 都刷坏两槽位都不可启动Bootloader 自身双备份,且备份区不参与 OTA
data 分区数据结构升级后不兼容内核起来了,业务读不出数据数据结构双版本兼容,升级前主动迁移
新版本能启动但功能异常应用自检没做或做得不够自检必须覆盖关键业务路径,失败即写回滚标记
设备永久卡在某槽位计数逻辑本身有 bug计数逻辑单独测试,包含断电重入场景

4 · 根文件系统构建

根文件系统是内核之外的一切。它不参与内核启动,但决定了「内核起来之后这个设备到底能做什么」。这一章回答三个问题:里面该放什么、怎么组合、怎么把体积压到目标以内。

4.1 根文件系统的内容清单

必需(缺一个系统就起不来)/bin、/sbin最小工具集与启动程序/lib 或 /usr/liblibc 与全部共享库/etc配置:passwd、fstab、启动脚本/dev、/proc、/sys内核伪文件系统的挂载点/sbin/init 及其链接第一个用户态程序/var 或 /run运行时状态与套接字/tmp临时文件,静态构建时也要建建议(缺了能跑但难维护)/usr/bin、/usr/sbin扩展工具与应用/var/log日志目录,不要放 /tmp/etc/udev 或 mdev设备节点生成规则/usr/lib/systemdunit 文件内核模块目录确认是否随镜像一起装/usr/share时区、证书、locale 数据/root 或 /home极少用到,可裁剪可裁剪(按体积目标砍)/usr/share/doc文档,几 MB 到几十 MB/usr/include头文件,量产镜像绝不该带/usr/src源码与调试信息/usr/share/man手册页静态调试符号目录分离部署时须删除测试套件与样例可由 CI 单独提供编辑器与交互增强vi、bash 补全等编译期残留文件构建中间产物最易被漏
图 13 · 根文件系统的内容清单:必需、建议、可裁剪
最容易漏掉的三个目录

① /etc/udev/rules.d 或 /etc/mdev.conf:没有它,/dev 下的设备节点要么不生成、要么权限全错,应用打开设备直接 EACCES。
② /var/log:很多产品把日志默认写 /tmp,而 /tmp 在 Linux 上默认是 tmpfs,占的是内存,重启即丢。
③ 内核模块目录:很多平台用 modprobe 加载模块,/lib/modules/$(uname -r)/ 不存在时模块加载会静默失败。

4.2 只读根与可写层的四种组合

方案一:单一可写根(ext4 直挂)机制:整个根文件系统就是一块可写分区,启动后直接挂载读写。优势:最简单,传统做法;调试方便,不需要任何额外设计。代价:镜像与运行态混在一起,异常断电可能让根文件系统本身损坏,需要 e2fsck 介入。适用:开发板、寿命要求不高、功能简单的终端。开发板、寿命要求不高、功能简单的终端。方案二:只读根 + tmpfs 可写层机制:overlayfs:lower 挂只读镜像,upper 与 work 放在 tmpfs。优势:升级即换镜像,重启即自动还原,现场状态永远干净。代价:可写层占 RAM 且重启即丢,日志与计数类数据必须另找地方存。适用:绝大多数无本地存储要求的 IoT 终端。绝大多数无本地存储要求的 IoT 终端。方案三:只读根 + 持久可写层机制:overlayfs:lower 挂只读镜像,upper 挂一块独立的可写分区。优势:数据可跨重启保留,同时升级只替换 lower,出问题回滚即可。代价:upper 无上限增长,需要明确配额与清理策略,否则几个月就把分区写满。适用:有本地数据留存需求的产品,如网关、记录仪。有本地数据留存需求的产品,如网关、记录仪。方案四:不可变根 + 独立 data 分区机制:根与 /usr 全只读,程序按约定把所有可变数据写进 /data。优势:根永远不变,故障定位时不需要考虑「根被谁改了」这类问题。代价:路径约定靠代码保证,写错位置就写到只读挂载点而失败,需要在开发期就检查。适用:安全要求高、生命周期长、需长期维护的设备。安全要求高、生命周期长、需长期维护的设备。
图 14 · 只读根与可写层的四种组合方案对比

四种方案没有优劣之分,只有适配与不适配。真正需要判断的是两个问题:这个设备的数据要活多久,以及出问题后现场的人能不能重新刷机。第一个问题决定选方案二还是三,第二个问题决定选方案二还是四。

# 方案二/三 的 overlay 挂载示例(写入 fstab 或 systemd unit)
# lower 为只读镜像,upper 为可写层,work 与 lower/upper 必须同格式
/       /rootfs   squashfs  loop,ro,defaults        0 0
/dev/mmcblk0p2  /       ext4    defaults,noatime    0 1
# 结果:/ 看到的是 lower 叠加 upper 的视图

4.3 镜像格式选型

格式只读压缩随机写挂载速度典型用途squashfs原生zstd / lz4不支持快只读根镜像、A/B 槽位,长期标准选择erofs原生lz4,压缩比更高不支持最快新内核上的只读根,元数据可直接 mmapext4否无支持中可写根与 data 分区,需配合日志回放ubifs否LZO / zstd支持慢NAND 介质,掉电安全性由 UBI 层保证jffs2否轻量支持很慢1~8 MB 级小容量 NOR,功能受限tmpfs否无支持即时/run 与 /tmp,占的是 RAM 不是存储选型的三条硬约束① 介质决定候选集。NAND 上不用 ext4 直挂(FTL 之上再叠日志回放,写放大与掉电风险都会放大),应选 ubifs 或只读 + 独立分区。② 生命周期决定是否可写。设备跑三年以上且无人上门,只读根 + 独立 data 是唯一能睡着觉的方案。③ 挂载时间决定是否可接受。erofs 与 squashfs 的挂载是近乎零成本的,ext4 在目录较大时会有明显延迟。
图 15 · 常见根文件系统镜像格式的横向对比
格式支持随机写元数据掉电保护适合的介质本文建议
squashfs否天然只读,无写放大NOR / eMMC / NANDA/B 方案的默认选择
erofs否天然只读,元数据可 mmapeMMC / NAND新设计优先考虑
ext4是依赖 jbd2 日志回放eMMC可写 data 分区
ubifs是UBI 卷 + PEB 原子写NANDNAND 上需要可写时
jffs2是节点级原子写小容量 NOR8 MB 以下的 NOR 备用方案
tmpfs是无(重启即失效)RAM只用于 /run 与 /tmp

4.4 镜像瘦身:体积去了哪里

内核模块24%只保留本板实际用到的驱动其余模块在构建期就应被排除libc / libstdc++22%改用 musl 或裁掉未用的 locale对 glibc 依赖重的产品收益最大shell 与基础工具18%BusyBox 单文件可替代十几个工具同时省去大量共享库依赖应用与配置15%业务代码本身通常不是大头,除非带大资源包文档、图标、字体12%量产镜像里几乎总能整块删掉调试时需要,量产时不需要locale 与证书9%只留 zh_CN.UTF-8 与实际用到的根证书其余语言包与 CA 可全部裁掉裁剪的三条原则① 用「白名单」构建根文件系统,而不是「构建完整系统再删」——后者永远删不干净,且删漏了没人知道。② 删完必须实测启动:很多程序在缺少某个库时报错信息都写不出来,直接静默退出。③ 分级出包:调试包带符号与工具,量产包只留运行必需,两者用同一份构建描述生成。
图 16 · 镜像体积去了哪里:裁剪前后的典型分布
# 查清镜像里到底什么最占地方(比 du 更快,能看到未压缩真实占用)
du -x -h --max-depth=1 /target/rootfs | sort -h
find /target/rootfs -type f -size +1M -exec ls -lh {} \; | sort -k5 -h

# 构建后必查的三类冗余
find /target/rootfs -name '*.a' -o -name '*.la' -o -name '*.pyc'   # 编译中间产物
find /target/rootfs -name '*.h' -o -name '*.hpp' -o -name '*.so.debug'  # 开发期文件
ldd /target/rootfs/usr/bin/app   # 确认没有多余的库依赖
把体积目标变成 CI 门禁

「镜像小于 200 MB」这种约束,如果只写在文档里,几个月后一定会被突破。正确的做法是把它做成构建流水线上的一步:构建完成后自动统计体积,超阈值直接让流水线失败。同理,镜像里出现 .h、.a、pyc 这类文件也应该直接判失败——它们出现的原因往往是某个包被错误地整包安装,而不是有意为之。

扫码打开本页
微信扫码 · 展会可扫

再分享给同事

同事可直接打开这份资料《Linux_嵌入式系统开发指南》,在线阅读;完整章节请下载芯参谋。