4. DMA 描述符链表:核心数据结构

聊到 DMA 多队列,绕不开的一个东西就是描述符链表。说实话,我刚入行那会儿,觉得描述符就是个控制块,跟寄存器差不太多。后来被坑了几次才明白——描述符的设计,直接决定了你的 DMA 能跑多快、稳不稳。

这节课,我就从描述符结构、链式传输原理、更新与回写几个方面,带你捋清楚。

4.1 描述符结构:三个关键字段

每个描述符,就像一个“快递单”。发件地址、收件地址、货物状态,都写在上面。硬件 DMA 控制器只管按单干活,干完了在单子上画个勾。

典型的描述符结构,至少包含三个核心字段:

字段 作用 位宽(常见)
控制字段 配置传输方向、数据大小、中断使能等 32 bit
地址字段 存放源地址、目的地址,或者存放下一个描述符的指针 32/64 bit
状态字段 硬件回写传输结果,比如完成标志、错误码 32 bit

我个人习惯在控制字段里留一个“所有权位”。用来告诉硬件:这个描述符是归软件管,还是归硬件管。这是个经典的同步机制,后面会细说。

举个例子,网卡驱动的 TX 描述符:

struct tx_desc {
    uint32_t ctrl;      // 控制:长度、最后一次、中断使能
    uint32_t addr_lo;   // 数据缓冲区的低 32 位地址
    uint32_t addr_hi;   // 高 32 位地址,如果支持 64 位
    uint32_t status;    // 状态:硬件回写的完成标志
};

你有没有发现,地址字段只存了数据缓冲区的地址,没存下一个描述符的地址?这就是环状队列的典型做法——描述符在内存里是连续排列的,硬件靠索引递进。说白了,描述符就是数组里的一条记录。

4.2 链式传输原理:比环状更灵活

环状队列虽然简单,但有一个硬伤:描述符必须在物理内存里连续。如果你要做动态增减、或者跨内存池分配描述符,连续内存就不够灵活了。

链式传输就是为了解决这个问题。

每个描述符里增加一个 next_ptr 字段,指向下一个描述符的物理地址。硬件读完当前描述符,自动跳到下一个。这样描述符本身就不需要在内存里连续了,链表可以跨页、跨内存池。

我曾经在一个项目中,为了省内存,把描述符链表跨了两个不连续的物理页。结果忘了保证页与页之间的地址连续性——嗯,硬件跳转时直接抓瞎。链式描述符的优点是灵活,代价是每次取下一个描述符要多一次内存访问。频率高了,缓存的压力会变大。

链式列表和环状队列的区别,我用一张表对比一下:

对比项 环状队列 链式链表
内存分布 必须连续 可以不连续
访问下一个描述符 索引递增,几乎零开销 取 next_ptr,多一次内存访问
动态增减 较难(需要整体搬迁) 容易(插入/删除节点)
适用场景 固定队列、高性能场景 需要灵活管理、跨内存池

下面这张图展示了链式描述符的核心流程,你可以直观感受一下:

DMA 描述符链表:链式传输原理 描述符 0 ctrl: 0x0001 buf_addr: 0xA000 next_ptr: 0x8000 status: (待回写) 描述符 1 ctrl: 0x0002 buf_addr: 0xB000 next_ptr: 0x9000 status: (待回写) 描述符 2 (尾) ctrl: 0x0003 buf_addr: 0xC000 next_ptr: NULL status: (待回写) 核心逻辑 硬件 DMA 控制器从描述符 0 开始,读取 ctrl、buf_addr 后执行传输。 完成后读取 next_ptr,跳转到下一个描述符,直到 next_ptr == NULL 停止。

4.3 描述符更新与回写

硬件处理完一个描述符,不会自动通知软件。它只是把状态字段改掉,比如把“完成位”置 1。软件通过轮询这个位,或者依赖中断,来判断是否完成。这个过程就叫回写。

更新描述符分为两步:

  1. 软件发送前:把控制字段和地址字段写进去,状态字段先清零。然后把所有权位交给硬件(常见做法是写一个特定的寄存器来触发)。
  2. 硬件完成后:硬件把状态字段写回去,同时把所有权位交还给软件。软件在轮询时读到所有权位是自己的,就知道这个描述符处理完了。

我建议你在状态字段里不要只放一个完成位。多塞几个位,比如传输字节数、错误码、甚至时间戳。调试的时候,这些信息能救命。我在调试一个多队列网卡驱动时,就是靠状态字段里的错误码,定位到了 DMA 地址对齐问题。

回写的另一个关键是缓存一致性。你是用 dma_sync_single_for_cpu 还是 dma_sync_single_for_device,顺序不能搞反。我曾经在某个 ARM 平台上,因为忘了在读取回写状态前做一次缓存无效化,结果 CPU 读到的永远是旧值,调试了一天一夜。

注意:描述符本身的内存,必须保证对硬件和软件同时可见。不要用 CPU Cacheable 内存来存描述符,除非你手动管理缓存一致性。最简单的做法是分配在非 Cacheable 区域,或者使用 DMA 一致性内存 API。

还有一个小细节:描述符的地址必须对齐。通常要求 32 字节对齐,具体取决于芯片。不齐的话,硬件可能直接报错,或者默默读错数据。你想想看,我当年第一次调 DMA,描述符地址是奇数页偏移,结果数据老是不对,查了三天才发现是对齐问题。

总结一下更新与回写的流程:

步骤 动作 谁操作
1 软件填写控制字段和地址字段 软件
2 软件清空状态字段,设置所有权位为硬件 软件
3 软件通知硬件开始(写门铃寄存器) 软件
4 硬件读取描述符,执行传输 硬件
5 硬件回写状态字段,设置所有权位为软件 硬件
6 软件轮询或中断,读取状态 软件

这里要特别提一句,如果你在 SMP 系统上跑 DMA,描述符的字段更新顺序也很讲究。软件在写所有权位之前,必须保证前面所有字段(控制、地址)都已经写完了。可以用 wmb()(写内存屏障)来保证顺序。硬件端,读取描述符时也应该做读屏障。嗯,这个在 ARM 和 x86 上的实现差别挺大,我在多核平台调同步时吃过不少亏。

我个人的经验是:描述符更新,宁慢勿乱。少的那个内存屏障,可能在单核上跑千百次都没事,一到多核并发,随机崩给你看。

好,关于描述符链表的三个核心内容就讲到这里。结构、原理、更新回写,环环相扣。你只要把这三个点吃透,多队列和中断优化的基础就算打牢了。