资讯动态

ZYNQ Linux应用层直管AXI DMA:绕过内核驱动的高效数据采集方案

发布时间:2026/10/5 5:04:47 来源:尧图企业网站定制
最近做一版ZYNQ数据采集板卡时碰到了一个很现实的问题PL端逻辑已经写好ADC采样数据进来之后做滤波抽取要由AXI DMA搬到DDRLinux应用层再读出来做算法和显示。按常规路子应该改一套BSP里的DMA驱动但这对每个外设都要单独适配开发节奏实在拖不起。于是我就动了心思能不能绕开内核驱动直接在应用层把AXI DMA管起来答案是能做到而且实测性能并不比裸机差多少。但这条路把底层一堆问题全暴露给了应用层去处理尤其是物理内存预留、缓存一致性、中断映射这几件事任何一个没想清楚数据都会出错或者直接卡死。这篇就把我从硬件配置、设备树、应用层代码到实测性能的完整过程写出来也把我踩过的几个印象深刻的坑一并交代清楚希望能给准备这么干的同行省点时间。1. 为什么要在应用层直管AXI DMA而不是走内核驱动1.1 裸机上随手就写的DMA到了Linux下为什么变别扭在裸机或者RTOS里操作AXI DMA是一件很直白的事情往寄存器里写源地址、目标地址、长度然后置位启动轮询状态到位。开发时脑子里根本不需要有MMU和cache两个概念地址拿过来直接给DMA用就行。但到了Linux应用层情况完全变了。MMU把用户态进程的虚拟地址空间和物理地址隔开了应用层手里的指针其实是一个虚拟地址DMA引擎不认这个地址它只会按照物理地址去访问内存。同时普通的malloc分配出来的内存在物理上很可能是不连续的DMA的一次突发传输要求物理地址连续即便你运气好拿到了一段连续页这段内存也可能被内核在稍后换出或者移动。这是很多人在应用层拿着普通buffer做DMA失败的根本原因不是DMA没跑起来而是你给的地址和内存属性从一开始就不满足DMA的要求。所以在Linux应用层操作AXI DMA必须具备三个条件能访问物理地址空间既包括寄存器也包括数据缓冲区缓冲区在物理上连续、在传输期间固定不变缓存属性可控制。裸机天然满足这三条Linux下则全部需要自己搭。1.2 这个需求做技术选型时我为什么没有选内核DMA引擎有人肯定会问Xilinx官方有dmaengine驱动UIO这东西是不是有点原始确实官方方案更正规但在我这个项目里选应用层直管有非常现实的理由。第一这个板卡的PL逻辑还在频繁调整PL侧的寄存器映射和传输模式随时可能变如果每次都去改内核驱动光编译内核、制作启动镜像、烧写一个来回就是半小时以上。应用层方案只需要重新编译一个可执行文件迭代成本低一个数量级。第二我的数据流非常简单规则的单向或双向搬运PL到DDR或者DDR到PL不涉及多队列、不涉及与网络栈或文件系统的联动。这种场景用内核DMA engine框架属于杀鸡用牛刀反而要维护不少框架代码。第三应用层直管并不慢。AXI DMA真正干活的是硬件应用层做的只是写几个寄存器、轮询一个状态位这中间几乎没有软件开销。实测下来带宽能跑到AXI接口理论峰值的九成以上这一点后面有数据。1.3 哪些场景千万别学我在应用层直管把话说得平衡一点应用层直管AXI DMA适合规则搬运和快速验证但不是万能的。碰到下面这些场景我建议你还是老老实实写内核驱动或者基于官方dmaengine框架来做多进程并发访问同一个DMA通道需要内核统一管理资源竞争缓冲区需要在传输过程中动态变化或者要和VFS、网络栈、V4L2这些内核子系统协同工作对中断处理有硬实时要求或者传输完成事件需要和内核线程紧密配合用户态传入的buffer是非连续内存需要SG描述符来收集分散的内存块。在这些场景下内核驱动能帮你处理大量的边界情况应用层直管方案维护成本会非常高。2. 动手前必须吃透的AXI DMA硬件行为与寄存器2.1 Direct Register、SG、还是换CDMA模式选择决定复杂度Xilinx AXI DMA IP在Vivado里配置时有一个Enable Scatter Gather Engine的选项。勾不勾直接决定了后续应用层代码的复杂度。不勾SG就是Direct Register模式也叫简单传输模式。应用层只需要往地址寄存器和长度寄存器里写值再设置Run/Stop位DMA就会按部就班地搬运。一次传输的长度上限由LENGTH寄存器决定Xilinx默认是24bit也就是单次最大16MB-1字节。这个模式最适合固定缓冲区、固定方向的搬运我的项目用的就是它。勾上SG之后DMA通过一组描述符链来工作。描述符里记录源地址、目标地址、传输长度多个描述符通过next指针串起来DMA硬件会自己沿着链表搬运。SG模式可以处理非连续内存但应用层要自己维护描述符表的物理地址和链表关系复杂度陡增。如果你的实际需求是任意用户buffer零拷贝这类不如直接上内核dmaengine。还有一个容易混淆的AXI CDMA它专门做内存到内存的搬运带一个内部的描述符引擎。如果场景是DDR到DDR的大块拷贝可以考虑它但如果数据流是PL外设到DDR用MM2S/S2MM两条通路才是对口的方案。2.2 MM2S和S2MM两条通路各自的状态流转AXI DMA在Direct Register模式下有两套独立但又对称的逻辑通路。MM2SMemory Map to Stream方向是DDR到PL。应用层设置好源地址和传输长度后启动DMA通过AXI4主接口从内存读数据再从AXI4-Stream主接口把数据吐给PL端的FIFO或者自定义逻辑。完成一次传输后状态寄存器里的Idle位会从0变成1。S2MMStream to Memory Map方向是PL到DDR。PL端逻辑往AXI4-Stream从接口灌数据DMA把这些数据通过写通道写入内存。应用层设置好目标地址和期望收到的长度启动后DMA会一直接收直到攒够指定字节数然后置Idle。理解这个状态机对排查问题特别重要。DMA内部有Halted和Idle两个状态位Halted说明DMA处于停止状态Idle说明当前没有活跃的传输。出错时比如AXI总线上的Slave Error或者Decode Error状态寄存器会把错误位锁存。这个锁存是粘性的必须显式写1清除否则下次传输根本启动不了。后面坑的部分我会再展开。2.3 关键寄存器偏移和位定义速查表这里给出一份我在应用层代码里实际用到的寄存器速查表对应AXI DMA IP v7.1在Direct Register模式下的行为偏移寄存器作用0x00MM2S_DMACRMM2S控制bit0 RSbit2 Resetbit12 IOC_IrqEn0x04MM2S_DMASRMM2S状态bit0 Haltedbit1 Idlebit4 IOC_Irqbit12 Err_Irq0x18MM2S_SAMM2S源地址32位地址总线时就是物理地址0x28MM2S_LENGTHMM2S传输长度字节写入即触发一次MM2S传输0x30S2MM_DMACRS2MM控制bit0 RSbit2 Resetbit12 IOC_IrqEn0x34S2MM_DMASRS2MM状态bit0 Haltedbit1 Idlebit4 IOC_Irqbit12 Err_Irq0x48S2MM_DAS2MM目标地址0x58S2MM_LENGTHS2MM期望接收长度字节写入即触发一次S2MM传输有几个细节我要特别强调LENGTH寄存器是写即启动也就是说你把长度值写进去的那一刻DMA就开始搬运了。因此必须先写地址、后写长度顺序反了会搬错位置。另外RESET位写1之后要等它自动回到0这段时间内不要操作其他寄存器。3. Linux侧准备设备树、UIO、预留连续物理内存3.1 用generic-uio把AXI DMA寄存器窗口暴露给应用层要让应用层能访问AXI DMA寄存器最省事的办法是使用内核的UIO框架具体是uio_pdrv_genirq驱动设备树里compatible写generic-uio。这个驱动会把设备树节点里的reg区域自动映射成UIO设备的maps并且把interrupts里的中断转换成/dev/uioX的可读事件。应用层打开/dev/uio0之后用mmap就可以直接操作寄存器和内存区域用的是UIO的map偏移。我的设备树节点这样写的/ { compatible xlnx,zynq-7000; axi_dma_uio: dma40400000 { compatible generic-uio; reg 0x40400000 0x10000; interrupt-parent intc; interrupts 0 29 4; status okay; }; };这里最需要注意的就是reg的基地址必须和Vivado的Address Editor里分配给AXI DMA的地址完全一致。如果Address Editor里分配的是0x40400000设备树就必须写0x40400000写错了应用层mmap出来的就是别人的寄存器空间调试起来相当痛苦。编译设备树后系统起来应该能看到/dev/uio0如果没看到先查/sys/class/uio/uio0/里有没有对应设备再回头看设备树有没有被正确加载。3.2 在DDR里预留一段搬不走、动不了的内存寄存器地址解决之后数据缓冲区的问题更棘手。普通应用层内存不行需要预留一段物理连续、并且在系统运行期间绝对不会被内核挪用的内存。我在设备树的reserved-memory里预留了16MBreserved-memory { #address-cells 1; #size-cells 1; ranges; dma_buf: dma_buf30000000 { compatible shared-dma-pool; reg 0x30000000 0x01000000; no-map; }; };shared-dma-pool的意思是这块内存专门给DMA用no-map非常关键它告诉内核不要为这段物理地址建立页表映射也就是把它从内核的常规内存管理里彻底隔离出来。没有no-map的话内核可能把这段内存分配给其他子系统传输过程中数据就会莫名其妙被改写。系统起来之后可以通过dmesg | grep -i reserved或者cat /proc/iomem | grep 30000000确认这段地址确实被保留了。应用层之后可以直接打开/dev/memmmap这段物理地址来使用。3.3 PL中断号换算以及很多人填错的地方中断这块是个高频翻车点。Zynq的GIC把中断分成两类PPI私有外设中断编号0到31SPI共享外设中断编号32到1019。在设备树里interrupts的第二个cell对SPI中断来说写的并不是GIC物理中断ID而是SPI相对编号也就是物理ID减去32。举个例子在Vivado的Block Design里你把一个PL中断接到了PS端的IRQ_F2P[0]Zynq-7000 TRM里这个中断对应的GIC物理ID是61。那么在设备树里第二个cell应该填61减32等于29也就是interrupts 0 29 4而不是写61。我第一次调的时候直接从网上抄了个interrupts 0 61 4结果/proc/interrupts里根本没看到注册成功后来翻Xilinx官方的dtsi才反应过来。触发类型4是高电平有效PL端中断通常用这个。写完之后可以通过/proc/interrupts看到uio这一行搬一次数据计数会加一。4. 应用层发起一次DMA传输代码、流程、边界4.1 映射寄存器区和DMA缓冲区应用层的起点是打开/dev/mem然后把物理地址映射进用户态虚拟地址空间。打开/dev/mem时我推荐加O_SYNC标志它会把mmap出来的区域映射成非缓存属性这对寄存器访问是完全正确的对DMA缓冲区的一致性也有很大帮助。#include stdio.h #include fcntl.h #include sys/mman.h #include unistd.h #include stdint.h static int map_phys(off_t phys_addr, size_t len, void **va_out) { int fd open(/dev/mem, O_RDWR | O_SYNC); if (fd 0) { perror(open /dev/mem); return -1; } void *va mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, phys_addr); close(fd); if (va MAP_FAILED) { perror(mmap); return -1; } *va_out va; return 0; }调用的时候寄存器区和DMA缓冲区分别映射#define AXI_DMA_BASE 0x40400000 #define AXI_DMA_RANGE 0x10000 #define DMA_BUF_PHYS 0x30000000 #define DMA_BUF_SIZE 0x01000000 volatile uint32_t *dma_regs; unsigned char *dma_buf; map_phys(AXI_DMA_BASE, AXI_DMA_RANGE, (void **)dma_regs); map_phys(DMA_BUF_PHYS, DMA_BUF_SIZE, (void **)dma_buf);这里把寄存器指针声明成volatile uint32_t非常重要。DMA硬件会异步修改状态寄存器普通非volatile指针可能会被编译器优化掉重复读取导致轮询永远读到旧值。4.2 MM2S把内存数据搬到PL的AXI StreamMM2S方向的操作顺序很固定先写源地址再写长度最后启动。长度寄存器是写即触发所以地址一定要先写。#define MM2S_DMACR 0x00 #define MM2S_DMASR 0x04 #define MM2S_SA 0x18 #define MM2S_LENGTH 0x28 #define DMACR_RS 0x0001 #define DMACR_RESET 0x0004 #define DMASR_IDLE 0x0002 #define DMASR_ERR 0x1000 #define DMASR_HALTED 0x0001 int dma_mm2s_transfer(uint32_t src_phys, uint32_t len) { volatile uint32_t sr; dma_regs[MM2S_DMACR / 4] DMACR_RESET; while (dma_regs[MM2S_DMACR / 4] DMACR_RESET) ; dma_regs[MM2S_SA / 4] src_phys; dma_regs[MM2S_LENGTH / 4] len; dma_regs[MM2S_DMACR / 4] DMACR_RS; sr dma_regs[MM2S_DMASR / 4]; while (!(sr DMASR_IDLE)) { if (sr DMASR_ERR) { dma_regs[MM2S_DMASR / 4] DMASR_ERR; return -1; } sr dma_regs[MM2S_DMASR / 4]; } return 0; }启动之前先做一次Reset这是一个容易被忽略的好习惯。上电后的第一次传输不做Reset通常也能跑但如果你上一次传输因为某个错误退出DMA内部状态很可能已经是异常的不做Reset第二次启动压根没反应。轮询循环里加一个超时阈值会更稳妥比如用clock_gettime记录启动时间超过200ms直接读状态寄存器报错退出避免应用层死等。4.3 S2MM把PL来的数据搬进DDRS2MM和MM2S完全镜像区别是写入的地址是DMA要写数据的目标地址。PL端逻辑一旦有数据进来DMA会自动通过AXI Stream收下攒够S2MM_LENGTH指定的字节数之后停止。#define S2MM_DMACR 0x30 #define S2MM_DMASR 0x34 #define S2MM_DA 0x48 #define S2MM_LENGTH 0x58 int dma_s2mm_transfer(uint32_t dst_phys, uint32_t len) { volatile uint32_t sr; dma_regs[S2MM_DMACR / 4] DMACR_RESET; while (dma_regs[S2MM_DMACR / 4] DMACR_RESET) ; dma_regs[S2MM_DA / 4] dst_phys; dma_regs[S2MM_LENGTH / 4] len; dma_regs[S2MM_DMACR / 4] DMACR_RS; sr dma_regs[S2MM_DMASR / 4]; while (!(sr DMASR_IDLE)) { if (sr DMASR_ERR) { dma_regs[S2MM_DMASR / 4] DMASR_ERR; return -1; } sr dma_regs[S2MM_DMASR / 4]; } return 0; }S2MM有一个实际工程中特别容易踩的坑写入的长度是期望接收的字节数如果PL端实际发送的数据少于这个长度DMA会永远等下去Idle一直不置位。所以S2MM长度不能拍脑袋填要和PL端逻辑约定好每一帧的精确长度。4.4 缓存一致性问题纯用户态绕不开给出三种解法这是整个应用层直管方案里最大的坑。CPU往DMA缓冲区里写数据时写操作不会立刻到达DDR而是留在L1/L2 cache里。AXI DMA访问DDR时读到的很可能是旧数据。反过来DMA往内存写完数据后CPU如果之前cache了同一物理地址的旧内容读出来的也是旧数据。我在裸机调DMA时完全没这个烦恼到了Linux应用层第一次遇到数据不对排查了很久才发现是cache问题。解决方案按工程代价从低到高有三种方案A把DMA缓冲区映射成non-cached。就是我们前面用/dev/mem加O_SYNC打开的方式或者通过UIO的map属性直接得到非缓存映射。这样CPU读写缓冲区每次都直接访问DDRDMA看到的数据一定是最新的。代价是CPU访问这块内存的速度变慢在数据量不大或者采集帧率不高的场景下完全够用。方案B写一个极小的内核模块用dma_alloc_coherent分配缓冲区再通过mmap把这个缓冲区映射到用户态。dma_alloc_coherent分配的内存天然非缓存且物理连续性能比手动/dev/mem映射好而且在Linux内核框架里属于正路。我们后面实际产品就是这么改的一个模块才两百行。方案C硬件层面把AXI DMA的主接口接到Zynq的S_AXI_ACP口通过SCU让PL端的DMA访问和CPU cache保持一致性。这个方案对性能损失最小但必须在硬件设计阶段就预留PCB和Block Design都已经定死了的话改不了。有一点要特别提醒应用层用msync去刷cache通常是不管用的msync针对的是文件映射的脏页回写对/dev/mem这种直接映射不保证清DMA相关的cache line。__builtin___clear_cache清的是指令cache对DMA数据一致性也毫无帮助。千万别在这上面浪费时间。4.5 轮询还是中断不同工程的取舍应用层最简单的是轮询也就是前面代码里死等Idle位置位。轮询的优点是代码路径极短不需要处理UIO事件也不涉及read阻塞缺点是传输期间CPU一直转圈虽然两次状态寄存器读取之间可以插调度但总归是占用CPU的。实测下来一次2048字节的短传输轮询循环大概会跑几十微秒CPU占用不算高。但如果是高频小包搬运轮询就浪费了。这时候可以换成UIO中断应用层通过poll或者阻塞read等待DMA完成int uio_fd open(/dev/uio0, O_RDWR); struct pollfd pfd { .fd uio_fd, .events POLLIN, }; int dma_mm2s_transfer_irq(uint32_t src_phys, uint32_t len) { uint32_t event_count; dma_regs[MM2S_SA / 4] src_phys; dma_regs[MM2S_LENGTH / 4] len; dma_regs[MM2S_DMACR / 4] DMACR_RS | (1 12); poll(pfd, 1, 1000); read(uio_fd, event_count, sizeof(event_count)); return 0; }UIO中断的机制是每次硬中断到来内核UIO驱动往/dev/uioX写一个32位的事件计数同时唤醒阻塞的read或poll。应用层read一次拿到这个计数注意这里读到的不是状态而是第几次中断发生的序号。如果连续两次中断太快事件可能合并所以不能依赖每次read对应一次传输要在传输前把那一次DMA的所有寄存器状态都准备好。5. 实测搬一张720P图像需要的实际耗时5.1 测试平台和测量方法测试平台是Zynq-7020PL侧DMA时钟100MHzAXI数据宽度32bitDirect Register模式预留DMA缓冲区4MB映射为非缓存。PL端逻辑里放了一个深度2KB的AXI Stream FIFO来模拟下游消费避免反压导致测速不准。测量用clock_gettime(CLOCK_MONOTONIC)把应用层发起DMA到轮询到Idle置位的完整耗时记录下来每组数据跑100次取平均。5.2 数据结果传输数据量平均耗时有效带宽4KB13us约315MB/s64KB170us约394MB/s1.8MB720P一帧4.75ms约388MB/s可以看到在32bit位宽、100MHz时钟的条件下单向传输的有效带宽稳定在390MB/s左右已经接近理论上限400MB/s。4KB小包因为启动和轮询开销占比大带宽会低一些属于正常现象。5.3 分析瓶颈在哪还能不能更快这个结果说明应用层直管AXI DMA几乎没有软件瓶颈。DMA硬件本身在搬数据应用层的寄存器写入和状态轮询只占微秒级时间。真正的瓶颈在AXI总线的数据宽度和时钟频率上。想进一步提升带宽方向有这么几个把Vivado里AXI DMA的Data Width从32bit扩到64bit同时把DMA时钟从100MHz提到150MHz甚至更高理论带宽能直接翻几倍确认DMA的M_AXI接口接在了PS的S_AXI_HP口上而不是通过GP口绕行HP口专为PL访问DDR设计带宽高得多在IP配置里把Burst Size设为最大16然后确保源地址和目标地址都按16字节以上对齐。这些改动都是在硬件配置层面应用层代码完全不用大改这也是这套方案讨喜的地方。6. 我踩过的坑与排查思路6.1 数据搬出去是错的缓存一致性引发的灵异现象第一次跑通MM2S的时候我在DMA缓冲区里填了0x55和0xAA交替的pattern然后在PL端抓AXI Stream数据发现搬出去的全是0或者上一轮的旧数据。第一反应是寄存器配置错了但寄存器读回来都是对的DMA确实启动了也确实从某个地址读了数据读的却是错的。排查链路是这样的先在PL端用ILA抓M_AXI_MM2S的读地址通道确认DMA访问的物理地址正好是0x30000000然后用/dev/mem直接把0x30000000的内容读出来发现内存里确实是0x55、0xAA最后才想到cache。因为CPU写pattern时写进了cacheDMA读的是DDR两边不一致。把/dev/mem改成O_SYNC非缓存映射后数据立刻变正常。这个坑的教训是一旦发现DMA搬出去的数据和CPU写的不同先按缓存一致性问题处理不要急着怀疑硬件时序。在嵌入式Linux里这种看着像时序问题实际是cache问题的情况太常见了。6.2 /dev/mem被内核拒绝映射RAM第二个坑来得很快。我把程序烧到板子上open(/dev/mem, O_RDWR | O_SYNC)成功了但mmap预留的DMA内存时返回Operation not permitted。当时以为权限不够试了chmod、sudo都没用。后来查内核配置才发现是CONFIG_STRICT_DEVMEM默认开启导致的。这个选项为了安全禁止应用层通过/dev/mem直接映射被内核标记为RAM的区域。解决办法有两个关掉内核配置里的CONFIG_STRICT_DEVMEM重新编译或者改用UIO设备来映射。UIO方案更干净所以我后来把DMA区也放进了UIO设备的reg区域通过UIO的map映射彻底绕开了/dev/mem的限制。6.3 一次传输完成后第二次启动不起来程序第一次DMA传输成功第二次调用却卡死在Idle轮询里。读状态寄存器发现Err_Irq错误位置1了但不知道为什么。原因其实前面提过DMA的错误标志是粘性锁存的一旦置位不手动清掉后续传输永远无法正常启动。所以在每次传输前做Reset只是第一步更稳妥的做法是启动前把DMASR读出来如果错误位是1先写1清掉然后再做寄存器配置。我把这个动作封装到了dma_reset_channel()函数里每次DMA启动前必调之后再也没有遇到过第二次起不来的问题。6.4 中断号和设备树对不上注册就失败第一次用UIO中断时/dev/uio0能看到但每次DMA传完/proc/interrupts里uio的中断次数纹丝不动。查了很久发现是中断号换算问题。我在设备树里写的是interrupts 0 61 4这个61直接取自Vivado里IRQ_F2P的GIC ID。但实际上Zynq设备树对SPI中断的第二个cell要求写的是物理GIC ID减32也就是29写61反而不在SPI范围内。改成interrupts 0 29 4之后/proc/interrupts里立刻能看到uioDMA完成两次传输计数也稳定加2。这里我用的例子是IRQ_F2P[0]如果你的PL中断接的是其他通道对应GIC ID也不一样最好以Vivado Block Design里实际的连接和Zynq TRM的中断表为准别直接复制我的编号。最后一个经验分享调试这类应用层直管DMA的问题我现在的固定流程已经固化下来了先确认设备树的物理地址和Vivado分配一致再用一段已知pattern的小数据包跑通轮询模式然后逐步加大数据量一旦出错第一反应永远是读DMASR寄存器按位对照Halted、Idle、Err_Irq这些状态而不是上手就怀疑AXI总线时序。这套流程帮我避开了绝大多数看着像硬件问题实际是上层配置问题的坑也希望你在自己板子上少走这几步弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑