4. PCIe 驱动开发入门:编写一个简单的 PCIe 字符设备驱动

本章将引导你从零开始构建一个完整的 PCIe 字符设备驱动。该驱动将注册为一个标准的 Linux 字符设备,并实现 open、read、write 和 ioctl 接口,为上层应用程序提供与 PCIe 硬件交互的通道。这是 PCIe 驱动开发中最基础、最核心的实践环节。

学习目标: 掌握 PCIe 驱动的基本框架、字符设备注册流程、以及如何通过 file_operations 结构体将系统调用与硬件操作绑定。

4.1 PCIe 字符设备驱动架构总览

一个典型的 PCIe 字符设备驱动在 Linux 内核中分为三个逻辑层:PCIe 设备层、字符设备层和用户接口层。下图展示了这三层之间的调用关系与数据流向。

PCIe 字符设备驱动三层架构图 PCIe 字符设备驱动三层架构 用户空间应用程序 open / read / write / ioctl 内核空间:字符设备层 file_operations 结构体(open/read/write/ioctl) 设备号注册 / cdev 初始化 内核空间:PCIe 设备层 pci_driver 结构体 / probe / remove BAR 空间映射 / DMA 配置 / 中断注册 PCIe 硬件设备 关键数据结构 • struct pci_driver • struct cdev • struct file_operations • struct pci_dev
架构要点: 用户空间的系统调用通过 VFS 层路由到字符设备驱动注册的 file_operations,再由字符设备层调用 PCIe 设备层提供的硬件访问函数,最终通过 PCIe 总线配置空间和 BAR 空间与物理设备通信。

4.2 驱动核心数据结构与初始化流程

编写 PCIe 字符设备驱动,首先需要定义并初始化两个核心结构体:struct pci_driver 和 struct file_operations。下表列出了这两个结构体中必须实现的成员及其作用。

结构体 成员 作用描述
pci_driver .name 驱动名称,用于与设备 ID 表匹配
.id_table 指向 pci_device_id 数组,声明支持的设备 VID/PID
.probe 设备发现时调用,完成硬件初始化、字符设备注册
.remove 设备移除时调用,释放资源、注销字符设备
file_operations .owner 通常设为 THIS_MODULE
.open 打开设备文件时调用,可进行权限检查或递增引用计数
.read 从设备读取数据,通常通过 ioread 系列函数访问 BAR 空间
.write 向设备写入数据,通常通过 iowrite 系列函数操作硬件寄存器
.unlocked_ioctl 实现自定义控制命令,如复位、配置读取、DMA 触发等

4.3 驱动入口与出口函数实现

驱动的生命周期由 module_init 和 module_exit 管理。在入口函数中,我们调用 pci_register_driver 将驱动注册到 PCI 核心子系统。

#include <linux/module.h>
#include <linux/pci.h>
#include <linux/cdev.h>
#include <linux/fs.h>
#include <linux/uaccess.h>

#define DRIVER_NAME "pcie_char_dev"
#define DEVICE_NAME "pcie_char"
#define CLASS_NAME  "pcie_char_class"

static struct class *pcie_char_class;
static int major_num;

// 设备私有结构体
struct pcie_char_dev {
    struct pci_dev *pdev;
    struct cdev cdev;
    void __iomem *bar0_addr;
    unsigned long bar0_len;
};

static int pcie_char_probe(struct pci_dev *pdev, const struct pci_device_id *ent);
static void pcie_char_remove(struct pci_dev *pdev);

// PCI 设备 ID 表
static const struct pci_device_id pcie_char_ids[] = {
    { PCI_DEVICE(0x10EE, 0x9034) }, // 示例 VID=0x10EE, PID=0x9034
    { 0, }
};
MODULE_DEVICE_TABLE(pci, pcie_char_ids);

static struct pci_driver pcie_char_driver = {
    .name     = DRIVER_NAME,
    .id_table = pcie_char_ids,
    .probe    = pcie_char_probe,
    .remove   = pcie_char_remove,
};

static int __init pcie_char_init(void)
{
    int ret;

    // 动态分配主设备号
    ret = alloc_chrdev_region(&major_num, 0, 1, DEVICE_NAME);
    if (ret < 0) {
        pr_err("Failed to allocate major number\n");
        return ret;
    }

    // 创建设备类
    pcie_char_class = class_create(THIS_MODULE, CLASS_NAME);
    if (IS_ERR(pcie_char_class)) {
        unregister_chrdev_region(major_num, 1);
        return PTR_ERR(pcie_char_class);
    }

    // 注册 PCI 驱动
    ret = pci_register_driver(&pcie_char_driver);
    if (ret) {
        class_destroy(pcie_char_class);
        unregister_chrdev_region(major_num, 1);
        pr_err("Failed to register PCI driver\n");
    }

    return ret;
}

static void __exit pcie_char_exit(void)
{
    pci_unregister_driver(&pcie_char_driver);
    class_destroy(pcie_char_class);
    unregister_chrdev_region(major_num, 1);
}

module_init(pcie_char_init);
module_exit(pcie_char_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("PCIe Debug Course");
MODULE_DESCRIPTION("Simple PCIe Character Device Driver");
注意事项: alloc_chrdev_region 必须在 pci_register_driver 之前调用,因为 probe 回调中需要用到已分配的设备号来初始化 cdev。如果顺序颠倒,probe 中将无法创建设备节点。

4.4 probe 函数:字符设备与 PCIe 硬件绑定

probe 函数是驱动初始化的核心。它负责启用 PCIe 设备、映射 BAR 空间、初始化字符设备并创建设备节点。下面是完整的 probe 实现。

static int pcie_char_probe(struct pci_dev *pdev, const struct pci_device_id *ent)
{
    struct pcie_char_dev *dev;
    struct device *device;
    int ret;

    // 1. 分配设备私有结构体
    dev = devm_kzalloc(&pdev->dev, sizeof(*dev), GFP_KERNEL);
    if (!dev)
        return -ENOMEM;

    dev->pdev = pdev;

    // 2. 启用 PCIe 设备
    ret = pcim_enable_device(pdev);
    if (ret) {
        dev_err(&pdev->dev, "Failed to enable PCI device\n");
        return ret;
    }

    // 3. 请求 BAR0 资源并映射到内核虚拟地址空间
    ret = pcim_iomap_regions(pdev, BIT(0), DRIVER_NAME);
    if (ret) {
        dev_err(&pdev->dev, "Failed to iomap BAR0\n");
        return ret;
    }
    dev->bar0_addr = pcim_iomap_table(pdev)[0];
    dev->bar0_len = pci_resource_len(pdev, 0);

    // 4. 设置 DMA 掩码(可选,用于 DMA 操作)
    ret = dma_set_mask_and_coherent(&pdev->dev, DMA_BIT_MASK(64));
    if (ret) {
        ret = dma_set_mask_and_coherent(&pdev->dev, DMA_BIT_MASK(32));
        if (ret) {
            dev_err(&pdev->dev, "Failed to set DMA mask\n");
            return ret;
        }
    }

    // 5. 初始化字符设备
    cdev_init(&dev->cdev, &pcie_char_fops);
    dev->cdev.owner = THIS_MODULE;
    ret = cdev_add(&dev->cdev, major_num, 1);
    if (ret) {
        dev_err(&pdev->dev, "Failed to add cdev\n");
        return ret;
    }

    // 6. 创建设备节点 /dev/pcie_char
    device = device_create(pcie_char_class, &pdev->dev, major_num,
                           dev, DEVICE_NAME);
    if (IS_ERR(device)) {
        cdev_del(&dev->cdev);
        return PTR_ERR(device);
    }

    // 7. 保存私有数据到 pci_dev
    pci_set_drvdata(pdev, dev);

    dev_info(&pdev->dev, "Probe successful, BAR0 at %p (len=%lu)\n",
             dev->bar0_addr, dev->bar0_len);
    return 0;
}
probe 函数执行流程图 probe 函数执行流程 分配私有结构体 pcim_enable_device pcim_iomap_regions(BAR0) cdev_add + device_create pci_set_drvdata

4.5 file_operations 接口实现

字符设备的核心交互接口定义在 file_operations 结构体中。下面给出 open、read、write 和 unlocked_ioctl 的典型实现。

static int pcie_char_open(struct inode *inode, struct file *filp)
{
    struct pcie_char_dev *dev = container_of(inode->i_cdev,
                                             struct pcie_char_dev, cdev);
    filp->private_data = dev;
    return 0;
}

static ssize_t pcie_char_read(struct file *filp, char __user *buf,
                              size_t count, loff_t *f_pos)
{
    struct pcie_char_dev *dev = filp->private_data;
    size_t bytes_to_read;
    u32 data;

    // 限制读取长度不超过 BAR0 空间大小
    bytes_to_read = min(count, (size_t)(dev->bar0_len - *f_pos));
    if (bytes_to_read < 4)
        return 0;

    // 从 BAR0 偏移 f_pos 处读取一个 32 位寄存器
    data = ioread32(dev->bar0_addr + *f_pos);
    if (copy_to_user(buf, &data, sizeof(data)))
        return -EFAULT;

    *f_pos += sizeof(data);
    return sizeof(data);
}

static ssize_t pcie_char_write(struct file *filp, const char __user *buf,
                               size_t count, loff_t *f_pos)
{
    struct pcie_char_dev *dev = filp->private_data;
    u32 data;

    if (count < sizeof(data))
        return -EINVAL;

    if (copy_from_user(&data, buf, sizeof(data)))
        return -EFAULT;

    // 写入 BAR0 偏移 f_pos 处的寄存器
    iowrite32(data, dev->bar0_addr + *f_pos);
    *f_pos += sizeof(data);
    return sizeof(data);
}

static long pcie_char_ioctl(struct file *filp, unsigned int cmd,
                            unsigned long arg)
{
    struct pcie_char_dev *dev = filp->private_data;
    u32 val;

    switch (cmd) {
    case IOCTL_READ_REG:
        // 从用户空间获取寄存器偏移地址
        if (copy_from_user(&val, (u32 __user *)arg, sizeof(val)))
            return -EFAULT;
        val = ioread32(dev->bar0_addr + val);
        if (copy_to_user((u32 __user *)arg, &val, sizeof(val)))
            return -EFAULT;
        break;

    case IOCTL_WRITE_REG:
        // 从用户空间获取 {偏移, 值} 结构体
        // 此处省略具体结构体定义
        break;

    case IOCTL_RESET_DEVICE:
        // 触发设备软复位
        iowrite32(0x1, dev->bar0_addr + RESET_OFFSET);
        break;

    default:
        return -ENOTTY;
    }
    return 0;
}

static struct file_operations pcie_char_fops = {
    .owner          = THIS_MODULE,
    .open           = pcie_char_open,
    .read           = pcie_char_read,
    .write          = pcie_char_write,
    .unlocked_ioctl = pcie_char_ioctl,
};
设计建议: 在实际项目中,read 和 write 通常用于流式数据传输(如 DMA 缓冲区),而 ioctl 更适合寄存器级别的配置和状态查询。建议将 BAR 空间的随机访问封装在 ioctl 中,保持 read/write 语义的清晰。

4.6 用户空间测试程序示例

编写完成后,可以通过一个简单的用户空间程序验证驱动功能。以下程序打开设备、读取一个寄存器并打印其值。

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>

#define DEVICE_PATH "/dev/pcie_char"
#define IOCTL_READ_REG _IOR('p', 1, unsigned int)

int main(void)
{
    int fd;
    unsigned int offset = 0x00;
    unsigned int value;

    fd = open(DEVICE_PATH, O_RDWR);
    if (fd < 0) {
        perror("Failed to open device");
        return -1;
    }

    // 通过 ioctl 读取 BAR0 偏移 0x00 处的寄存器
    if (ioctl(fd, IOCTL_READ_REG, &offset) < 0) {
        perror("ioctl failed");
        close(fd);
        return -1;
    }
    value = offset; // ioctl 将读取结果写回 offset 变量

    printf("Register at offset 0x00: 0x%08X\n", value);

    close(fd);
    return 0;
}

4.7 常见问题与调试技巧

  • 设备节点未创建: 检查 udev 规则或手动使用 mknod /dev/pcie_char c <major> 0 创建。
  • ioread32 返回 0xFFFFFFFF: 通常表示 BAR 空间未正确映射或设备未使能,检查 lspci -vv 中的 BAR 地址是否有效。
  • probe 未被调用: 确认 pci_device_id 表中的 VID/PID 与目标设备一致,并检查驱动是否已加载。
  • ioctl 返回 -ENOTTY: 检查命令编号是否与用户空间定义的宏一致,确保 _IOR/_IOW 的方向和大小匹配。
调试命令速查:
# lspci -vv -s 01:00.0 — 查看 PCIe 设备配置空间和 BAR 信息
# cat /proc/devices | grep pcie_char — 确认主设备号已注册
# ls -l /dev/pcie_char — 确认设备节点存在
# dmesg | tail -20 — 查看驱动打印的调试信息