
写这篇笔记的起因是我去年做一个工业传感器节点时遇到的局面既要保证长时间电池供电又得在设备本地保存几把主密钥防止固件被读出来之后密钥跟着裸奔。原来的方案是MCU外加一颗安全芯片成本压力大不说PCB面积也捉襟见肘。后来翻ST的选型手册看到STM32L5系列第一眼倒没觉得多特别等细看“TrustZone”这个词的时候我发现事情没那么简单。这篇笔记就是我把自己从零接触STM32L5、再把TrustZone跑起来的过程沉淀下来的一份应用笔记。我不会去复述ST官方文档的大段文字而是用自己折腾过一遍的经验把那些文档里藏得比较深、但实际又特别关键的门道讲清楚。适合已经有STM32基础、想了解TrustZone到底怎么用、以及准备在低功耗产品里引入安全特性的工程师参考。1. 为什么是STM32L5低功耗与安全的合流1.1 从L4到L5我看到了哪些变化在TrustZone之前ST的低功耗主力是STM32L4系列Cortex-M4内核主频最高80MHz或者120MHz功耗表现确实好很多电池设备都在用。而STM32L5系列换成了Cortex-M33内核主频能到110MHz特性上更像是“补齐了L4的短板并且加装了安全模块”。M33相比M4多了几条重要的指令扩展和架构特性比如TrustZone、浮点单元FPU、DSP指令、低中断延迟。简单说L5不是单纯把L4的频率提上去而是在架构层面加入了安全隔离能力。从选型角度看L5最明显的变化是Flash和SRAM容量有明显提升比如STM32L552系列能够提供最高512KB Flash和256KB SRAM这给安全固件和用户应用留足了空间。同时L5保持ST一贯的双Bank Flash特性可以做OTA升级时的安全回滚。对比L4L5新增了HDP硬件秘密保护这类机制用来保护Flash里的敏感数据不被调试接口和DMA随意读出这在很多注重防抄板和保护密钥的场合非常关键。我刚开始也有个疑问如果只是需要一个带安全特性的低功耗MCU为什么不用外挂安全芯片实际对比下来外挂方案的弱点在于一是安全芯片和主控之间的通信链路可能被监听或重放二是整体BOM成本高三是维护两套固件的复杂度变高。STM32L5把安全边界放进芯片内部固件可以直接在安全世界里处理密钥、签名、随机数生成再通过受控的函数接口给非安全世界的用户程序调用链路短、信任模型也更干净。1.2 TrustZone给MCU带来的不是“魔法”而是架构级的隔离很多人听到TrustZone第一反应是“跑个加密算法就安全了”这其实是个很大的误解。TrustZone的本质是“硬件级访问控制”它把系统资源和执行状态分成了两个世界安全世界Secure和非安全世界Non-Secure。非安全世界的任何代码包括被攻破的应用程序、被篡改的网络协议栈都无法直接访问安全世界的内存和外设。这个理念可以类比成写字楼里的公共区域和机房重地。任何人都能进入大堂和公共办公区但核心机房有独立门禁甚至机房内部还会划分出不同的储物柜。TrustZone做的事情就是让“门禁”和“储物柜”都由硬件来强制管理而不是靠软件自觉。这样即使应用层代码被拿到源码也没办法把安全区里的密钥读走。在Cortex-M33上TrustZone的改变比较彻底。以STM32L5为例内核每次访问地址时都会有一个“安全属性”的检查过程被访问的地址以及发出访问请求的上下文必须匹配否则就触发一个安全异常SecureFault或HardFault。这跟传统的MPU保护不一样MPU管的是“谁能访问这段地址”而TrustZone管的是“这段地址属于哪个世界”两个机制同时存在又相互补充。当然TrustZone并不等于万能安全。它解决的是隔离问题后续的密钥管理、通信协议、启动验证仍然需要自己设计。但至少架构上给了你一个“即使应用崩溃了核心资产还在”的底子。1.3 L5适合哪些项目从我自己的实践经验看L5比较适合这几类场景。第一类是需要长时间离线运行、又需要本地保存敏感数据的工业设备比如采集终端、边缘网关、计量仪表这类设备没办法频繁到云端拿密钥必须能在本地做安全存储和加解密运算。第二类是消费物联网设备涉及个人隐私数据主控本身就要实现数据保护外挂安全芯片又太贵L5可以平衡成本和安全性。第三类是做产品防抄板的公司L5的HDP和TrustZone组合能把固件里的核心算法保护得比单纯加壳强很多至少攻击者不能拿烧录器直接读出全部Flash。另外还要注意一点L5不是简单“加了个TrustZone”它依然保留了STM32家族丰富的低功耗模式比如STOP2模式、待机模式、关机模式配合LPTIM、LPUART等低功耗外设。这让我在同一个芯片上既能做安全运算又能保持nA级待机功耗需求确实比“主控加安全芯片”的方案更有吸引力。2. TrustZone在Cortex-M33上的真实实现2.1 核心概念安全世界与非安全世界在Cortex-M33里安全世界与非安全世界不是靠一个GPIO电平来切换的而是由内核内部的安全状态位和内存系统共同决定。处理器可以在“安全状态”下执行安全代码也可以通过一条特殊指令切换到“非安全状态”执行普通应用程序。常规中断和异常在非安全世界处理但安全中断优先级更高可以在非安全世界运行时抢占这一点对实时系统特别有用。这里有个常见困惑安全世界和非安全世界是不是像两个RTOS任务那样切换其实不太一样。它们更像是“两种特权上下文”。你可以把安全世界看成系统里的“内核域”非安全世界看成“用户域”但这里的域切换是由硬件状态和指令共同实现的不是由操作系统调度器主动切换的。在STM32L5上启动时处理器默认进入安全世界所以复位向量要在安全Flash里。上电后由安全世界的启动代码初始化系统再通过特定方式跳转到非安全世界的复位向量完成“交权”。这个“交权”过程如果没做好后面的非安全代码就起不来这也是很多初学者第一次跑L5工程时最容易遇到的问题。2.2 内存分区SAU、IDAU、GTZCTrustZone的隔离效果最终要落在内存地址和外设上。在Cortex-M33里地址空间的安全属性由两级机制决定IDAU和SAU。IDAU是芯片厂商硬线实现的属性单元它给整块地址空间划分出“始终安全”和“始终非安全”的默认属性软件无法修改。SAU是内核里的安全属性单元软件可以配置它的区域寄存器在IDAU的基础上再把某些地址重新标记为安全或非安全。最终的地址安全属性是IDAU和SAU综合出来的结果简单说就是“两者叠加比较严格的规则生效”。在STM32L5上外设和存储区域的安全属性还受到GTZC全局TrustZone控制器的影响。GTZC除了管理外设安全/非安全划分还负责中断和安全事件的路由。例如某个定时器可以配置成非安全外设非安全代码就能直接操作它某些高安全等级的外设必须一直留在安全侧非安全代码访问就直接触发异常。这个配置在CubeMX里通常以图形化方式勾选但搞清楚背后机制很重要不然遇到外设访问异常时你会非常懵。2.3 从非安全到安全SG指令与NSC非安全世界的代码不能直接调用安全世界的普通函数否则处理器会直接报告错误。TrustZone提供了一种受控的调用方式非安全世界先跳转到NSC区域Non-Secure CallableNSC区域里放着一条特殊的SG指令SG指令执行后处理器才被允许切换到安全状态并把控制流交给安全函数入口。这个“网关”设计本质上是一个可控的隧道口不是所有安全函数都能被非安全代码调用只有被标记成“可非安全调用”的函数才会出现在NSC区域的调用表里。在代码层面C编译器会通过CMSECortex-M Security Extensions支持来处理这些细节。你只需要在安全函数上用__attribute__((cmse_nonsecure_entry))声明编译器就会自动生成对应的NSC跳转和恢复机制。听起来不难但这里面有个坑NSC区域必须落在内存中被标记为“非安全可调用”的地址上同时函数入口地址需要4字节对齐否则SG指令执行时会触发错误。2.4 与Cortex-A上的TrustZone的差异如果你之前接触过Cortex-A平台上的TrustZone可能会下意识认为L5里也是一套完整的安全世界操作系统。实际上Cortex-M33上的TrustZone更轻量没有MMU也没有大页表、虚拟地址转换那套东西。Cortex-A的TrustZone经常配合正常的Linux/Android系统跑安全世界里通常会跑一个独立的TEE OS比如OP-TEE。Cortex-M33更常见的是安全世界运行一组精简安全服务非安全世界运行主应用两边通过CMSE机制互相通信。这种差异决定了开发方式完全不同。在L5上你不需要给安全世界装一个完整的操作系统一个裸机安全库或者一个小型RTOS就够用。ST官方在推荐方案里经常把TF-MTrusted Firmware-M作为安全侧的基础组件TF-M提供安全存储、密码学、初始认证等功能而不是一个完整的“双系统”方案。这对片内资源有限的MCU来说更现实也更容易上手。3. 开发环境与工具链选型3.1 硬件准备板卡与调试器想入门TrustZone开发我建议先弄一块官方的评估板。可选的有STM32L562E-DK板载资源比较多带屏幕和一些传感器外设适合做功能演示。如果只想验证核心逻辑和功耗NUCLEO-L552ZE-Q更实用性价比高外围电路简单。两块板子我都用过NUCLEO板做裸机试验很顺手DK板在跑最终GUI和应用时更方便。调试器方面NUCLEO板自带ST-LINK直接调试没问题。如果自己画板可以考虑外接ST-LINK V3或者J-Link。需要注意的是TrustZone调试需要调试器支持“安全属性访问”不是所有调试器都完美支持尤其是当你需要在安全世界里打断点、读取安全侧变量时一些老版本调试固件会有兼容问题。建议优先把调试器和IDE的固件升级到最新版本能少很多折腾。3.2 软件工具链CubeMX、编译器与调试器ST官方提供的STM32CubeMX从L5系列发布以来就支持TrustZone配置在工程生成阶段可以勾选“TrustZone enabled”。但有一点要注意勾选之后CubeMX生成的目录结构就不是传统单工程了而是会把代码分成一个“安全工程”和一个“非安全工程”两个独立子工程分别编译最终再通过特定方式合并烧录。编译器方面主流选择是Keil MDK、IAR EWARM和STM32CubeIDE。这三者对CMSE的支持都已经比较成熟。个人建议直接用STM32CubeIDE毕竟它是ST官方维护的对L5系列的启动文件、链接脚本、调试配置适配最直接而且免费不需要折腾授权。Keil和IAR在专业团队里依然很普遍尤其Keil的老用户上手快但它对TrustZone工程的管理方式比较依赖工程模板新手容易在“编译安全工程后又编译非安全工程、结果发现符号冲突”这类问题上浪费半天时间。烧录和调试阶段STM32CubeIDE配合ST-LINK的体验最顺。你在调试器配置里可以分别加载安全侧和非安全侧的ELF文件这样看变量和调用栈的时候不会出现“安全侧符号全红、找不到函数名”的尴尬。后面我会专门讲调试配置的细节。3.3 双工程还是单工程固件组织方式TrustZone开发的第一个决策就是固件组织方式。ST推荐的标准做法是安全侧一个工程非安全侧一个工程两个工程最终生成两个镜像。安全工程的输出二进制会先被烧写到安全Flash区域非安全工程的镜像则烧写到非安全Flash区域。两个镜像有各自独立的向量表和堆栈设置但整个芯片只有一个复位入口所以安全工程的启动代码负责完成安全初始化和“非安全跳转”。有的开发者会问能不能把所有代码放在一个工程里只在函数层面做安全/非安全区分理论上可以手动控制但这样做会严重牺牲可维护性链接脚本和启动流程要自己写很多魔法调试也很痛苦。我实际测下来的结论是不要跟ST的工程模型作对。老老实实让CubeMX生成双工程结构再往里面填业务代码省下来的时间足够把TrustZone的调用模型彻底弄明白。4. 从CubeMX到实际工程TrustZone项目搭建4.1 用CubeMX创建带TrustZone的L5工程我以NUCLEO-L552ZE-Q为例走一遍创建步骤。打开STM32CubeMX选择MCU后在“Project Manager”页面有一个“Project Settings”里面能看到“TrustZone enabled”选项勾上它。生成代码时会自动出现两个子工程通常命名成STM32L5xx_Secure和STM32L5xx_NonSecure。在生成前CubeMX会让你配置内存属性。比如你可以把部分Flash和SRAM划分为非安全区域其他留给安全世界。这里的原则是安全代码和关键数据放安全区域应用代码和常规数据放非安全区域。STM32L5的Flash是支持按扇区设置安全属性的也就是说你可以把Flash前面一部分划给安全区后面一部分划给非安全区。SRAM同理也可以划分成两段。我的建议是刚开始不要做太细的划分直接用CubeMX默认生成的安全/非安全内存布局后面再慢慢调。默认布局通常会留出足够的非安全Flash和SRAM来跑一个简单的用户程序等你理解了地址映射再根据项目需求微调。4.2 链接脚本与NSC区域的配置很多第一次接触L5的人会被链接脚本吓到因为里面多出了很多段定义。实际上每一个TrustZone工程内的链接脚本至少要定义三类区域安全执行区域、非安全执行区域、NSC区域。安全区域的代码运行在安全世界非安全区域的代码运行在非安全世界NSC区域则是一段特殊的内存它物理上位于非安全区但在内存属性标记里被设为“Non-Secure Callable”。NSC区域的作用我要强调一下。编译器在构建安全工程时会把所有通过cmse_nonsecure_entry声明的函数生成一个特殊的“跳板”跳板放在NSC区域。非安全代码调用安全函数时实际上先调用这个跳板跳板里的SG指令触发安全状态切换再进入真正的安全函数体。因此NSC区域的大小直接决定了你能暴露给非安全侧多少个安全函数。如果链接时遇到“NSC区域溢出”错误就得回去扩大链接脚本里的NSC段大小。在STM32CubeIDE生成的工程里STM32L5xx_Secure工程中的链接脚本已经定义了NSC段只要按照注释修改大小就行。我第一次做的时候直接没看链接脚本结果编译过了但一跑非安全调用就进HardFault查到最后才发现是NSC区域被代码段覆盖了。所以链接脚本这关建议认真过一遍不要跳。4.3 安全侧服务非安全侧Secure Gateway与非安全调用安全函数在安全工程里创建可被非安全侧调用的函数写法比普通函数多一个属性声明。以一个简单的加法为例/* 安全侧代码 */ __attribute__((cmse_nonsecure_entry)) int secure_add(int a, int b) { return a b; }编译器看到这个属性后会自动生成两条信息一是把该函数地址登记到NSC区域跳板二是标记该函数为“可被非安全调用”。在非安全侧你需要声明一个同样的函数指针类型并且用cmse_nonsecure_call属性来调用/* 非安全侧代码 */ typedef int (*funcptr)(int, int) __attribute__((cmse_nonsecure_call)); funcptr secure_add_ptr (funcptr)0x0C000001U; // 示例地址实际需要查链接脚本生成符号 int result secure_add_ptr(2, 3);看到0x0C000001这个地址别慌这是示例。实际工程里编译器会生成一个符号你可以在链接脚本里找到NSC区域的基地址。安全侧还需要一个CMSIS头文件比如core_cm33.h中有一些内建函数用于检查函数指针的安全属性确保你在非安全侧没有把一个普通非安全函数地址当作安全函数调用。这里还有一个重点cmse_nonsecure_call函数指针的调用路径会先验证目标地址是否落在NSC区域再执行调用。如果地址不在NSC区域处理器会直接进入安全错误。这种设计从根上避免了“伪造地址调用安全函数”的攻击路径。4.4 非安全侧调用安全函数的标准流程实际项目里我们不会把每个安全函数都直接暴露给非安全侧这样既混乱又不安全。更合理的做法是设计一组“安全服务API”非安全侧只通过这几个API访问安全世界的能力。典型场景包括安全存储读写、随机数生成、签名验证、设备密钥获取。标准流程是在安全工程里设计并实现安全服务函数用cmse_nonsecure_entry声明。在安全工程里把需要暴露的API地址通过链接脚本符号导出。在非安全工程里通过extern声明或者一个专门的“非安全调用头文件”引入这些函数指针。在非安全工程里按需调用调用时保持函数参数和返回值类型一致。如果传输的是缓冲区指针要特别注意缓冲区所在的内存区域。非安全代码不能直接把安全内存的地址传给安全函数安全侧代码需要对指针做安全属性检查和边界检查。第5点是最容易埋雷的地方。安全函数的参数如果是一个指向非安全内存的指针安全侧代码在解引用前必须确认这个地址是非安全的、长度没有越界否则攻击者可以通过一个恶意指针让安全代码去读取或篡改任意内存。ARM提供了cmse_check_address_range这类安全检查API在安全侧处理外部输入时强烈建议使用。4.5 一个最小实践的代码骨架为了让你更直观地感受整个工程长什么样我整理一个最小骨架。假设我们要在安全侧实现一个“计算平方并返回结果”的服务同时还要提供一个“向非安全侧输出日志”的函数。安全侧代码/* Secure/main.c 中的核心服务 */ __attribute__((cmse_nonsecure_entry)) uint32_t secure_square(uint32_t x) { return x * x; } __attribute__((cmse_nonsecure_entry)) void secure_log(const char *msg) { /* 这里可以调用安全侧UART或者其他日志通道 */ /* 注意msg指针很可能指向非安全内存需要先用cmse_check_address_range检查 */ }非安全侧代码/* NonSecure/main.c 中的调用 */ typedef uint32_t (*fp_square)(uint32_t) __attribute__((cmse_nonsecure_call)); typedef void (*fp_log)(const char *) __attribute__((cmse_nonsecure_call)); /* 这些地址需要和安全侧链接脚本导出的符号一致 */ extern uint32_t secure_square_addr; extern uint32_t secure_log_addr; void app_main(void) { fp_square square (fp_square)(secure_square_addr); fp_log log (fp_log)(secure_log_addr); log(compute square...); uint32_t val square(9); /* val 为 81 */ }这段骨架虽然简单但已经覆盖了TrustZone调用的完整链路。实际你需要做的就是把extern符号跟安全工程里导出的地址对上。CubeMX生成的工程模板中通常会在非安全侧提供一个头文件来声明这些函数指针不需要自己手写地址直接用就能跑通。5. 调试与问题排查实录5.1 如何调试安全与非安全两侧代码带TrustZone的MCU调试起来比普通MCU复杂因为调试器需要理解两个世界的上下文。在STM32CubeIDE里调试配置界面能看到一个“TrustZone”相关的选项。如果你只是用默认的调试配置连上开发板通常能停在非安全侧的断点但安全侧的变量和函数符号可能加载不出来。解决办法是在调试配置里把两个ELF都加进去作为调试符号文件。安全工程生成的ELF对应安全侧符号非安全工程生成的ELF对应非安全侧符号。加载后当你停在非安全代码时能看非安全变量停在安全代码时能看安全变量不会有“No source available”的问题。调试时还有一个常见的坑调试器可能默认把整个芯片的安全调试权限关闭你需要通过烧录选项来打开调试认证。在STM32CubeProgrammer里连接L5时如果发现能读到芯片ID但无法访问安全侧的Flash或寄存器多半是TZEN选项字节的配置和调试权限设置有问题需要手动勾选“Enable TZ debug”或者加上正确的调试证书。这个概念在Keil和IAR里也有类似选项只不过叫法不同。5.2 常见问题速查表我把这段时间踩过的问题整理成一张表方便你快速对照定位。现象可能原因排查思路与解决方法上电后程序不运行板子在复位循环安全启动代码跳转非安全侧前非安全向量表地址配置错误检查安全侧代码里的SCB-VTOR设置和非安全复位地址是否正确指向非安全Flash起始地址非安全代码访问外设时进入HardFault外设被GTZC配置为安全外设非安全侧无权访问查看CubeMX里该外设的安全属性改为Non-Secure或者把访问逻辑移到安全侧非安全调用安全函数时进入SecureFaultNSC区域没有被正确映射或函数地址不落在NSC区域检查链接脚本NSC段是否有效确认安全函数的NSC入口地址对齐到4字节并确认跳板符号已导出编译安全工程时报告“NSC区域溢出”暴露给非安全侧的函数太多NSC段容量不足增大链接脚本中NSC段大小或者精简暴露函数数量浮点运算顺序不对安全侧和非安全侧恢复上下文错乱安全状态切换时FPU上下文保护策略未配置好确认安全函数是否启用了FPU必要时在切换时执行__FPU_Enable或使用CMSIS提供的上下文保存函数断点停在安全代码时变量窗口全红调试符号没有加载安全侧ELF在调试配置里手动添加安全工程生成的ELF文件烧录后安全侧代码被读出来Debug授权和安全保护等级没有配置在CubeProgrammer里设置RDP等级配合TrustZone的Debug Authentication机制开启读保护这些问题的共性在于TrustZone里没有“差不多能用”的状态配置差一点就是异常原因通常集中在地址属性、状态切换和调试权限三个方面。遇到异常时建议先看异常类型是SecureFault还是HardFault再看异常发生时PC停在哪个地址这能省一半定位时间。5.3 我踩过的几次坑第一个坑是关于FPU的。我在安全侧写了一个带浮点运算的签名校验函数非安全侧调用后偶尔出现计算结果不对但又不报错的情况。排查了很久发现是安全状态切换时我没有主动保存FPU上下文。Cortex-M33的FPU上下文在TrustZone切换时不会自动完整保存需要在安全函数入口或出口做必要的上下文处理。ARM CMSIS提供了一些内建函数来简化这个操作但前提是你得知道要在什么时候调用。第二个坑是NSC地址对齐。某次我新增一个安全日志函数后非安全侧只要调用它就进HardFault。用调试器单步发现跳板地址没有4字节对齐SG指令无法正常执行。原因是链接器在NSC段里为函数跳板分配地址时因为函数体大小不是4的倍数导致下一个函数入口没对齐。解决方案是在链接脚本里的NSC段显式加上对齐约束或者把所有安全服务函数的导出地址统一放在一个对齐的表格里。第三个坑比较阴险。我把一个外设中断设置成了安全中断但非安全代码在初始化时给同一个中断源注册了回调结果中断一触发就卡死。TrustZone的NVIC也分为安全中断和非安全中断安全中断不能由非安全侧的异常处理来服务。设计中断路由时一定要想清楚这个中断归安全侧管它的回调就应该在安全工程里实现不能两边都操作同一个IRQ。6. 后续演进与个人体会如果你把L5这套流程打通了再去看更高端的STM32U5系列会发现大部分TrustZone经验是通用的。U5在L5的基础上增加了更丰富的低功耗模式、更强的图形性能和更高频率的M33内核安全机制也做了增强。TrustZone本身并不是一个固定不变的知识点而是一种需要刻进代码习惯里的思维方式。当你开始做固件架构时第一反应不再是“怎么让功能跑起来”而是“这段代码放在哪个世界更合理数据边界在哪里”。从我个人的体会来说TrustZone开发最难的并不是具体寄存器或编译选项而是思维模型的转变。传统单片机开发只需要关心“读写寄存器、中断、时序”到了TrustZone里还要关心“谁在什么上下文下访问什么资源”。刚开始总想着一口气把所有外设都配成安全侧结果发现非安全侧的应用被捆住手脚后来才明白好的划分策略应该是“安全侧只保留必须保护的资产其余尽可能开放给非安全侧以减少切换代价”。最后再分享一个小技巧如果你想把项目里的核心算法保护起来但暂时不想引入复杂的RTOS或TF-M可以先从最小TrustZone模型开始——安全侧只有密钥存储和算法运算函数非安全侧只管业务逻辑和通信。这个模型能跑通之后再逐步扩展安全服务范围。代码量不大但足以让你把TrustZone的调用机制、内存属性和调试方法吃透。有了这个底子后面再上TF-M或者做更细粒度的安全分区都会轻松得多。