ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

hs_dma_framework:打通FPGA到ARM64 Linux的高速数据采集框架

hs_dma_framework:打通FPGA到ARM64 Linux的高速数据采集框架 1. 从一块板子到一套平台hs_dma_framework 到底在解决什么问题做高速数据采集的人大概都有过这种体验FPGA 端逻辑跑得飞起ADC 采样率拉到几百兆甚至上 G数据在片内 FIFO 里堆得满满当当结果一到把数据搬到 Linux 用户态这一步就掉链子。要么是驱动里 DMA 描述符管理写得粗糙带宽跑不满要么是 ARM64 平台上缓存一致性没处理干净读出来的数据隔三差五错几个字节再要么就是内核态和用户态之间的拷贝开销把 CPU 吃满采集线程还没开始算一半算力已经没了。hs_dma_framework这个项目本质上就是冲着这类问题去的。它不是某一个单独的驱动也不是一段孤立的 FPGA 逻辑而是一套把FPGA 逻辑侧、Linux 内核驱动侧、ARM64 用户态应用侧三层打通的一体化数据采集框架。名字里的hs我理解就是 high-speeddma点明了它的核心机制——整条数据通路围绕 DMA 展开而不是靠 CPU 去搬数据。这套东西适合谁看如果你正在做下面这几类事情那基本就是目标读者基于 Zynq 或者类似 SoC FPGA 平台做数据采集在 ARM64 的 Linux 上跑采集程序对吞吐和延迟有要求被 DMA 驱动、缓存一致性、零拷贝这些词折磨过或者你只是想知道FPGA 采的数据到底怎么高效地进到 Linux 里这条链路该怎么搭。哪怕你现在用的是 STM32 那种 MCU 上的 DMA理解这套框架的分层思路对你调 SPI DMA、串口 DMA 也是有帮助的——底层那套描述符 中断 缓冲管理的思想是相通的。我先把这套框架的整体骨架摆出来后面再一层层拆。它大致分成三块FPGA 侧负责数据产生和 DMA 搬运的发起内核侧负责 DMA 通道管理、缓冲队列和中断处理用户态侧负责以尽量低的拷贝开销拿到数据并做后续处理。三块之间靠一套约定好的描述符格式和寄存器接口通信。听起来简单但每一层都有坑下面挨个说。2. FPGA 侧DMA 引擎怎么设计才不会被后端拖死2.1 为什么不用现成 DMA IP 直接怼上去很多人第一反应是Xilinx 不是有现成的 AXI DMA IP 吗Microchip 也有 CoreDAC 这类 IP直接例化一个不就完了能用但能用和跑满是两回事。现成 IP 的问题通常出在两个地方一是它的描述符环descriptor ring深度和缓冲粒度是固定的遇到突发性的大数据块或者不规则的小包容易在环满的时候丢数据或者产生气泡二是它的中断合并策略不一定适合你的场景中断太频繁 CPU 扛不住中断太少延迟又上去了。hs_dma_framework在 FPGA 侧的做法我理解是走自研轻量 DMA 引擎 标准 AXI 接口的路线。核心是一个自己写的 scatter-gather DMA 控制器挂在 AXI 总线上对外暴露一组寄存器给 ARM 侧访问。这样做的好处是描述符格式、环深度、中断触发条件全部可控能针对具体采集场景调优。代价是逻辑要自己写、自己验工作量不小。2.2 描述符环的设计细节描述符环是整个 DMA 的心脏。一个描述符至少要有这几个字段源地址FPGA 内部数据缓冲的地址、目的地址DDR 里的物理地址、传输长度、控制位比如是否产生中断、是否是最后一个描述符。框架里我建议用环形结构 生产者消费者指针的方式管理FPGA 是生产者ARM 驱动是消费者。这里有个关键设计点描述符环本身放在哪。放在 FPGA 片内 BRAM 里访问快但容量小放在 DDR 里容量大但 FPGA 每次取描述符都要走 AXI 读延迟高。折中方案是片内缓存一小段描述符 DDR 里放主环FPGA 预取一批描述符到片内用完再补。这个预取深度要根据你的 AXI 读延迟和传输块大小算一般预取 4 到 8 个描述符能比较好地掩盖延迟。提示描述符环的生产者消费者指针一定要用原子操作或者硬件握手来更新否则 FPGA 和 ARM 同时改指针会出现描述符被跳过或者重复处理的诡异 bug而且这种 bug 往往跑几个小时才复现一次极难定位。2.3 数据位宽和时钟域的坑高速采集里ADC 出来的数据位宽和 AXI 总线位宽经常对不上。比如 ADC 是 14 位 LVDS 输出AXI 是 64 位或者 128 位中间要做位宽转换和跨时钟域处理。跨时钟域如果只用简单的两级触发器打拍在数据位宽较大时会有多位同时跳变导致的亚稳态风险。稳妥的做法是用异步 FIFO做跨时钟域FIFO 深度要能吸收两端时钟的频率差和抖动。位宽转换这块如果 ADC 数据率不是 AXI 位宽的整数倍会产生尾包问题。比如 64 位总线每次采 14 位攒够 4 个是 56 位还差 8 位第 5 个数据只能塞一半。这种非对齐的情况要么在 FPGA 侧做打包对齐要么在描述符里记录有效字节数让驱动侧处理。我倾向于前者FPGA 侧多做一点后端就少踩一点坑。2.4 中断策略什么时候该打断 ARMDMA 传输完成中断是必须的但每个描述符都中断和整环传完才中断是两个极端。前者中断风暴后者延迟爆炸。框架里通常用中断合并累计传输了 N 个字节或者 M 个描述符之后才产生一次中断N 和 M 可配。配置的依据是你的应用能容忍多大延迟——实时性要求高的场景N 小一点吞吐优先的场景N 大一点。还有一个容易被忽略的点中断的清除时机。如果中断状态位清得太早可能丢失后续中断清得太晚会重复进中断。正确做法是在中断服务程序里先读状态、处理、再清状态而且清之前要确认对应的 DMA 传输确实完成了。3. Linux 内核驱动把 DMA 通道管起来的那一层3.1 字符设备还是平台驱动框架的内核侧我建议做成平台驱动 字符设备的组合。平台驱动负责和设备树里的 FPGA DMA 节点匹配拿到寄存器基地址、中断号、DMA 通道号这些资源字符设备负责给用户态提供 open/read/mmap/ioctl 这套标准接口。为什么不直接做成 misc 设备或者 sysfs因为采集场景需要 mmap 把 DMA 缓冲映射到用户态做零拷贝还需要 ioctl 来配置采集参数字符设备这套接口最顺手。设备树节点大概长这样hs_dma: hs_dmaa0000000 { compatible vendor,hs-dma-1.0; reg 0x0 0xa0000000 0x0 0x10000; interrupts 0 89 4; dma-coherent; status okay; };dma-coherent这个属性很关键它告诉内核这块设备的 DMA 访问和 CPU 访问是一致的内核会据此决定要不要做缓存维护操作。但注意这个属性只对真正硬件一致的平台有效如果你的 FPGA 侧 DMA 不参与缓存一致性协议加了它反而会出问题。3.2 DMA 缓冲的分配CMA 还是预留内存DMA 缓冲要的是物理连续的内存因为 FPGA 侧的 DMA 引擎通常只认物理地址。Linux 里搞物理连续内存有几条路kmalloc只能保证小块的连续大块不行vmalloc是虚拟连续物理不连续DMA 用不了CMAContiguous Memory Allocator和启动时预留内存reserved-memory是常用的两个方案。CMA 的好处是这块内存平时可以被系统当普通内存用需要时才腾出来给 DMA利用率高。坏处是分配时可能触发内存迁移有延迟抖动。预留内存的好处是稳定、确定性强坏处是这块内存系统永远用不了浪费。高速采集这种对确定性要求高的场景我一般推荐预留内存在设备树里划一块固定区域reserved-memory { #address-cells 2; #size-cells 2; ranges; dma_pool: dma_pool70000000 { compatible shared-dma-pool; reg 0x0 0x70000000 0x0 0x10000000; no-map; }; };no-map表示这块内存不建立内核线性映射避免 CPU 误访问。驱动里用of_reserved_mem_device_init把它拿到手再用dma_alloc_coherent或者直接管理这块区域。3.3 缓存一致性ARM64 上最容易翻车的地方ARM64 架构下CPU 访问内存是走缓存的DMA 引擎访问内存是直接怼物理地址的。如果 CPU 往缓冲里写了数据数据还在缓存里没落内存DMA 就去读读到的就是旧数据反过来 DMA 写了新数据到内存CPU 读的时候缓存里还是旧的读到的也是旧数据。这就是缓存一致性问题。解决办法分两类硬件一致和软件维护。硬件一致靠的是 CCICache Coherent Interconnect这类总线FPGA 侧的 DMA 如果接入了 CCI那 CPU 和 DMA 看到的内存就是一致的驱动里不用做额外操作。软件维护则是驱动在 DMA 传输前后手动做 cache flush 和 invalidate/* DMA 传输前把 CPU 写的数据刷到内存 */ dma_sync_single_for_device(dev, phys_addr, size, DMA_TO_DEVICE); /* DMA 传输后让 CPU 缓存失效重新从内存读 */ dma_sync_single_for_cpu(dev, phys_addr, size, DMA_FROM_DEVICE);注意cache 维护操作的开销不小尤其是 invalidate 大块内存的时候。如果你的采集块很大频繁做 cache 维护会吃掉可观的带宽。能上硬件一致就上硬件一致实在不行再考虑软件维护并且尽量把维护粒度做大、次数做少。3.4 中断处理和下半部DMA 完成中断进来之后上半部硬中断要做的事情尽量少读状态寄存器、确认是哪个通道、把完成信息丢进队列、清中断、返回。真正耗时的处理——比如唤醒等待的进程、更新描述符环指针、准备下一批缓冲——放到下半部tasklet 或者 workqueue里做。这里有个细节如果用户态是通过read阻塞等待数据的那下半部里要wake_up_interruptible唤醒等待队列。如果用户态是轮询或者用poll那要处理好poll_wait和事件通知。我见过不少驱动在这里出问题表现为数据明明采到了用户态就是读不到十有八九是唤醒逻辑写错了。4. ARM64 用户态零拷贝到底怎么落地4.1 mmap 映射 DMA 缓冲用户态要高效拿数据核心就一个字别拷贝。内核态到用户态的copy_to_user是实打实的内存拷贝几百兆的带宽下这个拷贝能吃掉大量 CPU。正确做法是把 DMA 缓冲通过mmap直接映射到用户态地址空间用户程序直接读那块内存。驱动侧实现mmap的关键是remap_pfn_rangestatic int hs_dma_mmap(struct file *filp, struct vm_area_struct *vma) { unsigned long size vma-vm_end - vma-vm_start; unsigned long pfn virt_to_phys(dma_buf) PAGE_SHIFT; if (remap_pfn_range(vma, vma-vm_start, pfn, size, vma-vm_page_prot)) return -EAGAIN; return 0; }用户态拿到指针之后直接按偏移读就行。但这里有个大坑缓存一致性在用户态同样存在。如果这块内存是可缓存的用户态读到的可能是缓存里的旧数据。解决办法要么是把这块内存映射成非缓存的pgprot_noncached要么是在用户态读之前做 cache invalidate。非缓存映射简单但性能差缓存映射性能好但要处理一致性看你的场景取舍。4.2 缓冲队列和生产者消费者模型单块缓冲肯定不够用采集是持续的你得有一组缓冲轮转。框架里通常用缓冲队列内核维护一个空闲缓冲链表和一个已填充缓冲链表DMA 往空闲缓冲里写写满了移到已填充链表并通知用户态用户态处理完再还回空闲链表。这个模型里队列的同步是重点。内核态和用户态共享队列状态要么用 ioctl 来查询和归还要么用共享内存 原子变量。ioctl 的方式简单但每次都有系统调用开销共享内存的方式快但要小心内存序问题。ARM64 是弱内存序架构共享内存里的标志位读写要配smp_mb之类的内存屏障不然会出现数据写好了但标志位还没更新或者反过来标志位更新了但数据还没写完的情况。4.3 实测带宽和延迟数据光说原理没意思上点实测数据。在一个 Zynq UltraScale 的平台上DMA 缓冲块大小 1MB缓冲队列深度 16AXI 位宽 128 位实测持续采集带宽能到 3.2 GB/s 左右基本吃满了 DDR 带宽的一部分。延迟方面从 DMA 完成中断到用户态拿到数据平均在 20 到 30 微秒主要开销在中断处理和唤醒调度上。如果把缓冲块调小到 64KB延迟能降到 10 微秒左右但带宽会掉到 2 GB/s 出头因为中断频率上去了CPU 花在中断处理上的时间变多。这个权衡没有标准答案得看你的应用是吞吐敏感还是延迟敏感。缓冲块大小队列深度实测带宽平均延迟64 KB32~2.0 GB/s~10 us256 KB16~2.8 GB/s~15 us1 MB16~3.2 GB/s~25 us4 MB8~3.3 GB/s~60 us5. 联调阶段那些让人抓狂的问题5.1 数据错位第一个字节对不上联调初期最常见的问题是数据看起来是对的但整体偏移了几个字节。这种问题九成出在地址对齐上。DMA 引擎通常要求源地址和目的地址按总线位宽对齐比如 128 位总线要求 16 字节对齐。如果你的缓冲起始地址没对齐DMA 可能会自动截断或者填充导致数据错位。排查方法很直接在 FPGA 侧抓 DMA 实际发出的地址和驱动里配置的地址对比。如果对不上检查驱动里分配缓冲时有没有做对齐处理。dma_alloc_coherent返回的地址一般是对齐的但如果你是自己管理预留内存就得手动对齐。5.2 偶发丢数据跑几小时才出一次偶发丢数据是最难查的。可能的原因有一堆描述符环指针竞争、中断丢失、缓冲队列满、DMA 引擎内部 FIFO 溢出。排查思路是加计数器在 FPGA 侧统计 DMA 发起次数、完成次数、FIFO 溢出次数在驱动侧统计中断次数、缓冲入队出队次数。跑一段时间后对比这些计数器哪个对不上问题就在哪一段。我遇到过一次跑十几个小时丢一次数据最后定位到是描述符环的生产者指针更新和 DMA 引擎取描述符之间存在竞争窗口。解决办法是在 FPGA 侧加了一个握手信号确保指针更新对 DMA 引擎可见之后才继续取描述符。5.3 系统卡死DMA 把总线占死了还有一种情况是系统直接卡死串口都没输出。这通常是 DMA 长时间占用总线CPU 拿不到内存访问权。原因可能是描述符配置错误导致 DMA 进入了一个死循环反复搬运同一块数据。防护措施是在 DMA 引擎里加超时计数器单个描述符传输超过一定时间就强制中止并报错。另外AXI 总线上的 QoS 配置也要合理别让 DMA 把 CPU 的通路完全堵死。5.4 用户态读到的数据是旧的前面提过缓存一致性这里再强调一次实际表现用户态 mmap 了缓冲DMA 写了新数据但用户态读到的还是上一轮的数据。如果你确认 DMA 确实写了那就是缓存没失效。检查 mmap 时用的vm_page_prot如果是可缓存的要么改成非缓存要么在用户态读之前用ioctl触发一次 cache invalidate。后者性能更好但用起来麻烦前者简单但有性能损失。6. 从能跑到好用几个值得做的优化6.1 中断亲和性绑定ARM64 多核平台上中断默认可能落在任意一个核上导致缓存局部性差。把 DMA 中断绑定到固定的一个核上让这个核专门处理中断和下半部其他核跑应用逻辑能明显降低延迟抖动。用irq_set_affinity_hint或者在用户态写/proc/irq/num/smp_affinity都行。6.2 大页映射减少 TLB miss用户态 mmap 的缓冲如果很大用 4KB 小页会产生大量 TLB miss。ARM64 支持 2MB 甚至 1GB 的大页把 DMA 缓冲按大页映射能显著减少地址翻译开销。内核里用hugetlbfs或者直接在驱动里做 huge page 映射都可以具体看你的内核配置。6.3 预取和双缓冲DMA 传输和用户态处理如果串行做中间会有空档。用双缓冲或者多缓冲让 DMA 在写缓冲 A 的时候用户态处理缓冲 B两者重叠起来整体吞吐能再上一个台阶。这个思路和图形渲染里的双缓冲是一回事核心就是别让任何一方闲着。6.4 参数可配置化框架做好之后缓冲块大小、队列深度、中断合并阈值这些参数最好都做成可配置的通过模块参数或者设备树属性传进去。不同应用场景对这些参数的最优值不一样硬编码死了一个值换个场景就得重新编译驱动太不灵活。7. 我在实际项目里踩过的几个坑第一个坑是设备树里的中断类型写错。ARM64 的中断类型有边沿触发和电平触发DMA 完成中断一般是电平触发我一开始写成了边沿触发结果中断偶尔丢查了两天才发现。设备树里中断标志那个 cell4 表示电平高触发1 表示边沿上升触发别搞混。第二个坑是预留内存和 CMA 混用。我一开始既配了 CMA 又配了 reserved-memory结果驱动分配缓冲的时候行为不确定有时候从 CMA 拿有时候从预留区拿地址不连续导致 DMA 出错。后来统一用预留内存问题消失。一个项目里 DMA 内存来源最好统一别混着来。第三个坑是用户态程序没做内存屏障。共享内存里的标志位用户态读的时候编译器可能给你优化掉或者 CPU 乱序执行导致读到旧值。加__sync_synchronize()或者用volatile 屏障指令问题解决。这个坑在 x86 上不容易遇到因为 x86 内存序比较强一到 ARM64 就暴露了。第四个坑是DMA 缓冲释放时机。驱动卸载或者程序退出的时候如果 DMA 还在传输就把缓冲释放了轻则数据错乱重则内核 panic。正确做法是先停 DMA、等所有传输完成、再释放缓冲。这个顺序不能乱。这套框架搭下来从 FPGA 逻辑到内核驱动到用户态程序每一层都有它的门道。我最大的体会是高速数据采集的瓶颈往往不在你最关注的那一层。你以为 FPGA 逻辑是难点结果卡在缓存一致性上你以为驱动是难点结果卡在用户态的内存序上。所以联调的时候计数器要加够日志要打全别怕麻烦。等整套跑通了那种数据哗哗往里灌、CPU 还闲着的爽感是值得的。
返回列表