尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

ARMv8-M TrustZone-M实战:MCU安全方案原理与落地实践

ARMv8-M TrustZone-M实战:MCU安全方案原理与落地实践 做嵌入式这些年“安全”这两个字从最早的加分项慢慢变成了硬性指标。早年间做MCU项目大家最担心的无非是bug、内存越界、EMC过不过认证但等物联网设备、车规控制器、智能门锁这类产品批量上线之后问题彻底变了固件被人从调试口读出来逆向、设备密钥被提取、OTA升级包被篡改每一件都足以让一个产品从“能用”变成“不能卖”。ARM在MCU这条线上推的新安全方案严格来说是ARMv8-M体系下的TrustZone-M以及围绕它的一整套安全启动、安全调试、安全存储机制正是冲着这些真实痛点来的。这篇文章我不打算念概念而是以一个实际做项目的角度讲清楚这套安全方案的设计逻辑、核心机制、在实际MCU工程里怎么落地以及我在调试过程中踩过哪些坑。无论你是刚接触ARM开发的新手还是被安全需求折磨过的老手这篇都适用。1. 这个安全方案到底解决了什么问题1.1 传统MCU安全手段的边界在ARMv8-M方案出现之前MCU做安全基本靠三板斧读保护RDP、MPU内存保护、唯一ID加加密。这套组合在早期够用但它的致命问题在于——整个系统只有一个安全域。什么叫一个安全域就是说固件里的代码不管你是业务逻辑还是认证逻辑都运行在同一个特权空间里。攻击者只要找到任意一个漏洞比如缓冲区溢出、串口命令注入拿到控制权后整个芯片对他来说就是透明的Flash随便读、密钥随便拿、固件随便改。RDP级别哪怕拉到最高也只是防“用调试器读”防不了“程序自己把内部数据吐出来”。MPU的好处是可以在运行时限制内存访问范围但它本质上还是由当前运行的代码自我管理。一旦恶意代码进入了同一个执行环境它完全可以自己把MPU配置改掉。这就相当于让保安自己决定自己的权限安全边界形同虚设。实时操作系统里常见的做法是特权级/用户级隔离比如FreeRTOS的MPU支持。可这依然逃不开一个事实所有任务共享同一套物理资源和外设一旦某个调度器漏洞被利用隔离就破了。安全性不是靠“约定”而是靠“物理限制”——这正是新方案和旧方案最本质的分水岭。1.2 ARMv8-M与TrustZone-M的设计定位ARM在Cortex-M23和Cortex-M33上引入的ARMv8-M架构把TrustZone机制带进了MCU世界。它不再是一个软性的“安全策略”而是在CPU架构和总线层面把芯片硬生生分成两个世界Secure World安全世界和Non-secure World非安全世界。我用一个生活化的类比解释以前的安全方案是给房间装了个保险柜钥匙就在房间里谁进了房间谁就能开。TrustZone-M则是在房间里砌了一堵承重墙墙的一侧是金库另一侧是办公区两边各有门但金库的门只有特定的人能用专用钥匙打开办公区的人再闹腾也碰不到金库的东西。对应到MCU非安全世界跑应用程序、通信协议栈、UI逻辑安全世界放密钥、证书、安全启动校验、关键固件升级逻辑。两个世界之间通过设计好的API交换数据互相又不能越界访问。硬件保证不是软件约定——这是整套方案的核心价值。这个方案能解决的问题很明确固件被完整提取后逆向非安全区代码可以随便读但安全区代码读不出来。密钥泄露密钥只存在安全世界里非安全代码只能调用安全API干活拿不到原始值。恶意注入与篡改安全启动逐级校验非安全区固件被改了设备启动不起来或者进入恢复模式。调试端口被利用安全世界的调试权限可以单独关死即使调试器能连上也只能看非安全区。所以如果你接到一个项目需求里写了“固件防抄板”“密钥保护”“安全OTA”那这个方案基本就是当前MCU上最主流的答案也是ARM在Cortex-M层面主推的方向。2. 核心机制拆解系统如何从“单安全域”变成“双世界”2.1 安全状态与处理器寄存器体系先说一个容易被新手绕晕的点TrustZone-M不是靠软件库来实现的它首先是CPU硬件状态的一部分。ARMv8-M的处理器核心增加了一个关键状态当前代码到底运行在Secure状态还是Non-secure状态。这个状态直接影响取指、访存、外设访问、中断响应等一切行为。在Secure状态下可以访问所有资源在Non-secure状态下凡是硬件标记为Secure的地址空间或外设一律访问不了强行访问直接触发总线错误或者Security Fault。处理器为此做了寄存器级的扩展。最直观的就是栈指针安全世界和非安全世界有独立的MSP和PSP。比如Cortex-M33有这样几组MSP_S/PSP_S安全世界的栈指针MSP_NS/PSP_NS非安全世界的栈指针代码在执行过程中从非安全世界切到安全世界硬件会自动切换栈指针等状态避免安全世界的栈被非安全世界的数据污染。这个设计相当关键——如果两个世界共用一套栈那隔离就形同虚设了。状态切换也不是软件随便跳转的。从非安全世界调用安全世界的函数必须通过一个叫SGSecure Gateway指令的机制配合NSCNon-secure Callable内存区域来完成。硬件会检查当前的入口地址是否落在NSC区域里如果不在直接拒绝进入并触发错误。也就是说安全世界的函数只能从合法的“门”进去其他路全被封死。我一开始调试时老在状态切换上栽跟头后来才明白这个方案把所有信任关系都建立在硬件校验上不是写一个函数就能随意跳转的。这一点非常重要后面实操部分我会专门演示。2.2 内存、外设与中断的安全属性除了CPU状态系统里的每个内存区域和外设都有“安全属性”。在ARMv8-M系统中决定一个地址安全属性的有两大单元IDAUImplementation Defined Attribution Unit由芯片厂商实现芯片出厂时就画好了一部分地址的安全底图。比如厂商可能规定内部Flash的高地址区域默认是Secure低地址区域默认是Non-secure。SAUSecurity Attribution Unit这是CPU核内可配置的单元软件可以在IDAU画好的底图上做“微调”把某些区域标记为Secure或Non-secure Callable。简单理解IDAU是硬件底图SAU是你软件可以改写的图层。最终生效的安全属性由这两者共同决定。在Flash和RAM这类存储上还有MPCMemory Protection Controller等机制做细粒度控制。有的芯片允许你以4KB甚至更小的粒度逐块配置Flash/RAM的安全属性。这样你可以把一段Flash划为Non-secure放APP代码另一段划为Secure放安全固件RAM也类似安全固件用的栈和堆必须在Secure的RAM区里否则一运行就崩。中断也不例外。NVIC里的每个中断源都有安全属性配置一个中断可以被标记为Secure中断并且强制由安全代码处理。非安全代码不能直接篡改安全中断的使能状态和优先级。这保证了像“安全升级触发”“密钥管理请求”这类关键操作不会被非安全世界干扰。外设层面有PPC/PPU之类的总线级保护单元控制外设总线桥的访问权限。比如你可以把某个UART口配置成只能由安全世界访问或者把某个定时器完全分配给非安全世界使用。注意外设一旦被标记为Secure非安全代码去读写寄存器就直接bus fault这个现象在初期调试时经常被误判成硬件坏了。2.3 安全启动信任从BootROM开始安全方案的另一个重头戏是安全启动Secure Boot。传统MCU启动就是一个简单的“从Flash地址0开始执行”信任链是断裂的——如果Flash里的固件本身被篡改过设备启动后跑的就是恶意代码。使用ARMv8-M新安全方案后整条启动链变成逐级验证、层层信任BootROM是芯片出厂固化的一段代码它不可修改是整条信任链的根RoT。BootROM上电后对第二级BootloaderBL2做签名校验和完整性校验。BL2通过后再校验应用固件APP校验通过后才跳转执行。每一步校验使用的公钥一般都存在OTPOne-Time Programmable区域或安全Flash区普通程序改不了。这种“信任链”设计在PC的TPM体系里已经很成熟ARM把它搬到了MCU上。配合防回滚机制即使攻击者拿到了一个旧版本固件也不能降级到带已知漏洞的老版本。具体到项目里你通常要和厂商提供的BootROM配合用自己的密钥给固件签名把公钥烧进OTP区然后固件升级流程就要围绕签名校验来重新设计。这一步做不好后面的OTA功能根本推不动。2.4 安全调试与密钥保护调试口一直是MCU安全的重灾区。早期芯片只要SWD/JTAG口没锁固件随便读。有了TrustZone-M之后调试权限也可以按世界隔离。在开发阶段你可以用调试器同时看安全世界和非安全世界但产品量产后把安全世界的调试接口关掉只保留非安全世界的调试能力或者整体关闭调试口。安全世界里的代码、密钥库对你来说都是“黑盒”即使SWD物理引脚被抓出来也拿不到任何敏感信息。密钥存储这块除了利用安全Flash很多芯片还有独立的OTP/密钥库硬件甚至集成CryptoCell之类的加密引擎。非安全软件请求“用私钥签名一段数据”安全软件执行签名然后把签名结果返回私钥本身从头到尾不会离开安全世界。这个机制对车规、工业控制这类高安全场景是刚需。3. 实战落地如何在一个MCU工程里把安全方案用起来3.1 工程拆分与编译工具链准备纸上谈兵完了说说实际动手。以带TrustZone-M的ARMv8-M MCU为例比如Cortex-M33或Cortex-M55内核的芯片。第一次接触这套方案的人通常会被一个概念吓到一个项目要拆成两个独立的工程。没错安全世界和非安全世界是分开编译、分开链接的。最终烧录时安全固件和非安全固件分别烧到各自的Flash区域。你需要准备两套工程Secure工程编译安全世界代码包含安全启动入口、密钥管理、安全固件升级、敏感算法。Non-secure工程编译应用代码如协议栈、UI、控制逻辑。如果使用GCC工具链编译Secure工程时要用-mcmse选项。这个选项是Cortex-M Security ExtensionsCMSE的关键编译安全代码时它会生成对应的veneer入口和调用规则。我用的是arm-none-eabi-gcc命令行大致如下arm-none-eabi-gcc -mcpucortex-m33 -mthumb -mcmse -O2 -ffunction-sections -fdata-sections -I./secure_inc -c secure_main.c -o secure_main.o非安全工程不需要-mcmse但需要通过函数指针调用安全世界的API并且这个函数指针要带CMSE属性让编译器知道这是“非安全调用安全入口”。声明方式类似typedef void (*secure_func_t)(uint32_t args) __attribute__((cmse_nonsecure_call));这个cmse_nonsecure_call属性非常关键它告诉编译器调用这个函数指针时要走安全调用的特殊路径编译器会自动插入必要的转换操作。用Keil MDK也可以较新的MDK版本对ARMv8-M支持得不错选AC6编译器armclang工程选项里开启TrustZone支持即可。有很多人问“Keil5怎么兼容C51和ARM”其实就是两个Pack包分开装MDK版本用5.36以上ARM Compiler可以用6.x系列编译ARMv8-M工程别用AC5AC5对CMSE支持不完整容易出莫名其妙的链接错误。另外如果你习惯用VS Code搭建这类工程的思路也类似用CMake arm-none-eabi-gcc加上Cortex-Debug插件和pyOCD/J-Link。国产MCU像普冉、GD32等在 VS Code 下的开发环境本质也是把厂商SDK编译成库然后对齐链接脚本和启动文件TrustZone工程的原理完全通用。3.2 SAU与安全内存区域配置拿到一个具体芯片后第一件事是查芯片手册里关于安全属性的“底图”。芯片上电默认状态通常是所有地址空间都按IDAU默认属性走有的芯片默认全是非安全有的芯片出厂就把高地址Flash划成安全。你需要根据自己的分区方案配置SAU和MPC。SAU的配置核心就是设置几个地址区间。伪代码如下不同厂家的CMSIS包略有差异但结构是一样的#include cmsis_armv8mm.h void SAU_Config(void) { /* 配置期间先关闭SAU */ SAU-CTRL 0U; /* 区域0把非安全代码区标成 Non-secure */ SAU-RNR 0U; SAU-RBAR 0x08020000U; /* 起点注意对齐要求 */ SAU-RLAR 0x0807FFFFU | SAU_RLAR_ENABLE_Msk; /* 区域1把安全固件的调用入口区标成 NSC */ SAU-RNR 1U; SAU-RBAR 0x08008000U; SAU-RLAR 0x08009FFFU | SAU_RLAR_NSC_Msk | SAU_RLAR_ENABLE_Msk; /* 区域2非安全RAM */ SAU-RNR 2U; SAU-RBAR 0x20020000U; SAU-RLAR 0x2003FFFFU | SAU_RLAR_ENABLE_Msk; /* 使能SAU */ SAU-CTRL SAU_CTRL_ENABLE_Msk; }提示不同芯片的地址分配差异很大上面的地址只是示例。实际配置前一定要对照所用MCU的Memory Map和IDAU属性来改千万别直接抄。RAM和Flash的细粒度安全属性一般通过芯片的MPC寄存器配置。这块每家芯片的驱动接口不同但逻辑一致你从Linker脚本就已经把Flash/RAM分成了安全和非安全两段运行时用MPC把这些物理区域的安全属性对上。配置完之后最有效的验证方法就是在非安全代码里故意访问一个安全外设的寄存器看是不是立刻触发BusFault。如果触发了说明隔离生效了。3.3 非安全世界调用安全APIVeneer与CMSE隔离做好了两边怎么通信就成了最大的工程难点。安全世界要向非安全世界提供API比如“用内部私钥做签名”“往安全Flash写密钥”。这个API不是简单声明一个函数就行你得把它放在NSCNon-secure Callable区域编译器还要为它生成一层“安全门”——这层门就是Veneer。在CMSIS里安全函数定义时加上下面的属性__attribute__((cmse_nonsecure_entry)) int secure_rsa_sign(uint8_t *hash, uint8_t *sig_out, uint32_t sig_len) { /* 只在安全世界执行 */ return do_rsa_sign(hash, sig_out, sig_len); }cmse_nonsecure_entry的作用是让编译器在这个函数入口生成SG指令和必要的安全检查代码。非安全代码想调用这个函数必须通过NSC区域的这个入口硬件才会允许进入安全状态。链接脚本里你要确保NSC区域里的这些“门”被放到了SAU配置成NSC的地址段。我踩过的坑就是GCC的--gc-sections把veneer给优化掉了结果非安全代码一调用就进HardFault。解决办法是在链接脚本里KEEP住NSC段。调用方非安全代码也要遵守规则。非安全函数指针声明时带上CMSE属性typedef int32_t (*rsa_sign_fn_t)(uint8_t*, uint8_t*, uint32_t) __attribute__((cmse_nonsecure_call)); /* 指向NSC区域的入口地址 */ rsa_sign_fn_t rsa_sign (rsa_sign_fn_t)0x08008000U; int32_t ret rsa_sign(hash, sig_out, sizeof(sig_out));编译器看到cmse_nonsecure_call函数指针后调用时会自动清空非安全世界寄存器里的敏感数据并执行正确的BLXNS指令。不要自己手写跳转指令除非你把CMSE规范背得滚瓜烂熟。3.4 固件签名与安全启动集成安全启动的落地是另一个新活儿。你需要在BootROM之后再加一个Bootloader阶段这个Bootloader负责验证APP的签名。我采用的做法是用OpenSSL生成RSA-2048或者ECDSA P-256的密钥对私钥保存在构建机上公钥烧进芯片安全区。构建时用脚本对APP的bin文件签名# 生成私钥和公钥 openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out image.key openssl ec -in image.key -pubout -out image_pub.pem # 对固件进行hash并签名 openssl dgst -sha256 -sign image.key -out app.sig app.bin然后把app.binapp.sig打包成升级文件。Bootloader在跳转前做验签int verify_app(void) { uint8_t hash[32]; sha256_compute(APP_BASE, APP_MAX_SIZE, hash); return ecdsa_verify(public_key, hash, signature); }验证失败就进入恢复模式等待合法固件包重新升级验证成功才把控制权交给APP。防回滚版本号最好放在OTP区Bootloader每次升级时检查新固件版本是否大于等于当前版本否则拒绝写入。这个流程一跑通整个安全方案的主干就出来了。后面再往上加密钥管理、安全日志、运行时内存保护都是在这个框架里做增补。4. 现场排障实录安全方案开发中的典型问题4.1 启动即进HardFault这是TrustZone项目里最常见的现象尤其是在第一次把两个工程合起来烧录的时候。现象是非安全代码上电后跑不了几步直接HardFault或者什么都不显示。排查思路依次是确认安全固件和非安全固件是否烧到了正确的Flash地址有没有覆盖。确认SAU/MPC配置后安全RAM和非安全RAM的划分和链接脚本是否一致。链接脚本里把变量放错段是最常见的问题。确认非安全世界的向量表是否被正确设置。非安全代码需要把VTOR指向非安全Flash区的向量表并且Non-secure异常向量里要有正确的初始SP和Reset_Handler地址。我遇到过最隐蔽的一次是安全固件启动时初始化了非安全RAM的某个区域导致非安全代码后续校验CRC失败直接卡住。后来在安全固件里限定只初始化自己那一段RAM问题消失。4.2 调试器连接异常与PC寄存器读取异常在SWD调试时安全方案最直观的影响就是你用调试器读到的内容可能是不完整的。我遇到过连接正常但读PC寄存器得到的是一个不在代码段里的地址读取Flash数据时某些区域全变成0xFF或者0x00像是Flash坏了一样。这其实是安全调试权限在起作用。调试器本身能连上但Secure世界不透出任何信息你看到的只是非安全世界的局部视图。排查建议确认芯片当前的调试权限配置很多芯片有Secure Debug的解锁流程开发阶段要配置成允许调试安全区。检查是否设置了某种级别的调试锁定比如生产模式禁用了SWD访问。如果是连不上是正常的这就是量产状态。如果部分Flash内容读不出来先确认这块Flash的安全属性是不是被标成了Secure。这里额外提一个热词里经常被问到的问题“SWD协议怎么读取PC寄存器”在普通Cortex-M上确实可以通过DAP的Core Register访问接口读R15PC但在TrustZone开启后你想读Secure状态的PC必须拥有Secure调试权限否则读出来的是被掩盖的值或者直接错误。所以排查方向不是“SWD命令对不对”而是“调试权限够不够”。4.3 外设和中断“无响应”问题还有一种典型故障非安全代码操作某个外设寄存器写进去没反应或者中断不触发。这个问题的根源往往是外设的安全属性没有配置外设被默认划到了安全世界非安全代码根本无权访问。处理方法是去配置PPC/PPU这类外设保护单元把需要给非安全世界使用的外设“放行”。比如串口、普通定时器、GPIO这些通常在非安全世界使用就要把对应外设桥配成Non-secure。中断同理。如果你把一个中断源配置成Secure中断但中断服务函数写在了非安全工程里这个中断永远不会有响应。要么把中断改成非安全属性要么把中断处理函数放到安全工程里。我建议的原则是安全关键功能的中断留在安全世界与应用耦合弱的中断全部划给非安全世界两边的逻辑尽量解耦减少状态切换频次。4.4 OTA升级失败的几种可能安全方案落地后OTA升级失败的概率会明显上升而且报错信息往往不直观。我整理过几个高频原因签名验证失败最常见。打包升级包时用的私钥和芯片里烧录的公钥不匹配。排查时先确认两边密钥指纹是否一致。版本回滚被拦芯片里已经有了更高的版本号你拿旧版本测试当然会被拒。这是防回滚设计在正常工作不是bug。新固件写入后被篡改升级包下载后但在写入前被非安全世界代码动了手脚导致验签不通过。这里要确保下载缓冲区的完整性校验在安全世界做。升级过程中安全区Flash被意外擦写如果你把安全固件也设计成可升级要特别注意升级流程里不能越界擦除了非安全区的关键数据。把这些常见问题整理成一张速查表现象最可能原因排查重点启动HardFault链接脚本段划分错误或SAU配置错误查看异常PC落入的地址区域调试器读不到数据安全调试权限不足或区域为Secure检查调试解锁流程与RDP级别外设写不进外设安全属性未放行检查PPC/PPU配置中断不触发中断安全属性与handler所在世界不匹配检查NVIC中断配置与向量表位置OTA验签失败密钥不匹配或固件被篡改比对公钥哈希与签名算法参数版本回滚被拒防回滚机制生效检查版本号计数器与APP版本声明根据我个人的实际经验调试这类安全项目最重要的一步永远是先明确当前故障发生在安全世界还是非安全世界。用调试器看PC寄存器、看fault状态寄存器里的异常返回地址判断它落在哪段Flash区域然后顺着安全属性配置表反推——这一块到底是被谁拦下来的。只要把“安全地图”画清楚大多数问题都能在十分钟内定位。最后再分享一个小技巧开发阶段把SAU的配置代码做成条件编译调试时全部静态关闭先把业务逻辑跑通等到功能稳定了再打开安全隔离逐个模块排查越界访问。这样能把“逻辑bug”和“安全属性bug”分开处理调试难度会低很多。这套方法我在好几个项目里试过实测下来很稳能省下大量时间。
返回列表