ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

高速数据采集下FPGA与Linux间DMA搬运框架的设计与调优

高速数据采集下FPGA与Linux间DMA搬运框架的设计与调优 做高速数据采集最折磨人的环节往往不是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处理数据不经过ARMFPGA内部处理完直接输出实时性最强无系统调度开销灵活性差Linux生态用不上网络/存储都要自研FPGA MCU裸机FPGA通知MCUMCU中断搬运响应快实现简单内存太小无文件系统和网络栈大数据量管理困难FPGA ARM64 Linux DMAFPGA内置DMA引擎数据经AXI直写内存吞吐高Linux生态完整应用开发效率高驱动和缓存一致性复杂调试难度大第一和第二种方案在特定场景下依然有价值但我要做的是一套能同时跑Linux、能通过网络或者存储把采集数据实时输出的通用平台所以第三条路几乎是必然选择。hs_dma_framework就是沿着这条路搭建的。1.3 市面现成方案的局限性确定走FPGA ARM64 Linux这条路之后我并没有马上动手写代码而是先把现成的方案研究了一遍。Xilinx的AXI DMA IP是很多人会第一个想到的。它确实成熟驱动也写在Linux内核里但用了之后有几处非常别扭首先是它挂在dmaengine子系统下这套内核框架原本是为内存到内存搬运设计的对外设持续流式写内存这种场景支持得并不顺手高吞吐连续传输时描述符管理非常繁琐其次是它的中断策略保守默认每个描述符完成都触发中断吞吐一大中断次数就跟不上再者AXI DMA的缓冲区地址和长度限制比较多做多通道时间交织采样时要额外加不少逻辑。另一种常见做法是用UIOUserspace 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 lengthAXI4协议里一次突发最多可以传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里。如果缓存和内存内容不一致用户态程序拿到的数据可能就是脏的。这个问题在带CCICache 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 MPSoCPL侧用Vivado搭建DMA引擎PS侧跑PetaLinux 5.4ARM64。FPGA和ARM之间的AXI总线位宽256bitDMA引擎时钟150MHz理论峰值带宽4.8GB/s。ADC源来自片内DDS生成的15MHz正弦波采样率1GSPS、12bit、双通道。测试程序做的事情很简单启动采集后从完成队列取块统计每秒收到的有效数据字节数和平均CPU占用率。连续运行30分钟看是否有丢帧或描述符链断裂。5.2 三轮调优的具体过程第一轮跑通时用的是基础单缓冲模式块大小64KB每个块完成触发一次中断。实测吞吐只有650MB/sCPU占用率约40%。初步看比最开始的GPIO读取方案强很多但离目标仍有距离。第二轮把缓冲改成环形缓冲描述符链块大小从64KB加大到512KB中断策略改成每4个块完成触发一次中断。吞吐提升到1.0GB/sCPU占用率降到25%。这一轮最主要的收益来自中断次数减少——中断频率从每秒钟上万次降到了两千次左右。第三轮进一步做了两项优化一是去掉采集中间环节的额外FIFO缓冲让ADC采样数据以更短的路径直达DMA引擎降低跨时钟域的等待时间二是把中断底半部从普通工作队列改为tasklet减少了内核调度延迟。最终稳定在1.2GB/s吞吐CPU占用率低于15%。对于这套ADC源来说1.2GB/s已经接近PL逻辑所能处理的上限而从总线能力来看还有大量余量。轮次块大小中断策略实测吞吐CPU占用率第一轮64KB每块中断650MB/s40%第二轮512KB每4块中断1.0GB/s25%第三轮512KB每4块中断tasklet1.2GB/s15%5.3 性能瓶颈的边界在哪里如果单纯用PL侧的pattern generator产生数据不经过ADC这套DMA框架能跑到多少我也测过峰值约3GB/s此时DMA引擎的AXI写事务几乎把总线带宽吃满驱动侧没有出现丢块。也就是说hs_dma_framework本身的设计在3GB/s以内都不会成为瓶颈更高速率的采集需要把AXI总线和DMA引擎进一步加宽、提频或者使用多通道DMA并行搬运。6. 踩过的坑缓存一致性、描述符断裂与中断风暴的完整排查链路6.1 缓存一致性导致的数据错乱这个问题在国产FPGAARM64平台上遇到的。由于没有硬件一致性的CCI互连DMA写入DDR后CPU的L2 Cache仍然保留着旧数据。第一版驱动我用的是标准的kmalloc内存然后用flush_dcache_page手动刷新Cache。结果跑了一段时间后发现用户态程序读到的数据块里会出现几十个字节的旧数据每次错位的位置都不固定非常诡异。排查过程我记录一下供大家复用思路。第一步FPGA侧写一个固定pattern0xA5A5_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菊花链级联采集的支持到时候有新结论再来分享。
返回列表