ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

FPGA与Linux软硬协同:高速DMA数据采集框架设计实战

FPGA与Linux软硬协同:高速DMA数据采集框架设计实战 做高速数据采集的人大概都遇到过同样的烦恼ADC和传感器越跑越快数据涌上来之后如果只靠FPGA内部逻辑处理要么存不下来要么就得反复跟CPU打交道结果瓶颈往往不在算力而在总线和中断上。这两年我把FPGA端的数据通路、Linux内核驱动和ARM64用户态封装到一起整理成了一套叫hs_dma_framework的软硬协同框架。它最大的价值是让FPGA和Linux之间形成一条清晰的DMA管道前端采集和后端处理各忙各的不再互相拖累。这篇文章就围绕这个框架把系统架构、FPGA端设计、Linux/ARM64驱动以及调试验证的关键经验捋一遍适合正在做FPGA数据采集、嵌入式Linux或者软硬一体平台的朋友参考。之所以要专门做一套FPGA-Linux-ARM64一体化平台是因为大多数采集项目的痛点都集中在数据搬移上。ADC采集端今天输出百兆采样率明天可能要上千兆而Linux侧的应用起来之后还有自己的调度延迟、页分配和缓存一致性问题任一个环节处理不好整体吞吐都会掉得很厉害。hs_dma_framework要解决的就是这些跨域问题。1. 框架定位与整体设计思路1.1 解决的根本问题不在“快”而在“不互相等”在真正动手设计之前我先想明白了一件事DMA框架的核心不是把某一端的时钟跑到最高而是让两端都不需要等待对方。FPGA内部处理数据几乎是确定性的只要逻辑写得好每个周期能处理多少样本是可以精确计算的。但Linux这边不一样CPU可能正被其他线程抢占页表缓存也可能没有命中甚至中断响应都有几十微秒的抖动。如果把FPGA和CPU设计成“一问一答”的同步方式CPU稍有波动FPGA就得把数据缓存在BRAM里缓存一满就只能丢掉新数据这是很多采集工程猝死的原因。hs_dma_framework采用异步批量搬运的思路FPGA端把数据连续写入DMA描述符指定的内存缓冲区写入足够长一个批次后再通过中断通知Linux。Linux侧拿到通知后直接对缓冲区做处理或转存不需要在每次样本到来时都立刻响应。这样FPGA只管按自己的节奏产生数据Linux只管按自己的节奏消费数据中间用环形缓冲区和硬件描述符做解耦。这个设计决定的第一个问题是DMA缓冲区必须足够大。太小的话FPGA很容易把缓冲区写穿太大又会在Linux启动时分配困难尤其在ARM64平台如果系统内存不多或者开启了CMA的默认限制一次性申请几十MB连续内存可能直接失败。所以我在框架里把缓冲区拆成多个描述符条目每个条目对应一个4KB或2MB的内存块由描述符链把它们串起来FPGA写满一个描述符就跳到下一个全部写完后回到开头形成环形队列。这样既保证了连续流动又不需要依赖物理连续的大块内存。1.2 为什么选择ARM64而不是普通MCU更合适很多做FPGA的老工程师会问搞个Cortex-M或者Cortex-A5不也能跑Linux吗为什么非要ARM64答案其实很现实。高速数据采集的后续处理要么是跑网络协议栈把数据发出去要么是跑各种软解调、FFT、滤波算法甚至需要直接跑Python或者MATLAB生成的模型。Cortex-M跑Linux本来就勉强内存带宽和算力都撑不住Cortex-A5虽然是ARMv7但在64位数据处理、NEON向量化能力和大内存寻址上有明显短板。ARM64架构在这类应用中最重要的优势是两样一是内存寻址空间大可以游刃有余地做多块DMA环形缓冲区和用户态映射二是SIMD指令宽度和执行力强很多采集后的处理可以直接在ARMv8的NEON上完成而不需要把数据再搬回FPGA。我这里用的平台是Xilinx Zynq UltraScale家族的ZU3EG/ZU7EV系列它集成了四核ARM Cortex-A53内存接口能跑到DDR4FPGA部分又有丰富的收发器和逻辑资源。hs_dma_framework就是为这类平台写的但设计上尽量跟具体型号解耦FPGA端只需要提供AXI接口Linux侧使用标准DMA API哪怕换到其他带ARM64核的FPGA SoC改动成本也很低。2. FPGA端DMA数据通路的关键设计2.1 AXI DMA和AXI Stream怎么选FPGA和ARM64之间搬数据最常规的路线是AXI DMA也就是Xilinx的AXI DMA IP核内部包含S2MMStream to Memory-Map和MM2SMemory-Map to Stream两条通道。hs_dma_framework里用的是S2MM方向ADC数据由FPGA内部逻辑打包成AXI-Stream格式连续送往AXI DMAAXI DMA按照描述符把数据写到DDR中由Linux分配的缓冲区里。在一些更高速的场景下单个AXI DMA通道可能带宽不够比如ADC输出超过了DDR控制器的理论带宽或者AXI DMA本身成为瓶颈。这时我通常会再挂一个AXI DataMover或者自己做定制状态机。但从工程成熟度看Xilinx官方AXI DMA在大部分项目中都够用而且驱动可以直接结合Linux DMA engine省去很多造轮子的时间。如果你的数据源是ADC直连的LVDS或者JESD204BAXI DMA只是数据通路里的一环前面还需要把串行数据转成并行样本再拼成连续的AXI-Stream总线。有一点非常关键AXI DMA的读写带宽跟数据位宽和突发长度强相关。在Zynq UltraScale上DDR的AXI接口通常配128bit或者256bit如果你把Stream的数据位宽设成32bit那么DMA在内部还需要做位宽转换转换逻辑本身会带来少量延迟和效率损失。我个人在项目里会把ADC采样宽度尽量扩展到64bit或者128bit用几个通道的样本拼成一个宽总线再交给DMA实测带宽往往能提升两成以上。2.2 描述符环与地址对齐的经验描述符环在Xilinx AXI DMA里是个容易踩坑的地方。它的原理是Linux驱动把要接收数据的内存地址、长度、控制信息写入一段描述符内存硬件按顺序读描述符然后搬运数据搬完一个再读下一个如此循环。为了让这个循环跑得顺畅描述符的内存地址必须满足自然对齐一般至少按8字节对齐缓存行相关的操作也跟对齐有密切关系。我在FPGA端写DMA状态机时会把每条描述符的长度设成整个缓冲区大小比如16KB或者1MB这样可以减少描述符切换的次数。但要注意AXI DMA传输完成后如果长度是固定的硬件会把状态信息写回描述符驱动通过检测状态位或CC完成通道来知道这一笔已经结束。描述符环深度如果太小比如只配置四五个条目FPGA端连续写入大流量数据时很快就会把环填满后面的数据要么被丢弃要么硬件停在某个描述符上等待。我在设计hs_dma_framework时默认把描述符深度做到64个以上并且每个条目不要求一定是相同长度但必须保证每个条目对应的物理地址连续且地址低字节对齐到缓存行长度。再强调一次对齐如果你使用2MB的hugepage或者CMA分配的缓冲区描述符里写的地址必须是物理地址而DMA长度需要经过审计防止硬件写完整个页后越界到其他页面。实操中我见过驱动和FPGA端长度不一致导致的数据错位往往就是“最后几个字节被硬生生塞进了下一笔数据里”查起来很难受。所以建议在FPGA端也做一个长度计数器跟描述符里的长度字段做交叉校验。2.3 背压处理与中断信号的产生FPGA端写数据的节奏不能完全看DDR有多快也得看AXI-Stream通路是否允许反压。AXI-Stream协议本身有ready和valid信号只要ready拉低源端就必须停下来这是天然的背压机制。但问题在于如果ADC是无时无刻在输出数据的遇到背压就只能丢数据这是我们绝对不想看到的。解决思路一般在设计时就要定好要么在FPGA内部加一个大容量FIFO比如几十KB的BRAM/URAM FIFO用来吸收ARM或DMA的抖动要么干脆在采集链路前做可配置的抽取或降速。hs_dma_framework在FPGA端维护了一个“水位阈值”FIFO剩余空间不足时会提前拉低DMA的ready同时把一个状态寄存器拉高Linux驱动可以读到这个状态并判断是否发生过背压。实际测试下来FIFO越大系统能够容忍的延时抖动越大但代价是引入的延迟和FPGA资源占用都会增加所以这个值要跟你的采集场景匹配而不是越大越好。中断产生也有讲究。AXI DMA完成一次描述符传输之后可以产生中断但FPGA端如果只是简单地把每个描述符完成都上报给Linux那高吞吐时中断频率会相当吓人。我的做法是在FPGA端设计一个中断汇总逻辑比如每完成4个或16个描述符才产生一次中断或者引入一个定时器超过一定时间没有累计到阈值也发一次中断保证偶尔的低流量场景下响应不会太慢。这个思路跟网络收包里的NAPI机制非常像Linux侧也能通过NAPI配合明显降低CPU占用率。3. Linux与ARM64侧驱动架构落地3.1 内核侧DMA引擎驱动怎么组织Linux内核里有一套DMA engine框架专门用来抽象各种DMA控制器。hs_dma_framework并不直接操作Xilinx AXI DMA寄存器而是通过xilinx_dma驱动注册到DMA engine框架中然后使用标准API申请通道、提交传输。驱动初始化时先用platform_driver_register注册在probe里拿到设备树里的DMA节点、中断号和时钟信息。接着用dma_request_channel申请一个S2MM通道如果失败就要检查设备树里的dma-channel别名是否配好。然后需要给通道绑定一个环形缓冲区这个缓冲区由DMA API分配一般是dma_alloc_coherent它返回的是虚拟地址、总线地址并且保证一致性映射。核心里配置好通道的segmented buffer属性因为AXI DMA需要一组描述符而不是单个连续的物理内存。一个容易忽略的点是DMA方向的cache一致性。通常的PCIe NIC是设备访问CPU内存CPU和DMA硬件都要共享数据所以要用dma_map_single配合sync操作或者干脆用dma_alloc_coherent。dma_alloc_coherent分配出来的内存是uncached或者强一致性的因此CPU访问这些缓冲区的速度可能没有普通内存快但对于纯DMA写入CPU一般只做读操作影响不明显。如果追求更快的CPU读速度可以用dma_map_single做SW direction映射然后在每次DMA完成后用dma_sync_single_for_cpu做无效化操作关键是不能漏掉任何一次同步否则会看到老数据。我在hs_dma_framework的驱动里为每一块缓冲区都记录了dma_address、size和方向属性并且提供了一组ioctl来配置缓冲区数量、触发阈值和中断合并参数。这样应用层可以灵活调整而不是把参数写死在驱动里。3.2 设备树配置与地址映射设备树是这个系统的重要组成部分尤其当FPGA逻辑里例化了AXI DMA后Linux启动时需要通过设备树知道DMA控制器挂在哪条总线上、中断号是什么、寄存器基地址是多少。我在实际项目里用的是简单的file hardware描述。下面是一段典型配置具体地址需要按你的工程调整axi_dma_0: dmaa0020000 { compatible xlnx,axi-dma-1.00.a; reg 0x0 0xa0020000 0x0 0x10000; dma-channels 1; #dma-cells 1; dma-channel0 { compatible xlnx,axi-dma-s2mm-channel; interrupts 0 29 4; xlnx,datawidth 0x40; xlnx,include-dre 0x0; }; };注意中断号需要结合你的中断控制器检查有些平台里是高电平有效有些是边沿触发如果中断触发方式不对最常见的现象就是偶尔丢失中断或者中断风暴。我在开发hs_dma_framework时最初用错触发类型DMA传输明明已经完成内核一直收不到中断后来用一个简单的FPGA计数器来触发测试逐个排查才定位出来。用户态要访问采集到的数据一般是通过mmap把内核里的DMA缓冲区映射给应用进程。只要驱动里的mmap实现了remap_pfn_range应用层就可以拿到一个直通缓冲区的虚拟地址不需要再用read()把数据拷一份出来。零拷贝路径是高速采集系统吞吐的关键尤其在数据量达到每秒几百MB时内核对内核和应用之间的拷贝会造成巨大开销。如果数据需要边采集边看用mmap之后还可以在应用侧再用几个线程分别做不同段的处理整个流水线就很灵活了。3.3 中断处理与CPU亲和性中断处理如果设计得不当会让CPU忙个不停导致数据处理的用户态线程抢不到时间片。hs_dma_framework默认开启了中断合并也就是让FPGA侧每完成多笔传输才触发一次中断同时在内核侧把中断处理函数做成对半处理一半在中断上半部完成必要的工作比如读取状态寄存器、清中断另一半放到tasklet或者工作队列里做缓冲区处理。如果能使用threaded IRQ那就更好直接在irq_handler线程里处理不容易阻塞其他软中断。中断的CPU亲和性也值得关注。在四核ARM64平台上我一般把做数据搬运和网络转发的进程绑定到某个CPU核把用户态算法进程绑定到另一个核中断默认路由到第一个核。这样可以减少cache line bouncing。实测下来当所有处理线程都集中在一个核上时DMA中断和数据处理相互争抢吞吐下降明显拆开之后整体吞吐能提升两成多。你可以在Linux用户态用taskset设置CPU affinity也可以在内核里通过irq_set_affinity_hint把中断固定到指定CPU。3.4 用户态代码和API稳定性为了让这块平台调用起来足够简单hs_dma_framework的用户态接口尽量压缩成几行代码。初始化时打开/dev/hsdma0用ioctl配置通道模式和缓冲参数启动采集后数据流通过mmap映射的一段环形缓冲区对外呈现结束后再关闭释放。开发者不需要了解描述符怎么摆放也不需要关心缓存同步框架把这部分都封装好了。对短时间用不到底层的人甚至可以拿现成的C接口直接对接自己的控制台或者GUI程序。用户态接口我会给两个操作模式一种是同步等待模式应用进程调用read或poll阻塞直到内核收到一批数据后唤醒它另一种是异步模式应用层注册自己的回调或者直接用事件fd。对大多数采集场景pollread的同步模式已经够用也更直观。如果要做流水线并行那么异步模式会更合适但复杂度也随之增加。4. 参数选择、性能验证与调优实录4.1 关键参数到底怎么定hs_dma_framework涉及的参数很多但最核心的其实就四个DMA缓冲区长度、描述符深度、中断合并阈值、FIFO水位。每个参数都有相互制约的关系不能单独调。DMA缓冲区长度决定了每次批量搬运能写多少数据。我通常根据期望的采集带宽来估算。比如目标是1GB/sDDR带宽在ARM64平台上足够那么缓冲区长度可以设置为4MB到16MB。太小的缓冲区会让DMA频繁切换描述符中断也频繁太大的缓冲区又会让延迟变得很高用户在示波器类软件里会感觉数据总是慢半拍。描述符深度则决定了环形队列能放下多少待处理的传输任务。深度和缓冲区长度相互配合假设每个描述符指向一个1MB内存块描述符深度为64那么总缓冲是64MB如果采集速率为1GB/s缓存最多能扛住约64ms的主机端处理停顿。对Linux这种非实时系统来说预留几十毫秒的缓冲余量非常必要因为一次进程切换或者一次系统调度的峰值延迟可能就有几毫秒到几十毫秒。中断合并阈值影响CPU占用率和实时性的平衡。阈值越低响应越快但中断频率越高CPU占用越高阈值越高CPU越省但数据处理的延迟越大。我一般会先在调试阶段把阈值设成最小值等确认整个链路无丢失之后再逐渐增大找到一个吞吐稳定且延迟可接受的区间。FIFO水位用于FPGA内部缓存需要根据上游数据生产者是否连续来决定。对于连续ADC输出FIFO的水位建议设置在50%到75%左右留出另一半空间作为突发余量。4.2 验证环境的搭建步骤拿到一块带ARM64核的FPGA开发板第一步建议先跑最基础的硬件测试而不是直接上整个框架。我会先做一个固定计数器的FPGA工程每隔一段时间产生一批连续递增的数据通过AXI DMA发到DDR里。然后在内核里分配一小块缓冲区用简单的字符设备reader把数据读回对比是不是1、2、3、4这样连续的值。这样可以把问题范围聚焦在“DMA搬运是否正确”上。基础验证通过之后再替换成真实的ADC采样数据同时加长采样时间。因为真实数据往往有噪声和固定模式反而很难一眼看出错位。如果你手头没有高分ADC也可以先用DDS或者PWM调制的伪随机序列送到FPGA让FPGA打包后发给DMA把伪随机序列放在用户态对比校验。性能验证时最直接的工具是在内核驱动里记录DMA成功完成的字节数用户态处理线程记录收到的字节数二者相减就是丢帧量。hs_dma_framework驱动里有一个调试节点/sys/kernel/debug/hsdma/stats读它会看到累计传输次数、失败次数、丢数据计数和中断次数。这个统计节点对排查问题太重要了建议每个做类似框架的人都有意识地加一套。4.3 实测中遇到的两个典型瓶颈第一个瓶颈是DDR带宽竞争。当FPGA端持续写DDRDDR控制器还要应付ARM64侧操作系统的页面清理、文件系统读写等。如果DMA通道和CPU访问同一个DDR bank很容易出现带宽互相挤压。解决方式就是利用多通道DDR布局把DMA和使用带宽的CPU应用分散到不同bank或者在FPGA侧把DMA的AXI端口优先级调高。调优先级要适度否则CPU侧会出现明显的卡顿。第二个瓶颈是ARM64平台上的TLB miss。当用户态通过mmap访问很大的DMA缓冲区时如果用的是4KB页访问顺序遍历几十MB会产生大量TLB miss和cache miss。解决方案是启用透明大页或者显式使用hugepage。我实测在ZU7EV上把DMA缓冲区改用2MB的HugePage之后同样的处理代码吞吐大约提升了10%到15%某些频繁遍历数据的代码提升更明显。不过HugePage的管理逻辑和应用侧malloc的方式不太一样需要提前分配并固定物理内存所以hs_dma_framework里做了一个简单的内存池专门管理这类大页缓冲区。5. 常见问题与解决速查5.1 数据错位和乱序现象用户态收到的数据不是预期的连续递增序列而是间歇性出现重复或缺失。排查顺序先确认FPGA侧打包逻辑是否正确在发送到AXI DMA的Stream数据里插入一个递增的帧序号靠着帧序号判断是不是数据在搬运过程中发生乱序。其次检查描述符长度与DMA缓冲区长度是否匹配很容易出现“前一次传输多写了几个字节后一次传输从错误的偏移开始写”的问题。最后确认是否做了足够的cache同步。如果你用dma_map_single而没有在每次传输完成后调dma_sync_single_for_cpu那么CPU读到的可能是缓存里的旧数据看起来就是“有数据但内容不对”。5.2 缓冲溢出导致丢包现象系统跑一段时间后统计信息里的丢数据量持续上涨。最直接的原因是用户态处理速度跟不上采集速度。可以先降低采集速率到原来的十分之一看是否仍然丢数据如果不丢就逐步提高速度找到临界点。其次要看描述符深度和中断合并阈值如果描述符环太小或中断合并阈值过高即使CPU算力足够也可能因为没有及时取走完成描述符而溢出。最后检查FIFO水位如果FPGA端FIFO在大量背压时溢出Linux无论如何提速都救不回来因为它已经在源头丢了。5.3 中断风暴现象CPU占用率居高不下ls /proc/interrupts看到DMA中断次数每秒几十万次用户态处理线程几乎饿死。此时需要降低中断合并阈值不要设太低同时检查FPGA端的中断状态寄存器有没有清除干净。如果中断未清除硬件会不断重触发中断形成风暴。另外一个容易被忽略的点是IRQ触发模式如果设备树里配置了电平触发但实际硬件是沿触发那么每次状态变化都可能产生大量无效中断。这时候在驱动里打印irq status并且对比FPGA侧寄存器基本能定位。5.4 DMA缓冲区与物理连续内存现象驱动初始化时dma_alloc_coherent失败。通常是因为一次申请的内存过大。把缓冲区拆成较小的多个块或者开启CMA并调整cma大小。ARM64平台的CMA大小可以在内核启动参数里设置也可以编译时配置。还要注意DMA缓冲区不要放在普通kmalloc能分配的范围内否则受限于碎片申请大块连续内存很容易失败。6. 框架后续扩展与一点个人体会hs_dma_framework做到现在我自己最大的体会是软硬协同系统最怕的不是单点速度不够而是链路中某处“等待”。FPGA端写得再好Linux侧驱动如果固守旧式中断拷贝带宽一样起不来用户态程序再花哨内核缓冲区如果没做好缓存同步数据照样错误。框架只是把这些环节用一种相对标准的方式串了起来真正关键还是设计者对每个环节的理解。后续我打算在三个方面继续扩展第一个方向是用内核的AF_XDP或DPDK类技术把DMA缓冲区和网络协议栈进一步解耦让采集数据直接发到远端服务器。第二个方向是加入更多调优接口比如动态调整中断合并阈值根据当前负载自动调节FIFO水位。第三个方向是做一个更友好的用户态图形界面能实时显示采集波形、丢包率、DMA带宽和CPU占用方便现场调参。毕竟数据采集系统只有让人做到心里有数才敢放心长时间运行。最后分享一个调试小技巧在FPGA端加一个简单的脉冲计数器对外引出到GPIODMA每搬运一个固定块就翻转一次电平用示波器观察这个引脚的频率就能直观判断瓶颈是在FPGA侧还是Linux侧。这个办法虽然土但排查隔山打牛的问题时真的非常管用。
返回列表