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 的调用位置与引用计数变化:

块设备 gendisk 生命周期流程 gendisk 生命周期:alloc → add → del → put alloc_disk(minors) 分配 gendisk + 位图 设置队列/fops/容量 disk->queue, disk->fops add_disk(disk) 注册到内核子系统 设备运行 / I/O 处理 del_gendisk(disk) 移除 sysfs / 设备节点 put_disk(disk) 释放 gendisk 内存 引用计数变化 alloc: cnt=1 → add: cnt=2 del: cnt=1 → put: cnt=0(释放)
工程提示: 在 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 引用计数的变化过程,以及每个阶段对应的内核对象状态:

gendisk 引用计数变化与对象状态 gendisk 引用计数与对象状态 alloc_disk cnt=1 (驱动) add_disk cnt=2 (驱动+内核) del_gendisk cnt=1 (驱动) put_disk cnt=0 (释放) 对象状态变化 未注册 → 已注册(可被内核访问) → 已移除(不可访问) → 内存释放 驱动持有引用期间,必须确保 gendisk 指针有效 ⚠ 常见泄漏:调用 del_gendisk 后忘记 put_disk,导致 gendisk 内存泄漏 泄漏后果:/proc/partitions 残留、设备号无法重用
调试技巧: 使用 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 验证资源释放的正确性。