字符设备驱动基础:设备号、file_operations、cdev与设备节点

字符设备驱动,说白了就是 Linux 里最简单、最基础的一类驱动。你想想看,键盘、鼠标、串口、LED 灯——这些设备本质上都是“按字节流读写”的,它们就是字符设备的典型代表。

我刚开始学驱动时,觉得这玩意儿挺玄乎。后来做多了才发现,字符设备驱动的骨架其实就四个东西:设备号、file_operations 结构体、cdev 结构体,还有设备节点。把这四个搞明白,字符设备驱动你就入门了。

设备号:驱动的身份证

Linux 里每个字符设备都有一个唯一的编号,这就是设备号。它分两部分:

  • 主设备号(major number):标识设备对应的驱动程序。比如 1 代表内存设备,4 代表 tty 设备。
  • 次设备号(minor number):标识同一个驱动管理的不同设备实例。比如两个串口,主设备号相同,次设备号不同。

设备号在内核里用 dev_t 类型表示,它是一个 32 位的数。高 12 位是主设备号,低 20 位是次设备号。嗯,这里要注意,主设备号范围是 0~4095,次设备号范围是 0~1048575。

关键点:设备号是用户空间访问内核驱动的“门牌号”。没有它,应用程序根本找不到你的驱动。

设备号的分配方式

分配设备号有两种路子:

方式 函数 适用场景 我的建议
静态分配 register_chrdev_region() 你知道设备号,或者设备号已固定 不推荐,容易冲突
动态分配 alloc_chrdev_region() 你不知道用哪个号,让内核帮你选 推荐,省心省力

我个人习惯用动态分配。为什么?因为静态分配你得去查 /proc/devices 看看哪个号还没被占,麻烦不说,换个内核版本可能就冲突了。动态分配让内核给你挑一个空闲的,多省事。

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

// 申请一个主设备号,次设备号从0开始,共1个设备
ret = alloc_chrdev_region(&dev_num, 0, 1, "my_char_dev");
if (ret < 0) {
    printk("设备号申请失败\n");
    return ret;
}

major = MAJOR(dev_num);
minor = MINOR(dev_num);
printk("分配的设备号: 主=%d, 次=%d\n", major, minor);

小技巧:用 cat /proc/devices 可以查看当前系统已分配的设备号。调试时经常用这个命令确认你的驱动是否注册成功。

file_operations:驱动与应用的接口契约

应用程序怎么操作你的设备?通过文件接口——open()、read()、write()、close() 这些。而驱动这边,就是用 file_operations 结构体来提供这些操作的实现。

说白了,file_operations 就是一张函数指针表。应用层调 read(),内核就去找这张表里的 .read 指针,然后调用你写的那个函数。

// file_operations 结构体定义(部分)
struct file_operations {
    struct module *owner;
    loff_t (*llseek) (struct file *, loff_t, int);
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
    int (*open) (struct inode *, struct file *);
    int (*release) (struct inode *, struct file *);
    // ... 还有很多
};

实际项目中,你不需要实现所有函数。只实现你设备需要的就行。比如一个简单的 LED 驱动,可能只需要 open、release 和 write。

// 一个简单的 file_operations 实例
static int my_open(struct inode *inode, struct file *filp) {
    printk("设备已打开\n");
    return 0;
}

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

static ssize_t my_read(struct file *filp, char __user *buf, 
                        size_t count, loff_t *f_pos) {
    // 从内核空间拷贝数据到用户空间
    // 注意用 copy_to_user()
    return 0;
}

static ssize_t my_write(struct file *filp, const char __user *buf,
                         size_t count, loff_t *f_pos) {
    // 从用户空间拷贝数据到内核空间
    // 注意用 copy_from_user()
    return count;
}

// 定义 file_operations 结构体
static struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .release = my_release,
    .read    = my_read,
    .write   = my_write,
};

注意:在 read 和 write 函数里,千万不要直接访问用户空间的指针!必须用 copy_to_user() 和 copy_from_user()。我曾经见过一个新手直接 memcpy,结果内核直接 oops 了。

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

设备号有了,操作函数表也有了,怎么把它们告诉内核?这就轮到 cdev 结构体出场了。

cdev 是内核用来管理字符设备的核心数据结构。它把设备号和 file_operations 绑定在一起,然后注册到内核的设备管理系统中。

// cdev 结构体定义
struct cdev {
    struct kobject kobj;
    struct module *owner;
    const struct file_operations *ops;
    struct list_head list;
    dev_t dev;
    unsigned int count;
};

使用 cdev 的步骤很简单,就三步:

  1. 分配:用 cdev_alloc() 或静态定义
  2. 初始化:用 cdev_init() 绑定 file_operations
  3. 注册:用 cdev_add() 告诉内核这个设备存在
// cdev 使用完整示例
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_device");
    if (ret < 0) {
        printk("设备号分配失败\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) {
        printk("cdev 注册失败\n");
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }

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

// 在模块卸载函数中
static void __exit my_driver_exit(void) {
    dev_t dev_num = my_cdev.dev;

    // 1. 删除 cdev
    cdev_del(&my_cdev);

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

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

经验之谈:我习惯把 cdev 放在一个自定义的设备结构体里,而不是用全局变量。这样方便管理多个设备实例。比如:

struct my_device {
    struct cdev cdev;
    // 其他设备相关数据
    int data;
    spinlock_t lock;
};

设备节点:用户空间的访问入口

驱动注册好了,设备号也有了,但用户空间的应用程序怎么找到它?答案是设备节点。

设备节点就是一个特殊的文件,通常放在 /dev 目录下。它记录了设备类型(字符设备还是块设备)和设备号。应用程序打开这个文件,内核就知道要调用哪个驱动的 file_operations。

创建设备节点的两种方式

方式 命令/函数 特点
手动创建 mknod /dev/mydev c 240 0 需要知道设备号,重启后消失
自动创建 class_create() + device_create() 驱动加载时自动生成,卸载时自动删除

手动创建太原始了,我从来不用。现在都用自动创建,配合 udev 或 mdev,驱动一加载,设备节点就自动出现在 /dev 下。

// 自动创建设备节点示例
static struct class *my_class;

static int __init my_driver_init(void) {
    // ... 前面的设备号分配和 cdev 注册代码 ...

    // 创建设备类
    my_class = class_create(THIS_MODULE, "my_class");
    if (IS_ERR(my_class)) {
        printk("类创建失败\n");
        cdev_del(&my_cdev);
        unregister_chrdev_region(dev_num, 1);
        return PTR_ERR(my_class);
    }

    // 创建设备节点(会在 /dev 下生成 my_device0)
    device_create(my_class, NULL, dev_num, NULL, "my_device%d", 0);

    return 0;
}

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

    // 先销毁设备节点
    device_destroy(my_class, dev_num);

    // 再销毁设备类
    class_destroy(my_class);

    // 最后删除 cdev 和释放设备号
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
}

注意顺序:卸载驱动时,一定要先销毁设备节点和类,再删除 cdev 和释放设备号。顺序反了,系统可能会崩溃。我踩过这个坑,那次调试了整整一个下午。

知识体系总览

下面这张图把字符设备驱动的核心脉络串起来了,你可以对照着理解:

字符设备驱动核心知识体系 用户空间 应用程序 → open/read/write/close → /dev/my_device 内核空间 设备节点 /dev/my_device VFS 虚拟文件系统 根据设备号查找驱动 file_operations 结构体 .open → .read → .write → .release → .llseek → ... cdev 结构体 设备号 dev_t 主设备号:次设备号

完整驱动框架模板

最后,我把上面所有知识点串起来,给你一个可以直接用的驱动模板。这个模板我用了好几年,改改就能用:

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

#define DEVICE_NAME "my_device"
#define CLASS_NAME  "my_class"

static int major;
static struct class *my_class;
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 count, loff_t *f_pos) {
    char kernel_buf[64] = "Hello from kernel!";
    size_t len = strlen(kernel_buf);

    if (count < len)
        return -EINVAL;

    if (copy_to_user(buf, kernel_buf, len))
        return -EFAULT;

    return len;
}

static ssize_t my_write(struct file *filp, const char __user *buf,
                         size_t count, loff_t *f_pos) {
    char kernel_buf[64];

    if (count > sizeof(kernel_buf))
        return -EINVAL;

    if (copy_from_user(kernel_buf, buf, count))
        return -EFAULT;

    printk("%s: 收到数据: %s\n", DEVICE_NAME, kernel_buf);
    return count;
}

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) {
    dev_t dev_num;
    int ret;

    // 1. 动态分配设备号
    ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
    if (ret < 0) {
        printk("设备号分配失败\n");
        return ret;
    }
    major = MAJOR(dev_num);

    // 2. 初始化并注册 cdev
    cdev_init(&my_cdev, &my_fops);
    my_cdev.owner = THIS_MODULE;
    ret = cdev_add(&my_cdev, dev_num, 1);
    if (ret < 0) {
        printk("cdev 注册失败\n");
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }

    // 3. 自动创建设备节点
    my_class = class_create(THIS_MODULE, CLASS_NAME);
    if (IS_ERR(my_class)) {
        printk("类创建失败\n");
        cdev_del(&my_cdev);
        unregister_chrdev_region(dev_num, 1);
        return PTR_ERR(my_class);
    }

    device_create(my_class, NULL, dev_num, NULL, DEVICE_NAME);

    printk("%s: 加载成功, 主设备号=%d\n", DEVICE_NAME, 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_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");

测试方法:加载驱动后,用 echo "hello" > /dev/my_device 写数据,用 cat /dev/my_device 读数据。看看内核日志里打印了什么。

嗯,字符设备驱动的基础就这些。设备号是身份证,file_operations 是操作手册,cdev 是内核里的登记表,设备节点是用户空间的入口。把这四个东西理解透,写一个简单的字符设备驱动就不成问题了。