3. 块设备驱动注册与注销:alloc_disk、add_disk、del_gendisk、put_disk 的调用流程与注意事项
块设备驱动的生命周期管理是内核模块开发的核心环节。从分配 gendisk 对象到最终释放,每一步都涉及内核引用计数、设备号管理和请求队列绑定。错误的调用顺序或遗漏清理操作,轻则导致内存泄漏,重则引发内核崩溃或设备号冲突。本节将系统梳理四个关键 API 的调用流程与工程注意事项。
3.1 核心 API 功能与调用顺序总览
四个 API 分别对应块设备对象的“分配—注册—注销—释放”四个阶段。下表总结了每个函数的核心职责与调用时机:
| 函数 | 头文件 | 核心作用 | 调用时机 | 引用计数影响 |
|---|---|---|---|---|
alloc_disk |
<linux/genhd.h> |
分配 gendisk 结构体及 minors 位图 |
模块初始化早期,设置完请求队列后 | 初始计数 = 1 |
add_disk |
<linux/genhd.h> |
将 gendisk 注册到内核块设备子系统,创建设备节点 | 所有字段(队列、fops、容量)就绪后 | 计数 +1(内核持有引用) |
del_gendisk |
<linux/genhd.h> |
从系统中移除 gendisk,清理 sysfs 和设备节点 | 模块卸载或设备热移除时 | 计数 -1(内核释放引用) |
put_disk |
<linux/genhd.h> |
释放 gendisk 结构体占用的内存 | 所有引用释放后,通常在 del_gendisk 之后 | 计数 -1,归零时释放 |
关键原则:
add_disk 调用后,内核即认为设备可用,驱动必须在之前完成所有初始化。调用 del_gendisk 后,驱动应确保没有未完成的 I/O 请求,再调用 put_disk 释放内存。
3.2 标准调用流程与生命周期 SVG 图
下图展示了从模块加载到卸载的完整生命周期,包含四个 API 的调用位置与引用计数变化:
工程提示: 在
add_disk 之后,gendisk 的引用计数为 2(驱动持有 1,内核持有 1)。驱动必须在 del_gendisk 之后调用 put_disk 释放自己的引用,否则会导致内存泄漏。
3.3 典型代码实现与字段设置
以下代码片段展示了一个简单块设备驱动的初始化与清理流程,包含所有关键 API 的正确调用顺序:
#include <linux/module.h>
#include <linux/blkdev.h>
#include <linux/hdreg.h>
#include <linux/genhd.h>
static struct myblk_dev {
spinlock_t lock;
struct request_queue *queue;
struct gendisk *gd;
int major;
} dev;
static int __init myblk_init(void)
{
int ret;
/* 1. 分配请求队列 */
dev.queue = blk_alloc_queue(GFP_KERNEL);
if (!dev.queue)
return -ENOMEM;
/* 2. 分配 gendisk:指定 minors 数量(此处为 1) */
dev.gd = alloc_disk(1);
if (!dev.gd) {
blk_cleanup_queue(dev.queue);
return -ENOMEM;
}
/* 3. 设置 gendisk 字段 */
dev.gd->major = register_blkdev(0, "myblk");
if (dev.gd->major <= 0) {
put_disk(dev.gd);
blk_cleanup_queue(dev.queue);
return -EBUSY;
}
dev.gd->first_minor = 0;
dev.gd->fops = &myblk_fops;
dev.gd->queue = dev.queue;
dev.gd->private_data = &dev;
snprintf(dev.gd->disk_name, 32, "myblk");
set_capacity(dev.gd, 1024); /* 512B 扇区,共 512KB */
/* 4. 注册 gendisk 到内核 */
add_disk(dev.gd);
pr_info("myblk: initialized, major=%d\n", dev.gd->major);
return 0;
}
static void __exit myblk_exit(void)
{
/* 5. 先移除 gendisk */
del_gendisk(dev.gd);
/* 6. 释放驱动持有的引用 */
put_disk(dev.gd);
/* 7. 清理请求队列和设备号 */
blk_cleanup_queue(dev.queue);
unregister_blkdev(dev.gd->major, "myblk");
pr_info("myblk: unloaded\n");
}
module_init(myblk_init);
module_exit(myblk_exit);
MODULE_LICENSE("GPL");
常见错误: 在
add_disk 之后修改 disk->queue 或 disk->fops 是未定义行为。内核在注册时已缓存这些指针,后续修改不会生效,且可能引发竞态。
3.4 注意事项与避坑清单
以下列表总结了驱动开发中最容易忽略的细节:
- minors 参数必须正确:
alloc_disk(minors)的minors表示该设备支持的次设备号数量,必须与first_minor和分区表兼容。通常设置为 1(无分区)或 16(支持分区)。 - add_disk 后禁止修改容量: 如果需要动态调整容量,应使用
set_capacity_revalidate或revalidate_disk,而非直接赋值。 - del_gendisk 不释放驱动引用:
del_gendisk只释放内核的引用,驱动必须额外调用put_disk释放自己的引用。 - 请求队列的清理顺序: 必须在
del_gendisk和put_disk之后调用blk_cleanup_queue,因为 gendisk 内部仍可能引用队列。 - 设备号注册时机: 建议在
alloc_disk之后、add_disk之前注册主设备号,避免add_disk时设备号无效。 - 并发安全:
add_disk调用后,I/O 回调可能立即被触发。驱动必须在add_disk之前完成所有锁和状态变量的初始化。
3.5 错误路径清理对比表
在模块初始化过程中,不同阶段的失败需要不同的清理策略。下表总结了各失败点的正确清理动作:
| 失败位置 | 已分配资源 | 清理动作 | 注意事项 |
|---|---|---|---|
blk_alloc_queue 失败 |
无 | 直接返回 | — |
alloc_disk 失败 |
queue | blk_cleanup_queue |
不要调用 put_disk(未分配) |
register_blkdev 失败 |
queue, gendisk | put_disk → blk_cleanup_queue |
顺序:先 disk 后 queue |
add_disk 失败(极少) |
queue, gendisk, major | put_disk → unregister_blkdev → blk_cleanup_queue |
需要注销设备号 |
最佳实践: 推荐使用
goto 错误处理模式,将清理逻辑集中到一处,避免重复代码。内核源码中大量采用此模式,例如 drivers/block/null_blk/main.c。
3.6 引用计数与内存管理 SVG 图
下图展示了 gendisk 引用计数的变化过程,以及每个阶段对应的内核对象状态:
调试技巧: 使用
cat /proc/partitions 检查设备是否残留;使用 kmemleak 检测 gendisk 内存泄漏。在 del_gendisk 后若 /proc/partitions 仍显示该设备,说明 put_disk 未被调用。
3.7 总结
块设备驱动的注册与注销必须严格遵循 alloc_disk → add_disk → del_gendisk → put_disk 的顺序。每个 API 都有明确的引用计数语义和资源管理职责。驱动开发者应特别注意:
add_disk之后设备立即可用,所有初始化必须在此之前完成。del_gendisk只移除内核视角的设备,驱动必须额外调用put_disk释放内存。- 错误路径的清理顺序与成功路径相反,推荐使用
goto模式集中管理。 - 使用
/proc/partitions和kmemleak验证资源释放的正确性。