描述符结构: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控制器原生支持环模式,硬件自己维护当前指针
描述符的初始化与配置
我不给你贴特别复杂的代码,咱们看一个实际环缓冲区的初始化片段:
#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;
}
这段代码有几个关键点:
- 描述符数组必须保证缓存一致性。我在ARM平台上吃过亏——CPU写描述符,DMA读到了脏数据。解决方案是描述符内存区标记为
非缓存,或者每次更新后手动clean cache。 - head和tail指针要用volatile修饰。我曾经少写了一个volatile,编译器优化后CPU根本看不到DMA更新后的tail值。
- 环大小选多少?我个人经验是:环大小 = 最大中断响应延迟内DMA能够完成的描述符数的2倍。太大会浪费内存,太小会导致DMA空等。
描述符映射方法选择
我整理了一份表格,帮你根据实际场景选对路:
| 场景 | 推荐方法 | 理由 |
|---|---|---|
| 少量传输(<16笔) | 寄存器直写 | 简单,延迟低,省内存 |
| 批量连续传输 | 环缓冲区 | 性能最优,CPU介入最少 |
| 动态任务队列 | 双链表 | 灵活增删,适合随机触发 |
| 极低内存环境 | 单链表 | 内存开销最小,但可靠性一般 |
描述符与中断的配合
描述符跑得好不好,中断策略是关键。我以前简单粗暴:每个描述符完成后都触发一次中断。结果呢?系统大量时间花在中断上下文中,CPU看到的是“我TM在拼命响应DMA”。
后来我学聪明了:只在描述符控制字的特定位置开启中断。比如每完成16个描述符才中断一次,或者当环缓冲区空到一定程度再通知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描述符系统的核心脉络画了出来。你看一次就明白了:描述符是中介,链表和环是队列,中断是把控节奏的手刹。
你仔细看这张图,从左往右、从上到下,就是一条清晰的优化路径:描述符定义好卡片 → 选择排队方式(链表或环) → 配合中断策略控制节奏 → 最终换来CPU的解放。我当年要是早点搞明白这个链条,也不至于熬夜调DMA调到凌晨四点。