简介:这份资源面向嵌入式Linux驱动开发学习者与FPGA高速通信实践者,聚焦Linux主机与FPGA之间通过PCIe接口进行数据交互的设备驱动实现,并引入DMA机制降低CPU干预、提升传输效率。内容围绕PCIe分层协议、单向与双向DMA、设备初始化、中断处理、DMA配置及数据传输等核心环节展开,适合已具备一定内核驱动基础、希望深入理解PCIe设备驱动框架的开发者参考。压缩包共11个文件,约27KB,以C源文件与头文件为主体承载驱动探测、DMA配置与命令定义,另含编译构建用的Makefile、内核模块文件,以及加载、卸载、运行与安装等辅助脚本,便于快速编译与验证。目前已有4825人学习下载,可作为理解PCIe DMA驱动从源码组织到模块加载完整链路的实践样本。
1. 一块板子插上就能跑:Linux 与 FPGA 的 PCIe 通道到底怎么打通
FPGA 采集完数据,Linux 主机要把它读上来,最直接的路子就是走 PCIe 总线。可板子插上去,lspci能看到设备号,/dev下却什么都没有——这是很多嵌入式 Linux 开发者第一次做 FPGA PCIe 驱动时最常撞上的墙。这个项目解决的正是这一段:在 Linux 内核里写一个 PCIe 设备驱动,把 FPGA 的 BAR 空间映射出来,再配一套 DMA 引擎,让主机内存和 FPGA 之间能大块搬运数据,而不是靠 CPU 一个字节一个字节地拷。适合谁?手上有一块带 PCIe 硬核的 FPGA 板、跑着嵌入式 Linux、需要做高速数据采集或加速卡原型的开发者。下面按「它是什么 → 怎么跑起来 → 怎么改」的顺序拆。
2. 驱动骨架拆解:从 probe 到 DMA 通道的完整注册链路
2.1 目录结构与模块划分
这类项目通常不会是一个孤零零的.c文件,而是一个能独立编译的内核模块工程。常见做法是下面这种布局,我按自己拆过的几个同类工程归纳:
fpga_pcie_dma/ ├── Makefile # 内核模块编译入口 ├── Kconfig # 可选,接入内核树时用 ├── fpga_pcie_main.c # 模块入口、PCI 驱动注册、probe/remove ├── fpga_pcie_dma.c # DMA 描述符管理与传输发起 ├── fpga_pcie_dma.h # 寄存器偏移、描述符结构体定义 ├── fpga_pcie_char.c # 字符设备节点,供用户态 read/write/ioctl └── fpga_pcie_regs.h # BAR 空间寄存器映射表拆开看,main.c负责和 PCI 子系统打交道,dma.c负责数据面,char.c负责把内核能力暴露给用户态。这种分层不是强迫症,是因为 PCIe 驱动的 probe 阶段要做的事太多:使能设备、申请 BAR、映射寄存器、申请中断、初始化 DMA、创建字符设备,全塞一个文件里,后面调 DMA 的时候根本找不到北。
2.2 probe 阶段:BAR 映射与设备初始化
PCIe 设备被内核枚举到之后,会调用驱动注册的probe回调。这一步是整个驱动的地基,写错一个顺序后面全是玄学问题。
static int fpga_pcie_probe(struct pci_dev *pdev, const struct pci_device_id *id) { int ret; struct fpga_dev *fdev; /* 1. 使能 PCI 设备,申请 BAR 资源 */ ret = pci_enable_device(pdev); if (ret) return ret; ret = pci_request_regions(pdev, "fpga_pcie_dma"); if (ret) goto err_disable; /* 2. 设置 DMA 掩码,64 位地址空间 */ ret = dma_set_mask_and_coherent(&pdev->dev, DMA_BIT_MASK(64)); if (ret) goto err_regions; /* 3. 映射 BAR0 为寄存器空间 */ fdev->bar0 = pci_iomap(pdev, 0, 0); if (!fdev->bar0) { ret = -ENOMEM; goto err_regions; } /* 4. 申请中断,注册处理函数 */ ret = pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_MSI); if (ret < 0) goto err_iomap; ret = request_irq(pci_irq_vector(pdev, 0), fpga_irq_handler, 0, "fpga_pcie", fdev); if (ret) goto err_irq; /* 5. 初始化 DMA 引擎并创建字符设备 */ fpga_dma_init(fdev); fpga_char_create(fdev); pci_set_drvdata(pdev, fdev); return 0; err_irq: pci_free_irq_vectors(pdev); err_iomap: pci_iomap(pdev, 0, 0); err_regions: pci_release_regions(pdev); err_disable: pci_disable_device(pdev); return ret; }这段代码里几个参数值得说清楚。pci_enable_device是唤醒设备,不做这一步后面读 BAR 全是 0xFFFFFFFF。pci_request_regions是向内核声明「这块地址归我」,防止别的驱动抢。dma_set_mask_and_coherent设成 64 位,是因为现代 FPGA 板卡基本都支持 64 位寻址,设成 32 位会在高地址内存上翻车。pci_alloc_irq_vectors用 MSI 而不是传统 INTx,是因为 PCIe 上 MSI 更可靠,中断延迟也低。错误处理用goto逐级回退,这是内核代码的标准写法,别嫌丑,漏掉一个pci_release_regions下次 insmod 就报资源占用。
2.3 DMA 描述符与传输发起
DMA 是这个项目的核心价值。FPGA 和主机之间搬数据,如果走 CPU 拷贝,带宽上不去还占 CPU。DMA 的做法是:驱动在内存里准备好一块缓冲区,把物理地址写进 FPGA 的 DMA 寄存器,FPGA 自己发起读写,完成后发中断通知驱动。
struct fpga_dma_desc { dma_addr_t src_addr; /* 源物理地址 */ dma_addr_t dst_addr; /* 目的物理地址 */ u32 length; /* 传输字节数 */ u32 ctrl; /* 方向、中断使能等控制位 */ }; static int fpga_dma_start(struct fpga_dev *fdev, struct fpga_dma_desc *desc) { /* 把描述符地址写入 FPGA 的 DMA 描述符基址寄存器 */ iowrite32(lower_32_bits(desc->src_addr), fdev->bar0 + DMA_SRC_LO); iowrite32(upper_32_bits(desc->src_addr), fdev->bar0 + DMA_SRC_HI); iowrite32(lower_32_bits(desc->dst_addr), fdev->bar0 + DMA_DST_LO); iowrite32(upper_32_bits(desc->dst_addr), fdev->bar0 + DMA_DST_HI); iowrite32(desc->length, fdev->bar0 + DMA_LEN); /* 最后写控制寄存器,启动传输 */ iowrite32(desc->ctrl | DMA_CTRL_START, fdev->bar0 + DMA_CTRL); /* 等待完成中断,超时则报错 */ return wait_for_completion_timeout(&fdev->dma_done, msecs_to_jiffies(1000)) ? 0 : -ETIMEDOUT; }iowrite32是 MMIO 写,每次写都会真正落到总线上,所以顺序不能乱——先写地址和长度,最后写控制位启动,这是 FPGA 侧状态机的约定。wait_for_completion_timeout等的是中断处理函数里complete(&fdev->dma_done)那一下。超时设 1 秒,实际传输通常毫秒级就完成,设太长会让出错时卡住。这里有个细节:lower_32_bits和upper_32_bits把 64 位地址拆成两个 32 位寄存器写,FPGA 侧再拼回去,这是 64 位 DMA 的标准做法。
2.4 字符设备接口:用户态怎么读写
内核里 DMA 跑通了,用户态还得能访问。常见做法是注册一个字符设备,用ioctl下发传输命令,用mmap把 DMA 缓冲区映射到用户空间,避免二次拷贝。
static long fpga_char_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { struct fpga_dev *fdev = filp->private_data; struct fpga_dma_req req; switch (cmd) { case FPGA_DMA_READ: if (copy_from_user(&req, (void __user *)arg, sizeof(req))) return -EFAULT; return fpga_dma_transfer(fdev, &req, DMA_DIR_READ); case FPGA_DMA_WRITE: if (copy_from_user(&req, (void __user *)arg, sizeof(req))) return -EFAULT; return fpga_dma_transfer(fdev, &req, DMA_DIR_WRITE); default: return -ENOTTY; } }copy_from_user是必须的,用户态指针不能直接解引用。FPGA_DMA_READ和FPGA_DMA_WRITE两个命令分别对应 FPGA 到主机、主机到 FPGA 两个方向。用户态程序拿到/dev/fpga_pcie0之后,open再ioctl就能发起一次 DMA,配合mmap直接读缓冲区,整条链路就通了。
3. 跑起来:编译、加载与第一次 DMA 验证
3.1 编译环境与 Makefile
这类驱动模块的编译依赖内核头文件,交叉编译时还要指定目标架构的工具链。Makefile 通常长这样:
obj-m := fpga_pcie_dma.o fpga_pcie_dma-objs := fpga_pcie_main.o fpga_pcie_dma.o fpga_pcie_char.o KDIR ?= /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) cleanobj-m表示编成可加载模块,fpga_pcie_dma-objs把三个源文件链成一个.ko。KDIR指向内核构建目录,本机编译直接用/lib/modules/$(uname -r)/build,交叉编译时改成目标板内核源码路径,并加上ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu-。这一步最容易翻车的是内核版本不匹配——编译用的头文件和板子上跑的内核不是同一个版本,insmod 会报invalid module format。
3.2 加载模块与设备识别
编译出fpga_pcie_dma.ko之后,拷到目标板,按顺序执行:
# 确认 PCIe 设备已被枚举 lspci -nn | grep -i fpga # 加载驱动 sudo insmod fpga_pcie_dma.ko # 查看内核日志,确认 probe 成功 dmesg | tail -20 # 确认字符设备节点生成 ls -l /dev/fpga_pcie0lspci能看到设备说明硬件链路没问题,PCIe 枚举是 BIOS 或内核做的,跟驱动无关。insmod之后看dmesg,正常会打印 BAR 地址、中断号、DMA 缓冲区物理地址。如果probe失败,日志里会有具体错误码,-ENODEV多半是 vendor/device ID 没对上,-ENOMEM是 BAR 映射失败。/dev/fpga_pcie0没出现,说明字符设备注册那步没走到,回头查probe的返回路径。
3.3 用测试程序发起一次 DMA
驱动加载成功后,写个小程序验证数据通路:
int fd = open("/dev/fpga_pcie0", O_RDWR); struct fpga_dma_req req = { .len = 4096, .dir = DMA_DIR_READ, }; ioctl(fd, FPGA_DMA_READ, &req); /* 此时 req.buf 里就是 FPGA 传上来的数据 */len设 4096 是一次传一页,实际项目里按 FPGA 侧 FIFO 深度和带宽需求调。dir决定方向。跑通这一步,说明 BAR 映射、DMA 描述符、中断、字符设备整条链路都活了。如果ioctl返回-ETIMEDOUT,先查 FPGA 侧 DMA 状态机有没有真的启动,再看中断有没有上来——cat /proc/interrupts里能看到这个设备的中断计数有没有涨。
4. 避坑与排查:PCIe DMA 驱动最容易翻车的几个点
4.1 现象:DMA 传输偶发数据错乱,重传又正常
原因通常是 DMA 缓冲区的 cache 一致性问题。CPU 写缓冲区后,数据可能还在 cache 里没落到内存,FPGA 通过 DMA 读到的就是旧数据。解决:传输前对缓冲区做dma_sync_single_for_device,传输后做dma_sync_single_for_cpu,或者直接用dma_alloc_coherent分配一致性内存。后者简单但性能略低,前者灵活但要自己管同步点。
4.2 现象:insmod 报资源忙,pci_request_regions失败
多半是上一次卸载没干净,或者 BIOS 把 BAR 分给了别的设备。先rmmod再insmod,还不行就cat /proc/iomem看 BAR 地址有没有被占。另一个常见原因是驱动里 vendor/device ID 写错,匹配到了错误的设备,把别人的 BAR 给申请了。
4.3 现象:中断一直不来,DMA 超时
先确认 FPGA 侧中断使能位有没有置上,再看 MSI 有没有真正分配成功——pci_alloc_irq_vectors返回负数说明 MSI 不可用,得回退到PCI_IRQ_LEGACY。还有一种情况是中断处理函数里没清 FPGA 的中断状态位,导致中断一直挂着,内核认为中断风暴把线给关了,dmesg里会有irq nobody cared的提示。
4.4 现象:大块 DMA 传输到一半卡死
检查描述符里的length有没有超过 FPGA 侧单次传输上限。很多 FPGA DMA 引擎单次最多传 4KB 或 64KB,超过就得拆成多个描述符链式传输。另外确认dma_set_mask_and_coherent设的位宽和 FPGA 实际支持的地址位宽一致,FPGA 只认 32 位地址而驱动给了 64 位,高位被截断,DMA 就写到错误的内存地址去了。
5. 二次开发:把通用驱动改成你的板子专用
拿到这个驱动骨架之后,真正要改的地方集中在三处:寄存器偏移、DMA 描述符格式、中断语义。寄存器偏移在fpga_pcie_regs.h里,把DMA_SRC_LO、DMA_CTRL这些宏改成你 FPGA 侧的实际地址,这是第一步,改错了后面全白搭。DMA 描述符格式如果 FPGA 用的是链式描述符而不是单次传输,就得把fpga_dma_start改成往描述符基址寄存器写链表头地址,并让 FPGA 自己遍历链表。中断语义方面,如果 FPGA 有多个中断源(传输完成、错误、FIFO 溢出),就得在fpga_irq_handler里读状态寄存器做分发,而不是简单complete一下就完事。
验证方法我一般这么走:先用devmem直接读写 BAR 寄存器,确认地址映射和 FPGA 寄存器手册对得上;再写一个只做单次 4KB DMA 的最小测试,跑通再加长度和方向;最后上压力测试,连续传几千次看有没有内存泄漏和中断丢失。/proc/interrupts的中断计数和dmesg里的 DMA 完成日志是两个最直接的观测点。
有个血泪教训:早期我图省事,把 DMA 缓冲区的分配放在probe里用kmalloc加virt_to_phys取物理地址,结果在开了 IOMMU 的板子上物理地址根本不对,DMA 写到别处去了,查了两天才定位到。从那以后我每次写 DMA 相关代码,分配缓冲区一律走dma_alloc_coherent或dma_map_single,绝不用virt_to_phys偷懒。希望这个习惯也能帮到你,少走那段弯路。
本文还有配套的精品资源,点击获取