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 通过 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
段范围为 [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. 正确性验证与正式版本恢复¶
迁移验证分开检查了四件事:
- 位置正确:ELF 符号位于预期区域,任务内 PSP 确认使用 DTCM 栈。
- 复位正确:非零探针经过软件复位后被清零。
- 计算一致:两轮 DTCM 测试各回放 20000 帧,AXI 与 DTCM 两个 VQF 实例输入完全相同,完整状态逐字节比较零差异。输入覆盖静止、转动、重力方向变化和短时附加加速度。这验证内存位置改变没有造成差异,不是外部运动真值标定。
- 接口和调度正常:三轮各 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 |
记录内存契约、测量结果和后续限制 |
资料与复查入口¶
- STM32H723 数据手册 DS13313 Rev 5:存储器说明见 3.3.2,H723 总线图见 Figure 3;2026-10-26 核对。
- ST:DMA/BDMA 访问路径问题:明确 DMA1/DMA2 和 TCM 的限制。
- ST:H7 DMA 与 D-Cache 问题:区分内存可达性和缓存一致性。
- GNU ld:输出段类型与 NOLOAD、GCC:变量属性:用于理解本次 GCC 链接实现,文档访问日期 2026-10-26。
- 实测数据 JSON、VQF 算法原理。文章图示的生成脚本随网站源码保存在
tools/figures/generate-h723-dtcm.py。