如果你也在ARM64 Linux平台上做过高速数据采集,应该对这种痛苦深有体会:FPGA那边已经把数据规规整整送到DDR里了,但CPU还没来得及处理,新一波数据又来了。我在ZCU104上调试一套双通道250MSPS ADC采集板时,最初靠中断加memcpy处理数据,一个Cortex-A53核有一大半算力都耗在搬数据上,采样率稍微调高就开始丢点。后来我把整条收数链路统一到一套以DMA为中心、由FPGA、Linux、ARM64协同的框架里,就是hs_dma_framework:FPGA侧做AXI Stream到AXI MM的DMA引擎,Linux侧用dmaengine驱动把数据直接铺进DDR,再通过mmap把内存交付给用户态做实时处理。这篇文章基本是这套框架从硬件到软件的完整搭建笔记,包含所有让我掉过头发的细节,适合准备用ARM64+FPGA做高速采集、又不想在DMA上反复踩坑的同行。
1. 为什么这套框架必须同时搞定FPGA、Linux和ARM64
很多刚接触高速采集的人会问:为什么不能只用一个FPGA,或者只用一个ARM SoC把活全干了?这个问题其实是在问系统的"分工边界"。
1.1 先把数据量算清楚:这不是靠CPU搬运能解决的
以我用过的AD9208为例,双通道、250MSPS、14位分辨率。为了DDR读写对齐方便,我在PL侧把数据按16bit(2字节)打包。单通道每秒就是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:标准IP,S/G模式、cyclic模式都支持,驱动在内核里有现成参考。缺点是行为固定,有些极端场景要配合额外逻辑。
- 用CDMA(Central 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里通常叫多个SLR(Super 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控制器抽象成统一的API,cyclic模式对应的关键代码大概是这样的:
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缓冲区地址、总长度、分段长度、数据传输方向。整个缓冲区被分成多个segment,DMA每写完一个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 现象与原理:数据明明在DDR,CPU却读不到新值
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采样率 | 双通道 250MSPS,14bit(按16bit打包) |
| PL数据通路 | AXI-Stream 256bit @ 250MHz |
| DMA模式 | AXI DMA cyclic,S/G on |
| RingBuffer | 16MB,segment 256KB |
| 到达DDR的峰值带宽 | 1GB/s |
| 中断频率 | 每秒约32次(256KB一个segment) |
| CPU占用 | 单核约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时心里非常笃定链路本身没有隐患。这套"从低到高、逐级压测"的思路,省掉了我大量排错时间。如果你也在准备搭建类似的采集平台,我建议把它作为默认工作方式。