2. DMA传输模式:单次、块、链式

聊DMA传输,第一步得搞清楚它怎么把数据搬来搬去。不同的搬法,对应不同的硬件开销、实时性和灵活性。我根据自己调通过的几个项目,把最常见的三种模式拆出来讲讲:单次传输、块传输、链式传输。

说白了,它们就像快递的三种配送方式:单次就是一单送一件、送完就收工;块传输是把一堆货打包成一个大包一次性送;链式传输呢,更像快递员手里拿着一张任务清单,送完一单自动跳到下一单。你会用哪种?取决于你的场景。

2.1 单次传输(Single Transfer)

这是最基础的DMA模式。CPU配好源地址、目的地址、传输长度,然后DMA控制器把数据从A搬到B,搬完就停,发个中断告诉CPU“活干完了”。

核心特点: 一次配置,一次传输,中断或轮询等待完成。

我在早期的项目中用过它,比如从ADC读取单次采样结果。那时候系统负载低,来一次数据搬一次,简单粗暴。但你想想看,如果每秒要搬几千次,CPU就得频繁处理中断,效率反而打折扣。

// 伪代码:单次传输配置
DMA_Channel->SourceAddr = &ADC_DataReg;
DMA_Channel->DestAddr   = &Buffer[0];
DMA_Channel->Length     = 1;          // 只传输1个字
DMA_Channel->Mode       = SINGLE;
DMA_Start(DMA_Channel);
// 等待传输完成中断
while(!DMA_IsComplete());
参数 说明 坑
源/目的地址 必须对齐(多数DMA要求字对齐) 我曾经用未对齐地址导致DMA死机,查了半天
传输长度 通常为1~65535个字/字节 小心长度0,有些控制器会误触发中断
中断 传输完成或错误时触发 不要在中断里做重配置,容易丢数据
适用场景: 低频单次搬运、初始化阶段、调试时。比如UART接收一个字节后就处理,用单次模式最清爽。

2.2 块传输(Block Transfer)

块传输,顾名思义,是一次搬运一大块连续数据。比如从内存搬运一帧图像到LCD显存,或者把一批传感器数据搬进缓存。

它的优势很明显:一次配置,DMA自己连续地按字/字节搬完整个块,CPU全程不参与。搬完后才发中断。效率比单次高一个数量级,因为减少了中断次数和配置开销。

我记得在给一个高速数据采集系统做优化时,原来代码每来一个数据就触发一次中断,CPU占用率飙到70%。换成块传输后,攒够512个点再触发一次中断,CPU占用直接降到3%。

// 伪代码:块传输配置 (以STM32为例)
DMA_Channel->SourceAddr = (uint32_t)&ADC_Buffer;
DMA_Channel->DestAddr   = (uint32_t)&SRAM_Buffer;
DMA_Channel->Length     = 512;        // 一次搬512个半字
DMA_Channel->Mode       = BLOCK;      // 块模式
DMA_Channel->BurstSize  = 4;          // 突发传输,提升总线效率
DMA_Start(DMA_Channel);
// DMA自动搬完,然后触发完成中断
对比项 单次传输 块传输
CPU参与度 每次传输后中断 整块完成后中断一次
适合数据量 1~几十个字 几十~几千个字
中断频率 高,易造成抖动 低,系统响应更平滑
总线占用 分散、短 集中、长(可能影响其他主设备)
注意: 块传输时,DMA会长时间霸占总线。如果系统里还有高优先级的中断(比如USB SOF),必须合理设置DMA优先级或使能循环仲裁,否则可能丢中断。我曾经因为没注意这点,导致USB包被DMA延迟,直接枚举失败。

2.3 链式传输(Linked List / Scatter-Gather)

这才是真正的“自动挡”。链式传输允许你提前准备一个描述符链表,每个描述符包含一次传输的参数:源地址、目的地址、长度、下一个描述符指针。DMA会按链表顺序自动执行所有传输,直到遇到链表尾。

为什么需要它?举个例子:你要从三个不连续的物理缓冲区(比如A、B、C)搬数据到连续的内存区域。如果用块传输,你必须先手动把A、B、C拷贝到一起再搬,效率很低。链式传输可以逐段描述,DMA自己跳转地址,把三次搬整合成一条“流水线”。

// 伪代码:链式传输描述符(以TI的EDMA3为例)
struct DMA_Descriptor desc1, desc2, desc3;

desc1.src = &Buffer_A;
desc1.dst = &Dest_Start;
desc1.len = 128;
desc1.next = &desc2;    // 指向下一个描述符

desc2.src = &Buffer_B;
desc2.dst = &Dest_Start + 128;
desc2.len = 64;
desc2.next = &desc3;

desc3.src = &Buffer_C;
desc3.dst = &Dest_Start + 192;
desc3.len = 256;
desc3.next = NULL;       // 链表结束

DMA_SubmitChain(&desc1);  // 从第一个描述符开始
DMA_Start();              // DMA自动遍历链表

链式传输的另一个好处是零CPU干预:CPU只需要一开始提交链表,之后可以做其他任务。链表还可以动态更新(比如加一个描述符),实现“热插拔”式的传输任务。

性价比: 链式传输额外需要消耗内存来存放描述符(每个描述符通常16字节左右)。但换来的是极强的灵活性和极低的CPU负担。

我个人的经验是:在需要频繁更换DMA传输参数(比如音频播放、网络数据包收发)的场景下,链式传输几乎是标配。比如一个I2S音频流,你可能要连续播放多个不同的音频段,每段起始地址、长度都不一样,用链式传输配置好链表后,DMA自己就能无缝切换,连中断都省了。

避坑指南: 链表的描述符必须存放在非Cache区域(或者DMA能直接访问的内存),否则DMA读取描述符时可能拿到脏数据。我因为图省事把描述符放到了可Cache区域,结果DMA随机读到旧描述符,程序崩溃了一天。

2.4 三种模式对比 & 知识结构图

下面这张SVG图帮你理清这三种模式的本质差别:

DMA传输三种模式核心逻辑 单次传输 • 配置一次,搬一次 • 每次都要中断 • 适合:低频、单数据 • 开销:中断频繁 块传输 • 配置一次,搬整块 • 搬完才中断 • 适合:批量数据 • 开销:总线独占 链式传输 • 链表描述符驱动 • 自动遍历任务列表 • 适合:动态、多段 • 开销:内存+链表 从左到右:CPU干预程度递减,灵活性递增 三种模式适用场景速查 单次 ADC单通道采集 UART单字节收发 GPIO事件触发 块 图像帧搬移 批量传感器数据 内存拷贝 链式 音频播放链表 网络数据包散收 多缓冲环形队列

看完这张图,你应该能快速判断:如果你的数据是一锤子买卖,用单次;如果是连续的大块数据,用块传输;如果数据来源零散、需要批量处理且不想频繁唤醒CPU,果断上链式。

最后提醒一句: 链式传输虽然强大,但调试起来很麻烦。描述符的地址、下一个指针、长度字段,任何一个写错都可能导致DMA死循环或访问非法地址。建议先在仿真器上单步验证链表,再上真机。