字符设备驱动核心:设备号、file_operations、cdev 与设备节点

字符设备驱动,说白了就是 Linux 驱动里最基础、最常用的一类。你写个 LED 灯、按键、传感器,基本都落在这个范畴里。今天咱们就把它的四个核心要素掰开揉碎讲清楚:设备号、file_operations 结构体、cdev 结构体,还有设备节点的创建。

我个人习惯把这四个东西看作一个「驱动四件套」——少了哪个,驱动都跑不起来。咱们一个一个过。

设备号:驱动的身份证

设备号分两部分:主设备号和次设备号。主设备号标识驱动类型,次设备号标识具体设备实例。内核里用 dev_t 类型来存,32 位,高 12 位是主设备号,低 20 位是次设备号。

关键点:主设备号是驱动和内核的「握手信号」。用户空间 open 设备文件时,内核根据主设备号找到对应的驱动。

设备号注册与注销

注册设备号有两种方式:

  • 静态申请:用 register_chrdev_region(),适合你知道设备号的情况。我早期做项目时喜欢这么干,因为调试方便,设备号固定。
  • 动态分配:用 alloc_chrdev_region(),让内核给你分配。产品级代码我建议用这个,避免设备号冲突。

注销就简单了,一个函数搞定:

// 动态分配设备号
dev_t dev_num;
alloc_chrdev_region(&dev_num, 0, 1, "my_driver");
printk("主设备号: %d\n", MAJOR(dev_num));

// 注销设备号
unregister_chrdev_region(dev_num, 1);

我曾经踩过的坑:有一次忘了注销设备号,驱动卸载后重新 insmod,结果设备号被占用,注册失败。后来我养成了习惯——在 exit 函数里第一行就写 unregister_chrdev_region。

file_operations:驱动与用户空间的桥梁

这个结构体定义了驱动能提供的操作。用户空间调用 open、read、write、ioctl 时,内核会通过这个结构体找到你写的函数。

常用的成员有这些:

成员 说明 我常用的场景
.open 打开设备时调用 初始化硬件、递增引用计数
.release 关闭设备时调用 释放资源、递减引用计数
.read 从设备读取数据 读取传感器值、寄存器状态
.write 向设备写入数据 设置参数、控制输出
.unlocked_ioctl 设备控制命令 配置模式、执行特殊操作

定义起来很简单:

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

static ssize_t my_read(struct file *filp, char __user *buf,
                       size_t len, loff_t *off) {
    // 从内核空间拷贝数据到用户空间
    if (copy_to_user(buf, kernel_buf, len))
        return -EFAULT;
    return len;
}

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

小技巧:.owner 一定要赋值为 THIS_MODULE,否则模块卸载时可能出问题。我见过有人漏掉这个,结果 rmmod 时系统直接 panic。

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

cdev 是内核用来管理字符设备的核心结构体。你把 file_operations 和设备号绑在一起,注册到内核里,驱动才算真正「活」了。

操作步骤就三步:

  1. 分配 cdev:cdev_alloc() 或静态定义
  2. 初始化:cdev_init(),绑定 file_operations
  3. 添加:cdev_add(),告诉内核这个设备存在
struct cdev my_cdev;
dev_t dev_num;

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

// 2. 注册设备号
alloc_chrdev_region(&dev_num, 0, 1, "my_device");

// 3. 添加 cdev 到内核
cdev_add(&my_cdev, dev_num, 1);

卸载时反过来:先 cdev_del(),再 unregister_chrdev_region()。顺序不能乱,你想想看——如果先注销设备号,cdev 还在内核里挂着,系统就找不到对应的设备了。

设备节点:用户空间看到的「文件」

设备节点就是 /dev/ 目录下的那个文件。用户程序通过它来操作硬件。创建方式有两种:

手动创建

用 mknod 命令:

mknod /dev/my_device c 240 0

c 表示字符设备,240 是主设备号,0 是次设备号。这种方式适合开发调试阶段,产品上就别用了——用户还得自己查设备号,体验太差。

自动创建(推荐)

用 udev 或 mdev 自动管理。驱动里需要做两件事:

  • 创建 class 和 device
  • 内核会通过 uevent 通知用户空间的 udev 自动创建设备节点
static struct class *my_class;

static int __init my_init(void) {
    // ... 注册设备号、添加 cdev ...

    // 创建类
    my_class = class_create(THIS_MODULE, "my_class");
    // 创建设备节点
    device_create(my_class, NULL, dev_num, NULL, "my_device");
    return 0;
}

static void __exit my_exit(void) {
    device_destroy(my_class, dev_num);
    class_destroy(my_class);
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
}

核心逻辑:驱动只负责向内核注册设备信息,设备节点的创建和删除由 udev 自动完成。你只需要提供 class 和 device 信息,剩下的交给系统。

知识结构总览

下面这张图把整个流程串起来了,我建议你多看几遍:

字符设备驱动核心四件套 设备号 dev_t file_operations cdev 结构体 设备节点 /dev/xxx 绑定 注册 cdev_add + udev 自动创建 用户程序 open/read/write/ioctl 核心流程: 1. 分配设备号(静态或动态) 2. 定义 file_operations,实现 open/read/write/release 3. 初始化 cdev,绑定 file_operations 和设备号,注册到内核 4. 通过 class/device 机制让 udev 自动创建设备节点

完整驱动框架示例

把上面所有东西串起来,就是一个完整的字符设备驱动骨架:

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

static dev_t dev_num;
static struct cdev my_cdev;
static struct class *my_class;

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

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

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

static int __init my_init(void) {
    // 1. 动态分配设备号
    alloc_chrdev_region(&dev_num, 0, 1, "my_device");

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

    // 3. 添加 cdev
    cdev_add(&my_cdev, dev_num, 1);

    // 4. 自动创建设备节点
    my_class = class_create(THIS_MODULE, "my_class");
    device_create(my_class, NULL, dev_num, NULL, "my_device");

    printk("my_device: 加载成功,主设备号 %d\n", MAJOR(dev_num));
    return 0;
}

static void __exit my_exit(void) {
    device_destroy(my_class, dev_num);
    class_destroy(my_class);
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
    printk("my_device: 卸载成功\n");
}

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

我的建议:把这个框架存成模板,以后写字符设备驱动直接往上加功能。我自己的代码库里就留着这个 skeleton,每次新建驱动都从它开始改。

嗯,到这里字符设备驱动的四个核心要素就讲完了。设备号是身份证,file_operations 是操作手册,cdev 是内核里的登记表,设备节点是用户看到的入口。四者缺一不可,顺序也不能乱。你写驱动的时候,按这个流程走,基本不会出大问题。