带D-Cache的STM32H723上配置DMA,说实话,这个坑我替大家踩得差不多了。自己第一次在H723上把D-Cache打开,然后高高兴兴去调UART DMA,结果收到的数据一会儿对一会儿错,ADC采出来的值还经常是整个缓冲区的旧数据,排查了整整一个晚上才意识到问题出在缓存一致性上——不是代码逻辑写错了,也不是DMA配置错了,而是CPU的高速缓存和DMA搬运的数据之间“对不上账”。
这篇文章想解决的就是这个问题。我会从D-Cache的行为机制讲起,然后给出三种可落地的配置方案,再针对STM32H723上最常见的UART空闲中断+DMA、ADC多通道扫描+DMA、SPI收发DMA这三个场景,给出完整的配置思路和关键代码片段,最后把我在实际调试中遇到过的坑和排查顺序整理出来。适合正在用STM32H7系列、刚把D-Cache打开就发现DMA数据错乱、或者准备把DMA相关外设从F1/F4平台移植到H7平台的开发者。
1. 为什么STM32H723开了D-Cache后DMA会“串数据”
1.1 D-Cache写回机制与数据滞留
Cortex-M7内置的D-Cache默认工作在write-back模式,含义是:CPU执行写操作时,数据并不立即写入物理RAM,而是先写入Cache Line,等到缓存行被替换或者收到Clean指令时,才真正回写到RAM。这套机制在纯CPU场景下效率极高,因为同一个地址短时间内被反复读写时,CPU根本不用去访问慢速RAM。
但一旦引入DMA,问题就出现了。DMA控制器和CPU看到的“内存”不是同一个视角:
- CPU通过D-Cache读写数据,看到的是Cache里的镜像。
- DMA直接访问物理RAM,看到的是主存里真实的数据。
举个例子,你把一块ADC缓冲区放在RAM中,CPU先往里面存过一些旧值,这部分数据还留在Cache里没有回写。然后你启动DMA让外设往这块RAM里搬运新数据,DMA确实把新数据写进了物理RAM,但CPU再去读的时候,Cache命中,读到的仍然是之前那个旧值。反过来也一样:CPU往发送缓冲区里写了数据,数据还在Cache里没回写,你就启动DMA去搬运这块缓冲区,DMA从物理RAM里读到的全是过期数据,串口发出去的自然就是乱码。
这就是典型的缓存一致性问题,英文社区叫cache coherency。在STM32H723这种Cortex-M7平台上,它不是一个“可能发生的边缘情况”,而是只要你启用D-Cache走DMA就必然会遇到的常规场景。
1.2 STM32H7内存架构与哪些RAM需要小心
STM32H7的内存架构和F1/F4有本质区别。F4的SRAM都挂在同一个总线矩阵上,CPU访问SRAM基本不会被缓存,所以DMA和CPU看到的数据基本一致。H7不一样,Cortex-M7核心挂了两套总线,AHB和AXI,同时还有TCM。
和DMA配置最相关的几块RAM区域:
- DTCM和ITCM:挂在CPU私有总线上,CPU访问速度极快,但DMA根本摸不到这块区域。
- AXI SRAM(地址0x24000000附近):CPU经AXI总线访问,D-Cache启用后会被缓存,DMA可以访问。
- D2域的SRAM1/SRAM2/SRAM3(地址0x30000000附近):同样会被D-Cache缓存,DMA也能访问。
所以结论很直接:在H723上,凡是DMA要读写的RAM缓冲区,只要这些区域被CPU访问过,并且D-Cache处于启用状态,就都必须考虑缓存一致性问题。很多人一开始把缓冲区放在DTCM里,抱怨“DMA根本不工作”,那是另外一个坑——不是配置错了,是DMA根本访问不到TCM区域。
1.3 典型故障现象速查
我把自己和身边同事遇到过的现象汇总了一下,方便你快速对照定位:
| 故障现象 | 传输方向 | 根本原因 |
|---|---|---|
| 串口发送出去的是乱码,但调试器看发送缓冲区内容是对的 | CPU → RAM → DMA → UART | CPU写的数据还在Cache里,DMA从RAM读到旧值 |
| 串口接收到的数据偶尔少字节或错位,但DMA计数正确 | UART → DMA → RAM → CPU | DMA写完RAM后,CPU读Cache命中旧值,尤其容易出现在循环接收场景 |
| ADC采样值全是0或者上一个周期的旧数据 | ADC → DMA → RAM → CPU | DMA已更新RAM,CPU还在读Cache里的旧缓冲 |
| SPI从机收到的数据错位,主机侧看TX缓冲内容正确 | CPU → RAM → DMA → SPI | TX缓冲区未回写,DMA搬错数据 |
| 第一次DMA传输正常,连续传输第二次、第三次开始错乱 | 任意方向 | 第一次运行时Cache未命中,之后Cache被污染,缓存一致性问题逐渐显现 |
这类问题最坑的地方在于:它不是每次都复现,而是“时好时坏”,和Cache命中的时机强相关。如果开了优化等级,编译器把某些变量优先放在寄存器或Cache里,复现频率还会变化。
2. 正确路径:缓存维护与非缓存内存的三种方案
2.1 方案A:用MPU把DMA缓冲区标记为Non-Cacheable
这是最省心的方案,思路是:既然DMA和CPU的缓存会对不上账,那我干脆不让CPU缓存这块区域。通过MPU配置,把DMA缓冲区所在的RAM区域设置为normal memory、non-cacheable属性,之后CPU读写这块区域每次都直接访问物理RAM,和DMA看到的自然就是同一份数据了。
STM32H723上配置代码如下:
void MPU_Config_DMA_Region(void) { MPU_Region_InitTypeDef MPU_InitStruct = {0}; HAL_MPU_Disable(); MPU_InitStruct.Enable = MPU_REGION_ENABLE; MPU_InitStruct.BaseAddress = 0x24000000; MPU_InitStruct.Size = MPU_REGION_SIZE_128KB; MPU_InitStruct.AccessPermission = MPU_REGION_FULL_ACCESS; MPU_InitStruct.IsBufferable = MPU_ACCESS_BUFFERABLE; MPU_InitStruct.IsCacheable = MPU_ACCESS_NOT_CACHEABLE; MPU_InitStruct.IsShareable = MPU_ACCESS_NOT_SHAREABLE; MPU_InitStruct.Number = MPU_REGION_NUMBER0; MPU_InitStruct.TypeExtField = MPU_TEX_LEVEL1; MPU_InitStruct.SubRegionDisable = 0; MPU_InitStruct.DisableExec = MPU_INSTRUCTION_ACCESS_DISABLE; HAL_MPU_ConfigRegion(&MPU_InitStruct); HAL_MPU_Enable(MPU_CONTROL_MMU_FOR_PRIVILEGED); }这段代码的关键点有两个:一是MPU_ACCESS_NOT_CACHEABLE,确保DMA缓冲区不会被缓存;二是MPU_TEX_LEVEL1配合non-cacheable属性,这是Cortex-M7 memory attribute的正确组合。配置完成后,如果缓冲区定义在0x24000000区域,CPU对这些地址的访问就不会再经过D-Cache了。
注意,这块区域要和你实际定义缓冲区的位置保持一致。如果你把缓冲区放在0x30000000的D2 SRAM里,就需要再配置另一个MPU region对应那个地址。不要试图用一个MPU region覆盖整个4GB空间,不现实也不安全。
这个方案的优点是调试简单,不用在每个DMA传输周期里手动维护缓存状态,特别适合DMA缓冲区数量多、数据持续刷新的场景。缺点是CPU访问non-cacheable区域时每次都要走总线,性能比cacheable区域低一些。但对DMA缓冲区来说,这个代价通常可以接受,因为缓冲区的主要写入方是DMA而不是CPU。
2.2 方案B:在DMA启动前做Clean,在DMA完成中断里做Invalidate
如果你不想给DMA缓冲区单独划分MPU区域,那就要手动维护缓存一致性。这里有两个核心操作:
Clean就是将Cache中的数据回写到物理RAM。CPU往发送缓冲区写完数据后,启动DMA搬运之前,必须做一次Clean操作,确保DMA能读到最新数据。
Invalidate则是把Cache中对应的行标记为无效,下次CPU访问时强制从物理RAM重新加载。DMA把接收缓冲区写好后,CPU读取之前必须做Invalidate,防止CPU继续命中Cache里的旧数据。
相关代码位于CMSIS提供的core_cm7.h中:
/* CPU写完发送缓冲区后,启动DMA前执行 */ SCB_CleanDCache_by_Addr((uint32_t *)txBuf, TX_BUF_SIZE); __DSB(); /* DMA接收完成中断里,处理数据之前执行 */ SCB_InvalidateDCache_by_Addr((uint32_t *)rxBuf, RX_BUF_SIZE); __DSB();这里必须强调一个注意事项:SCB_CleanDCache_by_Addr和SCB_InvalidateDCache_by_Addr都要求起始地址按32字节对齐,长度也必须是32字节的整数倍。Cortex-M7的D-Cache缓存行长度是32字节,如果地址和长度不对齐,函数内部实现会跳过无法覆盖的行,可能有一两个缓存行没被处理,程序就变成“偶尔出错、偶尔正常”的诡异状态。
缓冲区定义建议写成:
__attribute__((aligned(32))) uint8_t txBuf[256]; __attribute__((aligned(32))) uint8_t rxBuf[256];__attribute__((aligned(32)))确保数组首地址32字节对齐。长度方面,如果你实际传输长度不是32的整数倍,要么把缓冲区长度补到32倍数,要么宁可多clean/invalidate一点,也不能少。比如每次接收150字节,那就按192字节对齐来做invalidate,多出来的区域无所谓。
2.3 方案C:同时使用Clean和Invalidate覆盖双向DMA
很多实际场景是同一块缓冲区既有DMA写入又有CPU读取,或者同一块缓冲区被CPU写过之后还要被DMA读取,然后DMA搬完新数据CPU又要读。典型的就是SPI半双工、以及某些双缓冲以太网结构。
这种情况下,正确顺序是:
- CPU写完缓冲区后执行Clean,再启动DMA写/读操作。
- DMA传输完成后,CPU读缓冲区前执行Invalidate。
- 在整个过程中要保证清操作和失效操作都发生在正确的时间窗口。
我习惯封装成两个小工具函数:
static inline void dma_buf_prepare_for_tx(uint32_t addr, uint32_t len) { SCB_CleanDCache_by_Addr((uint32_t *)addr, len); __DSB(); } static inline void dma_buf_prepare_for_rx(uint32_t addr, uint32_t len) { SCB_InvalidateDCache_by_Addr((uint32_t *)addr, len); __DSB(); }这里在Clean和Invalidate之后都加了__DSB()数据同步屏障,确保缓存维护指令真正执行完毕后再继续后续操作。有些开发者在调试时发现,明明执行了Clean,DMA搬运过来的数据还是不对,仔细查过之后发现是缓存操作还在流水线上没执行完,DMA就开始搬运了。加上__DSB()能规避这个风险。
还有一个经常被忽视的细节:如果是DMA往缓冲区写数据,而CPU在DMA传输前对这块缓冲区执行过写操作,那在启动DMA前也建议做一次Clean。原因很简单,如果缓冲区对应的Cache行还是脏的(dirty),DMA往物理RAM写入新数据后,稍后CPU如果再次写这个缓冲区,Cache行可能被回写,把你期望保留的DMA数据覆盖掉。这种场景在环形缓冲区中尤其容易发生。
2.4 怎么选:三种方案的适用场景对照
| 方案 | 改动量 | 性能影响 | 适用场景 |
|---|---|---|---|
| MPU Non-Cacheable | 一次配置,后续零维护 | CPU读写缓冲区稍慢,DMA性能不受影响 | 高频率DMA传输、缓冲区多、接收数据要实时查询 |
| 手动Clean/Invalidate | 每次传输前/后各加几行代码 | 缓存操作有开销,但按地址操作比全局操作小很多 | 发送/接收频率不高,逻辑清晰,单缓冲区固定方向 |
| Clean+Invalidate组合 | 需要严格管理时序 | 开销最高,但是覆盖所有双向场景 | 双缓冲、环形缓冲、SPI半双工、以太网DMA共享缓冲 |
以我自己的习惯来说,如果板子上RAM够用,我会把需要DMA操作的缓冲区单独放到一个MPU non-cacheable区域,省心。如果缓冲区是零散分配的,没有规律,那就老老实实做手动Clean和Invalidate。
3. STM32H723上三个高频场景的完整实操
3.1 前提:D-Cache和MPU的初始化顺序
在进入具体外设配置前,先确认系统初始化顺序。CubeMX生成的main函数里,如果启用了I-Cache和D-Cache,会调用:
SCB_EnableICache(); SCB_EnableDCache();这两行代码必须在MPU初始化之后执行,并且MPU要在这之前已经启用了non-cacheable区域配置。否则可能出现MPU还没来得及生效,D-Cache已经先跑起来了,把缓冲区缓存了一遍。
我通常的做法是:先调用MPU_Config()函数,再调用SCB_EnableICache()和SCB_EnableDCache()。如果使用CubeMX,在System Core → MPU里可以先配置好region,然后在main函数最开始调用HAL_MPU_ConfigRegion相关代码。
另外提醒一句,调试的时候不要为了省事直接注释掉SCB_EnableDCache()来验证问题。这样确实能很快验证“是不是D-Cache导致的”,但正式发布的代码里必须保留D-Cache,否则Cortex-M7跑大循环和浮点运算的性能会明显打折扣。定位问题的时候可以临时禁用来做对比,但最终方案必须是“用正确的方式处理缓存一致性”。
3.2 UART接收空闲中断+DMA:只需要改三个地方
UART接收用空闲中断加DMA,是H7上最常见的组合。CubeMX配置时,UART的DMA接收请求选择UART_DMA_RX,DMA模式选Circular还是Normal,要看你的接收策略。
先说Normal模式,配合空闲中断的逻辑是:
- 调用
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rxBuf, RX_BUF_SIZE)启动接收。 - 每收到一帧数据,出现空闲线时,HAL库回调
HAL_UARTEx_RxEventCallback。 - 在回调里处理数据后,需要重新调用一次
HAL_UARTEx_ReceiveToIdle_DMA启动下一轮接收。
需要改的第一处,就是缓冲区定义必须32字节对齐:
__attribute__((aligned(32))) uint8_t rxBuf[256]; __attribute__((aligned(32))) uint8_t txBuf[256];第二处,是在启动DMA接收之前,先做一个Invalidate,把缓冲区对应的Cache行作废。因为如果不作废,缓冲区里残留的旧数据可能在Cache里还留着一份“快照”,DMA写入新数据后CPU去读时命中的反而是旧内容:
SCB_InvalidateDCache_by_Addr((uint32_t *)rxBuf, sizeof(rxBuf)); __DSB(); HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rxBuf, RX_BUF_SIZE);第三处,是在接收完成回调里,处理数据之前再做一次Invalidate:
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart->Instance == USART1) { SCB_InvalidateDCache_by_Addr((uint32_t *)rxBuf, Size); __DSB(); process_rx_data(rxBuf, Size); HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rxBuf, RX_BUF_SIZE); } }这里有个很容易踩的坑:Size是实际接收到的字节数,一般不是32的整数倍。我在前面强调过SCB_InvalidateDCache_by_Addr对长度有对齐要求,所以这里不能直接把Size传进去。稳妥做法是传入整个缓冲区的长度,或者计算出不小于Size的最小32字节倍数:
uint32_t aligned_len = ((Size + 31U) & ~(uint32_t)31U); if (aligned_len > RX_BUF_SIZE) { aligned_len = RX_BUF_SIZE; } SCB_InvalidateDCache_by_Addr((uint32_t *)rxBuf, aligned_len);你也可以偷懒直接invalidate整个rxBuf,反正缓冲区不大,性能损失可忽略。但如果缓冲区长1KB甚至更大,还是用对齐后的实际长度比较好。
3.3 ADC多通道扫描+DMA:D-Cache下的经典翻车现场
ADC多通道扫描配合DMA,在F1/F4上是最常规的写法,F4直接启用DMA循环模式就行。但到了H723,开了D-Cache后如果不做缓存维护,你会看到一个非常迷惑的现象:第一次采集的数据正常,之后每次采集的数据都是上一次的旧值,或者某个通道的数值被其他通道“污染”。
原因还是缓存一致性问题。DMA持续把ADC转换结果写入缓冲区,但CPU的Cache里保留着之前的数据副本,CPU读缓冲区时命中Cache,读出来的全是旧的。
关键配置方面,CubeMX里ADC连续转换模式和DMA请求要配合好。很多人在CubeMX里看到ADC配置里的“Continuous Requests”选项,不知道它到底是什么意思。这个选项对应的DMA请求标志是DMA_CONTINUOUS_REQUESTS,含义是每个ADC转换周期结束后,DMA自动发起一次新的数据传输请求,而不需要等待软件再次触发。和它容易混淆的是ADC的连续转换模式ContinuousConvMode。简单说:
- ADC连续转换模式:ADC持续采样,不断产生转换结果。
- DMA连续请求:DMA跟着ADC的转换节奏自动搬运数据。
对多通道扫描场景,正确组合通常是:使能ADC连续转换模式,同时使能DMA连续请求,这样DMA会一直把扫描结果按固定顺序搬运到缓冲区,形成连续采样的数据流。
中断回调里的处理如下:
#define ADC_BUF_LEN (32 * 8) /* 32字节对齐的缓冲区长度 */ __attribute__((aligned(32))) uint16_t adcBuf[ADC_BUF_LEN]; void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { SCB_InvalidateDCache_by_Addr((uint32_t *)adcBuf, sizeof(adcBuf)); __DSB(); process_adc_data(adcBuf); } } void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { uint32_t halfLen = sizeof(adcBuf) / 2U; SCB_InvalidateDCache_by_Addr((uint32_t *)adcBuf, halfLen); __DSB(); process_adc_half_data(adcBuf); } }需要注意,HAL_ADC_ConvCpltCallback和HAL_ADC_ConvHalfCpltCallback在DMA循环模式下都会持续触发。如果你的应用只关心完整缓冲区,那处理ConvCpltCallback即可;如果数据处理量大,需要流水线作业,那就半缓冲和全缓冲一起处理。不管哪种方式,访问数据前必须invalidate对应区域。
还有个小细节:ADC DMA缓冲区类型建议声明为uint16_t,但缓存行对齐按32字节。如果一次扫描10个通道,缓冲区长度至少是20字节,按32字节对齐后是32字节,那就定义uint16_t adcBuf[16],够放一轮扫描加上对齐空间。不要傻乎乎地定义一个刚好20字节的数组,后面invalidate长度凑不满32字节,又是折腾半天。
3.4 SPI收发DMA:发送与接收的对称操作
SPI DMA和UART DMA本质一样,但因为是同步协议,发送和接收往往同时进行,所以更容易踩缓存一致性的坑。
先明确方向:
- SPI从RAM发送数据:CPU把要发的内容写入TX缓冲区后,启动DMA之前必须Clean TX缓冲区。
- SPI接收数据到RAM:DMA搬完数据后,CPU读取RX缓冲区之前必须Invalidate RX缓冲区。
如果是全双工,使用HAL_SPI_TransmitReceive_DMA同时收发,那么TX方向先做Clean,RX方向在完成中断里做Invalidate:
__attribute__((aligned(32))) uint8_t spiTxBuf[128]; __attribute__((aligned(32))) uint8_t spiRxBuf[128]; /* 启动SPI DMA传输 */ SCB_CleanDCache_by_Addr((uint32_t *)spiTxBuf, sizeof(spiTxBuf)); __DSB(); SCB_InvalidateDCache_by_Addr((uint32_t *)spiRxBuf, sizeof(spiRxBuf)); __DSB(); HAL_SPI_TransmitReceive_DMA(&hspi1, spiTxBuf, spiRxBuf, 128);完成回调里:
void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { SCB_InvalidateDCache_by_Addr((uint32_t *)spiRxBuf, sizeof(spiRxBuf)); __DSB(); process_spi_data(spiRxBuf); } }这里要注意的是,启动传输前对RX缓冲区做的Invalidate很关键。如果不做,RX缓冲区里如果正好有从上次传输残留下来的Cache行,DMA写入物理RAM后,CPU访问时可能还是命中旧的Cache行。尤其循环传输时,第二次收到的数据大概率是错的。
如果你用MPU把SPI的RX/TX缓冲区都划成了non-cacheable区域,那这一切手动操作都可以省略。这也是为什么我建议缓冲区数量多、DMA频率高的项目直接用MPU方案,代码会干净很多。
4. 实操中的深坑与排查技巧
4.1 先定位再修改:三步排查法
遇到DMA数据错乱,不要急着把Cache维护指令到处乱插,先按下面的顺序排查:
第一步,把D-Cache临时关闭,跑一遍同样的功能。如果关闭D-Cache后一切正常,说明问题确实来自缓存一致性;如果关闭后仍然错乱,那问题大概率在DMA配置本身,比如DMA请求源配错、缓冲区地址越界、外设DMA请求使能没开。
第二步,如果确实是缓存一致性问题,检查缓冲区地址和长度是否32字节对齐。这是最容易被忽略的点。很多人写了SCB_InvalidateDCache_by_Addr,但地址不是32字节对齐,导致缓存行处理不完整,问题“偶尔重现”,非常难排查。
第三步,检查缓存维护操作是否在正确的时间点执行。Clean必须在DMA启动前,Invalidate必须在DMA完成后、CPU读数据前。时序错了,维护得再勤快也没用。如果用调试器在回调里打断点,尤其要注意:断点处Cache状态已经和正常运行不同,容易误导判断。建议只打日志,不要打断点验证Cache一致性。
4.2 为什么第一次正常,后续全部错乱
这个现象我见过太多次了。第一次DMA传输时,缓冲区对应的Cache行尚未被加载,CPU读缓冲区时必然从物理RAM读,所以数据是对的。但读完之后,Cache行被填充了。第二次DMA写入物理RAM时,Cache里还留着第一次读入的副本,CPU再访问就命中Cache,读出来的是第一轮的数据,不是第二轮DMA写入的。
这就是为什么很多人误以为“程序启动没问题,跑一会就坏了”。实际上程序逻辑一直是坏的,只是第一次恰好绕过了缓存命中这一环。
解决思路也很清晰:每次DMA传输完成后,在CPU访问缓冲区前做Invalidate,打破Cache行“命中旧值”的循环。Circular模式下尤其要注意半缓冲回调,漏掉任何一个处理分支,都会出现半个缓冲区是旧数据的现象。
4.3 不要在中断里频繁执行全缓存Clean或Invalidate
STM32H723的D-Cache有32KB,全缓存操作SCB_CleanDCache和SCB_InvalidateDCache会遍历所有Cache行,耗时和当前Cache命中情况有关,在高速中断里每帧数据都执行一次,性能损失非常大。我实测过,在高频SPI DMA中断里执行全缓存Invalidate,会明显拉高中断占用时间,严重时甚至影响DMA传输实时性。
正确做法是只操作涉及到的地址范围,使用SCB_CleanDCache_by_Addr和SCB_InvalidateDCache_by_Addr。这两个函数虽然也要遍历这个范围内的所有Cache行,但32字节一行,256字节的缓冲区也就遍历8行,比全缓存扫描快得多。
还有一点,SCB_InvalidateDCache_by_Addr虽然函数名带着Invalidate,但CMSIS内部实现里,如果发现某一行是脏的,它会先把这一行写回内存再失效,防止数据丢失。换句话说,如果你对一块CPU刚写过但要丢弃的缓冲区做Invalidate,多余的回写开销是免不了的。这也是为什么一定在DMA前把该Clean的区域Clean掉,降低中断里的意外开销。
4.4 数据屏障指令DSB/ISB的使用时机
__DSB()在前面代码里出现了很多次,我简单解释一下为什么需要它。SCB缓存操作指令提交给总线后,如果紧接着执行“启动DMA”的命令,理论上总线流水线可能还没把缓存维护指令执行完,DMA就开始搬运数据了,这时clean或invalidate可能没有生效。
我在调试一个SPI DMA发数据的问题时,就是靠__DSB()救回来的。当时在Clean TX缓冲区之后立即执行HAL_SPI_Transmit_DMA,实测偶尔会发错前几个字节。加了一个__DSB()之后,问题彻底消失。__ISB()一般用于指令流同步,缓存维护场景很少用到,但如果改动的是代码段或异常向量表,就必须加。
需要说明的是,CubeMX生成的HAL函数内部在关键时序点已经内嵌了内存屏障,所以如果你严格使用HAL库,问题不大。一旦你直接用寄存器操作,或者绕过HAL直接改写DMA的寄存器,就必须自己在代码里显式维护这些屏障指令。
5. 个人经验与最后的小建议
这篇文章里涉及的所有代码和步骤,我都已经在STM32H723板子上实际跑过。最开始我也是习惯用F4的思维,觉得DMA配置好就能直接跑,结果被D-Cache的数据滞留问题折腾了很久。后来养成了一个习惯:所有DMA缓冲区统一加上__attribute__((aligned(32))),定义缓冲区时预留足够的对齐余量。
对于项目选型,我的个人建议是:如果DMA缓冲区数量不多,就是两块收发缓冲区,那就用手动Clean/Invalidate,逻辑直观,不占用MPU资源。如果缓冲区很多,比如以太网描述符、USB FIFO、多个UART的收发缓冲混在一起,直接用MPU划分non-cacheable区域更省心,否则你会在每个外设回调里写一堆维护代码,还容易漏。
最后再送一个小技巧:调试缓存一致性问题时,可以用GPIO翻转来测量DMA完成回调和数据实际生效之间的延迟。先用一张GPIO在中断入口拉高、数据处理好之后拉低,配合逻辑分析仪看波形,能直观判断缓存维护操作到底消耗了多少时间,以及中断里是否出现了过多的回写开销。这个方法帮你省掉很多瞎猜的时间。