做高速数据采集这几年我最大的感受是FPGA 端把数据铺满了CPU 端却经常喂不饱。要么中断太多把核打满要么缓存一致性没处理好搬回来的数据全是脏的。这次要聊的hs_dma_framework就是一个把 FPGA 采集、Linux 驱动、ARM64 处理三件事串起来的完整平台。它不是为了做 demo而是为了解决真正的高吞吐采集问题ADC 或图像传感器不停往外吐数据CPU 要能在尽可能低的占用率下把数据拿进内存再交给应用层做实时处理。这套东西适合三类人看FPGA 工程师想知道自己这边该出什么接口Linux 驱动工程师想知道 ARM64 下 DMA 和中断怎么配才稳做异构采集系统的架构师想找一个能直接抄作业的软硬件划分方案。我会把设计取舍、关键机制、实际落地步骤和踩过的坑都缠在一起讲不绕弯子。1. 整体架构与设计思路为什么非要用 FPGA Linux ARM641.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 用了中断聚合和批处理两个机制中断聚合Interrupt CoalescingFPGA 端计数器攒够 N 个描述符完成或者超过时间阈值才拉一次中断。N 的取值通常是帧大小 / 描述符长度。比如图像一帧 2MB描述符 64KB那么 32 个描述符完成才中断一次。环形缓冲区批量收割驱动收到一次中断后不逐个读描述符而是扫描环形缓冲的写索引。因为有硬件写指针驱动只需比较头尾指针就能知道哪些描述符完成了一次性处理整个批次。这样调整之后实测一块 10 Gbps 采集卡CPU 占用从原来的 40% 降到 4% 左右。中断频率从每秒几万次降到每秒几百次CPU 内核对采集任务的开销几乎可以忽略。3. 平台落地实施从设备树到用户态的一条龙3.1 硬件环境与 FPGA 侧接口我的参考平台是一块 ZYNQ UltraScale 系列的 ARM64 SoCFPGA 内部实现了 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-controllera0000000 { 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_CMAy提供大块连续内存分配。CONFIG_CMA_SIZE_MBYTES512根据最大缓冲需求分配 CMA靠内核命令行或 DT 里调。CONFIG_VFIO_IOMMU_TYPE1y和CONFIG_VFIO_PCIy如果后续要上 VFIO 给用户态直通 DMA。3.3 驱动结构一个最小可跑的 kernel modulehs_dma_framework 的驱动我分成了三层而不是把所有逻辑塞进一个字符设备里平台驱动负责从设备树拿 DT 资源初始化 DMA 通道和中断。DMA 缓冲管理层负责分配一致性内存、维护描述符链、更新硬件寄存器。字符设备层向用户态提供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 字节 双端 barrieruser 态访问不到数据mmap 偏移不对cat /proc/pid/maps确认 devicemmap方法实现DMA 接收端收不到FPGA 还在复位dmesg查看驱动 probe 日志FPGA 启动后触发释放复位带宽上不去描述符链不是预取模式查看 AXI 总线统计使能 FPGA 端 prefetch4.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_DEBUGy开启内核 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 平台的特殊坑分清楚了换到任何具体项目上你都能再搭一套。我经常在晚上改驱动第二天早上出现奇怪现象时第一件事永远是查设备树地址而不是改逻辑代码——这个习惯帮我省了无数冤枉时间。