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,多一次内存访问 |
| 动态增减 | 较难(需要整体搬迁) | 容易(插入/删除节点) |
| 适用场景 | 固定队列、高性能场景 | 需要灵活管理、跨内存池 |
下面这张图展示了链式描述符的核心流程,你可以直观感受一下:
4.3 描述符更新与回写
硬件处理完一个描述符,不会自动通知软件。它只是把状态字段改掉,比如把“完成位”置 1。软件通过轮询这个位,或者依赖中断,来判断是否完成。这个过程就叫回写。
更新描述符分为两步:
- 软件发送前:把控制字段和地址字段写进去,状态字段先清零。然后把所有权位交给硬件(常见做法是写一个特定的寄存器来触发)。
- 硬件完成后:硬件把状态字段写回去,同时把所有权位交还给软件。软件在轮询时读到所有权位是自己的,就知道这个描述符处理完了。
我建议你在状态字段里不要只放一个完成位。多塞几个位,比如传输字节数、错误码、甚至时间戳。调试的时候,这些信息能救命。我在调试一个多队列网卡驱动时,就是靠状态字段里的错误码,定位到了 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 上的实现差别挺大,我在多核平台调同步时吃过不少亏。
我个人的经验是:描述符更新,宁慢勿乱。少的那个内存屏障,可能在单核上跑千百次都没事,一到多核并发,随机崩给你看。
好,关于描述符链表的三个核心内容就讲到这里。结构、原理、更新回写,环环相扣。你只要把这三个点吃透,多队列和中断优化的基础就算打牢了。