描述符链表组织:单向链表与双向链表,循环链表在 SG-DMA 中的应用

描述符链表怎么组织,这事我得好好聊聊。

你想想看,SG-DMA 的核心就是靠一串描述符来搬运数据。这些描述符怎么串起来,直接决定了你系统的灵活性和可靠性。我个人习惯把链表分成三种来理解:单向、双向和循环。别看它们只是指针方向不同,用起来差别可大了。

单向链表:最简单,也最脆弱

单向链表的结构很直白。每个描述符里只有一个 Next Pointer,指向下一个描述符。DMA 引擎从头走到尾,一条路走到黑。

典型结构:

struct sg_desc {
    uint32_t src_addr;
    uint32_t dst_addr;
    uint32_t length;
    uint32_t cfg;
    uint32_t next_ptr;  // 指向下一个描述符
};

它的优点很明显:每个描述符省掉一个指针域,内存占用小。我在一个低成本的 IoT 项目里用过,当时内存只有 8KB,能省一点是一点。

但单向链表的缺点也很要命。我曾经遇到过一个情况,某个描述符的 next_ptr 因为总线干扰被写坏了,DMA 直接跳到一个非法地址,整个系统挂了。单向链表一旦断裂,后面的描述符全部丢失,连排查都很困难。

单向链表的致命伤: 没有反向路径。一旦一个节点损坏或丢失,后续所有传输都无法恢复。

双向链表:可进可退,代价翻倍

双向链表在每个描述符里加了 Prev Pointer。DMA 既能往前走,也能往后走。这听起来有点多余,但实际中有大用处。

我个人的建议: 如果你的系统对可靠性有要求,或者需要动态更新描述符,优先考虑双向链表。

struct sg_desc_double {
    uint32_t src_addr;
    uint32_t dst_addr;
    uint32_t length;
    uint32_t cfg;
    uint32_t next_ptr;
    uint32_t prev_ptr;  // 多了这个
};

为什么说它强?我举个真实例子。在一个视频采集项目中,我们需要实时更新描述符链表,把新的内存缓冲区加进来。用双向链表,DMA 完成一个描述符后,我可以从当前节点往前找到上一个节点,安全地把新节点插在中间。整个过程不影响正在进行的传输。

代价呢?每个描述符多 4 字节(32 位系统)甚至 8 字节(64 位系统)。如果你有 1000 个描述符,多占 4KB 到 8KB 内存。值不值?看你系统的可靠性需求。

循环链表:无头无尾,永不停止

循环链表其实是单向链表的变种,但最后一个描述符的 Next Pointer 指向第一个描述符。这样链表就形成了一个环。

循环链表的核心机制:

  • DMA 完成最后一个描述符后,自动跳回第一个描述符
  • 适用于连续不间断的数据流,比如音频、视频采集
  • 配合 Wrap 或 Interrupt on Completion 标志位使用

我记得做一个音频环路测试时,用循环链表配合乒乓缓冲,DMA 在两个缓冲区之间来回切换,CPU 完全不用干预。那感觉,舒服。

用循环链表要小心:

  • 必须保证链表里的描述符是有效的,否则会无限循环在无效描述符上
  • 每次传输完成后,记得更新描述符的数据缓冲区地址
  • 我曾经在更新地址时忘了同步,DMA 把旧数据又传了一遍,找 bug 找了大半天

三种链表的对比

我把它们的核心差异整理成了一张表,方便你对比选择。

特性 单向链表 双向链表 循环链表
内存占用 低(每节点 1 指针) 高(每节点 2 指针) 低(每节点 1 指针)
插入/删除效率 低(需遍历前驱) 高(直接获取前驱) 低(需遍历前驱)
错误恢复能力 弱(断裂即丢失) 强(可反向追踪) 弱(需额外保护)
应用场景 简单、一次性批量传输 动态更新、高可靠性系统 连续数据流、乒乓缓冲

如何选择?给你几个场景

  1. 场景一:启动时一次性传输一批数据,完了就结束。 选单向链表。省内存,配置简单。
  2. 场景二:系统需要动态添加或移除缓冲区。 选双向链表。灵活,安全。我之前做一个网络数据包处理模块,就是双向链表,运行时可以动态添加 DMA 描述符。
  3. 场景三:音频、视频等连续数据流。 选循环链表。配上两个或三个描述符,形成环形缓冲,DMA 自己跑就行了。

来看一张结构对比图,直观感受一下三种链表的差异。

描述符链表三种组织方式结构对比 单向链表 Desc 1 Desc 2 Desc 3 End 双向链表 Desc 1 Desc 2 Desc 3 End 循环链表 Desc 1 Desc 2 Desc 3

关于链表初始化的一个技巧:

无论你选哪种链表,建议在初始化时就把所有描述符的指针域填好,不用的节点指向一个固定的无效地址(比如 0xFFFFFFFF)。这样万一 DMA 跑飞了,也能快速定位问题。我常用的做法是:先用 memset 把所有描述符清零,再逐一正确配置。

单向、双向、循环,三种链表各有取舍。没有的好坏,只有合不合适的场景。你想想看,你的系统是需要省内存,还是需要高可靠性,或者是需要连续数据流?想清楚了,选起来就不难。