4、字符设备驱动基础:实现一个基本的字符设备驱动,理解file_operations结构体与设备号注册

字符设备驱动是Linux驱动开发中最基础、最核心的模块之一。它通过file_operations结构体将用户空间的系统调用(如open、read、write)与内核空间的驱动函数绑定,并通过设备号注册让系统识别和管理设备。本章将手把手带你实现一个完整的字符设备驱动,并深入剖析其背后的机制。

学习目标:掌握字符设备驱动的骨架结构;理解主设备号与次设备号的作用;熟练使用alloc_chrdev_region与cdev_init等核心API;能够独立编写一个可加载/卸载的字符设备驱动模块。

4.1 字符设备驱动架构总览

字符设备驱动的核心是“文件操作”抽象。用户空间通过文件描述符操作设备,内核通过file_operations结构体将操作转发到驱动函数。下图展示了从用户态open()到驱动xxx_open()的完整调用链路。

字符设备驱动调用链路 用户空间 open("/dev/xxx", O_RDWR) 系统调用层 sys_open() → vfs_open() VFS层 查找inode → 获取file_operations 驱动层 (file_operations) .open = xxx_open 设备核心层 cdev_add() 注册 硬件抽象层 ioremap / 寄存器操作 回调 💡 关键点: 用户空间通过设备文件 → VFS根据设备号找到cdev → 调用file_operations中的对应函数。

4.2 设备号:主设备号与次设备号

每个字符设备在内核中由设备号唯一标识。设备号是一个dev_t类型的32位无符号整数,其中高12位为主设备号,低20位为次设备号。

概念 位数 范围 作用
主设备号 (Major) 12 bit 0 ~ 4095 标识设备对应的驱动程序
次设备号 (Minor) 20 bit 0 ~ 1048575 标识由同一驱动管理的具体设备实例
设备号注册方式对比:
  • 静态注册:register_chrdev_region(dev_t first, unsigned int count, const char *name) —— 需要提前知道可用的主设备号,容易冲突。
  • 动态分配:alloc_chrdev_region(dev_t *dev, unsigned int firstminor, unsigned int count, const char *name) —— 由内核自动分配未使用的主设备号,推荐使用。

4.3 file_operations 结构体详解

file_operations是字符设备驱动的“函数跳转表”,定义在<linux/fs.h>中。驱动开发者只需实现需要的回调函数,未实现的成员置为NULL,内核会使用默认行为。

成员 函数原型 说明
.owner struct module *owner 通常设为 THIS_MODULE,防止模块被卸载时仍有打开的文件
.open int (*open)(struct inode *, struct file *) 打开设备时调用,用于初始化或权限检查
.release int (*release)(struct inode *, struct file *) 关闭设备时调用,用于清理资源
.read ssize_t (*read)(struct file *, char __user *, size_t, loff_t *) 从设备读取数据,需使用 copy_to_user 传递数据到用户空间
.write ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *) 向设备写入数据,需使用 copy_from_user 从用户空间获取数据
.llseek loff_t (*llseek)(struct file *, loff_t, int) 修改文件读写位置,默认实现支持 seek 操作

4.4 实战:实现一个完整的字符设备驱动

下面实现一个名为“mychardev”的字符设备驱动,支持open、release、read、write四个基本操作。驱动内部维护一个4KB的缓冲区。

4.4.1 驱动代码框架

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

#define DEVICE_NAME "mychardev"
#define BUFFER_SIZE 4096

static dev_t dev_num;                // 设备号
static struct cdev my_cdev;          // 字符设备对象
static char *device_buffer;          // 内核缓冲区

// open 函数
static int my_open(struct inode *inode, struct file *filp) {
    printk(KERN_INFO "mychardev: device opened\n");
    return 0;
}

// release 函数
static int my_release(struct inode *inode, struct file *filp) {
    printk(KERN_INFO "mychardev: device closed\n");
    return 0;
}

// read 函数
static ssize_t my_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos) {
    size_t available = BUFFER_SIZE - *f_pos;
    size_t to_read = count < available ? count : available;

    if (to_read == 0) return 0;

    if (copy_to_user(buf, device_buffer + *f_pos, to_read)) {
        return -EFAULT;
    }

    *f_pos += to_read;
    return to_read;
}

// write 函数
static ssize_t my_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos) {
    size_t available = BUFFER_SIZE - *f_pos;
    size_t to_write = count < available ? count : available;

    if (to_write == 0) return -ENOSPC;

    if (copy_from_user(device_buffer + *f_pos, buf, to_write)) {
        return -EFAULT;
    }

    *f_pos += to_write;
    return to_write;
}

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

// 模块初始化
static int __init my_init(void) {
    int ret;

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

    // 2. 分配内核缓冲区
    device_buffer = kmalloc(BUFFER_SIZE, GFP_KERNEL);
    if (!device_buffer) {
        unregister_chrdev_region(dev_num, 1);
        return -ENOMEM;
    }
    memset(device_buffer, 0, BUFFER_SIZE);

    // 3. 初始化并添加字符设备
    cdev_init(&my_cdev, &my_fops);
    my_cdev.owner = THIS_MODULE;
    ret = cdev_add(&my_cdev, dev_num, 1);
    if (ret < 0) {
        printk(KERN_ERR "mychardev: failed to add cdev\n");
        kfree(device_buffer);
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }

    printk(KERN_INFO "mychardev: driver loaded successfully\n");
    return 0;
}

// 模块退出
static void __exit my_exit(void) {
    cdev_del(&my_cdev);
    kfree(device_buffer);
    unregister_chrdev_region(dev_num, 1);
    printk(KERN_INFO "mychardev: driver unloaded\n");
}

module_init(my_init);
module_exit(my_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Linux Kernel Expert");
MODULE_DESCRIPTION("A simple character device driver example");

4.4.2 驱动加载与设备文件创建

加载驱动后,需要手动创建设备文件(或使用udev自动创建)。设备文件是用户空间访问驱动的入口。

# 1. 编译驱动模块
make -C /lib/modules/$(uname -r)/build M=$(pwd) modules

# 2. 加载模块
sudo insmod mychardev.ko

# 3. 查看设备号分配情况
cat /proc/devices | grep mychardev
# 输出示例: 240 mychardev

# 4. 手动创建设备文件 (主设备号240,次设备号0)
sudo mknod /dev/mychardev c 240 0

# 5. 设置权限
sudo chmod 666 /dev/mychardev

# 6. 测试读写
echo "Hello, Kernel!" > /dev/mychardev
cat /dev/mychardev
# 输出: Hello, Kernel!

# 7. 卸载模块
sudo rmmod mychardev
sudo rm /dev/mychardev
⚠️ 常见错误与排查:
  • insmod失败:检查内核版本是否匹配,使用dmesg | tail查看内核日志。
  • open返回-1:确认设备文件的主次设备号与/proc/devices一致。
  • read/write返回-14 (EFAULT):检查copy_to_user/copy_from_user的指针是否正确,用户空间地址必须使用__user标记。
  • 模块卸载失败:确保没有进程持有设备文件描述符,使用lsof /dev/mychardev检查。

4.5 驱动初始化与退出流程

字符设备驱动的生命周期分为加载和卸载两个阶段,每个阶段都有固定的步骤顺序。下图展示了完整的初始化与退出流程。

字符设备驱动初始化与退出流程 加载流程 (module_init) ① alloc_chrdev_region() 分配设备号 ② kmalloc() 分配内核缓冲区 ③ cdev_init() 初始化cdev对象 ④ cdev_add() 向内核注册设备 卸载流程 (module_exit) ① cdev_del() 从内核移除设备 ② kfree() 释放内核缓冲区 ③ unregister_chrdev_region() 释放设备号 💡 注意事项: • 初始化时如果某一步失败,必须回滚之前已成功的步骤,防止资源泄漏。 • 卸载顺序必须与加载顺序相反,确保依赖关系正确。

4.6 关键API速查表

API 头文件 功能
alloc_chrdev_region() <linux/fs.h> 动态分配指定数量的设备号
register_chrdev_region() <linux/fs.h> 静态注册已知的设备号
unregister_chrdev_region() <linux/fs.h> 释放已注册的设备号
cdev_init() <linux/cdev.h> 初始化cdev结构体并与file_operations绑定
cdev_add() <linux/cdev.h> 将cdev添加到内核设备链表,使设备可用
cdev_del() <linux/cdev.h> 从内核设备链表中移除cdev
copy_to_user() <linux/uaccess.h> 将内核空间数据拷贝到用户空间
copy_from_user() <linux/uaccess.h> 将用户空间数据拷贝到内核空间

4.7 本章小结

  • 字符设备驱动的核心是设备号 + file_operations + cdev 三要素。
  • 设备号分为主设备号(驱动标识)和次设备号(设备实例标识),推荐使用alloc_chrdev_region动态分配。
  • file_operations结构体中的open、release、read、write是最常用的回调函数。
  • 用户空间与内核空间的数据交换必须使用copy_to_user/copy_from_user,不能直接解引用用户指针。
  • 驱动加载和卸载必须成对操作,注意资源回滚和释放顺序。
课后练习:
  1. 修改驱动,支持同时打开多次(记录打开计数)。
  2. 增加.llseek实现,支持lseek系统调用定位到缓冲区任意位置。
  3. 实现一个ioctl命令,用于清空缓冲区或获取缓冲区大小。