最近在整理一块自研板卡的固件启动链路,又把 Arm Trusted Firmware(ATF)的源码从 BL1 一路翻到了 BL33 交接的位置,顺便把平台移植过程中反复踩过的一些坑也梳理了一遍。这篇文章不是拿官方文档翻译充数,而是基于实际源码阅读和板级调试经验做的整理:ATF 的架构全景、它作为安全固件做了哪些关键设计、以及把 ATF 移植到一个新平台时真正需要动哪些代码、又必然会遇到哪些问题。想看热闹的可以绕行,想动手做安全固件、TEE 接入或者平台移植的,可以从这篇开始。适合正好在做安全启动方案、被 PSCI/SMC 折腾到失眠的内核与固件工程师。
1. 先搞清楚ATF在系统里的站位:它凭什么住在EL3
1.1 ARMv8异常等级划分与安全世界的逻辑
ARMv8 在 AArch64 状态下定义了 EL0 到 EL3 四个异常等级,可以简单理解成四层权限:EL0 是普通应用,EL1 是操作系统内核,EL2 是虚拟化 hypervisor,EL3 则是整个系统中特权最高的那层。EL3 上面没有更高层代码了,进入 Linux 之前最后一段能掌控全局的软件,就是运行在 EL3 的固件。
Arm Trusted Firmware(ATF,官方仓库现在叫 TF-A)就是 Arm 官方维护的 EL3 固件参考实现。这个位置有多关键?它管着安全世界和普通世界之间的切换,管着所有核心的上下电,管着系统级复位,还握着可信启动的第一棒。你可以把 EL3 想成小区物业的保安室:业主们(普通世界的内核)以为自己说了算,但真正决定单元门能不能开、电梯能不能动的,是保安室里的那套系统。ATF 就是这套系统的标准样板。
1.2 ATF具体扛了哪几类活
说句实话,很多做应用层开发的人对 ATF 的印象就停留在"启动时闪过一个 BL31 的 log",但实际上 ATF 的职责范围比这大得多:
第一是引导加载和镜像校验。BL1、BL2 负责在启动早期把后续固件从存储介质读出来、做签名验证、再交给下一级执行,这套链路就是可信启动的基础。第二是运行期服务,包括 PSCI 电源管理接口、系统关机/重启、以及 SMC(Secure Monitor Call)异常分发。第三是给 TEE 当"房东",通过 SPD/SPMD 组件把 OP-TEE 这类安全 OS 加载到 Secure EL1,并为它做好 Normal World 和 Secure World 之间的上下文切换。第四是安全启动相关的证书链、防回滚计数器等机制,这些在 TRUSTED_BOARD_BOOT 配置下会被编译进固件。
搞清楚这四类职责之后,你再去看 ATF 的源码目录就不会迷路。它的代码量虽然不算小,但模块边界非常清楚,关键就是别把 BL 阶段的加载流程和运行期的服务框架混在一起看。
2. 源码树与镜像接力:一次上电的完整旅程
2.1 源码目录怎么读才不迷路
ATF 的顶层目录结构多年来相对稳定,第一次打开仓库时建议按下面这个顺序扫一遍:
bl1/ 第一级引导固件,通常在BootROM之后接管 bl2/ 可信启动固件,负责加载BL31/BL32/BL33 bl31/ EL3运行时固件,常驻内存,包含运行期服务 bl32/ Secure-EL1 Payload(TEE OS)入口相关 common/ 通用代码,镜像加载、参数传递、BL通用逻辑 lib/ el3_runtime、psci、xlat、pmf等核心库 drivers/ GIC、console、wdt、partition等驱动 services/ SMC标准服务、SPD、SPM、FWU等运行期服务 plat/ 平台移植代码,按厂商/开发板两级组织 tools/ fiptool、cert_create等构建工具 include/ 各类公共头文件我的建议是先跳过 bl1/bl2 的具体细节,从 bl31 入手,因为 BL31 是所有运行期功能的核心,也是平台移植时改动最多的地方。等把 BL31 的初始化流程和 SMC 分发机制搞明白了,再回头补 BL1/BL2 的加载逻辑会顺很多。
2.2 BLx接力赛:哪一段代码在什么时候干活
ATF 把整个启动过程拆成了几个明确命名的阶段,每个阶段都只做好自己的事。我用一张表把它们的定位列出来:
| 模块 | 阶段职责 | 运行级别 | 去向 |
|---|---|---|---|
| BL1 | 第一级引导,BootROM之后最先执行,加载并验证BL2 | EL3 | 加载完即退出 |
| BL2 | 可信启动固件,从FIP包中解析并加载BL31/BL32/BL33 | S-EL1 | 加载完即退出 |
| BL31 | EL3运行时固件,常驻内存,提供SMC/PSCI等运行期服务 | EL3 | 常驻 |
| BL32 | TEE OS(如OP-TEE),可选 | S-EL1 | 常驻或按需 |
| BL33 | 普通世界引导程序,通常就是U-Boot或EDK2 | EL2/EL1 | 最终跳转目标 |
整个链条在概念上就是一条接力:BL1 把 BL2 拉起来,BL2 从 FIP 镜像包里把 BL31、BL32、BL33 都解析出来,然后跳进 BL31,BL31 完成 EL3 运行环境初始化后,再把控制权交给 BL33。注意,BL1 和 BL2 干完活就可以"死"了,BL31 才是真正留下来长期值班的那个。
2.3 FIP镜像包和fiptool的基本逻辑
上面提到的 FIP 就是 Firmware Image Package,一个把多个固件镜像打包在一起的容器。编译时执行make fip,构建系统就会调用fiptool把 BL2、BL31、BL32(如果有)、BL33 都塞进一个fip.bin。这个设计的意义在于:BootROM 和 BL1 只需要按固定偏移去找一个 FIP,而不需要关心具体每个镜像应该放在闪存的哪个位置。实际部署时,通常把 BL1 放在 BootROM 之后,FIP 放在 BL1 后面的固定偏移处。
如果你的平台不打算用 BL1/BL2,ATF 也提供RESET_TO_BL31=1的编译开关,让 SoC 直接从 BootROM 跳到 BL31。很多自研芯片为了简化流程会走这条路,代价是可信启动的信任根要换个方式建立,不能再用标准的 BL1/BL2 证书链。
2.4 SMC分发与runtime services的设计套路
BL31 启动完成后,大部分时间都在等一个东西:SMC 异常。普通世界的内核或者 U-Boot 想要请求电源管理、获取安全服务,就会执行smc指令,CPU 陷入 EL3,BL31 的异常向量把控制权交给 SMC handler。
ATF 用了一套"runtime services 注册表"的机制来管理这些服务。每个服务通过类似下面这样的宏声明注册:
DECLARE_RT_SVC(psci_svc, OEN_STD, OEN_STD, psci_smc_init, psci_smc_handler);具体宏参数形式以你手上版本的 runtime_svc.h 为准,但核心思想没变:每个服务声明自己能处理的 SMC 函数 ID 范围和一个入口 handler。SMC 进来之后,BL31 根据函数 ID 里的 OEN 字段去匹配注册表,找到对应的 handler 再分发下去。这也是平台移植时如果你想加自定义的 SIP/SEC 服务,需要照着写一个DECLARE_RT_SVC的原因。
3. 安全固件工程审计:ATF在安全设计上值得抄的地方
3.1 Trusted Board Boot的信任链模型
TBB(Trusted Board Boot)是 ATF 里最值得细读的安全机制。它的思路不是简单地对每个固件镜像做一次签名,而是建立一条从信任根出发的证书链。cert_create工具会用一组密钥生成各级证书——根信任密钥 ROTPK 的哈希会被烧进芯片的一次性存储(eFuse),而每一级镜像证书都由上一级私钥签名。BL1 启动时用 ROTPK 验证 BL2 证书,BL2 再用自己持有的密钥验证 BL31/BL32/BL33 的证书。
防回滚的设计也藏在这里。ATF 支持多个非易失计数器(NV counter),每个镜像版本递增时计数器也跟着递增,如果攻击者想拿旧版本固件降级,BL1/BL2 在验证证书时就会发现计数器的值对不上,直接拒绝启动。这套模型在工程上的启示是:信任根越小、越硬,整个链就越安全;而安全启动代码里最容易出问题的恰恰是"验证完就信任"的假设。
3.2 内存隔离与MMU配置:安全边界不是靠自觉
EL3 固件手里最大的权力就是能访问所有物理内存,但 ATF 并没有滥用这个权力。在bl31_plat_arch_setup里,平台代码要明确告诉 BL31 哪些内存区域可以用、属性是什么。xlat 库负责建立 EL3 页表,关键内存区域会被标记上 XN(不可执行)或者只读权限,BL31 的代码段和数据段也做了严格的权限分离,避免出现一块既可写又可执行的内存。
在真正的硬件上,Secure World 和 Normal World 的隔离还要靠 TrustZone 地址空间控制器(TZASC)这类硬件模块来兜底。ATF 会负责在启动早期就把 TZASC 配置好,划定安全内存区域,普通世界的 DMA 和设备根本碰不到这些区域。这也是审计 ATF 代码时一定要关注的地方:不要只看软件逻辑,还要看它对硬件隔离模块的配置是否覆盖了所有内存窗口。
3.3 审计时可以重点检查的工程细节
我自己在过 ATF 代码时,一般会照着下面这个清单逐项确认:
- 异常向量表是否覆盖所有异常类型,有没有统一的默认处理做兜底,而不是让未知异常静默吞掉。
- 每个 SMC handler 是否校验了调用者来自哪个世界、参数个数和地址范围是否合法。
- 关键系统寄存器(SCR_EL3、CPTR_EL3、MDCR_EL3)的初始化是否遵循最小权限原则,比如不该开放的调试接口是否默认关闭。
- 是否开启了栈保护(ENABLE_STACK_PROTECTOR)、PAN、WXN 这类加固选项;发布版是否和调试版做了差异化配置。
- panic 路径上的错误上报是否可读。真出问题时,一个能直接告诉你 PC 和调用现场的 crash log 能省掉一整天的 JTAG 调试时间。
- 固件更新(FWU)路径是否有防降级机制,不要让"支持升级"变成"允许回滚"的后门。
第 2 条容易被忽略。很多自研服务在写 SMC handler 时只想着功能,忘了检查调用者是不是有权限。ATF 的通用服务代码里这种检查是标配,移植自己业务时一定要照抄这个习惯。
4. 平台移植落地:把ATF搬到一块新板卡上
4.1 动手前必须准备好的三样东西
移植 ATF 不是打开工程改几个宏就能完成的。我建议动手前先把下面三样准备齐:
第一是 SoC 的 TRM 手册里关于启动流程、GIC 版本、系统计数器频率、内存映射、电源域划分的章节。ATF 的所有平台代码都是围绕这些硬件事实写的,缺一个信息后面就得多踩一个坑。第二是交叉编译工具链,一般用aarch64-linux-gnu-前缀的 GCC,也可以用 Arm 官方维护的aarch64-none-elf-工具链,关键是统一。第三是一个能跑起来并且你能拿到串口日志的参考环境,比如 FVP 模拟器或者 qemu 的virt平台。
从来没有接触过 ATF 平台代码的人,我强烈建议先拿make PLAT=qemu编译一遍,跑通之后再开始改自己的平台目录。不是因为你聪明就能跳过这一步,而是因为你得先知道"编译产物有哪些、log 从哪来、正常启动长什么样",才有资格谈调试。
4.2 平台代码到底要动哪些文件
ATF 的plat/目录按厂商/平台两级组织,你新建一个平台目录后,核心要改的文件集中在这么几类:
| 文件/接口 | 需要填的内容 |
|---|---|
| platform_def.h | 各种宏定义:寄存器基地址、BL31基址、最大亲和级别、中断控制器类型 |
| platform.mk | 平台源文件列表、BL31_SOURCES、编译选项 |
| plat_helpers.S | 早期汇编代码:设置栈指针、初始化少量寄存器、进入C环境 |
| plat_setup.c | 内存映射、MMU初始化、bl31_early_platform_setup等回调 |
| psci 相关回调 | CPU_ON/CPU_OFF、系统挂起、功率域控制 |
| GIC配置 | 初始化 GICD/GICC 或 GICD/GICR,配置中断路由 |
这些接口可以理解成一副骨架,平台移植的工作就是照着参考平台把骨头一块块填起来。最关键的几个函数是:bl31_early_platform_setup(早期环境,此时通常还没有开 MMU)、bl31_plat_arch_setup(建立页表、开启 MMU)、plat_get_next_bl_params(决定 BL31 之后跳转到哪个镜像、怎么跳)。
4.3 完整编译、链接和固件打包流程
在平台目录和配置文件都就绪后,编译命令大概是这个样子:
export CROSS_COMPILE=aarch64-linux-gnu- make PLAT=qemu DEBUG=1 LOG_LEVEL=50 BL33=u-boot.bin all make PLAT=qemu BL33=u-boot.bin fipDEBUG=1会带上调试符号,LOG_LEVEL=50是把日志级别调到最高,方便看 BL31 内部打印。BL33指向你编译好的 U-Boot 镜像,构建系统会把它一并打包进 FIP。编译完成后,产物在build/<platform>/debug/目录下,你需要关心的是bl1.bin、bl2.bin和fip.bin(如果不用 BL1/BL2,通常只要bl31.bin)。
烧录时要注意布局:BL1 放在 SoC BootROM 能找到的位置,FIP 放在你 platform_def.h 里约定的固定偏移。Flash 偏移一旦和 BL1/BL2 加载逻辑里的宏不一致,最常见的现象就是 BL2 从 FIP 里解析出一堆乱码,然后报镜像校验失败。
4.4 PSCI与GIC:平台移植里的两个大头
平台移植真正花时间的往往不是跑通单核,而是让 PSCI 和 GIC 工作正常。PSCI 部分需要你定义功率域拓扑,ATF 里用亲和级别(affinity level)表示,PLATFORM_MAX_AFFLVL决定支持到系统级还是 cluster 级。然后要实现plat_setup_psci_ops提供的回调,包括cpu_on、cpu_off、system_off这些。多核启动时,主核要把从核的启动入口地址写进约定好的 mailbox,从核才能从低功耗状态醒来后跳到正确的代码位置,这个机制在 PSCI CPU_ON 里是核心。
GIC 部分相对套路化,配置好 GICD 基地址、初始化分发器和 CPU 接口就行。GICv3 比 GICv2 多了一层 GICR 重分配器,每个核心都有自己的 Redistributor,初始化顺序不能乱。还有一个高频坑是plat_get_syscnt_freq,它返回的系统计数器频率必须和内核设备树里的clock-frequency一致,否则会出现内核里时钟跑得快或跑得慢的诡异问题。这类问题不仔细查,很容易先怀疑到 U-Boot 身上。
5. 调试手段与真实踩坑记录
5.1 让日志开口说话:console与LOG_LEVEL怎么配合
ATF 调试第一步永远是让板子能输出日志。新建平台时最先要跑通的就是 console 驱动,选用 UART 是 16550 还是 PL011 风格,直接在驱动目录里选对应的实例,然后在 platform_def.h 里配置 UART 基地址和时钟。LOG_LEVEL建议整个调试阶段都开 50,发布之前再降下来。ATF 遇到致命错误时会走 crash report 路径,打印一段包含异常类型、当前 PC、x30 等关键寄存器的信息。如果你看到这样的输出,第一件事是把 PC 值用addr2line或者objdump映射回源码行号。
实操中还有一个技巧:BL31 早期有些路径还没开 console,所以要在bl31_early_platform_setup里尽早把 console 初始化掉,否则你只能看到 U-Boot 的 log 而没有 ATF 的 log,排查问题像蒙着眼睛走路。
5.2 高频翻车现场与排查链路
下面这张表是按我自己和身边人遇到的频率排的,不一定覆盖所有情况,但能帮你在卡住的时候快速定位排查方向:
| 现象 | 常见根因 | 排查方向 |
|---|---|---|
| BL31 刚复位就 hang | 栈指针没设好、早期内存映射缺失 | 检查 plat_helpers.S 和 early_platform_setup |
| BL31 正常、U-Boot 起不来 | BL33 入口信息错误 | 检查 bl31_plat_get_next_image_info 和 entry point 参数 |
| 多核启动只起来一个核 | mailbox 地址错误或从核启动入口不对 | 检查 PSCI CPU_ON 实现和 warm boot 入口 |
| 内核时钟频率不对 | 系统计数器频率和设备树不一致 | 核对 plat_get_syscnt_freq |
| Secure World 一进去就崩 | BL32 加载地址和 TZASC 配置冲突 | 核对 FIP 中各镜像地址 |
| 中断收不到 | GIC 未初始化或触发方式配置错误 | 检查 GICD/GICR 初始化流程和 SPI 配置 |
这些问题的共同点是:表面现象离根因非常远。比如"U-Boot 起不来"可能是 BL31 传递的entry_point_info里 SPSR 设置错误。所以我一直建议,调试 ATF 问题不要猜,按数据流一层层看:入口参数是谁填的、经过哪些结构体、最终在哪被消费,链路走通问题自然浮出水面。
5.3 交叉编译与工具链相关的坑
ATF 对工具链版本并不算挑剔,但有几个细节值得注意。首先CROSS_COMPILE前缀一定要带对,并且整个构建过程中 ar、objcopy、ld 都要来自同一套工具链,混用不同版本的工具链可能产出奇怪的链接错误。其次,如果系统里同时装了 32 位和 64 位工具链,注意别让-mabi之类的选项悄悄混进构建参数。最后,ATF 的链接脚本是平台可定制的,当你看到 BL31 镜像的加载地址和链接地址不一致时,不要急着改 Makefile,先去看平台目录里的链接脚本和BL31_BASE宏。
我自己遇到过一次非常隐蔽的问题:交叉编译器更新之后,新版本在编译 ATF 时启用了某些新的重定位类型,导致 BL1 拷贝 BL2 时代码段出现错位。后来是通过锁定工具链版本、并且在 CI 里固定 toolchain 的 hash 才彻底解决。这事儿的教训是:安全固件这种敏感软件,工具链一旦验证通过就不要轻易升级。
最后分享一点个人的源码阅读习惯
读 ATF 源码时,我习惯按启动顺序走一遍数据流:BL1 到 BL2,BL2 到 BL31,BL31 到 TEE,再到 BL33,每一跳都问三个问题——参数结构体在哪定义,谁负责填值,谁负责消费。这个方法看起来慢,但一轮下来之后,任何评估板的安全固件放在你面前,基本十分钟就能定位到关键代码。移植 ATF 本身并不算难,难的是把每一个交接点都验证清楚,尤其在多核启动和中断路由这类跨模块交互的地方,多花一倍时间检查都不过分。希望这篇源码评测和落地笔记能帮你少走点弯路,剩下的,就交给你的板子和串口日志了。