2. 字符设备驱动核心数据结构

字符设备驱动的核心由三个关键数据结构支撑:struct cdev(内核中字符设备的抽象)、struct file_operations(设备操作函数表)和 dev_t(设备号类型)。理解这三者的关系与用法,是编写字符设备驱动的基础。

2.1 设备号:dev_t

在 Linux 内核中,每个字符设备或块设备都由一个唯一的设备号标识。设备号类型为 dev_t,定义在 <linux/types.h> 中,本质上是一个 unsigned int(32 位)。

设备号分为两部分:

  • 主设备号(major number):高 12 位,标识设备对应的驱动程序。
  • 次设备号(minor number):低 20 位,由驱动程序内部使用,通常用于区分同一驱动管理的不同设备实例。
位域 宽度 含义
31 – 20 12 位 主设备号(0 – 4095)
19 – 0 20 位 次设备号(0 – 1048575)

内核提供了宏来操作 dev_t:

#include <linux/kdev_t.h>

MAJOR(dev_t dev);   // 从 dev_t 中提取主设备号
MINOR(dev_t dev);   // 从 dev_t 中提取次设备号
MKDEV(int major, int minor); // 将主、次设备号组合为 dev_t
提示: 在驱动中,通常使用 alloc_chrdev_region() 动态分配设备号,避免与已有设备号冲突。静态指定主设备号仅适用于实验或已知未占用的场景。

2.2 设备操作函数表:struct file_operations

struct file_operations 是驱动与 VFS(虚拟文件系统)之间的接口。它定义在 <linux/fs.h> 中,包含一组函数指针,每个指针对应一个系统调用(如 open、read、write、release)。

驱动开发者只需实现需要的操作,其余置为 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 *) 从设备读取数据
write ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *) 向设备写入数据
unlocked_ioctl long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long) 设备控制命令(无大内核锁)
llseek loff_t (*llseek)(struct file *, loff_t, int) 修改文件读写位置
关键点: read 和 write 的缓冲区参数带有 __user 标记,表示用户空间地址,必须使用 copy_to_user() / copy_from_user() 进行安全访问,不能直接解引用。

2.3 字符设备内核对象:struct cdev

struct cdev 是内核中表示字符设备的核心结构体,定义在 <linux/cdev.h>。它将设备号与 file_operations 绑定在一起,并注册到 VFS 层。

struct cdev {
    struct kobject kobj;          // 内核对象基类,用于引用计数和 sysfs
    struct module *owner;         // 所属模块,通常设为 THIS_MODULE
    const struct file_operations *ops; // 设备操作函数表
    struct list_head list;        // 用于内核内部链表管理
    dev_t dev;                    // 设备号
    unsigned int count;           // 关联的次设备数量
};

操作 struct cdev 的常用 API:

  • cdev_alloc():动态分配一个 cdev 对象。
  • cdev_init(struct cdev *, struct file_operations *):初始化 cdev 并绑定操作函数表。
  • cdev_add(struct cdev *, dev_t, unsigned int):将设备添加到系统,使其可被用户空间访问。
  • cdev_del(struct cdev *):从系统中移除设备。
注意: cdev_add() 调用后,设备立即可被用户空间打开。因此,必须在 cdev_add() 之前完成所有初始化工作,包括设备号分配和硬件初始化。否则可能导致竞争条件。

2.4 三者关系:SVG 架构图

下图展示了 dev_t、struct cdev 和 struct file_operations 之间的关联,以及它们如何与 VFS 层交互:

字符设备驱动核心数据结构关系图 dev_t 主设备号 (12 bit) 次设备号 (20 bit) struct cdev dev: dev_t ops: file_operations * owner: THIS_MODULE file_operations .open, .read, .write .release, .unlocked_ioctl 包含 指向 VFS 层 inode / dentry / file cdev_add() 注册 用户空间 系统调用接口 说明 用户空间通过设备文件 访问驱动,VFS 根据 cdev 找到 ops 并调用

2.5 典型注册流程代码片段

以下代码展示了如何使用上述三个核心数据结构完成字符设备的注册:

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

static dev_t my_dev_num;
static struct cdev my_cdev;

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

static int my_release(struct inode *inode, struct file *filp) {
    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(&my_dev_num, 0, 1, "my_device");
    if (ret < 0) {
        pr_err("Failed to allocate device number\n");
        return ret;
    }
    pr_info("Allocated dev_t: major=%d minor=%d\n",
            MAJOR(my_dev_num), MINOR(my_dev_num));

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

    // 3. 将 cdev 添加到系统
    ret = cdev_add(&my_cdev, my_dev_num, 1);
    if (ret < 0) {
        pr_err("Failed to add cdev\n");
        unregister_chrdev_region(my_dev_num, 1);
        return ret;
    }

    pr_info("Character device registered successfully\n");
    return 0;
}

static void __exit my_exit(void) {
    cdev_del(&my_cdev);
    unregister_chrdev_region(my_dev_num, 1);
    pr_info("Character device unregistered\n");
}

module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
流程总结: 分配设备号 → 初始化 cdev 并绑定 fops → 调用 cdev_add() 注册到内核 → 在 exit 中逆序释放。

2.6 常见错误与避坑

  • cdev_add() 失败后未释放设备号: 必须调用 unregister_chrdev_region(),否则设备号泄漏。
  • file_operations 中的 .owner 未设置: 可能导致模块引用计数错误,在操作进行中模块被卸载。
  • 在 cdev_add() 之后才初始化硬件: 用户空间可能在硬件未就绪时打开设备,导致崩溃。
  • 混淆主设备号与次设备号范围: 主设备号 0 为动态分配保留,静态指定时应使用 1-255 范围内的未占用号。