第4章 DMA引擎设计:Scatter-Gather DMA原理、描述符链设计、FPGA端DMA控制器实现

各位同学,今天我们来啃一块硬骨头——Scatter-Gather DMA。说实话,这个知识点在PCIe通信里属于「会了就觉得简单,不会就觉得玄学」的那种。我当年第一次接触SG-DMA时,对着描述符链看了三天才搞明白指针怎么跳转的。嗯,今天咱们争取一节课把它讲透。

4.1 为什么需要Scatter-Gather DMA?

先问个问题:传统的Block DMA有什么毛病?

Block DMA要求物理地址连续。你想想看,在操作系统里申请一大块连续物理内存有多难?尤其是系统跑久了,内存碎片化严重,4KB都难保证连续,更别说你要传几MB的数据了。

我在项目中遇到过这种情况:FPGA要采集高速ADC数据,通过PCIe传给上位机。上位机驱动申请DMA缓冲区时,系统说「抱歉,没有那么大连续空间」。最后只能拆成小块,一次一次传,效率低得令人发指。

Scatter-Gather DMA就是来解决这个问题的。它允许数据在物理上分散存储,通过一个「描述符链」把这些分散的块串起来,DMA控制器自动遍历这个链,完成整批数据传输。

核心思想:物理上分散,逻辑上连续。你只管告诉DMA引擎「数据在哪儿、有多长」,剩下的它自己搞定。

4.2 Scatter-Gather DMA工作原理

说白了,SG-DMA就是一张「快递配送单」。每个描述符相当于一个配送地址,链在一起就是完整的配送路线。

每个描述符通常包含以下字段:

字段 说明 我常用的位宽
源地址 数据源物理地址(FPGA侧或内存侧) 64位
目的地址 数据目标物理地址 64位
传输长度 本次传输的字节数 16位或32位
控制标志 中断使能、链结束标记等 16位
下一个描述符指针 指向链中下一个描述符的物理地址 64位

工作流程是这样的:

  1. 主机驱动在内存中构建描述符链,每个描述符指向一块数据缓冲区
  2. 驱动将链首地址写入FPGA的DMA控制寄存器
  3. FPGA DMA控制器从链首开始,逐个读取描述符
  4. 根据描述符中的地址和长度,执行实际的数据搬运
  5. 处理完当前描述符后,自动跳转到下一个描述符
  6. 遇到链结束标记,产生中断通知主机

我的小技巧:描述符本身最好放在DDR中一个固定的、连续的物理区域。这样FPGA读取描述符时,不需要再处理Scatter-Gather,避免「套娃」问题。

4.3 描述符链设计要点

描述符链的设计,直接决定了DMA传输的可靠性和效率。我踩过的坑不少,这里挑几个重点说。

4.3.1 链结构选择

常见的链结构有两种:

  • 单向链表:每个描述符只指向下一个,最后一个指向NULL。简单,但一旦某个描述符损坏,后面的全丢了。
  • 环形链表:最后一个描述符指向第一个,形成环。适合持续流式传输,但需要处理「覆盖」问题。

我个人习惯:对于数据采集类应用,用环形链;对于文件传输类应用,用单向链。为什么?采集是持续的,环形链可以减少驱动重新配置的开销;文件传输是一次性的,单向链更清晰。

4.3.2 描述符对齐要求

这里要特别注意:描述符在内存中的地址必须对齐到16字节或32字节边界。为什么?因为FPGA内部DMA控制器读取描述符时,通常是一次性读取多个字节(比如128位)。如果地址不对齐,读操作会跨边界,增加复杂度。

我曾经踩过的坑:有一次描述符地址只做了8字节对齐,结果在某个FPGA型号上工作正常,换了个型号就随机出错。查了两天才发现是地址对齐问题。从那以后,我统一要求驱动做32字节对齐,再也没出过事。

4.3.3 描述符预取与缓存

FPGA读取描述符需要访问DDR,延迟不小。为了提高效率,可以在FPGA内部实现一个描述符缓存(FIFO或RAM),提前预取后续的描述符。

我一般这样设计:

  • 当前描述符正在处理时,预取引擎自动读取下一个描述符
  • 缓存深度设为4~8个描述符,足以掩盖DDR访问延迟
  • 当链很长时,预取可以一直进行,直到缓存满

4.4 FPGA端DMA控制器实现

好了,理论说完了,咱们看看FPGA里怎么实现。我以一个典型的PCIe DMA控制器为例,讲一下核心模块。

4.4.1 整体架构

DMA控制器通常包含以下几个子模块:

  • 描述符获取引擎:负责从DDR读取描述符,解析地址和长度
  • 数据搬运引擎:负责实际的读写操作,产生PCIe TLP
  • 完成状态管理:记录每个描述符的执行状态,产生中断
  • 控制/状态寄存器:与驱动交互的接口

4.4.2 核心状态机

描述符获取引擎的状态机,我通常这样设计:

// 伪代码描述状态机
IDLE:
    等待启动信号
    加载链首地址
    -> FETCH_DESC

FETCH_DESC:
    发起DDR读请求,读取描述符
    等待数据返回
    -> PARSE_DESC

PARSE_DESC:
    解析描述符字段
    检查链结束标志
    如果结束 -> DONE
    否则 -> START_XFER

START_XFER:
    启动数据搬运引擎
    设置源地址、目的地址、长度
    -> WAIT_XFER_DONE

WAIT_XFER_DONE:
    等待数据搬运完成
    更新完成状态
    如果有预取描述符 -> FETCH_DESC
    否则 -> IDLE

DONE:
    产生中断
    更新寄存器
    -> IDLE

注意:实际实现时,FETCH_DESC和START_XFER可以流水线并行。即当前描述符正在传输时,预取下一个描述符。这样能显著提高吞吐量。

4.4.3 数据搬运引擎设计

数据搬运引擎是核心中的核心。它负责生成PCIe读写请求(TLP)。

关键设计点:

  • 最大payload大小:通常设为128字节或256字节,取决于PCIe配置。我习惯用256字节,效率更高。
  • 地址递增:每次传输后,源地址和目的地址自动增加已传输的字节数。
  • 读写平衡:如果同时有读和写请求,要保证不会死锁。我一般用独立的读写FIFO,分别处理。

4.4.4 中断处理

中断是DMA与驱动通信的重要手段。我一般支持两种中断模式:

  • 描述符完成中断:每个描述符传输完成后产生中断。适合需要精确控制每个缓冲区的场景。
  • 批量完成中断:传输完N个描述符或整个链完成后产生中断。适合高性能批量传输。

实际项目中,我通常让驱动在描述符的控制标志中指定「是否在此描述符完成后产生中断」。这样灵活性最高。

4.5 实战经验总结

最后,分享几个我在项目中积累的经验:

  • 描述符链的更新:驱动在更新描述符链时,一定要确保FPGA不会读到「半更新」的描述符。我一般用「乒乓缓冲」或「描述符有效标志」来解决。
  • 错误处理:如果FPGA读取描述符时发生ECC错误或超时,要有重试机制。我曾经因为没做重试,导致一次传输失败后整个系统卡死。
  • 性能调优:描述符的大小和数量会影响性能。太小了,FPGA频繁读取描述符,开销大;太大了,灵活性降低。我一般每个描述符传输4KB~64KB数据。

一句话总结:Scatter-Gather DMA的精髓在于「用描述符链把分散的内存块串起来」,FPGA端实现的关键是「高效获取描述符、流水线执行传输、灵活处理中断」。掌握了这些,你就能在PCIe高速数据传输中游刃有余。

下一章,我们会深入描述符链的软件管理,包括驱动如何构建、提交、回收描述符。到时候我会结合Linux内核代码,给大家讲清楚用户态和内核态的交互流程。咱们下节课见。