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

好,咱们今天来聊聊字符设备驱动的核心骨架。说白了,就是驱动怎么跟内核“打招呼”,怎么让用户空间的程序找到它、调用它。

我刚开始学驱动时,最头疼的就是这几个概念搅在一起。设备号、操作函数、cdev结构体、设备节点……它们到底怎么串起来的?今天咱们就把它彻底理清。

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

内核里,每个设备都有一个唯一的“身份证”——设备号。它是个32位的无符号整数,高12位是主设备号,低20位是次设备号。

主设备号:标识设备对应的驱动程序。比如,串口驱动的主设备号通常是4,LED驱动可能是某个自定义值。

次设备号:由驱动自己管理,用来区分同类型的不同设备。比如两个串口,主设备号都是4,次设备号分别是64和65。

我见过不少新手直接写死主设备号,结果跟系统里已有的冲突了。嗯,这里要注意——主设备号是共享资源,不能乱占。

设备号的分配方式

方式 函数 说明
静态分配 register_chrdev_region() 手动指定主设备号,适合已知不冲突的场景
动态分配 alloc_chrdev_region() 内核自动分配,推荐使用
我的习惯: 项目初期用动态分配,省心。等到产品定型了,再考虑固定主设备号,方便udev规则管理。

来看一段实际代码:

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

dev_t dev_num;  // 设备号变量

// 动态分配:主设备号由内核决定
alloc_chrdev_region(&dev_num, 0, 1, "my_led_driver");
printk("主设备号: %d, 次设备号: %d\n", 
       MAJOR(dev_num), MINOR(dev_num));

// 静态分配:指定主设备号(比如 200)
// dev_num = MKDEV(200, 0);
// register_chrdev_region(dev_num, 1, "my_led_driver");

我曾经在一个项目里偷懒用了静态分配,结果跟系统自带的USB串口驱动冲突了。从那以后,我养成了“先动态、后固定”的习惯。

3.2 file_operations结构体:驱动的“函数表”

用户程序调用open、read、write时,内核怎么知道该执行哪段代码?答案就在file_operations结构体里。

它本质上是个函数指针表。你把它填好,内核就知道“哦,这个驱动支持这些操作”。

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

常用的成员有这些:

  • owner:指向模块自身,防止模块被卸载时还在被使用
  • open:设备打开时调用,通常做初始化
  • release:设备关闭时调用,做清理
  • read/write:数据读写,核心操作
  • unlocked_ioctl:控制命令,比如设置波特率、切换模式
注意: 如果某个操作不需要,直接设为NULL。内核会返回 -EINVAL。千万别写个空函数返回0,那会让用户程序以为操作成功了。

你想想看,用户程序调用read(fd, buf, 100),内核就找到这个设备的file_operations,然后调用my_read。就这么简单。

3.3 cdev结构体:内核里的“设备对象”

设备号是身份证,file_operations是技能表。那怎么把它们绑定在一起?用cdev结构体。

cdev是内核里代表字符设备的核心对象。每个字符设备驱动都要初始化一个cdev,然后注册到内核。

#include <linux/cdev.h>

struct cdev my_cdev;

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

// 2. 注册到内核(假设 dev_num 已经分配好)
cdev_add(&my_cdev, dev_num, 1);

这里有个坑——cdev_add之后,设备就“活”了。用户程序随时可能打开它。所以,所有初始化工作必须在cdev_add之前完成。

我见过一个同事,把硬件初始化放在了cdev_add之后。结果用户程序一打开,硬件还没准备好,直接崩溃。嗯,血的教训。

3.4 设备节点的创建:手动与自动

设备号、cdev都注册好了,用户程序怎么找到它?通过设备节点——就是/dev/xxx那个文件。

手动创建:mknod命令

最原始的方式,在终端里敲:

mknod /dev/my_led c 200 0
chmod 666 /dev/my_led

c表示字符设备,200是主设备号,0是次设备号。这种方式适合调试,但产品里千万别用——用户不可能手动敲命令。

自动创建:udev + class_create

现代Linux系统都用udev自动管理设备节点。驱动里只需要做两件事:

  1. 创建一个设备类(class)
  2. 在类下创建设备(device)

udev监听到新设备,就会自动在/dev下生成节点。

#include <linux/device.h>

static struct class *my_class;
static struct device *my_device;

// 在模块初始化函数中
my_class = class_create(THIS_MODULE, "my_led_class");
if (IS_ERR(my_class)) {
    // 错误处理
}

my_device = device_create(my_class, NULL, dev_num, 
                          NULL, "my_led");
if (IS_ERR(my_device)) {
    // 错误处理
}

// 在模块退出函数中
device_destroy(my_class, dev_num);
class_destroy(my_class);

这样,驱动加载后,/dev/my_led就自动出现了。权限也可以提前在udev规则里配好。

核心流程总结:
1. 分配设备号(alloc_chrdev_region)
2. 初始化cdev并绑定file_operations(cdev_init + cdev_add)
3. 创建设备类(class_create)
4. 创建设备节点(device_create)
5. 卸载时反向销毁

3.5 完整示例框架

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

#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 struct device *my_device;

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 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_driver");
    
    // 2. 初始化并注册cdev
    cdev_init(&my_cdev, &my_fops);
    cdev_add(&my_cdev, dev_num, 1);
    
    // 3. 自动创建设备节点
    my_class = class_create(THIS_MODULE, "my_class");
    my_device = device_create(my_class, NULL, dev_num, 
                              NULL, "my_device");
    
    printk("驱动加载成功,主设备号: %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("驱动卸载完成\n");
}

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

这个框架,我几乎每个项目都拿它当起点。你把它背下来,字符设备驱动就入门了。

避坑指南: 我曾经在cdev_add之后才分配设备号,结果内核直接Oops。记住顺序:设备号 → cdev_init → cdev_add → 设备节点。卸载时顺序反过来。

好了,这一章的内容就到这里。设备号、file_operations、cdev、设备节点——这四个概念是字符设备驱动的基石。下一章咱们会在这个骨架上,加上真正的硬件操作,让LED亮起来。