4. cdev 接口详解:cdev_init、cdev_add、cdev_del、设备号分配与释放

在 Linux 字符设备驱动中,cdev(character device)是内核用于管理字符设备的核心数据结构。驱动开发者通过 cdev_init、cdev_add、cdev_del 三个接口完成字符设备的注册与注销,同时必须配合设备号的分配与释放。本章将逐一拆解这些接口的语义、参数、调用时机及常见陷阱。

核心原则:设备号是用户空间访问驱动的“门牌号”,cdev 是内核中驱动功能的“登记簿”。先分配设备号,再初始化并添加 cdev,顺序不可颠倒。

4.1 设备号:主设备号与次设备号

设备号由 主设备号(major) 和 次设备号(minor) 组成,类型为 dev_t(32 位无符号整数,高 12 位为主设备号,低 20 位为次设备号)。

概念 说明 取值范围
主设备号 标识设备对应的驱动程序 0~4095(12 位)
次设备号 标识同一驱动管理的不同设备实例 0~1048575(20 位)
dev_t 内核中合并后的设备号类型 32 位无符号整数

4.1.1 设备号分配方式

内核提供两种分配策略:静态分配(手动指定)和 动态分配(内核自动分配)。

  • 静态分配:使用 register_chrdev_region(dev_t from, unsigned count, const char *name),需提前知道可用的主设备号(通常从 /proc/devices 或内核文档中查询)。
  • 动态分配:使用 alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name),内核自动分配未使用的主设备号,推荐用于大多数驱动。
推荐实践:优先使用动态分配 alloc_chrdev_region,避免主设备号冲突。静态分配仅用于已知的、固定的设备号场景(如早期驱动或特定硬件平台)。

4.1.2 设备号释放

无论使用哪种分配方式,在驱动卸载时都必须调用 unregister_chrdev_region(dev_t from, unsigned count) 释放设备号。

// 动态分配示例
dev_t dev_num;
int ret;

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("Allocated device number: major=%d minor=%d\n",
       MAJOR(dev_num), MINOR(dev_num));

// 卸载时释放
unregister_chrdev_region(dev_num, 1);

4.2 cdev 结构体与初始化

struct cdev 是内核用于描述字符设备的对象,定义在 <linux/cdev.h> 中。核心字段包括:

  • kobject kobj:内核对象基类,用于引用计数和 sysfs 管理。
  • struct module *owner:指向所属模块,通常设为 THIS_MODULE。
  • const struct file_operations *ops:文件操作函数指针表,驱动核心逻辑所在。
  • dev_t dev:设备号。
  • unsigned int count:关联的次设备号数量。

4.2.1 cdev_init 函数

cdev_init(struct cdev *cdev, const struct file_operations *fops) 用于初始化 cdev 结构体,建立 cdev 与文件操作表的关联。

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

cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;  // 显式设置 owner
注意:cdev_init 不会设置 owner 字段,必须在初始化后手动赋值 cdev->owner = THIS_MODULE,否则模块卸载时可能导致内核崩溃。

4.3 cdev_add:将设备注册到内核

cdev_add(struct cdev *cdev, dev_t dev, unsigned count) 将初始化后的 cdev 对象添加到内核的设备管理链表中,使设备对用户空间可见。

参数 含义 注意事项
cdev 已初始化的 cdev 指针 必须已调用 cdev_init
dev 起始设备号(dev_t 类型) 必须已分配
count 注册的次设备号数量 通常为 1,可注册多个连续次设备
// 完整注册流程
dev_t dev_num;
struct cdev my_cdev;
int ret;

// 1. 分配设备号
ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
if (ret < 0) goto err_alloc;

// 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) {
    unregister_chrdev_region(dev_num, 1);
    goto err_add;
}

// 成功
printk("Device registered: major=%d minor=%d\n",
       MAJOR(dev_num), MINOR(dev_num));
关键点:cdev_add 一旦成功,内核立即可以调用 cdev 中的 file_operations 函数。因此,必须在 cdev_add 之前完成所有初始化工作(包括硬件初始化、内存分配等),避免竞争条件。

4.4 cdev_del:从内核移除设备

cdev_del(struct cdev *cdev) 从内核中移除 cdev 对象,之后设备将不再对用户空间可见。通常在驱动卸载函数中调用。

// 卸载流程
cdev_del(&my_cdev);                 // 1. 移除 cdev
unregister_chrdev_region(dev_num, 1); // 2. 释放设备号
顺序要求:必须先调用 cdev_del 再释放设备号。如果先释放设备号,cdev 中仍持有已释放的设备号引用,可能导致内核访问非法内存。

4.5 完整生命周期流程图

以下 SVG 展示了字符设备从分配设备号到最终释放的完整生命周期,包含关键函数调用顺序和状态转换。

字符设备 cdev 生命周期流程图 字符设备 cdev 生命周期流程图 alloc_chrdev_region cdev_init + 设置 owner cdev_add 设备运行(用户空间可访问) cdev_del unregister_chrdev_region 关键顺序:分配设备号 → cdev_init → cdev_add → 运行 → cdev_del → 释放设备号

4.6 常见错误与避坑指南

错误场景 后果 正确做法
cdev_add 后未检查返回值 设备注册失败但驱动认为成功,后续操作崩溃 始终检查 cdev_add 返回值,失败时回滚设备号
忘记设置 cdev->owner 模块卸载时内核崩溃 在 cdev_init 后显式赋值 cdev->owner = THIS_MODULE
先释放设备号再调用 cdev_del cdev 持有非法设备号引用,内核 panic 严格遵循:先 cdev_del,再 unregister_chrdev_region
cdev_add 后立即卸载模块 用户空间可能正在访问设备,导致 use-after-free 使用引用计数或确保无打开文件句柄时再卸载
静态分配使用已被占用的主设备号 注册失败,驱动无法加载 优先使用动态分配,或查询 /proc/devices 确认可用号

4.7 完整驱动模板(字符设备骨架)

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

static dev_t dev_num;
static struct cdev my_cdev;

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

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

static ssize_t my_read(struct file *filp, char __user *buf,
                       size_t len, loff_t *off) {
    return 0; // 实际实现省略
}

static ssize_t my_write(struct file *filp, const char __user *buf,
                        size_t len, loff_t *off) {
    return len; // 实际实现省略
}

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, "my_device");
    if (ret < 0) {
        printk(KERN_ERR "alloc_chrdev_region failed\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 "cdev_add failed\n");
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }

    printk("my_device loaded: 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("my_device unloaded\n");
}

module_init(my_init);
module_exit(my_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Linux Driver Expert");
MODULE_DESCRIPTION("Character device skeleton with cdev interface");
工程要点:上述模板是字符设备驱动的最小骨架。实际项目中,my_read/my_write 需要实现数据拷贝(copy_to_user/copy_from_user),并考虑并发访问保护(互斥锁或自旋锁)。cdev 接口本身不提供同步机制,需要驱动开发者自行实现。

4.8 本章小结

  • 设备号是用户空间与驱动通信的标识,使用 alloc_chrdev_region 动态分配,unregister_chrdev_region 释放。
  • cdev_init 初始化 cdev 结构体并绑定 file_operations,之后必须手动设置 owner。
  • cdev_add 将设备注册到内核,成功后设备立即可用,必须在所有初始化完成后调用。
  • cdev_del 从内核移除设备,必须在释放设备号之前调用。
  • 严格遵循 分配 → 初始化 → 添加 → 运行 → 删除 → 释放 的生命周期顺序,避免竞争条件和内存非法访问。