Linux 嵌入式系统开发指南
编制日期 2026-10-05 | 版本号 Rev 1.4
面向 MPU / Linux 平台的用户态系统层 · 覆盖交叉工具链、Bootloader 交接、根文件系统构建、init 与 systemd、进程与线程、信号与异步 IO、网络栈接入、调试可观测、实时性、安全加固、电源管理、OTA 与容器 · 与内核篇互补
一句话结论:嵌入式 Linux 的用户态系统层,真正决定产品体验的不是内核版本,而是启动链的每一段是否都被测量与控制过。工具链不一致会让同一份代码在不同机器上行为不同;根文件系统多塞一个库会让镜像胖30% 并且启动慢两秒;init 顺序错会让服务在依赖未就绪时反复重启;日志刷在错误的位置会让现场问题无法复现。本文把这些点逐条变成可测量、可判据化的工程项——启动多少秒、镜像多少兆、峰值多少内存、断电后能否恢复,每一项都有量化判据。
这份文档解决什么
本文是用户态系统层的专项指南:讲清嵌入式 Linux 从上电到应用运行之间的全部用户态环节——工具链与构建体系、Bootloader 交接、根文件系统怎么搭、init 用什么、服务怎么编排、进程与线程怎么组织、异步 IO 怎么写、网络怎么接、崩溃怎么抓、性能怎么测、安全怎么加固、电源怎么管、OTA 怎么做、容器能不能用。内核侧(内核配置、设备树、内核态驱动、内核调试)由《Linux 内核开发指南(BSP Kernel)》承接;存储软件栈(FTL、文件系统、掉电一致性)由《嵌入式文件系统开发指南》承接;具体 Flash 器件驱动由《SPI NOR / SPI NAND / PPI NAND / eMMC 驱动开发指南》系列承接。本文站在它们之上,只讲用户态。
读者路径
路线 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 与内核篇的分工边界
两个文档的边界很清晰:内核篇讲内核返回用户态之前的事,本篇讲之后的事。但工程上出问题的地方,往往就在这条接缝上——设备树被应用层覆盖、挂载点不存在导致服务反复重启、内核已经 probe 成功但用户态找不到 /dev 节点。
遇到「设备不工作」类问题,第一步不是去翻内核代码,而是按顺序确认三个接缝:
① /proc/device-tree/ 里的设备树是不是应用预期的(有没有被覆盖);
② /dev/ 与 /sys/class/ 下的节点在不在、权限对不对;
③ dmesg 里有没有 probe 失败或 uevent 没发出。
三处都正常再看应用层,能省掉大量无效的内核侧排查。
1.2 从上电到第一个应用的九个阶段
图 2 下半部分的「责任方」列是本章最有价值的部分:每一段耗时归属于不同的团队与不同的工具。启动优化之所以难,往往不是因为某段特别慢,而是因为没人知道自己负责的那段占了多少。
在优化之前先做测量。方法很简单:在 Bootloader 入口、内核 start_kernel 之后、init 执行前后、以及每个服务启动完成时各打印一条带时间戳的日志。
分段之后你会发现,典型设备的启动时间分布往往是这样的:Bootloader 8%、内核 15%、initramfs 12%、systemd 拉起服务 55%、业务自检 10%。systemd 那55% 才是主战场,而它往往被忽略,因为内核和驱动都「看起来很快」。
1.3 用户态系统层的六个子系统
1.4 本文覆盖范围与前置约定
| 主题 | 本文覆盖 | 不在本文范围 |
|---|---|---|
| 工具链 | 交叉编译、sysroot、glibc/musl 选型 | 内核编译(见内核篇) |
| Bootloader | U-Boot 环境变量、设备树交接、A/B | U-Boot 移植的寄存器级细节 |
| 根文件系统 | 内容清单、构建方式、镜像瘦身 | 具体文件系统内部机制(见文件系统篇) |
| init | systemd 与 BusyBox init、依赖编排 | 内核 initcall 机制 |
| 运行时 | 进程、线程、内存、IPC、epoll | 内核调度器实现 |
| 网络 | Socket / netlink、DHCP / mDNS、保活 | 网卡驱动与协议栈内核实现 |
| 可观测 | 日志体系、崩溃转储、perf / eBPF | ftrace / KASAN(见内核篇) |
| OTA | A/B、验签、回滚触发、数据迁移 | 安全启动的硬件根信任 |
① 读者已有可启动的内核。本文不假设从零移植内核与BSP——那是《Linux 内核开发指南(BSP Kernel)》与芯片原厂 SDK 的范围。
② 读者有目标板。本文所有调优与验证都依赖真实硬件的时序、内存与温度,PC 上的仿真结论经常是错的。
③ 本文的判据都可量化。凡是「快」「稳定」「省电」这类形容词,本文都会给出对应的数字与测量方法;给不出数字的,说明它其实无法工程化。
2 · 交叉工具链与构建体系
工具链是所有后续工作的地基。它出问题时症状往往很奇怪(链接过但运行崩、同一份代码在不同机器上行为不同),所以必须在项目一开始就固定下来。
2.1 工具链的四段构成
| 环节 | 命令前缀 | 关键文件 | 出问题时看 |
|---|---|---|---|
| 预处理 | $CC -E | -include | 宏与头文件是否找到 |
| 编译 | $CC -c | -mabi / -march | 指令集与 ABI 是否匹配 |
| 汇编 | $AS | -x assembler | 内联汇编是否可编 |
| 链接 | $LD | 链接脚本 / crt*.o | 缺 _start、段布局、库顺序 |
| 后处理 | $OBJCOPY | -O binary / elf32-little | 镜像格式与地址 |
| 运行 | $LD_SO | ld-linux-*.so | 缺解释器 / 库版本不匹配 |
典型场景:SDK 里带了交叉工具链,但业务代码的构建脚本里写了 -I/usr/include(宿主机路径),或者手动从另一套 SDK 拷了库文件。
症状:编译链接全过,运行时在 malloc / strlen / printf 上崩溃或输出乱码。原因是结构体布局与符号解析来自不同版本。
防线有三条:① 编译时用 -nostdinc 配合显式 -isystem,确保头文件只来自 sysroot;② 构建脚本里打印 $CC -print-sysroot 确认路径;③ 制品归档时记录 ldd 输出。
2.2 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 的对比
| 关注点 | Buildroot | Yocto |
|---|---|---|
| 首次出镜像 | 分钟级 | 小时级 |
| 增量改一个包 | 重新配置 + 构建,几分钟 | 只重建该包,秒级 |
| 加一个驱动模块 | 配置内核选项后重编 | 写一个 .bbappend 即可 |
| 产物体积 | 小(默认只含需要的) | 偏大(需 toolschain 裁剪) |
| 多产品并行 | 需维护多份 defconfig | layer 天然支持 |
| 合规与审计 | 较简单 | 有 SPDX / 组件清单 |
| 团队学习成本 | 低 | 高 |
绝大多数项目应该先用 Buildroot 打通。原因:它的反馈快,能让你把精力花在真正的难点(驱动对接、启动时间、服务依赖)上,而不是构建系统本身。
当出现下面任一情况时再评估迁Yocto:
· 同一 BSP 要支撑 5 个以上产品(型号与分支差异多);
· 需要 SPDX 清单或第三方审计;
· 需要长期维护并且有专职构建工程师;
· 需要极致的裁剪(Buildroot 加patch 成本已变高)。
迁移时最费时的不是应用代码,而是 layer 结构与自定义 recipe 的整理。
2.4 手工构建根文件系统的步骤
这八步存在的意义不是「替代 Buildroot」,而是让你在 Buildroot 出问题时知道它到底做了什么。很多现场问题(镜像里少了某个 so、服务启动了但依赖库没进去)只有手工打过一遍的人才能快速定位。
2.5 构建可复现性的工程要求
| 破坏可复现的行为 | 为什么危险 | 对策 |
|---|---|---|
| 依赖 tag 而非 commit | tag 可被强制移动 | 锁 commit + 校验 sha256 |
| 下载「最新版」依赖包 | 上游更新即行为变化 | 自建 tarball 仓库 |
| 构建时联网下载 | 外部不可用或被劫持 | 预下载 + 离线构建 |
| 使用发行版滚动包 | 工具链版本随系统升级变 | 锁定 SDK 或自建工具链 |
| 在构建时嵌入时间戳 | 同 commit 产出不同 sha256 | 用 SOURCE_DATE_EPOCH |
| 产物未归档 | 无法回溯当时的环境 | 归档制品 + 清单 + 环境信息 |
最有效的一条工程要求是:同一 commit 在两台不同机器上构建,必须产出字节级相同的根文件系统。
验证方式很直接:构建两次,比对 sha256sum rootfs.tar。如果不同,用 diffoscope 或 tar -tvf 对比找出差异来源(通常就是时间戳、文件顺序、或某处随机 UUID)。
这条要求一旦落实,「这个版本怎么构建出来的」就不再是问题。
3 · Bootloader 与启动流程
Bootloader 是嵌入式系统里最容易被低估的一段代码。它只在上电后运行几百毫秒,却决定了后面所有环节的起点:内存对不对、设备树对不对、启动参数对不对、内核从哪来。这三个「对不对」任何一处出错,表现出来的都是「系统起不来」,而排查线索往往已经随串口日志一起消失了。
① U-Boot 与 BootROM、SPL 的职责边界在哪里,什么时候必须拆成多级;
② 环境变量、bootargs、设备树三者如何共同决定内核看到的硬件;
③ A/B 分区怎么用启动计数实现「升级失败自动回滚」,以及它在什么情况下救不了场。
3.1 从上电到内核接管:五级引导链
五级不是必须的,但对绝大多数带 DDR 的 SoC 来说,BootROM 与 U-Boot 之间必须有 SPL,原因是 BootROM 体积被限制在几十 KB,它做不了「初始化复杂 DDR 控制器」这种需要几百行寄存器配置的事。
如果 SoC 的 BootROM 支持 XIP 读取 U-Boot(从 QSPI 内存直接执行),确实可以省掉 SPL。但一旦 U-Boot 需要 NOR 以外的存储格式、需要设备树、或者需要网络下载,就必须有独立的加载阶段。判断标准很简单:BootROM 只做「固定格式的复制」就够,还是需要「理解文件系统」?
3.2 环境变量与 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 检查
U-Boot 的 saveenv 会在存储上执行「擦除 + 写入」,这本身会消耗 NOR 寿命。更严重的是:某些平台的 ENV 与固件同分区,一次 saveenv 可能连带破坏相邻的启动配置。量产阶段应当把关键配置固化到 defconfig,只保留调试用的 ENV 分区。
3.3 设备树在引导阶段的处理
| 处理路径 | 改动发生时机 | 谁能看到改动 | 适用场景 |
|---|---|---|---|
| 原样传递 | U-Boot 内部不产生 | 只有内核 | 单板卡、硬件固定 |
| fdtfixup | U-Boot 内存中的 dtb 被改写 | 内核看到的已是改后版本 | 同型号多板卡、MAC 差异化 |
| dtbo 覆盖 | 启动时由 fdtoverlay 合并 | 内核看到合并后的结果 | 大批量小差异、模组化设计 |
3.4 A/B 分区与自动回滚
启动计数 + 自动回滚最大的价值其实是防砖:无论是 OTA 升级出错、现场断电,还是用户自己刷了不匹配的固件,设备都能自己恢复到上一版。对无法上门维护的设备来说,这一条比「升级包小 10%」重要得多。
但它也有明确的失效边界,必须在设计阶段就想清楚:
| 失效场景 | 为什么回滚救不了 | 应对措施 |
|---|---|---|
| A 与 B 都刷坏 | 两槽位都不可启动 | Bootloader 自身双备份,且备份区不参与 OTA |
| data 分区数据结构升级后不兼容 | 内核起来了,业务读不出数据 | 数据结构双版本兼容,升级前主动迁移 |
| 新版本能启动但功能异常 | 应用自检没做或做得不够 | 自检必须覆盖关键业务路径,失败即写回滚标记 |
| 设备永久卡在某槽位 | 计数逻辑本身有 bug | 计数逻辑单独测试,包含断电重入场景 |
4 · 根文件系统构建
根文件系统是内核之外的一切。它不参与内核启动,但决定了「内核起来之后这个设备到底能做什么」。这一章回答三个问题:里面该放什么、怎么组合、怎么把体积压到目标以内。
4.1 根文件系统的内容清单
① /etc/udev/rules.d 或 /etc/mdev.conf:没有它,/dev 下的设备节点要么不生成、要么权限全错,应用打开设备直接 EACCES。
② /var/log:很多产品把日志默认写 /tmp,而 /tmp 在 Linux 上默认是 tmpfs,占的是内存,重启即丢。
③ 内核模块目录:很多平台用 modprobe 加载模块,/lib/modules/$(uname -r)/ 不存在时模块加载会静默失败。
4.2 只读根与可写层的四种组合
四种方案没有优劣之分,只有适配与不适配。真正需要判断的是两个问题:这个设备的数据要活多久,以及出问题后现场的人能不能重新刷机。第一个问题决定选方案二还是三,第二个问题决定选方案二还是四。
# 方案二/三 的 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 | 否 | 天然只读,无写放大 | NOR / eMMC / NAND | A/B 方案的默认选择 |
| erofs | 否 | 天然只读,元数据可 mmap | eMMC / NAND | 新设计优先考虑 |
| ext4 | 是 | 依赖 jbd2 日志回放 | eMMC | 可写 data 分区 |
| ubifs | 是 | UBI 卷 + PEB 原子写 | NAND | NAND 上需要可写时 |
| jffs2 | 是 | 节点级原子写 | 小容量 NOR | 8 MB 以下的 NOR 备用方案 |
| tmpfs | 是 | 无(重启即失效) | RAM | 只用于 /run 与 /tmp |
4.4 镜像瘦身:体积去了哪里
# 查清镜像里到底什么最占地方(比 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 # 确认没有多余的库依赖
「镜像小于 200 MB」这种约束,如果只写在文档里,几个月后一定会被突破。正确的做法是把它做成构建流水线上的一步:构建完成后自动统计体积,超阈值直接让流水线失败。同理,镜像里出现 .h、.a、pyc 这类文件也应该直接判失败——它们出现的原因往往是某个包被错误地整包安装,而不是有意为之。