用过带 TrustZone 的 MCU 做产品的人应该都有同感:刚听说 STM32L5 时,第一反应往往是“它不就是个跑得快一点的 L4 吗”,等看到 datasheet 里 STM32L5 和 TrustZone 这两个关键词组合在一起,才发现事情没那么简单。TrustZone 不是多一个加密外设,也不是给你加一层软件库,它直接把一个 Cortex-M33 内核拆成了两个世界。这个设计对老嵌入式工程师来说,既熟悉又陌生。我最初从普通 STM32 工程迁移到 L5 时,踩过的坑比预想多得多,尤其是“为什么我代码明明没问题,一开 TrustZone 就进 HardFault”这类问题。这篇文章把我在 STM32L5 系列微控制器和 TrustZone 开发入门过程中梳理出来的东西完整写出来,包括启动流程、CubeMX 工程生成、内存划分、中断路由、调试解锁这几个关键环节,希望能帮正准备上手 L5 的开发者少走弯路。
1. 为什么偏偏是 STM32L5 谈 TrustZone:先理解 Cortex-M33 的定位
1.1 从 Cortex-M4 到 M33:指令集相似,但安全模型是全新维度
很多开发者第一次拿到 STM32L552 或 L562 的评估板时,第一感觉是“这玩意儿跟 STM32L4 差不多”,主频 110MHz,带 FPU,跑 FreeRTOS 也挺顺。确实,从指令集角度看,Cortex-M33 对大多数 Cortex-M4 代码是二进制兼容的,你在 M4 上写的裸机逻辑大概率能直接在 M33 上编译通过。但这只是表象。M33 真正的差异,不是多了一条 DSP 指令,而是它实现了 Armv8-M 主线架构里的 TrustZone 扩展,也就是所谓的安全扩展(TrustZone for Cortex-M)。
TrustZone for Cortex-M 的核心思想,是把 CPU、内存、外设、中断统一划分成两个隔离的执行环境:安全世界(Secure world)和非安全世界(Non-secure world)。你可以把这两个世界理解成一套房子的两个独立房间:安全世界是上了门禁的主卧,非安全世界是客厅。客厅里的人可以正常生活,但进不了主卧,也看不到主卧里发生的事。最关键的是,这个隔离是硬件层面的,不是靠操作系统或者软件约定实现的。哪怕非安全世界里跑的是被攻破的裸机代码,它也无法直接读取安全世界保护的内存和外设。
这一点和传统的软件隔离方案有本质区别。以前我们在 MCU 上做安全,通常靠内存保护单元(MPU)加操作系统的权限管理,一旦跑在特权模式下的代码被攻破,整个系统基本就裸奔了。而 TrustZone 的隔离发生在总线层面,非安全代码从物理上无法访问被标记为安全的地址区域和寄存器。即便你在非安全世界写了*(volatile uint32_t *)0x0FFFFFFF = 0xdeadbeef;这种野指针操作,也只是触发一个总线错误,安全区里的数据安然无恙。
1.2 TrustZone 不等于一分为二的内存分区
新手最容易误解的地方在于,以为 TrustZone 就是简单地把 Flash 和 RAM 从地址上砍成两段,一半给安全代码,一半给非安全代码。这种理解不能说错,但远远不够。TrustZone 的隔离维度其实有三个:
- 地址空间隔离:通过 IDAU(实现定义属性单元)和 SAU(安全属性单元)定义哪些地址属于安全、哪些属于非安全。
- 外设隔离:通过 GTZC(全局 TrustZone 控制器)控制每个外设寄存器是安全属性还是非安全属性。
- 中断隔离:NVIC 里每个中断都可以被配置成安全中断或非安全中断,非安全代码无法操作安全中断的使能和挂起状态。
这三个维度叠加,才构成了完整的隔离边界。换句话说,你不仅要决定“代码放哪里”,还要决定“外设给谁用”“中断归谁管”。所以在 STM32L5 上做 TrustZone 开发,本质上是在做一套系统级的资源划分方案,而不是简单地改几行代码。
2. 启动那一刻发生了什么:安全世界优先和系统初始化路径
2.1 复位向量与安全状态跳转
STM32L5 上电复位后,CPU 默认从安全世界开始执行,或者说,取指地址只要落在被标记为安全的 Flash 区域,CPU 就处于安全状态。这是 TrustZone 的一个核心原则:安全世界先运行,非安全世界的代码必须先由安全世界的代码“放行”才能运行。
完整的启动路径大致是这样:
- 复位后,CPU 从安全向量表中取出初始 SP 和 PC。
- 如果使能了安全启动(Secure Boot),启动 ROM 里的固件会先对用户安全镜像做校验,校验通过后才跳转过去。
- 安全镜像运行后,配置 SAU、GTZC、中断目标安全状态,并把非安全区域、NSC 区域以及外设访问权限一一设置好。
- 最后,将系统状态切换到非安全世界,跳转到非安全向量表,执行非安全应用。
这里面有个容易被忽略的点:非安全代码其实“不会自己启动”。如果你用 STM32CubeProgrammer 只烧录一个非安全镜像,而不烧录安全镜像,板子大概率是跑不起来的。因为复位后首条指令必须在安全区域,而你没有提供任何安全代码来引导和切换。
我刚开始调 L5 的时候就犯过这个错。用 CubeMX 生成了一个只包含非安全工程的版本,烧进去以后调试器能看到复位向量,但一运行就不知道飞到哪里去了。后来才意识到,TrustZone 模式下系统的入口必须是安全世界的镜像,哪怕你的产品逻辑全部在非安全世界,也至少要保留一个极简的安全引导代码,完成初始化和世界切换。
2.2 SAU、IDAU、GTZC:三道关卡各管什么
在 STM32L5 上,地址到底算安全还是非安全,不是单一模块说了算,而是由 IDAU 和 SAU 共同决定。
- IDAU 是芯片厂商实现的硬件逻辑,它把地址空间预先划分成固定属性。在 STM32L5 里,Flash 和 SRAM 的某一段被硬件预设为安全地址,另一段预设为非安全地址,这个划分和选项字节中的 SECWM 相关。
- SAU 是 Cortex-M33 内核内的可编程单元,由安全软件在运行时配置。它可以在 IDAU 预设的“非安全”区域内,重新划出若干子区域标记为安全。注意,SAU 只能把非安全区域改成安全区域,反过来不行。这是为了保证硬件底线不被软件破坏。
- GTZC 则管理外设级别的访问权限。它决定某个外设的寄存器组是属于安全还是非安全,也决定每个总线主机能不能访问受保护的外设。
一句话总结:IDAU 定硬件底线,SAU 做灵活覆盖,GTZC 管外设访问。实际排查问题的时候,如果某段内存读写出错,就要按这个顺序层层检查:先看地址映射关系有没有配置错,再看 SAU 区域是不是覆盖了不该覆盖的地址,最后看外设的 SECCFGR 位是不是把外设设置成了安全属性却不小心让非安全代码直接操作。
2.3 NSC 和 SG 指令:安全函数怎么被非安全代码安全调用
TrustZone 两个世界之间不能直接互调函数。非安全世界想调用安全世界里的服务,不能简单地BL secure_function跳过去,否则会因为状态切换不合法直接触发硬件错误。Arm 提供的标准方案是 NSC(Non-secure Callable)区域配合 SG(Secure Gateway)指令。
NSC 区域是一段位于安全内存中、专门给非安全世界“入口”用的地址块。在这段区域内,必须放置一条 SG 指令,后面跟一个安全的函数入口。非安全代码跳转到 NSC 区域后,CPU 执行到 SG 指令,会完成从非安全状态到安全状态的转换,然后才进入真正的安全函数。整个过程可以类比成一个安检通道:非安全代码只能走到通道口,刷了 SG 指令这张“通行证”,才能进入安全区域内部。
在 CMSE(Cortex-M Security Extensions)规范下,编译器还能自动生成所谓的“安全网关”函数。GCC 里用-mcmse编译选项并给函数加特定属性,编译器和链接器会帮你生成 NSC 区域和入口表。我第一次用 GCC 工程时没加-mcmse,结果非安全世界调用安全函数时直接 HardFault,后来一查,链接脚本里压根没有 NSC 区域。这个细节没有报错提示,非常容易踩坑。
3. CubeMX 生成 TrustZone 工程的手把手流程
3.1 工程初始化与 TrustZone 选项
现在 STM32CubeMX 对 TrustZone 的支持已经相当成熟,基本不需要手写链接脚本和启动文件。以 STM32L552 为例,新建工程后在 SYS 配置里可以看到 TrustZone 相关的使能开关。开启后,CubeMX 会同步做几件事:
- 自动生成安全工程和非安全工程两个独立项目。
- 在安全工程里生成启动文件、系统初始化代码和 SAU/GTZC 初始化函数。
- 为用户划分 Flash 和 RAM 地址范围,提供可调的安全/非安全边界。
- 根据引脚和外设配置自动生成
tznsec.c等安全相关代码。
这里要注意一个选择:你希望非安全应用跑什么?如果你的产品逻辑大部分在非安全世界,那外设和中断的分配也要跟着走。CubeMX 的 Pinout 视图里,每个外设都可以设置成 Secure 或 Non-secure 属性,这个属性决定了 GTZC 的默认配置。比如你想让非安全世界直接操作 UART,就把 UART 设成 Non-secure;如果你希望 UART 完全由安全世界控制,非安全代码碰都不能碰,就设成 Secure。
3.2 生成出来的两个工程怎么分工
使用 CubeMX 生成 TrustZone 工程并打开 IDE 后,你通常会在工作区里看到至少两个项目:
| 项目 | 作用 | 启动入口 |
|---|---|---|
| Secure 工程 | 安全引导、SAU 配置、世界切换 | Flash 起始地址(安全区) |
| Non-secure 工程 | 用户业务逻辑、RTOS、协议栈 | 非安全区起始地址 |
如果你的开发环境是 STM32CubeIDE,还会看到一种安排:一个 Boot 工程加 Secure 工程加 Non-secure 工程。Boot 负责最底层的引导和镜像校验,Secure 工程提供安全服务,Non-secure 工程跑业务。生成后需要先编译 Secure 工程,再编译 Non-secure 工程,最后把两个烧录文件按地址合并烧录。合并这一步,CubeProgrammer 的选项字节和外部烧录器都能处理,手动操作时不能搞错烧录地址。
很多教程会直接把 Secure 工程里的 main 函数当成“主程序入口”,其实更准确的理解是:Secure 工程是“系统初始化引擎”,它做完安全配置后,会通过一个函数调用跳转到非安全工程的入口,类似TZ_JumpToNonSecure()。你在 Secure main 里写得最多的代码,不是业务逻辑,而是安全属性配置、内存保护配置和世界切换逻辑。
3.3 最小验证:用一个 GPIO 跑通两个世界
理论说太多容易飘,我建议第一个 TrustZone 工程别急着做复杂功能,先让一个 GPIO 在两个世界各亮一个 LED。操作步骤可以参考这条链路:
- 在 CubeMX 里把 PA5 设为 Secure 属性、PA6 设为 Non-secure 属性。
- 在 Secure 工程中初始化 PA5,并配置 SAU 和 GTZC,确认 PA6 的 GPIOB 时钟访问被授权给非安全世界。
- 调用跳转函数进入 Non-secure 工程,在 Non-secure main 里初始化 PA6,循环闪烁。
- 如果 PA5 正常、PA6 无反应,先检查 GTZC 里 GPIOB 是否被配置为安全属性,再检查非安全工程里能否读到相关寄存器。
这个验证虽然简单,但能一次性覆盖地址属性、外设访问属性、世界切换这三大核心配置。我见过不少项目在这个环节就卡住了,原因大多是 GPIOB 外设被默认设置成 Secure,而非安全代码直接操作寄存器,导致总线错误。
4. 从普通工程迁移到 TrustZone 的典型翻车现场
4.1 链接脚本和启动文件的隐藏变化
老项目迁到 TrustZone,最常见的心态是“Core 代码直接拷过去就行”。实际上代码本身大概率不需要改,但工程的构建配置必须改。普通 STM32 工程的链接脚本通常只有一段 Flash 和一段 RAM,而 TrustZone 工程的链接脚本至少包含以下区域:
- 安全 Flash(Secure FLASH)
- 非安全 Flash(Non-secure FLASH)
- 安全 RAM(Secure RAM)
- 非安全 RAM(Non-secure RAM)
- NSC 区域(Non-secure Callable,可选)
启动文件也要注意。Cortex-M33 的启动文件在 TrustZone 模式下,中断向量表可能需要分成安全和非安全两张表。Flash 起始处放的是安全向量表,非安全向量表放在非安全 Flash 区域的起始处。非安全代码开始执行时,要能正确取得非安全向量表,否则中断一进来就乱了。
我之前把一个 M4 工程直接拷到 M33 上编译,链接脚本没改,结果生成的镜像把代码全部放在安全 Flash 范围,非安全工程链接时报地址冲突。后来把 FLASH 区域按 CubeMX 的默认划分拆开,才恢复正常。这种东西手册里写得清清楚楚,但不实际操作一遍根本不会往那个方向想。
4.2 中断和 NVIC:安全中断与不可信的通道
TrustZone 模式下,NVIC 的每个中断都有独立的“安全目标”属性。某个中断是安全中断还是非安全中断,必须在安全世界代码里配置。非安全代码可以去配置非安全中断的优先级、使能、挂起,但碰不了安全中断。
这里埋着一个大坑:你的外设驱动代码可能是直接从旧项目拷过来的,里面大量使用了HAL_NVIC_EnableIRQ()。如果这个函数运行在非安全世界,而对应的中断被配置成了安全中断,那么调用时表面可能没什么异常,但中断永远不会触发,或者直接触发 SecureFault。排查这类问题最快捷的方式,是在调试器里看 NVIC 配置寄存器的状态,确认中断的目标世界和当前执行代码所在的世界是否匹配。
更隐蔽的情况是,某些外设中断用于唤醒低功耗模式,而 RTC、LPTIM 这类外设常被划分到安全世界。如果你在非安全世界调用HAL_RTC_SetAlarm_IT(),中断标志位可能已经置位,但由于 GTZC 和 NVIC 属性配置不对,中断到达不了非安全代码,系统就卡在睡眠状态醒不过来。
4.3 外设访问权限:为什么“时钟能开但寄存器写不了”
这个坑我印象最深。刚开始调 STM32L5 时,我在非安全代码里调用__HAL_RCC_GPIOB_CLK_ENABLE(),时钟使能正常,但紧接着写 GPIOB 的 MODER 寄存器时,数据就是写不进去,读出来的值始终不变。当时一度怀疑芯片坏了。
后来才明白,这是 GTZC 在起作用。GTZC 里每个外设的访问权限不仅包括“这个外设归谁管”,还涉及“非安全代码能不能配置时钟”。很多情况下,时钟使能位本身挂在 RCC 的安全属性下,非安全世界能读到状态,但配置位是只读或被屏蔽的。换句话说,非安全世界看起来“好像能操作”,实际被硬件物理屏蔽了。
遇到这种问题,先别急着怀疑代码逻辑,按这个顺序排查:
- 查看 GTZC 里该外设的 SECCFGR 配置,确认外设是否被标记为安全。
- 查看 RCC 的访问属性,确认非安全代码是否有权写配置位。
- 查看该外设中断在 NVIC 里的目标世界,确认中断是否可达。
- 确认使用的是非安全版本的 HAL 库,而不是直接混用了安全/非安全两套库。
5. 调试与安全选项字节:最容易把一颗新芯片闷死在板上的操作
5.1 TZEN、RDP、SECWM 到底是干嘛的
STM32L5 的 TrustZone 功能虽然默认存在,但真正启用它,是在选项字节里把 TZEN 置为 1。与它配套的还有 RDP(读保护)和 SECWM(安全区域水印)这两个选项字节字段。
- TZEN:TrustZone 使能位。开启后芯片进入 TrustZone 模式,复位后 CPU 从安全世界启动。
- RDP:读保护等级。在 TZEN 置 1 的情况下,RDP 一般会被自动设定到一个中间等级,防止非安全调试器直接读取安全区域数据。
- SECWM:Flash 安全区域水印。它把 Flash 从物理上划分出安全和非安全两个区域。注意,这个划分发生在选项字节层面,甚至早于软件里的 SAU 配置。
这里有一个非常重要的操作逻辑:不要一开始就急着把 RDP 调到最高保护等级。调试阶段先用最低保护等级,确认功能正常后,最后发布前再提升保护。否则一旦 RDP 等级升高,你可能连烧录和调试都做不了,只能先执行全片擦除再恢复,所有数据全没。
5.2 烧录时被读保护的现场
TrustZone 模式下的烧录和普通 STM32 有区别。普通 STM32 的烧录只要用 ST-Link 连上 SWD 接口,CubeProgrammer 点两下就完事。但 L5 开启 TZEN 后,CubeProgrammer 在连接时默认会先读取选项字节和 Flash 内容。如果 RDP 等级高于当前调试权限,或者 Flash 区域被标记为安全,读取操作就会失败,软件会直接跳出“无法连接”的错误。
应对方法是用 CubeProgrammer 的“选项字节”视图,先把 RDP 调回无保护状态,再重新连接。如果连选项字节都读不到,那大概率是芯片进入了需要全片擦除才能退出的状态。不要慌,这种情况不是芯片变砖,只要没有开启真正的 Level 2 保护,全片擦除后通常能恢复,但代价是板子上的所有调试信息和暂存数据全部丢失。
5.3 取消保护的正确顺序
如果你想把一颗 L5 从 TrustZone 模式完全恢复到普通模式,正确的顺序是:先取消 RDP 保护,再关闭 TZEN,最后执行全片擦除。顺序反了会出现一个很尴尬的局面:你关了 TZEN,芯片变成普通模式,但 RDP 还停留在保护等级,Flash 依然读不了,烧录器也连不上。
我第一次做这个操作时就犯了顺序错误,结果 ST-Link 连不上目标板,最后只能按住复位键,在全片擦除窗口期才拉回连接。后来我把操作顺序固定成下面这条链路,再也没出过问题:
- 打开 STM32CubeProgrammer。
- 在选项字节页面,把 RDP 设置为 Level 0(无保护)。
- 确认 TZEN 可以被修改后,把 TZEN 置 0。
- 执行 Mass erase 全片擦除。
- 重新上电,芯片回到普通 STM32 的调试状态。
这个操作只适用于开发调试阶段。到了量产阶段,取消 RDP 和关闭 TZEN 属于破坏性操作,会直接清空安全固件,需要有一套专用的产测工具和授权流程来管理。
6. 给实际项目的一点建议:TrustZone 不是安全终点
当你能用 CubeMX 生成工程、跑通 Secure 和 Non-secure 切换时,TrustZone 开发入门这一步就算完成了。但我想多提醒一句:TrustZone 只是给安全提供了一个硬件底座,产品是否真正安全,取决于你在两个世界里怎么划分资源、怎么管理密钥、怎么处理攻击面。
我的建议是,初期不用追求把整个系统都塞进安全世界。安全世界的代码越少,审计和分析的成本越低。合理的设计是把安全世界当作一个“保险箱”,只放密钥、证书、安全固件升级校验、安全日志这类核心资产;把 RTOS、协议栈、GUI 甚至用户业务统统放到非安全世界。这样即使非安全世界被攻破,攻击者拿到的也只是部分系统控制权,核心机密依然留在安全世界里。
另外,调试阶段一定要记住两句口诀:一是“永远先配好选项字节再写业务代码”,二是“安全保护等级是发布前最后一步打开的”。我自己在项目初期因为急着调功能,提前把 RDP 等级拉高,结果每次烧录都要先擦片再重来,浪费了大量时间。实际开发顺序应该是:先用默认保护等级调通功能,再逐一打开安全启动、读保护等高级选项,最后在出厂前完成最终安全配置和应用签名。
STM32L5 的 TrustZone 开发,说到底是工程设计问题,不是简单的代码编写问题。它逼着你把一个 MCU 当成一台“带门禁的小系统”来设计,提前想清楚哪些资源需要保护、哪些资源可以开放、安全世界和非安全世界如何通信。这部分设计做得越早,后面的开发越顺畅。如果你正准备做带安全功能的嵌入式产品,L5 这颗芯片确实是目前学习 TrustZone 性价比最高的平台。