描述符到底是什么?
说白了,描述符就是一块数据结构。它告诉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通过共享内存里的描述符来协作。这也是缓存一致性问题冒头的地方——这个后面几章会细聊。
描述符环的工作流程
我用一个典型的发送场景说明:
- 准备:CPU在内存中申请一块连续区域,初始化N个描述符,填好地址、长度,把最后一个描述符的next指针指向第一个。
- 提交:CPU把描述符环的起始地址写入DMA控制器的链表基址寄存器,然后写一个“启动”位。
- 自动搬运:DMA从基址寄存器拿到地址,开始读第一个描述符→搬运数据→更新状态位→自动读下一个描述符→继续搬运。
- 完成通知:当一个描述符的状态被DMA写为“完成”时,如果它的控制标志里开了中断,DMA就触发一次中断。CPU在中断中处理已完成的数据,并回收或重新填充描述符。
- 循环:DMA读到环末尾的next指针时,自动回到开头,形成无限循环。
常见坑:我曾经在调试一个PCIe网卡驱动时,把描述符环大小设成2,结果DMA高速运转时把环转穿了——CPU还没来得及回收第一个描述符,DMA就已经写回第二个描述符的状态了。数据被覆盖,出现了诡异的丢包现象。后来我把环深设为64,加一个“环半满”中断阈值,问题才解决。
下面这张图把整个流程串起来,你保存它,后面讲缓存一致性时还会反复用到:
一个简单的描述符代码示例
以常见的网卡驱动为例,描述符结构体大概长这样:
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()之后,问题立刻消失。别问我怎么知道的,全是泪。