Zynq SoC上AXI-Stream FIFO Linux驱动开发实战

发布时间:2026/9/27 1:59:00
Zynq SoC上AXI-Stream FIFO Linux驱动开发实战 简介这份资源面向在Xilinx Zynq SoC平台上开发嵌入式Linux驱动的工程师与学习者聚焦AXI-Stream FIFO IP核的内核驱动实现。它提供了一套可参考的驱动源码与构建脚本帮助解决Linux系统识别、控制该硬件IP并完成高速数据缓存与传输的问题适合具备一定内核驱动基础、希望深入软硬件协同设计的开发者。压缩包共10个文件约35KB以4个C源文件为核心配合头文件、Makefile构建脚本、说明文档与LICENSE覆盖驱动主体、测试程序及以太网桥接示例等模块结构紧凑。目前已有285人学习下载。通过研读这些代码读者可以掌握AXI-Stream协议下的设备注册、数据读写与错误处理思路理解Makefile如何将源码编译为可加载内核模块并借助测试程序验证功能为在Zynq平台上集成和管理自定义IP驱动提供可复用的实践参考。1. 从 PL 到用户态Zynq SoC 上 AXI-Stream FIFO 驱动到底在解决什么问题手里有一块 Zynq 开发板PL 端用 Vivado 搭了一个 AXI4-Stream FIFO IP数据从自定义逻辑灌进 FIFOPS 端跑着 Linux现在要让用户态程序能读到这批数据。听起来就是「写个驱动」但真正动手会发现三件事同时压过来AXI-Stream 本身没有地址概念FIFO IP 的寄存器映射要自己对着手册抠Linux 字符设备驱动的框架又要求你把中断、DMA、mmap 这些机制串起来。标题里的「适用于 Xilinx AXI-Stream FIFO IP 的 Zynq SoC Linux 内核驱动程序」讲的正是这条链路——把 PL 侧一个流式 FIFO 包装成 /dev 下的字符设备让应用层用 read/write/ioctl 就能收发数据。它适合已经在 Zynq 上跑通 PetaLinux、能独立完成 Vivado 综合与导出 XSA 的嵌入式工程师也适合想从裸机 AXI 读写过渡到 Linux 驱动开发的从业者。下面按「先搞清楚 IP 和硬件长什么样再写驱动最后排坑」的顺序展开中间会给出可直接抄的 Makefile 和驱动骨架。2. AXI-Stream FIFO IP 的寄存器模型与设备树落地2.1 先分清 AXI Stream FIFO 和 AXI FIFO MM S 的区别Xilinx 的 FIFO 类 IP 不止一个名字相近但用法完全不同。AXI4-Stream FIFO常写作 AXI Stream FIFO是「AXI4-Stream 进、AXI4 内存映射出」或者反过来的桥接型 FIFO它自带 AXI4-Lite 从接口用于寄存器访问PS 通过这个 Lite 接口配置和读写 FIFO 数据。而 AXI FIFO MM S 是纯内存映射到内存映射的 FIFO没有流接口。选错 IP 会导致设备树里根本找不到可用的寄存器基地址。判断方法很简单打开 Vivado 的 Address Editor看这个 IP 是否出现在 PS 的 AXI4-Lite 地址映射里有基地址和地址范围的就是我们要驱动的对象。常见做法是在 Vivado 里把 AXI Stream FIFO 配置成「AXI4-Stream 到 AXI4 内存映射」方向FIFO 深度按数据吞吐算。FIFO 深度计算有个经验公式深度 ≥ 突发长度 × 通道数 × 2留一倍余量防止溢出。比如 PL 侧每 1 ms 产生 512 个 32 位样本AXI4-Stream 位宽 32 bit那么瞬时突发 512 拍FIFO 深度至少 1024。深度不够时最典型的翻车现象是 TLAST 丢失、接收端读到的包长度对不上。2.2 寄存器映射把手册里的偏移量变成驱动里的宏AXI Stream FIFO 的寄存器空间通常 64 KB 对齐关键寄存器包括寄存器名偏移作用ISR0x00中断状态含接收完成、发送完成、FIFO 满/空IER0x04中断使能TDFR0x08发送数据 FIFO 复位TDFV0x0C发送 FIFO 当前可写 vacancyTDFD0x10发送数据写入端口TLR0x14发送长度写 TLAST 前必须设RDFR0x18接收 FIFO 复位RDFO0x1C接收 FIFO 占用计数RDFD0x20接收数据读出端口RLR0x24接收长度读出包长这些偏移量在 PG080 手册里有完整表格驱动里用宏定义固定下来不要散落在代码各处。写寄存器用 iowrite32读用 ioread32基地址来自 platform_get_resource 或 devm_ioremap_resource。2.3 设备树节点怎么写才不会被内核忽略设备树里要声明 compatible、reg、interrupts 三样。compatible 用 xlnx,axi-stream-fifo-1.0 这类字符串reg 填 Vivado Address Editor 里分配的基地址和长度interrupts 填 PL 中断号映射到 GIC 的 SPI 号。一个可用的节点长这样axi_stream_fifo: axi-stream-fifo43c00000 { compatible xlnx,axi-stream-fifo-1.0; reg 0x43c00000 0x10000; interrupt-parent intc; interrupts 0 29 4; clock-names s_axi_aclk; clocks clkc 15; };reg 的 0x10000 是 64 KB 地址空间和 IP 的地址范围一致。interrupts 里的 29 是 PL 中断经过 GIC 后的 SPI 编号具体值取决于 Vivado 里中断连接方式写错会导致 probe 成功但中断永远不来。验证方法insmod 后 cat /proc/interrupts看对应 SPI 号有没有计数增长。3. 字符设备驱动骨架从 file_operations 到中断处理3.1 驱动初始化probe 里要做完的五件事probe 函数是驱动的心脏顺序不能乱。第一拿 reg 资源并 ioremap第二申请中断号并注册 handler第三初始化等待队列和环形缓冲区第四注册字符设备并创建 /dev 节点第五使能 IP 的接收中断。任何一步失败都要回滚前面已做的操作否则 rmmod 时会 oops。static int axisf_probe(struct platform_device *pdev) { struct axisf_dev *dev; struct resource *res; int ret; dev devm_kzalloc(pdev-dev, sizeof(*dev), GFP_KERNEL); if (!dev) return -ENOMEM; res platform_get_resource(pdev, IORESOURCE_MEM, 0); dev-base devm_ioremap_resource(pdev-dev, res); if (IS_ERR(dev-base)) return PTR_ERR(dev-base); dev-irq platform_get_irq(pdev, 0); if (dev-irq 0) return dev-irq; spin_lock_init(dev-lock); init_waitqueue_head(dev-rx_wait); dev-rx_buf kzalloc(RX_BUF_SIZE, GFP_KERNEL); if (!dev-rx_buf) return -ENOMEM; ret devm_request_irq(pdev-dev, dev-irq, axisf_isr, 0, DRV_NAME, dev); if (ret) return ret; ret alloc_chrdev_region(dev-devno, 0, 1, DRV_NAME); if (ret) return ret; cdev_init(dev-cdev, axisf_fops); dev-cdev.owner THIS_MODULE; ret cdev_add(dev-cdev, dev-devno, 1); if (ret) { unregister_chrdev_region(dev-devno, 1); return ret; } /* 使能接收完成和接收溢出中断 */ iowrite32(ISR_RC_MASK | ISR_RX_OVR_MASK, dev-base IER_OFFSET); platform_set_drvdata(pdev, dev); return 0; }devm_ 系列函数的好处是失败时自动释放但 alloc_chrdev_region 和 cdev_add 不是 devm 管理的必须手动回滚。RX_BUF_SIZE 建议设为 FIFO 深度的两倍防止中断里数据还没搬完就被覆盖。3.2 中断处理顶半部只做该做的事中断处理函数要短。顶半部读 ISR 判断中断源清中断标志把数据从 RDFD 搬到内核缓冲区然后唤醒等待队列。不要在中断里做 copy_to_user那是用户态上下文的事。static irqreturn_t axisf_isr(int irq, void *data) { struct axisf_dev *dev data; u32 isr; u32 len, i; isr ioread32(dev-base ISR_OFFSET); if (!(isr (ISR_RC_MASK | ISR_RX_OVR_MASK))) return IRQ_NONE; if (isr ISR_RC_MASK) { len ioread32(dev-base RLR_OFFSET); if (len RX_BUF_SIZE) len RX_BUF_SIZE; for (i 0; i len; i) dev-rx_buf[dev-rx_head % RX_BUF_SIZE] ioread32(dev-base RDFD_OFFSET); dev-rx_len len; wake_up_interruptible(dev-rx_wait); } /* 写 1 清中断 */ iowrite32(isr, dev-base ISR_OFFSET); return IRQ_HANDLED; }RLR 寄存器给出当前接收包的长度单位是 32 位字数不是字节数。如果 PL 侧送的是字节流驱动里要乘以 4 再处理。中断里循环读 RDFD 时要注意 FIFO 可能被读空读空后 RDFD 返回的值无意义所以循环次数必须严格等于 RLR 的值。3.3 read/write 与 ioctl用户态接口怎么设计read 实现阻塞读没有数据时睡在 rx_wait 上。write 走发送通道先查 TDFV 确认有空间再写 TDFD最后写 TLR 触发发送。ioctl 用来做 FIFO 复位、查询状态这类控制操作。static ssize_t axisf_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { struct axisf_dev *dev filp-private_data; int ret; if (wait_event_interruptible(dev-rx_wait, dev-rx_len 0)) return -ERESTARTSYS; spin_lock_irq(dev-lock); if (count dev-rx_len) count dev-rx_len; ret copy_to_user(buf, dev-rx_buf, count); dev-rx_len - count; spin_unlock_irq(dev-lock); return ret ? -EFAULT : count; }copy_to_user 返回非零表示有字节没拷过去此时应该返回 -EFAULT 而不是部分成功否则用户态会拿到不完整数据却以为读完了。write 方向同理copy_from_user 之后写 TDFD每次写之前读 TDFV 确认 vacancy 足够。4. Makefile 与交叉编译把驱动编进内核还是编成模块4.1 外部模块 Makefile 的标准写法驱动开发阶段用外部模块方式编译改一次编一次不用重编整个内核。Makefile 核心就几行obj-m axisf_drv.o axisf_drv-objs : axisf_main.o axisf_fops.o KDIR ? /path/to/petalinux/build/linux ARCH ? arm CROSS_COMPILE ? arm-linux-gnueabihf- all: $(MAKE) -C $(KDIR) M$(PWD) ARCH$(ARCH) \ CROSS_COMPILE$(CROSS_COMPILE) modules clean: $(MAKE) -C $(KDIR) M$(PWD) cleanKDIR 指向 PetaLinux 工程里编译好的内核源码目录通常在 build/tmp/work/ 下面。ARCH 和 CROSS_COMPILE 必须和内核编译时一致否则会出现「模块版本魔术不匹配」的报错。编译产物是 axisf_drv.ko用 scp 推到板子上 insmod。4.2 编进内核的 Kconfig 和 Makefile 改法产品化阶段把驱动编进内核需要在 drivers/char/ 下建目录写 Kconfig 和 Makefile。Kconfig 里定义 config AXISF_DRVMakefile 里 obj-$(CONFIG_AXISF_DRV) axisf_drv.o。然后在 defconfig 里加 CONFIG_AXISF_DRVy。编进内核的好处是启动即加载不依赖文件系统里的 .ko 文件坏处是每次改驱动都要重编内核迭代慢。我的习惯是开发期用模块定型后再切进内核。4.3 交叉编译常见报错与排查make 报「没有指明目标并且找不到 makefile」通常是 KDIR 路径写错或者内核源码没编译过缺 .config 和 Module.symvers。报「disconnected from the target vm」这类是调试器问题和驱动编译无关。最坑的是内核版本和模块 vermagic 不一致insmod 时报「invalid module format」解决方法是确保 KDIR 指向的内核源码和板子上跑的内核是同一份编译产物。5. 避坑与排查五个让驱动跑不起来的真实原因5.1 中断注册成功但永远不触发现象insmod 成功/dev 节点也在但 cat /proc/interrupts 对应中断计数始终为 0。原因通常是设备树里 interrupts 的 SPI 编号写错或者 Vivado 里 PL 中断没有正确连到 PS 的 IRQ_F2P 端口。解决回 Vivado 检查中断连接确认 Address Editor 里中断号再对照 GIC 的 SPI 映射表改设备树。另一个可能是 IP 的 IER 寄存器没使能对应中断位probe 里补上 iowrite32。5.2 读到的数据全是 0 或固定值现象read 返回成功但缓冲区里全是 0x00000000 或 0xFFFFFFFF。原因一是 RDFD 读之前没有确认 RDFO 大于 0FIFO 空时读出来的是无效值原因二是 PL 侧数据没有真正写入 FIFOAXI4-Stream 的 TVALID 一直为低。解决先在驱动里加打印读 RDFO 确认 FIFO 里确实有数据再用 ILA 抓 PL 侧的 TVALID/TREADY 波形确认数据通路是通的。5.3 FIFO 溢出导致数据丢包现象高速数据流下偶尔丢包ISR 里读到 RX_OVR 标志。原因是中断处理太慢FIFO 满了新数据覆盖旧数据。解决加大 FIFO 深度或者改用 DMA 方式搬运数据把 CPU 从逐字读 RDFD 的循环里解放出来。临时缓解可以在 ISR 里一次多读几个字减少中断次数。5.4 设备树改了但内核没生效现象改了 .dts 重新编译启动后驱动 probe 还是用旧参数。原因是 PetaLinux 的 BOOT.BIN 里打包的是旧的 dtb没有重新生成。解决petalinux-build 之后确认 images/linux/system.dtb 的时间戳是新的然后重新打包 BOOT.BIN 并烧写到 SD 卡。用 fdtdump 或 dtc -I dtb -O dts 反编译板子上的 dtb确认改动确实进去了。5.5 rmmod 时内核 oops现象卸载模块时打印一堆 call trace系统卡死。原因是 probe 里申请的资源没有在 remove 里释放或者中断 handler 还在运行时模块就被卸载了。解决remove 函数里先 iowrite32(0, IER) 关中断再 free_irq然后 cdev_del 和 unregister_chrdev_region最后释放缓冲区。顺序反了就会出问题。6. 进阶用 DMA 替代 PIO 把吞吐拉上去PIO 方式逐字读写 RDFD/TDFDCPU 占用率高适合低速场景。数据率超过几 MB/s 后必须上 DMA。AXI Stream FIFO IP 本身不带 DMA但可以在 Vivado 里挂一个 AXI DMA IP让 DMA 在 FIFO 和 DDR 之间搬数据驱动里用 dmaengine 框架提交传输描述符。关键改动有三处设备树里加 DMA 节点并关联到 FIFO驱动里用 dma_request_chan 申请通道中断处理从「读 RDFD」变成「等 DMA 完成回调」。验证 DMA 是否真正生效看 /proc/interrupts 里 DMA 中断计数和 FIFO 中断计数的比例。如果 DMA 中断在涨而 FIFO 中断不涨说明数据走的是 DMA 通路。另一个验证手段是用 perf 或 top 看 insmod 后 CPU 占用率PIO 方式在满速时单核占用能到 80% 以上DMA 方式通常低于 10%。一个容易忽略的细节DMA 缓冲区的物理地址必须连续用 dma_alloc_coherent 分配不要用 kmalloc。kmalloc 返回的虚拟地址连续但物理地址不一定连续DMA 搬过去就是错位的数据。这个坑我在第一次做 Zynq DMA 驱动时踩过调试了一整天才发现是缓冲区分配方式的问题。希望帮到你。本文还有配套的精品资源点击获取