☰
FPGA+Linux+ARM64高速数据采集DMA框架架构与实践详解
2026/9/25 6:33:42 网站建设 项目流程

做高速数据采集这几年,我最大的感受是:FPGA 端把数据铺满了,CPU 端却经常喂不饱。要么中断太多把核打满,要么缓存一致性没处理好,搬回来的数据全是脏的。这次要聊的hs_dma_framework,就是一个把 FPGA 采集、Linux 驱动、ARM64 处理三件事串起来的完整平台。它不是为了做 demo,而是为了解决真正的高吞吐采集问题:ADC 或图像传感器不停往外吐数据,CPU 要能在尽可能低的占用率下把数据拿进内存,再交给应用层做实时处理。

这套东西适合三类人看:FPGA 工程师想知道自己这边该出什么接口;Linux 驱动工程师想知道 ARM64 下 DMA 和中断怎么配才稳;做异构采集系统的架构师想找一个能直接抄作业的软硬件划分方案。我会把设计取舍、关键机制、实际落地步骤和踩过的坑都缠在一起讲,不绕弯子。

1. 整体架构与设计思路:为什么非要用 FPGA + Linux + ARM64

1.1 这个组合到底解决了什么

先说一个最常见的场景:你要做一台 16 通道、每通道 250 MSPS 的高速数据采集设备。FPGA 从 ADC 收到数据后,本地做个滤波、抽取、FFT 或者只是简单打包,然后就要交给上层做存储、分析、显示。这里面的矛盾很直接:数据速率太高,CPU 直接轮询寄存器是绝对不行的;中断一多,CPU 全花在上下文切换上;如果每次搬运都要 CPU 手动写几十个描述符再启动 DMA,那也是灾难。

所以整个平台的核心思路是:FPGA 只负责产生数据、保证时序和协议正确,CPU 通过 DMA 描述符批量接收数据,驱动只在一批数据完成后收到一次中断。瓶颈不能用 CPU 硬扛,要靠专用硬件通道和合理的软件分层来分摊。

1.2 为什么 ARM64 而不是高配 x86 工控机

很多人问过这个问题。x86 工控机当然也能做采集,但在这个场景里 ARM64 有几张牌没办法替代:第一,典型 ARM64 SoC 会把 FPGA、DDR、DMA 控制器放在一个更紧的互联拓扑里,物理距离短,访问延时低;第二,ARM64 平台上用 Linux 的 DMA API 映射出的一致性内存,在硬件上本身就支持 cache 一致性或者有对应的 barrier 处理,不像某些 32 位平台需要绕路;第三,整板功耗和体积是采集系统绕不开的指标,ARM64 阵列能塞进更小的结构里。

当然,ARM64 也不是万能药。它的 PCIe 枚举习惯、中断控制器(GIC)配置、IOMMU 行为跟 x86 有差异。这些差异在我最初移植驱动时狠狠敲了一棒。所以后面所有关于设备树和中断的描述,都是 ARM64 视角,拿 x86 的直觉去套会翻车。

1.3 hs_dma_framework 的模块划分与数据流

这个框架我把数据通路分成三段:

  • 前端采集段:ADC/传感器 -> FPGA 逻辑。FPGA 负责把异步数据率变到 AXI4-Stream 时钟域。
  • 搬运段:FPGA 内 DMA/Pipeline -> 片外 DDR。这里的"搬运"不是简单把数据挪个地方,而是配合描述符实现批量、连续、低延迟的写入。
  • 协议段:ARM64 Linux 驱动注册的 DMA 通道、中断处理和用户态 mmap 出来的 ring buffer。

每一段都有独立的任务,段与段之间用标准的 AXI4-Stream 握手信号和内存描述符表对齐。我把这套接口固定下来后,前端逻辑可以换 ADC 型号,后端应用可以换上位机框架,中间都不用动。

2. 核心机制与关键技术点拆解:DMA 不能光会搬数据

2.1 描述符机制:真正决定吞吐上限的细节

我见过很多"FPGA DMA 项目",本质就是把数据往内存地址一写,CPU 再读回来。这在低速时没问题,但到 Gbps 量级就崩了。hs_dma_framework 用的是分散聚合(Scatter-Gather)描述符链,每个描述符里包含:目标物理地址、数据长度、完成标志、下一个描述符指针。FPGA 的 DMA 控制器顺着这个链把数据写进分散的内存块。

为什么要分散?因为 Linux 内存在长时间运行后不可能给你一整块连续物理内存。如果强制 1GB 连续内存,系统内存管理和分配都会出问题。分散聚合允许你把多个 4KB、64KB 的物理页拼成一个大逻辑缓冲,硬件自己跳转,CPU 无需干预。

这里有个关键参数:描述符个数和单个描述符大小。我调试时先把单个描述符长度和 L2 CACHE LINE 对齐,通常 64 字节或 128 字节。这样 FPGA 端 DMA 写描述符的标志位时不会同一个 cacheline 被不同通道争抢。实际上一开始我没对齐,两路 DMA 同时更新相邻描述符,造成数据互踩,这个问题在 4.2 节我可以展开讲。

2.2 ARM64 上的 cache 一致性:最容易踩的坑

FPGA 写内存,CPU 读内存。如果 CPU 缓存里还留着旧数据,读出来的就是脏数据。这个问题在 FPGA 侧没有缓存,只在 CPU 侧有 L1/L2,所以软件必须明确一致性边界。

hs_dma_framework 在 ARM64 上用了两类映射方式:

  • 流式映射:dma_map_single()或dma_map_sg()。它把内存块映射到设备访问地址,并在 CPU 访问前做 invalidation。适合一次性收发,比如网络包。
  • 一致映射:dma_alloc_coherent()。它返回一个既适合 CPU 访问也适合设备写的内存区,硬件或软件保证两者不会看到脏数据。适合长时间跑的流式采集。

在 ARM64 上,dma_alloc_coherent()返回的地址是 non-cacheable 的映射,CPU 访问时会绕过缓存。牺牲了一点点 CPU 访问速度,但换来了确定性,采集场景里确定性比那点速度重要。

注意:如果 FPGA 端修改了数据,CPU 端想读,必须保证硬件写完成后才让 CPU 去读。流程上要有一个同步机制——不能只依赖 cache flush,还要在驱动里加一个"数据 ready"的标志,这个标志必须用 DMA 屏障来保证顺序。

我试过跳过缓存操作直接读,开始 100MB 数据看着没问题,跑几小时后突然出几个坏点。这种偶发问题最烦躁,后来统一改成一致性映射加状态标志才根治。

2.3 中断策略:把 CPU 利用率从 50% 降到 5%

采集卡的中断设计可以直接决定系统能不能用。FPGA 每搬运完一个描述符就发一个中断,那 1Gbps 数据按 4KB 一段算,每秒要几十万次中断,CPU 直接瘫。

hs_dma_framework 用了中断聚合和批处理两个机制:

  1. 中断聚合(Interrupt Coalescing):FPGA 端计数器攒够 N 个描述符完成或者超过时间阈值才拉一次中断。N 的取值通常是帧大小 / 描述符长度。比如图像一帧 2MB,描述符 64KB,那么 32 个描述符完成才中断一次。
  2. 环形缓冲区批量收割:驱动收到一次中断后,不逐个读描述符,而是扫描环形缓冲的写索引。因为有硬件写指针,驱动只需比较头尾指针就能知道哪些描述符完成了,一次性处理整个批次。

这样调整之后,实测一块 10 Gbps 采集卡,CPU 占用从原来的 40% 降到 4% 左右。中断频率从每秒几万次降到每秒几百次,CPU 内核对采集任务的开销几乎可以忽略。

3. 平台落地实施:从设备树到用户态的一条龙

3.1 硬件环境与 FPGA 侧接口

我的参考平台是一块 ZYNQ UltraScale+ 系列的 ARM64 SoC,FPGA 内部实现了 AXI DMA IP,并把采集接口暴露为 AXI4-Stream。DMA IP 的输出接到 DDR 控制器的 AXI 端口。因为用的是 SoC 内部的硬核,所以不需要额外的 DDR 控制器逻辑。

如果你是纯 FPGA + 外部 ARM 板的分离架构,建议走 PCIe 或 Aurora 接口,而不是自定义并行总线。这样整体软件栈更干净,Linux 下可以挂标准驱动框架,也不用纠结设备树里面怎么描述一根私有总线。

FPGA 侧描述符表的位置要在系统启动早期就确定好。里边的物理地址不能乱写,要通过 Linux 分配好的 DMA 地址来填充。我通常让驱动先分配缓冲区,然后把物理地址和长度传给 FPGA 固件里的寄存器。启动顺序是:先 Linux 启动驱动,分配内存;驱动把内存信息写入 FPGA 寄存器;FPGA 开始采集并使能 DMA。这样避免 FPGA 提前开跑,写坏地址。

3.2 设备树与内核配置的 ARM64 细节

在 ARM64 Linux 里,设备树不只是描述硬件,它直接决定 DMA 通道和中断资源能不能被正确使用。最典型的问题有两个。

第一个是 IOMMU / SMMU 的配置。ARM64 服务器级 SoC 上常常有 SMMU。如果你的设备树节点加了iommus = <&smmu 0x1>,那 DMA 地址就不再是物理地址,而是经过 IOMMU 翻译的 IOVA。这对大内存系统是好事,但对 FPGA DMA 来说有一个坑:FPGA 端描述符里的地址必须是 IOVA,而不是物理地址。你不把 IOMMU 的映射关系理清楚,FPGA 拿到的物理地址和驱动里 DMA API 返回的地址对不上,数据会乱写。

我的做法是:如果 FPGA 搬运的是大块连续数据,且没有安全隔离需求,就先把设备树里的iommus属性去掉,让 DMA 直接走物理地址。等系统规模变大,再引入 SMMU 做设备隔离。调试阶段先绕开 IOMMU 这个变量。

第二个是中断控制器。ARM64 用的是 GIC,设备树里要正确填写interrupt-parent和interrupts。DMA 完成中断一般挂到 GIC 的 SPI(共享外设中断)上,要注意中断号不能与其他设备冲突。我用cat /proc/interrupts验证中断是否真的被触发,而不是只看驱动request_irq是否成功。

设备树节点示例:

hs_dma: dma-controller@a0000000 { compatible = "hs,dma-framework"; reg = <0x0 0xa0000000 0x0 0x10000>; interrupts = <0 84 4>; interrupt-parent = <&gic>; dma-coherent; status = "okay"; };

注意dma-coherent;这个属性。如果外设硬件本身就是一致性的,把它标上能让内核跳过许多 cache maintenance 调用。但我实际测过,它不代表 DMA API 会自动选择一致性映射,驱动的dma_alloc_coherent()还是要用。

内核配置方面,至少要保证这几项是编译进去的:

  • CONFIG_DMA_CMA=y:提供大块连续内存分配。
  • CONFIG_CMA_SIZE_MBYTES=512:根据最大缓冲需求分配 CMA,靠内核命令行或 DT 里调。
  • CONFIG_VFIO_IOMMU_TYPE1=y和CONFIG_VFIO_PCI=y:如果后续要上 VFIO 给用户态直通 DMA。

3.3 驱动结构:一个最小可跑的 kernel module

hs_dma_framework 的驱动我分成了三层,而不是把所有逻辑塞进一个字符设备里:

  1. 平台驱动:负责从设备树拿 DT 资源,初始化 DMA 通道和中断。
  2. DMA 缓冲管理层:负责分配一致性内存、维护描述符链、更新硬件寄存器。
  3. 字符设备层:向用户态提供open/close/mmap/ioctl。

mmap是关键。数据采集应用不希望每次读取都通过read()在内核态和用户态之间复制一次。我通过mmap把 DMA 环形缓冲区直接映射到用户态,应用只需轮询用户态里的写指针,就能拿到新数据。

驱动中缓冲分配简版:

static int hs_dma_alloc_bufs(struct hs_dma_dev *dev) { int i; for (i = 0; i < NR_BUF; i++) { dev->bufs[i].vaddr = dma_alloc_coherent(&dev->pdev->dev, BUF_SIZE, &dev->bufs[i].paddr, GFP_KERNEL); if (!dev->bufs[i].vaddr) return -ENOMEM; } return 0; }

用户态拿到 mmap 地址后,直接读这个 buffer;读的时候 CPU 看到的是经过一致性映射的地址。注意:如果内核里设置了dma-coherent但实际硬件不是真正硬件一致性,要做一次dma_sync_single_for_cpu才能确保 CPU 看到 FPGA 写的所有数据。我在遇到跑飞数据时第一反应就是检查这里。

4. 性能调优、实测与常见问题排查:别急着说带宽不够

4.1 影响实际速率的几个关键参数

很多人以为 DMA 速率只取决于总线位宽和时钟频率,实际上瓶颈更多卡在别的地方。

描述符粒度:描述符长度越小,频率越高,效率越差。因为每个描述符都有开销:FPGA 要读描述符、写状态、跳链。我测过 2MB 的缓冲拆成 1KB 描述符和整块 2MB 单描述符,前者虽然更灵活,但吞吐掉了 20% 左右。最后流式场景我一直用 32KB 或 64KB 的描述符,均匀分配,让 DMA IP 的排队逻辑不至于空转。

缓冲区数量:至少要双缓冲。当用户在用户态读 buffer0 时,FPGA 已经开始写 buffer1。如果只有一块缓冲区,FPGA 必须等 CPU 处理完才能继续,这种停顿对采集系统是致命的。典型的做法是把环形缓冲区做成 8~16 块,每块大小在 4KB 到 1MB 之间。

PCIe/ AXI 数据位宽:AXI4 带宽 = 位宽 × 时钟 / 8。128-bit @ 250MHz = 4GB/s,这看起来很大,但实际如果 FPGA 只使用 8-bit 数据通路的接口,那瓶颈立即降到 250MB/s。所以 FPGA 侧数据流位宽必须尽快抬到总线位宽,不能一直保持小位宽。

DDR 访问模式:FPGA DMA 写 DDR 时,如果总是随机地址,会触发大量的 bank 切换,效率直接减半。HS-DMA 框架里我把每块缓冲区地址按 2MB 对齐,让连续多块落在同一 DDR address interleave 区域内。这个动作纯粹靠软件分配时加ALIGN(2MB)实现,换来了很可观的性能提升。

下面是一组我实测的参考数据:

描述符大小参考速率CPU 占用备注
1KB1.2 GB/s23%中断频繁,效率低
4KB2.4 GB/s15%常规水平
64KB3.3 GB/s6%推荐
2MB 单块3.5 GB/s4%大缓冲场景

4.2 踩坑实录与排查速查表

我挑三个影响最大的坑,放在最前面。

坑一:描述符互相覆盖。现象是采集一段时间后,偶发数据错乱,且错乱位置总在相邻缓冲块边界。原因就是描述符的完成标志位和下一个描述符指针放在相邻的内存,两个描述符位于同一 cacheline 时,CPU 读 A 描述符,缓存预取了 B 描述符,FPGA 后来改了 B,缓存没有失效,CPU 读到的 B 还是旧值。解决办法是所有描述符按 64 字节对齐,并且在一个描述符内加READ_ONCE/dma_rmb()。

坑二:中断被不断竞态触发。驱动中断处理函数里如果读描述符没有一次性读完,FPGA 端可能又拉一个新中断,而驱动此时没清中断状态。表现为cat /proc/interrupts里中断数疯涨。解决方式:中断处理函数里核心部分是关中断,先把所有 completed 描述符收割到软件队列,再打开中断,绝不用一个while循环边收边开。

坑三:IOMMU 导致的地址错乱。最开始没意识到 SMMU,驱动里用dma_alloc_coherent拿到的地址传给 FPGA,看起来地址正常,但 FPGA 写进去之后 CPU 读全空。原因是 DMA API 返回的是 IOVA,不是物理地址;如果设备树里没把iommus关掉,硬件实际写到了经过 SMMU 翻译后的目的地,软件读的物理地址根本不是那一段。排查技巧:看内核日志里 DMA 分配的地址范围,再用debugfs或 FTrace 查看 SMMU 映射表,不一致就是这个问题。

常见问题整理成表给你直接查:

现象可能原因排查工具/命令解决办法
数据前几字节丢失FPGA DMA 还没就绪,就开始读缓冲devmem看 FIFO 水位驱动里加 ready flag
中断次数过多描述符太小cat /proc/interrupts加大描述符粒度
数据偶发错乱cacheline 冲突长时间压力测试复现对齐 64 字节 + 双端 barrier
user 态访问不到数据mmap 偏移不对cat /proc/pid/maps确认 devicemmap方法实现
DMA 接收端收不到FPGA 还在复位dmesg查看驱动 probe 日志FPGA 启动后触发释放复位
带宽上不去描述符链不是预取模式查看 AXI 总线统计使能 FPGA 端 prefetch

4.3 调试工具与实际操作流程

纯绕逻辑打补丁解决不了问题。这套框架下我最常依赖三条调式路径。

路径一:ftrace + tracepoint。在驱动的中断回调和 DMA 完成回调里trace_printk打时间戳,可以清楚看出每个缓冲块从 FPGA 产生到 CPU 可用的延迟。针对高速采集,延迟抖动比平均延迟更关键。只要某次间隔超过平均值的 2 倍,就需要查 FIFO 是否溢出或中断是否有锁竞争。

路径二:devmem 直接读写寄存器。当驱动跑不起来时,先用devmem直接看 FPGA 的 DMA 配置寄存器:

devmem 0xA0000000 32 devmem 0xA0000008 32

如果读出来全是 0,通常是驱动没有映射寄存器,或者设备树reg写错了。如果地址能读,再去查描述符 base,看跟dma_alloc_coherent返回值是否一致。这一步能快速区分"软件配置问题"和"硬件总线问题"。

路径三:QEMU 模拟 ARM64 环境做驱动建模。还没有硬件或者 FPGA 还在综合时,我常用 QEMU 起一个 ARM64 虚拟机,把驱动编译进去,用虚拟设备模拟 DMA 行为。这不等于真实硬件验证,但能先把 Linux 侧的中断处理、描述符管理、mmap 逻辑跑通。等到 FPGA 回来再联调,省掉了大量“驱动语法错误/内存写错”之类低级问题。如果需求比较简单,也可以直接用CONFIG_DMA_API_DEBUG=y开启内核 DMA 调试,它会自动检查映射是否对称、是否在dma_alloc_coherent之后又重复dma_map_single。

4.4 从 QEMU 到实机的移植细节

QEMU 上跑通之后,真正到开发板上还是要花半天确认几个差异。QEMU 虚拟的设备没有真正的中断线,request_irq之后中断回调在模拟环境里可能一次都不触发。所以驱动里要加一个 debugfs 节点,手动触发一次“伪 DMA 完成”,把 main 流程走通。到了实机再关掉这个 debugfs。

还有一些 ARM64 开发板的缓存清理行为比 QEMU 严格。QEMU 中 cache 一致性是模拟的,不刷也能读;真机上不刷就脏读。所以我建议在编写驱动时,无论目标环境是什么,都要严格执行dma_sync_for_device和dma_sync_for_cpu流程,给每个缓冲块建立明确的“所有权”切换模型,而不是到出了问题再补。

5. 扩展方向:这套框架还能怎么进化

5.1 从裸 DMA 到用户态直通

当你想进一步降低时延,就走 VFIO。把设备直通给用户态,驱动和 DMA 管理全在用户态完成,内核不再参与搬运。hs_dma_framework 里已经预留了设备树组合子和 VFIO 兼容属性。但要注意:VFIO 下中断处理要用 eventfd,而且用户态必须自己管理 IOMMU 映射,复杂度明显上升。我的建议是普通提示先不用它,等到 CPU 占用确实还是瓶颈的时候再上。

5.2 和图像处理管线结合

如果是图像采集,FPGA 端描完一行就会产生一个同步信号。这个信号可以作为描述符“帧结束”的标记。hs_dma_framework 的驱动设计是支持多队列的,对应不同传感器通道。在 ARM64 侧,你可以用dmabuf或v4l2框架直接把 DMA 缓冲拿到 GPU 或 NPU 处理,避免多一次拷贝。这个方向我还在做,目前已经跑通了 v4l2 的DMABUF_EXPORT方式,感兴趣的话后续可以单独开一篇讲。

5.3 一个容易被忽略的运维层面问题

这么多 DMA 缓冲占着内存,一旦系统运行半年,CMA 区域碎片化,分配大块连续内存就会失败。我写了个简单的监控脚本,定时看/proc/buddyinfo和 CMA 使用情况。另外一个经验是,采集应用崩溃后,驱动必须把 FPGA 的 DMA 通道停掉再释放内存,否则下次 DMA 还在跑,往已释放的地址写数据,整个系统可能直接 hang 住。驱动里release方法对这个问题要格外小心。

最后再聊几句个人的实践感受

如果你是自己折腾 FPGA 加 Linux ARM64 的采集平台,我最想说的两点是:先把中断和描述符做对,再谈带宽。还有不要相信仿真里 DMA 能跑多快。仿真里没有 cache 一致性问题,没有总线仲裁,也没有实际 DDR 刷新冲突。hs_dma_framework 这套框架留给你的真正价值,不是那几段代码,而是它在架构上画出的边界:哪里是 FPGA 的责任,哪里是 Linux 的责任,哪里是 ARM64 平台的特殊坑,分清楚了,换到任何具体项目上你都能再搭一套。我经常在晚上改驱动,第二天早上出现奇怪现象时,第一件事永远是查设备树地址,而不是改逻辑代码——这个习惯帮我省了无数冤枉时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询