做底层固件这几年,ARM体系里的安全启动和运行时固件一直是最“劝退”又最值得啃的一块。很多人U-Boot玩得很溜,设备树、内核启动也调得飞起,但一见到Arm Trusted Firmware——圈里习惯叫ATF——就有点发怵:BL1、BL2、BL31、BL32、BL33这些名字到底对应哪一段?为什么它在安全启动里是不可替代的那根轴?换一块新板子,平台移植到底要从哪里下手?
这篇文章我想从源码架构全景讲起,把ATF在整个系统中的位置、安全固件工程审计的几个核心维度,以及平台移植落地的实际操作一次讲透。内容比较适合正在做SoC bringup的工程师、接触过TrustZone但还没系统看过ATF的朋友,以及准备在自己的板卡上接入OP-TEE或安全启动的团队参考。我会尽量少讲空泛概念,多给能直接用的判断方法和代码路径。
1. 先理解ATF在系统里的位置
1.1 ARMv8异常模型与EL3的“特权天花板”
要理解ATF,绕不开ARMv8的异常模型。AArch64模式下,CPU有四个异常级别:EL0跑应用,EL1跑操作系统内核,EL2跑虚拟化或U-Boot这类引导程序,EL3是最高特权级别,也是Secure world和Normal world之间的切换点。
普通系统的启动路径往往是BootROM -> BL1 -> BL2 -> BL31 -> U-Boot -> Kernel,其中BL31之后,ATF的runtime firmware会常驻在EL3,U-Boot和Kernel都降级到EL2/EL1去跑。这样设计的原因很直接:EL3像大厦的总保安室,所有楼层之间的权限切换必须从这里过,而U-Boot和Kernel本身没有资格管理EL3。
很多初学者问:为什么不能用U-Boot做安全启动?因为U-Boot通常跑在EL2甚至EL1,它没有权限去配置TrustZone地址空间控制器,也没办法安全地切换Secure world和Normal world上下文。ATF天生就是为EL3设计的,所以哪怕是只做最基本的安全启动,ATF也都是绕不开的底座。
1.2 ATF解决的核心问题
ATF全称Arm Trusted Firmware,现在开源仓库一般叫trustedfirmware-a,简称TF-A。它的核心使命可以概括成四件事:
- 建立从SoC上电到正常世界引导程序之间的可信启动链,也就是Trusted Boot。
- 提供BL31常驻运行时服务,包括PSCI电源管理、SMC调用分发、系统复位与关机。
- 为Trusted OS(比如OP-TEE)提供EL3之下的隔离执行环境入口。
- 充当Secure world与Normal world之间的消息网关,所有跨世界调用都要经过EL3。
这四件事听着抽象,实际上一块开发板上电后,CPU先执行固化在BootROM里的代码,然后经过BL1、BL2一级级校验信任,最终把控制权交给BL31,BL31再拉起BL32(如果有TEE)和BL33(通常是U-Boot)。整个过程就像机场安检链条:BootROM是最初的闸机,ATF是贯穿全程的安检流程,U-Boot和Kernel都只是被检查通过的旅客。
1.3 这篇文章的阅读路线
我建议你按这样的顺序读:先看第二个章节,把BL1到BL33的启动链条搞清楚;然后进入第三个章节,从安全审计的角度理解ATF为什么安全;最后才是第四个章节的平台移植。移植部分我把步骤拆得比较细,就算你手上的板卡和示例平台差异很大,改动的思路也是共通的。
如果你完全是新手,建议先压下移植的冲动,在QEMU或者FVP上把ATF编译跑一遍,再回头读源码。这个过程会帮你省掉后面大量调试时间。
2. 源码目录里的启动链条:BL1到BL33各管一段
2.1 源码仓库结构与版本选择
TF-A的源码根目录下有bl1、bl2、bl31、bl32这几个核心代码目录,注意没有bl33目录,因为BL33属于正常世界引导程序,通常是U-Boot或者UEFI,ATF只负责把它加载起来。除此之外还有plat、drivers、lib、include、tools等目录,plat放平台定制代码,drivers放串口、GIC、TZASC等驱动抽象,lib里有MMU表、缓存、内存管理这类公共库。
版本选择上,我建议优先用维护中的LTS版本。ATF主线迭代很快,新的平台支持、安全补丁几乎每个月都在变。直接拉master虽然能体验到最新特性,但API变化也容易踩坑。v2.9、v2.10这些LTS版本经过大量平台验证,拿来移植和生产最稳妥。
2.2 五个BL阶段的职责边界
ATF把启动过程拆成多个阶段,每个阶段的可信度依赖上一阶段的验证,形成一条链。下面这个表格把各阶段职责整理得很清楚:
| 阶段 | 运行位置 | 主要职责 | 典型实现 |
|---|---|---|---|
| BL1 | BootROM内或片上SRAM | 最小信任根,初始化CPU,验证并加载BL2 | AP Trusted ROM |
| BL2 | 片上SRAM或DDR安全区 | 可信启动加载器,验证并加载BL31、BL32、BL33 | Trusted Boot Firmware |
| BL31 | 安全RAM | 常驻EL3运行时固件,提供PSCI、SMC等服务 | Runtime Firmware |
| BL32 | 安全DDR或TZRAM | 可选的Trusted OS,比如OP-TEE | Trusted OS |
| BL33 | 普通DDR | 正常世界引导程序,通常是U-Boot或UEFI | Normal World Bootloader |
BL1通常固化在SoC内部,芯片出厂就有,你没法改。你真正能改的是BL2、BL31以及BL32/BL33的集成方式。BL2有点像机场地勤,它把关卡之间的行李全部核对一遍;BL31则像总机台,启动完成后所有跨世界电话都得从这里转接。
2.3 FIP镜像格式与烧写布局
ATF的镜像打包方式是FIP,全称Firmware Image Package。FIP内部有一个TOC结构,每段镜像都有类型、版本、偏移量等信息,由fiptool生成和解析。
典型打包命令长这样:
fiptool create \ --tb-fw build/<plat>/release/bl2.bin \ --soc-fw build/<plat>/release/bl31.bin \ --tos-fw build/<plat>/release/bl32.bin \ --nt-fw u-boot.bin \ fip.bin烧写时,BootROM先加载FIP,BL1会通过平台定义的存储介质驱动找到FIP中的BL2镜像,然后按TOC偏移去拉取。开发阶段很多人喜欢把BL2直接丢到SRAM里,绕过FIP解析,这种方式调试快,但量产时必须走完整FIP和验签流程。
2.4 信任链:安全引导如何一级级验签
安全启动的核心是“信任根”,也就是RoT。ATF的默认信任链是:BL1内嵌一个公钥或公钥哈希,它用这把公钥去验BL2的证书签名;BL2再用自己证书里携带的下一级公钥,去验BL31、BL32和BL33的证书。
这条链上的每个环节都必须保证前一个环节绝对可信。如果RoT泄露,整条链就崩溃了。所以在安全固件工程审计中,我第一个会看的就是BL1公钥的存储方式和BL2验签失败后的处理逻辑,如果失败后还允许继续启动,那这个产品就谈不上安全固件。
开发阶段可以把TRUSTED_BOARD_BOOT设为0跳过验签,量产必须置1。真机上开启后每次启动都要做证书解析、哈希校验和RSA验签,通常会增加几百毫秒到一秒级别的启动时间,这点需要提前给产品团队打预防针。
3. 安全固件工程审计:从哪几个维度看代码质量
3.1 异常级别与权限边界审计
ATF最敏感的部分是EL3与EL1/EL2之间的边界。审计时我通常会顺着异常入口捋一遍,重点关注以下几个方面:
- 所有SMC入口是否校验了调用方当前的异常级别和安全状态。
- 接收Normal world传来的指针参数时,是否验证地址范围不在安全内存区间。
- BL31在处理唤醒、复位等事件时,是否先恢复安全上下文再切回Normal world。
有一次我在客户代码里发现,他们为了让某个调试命令能操作寄存器,直接放开了BL31中一个SMC handler的访问范围,任何EL1代码都能触发。这种问题通过静态代码扫描很难发现,必须在评审时盯着“入口参数校验”这个点反复问:如果这是一个恶意程序调用,会发生什么。
3.2 SMC调用约定与处理路径
ATF的运行时代理由SMC指令触发。AArch64下执行SMC会陷入EL3,ATF根据SMC函数ID分发到对应service。SMC调用约定(SMCCC)定义了函数ID的格式,包括调用类型、是否快速调用、调用者安全状态等。
ATF里每个service会注册自己的handler,比如标准服务、PSCI服务、OP-TEE服务。审计时我会重点看handler对函数ID的解析逻辑,尤其是通配或范围判断的地方。如果某个函数ID前缀判断写得太宽,Normal world可能调用到Secure world的调试接口。
举一个常见反面例子:某些平台的SMC handler用(func_id & 0xFF) == 0x01当作“安全服务标识”,结果把厂商自定义的高位前缀也匹配进去了。这类问题在代码走查时很容易被忽略,但危害极大。
3.3 内存隔离与MMU映射审计
ATF在EL3启用了自带的MMU管理库xlat_tables,它负责把安全RAM、外设寄存器、DDR区域映射到EL3的页表中。审计时我关注的是页表属性:
- 安全内存区域是否为EL3可读写,且不允许Normal world映射。
- 外设寄存器是否配置为Device memory,避免乱序访问。
- 页表本身放在哪里,如果页表可被Normal world改写,那MMU保护形同虚设。
此外,真正的内存总线隔离不能只靠MMU。TrustZone地址空间控制器(TZASC)需要在BL31启动早期配置,把DDR按区域分成Secure和Non-Secure。ATF只负责配置软件页表,TZASC寄存器还是得平台代码在bl31_platform_setup里完成。审计时我会同时检查这两层,少了哪一层都会出现内存越权风险。
3.4 密钥、证书与mbedTLS集成
ATF的验签能力通过集成mbedTLS库来实现。编译时如果开启了Trusted Boot,就需要指定MBEDTLS目录,ATF会用它做X.509证书解析和RSA验签。
证书链的生成由tools/cert_create完成,通过GENERATE_COT=1和ROT_KEY_PATH等选项指定密钥路径。审计时我会确认这几个点:
- ROT私钥是否只存在于构建机,有没有被提交到代码仓库。
- 证书中的公钥哈希是否和BL1内嵌的RoT一致。
- 验签流程是否对证书“有效期”做了检查。X.509证书带有效期,有些开发板会把有效期设置得太短,量产一段时间后固件无法过验,最后还得重新签发证书并升级。
值得提醒的是,ATF从较新版本开始支持多根信任链(dualroot CoT),允许一个板卡同时信任两套根密钥。这种方式适合复杂的供应链场景,但对普通产品来说,单根信任链已经足够,多根反而会增加密钥管理的复杂度。
4. 平台移植落地:从零添加一个新板卡
4.1 先搭起最小平台目录
ATF的移植单位是“平台”,所有平台代码放在plat/<厂商>/<板级>目录下。一个最小的平台通常需要如下几个文件:
plat/example_company/example_board/ ├── platform.mk ├── include/platform_def.h ├── plat_common.c ├── plat_setup.c ├── plat_helpers.S └── bl31_plat_setup.cplatform.mk负责告诉编译系统这个平台需要哪些源文件、链接脚本、编译选项。platform_def.h存放地址和尺寸宏,比如安全RAM基地址、BL33加载地址、GIC基地址、串口基地址等。plat_setup.c实现早期初始化,bl31_plat_setup.c实现BL31运行时的平台初始化。
我第一次移植时犯过一个错误:故步自封地照抄FVP的platform_def.h,结果板子的串口地址和GIC地址完全不同,ATF连早期打印都出不来。所以拿到一块新板卡,第一件事是打开对应SoC的TRM手册,把外设基地址列成一张表,再填写platform_def.h。
4.2 最关键的几个平台接口
BL31启动过程中,平台层必须提供若干接口。常见的有:
bl31_early_platform_setup2:早期初始化,包括串口、GIC、内存信息。bl31_platform_setup:BL31执行期间调用,配置TZASC、PSCI等。bl31_plat_get_next_bl_params:返回下一个要启动镜像的入口参数。plat_get_system_counter_freq:返回系统计数器频率。
这里给出一段示意代码,展示如何设置BL33的入口参数:
entry_point_info_t *bl31_plat_get_next_bl_params(void) { entry_point_info_t *next_image_info; next_image_info = bl31_plat_get_next_bl_params_common(); next_image_info->pc = BL33_BASE; next_image_info->spsr = SPSR_64(MODE_EL2, MODE_SP_ELX); next_image_info->args.arg0 = DTB_BASE; return next_image_info; }注意BL33_BASE和DTB_BASE都要和U-Boot的实际链接地址、内核加载地址匹配。如果U-Boot编译时链接在0x60000000,但ATF把它加载到0x70000000,大概率会直接跑飞。
4.3 调试三件套:串口、GIC、计数器
平台bringup刚开始,最重要的不是安全特性,而是能看见输出。串口是最先要调通的模块。在BL2和BL31的早期初始化里,必须调用串口驱动做初始化。ATF对串口驱动的抽象已经做得比较通用,你只需要把基地址、波特率、时钟频率填对。
GIC其次重要,因为PSCI的CPU开关和中断路由都依赖GIC。ATF要求GIC在BL31阶段初始化,并把安全中断配到Group 0,把普通中断留给Non-secure世界。
系统计数器频率容易被忽略,但它影响PSCI挂起和唤醒的时间计算。常见做法是在platform_def.h里定义PLAT_SYSCNT_FREQ,这个值必须是SoC的系统计数器实际时钟频率,不是CPU主频。
调试时建议在BL1、BL2、BL31的每个阶段出入口都加一行打印。ATF的打印函数是tf_printf,用起来和printf一样方便:
NOTICE("BL31: platform setup start\n");4.4 PSCI与CPU唤醒的实现
PSCI是ATF提供的最核心电源管理服务。平台层要填充psci_ops结构体,实现cpu_on、cpu_off、system_off、system_reset等回调。对于多核平台,cpu_on的实现通常是在目标CPU的mailbox地址写入要执行的入口函数,然后通过sev指令唤醒它。
这里我踩过一个很深的坑:mailbox内存需要保证缓存一致性。在写入唤醒地址后,一定要执行缓存操作和内存屏障,否则目标CPU读到的可能是旧值或乱序值,导致唤醒后跑飞到未知地址。ATF内部提供了flush_dcache_range接口,写mailbox后记得调用。
4.5 接入OP-TEE:BL32怎么加进来
如果产品需要Trusted OS,编译时需要把OP-TEE的镜像作为BL32集成进来。常见做法是在platform.mk里定义BL32_BINARY和BL32_SOURCE路径,并在FIP打包时用--tos-fw参数添加BL32镜像。
BL32需要一段安全DRAM区域,具体地址由SoC的TrustZone地址空间控制器决定。平台代码要在BL31阶段把这一段DDR配置为Secure,然后在bl31_plat_get_next_bl_params里把BL32入口参数传给BL31。OP-TEE启动后,ATF会通过标准SMC接口与它协作,完成安全世界上下文的切换。
4.6 编译、运行与烧录
编译命令以我常用的一个平台为例:
make PLAT=example_board \ CROSS_COMPILE=aarch64-none-elf- \ BL33=../u-boot/u-boot.bin \ BL32=../optee/tee.bin \ DEBUG=1 \ V=1生成的关键产物包括bl1.bin、bl2.bin、bl31.bin、bl32.bin、fip.bin。烧写时,BL1通常由SoC出厂BootROM加载,我们需要把fip.bin烧到平台指定的存储偏移位置。如果使用QEMU验证,可以用-bios bl1.bin之类的参数,具体取决于QEMU版本。
我先强调一点:移植ATF的过程中,真正花时间的是调地址、调GIC、调缓存一致性,而不是写代码。ATF平台接口已经封装得很稳定,大部分时间其实在看TRM。
5. 常见问题与排查技巧实录
5.1 串口没输出:怎么定位是哪个阶段挂的
我遇到最多的情况是BL31以后没有日志。这种问题第一步不是看代码,而是确认日志是“完全没有”还是“BL31以前有,之后没了”。如果完全没输出,先查BL1/BL2阶段的串口基地址初始化;如果BL31以前有、之后没了,通常是BL31代码在早期setup阶段崩溃,可能原因是GIC配置错误、页表映射缺失或者链接脚本内存布局不对。
调试这类问题,我习惯先把ATF的DEBUG级别打开,再用tf_printf逐行标记关键函数出入口。串口不输出时,还要检查是不是用了错误的console驱动。ATF的驱动是模块化的,不同SoC的串口外设寄存器差异很大,选错驱动即使基地址对,也什么都打印不出来。
5.2 编译报错:工具链、mbedTLS、宏定义问题
ATF对工具链版本比较敏感,建议使用官方发布的AArch64裸机工具链,而不是某些发行版自带的旧版交叉编译器。常见问题整理成一张表:
| 报错现象 | 常见原因 | 解决方法 |
|---|---|---|
aarch64-none-elf-gcc: command not found | 工具链未安装或未加入PATH | 安装官方AArch64工具链,检查环境变量 |
MBEDTLS_DIR not set | 启用了Trusted Boot但没指定mbedTLS路径 | 下载mbedTLS源码,编译时指定MBEDTLS_DIR=<path> |
undefined reference to plat_get_xxx | 平台接口未实现或函数名拼写错误 | 对照TF-A平台API列表逐个检查 |
section.bss' is not within region` | 链接脚本中RAM区域过小 | 调整platform_def.h中的安全内存尺寸定义 |
PLAT_XLAT_TABLE_SIZE too small | 页表空间不够,映射区域过多 | 增大PLAT_XLAT_TABLE_SIZE宏 |
编译问题相对好解决,把报错定位到具体文件后,基本都是配置或接口缺失问题。
5.3 卡在BL31,进不了U-Boot
如果BL31启动后一直没有跳转到U-Boot,优先检查这几个点:
- FIP里有没有打包BL33镜像。有时你改了BL33路径但没重新打包FIP,系统里跑的还是旧FIP。
- BL33入口地址和U-Boot实际链接地址是否一致。不一致时通常会在U-Boot非常早期的代码里跑飞。
- DTB地址是否落在U-Boot期望的位置。如果U-Boot在启动后要从固定地址读取设备树,这个地址错了,内核就没法启动。
- 有没有配置GIC的Group 0中断路由。有些平台需要把安全定时器中断配置正确,否则BL31会一直卡在中断等待里。
排查时我会先在bl31_plat_get_next_bl_params入口和出口各打一行,确认ATF确实把下一个镜像参数传出去了,再去看U-Boot侧有没有打印。
5.4 开启安全启动后启动失败
开发阶段跳过验签一切正常,一旦把TRUSTED_BOARD_BOOT置1,启动就卡住,多半是证书链生成的问题。检查顺序如下:
- 有没有生成
rot_key.pem,并把它放到正确路径。 - 证书中的公钥哈希是否和BL1内嵌的RoT匹配。
- mbedTLS版本是否与ATF要求匹配。
- 证书有效期是否合理。
我见过一个比较隐蔽的问题:构建机系统时间不准,签出来的证书直接过期。开Trusted Boot以后,ATF在验签时发现证书无效,就会拒绝启动。排查半天,最后发现是构建环境的NTP没同步。这种细节虽然在代码审计里不容易发现,但一旦遇到就是批量性的。
5.5 调试工具和日志推荐
除了tf_printf,ATF还支持用JTAG对EL3进行调试。有条件的话,我强烈建议在开发板上预留JTAG/SWD口,配合ARM Development Studio或OpenOCD直接在BL31断点调试。没有JTAG时,用串口打印加GPIO翻转的方法也能定位大多数问题。做法很简单:在怀疑出问题的函数前后分别拉高拉低一个GPIO,用示波器看GPIO变化,迅速判断程序执行到哪里。
我在实际移植中最深的一点体会是:把ATF当成“一个跑在EL3的小型操作系统”来对待,而不是单纯的“引导代码”。它有自己的页表管理、运行时服务注册表、电源管理框架和系统调用分发路径。顺着这个思路去读代码、加接口、做移植,会比把它当成一堆bl2/bl31文件拼拼凑凑要顺得多。第一次在自己定制的板卡上看到BL31成功跳到U-Boot时,那一刻的成就感,应该就是做底层固件最值得回味的时刻了。