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控制器 数据搬运 中断触发 共享内存 双缓冲 / 环形缓冲区 原子操作 / 内存屏障 读取数据 写入数据 中断通知 图:DMA与CPU通过共享内存协作,中断作为同步信号

这张图把整个协作流程串起来了:DMA往共享内存里写数据,写完了发中断通知CPU,CPU收到中断后从共享内存里读数据。关键在于共享内存的访问控制——不能让DMA和CPU同时操作同一块区域。

3.5 避坑指南

最后,分享几个我踩过的坑:

  • 中断优先级:DMA中断优先级不要设得太高,否则可能阻塞其他关键中断。我一般设在中等级别。
  • 中断标志清除顺序:先读数据,再清标志。反过来可能导致数据还没读完,下一次DMA传输又开始了。
  • 缓冲区对齐:DMA缓冲区地址最好按4字节对齐,否则有些DMA控制器会报错或者性能下降。
  • 缓存一致性:如果MCU有Cache,DMA传输完成后要记得Invalidate Cache,否则CPU读到的可能是旧数据。

我曾经犯过的错:在一个项目中,我忘了在DMA中断里清除中断标志,结果中断服务函数被反复触发,系统直接卡死。从那以后,我每次写DMA中断,第一件事就是清标志,第二件事才是处理数据。

好了,这一节的内容就到这里。DMA与CPU的协作,说白了就是分工明确、通知及时、互不干扰。掌握了这三点,你就能写出高效又稳定的DMA驱动了。


公众号:蓝海资料掘金营,微信deep3321