上周处理一个 STM32L552 的项目,客户在已有 TrustZone 分区方案的前提下,要求给非安全侧新增一路 USART1 中断收发,还被特别要求不能重新跑 CubeMX 生成。原因很直接:工程里已经手工改过链接脚本、SAU 配置和安全侧初始化代码,CubeMX 只要一重新生成,这些人工改动就会被覆盖,项目直接没法编译。于是我对着参考手册和 HAL 源码逐层去翻,最后在不打开 CubeMX 的情况下把 UART 中断完整跑通了。整个过程踩了不少坑,尤其是 TrustZone 对中断链路的影响,比普通 STM32F1/F4 项目复杂得多。这篇内容就是这次实践的全记录,适合已经有 STM32 HAL 基础、刚开始碰 TrustZone,或者正被 CubeMX 生成代码折腾的人参考。
先说清楚一个前提:我用的芯片是 STM32L552,Cortex-M33 内核,硬件支持 TrustZone。但同样的逻辑在 STM32U5、STM32H5 系列上也成立,只是寄存器名称和安全配置项的位位置略有差别。下面几节我会把“为什么不能直接用 CubeMX 生成”、“TrustZone 对 UART 中断配置到底卡在哪几层”、“完整的手写配置步骤”以及“实测遇到的坑”依次讲透。
1. 为什么要在 TrustZone 下手写 UART 中断:不用 CubeMX 的真实场景
很多人听到“不用 CubeMX”第一反应是“装高手”,其实不是。CubeMX 在 TrustZone 工程里做的事非常多,但也正因为多,它和已有工程之间经常打架。
1.1 正常 CubeMX TrustZone 流程到底做了什么
在带有 TrustZone 的 STM32 上,CubeMX 会默认生成两个工程:一个 Secure 工程,一个 Non-Secure 工程。它帮你完成的事情包括:
- 配置 Option Bytes,确定 Flash 里 Secure 和 Non-Secure 区域的边界。
- 在 Secure 工程初始化代码里设置 SAU 和 GTZC,给外设和内存区域分配安全属性。
- 生成两个独立的向量表,Secure 侧用
VTOR_S,Non-Secure 侧用VTOR_NS。 - 配置好 Secure 工程的初始中断和目标态,保证启动阶段能正常跳到 Non-Secure 应用。
- 生成两个独立工程各自的链接脚本,分别指向不同的 Flash/RAM 区域。
- 最后在 Non-Secure 工程的
main里才允许你写业务代码,UART、GPIO、定时器这些外设初始化,CubeMX 全都帮你铺好了。
如果你是从零开始一个新项目,这套流程的确省事。但问题也恰恰出在这里:它把“安全分区”和“应用功能”耦合得太死。你一旦在 Secure 工程里手工维护了 SAU 配置,或者像我们项目一样用了 TF-M 风格的启动流程,CubeMX 重新生成代码时,那一堆MX_..._Init()函数会毫不犹豫地覆盖你手工改过的安全属性初始化。
1.2 必须手工写的几种现实场景
我这次遇到的情况属于最典型的一种:工程里已经有自己的安全侧分区方案,并且 Non-Secure 工程是 CMake 管理的编译系统,CubeMX 的代码生成完全不适应这种构建方式。这种情况下,你没有选择,只能手写。
还有几种场景也特别常见:
- 你需要在运行过程中动态切换外设的安全属性。CubeMX 生成的是启动时一次性配置,运行时要改 GTZC 或者 TZSC,你依然得自己写寄存器操作。
- 你在做安全侧与普通应用侧的通信,比如 Non-Secure 工程通过安全服务调用 Secure 侧的 UART,接口没法定成 CubeMX 模板的样子。
- 内存分区是非标准的。CubeMX 的链接脚本只覆盖它自己定义的那几种分区模板,一旦你用了自定义的 LMA/VMA 布局,生成代码没法用。
- CI/CD 构建环境要求全部脚本化。CubeMX 是 GUI 工具,在 Linux CI 环境里自动化生成代码虽然可行,但每次刷新配置都会导致大量无意义的 git diff,不利于代码审查。
所以说,搞清楚手写方案不是要抛弃工具,而是当工具不适合项目现状时,你要能接管底层。
1.3 手写方案的实际收益
手动写一遍之后,你会对整条中断链路有完全不一样的理解。比如你会发现,普通 STM32 上只要配置 GPIO 复用、使能外设时钟、调用HAL_UART_Receive_IT就能跑通的 UART 中断,在 TrustZone 上其实暗藏着一层“这个外设归谁所有、中断能够去哪个世界”的问题。
而且从工程维护角度看,手写代码是可以进版本管理、可以 code review、可以写单测的。CubeMX 生成的代码虽然也能 review,但很多配置隐藏在与 IDE 绑定的.ioc文件里,review 起来反而不直观。后面我会给出完整的手写配置流程,并解释每一步为什么必须这么做。
2. TrustZone 对 UART 中断链路的影响:必须手动打通的三层安全配置
TrustZone 环境下,UART 中断能不能正常触发、触发后能不能进入正确的处理函数,取决于三层安全属性是否一致。这三层分别是:外设本身的安全属性、NVIC 中断目标态、向量表归属。任何一层配置不一致,问题就会以各种诡异的方式出现。
2.1 第一层:外设安全属性(GTZC 里的 TZSC/TZPC)
Cortex-M33 的 TrustZone 把系统分成了 Secure 世界和 Non-Secure 世界。判断一个内存地址属于哪个世界,靠的是 SAU 和 IDAU。SAU 是可编程的,由 Secure 代码在启动时配置;IDAU 是芯片厂家固化好的实现定义属性。在 STM32L5 上,IDAU 之前还有一层 GTZC,也就是 Global TrustZone Controller,它负责给外设和 GPIO 分配安全属性。
GTZC 内部又拆成几个部分,和 UART 直接相关的是:
- TZSC:负责 AHB/APB 外设的安全属性,比如 USART1 是 Secure 还是 Non-Secure。
- TZPC:负责 GPIO 端口的安全属性,比如 GPIOA 是 Secure 还是 Non-Secure。
- TZIC:负责捕获那些“非法中断”或者“非法访问”事件,调试时非常有用。
以 USART1 为例,复位默认值是 Secure。也就是说,Non-Secure 代码在没做任何配置的情况下直接访问 USART1 寄存器,会触发 SecureFault。同理,USART1 的引脚如果所在的 GPIO 端口还是 Secure 属性,Non-Secure 代码操作这个引脚也会出问题。所以你要手动处理的第一个配置就是:把 UART 实例和所在 GPIO 端口的安全属性,改成和目标使用世界一致。
/* 以 STM32L5 为例,Secure 世界启动早期执行 */ /* 将 USART1 配置为 Non-Secure */ TZSC->SECCFGR1 &= ~TZSC_SECCFGR1_USART1_S; /* 将 GPIOA 配置为 Non-Secure */ TZPC->DECSECCFGR1 &= ~TZPC_DECSECCFGR1_DECSECA;注意,这两个寄存器在复位后处于锁定状态吗?并不是,但强烈建议在 Secur 侧启动早期配完就不要再动,防止 Non-Secure 侧通过非法路径尝试修改。不同型号的 GTZC 寄存器布局不同,U5 和 H5 的位定义有差异,一定要对着对应参考手册的 “GTZC registers” 章节核对。
2.2 第二层:NVIC 中断目标态(ITNS 寄存器)
外设被标注为 Secure 或者 Non-Secure 只是第一步,紧跟其后的是中断目标态。Cortex-M33 的 NVIC 里,每个中断源都有一个“Target Non-Secure”属性位,集中在NVIC->ITNS寄存器组里。这一位决定了中断触发后,CPU 是进入 Secure Handler 还是 Non-Secure Handler。
ITNS 的配置规则很简单也有点坑:它必须和外设本身的安全属性一致。如果外设是 Non-Secure,那这个中断源在 ITNS 里必须置 1;如果外设是 Secure,ITNS 必须保持 0。如果不一致,轻则中断不触发,重则直接把系统引导到错误状态。
/* Secure 侧配置 USART1 中断目标态为 Non-Secure */ NVIC_SetTargetState(USART1_IRQn, 1); /* 或者直接操作寄存器 */ NVIC->ITNS[0] |= (1UL << USART1_IRQn);这里有个重要细节:ITNS 寄存器只在 Secure 世界可见,Non-Secure 代码是看不到也写不了的。所以哪怕你的 UART 完全属于 Non-Secure 应用使用,这个 ITNS 配置也必须由 Secure 侧提前做好。换句话说,多了一个“Secure 侧要为 Non-Secure 侧铺路”的步骤,这是以前做标准库/HAL 时完全不需要考虑的事。
2.3 第三层:向量表归属(VTOR_S 与 VTOR_NS)
Cortex-M33 针对于 Secure 和 Non-Secure 世界各有一个向量表偏移寄存器,分别是SCB->VTOR_S和SCB->VTOR_NS。中断触发后,CPU 会按照中断目标态去读取对应世界的向量表。中断目标是 Non-Secure,则从 Non-Secure 向量表取中断服务函数地址;目标是 Secure,则从 Secure 向量表取。
这一层最容易出问题的地方在于:你在 Keil/IAR/CMake 工程里写了USART1_IRQHandler,但这个函数被编译到了错误的链接区段。比如 Non-Secure 工程里定义了 Hander,但 Non-Secure 向量表在链接脚本里指向的地址和我们实际放置函数的位置不一致,那么中断一旦触发,CPU 取到的是错误地址,轻则跑飞,重则直接 HardFault。
所以手写方案里,你必须明确知道当前这个 UART 中断要交给哪个世界处理,然后把中断服务函数放在对应的向量表区段里。如果你用的是真正的双工程方案,Secure 侧一个工程、Non-Secure 侧一个工程,这个区分是天然的。如果你用了什么单工程双区域链接的骚操作,那就特别容易在这里踩坑。
3. 手写 UART 中断的完整代码:从 RCC 时钟到 HAL_Callback
前面讲了这么一大堆原理,现在进入实操。我这次用的方案是:USART1 属于 Non-Secure 世界,Non-Secure 应用直接使用 HAL 完成中断收发;Secure 侧只负责启动阶段的安全属性配置。这个结构最简单,也是大多数项目实际采用的形态。
3.1 安全属性预置与时钟、GPIO 初始化
Secure 侧的启动代码,必须在跳到 Non-Secure 应用之前完成 UART 和 GPIO 的安全属性预置。否则 Non-Secure 应用连寄存器都摸不到。下面这段代码放在 Secure 工程的main早期,或者对应的SystemInit之后执行。
/* Secure 侧启动早期执行 */ void Secure_Init_NS_UART(void) { /* 1. 把 USART1 切到 Non-Secure */ TZSC->SECCFGR1 &= ~TZSC_SECCFGR1_USART1_S; /* 2. 把 GPIOA 切到 Non-Secure */ TZPC->DECSECCFGR1 &= ~TZPC_DECSECCFGR1_DECSECA; /* 3. 设置 USART1 中断目标态为 Non-Secure */ NVIC_SetTargetState(USART1_IRQn, 1); }这里有个容易忽略的点:STM32 的 GPIO 默认上电后大部分是模拟输入,并且 GPIOA 这个端口在 GTZC 里默认是 Secure 的。如果你只清了 USART1 的安全属性,没清 GPIO 端口的安全属性,那 USART1 的 TX/RX 引脚依然无法被 Non-Secure 应用正常操作,因为引脚对应的 GPIO 寄存器还是 Secure 保护状态。
时钟这边,RCC 配置也有安全属性概念。在 STM32L5 上,RCC 寄存器同样受 TrustZone 控制。为了让 Non-Secure 的HAL_UART_Init能正常执行,我建议在 Secure 侧启动阶段直接把所需外设时钟打开,或者把 RCC 里对应的时钟使能权限也放开。实际工程里我习惯直接把时钟全部配置好,Non-Secure 侧拿到的是一个时钟树已经稳定的系统,省去很多排查时间。
/* Secure 侧启动时钟配置 */ void Secure_Init_Clock(void) { __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_USART1_CLK_ENABLE(); }时钟配置在 Secure 侧完成后,Non-Secure 侧的 HAL 也能访问这个外设了,因为外设总线时钟一旦使能,不会因为世界切换而关闭。
3.2 GPIO 复用与 UART 初始化
Non-Secure 工程的main里,初始化代码和普通 STM32 项目看起来差别不大,但每一步的背景都多了一层“这个资源已经归我所有”的意味。
/* Non-Secure 侧 */ UART_HandleTypeDef huart1; uint8_t rx_byte; void NS_UART_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_USART1_CLK_ENABLE(); GPIO_InitStruct.Pin = GPIO_PIN_9 | GPIO_PIN_10; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; GPIO_InitStruct.Pull = GPIO_PULLUP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; GPIO_InitStruct.Alternate = GPIO_AF7_USART1; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); huart1.Instance = USART1; huart1.Init.BaudRate = 115200; huart1.Init.WordLength = UART_WORDLENGTH_8B; huart1.Init.StopBits = UART_STOPBITS_1; huart1.Init.Parity = UART_PARITY_NONE; huart1.Init.Mode = UART_MODE_TX_RX; huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; huart1.Init.OverSampling = UART_OVERSAMPLING_16; huart1.AdvancedInit.AdvFeatureInit = UART_ADVFEATURE_NO_INIT; HAL_UART_Init(&huart1); /* 配置 NVIC 中断优先级并打开中断 */ HAL_NVIC_SetPriority(USART1_IRQn, 0, 0); HAL_NVIC_EnableIRQ(USART1_IRQn); }如果你之前用 CubeMX 生成过代码,会发现这段代码和生成的MX_USART1_UART_Init()基本一模一样。区别在于,CubeMX 帮你搞定了几层安全配置,而我们现在是手动在前面铺好的。
3.3 启动接收并实现回调
初始化完成后,启动中断接收:
void NS_UART_Start_Receive(void) { HAL_UART_Receive_IT(&huart1, &rx_byte, 1); }同时实现中断服务函数和 HAL 回调。注意,这个USART1_IRQHandler必须编译到 Non-Secure 工程的向量表里。
/* Non-Secure 侧 */ void USART1_IRQHandler(void) { HAL_UART_IRQHandler(&huart1); } void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { /* 收到一个字节,做业务处理 */ HAL_UART_Transmit(&huart1, &rx_byte, 1, 100); /* 继续等待下一个字节 */ HAL_UART_Receive_IT(&huart1, &rx_byte, 1); } }到这里,一个最简单的 Non-Secure 侧 UART 中断收发就串起来了:数据到达 -> NVIC 收到中断 -> 因为 ITNS 置 1,CPU 切到 Non-Secure 状态取 Non-Secure 向量表 -> 进入USART1_IRQHandler-> HAL 处理 -> 回调函数。
3.4 如果 UART 必须给 Secure 侧使用
有些场景下 UART 必须留在 Secure 世界,比如安全日志输出、安全数据通道。这时候代码结构会反过来:UART_Init、USART1_IRQHandler、HAL_UART_RxCpltCallback全部放在 Secure 工程里,ITNS 保持 0。Non-Secure 世界需要收发数据时,不能直接碰这个 UART,只能通过你单独写的安全服务函数(比如 SVC 或者 NSC 区域调用)间接使用。
/* Secure 侧服务接口,供 Non-Secure 侧调用 */ uint8_t Secure_UART_ReceiveByte(uint8_t *byte) { if (HAL_UART_Receive(&Secure_huart, byte, 1, 100) == HAL_OK) return 0; return 1; }这个设计在传统 STM32 上完全没有等价物,是 TrustZone 项目才特有的架构取舍。到底把外设放哪个世界,需要和安全架构一起决策,不是单纯看哪个代码好用。
4. 初始化顺序与中断优先级分组:TrustZone 下最隐蔽的联动
很多手写 TrustZone 工程跑不起来,不是配置写错了,而是顺序不对。TrustZone 的初始化顺序,本质上是由“Secure 世界要先把安全属性发牌,Non-Secure 世界才能拿到牌”这个逻辑决定的。
4.1 启动阶段的顺序链
如果你认真读过启动汇编,会发现 CM33 复位后,CPU 默认从 Secure 世界取向量表。整个启动链大致是:
- 复位向量进入 Secure 侧启动代码。
- Secure 侧配置 SAU、GTZC/TZSC/TZPC、NVIC ITNS。
- Secure 侧配置时钟、系统外设。
- Secure 侧设置 Non-Secure 向量表地址
VTOR_NS。 - Secure 侧用
BLXNS或者 SVC 机制跳到 Non-Secure 复位向量。 - Non-Secure 侧开始执行自己的
main,初始化 UART 等业务外设。
我把第 2 步提前强调,是因为很多人会先在 Secure 侧把 UART 初始化了,再在 Non-Secure 侧初始化一次,结果两边配置互相干扰。正确的做法是:安全属性一旦定好,UART 实例归属哪个世界,就由那个世界独立初始化,另一个世界不要重复碰它。
4.2 SAU 区域的潜在影响
SAU 负责把整个地址空间划分成 Secure 和 Non-Secure。如果你在 Secure 侧配置 SAU 时,把 USART1 的寄存器地址区间错误地标注成了 Secure,那么即使 TZSC 里 USART1 已经写成 Non-Secure,Non-Secure 侧访问 UART 寄存器照样会被拦截,因为 SAU 的优先级高于外设安全控制。
反过来,如果你把某个 SRAM 区域配置成 Secure,而 Non-Secure 侧 UART 接收缓冲区恰好放在这个 Secure SRAM 里,那 Non-Secure 代码操作这个缓冲区也会触发安全违规。所以手写工程里,你要画一张表,明确列出来:
| 资源 | 归属世界 | 配置位置 |
|---|---|---|
| USART1 实例 | Non-Secure | TZSC |
| GPIOA | Non-Secure | TZPC |
| USART1 中断目标态 | Non-Secure | NVIC ITNS |
| RX 缓冲区所在 SRAM | Non-Secure | SAU / 链接脚本 |
| UART 时钟 | Secure 已使能 | RCC |
这张表在排查问题时特别有用。一旦出现 SecureFault,先对表检查资源归属,能省一半时间。
4.3 中断优先级分组和特权级别
Cortex-M33 的中断优先级寄存器(IPR)有 Secure/Non-Secure 两套视图。Secure 侧可以通过AIRCR.PRIS锁定当前优先级分组,让 Non-Secure 侧只能用被允许的优先级范围。这看起来像安全特性,但实际项目里经常引发奇怪现象:Non-Secure 侧调用HAL_NVIC_SetPriority设置一个超出允许范围的优先级,写操作会被静默忽略,中断优先级保持默认值。
如果复位后优先级分组是NVIC_PRIORITYGROUP_4之类的默认值,Non-Secure 侧通常不会出问题。但你如果在 Secure 侧设置了更复杂的优先级分组,然后又期望 Non-Secure 侧用同样分组配置 UART 中断,就得确保 Non-Secure 侧的优先级范围匹配。
我踩过的一个具体例子是:Secure 侧把 PRIS 设成了NVIC_PRIORITYGROUP_2,Non-Secure 侧用HAL_NVIC_SetPriority(USART1_IRQn, 0, 0),结果优先级值被裁剪,UART 中断被一个低频定时器中断一直抢占,回调经常延迟好几毫秒。排查了很久才发现是优先级分组的问题。所以在手写 TrustZone 工程时,Secure 侧的优先级设置和 Non-Secure 侧的优先级调用要保持一致,否则宁可全用默认分组。
5. 实测踩坑记录:SecureFault、ITNS 不一致、回调失踪
前面给出的代码虽然是能跑通的,但手写过程中不会一帆风顺。我把这次实操里踩过的坑按现象整理出来,每个都附上根因和排查手段,下次你遇到类似问题可以直接抄作业。
5.1 现象一:Non-Secure 代码一访问 UART 就进 SecureFault
这是最常见的坑,也是最容易定位的。现象就是 Non-Secure 侧调用HAL_UART_Init时,程序直接进入SecureFault_Handler,或者复位循环。
排查手段首先是看异常状态寄存器。Cortex-M33 的SCB->SFSR会记录安全错误的原因,重点看这几个位:
| SFSR 位段 | 含义 | 排查方向 |
|---|---|---|
| INVEP | 无效异常入口 | 向量表配置问题 |
| INVTRAN | 无效状态转换 | BLXNS/安全函数调用问题 |
| AUVIOL | 属性违规 | 外设/内存安全属性不一致 |
| LSPERR | 浮点延迟保存错误 | 安全侧浮点上下文问题 |
实际中 AUVIOL 位最常见,说明就是安全属性没配置对。按 4.2 节的表格逐项检查,八成是 TZSC 或者 TZPC 漏配了。
/* Secure 侧调试用:读取并打印 SFSR */ uint32_t sfsr = SCB->SFSR; if (sfsr & SCB_SFSR_AUVIOL_Msk) { /* 属性违规,重点查 TZSC/TZPC/SAU */ }5.2 现象二:ITNS 配置和向量表不匹配,中断永远不执行
这个坑更隐蔽。外设和 NVIC 看起来都配好了,中断也确实触发了,但程序就是不进你写的USART1_IRQHandler。
根因通常是两种情况。第一种:ITNS 设成了 Non-Secure,但 Non-Secure 向量表里对应位置是空的,或者指向了一个 Secure 地址。第二种:ITNS 设成了 Secure,但 USART1 外设本身是 Non-Secure,这时中断行为可能变得很蹊跷,甚至触发 SecureFault。
排查手段是在调试器里同时检查VTOR_S、VTOR_NS和向量表地址处的值。
/* 在调试器里观察 */ SCB->VTOR_NS; /* Non-Secure 向量表基地址 */ *((uint32_t *)(SCB->VTOR_NS + USART1_IRQn * 4 + 16)); /* IRQ 向量 */IRQ 向量的偏移规律是:初始栈顶 + Reset + NMI + HardFault + MemManage + BusFault + UsageFault + ... + 16 个系统异常之后,才是 IRQ0。USART1 的 IRQ 号根据芯片不同一般在 20 左右,对应向量表索引大概是USART1_IRQn + 16。我常在调试器里直接打印这个值,和反汇编窗口里USART1_IRQHandler的地址对比。如果看到的是0xFFFFFFFF,说明链接脚本没把中断函数放到向量表里,得去查工程里向量表的符号导出。
5.3 现象三:HAL_UART_RxCpltCallback 不触发,但中断确实进来了
这个现象是我在调试优先级分组时发现的。中断服务函数里打断点,能看到HAL_UART_IRQHandler执行了,但回调就是没反应。
HAL 的 UART 接收中断流程里,如果出现 Overrun Error(ORE)标志,HAL_UART_IRQHandler会优先处理错误,进入HAL_UART_ErrorCallback,此时HAL_UART_RxCpltCallback自然不会触发。而在 TrustZone 环境下,如果 Non-Secure 侧对 UART 的访问频率和数据流速率不一致,ORE 很容易置位。
void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart->ErrorCode & HAL_UART_ERROR_ORE) { /* 清掉 ORE 标志,重启接收 */ __HAL_UART_CLEAR_OREFLAG(huart); HAL_UART_Receive_IT(&huart1, &rx_byte, 1); } }另外别忽略一种情况:你的回调函数名写对了没。HAL 库的回调都是 weak 函数,如果你在 Secure 侧也定义了一个HAL_UART_RxCpltCallback,而实际中断是进 Non-Secure 世界处理的,那 Non-Secure 侧链接器可能不会选到你的 Non-Secure 版本,两个工程各自链接各自的回调,看起来就像“回调失踪”。所以双工程方案里,回调函数的归属世界必须和中断处理世界严格一致。
5.4 现象四:Secure 侧初始化 UART 后跳到 Non-Secure,HAL 状态错乱
如果你的 Secure 侧在把 UART 交给 Non-Secure 之前,自己先调用过HAL_UART_Init,然后 Non-Secure 侧又调用HAL_UART_Init,HAL 内部的gState和Lock很可能已经不在初始状态,导致第二次初始化直接返回HAL_BUSY。
这是因为 HAL 的状态机是基于内存变量维护的,Secure 侧和 Non-Secure 侧如果各自有一份UART_HandleTypeDef变量,它们并不共享同一个对象。更稳妥的做法是:同一个 UART 实例,在它的归属世界里只初始化一次,另一个世界不管。如果确实需要交接,Secure 侧初始化完就把gState改回HAL_UART_STATE_RESET,或者干脆让 Secure 侧完全不碰这个 UART,把时钟打开即可。
5.5 一个最小复现实验
如果你也是第一次做这件事,我建议不要直接往大工程里加代码,先搭一个最小双工程验证环境。Secure 工程只做三件事:配时钟、把 USART1 和 GPIOA 置为 Non-Secure、设 ITNS 后跳到 Non-Secure。Non-Secure 工程只做 UART 回显。
实验步骤:
- 在 Non-Secure 工程的
main里初始化 UART 并启动HAL_UART_Receive_IT。 - 用 USB 转串口连接 PA9/PA10。
- 串口助手发送 0x41。
- 预期串口助手收到 0x41 回显。
如果没回显,按优先级排查:先看有没有进 SecureFault,再看有没有进USART1_IRQHandler,最后看有没有进HAL_UART_RxCpltCallback。这三步观察点都打上断点,很快就能定位是哪一层断了。
6. 最后的工程建议与调试心得
这个项目做完之后,我自己最大的体会是:TrustZone 环境下手写外设中断,本质上是在和一个“资源归属权”系统打交道。传统 MCU 编程里,你写完初始化就能直接用外设;TrustZone 里,你必须先把外设“分发”到正确的世界,然后那个世界的代码才能像以前一样使用它。这个思维转换很重要。
几个具体建议:
- 把所有安全属性配置集中放到 Secure 侧一个独立的
Secure_Init_函数簇里,不要散落在多个文件里。这样 Non-Secure 侧代码不会碰任何安全寄存器,审查时也只需要看这一处。 - 在 Secure 侧加一个
SecureFault_Handler,进去后把SFSR的值保存到固定内存地址,方便后续调试。别让空循环把现场丢掉。 - 向量表是最容易踩坑的地方。每次构建后,用
nm或者调试器查看USART1_IRQHandler的段地址,确认它在正确的世界区域里。 - 优先级的设置一定要统一。Secure 侧和 Non-Secure 侧如果都配中断优先级,最好使用相同的分组策略,否则低优先级中断抢高优先级这种诡异时序会把你折磨疯。
最后再分享一个小技巧:调试时,我习惯在 Secure 侧把 TZIC 的中断状态寄存器读出来看看。TZIC 会记录哪些“非法事件”违反了安全规则,它给出的违规外部中断源编号,能直接告诉你哪个中断配置错了。这个信息比在 Non-Secure 侧瞎猜要高效得多。
如果你手头正好有带 TrustZone 的板子,完全可以按这篇文章的流程走一遍。即使你最终还是会回到 CubeMX,这一遍手写也会让你在遇到生成代码和实际需求冲突时,知道去哪里改、怎么改。