1. 核心概念与整体设计思路
ARMv8-M TrustZone 是 Cortex-M23/M33 这类内核引入的硬件级隔离机制,我记得第一次接触时还以为它只是 MPU 的加强版,结果翻开手册才发现完全不是一回事。它的隔离层级比 MPU 深得多:内核会把运行状态分成安全(Secure)和非安全(Non-Secure)两个世界,硬件在取指、数据访问、外设访问、异常处理上全部按“当前世界 + 目标地址属性”来做仲裁。换句话说,哪怕非安全侧的代码被攻破,也没办法拿到安全侧内存里的密钥,更没机会篡改安全侧的关键流程。
说实话,ARMv8-M TrustZone 的难点不在于“为什么要做隔离”这种理念问题,而是落地时那些特别细碎的规则。我第一次上手时对着 VTOR_S、SAU_CTRL 这些带 S 后缀的寄存器,还有从来没见过的 SG、BLXNS、BXNS 指令,整个人是懵的。项目里同时冒出安全工程和非安全工程,链接脚本怎么分、NSC 区域放在哪里、中断表到底用哪一份,每一步都能让人卡住。后来我把整个过程按“先定地址属性,再写功能逻辑”的顺序重新捋了一遍,才真正跑通,这篇文章就把这套思路完整写出来。
1.1 TrustZone 在 M 系列上是怎么工作的
M 系列上的 TrustZone 并不是给每个物理内存单独加一把锁,它靠的是一套地址属性仲裁机制。每个地址在硬件眼里都带有安全属性,当 CPU 处于非安全状态时,访问安全属性地址会被直接拒绝;反过来安全状态可以访问全部地址空间。这套属性的来源主要有三个:IDAU、SAU 和 MPU。
IDAU(Implementation Defined Attribution Unit)是芯片厂家在流片时固化的,它决定了芯片上所有内存和外设的“初始安全属性”,这部分软件改不了。SAU(Security Attribution Unit)则是内核提供给软件工程师配置的,你可以把它理解成一张可以改写的属性表,把某个地址段标记成安全、非安全或者 NSC。MPU 在这里反而像配角,它负责在具体运行时做更细粒度的访问控制,但真正决定 TrustZone 边界的还是 SAU 和 IDAU 的配合。
这三者发生冲突时,最终属性取“更安全”的那一个。比如 IDAU 已经把某块外设定死为 Secure,SAU 无论如何也改不成 Non-Secure;反过来 IDAU 默认 Non-Secure 的外设,SAU 可以升级成 Secure。理解这一点以后,很多奇怪的 fault 就有了解释:不是代码逻辑错了,而是配置出来的属性比预想中更严格。
1.2 什么时候值得引入 TrustZone
我判断要不要上 TrustZone,标准很简单:产品里是否存在需要从普通应用里剥离出来的敏感资产。比如物联网设备里的设备证书、固件升级验签逻辑、关键业务密钥,这些如果和业务代码放在同一片平地内存里,一旦应用被攻破就等于全盘暴露。TrustZone 的价值就是把这些资产放进独立世界,非安全侧跑飞了也碰不到。
不过我也要泼一盆冷水:如果你的产品威胁模型很弱,只是为了“听起来很安全”去上 TrustZone,那就得不偿失。双世界开发意味着安全工程和非安全工程之间的函数调用、内存规划、中断分配都要额外维护,成本和复杂度是实打实增加的。我做过的项目里,最适合 TrustZone 的形态是:安全世界只跑一个小而精的服务库,非安全世界跑完整业务系统。反过来,如果硬要把复杂业务逻辑塞进安全世界,调试周期会成倍拉长。
1.3 整体拆解:安全侧、非安全侧、调用入口
实操时先把工程拆成三个角色,能省掉后面 80% 的边界混乱。
- 安全侧固件:承担启动初始化、密钥存储、安全算法、安全外设驱动这些关键职责。
- 非安全侧固件:处理用户交互、网络协议、业务逻辑等非敏感功能。
- NSC 入口:单独划出一小块 NSC 区域,放置专门接待非安全世界调用的入口函数。
这样划分完以后,安全侧不依赖非安全侧的任何逻辑,非安全侧通过明确的入口访问安全服务。很多新人最容易犯的错是先把功能写完再回头想边界,结果安全函数到处调非安全回调,非安全代码又直接扒拉着安全地址,最后只能在两个世界之间反复横跳,改到怀疑人生。先定边界再写代码,这句话在 TrustZone 项目里不是口号,是真的能救命。
2. 环境准备与内存布局规划
2.1 选板和工具链的有效标准
做 ARMv8-M TrustZone 实操,选硬件的第一标准不是主频,而是“内核是否真的支持 TrustZone”以及“厂家 SDK 是否给了双世界模板”。目前最适合入门的还是 Cortex-M33 这类内核,因为资料最全、编译器支持最成熟、调试器处理安全/非安全状态也最方便。M23 同样支持 TrustZone,性能更低,适合做轻量防伪设备,但学习和调试体验会稍微费劲一点。
工具链方面必须支持 CMSE(Cortex-M Security Extensions)编译扩展。CMSE 不是某个厂家的私货,它是 ARM 定义的编译模型,用来辅助生成跨安全边界时的跳转代码和检查代码。理论上你也可以手写汇编实现 SG 指令和 BLXNS,但实际项目中没人这么干。用支持 CMSE 的工具链,配合正确的链接描述文件,编译器会自动帮你把安全入口放到 NSC 区域,把非安全侧的函数指针包装成安全调用目标,大幅减少低级错误。
2.2 内存分区:先从一张地址表开始
动手写代码之前,先把内存分区表画出来。我见过太多项目死在布局混乱上,地址段谁跟谁挨着都说不清,后面 SAU 配置自然就是一笔糊涂账。这里给一个通用布局参考:
| 地址范围 | 大小 | 属性 | 用途 |
|---|---|---|---|
| 0x00000000 - 0x0001FFFF | 128KB | Secure | 安全固件向量表与代码区 |
| 0x00020000 - 0x00020FFF | 4KB | NSC | 非安全可调用入口区 |
| 0x00021000 - 0x0007FFFF | 约352KB | Non-Secure | 非安全代码区 |
| 0x20000000 - 0x2001FFFF | 128KB | Secure | 安全堆栈与安全数据 |
| 0x20020000 - 0x2003FFFF | 128KB | Non-Secure | 非安全堆栈与数据 |
这张表最关键的是 NSC 区域的位置和大小。NSC 区域本身落在非安全可执行范围内,但它被特殊标记为“可以从非安全世界跳到安全世界”的合法跳板。跳到 NSC 区域后必须落在一条 SG 指令上,否则立即 fault。布局时我会尽量把 NSC 区域放在 Flash 中段,前后用不同属性隔开,避免非安全代码区直接贴着安全代码区,减少意外跳进安全区的可能性。
2.3 SAU、IDAU、MPU 三者怎么配合
再强调一遍三者的关系:IDAU 是硬基础,SAU 是软规则,MPU 是辅助约束。IDAU 在流片时定死了大部分系统内存和外设的基础属性;SAU 在 IDAU 允许的范围内调整;MPU 则处理运行过程中动态变化的访问需求。配置 SAU 时如果发现区域和 IDAU 冲突,最终规则取更安全的结果。
实际项目里,我通常第一版只配置 SAU 和启动路径,不急着上 MPU,因为 TrustZone 的边界保护已经能挡住绝大多数的横向访问问题。跑顺之后再考虑加 MPU,把某个世界内部的缓冲区溢出风险也降下来。千万不要一开始就把三套配置全铺开,否则出现问题你根本分不清是哪一层在拦截。
3. SAU 配置实操与 NSC 区域搭建
3.1 SAU 寄存器速览
SAU 的寄存器不算多,日常打交道的主要是 CTRL、TYPE、RNR、RBAR、RLAR 这几个。TYPE 告诉你当前芯片实现了多少个 SAU 区域,RNR 用来选择当前配置的是第几个区域,RBAR 和 RLAR 分别保存区域的基地址和限制地址,外加一个 NSC 标志位和使能位。
RBAR 和 RLAR 里的地址低 5 位必须为零,因为 SAU 区域的最小粒度是 32 字节。实际使用中我习惯把区域对齐到 4KB 或更大,一方面满足 NSC 区域的约束,另一方面也方便在调试器里肉眼核对内存布局。每个区域的属性由 RLAR 里的 NSC 位和 ENABLE 位共同决定:NSC 位为 1 表示这块区域是安全入口跳板,为 0 就是普通安全或普通非安全区域。
3.2 一个可直接参考的 SAU 初始化代码
下面这段代码对应上面的地址表,可以直接放在系统初始化早期执行。以某颗常见 M33 芯片的 CMSIS 头文件为例:
#include "cmsis_compiler.h" void sau_setup(void) { // 配置期间先关闭SAU,避免区域配置到一半硬件就生效 SAU->CTRL = 0U; SAU->RNR = 0; SAU->RBAR = 0x00000000U; // 安全Flash SAU->RLAR = 0x0001FFFFU | (~SAU_RLAR_NSC_Msk) | SAU_RLAR_ENABLE_Msk; SAU->RNR = 1; SAU->RBAR = 0x00020000U; // NSC区域 SAU->RLAR = 0x00020FFFU | SAU_RLAR_NSC_Msk | SAU_RLAR_ENABLE_Msk; SAU->RNR = 2; SAU->RBAR = 0x20000000U; // 安全SRAM SAU->RLAR = 0x2001FFFFU | (~SAU_RLAR_NSC_Msk) | SAU_RLAR_ENABLE_Msk; // 未覆盖区域在SAU层面保持Non-Secure SAU->CTRL = SAU_CTRL_ENABLE_Msk; }这段代码里有几个容易翻车的细节。配置前先把 CTRL 清零,看起来多余,但在反复调试时非常关键,可以避免重新执行这段代码时 SAU 处于半生效状态。RLAR 里的 NSC 位必须按区域类型明确设置,不能默认清零,否则 NSC 区域会被当成普通区域,跨世界调用直接失败。最后,这只是通用示例,真实的芯片可能还有 IDAU 硬安全区域,务必打开芯片参考手册核对 RBAR 和 RLAR 的合法范围。
3.3 NSC 区域为什么必须单独划一块
NSC 区域本质上是安全世界向非安全世界开放的一扇窗。没有这个标记,非安全代码跳进任何安全地址都会触发异常;把整个安全代码区都标记成 NSC,又等于把安全世界的大门彻底敞开,非法软件可以顺着任何入口执行安全代码。NSC 的正确定位是:只放必要的入口函数或跳板存根,并且每条有效入口指令前方都要有 SG 指令保护。
我把 NSC 区域比喻成“接待室”。安全世界把对外开放的功能聚合到一个小文件里,用编译属性标记好,链接器把对应代码段放到 NSC 区域。非安全世界无论想调用多复杂的安全服务,统一走这个接待室。业务函数可以放在安全代码区的其他地方,但访客永远只能先见到接待室,这样安全边界的受控性才成立。
4. 安全侧与非安全侧代码的实现
4.1 安全侧固件的职责与启动路径
ARMv8-M TrustZone 的复位世界通常是安全世界,这也是安全启动能成立的前提。系统上电后,CPU 先在安全世界执行,利用这段窗口完成密钥读取、系统校验、SAU 配置、外设安全属性配置,一切就绪后再切到非安全世界。
我最早做切换时犯过一个很典型的错误:在 main 函数里先初始化了一堆非安全外设,然后才想起来配 SAU。结果 SAU 一使能,非安全外设访问立刻 fault。后来我养成一个习惯,启动流程严格按“先配置地址属性,再碰业务外设”的顺序走。安全侧固件要先把安全需要的那部分外设初始化完成,然后才轮到非安全世界接管。别小看这个顺序,它省掉了我大量查 fault 的时间。
4.2 非安全侧固件如何调用安全功能
非安全世界调用安全功能要借助编译器的 CMSE 机制。在安全侧,给一个对外开放的入口函数加上 cmse_nonsecure_entry 属性,并把该函数链接到 NSC 区域。看一个最小示例:
__attribute__((cmse_nonsecure_entry)) uint32_t secure_add_one(uint32_t x) { return x + 1U; }在非安全侧,先声明一个带非安全调用属性的函数指针,再把 NSC 区域里的入口地址包装成安全调用目标:
typedef uint32_t (*secure_add_one_fn)(uint32_t) __attribute__((cmse_nonsecure_call)); secure_add_one_fn p = cmse_nsfptr_create(0x00020004U); uint32_t result = p(41U);这里特意用了入口地址 0x00020004,因为 NSC 区域的开头可能有其他跳转指令或对齐填充,实际跳板函数地址要按函数边界定位。关键在于,编译器看到带 cmse_nonsecure_call 属性的函数指针时,调用它会自动生成 BLXNS,而不是普通 BL。这一条指令就是跨世界调用的核心,它先把调用状态切到安全世界,返回时再切回非安全世界。
4.3 参数校验:安全服务的边界在哪里
跨世界调用最容易忽略的是参数安全。非安全世界传一个指针进来,安全函数直接拿来读写,万一指针指向安全内存或被恶意篡改,轻则数据损坏,重则被当作攻击跳板。ARM 的 CMSE 提供了一组内建安全检查接口,用来确认一段内存范围是否完全落在非安全可访问区内。
我习惯在每一个对外安全入口函数里做三件事:第一,用安全检查接口验证传入指针的地址范围;第二,检查长度是否超限;第三,返回值明确区分“调用失败”和“业务返回的中立结果”。哪怕某个功能暂时不用指针参数,我也会保留统一的错误返回路径。安全侧固件一旦发布,很难像普通应用那样频繁热更新,入口做保守一点,后面少操很多心。
5. 中断与异常处理:最容易出问题的环节
5.1 VTOR 与中断归属
ARMv8-M TrustZone 里的异常向量表不是只有一张。安全世界用 VTOR_S 指向安全向量表,非安全世界用 VTOR_NS 指向非安全向量表。中断归属由 NVIC 的 ITNS 寄存器族控制,每个可配置中断都有一个安全/非安全目标位。想放一个安全中断处理器,就把对应 ITNS 位置为 0;想让非安全世界处理某个中断,就把位设为 1。
这个环节最容易踩的坑是:改了 ITNS,忘了同步检查 VTOR。你把中断交给非安全世界,但非安全向量表里没有对应中断向量,中断一旦来临就跑飞。而且这个故障的表现很不直观,可能是中断不响应,也可能是随机 HardFault。遇到奇怪中断问题,第一步就是确认中断归属在哪一侧,以及那一侧的向量表里有没有对应入口。
5.2 安全中断与非安全中断的现场保护
跨世界异常还有一个需要留意的点:非安全代码执行时来了安全中断,优先级的仲裁规则和普通 M 核类似,但如果开了 FPU,浮点现场保存就复杂一些。ARM 在安全入口和出口设计了浮点状态的保存恢复机制,编译器在生成 CMSE 调用时会自动处理,前提是工程选项里的浮点模型设置正确,不能一边用硬浮点一边把 CMSE 的浮点支持关掉。
我调试时遇到过函数返回值偶尔出现异常值的场景,怀疑了半天才发现是浮点现场恢复在某个优化级别下没正确触发。这个问题的排查成本不低,因此我的建议是:在设计阶段减少安全侧对外接口的浮点参数和返回值;如果必须用浮点,就单独做离线的单元测试,确认编译产物里真的生成了带浮点现场保护的跨世界调用序列。
6. 常见问题与排查技巧实录
6.1 常见问题速查表
| 现象 | 常见原因 | 解决思路 |
|---|---|---|
| 复位后进入 HardFault | SAU/安全配置使通过晚,外设访问越界 | 把 SAU 配置放到系统初始化最早期 |
| 非安全函数调用安全函数直接 fault | NSC 区域未正确链接,或目标地址不在 NSC 标区内 | 确认安全侧入口函数链接在 NSC 段,地址落在 SAU 的 NSC 区域 |
| 非安全中断不响应 | ITNS 目标属性未配置,或 VTOR_NS 未设置 | 检查中断归属和对应向量表 |
| 安全侧访问外设时报错 | 外设被 IDAU 固化为安全属性,或 SAU 区域配置不对 | 对照芯片手册核对每个外设的安全属性 |
| 导出函数被调用后参数全乱 | 跨世界调用约定不匹配,函数指针声明错误 | 统一使用 CMSE 属性声明,不要手写 BLXNS |
6.2 排查思路与调试心得
排查 TrustZone 问题时,不要一上来就怀疑应用逻辑,第一步先确认当前 CPU 处在哪个世界、目标地址是什么属性。主流调试器都能直接读取内核寄存器和 SAU 状态,我遇到 fault 时的操作顺序是:先看 fault 状态寄存器和 PSR,确认是在安全状态还是非安全状态触发;再看触发地址落在哪一段区域;最后回查 SAU、IDAU、ITNS 配置。
从我踩过的坑来看,大多数问题集中在 NSC 区域与地址对齐,以及中断向量归属。这两类问题一旦排查清楚,剩下的就是普通 M 核调试经验了。如果你做安全启动方向,强烈建议在非安全侧加一个简单的看门狗例程,因为安全世界退出后非安全固件可能跑废,系统不能无声无息地卡死,必须有自愈手段。
7. 从零搭建双世界工程的建议流程
7.1 逐步跑通最小可验证工程
第一次做双世界工程,完全手写所有链接脚本和启动汇编会非常痛苦。我建议按下面顺序走:
- 先找一颗带 TrustZone 模板的 M33 芯片开发板,用厂家官方模板生成安全工程和非安全工程这两个空工程。
- 对照数据手册,把 Flash 和 SRAM 的地址段写进链接描述文件,安全段、非安全段、NSC 段分开。
- 让安全工程先裸跑,点亮开发板上的一颗 LED,确认基础启动链路正常。
- 配置 SAU,把 NSC 区域划出来,用调试器读 SAU 寄存器确认属性正确。
- 写一个最简单的安全侧入口函数,用 CMSE 属性标记,链接到 NSC 区域。
- 在非安全工程里声明对应的非安全调用函数指针,调通第一个跨世界调用。
- 把一个定时器中断交给非安全世界,验证 ITNS 和 VTOR_NS 配置。
- 最后再加入密钥存储、安全启动等业务功能。
这个顺序的精髓是每步都能验证一个小结论,出了问题能立刻定位到具体环节。千万不要跳过前面几步直接冲算法和密钥,那些功能本身复杂,叠加上 TrustZone 边界问题会让人直接崩溃。
7.2 个人体会与后续扩展
真正跑通一次之后,我对 ARMv8-M TrustZone 最大的体会是:它并不神秘,但也绝对不是一个“打开开关就有安全”的功能。安全是设计出来的,不是配置出来的。你把边界划在哪、把哪些接口暴露给非安全世界、如何处理外部传入的参数,这些都要靠自己去定义。工具链只是把你定义好的边界翻译成机器指令而已。
后续如果想往深处扩展,可以试着把安全侧做成一个类似加密服务库的组件,把密钥管理、证书校验、安全启动校验都收进去,非安全侧只负责业务和网络。再往后可以研究安全调试通道、以及如何配合安全隔离方案做远程问题定位。每一步都会踩到新的坑,但只要把基础的双世界内存模型和调用机制吃透,后边的扩展只是在这张骨架上不断加肉而已。