描述符链设计与实现

好,咱们今天聊聊描述符链。这东西在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引擎的状态机设计,那才是真正把描述符跑起来的关键。