3、DMA描述符链:链式传输机制、描述符结构设计、环形缓冲区与链表的实现

DMA传输,说白了就是让数据自己跑,不占CPU。但单次DMA传输能做的事太有限了。你想想看,一个数据流可能持续几毫秒甚至几秒,难道每次传输完都要CPU去重新配置一次DMA?那还不如用中断呢。

所以就有了描述符链。它的核心思想是:把多次DMA传输的配置信息,提前排好队,让DMA自己按顺序执行。CPU只需要在开始时点个火,然后就可以去干别的事了。

描述符链的本质:一个由DMA硬件自动遍历的“任务列表”。每个描述符就是一条任务指令,告诉DMA从哪里读、写到哪里、传多少字节、传完通知谁。

3.1 链式传输机制:DMA如何“自动”干活

我记得第一次接触链式DMA是在一个视频采集项目上。摄像头每秒30帧,每帧数据量不小。如果用单次DMA,每帧都要CPU介入,帧率根本跑不满。后来换成描述符链,CPU只在初始化时配置一次,后面DMA自己循环跑,帧率直接拉满。

链式传输的工作流程大致是这样的:

  1. 初始化阶段:CPU在内存中创建一组描述符,每个描述符指向一块数据缓冲区。
  2. 启动传输:CPU将第一个描述符的地址写入DMA控制器的启动寄存器。
  3. 自动遍历:DMA完成当前描述符后,自动读取下一个描述符的地址(从当前描述符的Next指针字段获取),继续传输。
  4. 完成通知:每个描述符可以配置是否在传输完成后产生中断。通常只在最后一个描述符或出错时产生中断。
  5. 循环或终止:如果最后一个描述符的Next指针指向第一个描述符,就形成循环链;如果指向NULL,则传输停止。

我的习惯:在关键数据流(比如音频、视频)中,我通常只在链的尾部开一个中断。这样CPU被打扰的次数最少,吞吐量最高。但调试阶段我会在每个描述符都开中断,方便定位问题。

3.2 描述符结构设计:每个字段都有它的脾气

描述符的结构,不同芯片厂商的命名和布局略有差异,但核心字段大同小异。我以最常见的通用设计为例:

字段 位宽 说明
SrcAddr 32/64位 源地址。外设地址或内存地址。
DstAddr 32/64位 目的地址。内存地址或外设地址。
TransferSize 16/32位 本次传输的字节数。注意对齐要求。
NextDescPtr 32/64位 下一个描述符的指针。链式传输的关键。
Control 16/32位 控制位:中断使能、传输方向、数据宽度、突发长度等。
Status 16/32位 状态位:传输完成、错误标志、当前描述符是否被DMA占用等。

这里有个坑,我踩过不止一次:描述符本身必须放在DMA可访问的内存区域。有些DMA控制器只能访问特定的内存地址范围(比如片内SRAM),如果你把描述符放在DDR里,DMA根本读不到。嗯,这个在芯片选型阶段就要确认清楚。

我曾经在一个项目里把描述符放在外部SDRAM中,结果DMA死活不工作。查了两天,最后发现是DMA的地址总线根本连不到SDRAM控制器。后来把所有描述符挪到片内SRAM,问题解决。所以,描述符的存放位置,必须在DMA的地址可达范围内。

3.3 环形缓冲区与链表的实现:两种思路,各有千秋

描述符链的组织方式,主要有两种:环形缓冲区和链表。它们各有适用的场景。

3.3.1 环形缓冲区实现

环形缓冲区,说白了就是一个固定大小的数组,最后一个描述符的Next指针指向第一个。这样DMA就会一圈一圈地循环传输。

它的优点是:

  • 内存固定:描述符数组在初始化时一次性分配,运行时不需要动态分配内存。
  • 访问速度快:数组是连续内存,CPU和DMA访问时缓存友好。
  • 实现简单:只需要维护头尾指针,不需要处理链表插入删除。

缺点是:

  • 大小固定:如果数据流突然变大,描述符不够用,就得重新设计。
  • 灵活性差:所有描述符的缓冲区大小通常相同,不适合变长数据包。

我一般在周期性数据流(音频采样、视频帧、ADC连续采集)中使用环形缓冲区。比如一个音频项目,采样率固定,缓冲区大小固定,环形缓冲区完美匹配。

3.3.2 链表实现

链表实现就是每个描述符的Next指针指向下一个动态分配的描述符。最后一个描述符的Next指针可以指向NULL(单次链)或指向第一个(循环链)。

它的优点是:

  • 灵活:描述符可以动态添加和删除,适合变长数据包或不确定的数据流。
  • 内存利用率高:每个描述符的缓冲区大小可以不同,按需分配。

缺点是:

  • 内存碎片:频繁分配释放描述符,容易产生碎片。
  • 访问速度慢:描述符在内存中不连续,DMA预取效率低。
  • 实现复杂:需要处理链表插入、删除、异常恢复等逻辑。

链表适合网络数据包处理或不确定长度的数据采集。比如一个以太网项目,每个数据包大小不同,用链表可以灵活分配缓冲区。

我的建议:如果数据流是周期性的、大小固定的,优先用环形缓冲区。如果数据流是突发性的、大小不固定的,用链表。但不管哪种方式,描述符的更新一定要保证原子性,否则DMA读到一半的描述符,后果很严重。

3.4 核心逻辑关系图

下面这张图展示了描述符链的核心逻辑。你可以看到CPU、DMA控制器、描述符链和数据缓冲区之间的关系。

DMA描述符链核心逻辑图 DMA描述符链核心逻辑 CPU DMA控制器 描述符0 描述符1 描述符2 描述符N-1 缓冲区0 缓冲区1 缓冲区2 缓冲区N-1 启动/配置 读取描述符 环形回环(可选) 说明: 1. CPU初始化描述符链,将首地址写入DMA控制器。 2. DMA自动遍历描述符,每个描述符指向一个数据缓冲区。 3. 最后一个描述符的Next指针指向第一个(环形)或NULL(单次链)。

3.5 代码示例:环形缓冲区描述符链初始化

下面是一个简化的环形缓冲区描述符链初始化代码。我用的是类C伪代码,实际使用时需要根据具体芯片的寄存器定义调整。

/* 描述符结构体定义 */
typedef struct {
    uint32_t src_addr;      /* 源地址 */
    uint32_t dst_addr;      /* 目的地址 */
    uint16_t transfer_size; /* 传输大小 */
    uint16_t control;       /* 控制位 */
    uint32_t next_desc;     /* 下一个描述符指针 */
    uint32_t status;        /* 状态位 */
} dma_desc_t;

/* 描述符数组(环形缓冲区) */
#define NUM_DESCRIPTORS 4
dma_desc_t desc_ring[NUM_DESCRIPTORS] __attribute__((aligned(32)));

/* 数据缓冲区 */
uint8_t buffer[NUM_DESCRIPTORS][1024] __attribute__((aligned(32)));

void dma_ring_init(void)
{
    int i;
    
    for (i = 0; i < NUM_DESCRIPTORS; i++) {
        /* 配置每个描述符 */
        desc_ring[i].src_addr      = (uint32_t)&buffer[i][0];  /* 外设地址需根据实际情况修改 */
        desc_ring[i].dst_addr      = (uint32_t)&buffer[i][0];  /* 这里假设内存到内存传输 */
        desc_ring[i].transfer_size = 1024;
        desc_ring[i].control       = DMA_INT_EN;               /* 最后一个描述符才开中断 */
        desc_ring[i].status        = 0;
        
        /* 环形链接:最后一个指向第一个 */
        if (i == NUM_DESCRIPTORS - 1) {
            desc_ring[i].next_desc = (uint32_t)&desc_ring[0];
        } else {
            desc_ring[i].next_desc = (uint32_t)&desc_ring[i + 1];
        }
    }
    
    /* 将第一个描述符地址写入DMA控制器的启动寄存器 */
    DMA->START_ADDR = (uint32_t)&desc_ring[0];
    DMA->CONTROL    = DMA_CHAIN_EN | DMA_START;
}

注意:描述符数组的对齐很重要。很多DMA控制器要求描述符按32字节或64字节对齐。如果不对齐,DMA可能读错描述符字段。我习惯在定义时加上__attribute__((aligned(32))),省心。

3.6 避坑指南:描述符链的常见问题

  • 描述符被DMA占用时不要修改:DMA正在读取的描述符,CPU不要去改。否则DMA读到一半的数据,行为不可预测。我通常用状态位来标记描述符是否可用。
  • 中断处理要快:如果每个描述符都开中断,中断频率会很高。中断处理函数里只做必要的事(比如更新指针、清标志),不要做耗时操作。
  • 缓存一致性问题:如果CPU和DMA共享内存,注意缓存一致性。CPU修改描述符后,要flush缓存;DMA写入数据后,CPU要invalidate缓存。这个在ARM Cortex-A系列上尤其要注意。
  • 描述符链的异常恢复:如果DMA传输过程中出错(比如总线错误),描述符链可能卡住。我习惯在DMA错误中断里重置整个链,而不是尝试恢复当前描述符。

我曾经在一个项目中,DMA描述符链跑着跑着突然停了。查了半天,发现是CPU在中断里修改了当前正在被DMA读取的描述符。DMA读到一半的Next指针被改成了无效地址,直接挂掉。从那以后,我强制要求:CPU只修改状态位标记为“已完成”的描述符。

好了,描述符链的核心内容就这些。环形缓冲区适合固定周期数据流,链表适合变长数据包。选择哪种,取决于你的实际场景。但不管哪种,描述符的原子性更新和缓存一致性是绕不开的两个坎。跨过它们,DMA传输才能真正做到“让数据自己跑”。