描述符到底是什么?

说白了,描述符就是一块数据结构。它告诉DMA控制器:去哪个地址拿数据,拿多少字节,完了往哪放。我刚开始学DMA时,总觉得这东西多此一举,直接给DMA一个地址一个长度不就行了?后来踩了坑才明白,没有描述符,你根本玩不转复杂的数据搬运。

你想想看,一个网卡每秒可能收几千个包。如果每次收包都要CPU去配置DMA寄存器,那CPU还干不干别的了?描述符就把这些配置信息提前放在内存里,DMA自己会去读。我在项目中见过一个新手工程师,非要用寄存器模式做高速数据采集,结果CPU占用率直接飙到80%——换描述符环以后,占用率降到了5%以下。

描述符长什么样?

描述符的具体格式由芯片厂商定义,但核心字段大同小异。我列个最精简的结构:

字段含义典型位宽
物理地址数据缓冲区的物理地址32/64 bit
数据长度本次传输的字节数16/32 bit
控制标志中断使能、链结束、方向等16 bit
下一个描述符指针指向下一个描述符的物理地址32/64 bit
状态字段由DMA写入,表示传输完成/错误16/32 bit

你会注意到“下一个描述符指针”。对,描述符们不是散落的,它们通过指针串成一个环。这个环就是描述符环(Descriptor Ring)。

关键点:描述符环的最后一个节点,它的“下一个指针”指回第一个节点。这样DMA就能一圈一圈地转,永远有描述符可用。

为什么不能用普通DMA?

普通DMA,我指的是那种一次配置就传输一次的模式。你给它源地址、目的地址、长度,它搬完就停。这种模式的毛病很明显:

  • CPU必须干预每一次传输。搬完一次后,你要重新写寄存器才能启动下一次。高吞吐场景下CPU根本扛不住。
  • 无法流水线作业。DMA正在搬第一块数据时,你不能预置第二块数据的配置。中间有空闲等待。
  • 中断频率太高。每次传输完成都触发中断,CPU大部分时间在响应中断,而不是做有用计算。

我记得做某款工业相机驱动时,一个图像帧被拆成几十个DMA片段。如果用普通DMA,一帧数据要触发几十次中断,图像采集速度直接掉到30fps。改成描述符环后,一个中断就能收完整帧,帧率跳到120fps。

我的经验:如果你在设计中看到DMA中断频率超过CPU主频的1/1000,基本可以断定需要上描述符环了。

描述符 vs 普通DMA:一张表说清差异

对比项普通DMA(寄存器模式)描述符DMA
配置方式CPU写寄存器CPU写内存描述符,DMA自动读取
链式传输不支持通过指针链表/环连续传输
CPU参与度每次传输都需要CPU参与初始化后CPU基本不参与
中断频率每次传输都中断可配置为每N次或出错才中断
适用场景简单、低频、单次搬运高速、批量、流式数据
冲突风险低(一次性使用)高(需要缓存一致性保障)

看到了吧?描述符DMA本质上是把一部分控制权交给了内存。CPU和DMA通过共享内存里的描述符来协作。这也是缓存一致性问题冒头的地方——这个后面几章会细聊。

描述符环的工作流程

我用一个典型的发送场景说明:

  1. 准备:CPU在内存中申请一块连续区域,初始化N个描述符,填好地址、长度,把最后一个描述符的next指针指向第一个。
  2. 提交:CPU把描述符环的起始地址写入DMA控制器的链表基址寄存器,然后写一个“启动”位。
  3. 自动搬运:DMA从基址寄存器拿到地址,开始读第一个描述符→搬运数据→更新状态位→自动读下一个描述符→继续搬运。
  4. 完成通知:当一个描述符的状态被DMA写为“完成”时,如果它的控制标志里开了中断,DMA就触发一次中断。CPU在中断中处理已完成的数据,并回收或重新填充描述符。
  5. 循环:DMA读到环末尾的next指针时,自动回到开头,形成无限循环。

常见坑:我曾经在调试一个PCIe网卡驱动时,把描述符环大小设成2,结果DMA高速运转时把环转穿了——CPU还没来得及回收第一个描述符,DMA就已经写回第二个描述符的状态了。数据被覆盖,出现了诡异的丢包现象。后来我把环深设为64,加一个“环半满”中断阈值,问题才解决。

下面这张图把整个流程串起来,你保存它,后面讲缓存一致性时还会反复用到:

描述符环工作流程 CPU 初始化描述符 DMA控制器 读取描述符 写入环基址寄存器 内存中的描述符环 描述符0 addr,len,next→1 描述符1 addr,len,next→2 描述符2 addr,len,next→3 描述符3 addr,len,next→0 DMAC读描述符 数据缓冲区 (外设或内存) CPU初始化环 → DMA自动读取描述符 → 循环搬运 → 中断通知CPU回收

一个简单的描述符代码示例

以常见的网卡驱动为例,描述符结构体大概长这样:

struct dma_descriptor {
    __le32  src_addr;      /* 源物理地址 */
    __le32  dst_addr;      /* 目的物理地址 */
    __le16  length;        /* 传输长度 */
    __le16  flags;         /* 控制标志:OWN, INT, EOP */
    __le32  next_desc;     /* 下一个描述符物理地址 */
    __le32  status;        /* DMA写回的完成状态 */
} __attribute__((packed));

注意这句__attribute__((packed))——我见过有人忘了加,结果结构体内部填充了空洞,DMA读到的地址全错位了。硬件不认软件的对齐规则,它只认裸的字节流。

建议:描述符环尽量放在一个独立的、大小对齐到cache line的DMA内存区域中。我用dma_alloc_coherent()分配,这样既保证了物理连续性,又避免了缓存一致性的麻烦(至少软件层面不用操心)。

描述符带来的新问题:缓存一致性

你看,描述符是在内存里的。CPU写描述符时,数据可能停留在CPU的cache里,还没刷到主存。DMA通过总线直接访问主存,它看不到cache里的修改——于是它读到的描述符是过期的。

反过来也一样:DMA写回状态字段后,CPU的cache里可能还存着旧值。所以后面几章我们会深入聊如何保证CPU和DMA看到同一份描述符内容。这是嵌入式驱动里最容易翻车的点之一。

我踩过的一个坑:某次调试SPI DMA传输,总是收到乱序数据。查了两天才发现——CPU把描述符的length字段改了,但没刷cache;DMA读到的还是老的length值。加了dma_sync_single_for_device()之后,问题立刻消失。别问我怎么知道的,全是泪。