做嵌入式底层这一行的,十有八九都绕不开这么个场景:板子刚上电,U-Boot 还没跑起来,串口里打印的那几行字是谁输出的?答案往往指向一段不起眼但极其关键的 EL3 固件——Arm Trusted Firmware。但如果你只把它当成“一段能初始化安全世界的裸机代码”,那后面吃过的亏可能比想象的多得多。这篇评测我不会从概念讲起,而是直接按源码工程的维度拆解 ATF 的完整架构,以安全固件审计的视角过一遍信任链与密钥配置,最后落回平台移植的实际操作,把那些文档里不写、但实测必踩的细节一并交代清楚。
这篇内容适合三类人:一类是要在新 SoC 上移植 U-Boot 和 ATF 的 BSP 工程师,一类是负责产品安全功能评审或固件合规的研发同学,还有一类是刚开始接触 ARM64 启动流程、想搞明白 BL1/BL2/BL31 到底各自干什么的学生。
1. ATF 源码工程的顶层拆解,先看懂这颗“EL3 枢纽”长出哪些分支
1.1 四级启动阶段与五类镜像文件的职责边界
ATF 在 ARM64 系统中的角色,可以概括为一句话:它负责从复位向量到非安全世界引导加载程序之间的所有安全关键操作,并给运行时的 TEE(Trusted Execution Environment)提供入口。源码里也按照这个逻辑把启动过程拆得清清楚楚,典型流程从 BL1 开始,依次经过 BL2、BL31,再根据产品是否需要可信执行环境来决定是否拉起 BL32,最后跳转到 BL33。
BL1 是系统复位后最早执行的镜像,通常固化在片上 ROM 里,厂家在流片时已经写死,几乎不允许外部修改。它的任务非常有限:初始化 CPU、设置必要的内存控制器状态、建立一条输出到串口的最小调试通道,然后从 Boot 设备加载 BL2 并完成验签。BL2 由 BL1 加载,运行在 EL3 之下、但又能访问安全内存的特殊位置,它负责初始化 DRAM、加载并验证 BL31/BL32/BL33。BL31 则是整个 ATF runtime 的核心,它常驻在 EL3 中,提供 secure monitor、PSCI 电源管理、SMC 分发、可信 OS 交互等所有运行时能力。
BL32 是可选的,一般指 OP-TEE 这类可信操作系统;BL33 就是大家最熟悉的 U-Boot、UEFI 或其它非安全引导程序。理解这一条链路的关键并不在于记住每个字母代表什么,而在于看到每一级镜像之间的信任传递关系——前一级必须验证后一级,后一级运行所需的资源由前一级提供。源码树中 bl1/bl2/bl31 三个目录正好对应这个过程,加上 plat/ 下的平台代码和 drivers/auth 下的认证模块,整个工程的分层设计可以说相当清爽。
1.2 plat/ 目录的分层设计:为什么厂商代码不能被塞进 common
第一次翻 ATF 源码的人,往往会在 plat/ 目录里迷路。这个目录下面既有 arm 这种官方参考平台,又有 hisilicon、mediatek、qemu、rockchip 等一堆厂商目录。如果不理解它的分目录哲学,后面移植大概率会越改越乱。
ATF 的分层逻辑其实是:common 代码描述“所有 ARM64 平台都满足的通用行为”,platform 代码描述“某个具体 SoC 或板卡特有的行为”。比如平台要提供内存映射、要定义串口寄存器基址、要说明 GIC 如何配置、要给出电源域拓扑,这些都属于 platform 层。而 BL31 的启动主流程、SMC 分发的框架、PSCI 服务的主干逻辑,则属于 common 层,原则上不应该被修改。
反过来说,官方在 common 层预留了大量回调接口,就是为了让平台代码通过“填空”的方式接入。例如你在 plat/common/plat_psci_common.c 中会看到平台只需实现plat_get_sys_suspend_power_state等少数几个函数,而完整的电源状态机已经由services/std_svc/psci/psci_main.c统一调度。这种写法的好处非常明显:不同厂商之间的差异被隔离在各自目录里,公共逻辑的 Bug 修复可以直接同步上游,而不需要每个厂商单独合并。
1.3 编译产物与三种真实加载方式
ATF 的编译输出是典型的 ELF 与二进制分离字段:bl31.elf、bl31.bin、bl2.bin、bl1.bin。但生产环境中,这几份产物的组合方式远比 README 写的要灵活。
第一种是完整 Trusted Board Boot 链路:ROM Code → BL1 → BL2 → BL31 → BL33。这种组合对信任链要求严格,BL1 需要被厂商固化在 ROM 中,BL2 和 BL31 都要经过签名校验,通常用于需要产品安全认证(比如金融终端、车机)的场景。
第二种是 ROM Code 直接加载 BL31,绕开 BL1/BL2。很多商用量产芯片采用这种方案,因为片上 ROM 体积有限,厂商直接让 ROM 读取烧录在 flash 中的 BL31 镜像并交给 CPU 执行。这种情况下,BL31 需要自己完成原本 BL2 的部分初始化工作,包括 DRAM 的简单配置和 GIC 初始化。
第三种是把 BL31 打包进 U-Boot 的 FIT image,由 U-Boot 加载后通过smc指令切入 EL3,运行完 ATF 的初始化流程后再返回非安全世界。这种方案在调试板和低成本产品中很常见,优点是能缩短启动时间,但代价是 ATF 的信任根不再前置到复位向量,安全强度有所下降。我自己的建议是:如果产品不是特别在意防回滚与安全启动认证,第三种方案性价比最高;但一旦产品明确需要过安全评估,就不要节省这一段信任链成本。
2. BL31 运行时核心机制,SMC 分发、PSCI 与中断路由的真实工作逻辑
2.1 EL3 安全监控模式的 SMC 接口模型
BL31 启动完成后,CPU 停留在 EL3,随时等待非安全世界通过smc指令发起调用。这条指令在 ARM64 汇编里就是一行smc #0,但背后牵涉的上下文切换远比想象中复杂。CPU 在陷入 EL3 后,硬件会自动保存一部分寄存器到 EL3 的异常栈中,BL31 再通过异常向量表进入tspd、opteed或者std_svc这些运行时服务。
服务的注册机制非常值得单独讲一讲。ATF 在runtime_svc_descs数组中维护一份“运行时服务描述表”,每个服务通过DECLARE_RT_SVC宏声明。在services/std_svc/std_svc_setup.c里可以看到std_svc服务的rt_svc_descs定义,它注册了 SMC call ID 高 8 位对应的标识。当smc指令过来时,BL31 首先解析 call ID,按区间匹配服务索引,再调用该服务注册的 handler。
实际做平台对接时,你基本不需要改这套框架,但必须知道两个关键点:第一,如果你要在自己的 ATF 里添加自定义 SMC 指令,需要DECLARE_RT_SVC并选一个不冲突的 call ID 区间;第二,BL31 在处理 SMC 时会把标准调用转发给内核里的 EL3 固件接口(即 Arm SMCCC),而不会被内核误判。很多刚从 U-Boot 转过来的工程师,在这里会卡很久,因为他们在 U-Boot 里直接写裸机代码习惯了,不习惯这种“先注册再分发”的框架式写法。
2.2 PSCI 服务与 CPU 热插拔、系统挂起的落地细节
PSCI(Power State Coordination Interface)是 ATF 最常被调用的标准服务之一,kernel 里所有 cpu hotplug、cpuidle、系统休眠都需要通过 PSCI 指令落到 EL3 固件上。
我以最常见的CPU_ON流程来拆解一下真正的执行步骤。内核调用psci_cpu_on后,ATF 收到事件,进入psci_cpu_on_start状态机。该函数会先在目标 CPU 的上下文信息中记录 entry point(即该 CPU 被唤醒后要跳转的地址),接着通过平台层的plat_setup_psci_ops接口拉起电源域。以 ARM Juno 平台为例,实际动作是操作 SCP(System Control Processor)发消息,等 SCP 完成上电后,BL31 恢复目标 CPU 的异常向量栈,引导它进入指定的 entry point,最后执行eret返回非安全世界。
这一整套流程里,最容易被新平台忽略的是plat_psci_ops里cpu_standby、pwr_domain_on、pwr_domain_off这些回调函数是否都实现了。如果你的移植只实现了cpu_on而不实现cpu_off/cpu_suspend,内核跑起cpuidle后系统会立即挂死,而且报错链路非常难查——因为 CPU 进入 low-power 状态后,串口中断可能已经丢了。实测下来,先把psci_ops里每个回调都打上空实现或简单打印,再逐步补齐真实电源管理逻辑,会比一次性写完更快定位问题。
2.3 中断路由模型:FIQ 与 IRQ 如何在 EL3 转向安全世界
BL31 不仅处理同步异常(SMC),也处理异步异常——中断。传统单核裸机思维下,中断就是进了某个 ISR;但到了 ATF 这个层级,中断要按“安全/非安全”和“来源目标”来分类路由。
ATF 定义了三种中断类型:INTR_TYPE_S_EL1、INTR_TYPE_NS、INTR_TYPE_EL3。如果设备被配置为INTR_TYPE_S_EL1安全中断,当它触发时,GIC 会把中断以 FIQ 形式送进 EL3,BL31 在handle_fiq里把它注入到当前运行的 TEE(比如 OP-TEE)中,由 TEE 的安全中断处理程序处理;处理完后返回到 EL3,再由 BL31 返回到被抢占的上下文。如果配置为INTR_TYPE_NS,则中断以普通 IRQ 注入到非安全世界的内核。
平台移植时,GIC 的配置通常放在plat_gic.c之类的文件中,gicv3_driver_init会设置 GICD_CTLR 和 GICR_WAKER 等寄存器。常见的坑是 GICR_WAKER 的 ProcessorSleep 位没有正确清除,导致中断永远进不来,看起来像是 ATF 的 FIQ handler 没有工作,实际连 GIC 都没完成唤醒。遇到这种情况,先读GICR_WAKER寄存器确认处理器处于 awake 状态,再往下查,往往能省下好几个小时。
3. 安全固件工程审计,从源码角度核实 ATF 到底“锁”住了什么
这一节涉及的审计角度全部基于对固件源码及其构建流程的检查,不涉及任何攻击利用手法。以下检查项也能反向用于自查:如果你是固件的开发或维护方,这些就是你在交付前应该核实的内容。
3.1 信任根与信任链的建立——BL1 为什么不能背锅
安全启动的信任根(Root of Trust)是所有固件验签的起始点,ATF 中信任根的位置不是 BL31,更不是 BL33,而是 BL1 及与其配套的芯片 ROM Code。这意味着,如果攻击者能篡改 BL1 或者替换片上 ROM,那么整条信任链的起点就已经被污染了。
在 Trusted Board Boot 的实现中,BL2 的镜像必须提供一张证书链,证书里携带的镜像哈希值由 BL1 中的公钥验证。这里有个实践层面常被忽略的点:BL1 的公钥不是在编译时从任意位置读取的,而是从 E-fuse、OTP 这类一次性存储中获取,或者从 BL1 镜像自身签名的附加数据中解析。做审计的时候,需要检查固件构建环境里是否对这三类密钥做了严格隔离:签发 BL31 的密钥、签发 BL33 的密钥、以及用于 BL2 证书的密钥,是否在同一个目录、同一把私钥。安全实践中,这些密钥应当分级管理,不同镜像使用不同密钥,并配置对应的反回滚计数器。
如果想快速了解当前 ATF 的信任链配置,可以直接看源码中的drivers/auth/auth_mod.c和drivers/auth/tbbr/tbbr_cot.c。其中tbbr_cot.c里定义了一张“证书链描述表”,它描述了整个启动中各镜像文件的哈希计算顺序与校验方。通过读这张表,可以清楚掌握某个镜像的签名验证到底依赖哪张证书、哪个公钥、哪个计数器。
3.2 密钥格式、证书类型与防回滚设计
ATF 的 TBBR 实现中,证书格式总体遵循 X.509 v3,使用cert_create工具生成,默认采用 RSA 非对称算法。构建时TRUSTED_BOARD_BOOT=1会触发这些证书生成流程,同时编译出tb_fw_cert、soc_fw_cert、nt_fw_cert等文件。在审计时,要特别注意平台是否真的使用了这些证书产物来验证固件,还是只在编译时打了个-DTRUSTED_BOARD_BOOT=1的标记,实际烧录时完全没有做校验。这种“编译选项开启但启动链路没有启用验证”的情况,在真实产品中出现过不少次。
防回滚依赖 Non-Volatile Counter(NVCounter)。ATF 在plat/nxp/common、plat/arm/common这类平台代码中会有 NV 计数器实现,常见策略是把计数器存储在 OTP 里,每次升级时先校验新镜像的ctr值必须大于当前值,再更新固件。如果不支持防回滚,攻击者就可以把已经修复了漏洞的旧版本固件刷回去,安全加固形同虚设。
审计时我一般会按下面的清单逐项核对,每项都能落到具体文件和配置上:
- TRUSTED_BOARD_BOOT 编译开关是否为 1,产物中是否包含证书文件。
- 各镜像的签名密钥是否分离,私钥是否存在于普通文件系统中。
- NV 计数器的实现是否连接到了 OTP 或 E-fuse,而不是直接写 flash 上的固定偏移。
- BL31 的调试串口在 release 版本中是否被关闭,
ENABLE_DEBUG是否被置为 0。 - MMU 配置中安全内存区域的访问权限是否只读且不可执行,是否存在冗余的可写可执行内存映射。
3.3 拿到一份陌生 ATF 代码时的快速判读方法
工程审计不太可能从头到尾读完全部源码,更高效的做法是通过几个入口快速判断固件状态。第一步,查看plat/目录下平台代码的改动时间线,如果某个安全关键文件(比如bl31_main.c)被大量改动,说明该平台的定制很重,需要额外审查。第二步,通过编译产物bl31.elf中的符号、版本字符串、调试符号残留来判定这是 debug 构建还是 release 构建。第三步,用 binwalk 等二进制分析工具扫描固件镜像里的字符串和文件结构,看是否包含调试命令、测试密钥等不该出现在产品中的内容。
这些方法在拿到第三方厂商 ATF 代码时尤其有用。我见过不少项目的“安全固件”,实际是厂商 SDK 里默认测试密钥构建出来的,公钥和私钥都能在公开文档里找到。这种问题单看源码很难发现,只有从密钥和构建配置层面做审计才能抓到。
4. 平台移植落地,从零开始让 ATF 在一块新板卡上跑起来
4.1 新平台目录要准备的文件清单
移植 ATF 的第一步是新建平台目录。以plat/myplatform/myboard为例,需要准备的必备文件包括platform.mk、platform_def.h、plat_common.c、plat_topology.c、plat_psci.c。如果你的平台需要对接自定义电源管理,还要有plat_sip_svc.c或plat_scmi.c这类扩展文件。
platform.mk是构建入口,控制源文件列表和链接脚本选择,它会被顶层make系统自动引入。这里最需要注意的是平台宏的定义,比如PLAT_PLAT_MAX_PWR_LVL、PLAT_NUM_PWR_DOMAINS,这些值直接决定了 PSCI 状态机能管理的电源域数量,配置不对会导致 cpu_topology 识别失败。platform_def.h中主要定义各镜像的基地址、大小、外设寄存器基址。以串口为例,你需要把 UART 的寄存器基址填到PLAT_ARM_UART_BASE这类宏中,并确认时钟频率与波特率参数匹配,否则console_uart会因分频不准而打印乱码。
4.2 串口通信与调试手段,移植时先跑通的最小路径
ATF 的移植调试中,串口是第一优先级的调试通道。官方参考平台一般用 PL011 控制器,你的平台可能是 8250 兼容串口,就需要实现对应的console_putc、console_getc、console_flush。代码路径主要在drivers/arm/pl011/pl011_console.c或自定义驱动中。
这里有一个经常被忽略的接口:plat_crash_console。ATF 一旦在 EL3 陷入异常或者断言失败,常规打印链路可能已经失效,这时 BL31 会调用plat_crash_console输出崩溃信息。新移植平台如果只实现了常规 console 而没有实现 crash console,系统 panic 后将完全静默,排错会非常痛苦。移植时务必保证plat_crash_console在只有最基本 CPU 和串口寄存器初始化的情况下也能输出字符。
另一个实用建议是先用 ARM 官方 FVP(Fixed Virtual Platform)或 QEMU 做预移植验证,不要在真实板卡上直接从零调。FVP 可以模拟 ATF 需要的大部分基础外设,先在模拟器里跑通 BL31 启动,再把代码切到真实硬件,这样能隔离大量硬件初始化问题。
4.3 内存视图与 MMU 配置:ATF 为何只用一小段安全内存
ATF 并不会像 U-Boot 那样把手头所有 RAM 都纳入管理。BL31 的安全内存被严格限定在BL31_BASE到BL31_BASE + BL31_SIZE范围内,这段内存通常位于 TZRAM/TZDRAM 区域,既能被安全世界访问,又被硬件属性限制为非安全世界不可访问。如果 BL31 在运行时要借助栈、堆、页表,这些也都在该区域内分配。
写平台移植时,需要使用mmap_add_region()或者MAP_REGION_FLAT宏来定义页表。最常见的错误是页表的权限标志设置不正确,比如把代码段标记成了可写可执行,这会直接触发 ARM64 的 WXN(Write XOR Execute)安全策略,导致Permission fault。如果启动日志里出现ERROR: mmap_add_region_check() failed,基本可以断定是新增的 mmap 区域与已有区域重叠或权限冲突,需要回头检查平台定义的内存边界。
4.4 避坑重点:warm boot 入口和 reset handler 的处理
平台移植中最容易翻车的往往不是首次冷启动,而是二次启动。系统进入 suspend 后要恢复,CPU hotplug 后要重新唤醒,reboot 时要重新执行 platform 的 reset 流程,这些全部依赖 warm boot 路径。
plat_reset_handler在冷启动和 warm boot 时都会被调用,但它拿到的 CPU 状态不同。如果平台代码在plat_reset_handler里做了 DDR 控制器重配置或 MMU 初始化,而二次启动时这些硬件已经被设置为休眠态,重复初始化就可能触发总线错误。正确做法是在plat_reset_handler中先通过某个复位原因寄存器判断当前是冷启动还是 warm boot,再决定执行哪些初始化步骤。ARM Juno 平台在plat/arm/board/juno/plat_topology.c和电源管理文件里就有类似的判断,可以参考。
另一个容易踩的坑是 cache coherency。BL31 在 warm boot 早期访问共享内存时,如果数据 cache 尚未使能且指令 cache 中残留旧数据,会出现“代码在执行,但读取到的全局变量是旧值”的诡异现象。解决方式是先执行dcache清理/无效化操作,再访问共享结构体。这类问题在真实硬件上调试时,经常被误判为内存不稳定,实际上只是 cache 没有正确 maintain。
4.5 启动成功判据与回归验证
当 ATF 移植完成,串口日志应该能看到类似下面的关键节点:
NOTICE: BL31: v2.8(release):v2.8-rc1-123-g12345abc NOTICE: BL31: Built : 09:12:00, Jan 18 2025 INFO: BL31: Initializing runtime services INFO: BL31: Preparing for EL3 exit to normal world INFO: Entry point address = 0x82000000看到 “Preparing for EL3 exit to normal world” 只代表 BL31 本身的初始化完成,并不代表整体启动成功。接下来还要看 U-Boot 是否接管、内核是否正常启动、cpu hotplug和suspend/resume是否回归通过。建议每次代码改动后都跑一遍固定的电源管理测试,包括echo 0 > /sys/devices/system/cpu/cpu1/online与echo 1 > ...的 CPU 拔插操作,以及systemctl suspend的休眠唤醒操作。这些测试覆盖的就是 BL31 PSCI 层最核心的代码路径。
5. 工程实践层面的补充,给正在移植或审计 ATF 的几点建议
5.1 值得精读的几个源码文件
如果你准备深入 ATF,不用漫无目的地从顶层 Makefile 开始读。按照下面这份精简清单,可以较快建立工程视角:
bl31/bl31_main.c:BL31 的启动主流程,能看到 runtime services 是如何逐个初始化的。services/std_svc/std_svc_setup.c:标准服务注册与 SMC 分发的典型样例。services/std_svc/psci/psci_main.c:PSCI 状态机主干,架构最复杂的部分。plat/common/plat_psci_common.c:平台需要填空的 PSCI 接口样例。drivers/auth/tbbr/tbbr_cot.c:证书链配置,理解安全启动的入口。
5.2 验证工作不要只用一套工具链
做 ATF 移植时,建议不仅依赖板级串口调试,还要结合 FVP 模拟器和第三方测试工具。比如 OP-TEE 环境下的xtest能覆盖大量 SMC 路径,U-Boot 下的scp固件版本检查也能暴露 BL31 与 SCP 交互的兼容性问题。
我见过不少团队只在一款板卡上验证通过就发布了固件,结果换一批 DDR 颗粒或者换一个 GIC 版本,休眠唤醒就出问题。所以有条件的话,至少准备两套不同配置的硬件平台做交叉验证:一套作为功能基线,一套用于压力测试和低功耗验证。
5.3 个人反复使用的几条经验
最后分享几条从实际项目中沉淀下来的经验,每一条都对应过真实的线上问题:
第一,编译 ATF 时不要随手打开ENABLE_DEBUG,这样不仅增大镜像体积,还会在 release 固件中留下大量调试信息,给安全审计增加不必要的风险。第二,定制 SMC 服务时,务必为每个调佣编号单独写一个 handler 函数,而不是在一个switch里堆全部逻辑,否则后期维护和审计都很难下手。第三,在平台代码中保留一份固定的启动日志格式,包括 ATF 版本、构建时间、平台名称等基础信息,这对线上问题回溯非常有帮助。很多设备出问题后,厂商连固件版本都无法从日志里判断,往往就是少了这些基本信息。
写这篇评测之前,我特意重新打开源码树把启动流程、PSCI 状态机和 TBBR 证书链过了一遍,仍然能从一些边角逻辑里找到新收获。ATF 这套工程代码真正的价值在于,它把安全启动、电源管理、运行时服务这些底层事务沉淀成了一组可扩展的框架,而不仅仅是某一块板卡的启动补丁。希望这篇拆解能帮你在移植或审计 ATF 的时候,少走几步弯路。