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 Firmware(BL31)和可选的 OP-TEE(BL32),最后才把内核镜像和设备树加载进内存并跳转执行。
这个阶段的时间开销构成非常典型,我实际测过的 RK3588 板卡,大致可以拆成这么几块:
| 耗时项 | 典型耗时范围 | 说明 |
|---|---|---|
| TPL 执行(DDR 训练) | 80ms ~ 300ms | 重头戏,和 DDR 频率与训练算法强相关 |
| SPL 读取 U-Boot 镜像 | 20ms ~ 80ms | 取决于存储介质和读取速度 |
| U-Boot 自身初始化 | 50ms ~ 150ms | 串口、时钟、存储驱动都要跑一遍 |
| U-Boot 加载 ATF/OP-TEE | 20ms ~ 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.gz(gzip 压缩)解压速度尚可,但镜像体积较大;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 里加loglevel=3甚至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 这种大小核架构,默认的 HZ=250 或者 HZ=1000 对启动时间影响不大,但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 依赖服务 B,A 本身只要 50ms,但 B 需要 200ms 才能就绪,那 A 即使自己再快也没用,优化 B 才是关键。
4.2 通过服务依赖调整实现更深的并行化
systemd 默认会尽量并行启动服务,但"尽量"不等于"最优"。有些服务的依赖关系是隐性的:应用层明明在等待某个 socket 或者某个设备节点出现,却没声明对应的After=和Requires=依赖,systemd 就只能在 udev 枚举完成后统一处理。常见的优化手段包括:
- 给关键服务设置
Type=oneshot+RemainAfterExit,让 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 大概是 220ms,U-Boot 阶段 280ms,内核阶段 350ms,用户空间 200ms,合计约 1.05 秒。经过配置裁剪、DDR 训练参数复用、移除 initramfs、精简用户空间、调整 systemd 依赖(其实 Buildroot 场景下直接换成了最小 init),最终数据大致如下:
| 阶段 | 优化前 | 优化后 | 主要优化手段 |
|---|---|---|---|
| BootROM + TPL/SPL | 220ms | 120ms | DDR 训练参数复用 |
| U-Boot | 280ms | 90ms | 裁剪配置、FIT 单次加载 |
| 内核 | 350ms | 170ms | lz4 内核、DTS 裁剪、关闭 printk |
| 用户空间 | 200ms | 80ms | 最小 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 版又冒出来新的问题。