描述符链设计与实现
好,咱们今天聊聊描述符链。这东西在PCIe DMA里,可以说是灵魂般的存在。我刚开始接触DMA引擎时,觉得不就是搬数据嘛,搞那么复杂干嘛?后来被坑了几次才明白——描述符设计得好不好,直接决定了你的DMA引擎能跑多快、多稳。
描述符格式定义
先说说描述符长什么样。说白了,描述符就是一张"任务单",告诉DMA引擎:从哪搬、搬到哪、搬多少、怎么搬。
我个人习惯把描述符分成四个核心字段:
| 字段 | 位宽 | 说明 |
|---|---|---|
| 源地址 | 64位 | 数据来源的起始地址,可以是系统内存或PCIe BAR空间 |
| 目的地址 | 64位 | 数据要写到的目标地址 |
| 传输长度 | 32位 | 本次传输的字节数,通常以DWORD或Cacheline对齐 |
| 控制位 | 32位 | 包含中断使能、链指针有效位、方向标志等 |
嗯,这里要注意——地址位宽为什么是64位?因为现在的系统动不动就上TB内存,32位地址根本不够用。我在一个项目中就吃过这个亏,当时偷懒用了32位地址,结果客户的内存映射到了4GB以上,直接翻车。
控制位这块,我建议至少包含以下几个标志:
- 中断使能位:描述符完成后是否触发中断
- 链指针有效位:指示下一个描述符是否存在
- 方向标志:读内存写PCIe,还是读PCIe写内存
- 完成状态:硬件回写时标记成功/失败/重试
核心要点:描述符格式一旦定下来,后期改起来非常痛苦。我建议在设计阶段就把所有可能用到的字段都预留好,哪怕暂时用不上。多花两天时间定义格式,能省下后面两个月的返工时间。
链式描述符与环形描述符对比
这两种结构,说白了就是"链表"和"数组"的区别。你想想看,链表可以无限延伸,数组是固定大小的环。
链式描述符:每个描述符里有个Next Pointer,指向下一个描述符。最后一个描述符的Next Pointer为0,表示链的终点。
// 链式描述符结构示例
typedef struct {
uint64_t src_addr;
uint64_t dst_addr;
uint32_t length;
uint32_t ctrl;
uint64_t next_ptr; // 指向下一个描述符
} dma_desc_t;
链式的优点很明显——灵活。你可以随时在链尾追加新的描述符,不需要提前分配一大块连续内存。我在做视频采集卡驱动时就用过这种结构,因为视频帧的到达时间不确定,链式结构可以动态扩展,非常方便。
但链式也有坑。硬件每次处理完一个描述符,都要去读下一个描述符的地址。如果描述符分散在不同的内存页里,TLB miss会频繁发生,性能直接打折扣。
环形描述符:所有描述符在内存中连续排列,形成一个环。硬件维护一个Head指针(当前处理到哪了),软件维护一个Tail指针(新描述符写到哪了)。
// 环形描述符结构示例
typedef struct {
uint64_t src_addr;
uint64_t dst_addr;
uint32_t length;
uint32_t ctrl;
} dma_desc_ring_t; // 没有next_ptr,靠地址连续
环形的优势在于预取效率高。因为描述符在内存里是连续的,硬件可以用burst读一次性拉多个描述符到内部缓存。我做过测试,环形比链式在吞吐量上能高出30%左右。
但环形也有局限——大小固定。如果软件来不及处理,环满了就得丢包或者阻塞。我曾经在一个项目中,软件处理速度跟不上硬件,环被撑爆了,数据直接丢失,查了两天才找到原因。
| 对比项 | 链式描述符 | 环形描述符 |
|---|---|---|
| 内存布局 | 离散,可动态分配 | 连续,固定大小 |
| 预取效率 | 低,随机访问 | 高,顺序访问 |
| 灵活性 | 高,可无限扩展 | 低,环满即止 |
| 硬件复杂度 | 中等,需解析指针 | 低,只需递增地址 |
| 适用场景 | 任务到达时间不确定 | 高吞吐、持续传输 |
我的建议:如果做通用DMA引擎,两种都支持。让软件根据场景选择。我现在的设计里,默认用环形,但当环满时自动切换到链式模式,算是取了个折中。
硬件描述符预取与回写机制
这部分是性能的关键。你想想看,如果硬件每次处理一个描述符都要去内存里读一次,那PCIe带宽全浪费在读描述符上了,真正搬数据的时间反而少了。
预取机制:硬件会提前把后续的描述符读到内部FIFO里。对于环形描述符,预取很简单——直接按顺序读连续地址就行。对于链式的,硬件需要提前解析Next Pointer,然后发起新的读请求。
我一般会在DMA引擎里放一个深度为8或16的描述符缓存。这样硬件可以提前预取多个描述符,减少等待内存读返回的时间。
// 预取逻辑伪代码
always @(posedge clk) begin
if (desc_fifo_depth < PREFETCH_THRESHOLD) begin
// 发起预取请求
prefetch_addr = current_desc.next_ptr;
issue_read(prefetch_addr);
end
end
这里有个坑——预取太多也不行。如果软件修改了还没处理的描述符,硬件预取到的就是旧数据。我曾经遇到过这种情况:软件更新了描述符,但硬件已经预取了旧版本,结果搬错了数据。后来我加了一个"描述符有效位"的检查,硬件在开始处理前会重新读一下状态位,确保数据是最新的。
回写机制:描述符处理完成后,硬件需要把状态写回内存。回写的内容通常包括:完成状态、实际传输的字节数(可能因为错误而少于请求的字节数)、错误码等。
回写时机也很讲究。我建议两种策略:
- 立即回写:每个描述符完成后立即回写。优点是软件能及时知道进度,缺点是频繁写内存会占用PCIe带宽。
- 批量回写:攒够N个描述符或触发中断时再回写。优点是带宽利用率高,缺点是软件看到的进度有延迟。
注意:回写操作必须保证顺序。如果描述符A和B都完成了,但B的回写先到达内存,A的回写后到达,软件会看到B已完成而A未完成的错误状态。我在设计中会用一个"完成序号"字段,软件通过比较序号来检测乱序。
嗯,最后说一句——描述符预取和回写的深度,最好做成可配置的。不同的应用场景对延迟和吞吐的要求不一样。比如存储场景更看重吞吐,可以预取深一些、批量回写;而网络场景更看重延迟,预取浅一些、立即回写更合适。
好了,描述符链这块就聊到这儿。下一章咱们讲讲DMA引擎的状态机设计,那才是真正把描述符跑起来的关键。