☰
STM32H723启用D-Cache后DMA数据错乱?三套方案彻底解决缓存一致性
2026/10/24 7:51:06 网站建设 项目流程

调试STM32H723板子时,我从F1/F4系列迁移过来,第一件事就是在CubeMX里把D-Cache打开,结果原本在F4上跑得好好的串口DMA收发开始“犯病”:发送出去的数据偶尔乱几个字节,接收回来的数据时新时旧,用调试器看内存明明是对的,程序里读出来却是错的。折腾了两天,最后一注释SCB_EnableDCache(),一切恢复正常——那一刻我确定,这不是DMA配置的问题,而是DMA和D-Cache的缓存一致性问题。

这篇文章就围绕“STM32H723上启用D-Cache时如何正确配置DMA”这个主题,把原理、三套可行方案、完整可复现的代码步骤,以及我实际调试中踩过的坑完整梳理一遍。适合从F1/F4迁移到H7、刚接触Cortex-M7缓存机制,或者正被串口/ADC/SPI的DMA数据错乱折磨的开发者参考。

1. 先搞明白:DMA读出来的数据为什么是“过期的”

1.1 这不是H723独有的问题,而是所有带Cache的MCU都要面对的

Cortex-M7内核引入了L1 Cache,STM32H723的D-Cache默认是关闭的,你在CubeMX里勾选“Data Cache”后,HAL库会在初始化时调用SCB_EnableDCache()把它打开。开了之后,CPU读内存和写内存不再是直接访问RAM,而是先走Cache。

D-Cache的默认策略是write-back,也就是CPU写数据时先写到Cache里,Cache line被替换或者显式清理时才写回RAM。这就像你在工位上有一块草稿纸,记了数字之后没有立刻誊到账本上,而是等草稿纸攒满了才统一誊写。

问题就出在这里:DMA是直接访问RAM的外设,它不经过Cache。DMA去内存里搬数据时,拿到的可能是CPU还没写回RAM的“旧数据”;反过来,DMA从外设收了一堆数据写进RAM,而CPU再去读那个地址时,命中的却是Cache里的旧副本,读到的照样是“过期内容”。

所以,DMA和D-Cache在H7上并不是“配置好就能共存”,必须显式处理两者之间的数据同步问题。

1.2 读和写两个方向,对应两种不同的同步操作

缓存同步的本质就两件事:

  • 当CPU写了数据、要让DMA去搬运时,需要把Cache中的脏数据强制写回RAM,这个操作叫Clean(也可以叫Flush)。
  • 当DMA写入了数据、要让CPU去读取时,需要把Cache中对应地址的旧副本作废,下次CPU读的时候重新从RAM加载,这个操作叫Invalidate。

很多刚接触的人会把这两个操作搞混,尤其是Invalidate。Invalidate是“作废缓存行”,如果这一行本身是脏的(CPU改过但没写回),作废操作会直接丢掉脏数据。所以对于既被CPU写、又被DMA读写的缓冲区,不能只做Invalidate,要先Clean再Invalidate,或者干脆把缓冲区放Non-cacheable区域。

1.3 H723的RAM分布决定了问题的影响面

STM32H723不像F4那样只有一块统一SRAM,它的内部RAM分了好几块,不同区域对Cache和DMA的可见性都不一样:

地址区间RAM类型DMA1/DMA2能否访问D-Cache是否经过
0x00000000ITCM否否
0x20000000DTCM否是
0x24000000AXI SRAM能是
0x30000000 起SRAM1/2/3能是
0x38000000SRAM4能否

这里有两个关键点:

第一,DTCM是CPU私有的紧耦合内存,频率高、延迟低,但DMA根本访问不到。如果DMA缓冲区被链接到了DTCM,无论Cache同不同步,DMA都拿不到正确数据,这是很多人一开始容易踩的坑。

第二,SRAM4在D3域,默认不经过D-Cache,所以它天然没有缓存一致性问题。但SRAM4容量有限,而且BDMA最喜欢用这块区域,普通DMA也能访问,适合放小块的收发缓冲区。AXI SRAM和SRAM1/2/3都在D-Cache的覆盖范围内,如果不做任何处理,缓存一致性问题基本都会在这里爆发。

2. 三套解法横向对比:手动Cache操作、MPU改属性、专用RAM区

2.1 手动Clean/Invalidate:最精确,但责任全在你身上

方案一是在DMA传输的关键节点调用CMSIS提供的Cache操作函数:

  • SCB_CleanDCache()/SCB_InvalidateDCache():全Cache操作,简单粗暴,但会把所有Cache行都刷一遍,性能损耗大,不适合频繁DMA场景。
  • SCB_CleanDCache_by_Addr(uint32_t *addr, int32_t dsize):按地址清理指定长度的缓存。
  • SCB_InvalidateDCache_by_Addr(uint32_t *addr, int32_t dsize):按地址作废指定长度的缓存。

这套方案的好处是精准,不影响其他热数据的缓存性能。坏处是必须保证时机正确,一旦漏掉一次Clean或Invalidate,数据就可能出错,而且这种错是间歇性的,极难排查。

我通常只在收发缓冲区比较小、DMA频率不高、且缓冲区生命周期受控的场景用这个方案。典型的就是串口收发、小包SPI读取。

2.2 MPU把缓冲区所在区域设为Non-cacheable:一劳永逸

方案二是使用MPU把DMA缓冲区所在的RAM区域配置成Non-cacheable。这个方案相当于告诉CPU:这块地址你别缓存了,直接访问RAM。这样CPU和DMA看到的就是同一份数据,从根上消除一致性问题。

这是工业项目里最常见的做法,也是ST官方很多评估板例程采用的方案。CubeMX生成的代码里通常会有一个MPU_Config()函数,把SRAM区域配置成Write-Through,这已经能缓解写方向的问题,但读方向仍需要Invalidate,所以最省心的还是配置成Non-cacheable。

代价是这块区域的读写性能会下降。如果你的DMA缓冲区本来就只被DMA和外设访问,CPU很少碰它,那这块区域Non-cacheable性能损失完全感知不到。但如果你把整个SRAM都设成Non-cacheable,频繁访问热数据时就会明显感觉到比Write-Back慢。

2.3 利用SRAM4和DTCM的天然特性:最省心但也有代价

第三种解法是绕开问题区域。把DMA缓冲区放到SRAM4(0x38000000)或者备份RAM,因为它们在D3域,不经过D-Cache,天然没有一致性问题。

这个方案代码最简单,什么都不用管。但SRAM4只有64KB,而且BDMA、部分低功耗模式下的外设都要用它,资源比较紧张。DTCM虽然D-Cache也覆盖不到(因为DMA本身访问不了DTCM),但“DMA访问不了”意味着只能放CPU侧数据,不能作为DMA缓冲区,这点一定要注意。

我的建议是:小缓冲区丢SRAM4,大缓冲区用MPU Non-cacheable区域,需要极致性能且数据量大的场景再用手动Clean/Invalidate。

3. H723上的完整实操:CubeMX配置到代码落地

3.1 CubeMX里需要关注的几个开关

CubeMX的Cortex M7配置页面中,有Instruction Cache和Data Cache两个选项。如果你开启了Data Cache,建议同时确认MPU配置页面是否生成了默认的MPU配置。

很多人的翻车现场发生在这里:CubeMX生成的main.c里确实调用了SCB_EnableDCache(),但MPU要么没配置,要么配置成了Write-Through。没配置MPU时,Cortex-M7默认把所有Normal内存都当成Cacheable,这样必然出现一致性问题;配置成Write-Through时,发送方向的问题解决了(CPU每次写都同时写RAM),但接收方向Cache里的旧副本依然会导致读旧数据。

所以,我习惯在CubeMX里显式生成MPU配置,把DMA缓冲区所在的区域划分成Non-cacheable,而不是依赖默认的“关闭MPU”或者“只开Cache”。

3.2 串口DMA发送:发送前Clean

串口DMA发送是最典型的“CPU写,DMA读”场景。CPU先往缓冲区里填数据,然后启动DMA传输,DMA从RAM读出这些数据发给串口。如果只做了Clean,缓冲区数据就能正确被DMA读取。

这里给一个可用的模板:

#include "main.h" #define ALIGN_32BYTES __attribute__((aligned(32))) ALIGN_32BYTES static uint8_t uart_tx_buf[256]; static void BuildFrame(uint8_t *buf, uint32_t len) { /* 填充你自己的协议帧 */ } void SendViaDMA(uint8_t *buf, uint32_t len) { /* 关键:把D-Cache中的数据clean到RAM */ uint32_t aligned_len = (len + 31U) & ~31U; SCB_CleanDCache_by_Addr((uint32_t *)buf, aligned_len); HAL_UART_Transmit_DMA(&huart1, buf, len); } int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_USART1_UART_Init(); uint32_t len = 64; BuildFrame(uart_tx_buf, len); SendViaDMA(uart_tx_buf, len); while (1) { } }

特别提醒一下aligned_len的计算:SCB_CleanDCache_by_Addr要求地址和长度都按32字节对齐,因为Cortex-M7的Cache line大小就是32字节。长度向上取整到32的倍数之后,可能越界到缓冲区后面的地址,所以定义缓冲区时至少多留31字节的余量,或者直接让缓冲区大小就是32的倍数。我在实际工程里习惯把每个DMA缓冲区都定义成32字节对齐、大小也为32字节整倍数,彻底避开这个坑。

3.3 串口DMA接收:启动前Invalidate一次,完成后Invalidate一次

串口DMA接收是“DMA写,CPU读”场景,处理起来比发送稍微绕一点。

假设你定义了一个接收缓冲区,CPU在某个初始化阶段可能访问过它,这时Cache里保留了这块地址的旧副本。DMA把新数据写进RAM后,如果Cache里的旧副本还有效,CPU读到的依然是旧数据。所以要在启动DMA接收之前先Invalidate一次,把旧副本清掉;DMA传输完成后,再次Invalidate,让CPU强制从RAM重新加载DMA写入的新数据。

#define RX_BUF_SIZE 256 ALIGN_32BYTES static uint8_t uart_rx_buf[RX_BUF_SIZE]; static volatile uint8_t rx_frame_ready = 0; void StartRx(void) { /* 启动前作废Cache中旧副本 */ SCB_InvalidateDCache_by_Addr((uint32_t *)uart_rx_buf, RX_BUF_SIZE); HAL_UART_Receive_DMA(&huart1, uart_rx_buf, RX_BUF_SIZE); } void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { /* 完成后再次作废,强制CPU从RAM读取新数据 */ SCB_InvalidateDCache_by_Addr((uint32_t *)uart_rx_buf, RX_BUF_SIZE); rx_frame_ready = 1; } } int main(void) { /* ... */ StartRx(); while (1) { if (rx_frame_ready) { ProcessFrame(uart_rx_buf); rx_frame_ready = 0; StartRx(); } } }

注意一个细节:启动DMA接收之后,在DMA完成回调触发之前,CPU不要访问这块缓冲区。一旦CPU读取,会把Cache line重新填充,此时DMA可能还在往RAM写数据,CPU读到的就是半新半旧的数据。如果在主循环里轮询某个标志位来判断“是否收到数据”,标志位要放在独立的普通变量里,不要放在DMA缓冲区内部。

对于空闲中断加DMA这种常见用法,处理思路一样:HAL_UARTEx_RxEventCallback触发后,先Invalidate再解析数据。

3.4 ADC多通道扫描循环采样的缓冲区处理

很多做电机控制、电源检测的开发者都会遇到“ADC多通道扫描循环采样+DMA”这个热搜组合。在H723上,如果开了D-Cache,ADC的DMA数据同样会被缓存问题污染。

ADC的DMA传输和串口接收本质一样,都是外设写数据到RAM,再由CPU读取。配置方式:

#define ADC_CHANNEL_COUNT 8 ALIGN_32BYTES static uint32_t adc_values[ADC_CHANNEL_COUNT]; void StartADC(void) { /* 启动前作废旧Cache */ SCB_InvalidateDCache_by_Addr((uint32_t *)adc_values, sizeof(adc_values)); HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_values, ADC_CHANNEL_COUNT); } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { SCB_InvalidateDCache_by_Addr((uint32_t *)adc_values, sizeof(adc_values)); ProcessADC(adc_values); }

循环采样模式下,DMA会持续往这个缓冲区写数据,每次转换完成回调里都要Invalidate。但这里有个潜在风险:如果是半满中断和全满中断都启用的双缓冲模式,两个回调里都要Invalidate,否则另一个半区的数据可能读到旧内容。

其实对于ADC这种周期性、高频率的数据采集场景,我反而更推荐用MPU把缓冲区所在的SRAM区域设为Non-cacheable,或者干脆把ADC缓冲区放到SRAM4。因为高频Invalidate本身也消耗CPU周期,还会影响附近数据区域的Cache命中率,不如直接绕过Cache省心。

3.5 MPU配置完整代码:把DMA缓冲区区域划成Non-cacheable

如果你决定用MPU方案,可以直接把CubeMX生成的MPU_Config()改造成这样:

static void MPU_Config(void) { MPU_Region_InitTypeDef MPU_InitStruct = {0}; HAL_MPU_Disable(); /* 把0x30000000(SRAM1/2/3区域)划分为Non-cacheable */ MPU_InitStruct.Enable = MPU_REGION_ENABLE; MPU_InitStruct.BaseAddress = 0x30000000; MPU_InitStruct.Size = MPU_REGION_SIZE_256KB; MPU_InitStruct.AccessPermission = MPU_REGION_FULL_ACCESS; MPU_InitStruct.IsBufferable = MPU_REGION_NOT_BUFFERABLE; MPU_InitStruct.IsCacheable = MPU_REGION_NOT_CACHEABLE; MPU_InitStruct.IsShareable = MPU_REGION_NOT_SHAREABLE; MPU_InitStruct.TypeExtField = MPU_TEX_LEVEL0; MPU_InitStruct.SubRegionDisable = 0x00; MPU_InitStruct.DisableExec = MPU_INSTRUCTION_ACCESS_DISABLE; MPU_InitStruct.Number = MPU_REGION_NUMBER0; HAL_MPU_ConfigRegion(&MPU_InitStruct); HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT); }

如果你的DMA缓冲区分布在多个区域,可以多配几个MPU Region。Cortex-M7的MPU一共有8个Region,足够用。对于AXI SRAM(0x24000000),如果存放的是热数据,可以保持Write-Back Cacheable;如果只放DMA缓冲区,同样也可以设成Non-cacheable。

这里有一个需要澄清的点:很多ST例程里把SRAM配置成Write-Through,也就是IsCacheable = MPU_REGION_WRITE_THROUGH,这确实能解决“CPU写、DMA读”的发送方向问题,但接收方向依然需要Invalidate。所以如果你已经用了Write-Through并且接收数据偶尔出错,最省事的做法就是直接改成Non-cacheable,一步到位。

另外,改成Non-cacheable之后,SCB_CleanDCache_by_Addr和SCB_InvalidateDCache_by_Addr对这块区域就不需要再调用了——这正是MPU方案省心的原因。但要注意,如果工程里其他代码用了同一个数组并期望Cache生效,改成Non-cacheable后性能会有轻微下降,需要评估。

3.6 把缓冲区固定到指定RAM区域

代码里定义了缓冲区,但编译器不一定把它放到你想要的RAM区域。尤其H7的RAM分了好几块,链接脚本默认会按优先级分配,有时一个数组会落到DTCM里,DMA访问不到,表现就是DMA传输一直不完成、或者数据全零。

最稳妥的方法是给缓冲区指定段:

#define DMA_BUFFER_SECTION __attribute__((section(".dma_buffer"), aligned(32))) #define DMA_BUFFER_ATTR __attribute__((aligned(32))) DMA_BUFFER_SECTION static uint8_t uart_tx_buf[256]; DMA_BUFFER_SECTION static uint8_t uart_rx_buf[256]; DMA_BUFFER_SECTION static uint32_t adc_values[8];

然后在链接脚本里把.dma_buffer段放到你希望的区域。CubeMX生成的.ld或.icf文件里,可以手动添加:

/* 适用于GCC链接脚本 */ .dma_buffer (NOLOAD) : { . = ALIGN(32); *( .dma_buffer ) . = ALIGN(32); } > SRAM1

这样缓冲区就固定在了0x30000000起始的SRAM区域,结合上面的MPU Non-cacheable配置,物理上和逻辑上都没有缓存一致性问题了。

4. 调试过程中踩过的几个隐蔽坑

4.1 by_Addr函数的32字节对齐陷阱

这是最大的一个坑。SCB_CleanDCache_by_Addr的地址参数如果没对齐到32字节,行为是不确定的,而且不会报任何错误。很多人缓冲区定义了一个普通数组,没有加aligned(32),运行时地址可能是0x30000124这样的非对齐地址。此时Clean或Invalidate的Cache line是错位的,可能只刷了半个缓存行,其他半个缓存行的数据没有同步,最终现象就是数据“大多数时候正确,偶尔错一两个字节”。

解决思路很明确:所有DMA缓冲区定义时都加__attribute__((aligned(32))),并且长度向上取整到32的倍数。我个人还会在缓冲区初始化阶段加一个断言检查地址是否对齐:

assert(((uint32_t)uart_rx_buf & 0x1F) == 0);

这样如果哪天链接脚本改动导致缓冲区地址错位,程序在初始化阶段就会停下报警,而不是在运行时出现让人抓狂的间歇性错误。

4.2 DMA描述符和缓冲区不能“随便放”

H7的高级DMA支持链表模式(Linked-List Mode),这时DMA描述符节点也是放在RAM里的,DMA引擎会主动读取描述符来配置下一次传输。如果你初始化了描述符节点但没做Clean,DMA引擎读到的可能是描述符的旧版本,传输参数就会不对。

这种场景下的处理方式和数据缓冲区一样:描述符节点初始化完成后,调用一次SCB_CleanDCache_by_Addr把节点内容写回RAM。

另一个“随便放”是指缓冲区被放进了DTCM。前面提到DTCM不经过DMA总线,DMA根本读不到。检查方法很简单:编译完看.map文件,搜索缓冲区符号,确认地址在0x24000000或0x30000000段,而不是0x20000000。我在H723上就有一回是数组被链接到DTCM,DMA配置全对但就是不动,查了半天map文件才发现问题。

4.3 循环DMA下的Invalidate与Dirty Line冲突

Invalidate会丢失Cache line中的脏数据。如果一个缓冲区既被CPU写入过,又被DMA当作接收缓冲区,启动DMA前直接Invalidate就会把CPU还没写回的内容丢给RAM,可能导致后续处理拿到错误数据。

正确做法是分情况:

  • 缓冲区只由DMA写入、CPU只读:直接用Invalidate。
  • 缓冲区由CPU先写入、再由DMA读出:用Clean。
  • 缓冲区CPU和DMA都会写:要么先Clean再Invalidate,要么挪到Non-cacheable区域。

循环DMA模式下,上一个周期处理完数据后,下一次DMA可能已经开始覆盖缓冲区,此时如果你还停留在上上周期里做Invalidate,时序上就容易混乱。实际工程中,我倾向于为循环DMA分配双缓冲区,用半满中断和全满中断分别处理两个半区,保证“DMA正在写的半区”和“CPU正在读的半区”物理隔离,再对当前读的半区做Invalidate,这样时序就安全了。

4.4 调试手段:先复现,再定位

遇到DMA数据错乱,我一般按这个顺序排查:

  1. 先把SCB_EnableDCache()注释掉,或者把MPU里对应区域改成Non-cacheable,如果问题消失,基本可以锁定是缓存一致性问题。
  2. 用调试器全速运行,然后在数据处理断点处检查缓冲区内容。断点处读到的值和RAM中的实际值不一致时,基本就是Cache副本和RAM不一致的实锤。
  3. 查看.map文件

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询