STM32L5 和 TrustZone 组合起来确实是块硬骨头,资料虽然不少,但大多数都零零散散,看完容易一头雾水。我当初从零开始摸这块芯片的时候,光是把“安全世界”和“非安全世界”这两个概念理清楚,就花了不少时间,更别提第一次配置工程时踩过的那些坑了。
这篇应用笔记,我就把 STM32L5 的 TrustZone 开发入门路径完整梳理一遍。从核心概念、开发环境准备,到一个带 Secure Boot 的最小工程怎么搭、怎么调,再到那些特别容易让人卡住的疑难杂症,一次性讲清楚。这篇文章适合刚开始接触 STM32L5 或者想尝试 TrustZone 开发的嵌入式工程师,也适合正在评估下一代物联网设备安全方案的产品和技术负责人。
1. 内容整体设计与思路拆解
1.1 为什么是 STM32L5 和 TrustZone 这对组合
先说个实际痛点。以前做 MCU 产品,安全设计基本靠软件:写个加密库、加个校验算法、再用读保护锁死 Flash。这套方案不是说没用,但它有个天然缺陷——软件跑在同一个世界里,一旦攻击者通过漏洞拿到了 CPU 的控制权,那整个系统就裸奔了。你的密钥也好、算法也好、安全启动逻辑也好,全都暴露在对方面前。
TrustZone 做的事情,是在硬件层面把系统劈成两个世界:安全世界(Secure World)和非安全世界(Non-Secure World)。安全世界里跑的是可信代码,比如安全启动、密钥管理、加解密服务;非安全世界里跑的是常规应用,比如通信协议栈、用户界面、业务逻辑。非安全世界里的任何操作,哪怕是 CPU 跑飞了、被攻击者注入恶意代码了,也碰不到安全世界的内存和外设。这属于硬件强制隔离,不是软件“尽量隔离”,性质完全不同。
STM32L5 是意法半导体第一批把 Arm Cortex-M33 和 TrustZone 技术结合起来的 MCU 系列。Cortex-M33 核心本身支持 TrustZone 指令扩展,配合 STM32L5 内部的 TZSC(TrustZone Security Controller)、GTZC(Global TrustZone Controller)、SAU(Security Attribution Unit)这些外设,就能在 0.25 微安级低功耗模式下,依然保持完整的安全隔离能力。这个组合非常适合做物联网终端、智能门锁、支付终端、工业控制器这类既要低功耗又要强安全的设备。
1.2 硬件隔离与软件分区的核心思路
TrustZone 的整套逻辑,其实可以类比成一座房子里的两道门。非安全世界住着普通住户,安全世界放的是保险柜。普通住户可以在客厅自由活动,但通往保险柜的门是硬件级别的防爆门,普通住户手里没有钥匙,砸也砸不开。TrustZone 的隔离机制就是把“防爆门”直接焊死在芯片内部,而不是靠门口保安(软件)的自觉。
具体到寄存器层面,STM32L5 的每个物理地址空间都会被标记为安全或非安全属性。CPU 访问某个地址时,硬件会自动检查当前状态和地址属性是否匹配。不匹配的访问会直接触发一个安全错误(SecureFault 或 BusFault),然后进入异常处理流程。
这里有几个关键部件:
- SAU:负责给整个 4GB 地址空间进行安全属性分区,它更像顶层设计者,划定大块区域的归属。
- IDAU:芯片厂商在硬件层面实现的属性单元,它定义的是芯片出厂时的安全边界,属于不可修改的硬规则。
- GTZC:管理外设和 SRAM 的安全属性,比如某个定时器归安全世界独占,某个 DMA 通道归非安全世界使用。
- TZSC:配置外设中断的安全属性,同一个外设的中断可以路由到安全或非安全世界里去处理。
这些部件组合在一起,决定了整个系统的安全边界。开发者在配置阶段,主要操作对象就是 GTZC、TZSC 以及 Cortex-M33 内核里的 SAU 寄存器。
1.3 方案选型:裸机开发比 RTOS 更适合入门
很多刚接触 TrustZone 的朋友,一上来就想把 FreeRTOS 跑起来,甚至直接上带 TrustZone 支持的 Mbed OS 或者 Azure RTOS。我的建议是,入门阶段别急,先用裸机最小工程把 TrustZone 的机制摸透,再上 RTOS。
原因很简单。TrustZone 本身就是一套状态机逻辑,Secure 和 Non-Secure 之间通过专用的函数调用指令(SG、BXNS、BLXNS)切换,同时还要管理两个堆栈指针(MSP_S、PSP_S、MSP_NS、PSP_NS)。如果再加上 RTOS 的任务切换和调度器逻辑,两者的复杂度会叠加,出了问题你都分不清是 TrustZone 配置错了还是 RTOS 移植错了。
裸机环境下,状态切换路径是可控的、单线程的,每次 Secure 调用都能清晰地看到“传给谁、返回什么、权限如何切换”。等这套链路完全跑通了,再考虑引入 RTOS,移植时也会更有把握。
2. 核心细节解析与实操要点
2.1 TrustZone 状态切换的底层机制
Cortex-M33 运行时有四种状态:Secure 状态和非 Secure 状态,每种状态又可细分 Thread 模式和 Handler 模式。日常关注的重点是,CPU 如何在这两个世界之间安全地跳来跳去。
从非安全世界调用安全世界函数,必须经过一个叫 NSC(Non-Secure Callable)的特殊区域。这个区域本质是一段位于安全世界、但可以被非安全世界通过 SG 指令进入的代码段。在 STM32L5 的地址映射中,NSC 区域由 SAU 和 IDAU 共同标记,通常放在 Flash 和 SRAM 的边界区域。
从非安全世界调用安全函数的流程是这样的:
- 非安全世界代码执行 BLXNS 指令,跳转到 NSC 区域的入口地址。
- CPU 在 NSC 区域遇到 SG(Secure Gateway)指令后,正式切换到安全世界。
- 安全函数执行完毕后,通过 BXNS 指令返回到非安全世界,同时自动处理安全和非安全堆栈的切换。
整个链路中有三个细节特别容易出错:
- NSC 区域的地址必须 32 字节对齐,否则 SAU 配置会直接报错。
- SG 指令在 NSC 地址里必须位于第一条指令的位置,编译器会自动处理,但如果你手写汇编或者走全静态链接,需要特别小心。
- 安全函数返回时不能随便用 BX LR,必须用 BXNS 指令,因为返回时还要清掉安全世界的状态标记。
这些机制在 ARM 官方文档里叫“Procedure Call Standard for the Arm Architecture”,加上 TrustZone 扩展后细节特别多。我的建议是,第一遍不用把每一个寄存器含义都背下来,但务必要理解状态切换的四个必经步骤,后面排查问题时能少走很多弯路。
2.2 安全与非安全工程的内存分区配置
STM32L5 的 TrustZone 工程,在构建阶段就分成了两个独立的可执行文件:安全工程(S_工程)和非安全工程(NS_工程)。两个工程各自编译、各自链接,最后通过烧录工具把两个镜像烧到不同的 Flash 分区。
内存分区是整个过程中最基础也最关键的配置。以 STM32L552ZE 这款芯片为例,它自带 512KB Flash 和 256KB SRAM。开启 TrustZone 后,Flash 被划分为四个区域:安全 Flash、非安全 Flash、NSC 区域,以及一个用于安全启动的 Boot 区域。SRAM 则分为安全 SRAM 和非安全 SRAM。
典型的分区方案是这样的:
- Flash 0x0C0000 到 0x0C3FFF:NSC 区域,存放可被非安全世界调用的安全函数跳板。
- Flash 0x0C4000 到 0x0FFFFF:非安全应用区域,存放 NS 工程的代码。
- Flash 0x08000000 之前的部分:安全世界代码和 Secure Boot。
这个划分不是固定的,你可以根据实际需求调整。但有几个硬性约束必须遵守:
- NSC 区域的 Flash 地址空间必须是 32 字节对齐的整数倍。
- Flash 的 TZEN 位一旦置位,整个芯片启动后就会处于 Secure 状态,直到 SAU 被配置好才会启用非安全世界。
- Option Bytes 里的 TZEN 位是 TrustZone 功能的“总开关”,默认是关闭的。如果你发现自己的芯片无法启用 TrustZone,优先检查这个位有没有被置上。
在链接脚本层面,两个工程各自有独立的 linker script。S 工程的链接脚本需要把 NSC 区域的地址段单独剥离开,NS 工程的链接脚本则需要确保自己的代码段、数据段完全落在非安全地址范围内。很多朋友直接把默认链接脚本拿过来用,结果 NS 工程的向量表被放到了安全 Flash 区域,一启动就 HardFault,这个坑非常典型。
2.3 外设归属:谁该留在安全世界,谁该放出去
TrustZone 工程里不是所有东西都得留在安全世界。盲目地把所有外设都配成安全的,只会让非安全世界的代码处处碰壁,而且没有实际意义。正确的思路是遵循最小权限原则:能在非安全世界运行的,就放在非安全世界;只有那些真正涉及敏感数据和关键操作的,才留在安全世界。
我通常按这个分类逻辑来划分:
- 密钥存储、随机数生成、加密引擎、安全校验逻辑,必须留在安全世界。对应到 STM32L5 上就是 AES、RNG、OTP(One-Time Programmable)这些外设。
- GPIO、USART、SPI、I2C、定时器这些通用外设,全部划给非安全世界,这样用户应用开发才方便。
- DMA 通道需要特别留意,如果 DMA 的源和目标是安全地址,那么 DMA 控制器本身也必须配置成安全属性,否则访问会被拒绝。
- 中断控制器 NVIC 是基于 TrustZone 感知的,每个中断源都可以独立配置为 Secure 或 Non-Secure。安全外设的中断必须配置为 Secure 中断,否则中断回调进不了安全世界。
还有一个容易忽略的地方:当你在 CubeMX 里把某个外设配置为非安全属性时,它的默认时钟使能可能还在安全控制下。如果非安全代码访问了这个外设的寄存器但时钟没开,会直接卡死在总线访问上。排查这类问题时,优先看 RCC 里对应外设时钟的安全属性配置。
3. 实操过程与核心环节实现
3.1 开发环境搭建与 TrustZone 开关确认
实际操作层面,我建议按下面的步骤来准备环境。
第一步,安装 STM32CubeIDE。版本建议用 1.13 以上的,因为新版本对 STM32L5 系列的 TrustZone 生成支持更完善,代码模板也更成熟。除了 IDE,还需要安装 STM32CubeProgrammer,用于烧录和 Option Bytes 配置。
第二步,确认芯片的 TrustZone 功能是开启状态。芯片出厂时 TZEN 位默认是关闭的,你需要通过 STM32CubeProgrammer,在 Option Bytes 界面里把 TZEN 位设置为 1,同时指定 TZSC 寄存器的初始值。这一步操作完成后,芯片内部的 TrustZone 隔离硬件才会真正启用。
第三步,下载 STM32CubeL5 固件包。固件包里面包含了完整的 HAL 驱动、安全启动示例工程(SBSFU)以及 TrustZone 相关的文档和代码模板。建议用 STM32CubeMX 生成工程骨架,它能自动帮你处理好双工程的目录结构,省去很多手工配置的繁琐事。
这里要特别强调一下,开启 TZEN 位之后,芯片会做一次全擦除,Flash 里的所有内容都会被清空。所以一定要在确认没有重要数据的情况下再操作。如果你用的是开发板,操作前把板子上外挂的 Flash 芯片里的数据也备份一下,养成好习惯。
3.2 使用 STM32CubeMX 生成双工程骨架
CubeMX 的图形化配置界面把 TrustZone 的复杂度降低了不少,但我们还是要弄清楚它每一步到底做了什么。
新建 STM32L552ZE 的工程后,首先在 Project Manager 页面勾选 TrustZone enabled。勾选后,CubeMX 会自动生成两个子工程,名字通常是<工程名>_S和<工程名>_NS。前者是安全工程,后者是非安全工程。
接下来在 Pinout & Configuration 页面,需要手动指定 Flash 和 SRAM 的安全分区。CubeMX 提供了一个图形化的 Memory Map 编辑器,直接拖动边界就能调整安全区域和非安全区域的大小。
我这次演示用的是一个比较典型的配置:
- Secure Flash 起始地址 0x0C0000,大小 64KB,这一段存放安全固件。
- NSC 区域起始地址 0x0C0000,大小 16KB。
- Non-Secure Flash 起始地址 0x0C4000,大小 432KB,这一段留给非安全应用。
SRAM 区域的划分思路和 Flash 一致。Secure SRAM 分配 32KB,剩余的给 Non-Secure SRAM。这里需要注意,SRAM 的安全分区不是简单的地址切割,它还涉及每个 SRAM 区域的边界对齐要求。STM32L5 的 SRAM 安全属性是按 32 字节粒度管理的,因此 SRAM 分区的起始地址必须是 32 的整数倍。
配置完成后点击 Generate Code,CubeMX 会自动生成两个完整工程,包括各自的链接脚本、启动文件和 TrustZone 初始化代码。代码生成后,建议先编译一遍,确认工具链没有问题,再继续写业务逻辑。
3.3 Secure Boot 与安全启动链路设计
入门阶段的安全工程不需要设计得特别复杂,但一个最小可用的 Secure Boot 流程必须包含。
Secure Boot 的作用是在芯片上电后,先由安全世界的代码执行校验逻辑,确认非安全世界的固件是完整且可信的,然后才跳转过去。如果校验失败,系统就停在安全世界里等待恢复。
在 STM32L5 上,最小 Secure Boot 流程可以这样设计:
- 上电后 CPU 从 Secure Flash 起始地址执行,进入 Secure Boot 代码。
- 使用芯片内置的 AES 硬件模块,对非安全固件镜像进行哈希校验和签名认证。
- 校验通过后,配置 SAU、GTZC 和 TZSC,使非安全世界具备运行条件。
- 通过 SCMB(Secure Code Memory Bus)或其他路径,将非安全固件镜像的起始地址和栈指针加载到寄存器。
- 触发跳转指令,切换到非安全世界,开始执行用户应用。
这里有一个细节值得强调:Secure Boot 里的哈希校验,强烈建议使用 MCU 底层的硬件加密引擎,而不是用软件跑一个哈希算法。软件实现不仅慢,还容易有时间和功耗侧信道泄漏。STM32L5 内置的 AES 支持多种工作模式,配合 DMA 使用,整个校验过程可以做到微秒级完成。
在我的演示工程里,Secure Boot 部分我直接把 STM32CubeL5 固件包里的 SBSFU 示例框架拿过来改了改。这个框架封装好了校验算法、密钥管理、固件升级等基础逻辑,但它的通用性很强,需要适配自己的 Flash 分区和密钥烧录方案。如果只是入门验证,也可以先简化跳过分区校验步骤,只做一次简单的栈指针跳转,确保 TrustZone 的隔离链路是通的,再逐步加入校验逻辑。
3.4 非安全世界工程配置与 Secure 函数调用链路
非安全工程这边,在 CubeMX 生成之后,代码结构其实和普通 STM32 工程没有太大区别。区别在于,它引用了一个由安全工程导出的头文件,里面有 Secure 函数的声明和 NSC 区域的地址映射。
S 工程里我定义了一个安全服务函数,用于执行固件完整性校验:
/* secure_services.h */ #ifndef SECURE_SERVICES_H #define SECURE_SERVICES_H #include <stdint.h> /* 定义安全状态返回码 */ #define SECURE_SUCCESS 0x00U #define SECURE_ERROR 0xFFU /* 非安全世界可调用的安全校验函数 */ uint32_t SECURE_CheckFirmware(uint32_t start_addr, uint32_t length); #endif安全工程里的实现如下:
/* secure_services.c */ #include "secure_services.h" #include "main.h" #include "stm32l5xx_hal.h" /* 安全固件校验的敏感信息存放在安全世界 */ static const uint32_t expected_hash[8] = { 0x12345678U, 0x9abcdef0U, 0x0fedcba9U, 0x87654321U, 0xdeadbeefU, 0xcafebabeU, 0x12344321U, 0xabcdef01U }; /* 标记为 nonsecure callable,非安全世界可以调用 */ __attribute__((section("nsc_func"))) uint32_t SECURE_CheckFirmware(uint32_t start_addr, uint32_t length) { uint32_t hash_result[8] = {0}; uint32_t ret = SECURE_SUCCESS; /* 调用硬件 AES 模块计算哈希,这里省略具体实现 */ ret = HAL_AES_ComputeHash((uint8_t *)start_addr, length, hash_result); for (int i = 0; i < 8; i++) { if (hash_result[i] != expected_hash[i]) { ret = SECURE_ERROR; break; } } return ret; }关键点在于函数声明里的__attribute__((section("nsc_func"))),这个属性告诉链接器,把这个函数放进 NSC 区域。链接脚本里 NSC 区域的地址必须和 CubeMX 中的配置一致。
非安全工程这边调用安全函数的方式如下:
/* app.c - 非安全世界应用代码 */ #include "secure_services.h" void app_main(void) { uint32_t ret; /* 从非安全世界调用安全函数 */ ret = SECURE_CheckFirmware(0x0C4000U, 0x20000U); if (ret == SECURE_SUCCESS) { /* 固件校验通过,继续执行用户代码 */ } else { /* 校验失败,进入错误处理流程 */ } }编译顺序上有一个硬性要求:必须先编译安全工程,生成一个叫secure_services.h的接口头文件和包含 NSC 地址映射的符号表文件,然后非安全工程才能正确编译链接。CubeIDE 里可以通过工程依赖配置自动处理这个顺序,但如果你们都用命令行编译脚本,这个先后关系就要靠脚本保证了。
3.5 烧录验证与 GPIO 实证测试
工程编译通过后,烧录方式也有一套完整的流程,和普通 MCU 工程不太一样。
先用 STM32CubeProgrammer 连接芯片,确认 option bytes 里 TZEN 已经置位。然后把安全工程编译出来的S__工程.elf烧录到安全 Flash 起始地址,再把非安全工程编译出来的NS__工程.elf烧录到非安全 Flash 起始地址。
烧录完成后,用调试器连接。这里需要注意,调试器的连接策略也分安全和非安全两种模式。如果你想调试非安全世界的代码,需要在调试配置里设置 TrustZone 相关的初始化脚本,否则 CPU 可能先跑在安全世界里,你的断点无法命中非安全代码。
演示工程的验证逻辑比较简单。我在安全世界里做了一个 GPIO,在非安全世界里也做了一个 GPIO。上电后,安全世界的 GPIO 先拉高,延时 500ms 后拉低,然后跳转到非安全世界。非安全世界启动后,将自己的 GPIO 拉高,并在串口打印一条日志:非安全世界运行成功。
如果 TrustZone 隔离配置正确,会看到安全世界 GPIO 先亮一下,然后非安全世界 GPIO 常亮,串口输出正常日志。如果配置有问题,最常见的情况是 CPU 卡死在安全错误异常里,调试器会停在 HardFault 或者 SecureFault 中断处。
4. 常见问题与排查技巧实录
4.1 安全错误(SecureFault)频繁触发怎么办
这是 TrustZone 开发入门阶段遇到概率最高的问题。安全错误触发的原因通常是两类:一类是代码尝试从非安全世界直接访问安全地址,另一类是安全代码跳转时没有正确使用 BXNS 指令。
排查方法我总结了一套固定流程:
- 在 SecureFault_Handler 里打断点,查看 Fault Status Register 的值,也就是 SCB->CFSR 寄存器的 bit 段。
- CFSR 里的 SFSR 位会标明是哪种安全错误类型。如果显示 INVEP,说明指令执行时试图从非安全世界读取安全指令;如果显示 INVTRAN,说明状态切换指令使用有误。
- 结合 Fault Address Register 查看触发错误的地址,判断是非安全代码访问了安全地址,还是安全代码访问了非法地址。
实测下来,超过 70% 的安全错误都是因为 NSC 区域的配置产生了偏差,或者链接脚本里 NSC 区域的地址和实际代码段地址不对齐导致的。遇到这种问题,不用急着改代码,先核对一下 map 文件里nsc_func段的实际链接地址,再对照 CubeMX 里的 NSC 配置,八成能找到问题。
4.2 NSC 区域跳转不成功或死循环
另一种常见情况是,SG 指令执行后,CPU 并没有进入安全世界,而是直接卡死。通常是因为函数符号被编译器优化掉了,或者函数放在了错误的 section。
例如,你明明声明了__attribute__((section("nsc_func"))),但链接器仍然把这个函数放到了text段里。原因可能是你的链接脚本里没有定义nsc_func对应的输出 section,或者它的地址和 SAU 配置的 NSC 地址不一致。
检查办法是在 map 文件里搜索nsc_func,确认函数的符号地址落在了 NSC 区域范围内。如果发现地址不对,优先检查链接脚本里关于 NSC 段的定义是否和 CubeMX 生成的一致。
还有一种情况是函数被static修饰后,编译器内联优化导致函数体完全消失。在调试 TrustZone 工程时,我建议把所有 NSC 函数都加上__attribute__((noinline)),避免优化带来的不确定性。
4.3 非安全世界中断无法响应
非安全世界跑起来了,但它的外设中断一触发,系统就死机。这个问题的根源在 NVIC 的中断安全属性配置上。
每条中断向量在 STM32L5 里都有一个独立的 Secure/Non-Secure 属性位。如果你把某个外设分配给了非安全世界,但它的中断源仍然被配置为 Secure 属性,那么当外设触发中断时,NVIC 会尝试进入 Secure 中断处理流程,而非安全世界代码没有权限处理这个跳转,系统就挂死了。
解决办法是在 CubeMX 里对外设的中断源单独配置为 Non-Secure。具体路径是 NVIC 配置界面,每个中断源都会有一个对应的 TrustZone 安全属性选项。我一开始也以为外设属于哪个世界,它的中断就自动跟着改,实际上内核的 NVIC 配置和外设的 GTZC 配置是两套独立系统,必须分别设置,这个细节非常容易漏。
4.4 调试器连不上芯片或 Flash 擦写失败
最后一个高频问题出在烧录环节。如果你在配置了 TrustZone 的工程上,使用旧版本的烧录工具,或者工具没有正确识别芯片的安全状态,就会遇到连接失败或者擦写失败的问题。
解决方法是升级到最新版的 STM32CubeProgrammer,并在连接选项里明确指定调试接口为 SWD。连接成功后,使用-ob命令查看和修改 option bytes 时,注意 TZEN 位不要随意关闭,否则会触发全片擦除。
另外,如果 Secure Boot 设置了读保护等级 RDP 2,那么除了 Debug 接口会被完全禁用,连 ICP 也救不回来,只能通过恢复引脚重新设置,甚至直接换芯片。所以调试阶段,我建议把读保护等级控制在 RDP 0,等功能全部验证通过后,再考虑提升保护等级。
4.5 关于性能功耗与选型的实践心得
最后聊一点和 TrustZone 设计相关的选型心得。TrustZone 带来的安全隔离是有体积和功耗成本的。STM32L5 在运行 TrustZone 功能时,由于需要额外的安全状态管理,动态功耗会比关闭 TrustZone 时略高一些。我在实际项目里测过,典型工况下大概高出 1% 到 3%。对于大多数电池供电设备来说,这个消耗完全可以接受。
真正要注意的是 Flash 和 SRAM 的额外占用。为了支撑 NSC 区域和双工程结构,整个代码体积会比普通工程大 10% 到 20%。我在设计产品的时候,建议先在需求阶段就把安全功能需要的外设、Flash 分区大小、RAM 占用估算清楚,否则等硬件定型了再想增加安全模块,就只能换芯片重新画板了。
从我个人的体验来说,TrustZone 的学习曲线确实比普通 MCU 开发要陡峭,但掌握了这套硬件隔离的思路之后,再去设计物联网设备的安全方案,思路会清晰很多。希望这篇笔记能帮你省下几个通宵排查问题的时间。