ARTICLE DETAIL

资讯详情

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

ARM64与FPGA协同的高速数据采集:DMA与零拷贝框架实践

ARM64与FPGA协同的高速数据采集:DMA与零拷贝框架实践 如果你也在ARM64 Linux平台上做过高速数据采集应该对这种痛苦深有体会FPGA那边已经把数据规规整整送到DDR里了但CPU还没来得及处理新一波数据又来了。我在ZCU104上调试一套双通道250MSPS ADC采集板时最初靠中断加memcpy处理数据一个Cortex-A53核有一大半算力都耗在搬数据上采样率稍微调高就开始丢点。后来我把整条收数链路统一到一套以DMA为中心、由FPGA、Linux、ARM64协同的框架里就是hs_dma_frameworkFPGA侧做AXI Stream到AXI MM的DMA引擎Linux侧用dmaengine驱动把数据直接铺进DDR再通过mmap把内存交付给用户态做实时处理。这篇文章基本是这套框架从硬件到软件的完整搭建笔记包含所有让我掉过头发的细节适合准备用ARM64FPGA做高速采集、又不想在DMA上反复踩坑的同行。1. 为什么这套框架必须同时搞定FPGA、Linux和ARM64很多刚接触高速采集的人会问为什么不能只用一个FPGA或者只用一个ARM SoC把活全干了这个问题其实是在问系统的分工边界。1.1 先把数据量算清楚这不是靠CPU搬运能解决的以我用过的AD9208为例双通道、250MSPS、14位分辨率。为了DDR读写对齐方便我在PL侧把数据按16bit2字节打包。单通道每秒就是250M × 2B 500MB/s双通道直接翻倍满速率下跑满1GB/s。这个数字意味着什么Cortex-A53在1.2GHz左右实测memcpy带宽大概也就是1.5GB/s量级但那是在双方都命中cache、纯内存搬运的理想情况下。真正让CPU把数据从DDR读出来、再做解析、再做上下文切换有效吞吐能剩一半就不错了。更致命的是中断频率如果每搬16KB就产生一次DMA中断1GB/s的速率对应每秒约62500次中断Linux光在这些中断上花的上下文切换和调度开销就能吃掉一个核心的绝大部分算力。所以结论很明确高速采集里数据的物理搬运必须由硬件DMA完成CPU只负责接住搬移完成的通知然后把数据交给上层算法。用CPU去搬从一开始就输了。1.2 为什么是ARM64而不是软核或纯x86软核处理器比如MicroBlaze和RISC-V软核跑裸机没问题但跑Linux就比较吃力更别提跑算法栈、协议栈这类重活。x86倒是算力强可在嵌入式采集场景里功耗、体积、外围接口集成度都不如ARM64 SoC。ARM64这边我最常用的是Zynq UltraScale系列典型配置是四核Cortex-A53加FPGA可编程逻辑。A53有完整MMU能跑标准Linux工具链、发行版都非常成熟FPGA部分则负责采样时钟、数字接口、FIFO缓冲这些时序敏感的事。两者放在同一个封装里PL到PS的高速通路走片内总线省掉外部接口的转换损耗和延迟。这正是hs_dma_framework的设计起点FPGA管时序和数据成形ARM64管调度和协议Linux提供线程、文件、网络这些现成设施。每一层只做自己最擅长的事。2. 硬件侧的通路设计AXI DMA引擎从配置到跑起来框架的硬件核心其实就一句话把ADC送进来的AXI Stream数据流通过DMA引擎写进DDR。但这一句话背后藏着一堆选择。2.1 我是怎么选DMA引擎方案的PL侧实现DMA大体有三条路用Xilinx AXI DMA IP标准IPS/G模式、cyclic模式都支持驱动在内核里有现成参考。缺点是行为固定有些极端场景要配合额外逻辑。用CDMACentral DMA适合PL内部内存到内存的搬运用在流式外部数据采集上不够直接。自己写RTL DMA最灵活能把状态机裁剪到极致调试成本也最高。我在框架里选了AXI DMA IP。做产品不是炫技标准IP有大量用户踩过坑固件和驱动的问题都有迹可循。除非后续遇到IP无法满足的时序需求否则不建议一开始就自己写DMA。2.2 关键IP参数S/G模式和cyclic模式一定要开在Vivado里配置AXI DMA时我强烈建议把Scatter Gather Engine打开。S/G模式靠Buffer Descriptor链表管理不连续的物理内存这样Linux驱动申请DMA缓冲时不需要拼一大段连续的物理页任意分散页都能通过描述符串起来。对连续采集来说还有更关键的一步确认IP版本支持cyclic mode。cyclic模式下DMA硬件会循环执行一组描述符写满最后一段后自动跳回第一段不需要软件重新提交请求。这正好匹配ADC永远在出数据的语义。数据位宽方面Streaming Data Width和数据通路要跟AXI总线对齐。我的设计里PL侧AXI数据宽度是256bit时钟250MHz理论上单方向带宽有8GB/s跑1GB/s的采样流绰绰有余。Max Burst Size我固定成16这意味着一次突发传输在256bit位宽下能传512字节能明显减少DDR控制器的命令开销。2.3 地址对齐和burst传输的微妙关系4K边界是硬约束AXI协议里有个硬性规定INCR突发传输不能跨越4K地址边界。也就是说如果DMA要连续写一大块内存一旦某次突发到了4K边界总线控制器必须拆成两个请求重新发起。拆得越多效率越低。我实测过把DMA缓冲区首地址故意设成非4K对齐同样的采样率下总线有效带宽能掉30%以上。原因就是DDR控制器要反复做bank切换和预充电DMA引擎内部的写数据FIFO也会因为突发被拆散而频繁空等。所以申请DMA内存时务必按4K对齐。这也是为什么我最终没有用kmalloc随手分配的缓冲区而是走dma_alloc_coherent这类专门为DMA准备的分配接口——它们天然保证对齐要求。2.4 多Die FPGA上的约束跨SLR的时序教训现在新一点的FPGA几乎都是多Die结构Vivado里通常叫多个SLRSuper Logic Region。不同SLR之间的连线要经过Die-to-Die路径延迟比片内路径大不少。如果DMA引擎、FIFO、中断控制器这些模块被布局软件打散到不同SLR里时序收敛会很吃力。我调试时遇到过一种情况AXI DMA IP和它的状态上报寄存器布局在相邻SLR结果绕过了一条很长的跨Die路径综合工具无论怎么迭代都无法达到时序要求。后来手动加了Pblock布局约束把DMA引擎、FIFO以及相关的AXI Interconnect都限定在同一个SLR范围内路径长度立刻降下来时序一次性收敛。这个教训在单Die器件上不存在但如果你用的是大容量多Die FPGA从设计一开始就要留意模块的物理分布别等到跑完布线发现时序不过再回头挪。3. 软件侧的数据接管设备树、dmaengine驱动与零拷贝交付PL侧通了只是第一步。Linux侧要把DMA引擎驱动起来把数据交付给用户态这里涉及设备树、内核dmaengine框架以及用户态mmap。3.1 设备树里的DMA节点一个标点错误都会出幺蛾子在Zynq UltraScale的PetaLinux里AXI DMA的节点通常长这样axi_dma_0 { compatible xlnx,axi-dma-1.00.a; reg 0x0 0xa0000000 0x0 0x1000; dma-channels 0x1; #dma-cells 1; interrupt-parent intc; interrupts 0 29 4; xlnx,addrwidth 0x28; xlnx,sg-length-width 0x1e; };这里有个非常隐蔽的坑AXI DMA的MM2S和S2MM通道各自有独立中断线。设备树interrupts属性填的是哪个中断决定Linux驱动的中断服务函数能不能被正确触发。我在调试时把两个中断的顺序填反过结果驱动加载正常但一启动采集中断回调完全不执行数据链路上静悄悄。排查办法很简单用devmem直接读AXI DMA的状态寄存器。如果看到S2MM通道的Error和IOC中断位在置位说明数据搬移实际发生了只是中断没通知到Linux。这时候回设备树里对调interrupts的cell顺序就行。还要注意一个细节我没在节点里加dma-coherent属性。因为我走的是Zynq UltraScale的S_AXI_HP_FPD高吞吐口这个口没有硬件缓存一致性窥探能力。加了这个属性等于告诉内核DMA操作自动一致内核可能省掉维护缓存的操作但硬件实际上并不保证一致后果就是读到脏数据。如果你的PL模块挂在CCI端口、并且确实做了完整的一致性感测配置那另说否则不要随便加。3.2 dmaengine cyclic模式的调用主脉络Linux内核的dmaengine框架把DMA控制器抽象成统一的APIcyclic模式对应的关键代码大概是这样的struct dma_chan *chan; struct dma_async_tx_descriptor *desc; dma_cap_mask_t mask; dma_cap_zero(mask); dma_cap_set(DMA_CYCLIC, mask); chan dma_request_chan_by_mask(mask); desc dmaengine_prep_dma_cyclic(chan, hs_dma_rb.dma_addr, hs_dma_rb.buf_size, hs_dma_rb.seg_size, DMA_DEV_TO_MEM, DMA_PREP_INTERRUPT); if (IS_ERR(desc)) { /* 返回错误通常是段大小不对齐或通道不支持cyclic */ } desc-callback hs_dma_cb; desc-callback_param hs_dma_rb; dmaengine_submit(desc); dma_async_issue_pending(chan);dmaengine_prep_dma_cyclic里的四个关键参数是DMA缓冲区地址、总长度、分段长度、数据传输方向。整个缓冲区被分成多个segmentDMA每写完一个segment就触发一次回调。我用的segment大小是256KB总缓冲区16MB也就是64个segment循环使用。缓冲区大小不是随手定的它取决于用户态最晚多久来取一次数据。1GB/s的数据流256KB一个segment意味着每256微秒产生一次中断。用户态消费线程哪怕偶尔被调度延迟几百微秒后面还有整个16MB缓冲兜底不会立刻丢数据。3.3 用户态拿数据零拷贝才是真正答案驱动拿到数据后最忌讳的就是用read接口做一次内核到用户态的memcpy。1GB/s的拷贝会大量消耗内存带宽和中断风暴带来的问题几乎一样严重。正确做法是把DMA缓冲区直接mmap给用户态。驱动侧实现一个file_operations的mmap回调核心一行代码static int hs_mmap(struct file *fp, struct vm_area_struct *vma) { return dma_mmap_coherent(hs_dev, vma, hs_rb.cpu_addr, hs_rb.dma_addr, vma-vm_end - vma-vm_start); }dma_mmap_coherent会把这段DMA内存重新映射到用户进程的地址空间此后用户态程序读写这段内存底层对应的就是DMA写入的物理内存。后端算法直接在这块内存上做FFT、做脉冲分析不需要一次额外的数据搬运。4. ARM64缓存一致性重灾区DMA内存布局与RingBuffer设计如果说硬件和驱动是明面上的坑缓存一致性就是暗地里最阴险的坑。在ARM64平台DMA写进DDR的数据CPU不一定马上看得见。4.1 现象与原理数据明明在DDRCPU却读不到新值ARM64处理器的缓存行普遍是64字节。当CPU第一次读过某个地址之后那一段数据会被加载到cache里。之后如果DMA引擎往同一个物理地址写入了新数据CPU再访问时只要cache里的旧行仍然有效就直接命中cache返回旧值根本不会去DDR看发生了什么。我在早期版本里用kmalloc分配缓冲区没有做任何一致性处理现象非常典型用户态看到的波形是一段新、一段旧中间偶尔还夹着前几帧的内容。这就是典型的cache命中旧数据。要解决这个问题硬件上要么有CCI这种一致的互连端口要么在软件里明确管理DMA内存的缓存属性。对Zynq UltraScale走HP口的设计来说软件管理是必须的。4.2 dma_alloc_coherent和streaming DMA怎么选Linux里DMA内存分两种典型用法dma_alloc_coherent分配的内存本身被标记为DMA一致页表属性保证DMA写完后CPU能看到新数据。代价是CPU访问这段内存会变慢一点因为通常被映射成非缓存或透写属性。streaming DMA先用dma_map_single映射一块内存给设备访问设备写完后用dma_sync_single_for_cpu把缓存行失效或刷写CPU才能放心读下次设备要再访问前还要反向同步一次。对高速、持续的采集场景我选择dma_alloc_coherent。原因很直接cyclic模式本来就是硬件不停往同一块缓冲区写数据如果每次segment完成都做一次cache invalidation会产生额外的软件开销和延迟抖动。非缓存读取虽然会让用户的算法在某些随机访问场景慢一点但我们这里的数据访问模式基本都是线性读DDR实际吞吐影响并不大。4.3 RingBuffer结构设计缓存行隔离和游标管理我把整个DMA缓冲区组织成RingBuffer。管理结构体的设计直接决定并发安全性和性能这是整套框架里我觉得最值得沉淀的一部分struct hs_dma_rb { dma_addr_t dma_addr; u32 buf_size; u32 seg_size; u32 seg_mask; /* 驱动侧写用户侧只读 */ u32 head __aligned(64); /* 用户侧写驱动侧只读 */ u32 tail __aligned(64); wait_queue_head_t wait; };head和tail刻意放在单独的cache line里。原因是如果head和tail挤在同一个缓存行驱动更新head时会把整个缓存行置脏导致用户态线程读取tail时也要不断做缓存行同步这对多核系统来说是典型的伪共享问题。拆开后驱动写head只影响head所在的行用户读tail则走在另一个干净的行两边互不干扰。驱动的DMA中断回调也只做两件事更新head游标唤醒等待队列。绝不在中断上下文里做拷贝、做解析、做打印。这个原则救了无数次的稳定性测试中断回调一旦变重所有的延迟抖动都会传导到数据链路上。用户态拿到mmap内存后通过head和tail游标判断哪些segment已经写满可读。消费速度跟不上时要么用户态主动丢帧要么把缓冲调大延长容忍时间。在hs_dma_framework里我让用户态用一个独立的消费线程专门做这件事不占用算法主线程的时间。5. 峰值带宽实测与调优记录数字不会骗人最后当然要看实际跑出来的数字。我的测试平台是ZCU104开发板加上一块AD9208双通道采集子卡软件栈是PetaLinux 2022.2、内核5.15。采集项数值ADC采样率双通道 250MSPS14bit按16bit打包PL数据通路AXI-Stream 256bit 250MHzDMA模式AXI DMA cyclicS/G onRingBuffer16MBsegment 256KB到达DDR的峰值带宽1GB/s中断频率每秒约32次256KB一个segmentCPU占用单核约8%四核总计约3%首发数据延迟约1.1ms稳定性连续48小时采样零丢点对比最初用中断memcpy的方案差距非常直观方式最大稳定吞吐中断频率CPU占用丢点情况中断memcpy单通道250MSPS每秒约6.2万次单核80%以上偶发丢点hs_dma_framework双通道满载1GB/s每秒约32次单核8%左右48小时零丢点跑完这组数据之后我总结了几条调优经验对定制板卡很有参考价值DMA突发长度尽量拉满。256bit位宽下Max Burst Size设为16让每次突发都尽可能长DDR控制器的效率才高。中断回调只做标记。所有数据处理全部移到用户态消费线程。中断上下文多一行printk长时间采集下都可能变成瓶颈。绑核处理。给采集中断和消费线程分别绑到独立CPU核心上。四核A53里一个核处理DMA中断回调一个核跑用户态消费其他核留给算法和协议栈。这样可以最大限度避免调度抖动影响采集的确定性。先用测试码型验证通路。AD9208自带test pattern第一步先让它输出固定码型DMA链路跑通了再上真实模拟信号。真信号一但出问题你很难区分是DMA的毛病还是前端模拟链路的毛病排查起来非常痛苦。我个人在实际调试这套框架时还有个习惯每次修改完驱动或设备树都会先在低采样率下做长时间验证再逐步提高采样率。高速率下一切问题都会被放大直接从满速率开始调很可能被一堆耦合交织的问题淹没。先把1MSPS跑稳再上10MSPS、100MSPS到250MSPS时心里非常笃定链路本身没有隐患。这套从低到高、逐级压测的思路省掉了我大量排错时间。如果你也在准备搭建类似的采集平台我建议把它作为默认工作方式。
返回列表