ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RK3588嵌入式Linux启动优化实战:从BootROM到用户空间的亚秒级加速

RK3588嵌入式Linux启动优化实战:从BootROM到用户空间的亚秒级加速 1. RK3588 的启动链路到底长什么样从 ROM 代码到用户态的三段式旅程在做优化之前先把链路摸清楚是最重要的。RK3588 这颗 SoC 和绝大多数瑞芯微平台一样启动过程分成三个特征非常明显的阶段芯片内部的 BootROM、系统引导器U-Boot 体系、内核与用户空间。很多人一上来就调内核参数、改 systemd 配置结果发现启动时间怎么都压不进亚秒就是因为没搞明白时间到底消耗在哪个环节。我自己的经验是先做完整的链路时间拆分再谈优化方案否则全是盲调。1.1 BootROM 阶段芯片出厂固化的第一段代码RK3588 上电后CPU 首先执行的并不是外部存储上的任何代码而是芯片内部 ROM 中固化的 BootROM。这段代码用户没法修改也没法跳过。它要做的事情非常固定初始化最基础的时钟和引脚然后按照预设的启动介质顺序去读取第一份引导代码。RK3588 支持从 eMMC、SD 卡、SPI NOR/NAND、USB、UART 等多种介质启动BootROM 会按照烧录在 OTP 里或回退逻辑中预设的顺序逐个尝试。对于启动优化来说BootROM 阶段我们能主动干预的空间非常小但这不代表这个阶段不值得关注。BootROM 真正决定的是从哪个介质加载代码以及加载方式从 eMMC 读 idbloader 时找 4KB 对齐的区域从 SPI NOR 读时又是另一套偏移。很多开发者容易忽略一个事实——如果 BootROM 先尝试 SD 卡失败再回退到 eMMC时间会白白多出几十甚至上百毫秒。这部分时间完全可以通过配置启动介质优先级或者直接屏蔽不需要的介质来省掉。改 OTP 位域相对麻烦但至少在量产固件里通过烧录工具配置正确的启动介质选项能确保 BootROM 一次命中目标介质。1.2 TPL/SPL 与 U-Boot 阶段DDR 初始化和 ATF 固件加载BootROM 加载到的第一段用户可控代码是 idbloader.img这个镜像由 TPL 和 SPL 两部分拼接而成。为什么是两级因为 RK3588 的 DDR 初始化需要一段运行在 SRAM 里的代码来执行而 DDR 没初始化之前外部 RAM 根本用不了U-Boot 本体又放不进 SRAM。于是流程就成了TPL 先跑起来完成 DDR 初始化然后把 SPL 搬到 DDR 中SPL 负责从存储介质读取完整的 U-Boot 镜像并做校验U-Boot 跑起来之后还要加载 ARM Trusted FirmwareBL31和可选的 OP-TEEBL32最后才把内核镜像和设备树加载进内存并跳转执行。这个阶段的时间开销构成非常典型我实际测过的 RK3588 板卡大致可以拆成这么几块耗时项典型耗时范围说明TPL 执行DDR 训练80ms ~ 300ms重头戏和 DDR 频率与训练算法强相关SPL 读取 U-Boot 镜像20ms ~ 80ms取决于存储介质和读取速度U-Boot 自身初始化50ms ~ 150ms串口、时钟、存储驱动都要跑一遍U-Boot 加载 ATF/OP-TEE20ms ~ 60ms镜像解析、搬运、校验U-Boot 加载内核30ms ~ 100ms内核镜像解压前的读取与校验在很多默认配置的板卡上从按下电源键到 U-Boot 进入命令行花 400ms 甚至更长时间一点都不奇怪。而这一阶段的优化空间恰恰是整个全链路里最容易被低估的一块。1.3 内核与用户空间阶段系统真正活过来的最后一段路U-Boot 把内核镜像和设备树加载到内存并跳转后进入内核阶段。这里又分成几个子阶段内核解压、设备树解析、驱动初始化、initramfs 挂载如果有、根文件系统切换、init 进程启动。RK3588 的内核镜像如果不做压缩优化默认的 gzip 解压在 A76 大核上倒是很快但读取镜像的时间和镜像体积成正比。驱动初始化则是另一个大头——如果把 RK3588 的 HDMI、MIPI CSI、USB3.0、PCIe、NPU 等驱动全编译进内核并且设备树里全部默认 enable那启动时间一定不会好看因为这些驱动各自要申请时钟、复位、电源域串行初始化下来非常可观。进入用户空间之后systemd 风格的发行版还要经历服务依赖解析、设备枚举udev、各类守护进程拉起的过程。到了这一步启动时间优化的战场已经从怎么更快加载代码变成了怎么减少要初始化的东西和怎么让没必要的服务晚点启动。2. 系统引导器阶段优化把 U-Boot 从负累变成跳板U-Boot 本身是个完整的引导程序但完整意味着功能冗余。RK3588 的 U-Boot 默认配置里包含了大量我们根本用不到的驱动和命令比如网络协议栈、USB host 驱动、文件系统支持、环境变量编辑命令等。这些功能在开发调试阶段很有用但在追求亚秒级启动量产产品时它们每一行都会吞噬宝贵的启动时间。2.1 通过配置裁剪 SPL 和 U-Boot 的无效功能Rockchip 的 U-Boot 源码通过 defconfig 管理配置。对 SPL 阶段来说最关键的是去掉一切和当前存储介质无关的驱动。假如你的产品从 eMMC 启动那 SPL 里只需要保留对应的 MMC 驱动和一个简单的文件系统/原始分区读取逻辑其它全部关掉。SPL 里常见的裁剪项包括关闭 SPI NOR/NAND 驱动如果不用关闭 USB 驱动SPL 阶段基本不需要关闭网络协议栈PXE、TFTP 等在 SPL 阶段都不需要关闭命令行支持SPL 里不需要交互关闭校验和哈希算法如果固件本身不要求校验我在实际项目里把 SPL 的默认配置裁掉差不多一半的驱动后SPL 从 60ms 左右降到了 25ms 左右。别小看这几十毫秒后面每个阶段省一点加起来就接近亚秒目标了。U-Boot 本体同样要裁去掉 bootmenu、环境变量自动保存、各种不需要的命令。命令这块尤其能体现默认配置有多浪费——一个help命令背后是几十个命令的注册和字符串表全部去掉之后U-Boot 的镜像体积和初始化时间都会明显下降。2.2 DDR 训练优化耗时大户的两种处理思路DDR 训练是 TPL 阶段最耗时的部分。RK3588 支持的最高内存频率在 LPDDR4/LPDDR5 下能达到 2133MHz 甚至更高频率越高训练项越多耗时越长。官方默认的 DDR 训练算法为了兼容不同批次的内存颗粒和不同的 PCB 布线会执行完整的训练流程包括 write leveling、read gate training、CA training、DQ training 等整个流程跑下来超过 200ms 很常见。处理思路有两类。第一类是降频训练升频运行在 TPL 阶段用较低频率完成基本训练快速把系统拉起来之后在运行时再切到高频。这个方案理论上可行但对 DDR 控制器的动态调频要求比较高RK3588 平台上的实现复杂度也偏高。另一类更常见、也更容易出效果的思路是训练参数复用内存颗粒型号固定、PCB 布线固定之后DDR 训练结果其实是非常稳定的。我们可以通过 Rockchip 提供的 DDR 调试工具在开发阶段把训练好的参数导出然后在量产固件里跳过完整训练直接加载已有的参数配置。实测这一项就能省下 100ms 以上。当然跳过训练的前提是批次的硬件一致性足够好如果换了内存颗粒供应商或者改了 PCB 布局这个优化就得重新验证。2.3 精简 U-Boot 到内核之间的加载路径U-Boot 加载内核的方式也会影响启动时间。默认情况下U-Boot 可能会先探测存储设备、挂载文件系统、读取环境变量、解析 ext4 分区然后才去找 kernel 镜像。这一连串操作在开发时都合理但量产时完全可以把中间过程砍掉。推荐的路径是U-Boot 直接从固定偏移读取 FIT 镜像kernel dtb ATF 打成一个包不做文件系统解析不做动态环境变量加载读取完成后直接校验并跳转。FIT 镜像还有一个隐藏优势它可以把 BL31、OP-TEE、内核、设备树做成一个镜像U-Boot 只需要做一次 DMA 读取就能把整包搬到内存避免了多次读取带来的寻址开销。配合 U-Boot 的bootflow或者自定义的 bootcmd把启动流程写成读一个包、跳转两件事整个加载路径能控制在几十毫秒级别。3. 内核阶段提速压缩算法、设备树裁剪与关键驱动加载顺序内核阶段的优化很多人第一反应是裁剪内核配置。这个方向没错但有一个前提必须区分编译时裁剪和运行时裁剪。编译时裁剪是在 Kconfig 层面就把不需要的子系统去掉运行时裁剪则是通过设备树控制外设的 enable 状态。两者都会影响启动时间但机制完全不同。3.1 内核镜像压缩格式的选择与解压时机RK3588 的内核镜像在 U-Boot 里通常以压缩形式存放。默认的 Image.gzgzip 压缩解压速度尚可但镜像体积较大Image.lz4 的解压速度非常快在 A76 大核上差不多是 gzip 的 2 到 3 倍代价是压缩率略低。如果你的存储介质读取带宽充裕选 lz4 是划算的读取多花几毫秒解压省几十毫秒整体是正收益。还有一个容易被忽略的点U-Boot 跳转到内核后在内核解压完成之前系统处于bootloader 已结束、内核未接管的真空期。这个阶段 ARM64 平台的 decompress 代码会把整个镜像搬到内存再解压如果有 DMA 冲突或者 cache 未清理的问题还可能产生额外延迟。我的做法是把内核镜像放在连续的高位内存地址并在 U-Boot 启动参数里显式指定kernel_comp_addr_r和kernel_comp_size避免解压过程中因为内存布局不合理导致的额外拷贝。3.2 设备树裁剪不要打开你不需要的外设设备树对启动时间的影响比很多人以为的要大。RK3588 的设备树rk3588s.dtsi 加上板级 dts默认开启了一堆外设比如 HDMI、DP、MIPI DSI、多个 I2C/SPI 控制器、USB 控制器、PCIe 等。这些外设在内核启动时driver 的 probe 函数会去操作对应的时钟、电源、复位、中断控制器哪怕没有连接任何实际的设备初始化流程一样会走一遍。一个典型的 HDMI 驱动 probe 可能要花十几毫秒PCIe 的枚举更是可以达到几十毫秒。所以设备树裁剪的原则很简单凡是产品里用不到的外设直接删掉对应的 dts 节点或者在根节点下把 status 改成 disabled。但这里有个坑删掉节点和禁用节点的效果不完全一样。禁用节点只是让内核不 probe但某些时钟和电源域如果被其它模块引用还是会被持续供电。彻底裁剪时需要检查一下 clock 和 power-domain 的引用关系避免删了一个节点反而导致另一个节点初始化报错。我遇到过一种情况板子上不用 MIPI CSI但删掉 CSI 节点后RK3588 的 ISP图像信号处理器节点因为引用了同一个 VICAP 时钟而初始化失败。排查了很久才发现原来是某个公共时钟域的引用计数出了问题。所以设备树裁剪不是简单的删节点而是要结合内外设的依赖关系来做改完一定要做完整的启动日志对比。3.3 内核配置中的关键选项printk、initramfs 与强制并行内核启动过程中printk 的日志输出看似无关紧要实际影响不小。在串口波特率 115200 的情况下一行 80 字符的日志要输出差不多 7ms如果启动过程打了 200 行日志光是串口输出就占了 1.4 秒。这个开销在追求亚秒级启动的项目里完全不可接受。优化手段是把CONFIG_CONSOLE_LOGLEVEL_DEFAULT调低比如设为 3只输出 KERN_ERR 以上的日志同时在 cmdline 里加loglevel3甚至quiet。调试阶段可以把日志级别提高量产固件再压下来。initramfs 的取舍也值得斟酌。如果根文件系统直接放在 eMMC 的 ext4 分区上且内核配置了对应的文件系统和块设备驱动完全可以不用 initramfs省去加载 initramfs → 挂载 → switch_root这一整套流程。省下来的时间通常在 30ms 到 80ms 之间。但要注意如果根文件系统放在需要外部固件加载才能访问的设备上或者用了 LUKS 加密盘那就必须保留 initramfs。RK3588 很多方案的 rootfs 在 eMMC 的普通 ext4 分区上这几个条件都满足完全可以去掉 initramfs直接用root/dev/mmcblk0pX启动。内核配置里还有一项值得留意就是CONFIG_PREEMPT和CONFIG_HZ。RK3588 这种大小核架构默认的 HZ250 或者 HZ1000 对启动时间影响不大但CONFIG_PREEMPT甚至CONFIG_PREEMPT_RT会在调度器初始化时引入额外开销。如果产品不需要硬实时用默认的CONFIG_PREEMPT_VOLUNTARY反而对启动更友好。此外initcall 阶段的并行化策略和驱动 probe 的顺序RK3588 平台可以通过initcall_debug打印来观察找出那些真正阻塞启动的 initcall再做针对性处理。4. 用户空间启动systemd 并行化、依赖裁剪与关键服务的优先拉起内核算启动完成之后时间优化的主战场转移到用户空间。这里有两个流派一个是继续用完整的 systemd 发行版比如 Armbian、Ubuntu做服务级优化另一个是换成 Buildroot 或者 Yocto 的最小用户空间把 init 换成精简的 busybox init甚至直接 exec 一个应用。两者适合的场景不同优化手段也完全不同。4.1 用 systemd-analyze 找出用户空间的耗时瓶颈如果产品基于 Debian/Ubuntu 这类发行版那 systemd 就是你最好的诊断工具。systemd-analyze会给出内核启动耗时、initrd 耗时和用户空间耗时systemd-analyze blame则按服务启动耗时排序能直接知道是哪个服务拖了后腿。我见过不少 RK3588 板卡systemd-analyze blame显示最耗时的服务竟然是NetworkManager-wait-online.service和systemd-udev-settle.service这两个本质上都是在等某个事件或超时对启动没有任何正向贡献直接 mask 掉就能省一两百毫秒。systemd-analyze plot生成的 SVG 图则展示了服务之间的依赖关系和并行度。系统启动的总时间不是所有服务耗时之和而是关键路径上所有服务耗时之和。找到关键路径把关键路径上的服务耗时降下来才是正确做法。比如一个服务 A 依赖服务 BA 本身只要 50ms但 B 需要 200ms 才能就绪那 A 即使自己再快也没用优化 B 才是关键。4.2 通过服务依赖调整实现更深的并行化systemd 默认会尽量并行启动服务但尽量不等于最优。有些服务的依赖关系是隐性的应用层明明在等待某个 socket 或者某个设备节点出现却没声明对应的After和Requires依赖systemd 就只能在 udev 枚举完成后统一处理。常见的优化手段包括给关键服务设置TypeoneshotRemainAfterExit让 systemd 只在服务确实完成初始化后才继续用Wants替代硬依赖Requires非关键服务失败不阻塞关键路径把网络相关的服务从默认启动改为按需启动systemd.socket激活给 udev 规则做精简减少设备节点创建和权限设置的耗时这些调整比较依赖具体产品形态没有一个通用配置能覆盖所有场景。但方向是一致的让用户空间的关键路径尽可能短其它非关键路径全部并行或延迟执行。4.3 Buildroot 最小用户空间直奔亚秒的终极方案如果产品形态允许用 Buildroot 定制一个最小用户空间是冲击亚秒级启动最直接的路。一个只包含 busybox、最小 glibc 或 musl libc、几个关键服务和你的主应用的 rootfs可以做到不到 5MB 的镜像体积。init 方面可以不用 busybox init直接写一个极简的 init 程序先挂载必要的文件系统然后 exec 主程序。我在一个 RK3588 工业项目里这么做过从 U-Boot 跳转到内核到主程序开始运行整个过程在内核 5.10 上做到了 780ms其中用户空间只占不到 100ms。Buildroot 里还有几个细节文件系统镜像尽量用squashfs或者只读的ext4避免fsck拖慢启动如果必须可写用 overlayfs 挂一个 tmpfs 作为上层底层保持只读把不需要的 busybox applet 全部去掉编译时直接通过 config 裁剪关键的库和可执行文件用-static静态编译省去动态链接器查找和加载共享库的时间静态编译带来的体积增长通常只有几百 KB但对启动速度的提升很明显。尤其在 eMMC 随机读速度一般的情况下减少文件读取次数比减少文件体积更有效。一个动态链接的程序启动时要读动态链接器、一堆 .so 的 metadata 和代码段静态编译后一次 read 就能把所有代码拉进内存差别非常直观。5. 让数据说话启动时间测量方法、优化前后对照和几个容易栽的坑前面讲了这么多优化手段如果没一套准确的测量方法很难判断哪个改动真正有效。嵌入式 Linux 启动时间测量常用的手段有串口时间戳、GPIO 电平翻转搭配逻辑分析仪、以及内核和 systemd 自带的分析工具。5.1 串口日志时间戳与 GPIO 标记法串口是最基础的测量手段。内核自带printk时间戳U-Boot 的CONFIG_SYS_PROMPT也可以在命令行开启时间显示。通过给不同阶段加上特定的日志输出可以粗略估算每个阶段的耗时。但串口测量有个问题日志输出本身会拖慢启动速度所以测量时要先开完整日志量出各阶段时间优化完再把日志级别调低重新量一次最终时间。GPIO 标记法更精确。在合适的位置——比如 U-Boot 跳转前、内核 init 完成时、用户空间主程序入口——分别翻转一个 GPIO 电平然后用逻辑分析仪测量各个沿之间的时间间隔。这样能获得毫秒级甚至微秒级的精确时间且测量过程对启动过程几乎无干扰。实际上我经常在量产板上保留一个测试点就为了让产线上的人也能快速验证启动时间。5.2 优化前后的典型数据对照以我实际做过的一个 RK3588 工业控制板为例从 eMMC 启动rootfs 是 Buildroot 最小文件系统。初始状态下BootROM 加 TPL/SPL 大概是 220msU-Boot 阶段 280ms内核阶段 350ms用户空间 200ms合计约 1.05 秒。经过配置裁剪、DDR 训练参数复用、移除 initramfs、精简用户空间、调整 systemd 依赖其实 Buildroot 场景下直接换成了最小 init最终数据大致如下阶段优化前优化后主要优化手段BootROM TPL/SPL220ms120msDDR 训练参数复用U-Boot280ms90ms裁剪配置、FIT 单次加载内核350ms170mslz4 内核、DTS 裁剪、关闭 printk用户空间200ms80ms最小 init、静态编译合计从 1.05 秒降到 460ms 左右。这还不是极限——如果连 U-Boot 都可以换成更精简的 SPL 直跳方案还能再压缩一点但那种方案对固件更新、恢复模式等功能的影响比较大量产前需要充分评估。5.3 几个容易栽的坑第一个坑是过度裁剪导致功能回归。比如把 USB 驱动从 U-Boot 里裁了结果产线烧录时发现没法通过 USB 下载固件只能重新烧回完整版 U-Boot又比如把内核的某个解压算法关了结果 FIT 镜像用的压缩格式匹配不上启动直接失败。我的经验是每个裁剪项都单独提交、单独验证别把几十个裁剪项一次性合进去再排错。第二个坑是测量环境不一致。eMMC 的高速模式和普通模式读取速度差好几倍U-Boot 里有没有做 HS400 初始化、内核里 MMC 驱动是否跑在高速模式下都会直接影响启动时间。我在一次优化中发现U-Boot 阶段从 90ms 涨到 150ms排查了半天才发现是测量时用的 eMMC 是新批次支持的 HS400 速度等级不同。所以做启动时间对比时一定要保证存储介质、电源电压、温度这些条件一致。第三个坑是过于执着单点优化而忽略全局。比如花了很大力气把内核解压从 60ms 降到 30ms但用户空间有个服务因为等待网络超时占了 300ms那就完全本末倒置了。先用量化工具把各阶段的时间分布列清楚再集中火力优化排名靠前的耗时项才是正确姿势。第四个坑是把 debug 固件当量产固件测。开发板的固件里通常开着各种 debug 选项内核的 KASAN、KCOV、lockdep、ftrace、kprobe、串口 full log、U-Boot 的延时循环等等。这些在开发阶段极大地提升了排查效率但对启动时间的影响非常夸张有的能拖慢一倍以上。量产量测一定要基于 release 配置的固件否则你优化了半天一换 release 版发现时间早就达标了——或者恰恰相反release 版又冒出来新的问题。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进