3. 描述符链表(Descriptor Chain)原理

描述符链表是 DMA 传输的核心数据结构,它解决了传统单次传输模式下“CPU 必须等待 DMA 完成才能发起下一次传输”的瓶颈。通过将多个传输描述符组织成链表,DMA 控制器可以自动从一个描述符跳转到下一个,实现连续、批量、无需 CPU 干预的数据搬运。

3.1 链表结构

描述符链表在物理内存中是一个单向链表,每个节点(描述符)包含数据缓冲区地址、传输控制信息和指向下一个节点的指针。DMA 控制器从链表头开始,依次处理每个描述符,直到遇到链表尾标记。

核心特征:描述符链表是硬件直接遍历的数据结构,CPU 只需设置链表头地址并启动 DMA,后续所有描述符的跳转由 DMA 控制器硬件自动完成,无需软件干预。
描述符链表结构 描述符 0 buf_addr: 0x1000 buf_len: 1024 ctrl: DMA_INC next: → 0x2000 描述符 1 buf_addr: 0x2000 buf_len: 2048 ctrl: DMA_INC next: → 0x3000 描述符 2 buf_addr: 0x3000 buf_len: 512 ctrl: DMA_INC next: NULL (尾) 说明: 每个描述符占用固定大小内存(通常 16~32 字节),DMA 控制器通过 next 指针自动跳转。

3.2 节点定义

描述符节点的定义因 DMA 控制器硬件而异,但核心字段具有高度共性。以下是一个典型的 DMA 描述符结构体定义(以 ARM PL080 为例):

/* 典型 DMA 描述符结构体 */
struct dma_descriptor {
    uint32_t src_addr;      /* 源地址 */
    uint32_t dst_addr;      /* 目的地址 */
    uint32_t transfer_size; /* 传输字节数 */
    uint32_t ctrl;          /* 控制字:传输类型、宽度、突发大小等 */
    uint32_t llp;           /* 链表指针(next descriptor 地址) */
    uint32_t reserved;      /* 保留/状态 */
} __attribute__((packed, aligned(16)));
描述符节点关键字段说明
字段 位宽 说明
src_addr 32-bit 源数据缓冲区物理地址,必须按传输宽度对齐
dst_addr 32-bit 目的数据缓冲区物理地址,对齐要求同源地址
transfer_size 32-bit 本次传输的字节数,通常最大为 4095 或 65535
ctrl 32-bit 控制字:传输宽度(8/16/32-bit)、突发大小、源/目的地址递增模式、中断使能等
llp 32-bit 链表指针,指向下一个描述符的物理地址;0 表示链表结束
对齐要求:描述符节点必须按 16 字节(或 32 字节,取决于硬件)对齐到物理地址边界。未对齐会导致 DMA 控制器解析描述符时产生总线错误或数据错位。在 Linux 内核中,使用 dma_pool_alloc() 或 dma_alloc_coherent() 分配描述符内存可自动满足对齐要求。

3.3 链式传输机制

链式传输机制是描述符链表发挥效能的核心执行流程。DMA 控制器通过硬件状态机自动遍历链表,完成以下步骤:

  1. 加载链表头:CPU 将链表第一个描述符的物理地址写入 DMA 控制器的链表基址寄存器,然后启动 DMA 通道。
  2. 解析描述符:DMA 控制器通过内部总线读取当前描述符的 6 个字段(或更多),加载到内部寄存器。
  3. 执行传输:根据 src_addr、dst_addr、transfer_size 和 ctrl 字段,启动一次 DMA 传输。传输完成后,检查 ctrl 中的中断标志位,若置位则向 CPU 发送中断。
  4. 链表跳转:读取 llp 字段的值。如果 llp 非零,则将其作为下一个描述符的地址,跳转到步骤 2;如果 llp 为零,则进入空闲或完成状态。
  5. 完成通知:最后一个描述符传输完成后,DMA 控制器触发完成中断(如果使能),通知 CPU 整个链表传输结束。
链式传输机制流程图 ① CPU 设置链表头地址 ② 加载当前描述符 ③ 执行 DMA 传输 ④ 读取 llp 指针 llp ≠ 0 跳转到 下一描述符 llp = 0 ⑤ 传输完成 硬件状态机自动执行 无需 CPU 逐条干预

3.4 链表与循环缓冲的对比

描述符链表与循环缓冲(Ring Buffer)是 DMA 驱动中两种常见的数据组织方式,它们在结构、适用场景和性能特征上有显著差异:

描述符链表 vs 循环缓冲
对比维度 描述符链表 循环缓冲
数据结构 单向链表,节点分散在内存中 固定大小数组,物理连续
节点数量 动态可扩展,无上限 固定,初始化时确定
硬件遍历 通过 llp 指针跳转 通过地址回绕(wrap)
内存连续性 不要求连续 必须物理连续
适用场景 离散缓冲区、SG-DMA、大数据量 流式数据、音频、网络
CPU 维护成本 低(仅需更新链表头) 中(需维护头尾指针)
常见陷阱:描述符链表中的 llp 指针必须指向描述符的物理地址,而非虚拟地址。在启用 MMU 的 Linux 内核中,使用 dma_map_single() 或 dma_pool_alloc() 获取物理地址。直接使用 kmalloc() 返回的虚拟地址会导致 DMA 控制器访问错误内存。

3.5 链表初始化与提交示例

以下代码展示了如何在 Linux 内核驱动中初始化一个包含 3 个描述符的链表,并提交给 DMA 引擎:

/* 描述符链表初始化与提交示例 */
struct dma_chan *chan;
struct dma_async_tx_descriptor *tx;
struct scatterlist sg[3];
struct dma_slave_config config;

/* 1. 准备 3 个分散缓冲区 */
sg_init_table(sg, 3);
sg_dma_address(&sg[0]) = dma_map_single(dev, buf0, len0, DMA_TO_DEVICE);
sg_dma_len(&sg[0]) = len0;
sg_dma_address(&sg[1]) = dma_map_single(dev, buf1, len1, DMA_TO_DEVICE);
sg_dma_len(&sg[1]) = len1;
sg_dma_address(&sg[2]) = dma_map_single(dev, buf2, len2, DMA_TO_DEVICE);
sg_dma_len(&sg[2]) = len2;

/* 2. 配置 DMA 从设备参数 */
memset(&config, 0, sizeof(config));
config.direction = DMA_MEM_TO_DEV;
config.dst_addr = mmio_base + FIFO_DATA;
config.dst_addr_width = DMA_SLAVE_BUSWIDTH_4_BYTES;
config.dst_maxburst = 16;
dmaengine_slave_config(chan, &config);

/* 3. 创建链表传输描述符 */
tx = dmaengine_prep_slave_sg(chan, sg, 3, DMA_MEM_TO_DEV,
                              DMA_PREP_INTERRUPT | DMA_CTRL_ACK);
if (!tx) {
    pr_err("Failed to prepare DMA descriptor chain\n");
    return -ENOMEM;
}

/* 4. 注册完成回调 */
tx->callback = dma_done_callback;
tx->callback_param = my_data;

/* 5. 提交并启动 DMA */
dmaengine_submit(tx);
dma_async_issue_pending(chan);
性能调优要点:描述符链表的性能瓶颈通常出现在 dmaengine_prep_slave_sg() 调用中,该函数会遍历所有 scatterlist 条目并构建硬件描述符。对于高频提交场景(如网络驱动),建议使用描述符池(descriptor pool)预分配,避免每次提交时动态分配内存。