做嵌入式图形开发最怕的不是时序调不对,而是你明明把屏点亮过无数次,换了一颗带 TrustZone 的 MCU 之后,LTDC 反而不工作了。寄存器写不进、DMA2D 搬图花屏、中断始终不触发,这些现象单独看都像硬件问题,实际上全是安全属性没配好。我自己第一次在带 TrustZone 的芯片上配置 LTDC 时,就被这个组合狠狠教育了一顿,所以今天把整套配置思路和踩坑记录整理出来,希望对做安全产品、TFT 显示方案的朋友有帮助。
LTDC 是 ST 芯片上的液晶显示控制器,TrustZone 是 ARM 针对嵌入式设备做的硬件安全隔离方案。两者看起来一个管显示、一个管安全,但放到同一颗 MCU 上,就产生了一系列需要仔细处理的问题:外设归哪个世界管、显存放在哪个区域、中断走哪一套 NVIC,这些都要在写显示驱动之前搞定。这篇文章适合正在做带 GUI 的安全终端、支付设备、工业 HMI 的开发者,也适合那些刚接触 TrustZone、想搞清楚它到底怎么影响裸机外设开发的初学者。
1. 为什么 TrustZone 会管到显示驱动头上
1.1 先理解 TrustZone 是怎么“管”外设的
TrustZone 会把 CPU 的运行状态分成安全和非安全两个世界,在 Cortex-M33、M55 这类内核上,硬件层面不仅仅隔离了代码,还隔离了内存和外设访问。具体来说,芯片内部有一套硬件机制,给每一块内存区域、每一个外设都打上“安全”或“非安全”的标签。安全世界的代码可以访问所有安全和非安全资源,非安全世界的代码只能访问被标记为非安全的资源。
这个机制听起来有点抽象,我用一个生活化的类比来解释。你可以把 MCU 想象成一家公司,安全世界是管理层办公室,非安全世界是普通员工工区。管理层有门禁卡,能进所有房间;普通员工只能进自己的工区,如果误闯了管理层的资料室,报警器会响并且门会自动锁死。对应到 MCU 上,就是非安全代码一旦访问了安全地址或安全外设寄存器,会直接触发 HardFault 或者 Bus Error。
这套隔离机制在外设维度上,是由 GTZC(带 TrustZone 的全局安全控制单元)这类模块来管理的。每个外设都有默认的安全属性,绝大多数外设复位之后默认挂在安全世界名下。也就是说,只要芯片开了 TrustZone,LTDC 复位之后默认是“安全外设”,非安全世界的代码去操作它的寄存器,会直接被总线错误挡回来。“显示驱动跑不起来”往往就是这么来的。
1.2 LTDC 的特殊性:它是一条完整的数据通路
LTDC 和普通的外设不一样,普通外设比如 UART、SPI,你操作它的寄存器、收发数据就够了,数据链路很短。LTDC 则要复杂得多:它一方面要通过 AHB/AXI 总线不断读取显存中的像素数据,另一方面要产生 RGB 时序信号送给屏幕,中间还可能要经过 DMA2D 做图形加速,最终在中断里告诉你“这一帧已显示到哪一行了”。
这意味着 LTDC 涉及的资源不止一个外设寄存器本身,而是“LTDC 寄存器 + 显存区域 + DMA2D + 中断控制器”这一整条链路。而 TrustZone 的安全隔离,恰好就是针对这种“跨模块跨内存”的访问场景设计的。每一个环节一旦安全属性不对,整条显示通路就会断掉。
我碰到过一个很典型的情况:LTDC 已经能够正常配置,屏幕也能点亮了,但只要一开 DMA2D 做整屏搬图,就开始花屏。排查到最后发现,DMA2D 本身已经被我划到非安全侧了,但它的目标地址——显存,却还落在安全内存区域里。DMA2D 作为非安全外设,根本没有权限往安全内存里写数据,总线访问被自动拒绝,但因为没有仔细检查错误标志,代码还以为搬图成功了。这就是典型的“数据通路”问题。
1.3 先做决定:LTDC 到底放安全侧还是非安全侧
在开始配置 TrustZone 之前,你得先想清楚一个问题:你的显示链路到底应该放在哪个世界。这个问题没有标准答案,完全取决于产品需求。
如果你的产品是工业 HMI、家用电器的彩屏界面、普通消费类仪表盘,显示内容只是一些参数、图表、控制界面,不涉及敏感数据,那么推荐把 LTDC、DMA2D、显存区域都划到非安全世界。平时业务代码跑在非安全侧,直接操作显示外设,没有来回切换的开销,开发效率也高。
但如果你的产品带有安全属性,比如金融支付终端的界面会显示密钥状态、二维码、用户敏感信息,或者医疗设备需要在显示界面上体现安全提示,那你可能要让安全世界的代码来控制显示链路,避免非安全世界篡改屏幕内容。这种场景下,LTDC 就要放在安全侧,而完全禁止非安全侧直接访问。
这两种方案在配置流程上差别比较大。下面我以“LTDC 放非安全侧”为主线来讲,这是绝大多数项目的需求,同时也会补充一些安全侧方案的注意事项。
2. 配置前必须搞明白的四个核心机制
2.1 启动流程:安全世界永远先登场
TrustZone 开启后,CPU 复位后的第一条指令一定是执行安全世界代码。这是硬件设计决定的,因为只有安全代码才能配置安全属性和内存隔离。所以哪怕你的产品业务代码全部跑在非安全侧,也需要先有一个安全工程完成初始化,之后再由安全代码跳转到非安全应用的入口点。
这个启动顺序,直接决定了 LTDC 配置的代码结构。你不能像以前那样在 main 函数里直接噼里啪啦地初始化时钟、配置 LTDC,而是要拆成两个工程:安全工程负责配置 TrustZone 相关机制并跳转;非安全工程负责业务逻辑和显示驱动的实际初始化。
这个跳转动作有讲究,不是简简单单用函数指针调用一下就行的。安全代码需要设置好非安全世界的栈指针 MSP_NS、向量表 VTOR_NS,然后通过某些方式跳转过去。在 Cortex-M33 上,这通常涉及EXC_RETURN的用法和S、NS状态的切换。你可以用内联汇编配合特殊寄存器,也可以直接用 ST 提供的库函数。无论哪种方式,核心思想都是:在跳转前,你得先保证非安全代码能访问的 RAM 区域已经准备好并且是“非安全”的。
我在实际调试中见过不少新手在这里翻车:安全工程配置了 SAU 和 GTZC,但忘了把非安全工程的向量表放在非安全可访问的 SRAM 区域,导致跳转过去一执行中断就直接 HardFault。这一类问题往往不会在第一时间定位,因为你看到的现象是屏幕没反应,很难联想到是启动流程的问题。
2.2 外设安全属性:让 LTDC 和 DMA2D “划归”非安全
外设的安全属性,是需要显式配置的。在 ST 的带 TrustZone 芯片上,有一个专门的模块负责分配外设的安全归属,通常叫 GTZC 或者 TZSC。这个模块里面有一堆掩码寄存器,每个 bit 对应一个外设或者一组外设,把 bit 写成某个值,就能把对应外设标记成安全或非安全。
你需要重点关注的三个外设是:LTDC 本身、DMA2D 图形加速器,以及时钟控制的访问权限。LTDC 和 DMA2D 好理解,都是显示链路的核心;但很多人会忽略 RCC(时钟控制)也要能访问。你想想,如果 RCC 还在安全侧手里,非安全代码连使能 LTDC 时钟的寄存器都碰不到,那后面配置时序、图层全是空谈。
有些芯片的 RCC 安全属性是拆分的,比如某些寄存器位保留给安全侧,某些位可以配置成非安全访问。遇到这种芯片,你需要仔细查阅参考手册中关于 RCC 安全属性的章节,或者用 CubeMX 之类工具生成初始代码,它会帮你把这些分散的属性配置好。
还需要注意的是:一旦你把 LTDC 和 DMA2D 划成了非安全外设,安全世界的代码就再也无法直接访问这些外设寄存器了。这有时候会带来不便,比如你的图形界面有一部分需要安全世界来动态改写,这时候就得通过一个 NSC(Non-Secure Callable)函数做网关。安全世界编写一个可被非安全调用的函数,在函数内部操作显示控制器的硬件资源,非安全代码通过这个函数间接实现显示控制。这个函数本身要设立在带有 NSC 属性的内存区域里,是不是有点绕?但这是 TrustZone 下常见的软件架构设计,值得记住。
2.3 内存归属:显存必须能被 LTDC 读到
显存区域的安全属性,是显示链路里面最容易被忽略、也最容易出怪问题的一环。LTDC 有个 DMA 引擎,会持续从显存地址读取像素数据。如果 LTDC 被划成了非安全外设,那么它通过 DMA 读取的地址,也必须是“非安全”内存区域。哪怕只有一块显存落在安全区域,读取时会得不到合法像素值,表现就是花屏、黑屏、颜色错乱。
SRAM 的安全属性分配,在 Cortex-M33 这类芯片上,由 SAU(安全属性单元)负责。你需要在安全工程里明确划分好哪些 SRAM 是安全的、哪些是非安全的。理想情况下,把显存 Buffer 放在一段连片的非安全 SRAM 区域里,同时确保链接脚本里这个区域的符号能被非安全工程正确引用。
如果你的系统里用了 DMA2D,那么 DMA2D 要访问的所有地址也都要遵循同样的规则。包括 DMA2D 的源地址(比如片外 Flash 里的图片素材)、目标地址(显存 Buffer)、以及设置顔色时访问的常量内存。只要有一个地址不合法,DMA2D 传图就会出问题。更麻烦的是,有些芯片的 DMA2D 错误不会直接报出来,你的代码会像一切正常一样执行完毕,但结果就是屏幕上的图像不对。这种“假成功”问题最浪费调试时间,我在下文会讲具体的排查方法。
2.4 中断归属:NVIC 也要分成两个世界
LTDC 会用到中断,比如行中断(Line Interrupt)和全局中断(Global Interrupt),用来实现撕裂避免、同步刷新等高级功能。在 TrustZone 开启后,NVIC 同样分成了安全和非安全两个部分。每个中断线都有安全属性,默认情况下,所有中断线都归安全世界管理。
你把 LTDC 划成非安全外设之后,还要记得把 LTDC 对应的中断线也改成非安全属性,否则非安全侧的代码调用HAL_NVIC_EnableIRQ会被拒绝,或者中断响应了却进不了非安全的处理函数。
中断优先级也要注意。在 Cortex-M33 上,安全代码可以配置中断优先级分组,非安全代码只能使用非安全处理逻辑允许的优先级范围。如果安全侧把优先级分组设成了 3 bits 抢占优先级 + 1 bit 子优先级,非安全侧的代码要在配合这个分组的情况下正确赋值。否则会出现中断使能了但不响应,或者响应优先级跟预期完全不一致的情况。
3. 完整配置示例:将 LTDC 放在非安全世界的实操步骤
3.1 安全工程部分的处理
安全工程的核心职责是“搭好舞台”:配置好 SAU、GTZC、中断属性,然后让非安全应用登场。下面给出一段基于 Cortex-M33 架构、ST 带 TrustZone MCU 的示意代码,实际寄存器名请以你所用芯片的参考手册为准。
#include "tz_s.h" void TZ_Init(void) { /* 1. 配置 SAU,把需要开放给非安全世界的内存区域标记为非安全 */ SAU_Disable(); SAU_ConfigRegion(0, 0x20000000, 0x00020000, 1); /* 示例:将 128KB SRAM 划分给非安全 */ SAU_ConfigRegion(1, 0x08000000, 0x00080000, 1); /* 示例:将 512KB Flash 划分为非安全 */ SAU_Enable(); /* 2. 配置 GTZC/TZSC,把 LTDC 和 DMA2D 划为外设非安全 */ TZSC_ConfigPeripheral(TZSC_PERIPH_LTDC, TZSC_NS); TZSC_ConfigPeripheral(TZSC_PERIPH_DMA2D, TZSC_NS); /* 3. 配置 RCC 中与显示相关的时钟控制,允许非安全访问 */ TZSC_ConfigPeripheral(TZSC_PERIPH_RCC, TZSC_NS); /* 4. 将 LTDC 中断线配置为非安全,其他外设中断依需求配置 */ NVIC_ConfigTargetNonSecure(LTDC_IRQn); NVIC_ConfigTargetNonSecure(DMA2D_IRQn); } /* 安全跳转函数,跳转到非安全世界 */ void TZ_JumpToNonSecure(void) { /* 设置非安全世界栈指针和向量表 */ uint32_t ns_msp = *(volatile uint32_t *)NON_SECURE_START_ADDR; uint32_t ns_reset = *(volatile uint32_t *)(NON_SECURE_START_ADDR + 4); __TZ_set_MSP_NS(ns_msp); __TZ_set_VTOR_NS(NON_SECURE_START_ADDR); /* 切换到非安全模式,并跳转 */ typedef void (*pFunction)(void); pFunction NonSecure_ResetHandler = (pFunction)ns_reset; NonSecure_ResetHandler(); }这段代码里的关键点是:
- SAU 区域配置一定要在跳转之前完成,否则非安全代码拿不到可用内存。
- 外设归属配置顺序要固定,比如你先配 RCC 再配外设,这样后续外设时钟使能才合法。
- 跳转前要清掉安全侧未处理完的中断标志,避免把中断带到非安全世界。
很多用 STM32CubeMX 的开发者,会习惯性忽略安全工程这一步。实际上 CubeMX 能在生成代码时就把这些 TrustZone 属性先配置好,生成 nTZ(非安全)工程和 secure 工程。你只需要在 secure 工程的TZ_Security_Init类似函数里补上自己的配置即可。
3.2 非安全工程配置 LTDC 的完整流程
跳转到了非安全世界,接下来的工作还是很熟悉的 LTDC 配置顺序,只是你需要始终牢记:当前代码只能访问非安全资源,所有显示相关地址、寄存器、中断都必须落在非安全世界。配置流程如下:
void MX_LTDC_Init(void) { /* 使用 HAL 初始化 LTDC 基本时序 */ hltdc.Init.HorizontalSync = 40; /* HSYNC 宽度,单位像素时钟 */ hltdc.Init.VerticalSync = 9; /* VSYNC 宽度,单位行 */ hltdc.Init.AccumulatedHBP = 139; /* HSYNC + HBP,水平后肩累计值 */ hltdc.Init.AccumulatedVBP = 39; /* VSYNC + VBP,垂直后肩累计值 */ hltdc.Init.AccumulatedActiveW = 539; /* 有效显示区宽度 + 左边累计 */ hltdc.Init.AccumulatedActiveH = 339; /* 有效显示区高度 + 上边累计 */ hltdc.Init.TotalWidth = 569; /* 水平周期总长 */ hltdc.Init.TotalHeigh = 349; /* 垂直周期总高 */ hltdc.Init.BackColor.Blue = 0; hltdc.Init.BackColor.Green = 0; hltdc.Init.BackColor.Red = 0; hltdc.Init.PixelClock = 8000000; /* 像素时钟频率,根据屏规格选择 */ hltdc.Init.HSPolarity = LTDC_HSPOLARITY_AL; hltdc.Init.VSPolarity = LTDC_VSPOLARITY_AL; hltdc.Init.DEPolarity = LTDC_DEPOLARITY_AL; hltdc.Init.PCPolarity = LTDC_PCPOLARITY_IPC; if (HAL_LTDC_Init(&hltdc) != HAL_OK) { Error_Handler(); } } void MX_LTDC_Layer1_Config(void) { /* 配置图层 1:窗口位置、颜色格式、显存地址 */ pLayerCfg.WindowX0 = 0; pLayerCfg.WindowX1 = 480; pLayerCfg.WindowY0 = 0; pLayerCfg.WindowY1 = 272; pLayerCfg.PixelFormat = LTDC_PIXEL_FORMAT_RGB888; pLayerCfg.FBStartAdress = (uint32_t)frame_buffer; /* 必须是非安全 SRAM 地址 */ pLayerCfg.Alpha = 255; pLayerCfg.Alpha0 = 0; pLayerCfg.BlendingFactor1 = LTDC_BLENDING_FACTOR1_PAxCA; pLayerCfg.BlendingFactor2 = LTDC_BLENDING_FACTOR2_PAxCA; pLayerCfg.ImageWidth = 480; pLayerCfg.ImageHeight = 272; if (HAL_LTDC_ConfigLayer(&hltdc, &pLayerCfg, 1) != HAL_OK) { Error_Handler(); } }在这个阶段,最容易犯的一个错误是:显存地址用的是链接脚本自动分配的普通变量,而这些变量可能落在了安全 SRAM 区域。你需要确保frame_buffer这个数组被放到了你在安全工程划分的非安全 SRAM 区域里面,通常是用__attribute__((section(".non_secure_buffer")))或者在链接脚本里单独的段来做。因为很多 IDE 默认链接脚本会优先使用片内 SRAM 起点地址,而安全工程通常是从地址 0 开始分配安全资源,一不小心就撞车。
其次,像素时钟PixelClock这个参数要结合你的屏驱动 IC 允许的范围来选择,不是越大越好。算像素时钟最简单的办法是:总周期宽度乘以总周期高度再乘以目标帧率。比如 480x272 分辨率,水平周期 569,垂直周期 349,目标帧率 60Hz,那你至少需要 56934960 ≈ 11.9MHz 的像素时钟。你配置的时候可以留一定余量,但也不要高出太多,否则屏幕可能直接闪屏甚至白屏。
3.3 帧缓冲与 DMA2D 的实际操作
显存区域配好之后,千万别急着往 LTDC 上挂画面。先用最简单的办法测一下链路:直接把整块显存填成纯色,比如全部写 0x00 或 0xFF,看看屏幕有没有反应。如果不花屏,说明 LTDC 的时序和内存通路都是通的,这个时候再做 DMA2D 搬图才有意义。如果填充纯色就花屏或者完全黑屏,你得回去检查内存归属和时钟配置。
DMA2D 搬图在 TrustZone 下的额外要求,就是遵守内存归属规则。这里有一个非常实用的排查技巧:无论你用 DMA2D 做什么操作,做完之后不要立刻去刷新用户界面,而是先读回目标地址的头几个像素值,跟期望值比对一下。如果读回来的值不是你要的颜色,那几乎可以确定是 DMA2D 访问了安全内存地址或源地址未对齐。
下面是一个 DMA2D 搬运 RGBA8888 图片到显存的示意代码:
void DMA2D_CopyImage(uint32_t *src, uint32_t *dst, uint32_t size) { /* 确保 src、dst 都是非安全内存地址,否则这儿会静默失败或产生总线错误 */ DMA2D->CR = DMA2D_M2M; /* 内存到内存模式 */ DMA2D->FGMAR = (uint32_t)src; DMA2D->BGMAR = (uint32_t)dst; DMA2D->FGOR = 0; DMA2D->BGOR = 0; DMA2D->FGPFCCR = DMA2D_RGB888; DMA2D->BGPFCCR = DMA2D_RGB888; DMA2D->NLR = (uint32_t)(height << 16) | (uint32_t)width; DMA2D->CR |= DMA2D_CR_START; while ((DMA2D->ISR & DMA2D_FLAG_TC) == 0) { /* 等待传输完成 */ } DMA2D->IFCR = DMA2D_FLAG_TC; /* 校验:读回目标地址前 4 个像素 */ uint32_t check0 = *(volatile uint32_t *)dst; uint32_t check1 = *(volatile uint32_t *)dst + 1; if (check0 != src[0] || check1 != src[1]) { /* 说明存在访问属性/总线问题,做错误处理 */ } }注意这里我特别写了读回校验。在普通 MCU 开发里,大家都不习惯做这种校验,因为 DMA2D 一旦正常就是百分百正常的。但 TrustZone 开启后,“正常”变成了“地址合法才行”,所以多一次校验能帮你节省大量的排查时间。我自己的工程里,Debug 版本会开启这套读回校验,Release 版本再关掉,既不损失性能又能快速发现问题。
4. 常见问题与排查技巧实录
4.1 白屏 / 黑屏问题的系统排查
白屏或黑屏现象,往往是显示链路的第一道大坎。按照下面的排查顺序来,大概率能定位问题。
首先看时钟。检查 RCC 里 LTDC 时钟有没有被使能,像素时钟是否正常输出。如果 LTDC 时钟源还没配好,屏幕不会有任何反应。可以用调试器直接读 RCC 寄存器和 LTDC 的 GCR 寄存器,如果寄存器读出来全 0,或者写入的值立即丢失,说明寄存器访问权限有问题,那大概率是外设安全属性没配好。
其次看显存地址。把断点停在显示主循环里,查看显存地址处的值,确认填充的像素数据真的写进内存了。如果写入正常,屏幕还是黑屏,就要怀疑 LTDC 的图层配置,比如窗口坐标是否合理、透明度 Alpha 是否为零、混合因子是否设置正确。这一部分仍然需要逐项核对。
最后看时序参数。用示波器抓一下 LCD_CLK、HSYNC、VSYNC 引脚,看是否有波形输出。如果有波形,说明 LTDC 核心已经在工作了,问题大概率出在图层配置或者数据通路,如果完全没有波形,就回到时钟和外设使能上排查。
我个人的经验是:白屏问题 80% 出在外设归属和内存归属,15% 出在时钟树,只有 5% 才是时序参数写错。所以别一上来就死磕屏参,那样会浪费很多时间。
4.2 DMA2D 花屏和“假成功”问题
DMA2D 花屏有几个典型表现:画面错位但颜色正常、颜色完全不对但图形结构清晰、画面只有一半正常另一半是噪点。逐个排除下来,根因往往是地址访问属性或对齐问题。
其中“画面错位但颜色正常”,最可能是你的源图像宽度跟目标窗口宽度不匹配,DMA2D 的偏移FGOR、BGOR设置不当前。这种情况与 TrustZone 关系不大。真正和 TrustZone 强相关的是“颜色不对但结构清晰”和“半屏噪点”,这多半是 DMA2D 在搬运过程中访问了安全地址,导致某些行数据没搬进来,或者读回来的是总线上被拉高的垃圾数据。
另一个常见的坑是 DMA2D 中断。如果你用了 DMA2D 完成中断,要记得把 DMA2D_IRQn 也配置成非安全中断。不少人的代码把 LTDC 中断配置了,但忽略 DMA2D 中断,结果搬图动作是发起了,但完成回调永远不执行,程序一直卡在等待标志位的地方。这种问题看代码怎么等都找不出来,只有看中断配置才能发现。
4.3 中断相关问题的排查方向
如果你配置了 LTDC 行中断,但HAL_LTCD_LineEventCallback一直不触发,请按下面这个顺序查:
- 检查 NVIC 中 LTDC 中断线是否被设置成非安全属性,只有非安全中断线才能在非安全代码里使能。
- 检查优先级分组。安全侧和非安全侧使用的优先级分组要一致,否则非安全侧设置临界区、关中断时可能无法正确屏蔽安全侧中断。
- 检查你使能的中断类型。LTDC 有行中断和全局中断,两者在 NVIC 里可能是同一个 IRQn,但触发来源不同。确保在
LTDC_LIPCR里正确写入了比较行号,且LTDC_IER里对应使能位已经置 1。 - 最后确认你注册的回调函数是不是在非安全代码段里。如果回调函数被链接到了安全内存区域,非安全中断向量表里指向它就会出问题。
中断这一类问题,最有效的调试手段是在中断入口打断点。如果你的调试器支持 TrustZone 调试模式,能够同时调试安全和世界安全代码,那是最好不过的。如果不行,就在中断向量表处打断点,看中断是否确实响应了。
4.4 跳转非安全代码就 HardFault 的问题
安全工程跑得好好的,一跳转到非安全工程就 HardFault,这是 TrustZone 开发中极其常见的现象,但它也会导致显示功能完全不能工作,因为非安全侧的显示任务根本没跑起来。
排查优先级最高的项目是:非安全代码的向量表和栈指针有没有正确设置。非安全代码在复位时需要读取首地址作为 MSP_NS,读取偏移 4 的地址作为复位向量。如果这两个值没有正确初始化,跳转过去之后 CPU 连第一条指令都执行不对。
其次是 SAU 和内存属性。如果非安全代码的加载地址或者运行地址落在了安全区域,CPU 去取指令时就会触发安全违规。记住,不只是显存需要非安全,所有的代码、数据、堆栈,只要非安全代码会访问,都必须放到非安全区域。
还有一点容易忽略:浮点单元 FPU。如果安全世界使用了 FPU,而跳转时未正确保存浮点上下文,或者非安全世界启用了 FPU 但状态不一致,也会导致 HardFault。你可以查看 CPSR/CONTROL寄存器的 FPU 相关位来确认状态。
提供一个经验法则:跳转前,把安全侧所有已使能的中断全部屏蔽,清掉挂起中断标志,等非安全世界完全初始化后再恢复中断。这样能最大限度避免因为中断残留导致的 HardFault。
5. 调试 TrustZone 工程的一些额外心得
5.1 用双工程结构管理代码
配置 TrustZone 后,你的软件架构必须拆成安全工程和非安全工程两个部分。安全工程里放的是系统初始化、安全服务、以及需要的 NSC 回调函数;非安全工程里放的是业务、显示驱动、网络协议栈等普通应用代码。
我个人推荐用 CMake 来管理这种双工程结构,因为它能非常清晰地控制链接脚本和编译选项。下面是一个非常精简的目录结构示意:
project/ ├── CMakeLists.txt ├── secure/ │ ├── tz_s_init.c │ ├── tz_nsc_func.c │ └── secure_link.ld └── non_secure/ ├── main.c ├── ltdc_driver.c ├── dma2d_driver.c └── ns_link.ld安全工程的链接脚本要定义好非安全世界区域:放在 CPU 起始地址的非安全向量表、可用的非安全 SRAM 区域、NSC 区域。非安全工程的链接脚本则不能使用安全区域,所有内存段都必须落在安全工程划分出的非安全区域内。如果你用的 IDE,比如 STM32CubeIDE,你在创建 TrustZone 工程时它会帮你生成这套结构,但底层原理还是上面说的这些。
5.2 调试器的正确打开方式
在 TrustZone 开启的芯片上调试,你需要用支持 TrustZone 的调试器。ST-Link 搭配较新版本的 CubeIDE,默认就能处理这类场景,但你要有意识地区分:你当前正在调试的代码是安全世界还是非安全世界。在调试器里查看SAU寄存器、GTZC 寄存器、内存访问属性,能让你快速定位很多隐蔽问题。
我的经验是:遇到问题先别急着翻代码,先在调试器里把以下几个寄存器的值读出来看一眼,基本能确定问题范围:
- SAU CTRL 寄存器,确认是否使能。
- SAU 区域配置,确认你划分的非安全区域是否还在。
- LTDC 的 GCR 寄存器,确认 LTDC 是否已被使能。
- DMA2D 的 CR 和 ISR,确认最后一个传输操作的返回值和处理状态。
这些寄存器值一旦和你预期的不同,问题就找到了;如果全部正常,再回到软件执行流程上排查。
5.3 安全与非安全调试切换的小技巧
你在实际操作中一定会遇到一个痛点:代码断点打在了安全代码里,但程序停在非安全世界,断点不生效;反过来也是一样。这是因为 TrustZone 下安全调试和非安全调试的断点资源是分开的。
调试时,尽量把断点打在对应世界的函数入口处,而不是一条普通语句上。拿跳转函数举例,你可以在安全工程的TZ_JumpToNonSecure函数入口打一个断点,确认安全工程跑到这里时一切正常;然后在非安全工程的main函数入口打一个断点,看跳转过去后能不能顺利进入主程序。这样分段确认,能快速定位是哪一步出了问题。
另外一个实用技巧是:在安全侧放一个专用的串口调试通道。你可以把某个 UART 配置成安全外设,安全代码往这个串口打印日志;非安全侧用另一个 UART 打印业务日志。这样两边世界各自输出各自的日志,问题发生在哪一侧一目了然。如果没有条件用两路串口,那就在一个串口上加上世界标识前缀,比如 [S] 和 [NS],也能达到类似效果。
5.4 关于安全侧处理显示回调的进阶方案
有些项目虽然主要显示逻辑在非安全侧,但安全侧希望在特定时刻去更新屏幕,比如安全告警时需要立即全屏显示警告界面。这种情况下,你不想让非安全代码直接操作显示外设,又希望显示链路本身属于非安全侧,怎么办?
我采用的方案是:在安全侧写一个 NSC 函数,函数内部实现一个针对显示控制器的“安全代理调用”。非安全侧调用这个函数时,参数里带上你要写入的寄存器偏移和值,或者更抽象一点,带上你要执行的显示操作码,比如“全屏填充红色”、“切换到安全页面”。安全侧的这个函数在执行时,会主动切到非安全状态去操作 LTDC,执行完再切回来。这样既保留了显示外设的非安全属性,又实现了安全世界对显示内容的控制。
当然,这种设计需要你对状态切换和 NSC 机制非常熟悉,否则容易埋坑。入门阶段,我建议先用最简单的方案:把 LTDC 完全放在非安全侧,安全侧通过一个 NSC 函数向非安全侧发送“显示请求”,由非安全侧的业务代码去实际执行。这种方案架构清晰、调试也方便,等系统跑顺了,再考虑更复杂的安全代理方案。
写到这里,文章也接近尾声了。最后再分享一个小技巧:在真正调试 LTDC 和 TrustZone 组合之前,先用厂家提供的示例代码把屏幕点亮,确认开发板和屏幕硬件本身没有问题,然后才把示例代码逐渐替换成自己的 TrustZone 配置。不要一上来就同时挑战 TrustZone 和显示驱动这两个大块,那样出了问题你根本分不清是屏没配好还是安全属性没配好。我自己的习惯是,先跑通纯色填充,然后跑 DMA2D 搬图,最后才上 GUI 框架,每一步都确认无误再往下一步走,才把带着 TrustZone 的显示链路稳稳当当地跑起来。