一块开发板卡死在U-Boot的“Starting kernel”之前,日志全是空白,这种问题在ARM平台底层其实很常见,而且八成不是内核的问题,而是EL3固件没起来。我第一次接触Arm Trusted Firmware(ATF)时,光搞清楚BL1、BL2、BL31、BL32、BL33这条启动链就花了不少时间,后来又把源码从头到尾翻了一遍,在FVP虚拟平台上完整走了一次平台移植,才算真正理解这套安全固件到底在设计什么。这篇文章就是那次过程的全记录,适合做平台Bring-up的底层工程师、做固件安全审查的同事,以及想系统搞懂ATF的嵌入式方向学生来读。
我会直接从“ATF在上电链路里到底站在哪”“源码目录如何按信任边界组织”“安全审计要看哪几个关键点”“从零移植一份platform port需要碰哪些接口”这几个角度来写,尽量把工程上容易出问题的地方一次说清。
1. ATF到底管哪一段:从上电到U-Boot的“接力棒”划分
很多新人容易把ATF和U-Boot混在一起,觉得都是“引导程序”。实际上在AArch64架构里,ATF并不是一个单一固件,而是一整套由多个BL(Boot Loader)阶段组成的引导链条,它的工作范围从上电那一刻一直延伸到操作系统接管之前,并且其中一部分会在操作系统运行期间常驻EL3,随时响应安全服务请求。
1.1 为什么要单独做EL3这一层:普通固件的“权限天花板”
ARMv8-A定义了EL0到EL3四个异常级别。操作系统内核跑在EL1,虚拟化场景下Hypervisor跑在EL2,用户态跑在EL0,而EL3是Secure Monitor所在的最高特权层。U-Boot可以跑在EL1或者EL2,但它本身没有能力管理Secure World,也无法直接控制系统级电源操作。ATF的核心价值就是把这层EL3能力补上。
这种分层不是ARM拍脑袋设计的。你想想,如果没有EL3常驻的安全监控器,普通世界(Normal World)想关个CPU、做深度睡眠、触发热重启,就只能通过某些非标准机制绕过,安全边界会被打穿。有了EL3之后,所有敏感操作都被收拢到一个受控入口,这个入口就是后面要重点讲的SMC异常。它在设计上很像一个“门房”——外部世界只能通过门铃(SMC指令)提出申请,真正进入房间的权力始终掌握在EL3手里。
1.2 一个启动序列的真实画面
一次完整的ATF引导大致是这样:
- 上电后SoC执行片内ROM里的BL1,BL1完成基础时钟、串口等最小初始化,并验证BL2镜像后将其加载到SRAM。
- BL2在SRAM中运行,负责初始化DDR控制器等内存系统,加载BL31、BL32(可选TEE)以及BL33(U-Boot或EDK2),加载完成后跳入BL31。
- BL31在EL3完成运行时环境初始化,包括中断控制器、系统寄存器、PSCI服务注册,然后降级跳到BL33。
- BL33(U-Boot)接管平台,加载内核;内核运行期间通过SMC指令请求BL31提供的PSCI电源管理等运行时服务。
我用FVP平台模拟器跑了一遍Debug版,日志大致长这样:
NOTICE: BL1: v2.8(debug):v2.8 NOTICE: BL1: Built : ... INFO: BL1: RAM 0x0 - 0x1000 NOTICE: BL1: Booting BL2 INFO: BL2: Loading image id 3 NOTICE: BL31: v2.8(debug):v2.8 NOTICE: BL31: Initializing runtime services INFO: BL31: Preparing for BL33 entryU-Boot跑起来之后,ATF并没有退出舞台。BL31依然在内存里,负责响应系统运行过程中的电源管理请求。
为了直观理解各阶段的职责,我整理了这么一张表:
| 阶段 | 存储位置 | 主要职责 | 生命周期 |
|---|---|---|---|
| BL1 | SoC片内ROM | 信任根、加载BL2 | 启动完成后不再参与 |
| BL2 | SRAM | 初始化DDR、加载后续镜像 | 启动完成后可被覆盖 |
| BL31 | DDR/SRAM | EL3运行时、PSCI/SMC服务、安全中断 | 常驻 |
| BL32 | DDR | TEE OS(如OP-TEE) | 常驻 |
| BL33 | DDR | U-Boot/EDK2 | 最终被内核接管 |
这个表格基本就是你理解ATF源码地图的起点。每个阶段的代码在仓库里对应一个独立目录,边界非常清晰。
2. 源码结构解剖:按信任边界而非功能模块组织代码
ATF在GitHub上的官方仓库是ARM-software/arm-trusted-firmware,现在一般叫TF-A。我建议clone下来之后不要急着看某个文件,先把顶层目录扫一遍。这个仓库的组织方式很有意思,它不完全按“功能模块”来划分,而是按“信任边界和业务边界”来划分,理解了这一点,读代码会顺畅很多。
2.1 目录与四个关键模块
顶层目录里几个重点:
bl1/:BL1源码,ROM阶段代码,必须尽量精简。bl2/:BL2源码,负责镜像加载与验证。bl31/:BL31运行时源码,包含入口、主流程、上下文管理。bl32/:BL32相关的SPD(Secure Payload Dispatcher)代码路径,常见的是opteed。services/:BL31暴露给上层调用的运行时服务,最关键的是std_svc下的PSCI实现,还有spmd这类可选服务。plat/:平台移植代码,所有厂商和开发板支持都在这里。lib/:通用库,包括el3_runtime、psci、extensions(比如v8.x特性支持)、xlat_tables等。drivers/:各类外设驱动,包括ARM GIC、PL011串口等。fdts/:设备树源文件,主要用于FVP等平台。
bl31目录下有一个bl31_main.c,里面是BL31启动后的主流程,从入口汇编到C语言的交接点就在这里。services/目录下的std_svc是PSCI标准服务所在的目录,往下能看到psci/的实现,里面全是CPU开机、关核、休眠、重置、关机这些系统级操作的实际逻辑。
2.2 PSCI是什么,SMC到底是怎么被路由的
PSCI是电源状态协调接口。操作系统在实现CPU hotplug、idle、系统restart时,虽然逻辑在kernel里,但真实的硬件操作是通过SMC指令交给EL3完成的。每个SMC调用都有一个function id,也就是EBT(Exception Based Trap)之后的调用号。
SMC调用号的格式是这样的:32位调用号里,高16位包含OEN(Owner Entity Number)等属性字段,比如PSCI服务的OEN是0,SIP服务的OEN是2;低16位定义具体调用号,比如CPU_ON、CPU_OFF、SYSTEM_RESET这些。
在BL31中,每一个运行时服务都通过宏DECLARE_RT_SVC声明到特殊的链接段里。BL31启动时会在rt_svc_descs段里扫描这些服务描述符,构建一张服务路由表。当EL1的代码执行smc #0指令后,硬件自动陷入EL3,异常向量把控制权交给BL31的handle_smc,再根据OEN字段找到对应服务,最后分发到具体的handler函数。
这个过程相当于一个内部电话总机。调用方说“我要找PSCI的CPU_ON”,总机查表,转到对应分机。基数和转换逻辑不复杂,难的是对各个平台的适配。
2.3 编译产物:bl1.bin、fip.bin与烧录布局
编译ATF最基本的命令是这样:
make PLAT=fvp DEBUG=1编译成功后,在build/fvp/debug/目录下能看到bl1.bin、bl2.bin、bl31.bin,还有一个fip.bin。FIP(Firmware Image Package)是把多个镜像打包进一个文件的固件包,可以用仓库里的fiptool工具查看和拆包。实际产品烧录时,通常把bl1.bin放到ROM对应位置,把fip.bin放到SPI Flash或其他启动介质中,BL2会根据FIP的TOC(Table of Contents)加载后续镜像。
很多初学者一开始会疑惑,为什么BL2和BL31不分别烧写,非要打包成FIP。主要原因是生产时方便统一签名、统一管理,也方便用fiptool做差分升级。对于一个平台,可能只需要升级某个组件,重新打包一次FIP即可,不用单独擦除Flash上的独立分区。
3. 安全固件工程审计:从威胁模型看ATF的关键防护设计
说到“安全审计”,很多人第一反应是找漏洞、看CWE清单。实际做固件工程审计,更重要的先想清楚威胁模型:攻击者能碰哪里,他有多少能力,我们防的是什么。
对ATF这种EL3固件,攻击者通常能控制的是Normal World的整个软件栈,甚至可以跑自己的内核。他能发起SMC请求、能访问DDR内存、也可能尝试利用驱动漏洞改写DDR内容。ATF要保证的是:这套已经被“攻破”的Normal World,依然无法突破EL3的保护,无法读取Secure World数据,无法非法控制系统电源操作,无法篡改启动流程。所有关键机制都围绕这个目标展开。
3.1 ROM Code、SRAM、DDR:三层存储的信任边界
ATF的安全模型首先要看代码放在哪。BL1在芯片出厂ROM里,物理上不可写,是信任根。BL2在SRAM里,SRAM通常不被DMA控制器随意访问,安全等级也比较高。但BL31和BL33在DDR里运行,DDR是开放的,攻击者有机会接触。
所以ATF不能假设BL31运行的环境完全可信,它必须通过硬件隔离机制(TrustZone)把CPU和总线上的访问控制住。TrustZone地址空间控制器(TZASC)可以把DDR物理区域划分成Secure区域和Normal区域,只有Secure侧(EL3或Secure World)才能访问Secure区域。即便攻击者在Normal World跑任何代码,总线层面过不了TZASC,拿不到Secure Region内容。
审计时要重点检查平台代码里TZASC区域的配置是否过于宽松。有些开发板为了调试方便,把所有DDR都配成Non-Secure,这在开发阶段问题不大,但量产固件这样配就形同虚设。审核plat/*/plat_security.c这类配置时,第一件事就是看内存区域切割表。
3.2 世界切换与SMC返回时的状态保存
每次Normal World调用SMC进入EL3,都要保存Normal World的寄存器状态,再从Secure上下文(或EL3自己的上下文)恢复现场。这个保存恢复的代码在lib/el3_runtime/aarch64/context_mgmt.c里。审计时要特别留意的点是scr_el3寄存器的设置,尤其NS位。如果异常返回时NS位配错,处理器会错误地进入Secure World,导致权限错乱。
在实际攻击面里,SMC返回值的处理同样关键。Secure Monitor在结束时做eret回到Normal World之前,会检查返回上下文里的PSTATE和PC是否合法。ATF的通用代码整体做得比较严,问题往往出在平台自定义的handler里。例如平台自己实现了一个SMC查询接口,返回了一个指向Secure内存的指针,但没做地址范围检查,Normal World拿到后就能直接访问Secure信息。这就是工程审计里需要重点追的问题模式。
3.3 输入校验:SMC请求参数不能直接信
BL31接收到SMC请求,所有参数都来自低权限层,不能直接当成可信数据使用。以PSCI的CPU_ON为例,调用者需要传入目标核心的mpidr。BL31要先通过plat_core_pos_by_mpidr()把这个物理ID映射到平台核心编号的索引范围,并检查是否越界,然后才能操作核心。
曾有人专门审计ATF历史CVE,很多问题都出在跨平台代码和平台回调之间的信任边界没掐好。举个例子,如果平台的plat_core_pos_by_mpidr实现没有对未知MPIDR做统一错误码返回,而是直接返回0,那上层的循环就可能把0号核重复操作,造成系统状态错乱。审计这份代码时,不要去读PSCI核心逻辑,直接把所有plat_开头的回调函数全部拉出来,逐个核对返回值检查是否完备,这个思路效率高得多。
3.4 安全启动与签名证书链
ATF的Trusted Board Boot(TBB)功能是安全审计绕不开的主题。启用TRUSTED_BOARD_BOOT=1后,BL2会验证BL31、BL32、BL33的证书链,每个镜像都带一个X.509证书,证书由平台信任的密钥签发。密钥管理的根在哪儿,信任链就从哪儿开始。
典型的TBBR流程是:BL1在ROM里信任“根公钥”,这个公钥通常被烧在fuse或OTP里;BL1验证BL2的证书;BL2再验证后续所有镜像。这样即便攻击者能改写Flash上的BL31/BL33,没有私钥也无法通过签名验证。但是反过来,如果fuse配置时把调试密钥(development key)或RSA公钥hash留空,就会导致验证被跳过。审计安全启动时,第一件事就是确认构建和烧录阶段用的是生产密钥还是调试密钥。
3.5 一个可直接用的审计清单
| 审计维度 | 关键检查点 | 常见问题 |
|---|---|---|
| 接口层 | SMC handler是否校验所有入参、返回地址是否在合法范围 | 参数未校验、返回SMC功能号错误 |
| 数据层 | 全局缓冲区是否有边界检查、是否有越界写 | 数组越界、整型溢出导致绕过 |
| 存储层 | TZASC区域划分、Secure DDR是否过大或过小 | 全部DDR配成Non-Secure |
| 启动链 | 证书链是否强制启用、密钥来源是否可控 | 调试密钥带入量产 |
| 运行时 | 异常向量表是否完整、SDEI中断是否可被恶意触发 | 异常向量未初始化、中断抢占异常 |
这个清单不是让你一条条机械打钩,而是用来建立“威胁模型→代码位置→检查方法”的对应关系。拿到一份新平台固件时,沿着这个表过一遍,基本能把高风险点筛出来。
4. 平台移植落地:从零让ATF跑在一块新板上
ATF源码本身支持很多参考平台,比如ARM官方FVP、Juno,以及众多厂商的SoC平台。但新板子的Bring-up,终归要自己动手做platform port。这一节我按实际移植顺序整理一套可执行步骤。
4.1 先找“母板”:复制一份最小平台框架
移植第一步不是写代码,而是选参考平台。如果你的新芯片是某厂商SoC的迭代版本,尽量用该厂商已支持的最新平台代码作为底子,复制一份改。如果完全从零起步,用FVP平台代码做模板是最稳的,它结构清晰、不依赖具体外设。
在plat/下面创建自己的目录结构,比如plat/acme/board/alpha/。这个目录里需要包含:
platform.mk:定义平台源文件、编译选项、内存基址。plat_setup.c:实现平台启动早期初始化和运行时初始化。plat_pm.c:实现PSCI的电源控制回调。plat_sip.c:实现平台自定义的SIP SMC服务(可选)。include/platform_def.h:定义平台相关的所有宏。
platform.mk里最核心的几行大概长这样:
PLAT_BL31_BASE := 0x40100000 $(eval $(call add_define,PLAT_BL31_BASE)) BL31_SOURCES += \ plat/acme/board/alpha/plat_setup.c \ plat/acme/board/alpha/plat_pm.c对新手来说,先记住一个原则:BL31_BASE必须指向一块在BL31运行期间不会被覆盖的DDR区域。很多人一开始直接把BL31放到0x40000000,而U-Boot又把自己放在同一个地址,结果BL31被U-Boot加载后覆盖,系统莫名其妙的挂掉。
4.2 四个绕不开的核心接口
在BL31的启动流程里,平台需要实现几个关键回调。若任何一个没实现或实现不完整,系统都会卡死或者提前进入异常。
第一个是bl31_plat_arch_setup(),它的任务是配置MMU和内存映射。BL31刚接手时,MMU通常是关闭的,需要在这里借助ATF的xlat_tables库建立页表。要特别注意的是,串口地址的映射必须在这步之前或这步过程中配好,否则你后面所有printf都会变成黑暗中的呐喊,什么都没有。
void bl31_plat_arch_setup(void) { /* 配置MMU映射,包含UART、GIC、以及BL31自身所在内存 */ enable_mmu_el3(0); }第二个是bl31_platform_setup(),它负责初始化串口、GIC等基本外设。GIC初始化的时机很敏感,太早会导致中断配置不完全,太晚又会丢了需要EL3响应的中断。
第三个是plat_get_next_bl_params(),它负责构造下一个阶段镜像的启动参数。ATF内部用bl_params链表记录BL33等信息,BL31在跳转前会从这个结构里取入口地址和参数。这里也是要把设备树地址传递给U-Boot的地方。
void bl31_plat_get_next_bl_params(struct bl_params *params) { bl_params_node_t *node = params->head; struct entry_point_info *bl33_ep = node->ep_info; /* 设置BL33入口的x0为设备树地址 */ bl33_ep->args.arg0 = PLAT_DTB_ADDR; }第四个是PSCI电源管理相关回调。你至少要实现CPU上电、CPU下电、系统重启、系统关机的回调。代码通常在plat_pm.c里实现,然后通过plat_setup_psci_ops()注册到PSCI框架中。
接口实现有一个通用的调试顺序:先把串口和GIC跑通,再调试MMU,最后再接手PSCI。不要一次把全部代码写满再统一调试,那样出问题时你会完全找不到切入点。
4.3 和U-Boot怎么对接:BL33入口和设备树传递
很多人卡在“ATF启动后怎么跳到U-Boot”这个问题上。其实ATF到U-Boot的跳转就是一个普通的eret,但在跳转前要做几件事:
- 从
bl_params链表里取出BL33的入口地址。 - 在x0寄存器里放入设备树地址(AArch64上约定参数放x0)。
- 设置好SP、ELR、SPSR这些异常返回寄存器。
- 执行
el3_exit完成异常返回到Normal World。
U-Boot启动后,如果能看到设备树地址参数,说明BL31到BL33的这一棒交接成功。如果U-Boot启动后没有任何设备树信息,大概率是plat_get_next_bl_params没有正确设置args,或者BL2传给BL31的bl_params链表本身就不包含BL33。
对很多ARMv8平台,U-Boot本身也可以作为BL33运行在EL2,而内核则进一步降到EL1。这一层层降级,都依赖ATF在EL3侧做好的上下文切换。
4.4 交叉编译工具链的选型问题
ATF官方构建默认使用GCC或者Clang,命令大致是:
make PLAT=myboard DEBUG=1 CROSS_COMPILE=aarch64-linux-gnu-有些初学者看到网上的ARM Compiler 5.06下载教程,以为编ATF也得用AC5/AC6。AC5/AC6主要是ARM自家为Cortex-M等裸机场景提供的商业编译器,一般用于keil这类IDE开发STM32工程,与ATF完全是两条线。ATF的链接脚本、内联汇编、编译选项都面向GCC/Clang风格,所以老老实实用GNU工具链就好。主机是x86时,装上gcc-aarch64-linux-gnu交叉编译工具链就够了;在ARM服务器上原生编译也可以,只是编译速度通常不如x86。
5. 移植过程中的真实调试经历与常见坑
最后这一部分,我把这几年来在ATF移植过程中实际踩过、见过的问题整理一下。这些问题在官方文档里基本不会太细提,但碰到的概率高得令人发指。
5.1 卡在BL31开头,串口连一个字符都不出
这是一半以上板子Bring-up会遇到的第一个问题。串口不出日志,不代表系统完全没跑,很可能是串口还没初始化就开始调printf了。Debug版里BL31的NOTICE打印发生在bl31_early_platform_setup阶段,如果这一步没有正确初始化UART时钟和引脚,后面全白搭。
排查思路是这样:
- 先确认UART硬件本身有没有问题,可以在U-Boot里把BL31所在区域擦掉,让U-Boot直接接管串口,看能不能输出。
- 在
bl31_plat_arch_setup里加最简单的裸寄存器操作,往UART发送寄存器写一个字节,绕过ATF的串口驱动直接验证。 - 检查MMU映射,如果UART地址没有映射成Device类型内存,访问会触发同步异常,系统会直接死掉。
以我的经验,80%的“没有任何输出”到最后都是MMU映射没配好,而不是串口驱动代码写错了。
5.2 SMC调用返回错误值或hang死
如果你已经在U-Boot或内核里通过smc指令调用了ATF的功能,但返回的值不对,第一步先不要怀疑固件逻辑,查一下调用号对不对。SMC function id的OEN字段和调用编号必须和实际注册的服务匹配。比如平台SIP服务一般在plat_sip.c里用宏注册,OEN是2。如果你在内核里发的调用号OEN写成了0,就会走到PSCI handler,自然返回不认识。
再看一种更隐蔽的情况:handler返回了,但x0的值不是预期值。这种问题多半出在上下文保存环节。BL31在SMC入口把异常现场保存在cpu_context中,handler的返回值最终写回x0。如果handler内部不小心用了同一个寄存器保存临时值,返回现场恢复后期望值就会被覆盖。调试这类问题,用gdb连上目标板或QEMU,在handle_smc入口和el3_exit处打断点,对比x0的变化是最直接的。
5.3 在QEMU上把整套链跑起来
没有真实开发板时,QEMU是最靠谱的调试环境。ATF源码直接支持make PLAT=qemu,编出来的镜像可以配合QEMU运行。
一个常见做法是单独构建QEMU平台ATF,然后用QEMU的-bios参数加载BL1,或者直接用-kernel加载FIP。对于调试BL31流程,我建议加上-S -gdb tcp::1234参数,让QEMU在启动时挂起,再用aarch64-none-elf-gdb连接上去,直接在bl31_main下断点,单步看每一步寄存器和内存的变化。这种方式比串口日志有效得多,因为能直接看到MMU是否开启、页表内容是否正确、异常向量是否指向预期地址。
5.4 热词里的常见困惑:从“编译后没有arm文件夹”到“找不到用户态ATF接口”
我注意到最近社区里有些帖子提到“编译后没有arm文件夹”这类问题。出现的原因一般是编译命令里没有正确指定PLAT参数,ATF默认可能在当前目录找不到平台目录,于是构建系统就报错或者只生成了一部分中间文件。解决方式很简单:编译前先make distclean清理缓存,再重新用完整的make PLAT=myboard DEBUG=1 CROSS_COMPILE=aarch64-linux-gnu-命令编译。
还有一个困扰不少人的点是:ATF作为EL3固件,用户态程序和内核态驱动并不能直接“调用”它,只能通过smc指令陷入EL3。内核里一般用smc调用的helper或者在驱动里直接内联汇编实现。用系统调用去调ATF是个绕远路思路,实际上ATF对外暴露的接口主要是面向内核的,用户态基本不会直接接触,除非你在设计某种特殊的安全服务框架。
最后再说一点关于工具链的小建议。有些人在x86的Ubuntu上交叉编译ATF,总喜欢把网上找来的“ARM Compiler 5.06”装上,其实没什么必要。用发行版自带的crossbuild-essential-arm64或者直接从ARM官网下载aarch64-none-elf工具链,编ATF都很顺畅。至于Clang,新版本ATF也支持make CC=clang,但GCC是最不容易出错的路线。第二点经验是,调试期间一定要把DEBUG=1加上,它会额外输出很多INFO级别的日志,虽然慢一些,但能极大节省定位问题的时间。
这套ATF的源码和平台代码,说实话看一遍不会全记住,但它背后的设计逻辑——信任边界、服务路由、上下文切换、平台抽象——是底层固件通用的一套语言。把这条链路走通后,再去看其他厂商的EL3实现,基本都能快速对齐概念。希望这篇带点“审计视角”的盘点能帮你少走点弯路。