STM32MP2 M33核开发:D-Cache与DMA一致性实战指南
2026/8/30 9:20:35 网站建设 项目流程

做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把要发送的数据写进缓冲区,然后启动DMA,DMA去内存里读出来的却是老数据。现象是发送内容错乱,可能第一包对,后面重复旧数据。

第三种,CPU和DMA同时操作链表描述符或状态字段。例如DMA的linked-list描述符在RAM中,CPU初始化描述符后启动DMA,DMA读取描述符时可能会读到过期的缓存内容;DMA更新描述符中的状态后,CPU再去读,也可能看不到更新。这种问题更隐蔽,因为描述符只占几十字节,而且往往不是按cache line对齐,出错后表现为DMA随机卡死或状态异常。

1.3 STM32MP2 M33的缓存结构特点

STM32MP2是异构MPU,A35核跑Linux,M33核做实时控制。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指令DCCMVAC,invalidate对应DCIMVAC,clean加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-cacheable,CPU访问这块区域时直接读写主存,不会在缓存里留副本,也就不会出现一致性问题。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和Cache

STM32MP2的开发通常会从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-cacheable,CPU可能已经把缓冲区数据缓存了一部分,后续再改MPU属性也不一定能清掉已经存在的脏行,容易出诡异问题。如果代码已经在跑,我建议把MPU_Config和SCB_EnableDCache的顺序单独检查一下。

CubeMX里可以手动添加MPU配置,在System Core -> MPU里添加一个Region,把DMA缓冲区的地址和大小填进去,Memory Type选Normal Memory,Cacheability选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 = 0IsBufferable = 0,实际上对应ARMv7-M属性里的Normal memory, Non-cacheable,寄存器编码为TEX=0b001、C=0b0、B=0b0。这个组合在不同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修改描述符后clean,DMA完成中断里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);

这里关闭半传输中断是因为我只想等空闲中断,减少处理次数。等到空闲中断触发时,判断接收长度,然后做invalidate:

void 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完成回调里invalidate
DMA发送前几字节正确,后面乱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外设启动前再打印一次当前地址和长度。这套调试日志看起来笨,但每次排查一致性问题时,都能快速判断到底是哪个环节没做对,比瞎猜高效得多。

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

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

立即咨询