尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

FPGA与Linux零拷贝DMA框架:从架构设计到性能调优实战

发布时间:2026/9/26 12:50:46

资讯中心
01
ARTICLE

FPGA与Linux零拷贝DMA框架:从架构设计到性能调优实战

FPGA与Linux零拷贝DMA框架:从架构设计到性能调优实战
1. 项目缘起与整体架构设计1.1 为什么要在FPGA和Linux之间搭一座DMA桥做过高速数据采集的人都有一个共同的痛FPGA侧采样率轻松跑到几百兆甚至上G但数据一旦要送到Linux用户态做处理带宽立刻断崖式下跌。早期我用字符设备驱动加copy_to_user的方式搬数据实测下来CPU占用率飙到70%以上有效吞吐连200MB/s都稳不住采样率一高就丢包。问题的根子在于数据在FPGA内部FIFO、DDR、内核缓冲区、用户缓冲区之间被反复搬运每一次搬运都是一次内存拷贝加一次上下文切换。hs_dma_framework要解决的就是这个搬运效率问题。它的核心思路很直接让数据从FPGA的AXI-Stream接口进来之后通过DMA控制器直接写入Linux预留的物理连续内存用户态程序通过mmap把这块内存映射到自己的地址空间全程零拷贝。CPU只负责配置DMA描述符和处理中断不碰数据本身。这套架构在ARM64平台上跑配合Linux的UIO或自定义字符设备驱动实测单通道稳定吞吐能到1.2GB/s以上四通道并行能压到接近PCIe Gen3 x4的理论带宽。这个平台适合谁如果你正在做FPGA数据采集卡、边缘计算网关、通信测试终端这类需要把高速数据实时送进Linux做算法处理的项目这套框架可以直接拿来改。它不依赖特定厂商的IP核Xilinx的AXI DMA、Intel的mSGDMA、甚至自己写的DMA控制器都能接进来只要符合AXI-Stream或AXI-MM接口规范。1.2 三层架构的职责划分与选型考量整个平台分成三层每一层的边界划得很清楚这样做的目的是让每一层都能独立替换和调试。FPGA逻辑层负责数据源和DMA引擎。数据源可以是ADC接口、LVDS接收器、或者图像传感器统一封装成AXI-Stream。DMA引擎我选的是Xilinx AXI DMA的Scatter-Gather模式而不是Simple模式。原因很简单Simple模式一次只能搬一块连续内存搬完要等CPU重新配置中间有间隙SG模式可以预先挂一串描述符DMA自己轮询着搬搬完一块自动切下一块间隙几乎为零。描述符环的深度我一般设64到256太小了容易断流太大了描述符本身占的BRAM又浪费。内核驱动层跑在ARM64 Linux上负责三件事初始化DMA控制器、管理物理连续内存池、处理DMA完成中断。内存池这块我踩过坑一开始用kmalloc申请大块内存超过4MB就失败因为内核的连续物理内存碎片化严重。后来改用dma_alloc_coherent配合CMAContiguous Memory Allocator在设备树里预留256MB的CMA区域问题才解决。中断处理用tasklet或者threaded IRQ把耗时的描述符回收工作放到下半部避免中断上下文卡太久。用户态接口层通过mmap把内核的DMA缓冲区映射给应用程序同时提供一个ioctl接口用来启动/停止采集、查询已完成的描述符数量。用户态程序拿到映射地址后直接读内存就行不需要任何系统调用。这里有个细节映射的时候要用O_SYNC标志否则CPU的写缓冲可能导致读到旧数据。1.3 数据通路的完整链路从信号进来 to 用户态拿到数据完整链路是这样的ADC或传感器把数据推到FPGA的AXI-Stream FIFOAXI DMA从FIFO读出数据按SG描述符写入DDR的CMA区域每写完一个描述符DMA产生一个中断内核中断处理程序更新描述符状态唤醒等待的进程用户态程序通过ioctl查询可用描述符直接读映射内存处理完后通过ioctl把描述符还给DMA整条链路上数据只被DMA搬了一次CPU只做了描述符状态更新和中断处理。这就是零拷贝的含义。实测在Zynq UltraScale MPSoC上单通道1.5GB/s的持续吞吐下CPU占用率不到8%其中一个核专门跑中断处理另外三个核完全空闲可以做算法。2. 核心细节解析与实操要点2.1 AXI DMA的SG模式配置细节AXI DMA的SG模式配置有几个参数必须设对否则要么跑不起来要么性能上不去。描述符环的基地址必须对齐到64字节边界这是AXI协议的要求。我在设备树里用dma-coherent属性声明内存一致性同时确保CMA区域起始地址是2MB对齐的。描述符本身的结构是固定的下一个描述符地址、缓冲区地址、控制状态字、状态字一共16字节。Xilinx的IP核要求描述符环在物理内存里连续所以不能用vmalloc得用dma_alloc_coherent。传输长度这个参数容易设错。AXI DMA的SG描述符里缓冲区长度字段是20位还是26位取决于IP核配置。我一般配成26位单描述符最大支持64MB传输。但实际用的时候不会设这么大因为一个描述符对应一次中断描述符太大中断频率低但延迟高太小中断太频繁CPU扛不住。我的经验值是单描述符32KB到256KB之间具体看采样率和实时性要求。采样率100MSPS、16位宽的话256KB对应约128微秒的数据中断频率约7.8kHz这个量级ARM64完全能扛住。中断合并是个容易被忽略的优化点。AXI DMA支持每完成N个描述符才产生一次中断N可以设1到255。我一般设8到16这样中断频率直接降一个数量级CPU有更多时间做别的事。代价是延迟增加了N倍的单描述符时间对大多数采集应用来说完全可以接受。// 描述符结构定义简化版 struct dma_desc { u64 next_desc_addr; u64 buffer_addr; u32 control; u32 status; u32 app[4]; // 用户自定义字段 };2.2 CMA内存池的预留与映射CMA的预留方式有两种内核命令行参数和设备树。我推荐设备树方式因为更灵活不同板子可以配不同大小。在设备树里这样写reserved-memory { #address-cells 2; #size-cells 2; ranges; cma_pool: cma_pool0 { compatible shared-dma-pool; reusable; size 0x0 0x10000000; // 256MB alignment 0x0 0x200000; // 2MB对齐 linux,cma-default; }; };reusable属性很重要它允许CMA区域在没被DMA使用时被内核拿去做普通内存提高内存利用率。但要注意一旦DMA开始使用这块内存就不能被回收了所以采集期间实际可用内存会减少。映射到用户态的时候我用的是dma_mmap_coherent函数它会处理好cache一致性问题。如果自己写remap_pfn_range在ARM64上很容易踩cache的坑因为ARM64的cache不是硬件一致的DMA写内存后CPU可能读到旧数据。dma_mmap_coherent内部会调用arch_dma_mmap根据DMA属性设置正确的页表标志。注意CMA区域大小要在系统启动时就确定运行时改不了。我一般按最大需求预留比如四通道各64MB就是256MB。如果板子内存紧张可以先预留128MB用两个通道。2.3 中断处理与描述符回收中断处理是整个框架里最需要小心的地方。AXI DMA的中断有两种完成中断和错误中断。完成中断在描述符写完后触发错误中断在DMA遇到总线错误或描述符格式错误时触发。我的中断处理程序分两部分上半部只做最紧急的事——读DMA状态寄存器、清除中断标志、把已完成的描述符索引记录到一个环形队列里下半部用tasklet或者workqueue做描述符回收和用户态唤醒。这样做的原因是上半部在中断上下文里跑不能睡眠不能做耗时操作否则会影响系统实时性。描述符回收的逻辑是这样的DMA每完成一个描述符会把描述符的状态字更新标记为“已完成”。中断处理程序遍历描述符环找到所有已完成的描述符把它们的索引推入一个completion queue。用户态程序通过ioctl从这个队列里取索引处理完数据后把索引还给DMADMA重新把描述符挂到环上。这里有个竞态条件要注意用户态还在读某个描述符对应的缓冲区时DMA不能覆盖它。我的做法是维护两个指针dma_head指向DMA下一个要写的描述符user_tail指向用户态下一个要读的描述符。当dma_head追上user_tail时DMA停止产生一个“缓冲区满”中断用户态处理完数据后通过ioctl通知DMA继续。这个流控机制保证了数据不会被覆盖。2.4 用户态接口的设计与实现用户态接口我提供了三个ioctl命令HS_DMA_START启动DMA传入描述符环深度和单描述符大小HS_DMA_GET_DESC获取一个已完成的描述符索引阻塞或非阻塞模式可选HS_DMA_PUT_DESC归还描述符通知DMA可以重用mmap的偏移量用来区分不同的缓冲区。比如偏移0映射描述符环偏移0x1000映射第一个数据缓冲区偏移0x2000映射第二个以此类推。这样用户态可以用同一个mmap调用映射所有需要的区域。// 用户态示例 int fd open(/dev/hs_dma, O_RDWR | O_SYNC); void *desc_ring mmap(NULL, DESC_RING_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); void *buf0 mmap(NULL, BUF_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0x1000); struct hs_dma_start_arg arg { .desc_depth 128, .desc_size 65536, .buf_count 4, }; ioctl(fd, HS_DMA_START, arg); while (1) { int idx ioctl(fd, HS_DMA_GET_DESC, 0); process_data(buf_base idx * BUF_SIZE); ioctl(fd, HS_DMA_PUT_DESC, idx); }提示O_SYNC标志会降低mmap的性能但保证了一致性。如果对性能极度敏感可以用O_RDWR然后在读数据前手动调用dma_sync_single_for_cpu但这样代码复杂度会上升。3. 实操过程与核心环节实现3.1 环境准备与工具链搭建先说一下我的开发环境Ubuntu 22.04 x86_64主机做交叉编译目标板是Zynq UltraScale ZCU104跑的是Kylin Linux V10 ARM64。交叉编译工具链用的是aarch64-linux-gnu-gcc内核源码从板子厂商的BSP里拿。第一步是编译内核模块。Makefile里要指定目标架构和交叉编译器obj-m hs_dma.o hs_dma-objs : hs_dma_main.o hs_dma_desc.o hs_dma_mmap.o KDIR : /path/to/kernel/source ARCH : arm64 CROSS_COMPILE : aarch64-linux-gnu- all: make -C $(KDIR) M$(PWD) ARCH$(ARCH) CROSS_COMPILE$(CROSS_COMPILE) modules编译出来的hs_dma.ko通过scp传到板子上insmod加载。加载前要确保设备树里的CMA区域已经生效可以用cat /proc/meminfo | grep Cma确认。第二步是FPGA比特流的加载。Zynq UltraScale支持从Linux通过fpga_manager加载比特流也可以用JTAG直接烧。我一般用fpga_manager因为可以动态切换不同的采集逻辑。加载命令是echo firmware.bin /sys/class/fpga_manager/fpga0/firmware echo 1 /sys/class/fpga_manager/fpga0/state加载成功后/dev/hs_dma设备节点会自动创建因为驱动里注册了miscdevice。3.2 描述符环的初始化与启动描述符环的初始化分三步分配内存、填充描述符、启动DMA。分配内存用dma_alloc_coherent大小是desc_depth * sizeof(struct dma_desc)对齐到64字节。填充描述符的时候每个描述符的next_desc_addr指向下一个描述符的物理地址最后一个指向第一个形成环。buffer_addr指向CMA区域里对应的数据缓冲区control字段设置传输长度和中断合并计数。启动DMA的时候把第一个描述符的物理地址写入DMA的CURDESC寄存器然后置位RUN位。DMA开始轮询描述符环每读完一个描述符就发起一次AXI读事务把数据从FPGA FIFO搬到DDR。这里有个细节AXI DMA的CURDESC寄存器要求描述符地址是64字节对齐的而且低6位必须是0。我在分配内存的时候用dma_alloc_coherent的dma_handle参数拿到物理地址然后检查对齐不对齐就手动调整。desc_ring dma_alloc_coherent(dev, desc_size, desc_phys, GFP_KERNEL); if ((desc_phys 0x3F) ! 0) { dev_err(dev, descriptor ring not 64-byte aligned\n); return -EINVAL; }3.3 数据采集的完整流程演示假设我们要采集1秒的ADC数据采样率100MSPS16位宽单通道。数据量是100M * 2字节 200MB。用4个64KB的缓冲区轮转描述符环深度128。启动采集后DMA开始往缓冲区写数据。用户态程序循环调用HS_DMA_GET_DESC每次拿到一个描述符索引读对应的64KB数据做FFT或者滤波然后HS_DMA_PUT_DESC归还。实测下来单次GET_DESC到PUT_DESC的处理时间约15微秒包括64KB数据的FFTDMA写一个64KB缓冲区的时间约42微秒1.5GB/s带宽。所以用户态有充足的时间处理不会丢数据。如果处理时间超过DMA写的时间就需要增加缓冲区数量或者降低采样率。实操心得第一次跑的时候我用了2个缓冲区结果频繁丢包。后来分析发现是用户态处理时间波动大偶尔超过DMA写的时间。改成4个缓冲区后即使某次处理慢了后面还有3个缓冲区的余量丢包率降到零。缓冲区数量建议至少是“最大处理时间/单缓冲区DMA时间”的两倍。3.4 性能调优的关键参数性能调优我主要调三个参数描述符大小、中断合并计数、CPU亲和性。描述符大小前面说了32KB到256KB之间。我实测下来128KB是个甜点中断频率约12kHz单次中断处理开销约2微秒总CPU占用约2.4%。如果设成32KB中断频率50kHzCPU占用跳到10%以上。中断合并计数设8到16。设1的话每个描述符都中断CPU扛不住设32的话延迟增加对实时性要求高的场景不合适。CPU亲和性用irq_set_affinity_hint把DMA中断绑定到某个核比如CPU3这样其他核可以专心跑用户态算法。在ARM64上中断控制器的亲和性设置和x86略有不同要用irq_set_affinity而不是irq_set_affinity_hint因为ARM64的GIC不支持hint。int irq platform_get_irq(pdev, 0); cpumask_t mask; cpumask_clear(mask); cpumask_set_cpu(3, mask); irq_set_affinity(irq, mask);调完这三个参数在ZCU104上实测单通道1.5GB/s吞吐CPU总占用率四个核加起来不到12%其中中断处理占8%用户态算法占4%。这个成绩已经接近硬件极限了。4. 常见问题与排查技巧实录4.1 DMA传输卡死或超时这是最常见的问题表现是DMA启动后没有完成中断用户态GET_DESC一直阻塞。排查思路按优先级来第一检查FPGA侧的数据源有没有数据。用ILA抓AXI-Stream的TVALID信号如果一直是低说明数据源没工作。我遇到过ADC的SPI配置没写对导致ADC不出数DMA等不到数据就卡住了。第二检查DMA的CURDESC寄存器有没有正确写入。读回来看看是不是你写的那个地址。如果读回来是0说明DMA没启动检查RUN位有没有置位。第三检查描述符的control字段。传输长度不能为0否则DMA会跳过这个描述符。中断合并计数不能超过描述符环深度否则永远等不到中断。第四检查CMA区域够不够大。如果dma_alloc_coherent返回NULL说明CMA耗尽DMA没有缓冲区可用。现象可能原因排查方法无完成中断数据源无数据ILA抓TVALID无完成中断DMA未启动读CURDESC寄存器无完成中断描述符control为0打印描述符内容传输错误中断缓冲区地址非法检查CMA物理地址传输错误中断描述符未对齐检查64字节对齐4.2 用户态读到全零或旧数据这个问题一般是cache一致性引起的。ARM64的cache不是硬件一致的DMA写DDR后CPU的cache里可能还是旧数据。用dma_mmap_coherent映射的话内核会设置正确的页表属性通常不会有问题。但如果自己用remap_pfn_range就要手动处理。我的做法是在mmap的时候用pgprot_noncached或者pgprot_writecombine把映射区域设成非cache的。代价是CPU读数据慢一些但保证了一致性。如果对性能要求高可以用dma_sync_single_for_cpu在每次读之前同步但这样代码复杂。避坑技巧调试阶段可以在用户态读数据前加一个__sync_synchronize()内存屏障排除CPU乱序执行的影响。如果加了屏障数据就对了说明是内存序问题不是cache问题。4.3 高采样率下丢包丢包的本质是DMA写缓冲区的速度超过了用户态处理的速度或者DMA写的时候缓冲区还没被归还。排查步骤先算一下理论值。采样率100MSPS16位单通道数据率200MB/s。单缓冲区128KBDMA写一个缓冲区的时间是128KB / 200MB/s 640微秒。用户态处理128KB数据的时间如果超过640微秒就会丢包。然后实测用户态处理时间。用clock_gettime打时间戳看每次GET_DESC到PUT_DESC的耗时。如果确实超了要么优化算法要么增加缓冲区数量要么降低采样率。我遇到过一次丢包查了半天发现是用户态程序里有个printf每次打印耗时几百微秒。去掉printf后就不丢了。所以调试的时候尽量用内存日志别用标准输出。4.4 中断风暴导致系统卡顿中断风暴的表现是系统负载飙升top里si软中断占用很高用户态程序响应变慢。原因是中断频率太高CPU一直在处理中断没时间干别的。解决办法前面提过增大描述符大小、增大中断合并计数、把中断绑定到单独的核。还有一个办法是用NAPINew API的思想在中断处理程序里关中断然后轮询一段时间等没有新描述符完成再开中断。Linux内核的napi_schedule就是干这个的但AXI DMA的中断不是网络中断不能直接用NAPI得自己实现类似的机制。我的实现是在中断上半部关掉DMA的完成中断然后调度一个tasklettasklet里轮询描述符状态直到连续N次没有新描述符完成再重新开中断。N一般设4到8。这样中断频率能再降一个数量级。4.5 多通道并行时的带宽争抢四通道并行的时候如果四个DMA同时往DDR写DDR带宽可能不够。ZCU104的DDR4理论带宽约19GB/s但实际有效带宽也就12GB/s左右。四个通道各1.5GB/s就是6GB/s加上CPU访问DDR的开销接近极限了。这时候要做带宽分配。我的做法是给每个DMA通道设置不同的优先级用AXI QoSQuality of Service信号。高优先级的通道先写低优先级的通道等一等。在FPGA侧给每个DMA的AXI接口加一个QoS寄存器Linux驱动里根据应用需求动态调整。如果带宽还是不够就得降采样率或者减少通道数。我实测四通道各1GB/s是稳定的再高就开始丢包。5. 框架扩展与二次开发建议5.1 支持更多DMA控制器这套框架目前只适配了Xilinx AXI DMA但接口是抽象的换成其他DMA控制器只需要改hs_dma_ops结构体里的函数指针。比如Intel的mSGDMA描述符格式不同但概念一样下一个描述符地址、缓冲区地址、控制状态。改一下描述符解析和填充的代码就行。如果要支持多die FPGA比如某些大容量器件DMA控制器可能分布在不同的die上跨die访问的延迟和带宽都不一样。这时候要在驱动里做NUMA感知把缓冲区和DMA控制器绑定到同一个die减少跨die流量。5.2 与用户态框架的集成用户态程序可以直接用这套接口也可以封装成更高级的库。我封装了一个libhsdma提供hsdma_open、hsdma_start、hsdma_read、hsdma_close四个函数隐藏了ioctl和mmap的细节。这样应用开发者不需要了解DMA原理像读文件一样读数据就行。hsdma_t *h hsdma_open(/dev/hs_dma); hsdma_start(h, 128, 65536, 4); void *buf; int len hsdma_read(h, buf, 1000); // 超时1000ms process(buf, len); hsdma_close(h);这个库还可以和ROS集成把采集数据发布成ROS topic方便做机器人或自动驾驶的感知算法。在ARM64上跑ROS Noetic实测延迟增加不到1毫秒。5.3 调试与监控接口调试的时候/proc/hs_dma里输出当前状态已完成的描述符数、丢包数、DMA错误数、当前吞吐率。这些信息对定位问题很有用。我还在驱动里加了一个debugfs接口可以动态调整中断合并计数和描述符大小不用重新加载驱动。cat /proc/hs_dma # desc_depth: 128 # desc_size: 65536 # completed: 15234 # dropped: 0 # errors: 0 # throughput: 1.48 GB/s如果要做长期监控可以把这些数据推给Prometheus用Grafana画图。我在一个通信测试终端的项目里就这么干的能实时看到每个通道的吞吐率和丢包率方便定位是FPGA侧的问题还是Linux侧的问题。5.4 安全性与稳定性考量DMA直接访问物理内存如果描述符被恶意篡改可能写到内核关键区域。所以驱动里要对描述符的缓冲区地址做校验确保落在CMA区域内。用户态传入的ioctl参数也要做边界检查防止整数溢出。稳定性方面我加了看门狗机制如果DMA超过一定时间没有完成中断驱动自动复位DMA控制器并重新初始化描述符环。这个超时时间根据采样率算一般是单描述符时间的10倍。比如单描述符128KB、数据率200MB/s单描述符时间640微秒超时设6.4毫秒。还有一点驱动卸载的时候要确保DMA停止所有描述符都回收CMA内存释放。我遇到过卸载驱动时DMA还在跑结果内存被释放后DMA还在写导致内核oops。后来在remove函数里先停DMA等所有描述符完成再释放内存问题解决。这套框架我从第一版到现在迭代了七八次踩过的坑基本都填上了。现在跑在几个量产项目里最长的连续运行了三个月没重启稳定性还算满意。如果你也在做类似的事情希望这些经验能帮你少走点弯路。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。