ARM可信固件ATF架构解析:BL31启动流程、安全机制与平台移植实践
2026/9/7 10:56:25 网站建设 项目流程

最近在调一块ARMv8.2核心板,要把安全启动、TEE和Linux引导一条龙贯通,被迫把Arm-Trusted-Firmware(ATF)的源码从启动入口到平台适配翻了个底朝天。网上关于ATF的讨论大多停留在“怎么建个plat目录”的阶段,真正能把BL1/BL2/BL31链路、信任根设计、SMC分发和平台移植串成体系讲透的并不多。这篇文章就用源码审计的视角,把ATF的架构全景、安全机制和落地移植一起拆开,适合刚接手安全固件、想读懂TF-A代码,或者正准备在新芯片上跑BL31的工程师参考。全文按照我的工程习惯组织,启动流程、源码模块、关键安全机制都会给对应代码路径,移植部分给了可直接照抄的思路。

1. ATF为何存在:ARM安全固件的信任根与分级体系

1.1 安全世界和普通世界:ATF的立足点

先聊一个最基本的问题:ATF解决的是什么事。ARM从Cortex-A系列开始引入TrustZone技术,把整个系统划分成安全世界(Secure World)和普通世界(Normal World)。普通世界跑Linux、Android、RTOS,安全世界跑可信执行环境(TEE),比如OP-TEE或Trusty。两者通过SMC(Secure Monitor Call)指令互相跳转,而负责这个跳转、管理安全状态切换、提供电源管理接口的底层固件,就是ATF。

ATF运行在最高的异常级别EL3,这话怎么理解?ARMv8的异常级别从低到高是EL0、EL1、EL2、EL3。Linux内核跑在EL1,虚拟化场景下Hypvisor跑在EL2,而EL3就是整个系统里权限最大的那一层。打个比方,EL3相当于物业总配电房的钥匙,Linux和TEE再怎么折腾,都只是在自己那间屋子里动电闸,真正能拉总闸、换总路的人只有EL3的ATF。

所以你会发现,ATF不是一个传统意义上的“裸机程序库”,它是一个安全管理器。它管安全启动、管异常级别切换、管PSCI电源管理、管SDEI事件,还管内存隔离配置。没有ATF,ARMv8平台上Linux的CPU启动、休眠、重启这些操作就没有一个可依赖的底层实现路径。

1.2 BL1/BL2/BL31/BL32/BL33一条链路看启动全貌

ATF源码和运行时被拆成好几个组成部分,最常出现的就是BL1、BL2、BL31、BL32、BL33这一串缩写。很多新人一开始就被这五个名字绕晕,其实记住一个原则就不乱:BL(Boot Loader)后面的数字越大,阶段越靠后,代码在系统里存活的时间也越短(除了常驻的BL31)。

我在源码里对应关系整理成一张表,方便对照理解:

阶段运行位置职责生命周期典型实现
BL1SRAM或BootROM加载最小初始化、信任根验证、加载BL2启动早期执行完跳转ATF自带bl1
BL2SRAM/DDR可信启动引导,加载BL31/BL32/BL33并验签加载完BL31后销毁ATF自带bl2
BL31DDR常驻EL3运行时固件,提供PSCI/SDEI等运行时服务系统运行期间常驻ATF自带bl31
BL32安全世界DDRTEE操作系统系统运行期间常驻OP-TEE等
BL33普通世界DDR正常世界引导程序引导操作系统后转交U-Boot、EDK2

BL1作为信任链的第一环,代码通常由SoC内部的BootROM加载到片内SRAM,然后BL1校验BL2。BL2主要负责从存储介质里找到BL31、BL32、BL33,验证签名后把它们放到内存正确的位置。BL31启动后不会退出,它会一直驻留在EL3,等普通世界通过SMC指令向它请求服务。后面讲源码结构时,你会发现BL1、BL2的代码相对少而且路径集中,真正复杂的是BL31,因为它要同时处理运行时服务、安全调度和上下文切换。

1.3 ATF不是唯一答案,但已是事实标准

早年间ARM平台上也有其他方案,比如U-Boot早期提供的PSCI支持,以及各芯片厂商自己闭源的Secure Monitor。但ATF开源、免费、有ARM官方持续维护,加上QEMU和FVP模拟器原生支持,几乎所有主流ARM SoC和开发板最终都回归到了TF-A这条路。对新平台而言,如果不是想自己从零实现EL3固件,ATF基本就是唯一值得考虑的基础。

我之前做NXP i.MX8M系列板卡时,官方BSP就是把ATF、OP-TEE、U-Boot三者打包在一起,ATF负责最底层那层安全链路。这种分工合作的方式已经成了当前ARM生态里的默认组合。

2. 源码结构全景:拿到TF-A仓库后怎么下口

2.1 顶层目录逐个过一遍

ATF源码目前在GitHub上的arm-trusted-firmware仓库维护,官方名称已经改为Trusted Firmware-A(TF-A),仓库里仍然保留了ATF的历史命名习惯。拿到代码后,先别急着改代码,把顶层目录过一遍,后面查问题会快很多。

关键的目录有这些:

  • bl1:BL1阶段的源码,主要是boot loader第一阶段实现,包含aarch64下的启动汇编。
  • bl2:BL2阶段的源码,负责可信启动加载流程。
  • bl31:BL31运行时固件源码,包括入口、主流程、以及aarch64下的异常处理。
  • common:跨阶段公共代码,比如启动流程的公共宏、参数传递、镜像加载逻辑。
  • plat:平台相关代码,所有板级适配都放这里,也是移植工作的主战场。
  • drivers:各类驱动,包括GIC中断控制器、UART串口、存储等底层驱动。
  • lib:公共库,比如el3_runtime、psci、xlat_tables_v2、el3_common等。
  • services:EL3运行时服务,包括PSCI、SDEI、SPMD、std_svc等。
  • include:公共头文件,按目录分好类,比如include/lib、include/plat、include/services。
  • tools:工具链,包括fiptool、cert_create、sptool等。
  • docs:官方文档,平台移植前强烈建议先翻这里的porting-guide。

我最早接触ATF时,一上来就钻进了bl31的代码里,结果被各种宏定义绕晕。后来才发现,正确的下口顺序应该是先看docs目录下的porting-guide.rst,再看platform porting flow,然后顺着BL阶段去追代码。这个顺序能少走很多弯路。

2.2 核心服务模块:PSCI、SDEI、SPM这些服务都在哪

ATF在EL3层提供的服务,在源码里对应services目录下的各个子模块,其中PSCI是我认为最核心的。PSCI(Power State Coordination Interface)负责CPU的启动、关闭、挂起、系统重启和关闭,是Linux和ATF之间电源管理的主要协议。它在services/std_svc/psci/目录下实现,包括psci_main.c、psci_cpu_on.c、psci_common.c这些文件。

举例来说,Linux内核要启动一个CPU核心时,会通过SMC指令调用PSCI_CPU_ON,ATF在EL3收到这个请求后,把目标CPU从OFF状态带起来,设置好入口地址,然后让它跳到内核指定的启动入口。整个过程涉及电源域状态管理、当前核心和目标核心的上下文切换,不是简单的“写个寄存器就能跑”。

SDEI(Software Delegated Exception Interface)则用于软中断事件的分发,在services/std_svc/sdei/目录下。它允许普通世界通过SMC注册事件处理函数,ATF在收到事件后帮普通世界切换上下文并跳转到处理函数。SPMD和SPM则负责与管理安全分区相关,是Arm CCA等新特性的基础。

2.3 构建系统与镜像生产流程

ATF使用make作为构建系统,构建配置的核心是PLAT变量,指定要编译的平台,比如make PLAT=fvp或者make PLAT=qemu。构建完成后,产物会输出到build/<平台>/<debug|release>/目录下,常见的产物有bl1.bin、bl2.bin、bl31.bin。这些镜像最终需要被打包成FIP(Firmware Image Package),使用tools/fiptool工具完成。

打包命令大致长这样:

fiptool create \ --tb-fw build/qemu/release/bl2.bin \ --soc-fw build/qemu/release/bl31.bin \ --nt-fw u-boot.bin \ --tos-fw tee.bin \ fip.bin

值得注意的是,BL1通常不打包进FIP,因为BL1要放在BootROM加载的位置,而BL2开始往后的所有镜像都可以由BL1/BL2从FIP里解析加载。fiptool不仅能制作FIP,还能列出FIP里的镜像列表。

3. 源码级安全审计:信任根、SMC分发与内存隔离

3.1 TBB信任链与证书链

ATF的安全启动机制叫Trusted Board Boot(TBB),核心思路是建立一条从SoC信任根到最终启动镜像的验证链。这就像进一栋大楼,你必须先验证第一道门的钥匙(信任根),之后每一道门都由上一道门的验证结果来背书。

源码里TBB相关代码在drivers/auth/目录下,包括认证框架auth_mod.c、镜像解析器img_parser_mod.c、证书解析器cert_parser等。在TBB模型里,BL1内置了Root Of Trust Public Key(ROTPK),也就是根公钥。启动时BL1拿ROTPK验证BL2的证书和镜像,BL2再用从证书链里提取的密钥去验证BL31、BL32、BL33这些后续镜像。

实际开启TBB需要在编译时打开开关,同时还要指定mbedtls库路径:

make PLAT=qemu TRUSTED_BOARD_BOOT=1 \ MBEDTLS_DIR=/path/to/mbedtls \ GENERATE_COT=1 \ all

如果用的是签名证书链,还要用cert_create工具生成密钥和证书。我的建议是,第一次跑通平台时先不要开TBB,等裸启动链稳定了再做安全启动,不然会出现“前面还没跑亮,后面验签又失败”的叠加问题。

3.2 EL3入口与SMC调用路径

ATF里最值得读的一段启动代码是BL31的入口。BL31的启动流程从bl31/aarch64/bl31_entrypoint.S开始,经过el3_entrypoint_common宏完成异常向量表设置、系统寄存器初始化和栈指针配置,然后调用bl31_main。bl31主流程里最关键的几步是:

  • bl31_early_platform_setup2:平台早期初始化,比如配置串口、读取内存信息。
  • bl31_plat_arch_setup:配置MMU和内存映射。
  • bl31_platform_setup:平台外设初始化。
  • runtime_svc_init:注册并初始化所有运行时服务。

服务注册完成后,BL31进入一个无限等待状态,每次普通世界或安全世界发生SMC异常,都会先进入bl31/aarch64/runtime_exceptions.S里的异常入口,按照ESR_EL3的EC(Exception Class)字段判断异常类型。EC等于0x17表示这是SMC调用,随后代码进入SMC处理路径,解析FID(Function ID)。

每个SMC服务在初始化时都通过DECLARE_RT_SVC声明一个rt_svc_desc结构体,注册自己的OEN(Owning Entity Number)、调用号范围、初始化函数和handler函数。运行时,SMC分发逻辑根据FID里的OEN找到对应服务,把控制权交给该服务的handler。看懂这条路径,以后加自定义SMC服务就是顺水推舟的事情。

3.3 内存隔离与MMU配置

安全固件最怕的不是功能跑不通,而是内存权限配置错误导致的安全漏洞。ATF通过MMU和TrustZone控制器把普通世界和安全世界的内存严格隔离开。在BL31阶段,平台代码要用xlat_tables_v2库配置页表,把内存区域划分为不同属性:

  • 普通内存:使用MT_MEMORY属性,比如BL31的数据段、堆栈。
  • 设备内存:使用MT_DEVICE属性,比如GIC寄存器、UART寄存器。
  • 代码段:MT_RO,只读可执行。
  • 数据段:MT_RW,可读写但不可执行。

实际配置时一般通过mmap_add_region或mmap_add把这些区域加到页表里,然后初始化translation table并enable MMU。我见过太多因为漏配UART地址范围,导致打印函数触发同步异常直接死机的案例。

像GIC这类外设寄存器如果不标记为MT_DEVICE,而误标成MT_MEMORY,在ARMv8上会因为内存序问题导致中断配置错乱,轻则功能异常,重则卡死。所以platform移植时,第一件事就是把这几个关键区域的属性核对清楚。

4. 平台移植落地:从零部署一个新平台

4.1 最小编译目录与platform.mk

ATF平台移植的第一步是在plat目录下创建自己的平台目录,通常路径是plat/<厂商>/<板卡名>。目录里必须有platform.mk、平台头文件和平台C文件。platform.mk是整个移植的入口,它告诉构建系统这个平台需要编译哪些源文件、使用哪些编译选项、内存布局如何定义。

我习惯拿一个同类平台做底子来改。比如想做一个和QEMU virt类似的虚拟平台,就先看plat/qemu/platform.mk里写了什么。一个最小的platform.mk大致是这样的:

include plat/common/plat_common.mk # 定义BL31源文件 BL31_SOURCES += \ plat/demo/demo_board/demo_board.c \ plat/demo/demo_board/demo_board_common.c # 平台内存布局宏 $(eval $(call add_define,PLAT_DEMO_BL31_BASE)) $(eval $(call add_define,PLAT_DEMO_BL31_LIMIT))

platform.mk里还可以通过BL32、BL33变量指定TEE和正常世界引导程序的路径,用CRASH_CONSOLE指定崩溃时用的串口,用LOG_LEVEL指定日志级别。这些选项看着琐碎,但每一个都直接影响镜像能不能在目标板上跑起来。

4.2 内存布局:BL31_BASE到BL31_LIMIT的取舍

内存布局是移植过程中最需要谨慎的部分。BL31要放在内存的什么位置,BL32放哪,BL33放哪,要用多大空间,这些都要在平台头文件和链接脚本里明确。ATF对不同平台提供了一套自定义内存布局的宏,比如BL31_BASE、BL31_LIMIT、BL32_BASE、BL32_LIMIT。

我通常按照“BL31放低地址、BL32放BL31之后、BL33放更高地址”的原则设计。比如一个8GB内存的板子,可以留出最前面的256MB给安全固件使用,其中BL31位于0x04200000,大小2MB,BL32位于0x04400000,大小8MB。链接脚本会按照这些宏生成bl31.elf的内存布局,如果BL31_LIMIT设小了,链接阶段就会发现溢出。

这部分特别容易出现“能编译但一跑就崩”的情况,尤其是BL31的栈和数据放在哪个区域,在不同平台上差异很大。建议一开始内存区域宁可大一些,并且加上越界检查,等稳定后再收缩空间。

4.3 必现平台函数清单

BL31移植时必须实现一组平台函数,否则构建会报未定义错误。我把BL31阶段最常见的那几个列出来,刚做移植时直接对着这个清单逐项补齐:

函数阶段作用遗漏后果
bl31_early_platform_setup2BL31早期串口、内存基本信息初始化打印不了日志,无法调试
bl31_plat_arch_setupBL31架构配置MMU、页表、内存映射一进C代码就同步异常
bl31_platform_setupBL31平台外设、GIC、系统计数器等初始化中断和PSCI功能异常
plat_get_syscnt_freq2运行时提供系统计数器频率PSCI延时和时钟相关功能错乱
plat_crash_console_init崩溃时准备崩溃打印串口PANIC时完全黑屏
plat_my_core_pos运行时返回当前CPU的逻辑ID多核启动错乱
platform_get_core_pos运行时把物理核心映射到逻辑核心多核调度和中断路由异常

这些函数里,plat_crash_console_init最容易被忽略,但在真机调试时几乎离不开它。没有它的话,BL31一旦发生未捕获异常,系统就是一片死寂,只能靠JTAG或者示波器猜问题。有了它,至少能看到原始的寄存器状态和调用地址。

4.4 编译、FIP制作与烧写调试

以QEMU平台为例,完整编译一条链路可以用这个流程:

make CROSS_COMPILE=aarch64-none-elf- PLAT=qemu DEBUG=1 bl31

如果要编译并打包BL1、BL2、U-Boot等,还需要指定BL33路径,然后用fiptool制作FIP,再将FIP加载到目标存储介质。在QEMU virt平台上,可以直接把FIP作为Flash加载,启动后串口会输出BL1、BL2、BL31的日志。日志能打到BL31说明最先的启动链路已经通了,接下来就可以开始调试BL33引导和TEE。

如果是真机,烧写方式取决于SoC的启动设备,可能是SD卡、eMMC、NAND或USB下载。在拿到板子的前半天,我建议先用厂商BSP里验证过的ATF构建产物做一次完整的启动,确认硬件环境没问题,再换上自己改的代码迭代。

4.5 几项高频功能移植点

除了把BL31跑起来,实际项目里还经常要补这几块:

  • GICv3中断控制器初始化:BL31平台启动时要初始化GIC,包括配置GICD、GICC寄存器,设置中断组和安全属性。
  • 系统计数器:PSCI功能依赖系统计数器,必须提供正确的频率和初始化逻辑。
  • 串口驱动:BL31的日志输出全靠它,移植时建议用最简轮询模式,先不要用中断。
  • 唤醒源配置:实现PSCI_CPU_SUSPEND和PSCI_SYSTEM_SUSPEND时,需要结合具体硬件确认唤醒源和支持的电源状态。

其他像RAS、AMU、MPAM这些可选特性,功能模块在ATF里都有现成实现,但每加一个特性都可能引入平台相关配置。我把这些特性当“后期加分项”,绝不因为它们影响基础启动链路。

5. 常见问题与排查实录:ATF跑出问题的现场解决流程

5.1 三个典型故障场景

我把自己在真机和模拟器上踩过的坑整理成三个场景,基本都是移植初期高频出现的。

第一个是“BL31一进去就PANIC,PC停在某个未知地址”。这通常是MMU和内存映射配置问题,比如代码段映射成不可执行,或者把设备寄存器映射成了普通内存。定位方法很简单:打开LOG_LEVEL,查看PANIC时打印的ESR和FAR寄存器值,再配合bl31.elf的符号表,用addr2line就能看到撞在哪个函数上。

第二个是“串口没有任何输出”。这种情况先确认是不是bl31_early_platform_setup2里串口初始化就没做对,再看串口地址在MMU table里有没有映射。如果这两项都正常,那就检查是不是BL31镜像根本没被加载到正确地址。别忘了CONSOLE的时钟是不是和实际硬件一致,很多板子串口波特率对不上就是时钟配置错误。

第三个是“PSCI调用返回错误码”。Linux执行CPU hotplug时报PSCI返回EINVAL之类,常见原因是ATF里的PSCI ops没有实现对应函数,或者平台电源域描述和实际硬件不匹配。可以打开内核PSCI的trace,对照ATF的psci日志一起看。

我用下面的表格做了一下速查,现场排查时可以快速对照:

现象可能原因线索解决方向
BL31进不去,完全无输出镜像加载地址错误检查FIP中BL31入口核对内存布局和FIP打包参数
同步异常PANICMMU映射错误查看ESR/FAR寄存器检查mmap_add_region属性
串口乱码/无输出UART时钟或引脚配置错检查板级clock核对串口驱动中的时钟频率
PSCI调用失败电源域描述不匹配查看PSCI返回错误码检查plat_setup里的power domain

5.2 日志调试开关与模拟器技巧

ATF的日志分级在include/common/debug.h里定义,LOG_LEVEL从0到50分五档,0代表关闭,50代表最详细的VERBOSE。日常调试建议先拉到40(INFO),能看到BL31初始化时各个服务的注册情况。如果还不够细,就拉到50,但同时要注意日志太细会影响时序,特别是涉及到PSCI电源切换时,输出过多可能掩盖真实问题。

在QEMU上可以用-gdb tcp::1234调试,再用GDB的target remote连接。真机上如果没有JTAG,我一般靠crash console打印。还有一个技巧:在BL31的panic路径里,ATF会把当时的ELR、FAR和ESR都打到崩溃串口上,把这些信息记录下来,再用objdump反汇编bl31.elf,基本能定位到具体指令。

5.3 性能与体积优化技巧

BL31的镜像体积在一些存储敏感的场景下很关键。我的经验是先把LOG_LEVEL降到20(NOTICE),只保留必要提示;然后关闭DEBUG模式,去掉调试符号和断言;最后通过裁剪未使用的服务和驱动来减体积。ATF通过每个服务的Makefile源文件列表控制编译内容,没用到的服务直接不加入BL31_SOURCES即可。

如果追求极致性能,可以考虑ATF的链接时优化(LTO)支持,在编译选项里加ENABLE_LTO=1。这会延长编译时间,换来一定体积和性能收益。但是LTO对工具链版本有要求,升级编译器的同时一定要回归测试一遍启动链路。

5.4 移植避坑建议

最后聊几条移植层面的经验。第一,不要在一开始就打开TBB安全启动,先把裸启动跑通,再逐步叠加安全特性。第二,优先参考上游仓库里和自己平台最接近的现有实现,比如同样是Cortex-A53的板子,参考qemu或juno的代码,比自己硬写GIC初始化要快得多。第三,遇到版本差异时,以官方docs文件夹里的porting-guide为准,不要盲目相信老版本的博客和笔记。第四,每改一次内存布局或者MMU映射,都顺手更新平台头文件里的宏定义,防止改着改着前后不一致,这是我踩过最深的一次坑。

移植过程的节奏感也很重要,我一般遵循一条原则:先让EL3的console响起来,再谈其他。只要BL31能在目标板上打印出第一行日志,这条链路就已经通了百分之八十,后续无非是补齐功能和排查细节。这听起来简单,但真正做起来需要把架构、源码和调试工具融会贯通。希望这篇基于源码的ATF评测和落地指南,能让你在新平台的安全固件移植上少一点手足无措。

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

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

立即咨询