字符设备驱动基础:设备号、file_operations 与 cdev 注册

字符设备驱动,说白了就是 Linux 驱动里最基础、最常用的一类。你想想看,键盘、鼠标、串口、LED 灯——这些硬件在 Linux 下基本都是字符设备。今天我就带你把这套机制彻底搞明白。

设备号:主设备号与次设备号

Linux 内核怎么区分不同的设备?靠的就是设备号。一个设备号由两部分组成:主设备号和次设备号。

概念 作用 典型值
主设备号 标识设备对应的驱动程序 1 (内存), 4 (tty), 89 (i2c)
次设备号 标识同一个驱动下的不同设备实例 0, 1, 2 ...

内核用 dev_t 类型来保存设备号,这是一个 32 位的无符号整数。高 12 位是主设备号,低 20 位是次设备号。我个人习惯用 MAJOR(dev) 和 MINOR(dev) 这两个宏来提取,比手动移位靠谱多了。

关键点:主设备号是驱动和内核之间的"契约"。同一个主设备号下的所有次设备号,都由同一个驱动处理。

设备号分配:静态 vs 动态

分配设备号有两种方式,我刚开始做驱动时踩过静态分配的坑,后来就只用动态分配了。

  • 静态分配:调用 register_chrdev_region(),需要你自己指定主设备号。问题是——你怎么知道这个号没被占用?查 /proc/devices?那只是当前状态,不代表以后不被占。
  • 动态分配:调用 alloc_chrdev_region(),内核自动给你分配一个可用的主设备号。省心,而且不会冲突。
// 动态分配设备号
dev_t dev_num;
int ret = alloc_chrdev_region(&dev_num, 0, 1, "my_driver");
if (ret < 0) {
    printk(KERN_ERR "设备号分配失败\n");
    return ret;
}
printk("主设备号: %d, 次设备号: %d\n", 
       MAJOR(dev_num), MINOR(dev_num));

我的建议:产品代码里永远用动态分配。静态分配只适合教学实验,或者你明确知道某个主设备号被预留给你了。

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

用户程序调用 open()、read()、write() 时,内核怎么知道该执行你驱动里的哪个函数?答案就是 file_operations 结构体。

这个结构体里全是函数指针。你实现了哪些操作,就填充哪些字段。没实现的填 NULL,内核会帮你处理默认行为。

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) {
    printk("设备读取请求, 长度: %zu\n", len);
    return 0;
}

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

这里有个细节我当年栽过跟头:.owner 一定要赋值为 THIS_MODULE。否则模块被卸载时,如果还有进程持有文件描述符,内核直接崩溃给你看。

常用操作 说明 注意点
open 设备打开时调用 可以做初始化、计数
release 设备关闭时调用 记得释放资源
read 从设备读取数据 数据拷贝用 copy_to_user
write 向设备写入数据 数据拷贝用 copy_from_user
unlocked_ioctl 设备控制命令 替代旧的 ioctl

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

file_operations 只是定义了操作函数,内核还需要一个对象来把设备号和这些操作绑定起来。这个对象就是 cdev。

注册 cdev 的流程很简单,就三步:

  1. 分配 cdev:静态定义或者用 cdev_alloc() 动态分配
  2. 初始化 cdev:调用 cdev_init(),绑定 file_operations
  3. 添加 cdev:调用 cdev_add(),告诉内核这个设备号归你管
static struct cdev my_cdev;

static int __init my_init(void) {
    dev_t dev;
    int ret;

    // 1. 分配设备号
    ret = alloc_chrdev_region(&dev, 0, 1, "my_driver");
    if (ret) return ret;

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

    // 3. 注册到内核
    ret = cdev_add(&my_cdev, dev, 1);
    if (ret) {
        unregister_chrdev_region(dev, 1);
        return ret;
    }

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

我曾经踩过的坑:cdev_add() 之后,内核随时可能调用你的操作函数。所以一定要在 cdev_add() 之前完成所有初始化工作,否则并发访问会出问题。

注销 cdev 与释放设备号

有注册就有注销。模块卸载时,顺序和注册相反:

static void __exit my_exit(void) {
    dev_t dev = MKDEV(major, 0);

    // 1. 从内核移除 cdev
    cdev_del(&my_cdev);

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

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

这里有个顺序问题:必须先 cdev_del() 再 unregister_chrdev_region()。如果反过来,设备号已经释放了,但 cdev 还在,内核访问时就找不到对应的驱动了。

手动创建设备节点

驱动加载成功后,用户空间怎么访问它?需要通过设备节点。设备节点就是 /dev 目录下的一个文件,用 mknod 命令创建:

# 查看驱动打印的主设备号
dmesg | grep my_driver

# 创建设备节点(假设主设备号为 240)
sudo mknod /dev/my_device c 240 0
sudo chmod 666 /dev/my_device

参数说明:c 表示字符设备,240 是主设备号,0 是次设备号。

小技巧:实际产品中不会手动 mknod,而是用 udev 自动创建。但调试阶段手动创建更直接,能帮你确认驱动本身没问题。

完整知识结构图

下面这张图把整个字符设备驱动的注册流程串起来了,我建议你多看几遍:

字符设备驱动注册流程 字符设备驱动注册与注销完整流程 alloc_chrdev_region() cdev_init(&cdev, &fops) cdev_add(&cdev, dev, count) mknod /dev/my_device c major 0 注销流程(顺序相反) cdev_del(&cdev) unregister_chrdev_region(dev, count) 关键提醒 cdev_add 之后 内核随时会调用 你的操作函数 注销顺序 先删 cdev 再释放设备号

完整示例代码

把上面所有知识点整合到一起,就是一个完整的字符设备驱动骨架:

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

#define DEVICE_NAME "my_driver"

static dev_t dev_num;
static struct cdev my_cdev;

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

static int my_release(struct inode *inode, struct file *filp) {
    printk("%s: 设备关闭\n", DEVICE_NAME);
    return 0;
}

static ssize_t my_read(struct file *filp, char __user *buf,
                       size_t len, loff_t *off) {
    char data[] = "Hello from kernel!";
    size_t datalen = strlen(data);

    if (*off >= datalen) return 0;
    if (len > datalen - *off) len = datalen - *off;

    if (copy_to_user(buf, data + *off, len)) return -EFAULT;

    *off += len;
    return len;
}

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

static int __init my_init(void) {
    int ret;

    ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
    if (ret) {
        printk(KERN_ERR "设备号分配失败\n");
        return ret;
    }

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

    ret = cdev_add(&my_cdev, dev_num, 1);
    if (ret) {
        unregister_chrdev_region(dev_num, 1);
        printk(KERN_ERR "cdev 注册失败\n");
        return ret;
    }

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

static void __exit my_exit(void) {
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
    printk("%s: 卸载完成\n", DEVICE_NAME);
}

module_init(my_init);
module_exit(my_exit);

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

测试步骤:

  1. 编译:make
  2. 加载:sudo insmod my_driver.ko
  3. 查看设备号:dmesg | tail
  4. 创建设备节点:sudo mknod /dev/my_driver c 主设备号 0
  5. 测试读取:cat /dev/my_driver
  6. 卸载:sudo rmmod my_driver

嗯,到这里字符设备驱动的基础框架就搭好了。你把这个骨架跑通,后面加硬件操作、加 ioctl、加中断,都是在这个基础上做加法。记住核心思路:设备号是身份证,file_operations 是操作手册,cdev 是内核里的登记表——三样东西凑齐,你的设备就能被用户空间认出来了。