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 展示了字符设备从分配设备号到最终释放的完整生命周期,包含关键函数调用顺序和状态转换。
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 从内核移除设备,必须在释放设备号之前调用。
- 严格遵循 分配 → 初始化 → 添加 → 运行 → 删除 → 释放 的生命周期顺序,避免竞争条件和内存非法访问。