拿到一个新板子,第一步不是点灯,不是跑Hello World,而是先回答一个问题:谁来把这个SoC的安全启动和运行时特权管理撑起来?U-Boot可以等,内核可以等,但Arm Trusted Firmware(ATF)不能等。它是一颗Cortex-A/AArch64芯片的"地基"里最靠底下的那层承重墙。这篇文章我不打算复述官方文档,而是按我实际做BSP移植和安全审计时的路径,把ATF的架构全景、源码关键点、安全机制和平台移植落地讲透,希望能帮到正在啃这部分代码的嵌入式工程师。
1. 先从片上的安全世界说起:ATF到底在解决什么问题
很多工程师第一次接触ATF时,最大的困惑是:Linux和U-Boot跑得好好的,为什么非要插进来一个"可信固件"?原因很简单:ARMv8/AArch64的CPU从复位那一刻起,就处在EL3,而Linux内核只能运行在EL1,U-Boot也只能运行在EL2/EL1。中间这层最高特权级没有人管,所有安全相关的脏活、累活就没人干。
TrustZone把整个SoC切成了两个平行的世界:安全世界(Secure World)和普通世界(Normal World)。外设和内存通过TZASC、TZPC等控制器被贴上"安全"或"非安全"的标签。普通世界的代码物理上摸不到安全资源,但问题是,谁来配置这些安全资源?谁来在启动初期把系统从EL3安全地交接给EL2/EL1?谁在后续运行中响应普通世界发起的电源管理请求?答案就是ATF。
ATF(全称Arm Trusted Firmware-A,源码仓库叫TF-A)是Arm官方维护的EL3参考实现。它干的事可以概括成三件:可信启动链的建立与验证、EL3运行时服务(PSCI、SMC分发等)、安全世界和普通世界的隔离与调度。注意,ATF不是某个芯片厂家写的,它是"通用骨架",每一家SoC厂商都需要做平台移植,把自己的初始化代码、内存布局、电源操作填进去。所以你打开ATF源码会发现,最核心的逻辑(BL31框架、PSCI框架)是芯片无关的,真正复杂的是plat/目录下每家芯片各不相同的平台代码。
这个项目适合谁来学?说实话,不是所有人都有必要深入ATF。如果你是应用层开发者,知道有EL3这个东西就够了;但如果你是BSP工程师、TEE移植工程师、安全固件审计工程师,或者在做芯片bring-up,那ATF源码就是你绕不过去的一座大山。我当年被安排做新平台移植时,也是从零啃起,踩了不少坑,这篇就当是把我踩过的坑标出来。
1.1 普通世界如何"够到"安全世界:SMC指令的前世今生
两个世界不是孤立存在的。普通世界在运行中经常需要安全世界帮它做事,比如关机、重启、设置安全配置项。ARM提供了一条专门的指令:smc(Secure Monitor Call)。执行这条指令后,CPU会陷入EL3,ATF接管,根据传递进来的Function ID判断该把请求转发给哪个服务。
这个调用过程很像操作系统里的系统调用:用户程序通过svc进入内核,普通世界通过smc进入EL3。区别在于,用户程序的svc是陷入同一个特权世界,而smc是跨世界调用,中间经过了CPU硬件强制切换。这也是整个安全设计的地基:普通世界永远无法直接写安全世界的寄存器,只能通过定义好的接口"请求"安全世界代劳。
1.2 ATF和U-Boot、Linux之间的分工
这里要澄清一个最常见的混淆:ATF和U-Boot到底谁先跑?按标准ATF启动流程,上电后最先运行的是固化在片内BootROM里的代码,它把BL1加载到SRAM并跳转;BL1再把BL2加载到SRAM并验证;BL2负责加载BL31、BL32(可选)和BL33;BL33就是U-Boot。所以回答是:ATF代码整体上先于U-Boot运行,但BL31是一个常驻内存的服务型固件,U-Boot和内核启动后,依然要随时通过SMC调用它来获得PSCI等服务。
打个比方:U-Boot和Linux是"普通世界"的两个应用,ATF是"安全世界"的一个常驻内核。前者在明处跑业务,后者在暗处管底层资源和安全边界。理解了这层关系,后面看启动流程就不会乱。
2. 启动全景:BL1到BL33如何完成信任链传递
ATF的启动过程被拆成了多个"Boot Loader"阶段,每个阶段有明确职责和生命周期。这个设计不是为了显得高大上,而是为了在每一级都做一次验签,形成一条从根信任点到最终操作系统的信任链。下面这张表是我整理的各阶段职责对照,建议收藏着看。
| 阶段 | 运行位置 | 异常级别 | 主要职责 | 生命周期 |
|---|---|---|---|---|
| BL1 | 片内SRAM/BootROM | EL3 | 最小硬件初始化,加载并验证BL2 | jump到BL2后失效 |
| BL2 | 片内SRAM | EL3 | 可信启动固件,加载BL31/BL32/BL33并验签 | jump到BL31后失效 |
| BL31 | SRAM或DRAM保留区 | EL3 | EL3运行时固件,常驻,处理SMC/PSCI | 系统运行期间常驻 |
| BL32 | 安全DRAM | S-EL1 | 安全世界OS,如OP-TEE | 视方案而定 |
| BL33 | 非安全DRAM | EL2/EL1 | 普通世界引导程序,通常是U-Boot | 引导内核后失效 |
建议在纸上把这条链画一遍:BootROM → BL1 → BL2 → BL31 → BL33 → Linux。每次箭头指向的下一级都会被上一级验证签名,任何一级被篡改,链就断了,系统拒绝启动。这就是"可信启动"的核心逻辑。
2.1 BL1和BL2:跑完就"死"的短命固件
BL1通常是芯片出厂时固化在BootROM里的,也可以放到外部flash由芯片内部逻辑加载。它只做最少的初始化:设置串口、DDR可能还没起来、加载BL2到SRAM、验证BL2、然后跳转。因为BL1代码量极小,一般只有几十KB级别,所有能用代码解决的事情尽量留给BL2去做。
BL2在SRAM里运行,这时候DRAM还没有完全可用,它负责把BL31、BL32、BL33这些大镜像从flash加载到内存里,同时对每个镜像做验签。如果开启了Trusted Board Boot(TBBR),BL2还需要验证证书链。BL2运行完毕后就跳回BL31(BL2会把内存信息通过寄存器/共享内存传给BL31),自己就被覆盖或废弃了。
这里有个工程细节:BL2运行在SRAM,空间非常紧张,如果平台把BL2编译得太大,链接阶段就会直接报地址溢出。我在移植时经常为BL2的体积发愁,能往BL31挪的代码绝不留在BL2里。
2.2 BL31:整个EL3世界的核心中枢
BL31才是真正陪你走到最后的固件。它常驻内存,负责的事情包括:EL3异常向量表的建立、GIC中断路由配置、Runtime Services的注册与SMC分发、PSCI电源管理、安全世界与普通世界的上下文切换。
从代码上看,BL31的入口在bl31/bl31_main.c的bl31_main()函数。它干的事顺序大致是:先初始化平台(bl31_platform_setup),再注册运行时服务(runtime_svc_init),然后设置异常向量表并开中断,最后原地等待普通世界通过SMC来调用它,或者直接跳转给BL33。需要注意的是,BL31并不是"引导完BL33就下班",它会一直驻留在内存里,随时响应来自普通世界的SMC请求。
2.3 PSCI:Linux内核眼中的ATF
普通世界的内核是怎么感知到ATF存在的?答案是设备树里的psci节点。比如:
psci { compatible = "arm,psci-1.0"; method = "smc"; cpu_on = <0xC4000003>; cpu_off = <0xC4000004>; };内核里所有CPU热插拔、suspend/resume、系统重启/关机操作,都会编译成一次SMC调用,把对应的Function ID传给EL3。ATF收到后,通过PSCI框架回调到平台定义的具体电源操作函数。也就是说,你在内核里执行reboot,最终真正操作硬件电源管理寄存器的,可能是一段跑在EL3的ATF代码。
PSCI框架位于services/std_svc/psci/。它定义了一套与平台无关的协议,平台需要实现plat_psci_ops里的函数,例如cpu_on、cpu_off、cpu_suspend、system_reset等。移植ATF时,这一块几乎是必改项,因为你换了芯片,power controller的寄存器必然换。
3. 源码工程面面观:代码仓结构、构建系统与Runtime服务框架
从GitHub拉下TF-A源码后,第一眼往往会懵:目录怎么这么多?这里我建议按"从入口到平台"的顺序去读,而不是从plat/开始。代码仓根目录的关键部分大概是这样的。
| 目录 | 内容 |
|---|---|
bl1/bl2/bl31/bl32/ | 各阶段固件主逻辑 |
common/ | 镜像加载、描述符处理等通用逻辑 |
lib/ | EL3运行时库、PSCI、GIC驱动、MMU页表等 |
services/ | Runtime Services:PSCI、SDEI、SPD(OP-TEE调度器等) |
plat/ | 平台相关代码,含Arm官方参考平台 |
drivers/ | 各种外设驱动:串口、GIC、IO等 |
tools/ | 构建辅助工具:fiptool、cert_create |
docs/ | 官方文档,非常值得读 |
读源码的正确姿势:先读bl31/bl31_main.c,再看services/std_svc/psci/psci_main.c,然后跳到plat/arm/board/fvp/这个参考平台,感受平台代码的"接口长什么样",最后才去对照你自己的芯片手册写平台代码。直接一头扎进plat/目录,大概率是被各种宏和汇编淹没。
3.1 构建系统:一行make背后的产物
ATF的构建系统是纯Makefile。最朴素的构建命令是:
make PLAT=fvp DEBUG=1指定PLAT告诉构建系统加载plat/arm/board/fvp/platform.mk。DEBUG=1表示带调试符号和详细日志打印。构建完成后,在build/fvp/debug/下能找到bl1.bin、bl2.bin、bl31.bin以及打包好的fip.bin。
很多人不理解FIP是什么,以为直接把bl1.bin烧进flash就行。实际上,BL2以后的所有镜像(BL31、BL32、BL33)都被打包进一个FIP(Firmware Image Package)文件,BL2从flash里读取的是这个FIP,从中解析并加载各镜像。FIP的格式很简单,本质是文件头加若干镜像条目,可以用工具查看:
fiptool info fip.bin这个工具在tools/fiptool/下,构建ATF时会自动生成。我调试时经常用它确认某个镜像有没有被打进FIP里,改过U-Boot之后忘记重新打包FIP是新手最常犯的错误之一。
3.2 Runtime Service框架:一张SMC分发表
BL31能处理那么多不同类型的SMC请求,靠的是注册表机制。ATF里定义了一个全局的rt_svc_descs段,各个服务通过宏把自己的描述符"塞"进这个段里。以PSCI为例,它在services/std_svc/psci/psci_main.c里这样注册:
DECLARE_RT_SVC( psci_std_svc, OEN_STD_START, OEN_STD_END, SMC_TYPE_FAST, psci_setup, psci_smc_handler );宏的参数分别是服务名、Function ID的OEN范围、调用类型、初始化函数和分发处理函数。SMC调用中的Function ID里有一个OEN字段(Owner Encode),用来标识这个调用属于哪个"所有者":是标准服务、安全服务还是SiP服务。BL31拿到一次SMC调用后,会解析OEN,然后根据OEN去查这张分发表,找到对应的处理函数。
我们在平台移植时,如果想增加自定义的SiP服务(比如让普通世界读取某个安全寄存器),就可以参照这个模式写一个自己的Runtime Service,用OEN_SIP_START和OEN_SIP_END指定OEN范围。这在企业内部固件开发中非常常见。
3.3 平台目录长什么样:拿FVP当模板
Arm官方维护了多个参考平台,其中plat/arm/board/fvp/是移植的最佳起点。FVP(Fixed Virtual Platform)是Arm提供的仿真平台,可以在没有真实开发板的情况下,把ATF、U-Boot、内核完整跑起来。
plat/arm/board/fvp/目录下的核心文件大致有:platform_def.h(平台宏定义,内存基地址、UART地址、CPU数等)、plat_helpers.S(早期汇编初始化)、bl31_plat_setup.c(BL31平台初始化)、plat_topology.c(CPU拓扑)、plat_psci.c(PSCI电源操作)等。移植到自己的SoC时,基本就是复制这套骨架,然后替换成自己芯片的寄存器地址和行为。
4. 工程审计的三个重心:Trusted Boot、FIP与证书链
标题里带了"工程审计"四个字,我重点讲讲如果领导让你对一款ATF安全固件做审计,你该从哪几个角度切入。不是让你去审计全部源码,那不现实,而是要抓住"信任根、构建配置、攻击面"这三大块。
4.1 Trusted Board Boot:签名验证链路
TBBR(Trusted Board Boot Requirements)是Arm定义的可信启动规范。打开构建选项TRUSTED_BOARD_BOOT=1后,ATF会在每一级加载时做签名验证。验证用的证书由tools/cert_create/生成,根信任是ROTPK(Root of Trust Public Key),通常烧录在芯片的OTP/efuse里。
证书链大致是:ROTPK验证BL2证书,BL2证书里包含BL2镜像的哈希;BL2再验证BL31、BL32、BL33各自的证书。为了跑通TBBR,你需要配置GENERATE_COT=1,同时把mbedTLS集成进来。构建命令类似:
make PLAT=fvp TRUSTED_BOARD_BOOT=1 GENERATE_COT=1 \ MBEDTLS_DIR=../mbedtls ARM_ROTPK_LOCATION=devel_rsa \ ROT_KEY=rot_key.pem审计时第一步就是看ROTPK是否真正烧进了OTP、是否开启了写保护。如果ROT密钥还能通过某个固件升级接口刷写,那整条信任链就是假的。
4.2 FIP隐藏的细节往往最致命
FIP格式不复杂,但审计时容易被忽略。用fiptool info和fiptool dump能看到FIP里每个镜像的类型、UUID、偏移和大小。我习惯把每个镜像导出来重新算一遍哈希,和打包时的证书记录对一下,确认加载的镜像确实没被替换过。
还有一点容易被忽视:flash上存BL1的位置和存FIP的位置是否有重叠。如果地址规划失误,BL1加载BL2时会从flash里读到被污染的FIP头,轻则启动失败,重则在安全启动关闭时被恶意数据注入。平台移植时,地址空间布局一定要画清楚,FIP分区不能和BL1、证书分区交叉。
4.3 审计清单:我拿到一套ATF会查什么
下面这个表格是我实际审计固件时使用的自查清单,不一定完整,但可以帮你在安全设计阶段就挡住大部分低级问题。
| 审计项 | 检查内容 |
|---|---|
| 构建配置 | 是否以DEBUG=0发布,调试宏是否残留 |
| ROT密钥 | 是否写入OTP,写保护是否开启 |
| SMC暴露面 | 平台自定义SMC服务是否做了参数校验 |
| 内存映射 | 安全内存是否误映射为Non-Secure可访问 |
| GIC路由 | 安全中断是否可能被路由到普通世界 |
| 页表属性 | 是否存在可写可执行(W^X)违反 |
| BL33加载 | 非安全镜像入口地址是否被硬编码且未校验 |
这些点不需要你是一个安全专家,只要花时间逐项检查,通常能在普通开发者看不到的地方找出问题。比如我曾经在一套固件里发现,平台自定义的SiP服务在接收普通世界传入的地址参数时没有做范围校验,普通世界可以直接传一个安全内存地址,让EL3帮它读。这个问题如果漏掉,TrustZone的隔离就被击穿了一半。
5. 平台移植落地:目录结构、关键接口与最小启动
移植ATF到一颗新SoC,核心目标只有一个:让BL31在板子上跑起来,能稳定输出日志,能从BL33跳转到U-Boot。完成这一步,后面加TEE、加TBBR就有了舞台。
5.1 从FVP起步,不要直接上板子
强烈建议先在FVP上把整个链路跑通,再投真实芯片。FVP是Arm官方仿真器,免费版需要去官网注册下载,它模拟的是一颗Cortex-A系列多核处理器,支持跑ATF、U-Boot和Linux。你可以先在FVP上理解启动流程,再对照移植你自己的平台。
FVP跑ATF的基本启动命令类似这样:
./FVP_Base_RevC-2xAEMvA \ -C bp.flashloader0.fname=build/fvp/debug/bl1.bin \ -C bp.secureflashloader.fname=build/fvp/debug/bl1.bin \ -C bp.fip.fname=build/fvp/debug/fip.bin看到串口输出NOTICE: BL1: v2.9这类日志,就说明ATF在仿真平台上起来了。
5.2 最小移植改动清单
真正做自己的平台时,我会复制一份plat/arm/board/fvp,然后按下面这个顺序修改:
platform_def.h:这是第一个要动的文件。定义CPU数量、UART地址、SRAM/DRAM基地址与大小、FIP加载地址、BL31/32/33加载地址等。这个文件里90%的宏都会在编译期影响到链接脚本,改错一个地址,轻则启动卡死,重则无法编译。plat_helpers.S:实现平台早期汇编初始化,包括plat_my_core_pos(返回当前CPU的编号)、cache操作等。如果你的CPU有自定义的启动配置寄存器,一般在这里操作。bl31_plat_setup.c:实现bl31_platform_setup,在这里完成串口使能、GIC初始化、内存映射表的建立。串口驱动:ATF默认支持16550系列串口,如果SoC用的是自研UART,需要自己写一个console驱动并注册到ATF的log框架。
PSCI电源操作:你的SoC的power controller如果和arm标准参考设计差别不大,可以先让
cpu_on函数只打印日志,不真正执行操作,先把启动流程跑通再填实现。
这里列一个platform_def.h中比较核心的宏示例:
#define PLAT_PRIMARY_CPU 0x0 #define PLAT_MAX_CPUS 4 #define PLAT_UART_BASE 0x12340000 #define PLAT_DRAM_SIZE 0x80000000 #define BL31_BASE 0x80000000 #define BL31_SIZE 0x100000注意这些地址必须和你的链接脚本以及实际SoC地址映射一致。我在真实项目里踩过最狠的坑是UART地址写错,日志完全没有,看起来像死机,实际是固件在初始化串口时写了一个非法地址,触发了EL3同步异常。
5.3 从BL31到BL33的跳转
BL31的所有初始化完成后,CPU最终要交棒给普通世界的U-Boot。这个动作在ATF内部叫"Handoff"。BL31会从BL2传过来的镜像描述符中找到BL33的入口地址,设置好异常级别(通常是EL2),然后执行eret跳入BL33。
如果跳转前MMU配置或异常向量表有问题,U-Boot可能起不来。排查思路是:先让BL33用一个极小代码块替代U-Boot,这个代码块只在入口处循环打印,验证跳转本身是否成功。如果小代码块能跑,说明ATF跳转没问题,再去怀疑U-Boot本身的配置。这种二分头法在bring-up阶段非常高效。
5.4 自己平台上的编译命令
假设你的平台名改成myboard,在根目录下新增plat/myboard/目录,构建命令就是:
make PLAT=myboard DEBUG=1构建系统会自动搜索plat/myboard/platform.mk并编译。如果你的平台不在编译环境里,需要先安装AArch64交叉编译工具链。在Ubuntu等环境下可以这样装:
sudo apt install gcc-aarch64-linux-gnu装完后用make CROSS_COMPILE=aarch64-linux-gnu-指定。注意老方案里还会看到很多人传CROSS_COMPILE=aarch64-none-elf-,那是裸机工具链,也能用,但我个人更推荐带-linux-gnu-的工具链,因为它带的库和工具更完整,调试也更方便。
6. 移植和调试中的真实踩坑记录
ATF移植调试和普通Linux驱动调试最大的区别在于,ATF运行在EL3,很多调试手段用不上。你没法在BL31里挂gdb(除非用仿真器),唯一的窗口经常就是一串串口日志。下面是几个我踩过的坑,每一个背后都有一段白头发。
6.1 用Arm Compiler 5编AArch64固件的历史性错误
先说个工具链问题。很多朋友在找arm compiler 5.06u7这类老编译器,因为老的ARM32项目还在用。但ATF的BL31是AArch64代码,Arm Compiler 5(armcc)默认是针对ARMv7时代的裸机编译器,用它编AArch64 EL3固件不是简单换个参数就行的事,很多场景下它根本编不出正确的AArch64代码,或者编出来在FVP上直接跑飞。
我建议直接把工具链切到aarch64-linux-gnu-gcc。这是一条纯开源工具链路径,没有任何授权困扰。编ATF不需要像编老ARM32项目那样依赖特定收费编译器版本,至少我这两年做ATF移植,全部用GCC,没有遇到过非Arm Compiler不可的情况。
6.2 串口一片空白:从零定位的四板斧
如果你刷进BL1后,串口什么都没有,先别怀疑日志系统,按顺序排查:
- 确认BL1有没有真的跑起来。看flash加载地址是否正确,BL1有没有被BootROM正确拉到SRAM。
- 确认UART物理地址。对照芯片手册核实platform_def.h里的基地址,这个地址错了,串口当然没输出。
- 确认UART时钟。很多SoC的UART需要先使能时钟域,否则写寄存器没反应。
- 确认console驱动注册。ATF在BL1和BL31会各注册一次console,要注意驱动的IO base和时钟频率参数是否匹配。
一个实用的技巧是在BL1入口最开始几行就先输出一个固定字符,比如在bl31_main之前先打印一个B,这样你可以基于"能打哪个字"来判断代码死在哪个阶段。这个方法土,但在bring-up时比任何调试器都直接。
6.3 BL31日志正常,但永远跳不进U-Boot
如果BL31日志打得很欢,U-Boot却死活不起来,优先怀疑BL33的加载地址。U-Boot通常被链接到DDR里的固定地址,而ATF的BL33镜像描述符里记录的加载地址必须和U-Boot的链接地址一致。不一致时,BL2会把U-Boot加载到一个地址,BL31跳转时又跳到另一个地址,直接异常。
这类问题的排查方式很简单:看最后一段日志里BL31打印的BL33入口地址,再用aarch64-linux-gnu-objdump -h u-boot看U-Boot的加载地址,两个地址不一致就调整platform_def.h里的宏。另外,如果你修改了U-Boot但没有重新生成FIP,也会出现"跳转地址对但镜像内容旧"的诡异现象,这种情况在fiptool dump里一眼就能看出来。
6.4 一开启MMU和Cache就崩溃
BL31初始化到一半,会把MMU打开。这个环节非常容易出问题,而且表现形式很不直观:可能在打开MMU后第一条指令就异常,也可能运行几分钟后才随机崩溃。
绝大多数原因是内存映射属性配错了。ATF里用mmap_add_region(base, size, attrs)来建立页表映射,外设地址必须标记为MT_DEVICE,普通内存标记为MT_MEMORY。如果把UART、GIC这类外设映射成了MT_MEMORY,开启Cache后,CPU会擅自缓存外设寄存器的读取结果,你读到的寄存器值可能是几个月前的旧值,代码逻辑就会像疯了一样乱跳。
还有一个隐藏坑:BL31的链接地址、运行地址、加载地址三个值是否一致。ATF对这几者的关系非常敏感。如果BL31加载到DRAM但链接脚本认为它在SRAM运行,打开MMU后第一个页表查询就会触发翻译错误。排查时可以用readelf -h bl31.elf和fiptool dump对比加载地址。
6.5 SMC调用没有返回值:OEN范围与服务注册表的坑
你辛苦写好自定义SiP服务,烧进固件,结果从U-Boot或内核里发SMC调用,一点反应都没有。最常见的两个原因:Function ID的OEN范围超出了注册时声明的范围,BL31查表时根本没匹配到服务;或者服务注册的初始化函数没有被调用,服务描述符压根没进入rt_svc_descs段。
遇到这类问题,第一件事是确认自定义SMC功能的OEN值是落在OEN_SIP_START和OEN_SIP_END之间(SIP的范围通常是2),再确认调用时的Function ID是64位还是32位。ATF里调用格式比较复杂,包含fast call标志位、64位标志位等,一个bit错了,查表结果就完全不同。可以在BL31的smc_handler里先加日志,把收到的Function ID原样打印出来,和自己的预期值对一下就清楚了。
移植ATF这件事,本质上不是在写一个功能,而是在搭一套体系。把BL31跑起来只是第一步,后面还有TBBR、TEE、ff-a、RAS这些扩展在排队。如果能把启动链条里的每一级都吃透,再看OP-TEE、U-Boot、内核的交互,整个系统在你眼里就不再是一个个孤立的镜像,而是一套完整的分层协作。这份源码里藏着的设计思路,值得反复咀嚼。