描述符结构:DMA的“任务卡片”

其实很多工程师刚开始接触DMA的时候,满脑子想的都是寄存器配置。写几个寄存器,设个源地址、目的地址、传输长度,然后启动——对吧?

但实际做高性能系统时你会发现,靠寄存器驱动的DMA就是个玩具。一旦你需要传输几十上百个数据包,CPU就得傻乎乎地不停写寄存器、查状态、再写寄存器。

这时候,描述符就登场了。说白了,它就是一张“任务卡片”,告诉DMA引擎:你要从哪里搬数据,搬到哪里,搬多少,搬完之后怎么办。CPU只需把这些卡片排好队,DMA就会一张一张自己执行。

描述符的核心结构

不同厂商的描述符字段有些差异,但骨架基本长这样。我个人习惯把这几个字段称为“三地址一控制”,你记住它们就抓住了精髓。

字段作用我踩过的坑
源地址数据从哪来地址对齐不对,传输直接挂掉
目的地址数据放哪去目的缓冲区不够大,内存越界
传输长度这次搬多少字节超过硬件最大长度,DMA自动截断
控制标志中断、链指针、完成状态中断标志位设错,中断风暴别怪我

我举个例子,一个标准描述符的C语言结构体长这样:

struct dma_descriptor {
    uint32_t src_addr;      // 源地址
    uint32_t dst_addr;      // 目的地址
    uint32_t length;        // 传输字节数
    uint32_t ctrl;          // 控制字:中断使能、链指针、状态
    uint32_t next;          // 下一个描述符地址(链表用)
};
小技巧:我的习惯是每个描述符末尾留一个“私货”字段,给驱动层挂回调函数指针。后期调试时,这个字段能帮你快速定位是哪个描述符出了问题。

描述符链表:让DMA学会“自动接单”

单个描述符能干的事有限。但如果你把几十个描述符串起来,变成一条链表,DMA就能自动执行一个任务队列。

我最常用的有两种链表形态:

  • 单链表:描述符里只保存下一个描述符的地址。简单,但一旦中间断了,整个队列就废了。
  • 双链表:每个描述符保存前一个和后一个的地址。占用内存多,但我个人偏爱这种——重启队列时不用从头遍历。

我在项目中遇到过这样一个问题:某次做网络数据采集,单链表描述符被网卡意外覆盖了“next”指针,结果DMA卡死在一个无效地址上,丢了几秒的数据才被发现。从那以后,我只要条件允许,一定用双链表。

环缓冲区:循环利用的“水桶”

链表虽然灵活,但有个痛点:你需要操作系统动态分配内存,还得管理链表节点的生命周期。在高吞吐场景下,这会造成严重的不确定性延迟。

怎么解决?环缓冲区,也叫“循环描述符队列”。

说白了,就是预先分配一块连续内存,装固定数量的描述符,头尾相接形成一个环。DMA从当前描述符开始执行,执行完一个自动跳到下一个,到头了绕回开头。

它的优点很明显:

  • 内存固定:没有运行时分配,没有释放,没有碎片
  • 极致效率:CPU只需要更新头尾指针,不用管每个描述符的链接
  • 硬件友好:很多DMA控制器原生支持环模式,硬件自己维护当前指针
我强烈推荐环缓冲区。 说实话,只要你的系统里DMA通道数量不超过16个,用环缓冲区都比链表简单。我手头一个项目用了6个环形队列跑传感器数据采集,CPU占用率从原来的23%降到了4%。你说值不值?

描述符的初始化与配置

我不给你贴特别复杂的代码,咱们看一个实际环缓冲区的初始化片段:

#define RING_SIZE 64

struct dma_ring {
    struct dma_descriptor desc[RING_SIZE];
    uint32_t head;  // CPU填充位置
    uint32_t tail;  // DMA消费位置
};

void dma_ring_init(struct dma_ring *ring) {
    memset(ring->desc, 0, sizeof(ring->desc));
    for (int i = 0; i < RING_SIZE; i++) {
        ring->desc[i].ctrl |= DMA_NEXT;  // 硬件环模式标志
    }
    ring->head = 0;
    ring->tail = 0;
    // 告诉DMA控制器的环形基地址
    DMA_BASE_ADDR = (uint32_t)ring->desc;
    DMA_RING_SIZE = RING_SIZE;
    DMA_CTRL |= DMA_RING_ENABLE;
}

这段代码有几个关键点:

  1. 描述符数组必须保证缓存一致性。我在ARM平台上吃过亏——CPU写描述符,DMA读到了脏数据。解决方案是描述符内存区标记为非缓存,或者每次更新后手动clean cache。
  2. head和tail指针要用volatile修饰。我曾经少写了一个volatile,编译器优化后CPU根本看不到DMA更新后的tail值。
  3. 环大小选多少?我个人经验是:环大小 = 最大中断响应延迟内DMA能够完成的描述符数的2倍。太大会浪费内存,太小会导致DMA空等。

描述符映射方法选择

我整理了一份表格,帮你根据实际场景选对路:

场景推荐方法理由
少量传输(<16笔)寄存器直写简单,延迟低,省内存
批量连续传输环缓冲区性能最优,CPU介入最少
动态任务队列双链表灵活增删,适合随机触发
极低内存环境单链表内存开销最小,但可靠性一般

描述符与中断的配合

描述符跑得好不好,中断策略是关键。我以前简单粗暴:每个描述符完成后都触发一次中断。结果呢?系统大量时间花在中断上下文中,CPU看到的是“我TM在拼命响应DMA”。

后来我学聪明了:只在描述符控制字的特定位置开启中断。比如每完成16个描述符才中断一次,或者当环缓冲区空到一定程度再通知CPU补充。这就是所谓的中断合并。

我踩过的大坑:中断合并后,你的CPU必须有能力一次处理多个待回收的描述符。有一回我就这么干的,中断来得少了,但每次中断进来我一个个回收,居然比不开合并还慢!后来改成批量回收,一次性把一批描述符放回空闲池,CPU负载才降下来。

举个例子:

// 坏习惯:中断里一个个回收
void dma_isr_slow(void) {
    while (completed_count--) {
        recycle_one_desc(current_desc);
        current_desc = get_next(current_desc);
    }
}

// 好习惯:批量回收
void dma_isr_fast(void) {
    uint32_t batch_end = dma_get_tail();
    recycle_desc_range(ring->tail, batch_end);
    ring->tail = batch_end;
}

这两者差异有多大?我在一个720p视频传输的项目上实测,坏习惯CPU占用率27%,好习惯直接掉到8%。

整章知识结构一览

下面的图把一个DMA描述符系统的核心脉络画了出来。你看一次就明白了:描述符是中介,链表和环是队列,中断是把控节奏的手刹。

DMA描述符知识体系 DMA描述符知识体系 DMA描述符(任务卡片) 源/目的地址 + 长度 控制标志 + 中断使能 链指针 / 环索引 链表模式(单/双链表) 环缓冲区(固定预分配) 中断策略:中断合并 · 批量回收 最终效果:CPU负载大幅降低 关键:缓存一致性 · volatile 环大小 = 2×中断延迟

你仔细看这张图,从左往右、从上到下,就是一条清晰的优化路径:描述符定义好卡片 → 选择排队方式(链表或环) → 配合中断策略控制节奏 → 最终换来CPU的解放。我当年要是早点搞明白这个链条,也不至于熬夜调DMA调到凌晨四点。