跳转至

STM32H723 DTCM 实战:把 VQF 状态和 IMU 任务栈放到合适的内存

在达妙 MC02 的 STM32H723VG 工程中,1000 Hz IMU 解算已经可以正常运行,但算法状态、任务栈和通信缓冲原来主要集中在 AXI SRAM。本次把 VQF 状态、温控 PID 状态、IMU 任务栈放入 DTCM,保留 DMA 需要访问的缓冲区在原 SRAM。相同配置的板上测量中,VQF 更新加欧拉角输出平均从 51.71 μs 降到 39.46 μs。

本文按实际修改解释内存位置是怎样决定的、为什么还要改启动文件、FreeRTOS 任务栈怎样单独迁移,以及如何确认没有破坏 DMA 和原有计算结果。算法本身见 DM IMU 六轴解算与零偏估计。

工程快照:2026-10-26,GCC / CMake FLASH 构建;VQF 基线提交 726b3c1,DTCM 迁移提交 7c434e5。本文的地址和数值对应这个版本,以后重新链接时应再次检查 ELF。

1. 先理解我们究竟移动了什么

程序里至少有三类不同的内容:

  • 指令:CPU 要执行的函数代码。
  • 长期状态:跨多帧保存的四元数、滤波历史、偏置、协方差、PID 累积量。
  • 临时工作数据:函数调用时产生的局部数组、临时矩阵、保存的寄存器等。

本次把后两类中的一部分放到 DTCM。函数指令仍位于 Flash,没有把 VQF 函数搬进 ITCM,也没有更改数学公式、FPU 配置或算法参数。CPU 执行的逻辑保持不变,读写数据的路径发生了变化。

CPU 热数据与 DMA 缓冲的访问路径

图里要关注的是“谁访问哪一块数据”。CPU 通过 TCM 接口访问 DTCM,DMA1/DMA2 则没有这条访问路径。将算法工作区与通信缓冲分开,才有条件在提高计算效率的同时保留外设收发能力。

1.1 H723 的几个内存区域

当前链接脚本使用以下配置。表中列的是本工程配置,并非所有 H7 型号通用,也不是 H723 所有可选 RAM 重分配方案。

区域 起始地址 本工程容量 当前主要用途
Flash 0x08000000 1024 KiB 程序指令、只读常量、初始化数据的镜像
DTCM 0x20000000 128 KiB 本次加入的 CPU 状态和 IMU 栈
AXI SRAM / RAM_D1 0x24000000 320 KiB 普通全局数据、堆、其他任务栈及通信数据
RAM_D2 0x30000000 32 KiB 本构建未分配普通段
RAM_D3 0x38000000 16 KiB 本构建未分配普通段
ITCM 0x00000000 64 KiB 本构建未迁入代码

DTCM 是 Data Tightly Coupled Memory,即数据紧耦合内存。H723 提供 128 KiB DTCM;它有独立的 CPU 访问接口,适合经常读写的实时数据。AXI SRAM 则经过另一条访问路径,性能与缓存命中、总线访问等条件有关。因此 DTCM 值得用于热点数据,但不能凭名称就断言整个程序会固定加速多少。ST 数据手册 DS13313 Rev 5,3.3.2 节

1.2 按访问方式选对象

VQF 每帧需要读写四元数、滤波器历史、零偏和协方差,而且运行入口由 IMU 任务独占。这些数据属于合适的迁移对象。温控 PID 也是 CPU 状态,体积较小,一起归入这类数据;本次没有单独测量它的收益。

DMA 缓冲则先保留。ST 明确说明 H72x/H73x 的 DMA1/DMA2 不能访问 ITCM/DTCM;MDMA 是另一类控制器,不能把限制概括成“所有 DMA 都不能访问 DTCM”。ST DMA 访问说明

2. C 变量的地址由哪些环节决定

一个普通的静态对象:

static DmVqf INS_Filter;

在当前构建中,编译器为它产生未初始化数据的输入段;链接脚本将相应普通 .bss 输入段收进 RAM_D1。最后生成的 ELF 才确定它的具体地址。

在 C 文件中写 static 只决定存储期和可见性,不会指定 DTCM。仅在链接脚本中声明 DTCMRAM 也不会自动把对象分配进去。实际要接通下面四个环节。

声明、链接、启动初始化与任务运行

运行时没有“每帧把结构体从 AXI 复制到 DTCM”这个步骤。链接完成后对象已经有固定的 DTCM 地址,代码中的指针直接指向那里。算法初始化仍会按原逻辑设置滤波状态。

3. 第一步:用一个明确的宏标记 CPU 状态

新增 User/bsp/bsp_memory.h,只提供一个用途明确的标记:

/**
 * @brief 将零初始化的 CPU 专用静态对象放入 H723 DTCM,启动时统一清零。
 * @note 仅用于当前 GCC / FLASH 链接配置;不可传给 DMA1/DMA2。
 * 不得用于需要非零初值的对象。任务栈迁入后,局部数组也位于 DTCM,
 * 新增异步驱动调用前须重新审计缓冲区地址与生命周期。
 */
#define BSP_DTCM_BSS __attribute__((section(".dtcm_bss"), aligned(8)))

section(".dtcm_bss") 让编译器把带标记的对象放入指定输入段;aligned(8) 要求至少 8 字节对齐。此处的段名是工程自定义名称,真正的地址由下一步链接脚本决定。GCC 的变量属性定义见 Common Variable Attributes。

随后在 modules/imu/ins_task.c 中标记两个对象:

// 这些状态只由 CPU 更新;传感器原始缓冲和对外发布数据仍留在 AXI SRAM。
static PIDInstance INS_TemperaturePid BSP_DTCM_BSS;
static DmVqf INS_Filter BSP_DTCM_BSS;

原有函数继续用 &INS_Filter,消费者仍通过只读指针或一致快照读取发布数据。宏不会改变结构体字段、函数签名、所有权或并发规则;DTCM 本身也不会让多字段读取自动变成一致快照。

3.1 为什么只允许零初始化对象

本次只建立了一个“复位时清零”的段,没有建立“从 Flash 复制非零初值”的 DTCM 数据段。例如下面这个写法不适用于当前宏:

// 错误示例:本工程启动流程会把整个 .dtcm_bss 清零。
static float gain BSP_DTCM_BSS = 1.0f;

需要非零初值时,可以在完成段清零后显式初始化;如果未来确实需要大量有初值对象,再设计单独的 .dtcm_data、Flash 装载镜像和复制流程。当前没有实现这一扩展。

4. 第二步:链接脚本把段放到 DTCM

当前 CMakeLists.txt 选用 STM32H723VGTX_FLASH.ld。该脚本原来已经声明了 DTCM,但并未分配这次涉及的对象:

MEMORY
{
  DTCMRAM (xrw) : ORIGIN = 0x20000000, LENGTH = 128K
  RAM_D1  (xrw) : ORIGIN = 0x24000000, LENGTH = 320K
  /* 其他区域沿用工程配置。 */
}

本次在 SECTIONS 中增加:

.dtcm_bss (NOLOAD) : ALIGN(8)
{
  __dtcm_bss_start__ = .;
  *(.dtcm_bss)
  *(.dtcm_bss.*)
  . = ALIGN(8);
  __dtcm_bss_end__ = .;
} >DTCMRAM

ASSERT(SIZEOF(.dtcm_bss) <= LENGTH(DTCMRAM),
       "DTCM objects exceed 128 KB")

逐句理解:

写法 在这里负责什么
.dtcm_bss 建立最终 ELF 中的输出段
*(.dtcm_bss) 收集所有目标文件中同名输入段
*(.dtcm_bss.*) 同时收集带子名称的输入段,便于后续扩展
>DTCMRAM 指定运行地址分配到 DTCM 区域
__dtcm_bss_start__ / end__ 导出首地址和末尾后一地址,供启动代码使用
ALIGN(8) 约束段及末端对齐;4 字节清零循环不会留下尾部字节
ASSERT(...) 在链接阶段检查容量;链接器本身也会检查区域溢出
NOLOAD 本构建不为这段生成要从固件装载的初始化内容

NOLOAD 的重点是装载属性,不是“硬件自动清零”,也不是“复位保留数据”承诺。GNU ld 输出段类型文档

没有修改 _estack,因此启动和异常使用的 MSP 主栈仍保留在 RAM_D1;也没有把整个 .bss、普通堆或其他任务栈重定向到 DTCM。

5. 第三步:启动代码必须清零新段

原有 Reset_Handler 只清零 _sbss 到 _ebss 之间的普通 .bss。新段在另一块地址区域,不能指望原循环顺便覆盖。

在 Core/Startup/startup_stm32h723vgtx.s 中,普通 .bss 清零之后、C 构造函数执行之前,增加:

  ldr r2, =__dtcm_bss_start__
  ldr r4, =__dtcm_bss_end__
  movs r3, #0
  b LoopFillZeroDtcm

FillZeroDtcm:
  str r3, [r2]
  adds r2, r2, #4

LoopFillZeroDtcm:
  cmp r2, r4
  bcc FillZeroDtcm

等价逻辑是:从段起始地址开始,每次写入一个 32 位零,地址前进 4 字节,直到抵达段末尾。循环先判断再写入,空段不会被误写一次。

本工程的实际启动顺序为:

设置 MSP → SystemInit
→ 从 Flash 复制普通 .data
→ 清零普通 .bss
→ 清零 .dtcm_bss                 本次增加
→ __libc_init_array              C/C++ 运行时构造
→ main → 创建任务 → IMU 算法初始化

清零只是让静态对象得到确定的零值。dm_vqf_init() 仍然必须执行,因为滤波参数、单位四元数、协方差以及启动标记不能靠全零内存替代。1000 Hz 工程使用 dm_vqf_init(..., NULL, 0.001f);不能误用另一个固定 0.0005 s 的标定入口。

软件复位测试中,先把额外的 DTCM 测试探针填成 0xA5A5A5A5,再复位并重新统计探针非零字节数,结果为 0。这直接验证了清零路径;未进行断电冷启动验证。探针已从正式固件移除。

6. 第四步:让 IMU 的临时工作区也进入 DTCM

仅迁移 INS_Filter,只覆盖跨帧状态。VQF 函数及它调用的数学函数还有临时向量、矩阵和编译器保存的数据,其中落在栈上的部分仍跟随调用者的任务栈;寄存器中的临时量则不存在 RAM 搬迁问题。

原来 IMU 任务用动态创建,任务栈从 FreeRTOS 通用堆分配。这个堆在 AXI SRAM,所以即使入口函数只操作 DTCM 状态,栈上工作区也仍可能留在 AXI。

6.1 单独声明静态栈

application/robot_task.h 中新增以下对象;该文件依照原工程约定,只由任务初始化实现包含:

// 本工程 CMSIS-RTOS v1 实现直接把 stacksize 传给 FreeRTOS,单位是栈字。
#define INS_TASK_STACK_WORDS 1024U

_Static_assert(configSUPPORT_STATIC_ALLOCATION == 1 && sizeof(StackType_t) == 4U,
               "INS DTCM stack requires static allocation and 32-bit stack words");

static StackType_t INS_TaskStack[INS_TASK_STACK_WORDS] BSP_DTCM_BSS;
static StaticTask_t INS_TaskControlBlock;

INS_TaskStack 是实际栈内存,1024 × 4 = 4096 B,与迁移前容量相同。INS_TaskControlBlock 是 FreeRTOS 用于记录任务状态的控制块,体积 96 B,仍在 AXI SRAM。

这里特别核查了本工程的 Middlewares/Third_Party/FreeRTOS/Source/CMSIS_RTOS/cmsis_os.c:它把 thread_def->stacksize 原样传给 xTaskCreateStatic(),不做除 4。因此这里的 1024 是栈字数。这是当前适配层的事实,不能直接套到其他 CMSIS-RTOS 实现或 RTOS v2 的字节参数上。

6.2 把指定栈交给 FreeRTOS

// 后续引入 SPI/UART DMA 时,收发缓冲必须独立放在 DMA 可达的 SRAM。
osThreadStaticDef(instask, StartINSTASK, osPriorityAboveNormal, 0,
                  INS_TASK_STACK_WORDS, INS_TaskStack, &INS_TaskControlBlock);
insTaskHandle = osThreadCreate(osThread(instask), NULL);
configASSERT(insTaskHandle != NULL);

当前适配层识别出提供了静态栈与控制块,转入 xTaskCreateStatic()。任务执行时,线程使用的 PSP 指向这块 DTCM 栈。验证固件在 IMU 任务内读到 PSP 为 0x20000FA0,与最终栈区域一致。

这不会自动改变 malloc() 的分配区域。其他动态任务仍从原来的 FreeRTOS 堆分配;中断处理也没有因为 IMU 使用 DTCM 栈就全部改用它。

6.3 为什么不把通用堆整体搬进去

本工程有串口实例通过堆分配,其结构体内嵌 DMA 接收缓冲。如果把通用堆整体移到 DTCM,驱动中看似没有改动的 DMA 缓冲也可能随之变成不可访问地址。

独立静态栈可以明确限定迁移范围。但它没有让 AXI SRAM 中预留的 ucHeap 自动缩小:固定堆数组仍占 25600 B,只是不再为 IMU 动态分配那块栈和控制块。对运行时可用堆和 ELF 静态占用,要分别分析。

7. DMA 审计:最容易遗漏的是局部数组

本次检查到的实际路径如下:

路径 当前实现 对 DTCM 栈的影响
BMI088 SPI HAL_SPI_TransmitReceive() 阻塞收发一个字节 CPU 访问局部数据,当前不提交 DMA
VisionSetAltitude() 将三个角度标量复制到协议数据结构 不保存指向 IMU 局部数组的指针
串口 DMA 接收 接收区在 AXI 堆分配的串口实例内 不随 IMU 栈迁移
视觉发送 使用独立 AXI 静态发送缓冲 不直接使用 IMU 的局部数组

这里“阻塞”不是唯一判断条件;关键是当前 HAL 函数采用 CPU 轮询传输,没有配置 DMA 读取这些地址。

未来最危险的改动是把原来正常的阻塞接口替换为 DMA,却继续传入任务局部数组:

// 错误示例:假设此函数在使用 DTCM 栈的 IMU 任务内执行。
void ExampleBadTransmit(UART_HandleTypeDef *uart)
{
    uint8_t packet[16] = {0};
    HAL_UART_Transmit_DMA(uart, packet, sizeof(packet));
}

这个例子有两个独立问题:栈上的 packet 可能位于 DMA1/DMA2 不可达的 DTCM;函数返回后,异步发送还可能继续,而局部数组的有效期已经结束。只解决其中一项并不足够。

后续应让通信服务拥有长期有效的、DMA 可达的收发区,IMU 只提交数值或待发送数据;由通信服务管理发送完成、忙状态和缓冲重用。即使缓冲处于可达 SRAM,若启用 D-Cache,还要按实际 MPU/缓存配置处理一致性和对齐。可达性、生命周期、缓存一致性是三个分别检查的问题。ST 关于 H7 DMA 与缓存问题的说明

这次完成的是源码路径和分配位置审计,未连接额外设备进行 DMA 回环测试。

8. 用最终 ELF 确认内存分配

只看 C 宏或编译成功还不够。构建后用 ELF 符号、段头及 map 检查实际结果:

arm-none-eabi-nm -S firmware.elf
arm-none-eabi-objdump -h firmware.elf

将 firmware.elf 换成构建出的固件。当前正式版本的关键符号如下,长度列为十六进制:

20000000          B __dtcm_bss_start__
20000000 00001000 b INS_TaskStack
20001000 00000268 b INS_Filter
20001268 000000a8 b INS_TemperaturePid
20001310          B __dtcm_bss_end__
2400bcb0 00000060 b INS_TaskControlBlock
24001bbc 00006400 b ucHeap

正式版本的 DTCM 对象地址布局

段范围为 [0x20000000, 0x20001310),长度 0x1310 = 4880 B,约占 128 KiB 的 3.72%。其中三个对象的大小恰好合计为 4880 B;本次布局没有额外占用的对齐间隙。图中地址是真实值,但框高不按容量比例。

正式固件资源变化:

链接区域 迁移前 迁移后 怎样理解
DTCM 0 B 4880 B 新增三个显式对象
RAM_D1 91680 B 90984 B 部分状态移出,增加静态控制块,含链接对齐变化
Flash 103800 B 103832 B 启动和任务创建路径发生变化

RAM_D1 没有减少完整 4096 B,因为旧的动态栈来自仍然保留的固定堆。不能将“某任务不再占用动态堆”误读成“链接器自动删除了同等大小的堆数组”。

9. 板上计时:收益有多大,结论覆盖到哪里

9.1 测量条件

  • 同一 MC02,运行 CPU 时钟 480 MHz,采样和解算目标频率 1000 Hz。
  • GCC Debug -Og,基线与 DTCM 使用相同计时探针;VQF 相关源文件继续使用 -fno-fast-math。
  • DWT 周期计数器计时,按运行时频率换算 μs;正常中断,不通过屏蔽中断制造最好结果。
  • 每轮预热 5000 帧,随后统计 20000 帧,测量窗约 20 秒。
  • 分别计时 dm_vqf_update() 和“更新 + 一次欧拉角读取”,不包含传感器收发、温控、协议和完整任务调度。

由记录数据生成的板上平均耗时比较

本图来自实际板上结果,不是仿真。可下载 结构化测量数据。

指标(μs) AXI 基线 DTCM 软件复位后 DTCM
更新平均 46.337 34.374 34.364
更新最小 46.133 34.237 34.237
更新最大 51.658 38.992 39.087
更新 P99 分桶上界 49 37 37
更新加欧拉角平均 51.713 39.464 39.463
更新加欧拉角最小 51.481 39.317 39.321
更新加欧拉角最大 57.077 44.075 44.175
更新加欧拉角 P99 分桶上界 54 42 42

更新平均耗时减少 25.82%;更新加欧拉角减少 23.69%,每帧约节省 12.25 μs。对于 1 ms 周期,这相当于该计算段占用的时间比例从约 5.17% 降到 3.95%,减少约 1.22 个百分点;不等于整机 CPU 占用降低 24%。

P99 以 1 μs 桶统计,表里是分桶上界,不是精确到小数的分位值。最大值仅是这段记录中观察到的最大耗时,不是最坏执行时间证明。

9.2 能证明什么,不能证明什么

这组比较同时改变了状态位置和任务栈位置,没有单独拆分每个因素。DTCM 版本还在计时区间外进行了额外的回放验证,二进制布局和缓存状态也可能有影响。因此它证明了当前组合方案在该负载下有明显收益,不能把全部收益归因于 616 B 的 VQF 状态,也不能保证 Release 构建或其他工况有相同百分比。

若以后需要进一步优化,应分别测“只移状态”“只移栈”“两者都移”,固定外部负载并记录完整任务周期、抢占和调度抖动。当前 1 kHz 运行已经通过,不需要为了填满 DTCM 就继续移动低频数据。

10. 正确性验证与正式版本恢复

迁移验证分开检查了四件事:

  1. 位置正确:ELF 符号位于预期区域,任务内 PSP 确认使用 DTCM 栈。
  2. 复位正确:非零探针经过软件复位后被清零。
  3. 计算一致:两轮 DTCM 测试各回放 20000 帧,AXI 与 DTCM 两个 VQF 实例输入完全相同,完整状态逐字节比较零差异。输入覆盖静止、转动、重力方向变化和短时附加加速度。这验证内存位置改变没有造成差异,不是外部运动真值标定。
  4. 接口和调度正常:三轮各 20000 帧实时测试,快照失败、读取不一致及非有限角度均为 0;随后删除探针、回放和周期调试代码,重新构建并烧写正式固件。

正式固件启动与 IMU 初始化正常;间隔 1000 ms 读取更新计数,分别增加 1001、1000,符合 1000 Hz 运行。验证负载下栈最少剩余 767 个栈字,即 3068 B;这个数只能说明本次测试,不能据此立即缩减栈。

此次未进行断电冷启动、外接 DMA 回环或完整机器人比赛负载验证。构建仍有原工程的 RWX LOAD segment 警告,这次没有通过更改无关链接段来掩盖它。

11. 后续在新框架中怎样使用这套约束

新增对象时,先确认访问者:算法内部长期状态可以考虑 BSP_DTCM_BSS;跨模块发布结构保留清晰的读取接口;外设异步使用的缓冲由驱动或通信服务长期持有,单独选择 DMA 可达区域。不要把一个内存宏当作所有对象的默认标记。

新增任务时,按负载判断是否值得使用独立静态栈,并明确栈大小的单位。任务栈地址改变后,要沿着被调用函数检查局部数组和异步指针;调用者看不到底层 DMA,并不意味着这条调用链就没有 DMA。

当前只有 GCC FLASH 配置接通了段放置和启动清零。工程中其他链接脚本、其他编译器或启动文件切换前,需要逐项同步并重新验证。CubeMX 重新生成启动文件后,也应确认清零循环未被覆盖。

本次代码范围保持在六个文件:

文件 职责
User/bsp/bsp_memory.h 集中声明 DTCM 零初始化对象的标记和限制
STM32H723VGTX_FLASH.ld 分配输出段,导出边界,检查容量
Core/Startup/startup_stm32h723vgtx.s 在任务和 C 构造前清零新段
application/robot_task.h 提供 IMU 静态栈与任务控制块,选择静态任务创建
modules/imu/ins_task.c 标记 CPU 专用算法与温控状态
modules/imu/ins_task.md 记录内存契约、测量结果和后续限制

资料与复查入口