4、字符设备驱动框架:file_operations结构体、设备号注册与注销、cdev_init与cdev_add

好,咱们今天来聊聊字符设备驱动的核心骨架。说实话,这部分内容我当年刚入门时也绕了不少弯路。你想想看,一个驱动要跟用户态程序打交道,总得有个“接口”吧?这个接口就是字符设备驱动框架。说白了,它就是一套约定,告诉内核:我这个驱动能干啥,你怎么找到我。

4.1 file_operations:驱动与用户态的“翻译官”

先说说 file_operations 结构体。这个家伙,是驱动世界里最核心的数据结构之一。我习惯叫它“操作函数表”。为什么?因为它里面装满了函数指针,每个指针都对应一个系统调用。

举个例子,你在用户态调用 open("/dev/mydev", O_RDWR),内核就会找到这个设备对应的 file_operations,然后调用里面的 .open 函数。同样的,read、write、ioctl 也都是这么映射的。

核心要点:file_operations 是驱动与 VFS(虚拟文件系统)之间的桥梁。没有它,用户态程序根本碰不到你的硬件。

来看一个典型的定义:

static 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 字段。我见过不少新手会漏掉它。这个字段指向模块本身,用来防止模块还在被使用时就被卸载。嗯,这里要注意,如果你不设 THIS_MODULE,模块卸载时可能会引发内核崩溃。我在项目中遇到过这种问题,调试了一下午才发现是忘了赋值。

常用的操作函数大概有这些:

函数指针 对应系统调用 典型用途
.open open() 初始化设备、递增引用计数
.release close() 释放资源、递减引用计数
.read read() 从设备读取数据
.write write() 向设备写入数据
.unlocked_ioctl ioctl() 设备控制命令(无大内核锁)
.llseek lseek() 修改文件读写位置

个人习惯:我一般会把所有用不到的函数指针设为 NULL。内核会帮你做默认处理。比如 .llseek 为 NULL 时,默认不允许定位。

4.2 设备号:驱动的“身份证”

每个字符设备在内核里都有一个唯一的编号,叫设备号。它由两部分组成:主设备号和次设备号。主设备号用来标识驱动类型,次设备号用来区分同一驱动下的不同设备。

你可以把主设备号想象成“小区门牌号”,次设备号就是“单元号”。同一个驱动(比如串口驱动)可以管理多个串口设备,每个设备用不同的次设备号区分。

设备号用 dev_t 类型表示,这是一个32位的整数。高12位是主设备号,低20位是次设备号。内核提供了宏来操作它:

#include <linux/kdev_t.h>

dev_t devno = MKDEV(major, minor);  // 合成设备号
int major = MAJOR(devno);           // 提取主设备号
int minor = MINOR(devno);           // 提取次设备号

注册设备号有两种方式:

  1. 静态分配:你自己指定一个主设备号,调用 register_chrdev_region()。适合你知道哪些号没被占用的情况。
  2. 动态分配:让内核帮你分配,调用 alloc_chrdev_region()。我强烈推荐这种方式,省心又安全。

来看代码:

dev_t devno;
int major, ret;

// 动态分配设备号
ret = alloc_chrdev_region(&devno, 0, 1, "my_audio_dev");
if (ret < 0) {
    printk(KERN_ERR "Failed to alloc chrdev region\n");
    return ret;
}

major = MAJOR(devno);
printk(KERN_INFO "Allocated major number: %d\n", major);

避坑指南:我曾经在项目里用了静态分配,选了一个“看起来没人用”的主设备号。结果跟另一个模块冲突了,系统启动时直接 panic。从那以后,我再也不敢偷懒了,一律用动态分配。

注销设备号同样重要,在模块卸载时一定要记得释放:

unregister_chrdev_region(devno, 1);  // 1 表示只注销一个设备

4.3 cdev_init 与 cdev_add:让设备“活”起来

有了操作函数表,有了设备号,接下来就要把它们绑定到一起。这就是 cdev 结构体的工作。cdev 是内核里代表字符设备的对象。

流程很简单,就两步:

  1. cdev_init:初始化 cdev 结构体,绑定 file_operations。
  2. cdev_add:把 cdev 注册到内核,告诉内核“这个设备号归我管”。

看代码:

#include <linux/cdev.h>

struct cdev my_cdev;
dev_t devno;

// 第一步:初始化 cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;

// 第二步:添加到内核
ret = cdev_add(&my_cdev, devno, 1);
if (ret < 0) {
    printk(KERN_ERR "Failed to add cdev\n");
    unregister_chrdev_region(devno, 1);
    return ret;
}

这里有个关键点——cdev_add 一旦成功,你的设备就“活”了。用户态程序马上就能通过 /dev 下的设备文件来访问它。所以,我建议在 cdev_add 之前,先把硬件初始化好。否则用户一打开设备,你的驱动还没准备好,那就尴尬了。

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

struct my_audio_dev {
    struct cdev cdev;
    void __iomem *regs;
    int irq;
    // ... 其他字段
};

注销时顺序要反过来:

cdev_del(&my_cdev);          // 先删除 cdev
unregister_chrdev_region(devno, 1);  // 再释放设备号

为什么要先删 cdev?因为 cdev_del 会确保没有正在进行的操作,然后才把设备从内核中移除。如果顺序反了,可能会出现设备号已经被释放,但还有进程在访问的情况。

4.4 完整的驱动骨架

把上面这些串起来,就是一个最简的字符设备驱动框架:

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

static dev_t devno;
static struct cdev my_cdev;

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

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

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

static int __init my_init(void)
{
    int ret;

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

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

    // 3. 添加 cdev
    ret = cdev_add(&my_cdev, devno, 1);
    if (ret) {
        unregister_chrdev_region(devno, 1);
        return ret;
    }

    printk(KERN_INFO "Mydev loaded, major=%d\n", MAJOR(devno));
    return 0;
}

static void __exit my_exit(void)
{
    cdev_del(&my_cdev);
    unregister_chrdev_region(devno, 1);
    printk(KERN_INFO "Mydev unloaded\n");
}

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

这个骨架虽然简单,但五脏俱全。你往 my_open 和 my_release 里加硬件操作,就是一个真正的驱动了。

总结一下:字符设备驱动的核心就三件事——定义操作函数、分配设备号、注册 cdev。顺序不能乱,注销时反着来。记住这个套路,后面写音频驱动时,你只需要往这个框架里填充 ALSA 相关的代码就行了。

嗯,今天就先聊到这儿。下一节咱们会讲如何创建设备节点,让用户态能真正访问到你的驱动。到时候你会看到,udev 和 class_create 是怎么自动帮你搞定 /dev 下那些设备文件的。