字符设备驱动:设备号、file_operations结构体、cdev注册与注销

好,咱们今天聊聊字符设备驱动。这是Linux驱动开发里最基础、也最核心的一块。说白了,你写的驱动最终要跟用户态的程序打交道,而字符设备就是最常见的接口。

我记得刚接触Linux驱动时,第一件事就是搞懂设备号。没有设备号,内核根本不知道你在操作哪个设备。嗯,这里咱们一步步来。

设备号:设备的身份证

每个字符设备在内核里都有一个唯一的标识,就是设备号。它分两部分:主设备号次设备号

  • 主设备号:标识设备对应的驱动程序。比如硬盘驱动用一个号,串口驱动用另一个号。
  • 次设备号:标识同一个驱动管理的不同设备实例。比如两个串口,主设备号相同,次设备号不同。

在内核里,设备号用 dev_t 类型表示。这是一个32位的数,高12位是主设备号,低20位是次设备号。

关键点:主设备号范围是0~4095,次设备号范围是0~1048575。别搞混了。

我早期做项目时,有一次随手写了个主设备号,结果跟系统里已有的驱动冲突了。系统直接报错,设备节点创建失败。从那以后,我养成了一个习惯:要么用动态分配,要么查清楚哪些号是空闲的。

设备号的分配方式

分配设备号有两种方式:静态分配和动态分配。

静态分配

你自己指定一个主设备号。用 register_chrdev_region() 函数。

#include <linux/fs.h>

dev_t dev_num;
int major = 240;  // 自己选一个号
int minor = 0;
int count = 1;

dev_num = MKDEV(major, minor);
ret = register_chrdev_region(dev_num, count, "my_device");
if (ret < 0) {
    printk("设备号注册失败\n");
    return ret;
}

静态分配的好处是设备号固定,用户态程序不用改。但风险也大——万一这个号被别的驱动用了呢?

注意:静态分配前,最好查一下 /proc/devices,看看哪些主设备号已经被占用了。我吃过这个亏,你千万别踩坑。

动态分配

让内核帮你分配一个空闲的主设备号。用 alloc_chrdev_region() 函数。

dev_t dev_num;
int minor = 0;
int count = 1;
const char *name = "my_device";

ret = alloc_chrdev_region(&dev_num, minor, count, name);
if (ret < 0) {
    printk("动态分配设备号失败\n");
    return ret;
}
printk("分配的主设备号: %d\n", MAJOR(dev_num));

动态分配是推荐的做法。内核会帮你找一个没被占用的号,省心多了。你想想看,要是每个驱动都硬编码一个号,那系统得多乱?

我个人习惯用动态分配。除非有特殊需求,比如你要跟已有的用户态工具兼容,那才考虑静态分配。

file_operations结构体:驱动与用户态的桥梁

设备号只是让内核能找到你的驱动。但用户态程序怎么操作设备?比如打开、读写、关闭?这就靠 file_operations 结构体了。

说白了,这个结构体就是一堆函数指针。你把驱动里实现的函数填进去,内核就知道该调用哪个了。

struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .release = my_release,
    .read    = my_read,
    .write   = my_write,
    .llseek  = my_llseek,
};

常用的成员有这些:

成员 说明 用户态对应操作
open 打开设备 open()
release 关闭设备 close()
read 从设备读取数据 read()
write 向设备写入数据 write()
llseek 改变文件读写位置 lseek()
unlocked_ioctl 设备控制命令 ioctl()

每个函数的原型都是固定的。比如 open 函数长这样:

static int my_open(struct inode *inode, struct file *filp) {
    printk("设备已打开\n");
    return 0;
}

这里 inode 指向设备节点,filp 指向打开的文件实例。你可以把设备的私有数据存在 filp->private_data 里,方便其他函数使用。

小技巧:我习惯在 open 里用 container_of 宏从 inode 拿到自定义的设备结构体,然后挂到 filp->private_data 上。这样 readwrite 里就能直接用了,不用到处找。

cdev结构体:内核里的设备对象

设备号有了,操作函数也有了。怎么把它们告诉内核?答案是 cdev 结构体。

cdev 是内核里表示字符设备的对象。你需要初始化它,然后注册到内核里。

初始化cdev

struct cdev my_cdev;

cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;

cdev_init()file_operations 关联到 cdev 上。设置 owner 是为了防止模块被卸载时还有人在用设备。

注册cdev

ret = cdev_add(&my_cdev, dev_num, count);
if (ret < 0) {
    printk("cdev注册失败\n");
    // 别忘了释放之前分配的设备号
    unregister_chrdev_region(dev_num, count);
    return ret;
}

cdev_add() 告诉内核:这个设备号归我管了,操作函数就是 my_fops。第三个参数 count 表示管理几个次设备号。

注意顺序:一定要先分配设备号,再注册cdev。我曾经调换过顺序,结果内核直接崩溃。嗯,血的教训。

cdev的注销

驱动卸载时,必须把cdev从内核里移除,释放设备号。顺序跟注册时相反。

cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, count);

cdev_del() 从内核里移除cdev。之后内核就不会再调用你的操作函数了。unregister_chrdev_region() 释放设备号,供其他驱动使用。

我见过有人只注销cdev,忘了释放设备号。结果模块卸载后,设备号还占着,下次加载时就分配失败了。这种小细节,往往最坑人。

完整的驱动框架

把上面这些串起来,就是一个完整的字符设备驱动骨架:

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

static dev_t dev_num;
static struct cdev my_cdev;

static int my_open(struct inode *inode, struct file *filp) {
    printk("my_open\n");
    return 0;
}

static int my_release(struct inode *inode, struct file *filp) {
    printk("my_release\n");
    return 0;
}

static ssize_t my_read(struct file *filp, char __user *buf,
                       size_t len, loff_t *off) {
    printk("my_read\n");
    return 0;
}

static ssize_t my_write(struct file *filp, const char __user *buf,
                        size_t len, loff_t *off) {
    printk("my_write\n");
    return len;
}

static struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .release = my_release,
    .read    = my_read,
    .write   = my_write,
};

static int __init my_init(void) {
    int ret;

    // 1. 分配设备号
    ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
    if (ret < 0) {
        printk("alloc_chrdev_region failed\n");
        return ret;
    }
    printk("主设备号: %d\n", MAJOR(dev_num));

    // 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) {
        printk("cdev_add failed\n");
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }

    printk("驱动加载成功\n");
    return 0;
}

static void __exit my_exit(void) {
    // 1. 注销cdev
    cdev_del(&my_cdev);

    // 2. 释放设备号
    unregister_chrdev_region(dev_num, 1);

    printk("驱动卸载成功\n");
}

module_init(my_init);
module_exit(my_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple char device driver");

这个框架虽然简单,但五脏俱全。你可以在 my_readmy_write 里加上真正的硬件操作逻辑。

建议:刚开始写驱动时,先用这个框架跑通。确认设备号分配、cdev注册、用户态访问都没问题,再往里填业务逻辑。这样调试起来轻松很多。

好了,字符设备驱动的核心就这些。设备号是身份证,file_operations是操作手册,cdev是内核里的登记表。三者缺一不可。下一章咱们聊聊如何创建设备节点,让用户态真正能用上你的驱动。