4、字符设备驱动基础:cdev结构体、file_operations、设备号分配、miscdevice框架

好,咱们今天聊聊字符设备驱动。这是Linux驱动里最基础、也最常用的一类。说白了,你只要想跟硬件做点简单的数据交互——读个传感器、发个指令、控制个LED——大概率就得用字符设备驱动。

我个人习惯,学驱动先从框架入手。别急着看硬件手册,先把软件骨架搭明白。今天这节,咱们就把字符设备驱动的四个核心零件拆开揉碎:cdev结构体file_operations设备号分配,还有那个偷懒神器——miscdevice框架

4.1 设备号:驱动的身份证

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

你想想看,系统里那么多设备,内核怎么知道谁是谁?就是靠这个号。主设备号就像小区门牌号,次设备号就是单元号。

设备号的数据类型是 dev_t,一个32位无符号整数。高12位是主设备号,低20位是次设备号。

分配设备号有两种方式:

  • 静态分配:调用 register_chrdev_region(),你得自己指定主设备号。适合你知道哪些号没被占用的场景。
  • 动态分配:调用 alloc_chrdev_region(),内核自动给你分配一个空闲的主设备号。我强烈建议你用这个,省心。

我曾经在项目里吃过静态分配的亏。写驱动时随便选了个主设备号,结果跟系统里某个模块冲突了,加载驱动直接报错。从那以后,我再也不用静态分配了。

// 动态分配设备号示例
dev_t dev_num;
int ret;

ret = alloc_chrdev_region(&dev_num, 0, 1, "my_driver");
if (ret < 0) {
    printk(KERN_ERR "Failed to allocate device number\n");
    return ret;
}

printk("Major: %d, Minor: %d\n", MAJOR(dev_num), MINOR(dev_num));

嗯,这里要注意:用完设备号一定要释放。调用 unregister_chrdev_region(),不然下次加载驱动时可能分配失败。

4.2 cdev结构体:驱动的内核化身

设备号是身份证,那cdev就是驱动在内核里的实体。每个字符设备在内核里都对应一个cdev结构体。

它的定义在 <linux/cdev.h> 里,核心成员就几个:

成员 说明
owner 所属模块,一般设为 THIS_MODULE
ops 指向 file_operations 结构体的指针
dev 设备号(dev_t 类型)
count 次设备号数量

初始化cdev的流程很简单:

struct cdev my_cdev;

cdev_init(&my_cdev, &my_fops);  // 关联 file_operations
my_cdev.owner = THIS_MODULE;

cdev_add(&my_cdev, dev_num, 1); // 注册到内核

我个人习惯把cdev定义成全局变量或者动态分配。如果设备数量不确定,用 cdev_alloc() 动态分配更灵活。

注意:cdev_add() 之后,设备就对外可见了。所以一定要在硬件初始化完成后再调用,否则用户空间可能访问到未就绪的设备。

4.3 file_operations:驱动的操作手册

用户空间程序怎么跟你的驱动交互?通过系统调用,比如 open、read、write、ioctl。而驱动这边,就是通过 file_operations 结构体来响应这些调用。

说白了,这就是一张函数跳转表。用户调 open,内核就调你的 xxx_open 函数。

常用的操作有这些:

  • open:打开设备,做初始化工作
  • release:关闭设备,释放资源
  • read:从设备读取数据
  • write:向设备写入数据
  • unlocked_ioctl:设备控制命令(注意,现在都用 unlocked_ioctl 了,老的 ioctl 已废弃)
  • llseek:修改文件读写位置

来看个最简单的实现:

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

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

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

static ssize_t my_write(struct file *file, const char __user *buf,
                        size_t len, loff_t *off) {
    // 注意:要用 copy_from_user 从用户空间拷贝数据
    return len;
}

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

避坑指南:我曾经在 read 函数里直接用了 memcpy 来拷贝数据,结果内核崩溃。记住,用户空间和内核空间不能直接传指针,必须用 copy_to_user / copy_from_user。这是新手最容易犯的错误。

4.4 miscdevice框架:偷懒的艺术

每次写字符设备驱动都要手动分配设备号、注册cdev、创建设备类……太麻烦了。对于简单的设备,Linux 提供了一个偷懒框架——miscdevice

miscdevice 是「杂项设备」的意思。它自动帮你处理了设备号分配和cdev注册。你只需要填一个结构体:

struct miscdevice my_misc = {
    .minor = MISC_DYNAMIC_MINOR,  // 让内核自动分配次设备号
    .name  = "my_misc_device",    // 设备名,会在 /dev 下创建
    .fops  = &my_fops,            // 还是那个 file_operations
};

注册和注销也简单到离谱:

misc_register(&my_misc);   // 注册
misc_deregister(&my_misc); // 注销

你想想看,原来要写四五步的工作,现在两步搞定。miscdevice 的主设备号固定是 10,所有杂项设备共享这个主设备号,通过次设备号区分。

我记得刚学驱动时,看到别人用 miscdevice 觉得太方便了。但要注意,它只适合简单的、数量少的设备。如果你要管理几十个同类设备,还是老老实实用标准字符设备框架。

miscdevice 的适用场景:

  • 只有一个设备的驱动(比如一个LED、一个蜂鸣器)
  • 不需要复杂的设备模型管理
  • 快速原型验证

4.5 完整示例:一个最小字符设备驱动

把上面这些串起来,就是一个能用的驱动了。咱们用 miscdevice 框架,写个最简单的:

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

#define DEVICE_NAME "demo_device"

static int demo_open(struct inode *inode, struct file *file) {
    printk("demo: device opened\n");
    return 0;
}

static int demo_release(struct inode *inode, struct file *file) {
    printk("demo: device closed\n");
    return 0;
}

static ssize_t demo_read(struct file *file, char __user *buf,
                         size_t len, loff_t *off) {
    char data[] = "Hello from kernel!\n";
    size_t data_len = strlen(data);

    if (*off >= data_len)
        return 0;

    if (len > data_len - *off)
        len = data_len - *off;

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

    *off += len;
    return len;
}

static struct file_operations demo_fops = {
    .owner   = THIS_MODULE,
    .open    = demo_open,
    .release = demo_release,
    .read    = demo_read,
};

static struct miscdevice demo_misc = {
    .minor = MISC_DYNAMIC_MINOR,
    .name  = DEVICE_NAME,
    .fops  = &demo_fops,
};

static int __init demo_init(void) {
    int ret;

    ret = misc_register(&demo_misc);
    if (ret) {
        printk("demo: failed to register misc device\n");
        return ret;
    }

    printk("demo: device registered\n");
    return 0;
}

static void __exit demo_exit(void) {
    misc_deregister(&demo_misc);
    printk("demo: device unregistered\n");
}

module_init(demo_init);
module_exit(demo_exit);

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

编译加载后,你会在 /dev/demo_device 看到这个设备。用 cat /dev/demo_device 就能读到内核发来的消息。

嗯,这就是字符设备驱动最基础的样子。别看简单,所有复杂的驱动——摄像头、音频、GPU——底层都是这套机制。把今天这四个概念吃透,后面学 V4L2、ALSA 就轻松多了。

下一节咱们聊聊如何用 udev 自动创建设备节点,让驱动加载后设备自动出现,不用手动 mknod。