4. 字符设备驱动:设备号、file_operations结构体、cdev注册、open/read/write实现

字符设备驱动,说白了就是Linux驱动里最基础、最常用的一种。你想想看,键盘、鼠标、串口、LED灯,这些设备本质上都是字符设备——数据按字节流一个一个传,没有缓冲区,也不讲究块结构。我刚开始学驱动时,第一个练手的项目就是写一个虚拟字符设备,那会儿踩了不少坑,今天我把这些经验都摊开来跟你聊聊。

4.1 设备号:驱动的身份证

每个字符设备在内核里都有一个唯一的编号,这就是设备号。设备号分两部分:主设备号和次设备号。主设备号标识驱动类型,次设备号标识具体设备实例。

设备号结构:dev_t 类型,高12位是主设备号,低20位是次设备号。

我个人习惯用 MKDEV(major, minor) 宏来组合设备号,用 MAJOR(dev) 和 MINOR(dev) 来拆分。别自己去算位移,容易出错。

#include <linux/types.h>   // dev_t
#include <linux/kdev_t.h>  // MKDEV, MAJOR, MINOR

dev_t dev_num;
int major = 240;   // 主设备号
int minor = 0;     // 次设备号

dev_num = MKDEV(major, minor);  // 组合

// 反过来拆
major = MAJOR(dev_num);
minor = MINOR(dev_num);

4.2 设备号分配:静态还是动态?

分配设备号有两种方式:静态分配和动态分配。我建议你优先用动态分配,除非你有特殊理由。

分配方式 函数 优点 缺点
静态分配 register_chrdev_region() 设备号固定,方便用户空间mknod 可能冲突,需要向内核申请保留
动态分配 alloc_chrdev_region() 自动分配,避免冲突 设备号不固定,需要从/proc/devices查看

我在项目中遇到过一件事:有个同事用了静态分配,主设备号选了241,结果跟另一个模块冲突了,两个驱动都加载失败。后来改成动态分配,问题就解决了。所以,除非你明确知道这个主设备号是空闲的,否则老老实实用动态分配。

// 动态分配
dev_t dev_num;
int ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
if (ret < 0) {
    printk(KERN_ERR "Failed to allocate device number\n");
    return ret;
}
printk(KERN_INFO "Allocated major=%d, minor=%d\n", 
       MAJOR(dev_num), MINOR(dev_num));

// 静态分配
dev_t dev_num = MKDEV(240, 0);
int ret = register_chrdev_region(dev_num, 1, "my_device");
if (ret < 0) {
    printk(KERN_ERR "Failed to register device number\n");
    return ret;
}

注意:用完设备号一定要释放!在模块卸载时调用 unregister_chrdev_region(),否则下次加载时会分配失败。

4.3 file_operations结构体:驱动的函数表

file_operations 结构体是字符设备驱动的核心。它就像一个函数指针表,告诉内核:当用户空间调用 open、read、write 时,该执行驱动里的哪个函数。

嗯,这里要注意:这个结构体里的成员非常多,但你不需要全部实现。只实现你设备需要的操作就行。比如一个只读设备,你只需要 open 和 read,write 可以留空。

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

我个人习惯用 .owner = THIS_MODULE 来防止模块被意外卸载。你想想看,如果用户空间正开着设备文件,你突然把模块卸载了,内核直接就崩溃了。这个字段就是用来避免这种情况的。

4.4 cdev注册:让设备活起来

有了设备号和 file_operations,接下来就是把它们绑定到一起,注册到内核里。这一步用 cdev 结构体来完成。

注册流程分三步:

  1. 分配cdev:用 cdev_alloc() 或静态定义
  2. 初始化cdev:用 cdev_init() 绑定 file_operations
  3. 添加cdev:用 cdev_add() 注册到内核
#include <linux/cdev.h>

struct cdev my_cdev;
dev_t dev_num;

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

// 2. 添加cdev到内核
int ret = cdev_add(&my_cdev, dev_num, 1);
if (ret < 0) {
    printk(KERN_ERR "Failed to add cdev\n");
    return ret;
}

小技巧:cdev_add() 之后,设备就立即可用了。所以一定要确保所有初始化都完成了再调用它。我曾经在初始化顺序上吃过亏——先加了cdev,然后才分配内存,结果用户空间一读就崩溃。

4.5 open/read/write实现:真正的干活

好了,前面的都是准备工作。现在我们来写真正的驱动逻辑。

4.5.1 open函数

open 函数在用户空间调用 open() 时触发。它的主要工作是:初始化设备、分配资源、检查权限。

int my_open(struct inode *inode, struct file *filp)
{
    // 从inode获取设备结构体
    struct my_device *dev = container_of(inode->i_cdev, 
                                          struct my_device, cdev);
    filp->private_data = dev;  // 保存到file结构体
    
    printk(KERN_INFO "Device opened\n");
    return 0;
}

这里有个关键点:container_of 宏。它通过结构体成员的地址,反推出整个结构体的地址。我在项目中见过有人不用这个宏,而是自己算偏移量,结果算错了,整个驱动都跑飞了。所以,老老实实用 container_of。

4.5.2 read函数

read 函数负责把设备数据拷贝到用户空间。注意,这里要用 copy_to_user(),不能直接用 memcpy。因为内核空间和用户空间的地址不能直接互访。

ssize_t my_read(struct file *filp, char __user *buf, 
                size_t count, loff_t *f_pos)
{
    struct my_device *dev = filp->private_data;
    size_t available = dev->data_size - *f_pos;
    size_t to_read = min(count, available);
    
    if (to_read == 0)
        return 0;  // EOF
    
    if (copy_to_user(buf, dev->data + *f_pos, to_read)) {
        return -EFAULT;  // 拷贝失败
    }
    
    *f_pos += to_read;
    return to_read;
}

避坑指南:copy_to_user() 和 copy_from_user() 可能会睡眠,所以不能在自旋锁保护的临界区里调用。我曾经犯过这个错,结果内核直接报 scheduling while atomic 错误。

4.5.3 write函数

write 函数跟 read 对称,负责从用户空间接收数据。

ssize_t my_write(struct file *filp, const char __user *buf, 
                 size_t count, loff_t *f_pos)
{
    struct my_device *dev = filp->private_data;
    size_t available = DEVICE_BUFFER_SIZE - *f_pos;
    size_t to_write = min(count, available);
    
    if (to_write == 0)
        return -ENOSPC;  // 空间不足
    
    if (copy_from_user(dev->data + *f_pos, buf, to_write)) {
        return -EFAULT;
    }
    
    *f_pos += to_write;
    dev->data_size = max(dev->data_size, *f_pos);
    return to_write;
}

4.6 完整流程:一张图看懂

说了这么多,我们来画一张图,把整个流程串起来。从模块加载到设备操作,每一步都清清楚楚。

字符设备驱动注册与操作流程 字符设备驱动注册与操作流程 模块加载 (module_init) 1. alloc_chrdev_region() 分配设备号 2. cdev_init() 绑定 file_operations 3. cdev_add() 注册到内核 用户空间操作设备 open() → my_open() read() → my_read() write() → my_write() 模块卸载 (module_exit) 关键:cdev_del() 删除cdev → unregister_chrdev_region() 释放设备号

4.7 完整示例:一个虚拟字符设备

最后,我把上面所有知识点整合成一个完整的驱动示例。这个驱动创建了一个虚拟字符设备,支持读写操作,缓冲区大小4KB。

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

#define DEVICE_NAME "vchar"
#define BUFFER_SIZE 4096

struct vchar_device {
    struct cdev cdev;
    char *buffer;
    size_t size;
};

static struct vchar_device *vchar_dev;
static dev_t dev_num;

static int vchar_open(struct inode *inode, struct file *filp)
{
    struct vchar_device *dev = container_of(inode->i_cdev,
                                            struct vchar_device, cdev);
    filp->private_data = dev;
    return 0;
}

static ssize_t vchar_read(struct file *filp, char __user *buf,
                          size_t count, loff_t *f_pos)
{
    struct vchar_device *dev = filp->private_data;
    size_t available = dev->size - *f_pos;
    size_t to_read = min(count, available);

    if (to_read == 0)
        return 0;

    if (copy_to_user(buf, dev->buffer + *f_pos, to_read))
        return -EFAULT;

    *f_pos += to_read;
    return to_read;
}

static ssize_t vchar_write(struct file *filp, const char __user *buf,
                           size_t count, loff_t *f_pos)
{
    struct vchar_device *dev = filp->private_data;
    size_t available = BUFFER_SIZE - *f_pos;
    size_t to_write = min(count, available);

    if (to_write == 0)
        return -ENOSPC;

    if (copy_from_user(dev->buffer + *f_pos, buf, to_write))
        return -EFAULT;

    *f_pos += to_write;
    dev->size = max(dev->size, *f_pos);
    return to_write;
}

static int vchar_release(struct inode *inode, struct file *filp)
{
    return 0;
}

static struct file_operations vchar_fops = {
    .owner   = THIS_MODULE,
    .open    = vchar_open,
    .read    = vchar_read,
    .write   = vchar_write,
    .release = vchar_release,
};

static int __init vchar_init(void)
{
    int ret;

    // 1. 分配设备号
    ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
    if (ret < 0) {
        printk(KERN_ERR "Failed to allocate device number\n");
        return ret;
    }
    printk(KERN_INFO "Allocated major=%d, minor=%d\n",
           MAJOR(dev_num), MINOR(dev_num));

    // 2. 分配设备结构体
    vchar_dev = kzalloc(sizeof(*vchar_dev), GFP_KERNEL);
    if (!vchar_dev) {
        unregister_chrdev_region(dev_num, 1);
        return -ENOMEM;
    }

    // 3. 分配缓冲区
    vchar_dev->buffer = kmalloc(BUFFER_SIZE, GFP_KERNEL);
    if (!vchar_dev->buffer) {
        kfree(vchar_dev);
        unregister_chrdev_region(dev_num, 1);
        return -ENOMEM;
    }
    vchar_dev->size = 0;

    // 4. 初始化并注册cdev
    cdev_init(&vchar_dev->cdev, &vchar_fops);
    vchar_dev->cdev.owner = THIS_MODULE;
    ret = cdev_add(&vchar_dev->cdev, dev_num, 1);
    if (ret < 0) {
        kfree(vchar_dev->buffer);
        kfree(vchar_dev);
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }

    printk(KERN_INFO "vchar device initialized\n");
    return 0;
}

static void __exit vchar_exit(void)
{
    cdev_del(&vchar_dev->cdev);
    kfree(vchar_dev->buffer);
    kfree(vchar_dev);
    unregister_chrdev_region(dev_num, 1);
    printk(KERN_INFO "vchar device removed\n");
}

module_init(vchar_init);
module_exit(vchar_exit);

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

测试方法:加载模块后,用 cat /proc/devices 查看分配的主设备号,然后用 mknod /dev/vchar c major 0 创建设备节点,最后用 echo "hello" > /dev/vchar 和 cat /dev/vchar 测试读写。

好了,字符设备驱动的核心内容就这些。设备号、file_operations、cdev注册、open/read/write实现,这四个点你掌握了,大部分字符设备驱动都能写。剩下的就是根据具体设备的需求,往这些框架里填逻辑而已。