Cortex-M33 DCACHE与DMA一致性:STM32MP2嵌入式开发避坑指南 📅 发布时间:2026/8/30 3:41:54 👁 浏览次数: 几个月前在STM32MP2的M33核上调试串口DMA接收我遇到过一个非常诡异的BugDMA配置完全正常缓冲区地址也没有问题但收到的数据总是间歇性错乱——有时是旧帧的尾巴有时是半新半旧的数据更奇怪的是在调试器里看内存地址数据明明是对的可程序读出来就是不对。排查到最后一根线还是落在了DCACHE上。如果你也遇到过类似现象或者正准备在STM32MP2系列Cortex-M33上做DMA开发这篇文章大概率能帮你少走几天的弯路。先说清楚这篇文章要解决什么问题Cortex-M33自带数据缓存DCACHE时CPU读写内存和DMA搬运数据走的是完全不同的路径缓存里的内容和实际内存内容可能不一致导致DMA传出去、传进来的数据是错的。下面我会从故障现象、根本原理、解决方案到代码模板把这件事一次讲透。1. 现象先行DMA搬运数据“时灵时不灵”的诡异故障1.1 典型的故障表现这类问题在开发中伪装得很好往往不是“完全不能用”而是“偶尔不对”。我自己归纳了几种最典型的表现串口DMA接收帧错位帧头校验偶尔失败收到的数据里有上一帧的残留。最让人崩溃的是把缓冲区打印出来能看到新旧数据混在一起但是完全没有固定规律。ADC DMA多通道扫描采样数组部分更新部分还是旧值。比如开启ADC多通道循环采样理想情况下DMA应该把最新的一组数据搬进内存但实际读出来发现前两个通道是新的后面几个通道是几秒前甚至上电时的旧值。SPI或以太网DMA发送程序明明把发送缓冲区的内容写好了数据发出去却不对。外部设备收到的内容与预期不符偶尔还会触发CRC错误。调试器视角的内存数据和CPU读到的数据不一致。在IDE里看缓冲区地址内存里的值是对的但程序执行时读出来却是旧数据。这是最具有迷惑性的现象会让人误以为是编译器优化或者指针问题。我之前被第二种现象坑过很久ADC采样数组里只有部分通道会更新看起来像是DMA配置问题折腾了好几天才发现是DCACHE在作祟。1.2 为什么Cortex-M33上这个问题尤其值得注意STM32MP2系列和传统MCU不同它是一个异构MPU内部有一颗Cortex-A35通常跑Linux和一颗Cortex-M33做实时控制。M33作为M-profile内核在STM32MP2产品线中首次配备了可配置的DCACHE和ICACHE。之前习惯用M4/M3的开发者往往没有处理缓存的意识因为那些内核根本没有DCACHE。M7内核也有DCACHE遇到同样的问题但M7的场景通常是单核MCU内存是内部SRAM或者外部SDRAM问题相对简单。MP2的M33则处在更复杂的异构系统中M33和A35共享DDR和SRAM地址空间DMA可以访问的区域比传统MCU大得多。一旦涉及共享内存和双核通信缓存一致性的坑就更多了。还有一个原因DCACHE开启后性能提升显著尤其在DDR上跑代码和数据的场景。很多开发者默认就让CubeMX把DCACHE打开了却不知道这个开关会把DMA的数据搞乱。所以这个问题在M33上不算罕见甚至可以说是MP2开发必须跨过的一道坎。2. DCACHE与DMA冲突的本质缓存行、写回策略与数据流方向2.1 写回式DCACHE的工作方式要理解这个问题先要明白DCACHE是怎么工作的。你可以把DCACHE看作CPU和内存之间的一个“快速暂存区”暂存区里的数据是内存中某些地址的一份副本。DCACHE的工作单位是缓存行Cache LineCortex-M33的DCACHE缓存行大小通常是32字节具体以参考手册为准。CPU读内存时会把整条缓存行从内存搬到Cache中CPU写内存时不会立刻把数据写到内存而是先写到Cache里标记这条缓存行为“脏”Dirty等到合适的时机或者被替换掉时再统一写回内存。这就是写回Write-back策略。这样做的好处是性能高CPU频繁访问的同一段数据可以从Cache里快速命中不需要每次都去等慢速的DDR。坏处就是一句话内存中的数据可能不是最新的最新的数据可能只在Cache里。2.2 两个方向的数据流各自会出什么问题DMA和外设不一样它是系统总线上的一个Master直接访问内存完全不经过CPU内部的DCACHE。这就导致了两个方向都会出问题。第一个方向DMA把数据写入内存之后CPU来读。DMA写入内存时如果内存中某个地址对应的缓存行恰好已经在DCACHE里那么CPU之后读这个地址时命中的是Cache里那份旧副本而不是DMA刚写入的新数据。这就是我遇到的ADC和串口接收故障的根源——数据明明已经在内存里了CPU却读成了Cache里的旧值。第二个方向CPU先把数据写入内存之后DMA来读。CPU写数据后数据只停留在Cache里并被标记为脏并没有真正落到内存。这时DMA从内存读取读到的是旧数据。这就是发送方向出问题的原因——程序把发送缓冲区的数据更新了DMA却把老数据发出去了。从这两个方向可以看出DCACHE和DMA的一致性维护本质上就是解决“Cache里的副本”和“内存中的实体”相互之间谁新谁旧的问题。2.3 什么场景下可以完全不用管不是说所有DMA场景都需要处理cache一致性以下几个场景可以完全不管DMA在外设到外设之间搬运数据不经过系统内存。比如外设的FIFO到另一个外设的FIFO根本不涉及CPU缓存。缓冲区位于TCMTightly Coupled Memory且TCM本身不是带缓存的存储。TCM直连CPU没有缓存层次理论上天然一致。缓冲区所在的地址区域通过MPU配置成了Non-cacheable。既然CPU访问该区域时完全不进Cache自然不存在一致性问题。DMA搬运的目标或源本身是外设寄存器而不是内存地址。除了这几种情况只要DMA缓冲区位于可缓存的内存区域就必须认真对待一致性维护。3. 三种维护方案怎么选手动Clean/Invalidate、MPU划区、TCM绕开3.1 Clean、Invalidate、CleanInvalidate各自负责什么在Cortex-M33上CMSIS提供了一组Cache维护接口核心是三个操作Clean回写把Cache中标记为脏的缓存行写回内存。执行之后缓存行里的数据和内存中的数据就一致了脏标记被清除。Invalidate失效把Cache中的缓存行直接丢弃。下次CPU再访问这个地址时会强制从内存重新加载保证拿到的是内存中的最新数据。CleanInvalidate先回写再失效先执行Clean再执行Invalidate。这个操作最稳妥不会丢失Cache中的脏数据也能保证后续读取强制走内存。这三个操作都有按地址范围操作的版本CMSIS中的接口是SCB_CleanDCache_by_Addr(void *addr, int32_t dsize); SCB_InvalidateDCache_by_Addr(void *addr, int32_t dsize); SCB_CleanInvalidateDCache_by_Addr(void *addr, int32_t dsize);还有对整个Cache操作的原子版本比如SCB_CleanDCache()、SCB_InvalidateDCache()。它们会清理全部缓存简单粗暴但性能损失很大尤其是在大工程里会误伤大量不需要处理的缓存行一般不建议在DMA收发路径上使用。3.2 手动维护的适用场景与时间点手动维护的思路是在DMA启动前或完成后对缓冲区对应的地址范围执行对应的Cache操作。下面这个表是我实践中总结的黄金规则可以直接照抄场景操作时机发送CPU写数据DMA读CleanDMA启动前CPU写完数据后接收DMA写数据CPU读InvalidateDMA传输完成后CPU读数据前接收缓冲区复用CleanInvalidate每次启动DMA接收之前发送缓冲区复用Clean每次启动DMA发送之前接收缓冲区复用是一个很容易踩坑的点。假如缓冲区上次接收的数据被CPU处理过Cache里可能残留了脏行。下次DMA往同一块内存写入新数据时如果Cache里的脏行没有写回内存后续CPU一旦把脏行写回就会覆盖DMA刚刚写入的新数据。所以启动DMA接收之前最安全的做法是对缓冲区先做一次CleanInvalidate。还有一个容易被忽略的细节执行完Cache维护操作后最好加一条内存屏障指令__DSB()确保维护操作真正完成后再启动DMA。否则理论上存在Cache维护操作尚未完成、DMA已经开始搬运数据的窗口期。SCB_CleanDCache_by_Addr(tx_buffer, sizeof(tx_buffer)); __DSB(); HAL_UART_Transmit_DMA(huart, tx_buffer, len);3.3 MPU配置Non-cacheable区域的做法如果不想每次都在代码里手写Cache维护逻辑还有一个思路是用MPU把DMA缓冲区所在的地址区域配置成Non-cacheable。这样CPU访问这块区域时压根不经过CacheDMA和CPU看到的数据永远是一致的。在STM32CubeMP2工程中配置MPU区域可以使用HAL的API。例如MPU_Region_InitTypeDef MPU_InitStruct {0}; HAL_MPU_Disable(); MPU_InitStruct.Enable MPU_REGION_ENABLE; MPU_InitStruct.BaseAddress 0x30000000; // 根据实际共享缓冲区地址修改 MPU_InitStruct.Size MPU_REGION_SIZE_64KB; 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.Number MPU_REGION_NUMBER0; MPU_InitStruct.TypeExtField MPU_TEX_LEVEL_0; MPU_InitStruct.SubRegionDisable 0x00; MPU_InitStruct.DisableExec MPU_INSTRUCTION_ACCESS_DISABLE; HAL_MPU_ConfigRegion(MPU_InitStruct); HAL_MPU_Enable(MPU_CONTROL_MPU_ENABLE);这种方式适合频率不高、数据量不大的场景比如双核共享内存、命令缓冲区之类。缺点也很明显频繁访问Non-cacheable区域的性能比Cacheable区域差很多每次读写都要实打实地访问DDR或SRAM等待延迟会拖慢CPU。所以不要把整个RAM都设为Non-cacheable只把需要和DMA共享的小块区域划出来即可。3.4 TCM方案什么时候值得用Cortex-M33的本地TCM是一种没有缓存层次的内存CPU访问它时延迟极低而且通常不会被DCACHE缓存操作影响。如果把DMA缓冲区放在TCM里就能绕开缓存一致性的问题。但TCM方案有两个前提需要确认一是TCM容量通常有限不适合放大的数据缓冲区二是DMA控制器是否能够访问TCM区域这取决于SoC内部的总线互连设计需要翻阅STM32MP2参考手册中的内存映射和总线矩阵部分。如果DMA无法访问TCM这个方案就直接失效。我的建议是TCM优先留给中断向量表、实时性要求极高的代码和关键数据DMA缓冲区还是放在普通RAM里配合手动Cache维护更灵活。TCM不是用来解决DMA一致性问题的首选方案它是备选方案。4. 从代码层面落地串口DMA收发模板与缓存行对齐4.1 串口DMA发送与接收的Cache维护代码模板下面给出一套我在实际工程中使用的串口DMA收发代码模板涵盖发送和接收两个方向可以直接改改地址和长度拿来用。发送方向#define TX_BUF_SIZE 128 __ALIGNED(32) static uint8_t tx_buffer[TX_BUF_SIZE]; void send_data_via_dma(uint8_t *data, uint32_t len) { memcpy(tx_buffer, data, len); /* CPU写完数据后启动DMA前必须把缓冲区对应的脏行回写到内存 */ SCB_CleanDCache_by_Addr(tx_buffer, sizeof(tx_buffer)); __DSB(); HAL_UART_Transmit_DMA(huart, tx_buffer, len); }接收方向#define RX_BUF_SIZE 128 __ALIGNED(32) static uint8_t rx_buffer[RX_BUF_SIZE]; void start_uart_rx_dma(void) { /* 启动接收前先CleanInvalidate清掉上次可能残留的脏行 */ SCB_CleanInvalidateDCache_by_Addr(rx_buffer, sizeof(rx_buffer)); __DSB(); HAL_UART_Receive_DMA(huart, rx_buffer, RX_BUF_SIZE); } void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance UART_INSTANCE) // 改为实际串口 { /* DMA已经写入内存CPU读之前必须invalidate确保读到新数据 */ SCB_InvalidateDCache_by_Addr(rx_buffer, sizeof(rx_buffer)); __DSB(); /* 到这里rx_buffer中的数据才是DMA真正写入的新数据 */ } }这段代码看起来简单但每一个操作的顺序都很关键尤其是接收方向如果漏掉启动前的CleanInvalidate在缓冲区复用的场景下很容易出现旧数据覆盖新数据的隐蔽Bug。4.2 按地址操作接口的边界效应对齐比想象中更重要我特意在缓冲区定义处加上了__ALIGNED(32)这不是随便写的。SCB_CleanDCache_by_Addr这类接口底层是循环操作缓存维护指令每条指令处理一个缓存行。在实际处理时起始地址会向下取整到缓存行边界长度也会向上取整到缓存行边界。也就是说如果你的缓冲区起始地址没有32字节对齐函数会额外处理前一个缓存行的部分数据如果缓冲区长度不是32的倍数会额外处理后一个缓存行的部分数据。额外处理一个缓存行最直接的后果是可能误伤相邻变量的缓存行。如果那个缓存行恰好是脏行Clean操作会把不属于这个缓冲区的数据写回内存倒也无害Invalidate操作则会直接丢弃相邻变量所在的缓存行如果该缓存行里还有未写回的脏数据数据就丢了。这比不处理还可怕。所以我的实际经验是DMA缓冲区起始地址必须32字节对齐缓冲区大小必须是32字节的整数倍。这两个条件可以消灭大部分由Cache维护引发的连带问题。动态分配缓冲区时也要注意对齐问题。直接用malloc分配无法保证32字节对齐建议使用memalign、aligned_alloc或者自己在内存池里维护一个对齐分配器。在RTOS环境下也可以用堆扩展接口或者预定义一块对齐的内存池。4.3 缓冲区大小、DMA描述符与链接脚本的配合除了Cache对齐DMA缓冲区的大小也会影响Cache维护的收益。如果缓冲区只有几十字节每次DMA传输后的Invalidate操作可能只需要处理一两个缓存行开销很小。如果缓冲区是几KB甚至更大每次操作几十上百个缓存行虽然不能省略但可以想办法优化。一个思路是让DMA传输长度尽可能和实际需要的数据长度匹配不要每个DMA通道都挂一个超大的缓冲区。另一个思路是对于高频小数据量的DMA传输把缓冲区设计成小的环形缓冲每次失效的地址范围更小Cache维护开销更低。还有一点需要注意在STM32的DMA设计中有些外设的HAL驱动比如ETH、SDMMC内部已经实现了Cache维护逻辑而UART、SPI、ADC这些外设的驱动通常不处理。所以在使用HAL库时我建议先查一下对应外设驱动的源码确认驱动里是否已经有SCB_CleanDCache或SCB_InvalidateDCache之类的调用。如果驱动已经做了就不要再手动重复操作如果没有做就需要自己补上。重复的Invalidate操作大概率不会出问题但会白白浪费性能。5. 双核共享内存场景M33与A35协同工作时的额外功课5.1 异构双核下的共享内存一致性STM32MP2最典型的应用是A35跑LinuxM33跑实时任务两者通过共享内存交换数据。在这种场景下一致性维护不再只是M33自己的事还涉及A35侧的Linux缓存管理。最稳妥、最简单的方案就是把M33和A35共享的内存区域在两侧都配置成Non-cacheable。M33侧用MPU配置A35侧在设备树中把对应的内存区域标记为不缓存。这样双方读写共享内存时都不使用缓存保证了一致性缺点是性能受限不适合高频大数据量的通信。如果追求性能可以考虑让双方都保持缓存然后遵循一个简单的规矩谁写数据谁负责Clean谁读数据谁负责Invalidate。这个规矩在单核DMA场景下适用在双核共享内存场景下同样成立只是两侧都要遵守。A35侧在Linux中可以通过DMA API或者直接操作Cache控制寄存器来维护M33侧就是前面提到的那几个CMSIS接口。5.2 一个简易RingBuffer的Cache维护思路双核共享内存很多时候用RingBuffer来实现这里我给一个典型的维护思路。假设M33是生产者往RingBuffer中写数据A35是消费者从RingBuffer中读数据。缓冲区结构大致是头部保存读写索引数据区保存实际数据。M33生产者的流程往数据区写入