做高速数据采集的人大概都遇到过同样的烦恼: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,内存接口能跑到DDR4,FPGA部分又有丰富的收发器和逻辑资源。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核,内部包含S2MM(Stream to Memory-Map)和MM2S(Memory-Map to Stream)两条通道。hs_dma_framework里用的是S2MM方向,ADC数据由FPGA内部逻辑打包成AXI-Stream格式,连续送往AXI DMA,AXI DMA按照描述符把数据写到DDR中由Linux分配的缓冲区里。
在一些更高速的场景下,单个AXI DMA通道可能带宽不够,比如ADC输出超过了DDR控制器的理论带宽,或者AXI DMA本身成为瓶颈。这时我通常会再挂一个AXI DataMover或者自己做定制状态机。但从工程成熟度看,Xilinx官方AXI DMA在大部分项目中都够用,而且驱动可以直接结合Linux DMA engine,省去很多造轮子的时间。如果你的数据源是ADC直连的LVDS或者JESD204B,AXI 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: dma@a0020000 { compatible = "xlnx,axi-dma-1.00.a"; reg = <0x0 0xa0020000 0x0 0x10000>; dma-channels = <1>; #dma-cells = <1>; dma-channel@0 { 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。对大多数采集场景,poll+read的同步模式已经够用,也更直观。如果要做流水线并行,那么异步模式会更合适,但复杂度也随之增加。
4. 参数选择、性能验证与调优实录
4.1 关键参数到底怎么定
hs_dma_framework涉及的参数很多,但最核心的其实就四个:DMA缓冲区长度、描述符深度、中断合并阈值、FIFO水位。每个参数都有相互制约的关系,不能单独调。
DMA缓冲区长度决定了每次批量搬运能写多少数据。我通常根据期望的采集带宽来估算。比如目标是1GB/s,DDR带宽在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端持续写DDR,DDR控制器还要应付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端加一个简单的脉冲计数器,对外引出到GPIO,DMA每搬运一个固定块就翻转一次电平,用示波器观察这个引脚的频率,就能直观判断瓶颈是在FPGA侧还是Linux侧。这个办法虽然土,但排查隔山打牛的问题时真的非常管用。