4、Linux字符设备驱动框架回顾:file_operations与核心数据结构

做嵌入式驱动开发,字符设备驱动是最基础、也是最常打交道的一类。我刚开始接触Linux驱动时,面对那堆结构体和回调函数,说实话有点懵。但后来发现,只要抓住几个核心数据结构,整个框架就清晰了。

今天咱们就一起回顾一下字符设备驱动的骨架。嗯,这部分内容虽然基础,但很多坑都藏在这些细节里。

4.1 字符设备驱动的核心三要素

一个完整的字符设备驱动,说白了就三样东西:

  • 设备号:系统用来识别设备的ID,分主设备号和次设备号
  • file_operations:操作函数集,告诉内核“这个设备能干啥”
  • 设备私有数据:驱动自己维护的状态信息

这三者缺一不可。我在项目中遇到过新手把设备号搞错,结果驱动加载成功但应用层死活打不开设备——其实就是主设备号冲突了。

核心要点:字符设备驱动的本质就是将设备操作映射到文件操作。应用层读写文件,内核层调用你的驱动函数。

4.2 设备号管理:从注册到注销

设备号是驱动的“身份证”。Linux提供了两种分配方式:

分配方式 函数 适用场景
静态分配 register_chrdev_region() 已知设备号,避免冲突时使用
动态分配 alloc_chrdev_region() 推荐方式,由内核自动分配

我个人习惯用动态分配。为什么?省心。你想想看,静态分配你得去查Documentation/devices.txt,还得祈祷别的驱动没占你的号。动态分配就简单了,内核给你一个可用的号,省去一堆麻烦。

// 动态分配设备号示例
dev_t dev_num;
int major, minor;

// 申请一个主设备号,次设备号从0开始,数量为1
alloc_chrdev_region(&dev_num, 0, 1, "my_driver");
major = MAJOR(dev_num);
minor = MINOR(dev_num);

// 使用完毕后记得释放
unregister_chrdev_region(dev_num, 1);

注意:设备号是系统资源,申请了就必须释放。我曾经见过驱动卸载时忘了调用unregister_chrdev_region,结果设备号一直被占用,其他驱动加载失败。这种问题排查起来很头疼。

4.3 file_operations:驱动的操作接口

这是字符设备驱动的灵魂。每个成员都是一个函数指针,对应应用层的系统调用。

常用的几个操作:

  • open:打开设备,做初始化工作
  • release:关闭设备,清理资源
  • read:从设备读取数据
  • write:向设备写入数据
  • unlocked_ioctl:设备控制命令(注意,现在都用unlocked版本了)
// 典型的file_operations定义
static struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .release = my_release,
    .read    = my_read,
    .write   = my_write,
    .unlocked_ioctl = my_ioctl,
};

这里有个细节:.owner一定要赋值为THIS_MODULE。为什么?它用来管理模块的引用计数。如果不设置,驱动被卸载时如果有进程还在使用设备,系统会崩溃。嗯,这个坑我踩过。

4.4 核心数据结构关系图

光说理论不够直观,我画了一张图,把字符设备驱动的核心数据结构串起来:

字符设备驱动核心数据结构关系图 应用层 (用户空间) VFS (虚拟文件系统) struct file f_op → file_operations file_operations open/read/write/ioctl struct inode i_cdev → cdev struct cdev ops → file_operations 驱动层 (内核空间) 应用层 → VFS → struct file → file_operations → 驱动函数 → 硬件

这张图展示了数据流的完整路径。应用层调用open(),VFS找到对应的struct file,通过f_op找到file_operations,最终调用你的驱动函数。反过来,struct inode里保存了cdev的指针,用于设备号的匹配。

4.5 cdev结构体与设备注册

struct cdev是内核用来管理字符设备的核心对象。注册流程如下:

  1. 分配cdev对象:cdev_alloc()或静态定义
  2. 初始化cdev:cdev_init(),绑定file_operations
  3. 添加cdev到系统:cdev_add(),关联设备号
// 完整的字符设备注册流程
static struct cdev my_cdev;

static int __init my_driver_init(void)
{
    dev_t dev_num;
    int ret;

    // 1. 分配设备号
    ret = alloc_chrdev_region(&dev_num, 0, 1, "my_driver");
    if (ret < 0) {
        pr_err("Failed to alloc chrdev region\n");
        return ret;
    }

    // 2. 初始化cdev
    cdev_init(&my_cdev, &my_fops);
    my_cdev.owner = THIS_MODULE;

    // 3. 注册cdev
    ret = cdev_add(&my_cdev, dev_num, 1);
    if (ret < 0) {
        pr_err("Failed to add cdev\n");
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }

    pr_info("My driver loaded, major=%d\n", MAJOR(dev_num));
    return 0;
}

static void __exit my_driver_exit(void)
{
    dev_t dev_num = my_cdev.dev;

    // 先删除cdev,再释放设备号
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
    pr_info("My driver unloaded\n");
}

经验之谈:注册和注销的顺序一定要对称。先cdev_add再device_create,卸载时先device_destroy再cdev_del。顺序错了,系统可能在设备文件还存在时访问已经释放的cdev,导致Oops。

4.6 私有数据与容器宏

驱动里经常需要维护自己的数据结构,比如硬件寄存器基址、中断号、缓冲区等。怎么把这些数据和cdev关联起来?

常用的做法是定义一个包含cdev的结构体:

// 自定义设备结构体
struct my_device {
    struct cdev cdev;        // 必须放在第一个成员?不一定
    void __iomem *base;      // 寄存器基址
    int irq;                 // 中断号
    struct mutex lock;       // 互斥锁
    wait_queue_head_t wq;    // 等待队列
};

// 在open函数中获取私有数据
static int my_open(struct inode *inode, struct file *filp)
{
    struct my_device *mydev;

    // 从inode中获取cdev,再通过container_of获取my_device
    mydev = container_of(inode->i_cdev, struct my_device, cdev);
    filp->private_data = mydev;  // 保存到file的私有数据区

    return 0;
}

// 在read/write中直接使用
static ssize_t my_read(struct file *filp, char __user *buf,
                       size_t count, loff_t *f_pos)
{
    struct my_device *mydev = filp->private_data;
    // 使用mydev->base, mydev->lock等
    ...
}

container_of这个宏很关键。它通过结构体成员的地址,反推出结构体的首地址。我在项目中经常用它来获取驱动私有数据,比全局变量优雅多了。

4.7 常见避坑指南

回顾这些年调试字符设备驱动的经历,有几个坑特别容易踩:

  • 设备号冲突:静态分配时没检查,导致驱动加载失败。建议用动态分配。
  • cdev_add后忘了检查返回值:注册失败还继续使用,系统直接挂掉。
  • open/release不配对:引用计数混乱,模块卸载时还在使用中。
  • ioctl命令号重复:不同驱动用了相同的命令号,行为异常。
  • 没有处理并发:多个进程同时访问,数据错乱。记得加锁。

我曾经踩过的坑:有一次写SPI字符设备驱动,cdev_add成功了,但device_create时传错了设备号,结果/dev下生成的设备文件主设备号是0。应用层打开设备时返回ENODEV,排查了半天才发现是设备号不匹配。所以,注册完成后一定要检查/proc/devices和/dev下的设备号是否一致。

4.8 小结

字符设备驱动的框架其实不复杂。记住三个核心:设备号、file_operations、cdev。再配合container_of管理私有数据,一个完整的驱动骨架就搭起来了。

嗯,这部分内容虽然基础,但它是后续所有驱动开发的基础。接口隔离、中间件集成,都是在这些数据结构上做文章。把基础打牢,后面才能走得更远。