4. Zynq中断控制器与GIC驱动实践

字符设备驱动,说白了就是Linux世界里最基础、最常用的一种驱动形态。你想想看,串口、GPIO、I2C、SPI,这些在Zynq上天天打交道的硬件,本质上都是字符设备。我刚开始做Zynq开发时,第一个正经写的驱动就是字符设备驱动——一个简单的LED控制。嗯,从那以后,我对这套机制算是彻底摸透了。

4.1 Linux设备驱动模型:别被它吓到

很多初学者一听到“设备驱动模型”就觉得高大上,其实没那么玄乎。Linux内核从2.6开始引入了一套统一的设备驱动模型,核心就三个东西:总线(Bus)、设备(Device)、驱动(Driver)。

我个人习惯把这套模型理解成“相亲平台”:

  • 总线就是婚介所,负责牵线搭桥
  • 设备是征婚方,把自己的硬件资源(寄存器地址、中断号等)挂在总线上
  • 驱动是应征方,声明自己能支持哪些设备(通过id_table匹配)

当设备和驱动在总线上“看对眼”了,内核就会调用驱动的probe函数,驱动正式接管设备。这套机制的好处是——驱动代码和设备信息解耦了。你在Zynq上写驱动,设备信息通常放在设备树里,驱动只关心怎么操作硬件,两者通过platform总线匹配。

核心要点:在Zynq平台上,字符设备驱动通常挂载在platform总线上。设备树描述硬件资源,驱动通过platform_driver结构体注册,probe函数里完成字符设备的初始化。

4.2 字符设备注册与注销:从申请到释放

字符设备驱动的生命周期,说白了就是“申请设备号 → 注册设备 → 提供操作接口 → 注销设备 → 释放设备号”。我在项目中见过不少新手直接跳过设备号申请这一步,结果驱动加载时跟其他模块冲突,系统直接panic。

4.2.1 设备号管理

Linux用dev_t类型表示设备号,高12位是主设备号,低20位是次设备号。主设备号标识驱动类型,次设备号标识同一个驱动管理的不同设备。

函数 作用 适用场景
register_chrdev_region() 静态申请指定范围的设备号 你知道要用的主设备号(比如从/proc/devices查到的)
alloc_chrdev_region() 动态分配设备号 不关心主设备号,让内核帮你分配(推荐)
unregister_chrdev_region() 释放设备号 驱动卸载时必须调用

我的建议:在Zynq开发中,除非你有特殊需求(比如要跟旧驱动保持兼容),否则一律用alloc_chrdev_region()动态分配。我曾经在一个项目里硬编码了主设备号,结果跟另一个模块冲突,排查了半天才发现是设备号撞车了。

4.2.2 cdev结构体与注册

设备号申请好了,接下来就是初始化cdev结构体,然后把它添加到内核中。这一步很多人容易漏掉cdev_init()和cdev_add()的调用顺序——必须先init再add,否则内核访问到未初始化的函数指针,直接oops给你看。

static int my_driver_probe(struct platform_device *pdev)
{
    int ret;
    dev_t devno;
    struct cdev *cdev;
    
    // 1. 动态分配设备号
    ret = alloc_chrdev_region(&devno, 0, 1, "my_char_dev");
    if (ret < 0) {
        pr_err("Failed to alloc chrdev region\n");
        return ret;
    }
    
    // 2. 初始化cdev
    cdev = cdev_alloc();
    cdev_init(cdev, &my_fops);
    cdev->owner = THIS_MODULE;
    
    // 3. 添加cdev到内核
    ret = cdev_add(cdev, devno, 1);
    if (ret) {
        pr_err("Failed to add cdev\n");
        unregister_chrdev_region(devno, 1);
        return ret;
    }
    
    platform_set_drvdata(pdev, cdev);
    return 0;
}

注意:cdev_add()一旦成功,内核立刻就能通过设备号访问到你的驱动。所以在这之前,必须确保所有硬件初始化都已完成,否则用户空间程序打开设备时可能触发空指针访问。我曾经在probe函数里先调了cdev_add,再去初始化硬件,结果用户程序抢先打开了设备,直接崩溃。

4.2.3 注销流程

驱动卸载时,顺序跟注册相反:先删除cdev,再释放设备号。这个顺序搞反了,设备号虽然释放了,但cdev还在内核里挂着,万一有进程正持有文件描述符,就会访问到野指针。

static int my_driver_remove(struct platform_device *pdev)
{
    struct cdev *cdev = platform_get_drvdata(pdev);
    dev_t devno = cdev->dev;
    
    // 1. 删除cdev
    cdev_del(cdev);
    
    // 2. 释放设备号
    unregister_chrdev_region(devno, 1);
    
    return 0;
}

4.3 file_operations结构体:驱动与用户空间的桥梁

这个结构体,说白了就是驱动暴露给用户空间的操作接口表。用户程序调用open()、read()、write()、ioctl()时,内核通过这个结构体找到对应的驱动函数。

4.3.1 核心成员

成员 函数原型 说明
open int (*open)(struct inode *, struct file *) 设备打开时调用,通常做硬件初始化或权限检查
release int (*release)(struct inode *, struct file *) 设备关闭时调用,释放资源
read ssize_t (*read)(struct file *, char __user *, size_t, loff_t *) 从设备读取数据,注意要用copy_to_user()
write ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *) 向设备写入数据,注意要用copy_from_user()
unlocked_ioctl long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long) 设备控制命令,Zynq上常用它来配置硬件寄存器
llseek loff_t (*llseek)(struct file *, loff_t, int) 文件定位操作,如果设备不支持,直接返回-ESPIPE

关键点:用户空间的指针(__user标记的)绝对不能在内核态直接解引用。必须用copy_to_user()和copy_from_user()来安全传递数据。我见过有人图省事直接memcpy,结果在内核态访问了用户空间地址,系统直接挂掉。

4.3.2 一个完整的file_operations实现示例

static int my_dev_open(struct inode *inode, struct file *filp)
{
    pr_info("Device opened\n");
    return 0;
}

static int my_dev_release(struct inode *inode, struct file *filp)
{
    pr_info("Device closed\n");
    return 0;
}

static ssize_t my_dev_read(struct file *filp, char __user *buf,
                           size_t count, loff_t *f_pos)
{
    char kernel_buf[64];
    size_t len;
    
    // 从硬件读取数据到内核缓冲区
    len = min(count, sizeof(kernel_buf));
    // 假设从硬件寄存器读取
    // ...
    
    if (copy_to_user(buf, kernel_buf, len)) {
        return -EFAULT;
    }
    
    return len;
}

static ssize_t my_dev_write(struct file *filp, const char __user *buf,
                            size_t count, loff_t *f_pos)
{
    char kernel_buf[64];
    size_t len;
    
    len = min(count, sizeof(kernel_buf));
    if (copy_from_user(kernel_buf, buf, len)) {
        return -EFAULT;
    }
    
    // 将数据写入硬件
    // ...
    
    return len;
}

static long my_dev_ioctl(struct file *filp, unsigned int cmd,
                         unsigned long arg)
{
    switch (cmd) {
    case MY_IOCTL_SET_CONFIG:
        // 配置硬件寄存器
        break;
    case MY_IOCTL_GET_STATUS:
        // 读取硬件状态
        break;
    default:
        return -ENOTTY;
    }
    return 0;
}

static struct file_operations my_fops = {
    .owner          = THIS_MODULE,
    .open           = my_dev_open,
    .release        = my_dev_release,
    .read           = my_dev_read,
    .write          = my_dev_write,
    .unlocked_ioctl = my_dev_ioctl,
};

4.4 知识体系总览

下面这张图把字符设备驱动的核心流程串起来了,从设备树描述到用户空间操作,每一步都离不开前面讲的内容。

字符设备驱动开发核心流程 设备树描述 platform总线匹配 probe函数调用 alloc_chrdev_region() cdev_init() + cdev_add() file_operations实现 用户空间:open / read / write / ioctl / close 内核空间:copy_to_user / copy_from_user / 硬件寄存器操作 卸载:cdev_del() → unregister_chrdev_region()

经验之谈:在Zynq上调试字符设备驱动时,我习惯先在/proc/devices里确认设备号是否注册成功,再用mknod手动创建设备节点。等驱动稳定了,再让udev自动管理设备节点。这样排查问题更清晰,不会把驱动问题和设备节点问题混在一起。

4.5 避坑指南

  • 设备号冲突:我曾经在一个项目里同时加载了两个驱动,都用了同一个主设备号,结果第二个驱动加载时直接报错。从那以后,我统一用alloc_chrdev_region()动态分配,再也没出过问题。
  • copy_to_user/copy_from_user返回值检查:这两个函数返回未拷贝成功的字节数,不是错误码。很多人直接判断返回值是否小于0,结果数据拷贝失败却没发现。正确的做法是判断返回值是否等于0,不为0说明拷贝不完整。
  • ioctl命令号冲突:Zynq上多个驱动可能共用同一个ioctl命令号,导致误操作。建议用_IO()、_IOR()、_IOW()宏生成唯一的命令号,避免冲突。
  • 并发访问:字符设备驱动默认不支持并发。如果多个进程同时打开设备,read/write操作会互相干扰。我建议在驱动里加一个原子操作或者互斥锁,确保同一时间只有一个进程在操作硬件。