STM32H723这块板子我调了大半年,DMA和D-Cache的缓存一致性问题前前后后踩了不少坑。标题里这个配置问题,几乎每个从F系列或G系列转过来的朋友都会遇到:DMA单独用是好的,D-Cache单独用也是好的,但两者一同时开启,数据就开始“飘”——有的串口偶发丢数据,有的ADC采回来的值像“隔夜饭”一样陈旧。搞懂这个问题,不仅H723适用,H743、H750,甚至GD32的M7内核型号,逻辑完全一样。
这篇文章我直接把我自己的配置思路、代码模板和踩坑记录摊开讲,从D-Cache的工作原理说到三种解决方案,再给两个实际案例(串口空闲中断加DMA、ADC多通道扫描循环采样DMA),最后是排障清单。不管你是刚上手H7,还是已经在跟缓存一致性搏斗,应该都能找到能直接抄作业的部分。
1. 问题根源:DMA和D-Cache为什么“天生犯冲”
1.1 先搞懂D-Cache到底干了什么
Cortex-M7内核引入了I-Cache和D-Cache,目的是减少CPU访问慢速存储器(比如Flash、外部SDRAM)的等待时间。D-Cache位于CPU核心和总线系统之间,CPU对内存的读写会先经过它。它的缓存行(Cache Line)一般是32字节,也就是说,CPU访问内存时是以32字节为最小单位加载到Cache里的,而不是只加载你操作的那一个字节。
当CPU执行store指令向某个地址写数据时,数据通常先写进D-Cache,并且被标记为“脏”(Dirty),并不会立刻写回物理内存。这种策略叫Write-back(回写法)。回写机制的好处是减少访问慢速内存的次数,坏处嘛,就是内存里留着一份可能已经过期的数据。
关键点来了:DMA控制器是一个总线主设备,它直接通过AHB总线矩阵访问物理内存,完全不经过CPU的D-Cache。于是两边就出现了“信息不对称”——CPU看到的是一套数据,DMA看到的可能是另一套。
1.2 两种最典型的数据不一致场景
我总结了一下,所有DMA加D-Cache的问题都可以归成两类方向:
第一种,CPU写入数据,DMA往外读。典型场景是串口发送、SPI发送。CPU把要发送的数据write进内存,数据还躺在D-Cache里没回写,这时候你启动DMA,DMA跑到物理内存去读,结果读到的是旧数据。表现就是:第一次发可能正常,后面发的内容是错的、乱的,或者干脆发不出来。
第二种,DMA写入内存,CPU要读。典型场景是串口接收、ADC采集、SPI接收。DMA把数据从外设搬到内存,物理内存里的数据是最新的,但CPU之前可能已经读过这块区域,Cache里还残留着旧数据。由于Cache命中的优先级高于内存访问,CPU读到的还是旧值。表现就是:数据永远是上一次的,第一次读全是0,或者隔几次才更新一次。
搞清楚了这两种方向,后面选方案和写代码就非常顺理成章了。
2. 方案全景:关Cache、软件维护、MPU分区,到底选哪个
2.1 三种主流方案的对比
面对DMA和D-Cache打架的问题,工程师们摸索出了三种主流思路:
方案一是最暴力的:直接关掉D-Cache。在main函数里不调用SCB_EnableDCache()就行了。这种方法确实能彻底解决一致性问题,因为CPU每次读写都直接走内存。但代价很大,等于把H7标榜的高性能砍掉一大截,特别是跑图形、跑算法、大量数据处理时会明显变慢,有点“买椟还珠”的意思。
方案二是软件维护D-Cache,也就是在合适的时机手动执行Clean(把脏数据写回内存)和Invalidate(把Cache行标记为无效)。这是最常用的方案,灵活、节省内存资源,也不需要用MPU,完全靠代码逻辑控制。缺点是每个DMA操作都要按场景加代码,遗漏一处就会出bug。
方案三是用MPU(Memory Protection Unit)把DMA缓冲区设置成Non-cacheable内存区域。这样DMA缓冲区不经过Cache缓存,硬件上保证一致性。好处是代码简洁,不需要在每个DMA调用前写Clean/Invalidate,也不容易漏;缺点是这块缓冲区失去了Cache加速,而且MPU配置本身有一些对齐要求,配置错了会直接HardFault。
2.2 我自己在实际项目里的选型建议
这三个方案不是非此即彼的,实际项目中完全可以混合使用。我的习惯是这样:
低频的、小数据量的DMA传输,比如串口发送一帧几十字节,我倾向于用方案二,软件Clean。因为这部分数据量小,Clean一次的开销远小于处理器维护整个Cache。
高频的、大块数据的DMA缓冲区,比如ADC多通道连续采集、高速SPI接收,我用方案三(MPU Non-cacheable)或者双缓冲加Invalidate的方式。这里有几个考虑:如果缓冲区是Non-cacheable的,CPU读取时虽然不经过Cache,但是H7的SRAM本身够快,数据搬运和处理逻辑通常也能跑得起来;如果你对处理速度要求极端苛刻,那就用双缓冲+Invalidate,但代码会复杂一截。
最不建议的场景是,MPU配置得很随意,缓冲区一会儿Cacheable一会儿Non-cacheable,代码里又顺手加了一些Clean/Invalidate调用,最后自己也分不清哪些区域是缓存过的。这种状态最容易出那种“时好时坏”的诡异bug。
3. 软件维护D-Cache的完整实操指南
3.1 缓存操作函数怎么用才正确
如果你决定用软件维护方案,CMSIS已经提供了现成的接口。最常用的有四个:
SCB_CleanDCache(); // 回写所有脏行 SCB_InvalidateDCache(); // 使所有缓存行失效 SCB_CleanDCache_by_Addr(uint32_t *addr, int32_t dsize); // 按地址回写 SCB_InvalidateDCache_by_Addr(uint32_t *addr, int32_t dsize); // 按地址失效这四个函数在core_cm7.h里有定义。要注意的是,SCB_CleanDCache_by_Addr和SCB_InvalidateDCache_by_Addr这两个函数本身会处理边界对齐:它会根据CTR寄存器里的Cache Line Size自动计算,把你传入的地址所在的那一行整行处理掉。所以哪怕你的缓冲区没有32字节对齐,函数也能“工作”,不会崩溃。
但“能工作”不代表“高效”。如果你的缓冲区数据占用了一个Cache行的部分字节,然后和另一个变量共享同一行,Invalidate操作会把那一整行失效,连带把另一个变量的缓存放掉,下次访问那个变量就得重新走内存,性能悄悄就下去了。反过来,Clean操作会把整行写回内存,如果那行里还有别的变量,可能产生不必要的写内存操作。所以缓冲区我建议都用__ALIGNED(32)修饰,长度也尽量填充成32的倍数。
还有一个特别重要的点:调用完Invalidate函数后,最好紧接着加一条__DSB()指令(数据同步屏障)。这能确保缓存失效操作在后续CPU访问之前真正完成。因为CMSIS里的Invalidate操作是往寄存器里写命令,总线是异步的,你不加屏障,下一条CPU load指令可能先执行了,读到的数据还是旧缓存。
3.2 DMA发送场景:Clean不能漏
先看发送场景。你需要把CPU刚写好的数据通过DMA发出去,正确姿势是先Clean再启动DMA:
__ALIGNED(32) uint8_t tx_buffer[128]; void UART_SendViaDMA(uint8_t *data, uint16_t len) { memcpy(tx_buffer, data, len); // 关键:把DMA要读的内存区域从Cache回写到物理内存 SCB_CleanDCache_by_Addr((uint32_t *)tx_buffer, len); __DSB(); HAL_UART_Transmit_DMA(&huart1, tx_buffer, len); }这里为什么要用专门的tx_buffer而不是直接把data指针传给DMA?因为data可能指向一个局部变量、结构体字段,地址可能没有做Cache行对齐,Clean一下可能把整个行都写回,虽然结果是对的,但不够干净。用一个全局的、32字节对齐的DMA缓冲区,可以规避很多内存边界问题。
3.3 DMA接收场景:Invalidate时机决定成败
接收场景比发送更要注意时机。一个常见的坑是:DMA已经完成传输了,但CPU读缓冲区前忘了Invalidate。DMA把数据写进物理SRAM,CPU那边Cache里还躺着旧数据。正确的顺序是:等待DMA传输完成(中断或标志位),然后Invalidate,最后再读缓冲区。
__ALIGNED(32) uint8_t rx_buffer[256]; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { // DMA传输完成后,先无效化CPU缓存行,确保读到的是新数据 SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buffer, sizeof(rx_buffer)); __DSB(); ProcessRxData(rx_buffer, sizeof(rx_buffer)); } }有个细节值得强调:如果你在DMA传输还没完成的时候,就先Invalidate了一下,然后CPU读到旧值(因为DMA还没写完),之后再怎么等待也没用。Cache已经被重新填充了旧数据。所以Invalidate的位置必须在DMA完成之后、CPU读取之前。这是代码上最容易出错的时序逻辑。
3.4 实战案例一:串口空闲中断加DMA接收不定长数据
串口空闲中断(IDLE)加DMA是嵌入式圈子里非常流行的一套接收方案,特别适合处理不定长协议帧。思路是:DMA配置为循环模式,数据持续写入环形缓冲区,当串口总线上出现空闲(间隔超过一个字节时间)时触发IDLE中断,在中断里根据DMA计数器的值算出当前写入位置,把新增的数据取走。
首先定义缓冲区,注意对齐:
#define RX_BUF_SIZE 1024 __ALIGNED(32) uint8_t uart_rx_buf[RX_BUF_SIZE]; volatile uint16_t rx_last_pos = 0;注意,这个缓冲区必须放在DMA能访问到的RAM区域。在H723上,DMA不能访问TCM内存,所以如果你把缓冲区定义在DTCM区域,DMA是写不进数据的。后面我会在排查清单里再强调一次。
DMA配置为循环模式后,缓冲区可以看作一个环形队。获取当前写位置的代码要注意,__HAL_DMA_GET_COUNTER返回的是剩余字节数,不是已传输字节数:
uint16_t current_pos = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx);然后比较current_pos和上次记录的rx_last_pos,差值就是本次新收到的数据长度。环形缓冲可能回绕,所以要做两次判断:
void UART_IdleHandler(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { uint16_t current_pos = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); uint16_t len; if (current_pos >= rx_last_pos) { len = current_pos - rx_last_pos; if (len > 0) { SCB_InvalidateDCache_by_Addr((uint32_t *)&uart_rx_buf[rx_last_pos], len); __DSB(); ProcessRxFrame(&uart_rx_buf[rx_last_pos], len); } } else { // 回绕情况,分两段读取 uint16_t len1 = RX_BUF_SIZE - rx_last_pos; if (len1 > 0) { SCB_InvalidateDCache_by_Addr((uint32_t *)&uart_rx_buf[rx_last_pos], len1); __DSB(); ProcessRxFrame(&uart_rx_buf[rx_last_pos], len1); } uint16_t len2 = current_pos; if (len2 > 0) { SCB_InvalidateDCache_by_Addr((uint32_t *)&uart_rx_buf[0], len2); __DSB(); ProcessRxFrame(&uart_rx_buf[0], len2); } } rx_last_pos = current_pos; __HAL_UART_CLEAR_IDLEFLAG(&huart1); } }IDLE中断里处理完业务后,要记得清标志并重新使能IDLE中断。另外,当你调用ProcessRxFrame去处理数据时,实际上这些数据还是会被CPU重新缓存起来的,但没关系,我们是按照“DMA写完-Invalidate-CPU读取”的顺序操作的,缓存的数据和内存是一致的。
这套环形缓冲方案对DMA的配置有一个小要求:DMA必须工作在循环模式,且要使能“连续请求”(DMA_CIRCULAR配合HAL里的HAL_UART_Receive_DMA即可,不要在中断里调用HAL_UART_DMAStop,否则环形缓冲就断了)。ADC等外设也有类似的连续请求概念,本质上都是让DMA一直跑下去,不停止。
3.5 实战案例二:ADC多通道扫描循环采样加DMA
ADC多通道扫描循环采样加DMA,也是H7上常见的采集方案。STM32H723的ADC支持规则组多通道扫描,开启DMA后,每次转换完成,DMA自动把结果搬进内存。配置成循环模式后,DMA就源源不断地更新缓冲区里的采样值。
缓冲区定义:
#define ADC_CHANNEL_COUNT 8 __ALIGNED(32) uint32_t adc_buf[ADC_CHANNEL_COUNT];ADC配置为扫描8个通道,DMA传输长度设置为8,传输模式为循环。每次需要读取最新采集结果时,先Invalidate再读取:
void ReadAdcAllChannels(uint32_t *values) { // DMA持续写入adc_buf,读取前必须失效缓存的旧行 SCB_InvalidateDCache_by_Addr((uint32_t *)adc_buf, sizeof(adc_buf)); __DSB(); for (int i = 0; i < ADC_CHANNEL_COUNT; i++) { values[i] = adc_buf[i]; } }这里有一个常见问题:如果缓冲区长度刚好是8个uint32_t,也就是32字节,对齐和长度都刚好是32的倍数,Cache操作非常干净。但如果通道数不是8而是比如5个,缓冲区长度20字节,跨了Cache行边界,Invalidate操作会把相邻的地址也一起失效。这本来不至于出错,但会多引一次内存读取开销。所以我在实际项目里,即使只用3个通道,也会把缓冲区长度定义成8的倍数,或者至少32字节的倍数。
ADC的DMA循环采样场景,如果采样频率很高,CPU频繁调用ReadAdcAllChannels,每次Invalidate的开销是不能忽略的。H7的D-Cache失效操作是按行执行的,行数一多会有明显损耗。这种情况有两种优化路线:
一是改用双缓冲模式(DMA Half Transfer中断+ Transfer Complete中断),CPU在半区完成中断里处理“已经被DMA写满、当前DMA正在写另一半”的数据块。处理之前同样要Invalidate对应的半区。这种做法读回来的数据一致性很好,而且不会阻塞DMA。
二是直接把adc_buf所在的区域用MPU设置为Non-cacheable。这样adc_buf不需要Invalidate,直接读就是最新的。代价是这块区域没有缓存加速,CPU每次读都要走SRAM(不过SRAM本身够快,大部分场景都可以接受)。
4. MPU配置Non-cacheable区域:一劳永逸但细节多
4.1 MPU配置代码与参数解释
用MPU方案,等于给DMA缓冲区开了一条“不经过缓存”的绿色通道。这里我给出一个可以直接复制修改的配置模板:
void MPU_Config_NonCacheableRegion(void) { MPU_Region_InitTypeDef MPU_InitStruct = {0}; HAL_MPU_Disable(); MPU_InitStruct.Enable = MPU_REGION_ENABLE; MPU_InitStruct.BaseAddress = (uint32_t)noncache_buf; MPU_InitStruct.Size = MPU_REGION_SIZE_512B; MPU_InitStruct.AccessPermission = MPU_REGION_FULL_ACCESS; MPU_InitStruct.IsBufferable = MPU_ACCESS_NOT_BUFFERABLE; MPU_InitStruct.IsCacheable = MPU_ACCESS_NOT_CACHEABLE; MPU_InitStruct.IsShareable = MPU_ACCESS_NOT_SHAREABLE; 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_PRIVILEGED_DEFAULT); }这个配置里最关键的是TypeExtField和IsCacheable、IsBufferable的组合。MPU_TEX_LEVEL1加IsCacheable=NOT_CACHEABLE、IsBufferable=NOT_BUFFERABLE,代表TEX=1、C=0、B=0、S=0,这是一个Normal Non-cacheable区域的经典设置。这里解释一下:
- Normal Non-cacheable的意思是,这个区域是普通内存属性,但不会被缓存,也不会缓冲写操作。CPU访问它和访问Strongly-ordered内存还有一个区别:Normal类型允许一定的总线合并和字节访问优化,而Strongly-ordered要求完全的顺序化访问,每次访问都必须等待总线事务完成。对DMA缓冲区这种高频访问的数据,用Normal Non-cacheable比Strongly-ordered要快一些。
如果你用CubeMX的MPU配置工具,它会默认生成一组参数,注意看一下TypeExtField和缓存属性,别用成Strongly-ordered(TEX=0,C=0,B=0,S=0),性能会差一些。
4.2 配置MPU的几个致命细节
MPU配置最坑的不是代码长,而是对齐和区域大小要求。MPU Region的Size必须是2的幂,而且Region的基地址必须与该Size对齐。举个例子,如果你设置的Region大小是512字节,那么基地址必须512字节对齐。如果你只是定义了__ALIGNED(32)的数组,可能满足不了512字节对齐要求。所以我在项目中会用:
__ALIGNED(512) uint8_t noncache_buf[256];这样既满足MPU的对齐要求,又留足缓冲区空间。如果你需要更大的Non-cacheable区域,用__ALIGNED(4096)之类的也行,但要注意,定义这种大对齐缓冲区时,链接器可能会多占用一些空间,因为要对齐到边界,前后的空洞都浪费了。
另外一个细节:MPU配置了Non-cacheable区域后,如果代码里不小心对该区域做了非对齐的32位或16位访问,可能会触发总线错误,因为Normal Non-cacheable的约束比Write-back区域严格。我自己就遇到过这种情况:某个库函数对缓冲区做了一个非对齐的memcpy,当场HardFault。解决方法是确保对Non-cacheable区域的访问都是对齐的,或者干脆把区域属性设置为允许非对齐访问(Cortex-M7的Normal区域是支持非对齐访问的,但前提是MPU配置的TEX和属性没把访问限制掉)。
还有,如果你启用了MPU,一定要在启动文件或SystemInit阶段就配置好,并在main函数最开始处调用HAL_MPU_Enable。如果中途再改MPU配置,有些外设可能已经开始了DMA传输,容易出错。
5. 常见问题与排查经验速查
5.1 症状和原因的对照表
我在调试H7项目时,把遇到的典型问题整理成了一张表,每次排查直接对着看,效率高很多:
| 现象 | 大概率原因 | 验证方法 | 解决办法 |
|---|---|---|---|
| DMA发送的数据少字节或内容错乱 | 发送缓冲区在Cache里没有Clean | 在启动DMA前单步看内存,发现内存中的值还是旧的 | 发送前调用SCB_CleanDCache_by_Addr |
| DMA接收的数据全是0或第一次的旧值 | 接收缓冲区没有Invalidate | 单步执行,Invalidate后再读取,值正常 | 接收完成后调用SCB_InvalidateDCache_by_Addr |
| 传输偶尔错误,频率不稳定 | Cache行边界被截断,相邻数据被误失效或误回写 | 观察缓冲区和相邻变量的地址是否在同一Cache行 | 缓冲区加32字节对齐,长度填充到32的倍数 |
| DMA没有数据到达,计数器一直没变 | 缓冲区可能在DTCM区域,DMA访问不到 | 查看链接脚本中缓冲区所在段 | 把缓冲区放到AXI SRAM或SRAM1/2/3 |
| 启动MPU后直接HardFault | MPU Region地址或大小不满足对齐约束 | 读FAULTSTATUS寄存器判断总线错误 | 按Region大小对齐缓冲区基地址 |
这张表是我实际排查中反复用到的核心清单。很多问题的表象其实只有一两种,但背后的原因因为Cache的存在变得很隐蔽。
5.2 一个非常隐蔽的坑:缓冲区放在了DTCM里
H723的内存架构里,DTCM和ITCM是紧耦合内存(TCM),直连CPU内核,CPU访问它们是零等待的,性能非常好。但TCM不走总线矩阵,DMA根本访问不到。如果你在链接脚本里把默认的RAM区域指到了DTCM,又在里面定义DMA缓冲区,那么DMA搬运的数据实际上是到不了缓冲区的。
排查方法:编译后看map文件,确认缓冲区的地址范围。比如H723的DTCM一般映射在0x20000000附近(具体看参考手册),而AXI SRAM在0x24000000、SRAM1/2/3在0x30000000附近。如果你发现缓冲区地址落在0x20000000到0x2001FFFF之间,赶紧把它移到别的RAM区域去。
实际项目中,我把大块的DMA缓冲区、DMA描述符都放在AXI SRAM或SRAM1里,栈和热数据放在DTCM,这样两边都能发挥各自优势。
5.3 排查缓存问题的套路
如果你遇到一个“开启D-Cache后出现的bug”,我建议按下面的顺序排查:
第一步,先复现,确认问题只在使能D-Cache时出现。如果关闭D-Cache后一切正常,那基本锁定了缓存一致性问题。
第二步,确认数据传输方向。判断当前场景是CPU写DMA读(需要Clean),还是DMA写CPU读(需要Invalidate)。这一步能直接帮你定位缺失的是哪种Cache操作。
第三步,检查缓冲区地址和大小。看map文件确认缓冲区在哪个RAM,是否为32字节对齐,长度是否跨越了多个Cache行。用调试器查看缓冲区的地址,确认没有落在DTCM。
第四步,检查代码时序。Invalidate必须在DMA完成之后、CPU读之前;Clean必须在CPU写完、启动DMA之前。这两个顺序不能反。特别留意中断抢占的情况,如果你的主循环和中断都对同一个DMA缓冲区做操作,很容易出现“先Invalidate后DMA完成”的时序错误。
第五步,如果以上都查不出来,试着用MPU把该缓冲区设为Non-cacheable,如果bug消失,那说明问题确实是缓冲区被缓存了;如果bug还在,可能就不是缓存一致性问题,而是DMA配置、中断标志或者缓冲区长度计算的问题了。
5.4 额外的调试技巧
调试DMA加Cache的问题,我习惯用调试器把Cache的Enable/Disable作为切换开关,对比两组行为。CubeIDE或Keil里,可以在main函数最开始写一个条件的SCB_DisableDCache(),然后跑两遍:
- 第一遍关闭D-Cache,功能全正常,记录数据值。
- 第二遍开启D-Cache,对比异常表现。
这样能快速确认问题是否和Cache相关。如果确实相关,再用上面的步骤细查。
另外一个技巧是,在调试器里可以直接查看SCB->DCCMVAC、SCB->DCIMVAC等寄存器,但说实话这个作用不大,我一般直接看内存窗口:
- Clean前,看物理内存窗口,如果和CPU看到的值不一致,说明脏行还没回写。
- Invalidate前,看缓存窗口,如果用GDB/IDE的缓存视图能看到旧值,说明缓存还没有失效。
不过多数IDE默认访问的是物理内存,所以内存窗口显示的值可能和你单步执行的读取结果不一样,这个现象本身就是一个线索。
6. 收尾前再说两句实操体会
从我自己的项目经历看,DMA加D-Cache的调试,本质上是在跟“你以为的内存”和“实际所在的内存”作斗争。H7这种高性能MCU,Cache带来的性能收益很大,但缓存一致性问题的排查成本也真实存在。我踩过最大的坑就是在串口环形缓冲里少了一次Invalidate,导致协议握手时对端总是认为我发的数据是错的,查了两天才定位到是缓存残留。
如果你打算长期在H7或类似M7内核的芯片上开发,我建议把这个流程固化成自己的代码模板:所有DMA缓冲区统一用__ALIGNED(32)修饰,统一放一个专门的C文件里,统一提供一对Send/Receive函数,在函数内部封装好Clean/Invalidate调用。这样每次开新项目,直接复制模板,不需要再重新痛苦一轮。最后再分享一个我个人的小习惯:在缓冲区定义旁边用注释标明它属于Cacheable还是Non-cacheable区域,以及哪个函数负责Clean、哪个函数负责Invalidate。等代码量上来之后,这种注释比写在文档里靠谱得多,能帮你省下大量翻代码的时间。