第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位 |
工作流程是这样的:
- 主机驱动在内存中构建描述符链,每个描述符指向一块数据缓冲区
- 驱动将链首地址写入FPGA的DMA控制寄存器
- FPGA DMA控制器从链首开始,逐个读取描述符
- 根据描述符中的地址和长度,执行实际的数据搬运
- 处理完当前描述符后,自动跳转到下一个描述符
- 遇到链结束标记,产生中断通知主机
我的小技巧:描述符本身最好放在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内核代码,给大家讲清楚用户态和内核态的交互流程。咱们下节课见。