
1. 为什么TrustZone项目总在寄存器改对了但功能死活不对上翻车做安全固件、密钥管理、安全OTA这类项目的工程师基本都会遇到同一个状况把TrustZone相关的寄存器按参考手册配了一遍程序烧进去却要么直接HardFault要么安全代码被非安全代码随意调用要么调试器根本连不上芯片。更头疼的是这类问题不像普通外设配置那样查个datasheet就能定位——TrustZone的坑往往不在怎么写寄存器而在安全属性和物理执行路径之间的匹配关系。这篇文章要聊的就是STM32 TrustZone开发里最基础、也最容易出问题的一环——地址安全区配置和资源安全属性配置。它是整个TrustZone工程的基石也是把你从看手册看得懂上手就翻车里拉出来的关键。适用对象是三类人一是刚从标准库/老的HAL库切到TrustZone架构的嵌入式工程师二是正在做安全启动或安全固件升级、需要把代码和数据物理隔离开的项目三是单纯想搞明白Cortex-M33上Security Attribution到底怎么运作的爱好者。文章不涉及具体业务的密钥分发逻辑只聚焦在MCU底层的安全属性划分这件事上配合实操步骤和排查思路让你能直接照着做。2. TrustZone的底层逻辑不是两个CPU而是一套硬件两套状态很多人第一次接触TrustZone时会把它理解成芯片里面有俩核一个跑安全系统一个跑非安全系统。这个理解不算全错但会严重误导后续的调试方向。2.1 安全状态、非安全状态和内存映射的对应关系TrustZone的核心机制是让单个Cortex-M33核在运行时维护一个安全状态位。当处理器处于Secure状态时它可以访问被标记为Security属性的地址当处于Non-Secure状态时它只能访问Non-Secure地址。这跟你用两个物理芯片隔离是两码事而是同一颗芯片、同一个核心在时间片上做状态切换。这里就牵扯出一个绝大多数人第一次配置时会犯的错以为只要把某块地址的属性配成Secure它就只能被Secure代码访问。实际上属性配置只是第一道闸门第二道闸门是当前CPU处于什么状态。如果CPU处于Non-Secure状态而代码去访问一个Secure地址会直接触发SecureFault或BusFault反过来Secure状态的CPU访问Non-Secure地址是允许的——这也是为什么很多人写的安全固件里Secure代码可以读非安全内存但非安全代码一碰安全区域就炸。还有个更隐蔽的细节非安全内存和非安全外设的取值不是等价的。寄存器的地址落在哪个区域、该区域被GTZC标记成什么属性、外设本身的TrustZone感知能力如何这三者必须同时满足才能正常工作。2.2 物理地址经过IDAU和SAU两层门卫才能定属性Cortex-M33内部对每笔访问的地址判定会经过两条路径IDAUImplementation Defined Attribution Unit由芯片厂商在硬件设计时定死负责把整个物理地址空间划分成Security区域和Non-Security区域。这是芯片设计时烧死的软件改不了。SAUSecurity Attribution Unit这是软件可配的用来覆盖IDAU的默认属性。如果SAU没有启用那么一切以IDAU的划分为准。在STM32L5、U5这些带TrustZone的型号上Flash和SRAM各有自己的安全划分寄存器——具体来说Flash的属性由FLASH_OPTR寄存器里的TZEN位、以及SECWM等选项字节来决定SRAM则通过GTZC的MPCBBMemory Protection Controller Block Based配置来逐块划分。这就引出了第一个实操准则如果你配置了SAU但没设置好Flash和SRAM本身的硬件安全边界那么SAU配得再漂亮外设访问照样会出问题。我见过不少项目代码里SAU region配了一大堆烧进去直接在启动阶段卡死查到最后发现是Flash的SECWM把启动向量表所在的扇区给划成了Secure而芯片复位后第一条指令是从Non-Secure的Reset_Handler跑的。2.3 安全属性配置的完整链路TZEN → SAU → GTZC → 外设要把TrustZone真正跑起来光会配一个寄存器远远不够。完整的链路是确认芯片的TZEN选项字节被使能——它决定了芯片上电后是否进入TrustZone模式。如果TZEN是0那么整个芯片就是一颗普通M33SAU和GTZC都不生效。在Secure侧启动代码里配SAU region定义哪些地址范围走Non-Secure属性。用GTZC给SRAM、外设寄存器、Flash扇区等资源做物理安全属性标记。把具体外设的允许非安全访问位打开或关闭不同外设型号叫法不一样有的是SECCFG有的是TZEN有的是NS bit。在RTOS或裸机代码里做好安全/非安全状态切换通过SG指令进入Non-Secure或者通过Non-Secure Callable函数实现受控调用。也就是说地址安全区配置和资源安全属性配置这两个概念一个是内存寻址的规则一个是资源实体被允许给谁用。这篇文章主要展开前两步和第4步的常见配置方法。3. 地址安全区配置SAU Region、NSC和你的启动向量表3.1 SAU region配置的寄存器逻辑STM32的SAU一共支持最多8个region在Cortex-M33里是8个。每个region有三个关键参数起始地址SAU_RBAR、结束地址SAU_RLAR、属性位SAU_RLAR中的NSC位和EN位。为什么说结束地址而不是大小因为SAU的粒度是按32字节对齐的而且RLAR寄存器的低5位必须忽略。所以配置的时候起始地址的低5位一定是0结束地址的低5位一定是1或者说结束地址是包含的最后一个32字节块的起始地址。这块如果没对齐SAU会直接忽略这条region配置你烧进去的代码就跟没配一样。我习惯的配置模板是/* SAU region 0把整个4GB空间默认设为Non-Secure */ SAU-RNR 0; SAU-RBAR 0x00000000U; SAU-RLAR 0xFFFFFFFFU ~0x1FU; /* 忽略低5位 */ /* 使能region且NSC0 */ SAU-RLAR | 0x1U; /* ENABLE */ /* SAU region 1把安全代码所在的Flash区域划为Secure */ SAU-RNR 1; SAU-RBAR 0x0C000000U ~0x1FU; SAU-RLAR 0x0C01FFFFU ~0x1FU; SAU-RLAR | 0x1U; /* ENABLE */但这里有个容易漏掉的步骤SAU_CTRL的ALLNS位。如果ALLNS1那么所有region都不参与判定整个地址空间全按Non-Secure跑。很多人写完region配置忘了清ALLNS调试器一看寄存器值都对但实际效果全是Non-Secure白白浪费半天时间。还有一个优先级问题SAU判定时编号越大优先级越高。如果你region0把全部空间设成Non-Secureregion1把某块设成Secure最终生效的是region1。如果你的region0设置成Secure、region1设置成Non-Secure那最终生效的是region1的Non-Secure。这种覆盖逻辑在调试时特别容易踩。3.2 NSC——Non-Secure Callable区域的坑NSCNon-Secure Callable是TrustZone里非常特殊的一类地址属性。它本身属于Secure区域但允许Non-Secure代码通过SG指令飞进来执行一次受控跳转。难点在于NSC区域的代码必须是特殊的veneers函数由编译器生成的跳板函数而不是普通函数。如果你把一个普通C函数的地址配成NSC属性Non-Secure侧通过函数指针调用时要么进SecureFault要么行为完全不可预测。我在armclang环境下的做法是给跳板函数加上__attribute__((cmse_nonsecure_call))或__attribute__((cmse_nonsecure_entry))让编译器自动生成带SG指令的veneers。这样写虽然简单但背后要理解的是被标注为cmse_nonsecure_entry的函数会放在名为.gnu.sgstubs或类似名字的段里这个段的链接地址必须落在NSC属性区域里否则运行时会直接崩。链接脚本里需要额外加一段/* 在IAR/Keil/GCC的链接脚本里给NSC段留出一块独立的区域 */ . 0x0C020000; .trustzone_nsc : { KEEP(*(.gnu.sgstubs*)) } FLASH_NSC这个给NSC段单独划分Flash区域的操作是我见过所有TrustZone工程里第二个高频翻车点。很多人配了SAU region把某块地址设成NSC但链接脚本里压根没有把跳板函数放进去结果那一片Flash全是0xFF运行到跳转指令时直接进HardFault。3.3 启动流程里最容易出的问题向量表和安全/非安全状态STM32 TrustZone 芯片复位后的默认状态是Secure。也就是说芯片上电后会从Secure的Reset_Handler开始执行。这里就出现一个经典问题如果你把整个Flash的默认属性都设成Non-Secure那复位后第一条指令在Non-Secure区域执行但CPU却是Secure状态——它访问Non-Secure内存是允许的但Non-Secure内存里的代码如果不具备跳回Secure的能力整个安全体系就形同虚设。所以标准做法是Flash的起始区域包含Secure工程的所有代码和向量表必须被标记为Secure属性然后Secure侧代码在完成初始化后通过一个NSC函数跳转到Non-Secure应用。这一步在系统集成时容易出问题的地方是Secure工程和Non-Secure工程的Flash地址划分有重叠或空隙导致SAU区域边界悬空。向量表的起始地址和SAU区域边界没对齐。比如向量表从0x0C000000开始但SAU region的起始地址你写成了0x0C000020那前面32字节的向量表就落到了Non-Secure属性上。我的建议是在工程里打印出实际链接地址并用SVD文件或调试器的Memory窗口逐一核对向量表所在地址的SAU属性不要光看代码里配置的数值。4. 资源安全属性配置Flash、SRAM和外设的谁可以用4.1 Flash安全水印SECWM与选项字节Flash区间的安全划分在STM32L5/U5上由选项字节里的SECWMSecure Water Mark来控制。简单理解SECWM定义一个安全水印Flash地址在水印之下的扇区属于Secure水印之上的扇区属于Non-Secure两端都可以单独控制比如SECWM1_PSTRT和SECWM1_PEND。这块有两个坑修改选项字节本身是个系统级操作需要先解锁Flash、把OPTSTRT位置1、然后执行OB_LOAD之类的命令期间代码跑在Flash上会有被卡住的风险。很多人在调试器里直接改选项字节改完发现程序没按预期跑是因为改完必须复位或重新上电才生效。SECWM只按扇区粒度划分不是按地址任意切。如果你需要把某个扇区的前半部分留给Secure、后半部分给Non-Secure那是做不到的。这种情况下只能重新规划链接地址把安全代码挪到整个扇区里。还有一个和OTP相关的细节如果项目里使用了安全启动那么第一条引导代码所在的Flash区域必须被标成Secure并且最好是只读的。否则攻击者可以改掉启动代码。这块在STM32H5等型号上还有额外的HDPHardware Debug Protection和HDPL等级控制属于进阶话题本文先不展开但你要知道有这个方向。4.2 SRAM的MPCBB配置按块划分的隔离区SRAM的安全属性配置不是按地址任意切的而是通过GTZC里的MPCBBMemory Protection Controller Block Based把SRAM划分成固定大小的块比如每个block 4KB或8KB具体看型号然后逐一设置每个block是Secure还是Non-Secure。这里最容易出的问题是堆栈。如果你把SRAM的后半部分设成了Non-Secure却把Secure代码的堆栈设在Non-Secure区域那么Secure代码每次函数调用、每次压栈都是在Non-Secure内存上操作。这看起来能跑但实际上你在没有任何保护的情况下把安全侧的调用现场暴露给了非安全侧。这属于功能上没错但安全上失守的典型。更糟糕的是有些RTOS在切换任务时会把栈指针切到Non-Secure地址上导致Secure函数调用时栈指针穿越了安全边界一旦中断到来就会触发BusFault或SecureFault。实操建议是Secure侧的系统堆栈务必放在SRAM的Secure区域用链接脚本固定地址并在MPCBB配置里同步确认。Non-Secure侧RTOS的堆和栈放在Non-Secure区域。这两个区域之间的地址留出足够的保护间隔防止缓冲溢出跨界。调试时在SecureFault_Handler里加断点并在故障现场把MMFAR(BFAR)读出来——它能告诉你是哪个地址访问出了问题。4.3 外设的TrustZone属性GTZC外设保护和具体外设的SECCFG位外设的安全属性是大多数人的知识盲区即便内存地址安全划分对了外设寄存器访问权限也可能是错的。STM32的GTZC里有个外设保护单元Peripheral Protection可以对外设地址区间做Secure/Non-Secure标记。但和SAU不一样的是GTZC外设保护只覆盖外设总线地址空间的整体归属而具体某个外设是否允许Non-Secure访问还需要看这个外设自己的配置位。以GPIO为例GPIO本身是TrustZone感知外设它的每个端口都有一个SECCFGR寄存器其中SEC[15:0]对应16根引脚。如果某个引脚设成SecureNon-Secure代码就不能操作它即便GTZC把GPIO地址区间划成Non-Secure引脚的安全属性依然由SECCFGR决定。再比如UART串口通常需要在Non-Secure侧使用但如果你在Secure侧初始化时把UART的TZEN位置1Non-Secure侧跑应用时一开串口就死。这种情况不是代码逻辑错而是外设的安全属性被你锁死了。所以我建议画一张表把工程里用到的每个外设列出来标好外设基地址落在哪个地址区、GTZC给了什么属性、外设自己的TZEN/SECCFG给成什么、实际使用方是Secure还是Non-Secure。这张表做完90%的外设访问类故障都能一眼看穿。5. 调试技巧SecureFault、地址属性和调试器协同5.1 SecureFault的现场还原用手册里的寄存器找出第一现场TrustZone项目调试时最常遇到的就是在非安全工程里跑着跑着突然进HardFault。此时如果你用的是Non-Secure调试工程断点停下的位置往往在HardFault_Handler里——但这个Handler本身是Non-Secure的它看到的故障信息可能已经被Secure侧的故障处理屏蔽掉了。正确的做法是在Secure工程里也加上SecureFault_Handler的断点或者用调试器的Fault Reports窗格查看。ARMv8-M架构里SecureFault状态寄存器SFSR、SecureFault地址寄存器SFAR会记录触发SecureFault的地址和原因。排查步骤我一般按这个顺序来查看SFSR寄存器的bit如果SFARVALID置1说明SFAR里有具体的违规访问地址。拿这个地址对照GTZC和SAU配置判断它到底落在Secure还是Non-Secure区域。如果地址落在Secure区但访问者是Non-Secure状态那问题出在调用路径上——有没有走NSC函数函数指针是不是直接指向了Secure地址如果地址落在Non-Secure区但访问者是Secure状态那多半是Secure代码里用了某个非安全外设的地址而GTZC配置该外设时把它标成了Non-Secure但访问本身是允许的——这时要查的是外设时钟和复位控制。5.2 调试器的脱机与多工程联合调试TrustZone项目通常拆成Secure和Non-Secure两个工程烧录时是先把两个固件分别烧到各自的Flash地址区再用调试器做联合调试。这里有个踩坑点很多调试器默认只加载当前活动工程的符号表。你断点设在Non-Secure工程里但Secure工程跑出来的符号和变量你根本看不到。解决方法是在调试器里同时加载两个工程的符号文件。以IAR为例可以在调试器的Image配置里把Secure工程的.out或.axf文件一起加载进去Keil则是在Options for Debug的Download里把两个hex都烧进去并在调试时通过System Viewer添加Secure工程的SVD文件。还有个实用技巧用调试器直接改SAU寄存器来验证配置。当你怀疑SAU region配错时不用重新编译烧录直接在Debug寄存器窗口改SAU_RBAR/SAU_RLAR然后单步执行马上就能看到行为变化。这比反复改代码重烧快得多。这里要特别提醒TrustZone调试本身也受安全属性控制。如果你的Secure工程拒绝Non-Secure调试器连接需要检查调试接口的JTAG/SWD引脚安全配置以及DBGMCU里的TZEN调试使能位。搞不好就会出现代码能跑但就是连不上调试器的情况。5.3 从排查角度聊聊我见过最典型的三个翻车现场第一个是改了SAU但没改链接脚本。SAU region明明把0x0C000000~0x0C01FFFF设成了Secure但Secure工程把向量表和启动代码链接到了0x0C020000之后的Non-Secure地址上。这会导致复位后直接跑Non-Secure代码SAU的Secure region形同虚设。第二个是NSC函数忘加属性修饰。Non-Secure侧调用Secure函数时如果Secure函数的导出不是标准的SG指令双字地址那么一切看似正常但每次调用时都会在某些优化等级或某些编译器版本下炸掉。这个问题的隐蔽性在于调试版本经常跑得好好的一开-O2或者-RelWithDebInfo运行到调用点就死。第三个是GTZC和SAU冲突。SAU把外设地址区间设成了Non-Secure但GTZC的外设保护单元把这个外设设成了Secure-only。这种情况下Non-Secure代码访问外设寄存器时取决于总线的判定顺序可能会被其中一层拦截也可能两层都拦。要解决就得把两层的配置统一起来只保留一层做防护否则出问题时责任很难分清。6. 综合配置实例L5系列上的最小可运行TrustZone工程6.1 目标设定假设我们要在一颗STM32L552上跑一个最小TrustZone工程Secure侧包含系统初始化、SAU配置、GTZC配置、一个安全的LED控制函数通过NSC导出给Non-Secure、一个串口打印函数仅Secure侧使用。Non-Secure侧一个普通的主循环调用Secure侧导出的LED控制函数并通过Secure侧提供的NS Callable接口翻转LED。硬件要求PC13接一个LEDUSART2接串口调试。6.2 操作步骤步骤一确认TZEN选项字节为1。这一步通常用STM32CubeProgrammer完成。注意开启TZEN后芯片复位地址和启动行为会发生改变。如果你发现程序全部烧进去但完全跑不起来先回头确认TZEN到底有没有打开。步骤二编译Secure工程确认链接脚本中安全区和NSC区地址。我习惯把Secure工程链接到0x0C000000把NSC段链接到0x0C020000Non-Secure工程链接到0x0C040000。这样地址划分简单SAU region也方便配置。步骤三SAU配置。按前文模板region0全部Non-Secureregion1把0x0C000000~0x0C03FFFF划成Secure包含Secure代码和NSC区region2把0x20000000~0x2003FFFF划成SecureSRAM安全区。这里加一点不要照抄别人的region地址。不同型号的Flash/SRAM基地址不同L5是0x0C000000但G0系列没有TrustZoneU5/H5的地址空间又不一样。一定要对着自己芯片的reference manual确认基地址。步骤四GTZC和MPCBB配置。把SRAM的前半部分全部配成Secure后半部分全部配成Non-Secure把GPIOA、GPIOB等外设地址区间配成Non-Secure可访问——因为Non-Secure应用要操作LED引脚和串口。步骤五编写Secure侧代码导出NSC函数。函数声明加上__attribute__((cmse_nonsecure_entry))然后把它放到NSC链接段里。__attribute__((cmse_nonsecure_entry)) void SECURE_LED_Toggle(void) { HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); }步骤六编写Non-Secure侧代码通过函数指针调用。Non-Secure侧要用cmse_nonsecure_call声明函数指针确保编译时生成正确的调用指令。typedef void (*funcptr)(void) __attribute__((cmse_nonsecure_call)); funcptr secure_led (funcptr)0x0C020001U; secure_led();注意函数指针地址必须是NSC地址最低位为1否则CPU会认为这是一个Non-Secure函数直接调用而触发SecureFault。这个地址奇偶校验是所有TrustZone调用的一个通用规则具体来说bit0表示目标是不是带状态切换的入口。步骤七配置调试器联合加载两个工程。在调试工程里把Secure和Non-Secure两个可执行文件都加载上并确保SecureFault_Handler有断点。6.3 验证方法烧录完成后正常现象是LED周期翻转串口能打印Non-Secure侧的日志Secure侧打印的命令能收到。如果LED不动用调试器看PC指针停在哪个地址、SFSR值是多少迅速定位到是SAU、GTZC还是外设配置的问题。7. 避坑总结这些细节决定了你是跑通还是跑崩最后把本文涉及的典型坑整理一遍SAU相关ALLNS位忘了清、region地址没对齐、region优先级用反、NSC段没放跳板函数。GTZC相关外设地址区间配置和SAU配置冲突、MPCBB没把安全堆栈包含进来、SRAM块大小与编译器对齐不一致。外设相关外设自带的安全属性位TZEN/SECCFG没配、调试器无法连接时忽略了DBGMCU的TZEN使能、NVIC中断的目标状态配置错——比如Secure中断写到了Non-Secure的NVIC配置寄存器里。链接脚本相关Safe区和NSC区的链接地址和SAU配置不一致、向量表地址与SAU边界错位、Secure和Non-Secure工程里共用了同一个链接地址。我个人在实际项目里有个习惯把完整的地址安全划分画成一张Memory Map图贴在工位前标记好Flash的Secure区、NSC区、Non-Secure区、SRAM的Secure区/Non-Secure区、以及每个外设的归属。配置任何一项时先瞄一眼图再动寄存器。看起来多此一举但这种图上TrustZone的安全边界对整体系统的牵制作用会变得特别清晰遇到怪问题时先回看图反而比对着寄存器瞎试省时间得多。如果你正在做TrustZone相关项目建议把这篇文章里的配置链走一遍跑通一个最简单的Secure/Non-Secure工程后再往上叠加安全启动、密钥存储、固件更新这些业务功能。底层安全属性的理解扎实了上层的坑就会少很多。