4. DMA描述符链与环形缓冲区:从结构到实战
DMA传输这件事,说白了就是让数据自己跑,CPU别管。但数据怎么跑、跑完怎么通知你,这里面的门道就多了。我个人习惯把描述符结构设计放在第一位——结构没定好,后面全是坑。
4.1 描述符结构设计:骨架要硬
描述符是什么?它就是DMA的“任务单”。每个描述符告诉DMA控制器:从哪读、写到哪、传多少字节、传完干啥。我见过不少新手把描述符设计得过于简单,结果后面加功能时痛苦不堪。
一个典型的描述符结构长这样:
typedef struct {
uint32_t src_addr; // 源地址
uint32_t dst_addr; // 目标地址
uint32_t control; // 控制字:长度、传输类型、中断使能
uint32_t status; // 状态字:完成标志、错误标志
uint32_t next_desc; // 下一个描述符指针(链表用)
uint32_t reserved; // 保留,对齐用
} dma_desc_t;
这里我特别想强调control字段的设计。它通常包含:
- 传输长度:字节数,注意对齐要求
- 传输类型:内存到内存、外设到内存、内存到外设
- 中断使能:传完要不要触发中断
- 链式使能:是否继续处理下一个描述符
我的习惯:control字段里留一个“软件标记位”。调试时往里面写个魔数,配合逻辑分析仪看DMA行为,比猜寄存器快多了。
status字段也很关键。我建议至少包含:
- 完成位:硬件置1,软件清0
- 错误位:总线错误、对齐错误等
- 所有权位:当前归CPU管还是DMA管
注意:所有权位是缓存一致性问题的重灾区。CPU改了描述符但DMA看到的还是缓存里的旧数据——这个问题我后面会细讲。
4.2 链表实现:灵活但费心
链表方式就是把描述符串起来。每个描述符的next_desc指向下一个,最后一个指向NULL或者指向第一个(形成环)。
实现起来其实不复杂:
// 分配三个描述符
dma_desc_t desc_pool[3];
// 初始化链表
desc_pool[0].next_desc = &desc_pool[1];
desc_pool[1].next_desc = &desc_pool[2];
desc_pool[2].next_desc = NULL; // 链表结束
// 配置DMA基地址指向第一个
DMA->BASE_ADDR = &desc_pool[0];
链表的优点很明显:
- 动态增减:随时可以插入或删除描述符
- 非连续内存:描述符可以分散在内存各处
- 灵活排序:改变next_desc就能重排传输顺序
但缺点也让人头疼:
- 遍历开销:每次都要顺着指针找下一个
- 缓存不友好:描述符可能分散,CPU预取失效
- 调试困难:指针指飞了,查起来想哭
我曾经踩过的坑:在一个音频采集项目里用了链表,结果DMA跑到一半,某个描述符的next_desc被意外改写了。DMA直接飞到非法地址,系统挂死。查了两天才发现是缓存一致性问题——CPU改了描述符但没刷回内存。
4.3 环形缓冲区实现:简单粗暴
环形缓冲区,说白了就是一个固定大小的数组,加上头尾指针。描述符在内存里连续存放,最后一个指向第一个。
#define RING_SIZE 8
dma_desc_t ring[RING_SIZE];
// 初始化环形
for (int i = 0; i < RING_SIZE; i++) {
ring[i].next_desc = &ring[(i + 1) % RING_SIZE];
}
// 头指针和尾指针
uint32_t head = 0; // DMA当前处理的位置
uint32_t tail = 0; // CPU新加任务的位置
环形缓冲区的优势:
- 内存连续:CPU预取效率高,缓存命中率好
- 索引访问:不用遍历,直接算偏移
- 实现简单:头尾指针一追,逻辑清晰
- 确定性:最坏情况下的处理时间可预测
缺点也有:
- 大小固定:满了就得等,不能动态扩展
- 覆盖风险:CPU追尾了DMA,数据就乱了
我的建议:环形缓冲区的大小选2的幂次。这样取模可以用位运算,快很多。比如 size=16,索引 = index & 0x0F。
4.4 性能对比:数据说话
我在一个视频采集项目里做过实测。系统跑在400MHz,DMA传输4K字节块,连续传1000次。结果如下:
| 指标 | 链表方式 | 环形缓冲区 | 差异 |
|---|---|---|---|
| 描述符分配时间 | 动态分配,不确定 | 静态分配,固定 | 环形快10~50倍 |
| DMA启动延迟 | 需遍历链表 | 直接索引 | 环形快3~5倍 |
| 缓存缺失率 | 高(描述符分散) | 低(连续内存) | 环形低60% |
| 最大吞吐量 | 约280MB/s | 约410MB/s | 环形高46% |
| 调试难度 | 高(指针追踪) | 低(数组索引) | 环形更友好 |
为什么会这样?说白了就是内存局部性。环形缓冲区的描述符在内存里挨着,CPU的L1缓存一次预取能拉进来好几个。链表就不行了,描述符东一个西一个,缓存命中率上不去。
关键结论:如果你的系统对延迟和吞吐量有硬性要求,优先选环形缓冲区。如果描述符数量动态变化很大,或者内存碎片严重,再考虑链表。
4.5 核心逻辑:一张图说清楚
下面这张图展示了两种结构的核心差异。我建议你仔细看看,尤其是内存布局和指针流动这两块。
4.6 避坑指南:我替你试过了
最后分享几个实战中容易踩的坑:
- 描述符对齐:很多DMA控制器要求描述符按8字节或16字节对齐。不对齐的话,轻则性能下降,重则直接挂死。我习惯用
__attribute__((aligned(16)))强制对齐。 - 缓存刷新时机:CPU修改描述符后,一定要刷缓存。DMA读取描述符前,也要无效化缓存。这个顺序搞反了,数据就乱了。
- 环形缓冲区满判断:头指针追上尾指针就是满。但注意要留一个空位做“满”和“空”的区分,否则你分不清是满还是空。
- 中断风暴:每个描述符都开中断,高吞吐时CPU会被淹死。我建议每N个描述符开一次中断,或者用定时器轮询。
我曾经犯过的错:在一个网络加速项目里,我用了环形缓冲区但没做满保护。结果CPU写入速度比DMA消费速度快,头指针追上了尾指针,新数据覆盖了还没传输的旧数据。抓包发现报文全是乱码,查了三天才定位到问题。
嗯,以上就是DMA描述符链和环形缓冲区的核心内容。结构设计是基础,链表和环形各有优劣,关键看你的场景。我个人更偏爱环形缓冲区——简单、高效、好调试。但如果你需要动态管理大量描述符,链表也有它的用武之地。