4. 缓存一致性基础

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 核心逻辑:一张图说清楚

下面这张图展示了两种结构的核心差异。我建议你仔细看看,尤其是内存布局和指针流动这两块。

DMA描述符结构对比:链表 vs 环形缓冲区 链表方式 描述符 0 next→1 描述符 1 next→2 描述符 2 next→NULL • 描述符可分散在内存任意位置 • 遍历需跟随指针,缓存不友好 • 动态增减灵活,但调试困难 环形缓冲区 描述符 0 next→1 描述符 1 next→2 描述符 2 next→3 描述符 3 next→0 • 描述符连续存放,缓存友好 • 头尾指针管理,索引访问 • 大小固定,需防覆盖 核心差异总结 内存布局:链表分散 vs 环形连续 访问方式:链表指针遍历 vs 环形索引计算 缓存效率:链表低(预取失效) vs 环形高(空间局部性) 适用场景:链表适合动态变化 vs 环形适合固定高吞吐

4.6 避坑指南:我替你试过了

最后分享几个实战中容易踩的坑:

  • 描述符对齐:很多DMA控制器要求描述符按8字节或16字节对齐。不对齐的话,轻则性能下降,重则直接挂死。我习惯用__attribute__((aligned(16)))强制对齐。
  • 缓存刷新时机:CPU修改描述符后,一定要刷缓存。DMA读取描述符前,也要无效化缓存。这个顺序搞反了,数据就乱了。
  • 环形缓冲区满判断:头指针追上尾指针就是满。但注意要留一个空位做“满”和“空”的区分,否则你分不清是满还是空。
  • 中断风暴:每个描述符都开中断,高吞吐时CPU会被淹死。我建议每N个描述符开一次中断,或者用定时器轮询。
我曾经犯过的错:在一个网络加速项目里,我用了环形缓冲区但没做满保护。结果CPU写入速度比DMA消费速度快,头指针追上了尾指针,新数据覆盖了还没传输的旧数据。抓包发现报文全是乱码,查了三天才定位到问题。

嗯,以上就是DMA描述符链和环形缓冲区的核心内容。结构设计是基础,链表和环形各有优劣,关键看你的场景。我个人更偏爱环形缓冲区——简单、高效、好调试。但如果你需要动态管理大量描述符,链表也有它的用武之地。