ARM Trusted Firmware深度解析:从EL3信任链到平台移植实践
2026/9/5 12:57:42 网站建设 项目流程

1. 为什么我花了大几个晚上啃ATF源码,以及这篇评测的由来

这不是一篇从ARM官方文档里抄一遍概念再拼几个链接的“伪深度”文章。过去几周我在做一款基于Armv8架构的定制SoC平台适配,整个启动链路里最绕不开、也最让人头疼的一块,就是Arm Trusted Firmware(简称ATF,现在官方叫TF-A)。我把自己从看文档、追源码、跑QEMU仿真到在真实板子上把BL31拉起来、把BL33引导进内核的完整过程复盘了一遍,决定用一篇评测加落地指南的方式,把ATF的架构全景、安全固件工程审计要点、以及平台移植的实操路径一次性写透。

先说清楚这篇文章适合谁:你手上有一颗基于Armv8/Armv9的SoC,需要把ATF跑起来;或者你不需要自己从零移植,但要做安全固件审计、需要搞清楚启动链路里每一步在验证什么、信任边界在哪;又或者你就是想系统地把ATF源码啃一遍,但觉得文档零散、代码跳转太绕。看完这篇文章,你能收获一套完整的源码阅读路径、工程审计关注点、以及一条可复现的移植落地路线。

我默认你对嵌入式Linux启动、U-Boot、设备树这些有一定基础,但如果个别概念不熟,文里也会补必要的背景。ATF是安全固件,不是普通应用层代码,它背后是一整套关于信任根、特权级隔离、安全启动的工程哲学。理解ATF,不只是看懂几个C文件而已。

在展开正文之前,先把一个基本概念说透:ATF是运行在EL3(异常级别3)下的固件。Armv8-A架构定义了四个异常级别,EL0是用户态、EL1是内核态(Linux内核就跑在EL1)、EL2是虚拟化(Hypervisor)、EL3是安全监控态。EL3是最高特权级别,只有可信固件才能运行在这里。ATF的核心使命,就是充当EL3下的安全运行时,负责在安全世界和非安全世界之间切换、引导下一阶段镜像、并提供安全服务。这个定位决定了你理解ATF的每一个模块时,都要带着“特权级、信任边界、安全状态切换”这三个关键词去思考。

整篇文章的结构是这样安排的:先从整体架构层面拆解ATF的启动流程和关键镜像,再深入源码重点模块做审计式分析,接着梳理平台移植的完整实操路线,最后给出高频问题排查思路。这基本也是我从“看懂”到“改得动”的真实进阶过程。

2. ATF架构全景:从BL1到BL33,一条完整的信任链

2.1 ATF到底由哪几部分组成,各管什么事

ATF最核心的产出物是四个阶段镜像:BL1(Boot ROM阶段)、BL2(Trusted Boot Firmware)、BL31(EL3 Runtime Firmware)、BL32(可选的可信OS,比如OP-TEE)。此外还有一个BL33,它通常就是U-Boot或UEFI,ATF本身不编译生成BL33,但负责在合适的时机把控制权交给BL33。

先看BL1。我们在学ARM启动的时候,总能看到“SoC内的BootROM是第一段代码”这个说法,BL1要解决的就是BootROM之后的信任接力问题。BootROM是芯片出厂固化的、不可更改的,它本身就是硬件信任根。BootROM在完成最基本的初始化之后,会把BL1从Boot Device(比如SD卡、eMMC、Flash、USB)加载到SRAM里,然后跳过去执行。BL1代码量非常小,但是职责极其明确:初始化必要的最小系统环境,验证BL2镜像的完整性,然后跳转到BL2。

这里有一个在工程上很容易踩坑的点:BL1是运行在SRAM里的,所以BL1本身没有DDR初始化的能力——它根本用不了DDR,DDR还没初始化。BL1只做两件核心事:建立最基础的异常向量表、配置少量外设(主要是UART打印和定时器),然后按照FIP(Firmware Image Package)格式从存储介质中读取BL2、BL31、BL32、BL33等镜像并做校验。这也是为什么很多时候板子启动卡在BL1阶段打印“ERROR: Unsupported image”或校验失败时,问题大概率不是BL1本身坏了,而是BL2镜像和BL1的验签规则对不上。

BL2是可信启动固件,运行在EL1安全世界。它的核心工作是继续建立信任:当BL1把控制权交给BL2之后,BL2负责初始化DDR控制器、加载BL31、BL32、BL33(也可能还有SCP固件、MCE固件等芯片自有固件),对它们做认证,然后把它们放到约定的内存地址,最后把控制权交给BL31。BL2的一大特点是运行完了之后会“死掉”——它把自己的内存让位给BL31/BL33使用,生命周期非常短。这种设计让攻击面最小化:BL2只处理启动早期的那点事,越早退出越安全。

BL31是整个ATF的核心常驻组件,运行在EL3,启动完成之后永远活着。它提供以下几类关键服务:Runtime Service框架、SMC(Secure Monitor Call)分发、PSCI(电源管理接口,Power State Coordination Interface)实现、以及安全世界与非安全世界之间的上下文切换。Linux内核在启动和运行过程中会不断通过SMC指令陷入EL3,请求BL31执行CPU热插拔、挂起、重启、关机等PSCI操作,也可能会请求其它安全服务。BL31就是我们常说的“Monitor”——安全监视器。它的地位用一张图可以理解:EL3是最高级,BL31是EL3的常住居民,所有想要触碰EL3的请求,都必须经过BL31。

BL32是可信操作系统(Trusted OS),常见的就是OP-TEE。OP-TEE运行在EL1安全世界,通过SMC和EL3的BL31通信。从ATF的角度看,BL32属于可选组件:如果不需要TEE功能(secure storage、加解密、可信应用运行环境等),你可以不编译BL32,BL31直接跳到BL33启动非安全世界。这个取舍影响的是启动速度和固件大小。但要注意,不编译BL32不等于没有安全需求,EL3本身由BL31管理,安全世界的边界依然是清晰的。

BL33就简单了,它一般是U-Boot或者UEFI固件,运行在非安全世界EL2或者EL1。ATF启动到BL31后,会将BL33镜像加载到约定地址并跳转执行。

2.2 启动流程全链路:每一行关键代码在做什么

如果你读过ATF源码,启动流程的主线其实是那几个文件夹里反复出现的几个函数。读懂这条主线,你就读懂了ATF的骨骼。

先理清启动主线:BL1.Entrypoint -> BL1_main -> bl1_run_bl2 -> BL2.Entrypoint -> bl2_main -> bl2_run_bl31 -> BL31.Entrypoint -> bl31_main -> el3_exit -> BL33(U-Boot/内核)。

在第一阶段,BL1的入口是bl1_entrypoint(路径在bl1/aarch64/bl1_entrypoint.S),这个汇编文件的重点是设置异常向量表、初始化TPIDR_EL3(用于记录当前CPU的上下文指针),然后跳转到C代码bl1_mainbl1_main接着调用bl1_early_setup做一堆基础设置(PSCI版本、串口初始化、内存布局信息),随后进入bl1_prepare_next_image函数。这个函数做的事情非常关键:从FIP里找到下一个镜像(BL2)的头部信息,加载到指定内存位置,然后做认证。

BL1将BL2加载进SRAM后,通过bl1_run_bl2完成异常级别下降——从EL3降级到EL1安全世界,跳进BL2入口。这里注意ATF的天才设计:BL1加载BL2之后,BL1在SRAM里给BL31存的上下文、内存信息、认证参数,都要在这一刻交接给BL2。所谓“信任交接”,就是从这里开始的一环扣一环。

进入BL2阶段,入口在bl2/aarch64/bl2_entrypoint.S,C入口是bl2_main。如果你仔细看bl2_main的代码,会发现它第一步就是调用bl2_early_platform_setup来做平台相关初始化,然后注册一个bl2_el3bl2_secure的上下文到BL31的内存区域,再按顺序bl2_load_bl31bl2_load_bl32(如果有)、bl2_load_bl33。这几个load函数的调用路径是load_image->load_image_flush(涉及cache flush),最终会把镜像加载到约定的内存地址,并返回entry_point_info_t——包含了入口地址、SPSR(保存的程序状态寄存器)、参数寄存器(x0/x1/x2/x3)。这些信息被组合成上下文,交给BL31在启动时使用。

随后BL2调用bl2_run_bl31,这个函数会做两件事:把控制权交给BL31,Bl31在EL3开始执行。这看上去只是简单的函数跳转,但关键在于跳转时大家约定好的寄存器规则——BL31的入口函数bl31_entrypoint会从寄存器里拿到BL2传过来的bl31_params指针,里面装着所有镜像的入口信息和内存信息。这个阶段如果参数传错,后面BL31根本找不到BL32/BL33的入口,会出现无法跳转或者跳转无打印的诡异问题。我在实际调试中见过最多的启动卡死,就发生在这个交接段。

BL31是真正的重头戏。入口在bl31/aarch64/bl31_entrypoint.S,C入口是bl31_mainbl31_main做的事情可以概括为三步:设置BL31自身的运行时环境(包括中断控制器、域转换)、注册Runtime Service、最后通过el3_exit返回非安全世界跳转到BL33入口。

这里展开讲一下Runtime Service机制,因为它是理解BL31的钥匙。ATF定义了一套中断和SMC分发框架,每个Service通过DECLARE_RT_SVC宏注册到rt_svc_descs表里。当EL1(内核)或EL2(Hypervisor)执行SMC指令陷入EL3时,BL31的SMC #0 handler会解析SMC的function ID(比如PSCI的function ID范围是0x84000000),查找对应的Service描述符,并调用其注册的handle函数。PSCI服务本身就是这样注册的:它实现psci_smc_handler,处理CPU_ON、CPU_OFF、SYSTEM_OFF、SYSTEM_RESET等电源管理操作。所以从架构上看,BL31本质上是一个“EL3下的服务总线”,SMC在其中扮演了系统调用的角色,只是这个“系统调用”发生在最高特权级。理解了这一点,你再去看BL31里面让人看花眼的context_mgmtinterrupt_mgmtruntime_svc等模块,就都有落点了。

2.3 可信固件中的信任链构建:RoT、FIP、签名认证

ATF中“可信”二字的根基,是一条从SoC BootROM到BL33的信任链。这条链最核心的机制是Chain of Trust(信任链)。

信任链的第一环是RoT(Root of Trust,信任根)。在ATF的标准实现里,RoT一般固化在SoC内部的一次性可编程存储(OTP,One-Time Programmable)里,或者作为BootROM的一部分。OTP里存储的主要是不可变公钥的哈希(ROTPK Hash),ATF启动时用这个哈希去校验BL2镜像的签名。换句话说,即使攻击者把BL2镜像替换掉,BL1在验证BL2时会发现签名无法匹配OTP里记录的公钥,启动直接中止。这就是RoT是“根”的含义:它不可修改、不可篡改,是芯片出厂时烙下的信任基线。

BL2之后的每次跳转,都重复同一种模式:当前阶段镜像在跳到下一阶段前,先验证下一阶段镜像的证书和签名。BL2验证BL31、BL32、BL33,BL31验证BL32(如果BL31启动时需要动态加载)等等。每一级验证的凭证都封装在证书里。ATF使用了一套基于X.509证书的证书链体系,证书格式定义在drivers/auth/mbedtlstools/cert_create工具里。你需要在构建时用cert_create工具生成包含公钥、私钥、签名信息的证书链,然后把它和各个阶段镜像打包进FIP文件。

FIP的全称是Firmware Image Package,它本质上就是镜像的集装箱。ATF所有要交给BL1和BL2加载的镜像,最终都要打包进FIP文件——FIP里包含了BL2镜像和它的证书、BL31、BL32、BL33及对应证书。BL1从存储介质中读取FIP,FIP有一个固定格式的ToC(Table of Contents),BL1按照ToC找到对应的镜像条目再读取验证。我刚开始接触ATF时,经常把FIP和镜像混淆。简单说:镜像单独存放不行,fip_create工具会按照格式把这些都塞到一个文件里,而BL1和BL2只认FIP格式。

整个信任链在工程上带来的约束是:只要你在开发阶段改了任意一个阶段镜像(比如改了BL33的U-Boot),就需要重新生成证书和FIP,否则BL2在验证BL33时会报签名失败。开发调试的早期阶段,很多人会直接关闭认证(TRUSTED_BOARD_BOOT=0),以确保改动镜像后不用每次重签。但正式固件必须开启TBBR(Trusted Board Boot Requirements)。后面在移植落地部分我会再讲这两者的切换细节。

2.4 安全世界与非安全世界:EL3为什么如此至关紧要

理解ATF,至少有三种“世界”必须分清:安全世界(Secure World)、非安全世界(Normal World)、以及EL3的监控世界。我们用手机来解释:非安全世界就是Android/iOS系统常驻的位置,它跑Linux/内核、App,但它看不到也碰不到安全世界的东西。安全世界跑的是TEE OS(如OP-TEE),负责指纹识别比对、DRM密钥管理、secure storage。EL3则是把持着两个世界切换开关的那个守门人。

从硬件上说,ARM通过TrustZone技术实现了物理隔离——内存和总线访问都被标记为安全或非安全。SoC里的TZASC(TrustZone Address Space Controller)可以给每一段内存配置安全属性,CPU在EL3时能访问所有内存,但在EL1非安全世界访问安全内存会被硬件直接拦下来,产生异常。所以安全世界并不是靠“软件约定”来和普通世界分隔的,而是靠硬件的强制校验。ATF作为EL3里的常驻代码,核心职责之一就是在这两个世界之间安全地切换上下文、保存/恢复寄存器状态,并且保证任何数据在跨世界传递时不会泄露。

这里有个很反直觉的点:EL3能访问所有内存,但BL31的内存布局是十分敏感的。ATF源码中多处对BL31_BASEBL31_LIMIT有定义,平台移植时如果把这些地址设错,导致BL31的代码或数据段与BL33的加载地址重叠,等BL33启动时就会把BL31的代码踩掉。轻则BL31后续SMC调用异常,重则启动即崩。这也是为什么ATF平台移植手册里反复强调内存布局的含义。

当你理解了EL3的职责之后,再看BL31的context_mgmt模块就不会觉得抽象了。它维护着cpu_context结构体,里面保存了通用寄存器、系统寄存器、异常返回值等完整上下文。在SMC陷入EL3时,BL31会把普通世界的寄存器现场保存到cpu_context里,然后切换到安全世界或直接在EL3处理请求,最后从cpu_context恢复现场,回到普通世界。这个保存恢复过程,是安全固件工程的核心“面子”,也是很多SMC处理bug的温床——寄存器压栈和出栈一旦错位,回到普通世界时CPU状态就已经不是原来的样子了,表现为启动后系统随机死机或者内核态oops,非常难排查。

3. 安全固件工程审计的重点:源码级视角下需要盯紧的几件事

3.1 启动路径上的身份验证点与篡改防护策略

安全固件审计的第一个关注点,就是对启动路径上的每个镜像、每条跳转路径做逐一验证。我在看ATF源码时,最先做的事就是画出整个启动链路图,然后给每个节点标记“做了什么检测”、“检测的目标是谁”、“如果检测失败会怎样”。这个审计习惯可以让你迅速定位哪里是攻击面最广的位置。

BL1阶段的核心验证集中在bl1_prepare_next_image函数。它会检查BL2镜像头部的认证参数和证书信息,调用auth_mod_verify_signature等接口。在drivers/auth目录下,你能看到完整的认证框架:认证模块(Auth Module)会解析镜像头部,提取出auth_param_type_descauth_param_data_desc,然后驱动认证方法(比如mbed TLS的RSA验签、ECDSA验签)来完成验证。有一个容易被忽略的细节:DECLARE_AUTH_PARAM宏定义的认证参数里,不仅有镜像内容本身,还有该镜像对应的证书哈希、扩展信息。如果攻击者伪造了一个镜像,但无法同时伪造证书,那么验签还是过不了。

BL2阶段的审计重点则变成“谁在验证BL31和BL33”。BL2加载BL31时,调用load_auth_image而非单纯的load_imageload_auth_image内部会先加载、再认证。这里要特别注意的是plat_get_mbedtls_heap——如果开启了TBBR,mbed TLS库需要一块私有堆内存来执行大数运算(RSA验签需要它),这块堆的内存在哪里配置,是平台移植时很容易忽略的点。如果不配置,启动时mbed TLS库就会报“Mbed TLS heap is not configured”错误。我调试时遇到过一次,查了很久才意识到是平台没有实现plat_get_mbedtls_heap

信任链最后一大检查点是BL31跳到BL33之前。BL31跳转前已经不在FIP的ToC里重新校验BL33了,因为BL33的校验发生在BL2阶段。所以如果你的攻击面评估显示BL2阶段可能被绕过(比如通过JTAG干扰),那么BL33的完整性问题就会暴露。这也是为什么很多严格的安全固件要求BL33也必须放在只读存储介质里面,而不只是依赖签名校验。

3.2 异常级别与特权级隔离的设计验证

审计ATF时,第二件必须盯着的事是异常级别的使用是否合规。换个角度说,你要检查每个镜像是否运行在它应该运行的EL下、是否使用了正确的SPSR配置。

BL2运行在EL1安全世界,它的SPSR设置在bl2_entrypoint.S里有明确设定——SPSR_EL3的值是SPSR_MODE_EL1配合安全标志位。如果你不小心把BL2的SPSR配成了EL2或者EL3,启动时可能直接异常,或者直接进入了错误的安全状态。BL31使用SPSR_EL3_H(即EL3 handler模式)初始化自身上下文。

EL3的核心特权还体现在系统寄存器访问上。比如SCR_EL3(Secure Configuration Register)是EL3下的关键寄存器,它决定了非安全世界的异常是否能进入EL3、安全世界是否允许访问虚拟化扩展等。在bl31_entrypoint.S里,BL31会显式设置SCR_EL3的值。审计时要检查这些系统寄存器配置是否符合设计意图:比如SCR_EL3.NS位(Non-Secure位)决定了当前CPU是在安全世界还是非安全世界,如果在正常世界里,它必须是1;在安全世界里必须是0。这种位级的检查异常琐碎但至关重要。

还有一个交叉验证的思路:利用ETM或硬件调试器在EL3代码路径上打点。我在QEMU仿真阶段用过GDB的hbreak(硬件断点)去卡EL3的地址,确认某些SMC是否真的进入了BL31,再确认EL3返回时是否真的恢复到了非安全世界的中断现场。这种方式比单纯看log要精准得多。

3.3 内存隔离与TrustZone配置的工程核对

审计安全固件不能只看代码,还要看硬件配置。TrustZone的内存隔离是“安全固件不背锅”的最后一层防线。软件写得再好,如果TZASC没有正确配置,安全内存可能被非安全世界的DMA攻击读取——这是硬件级别的漏洞,软件完全防不住。

RA(Region Address)的配置集中在对TZASCTZPC(TrustZone Protection Controller)的初始化。在ATF的平台代码里,特别是在plat/arm/board/的文档示范代码中都有对应实现。你需要系统性地检查以下几点:

内存区域的划分是否覆盖了所有安全敏感区域(比如BL31的代码/数据段、OP-TEE的内存区域、secure storage区域)。安全世界访问非安全世界内存必须有明确的门控逻辑,反过来也是。非安全世界的DMA(例如UFS、SD控制器发起的内存访问)不能触碰安全内存。这些配置通常固化在平台初始化代码中,审计时要找到tzc_reg的配置列表,逐条核对地址区间和允许的访问权限。

这里多说一点实际体会:平台移植时,TZASC初始化非常容易出问题,因为各家SoC的TZASC实现细节差异很大。有些SoC用ARM官方TZC-400,代码可以直接复用;有些则用自定义的地址过滤控制器,需要重新实现平台函数。我在移植时遇到的一个典型案例是:系统启动后OP-TEE能正常加载,但一旦Linux内核里某个驱动访问到Secure DDR区域,整个系统就hang住。最后排查发现是TZASC没有把那段区域标记为Secure,非安全世界的访问没有触发异常,直接读到了乱序数据,把系统搞崩了。这属于硬件隔离缺失引发的连锁反应,软件日志完全看不出问题。

3.4 密钥管理、证书链和固件更新的安全实践

密钥和证书链是安全启动工程里最“宝”的部分。ATF的证书链由tools/cert_create的工具生成,它会读入各阶段镜像文件、公钥/私钥文件,输出证书(x509格式)和最终的FIP。整个流程的信任根是ROTPK(Root of Trust Public Key)——这个公钥被烧录在SoC的OTP中,无法篡改。

审计密钥管理需要重点关注三点:开发密钥和量产密钥必须分离。开发环境用一套自签名密钥(Debug证书),量产固件用另一套受控密钥(Production证书),两套密钥的私钥必须物理隔离存放。BL1里面会有一段代码检查ROTPK hash是否匹配OTP里的值,很多验证调整都要在这里做。密钥轮换策略:如果某把密钥泄露了,你有没有机制替换下游密钥?ATF的证书密钥结构支持多个层级的密钥,中间层级的更新可以不用动ROTPK,这是工程设计上的关键缓冲。

固件更新审计也值得一提。ATF本身支持通过FIP Update机制(fwu_fip)将新固件烧入存储介质,但更新的时序要围绕认证来做:新的BL2、BL31、BL33会被一起打包成新的FIP,再由运行环境里的更新代理(比如U-Boot的FWU功能)写入。如果你只更新BL33而不更新BL2,而BL2对BL33的验签公钥没变,那么可以;但如果信任链顶层的关系变了,整个FIP都要重新生成。这个逻辑在审计时必须一级一级理清楚,否则会出现“固件版本混杂导致的启动不信任”问题。

4. 平台移植落地:从QEMU到真实SoC的编排实践

4.1 移植前必须想清楚的六个决定

真正动手移植ATF之前,先别急着写代码。我在踩过几次坑之后总结了六个必须提前确认的问题,这些问题会决定整个移植工作的方向和难度。

第一,目标SoC的架构版本。Armv8.0、Armv8.2、Armv9的差异会影响你选用的ATF版本(lts-v2.8master分支都会对应不同架构特性)。如果你的SoC支持Armv8.2的强化安全特性(比如FEAT_MTE、FEAT_BTI),那ATF构建时要打开对应的编译选项。

第二,BL31运行在SRAM还是DDR里。很多低端SoC把BL31直接放在DDR里(用固定地址映射),但高端SoC的BL31可能放在L2 Cache的SRAM里或者专用的TCM里。这决定了BL31的BL31_BASE和链接脚本配置。

第三,BL2是否需要支持DDR初始化。如果SoC的DDR初始化由芯片内部的SCP(System Control Processor)或MCE(Management Complex Engine)固件完成,那么BL2就不用碰DDR;如果DDR初始化要靠ARM核自己跑,那就需要把DDR厂家提供的初始化序列(通常以二进制库或C源码形式存在)集成进BL2。

第四,BL32(OP-TEE)是否会启用。启用TEE会让整个系统复杂度上一个大台阶,内存、中断、动态共享内存都要额外配置。如果产品安全需求没有那么高,前期可以关闭BL32。

第五,BL33用什么,U-Boot还是UEFI。这会直接影响BL31跳转时SPSR的设置方式和FIP打包的内容(UEFI的FIP相较于U-Boot还要多放一些ACPI表)。

第六,TBBR是否在生产固件里开启。决定开发期直接用TRUSTED_BOARD_BOOT=0,还是在开发期就开启完整签名流程。

这六个问题,每一条都能引申出一堆工程细节。我的建议是先把它们写成一份决策清单,找SoC原厂的FAE确认清楚,再动手改代码。ATF移植本身是一个软硬件交界的系统工程,前期的需求澄清往往比写代码本身更节省时间。

4.2 一步步在QEMU上把ATF跑起来的操作记录

如果你手上的真实板子还没就绪,或者你想先掌握ATF的运行机制,QEMU的virt平台是最省心的起步环境。下面完整记录我在QEMU上编译和运行ATF的步骤,每一步我都尽量还原当时的选择和原因。

先准备编译工具链。在x86主机上编译ARM64代码,需要用aarch64-linux-gnu-gcc。Ubuntu/Debian直接安装:

sudo apt install gcc-aarch64-linux-gnu build-essential

需要确认版本。我的环境是gcc 10.x系列。ATF对GCC版本要求并不苛刻,新版ATF需要GCC 10以上才支持部分特性,但用系统默认即可。

接下来克隆ATF源码。我用的是官方GitHub仓库,选择lts-v2.8分支:

git clone https://github.com/ARM-software/arm-trusted-firmware.git cd arm-trusted-firmware git checkout lts-v2.8

选择LTS版本而不是master,原因是生产级固件开发需要稳定性,master分支会有功能变动。如果只是为了学习,master也行。

QEMU的virt平台在ATF里自带支持,所以你不需要改任何平台代码就可以先编一个能跑的固件。编译命令:

make PLAT=qemu DEBUG=1 V=1 all

PLAT=qemu指定平台,DEBUG=1开启日志调试(会包含很多调试打印),V=1输出详细编译过程。编译完成后,在build/qemu/debug/下会生成bl1.binbl2.binbl31.binfip.bin等文件。

但我们不能只有ATF,还需要一个BL33镜像来跑。我先用U-Boot作为BL33。构建U-Boot也很简单,在U-Boot源码目录下:

make qemu_arm64_defconfig make CROSS_COMPILE=aarch64-linux-gnu- -j8

生成u-boot.bin之后,打包FIP。在ATF目录下用fiptool完成这一步:

make PLAT=qemu fiptool tools/fiptool/fiptool create --tb-fw build/qemu/debug/bl2.bin --soc-fw build/qemu/debug/bl31.bin --nt-fw /path/to/u-boot.bin fip.bin

这条命令把BL2、BL31、BL33打包进fip.bin。把它放到QEMU可以读取的位置。启动QEMU时,我用的是:

qemu-system-aarch64 -machine virt -cpu cortex-a57 -smp 4 -m 1024 \ -bios build/qemu/debug/bl1.bin \ -nographic

需要注意-bios参数加载的是bl1.bin,B1会把FIP从QEMU固件模拟的存储设备中读取出来。这里我花了一点时间搞明白:-bios是QEMU直接当作第一段代码去执行的,BL1在这个环境里运行后,会在模拟的Flash设备中找到FIP,然后继续后续启动流程。

正常启动后,终端会依次出现BL1、BL2、BL31、BL33(U-Boot)的启动日志。在我的测试里,启动顺序是:

NOTICE: BL1: v2.8(debug): NOTICE: BL1: Built : ... NOTICE: BL1: Booting BL2 NOTICE: BL2: v2.8(debug): NOTICE: BL2: Booting BL31 NOTICE: BL31: v2.8(debug): NOTICE: BL31: Preparing for EL3 exit to normal world

看到Preparing for EL3 exit to normal world就说明BL31已经成功把控制权交给U-Boot了。到这里整个ATF最基础的一条链路已经跑通。

QEMU还有一个好处是方便调试。我一般会在QEMU编译时加入-s -S参数,然后用GDB连接:

qemu-system-aarch64 -machine virt -cpu cortex-a57 -smp 4 -m 1024 \ -bios build/qemu/debug/bl1.bin -nographic -s -S

另开终端:

aarch64-linux-gnu-gdb build/qemu/debug/bl1.elf (gdb) target remote :1234 (gdb) continue

这样做可以看到BL1真正执行的汇编级指令流。对于研究ARM启动时期的行为,这种调试方式非常好用,特别是在最早期的启动代码里,打印函数都没初始化时,就只能靠断点观察寄存器状态。

4.3 真实SoC移植:必须实现的自定义平台代码清单

从QEMU换到真实SoC之后,就不能直接用现成的平台代码了。你需要按ATF的平台抽象层要求实现一套“平台代码”。这里我给出一份我在真实项目里验证过的最小实现清单,每一项都有对应源码路径,你照着这份清单去填自己SoC的细节即可。

首先是平台目录结构。在plat/下创建一个新目录,比如plat/mycompany/myboard/。然后在里面至少创建:platform.mk(构建配置)、plat_common.mk(平台宏定义和编译选项)、plat_def.h(内存地址、宏常量定义)、plat_sip_svc.c(可选,自定义SIP服务)、plat_pm.c(电源管理回调实现)、plat_setup.c(平台早期初始化和运行时初始化)。

编译配置的关键在于platform.mk,里面需要定义BL31_BASEBL31_LIMITBL2_BASEBL2_LIMITBL32_BASE(如果用OP-TEE)以及BL33_BASE。这几个地址的规划是平台移植的第一道关卡。我以ARM FVP平台做参考,BL31_BASE通常放在DDR地址空间的偏中间位置,留出足够空间给BL32和BL33。你要根据自己SoC的实际DDR映射和BootROM约定来定。

接下来必须实现的核心函数包括:

  • plat_get_next_bl_params:BL2在准备跳转BL31前,会调用它获取所有镜像的入口信息。这个函数要把BL31、BL32、BL33的入口地址、SPSR、参数全部填好。
  • plat_setup_drambl2_platform_setup:这是BL2阶段做DDR初始化和平台外设初始化的主阵地。DDR训练代码如果来自SoC厂家的库,就需要在这里封装。
  • plat_early_platform_setup:BL31阶段的早期初始化,必须在这个阶段配置UART(后续所有打印都依赖它)、配置GIC、配置系统计数器(system counter)。
  • plat_get_syscnt_freq:返回系统计数器频率,这个值会被PSCI和定时器框架用到。如果配错了,系统挂起/唤醒的时机都会乱掉。
  • plat_psci_ops:实现PSCI相关回调,比如cpu_standbycpu_oncpu_offsystem_offsystem_reset。如果你是首次移植且还没有电源管理硬件细节,可以先做成空调用,但至少要保证system_reset能触发看门狗复位,否则关机功能不可用。
  • plat_setup_topology:确认系统里有多少个CPU簇、多少核,这个直接决定了PSCI如何管理核的开关。
  • plat_get_rotpk_info:安全启动用,返回ROTPK公钥信息。开发阶段临时可以把MARVELLARM的示例密钥拿来,生产时再换成自己的密钥并烧录到OTP。

真实移植最痛苦的往往是串口初始化。ATF在BL31早期打印过程中需要UART已经可用,但你必须在没有任何日志的情况下先确保时钟、Pinmux配置正确。我的经验是先把SoC的参考手册中关于UART控制器和时钟树的部分吃透,在裸机上用一个最简单的串口helloworld程序验证UART通,再集成到ATF里。否则你会在“串口不打印”这个问题上卡很久,分不清是ATF代码问题还是硬件问题。

GIC(Generic Interrupt Controller)也在这一环节非常关键。在BL31阶段初始化GIC,主要是让它把安全中断和普通中断正确发给对应的异常级别上下文。ARM官方在drivers/arm/gic/v3/gicv3_main.c里提供了GICv3的驱动,平台代码主要做两件事:plat_arm_gic_init初始化GIC的distributor和redistributor,以及plat_arm_gic_redistif_on/plat_arm_gic_redistif_off处理核上下线时的redistributor开关。如果GIC配置不对,最常见的现象是BL31跳转到BL33后,内核启动到一半中断就“糊”了——控制台乱跳、调度崩溃或者直接panic。

内存映射需要认真核对plat_get_ns_image_entrypointplat_get_secure_partition_boot_info(如果使用FF-A)。BL31对非安全世界镜像入口地址的返回要和你实际加载BL33的地址一致,否则跳转后直接跑飞。这个函数在调试时极其容易出错,因为你在uboot load地址、ATF平台定义、FIP打包地址三个地方容易“三处不一致”。

4.4 移植调试技巧:没有printk的三板斧

真正做固件移植时,ATF的打印能力远不如Linux内核,尤其是早期阶段(BL1、BL2早期)串口还没有初始化,你根本看不到log。这时候要在没有printk的环境里定位崩溃点,就要靠三板斧。

第一板斧:用LED或GPIO指示运行点。在平台代码里,每个关键环节设置一个GPIO电平翻转。比如BL2进入DDR初始化前GPIO1拉高,DDR初始化完成后GPIO1拉低,BL31进入前GPIO2拉高。然后上电后用示波器观测GPIO波形,你就能确定代码跑到哪个阶段。这个办法土,但在BootROM阶段没日志时就是最可靠的。

第二板斧:用ATF自带的crash_reporting和寄存器转储功能。BL31支持在遇到异常时打印el3_panic信息,包含了异常类型、ESR、ELR、FAR寄存器值。ESR能告诉你是什么类型的异常:是未定义指令、还是数据访问异常、还是SMC不合法。ELR告诉你异常发生在哪个地址。有了这两个,配合addr2line工具就能定位到C源码行号,这是最常用的手段。

aarch64-linux-gnu-addr2line -e build/qemu/debug/bl31/bl31.elf 0x400xxxx

第三板斧:QEMU的-d in_asm-d int选项可以逐条打印执行的汇编指令和异常信息。在QEMU上先把ATF的数据流跑顺,再到真板子上调试,能省一半的瞎猜时间。

5. 常见问题与排查技巧实录:移植路上的高频深坑

5.1 启动卡在BL1阶段完全无日志

现象表现为上电后接上串口没有任何输出,连BL1的版本信息都没有。这种情况有两种主要可能:BL1根本没被执行、或者串口初始化失败。

BL1没被执行最常见的原因是SoC的BootROM根本没有找到BL1镜像所放置的地址——可能BootROM约定的启动介质和你在编译时选择的存储地址不一致。我遇到过的一个案例是,SoC手册说启动介质是eMMC,但实际BootROM还要求eMMC里有一个特定的启动头(boot header),而我直接烧裸BL1镜像进去,BootROM找不到有效头部直接不做任何动作。

另外一类就是串口初始化失败。BL1的bl1_early_platform_setup会调用console_init,这里面要配置UART的时钟、波特率、Pinmux。如果这些配置和你的SoC硬件不一致,输出的会全是乱码或者什么都不输出。我建议在早期调试里先确认串口的物理通路:用一个已知工作正常的U-Boot镜像打印一串字节,确认硬件链路没有问题,再烧ATF。

5.2 BL2阶段报验证失败或断言失败

如果在BL2阶段遇到ERROR: Authenticate BL31 failed或类似的报错,通常不是因为你改坏了BL31,而是证书链和FIP不匹配。

典型场景:你修改了BL31代码后,只重新编译了BL31镜像、重新打包了FIP,但是没有重新生成BL31的证书。cert_create生成的证书是对旧BL31镜像的哈希签名,你把新镜像放进证书验证时,哈希对不上就必然失败。解决办法是每次重新编译后续镜像后,都要重新跑一遍cert_createfiptool的完整流程。开发阶段关掉TBBR就不会有这种问题,但生产固件必须维持签名链的时序。

断言失败更常见于平台初始化代码里。比如assert检查某个寄存器的值是否是预期值,但你的SoC硬件状态和预期不同。这种错误一般可以在串口上直接看到assert的确切位置,直接拉源码对应行排查就行。这里我的经验是:在移植初期不要随便挪动ATF自带的断言代码,那些断言是官方用来约束平台实现正确性的,等你跑通基础链路后再按需裁剪。

5.3 BL31跳转BL33后U-Boot完全跑不起来

这是我最常遇到的移植bugs区。它表现很多样:U-Boot根本没有输出、U-Boot启动到一半挂起、U-Boot能起来但Linux启动崩溃。我总结了几个特别容易忽视的原因。

首先是BL33的入口地址与平台代码里配置的地址不一致。plat_get_ns_image_entrypoint返回的地址和存储介质中BL33实际加载到的地址必须一致,否则跳到错误的地址就是随机执行。

其次是SPSR设置不当。BL31跳转BL33时,SPSR里保存着要跳转到的“目标异常级别”。如果你希望直接跳到U-Boot的EL2(虚拟化环境),SPSR要设置成SPSR_EL2;如果跳到EL1,则设置成SPSR_EL1。如果SPSR里设置的异常级别和U-Boot编译时的运行级别不一致,会导致U-Boot入口进入错误的状态,最常见的现象是启动几行之后立即报异常或者死循环。

第三个原因是cache和MMU状态问题。BL31在跳转BL33之前,有责任把不必要的cache清掉。ATF用write_ctr_el0dcache_op_all等逻辑来处理。如果这部分在你的平台代码中被不小心删掉了,BL33入口时CPU cache的状态是脏的,那就会随机读错数据,表现为程序行为完全不确定。这里我最推荐的debug方法是在BL31跳转点之前增加一个打印NOTICE: entry point = 0x...,确认一个地址确实符合预期,再判断是不是cache的问题。

5.4 启动时TZASC配置不生效导致安全内存被踩

真实SoC上TZASC的配置是引发疑难杂症的重灾区。我上面提到过的一个案例:OP-TEE能正常加载,但Linux跑一跑就崩,整个系统hang住。最终定位是TZASC没有把OP-TEE的内存区域标记为Secure。解决办法就是根据SoC手册重新设置TZASC的region配置。

调试这个问题的技巧是:在BL31初始化完成后,用GDB连上实时内存视图,去看看OP-TEE镜像在内存里的数据是否被Linux修改过。如果数据变更了,几乎可以锁定是非安全世界DMA或CPU访问踩入了安全区域,而TZASC没有阻止,那就要查TZASC配置了。

5.5 关掉调试打印后System hangs但开着Debug一切正常

这类神秘问题通常是时序问题。ATF源码里大量的调试打印会改变函数执行的时序,如果代码里有对硬件寄存器轮询超时逻辑,那么在Debug模式下超时阈值可能还能勉强撑过,Release下打印变少,函数跑得更快,某些硬件外设还来不及准备好,就已经被轮询踩过去了。这种bug非常难复现,解决思路是在Release模式下增加相应的延迟或等待机制,而不是把Debug日志一直留着。

经验上来讲,遇到“Debug好了Release崩”这种问题,先检查所有GPIO外设、DDR、PLL的初始化代码里是否有明确的等待完成位(polling done bit),没有的话就补上等待逻辑。很多时候是硬件初始化的“完成判定条件”写得不严谨,比如等了一个不该等的位,或者没等一个必须等的位。

6. 我在源码级审计和移植实践中的几条深度心得

走到这一步,ATF的架构、源码、移植和调试链路已经基本完整了。最后分享几条我在过程中沉淀下来的个人经验,它们不会写进任何官方文档,但对于实际做工程的人来说价值很高。

第一条是“永远不要试图在EL3里做太多事情”。EL3代码的特权和风险是对等的,你在这里写得越多,安全攻击面就越大。ATF的哲学是让各层职责专一、生命周期短。如果你发现你的BL31代码里开始出现复杂的业务逻辑,你需要停下来重新评估是否应该把这个逻辑下放到OP-TEE或者SIP Service里由安全世界处理。

第二条是“信任链的调试要从信任根开始逐级排查”。当安全启动不起作用时,不要直接从BL33往上排查,那是最低效的方式。先确认OTP里的ROTPK Hash是否和你生成的key匹配,再逐级验证BL2、BL31、BL32、BL33的签名。我总结了一个排查顺序:OTP -> ROTPK -> BL2证书 -> BL2镜像 -> BL31证书 -> BL31镜像 -> BL33证书 -> BL33镜像。每级之间都有一条清晰的验签链,绝不能跨级跳。

第三条是关于开发的版本管理。ATF的代码迭代非常快,而且不同SoC平台对ATF版本的兼容性差异很大。在进入量产阶段前,一定要锁定一个ATF版本(比如lts-v2.8),并把你对平台代码的所有改动打成一个独立的patch系列,应用在固定的tag上。不要动不动就升级ATF版本,那会给你带来巨大的回归测试成本。

第四点也是最后一点,是工具链验证。ATF对编译优化级别敏感,不同GCC版本、不同优化选项可能导致启动行为变化。如果你从DEBUG=1切到DEBUG=0后出现莫名其妙的崩溃,不要总怀疑代码逻辑,先用不同的优化级别(-O0到-O2)交叉测试,同时用addr2line对应上崩溃地址的源码位置。这能帮你快速定位是优化器把某些代码优化掉了、还是代码本身存在未定义行为。安全固件里对未定义行为是零容忍的,因为它会导致不可预测的启动结果,比普通应用层的后果严重得多。

整个ATF的咀嚼过程里,我最深的感受是:这不仅仅是一份固件源码,它代表了一整套安全启动的工程哲学。读懂ATF,你不只是学会了如何让U-Boot跑起来,而是理解了一条从硬件信任根到操作系统启动的完整安全链条如何构建、如何审计、如何维护。希望这篇文章能帮你在ATF的学习和移植之路上少踩几个坑,把更多精力放在真正需要你去设计、优化和改进的地方。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询