☰
ARMv8-M TrustZone实战:MCU安全隔离从原理到落地
2026/10/17 1:17:11 网站建设 项目流程

去年帮朋友评估一个物联网终端的安全强度,拿到的样品功能做得挺全,通信加密、固件签名、安全启动全都号称有。可我用调试器把CPU一停,直接dump整块Flash,固件里用于签名的私钥就明文躺在固定偏移上——业务层做了那么多加密,最终密钥还是裸奔在单一地址空间里。后来我在模拟项目X里认真落地了一遍ARMv8-M TrustZone实操,才算是把“MCU上如何做可信隔离”这件事从文档认识变成肌肉记忆。这篇稿子就把那段时间踩过的坑、理顺的原理、可以直接抄的配置全部写出来,给同样在搞Cortex-M33/M23安全方案的朋友做个参考。

TrustZone不是魔法,它只是把“安全世界”和“非安全世界”两个状态、两套内存视图、两套中断处理机制真正做进了CPU。你需要的不是再靠应用层防破解,而是在硬件层面把密钥、安全启动、认证逻辑圈在一堵墙后面,非安全世界的代码即便跑飞了也碰不到墙里的东西。下面从需求、环境、寄存器、工程拆分到调试排障,一条线讲完。

1. 从密钥裸奔说起:TrustZone-M 要弥补的安全短板

1.1 为什么单靠 MPU 挡不住攻击

传统MCU上常用的防护手段是MPU(内存保护单元)。MPU能把Flash和RAM划分成若干区域,设置特权/用户权限、读写权限,听起来也能做隔离。可它有个本质问题:它保护的是“当前软件对内存的访问控制”,而不是“系统里不同信任级别的代码之间的物理隔离”。当攻击者通过调试接口、Bootloader漏洞或者一个简单的栈溢出把自己变成特权模式后,MPU的保护规则就是一层窗户纸,改个寄存器就能撕开。

更棘手的是,现代MCU几乎都开放了调试接口,哪怕量产时关闭了调试口,只要固件里有任意地址读写的漏洞,攻击者同样能把整个地址空间当字典翻一遍。我见过不止一个产品把关键密钥放在Flash末尾,然后靠“反正地址不公开”来保密——这种思路在安全设计里是最危险的。TrustZone-M则把问题从“软件权限控制”升级为“CPU 状态级隔离”:安全世界和非安全世界是两个不同的执行状态,非安全代码不仅不能访问安全内存,连“试图访问”这个动作都会被硬件直接拦下并触发异常。

1.2 TrustZone-M 和 TrustZone-A 到底差在哪

很多人先接触过应用处理器上的TrustZone-A(比如手机里的TEE方案),跑到MCU上发现概念很多相似,但实现完全不是一回事。TrustZone-A有MMU,靠页表描述符里的NS位标记内存页的安全属性,世界切换时有Monitor模式在中间做上下文保存。ARMv8-M的TrustZone则没有MMU,也没有独立的Monitor模式,它靠的是一个更轻量的机制:

  • CPU状态分为Secure和Non-Secure两种。
  • 每种状态都有自己独立的堆栈指针、控制寄存器和向量表。
  • 状态切换只能通过受控的入口(Secure Gateway,硬件指令SG配合veneer)完成,非安全代码不能随意“跳”进安全世界。
  • 内存和外设的安全属性由两部分共同决定:芯片出厂时固定死的IDAU(Implementation Defined Attribution Unit)和软件可配置的SAU(Security Attribution Unit)。

这里的核心思想很接近“把房子分成两套房间”:非安全世界住在客厅,安全世界住在保险库。保险库的门不是普通门,而是带安检通道的门(NSC区域),只有从特定位置、以特定方式进入才会开门。这样设计自然比MMU轻量,对MCU的中断延迟、功耗和面积都友好得多。

2. 双世界工程实战:Secure/Non-Secure 固件怎么分工协作

2.1 方案设计:密钥放 Secure 世界,业务放 Non-Secure 世界

动手写代码之前,我建议先画一张“信任边界图”。在模拟项目X里,我的划分很明确:

维度Secure 世界Non-Secure 世界
内存区域安全Flash、安全RAM非安全Flash、非安全RAM
核心代码安全启动、密钥存储、认证算法、安全更新业务逻辑、协议栈、GUI、OTA下载
外设安全相关的加密引擎、随机数发生器、防拆检测常规UART、SPI、传感器、LCD
中断安全敏感中断(如安全报警)大部分外设中断

这样划分的意义在于:就算非安全世界的业务代码被完全攻破,攻击者看到的只是一个空壳,他拿不到密钥,也调不动认证接口。我在实际操作中把根密钥生成、设备唯一ID读取、签名验证全部放在安全世界,非安全世界只通过一个“认证请求”入口向安全世界索要结果,连密钥的影子都碰不到。

2.2 双工程目录结构与构建流程

ARMv8-M的固件通常不是“一个工程里定义两个宏”,而是物理上拆成两个独立编译单元:Secure工程(简称S工程)和Non-Secure工程(简称NS工程)。我的目录结构大致是:

project_x/ ├── secure/ │ ├── src/ # 安全启动、密钥管理、安全入口函数 │ ├── inc/ │ └── linker/secure.ld ├── nonsecure/ │ ├── src/ # 业务代码、主循环、外设驱动 │ ├── inc/ │ └── linker/nonsecure.ld └── tools/ # 镜像合并、地址映射脚本

构建时先编译Secure工程,生成带安全入口的库和镜像;然后编译Non-Secure工程,链接时引用Secure侧导出的入口地址。要注意的是,Secure工程编译时必须开启CMSE(Cortex-M Security Extensions)相关选项,GCC工具链下我会加:

arm-none-eabi-gcc -mcpu=cortex-m33 -mcmse -mthumb ...

-mcmse这个选项会告诉编译器:代码可能运行在带TrustZone扩展的CPU上,需要生成安全调用相关的特殊指令序列和veneer。如果漏了这个选项,安全入口函数不会生成配套的SG指令门,非安全代码调用时直接进HardFault,这一点后面排障还会提到。

链接脚本是两个工程最容易出错的地方。Secure工程的链接脚本必须把安全入口函数放在NSC(Non-Secure Callable)区域中,这个区域在SAU配置里会被标记为“可被非安全代码调用”。NS工程则不用关心安全区域的具体布局,它只需要知道Secure侧对外开放的入口地址是多少。我一般让Secures侧生成一个符号映射文件,里面导出:

__veneer$$secure_authenticate = 0x10000201;

Non-Secure工程在调用前声明这个地址并转换一次即可。真正落地时,两个镜像先各自烧写对应区域,再通过Bootloader做校验和跳转;但在开发阶段,直接通过调试器分别加载两个镜像更方便。

3. 内存安全属性配置:SAU 寄存器手把手设置

3.1 SAU 和 IDAU 的分工逻辑

刚开始看文档时,我一度被IDAU和SAU搞得有点晕。后来我给自己总结了一句话:IDAU是硬件给你的“下限”,SAU是软件能调的“上限”。更准确地说,一块内存或者外设最终是否安全,要由两者共同决定:

IDAU 出厂属性SAU 软件配置最终安全性
Non-Secure无论配成什么强制 Non-Secure
SecureNon-SecureNon-Secure
SecureSecureSecure
SecureNSC非安全代码只能从该地址作为函数入口进入安全世界

IDAU是芯片设计者在总线层面固定实现的,软件改不了。这也是为什么买TrustZone芯片时一定要看芯片手册:有些芯片只开放了部分Flash/RAM给SAU配置,另外一些地址被IDAU钉死为安全属性。如果芯片IDAU把整颗Flash都固定成Secure,那你没法跑标准双镜像方案,只能把所有代码都放安全世界;反之,如果IDAU把某块RAM固定成Non-Secure,那么安全世界也访问不了它,密钥就不能放那里。

SAU则是一组可直接编程的寄存器。CMSIS里提供了操作接口,核心寄存器有:

  • SAU->CTRL:SAU总开关和ALLNS位,ALLNS=1时默认所有地址为非安全。
  • SAU->TYPE:芯片支持的SAU region数量。
  • SAU->RNR:当前要配置的region编号。
  • SAU->RBAR:region起始地址。
  • SAU->RLAR:region结束地址,以及NSC标志和使能位。

3.2 SAU 区域初始化代码实战

在模拟项目X里,我规划了一块安全Flash存放密钥和认证代码,地址区间是0x10000000~0x10007FFF,同时把安全RAM放在0x30000000~0x30000FFF。启动早期,Secure的复位向量里先关闭SAU、清空所有region,再逐个配置:

#include "cmsis_armv8mn.h" void sau_setup(void) { // 1. 先把SAU关掉,全默认非安全 SAU->CTRL = 0U; // 2. 清空所有已有region for (uint32_t r = 0; r < SAU->TYPE; r++) { SAU->RNR = r; SAU->RBAR = 0U; SAU->RLAR = 0U; } // 3. 配置安全Flash region SAU->RNR = 0U; SAU->RBAR = 0x10000000U & ~0x1FU; // 32字节对齐 SAU->RLAR = (0x10007FFFU & ~0x1FU) | (1U << 1) /* NSC */ | 1U /* ENABLE */; // 4. 配置安全RAM region SAU->RNR = 1U; SAU->RBAR = 0x30000000U & ~0x1FU; SAU->RLAR = (0x30000FFFU & ~0x1FU) | 1U /* ENABLE */; // 5. 开启SAU,未匹配区域一律非安全 SAU->CTRL = (1U << 1) /* ALLNS */ | 1U /* ENABLE */; __DSB(); __ISB(); }

这段代码里最容易忽略的细节是地址对齐。ARMv8-M的SAU region地址解析不看低位几位,RBAR和RLAR的低位要么清零要么用于标志位,如果你不按32字节对齐,最后配出来的region可能跟你想象的范围完全不一样。我调试时吃过这个亏,配了一个安全RAM区域,结果把隔壁一大片非安全RAM也圈成了安全区,业务代码一跑就进总线错误。

另外,NSC标志只对“安全属性为Secure”的region有意义。那段配置里我故意把Secure Flash region设成NSC标记,目的是让非安全代码能通过这个区域内的函数入口跳进安全世界。如果没有NSC区域,非安全代码执行SG指令会触发UsageFault,这是特别容易踩的坑,后面详细说。

4. 非安全调用与入口检查:SG 指令背后的边界机制

4.1 安全导出函数的声明与 Veneer 生成

安全世界不能对外裸奔全部函数,只能把专门设计的入口函数暴露给非安全世界。ARMv8-M的推荐做法是用cmse_nonsecure_entry属性标记这个函数:

__attribute__((cmse_nonsecure_entry)) uint32_t secure_authenticate(uint32_t nonce, const uint8_t *input, uint32_t len) { // 安全世界内部逻辑:读取密钥、执行MAC计算 // 所有敏感数据都不会离开安全RAM return mac_result; }

编译器看到这个属性后,会在NSC区域生成一段veneer代码。veneer的核心是一条SG指令,配合BXNS/BLXNS完成状态的切入切出。硬件层面,SG指令只能在NSC区域执行,不在NSC区域执行SG就直接异常。也就是说,非安全代码没法“摸到”一个普通安全函数的地址强行跳进去,它必须走那个被硬编码在NSC区域的入口。

我写工程时的习惯是,把所有安全入口函数集中放在一个源文件里,链接脚本让这段代码固定在NSC区域。比如链接脚本里可以这么写:

.nsc_veneer : { . = ALIGN(4); KEEP(*(.nsc_veneer*)) . = ALIGN(4); } > NSC_FLASH

在函数定义之外,还要用__attribute__((cmse_nonsecure_entry))保证入口函数的符号地址位于NSC区域。如果漏了链接段或者区域配置不对,最常见的结果是:非安全侧函数指针拿到一个好看的数字,一调用就HardFault,而且调试器里看不出任何逻辑错误。

4.2 参数传递和返回时的数据泄露风险

安全世界和非安全世界之间的函数调用,参数传递会复用通用寄存器R0~R3。这意味着:如果你把指向安全RAM的指针直接传给非安全侧的函数,非安全代码理论上可以通过这个指针去读安全RAM——虽然SAU会挡住非安全访问,但前提是你没把安全RAM范围配错。更隐蔽的风险在返回值方向上:入口函数返回前,编译器生成的veneer会自动清理某些敏感寄存器,但它不会替你把安全RAM清零。如果安全函数内部临时存过密钥的中间态,返回非安全世界后那片临时变量还留在安全RAM里,下一次非安全代码如果通过一个未收口的调试后门或侧信道攻击,仍可能嗅探到痕迹。

所以我在Secure工程里实现了一个强制清零原则:安全函数里所有涉及密钥、中间哈希、临时认证结果的局部缓冲,在函数返回前都要用memset刷掉。这个动作不能靠编译器优化掉,我会用volatile指针或者写一个带内存屏障的清零函数:

void secure_scrub(volatile uint32_t *buf, uint32_t words) { while (words--) { *buf++ = 0U; } __DSB(); __ISB(); }

除了数据泄露,还要注意非安全侧的调用规范。非安全代码调用安全入口时,编译器需要按CMSE约定生成BLXNS,并且要处理返回地址和LR的保存。如果你用的是老版本工具链,或者写汇编时自己跳了函数地址,建议直接用安全侧导出的veneer地址,别自己拼函数指针。手拼指针时一旦LR里带的安全标志位不对,返回时CPU会误以为自己还在安全世界,然后直接跑飞。

5. 中断与外设隔离实操:Secure 中断不能被 Non-Secure 访问的秘密

5.1 NVIC 安全状态与中断向量表分区

内存隔离只是TrustZone的一半,另一半是“执行状态隔离”。ARMv8-M的NVIC被设计成既能处理安全中断,也能处理非安全中断。关键配置点在SCB->AIRCR的PRIS位和NVIC自身的中断目标寄存器。每个外部中断都可以被配置为Secure或者Non-Secure属性:

  • 安全属性中断:即便CPU当前在Non-Secure状态执行,一旦该中断触发,CPU强制切换到Secure状态,从Secure向量表取入口,然后才进入安全中断服务函数。
  • 非安全属性中断:CPU如果在Secure状态,非安全中断也会正常响应,但入口从Non-Secure向量表取。这里有个设计细节:非安全中断永远不会“迫使”CPU从安全世界切到非安全世界;处理器内部会做延迟处理,直到安全代码主动允许切换。

我踩过一个具体问题:在Non-Secure工程里初始化UART中断,发现中断响应后CPU不断进HardFault。查了一圈,发现那个UART外设本身被芯片IDAU标记成了Secure外设,而我在非安全侧只想调用它的收发接口。正确做法是:要么把UART整体配置为Non-Secure外设(权限允许时),并让对应中断也配成Non-Secure;要么在安全侧写一个安全中断入口,由Secure代码处理完数据后再通过邮箱传给非安全侧。后者隔离更强,但复杂度高,适合安全日志、密码运算这类“绝不落非安全世界”的场景。

5.2 外设安全属性配置与安全外设访问规则

外设不像内存那样有统一的SAU配置入口,它的安全属性通常由芯片的某个“外设安全控制器”单独管理。不同芯片寄存器名字不一样,但基本套路差不多:每类外设或者每个外设实例都有一个对应的安全配置位,置1表示该外设只能在Secure世界访问,置0表示Non-Secure世界可访问。

在模拟项目X里,我会把以下外设强制配成Secure:

  • 硬件加密引擎(如果用它存根密钥或做解密)
  • 真随机数发生器
  • 防拆/安全报警检测引脚
  • 关键GPIO(比如控制物理锁的IO)

配成Non-Secure的则是普通串口、蓝牙、传感器这类业务外设。一旦配错,症状非常明显:Non-Secure代码一访问安全外设的寄存器,立即触发BusFault;但Secure代码反过来能不能访问Non-Secure外设?通常可以,因为安全世界对非安全资源往往有“降级访问”能力——除非那段总线还带额外的安全保护位。实际工程里我一般按“默认非安全,按需开安全”的原则设计,这样即便以后要开放外设给业务固件,也有回旋余地。

6. 调试与故障排查:TrustZone 项目里最常踩的坑

6.1 启动阶段 HardFault 排查链路

第一次把Secure和Non-Secure两个镜像同时烧进芯片后,我的系统在启动阶段就HardFault了。早期调试TrustZone最麻烦的地方是:故障现场不在你预期的那一侧,而且默认调试器往往停在Secure侧的HardFault handler里,可你的源码窗口却开着Non-Secure工程,光标指哪都对不上。

我后来总结了一条完整的排查链路,遇到复位后立刻异常的情况,按顺序来:

  1. 断开所有断点,确认复位后停在哪个向量表。如果停在Secure项目里的Reset_Handler,说明安全世界的启动链路还没走完;如果停在Non-Secure工程向量表,说明Secure侧已经正常跳转完成。这一步能快速缩小范围。
  2. 查看SAU寄存器当前值。用调试器读SAU->CTRL、SAU->RNR、SAU->RBAR、SAU->RLAR,核对每个region的起止地址和NSC标志。我常在代码里加一个调试变量,把每个region的配置打印出来,比肉眼对十六进制方便。
  3. 查看SCB故障寄存器。读SCB->CFSR、SCB->HFSR、SCB->BFAR、SCB->MMFAR。如果是BusFault且BFAR里面指向某个安全地址,那基本就是Non-Secure代码访问了安全资源;如果是UsageFault,大概率跟SG指令执行位置不对有关。
  4. 确认NSC区域是否被正确标记。这个坑我踩过不止一次:SAU region配好了,但忘了在RLAR里置NSC位。结果就是安全世界一切正常,非安全世界只要一调用安全入口,CPU立刻在SG指令上报UsageFault。

完整链路排查完,往往问题不是在逻辑代码,而是一两个标志位。TrustZone的排障经验告诉我:先信寄存器,再信代码,最后信编译器——因为状态切换这种活,代码在编译阶段看不到运行现场的硬件状态。

6.2 SAU 配置的典型错误症状对照

为了让大家少走弯路,我把实测中遇到过的问题列成一个对照表:

症状可能原因排查方向
Non-Secure代码访问某区域时BusFault该区域被判为Secure,且Non-Secure无访问权限检查SAU region,确认该地址是否落在安全区间内
安全入口函数一调用就UsageFaultNSC区域未配置,或入口函数不在NSC区域检查RLAR的NSC位和链接脚本中入口段位置
Secure世界正常运行,Non-Secure复位后直接跑飞Non-Secure向量表地址配置错误检查VTOR.NS是否指向NS工程的向量表
非安全中断不响应,或被安全中断“抢占”后无法恢复中断安全属性配置错误或AIRCR.PRIS位冲突逐一核对NVIC目标寄存器和异常优先级设置
调试器无法访问Secure代码安全调试未授权确认调试认证相关寄存器是否允许Secure调试

最后这个调试授权要重点提醒。TrustZone里有一套安全调试控制逻辑,默认情况下外部调试器可能只能看Non-Secure世界,想读Secure内存和寄存器必须额外配置。很多同学拿到开发板,Secure初始化代码一执行,调试器就断片了,以为是仿真器坏了。其实只要在Secure启动早期把安全调试授权打开即可,量产时再关掉这个口。开发阶段怎么开可以看芯片参考手册里调试安全控制那章,不同芯片位置差得挺多,但思路一致。

7. 一点实操后的总结性建议

整套ARMv8-M TrustZone实操做下来,我最大的感受是:TrustZone真正解决的不是“让安全固件更安全”,而是“让非安全固件搞不坏安全的底线”。它把安全机制从“软件写的牢”变成“硬件兜着底”,代价是你必须放弃“一个工程打天下”的习惯,接受双世界、双镜像、双向量表的复杂度。

如果以后再接手同类项目,我会优先做三件事:第一,拿到芯片先查IDAU默认属性图,确认哪些区域是天然安全飞地,别想当然地把Flash前段当作安全区;第二,把SAU配置和启动代码放到独立的汇编/早期C文件里,任何改动都要走评审,因为这块一错整个系统的信任根基就没了;第三,在安全入口函数里强制做寄存器清理和临时数据擦除,宁可性能低一点,也别留侧信道尾巴。TrustZone不是锁,它是一扇带安检的门;真正决定安全的,仍然是门的布置和里面的人怎么对待钥匙。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询