3. DMA与CPU的协作模型:中断通知与数据竞争避免
好,咱们接着聊DMA。上一节我们把DMA的基本原理讲清楚了,说白了就是让一个专门干搬运活的硬件帮你搬数据,CPU腾出手来做更重要的事。但这里有个关键问题:DMA搬完了,CPU怎么知道?
你想想看,DMA在后台默默工作,CPU在前台跑主程序。如果DMA搬完数据,CPU还傻等着,那跟没用DMA有啥区别?所以,必须有一种机制,让DMA干完活后通知CPU一声。
3.1 中断通知机制:DMA的“举手报告”
最常见的做法就是中断。DMA传输完成后,会向CPU发送一个中断信号。CPU收到这个信号,就知道“哦,数据搬完了,我可以去处理了”。
我个人习惯把DMA中断分为两类:
- 传输完成中断:整块数据搬完了,触发一次。最常用。
- 半传输中断:数据搬了一半时触发。适合做双缓冲(ping-pong buffer)的场景。
我在项目中遇到过这样一个坑:有个同事把DMA中断配置成了“每次传输一个字节都触发中断”。结果呢?中断频率比CPU自己搬数据还高,性能反而下降了。所以记住:DMA的优势在于批量传输,中断要少而精。
核心原则:DMA中断是“通知”机制,不是“同步”机制。CPU收到中断后,应该尽快处理,不要在中断服务函数里做耗时操作。
下面是一个典型的DMA中断配置代码,以STM32为例:
// DMA传输完成中断配置
void DMA_Init_With_Interrupt(void)
{
// 使能DMA时钟
RCC->AHBENR |= RCC_AHBENR_DMA1EN;
// 配置DMA通道
DMA1_Channel1->CPAR = (uint32_t)&USART1->DR; // 外设地址
DMA1_Channel1->CMAR = (uint32_t)rx_buffer; // 内存地址
DMA1_Channel1->CNDTR = BUFFER_SIZE; // 传输长度
// 配置传输方向、优先级等
DMA1_Channel1->CCR = DMA_CCR_MINC | // 内存地址递增
DMA_CCR_PSIZE_0 | // 外设数据宽度8位
DMA_CCR_MSIZE_0 | // 内存数据宽度8位
DMA_CCR_TCIE; // 使能传输完成中断
// 使能DMA中断(在NVIC中)
NVIC_EnableIRQ(DMA1_Channel1_IRQn);
// 使能DMA通道
DMA1_Channel1->CCR |= DMA_CCR_EN;
}
// 中断服务函数
void DMA1_Channel1_IRQHandler(void)
{
if(DMA1->ISR & DMA_ISR_TCIF1)
{
// 清除中断标志
DMA1->IFCR |= DMA_IFCR_CTCIF1;
// 通知CPU:数据已就绪
data_ready_flag = 1;
// 这里不要做复杂处理,尽快退出中断
}
}
小技巧:中断服务函数里尽量只设置标志位,不要直接处理数据。主循环里轮询这个标志位,再做实际的数据处理。这样可以避免中断嵌套带来的问题。
3.2 数据竞争:DMA与CPU的“抢方向盘”问题
中断通知解决了“什么时候数据准备好了”的问题,但还有一个更隐蔽的问题:数据竞争。
什么叫数据竞争?说白了就是DMA和CPU同时访问同一块内存,一个在写,一个在读,结果读到的数据是半新半旧的,乱套了。
我曾经在一个音频采集项目里吃过这个亏。DMA把ADC采样数据往缓冲区里写,CPU从同一个缓冲区里读出来做FFT分析。结果呢?CPU读到一半的时候,DMA把新数据覆盖进来了,FFT出来的结果全是噪声。查了两天才找到原因。
3.3 避免数据竞争的三种策略
嗯,这里我总结了三种常用的策略,你可以根据实际场景选择:
策略一:双缓冲(Ping-Pong Buffer)
这是最经典的做法。准备两个缓冲区,DMA往A里写的时候,CPU从B里读。等DMA写满了A,触发中断,然后交换角色:DMA往B里写,CPU从A里读。
// 双缓冲示例
#define BUFFER_SIZE 1024
uint8_t buffer_a[BUFFER_SIZE];
uint8_t buffer_b[BUFFER_SIZE];
uint8_t *active_buffer = buffer_a; // DMA当前写入的缓冲区
void DMA_IRQHandler(void)
{
// DMA传输完成
if(active_buffer == buffer_a)
{
// 切换:DMA写buffer_b,CPU读buffer_a
active_buffer = buffer_b;
DMA_SetMemoryAddress(buffer_b);
data_ready_buffer = buffer_a; // 通知CPU处理
}
else
{
active_buffer = buffer_a;
DMA_SetMemoryAddress(buffer_a);
data_ready_buffer = buffer_b;
}
}
优点:CPU和DMA永远不会同时访问同一块内存,彻底避免数据竞争。
缺点:内存占用翻倍。
策略二:环形缓冲区 + 原子操作
如果内存紧张,可以用环形缓冲区。但要注意:读写指针的更新必须是原子的。
我建议的做法是:DMA只负责写数据,更新写指针;CPU只负责读数据,更新读指针。两者各管各的,互不干扰。
// 环形缓冲区示例
#define RING_SIZE 4096
uint8_t ring_buffer[RING_SIZE];
volatile uint32_t write_index = 0; // DMA更新
volatile uint32_t read_index = 0; // CPU更新
// DMA中断中只更新写指针
void DMA_IRQHandler(void)
{
write_index = (write_index + transferred_bytes) % RING_SIZE;
}
// CPU读取数据
uint32_t get_available_data(void)
{
// 计算可读数据量
if(write_index >= read_index)
return write_index - read_index;
else
return (RING_SIZE - read_index) + write_index;
}
注意:volatile关键字不能少!编译器可能会优化掉对共享变量的访问,导致CPU读到的不是最新值。我见过有人因为这个bug查了一整天。
策略三:内存屏障(Memory Barrier)
在一些高性能场景下,CPU的乱序执行或者缓存一致性可能导致数据不同步。这时候需要插入内存屏障指令。
// ARM Cortex-M 内存屏障示例
void DMA_IRQHandler(void)
{
// DMA传输完成
// 数据同步屏障:确保DMA写入的数据对CPU可见
__DSB(); // 数据同步屏障
__DMB(); // 数据内存屏障
data_ready_flag = 1;
}
void CPU_ProcessData(void)
{
if(data_ready_flag)
{
// 读数据之前也要加屏障
__DMB();
// 处理数据...
}
}
什么时候需要内存屏障?一般来说,在单核MCU上,如果DMA和CPU共享内存且没有缓存一致性问题,可以不用。但在多核处理器或者有Cache的系统中,内存屏障是必须的。
3.4 协作模型总结
说了这么多,我画了一张图帮你理清思路:
这张图把整个协作流程串起来了:DMA往共享内存里写数据,写完了发中断通知CPU,CPU收到中断后从共享内存里读数据。关键在于共享内存的访问控制——不能让DMA和CPU同时操作同一块区域。
3.5 避坑指南
最后,分享几个我踩过的坑:
- 中断优先级:DMA中断优先级不要设得太高,否则可能阻塞其他关键中断。我一般设在中等级别。
- 中断标志清除顺序:先读数据,再清标志。反过来可能导致数据还没读完,下一次DMA传输又开始了。
- 缓冲区对齐:DMA缓冲区地址最好按4字节对齐,否则有些DMA控制器会报错或者性能下降。
- 缓存一致性:如果MCU有Cache,DMA传输完成后要记得Invalidate Cache,否则CPU读到的可能是旧数据。
我曾经犯过的错:在一个项目中,我忘了在DMA中断里清除中断标志,结果中断服务函数被反复触发,系统直接卡死。从那以后,我每次写DMA中断,第一件事就是清标志,第二件事才是处理数据。
好了,这一节的内容就到这里。DMA与CPU的协作,说白了就是分工明确、通知及时、互不干扰。掌握了这三点,你就能写出高效又稳定的DMA驱动了。
公众号:蓝海资料掘金营,微信deep3321