
做STM32MP2的M33核开发第一课不是点灯而是和DCACHE、DMA这对冤家打交道。我第一次跑UART DMA接收回调里数据全是乱码查了两天最后发现是DMA往内存写的数据被CPU的D-Cache挡住了——不是数据没到是CPU读的是缓存里的旧值。这个坑几乎每人都会踩一次所以我决定把DCACHE coherency的完整处理思路写出来覆盖STM32MP2xx Cortex-M33从原理到代码的各个环节真实踩过坑的地方都会标出来希望能帮你省下那两天。这篇文章适合正在用STM32MP2的M33核做裸机或RTOS开发并且计划在UART、SPI、ADC或者自定义DMA传输中使用DMA的人。即便你只是用CubeMX生成了一套带DMA例程也需要理解背后的cache一致性逻辑否则一旦数据量大、传输模式改成循环问题会立刻暴露出来。1. 问题从哪来DMA、DCACHE和内存之间的三角关系1.1 CPU缓存为什么对DMA“不透明”Cortex-M33带D-Cache之后CPU访问内存并不一定会直接命中SRAM或DDR而是先查缓存。缓存以cache line为粒度管理可以简单理解成CPU身边的一小份内存副本。CPU写数据时如果命中缓存数据可能先改在缓存里这一行变“脏”了真正的内存后面才被更新。CPU读数据时如果命中缓存就直接读缓存里的副本根本不看内存。DMA控制器则完全绕过CPU缓存直接通过总线读写内存。这里就出了岔子DMA把数据从外设搬进内存时缓存里可能还留着旧副本DMA从内存往外设搬数据时内存里可能还留着陈旧的版本CPU刚写进缓存的那份新数据还没回写。两边各看各的结果就是数据错乱。用个生活类比缓存是办公桌内存是文件柜。DMA像快递员直接往文件柜里塞文件或者从文件柜取文件。你桌子上有一份旧文件副本快递员把文件柜里的文件换了你桌上的副本不会自动更新你桌上改了文件没归档快递员拿走的还是柜里的旧版本。你要么把桌上文件放回柜子clean要么扔掉桌上旧副本下次从柜子里重新拿invalidate。1.2 三种典型的数据错乱场景第一种DMA往内存写数据CPU后读。这是UART接收、ADC采样最常见的场景。外设把接收到的字节通过DMA搬到内存缓冲区传输完成后CPU再去读缓冲区。如果CPU缓存里之前已经有这一片内存的旧副本DMA完成中断来了CPU读到的还是旧数据。现象就是数据看起来“没更新”甚至全为0。第二种CPU往内存写数据DMA后读。这是DMA发送、SPI发送、或者内存到内存拷贝的场景。CPU把要发送的数据写进缓冲区然后启动DMADMA去内存里读出来的却是老数据。现象是发送内容错乱可能第一包对后面重复旧数据。第三种CPU和DMA同时操作链表描述符或状态字段。例如DMA的linked-list描述符在RAM中CPU初始化描述符后启动DMADMA读取描述符时可能会读到过期的缓存内容DMA更新描述符中的状态后CPU再去读也可能看不到更新。这种问题更隐蔽因为描述符只占几十字节而且往往不是按cache line对齐出错后表现为DMA随机卡死或状态异常。1.3 STM32MP2 M33的缓存结构特点STM32MP2是异构MPUA35核跑LinuxM33核做实时控制。M33核自带可配置的D-Cache大小不高但足够让DMA数据在缓存里“隐身”。它的D-Cache默认策略类似Write-Back即CPU写操作不会立刻穿透到主存这虽然在多数场景下能提升性能但在DMA场景里就显得麻烦。需要特别强调的是M33没有像Cortex-A核那样的硬件缓存一致机制DMA不会去查M33的缓存M33也不会主动监听DMA的写入。所以一致性必须靠软件显式维护没有捷径。好的一点是M33没有MMU只有MPU地址是物理地址所以做cache维护时不需要像A核那样考虑虚拟地址映射直接对缓冲区地址操作就行。这一点让M33比A核容易很多。2. 维护一致性的两条路线2.1 路线A每次DMA前后手动Clean/Invalidate第一个思路是让内存和缓存之间保持同步。做法是在启动DMA之前把缓冲区里可能存在的脏数据“clean”回内存在DMA传输完成后把缓存里对应的行“invalidate”掉让CPU下次直接从内存读。具体来说clean对应ARM指令DCCMVACinvalidate对应DCIMVACclean加invalidate对应DCCIMVAC。在CMSIS-Core里封装成了非常方便的函数SCB_CleanDCache_by_Addr((uint32_t *)buf, len); SCB_InvalidateDCache_by_Addr((uint32_t *)buf, len); SCB_CleanInvalidateDCache_by_Addr((uint32_t *)buf, len);这三个函数都是按地址操作的只影响buf所在的cache line不影响整个缓存。适用场景是缓冲区不是特别大DMA频率不是特别高CPU又希望频繁读写这块区域。比如UART串口DMA收发一包数据几百字节发送频率几十赫兹手动维护完全没问题性能损失可以接受。有一点需要注意by_Addr函数的地址和长度要按cache line对齐。常见的M33 D-Cache line size是32字节但不同实现可能有差异建议以参考手册为准。我习惯直接把缓冲区对齐到64字节尺寸也向上取整到64的倍数这样无论实现是32还是64都能对齐。2.2 路线B用MPU把缓冲区分成Non-cacheable另一个思路是干脆不让这块缓冲进缓存。通过MPU把特定内存区域配置成Non-cacheableCPU访问这块区域时直接读写主存不会在缓存里留副本也就不会出现一致性问题。DMA和CPU看到同一份内存数据代码里完全不需要clean和invalidate。在STM32MP2的M33上MPU可以配置多个region。只需要把DMA缓冲区所在的地址范围配置为一个Non-cacheable region这块内存就和DMA“直连”了。这对高频周期性的DMA传输特别友好比如ADC多通道循环采样每次采样完成中断里如果还要先invalidate不仅代码啰嗦还会因为缓存维护指令本身占用总线而影响采样时序。代价是这块区域的CPU访问性能会下降。如果缓冲区被CPU频繁读写比如做协议解析时逐字节处理Non-cacheable会让每次读都慢一些尤其是在DDR上的缓冲区性能差距会更明显。所以Non-cacheable适合那些本来就是DMA专享、CPU访问频率不高的缓冲区。2.3 两条路线怎么选一张表说清楚场景推荐方案原因UART串口DMA收发频率不高Clean/Invalidate灵活保留cache性能SPI DMA接收大块数据偶尔一次Clean/Invalidate结构简单代码清晰ADC多通道循环采样持续不间断Non-cacheable避免高频缓存维护内存到内存DMA数据量大Non-cacheable减少DMA启动延迟DMA描述符LLI放在RAM单独Non-cacheable段或clean/invalidate描述符状态必须实时可见CPU会反复读写缓冲区的协议栈Clean/Invalidate保留缓存加速效果选型时记住一条原则如果DMA传输是低频、大块、CPU需要频繁处理缓冲区的用Clean/Invalidate如果是高频、小粒度、循环模式或者对确定性要求高用Non-cacheable。完全没有必要在任何场景都强行用缓存维护有时候Non-cacheable反而让整个设计更干净。3. 在STM32MP2 M33上动手配置3.1 CubeMX里先开MPU和CacheSTM32MP2的开发通常会从STM32CubeMX生成工程生成后默认可能不会自动开启M33的D-Cache也不会自动配置MPU。因此第一步是检查两个地方系统初始化中是否调用了SCB_EnableDCache()以及MPU的region默认配置是什么样。如果使用STM32CubeMP2的HAL典型初始化顺序应该是void SystemClock_Config(void) { ... } void MPU_Config(void) { /* CubeMX生成的MPU配置 */ } int main(void) { HAL_Init(); SystemClock_Config(); MPU_Config(); SCB_EnableDCache(); /* ... */ }这里有个容易被忽略的顺序问题最好先配置MPU再使能D-Cache。如果先打开D-Cache而MPU还没有把DMA缓冲区配置成Non-cacheableCPU可能已经把缓冲区数据缓存了一部分后续再改MPU属性也不一定能清掉已经存在的脏行容易出诡异问题。如果代码已经在跑我建议把MPU_Config和SCB_EnableDCache的顺序单独检查一下。CubeMX里可以手动添加MPU配置在System Core - MPU里添加一个Region把DMA缓冲区的地址和大小填进去Memory Type选Normal MemoryCacheability选Non-cacheable。地址最好用宏定义管理不要硬编码零散数字。3.2 Non-cacheable区域的MPU配置细节如果不想完全依赖CubeMX图形界面也可以直接在代码里用HAL配置。以M33的MPU为例配置一个Non-cacheable区域的代码大致是这样#define DMA_BUF_ADDR 0x30000000UL /* 以实际SRAM地址为准 */ #define DMA_BUF_SIZE (4 * 1024) static void MPU_Config(void) { MPU_Region_InitTypeDef MPU_InitStruct {0}; HAL_MPU_Disable(); MPU_InitStruct.Enable MPU_REGION_ENABLE; MPU_InitStruct.BaseAddress DMA_BUF_ADDR; MPU_InitStruct.Size MPU_REGION_SIZE_4KB; MPU_InitStruct.SubRegionDisable 0; MPU_InitStruct.TypeExtField MPU_TEX_LEVEL1; MPU_InitStruct.AccessPermission MPU_REGION_FULL_ACCESS; MPU_InitStruct.DisableExec MPU_INSTRUCTION_ACCESS_DISABLE; MPU_InitStruct.IsShareable MPU_ACCESS_NOT_SHAREABLE; MPU_InitStruct.IsCacheable MPU_ACCESS_NOT_CACHEABLE; MPU_InitStruct.IsBufferable MPU_ACCESS_NOT_BUFFERABLE; HAL_MPU_ConfigRegion(MPU_InitStruct); HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT); }这里TypeExtField MPU_TEX_LEVEL1配合IsCacheable 0和IsBufferable 0实际上对应ARMv7-M属性里的Normal memory, Non-cacheable寄存器编码为TEX0b001、C0b0、B0b0。这个组合在不同HAL版本里的宏名称略有差异但含义是确定的这一片区域不缓存、不缓冲。Bufferable和Shareable这里也最好设成false避免DMA和外设访问产生额外的合并或乱序。需要特别提醒的是MPU region的大小必须覆盖整个DMA缓冲区而且Region Size本身有限制通常是2的整数次幂比如4KB、8KB、16KB。如果你定义了1个2KB缓冲区MPU最小region可能要取4KB那么这4KB内其他区域也都会被配置成Non-cacheable要注意别误伤其他数据。3.3 Clean/Invalidate的CMSIS函数正确用法如果选择手动维护而不是Non-cacheable代码要遵循固定套路。下面以DMA接收为例展示标准流程__attribute__((aligned(64))) static uint8_t rx_buf[256]; void Start_DMA_Receive(void) { /* CPU可能之前写入了脏数据先clean */ SCB_CleanDCache_by_Addr((uint32_t *)rx_buf, sizeof(rx_buf)); __DSB(); /* 启动DMA接收 */ HAL_UART_Receive_DMA(huart, rx_buf, sizeof(rx_buf)); } void DMA_Receive_Complete_Callback(void) { /* DMA写完了缓存里可能有旧数据先invalidate */ SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, sizeof(rx_buf)); __DSB(); /* 现在可以安全读取rx_buf */ ProcessData(rx_buf, sizeof(rx_buf)); }这个流程里有几个细节第一__DSB()不能省。Cache维护指令发出后可能还在总线队列里如果紧接着启动DMA或者读取数据理论上存在还没生效的窗口。加上数据同步屏障可以确保前面的cache操作真正完成再执行后面的操作。第二rx_buf定义时必须对齐到cache line的整数倍。我用aligned(64)是图省事兼容32和64字节line。如果你用全局数组编译器默认对齐可能只有4字节那就容易出现跨行问题导致clean/invalidate时误伤相邻变量。第三如果是DMA发送场景在启动DMA之前必须clean而且不能在clean之后又往缓冲区写数据。典型的错误是先clean然后拼接报文、填校验再启动DMA。看起来逻辑完整但如果拼接写入的数据留在缓存中没回写DMA读到的还是clean之前的老内容。正确做法是先填好缓冲区再clean再启动DMA。3.4 DMA描述符也要管不要以为DMA缓冲区处理完就万事大吉了。STM32MP2的DMA支持链表描述符描述符本身存放在内存里DMA控制器会从内存读取描述符也会把中断标志、传输状态等写回描述符。如果描述符区域是Cacheable的同样存在一致性问题。处理办法有两种。最稳妥的是在链接脚本里单独划出一段Non-cacheable内存专门放所有DMA描述符这样CPU和DMA对描述符的访问天然一致。如果项目里只有一个DMA通道描述符就几十字节用Non-cacheable区域一点都不浪费。第二种办法是沿用Clean/Invalidate套路CPU修改描述符后cleanDMA完成中断里invalidate后再读取状态。不过描述符的操作频率低、长度小一旦流程里少一次clean或invalidate问题又变成了随机偶发排查成本非常高。我个人的习惯是只要DMA使用链表模式就把描述符数组放到Non-cacheable区域哪怕没有cache一致性问题也省心很多。数据缓冲区则根据上一节的选择来。这样分层管理代码意图更清晰。4. 实战UART DMA接收、ADC多通道扫描循环采样4.1 UART DMA接收经典Case串口DMA接收是DMA一致性最容易暴露问题的场景因为串口中断频率低、数据量不定一旦加缓存问题就会出现。推荐使用“空闲中断 DMA”方式DMA负责把数据搬进缓冲区串口的空闲中断负责在总线空闲时通知CPU“一帧数据收完了”。假设缓冲区定义如下#define RX_BUF_SIZE 512 __attribute__((aligned(64))) static uint8_t uart_rx_buf[RX_BUF_SIZE];初始化时先配置好MPU把这一片内存设为Cacheable然后启动DMA接收SCB_CleanDCache_by_Addr((uint32_t *)uart_rx_buf, sizeof(uart_rx_buf)); __DSB(); HAL_UARTEx_ReceiveToIdle_DMA(huart, uart_rx_buf, RX_BUF_SIZE); __HAL_DMA_DISABLE_IT(hdma_uart_rx, DMA_IT_HT);这里关闭半传输中断是因为我只想等空闲中断减少处理次数。等到空闲中断触发时判断接收长度然后做invalidatevoid HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart huart) { SCB_InvalidateDCache_by_Addr((uint32_t *)uart_rx_buf, Size); __DSB(); ProcessUartFrame(uart_rx_buf, Size); SCB_CleanDCache_by_Addr((uint32_t *)uart_rx_buf, Size); __DSB(); HAL_UARTEx_ReceiveToIdle_DMA(huart, uart_rx_buf, RX_BUF_SIZE); } }注意这里我在处理完数据后又clean了一次再重新启动DMA。原因是处理函数可能会修改缓冲区内容下一次DMA写入前必须把这些修改写回内存否则DMA会在老数据基础上叠加造成越收越乱。这个二次clean非常容易被遗漏我踩过好几次。4.2 ADC多通道扫描循环采样高频场景ADC多通道扫描加循环DMA是网上问得最多的热词之一。原因是一旦DMA循环模式开启数据会不停地刷新缓冲区即使在中断回调里invalidate也很难保证时序不出问题并且invalidate调用本身会成为采样周期里的性能瓶颈。假设配置了4个ADC通道每个通道采样100次一共400个样本DMA循环写入缓冲区。这时候用Non-cacheable方案最合理。把缓冲区定义到Non-cacheable段__attribute__((section(.non_cacheable), aligned(64))) static uint32_t adc_samples[400];然后在MPU配置中把.non_cacheable段的地址范围设置为Non-cacheable。这样ADC触发DMA写入时CPU随时可以读取最新数据不需要任何缓存维护。循环模式下通常用DMA半传输中断和传输完成中断来切分前后半缓冲区void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { ProcessSamples(adc_samples[0], 200); } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { ProcessSamples(adc_samples[200], 200); }因为缓冲区不缓存回调里直接读取即可。这里要小心一个陷阱如果代码里对整片缓冲区使能了MPU的SubRegion需要确认SubRegion的划分不会把adc_samples之外的其他变量也弄成Non-cacheable。如果MPU region设得太大可能导致相邻的另一个高频变量失去缓存性能莫名下降。4.3 DMA中断和环形缓冲的配合串口DMA接收还有更进阶的玩法DMA循环模式 中断 环形缓冲。DMA始终在写缓冲区写满后自动回到开头CPU通过中断记录写入位置然后用环形缓冲逐字节或者逐帧读取。这种模式里一致性问题变成了持续性的不可能每次DMA完成都做整个缓冲区的invalidate。我的建议是循环DMA加环形缓冲的缓冲区一定要用Non-cacheable。否则在DMA还在写后半部分时你要invalidate前半部分等DMA又回来写前半部分时你可能还没读完后半部分缓存维护的时机根本控制不住。即使强行用Invalidate by Address也会因为DMA持续写入而存在窗口期数据理论上会乱。Non-cacheable并非完全没有风险。如果DMA循环写指针绕回时CPU恰好也在读同一区域可能出现半新半旧的数据。这个问题属于应用层的数据一致性和缓存无关。解决办法是记录访问序号或者用双缓冲交替保证每个样本在DMA写入期间不会被CPU读取。5. 避坑清单和排查技巧5.1 典型错误与速查表症状可能原因解决方向DMA接收后数据全是旧值未在接收完成后invalidate在DMA完成回调里invalidateDMA发送前几字节正确后面乱clean时机不对clean后又写数据先填完缓冲区再clean再启动DMA数据时好时坏偶发错位缓冲区未按cache line对齐使用aligned(64)定义缓存维护后相邻变量被破坏by_Addr长度覆盖了邻居按cache line对齐长度不越界ADC循环采样偶尔卡死DMA描述符或缓冲区数据不一致描述符与缓冲区放Non-cacheable区域中断里处理数据耗时很长用了invalidate整个cache改用by_Addr按地址操作代码有MPU配置但完全不生效先使能了Cache后配置MPU调整顺序MPU先于Cache配置Non-cacheable缓冲区和普通变量混在一起MPU region太大覆盖了其他变量拆分region或调整链接脚本5.2 通过故障现象反推原因如果遇到DMA数据异常不要急着改代码先判断方向。我的调试顺序是第一步关掉D-Cache看问题是否消失。如果关掉D-Cache后一切正常基本可以确定是缓存一致性问题如果关掉后还是乱说明是DMA配置、GPIO、外设时钟等问题。第二步确认Cache到底开没开。很多例程默认不开Cache所以第一步可能会误判。查看SCB-CCSIDR或者直接看启动汇编里的初始化确定D-Cache状态。第三步仔细检查缓冲区地址。用调试器看缓冲区实际地址低5位或低6位是否为0如果不足32/64字节对齐先对齐再说。对齐后往往问题就解决了一半。第四步检查MPU region。在调试器中查看MPU的RASR寄存器确认TEX、C、B位的实际值不要只看HAL宏。因为CubeMX生成代码和手写代码可能重复配置MPU后配置的会覆盖前面的。第五步如果还是不行试试缓冲区加volatile。理论上cache维护之后读到的应该是最新值但编译器如果做了全局优化可能把读操作提前或合并。volatile不能替代cache维护但可以排除编译器层面的误读。5.3 性能优化的一些心得最后聊点实际体验。手动Clean/Invalidate看起来代码简单实际在跑高频业务时会拖慢系统。一次By Address的cache操作如果缓冲区跨了很多行可能几十到几百个周期在中断里尤其明显。我遇到过ADC采样加上中断invalidate后采样率被压低了近三成最后改成Non-cacheable才恢复。如果确实要在缓存策略上极致优化可以考虑以下做法把“DMA写入缓冲区”和“CPU计算区”分开DMA先搬进一块Non-cacheable的小缓冲区CPU处理一小段后通过DMA内存拷贝再搬进Cacheable的大缓冲区做计算。不过这适合数据量大的场景小数据量没必要这么折腾。描述符的Non-cacheable我再次建议。描述符这种东西平时不出问题一出就是随机偶发按地址clean/invalidate如果不小心漏一次想复现都难。用Non-cacheable之后至少把一致性问题从描述符这个维度彻底拿掉调试起来会少很多焦虑。另外无论在哪个方案里__DSB()和__DMB()的位置都值得认真检查。缓存维护和DMA启动之间用__DSB()这是为了确保维护指令完成共享标志位和环形缓冲区的指针更新可以用__DMB()防止编译器或CPU乱序执行把状态标志提前暴露。用错级别的屏障虽然表面看不出问题但在高负载或缓存压力大的情况下隐患就会冒出来。最后再分享一个小技巧我习惯在初始化时打印出SCB_CTL的DCache位、MPU的region属性以及缓冲区地址的hex值。每个DMA外设启动前再打印一次当前地址和长度。这套调试日志看起来笨但每次排查一致性问题时都能快速判断到底是哪个环节没做对比瞎猜高效得多。