做嵌入式这些年,只要碰到带 TrustZone 的 MCU,多数工程师第一反应都是头疼。尤其看到 2023 STM32 峰会那份《STM32 MCU TrustZone 开发调试技巧分享》的资料时,我一度以为又是满篇 PPT 概念,结果翻完发现里面全是调试器怎么接、SAU 怎么配、双工程怎么断点这种实打实的内容。这篇文章就围绕这份资料,把我实际跑 TrustZone 工程时积攒的经验、踩过的坑、以及调试器层面的关键细节,完整整理一遍。如果你正准备上手 STM32L5、STM32U5、STM32H5 或者 STM32H7 这类带 TrustZone 的芯片,这篇文章能帮你少走很多弯路。
TrustZone 在 Cortex-M 上并不是什么新概念,但真正把它做进普通 MCU 开发流程之后,开发体验和之前的裸机/RTOS 风格差别巨大,尤其是“双工程”和“安全/非安全分区”的引入,会让很多刚接触的人一开始找不到北。好消息是,一旦理解了它的世界划分逻辑和调试器的配合方式,TrustZone 其实比想象中容易上手,甚至能帮你把安全和业务逻辑解耦得更干净。
1. TrustZone 到底在解决什么问题
1.1 传统 MCU 安全方案的短板
过去做 MCU 安全,最常见的手段是读保护(RDP)、唯一 ID 校验、外部加密芯片,再加上一部分代码混淆。读保护能挡住大部分静态读取,但挡不住调试接口被降级或固件被整体搬移;代码混淆则更多是心理安慰,面对有耐心的分析者,效果有限。至于加密芯片,虽然能解决密钥存储,但 MCU 主控一侧的固件一旦被完整 dump,攻击者完全可以绕过认证逻辑,直接 Patch 掉校验跳转。
TrustZone 解决的正是这类结构化问题:它不依赖“不被读走”,而是从体系结构上把系统拆成两个世界,Secure World 和 Non-Secure World。即便攻击者拿到了非安全侧的完整固件,也触碰不到安全侧的内存和外设,更没法直接拿到安全侧的密钥或关键算法。
1.2 Cortex-M 上 TrustZone 的硬件基础
Cortex-M23 和 Cortex-M33 内核在 Armv8-M 架构下引入了 TrustZone 扩展,Cortex-M55 和 M85 进一步强化了 DSP/AI 场景下的安全能力。STM32 这边,STM32L5、STM32U5、STM32H5、部分 STM32H7(如 H7A3/H7B3)以及 STM32MP13 的 Cortex-M33 核心都支持 TrustZone。
它依赖几个关键机制:
- SAU(Security Attribution Unit):用来给整个内存映射打上“安全”或“非安全”的标签,决定 CPU 在哪个世界执行时能访问哪些地址。
- IDAU(Implementation Defined Attribution Unit):由芯片厂商实现,在 SAU 之前先做一层硬性安全归属判断,两者结合决定最终访问权限。
- MPC(Memory Protection Controller):用于保护内部 Flash/SRAM 区域,把物理存储切成安全和非安全区域。
- PPC(Peripheral Protection Controller):控制外设总线级别的安全访问属性,决定外设归哪个世界。
- SG 指令与 Veneer:非安全代码要调用安全函数时,必须通过特定的安全网关(Secure Gateway,SG)跳板机制,防止直接乱跳。
可以这么理解:SAU 是“内存地图上的红绿灯”,决定哪些路能走、哪些路禁行;MPC 是物理存储的“分区管理员”,负责把每一块 Flash/RAM 划归给某个世界;PPC 则是外设的“门禁系统”,外设要被指定给安全世界或非安全世界使用。这套组合拳打下来,即使非安全侧代码完全失控,安全侧的关键数据和代码也依然处于隔离状态。
1.3 双世界隔离能带来什么实际收益
实际项目里最典型的收益是密钥保护。比如做 OTA 固件签名校验、安全启动、TLS 握手时用到的私钥或预置证书,存放在 Secure 世界内部 Flash 里,并用安全代码封装成 API 供非安全侧调用。非安全侧即使被攻破,攻击者也拿不到私钥本身,只能按你允许的频率和参数去调用签名接口,这会大大抬高攻击成本。
另一个收益是安全启动链的建立。Secure 工程先运行,完成启动镜像校验、信任根建立,再跳转到非安全侧的应用代码。这样即便非安全侧应用损坏或被替换,Secure 侧的校验逻辑仍能拦住非法固件,让系统保持可控。
2. 从工程结构看 TrustZone:双工程、SAU 与安全函数边界
2.1 STM32CubeMX 生成的双工程结构
无论是 STM32L5 还是 STM32U5,只要在 STM32CubeMX 里开启 TrustZone 选项,CubeMX 会自动生成两个工程:一个 Secure 工程,一个 Non-Secure 工程。这两个工程协作完成整个固件,Secure 工程通常负责启动、安全服务、密钥管理、安全分区配置,Non-Secure 工程则承载用户的业务逻辑、协议栈和 GUI 之类。
这里有个容易误会的点:Secure 工程不是“所有错误都在这边”的容器,它更像系统启动的“第一信标”。芯片上电后,CPU 先执行 Secure 工程里的复位向量和初始化代码,比如配置 SAU、MPC、PPC,然后才决定是否跳转到 Non-Secure 世界运行。Non-Secure 工程只有在 Secure 工程设置好允许跳转的情况下才会运行,否则 CPU 永远停留在 Secure 状态。
CubeMX 生成的链接脚本里,Secure 工程和 Non-Secure 工程的 Flash/SRAM 地址范围是错开的。比如 STM32L5 内部 Flash 起始地址是 0x0C0000,Secure 工程默认放在低位地址,Non-Secure 工程放在高位地址。SRAM 同理,Secure 放在低地址,Non-Secure 放在高地址。两个工程的烧录地址必须严格按照脚本分配来,不能由着性子乱放。
2.2 SAU 配置:安全世界的边界就是由它画出来的
SAU 是 TrustZone 的灵魂寄存器组,MCU 上电默认行为是所有地址都被认为是 Secure,除非你在代码里显式配置 SAU 区域将其标记为 Non-Secure。SAU 寄存器包括:
- SAU_CTRL:总开关,ENABLE 位设为 1 时 SAU 才生效;ALLNS 位设为 1 时所有地址默认非安全,这是最“激进”的配置,通常不会直接用。
- SAU_RNR:区域号寄存器,选择当前配置第几个区域。
- SAU_RBAR:区域基地址,需要按照粒度对齐,不同芯片粒度不同,常见为 32 字节或 256 字节。
- SAU_RLAR:区域上限地址 + 安全/非安全属性位(NSC、EN),用来描述这个区域是否可执行 SG 指令。
在 STM32CubeMX 的 TrustZone 配置面板里,你会看到芯片的整个内存映射图,可以直接把非安全工程用到的 Flash、SRAM、外设总线区域拖到 Non-Secure 区域里。CubeMX 生成的代码会自动写入 SAU 寄存器。需要注意的是,SAU 区域不是越多越好,每个区域都需要至少满足对齐和大小限制,硬凑一堆零碎区域反而容易越界。
我自己踩过一次坑:当时想把某段 SRAM 给非安全侧做数据缓冲区,但那段 SRAM 地址没按 256 字节对齐,SAU_RLAR 写入后系统直接 HardFault。后面查了参考手册,发现 RLAR 的低位有粒度限制,地址必须是粒度的整数倍,不是随便什么地址都能当区域边界。所以选 SRAM 区域和 Flash 区域时,最好按照连续的整块区域去划分,避免出现零头。
2.3 安全函数调用:非安全侧怎么进安全侧
非安全侧的业务代码经常需要调用安全侧的密钥计算或安全存储接口,但它不能直接跳进 Secure 区域的普通函数。为了让这种跨世界调用可控,Cortex-M33 提供了 NSC(Non-Secure Callable)区域,配合 SG 指令实现安全跳板。
所谓 Veneer 跳板,其实就是一小段放在 NSC 区域内的代码,它位于 Secure 和 Non-Secure 的交界位置,非安全侧可以把它当作一个合法的“入口”。当 CPU 执行到 NSC 区域包含 SG 指令的地址时,硬件会自动切换到 Secure 状态,并跳转到对应的安全函数实现。这样非安全侧只知道入口地址,不知道安全函数内部实现细节,也没法直接跳到安全函数的任意位置。
用 STM32CubeMX 配置安全函数时,只要在 Secure 工程里用__attribute__((cmse_nonsecure_entry))声明导出函数,编译器就会自动在 NSC 区域生成对应的 veneer。你不需要手写汇编 SG 指令,但了解这层机制对调试很有帮助,因为一旦断点停在这种跨世界跳转的边界上,单步行为会变得非常微妙。
非安全侧调用安全函数的流程可以简化为四步:
- 非安全侧获取安全入口函数的地址(这个地址通常由 Secure 工程在启动时通过某种方式告知,比如固定在安全 RAM 的某个约定位置)。
- 非安全侧用 BL 指令跳向 NSC 区域的入口。
- NSC 区域的 SG 指令触发世界切换,硬件自动进入 Secure 状态。
- Secure 侧校验调用参数后执行安全函数,返回时通过特殊指令切回非安全世界。
实际调试时,最常见的错误是 NSC 区域配置有误,导致非安全侧一跳进去就触发 UsageFault。后面我会在问题排查部分详细展开。
2.4 中断与外设的归属分配
TrustZone 工程里,中断向量表同样要分安全和非安全两份。Secure 工程在启动阶段就把非安全世界的中断向量表地址告诉 NVIC 的某些寄存器,当系统运行在非安全世界时,非安全中断走它自己的向量表,安全中断则仍然由安全侧处理。
每个外设中断的归属由中断控制器的 Security 属性决定。在 STM32 上,很多外设中断可以单独配置为 Secure 或 Non-Secure,怎么分配取决于你的业务模型。如果某个外设完全是非安全侧的业务外设,那它的中断也应当分配给非安全世界,这样非安全侧可以直接处理中断,不需要每次中断都请求安全侧介入。
但如果某个外设同时被两个世界访问,比如非安全侧用 DMA 搬运数据,安全侧需要对数据做加密,就必须把 DMA 触发的外设中断设计得更仔细。一个常用的做法是:DMA 由非安全侧配置,但 DMA 操作的内存区域必须是 SAU 标记为非安全的地址,否则 DMA 访问会触发安全违规。这看起来像是“非安全侧碰了安全侧的东西”,实际上是因为 SAU 的访问控制和 DMA 的物理访问路径都要过一遍安全属性检查,任何一侧不满足都会被总线拒绝。
3. 调试器配置与多工程联合调试
3.1 多工程调试:Keil 与 IAR 的两种玩法
TrustZone 双工程的调试和普通单工程不一样,最明显的差别是:你往往需要同时加载 Secure 工程和 Non-Secure 工程的调试信息,才能在跨世界跳转时正确解析函数名和源码位置。
Keil MDK 的推荐做法是在 IDE 里以 Secure 工程为主工程,同时把 Non-Secure 工程生成的 axf/elf 文件加载为调试符号或辅助加载文件。打开 Debug 选项卡,在“Load Application at Startup”之外,手动添加 Non-Secure 工程的调试符号文件,这样断点可以落在非安全工程源码行上,单步跨过跳转时也能看到正确调用栈。
IAR EWARM 的做法类似,一般是在菜单 Project -> Debug 里配置 Multiple project,把两个工程关联到同一个会话中。也可以直接打开两个工程实例,分别 attach 到同一个调试会话,只是需要手动切换当前活跃的工程。
我自己的经验是,前期开发时最好始终从 Secure 工程的复位启动开始调试,因为非安全代码依赖 Secure 侧的分区初始化和跳转逻辑。如果你单独调试 Non-Secure 工程,硬件没有完成 SAU 配置,非安全地址访问会被拒绝,调试毫无意义。
3.2 调试时的复位类型选择
Cortex-M33 带 TrustZone 的复位行为有两种常见类型:一种是 Core Reset,只复位内核,不改变安全属性状态;另一种是 System Reset,复位整个系统,包括 TZ 状态和 SAU 配置。
调试器里设置复位策略时,要明确选择“connect under reset”或“reset and halt”模式。有些场景下,比如你只想重新跑一遍非安全应用,但 Secure 侧已经初始化好了,就不要使用会清空 TZ 状态的总复位,否则又得从 Secure 启动开始跑。反过来,如果 Secure 侧配置有改动,必须做完整复位,把 SAU 重新初始化一遍。
之前的调试经历里,我曾在 Secure 侧修改了 SAU 配置后只做了内核复位,结果 Non-Secure 访问旧地址全部 HardFault,排查了半天才发现问题出在复位类型上。从此养成了习惯:改完 TZ 相关代码,一律全系统复位,别省这一步。
3.3 断点、单步和 Watch 窗口的微妙之处
TrustZone 对调试器最直接的影响是断点。如果你在 Secure 世界跑,普通调试器用硬件断点(FLASH)或软件断点(SRAM)都有效,但断点地址落在 Non-Secure 区域时,Secure 状态下的 CPU 可能无法触发断点,因为该地址在安全属性上不允许 Secure 访问。
另一个坑是单步。当你执行到跨世界跳转指令(BL 到 NSC 入口)时,单步可能只会停留在跳转指令本身,而不会进入 Secure 函数内部,这和编译器生成的安全调用封装有关。遇到这种情况,可以直接在 Secure 函数体内手工打一个断点,然后继续运行,等断点命中。这样虽然少了连续性,但能准确定位问题。
Watch 窗口查看变量时,也要注意地址的安全属性。如果调试会话以 Secure 状态运行,Watch 窗口读取 Non-Secure 地址多半会失败,显示“Cannot access memory”一类错误。这时候不用慌,切换到非安全世界的执行状态后再读,或者直接在代码里用__TZ_get_CPU_NS_State()之类的接口去探测状态。
3.4 调试器连接 SWD 的注意事项
STM32 的 TrustZone 芯片在调试接口上也有自己的脾气。SWD 调试接口在设备处于 Secure 状态时权限最高,可以自由读写安全和非安全内存;如果设备当前处于 Non-Secure 状态,调试器只有有限权限,很多安全内存区域的访问会被拒绝。
调试时最好把 SWD 连接模式设置为“Connect under Reset”,确保调试器在复位状态下直接接管 CPU,否则可能因为固件在运行时把调试接口禁用或配置为受限模式,导致连不上目标板。还需要注意调试接口引脚是否被复用或被代码保护,如果设置了 RDP Level 1,调试器读内存会被限制;RDP Level 2 更严格,调试接口就会被永久关闭,几乎相当于变砖。
4. 开发调试中的常见问题与排查技巧
4.1 非安全代码访问安全地址导致的 HardFault
这是 TrustZone 项目里最高频的问题,没有之一。症状通常很直白:Non-Secure 代码刚跑起来几行,就跌进 HardFault 异常,调试器查调用栈发现非法地址访问。
排查方法就一句话:看 SAU 配置,看地址归属。
先把出问题的地址找出来,对照 SAU 区域表确认它是不是被标记为 Secure。如果是,那非安全侧访问它自然会被拒绝。解决方式有两种:要么把该地址划给非安全侧(修改 SAU 配置),要么让非安全侧通过安全接口来间接访问,而不是直接操作内存。
更隐蔽的情况是 DMA。非安全侧配置 DMA 搬运数据,目标地址却是 Secure 区域的 SRAM,这时 DMA 控制器会返回总线错误,但触发点不一定在 CPU 现场,HardFault 可能滞后出现在某个奇怪的时间点。遇到 DMA 相关 HardFault,一定要优先检查缓冲区地址的安全属性。
4.2 NSC 区域配置错误导致跳转失败
非安全侧调用安全函数时,如果 NSC 区域没有正确配置,SG 指令无法执行,CPU 会进入 UsageFault 或 HardFault。我调试过的一个项目里,Secure 工程把 NSC 区域配置写在了一个被编译器优化掉的初始化函数里,结果非安全侧一调用就崩,最后只能在 SAU 初始化函数前后加断点,才发现代码根本没被执行。
配置 NSC 区域时要注意两个点:
- 该区域在 SAU 里必须标记为 NSC 属性,而不是普通 Secure 区域,也不是 Non-Secure 区域。
- 编译器生成 veneer 函数的地址必须落在 NSC 区域内,如果链接脚本把 veneer 放到了普通 Secure 区域,调用同样会失败。
检查链接脚本里的 NSC 区域放置位置,打开映射文件确认__Veneer相关符号的地址范围,这是最直接的验证方法。
4.3 中断隔离与数据残留
有些项目里,非安全侧拥有某个外设的中断,但该外设的数据缓冲区位于安全 SRAM 中。中断发生时会从非安全世界的向量表跳转到非安全中断处理函数,处理函数访问安全 SRAM 会失败。这类问题比直接访问 HardFault 更隐蔽,因为中断处理函数可能根本没有被触发,你看到的现象是外设不回中断,或者回调函数里的日志打印不出来。
解决办法是在分配外设和中断时,一次性把“外设、中断、数据缓冲区”三者归属到同一个世界。如果非要跨世界,必须设计显式的数据复制接口:非安全侧的数据先拷贝到非安全侧缓冲区,再通过安全调用接口把数据传进去,不要让非安全中断处理函数直接碰安全内存。
数据残留则是另一个容易被忽视的坑。安全侧进程结束时,SRAM 里的敏感数据(密钥明文、会话令牌)不会自动擦除。因为 TrustZone 的安全属性是针对地址的,而不是针对数据的。如果这块 SRAM 后来被 SAU 重新配置成 Non-Secure,之前的残留数据就暴露给了非安全世界。生产级代码一定要在安全上下文退出前主动 memset 敏感缓冲区,或者在分区配置变更时添加擦除逻辑。
4.4 缓存一致性对 TrustZone 调试的影响
STM32H7 这类带 Cache 的 MCU,在 TrustZone 场景下缓存一致性会放大调试困难。比如非安全侧 DMA 写了一块 Non-Secure SRAM,Secure 侧 CPU 读取同一块地址,如果 CPU 的 D-Cache 里还残留旧数据,读到的会是被缓存污染的内容。
排查时最容易出现的现象是:Secure 侧读到的数据和 DMA 写入的不一致,但单步调试时又一切正常,因为调试器访问内存往往自带刷新效果。遇到这种诡异问题,优先检查是不是开启了 DCache,并考虑在跨世界数据交换前执行SCB_CleanDCache()或SCB_InvalidateDCache()。
ICache 同理,如果 Secure 侧代码在 NSC 区域执行,或者通过补丁方式修改了安全函数,ICache 未失效会导致执行旧指令。调试器重置时通常会清 Cache,所以很多问题在重新运行后才消失,这本身就是 Cache 陈旧性的典型表现。
4.5 调试连接被干扰
TrustZone 芯片上如果 Secure 侧代码主动关闭了调试接口,或者启用了 RDP Level 1/2,调试器就会连不上或者只能访问有限空间。开发初期最尴尬的情况是:Secure 侧代码因为某个错误触发了安全锁定机制,导致调试接口直接被禁用。
建议在开发阶段严格禁止启用 RDP Level 2,甚至 Level 1 都最好不要开,一定要等所有功能验证完毕、量产前再启用。如果实在需要在调试阶段验证 RDP 逻辑,先用普通按键或串口命令触发 RDP 升级,不要直接烧录一个将 RDP 固化为 Level 2 的镜像,否则每改一次都得擦除整个 Flash。
5. 开发效率提升与实用心得
5.1 官方资源与社区资料的高效利用
ST 官方提供的 STM32CubeMX 对 TrustZone 支持已经很成熟,CubeL5 和 CubeU5 里都自带大量 TrustZone 示例工程。不要从头移植,直接在官方示例基础上改,能省下大量配置时间。TF-M 的移植示例也很值得参考,它能让你看到一个完整的安全启动、安全存储、密钥管理框架是怎么落地的。
另外,ST 官方论坛和 GitHub 上的 trustzone 示例仓库有不少实战代码,尤其适合排查特定型号的 PPC/MPC 配置问题。
5.2 调试 TrustZone 工程时推荐的最小配置清单
调试 TrustZone 项目时,我总会先确认这么几件事:
- SAU 区域配置是否符合预期,用调试器查看 SAU_RBAR/SAU_RLAR 寄存器的实际值。
- Secure 工程和 Non-Secure 工程的加载地址是否与链接脚本一致。
- 非安全侧调用的第一个安全函数地址是否指向 NSC 区域。
- 当前系统状态是 Secure 还是 Non-Secure,在内核寄存器里看 CONTROL 或执行状态相关字段。
- Cache 是否影响跨世界数据交换。
列一个快速检查表:
| 检查项 | 工具/方法 | 通过标准 |
|---|---|---|
| SAU 寄存器配置 | 调试器寄存器窗口 | 区域属性、NSC 属性与设计一致 |
| 双工程加载地址 | 链接脚本 + Map 文件 | 地址与 CubeMX 分配一致 |
| NSC 入口地址 | 反汇编 + Maple | 入口落在 NSC 区域 |
| 跨世界调用 | 断点停在 veneer | SG 指令正常执行,不触发 Fault |
| DMA 缓冲区地址 | 调试器查看源/目的地址 | 地址安全属性匹配 |
| Cache 刷新 | 调试器观察前后值 | 数据同步一致 |
5.3 团队协作与代码组织
TrustZone 工程天然适合安全团队与应用团队协作:安全团队负责 Secure 工程、密钥管理、安全启动,应用团队负责 Non-Secure 工程和业务逻辑。接口层通过 CMSE veneer 导出的安全 API 来解耦,双方只需要约定好函数签名和调用约定。
这种协作模式下,Secure 工程师必须把安全 API 的边界定义得足够清晰,避免把实现细节暴露给非安全工程。另外要注意 Secure 工程要处理非安全侧的非法调用请求,因为攻击者一定会尝试直接构造一个恶意参数去调用安全接口,所以在每个安全 API 入口做参数校验是必须的,不能只靠外围代码兜底。
在大型项目中,我强烈建议把安全 API 的版本号和管理策略固定下来,这样后续升级 Secure 固件时,非安全侧可以检查 API 版本是否匹配,避免出现安全接口变更但应用还按旧逻辑调用的兼容性问题。
5.4 最后想分享的一个小心得
TrustZone 开发和其他 MCU 开发最大的不同是,它强迫你在设计阶段先想清楚“这个世界有什么、那个世界有什么”。这种思维转换比写代码本身更有价值。很多团队在开始迁移到 TrustZone 时,最容易犯的错误就是把现有代码整个塞进 Secure 工程,然后发现启动流程、外设配置、中断处理全部纠缠在一起,根本剪不断理还乱。但实际上,TrustZone 的初衷是让你划分边界,而不是把一切锁起来。
从调试工具链的角度看,我强烈建议开发阶段多花一点时间把多工程调试环境配好,哪怕配置调试器会花掉半天时间,也远远好过之后每次排查问题都要靠 Log 输出猜原因。TrustZone 的很多 bug,尤其是安全属性配置错误、NSC 区域跳转失败,都是可以从调试器窗口直接看出来的。
最后再分享一个实际调试时的偏好:我通常会先把 Secure 工程的 SAU 配置做成可改的调试接口,比如通过串口命令动态增删非安全区域,这样前期调业务时不用反复重刷 Flash,等所有功能稳定了再把调试接口收掉。TrustZone 开发最忌讳一上来就堆安全策略,先把业务跑通、再逐步收紧安全边界,是更现实也更高效的路径。