4. DMA描述符链:链式DMA的概念、描述符结构、链表构建、硬件自动加载机制

各位做嵌入式开发的同行,咱们今天聊一个硬核话题——DMA描述符链。说白了,就是让DMA自己学会“接活儿”,不用CPU一次次去喂指令。

我记得刚入行那会儿,做一款视频采集卡。数据量一大,CPU光忙着配置DMA寄存器就占用了30%的负载。后来用了描述符链,CPU负载直接降到5%以下。嗯,这就是链式DMA的魅力。

4.1 为什么需要链式DMA?

先看一个场景:你要从ADC采集1000个数据块,每个块送到内存的不同位置。用普通DMA怎么做?

  • 传输完一块 → 触发中断 → CPU重新配置下一块 → 启动DMA
  • 1000块就要进1000次中断
  • 中断响应+寄存器配置,每次至少几微秒

你想想看,这效率能高吗?

链式DMA的思路完全不同:提前把1000个传输任务做成一个链表,告诉DMA“链表头在哪”,然后DMA自己顺着链子一个个执行。CPU?去干更重要的事吧。

核心收益:

  • 中断次数从N次降到1次(甚至0次)
  • CPU负载大幅降低
  • 传输延迟更稳定,没有中断抖动

4.2 描述符结构——链表的“节点”长什么样?

每个描述符就是一个数据结构,告诉DMA“这次搬什么、搬多少、搬完去哪”。不同厂商的DMA控制器细节不同,但核心字段大同小异。

字段 说明 我常用的值/建议
源地址 数据从哪来 外设地址或内存地址
目的地址 数据搬到哪 内存缓冲区地址
传输长度 本次传输的字节数 注意对齐要求
控制标志 中断使能、传输方向、地址增减等 最后一块才开中断
下一个描述符指针 指向链表中的下一个节点 NULL表示链尾

以STM32的DMA描述符为例,实际代码中是这样定义的:

typedef struct {
    uint32_t SADDR;      // 源地址
    uint32_t DADDR;      // 目的地址
    uint32_t LLI;        // 下一个描述符指针 (Linked List Item)
    uint32_t Control;    // 控制寄存器值
} DMA_Descriptor_t;

这里有个坑——描述符必须放在内存中特定的对齐地址上。我曾经在一个项目里,因为描述符没按32字节对齐,DMA死活不认链。查了两天手册才发现,嗯,教训深刻。

避坑指南:描述符的内存对齐要求因芯片而异。STM32要求32字节对齐,有些DSP要求64字节对齐。建议用 __attribute__((aligned(32))) 强制对齐。

4.3 链表构建——手把手搭一条链

构建链表其实不复杂,核心就是三步:

  1. 分配描述符数组:按最大传输块数分配
  2. 填充每个描述符:设置地址、长度、控制标志
  3. 链接成链:把每个描述符的LLI字段指向下一个

看代码更直观:

#define NUM_DESCRIPTORS  10

DMA_Descriptor_t desc[NUM_DESCRIPTORS] __attribute__((aligned(32)));
uint8_t buffer[NUM_DESCRIPTORS][1024];

void build_dma_chain(void) {
    for (int i = 0; i < NUM_DESCRIPTORS; i++) {
        desc[i].SADDR   = (uint32_t)&ADC_DATA_REG;
        desc[i].DADDR   = (uint32_t)buffer[i];
        desc[i].Control = DMA_CCR_MSIZE_16BIT | DMA_CCR_PSIZE_16BIT;

        // 最后一块才产生中断
        if (i == NUM_DESCRIPTORS - 1) {
            desc[i].Control |= DMA_CCR_TCIE;  // 传输完成中断
            desc[i].LLI     = 0;              // 链尾
        } else {
            desc[i].LLI = (uint32_t)&desc[i + 1];
        }
    }
}

我个人习惯把描述符数组放在DMA专用的内存区(如SRAM2或紧耦合内存),这样DMA访问描述符时不会和CPU争总线。

4.4 硬件自动加载机制——DMA自己“读链”

这才是链式DMA的精髓。你只需要告诉DMA控制器:

  • 链表头地址(第一个描述符的地址)
  • 启动传输

剩下的,DMA自己搞定:

  1. 从链表头加载第一个描述符
  2. 按描述符配置执行传输
  3. 传输完成后,读取LLI字段
  4. 如果LLI != NULL,自动加载下一个描述符
  5. 重复步骤2-4,直到链尾

整个过程,CPU完全不用参与。这就是所谓的“硬件自动加载”。

小技巧:有些DMA支持“乒乓缓冲”模式——用两个描述符链交替工作。一个链在传输时,CPU可以修改另一个链的描述符。这样就能实现“无中断连续传输”。

4.5 链式DMA的典型应用场景

场景 描述 链式DMA的优势
高速数据采集 ADC连续采样,数据存入循环缓冲区 无需中断,采样率更高
显示驱动 从多个图层合成一帧图像 一次启动,自动搬运所有图层
网络数据包处理 将分散的数据包描述符链式传输 减少CPU拷贝,提升吞吐量
音频流处理 多通道音频数据交错传输 精确控制每个通道的传输时机

4.6 链式DMA的核心流程

下面这张图展示了链式DMA的完整工作流程,从CPU初始化到硬件自动加载:

链式DMA工作流程 CPU:构建描述符链表 CPU:设置链表头地址 → 启动DMA DMA:加载描述符 #1 → 执行传输 LLI != NULL? 是 加载下一个描述符 否 触发传输完成中断 关键点:整个传输过程中,CPU只在初始化和最后中断时参与 DMA硬件自动完成描述符加载、传输执行、链表遍历,实现“一次配置,批量传输”

4.7 实际项目中的避坑经验

做了这么多年嵌入式,链式DMA的坑我踩过不少。分享几个印象深刻的:

  • 描述符被Cache污染:CPU写描述符时只写了Cache,DMA从内存读到的却是旧数据。解决方案是使用非Cacheable内存区域,或者在修改描述符后手动Clean Cache。
  • 链尾处理不当:如果最后一个描述符的LLI指向了无效地址,DMA会跑飞。我习惯在链尾放一个“哨兵描述符”,LLI指向自身,控制标志设为只中断不传输。
  • 描述符更新时机:DMA正在执行的描述符不能修改。我曾经在调试时,DMA刚加载完描述符#3,我改了描述符#3的地址,结果传输数据全乱了。正确做法是只修改“尚未被加载”的描述符。

重要提醒:链式DMA虽然强大,但调试起来比普通DMA复杂得多。建议先用逻辑分析仪或DMA调试寄存器观察描述符加载过程,确认链表构建正确后再跑实际数据。

好了,链式DMA的核心内容就这些。说白了就是让DMA自己管理自己的任务队列,CPU只负责“搭链”和“收尾”。这个机制用好了,系统性能能上一个台阶。