做高速数据采集,最折磨人的环节往往不是ADC本身,而是数据从FPGA流进Linux用户态的那条路。我在第一版板卡上吃过这个亏:FPGA逻辑用中断通知ARM64核来搬FIFO数据,单通道1GSPS、12bit,理论吞吐1.5GB/s,实际连一半都跑不到,CPU占用率长期在90%以上,系统动不动就卡死。那时候我就下了决心,把数据搬运彻底从CPU手里抢过来,交给DMA专职处理。后来这条路走通了,沉淀下来的就是hs_dma_framework——一个面向高速数据采集的FPGA-Linux-ARM64一体化DMA搬运框架。这篇文章不打算写泛泛的概念介绍,而是把框架里最关键的几个设计决策、实测数据、以及调试过程中踩过的坑完整摊开来讲,给正在搞同类平台的人一个可以直接参考的样本。
1. 为什么把数据搬运从CPU手里抢过来:项目缘起与路线对比
1.1 第一版方案是怎么被数据吞吐压垮的
最初做的采集板是Xilinx FPGA + ARM64处理器的组合。FPGA端负责接收ADC采样数据,存进片内的FIFO,然后通过GPIO中断通知处理器来读。用的时候发现,这套逻辑在低速场景下没问题,一旦ADC采样率提上来,数据积压的速度远远大于ARM端读取的速度。处理器每收到一次中断,都要执行一遍地址解码、总线读取、缓存写入、寄存器清中断的流程,单次搬运几百KB数据的时间足够FPGA那边再写进来好几MB。最终的结果就是:采样的数据不断被覆盖,有效数据零零散散,CPU因为频繁响应中断而忙得不可开交。
后面我把读取方式从GPIO模拟改成了AXI-Lite突发读取,带宽上来了一些,但问题本质没变——每次搬运仍然需要CPU发起,搬运一个块就要发一次命令,CPU永远处于"被数据追着跑"的状态。这个阶段我做了个简单的统计:系统持续运行30秒,CPU约有75%的时间花在memcpy和中断响应上,真正处理数据的逻辑只有零头。
1.2 三条技术路线的取舍
带着这个痛点,我去对比了行业里常用的几种做法。
| 方案 | 数据通路 | 优势 | 硬伤 |
|---|---|---|---|
| 纯FPGA处理 | 数据不经过ARM,FPGA内部处理完直接输出 | 实时性最强,无系统调度开销 | 灵活性差,Linux生态用不上,网络/存储都要自研 |
| FPGA + MCU裸机 | FPGA通知MCU,MCU中断搬运 | 响应快,实现简单 | 内存太小,无文件系统和网络栈,大数据量管理困难 |
| FPGA + ARM64 Linux + DMA | FPGA内置DMA引擎,数据经AXI直写内存 | 吞吐高,Linux生态完整,应用开发效率高 | 驱动和缓存一致性复杂,调试难度大 |
第一和第二种方案在特定场景下依然有价值,但我要做的是一套能同时跑Linux、能通过网络或者存储把采集数据实时输出的通用平台,所以第三条路几乎是必然选择。hs_dma_framework就是沿着这条路搭建的。
1.3 市面现成方案的局限性
确定走FPGA + ARM64 Linux这条路之后,我并没有马上动手写代码,而是先把现成的方案研究了一遍。
Xilinx的AXI DMA IP是很多人会第一个想到的。它确实成熟,驱动也写在Linux内核里,但用了之后有几处非常别扭:首先是它挂在dmaengine子系统下,这套内核框架原本是为内存到内存搬运设计的,对"外设持续流式写内存"这种场景支持得并不顺手,高吞吐连续传输时描述符管理非常繁琐;其次是它的中断策略保守,默认每个描述符完成都触发中断,吞吐一大中断次数就跟不上;再者,AXI DMA的缓冲区地址和长度限制比较多,做多通道时间交织采样时要额外加不少逻辑。
另一种常见做法是用UIO(Userspace I/O)把整个硬件控制权交给应用层。UIO的好处是内核侧代码少,但代价是缓存一致性管理、中断处理、描述符生命周期这些原本内核该干的事全得应用层自己扛,开发门槛不降反升。
权衡之后我决定做一个自己的框架:FPGA侧的目标不是堆功能IP,而是做一个精简、可控、可参数配置的DMA引擎;ARM64侧不依赖dmaengine子系统,而是写一个独立的字符设备驱动,主动管理描述符链、中断和缓冲区。这套框架在项目里被命名为hs_dma_framework,此后分别在ADC高速采集、LVDS图像传输、多通道慢速采集三套板卡上落地,我都把验证结果记录在案。
2. 从FPGA到ARM64内存:hs_dma_framework的通路架构
2.1 一条完整的数据通路是怎样的
整个系统的数据流向可以拆成五段:传感器源 -> FPGA采集前端逻辑 -> FPGA内部DMA引擎 -> AXI总线 -> ARM64侧DDR内存。其中DMA引擎是FPGA内部的核心模块,它做的事情可以简单归纳为:从采集前端逻辑拿到连续的采样数据流,按照描述符链的指示把这些数据写到内存中去。
为了讲清楚通路架构,我用一张抽象的数据流描述一下(不画具体框图,用文字梳理):
首先,ADC的采样数据进入FPGA后,通常先经过一个跨时钟域FIFO。FIFO的作用有两个:一是把ADC的采样时钟域和DMA总线时钟域解耦,二是做突发长度的速率匹配。接着,FIFO输出的AXI4-Stream数据流进入DMA引擎。DMA引擎内部有一组状态机,它会从内存中读取DMA描述符——描述符里记录了这次搬运的写地址、突发长度、突发次数等参数——然后把AXI4-Stream转换成一笔笔AXI4内存写事务发到总线上。
最终,这些写事务穿越AXI互联矩阵,落到ARM64侧的DDR内存中。驱动在中断处理里得知某次搬运完成,再把内存块通过mmap映射给用户态程序消费。
2.2 FPGA侧的DMA引擎设计要点
FPGA侧DMA引擎是我在整个框架里最花心思的部分。它并不复杂,但参数化做得越细,后面适配不同板卡就越省力。我把它设计成以下几个可配置项:
- AXI数据位宽:64bit、128bit、256bit可选。位宽越大,同频率下带宽越高。
- 突发长度(burst length):AXI4协议里一次突发最多可以传256个数据节拍。突发越长,总线的地址/命令开销占比越低。
- 描述符深度与条数:根据DDR缓冲区的块数来确定。
- 中断策略:支持"每N个块完成触发一次中断"和"超时触发中断"两种模式组合。
另一个容易被忽略的点是地址对齐。AXI总线上内存访问有对齐要求,DMA引擎发出的写地址必须是突发长度的整数倍对齐。如果是128bit位宽、8拍突发,那么每次写事务至少要16字节对齐。实际操作中,驱动分配缓冲区时我会专门做对齐处理,避免描述符跨页、写地址不对齐的问题。
2.3 ARM64侧的分层结构
ARM64侧我把它分成三层:设备树层、内核驱动层、用户态库层。
设备树里的节点负责描述FPGA硬件的基地址、中断号、DMA缓冲区大小等静态信息。驱动probe的时候读出这些参数,再根据参数申请内存、初始化描述符链。用户态库则封装了一套API,比如hs_dma_alloc、hs_dma_start、hs_dma_wait、hs_dma_release,让上层应用不需要理解内核细节就能拿到采集数据。
这个分层带来的最直接好处是:重新适配一块新板卡时,通常只需要改设备树里的几个参数,驱动和用户态库基本不用动。我第二块板卡从Zynq平台切到国产FPGA + 独立ARM64处理器时,FPGA侧描述符引擎重写了一遍,但Linux侧的三层结构几乎没有改动。
3. 描述符链与环形缓冲:DMA框架最关键的两个设计
3.1 为什么不能只用"固定地址DMA"
最简单粗暴的做法是:DMA引擎把数据写到内存里一个固定地址,写满以后触发中断,CPU把整块数据读走。
这个方案的问题在于,CPU处理数据需要时间,DMA写数据是持续的。如果DMA还在写这块地址,CPU就不能去处理,否则会读到半新半旧的数据。结果要么是CPU等DMA,要么是DMA等CPU,吞吐必然被拖死。
更麻烦的是数据对齐和长度问题。高速采集的数据是连续流,不是整齐的几百KB一个包,固定地址方案应对不了"数据长度不停变化"的场景。所以必须引入描述符链。
3.2 描述符链的本质是"由DMA自己找活干"
描述符链的思路很像工厂流水线上的工单:每个工单(描述符)写明了"这一单要搬到哪个地址、搬多少数据",DMA引擎拿到一个工单就执行一个,干完自动去工单上写的地址取下一个工单,直到整条链都完成。
描述符本身是放在内存里的一段结构体数组,每个结构体通常包含:
- 目标地址:本次搬运数据写入的DDR物理地址。
- 搬运长度:本次要写入的字节数。
- 控制标志:标记这是不是最后一个描述符、完成后是否触发中断。
- 下一个描述符指针:指向下一单的物理地址。
驱动初始化时会提前把几十个描述符按序连成环,DMA引擎每次写完一个块就顺着指针执行下一个,因此不需要CPU在每次搬运之间反复下发命令。CPU只负责在中断到来时把"已经写好的块"放入完成队列,同时从空闲队列里拿一个空块补充到描述符链末尾。
用数字感受一下效率提升:假设DDR缓冲被分成32个块,每块512KB,总缓冲16MB。描述符链只要维护连续的32条记录就够了。DMA每搬完一块触发一次中断。在1GB/s吞吐时,中断频率大约2000次/秒,这对现代ARM64处理器来说非常轻松。
3.3 环形缓冲的消费与回填机制
环形缓冲是这个框架里和描述符链搭配使用的另一块拼图。它的操作逻辑可以类比成厨房里的两个厨师:DMA是一个只负责往桌上放菜的厨师,用户态程序是只管端菜的服务员。
驱动维护两个队列:空闲队列和完成队列。初始化时所有块都在空闲队列。DMA写完一个块,驱动在中断中把这个块从空闲队列移到完成队列;用户态程序从完成队列里取块消费,消费完通过API把块归还到空闲队列。同时驱动会把归还的块再挂到描述符链上,让DMA继续往里面写。
这里最需要注意的细节是:块在"空闲队列"和"描述符链"之间不能产生竞争。我设计了如下流程保证:
- 用户态程序释放块时,先写驱动层,驱动把块标记为"空闲"。
- DMA中断处理里,驱动只移动"完成队列",不直接操作"空闲队列"。
- 用户态程序下一次调用hs_dma_alloc时,才从空闲队列取块并补充到描述符链尾部。
这套流程避免了内核态和用户态同时修改队列节点的条件竞争,实测下来在高负载时没有出现重复写或者丢块的情况。
3.4 缓存一致性:一个绕不过去的核心问题
DMA直接写内存,而CPU的缓存(Cache)会把内存数据缓存一份在L1/L2里。如果缓存和内存内容不一致,用户态程序拿到的数据可能就是"脏"的。这个问题在带CCI(Cache Coherent Interconnect)的SoC上可能不存在,因为硬件会帮忙维持一致性,比如Zynq UltraScale+ MPSoC。但如果用的是普通ARM64处理器+FPGA的组合(尤其是国产FPGA),往往不具备硬件一致性,就必须靠软件维护。
我在hs_dma_framework驱动里处理缓存一致性的原则是:DMA缓冲使用dma_alloc_coherent接口分配。这个接口分配的内存要么是uncached,要么是write-through,保证CPU读到的和DMA写的一致。如果由于某种原因不能用这个接口(比如缓冲必须在特定物理地址范围内),那就每次DMA搬运完成后调用dma_sync_single_for_cpu、每次补充缓冲时调用dma_sync_single_for_device,用软件刷缓存。
这条原则说起来简单,但实际调试中我为此踩过一个大坑,后面专门用一节来讲。
4. Linux驱动与用户态访问:零拷贝链路怎么打通
4.1 自定义驱动还是dmaengine子系统
在Hs_dma_framework立项时,我反复权衡过一个问题:驱动是挂在Linux内核的dmaengine子框架下,还是写一个完全独立的字符设备驱动。
dmaengine子系统的设计初衷是统一各类DMA控制器的驱动接口,让上层(比如音频子系统、存储子系统)可以复用。它确实很适合"CPU主动发起搬运"的场景。但高速数据采集是"外设主动持续搬运",CPU在搬运过程中是被动的,dmaengine的异步提交模型在这种场景下反而显得笨重。尤其当FPGA侧的DMA引擎带有自定义描述符格式和自定义中断语义时,硬往dmaengine上套需要写很多无意义的适配代码。
所以我选择了独立字符设备驱动。整个驱动大约三千行,核心职责有四块:设备树解析与硬件初始化、描述符池与缓冲队列管理、中断底半部处理、mmap接口与文件操作。三千行换来的是对每一个环节的完全掌控,调试和扩展都更自由。
4.2 mmap零拷贝的设计细节
数据流的最后一段是用户态程序拿到DMA写入的数据。最差的做法是驱动把数据从内核缓冲copy到用户缓冲,一次1MB的拷贝大概要几毫秒,吞吐一大就顶不住。正确做法是mmap。
驱动在初始化DMA缓冲时,会记录每个块的物理地址。用户态程序通过mmap把这片物理内存映射到进程的虚拟地址空间,之后DMA写入物理内存的数据,用户态程序直接通过虚拟地址就能读到。整个过程没有任何内核态数据拷贝,唯一的开销是页表映射和TLB切换。
但零拷贝不意味着用户态程序可以无脑访问。我在用户态库hs_dma_wait里做了额外一层内存屏障(barrier),确保用户态读到的数据是DMA中断发生之后写入完毕的。同时,库提供了hs_dma_block_index和hs_dma_block_data两个辅助函数,用来从完成队列中取出块号和块地址,避免用户态自己推算内存布局。
4.3 与FPGA侧的帧握手协议
高速数据采集中,用户不仅需要数据流,还需要知道"一帧从哪里开始、到哪里结束、这一帧是否完整"。为此,FPGA侧在DMA引擎旁边还维护了一个帧描述符区,放在固定的物理地址(通常是保留的DDR区域或者片上RAM)。
每一帧被DMA完整写入DDR后,FPGA会更新这个帧描述符,内容包括:帧序号、帧长度、采样时钟时间戳、通道数、错误标志(比如是否有FIFO溢出)。ARM64侧驱动的中断处理函数会一并读取该帧描述符,把它放进一个元数据队列。用户态程序把数据块和元数据结合使用,就能准确切分出每一帧,不会因为缓冲块大小与帧长度不匹配而割裂数据处理。
4.4 多通道与多缓冲区的管理策略
针对多通道交替采样,框架采取的策略是在FPGA侧做通道交织解交织。DMA引擎按固定规则把交织后的数据写到内存的交替区域,驱动在描述符里定义好每个通道对应的目标地址区间。用户态程序按块读取时,自然就能拿到按通道分离的数据。
这个方案相比"每通道一条DMA链"要简单可靠得多——不需要同时维护多条描述符链,也不用担心通道间数据速率不平衡导致的缓冲饥饿。
5. 实测数据与三轮调优:从650MB/s到满载不丢帧
5.1 测试平台与方法
实际测试平台是Zynq UltraScale+ MPSoC,PL侧用Vivado搭建DMA引擎,PS侧跑PetaLinux 5.4(ARM64)。FPGA和ARM之间的AXI总线位宽256bit,DMA引擎时钟150MHz,理论峰值带宽4.8GB/s。ADC源来自片内DDS生成的15MHz正弦波,采样率1GSPS、12bit、双通道。
测试程序做的事情很简单:启动采集后,从完成队列取块,统计每秒收到的有效数据字节数和平均CPU占用率。连续运行30分钟看是否有丢帧或描述符链断裂。
5.2 三轮调优的具体过程
第一轮跑通时用的是基础单缓冲模式,块大小64KB,每个块完成触发一次中断。实测吞吐只有650MB/s,CPU占用率约40%。初步看比最开始的GPIO读取方案强很多,但离目标仍有距离。
第二轮把缓冲改成环形缓冲+描述符链,块大小从64KB加大到512KB,中断策略改成"每4个块完成触发一次中断"。吞吐提升到1.0GB/s,CPU占用率降到25%。这一轮最主要的收益来自中断次数减少——中断频率从每秒钟上万次降到了两千次左右。
第三轮进一步做了两项优化:一是去掉采集中间环节的额外FIFO缓冲,让ADC采样数据以更短的路径直达DMA引擎,降低跨时钟域的等待时间;二是把中断底半部从普通工作队列改为tasklet,减少了内核调度延迟。最终稳定在1.2GB/s吞吐,CPU占用率低于15%。对于这套ADC源来说,1.2GB/s已经接近PL逻辑所能处理的上限,而从总线能力来看还有大量余量。
| 轮次 | 块大小 | 中断策略 | 实测吞吐 | CPU占用率 |
|---|---|---|---|---|
| 第一轮 | 64KB | 每块中断 | 650MB/s | 40% |
| 第二轮 | 512KB | 每4块中断 | 1.0GB/s | 25% |
| 第三轮 | 512KB | 每4块中断+tasklet | 1.2GB/s | 15% |
5.3 性能瓶颈的边界在哪里
如果单纯用PL侧的pattern generator产生数据,不经过ADC,这套DMA框架能跑到多少?我也测过:峰值约3GB/s,此时DMA引擎的AXI写事务几乎把总线带宽吃满,驱动侧没有出现丢块。也就是说,hs_dma_framework本身的设计在3GB/s以内都不会成为瓶颈,更高速率的采集需要把AXI总线和DMA引擎进一步加宽、提频,或者使用多通道DMA并行搬运。
6. 踩过的坑:缓存一致性、描述符断裂与中断风暴的完整排查链路
6.1 缓存一致性导致的数据错乱
这个问题在国产FPGA+ARM64平台上遇到的。由于没有硬件一致性的CCI互连,DMA写入DDR后,CPU的L2 Cache仍然保留着旧数据。第一版驱动我用的是标准的kmalloc内存,然后用flush_dcache_page手动刷新Cache。结果跑了一段时间后发现:用户态程序读到的数据块里,会出现几十个字节的"旧数据",每次错位的位置都不固定,非常诡异。
排查过程我记录一下,供大家复用思路。第一步,FPGA侧写一个固定pattern(0xA5A5_5A5A)灌进DDR,然后ARM侧CPU读回来对比,发现读回来的数据里pattern被打了断点。第二步,逐步缩小怀疑范围,每次DMA只搬运1KB,中断后用jtag读内存,发现内存在总线层面是正确的,但CPU读出来的数据有旧值。这时候基本锁定是Cache问题。第三步,把驱动里的缓冲区改成dma_alloc_coherent之后,pattern恢复完全一致,确认根因。
教训是:在没有硬件一致性的平台上,不要试图用"手动flush Cache"来省事。直接用dma_alloc_coherent分配缓冲区,虽然访问速度会稍慢(uncached),但一致性有保障。采集系统优先保证的是数据正确,而不是缓存上的一点点速度优势。
6.2 描述符链断裂的定位过程
另一次严重故障是:系统连续运行约一刻钟后,DMA引擎突然停住不再搬数据,中断也不再触发。查FPGA侧逻辑发现,DMA引擎状态机停在"等待读下一个描述符"状态,说明它去读取下一个描述符时没能拿到合法数据。
这个问题的根因是描述符内存的分配方式。当时为了节省一致内存的宝贵空间,我把描述符池放在了用kmalloc分配的普通内核内存上。kmalloc内存虽然在物理上连续,但无法保证不被换出、无法保证物理地址在高位DDR的可用范围内。系统运行一段时间后,内存压缩(compaction)或者页迁移导致描述符所在的物理页内容发生了变化,DMA引擎读到的描述符地址变成了无效值。
修复很简单:描述符池和DMA缓冲区一样,一律用dma_alloc_coherent分配,并且分配后锁定(防换出)。从此再没出现过描述符链断裂的情况。这个坑的价值在于提醒后来者:DMA能访问的物理内存在Linux里必须特殊对待,绝不能当作普通内存来管理。
6.3 中断风暴是怎么消灭的
把块大小从64KB减小到8KB测试时,系统出现了明显的中断风暴,吞吐反而下降。原因不难理解:8KB块在1GB/s下对应每秒12.5万次中断,ARM64虽然性能强,但中断处理函数里如果还做了不少操作(写日志、读状态寄存器),每秒12.5万次也扛不住,最终触发中断丢失和吞吐下降。
解决思路不是单纯加大块大小,而是给中断做了合并:DMA引擎允许设置阈值,比如累计完成4个块才触发一次中断;再搭配超时中断(比如2ms没凑满4个块也触发一次),保证低速率时数据仍然及时上报。这套"数量阈值+超时兜底"的组合在高速和低速采集场景下都表现稳定。
6.4 调试工具与建议
Linux侧我常用的调试手段有三个:bpftrace跟踪驱动里的关键函数调用次数,perf top看中断和softirq占比,以及一个自研的小工具hs_dma_stats,能实时打印每个通道的完成块数、丢块计数、中断耗时分布。FPGA侧主要靠ILA抓DMA引擎状态机和AXI写事务。
调试中最重要的一条经验是:先把Linu侧和FPGA侧各自的边界弄清楚。很多问题看起来像驱动bug,其实是FPGA侧描述符格式没写对;看起来很像是FPGA逻辑bug,其实是内存被内核偷偷搬了家。我后来养成一个习惯:最终的现场联调,先让FPGA侧输出pattern数据,跑通整条链路之后再接入真实ADC,这样能快速定位到问题属于哪一端。
这套框架前后迭代了四轮,目前已经稳定跑在三块不同板卡上。真正让我觉得有收获的,不是最终吞吐数字多好看,而是搞明白了一件事:FPGA和Linux之间的协作,难点在于两边对内存的理解完全不同。FPGA认为世界是并行的、地址确定性的、时刻确定的;Linux认为世界是分页的、调度器决定的、时序不定的。DMA框架做的就是这两套世界观的翻译。如果你们也在搭类似的FPGA-Linux-ARM64平台,我建议先花时间把完整数据通路图画出来,比急着写代码能省下至少两周的调试时间。hs_dma_framework后续我计划补上Linux内核IIO(工业IO子系统)的对接,以及多FPGA菊花链级联采集的支持,到时候有新结论再来分享。