字符设备驱动:设备号注册与注销、file_operations结构体、cdev结构体、设备节点的自动创建

字符设备驱动,说白了就是Linux驱动里最基础、最常用的一种。你想想看,键盘、鼠标、串口,这些设备本质上都是字符设备——数据按字节流一个个传过来。今天我就把这块的核心知识点给你捋清楚。

设备号:驱动的身份证

每个字符设备在内核里都有一个唯一的编号,叫设备号。它分两部分:主设备号和次设备号。主设备号标识驱动类型,次设备号标识具体设备实例。

我个人习惯用MKDEV(major, minor)这个宏来组合设备号。注册时,内核提供两种方式:

  • 静态分配:你指定主设备号,用register_chrdev_region()注册。适合你知道设备号的情况。
  • 动态分配:让内核帮你分配,用alloc_chrdev_region()。我强烈推荐这种方式,避免设备号冲突。

核心API:

// 动态分配设备号
int alloc_chrdev_region(dev_t *dev, unsigned int firstminor, 
                        unsigned int count, char *name);

// 静态分配设备号
int register_chrdev_region(dev_t from, unsigned int count, 
                           const char *name);

// 注销设备号
void unregister_chrdev_region(dev_t from, unsigned int count);

注意:注销设备号一定要在驱动卸载时做。我曾经见过有人忘了这步,结果模块卸载后设备号还被占用,下次加载直接失败。嗯,这种bug排查起来挺头疼的。

file_operations:驱动与应用的桥梁

这个结构体定义了应用层能对设备做哪些操作。说白了,它就是一张函数指针表。应用调用open()、read()、write()时,内核会通过这张表找到你写的驱动函数。

我一般这样初始化:

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

这里有个坑——.owner一定要赋值为THIS_MODULE。为什么?它用来防止模块在使用中被卸载。你想想看,如果应用正读着数据,驱动模块突然被卸载了,内核直接崩溃。我早期做项目时就踩过这个坑,从那以后每次写驱动都会检查这一行。

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

cdev是内核里表示字符设备的核心结构体。你需要做三件事:

  1. 分配:用cdev_alloc()或静态定义
  2. 初始化:用cdev_init()绑定file_operations
  3. 添加:用cdev_add()将设备注册到内核
struct cdev my_cdev;
dev_t dev_num;

// 第一步:分配设备号
alloc_chrdev_region(&dev_num, 0, 1, "my_device");

// 第二步:初始化cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;

// 第三步:添加到内核
cdev_add(&my_cdev, dev_num, 1);

经验之谈:cdev_add()之后,设备就对外可见了。所以一定要确保所有初始化都完成后再调用它。我习惯把它放在驱动入口函数的最后一步。

设备节点的自动创建:udev与mdev

早期Linux驱动需要手动mknod创建设备节点,太麻烦了。现在有了udev(桌面系统)和mdev(嵌入式系统),可以自动创建。

实现自动创建需要两步:

  • 创建设备类:用class_create()
  • 创建设备:用device_create()
static struct class *my_class;

// 在驱动入口函数中
my_class = class_create(THIS_MODULE, "my_class");
device_create(my_class, NULL, dev_num, NULL, "my_device");

// 在驱动出口函数中
device_destroy(my_class, dev_num);
class_destroy(my_class);

这样,驱动加载后,/dev/my_device就会自动出现。udev/mdev会监听内核发出的uevent事件,然后根据规则创建设备节点。

完整示例代码:

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

static int major = 0;
static struct cdev my_cdev;
static struct class *my_class;

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

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

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

static int __init my_init(void) {
    dev_t dev_num;
    
    // 分配设备号
    alloc_chrdev_region(&dev_num, 0, 1, "my_device");
    major = MAJOR(dev_num);
    
    // 初始化cdev
    cdev_init(&my_cdev, &my_fops);
    cdev_add(&my_cdev, dev_num, 1);
    
    // 自动创建设备节点
    my_class = class_create(THIS_MODULE, "my_class");
    device_create(my_class, NULL, dev_num, NULL, "my_device");
    
    printk(KERN_INFO "Driver loaded, major=%d\n", major);
    return 0;
}

static void __exit my_exit(void) {
    dev_t dev_num = MKDEV(major, 0);
    
    // 销毁设备节点
    device_destroy(my_class, dev_num);
    class_destroy(my_class);
    
    // 注销cdev和设备号
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
    
    printk(KERN_INFO "Driver unloaded\n");
}

module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");

核心流程总结

我把整个流程画了张图,方便你理解:

字符设备驱动注册与注销流程 模块加载 模块卸载 alloc_chrdev_region() cdev_init() + cdev_add() class_create() device_create() device_destroy() class_destroy() cdev_del() unregister_chrdev_region() 加载顺序:设备号 → cdev → 类 → 设备节点 卸载顺序:设备节点 → 类 → cdev → 设备号(完全相反)

这张图把加载和卸载的流程对应关系画得很清楚。你注意看,卸载的顺序和加载完全相反——这是内核编程的一个通用原则:谁先申请,谁后释放。

避坑指南:我曾经在项目里遇到一个问题——驱动卸载时先调了cdev_del(),然后才调device_destroy()。结果设备节点还在,但内核已经找不到对应的cdev了。应用层访问这个节点直接导致Oops。从那以后,我每次写驱动都会严格按「反向顺序」来清理资源。

关于udev和mdev的区别

简单说两句:

  • udev:运行在用户空间,基于netlink通信。功能强大,支持复杂规则。桌面Linux都用它。
  • mdev:BusyBox自带的精简版,基于uevent文件系统。嵌入式系统常用,资源占用小。

不管用哪个,驱动层的接口是一样的——都是class_create + device_create。内核发出uevent,用户空间的守护进程收到后去创建设备节点。驱动开发者只需要做好内核侧的工作就行。

嗯,字符设备驱动的核心内容就这些。设备号、file_operations、cdev、自动创建节点,这四个点串起来就是一套完整的驱动框架。写驱动时按这个模板来,基本不会出大问题。