第3章 字符设备驱动框架:设备号申请与注销、file_operations结构体、cdev结构体、设备节点创建

字符设备驱动,说白了就是Linux驱动里最基础、最常用的一种驱动模型。你想想看,我们平时用的键盘、鼠标、串口,甚至一些传感器,本质上都是字符设备。它们的特点是数据按字节流传输,顺序读写,不像块设备那样可以随机访问。

这一章,我带你手把手搭建一个完整的字符设备驱动框架。从设备号怎么申请,到file_operations结构体怎么填,再到cdev结构体怎么注册,最后创建设备节点让用户空间能访问。嗯,这一套流程走下来,你就能写出自己的第一个字符设备驱动了。

3.1 设备号:驱动的身份证

每个字符设备在Linux内核里都有一个唯一的标识,这就是设备号。设备号由两部分组成:主设备号次设备号

  • 主设备号:标识设备对应的驱动程序。比如,同一个驱动可以管理多个同类型的设备。
  • 次设备号:由驱动自己管理,用来区分同一个驱动下的不同设备实例。

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

#include <linux/types.h>

MAJOR(dev_t dev);  // 从设备号中提取主设备号
MINOR(dev_t dev);  // 从设备号中提取次设备号
MKDEV(int major, int minor); // 将主次设备号组合成一个dev_t

我个人习惯在写驱动时,先用MKDEV把主次设备号拼起来,再去做注册。这样代码看起来更清晰。

3.2 设备号申请:静态与动态

申请设备号有两种方式:静态申请动态申请

3.2.1 静态申请

静态申请就是你自己指定一个主设备号。内核提供了register_chrdev_region函数:

int register_chrdev_region(dev_t from, unsigned count, const char *name);
  • from:起始设备号
  • count:连续设备号的个数
  • name:设备名称,会在/proc/devices中显示

返回值:成功返回0,失败返回负的错误码。

注意:静态申请的主设备号不能和系统中已有的冲突。你可以查看/proc/devices来确认哪些主设备号已经被占用了。我曾经在项目里直接用了254这个号,结果跟系统里某个驱动冲突了,调试了半天才发现。嗯,从那以后我基本都用动态申请了。

3.2.2 动态申请

动态申请由内核自动分配一个可用的主设备号。用alloc_chrdev_region函数:

int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name);
  • dev:输出参数,返回分配到的设备号
  • baseminor:起始次设备号
  • count:连续设备号的个数
  • name:设备名称

我个人强烈建议你在开发阶段使用动态申请。为什么?因为省心啊!你不用去查哪些号被占了,内核会帮你搞定。等产品定型了,再考虑要不要用静态的固定号。

3.3 设备号注销

有申请就有释放。在驱动卸载时,一定要记得把设备号还回去。用unregister_chrdev_region

void unregister_chrdev_region(dev_t from, unsigned count);

参数跟申请时一样。不释放的话,下次加载驱动时可能就申请不到设备号了。我见过不少新手写的驱动,加载一次后卸载再加载就报错,十有八九是忘了这步。

3.4 file_operations结构体:驱动与用户空间的桥梁

用户程序通过系统调用(如openreadwrite)来操作设备文件。内核怎么知道这些系统调用该调用驱动里的哪个函数呢?答案就是file_operations结构体。

这个结构体定义在<linux/fs.h>中,里面全是函数指针。我们只需要把我们实现的函数填进去就行。常用的成员有:

成员 说明
owner 通常设为THIS_MODULE,防止模块被卸载时还有操作在进行
open 打开设备时调用
release 关闭设备时调用
read 从设备读取数据
write 向设备写入数据
unlocked_ioctl 设备控制命令(无大内核锁)
llseek 修改文件读写位置

来看一个最简单的实现:

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

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

static ssize_t my_read(struct file *filp, char __user *buf,
                       size_t len, loff_t *off)
{
    printk(KERN_INFO "read called\n");
    return 0;
}

static ssize_t my_write(struct file *filp, const char __user *buf,
                        size_t len, loff_t *off)
{
    printk(KERN_INFO "write called\n");
    return len;
}

static struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .release = my_release,
    .read    = my_read,
    .write   = my_write,
};
小技巧:readwrite函数中,用户空间的缓冲区指针是__user类型的。千万不要直接解引用!要用copy_to_usercopy_from_user来安全地拷贝数据。这是内核开发的基本常识,搞错了会导致内核崩溃。

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

有了设备号和file_operations,接下来就要把它们绑定到一起。这个绑定的载体就是cdev结构体。

cdev定义在<linux/cdev.h>中。使用它需要三步:

  1. 分配cdev:可以用静态分配,也可以用cdev_alloc动态分配
  2. 初始化cdev:调用cdev_init,关联file_operations
  3. 添加cdev到系统:调用cdev_add,注册到内核

代码示例:

#include <linux/cdev.h>

static dev_t dev_num;
static struct cdev my_cdev;

static int __init my_init(void)
{
    int ret;

    // 1. 申请设备号
    ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
    if (ret < 0) {
        printk(KERN_ERR "failed to alloc chrdev region\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(KERN_ERR "failed to add cdev\n");
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }

    printk(KERN_INFO "my device initialized, major=%d, minor=%d\n",
           MAJOR(dev_num), MINOR(dev_num));
    return 0;
}

static void __exit my_exit(void)
{
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
    printk(KERN_INFO "my device exited\n");
}

module_init(my_init);
module_exit(my_exit);

这里要注意顺序:先申请设备号,再初始化cdev,最后添加cdev。如果cdev_add失败,要记得把之前申请的设备号释放掉。我在项目中见过有人把顺序搞反了,结果cdev_add时设备号还没申请,内核直接报错。

3.6 设备节点创建:让用户空间能访问

驱动加载成功后,内核里已经有了这个设备。但用户空间怎么访问它呢?需要有一个设备文件,也就是设备节点。通常放在/dev目录下。

创建设备节点有两种方式:

3.6.1 手动创建(调试用)

mknod命令:

mknod /dev/my_device c 240 0

其中c表示字符设备,240是主设备号,0是次设备号。这种方式适合调试,但不适合产品发布。

3.6.2 自动创建(推荐)

利用内核的udev机制,驱动加载时自动创建设备节点。需要用到classdevice

#include <linux/device.h>

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

static int __init my_init(void)
{
    // ... 前面的设备号申请和cdev_add代码 ...

    // 创建类
    my_class = class_create(THIS_MODULE, "my_class");
    if (IS_ERR(my_class)) {
        ret = PTR_ERR(my_class);
        goto err_class;
    }

    // 创建设备节点
    my_device = device_create(my_class, NULL, dev_num, NULL, "my_device");
    if (IS_ERR(my_device)) {
        ret = PTR_ERR(my_device);
        goto err_device;
    }

    return 0;

err_device:
    class_destroy(my_class);
err_class:
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
    return ret;
}

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);
}

这样,驱动加载后,/dev/my_device就会自动出现。用户程序直接open("/dev/my_device", O_RDWR)就能操作了。

核心要点:字符设备驱动的完整框架就是「设备号 + file_operations + cdev + 设备节点」这四步。每一步都有对应的申请和释放操作,成对出现,缺一不可。

3.7 完整示例:一个什么都不做的字符设备

把上面的代码拼起来,就是一个完整的字符设备驱动。它什么都不做,只是注册了设备,创建了节点。你可以用它来验证整个框架是否跑通。

编译加载后,用ls -l /dev/my_device看看设备节点有没有创建。然后用cat /dev/my_device试试,应该能看到内核打印的read called信息。

嗯,这一章的内容就到这里。下一章我们会在这个框架基础上,加入真正的硬件操作,让驱动能干点实事。