
一块开发板卡死在U-Boot的“Starting kernel”之前日志全是空白这种问题在ARM平台底层其实很常见而且八成不是内核的问题而是EL3固件没起来。我第一次接触Arm Trusted FirmwareATF时光搞清楚BL1、BL2、BL31、BL32、BL33这条启动链就花了不少时间后来又把源码从头到尾翻了一遍在FVP虚拟平台上完整走了一次平台移植才算真正理解这套安全固件到底在设计什么。这篇文章就是那次过程的全记录适合做平台Bring-up的底层工程师、做固件安全审查的同事以及想系统搞懂ATF的嵌入式方向学生来读。我会直接从“ATF在上电链路里到底站在哪”“源码目录如何按信任边界组织”“安全审计要看哪几个关键点”“从零移植一份platform port需要碰哪些接口”这几个角度来写尽量把工程上容易出问题的地方一次说清。1. ATF到底管哪一段从上电到U-Boot的“接力棒”划分很多新人容易把ATF和U-Boot混在一起觉得都是“引导程序”。实际上在AArch64架构里ATF并不是一个单一固件而是一整套由多个BLBoot 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里的BL1BL1完成基础时钟、串口等最小初始化并验证BL2镜像后将其加载到SRAM。BL2在SRAM中运行负责初始化DDR控制器等内存系统加载BL31、BL32可选TEE以及BL33U-Boot或EDK2加载完成后跳入BL31。BL31在EL3完成运行时环境初始化包括中断控制器、系统寄存器、PSCI服务注册然后降级跳到BL33。BL33U-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依然在内存里负责响应系统运行过程中的电源管理请求。为了直观理解各阶段的职责我整理了这么一张表阶段存储位置主要职责生命周期BL1SoC片内ROM信任根、加载BL2启动完成后不再参与BL2SRAM初始化DDR、加载后续镜像启动完成后可被覆盖BL31DDR/SRAMEL3运行时、PSCI/SMC服务、安全中断常驻BL32DDRTEE OS如OP-TEE常驻BL33DDRU-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相关的SPDSecure 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也就是EBTException Based Trap之后的调用号。SMC调用号的格式是这样的32位调用号里高16位包含OENOwner Entity Number等属性字段比如PSCI服务的OEN是0SIP服务的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 PLATfvp DEBUG1编译成功后在build/fvp/debug/目录下能看到bl1.bin、bl2.bin、bl31.bin还有一个fip.bin。FIPFirmware Image Package是把多个镜像打包进一个文件的固件包可以用仓库里的fiptool工具查看和拆包。实际产品烧录时通常把bl1.bin放到ROM对应位置把fip.bin放到SPI Flash或其他启动介质中BL2会根据FIP的TOCTable 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 BootTBB功能是安全审计绕不开的主题。启用TRUSTED_BOARD_BOOT1后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 PLATmyboard DEBUG1 CROSS_COMPILEaarch64-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 PLATqemu编出来的镜像可以配合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 PLATmyboard DEBUG1 CROSS_COMPILEaarch64-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 CCclang但GCC是最不容易出错的路线。第二点经验是调试期间一定要把DEBUG1加上它会额外输出很多INFO级别的日志虽然慢一些但能极大节省定位问题的时间。这套ATF的源码和平台代码说实话看一遍不会全记住但它背后的设计逻辑——信任边界、服务路由、上下文切换、平台抽象——是底层固件通用的一套语言。把这条链路走通后再去看其他厂商的EL3实现基本都能快速对齐概念。希望这篇带点“审计视角”的盘点能帮你少走点弯路。