
做嵌入式安全固件这几年Arm Trusted FirmwareATF是我绕不开的一个坑。不管你做的是智能硬件、边缘网关、车载平台还是服务器主控只要用到TrustZone和AArch64ATF这套开源安全固件基本就是EL3侧绕不过去的默认地基。这篇文章我打算按自己的源码工程笔记来写把ATF的架构全景、安全固件审计思路、以及平台移植落地过程中真正会踩的坑从头到尾讲一遍。如果你是刚接触安全固件、看到BL1/BL2/BL31一脸懵的开发者这篇可以帮你把整个体系串起来如果已经做过UBoot或内核移植想搞清楚ATF和它们怎么配合这篇也能直接给你一份“抄作业”式的落地清单。我把大量篇幅放在实操上尤其是一些官方文档没说透、必须实际调板子才能发现的细节。1. 先搞明白ATF在Arm安全体系里到底扮演什么角色1.1 为什么需要EL3和TrustZoneArmv8-A引入的异常级别Exception Level从EL0到EL3一共四级。大家熟悉的内核跑在EL1Hypervisor/虚拟机监视器跑在EL2实际用户态App在EL0。那EL3是什么用一句话概括EL3是“安全世界”和“普通世界”之间的管理员是所有安全切换的必经之路。TrustZone的核心不是某个单独的软件而是硬件上对总线、内存、外设做安全属性隔离的一套机制。比如某块DRAM被TZASCTrustZone Address Space Controller标记为Secure那么普通世界里所有Master去访问它都会被硬件挡下来。可是谁来配置这块区域、谁来决定当前在Secure状态还是Normal状态这些工作由EL3固件来完成。ATF就是常驻EL3的那一段程序。这里有一个容易被忽略的点所谓“安全”不是指EL3代码本身一定没有漏洞而是说整个系统的安全决策要集中在最小的一块可信代码里并把这部分代码做成可审计、可验证的状态。ATF的责任就是在系统启动早期把各级镜像验证好然后在运行期接收来自普通世界的请求帮忙切换CPU状态、完成电源管理、以及把TEE的调用分发到安全世界。1.2 ATF不是Linux内核那种“一次编译到处跑”的东西ATF在生态里的位置很特殊它介于SoC厂商的BootROM和主流Bootloader之间。BootROM在上电后先执行通常固化在芯片内部ROM里不由开发者修改。ATF作为第一个可替换、可移植的安全固件负责把启动链往下延续。之后才轮到UBoot、UEFI这类大家更熟悉的引导程序。我在SDK里见过很多项目把ATF和UBoot混为一谈实际上它们的边界很清楚如果你只是刷个系统、引导Linux那么不关心ATF也能跑起来——很多SoC出厂BootROM里已经固化了一份BL1。只要你不需要自定义安全启动、不需要PSCI休眠唤醒、不需要装TEEATF确实可以被“透明化”。但一旦涉及量产设备的固件签名、密钥管理、安全世界隔离ATF就必须被当成一个独立的工程模块认真对待。NXP的i.MX8系列、Xilinx ZynqMP这些平台的BSP里都会单独放一份TF-A源码目录构建时首先生成BL31镜像再和U-Boot一起打包成烧录镜像。所以不要把ATF理解成一个单纯的“启动镜像”它实际上是负责从BootROM手中接管安全控制权的一段完整运行时软件。1.3 ATF到底能解决什么问题拿一个实际场景举例手机/设备休眠后内核调用一个PSCI接口让CPU掉电这个请求通过SMC指令进入EL3由BL31里的PSCI服务处理。如果ATF没处理好CPU可能就唤不醒了。再比如你要做安全启动BootROM上电后先验BL1BL1验BL2BL2验BL31和BL33整条链必须环环相扣任何一环的验签缺失都可能导致量产设备被恶意固件攻击。ATF还肩负着普通世界与TEE之间的上下文切换。OP-TEE一类的安全操作系统通常跑在Secure EL1它不能直接访问普通世界的内存也不能直接接收普通世界中断所有双向通信都要靠EL3转发。这一套调度逻辑基本都实现在BL31的runtime框架里。所以在开始看源码之前最好先建立一个认知ATF不是一个“跑完就退出”的引导程序BL1、BL2是阶段性的而BL31是长期驻留在EL3的运行时固件。后面看代码时你会发现BL31里包含了完成世界切换所需的一切状态保存与恢复机制。2. 源码架构全景镜像分层、目录结构与启动链路2.1 BL1/BL2/BL31/BL32/BL33到底是怎么串起来的先给出一张我习惯的分层表这个表基本够用一辈子镜像运行阶段主要职责常见运行环境BL1上电后第一段可替换固件初始化最小环境验签并加载BL2EL3通常是芯片内部RAMBL2BL1验证通过后运行从FIP包中加载BL31、BL32、BL33并验签Secure World早期环境通常不常驻BL31主CPU运行时固件PSCI、SMC分发、世界切换、管理TEE常驻EL3BL32可选TEE OS例如OP-TEESecure EL1BL33最终引导程序U-Boot、UEFI或者直接是LinuxNormal World EL2/EL1很多人一上来就被BL1/BL2/BL31绕晕。其实它们的逻辑很像公司里的审批链BL1是门卫确认BL2身份没问题才放行BL2是行政把后续需要运行的BL31、BL32、BL33都检查好、放到指定位置BL31是长期值班经理之后所有跨部门请求都找它。FIPFirmware Image Package这个概念也很重要。BL2从FIP包中解析各个镜像包内每个镜像都带有元信息用于身份验证。默认构建时我们通常用fiptool工具把BL2、BL31、BL32、BL33打包成一个fip.bin。量产烧录时可以把BL1单独烧到BootROM自带的存储里FIP则放在外部Flash或存储介质上。2.2 TF-A源码顶层目录怎么快速建立地图我第一次打开TF-A源码时也觉得目录很深但其实它把通用逻辑和平台逻辑分得很清楚。核心的几个目录是这样的bl1/ BL1相关代码 bl2/ BL2相关代码 bl31/ BL31运行时框架 bl32/ TEE的spd层相关 plat/ 平台目录移植工作大部分在这里 lib/ 通用库比如el3_runtime、psci、xlat_tables drivers/ 串口、GIC、定时器等驱动 services/ 标准服务、SPD、SIP等 include/ 公开头文件 tools/ fiptool等辅助工具 fdts/ 设备树源文件平台移植时你最需要花时间的是plat/目录。很多SoC厂商会把自己的BSP直接塞进plat/vendor/board这种层级里所以你在网上看社区代码时经常能看到plat/nxp/、plat/arm/这类命名。ARM官方也维护了包括QEMU、Juno、FVP在内的一批参考平台这些平台代码就是做新平台移植最好的模板。2.3 核心代码路径从上电到BL33要真正读源码建议按下面这条路径走一遍比漫无目的地翻目录高效得多BL1入口上电后CPU从Reset Vector开始跑通常对应bl1/aarch64/bl1_entrypoint.S。这里会做最基本的异常向量表、栈指针设置然后进入bl1_main()。BL2加载BL1完成BL2的验签和加载后通过bl2_entrypoint.S进入BL2。BL2会从FIP中读取并验证后续镜像。BL31启动BL2把BL31镜像放到约定地址设置好入口参数然后切到EL3让BL31接管。这里大概率会进入bl31/bl31_main.c里的bl31_main()。BL33移交BL31初始化完成后通过bl31_prepare_next_image_entry()准备BL33的上下文把CPU切到Non-Secure世界跳到U-Boot或内核入口。我建议第一次读源码的人一定要自己去bl31_main.c里加一条打印或者在QEMU上打断点实际感受一下从BL2交接到BL31的时刻。这一步如果理解不了后面做安全审计和平台移植会非常吃力。2.4 源码工程本身的质量值得说几句TF-A的代码风格整体非常规范每个模块之间有明确的接口。比如运行时服务框架定义了一组回调结构任何服务通过DECLARE_RT_SVC注册后就能在SMC分发时被自动调用。这种设计对安全审计有天然的好处新增的服务很容易被识别滥用标准框架的情况反倒少见。但它的学习曲线也很陡原因是宏和回调非常多。你翻services目录时会觉得明明只是打印一条日志却牵涉到console框架、console_register、多控制台优先级等一整套机制。这是为了支持多种物理串口、以及在不同异常级别输出日志付出的复杂度代价。生产环境里不要轻易改这些通用机制否则后续升级上游版本时会让你怀疑人生。3. 安全固件工程审计重点看哪几类问题3.1 先把威胁模型想清楚每次做ATF安全审计我都会先把一个前提写下来普通世界的软件是不可信的。也就是说Linux内核、U-Boot、甚至普通世界的Hypervisor都可能在攻击者控制下。它们能发起SMC调用、能往特定地址写数据、能通过外设发起DMA。ATF作为EL3固件必须假设所有从普通世界进来的输入都可能是恶意输入。从威胁模型出发ATF的审计重点就很清晰了启动链完整性每一级启动镜像是否都做了签名校验密钥是否安全存储是否支持防回滚。运行时服务入口哪些SMC功能ID可以从普通世界调用函数对入参的校验是否完备。内存映射权限BL31的数据段、页表、安全内存区域是否被正确标记普通世界能不能通过某种方式读到。调试通道JTAG、串口、coresight等调试接口是否在量产固件中关闭或是否有安全认证保护。世界切换上下文BL31在保存和恢复CPU上下文时是否可能把安全世界的寄存器状态泄露给普通世界。3.2 启动链审计信任根和防回滚是两条主线从BootROM开始整个链路要有一个明确的信任根。BootROM通常把公钥哈希固化在OTP/efuse里BL1用这把公钥去验BL2的签名BL2再用自己信任的密钥验后续镜像。审计时先看密钥从哪里来是整个产业链中第一件要确认的事。还要关注版本号管理。很多项目只验签不检查版本导致攻击者拿一个旧版本但签名有效的固件降级回去绕过已修复的漏洞。ATF的BL2里通常会把镜像版本信息作为校验数据的一部分。如果平台没有实现防回滚那么即便每级签名都正确整个安全启动的意义也会大打折扣。我在真实项目里见过的情况是产品固件签名做得很完整但OTA升级时没有检查版本号最后设备被降级到了包含已知漏洞的老版本固件。这种问题在代码审计里不容易一眼看出来需要结合升级服务器逻辑和安全固件两部分一起查。3.3 运行时服务审计SMC入口是最容易出问题的位置BL31的SMC分发框架本身是安全的它只负责把调用分发给对应服务。真正的风险在于每个服务怎么处理参数。ATF里的大部分标准服务代码是比较成熟的但厂商经常会新增自定义SIP服务把一些私有功能暴露给普通世界比如读写某个安全寄存器、给RPMB提供密钥、切换安全状态等。审计自定义服务时我一般按这个顺序看服务注册时的SMC功能ID范围是否合理是否误用了ARM预留的ID段。服务入口函数是否校验调用来源。必须确认调用确实来自普通世界的可信通道而不是被劫持的SMC。传入的参数地址、长度、索引有没有做边界检查。有些服务会把普通世界传进来的物理地址直接拿来读写如果地址没做限制内核提权攻击者就能让EL3帮他去读任意地址。32位调用和64位调用是否做了区分。ATF中同一个服务往往要同时支持SMC32和SMC64如果实现时只按一种方式解析参数很容易产生截断问题。这里说一个很典型的场景厂商加了一个SIP服务功能是让内核获取某个安全世界生成的随机数。第一次审的时候代码看着没太大问题——入口校验了调用来源返回值也是通过寄存器正常传递。但再看一眼实现发现它直接从普通世界传入的缓冲区地址拷贝数据却没有检查这个地址是不是属于普通世界可访问范围。攻击者只要伪造一个指向安全内存的地址就能让EL3固件把安全数据写出来。这类问题不在ATF框架层而在服务实现层是审计时最容易漏掉的地方。3.4 工程审计中要主动看的几份关键文件如果你要给别人做一个ATF安全审计建议重点看下面这些内容并形成一份checklistplatform_def.h平台宏定义看安全内存范围、外设地址映射是否合理。plat_setup.c/bl31_plat_setup.c看平台初始化是否调用了TZASC、是否把调试接口关闭。.mk文件看BL31实际链接了哪些源文件有没有把容易出问题的测试代码编进去。services/下的厂商自定义服务这是最容易被植入后门或者留下漏洞的位置。链接脚本.ld.S确认BL31镜像的布局、可执行段是否在可信内存区域。审计完成后最好输出一张问题清单每条问题注明影响范围、利用条件、复现步骤。安全固件审计不能只停留在“代码看了没问题”必须能回答“攻击者最终能做什么”这个问题。4. 工具链选择和开发环境搭建别再拿Keil去编ATF4.1 ARMCompiler老版本与AArch64之间的坑搜ATF相关内容时经常能看到有人问“ARM Compiler 5.06能不能编译ATF”。答案非常明确ARM Compiler 5也就是ARMCC不支持AArch64目标ATF在AArch64平台上根本没法用它编译。很多人被Keil MDK里带的AC5/AC6搞混其实Keil主要面向Cortex-M系列和Cortex-A上的ATF不是一个世界。ATF官方长期支持的编译方式是以GCC为主的交叉编译链此外也支持ARM Compiler 6armclang。在实际工程里我用得最多的是GNU官方的aarch64-none-elf-工具链因为它在社区里验证最充分遇到兼容性问题时能找到最多参考资料。还有一种常见工具链是发行版自带的aarch64-linux-gnu-比如Ubuntu上装的gcc-aarch64-linux-gnu。它也能编ATF但需要注意它默认链接的是Linux环境下的glibc对裸机固件来说只要不把C库链接进来就问题不大。建议工程固定使用一套工具链版本不要频繁更换否则可能因为GCC版本差异导致编译告警变化进而影响构建流程。4.2 构建一个最小ATF镜像需要哪些步骤假设你已经拿到TF-A源码在Linux环境下构建最小的命令是这样export CROSS_COMPILEaarch64-none-elf- make PLATqemu DEBUG1 LOG_LEVEL40 all如果工具链路径不在PATH里需要带上绝对前缀。构建完成后所有产物会生成在build/qemu/debug/目录下其中bl1.bin、bl2.bin、bl31.bin就是最核心的镜像。这里有一个容易忽略的点all目标默认只会编译ATF自身的BL1/BL2/BL31它不会自动帮你准备BL33。如果你需要生成完整的FIP包得先单独编译好U-Boot或UEFI然后用fiptool打包make PLATqemu DEBUG1 BL33../u-boot/u-boot.bin fip如果你在真实SoC上做移植建议先用QEMU/FVP这些虚拟平台把整个编译和引导流程跑通再去折腾硬件。虚拟平台的好处是可以随时复位、打断点、看寄存器不用一遍遍烧Flash。4.3 Makefile里几个对移植影响极大的配置项配置项作用说明DEBUG1是否开启调试版本调试版本会保留符号信息LOG_LEVEL生效LOG_LEVEL40日志详细级别40表示打印INFO级别50是VERBOSE用于调启动流程很好用PLATxxx选择平台目录对应plat/vendor/board目录RESET_TO_BL311上电直接进BL31跳过BL1当SoC BootROM已经承担BL1职责时常用ARM_ARCH_MAJOR8指定架构版本一般为8部分指令差异影响编译ENABLE_PIE1生成位置无关代码有些场景下需要把BL31放到动态地址运行SPDopteed启用OP-TEE调度配合BL32镜像是TEE集成关键配置RESET_TO_BL31这个东西值得单独讲。很多SoC的BootROM已经做完了最基本的安全启动和硬件初始化此时不需要ATF里再来一套BL1/BL2逻辑直接让BootROM验签并加载BL31即可。这种情况下RESET_TO_BL311会绕过BL1代码路径省去大量内存和启动时间但代价是平台必须自己处理好BL31的冷启动初始化不能天真地以为所有寄存器都处于复位默认值。4.4 从IDE到命令行调试ATF的方式与J-Link经验ATF的开发和常规单片机开发差别很大。Keil、DS-5这类IDE可以连接调试器但真正做ATF移植时我多数时间还是在命令行下完成编译、然后通过JTAG/SWD加载ELF文件来单步调试。用J-Link调AArch64平台时有几个经验值得分享。第一先确认SoC的调试接口是否允许外部访问很多安全平台默认关闭了secure debug必须在efuse里开启或者通过证书授权否则J-Link能连上但访问不了内核寄存器。第二如果平台运行在AArch64状态调试器必须支持AArch64一些老版本J-Link固件需要在连接前升级。第三首次调BL31时直接用loadfile加载bl31/bl31.elf然后把断点下在bl31_main()入口通过PC值判断代码是否真的跑到了那里。如果你用的是DS-5可以考虑直接使用它内置的ARMv8调试模板它对ATF源码级调试支持更好。不过DS-5的授权和版本问题会让不少团队劝退所以纯命令行加OpenOCD也是完全可行的路线。5. 平台移植落地手记从QEMU到一颗真正SoC5.1 新平台移植先不要急着从零写代码ATF平台移植最忌讳一上来就从头写。官方在plat/arm/board/下提供了Juno、FVP、QEMU等参考平台绝大多数SoC厂商的BSP也是以某一个参考平台为基础改出来的。一个合理的路径是先用QEMU或FVP把开发环境跑通然后基于一个相近的官方平台复制出你自己的平台目录逐步修改。比如做一颗新的Cortex-A55芯片可以参考qemu平台如果带GICv3就要确认平台代码里GIC版本配置正确。移植前最好看三样东西官方docs/porting-guide.rst目标SoC的Technical Reference ManualTRM同架构芯片的开放BSP代码把这三种资料对照着看能少走非常多弯路。我自己第一次移植时因为没看TRM照着qemu平台改了几天都不对最后才发现目标SoC的UART控制器根本不是PL011而是8250兼容的。5.2 平台目录的最小构成以一个常规新平台myboard为例它至少需要