1. 架构全景:先搞清楚ATF在整个系统中到底是什么
做嵌入式或者系统级开发的朋友,对ARM TrustZone应该不陌生,但很多人对ATF(Arm Trusted Firmware)的印象是“知道它很重要,但不知道它具体干了什么”。我最早接触ATF是在做飞腾平台适配的时候,当时拿到一块板子,SDK里躺着一堆BL1、BL2、BL31的二进制,串口打印停在某个位置就起不来了,查来查去最后发现是ATF的某个配置没对齐。从那以后我就意识到,不管你是做安全启动、做虚拟化,还是单纯想把Linux在ARM64平台上跑起来,ATF都是绕不开的一环。
ATF是ARM官方维护的一套开源安全固件,运行在EL3异常级别,是系统上电后最早执行的可信代码之一。它的核心使命可以概括成三件事:建立可信启动链、提供安全运行时服务、管理安全世界和普通世界之间的切换。换句话说,ATF是TrustZone生态的软件基石,它的可信程度直接决定了整台设备的安全基线。如果说u-boot是Linux的前导,那么ATF就是整个系统“安全世界观”的前导。
这篇文章我打算从源码角度把ATF的架构拆开,结合我实际做平台移植和安全审计的经验,讲清楚它的分层设计、核心模块、移植路径,以及我踩过的那些坑。内容适合三类人看:一是准备在新ARM平台上适配ATF的BSP工程师,二是做固件安全评估或等保合规的测试人员,三是对ARM生态好奇、想深入理解安全固件原理的底层爱好者。读完你至少能回答这几个问题:ATF为什么非要存在、它的四个BL各管什么、新平台移植到底要动哪些文件、以及拿到一份陌生固件该怎么审。
2. 四个BL到底在干什么
2.1 从BL1到BL33,一条完整的上电可信链
ATF把启动过程切成了几个阶段,每个阶段都运行在特定的异常级别,并且由前一个阶段验证后一个阶段的镜像。这串“链式信任”是ATF设计里最核心的思想。整体来看是这样的:
- BL1:Boot ROM里的第一段代码,由芯片出厂固化,不可篡改。它做最基本的硬件初始化(比如把时钟和串口拉起来),然后加载并验证BL2。
- BL2:运行在EL1安全世界,负责加载后续所有镜像。它会读平台定义的TOS_FW_CONFIG、HW_CONFIG这些配置,把BL31、BL32(如果有TEE)、BL33(通常是u-boot或UEFI)一个个验证后搬进内存。
- BL31:运行在EL3的运行时固件,启动完成后常驻内存,负责管理安全世界和普通世界之间的切换。SMC指令的入口就在这。
- BL32:可选的TEE OS,比如OP-TEE,运行在S-EL1。没有TEE的话,BL32可以跳过。
- BL33:非安全世界的引导程序,常见的是U-Boot或者UEFI,最终拉起Linux内核。
我第一次看这个流程的时候有个疑问:BL1为什么不能把BL33也一起加载了?非要绕这么大一圈。后来想明白了,芯片出厂时Boot ROM是固定的,如果所有逻辑都塞在BL1里,后续想改启动流程、换TEE、调内存布局就得改芯片掩膜,代价完全不可接受。所以ARM把BL1做得极简,后面的逻辑全交给可升级的BL2和BL31。这个“固定最小、可变最大”的设计,保证了既有出厂信任根,又有后续迭代的灵活性。
2.2 BL31的真正身份:EL3里的“操作系统”
很多人把BL31当成一个引导程序,这是个误解。BL31启动完成后不会退出,它会一直驻留在EL3,像一个微型操作系统那样管理异常级别的切换。所有从普通世界发起的SMC调用,都会陷入EL3,由BL31路由到对应的服务模块。
BL31内部有几个核心组件:
- 上下文管理(Context Management):保存和恢复安全世界/普通世界的CPU寄存器状态,这是世界切换的基础。
- 运行时服务(Runtime Services):包括PSCI(电源状态协调接口)、SDEI(软件触发的事件接口)、标准服务、以及平台自定义服务。
- 中断管理(Interrupt Management):处理安全中断和普通中断的路由策略,比如FIQ路由到EL3,IRQ路由到普通世界。
- 异常处理(Exception Handling):覆盖同步异常、SError等场景。
其中PSCI是最常用的接口。你在u-boot里执行cpu reset、system off,内核里用cpuidle进入低功耗状态,底层都是通过SMC指令调用PSCI服务,最终由BL31来执行电源状态的切换。我调试低功耗问题的时候,经常需要在BL31的psci_cpu_suspend里打断点,确认到底是哪个核在什么时候进入了什么状态。
2.3 从概念到代码:ATF源码里的目录结构
如果你把ATF源码克隆下来,会发现顶层结构非常清晰:
bl1/ bl2/ bl2u/ bl31/ plat/ common/ drivers/ lib/ services/ tools/ include/bl1、bl2、bl31目录是各阶段的入口代码;plat目录是平台相关的抽象层,每个芯片平台在这里都有一个子目录;services目录包含PSCI、SDEI、SPMD等运行时服务;tools目录里有生成FIP镜像、生成证书、签名等工具。
我建议初学者先看两个文件:docs/design/firmware-design.rst(设计文档)和plat/arm/board/fvp/(ARM官方虚拟平台的实现)。前者讲清了整体设计思路,后者是一个麻雀虽小五脏俱全的参考实现。把FVP这个平台的代码读懂一半,再看其他商业平台,思路会顺畅很多。
3. 源码工程审计:深入ATF可信启动与安全服务
3.1 可信启动链路的核心验证逻辑
ATF的可信启动不是靠一句“代码在EL3运行所以安全”的口号,而是实打实的一级验一级。BL1验证BL2,BL2验证BL3x镜像,验证手段包括数字签名、哈希比对、以及可选的身份认证。ATG的源码里这套逻辑主要落在drivers/auth/目录下,抽象出了认证框架。
整个认证流程的核心数据结构是auth_method和auth_param。每个镜像会声明自己的认证方式,比如AUTH_METHOD_SIGN表示需要验证签名,AUTH_METHOD_HASH表示只需要比对哈希。认证过程中会用到一个叫做“证书”的东西,证书本身不复杂,就是一组被签名的参数集合,里面包含镜像的公钥、哈希值、加载地址等信息。
安全启动落地时有个容易被忽略的点:信任根存哪。ATF源码中BL1的ROT_KEY(根密钥)通常从OTP(一次性可编程存储)读取,或者由平台自己实现plat_get_rot_key_info接口。我在实际项目里见过把根密钥硬编码在源码里的做法,这在DEMO板上可以理解,但量产设备这么干,等于把保险箱钥匙挂在门外。正确的做法是把公钥哈希烧进OTP,并且提供防回滚的版本计数器。
3.2 关键数据结构:context、entry_point_info与smc
做审计和移植时,有几个结构体你必须熟得像自己的手指。
第一个是entry_point_info_t。它描述一个镜像的入口信息,包括入口地址pc、SPSR、以及要传给下一个阶段的参数arg0~arg3。BL1加载BL2时,会先把BL2的入口信息填成这个结构体。bl31_entrypoint也好,bl33的跳转也好,本质上都是围绕这个结构体做文章。
第二个是cpu_context_t。它保存CPU在某个世界运行时的完整寄存器现场。BL31在切换世界之前会把当前现场压栈保存,等切换回来时再恢复。这个现场切换代码在bl31/aarch64/context_mgmt.c里,如果你要做混合虚拟化或者自定义安全服务,经常会跟它打交道。
第三个是smc调用的传参规约。函数ID放在x0寄存器,参数放在x1~x6,返回值也走x0。x0的高16位代表服务类型,比如0x84000000开头的通常是ARM标准服务(PSCI),0x82000000开头的也有类似用途,0x86000000是SDEI,0xC4000000是64位标准服务。平台自定义服务一般用0x82000000以外的厂商区间。
3.3 编译模型:CBn的“魔法”与FIP镜像生成
ATF的构建系统基于Makefile,但它不像普通Linux内核那种简单的递归make,而是用了一套名为build-system的include模型。每个BL的源码列表通过BL_SOURCES变量声明,平台通过PLAT_BL_COMMON_SOURCES和BL31_SOURCES来追加自己的文件。
我见过不少新手在这栽跟头:明明在plat/<platform>目录下加了新文件,编译却没编进去。原因几乎都是没把文件名加进对应Makefile的源码列表里。这是ATF和普通CMake工程最大的不一样,它没有任何“自动扫描目录添加源文件”的机制,一切都要显式声明。
编译完成后,ATF会把BL1、BL2、BL31以及可选的BL32、BL33打包成一个.fip文件,用fiptool生成。FIP镜像本质上就是一个固定格式的容器,里面包含若干镜像条目,各有UUID标识。烧录时把FIP放到指定的存储位置,BL1会按UUID找到BL2,BL2再加载后续镜像。
# 构建ATF(以FVP平台为例) make PLAT=fvp DEBUG=1 BL33=../u-boot/u-boot.bin all fip # 打包FIP镜像 tools/fiptool/fiptool create --tb-fw build/fvp/debug/bl2.bin \ --soc-fw build/fvp/debug/bl31.bin \ --nt-fw ../u-boot/u-boot.bin fip.bin这套构建链路看起来简单,实际平台适配时的坑点全藏在PLAT_*宏和链接脚本里。比如ARM_LINUX_KERNEL_AS_BL33这类选项,选错了镜像入口就会异常。
4. 平台移植:从零把ATF跑起来的关键路径
4.1 新平台移植前先想清楚的几件事
平台移植是ATF使用场景里最硬核的一环。我接到过不少咨询,大多是“我们换了一颗新的SoC,ATF怎么跑起来”。我的建议是动手前先梳理清楚四件事:
第一,工具链选型。ATF对编译工具链有要求,64位平台通常用aarch64-none-elf-或aarch64-linux-gnu-前缀的工具链。我推荐用ARM官方维护的AArch64 GNU toolchain,版本不要选太老的,我遇到过gcc 9编译ATF 2.8以上的版本时出现链接器告警。32位平台(ARMv7)一般用arm-none-eabi-工具链,注意别混淆。
第二,启动介质。你是从ROM启动、还是从SPI NOR、SD卡、eMMC启动?这决定了BL1怎么加载BL2。如果BL1已经固化在芯片ROM里,芯片厂商通常会把BL2和BL31打包成特定格式,你需要确认自己的FIP生成方式与芯片方案匹配。
第三,内存布局。ATF各阶段镜像加载到哪个地址,BL31的堆栈放哪,共享内存在哪,这些都在平台的platform_def.h里定义。地址规划错了,最常见的就是串口打印到一半系统挂掉,或者BL31无法正确跳转BL33。
第四,调试通道。如果串口驱动没初始化,后面全瞎。所以平台移植的第一步永远是先把UART驱动跑通,输出第一行日志。
我在飞腾平台上的经验是:先让ATF跑起来输出日志,再谈TEE,再谈U-Boot,最后才是安全启动。一次引入太多变量,出了问题你根本定位不到根因。
4.2 手把手搭一个最小平台目录
以ATF 2.9版本为例(当前常见官方版本,长期支持),新平台的最小工程需要这几样东西:
- 平台目录:
plat/vendor/board/,比如plat/example/tc。 - 平台Makefile:声明平台名称、要编译的源文件、以及依赖的头文件路径。
- platform_def.h:定义内存基址、外设基址、中断号、版本号等所有平台常量。
- plat_helpers.S:包含CPU reset入口、平台初始化汇编代码。
- plat_topology.c:描述CPU核心拓扑,即哪些core在哪些cluster里,支持什么电源状态。
- plat_psci.c:实现PSCI相关的电源管理回调,比如核心上电、核心关闭、系统重启、系统关机。
- plat_sip_svc.c:可选,实现平台自定义SMC服务。
- 串口驱动:一般放在
plat/common或drivers/uart里。
从逻辑上讲,第一步只需要让BL1和BL2能串口输出、能加载BL31,BL31能跳转BL33(哪怕是跳到一个死循环),这套最小闭环就算成立了。我做过最快的平台适配是拿ARM官方qemu平台改的,一个晚上能把最小boot链路跑通,但真实硬件平台因为要考虑时钟、DDR初始化等,周期会拉长不少。
4.3 平台Makefile里最容易被忽视的配置项
平台Makefile里有一堆*_SOURCES变量,我摘几个容易踩坑的:
# 平台源文件 PLAT_BL_COMMON_SOURCES := \ plat/example/board/common/plat_helpers.S \ plat/example/board/common/plat_pm.c \ plat/example/board/common/topology.c BL31_SOURCES += \ plat/example/board/common/plat_sip_svc.c \ services/arm_arch_svc/arm_arch_svc.c BL2_SOURCES += \ plat/example/board/common/plat_image_load.c一个典型问题是:如果BL2_SOURCES里没有实现plat_get_next_bl_params相关的镜像加载接口,BL2默认只会加载标准的三类镜像,你想多加一个自定义镜像(比如给OP-TEE传个配置)就会失败。
另一个常被忽略的是ERRATA_*系列配置。某些Core的勘误表规避代码是通过ATF的lib/cpus/aarch64/目录下的CPU具体实现来做的,需要在平台Makefile里显式打开,比如ERRATA_A53_855873 := 1。如果不同开,某些老芯片在特定低温场景下会产生无法解释的随机挂起问题,这类问题排查起来极其痛苦。
4.4 从编译到烧录:一次典型构建与运行验证
接下来以一个伪代码级别的示例,演示一颗新ARMv8平台芯片(类似飞腾FT-2000/4的场景)的ATF启动过程。
假设BL1和BL2由芯片Boot ROM加载,我们要做的就是从BL31开始移植。实际上很多国产化平台的初始移植就是这个路线:
# 设置环境变量 export CROSS_COMPILE=aarch64-linux-gnu- # 清理并编译 make PLAT=myboard DEBUG=1 \ BL33=../u-boot/u-boot.bin \ PRELOADED_BL33_BASE=0x90100000 \ all fipPRELOADED_BL33_BASE这个参数的意思是,BL33已经被前级引导程序(或Boot ROM)加载到了固定地址,BL31不需要再去搬移它,只需跳转过去。我在麒麟系统的适配中常用这种方式,因为U-Boot已经被厂家固化在特定位置了。
编译完成后,生成的build/myboard/debug/bl31.bin和fip.bin按厂商方案烧录。上电后串口日志会依次出现:
NOTICE: BL1: v2.9(release):v2.9 NOTICE: BL1: Built : ... INFO: BL1: RAM 0x1000 - 0x99000 INFO: BL2: v2.9(release):v2.9 NOTICE: BL31: v2.9(release):v2.9 INFO: BL31: Initializing runtime services INFO: BL31: Preparing for EL3 exit to normal world INFO: BL31: Next image address = 0x90100000看到这最后一行,说明ATF已经成功把世界切换的权力交给了U-Boot,启动链路打通了。如果卡在某一步,下面的常见问题章节可以给你一个排查方向。
5. 安全审计:固件里那些“看着对但经不起推敲”的地方
5.1 EL3固件的攻击面到底有多大
做固件安全审计,第一个要建立的概念是攻击面。ATF运行在最高特权级,如果它被攻破,整个系统沦陷。攻击面通常来自几个地方:
- SMC调用:普通世界可以主动发起SMC,如果BL31里的某个服务对参数校验不严,就可能被利用。我审计过一些厂商的
SIP SMC实现,有的直接把参数透传给某个驱动操作物理内存,这种漏洞一旦被利用就是任意读写。 - 中断处理:FIQ/IRQ路由到EL3后,如果相关处理里有逻辑漏洞,也能成为攻击路径。
- 共享内存:BL31为了跟普通世界通信,往往会划一块共享内存,如果这块内存的读写权限没有严格隔离,或者指针校验不严格,风险极大。
- 调试接口:很多固件默认开着
DECRYPTION或者ASSERT相关的调试功能,量产固件如果忘了关,攻击难度会直线下降。
5.2 安全审计的几个核心检查点
结合我在等保和三方测试项目里的经验,审计ATF安全固件我一般按下面几个维度来:
第一,检查是否启用了ENABLE_PIE。位置无关代码让固件在内存中的加载地址可以随机化,缓解固定地址攻击。在支持PIE的平台上不要关。
第二,检查ENABLE_STACK_PROTECTOR。栈保护开启后,BL31会在栈帧插入金丝雀值,栈溢出攻击的难度大增。我见过不少性能导向的板卡把这项关掉,美其名曰降低开销,实际上这点开销跟安全收益完全不成比例。
第三,检查MEASURED_BOOT。如果平台要求做安全启动评测,受测固件是否支持测量启动、是否能把度量值扩展到TEE或安全元件里,这是硬指标。ATF里对应MEASURED_BOOT := 1的配置以及drivers/measured_boot/的代码。
第四,检查内存映射权限。用xlat tables的工具或者代码审查的方式,确认BL31的代码段是只读的、数据段没有同时映射成可写可执行。我见过一些平台的杂散驱动为了省事,把大块内存映射成RWX,这在安全评审里是最低级的Red Flag。
第五,检查认证框架是否真正启用。TRUSTED_BOARD_BOOT(TBB)是否编译进固件,签名验证失败时BL2是直接丢弃镜像还是忽略错误继续跑。后者基本等于安全启动形同虚设。
5.3 常见的“伪安全”固件长什么样
这类问题在商业闭源固件里更多见,ATF开源项目本身因为有社区审计,情况好得多,但下游厂商魔改后就不好说了。常见的伪安全表现有:
- 私钥硬编码在固件镜像里,被提取后可以给任意镜像签名。
- 签名算法用SHA-1甚至MD5,碰撞成本已经低到不可接受。
- “安全启动”只验证U-Boot,之后的内核、根文件系统没有验证链覆盖。
- 防回滚计数器只做了个摆设,升级工具里根本没传旧版本号检查。
如果你是在做产品选型或者合规评估,看到这些情况可以直接一票否决。别抱侥幸心理,“我们威胁模型里没有能物理接触设备的攻击者”这种话骗骗自己可以,骗不了等保测评师。
6. 常见问题与排错实录:固件引导失败的那点事
6.1 按日志形态对症下药
我整理了一个ATF引导失败排查速查表,按日志表现分门别类。这个表是我在几年时间里从几十次现场问题里提炼出来的,覆盖面比较广,建议收藏。
| 日志/故障现象 | 可能原因 | 排查方向 |
|---|---|---|
| 串口完全无输出 | UART驱动未初始化、时钟未配置、串口引脚mux错 | 先查时钟树,再查波特率,然后确认驱动有没有挂在BL31的初始化链上 |
| 打印到BL1后停止 | BL2镜像加载失败、FIP镜像烧录位置不对、MMU配置异常 | 用JTAG读取当前PC,确认卡死位置;核对链接脚本 |
| 打印到BL2后停止 | FIP里BL31镜像缺失或损坏、BL31加载地址非法 | 用fiptool确认FIP内容;核对BL31基址与内存映射 |
| 打印到“BL31: Initializing”时挂起 | Runtime services初始化顺序有依赖、中断没初始化好 | 单步调试,确认挂在哪一个service;检查bl31_main里的初始化顺序 |
| BL31加载BL33后跳转异常 | BL33链接地址和加载地址不一致、SPSR设置错误 | 检查entry_point_info的pc值;检查MODE_EL1等SPSR状态位 |
| 启动随机挂起,无规律 | DDR时序不稳、CPU勘误表未打开、电源域切换竞态 | 查ERRATA_*配置;调低DDR频率验证;检查PSCI状态机 |
| 休眠唤醒后系统异常 | PSCI suspend/resume状态未正确保存、CPU上下文丢失 | 检查BL31的cpu_context保存/恢复逻辑;用JTAG比对唤醒前后的关键寄存器 |
6.2 案例一:飞腾平台串口无输出的排障
这是我在做FT-2000/4适配时遇到最典型的问题。ATF代码编出来,烧进去,串口一个字符都没有。
第一步排查时钟。ATF启动还没到MMU阶段,串口驱动需要自己搞定时钟分频。我查到平台给的频率是50MHz,但实际PLL配置出来是48MHz,波特率差了4%,看起来不多,但串口就是不出字。把分频系数按实际频率重算后,日志刷出来了。
第二步确认UART驱动有没有被编译进BL31。ATF的串口驱动在BL31里默认挂载于bl31_plat_setup的console_init。如果平台Makefile里没把console的源文件加进PLAT_BL_COMMON_SOURCES,任何打印都不会有。我当时就是漏了这一行,折腾了快一天。
6.3 案例二:BL31起不来的内存布局问题
另一个项目里,BL1和BL2正常,BL31卡死,日志中断在“Loading BL31”。
我查了链接映射,BL31_BASE被定义在了DDR地址,但此时DDR还没有初始化完。在低成本平台上,BL31有可能要在SRAM里跑起来完成DDR初始化,再跳转到大内存。如果platform_def.h里把BL31_BASE直接指到DDR,而BL31代码里又没有DDR初始化逻辑,那就是死局。解决方案是让BL31先跑在片内SRAM,完成DDR controller配置后再重映射代码段到DDR。这类问题用串口日志很难看懂,因为崩溃发生在MMU映射切换的瞬间,最好是JTAG加breakpoint在bl31_entrypoint处,一步一步看PC走到哪停了。
6.4 案例三:TBB安全启动开启后,启动时间暴涨
这不是故障,但很影响体验。有客户反馈说开启TRUSTED_BOARD_BOOT后,系统启动时间从3秒变成12秒。一开始以为是签名验证引入的开销,后来一查,罪魁祸首是BL2在认证BL31时,使用软件实现的RSA验签,且密钥长度2048位,在没有硬件加速的平台上确实慢。
优化思路有两个:一是看硬件是否支持加密加速,把验签操作卸载到硬件引擎;二是确认PRNG(伪随机数生成器)的种子获取是否阻塞。ATF在启动早期如果环境里没有硬件熵源,PRNG初始化会等待外部熵源超时,这个等待有时候是好几秒。在plat_get_entropy里,如果平台没有TRNG,可以退化到用jitter entropy,大幅缩短启动时间。但注意这不是在所有安全评审场景里都接受,需要跟合规方确认。
7. 写在最后的移植建议
前面把架构、源码、移植、审计、排错都拆开讲了,收尾我不再做总结,只说几点我在实际项目里沉淀下来的经验和建议,希望能帮你在ATF这条路上少走弯路。
第一,先跑通、再优化、最后才谈安全。没有极具针对性的需求,不要一上来就开TBB和测量启动。我在不止一个项目里见过,安全功能全开,结果连正常启动都跑不过,最后排查发现是证书链配置少了一环。安全是加分项,前提是基础链路是稳的。
第二,移植平台时,多参考ARM官方FVP和qemu平台的实现。这两个平台的代码质量很高,而且覆盖了从标准启动到测量启动的各种配置,是绝佳的活教材。看不懂某个接口,就在这两个平台里搜它的实现,比看文档直观得多。
第三,善用fiptool和create证书的工具链。ATF自带的工具链非常完整,但很多开发者只用它们做打包。建议把cert_create和fiptool组合起来,做一个自动化脚本管理密钥和证书。别让密钥裸奔在开发机里,至少加个密码保护。
第四,重视日志。ATF的VERBOSE和INFO级别日志量差别巨大,线上固件跑DEBUG级别日志可能泄露敏感信息,但发生问题后又没有足够的日志可查。建议在量产版本中把LOG_LEVEL调到INFO或NOTICE,但保留VERBOSE可切换的编译开关,同时用宏把敏感信息过滤掉。
最后,ATF是个不断演进的活项目,RME、FF-A、SPMC这些新特性还在快速迭代。作为一个长期维护固件的人,我习惯每隔两三个版本就看看release notes和design文档,哪怕不升级,也要知道社区在做哪些安全加固,这些信息经常能提前预警我现有方案里的潜在问题。
希望这篇文章对你有用。如果你正在某个ARM平台上折腾ATF,遇到具体报错或者拿不准的配置,欢迎一起讨论,固件这行就是踩坑踩出来的经验最值钱。