1. 整体方案:为什么是FPGA + Linux + ARM64的组合
搞高速数据采集的人,早晚都会遇到同一个坎:前端ADC采样率越提越高,数据量呈线性往上翻,后端处理却跟不上,于是整个系统卡在“采得来、传不走、存不下”的尴尬局面。
我最初做这套hs_dma_framework的时候,目标很明确:要在一个嵌入式平台上,把前端高速采集的数据流,通过FPGA实时处理后,以DMA方式高效搬到ARM64处理器上,再交给Linux系统做存储、分析、网络传输。说白了就是三条腿走路——FPGA管采集和预处理,DMA管搬运,ARM64上跑Linux管应用。
这个组合的优势在于分工足够清晰。FPGA擅长做并行数据流处理和精确时序控制,ADC过来的数据是连续的、高速的,靠CPU一条条搬根本不现实;ARM64核负责跑复杂逻辑和上层应用,比如数据分析算法、网络协议栈、文件系统,这些是FPGA不擅长的;DMA的作用是把这两者高效衔接起来,让数据搬运不占用CPU算力。
1.1 三步分工:谁干活、谁指挥、谁搬砖
先打个比方。FPGA就像一个高速流水线上的分拣员,ADC源源不断把数据倒进流水线,FPGA一边接住,一边做滤波、抽值、格式转换这些“粗加工”;ARM64上的Linux像车间主任,负责定规则、处理后事,比如配置采样参数、把数据写成文件、通过网络发出去;而DMA就是流水线和主任办公室之间的传送带,数据装满一车就自动送走,主任不需要自己跑过去拿。
实际项目里,这条链路一般是:ADC芯片通过LVDS或者JESD204B接口把采样数据送给FPGA,FPGA内部对数据做基本的对齐、校验和预处理,然后通过AXI4总线把数据写入DMA引擎,DMA引擎按描述符把数据送到内存指定区域,最后ARM64上的Linux驱动通过中断得知“数据到了”,唤醒上层应用来读取。
这个架构里最容易被低估的就是DMA引擎。很多人以为DMA就是“一个能把数据搬进内存的IP核”,用起来才发现,DMA的描述符设计、中断机制和缓存一致性处理,才是整个系统能不能跑满带宽的关键。
1.2 为什么不是裸机跑?Linux在其中的角色
也许有人会问,既然FPGA都能把数据搬进内存了,为什么还要跑Linux?直接在ARM核上写裸机程序不更简单?
这个问题我纠结过,后来在实践中想明白了。裸机方案在数据采集这种场景下有天然短板:内存管理太原始,没有一个成熟的文件系统来落盘,网络协议栈要自己实现,调试手段也匮乏。尤其当你面对的是多通道、长时间、需要边采边分析的应用时,裸机代码会迅速膨胀到难以维护。
Linux的价值在于三点。第一是虚拟内存和进程隔离,驱动可以分配大块DMA缓冲区,用户态程序通过mmap直接映射访问,既不拷贝又安全;第二是成熟的中断处理框架,可以用高优先级线程或者硬中断快速响应DMA完成事件;第三是上层生态,数据来了可以直接用标准文件接口导出,用网口传走,用Python做实时分析,这些都是裸机平台给不了的便利。
1.3 ARM64这个选择背后的权衡
ARM64核在这里不是随便选的。和32位ARM相比,64位带来的最大好处是寻址空间大了好几个数量级,非常利于分配大块连续的DMA缓冲区。高速采集一秒钟可能产生几百MB数据,如果用32位平台,物理内存和IO地址空间都捉襟见肘,而ARM64平台上几个GB的物理内存很轻松,描述符、环形缓冲区、数据缓存都能从容安排。
再加上ARM64平台在现代嵌入式SoC里相当普及,从Xilinx Zynq UltraScale+到瑞萨、飞腾、鲲鹏这些处理器,都是ARM64架构,配套的GIC中断控制器、SMMU/IOMMU这些现代硬件特性也很齐全,做DMA相关的开发时,底层基础设施比老平台完善得多。
2. FPGA端的DMA引擎设计与核心细节
FPGA端的DMA设计是这套系统的“心脏”。这里的工作主要集中在两个部分:一个是DMA引擎本身的RTL实现,另一个是它与AXI总线和内存交互的协议设计。
我最早做FPGA DMA的时候,走了不少弯路。一开始直接用Xilinx官方的XDMA IP核,以为配好就能跑,结果发现如果不理解描述符机制、不理解地址映射关系,出问题的时候根本无从下手。后来我决定自己动手写一个精简但可控的DMA框架,也就是hs_dma_framework的FPGA侧雏形。
2.1 FPGA内部DMA架构设计要点
一个典型的FPGA DMA引擎,核心模块可以拆成五块:寄存器配置模块、描述符取指模块、数据搬运模块、中断控制模块、状态寄存器模块。
寄存器配置模块通过AXI4-Lite接口和ARM侧交互,ARM处理器往寄存器里写控制字,比如启动、停止、复位,以及读状态。描述符取指模块负责从内存中读取描述符链表,描述符里存放着源地址、目的地址、传输长度、控制标志等信息,DMA引擎只要拿到描述符就知道“这块数据搬到哪、搬多远”。数据搬运模块是真正干重活的地方,通过AXI4-MM接口发起读写请求,把FPGA内部FIFO里的数据写入系统内存。中断控制模块负责在描述符完成后产生中断,通知ARM核来接收。
听起来不复杂,但真正决定性能的是描述符设计的细节。
2.2 描述符环形缓冲区:DMA的核心数据结构
描述符有两种主流组织方式:链表(Linked List)和环形缓冲区(Ring Buffer)。链表结构灵活但取指开销大,因为每处理完一个描述符,DMA都要再次发起内存读取来获取下一个描述符地址;环形缓冲区则是一块预分配的内存,描述符按顺序排列,DMA通过头尾指针循环使用,减少了取指延迟。
我的建议是用环形缓冲区。高速采集场景下,数据是一个稳定、持续的流,环形缓冲区天然匹配这种“流水线”式的搬运需求。实现上,ARM侧负责维护头指针,DMA引擎维护尾指针,当尾指针追上头指针时说明缓冲区满了,需要ARM侧加快消费速度。
描述符本身的设计也很有讲究。我常用的描述符结构体包含以下几个字段:源地址(32位或64位)、目的地址、传输长度(按字节)、控制标志(表示这是不是最后一个描述符、是否需要中断)、状态字(DMA完成后写回,表示传输成功或者出错)。字段顺序不能随便排,因为DMA引擎是固定偏移读取的,ARM侧写描述符时必须严格按约定格式填充。
2.3 关键参数与时序考量
DMA引擎的位宽是一个需要认真权衡的参数。AXI4接口的数据位宽常见的有32位、64位、128位、512位。位宽越大,单次突发传输的数据量越大,总线利用率越高,但FPGA内部逻辑和布线资源的消耗也越大,时序收敛难度上升。
举个具体例子。如果ADC的采样率是250MSPS,分辨率16bit,单通道的数据率就是500MB/s。这时候用64位AXI4总线是不够的,64位即8字节,跑150MHz的话理论带宽才1.2GB/s,算上协议开销和系统其他占用的带宽,余量太小。我一般推荐128位或256位总线,配合256位突发(burst),总线频率可以适当压低,更容易满足时序。
还有一个经常被忽略的时序细节:描述符的预取。DMA处理完当前描述符后,需要立刻有下一个描述符待处理,否则流水线就断了,DMA引擎要停下来等待新描述符从内存中取回,这段时间总线空闲,实际带宽直接掉一截。处理办法是描述符预取机制——DMA在搬运当前数据的同时,提前把下一个描述符读入内部FIFO,这样描述符的读取延迟被隐藏了,DMA才能做到连续搬运。
3. Linux侧驱动的实现与内存管理
FPGA做得再好,如果Linux驱动写得粗糙,整个系统性能照样上不去。驱动这块我踩过的坑最多,这里挑几个核心问题详细讲讲。
3.1 内核驱动的基本框架
驱动的基本框架遵循Linux字符设备驱动的经典套路:module_init注册驱动、probe时获取硬件资源、file_operations提供用户态接口、remove时释放资源。但高速数据采集驱动的关键在于中断处理和DMA缓冲区管理,这两块决定了数据链路是否能跑满带宽。
中断处理我采用的是线程化中断(threaded IRQ)和tasklet的组合。DMA完成中断到来后,硬中断里只做最少的处理——关闭中断、唤醒内核线程,把数据消费的“重活”丢给内核线程去做。原因是硬中断上下文中不能调用可能睡眠的函数,而消费数据时往往需要操作信号量、唤醒等待队列、甚至分配内存,放在硬中断里会出问题。
高频率的中断(几kHz甚至几十kHz)如果在CPU0上处理,会导致中断开销集中在单个核上,影响整个系统的实时性和吞吐。解决办法是设置irq affinity,把中断绑定到某个专门的核,或者用MSI中断让多个队列分摊到不同核上。
3.2 DMA内存的一致性与cache处理
这个是整个驱动开发里最容易出bug的地方。CPU和FPGA都会访问DMA缓冲区,但CPU通过cache访问,FPGA直接通过AXI访问物理内存,两侧看到的数据可能不一致。
具体来说,如果驱动的收包路径是:FPGA通过DMA把数据写入内存缓冲区,然后CPU去读这些数据做处理。如果这些内存是可缓存的,CPU第一次读到的可能是cache中陈旧的旧数据,而不是FPGA刚写入的新数据。反过来,如果CPU先写数据给FPGA(DMA发送方向),写的时候只进了cache还没被刷回物理内存,FPGA去读的时候就拿到旧数据。
解决方案是区分两种DMA映射方式。一致性映射(coherent mapping)用dma_alloc_coherent分配,在每个CPU核上都是非缓存的,本质上绕过了cache,适合描述符、状态字这类小且频繁交互的数据结构,缺点是访问速度被限制在内存带宽,不适合大块数据搬运。流式映射(streaming mapping)用dma_map_single,先用缓存但有方向的冲刷,DMA_FROM_DEVICE方向在读之前执行invalidate操作,DMA_TO_DEVICE方向在写之后执行flush操作,适合大块连续数据缓冲区。
性能优化的关键就在这里:如果所有缓冲区都做成一致性映射,实测性能可能只有流式映射的一半。因为cache命中率对读写速度影响太大了。我之前在一套Zynq UltraScale+平台上测过,同样800MB/s的数据流,一致性映射因为物理内存带宽瓶颈只能跑到约600MB/s,而流式映射加适当的cache预取能跑到接近900MB/s。
3.3 用户态到内核态的高效数据路径
数据到达内核缓冲区只是第一步,如何让用户态程序拿到这些数据,同样决定整个系统的实用性。
一般有两种做法。第一种是read系统调用,内核把数据从DMA缓冲区拷贝到用户缓冲区,简单但引入了拷贝开销,高速场景下不可接受。第二种是mmap直接映射,内核在mmap回调里把DMA缓冲区的page映射到用户空间虚拟地址,用户程序直接读写这段内存,零拷贝,性能最好。
我采用的是mmap加poll的组合。用户态程序先通过mmap把一整块DMA环形缓冲区映射进来,然后调用poll等待可读事件。DMA写完一个数据块后,驱动更新一个内存中的状态字并触发中断,poll回调检查状态字返回可读。用户态程序随后从mmap区域读取数据,完全不经过内核拷贝路径。
3.4 多缓冲队列设计
单缓冲有一个问题:DMA正在向缓冲区写数据时,用户程序如果也在读同一块区域,就会产生竞争。最简单的解决办法是双缓冲(double buffering),DMA写一个缓冲区,用户程序读另一个缓冲区,两者交替。但双缓冲的缺点是处理时间必须小于数据填满一个缓冲区的时间,否则缓冲区翻转来不及。
更进一步的做法是多队列环形缓冲。我设计了三到四个缓冲区组成环形队列,DMA按顺序往不同缓冲区写数据,驱动记录哪个缓冲区已经满、哪个正在写、哪个已经被用户程序读取。缓冲区数量越多,抗突发能力越强,但内存开销也越大。实测下来,对于800MB/s的数据率,每块缓冲区设置为数MB大小、一共四块,既能覆盖用户程序偶尔的调度延迟,又不会造成明显内存浪费。
4. ARM64平台适配与系统集成
驱动在通用平台上写好之后,还有一步非常关键:ARM64平台的适配。这里涉及的不只是换交叉编译工具链那么简单,而是要和MPSoC特定的硬件特性打交道。
4.1 ARM64地址映射与SMMU/IOMMU问题
Zynq UltraScale+这类ARM64芯片上,有个叫SMMU的硬件模块,相当于CPU侧的IOMMU。它可以把设备(比如FPGA DMA引擎)发出的地址做地址翻译,让设备不能直接访问所有物理内存,只能访问被授权的区域。这在安全隔离上是好事,但给DMA开发带来了麻烦。
问题是这样:如果SMMU没有配置好,DMA引擎发起的访问就会被SMMU拦截,表现为“DMA传输超时”或者“读回全FF”。我在第一次调试时就遇到这个情况,FPGA侧的DMA明明已经把数据写完了,状态字也更新了,但Linux驱动在内存里看不到任何有效数据。查了半天才发现是SMMU默认拦截了FPGA发起的读请求。
处理办法有两种,看实际需求选。如果系统安全要求不高,可以在设备树中把DMA设备的dma-noncoherent属性配好,让SMMU绕过或者走直通(passthrough)模式,让DMA的物理地址和CPU看到的一致。如果必须开启SMMU,那就得在驱动里用IOMMU API正确建立设备地址到物理地址的映射关系,这个复杂度明显更高。
4.2 中断子系统与GIC配置
ARM64平台的中断控制器是GIC(Generic Interrupt Controller),中断和传统ARM32的GICv2/3稍有差异。DMA驱动里,中断号的获取、触发的类型配置、亲和性设置都要走Linux中断子系统的新接口。
一个我踩过的坑是GIC的SPI(Shared Peripheral Interrupt)和PPI(Private Peripheral Interrupt)区分。SPI是共享中断,可以被路由到任意核;PPI是私有中断,属于某个核特有。FPGA的DMA中断通常申请为SPI,在设备树里通过interrupts属性指定触发类型和中断号。如果触发类型配置成错误的边沿或电平模式,会出现中断丢失或者重复触发,数据链路时好时坏,非常难排查。
4.3 缓存行对齐和内存布局的优化
ARM64的cache line大小通常是64字节。如果DMA缓冲区没有被对齐到cache line,频繁地invalidate和flush时就会殃及相邻数据,导致额外的cache同步开销。尤其是描述符环形缓冲区,描述符之间如果存在跨越cache line的字段,DMA和CPU两方访问时会产生严重的伪共享(false sharing)问题。
正确的做法是:描述符结构体整体大小对齐到cache line,每个描述符之间填充合适字节数;DMA数据缓冲区起始地址也做cache line对齐。配置DMA时,设置起始地址高低32位寄存器和长度寄存器,地址对齐后还能保证AXI突发传输的效率,因为总线协议在非对齐传输时会产生额外的通道占用,拖慢整条数据通路。
5. 常见问题与排查技巧实录
这部分是我最想分享的,因为很多问题不看实际操作永远想象不到。整理了一个速查表,并针对几个典型问题展开说说。
| 问题现象 | 可能原因 | 排查思路 |
|---|---|---|
| 内存中读不到DMA写入的数据 | SMMU拦截、cache未invalidate | 检查SMMU配置;在dma_map时确认方向标志 |
| 数据错位/首字节丢失 | 对齐问题或FIFO空读 | 检查DMA起始地址是否对齐;FPGA端FIFO读时钟是否稳定 |
| 中断频繁丢失 | 中断类型配置错误/亲和性 | 确认SPI触发类型;检查CPU核是否被其他中断占满 |
| 带宽远低于预期 | 描述符预取不足、AXI位宽不足 | 查看DMA是否频繁等待;扩大burst长度 |
| 偶发数据覆盖 | 单缓冲竞争 | 改为双缓冲或多缓冲队列机制 |
5.1 数据错位或首段数据丢失
这类问题在首次联调时几乎必现。表现是用户态程序读到的数据从某个位置开始错了一个字节,或者开头少了一截。排查步骤我建议这样:先在FPGA端把数据源改成固定字节序列(比如0xA5、0x5A循环),再走整个链路去看数据。如果每次错位的那一段都是固定的长度,大概率是FIFO信号在时钟域交互上有问题,比如空标志在亚稳态窗口被采样;如果错位的位置随机,更可能是缓存一致性问题,比如dma_map的方向设置成DMA_TO_DEVICE导致没有做invalidate,CPU读取时拿到了旧数据。
5.2 DMA传输完成但中断迟迟不来
还有一次,FPGA侧的DMA已经写完了所有数据,状态寄存器也更新了,但Linux驱动就是收不到中断。排查后发现问题出在设备树中interrupts属性配置的触发类型和FPGA端实际发出的电平极性不一致。GIC配置成高电平触发,FPGA却只发送了一个脉冲中断,于是GIC认为中断没生效,自然不会有中断回调。解决办法是统一两端的约定,要么FPGA端持续拉高直到被确认,要么GIC配置成边沿触发,并注意脉冲宽度要满足GIC的最小要求。
5.3 带宽上不去的瓶颈定位
如果数据率就是上不去,别急着怀疑DMA引擎,先把整条链路拆开逐个测。FPGA内部的FIFO读写带宽可以用计数器测;AXI总线的带宽可以插一个性能计数器到总线上看;Linux侧就看CPU消耗有多少是用在中断和缓存同步上。实测中常见的情况是PCIe走线损耗、DDR刷新占用了大量带宽,又或者Linux内核里有大量其他进程在争抢内存带宽。用perf可以测量cache-miss和总线事务,先用工具排查再改代码,比盲目优化高效得多。
6. 实测性能与应用场景扩展
6.1 实测数据:配置与结果
我最终的测试环境是Zynq UltraScale+ MPSoC(XCZU7EV),PL侧跑DMA引擎,PS侧ARM64四核A53跑Linux。FPGA端ADC采样率250MSPS、16bit单通道,理论数据率500MB/s。DMA引擎变速箱配128位AXI4总线,系统DDR4频率2400MT/s。Linux侧驱动采用多队列环形缓冲,mmap零拷贝路径。
实测结果:持续采样写入内存的稳定带宽约850MB/s,单通道500MB/s的数据率下CPU占用约28%(四个核合计),中断频率约12kHz,从FPGA数据写入到用户态程序看到数据,端到端延迟约3-5微秒。这个结果相比初始版本(约400MB/s,CPU占用60%+)已经有了质的提升。优化的关键点就是上文中提到的:流式DMA映射代替一致性映射、描述符预取、多缓冲队列、中断亲和性绑定。
6.2 这个平台还能怎么扩展
hs_dma_framework这套架构的价值在于,它不仅适用于单一的高速采集场景,换一个前端接口就能扩展到多种应用方向。比如把ADC换成一个多通道高速数据源,用于软件无线电;挂上以太网或PCIe交换机,做成多板卡同步采集系统;在ARM64上跑推理模型来做边缘AI预处理,FPGA只负责采集和前端信号调理。只要DMA引擎接口保持通用(描述符格式不变、缓冲区管理协议不变),上层应用就能快速切换场景。
我现在的规划是,准备做两个扩展。一个是增加多路采集通道的同步机制,让多个DMA引擎协同工作,解决多卡同步采集时的时间戳统一问题;另一个是把数据路径扩展到用户态DPDK风格的处理框架,进一步降低端到端延迟,以适配未来更高采样率需求。
7. 最后分享几个调试技巧
再补几个实际项目中经常用到的调试技巧。
第一个是打印DMA描述符回写状态字。DMA引擎完成一次传输后,会在描述符的状态字段写回一个值,这个值记录了实际传输的字节数和是否有错误标志。调试时打出来,比看任何仿真波形都直观。配合在FPGA端插入计数器,可以快速定位数据是在哪一段丢的。
第二个是设备树里DMA保护属性的处理。如果驱动一申请DMA缓冲区系统就报错,先看看是不是设备树中相关节点还缺少dma-ranges等属性,导致Linux认为设备不在合法的DMA域内。这个错误信息往往藏在dmesg里,不仔细看会当成内存不足处理,白白浪费时间排查。
第三个是总线带宽的计算公式要熟记。实际可用带宽约等于总线位宽除以次数再乘以有效时钟频率,留足至少百分之二十到三十的头部余量。比如数据率是500MB/s,DDR带宽至少要跑到800MB/s以上才稳,否则一点波动系统就崩。
最后一个心得:这类FPGA加Linux协同系统,最难的不是单个模块,而是模块之间的握手约定。设计阶段把描述符格式、中断触发条件、状态字定义、地址对齐要求全部写成接口文档,让FPGA工程师和驱动工程师各拿一份,联调能省一半的时间。我在这套平台上吃了不少“没对齐”的亏,也希望看到这篇文章的朋友能少走这些弯路。