3. 描述符链表(Descriptor Chain)原理
描述符链表是 DMA 传输的核心数据结构,它解决了传统单次传输模式下“CPU 必须等待 DMA 完成才能发起下一次传输”的瓶颈。通过将多个传输描述符组织成链表,DMA 控制器可以自动从一个描述符跳转到下一个,实现连续、批量、无需 CPU 干预的数据搬运。
3.1 链表结构
描述符链表在物理内存中是一个单向链表,每个节点(描述符)包含数据缓冲区地址、传输控制信息和指向下一个节点的指针。DMA 控制器从链表头开始,依次处理每个描述符,直到遇到链表尾标记。
核心特征:描述符链表是硬件直接遍历的数据结构,CPU 只需设置链表头地址并启动 DMA,后续所有描述符的跳转由 DMA 控制器硬件自动完成,无需软件干预。
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 控制器通过硬件状态机自动遍历链表,完成以下步骤:
- 加载链表头:CPU 将链表第一个描述符的物理地址写入 DMA 控制器的链表基址寄存器,然后启动 DMA 通道。
- 解析描述符:DMA 控制器通过内部总线读取当前描述符的 6 个字段(或更多),加载到内部寄存器。
- 执行传输:根据 src_addr、dst_addr、transfer_size 和 ctrl 字段,启动一次 DMA 传输。传输完成后,检查 ctrl 中的中断标志位,若置位则向 CPU 发送中断。
- 链表跳转:读取 llp 字段的值。如果 llp 非零,则将其作为下一个描述符的地址,跳转到步骤 2;如果 llp 为零,则进入空闲或完成状态。
- 完成通知:最后一个描述符传输完成后,DMA 控制器触发完成中断(如果使能),通知 CPU 整个链表传输结束。
3.4 链表与循环缓冲的对比
描述符链表与循环缓冲(Ring Buffer)是 DMA 驱动中两种常见的数据组织方式,它们在结构、适用场景和性能特征上有显著差异:
| 对比维度 | 描述符链表 | 循环缓冲 |
|---|---|---|
| 数据结构 | 单向链表,节点分散在内存中 | 固定大小数组,物理连续 |
| 节点数量 | 动态可扩展,无上限 | 固定,初始化时确定 |
| 硬件遍历 | 通过 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)预分配,避免每次提交时动态分配内存。