资讯详情

Linux内核gendisk结构体深度解析:块设备驱动开发指南

📅 2026/10/10 9:37:55 | 华诺云谱 👁 阅读
Linux内核gendisk结构体深度解析:块设备驱动开发指南
很多人第一次在 Linux 内核里碰struct gendisk是在写块设备驱动或者读genhd.c的时候。我当时比较典型的一个经历是做一个用内存模拟硬盘的小驱动数据页、读写回调都写好了insmod之后却怎么都看不到/dev/vdsk。查了半天才发现问题压根不在 IO 路径上而是 gendisk 虽然分配了却没走add_disk()内核根本不知道这块“盘”存在。所以如果你正在学 Linux 内核、写块设备驱动、或者想弄明白nvme、sd、loop这些驱动底层那个“磁盘对象”到底是什么从struct gendisk入手是对的它撑起了整个块设备层的“户口本”角色。这篇文章不会把include/linux/genhd.h从头注释到尾而是按我实际开发、排障的顺序来先讲清楚 gendisk 在块设备层里到底代表什么再把关键字段逐个拆开讲它们和 sysfs、设备号、IO 调度是怎么挂钩的接着用一段最小驱动代码把注册生命周期串起来最后聊聊热插拔、容量变更和泄漏排查这种容易被忽略、但线上最容易翻车的地方。1. gendisk 在内核里的定位它到底代表了哪一层的什么设备1.1 从设备号开始理解 gendiskLinux 内核里的块设备block device和字符设备一样靠dev_t来区分也就是我们常说的主设备号 次设备号。struct gendisk的核心使命之一就是管理一组连续的dev_t它用major记录主设备号用first_minor记录起始次设备号用minors记录自己能占用的次设备号数量。也就是说一个 gendisk 不是“一个设备节点”而是一组设备节点的拥有者。你去查看/dev/sda、/dev/sda1、/dev/sda2它们背后其实都指向同一个 gendisksda本身由 partition 0 代表内核里叫part0sda1、sda2则是挂在这个 gendisk 分区表下面的分区对象。这个设计初看有点绕但只要理解“盘”和“分区”在驱动看来是两个层次就能明白为什么需要一个 gendisk 把两者打包底层硬件关注的是整块盘的 IO而上层应用和文件系统关注的是某个分区的 IO。gendisk 就是这两者之间的桥它既提供了整盘粒度的信息容量、磁盘名、请求队列也通过part0和分区表把各个分区串起来。1.2 它和 block_device、part0、request_queue 的分工很多人会把gendisk和block_device搞混这两个结构体其实分工完全不同struct block_device代表一个已打开或者已挂载的块设备实例。同一个物理盘可以被 opens 多次每次都会引用同一个 block_device 对象但它更偏向“文件操作”那一层。struct gendisk代表一块真实或虚拟的磁盘是驱动和内核块层之间的“磁盘实体”。它的生命周期由驱动负责分配和释放。struct request_queue块设备的 IO 调度入口。gendisk 内部持有queue指针所有发往这个盘的 bio 最终都会进到这个队列。在比较新的内核里gendisk里有一个part0字段类型是struct hd_struct __rcu *这个part0就是整块磁盘本身。为什么要单独搞一个hd_struct因为内核统一用hd_struct表示“一个可被调度的分区单元”整块盘在分区表语境下就是分区 0。这样处理有一个很实在的好处分区统计、IO 计数、sysfs 属性这些逻辑不用为“整盘”和“分区”各写一套。你在/sys/block/sda/sda1/stat看到的分区 IO 计数和/sys/block/sda/stat看到的整盘计数内部走的是同一套hd_struct统计机制只是整盘对应的hd_struct是part0。用生活化的方式理解gendisk 就像小区的大门口part0是小区本身sda1、sda2是小区里的楼栋。你寄快递可以写“XX小区”part0也可以写“XX小区XX栋”sda1但快递员IO 请求最终都从小区大门进来。gendisk 管理的就是大门和楼栋登记表。1.3 从源码角度扫一眼 gendisk 的关系网我把struct gendisk里比较核心的字段按关系分类整理成一张表方便对照着看类别字段作用设备号major,first_minor,minors确定这个盘在/dev下占据哪些设备号标识disk_name就是sda、nvme0n1这类名字sysfs 下也会用到容量capacity以 512 字节扇区为单位的磁盘容量操作表fopsblock_device_operations包含 open、release、getgeo 等回调IO 队列queue指向request_queueIO 请求从这里流入私有数据private_data驱动自己的数据指针通常指向设备的自定义结构体分区part0,part_tblpart0 表示整盘part_tbl 管理和各个分区的对应关系事件ev,events处理磁盘热插拔、介质变更等 uevent内核对象disk_dev对应/sys/block/sda这个 device 对象驱动里最常见的模式就是disk-private_data my_dev;然后在 IO 回调里再container_of或者直接强转拿回自己的设备结构体。fops和queue这两个字段是 gendisk 和上层文件系统、下层驱动交互的两个大门后续章节会细讲。2. 关键字段逐个拆设备号、盘名、容量、fops 和 queue2.1 设备号相关的三个字段major、first_minor、minors这三个字段看起来简单但决定了一个 gendisk 能提供多少分区节点。老派做法是驱动自己向内核注册主设备号比如register_blkdev(major, name)然后把major填进 gendisk。first_minor通常从 0 开始minors则决定了这个盘最多能有多少个次设备号可用。为什么要刻意限制minors因为次设备号是稀缺资源一个dev_t的次设备号范围有限而系统中可能有几十上百个磁盘每个都大手大脚地分配反而浪费。经典的sd驱动早期只给每个 gendisk 分配 16 个次设备号其中 0 表示整盘1 到 15 给分区。也就是说如果一块盘分区超过 15 个就得用额外的“扩展分区次设备号”机制来补。很多驱动只分配了 8 个甚至 1 个次设备号意味着这块盘根本不做分区支持例如很多嵌入式里直接访问整盘的驱动minors设为 1 就够了。不过在比较新的主线内核里块设备的次设备号分配方式在逐步变化minors这个概念被淡化分配方式更动态。但掌握“major first_minor minors 决定设备节点范围”这个思路在任何版本里都不过时因为 sysfs、uevent、块设备打开这些路径依然依赖dev_t来定位 gendisk。2.2 描述“这个盘是谁”的字段disk_name、fops、private_datadisk_name是一个最长 32 字节的字符串DISK_NAME_LEN它就是这块盘的用户态名字。你在lsblk里看到的sda、nvme0n1很多就是从disk_name来的。驱动注册时通常得自己把它填好比如snprintf(disk-disk_name, DISK_NAME_LEN, vdsk%d, idx)。这个名字还会出现在/sys/block/下面所以它是用户在用户态识别一块盘最直接的标识。fops字段指向struct block_device_operations这个结构体是块设备驱动的“文件操作表”常见成员包括open(struct block_device *bdev, fmode_t mode)设备被打开或挂载时调用常用于热插拔检测和引用计数。release(struct gendisk *gd, fmode_t mode)最后一次关闭时调用。getgeo返回磁盘的几何参数柱面、磁头、扇区。ioctl处理设备相关 ioctl。submit_bio在部分新框架里直接处理 bio不过这个通常还是在队列或request_fn层面处理。private_data就是驱动自己的数据出口。比如你有一个struct vdisk_dev这样的结构体里面存着内存页指针、设备状态、互斥锁等那么disk-private_data vdisk;之后在fops-open或者 IO 回调里用disk-private_data就能拿回来。2.3 大小和状态相关capacity、flags、part0capacity这个字段坑过很多人因为它的单位不是字节也不是 1KB而是“512 字节扇区”。也就是说一块 1GiB 的盘capacity应该填2 * 1024 * 10242097152代表 2097152 个 512 字节扇区。内核里大量代码直接用sector_t做偏移计算而bio的bi_sector也是这个单位所以驱动里必须保持统一。如果硬件设备逻辑块大小是 4096 字节你说capacity bytes / 512其实没问题因为 4096 字节等于 8 个扇区但如果理解错了把容量当字节填进去上层分区工具一看容量不对文件系统也可能直接拒绝挂载。flags字段控制 gendisk 的行为开关例如表示“可移动介质”的GENHD_FL_REMOVABLE、表示“介质变更需要检测”的GENHD_FL_MEDIA_CHANGE。现代内核里这些标志也在收敛但观念上还是一个“这个盘支持哪些特殊行为”的开关集合。part0和part_tbl负责把分区信息组织起来。part0不是独立的 gendisk 之外的东西它是挂在 gendisk 下面的“零号分区”负责承载整个盘的计量和 sysfs 目录。part_tbl是一个用 RCU 保护的分区列表/sys/block/sda/sda1就是遍历这个表生成出来的。理解 RCU 很重要因为分区表可能在你正在读写的时候被重扫内核不能因为洗仓单就阻塞其他 IO。3. 磁盘注册全流程从 alloc_disk 到 add_disk 我都踩过哪些点3.1 先分清 alloc_disk 系与 blk_mq_alloc_disk 系创建 gendisk 最传统的方式是alloc_disk(int minors)同时还有一个alloc_disk_node(int minors, int node)区别是后者可以在指定 NUMA node 上分配内存。老的内核里alloc_disk会顺带分配request_queue的部分结构但驱动通常还是要自己初始化队列。后来代码演进出现了blk_mq_alloc_disk、blk_alloc_disk这种“一步到位”的接口。它们把request_queue和 gendisk 打包创建这在 blk-mq 时代更省心你传入 tag_set、操作回调参数返回的 gendisk 自带了完整的队列。两种方式的本质区别在于alloc_disk偏向“我先建盘再自己配队列”blk_mq_alloc_disk偏向“队列和盘一起初始化后面直接 add_disk ”。我建议新写的块设备驱动尽量走 blk-mq 那套接口毕竟现在内核里传统的request_fn机制已经越来越少见了。不过学习阶段用传统接口也很值得它结构更清晰不容易把队列和盘的初始化混在一起。分配之后不管哪种方式都需要检查返回值gd alloc_disk(16); if (!gd) { return -ENOMEM; } strcpy(gd-disk_name, vdsk); gd-major major; gd-first_minor 0; gd-fops vdsk_fops; gd-private_data vdisk; set_capacity(gd, nsectors);这里有一个常见误区很多人以为分配完 gendisk 就立刻能用其实这时它还没“挂上网关”。alloc_disk只是把一段内存初始化为一个 gendisk 对象用户态完全感知不到它。必须等到add_disk()或新版本的device_add_disk()调用之后这个盘才会被内核块层接受才有 sysfs 节点和/dev设备文件。如果分配了一直不 add盘就只是个“幽灵盘”。3.2 add_disk / device_add_disk 的触发链路add_disk()在过去和现在都是注册流程里的“户口登记”动作。新版本内核里推荐用device_add_disk(struct device *parent, struct gendisk *disk)区别在于它把 gendisk 挂到某个 device 父节点下面这样这个盘能出现在设备模型里正确的位置比如 USB 磁盘会自动挂到 USB 设备下。这个函数内部做的事情非常多我梳理过至少有这几件把 gendisk 的disk_dev注册到设备模型生成/sys/block/命名的目录结构。建立 gendisk 与request_queue的关联让磁盘能正确接收 bio。如果磁盘支持分区则触发分区表扫描生成sda1、sda2这样的分区对象。发 uevent告诉用户态的 udev 有新的块设备出现udev 根据规则创建/dev下对应的设备节点和软链接。所以如果你在驱动里 add_disk 之后马上打开/dev/vdsk大多数情况下已经可以了因为 udev 处理事件也需要时间比较稳妥的做法是用udevadm settle等事件队列清空。我自己踩过的坑是在device_add_disk之前忘了设置disk-queue或者容量导致后续一访问就触发内核 WARN甚至直接 oops。而set_capacity必须放在 add_disk 之前才有效因为 add_disk 时内核会读取capacity生成磁盘 size 属性之后你再改用户态看到的大小可能还是旧的。3.3 del_gendisk 与 put_disk下线不等于释放有创建就有销毁销毁流程比创建更容易犯错。核心是del_gendisk(struct gendisk *disk)和put_disk(struct gendisk *disk)的配合。del_gendisk()的作用是把这块盘从系统中“下线”包括撤销 sysfs 目录、删除分区表、发送移除事件的 uevent以及让后续新的open请求失败。但它不等于释放 gendisk 内存因为可能还有别的内核模块或者用户进程正握着这个 block_device 的引用。真正把引用计数降零的是put_disk()如果引用计数没归零put_disk只是在内部把计数器减一内存并不会立刻回收。正确的卸载顺序通常是del_gendisk(disk); blk_cleanup_queue(disk-queue); // 如果队列是独立管理的按顺序清理 put_disk(disk); // 最后释放你自己分配的设备结构体、缓冲区等有个容易出问题的点很多驱动代码是先blk_cleanup_queue再del_gendisk这在有些场景下会导致 IO 路径还在跑队列已经被清理了然后触发空指针。正确逻辑是先把盘“停掉”del_gendisk让新 IO 无法进来再清理队列最后释放 gendisk。如果驱动里还有未完成 IO得先做同步或等待不要硬砍。另外别忘了del_gendisk之后如果用户空间的某个进程仍然用open()的 fd 挂载着文件系统put_disk很可能不会触发真正的释放盘对象会等到最后一个引用被释放才消失。这时候你rmmod会卡住等下第 6 章专门讲这种情况怎么排查。4. 驱动实战用内存模拟一块 64MB 磁盘的 gendisk 用法4.1 设计思路数据页 gendisk request_queue理论学习再多不如直接写一个能跑的驱动。这里我用最简单的设计分配一个 64MB 的内存 buffer 当作磁盘存储然后注册一个 gendisk让所有 bio 请求都直接读写这块 buffer。这个模型其实就是 ramdisk 的简化版很多嵌入式里做内存盘、模拟存储卡、甚至测试新块设备框架的驱动都是这种思路。逻辑上三层结构第一层一个struct vdisk_dev结构体保存 buffer 指针、大小、互斥锁、gendisk 指针等。第二层struct gendisk负责内核块层和用户态之间的“身份”登记。第三层struct request_queue负责承接 IO 请求。在这个例子里我们用 blk-mq 的 submit_bio 路径也可以封装一个最简单的 request_fn。我会用比较现代的blk_mq_alloc_disk来做因为它能直接得到带队列的 gendisk省掉手搓队列的环节。4.2 核心代码骨架#include linux/module.h #include linux/blkdev.h #include linux/hdreg.h #define VDISK_SECTOR_SIZE 512 #define VDISK_SECTORS 131072 /* 64MB */ struct vdisk_dev { void *data; struct gendisk *disk; struct blk_mq_tag_set tag_set; sector_t nsectors; spinlock_t lock; }; static struct vdisk_dev *vd; static blk_status_t vdisk_submit_bio(struct bio *bio) { struct vdisk_dev *dev bio-bi_disk-private_data; sector_t sector bio-bi_iter.bi_sector; unsigned int len bio-bi_iter.bi_size; unsigned long offset sector * VDISK_SECTOR_SIZE; if ((offset len) (dev-nsectors * VDISK_SECTOR_SIZE)) { bio_io_error(bio); return BLK_STS_IOERR; } void *dst (void *)((char *)dev-data offset); struct bvec_iter iter; struct bio_vec bvec; bio_for_each_segment(bvec, bio, iter) { char *buf page_address(bvec.bv_page) bvec.bv_offset; if (bio_data_dir(bio) READ) memcpy(buf, dst, bvec.bv_len); else memcpy(dst, buf, bvec.bv_len); dst bvec.bv_len; } bio_endio(bio); return BLK_STS_OK; } static const struct block_device_operations vdisk_fops { .owner THIS_MODULE, }; static int __init vdisk_init(void) { vd kzalloc(sizeof(*vd), GFP_KERNEL); if (!vd) return -ENOMEM; vd-data vzalloc(VDISK_SECTORS * VDISK_SECTOR_SIZE); if (!vd-data) { kfree(vd); return -ENOMEM; } vd-nsectors VDISK_SECTORS; spin_lock_init(vd-lock); vd-tag_set.ops NULL; /* 这里按实际 blk-mq 接口填简述起见省略 */ /* vd-tag_set 初始化 blk_mq_alloc_tag_set */ vd-disk blk_mq_alloc_disk(vd-tag_set, NULL, vdisk_fops); if (IS_ERR(vd-disk)) { vfree(vd-data); kfree(vd); return PTR_ERR(vd-disk); } vd-disk-private_data vd; strcpy(vd-disk-disk_name, vdsk); set_capacity(vd-disk, vd-nsectors); device_add_disk(NULL, vd-disk); return 0; } static void __exit vdisk_exit(void) { if (vd-disk) { del_gendisk(vd-disk); put_disk(vd-disk); } vfree(vd-data); kfree(vd); } module_init(vdisk_init); module_exit(vdisk_exit); MODULE_LICENSE(GPL);这个骨架故意省略了 tag_set 的完整初始化是因为 blk-mq 的tag_set配置要对齐你内核版本的具体接口真正写驱动时得看include/linux/blk-mq.h。但核心思想已经完整呈现blk_mq_alloc_disk返回一个带队列的 gendiskprivate_data指向虚拟盘结构体submit_bio回调直接搬运数据最后device_add_disk让盘在系统里可见。注意我用了bio-bi_disk-private_data来拿盘私有数据这是因为bio本身就携带了目标磁盘指针。很多驱动新手不知道这一点还会去全局变量里找其实 gendisk 就是 bio 和 IO 请求的“目的地身份证”。4.3 add_disk 之后可能出现的问题跑起来之后你大概率会遇到下面几个问题中的一个看不到设备节点如果device_add_disk没被调用/dev/vdsk根本不存在如果被调用了但 udev 规则没有匹配设备节点也可能没有但/sys/block/vdsk一定存在。排查时先用ls /sys/block/确认盘是否被内核接受。写数据没问题读回来全 0多半是我的示例里 bio 遍历那步写错了bio_for_each_segment的bvoffset和bvlen没有正确推进。调试时可以用print_hex_dump打印关键的 offset 和 len。卸载时 oops多半是exit函数里顺序不对。比如先vfree(vd-data)再del_gendisk而某个还在执行的 IO 回调访问了已释放的内存。这类问题用 KASAN 很容易定位。fdisk 显示容量不对那就是set_capacity的单位搞错了。记住capacity永远按 512 字节扇区算如果是要 1GiB填2097152不是1073741824。另外一个实际工程中会遇到的问题是device_add_disk(NULL, disk)这种写法意味着 gendisk 没有父设备虽然能跑但 sysfs 里缺少设备拓扑信息lsblk -S之类工具就看不到它的传输类型。有真实硬件时应该把 gendisk 挂到struct device *父节点下。5. 热插拔与容量变更gendisk 生命周期里最容易被忽略的两件事5.1 分区扫描add_disk 之后sda1 是怎么冒出来的add_disk或device_add_disk之后内核会调用分区扫描逻辑。它会读取磁盘上分区表比如 GPT 或 MBR然后为每个分区创建对应的hd_struct再生成sda1、sda2这种分区块设备。分区扫描的入口路径是bdev_disk_changed()它本质上是重新检查磁盘内容并更新分区表同时给part_tbl做 RCU 更新。这个机制带来的坑是如果你自己模拟出来的磁盘 buffer 里一开始全是 0那么它没有合法分区表add_disk后就只有一个整盘节点没有分区节点。这很正常。但如果你先在外部写了 MBR/GPT 到 buffer然后才add_disk分区节点一般会自动生成。如果你在add_disk之后再去更新 buffer 里的分区表内核不会自动重扫需要手动触发。用户态通常用blockdev --rereadpt /dev/vdsk或者调用 ioctlBLKRRPART。内核会重新走一遍rescan_partitions/bdev_disk_changed把新分区节点补上。在驱动里如果你要主动触发重扫需要调用blk_drop_partitions或者revalidate_disk之类的接口但要注意分区重扫会导致分区块设备被删除再创建如果这时候有文件系统挂着就会出各种一致性错误。所以生产驱动里很少硬刷分区表都是通过 ioctl 或者策略让用户态决定。5.2 热插拔的合规顺序先把门关上再拆墙热插拔设备USB 磁盘、NVMe 热移除对 gendisk 的处理顺序很有讲究。我见过好几个驱动热拔时直接put_disk然后就崩了原因都是没走“先下线、再清理”的流程。标准顺序可以总结为停止设备上的新 IO 请求关闭硬件中断或者把它置为 offline 状态。调用del_gendisk(disk)这一步会让内核认为整个盘已经不在线后续 open 失败文件系统在访问时会返回 IO 错误。等待正在处理的 IO 请求全部完成。这个等待通常要借助blk_mq_freeze_queue等机制强制暂停队列。清理队列资源blk_cleanup_queue或者相应的 blk-mq 清理。调用put_disk放掉最后引用。释放驱动自己的内存、寄存器、中断资源。为什么不能直接put_disk因为put_disk只是减引用计数如果内核某个模块正拿着 gendisk 的指针内存就不会被释放但盘已经被标记成“死亡”后续访问可能空指针。合理的流程是先del_gendisk把门关上让所有外部路径都感知到盘没了再慢慢拆内部资源。还有一点容易被忽视热插拔要发 uevent。del_gendisk在底层会生成KOBJ_REMOVE事件udev 才能把/dev/sda之类节点删掉。如果驱动自己单独搞一套设备模型而没有正确触发这个事件最后就会出现“内核里盘没了/dev 节点还在”的灵异现象用户 open 旧节点反而得到不存在设备的错误。5.3 容量变更改 capacity 之后为什么用户态毫无反应虚拟磁盘、动态扩容、在线调整 LUN 容量都会遇到容量变更的场景。最粗浅的做法是set_capacity(disk, new_sectors);然后你以为用户态fdisk、df就会自动变实际不会。因为内核块层的容量信息被缓存到多个地方gendisk 的capacity只是最底层的数据源上层还需要重新读取一次。常见的原因是block_device的bd_inode-i_size已经根据旧容量被设置过了改 gendisk 容量不会主动同步。分区表的扇区边界还是老数据需要重扫。文件系统缓存了超级块和块组信息除非把容量变化当成卷增长通知处理否则文件系统需要工具主动扩展。所以正确的驱动做法是先改 capacity再调用revalidate_disk()或者blk_update_nr_sectors()让内核重新生成磁盘 size 和块设备 inode size。很多驱动也会发RESIZE事件给用户态例如KOBJ_CHANGEuevent这样 udev 规则和系统管理工具能感知到磁盘大小变化并触发扩展逻辑。单纯改字段而不通知是我见过的容量变更最常见的“没反应”原因。6. 调试 gendisk怎么看它的状态、怎么排查删不掉的磁盘6.1 把 gendisk 状态映射到 /proc 和 /sys内核里看不见摸不着的 gendisk在用户态其实很容易“看见”。最常用的入口cat /proc/partitions ls -l /sys/block/ ls -l /dev/sda*/proc/partitions每一行对应一个 gendisk 或分区头部的主次设备号直接映射到 gendisk 的major : first_minor。如果你看到主设备号成了 0或者盘名乱了基本说明 gendisk 没被正确注册或者已经被 del 掉了。/sys/block/vdsk/下面的size文件读取出来就是 gendisk 的capacity还是以 512 字节扇区计。比如cat /sys/block/vdsk/size返回131072那它容量就是 64MB。而/sys/block/vdsk/queue/下面会出现调度器、最大扇区数等属性它们都来自request_queue和块层限制。如果 gendisk 被del_gendisk但内存没释放你在/proc/partitions里就会看到它从列表里消失了但驱动模块可能还卡在释放流程上。那种“盘不见了但 rmmod 出不来”的现场基本就是引用计数泄漏。6.2 挂着 open 引用时 rmmod 卡死经典现场我自己遇到过一次很典型的卡死一个模拟磁盘驱动用户态有个进程一直开着/dev/vdsk的 fd然后我执行rmmod模块卸载函数走到del_gendisk之后内核一直不返回因为del_gendisk内部会等待 open 引用释放。那个用户进程不关 fd盘对象就释放不了卸载流程就卡在那。这种问题的本质是gendisk或者block_device的引用计数仍然大于 0。内核不让你把还在被使用的盘删掉是保护机制不是死锁。真正要排查的是谁占着引用lsof /dev/vdsk fuser -v /dev/vdsk如果发现有进程占着先让它退出或者kill掉再重新执行卸载。如果没有任何进程占着但模块还是卸不掉那要考虑内核内部路径是否还拿着引用。例如某些异步 IO、块层回写任务还没完成把 io 队列冻结、等待一下往往就能解开。内核调试器里可以查看struct gendisk的dkobj引用计数或者在 crash 工具里用struct gendisk *gd ...; struct block_device *bdev gd-part0-bdev; /* 部分内核版本路径可能不同 */ atomic_read(bdev-bd_openers);bd_openers是非常直观的“被打开次数”如果它不为 0说明有设备节点被 open 着。6.3 泄漏定位常用的几条命令和技巧gendisk 泄漏通常不像普通内存泄漏那么明显因为单个 gendisk 很小但它的生命周期问题会造成大麻烦。排查时我喜欢按这个顺序来先看模块状态lsmod里模块 refcnt 是否一直不为 0或者rmmod反复提示 busy。再看/proc/slabinfo里的 gendisk 对象grep gendisk /proc/slabinfo观察 allocated 数量是否在反复insmod/rmmod之后只增不减。用trace或者bpftrace抓alloc_disk/put_disk的调用栈/sys/kernel/debug/tracing/kprobe_events里挂kprobe:alloc_disk再在put_disk上挂一个 kprobe比对谁分配了没释放。在驱动 exit 函数里加引用计数校验比如用一个 atomic_t 记录 alloc/free卸载时如果计数不为零就WARN_ON顺便打印当前持有者的调用栈。如果碰到持续增长的 gendisk 对象往往不是 gendisk 本身泄漏而是block_device的bdget/blkdev_get_by_dev没配对释放。记住这个逻辑打开块设备会拿 block_device 引用而 block_device 经常把 gendisk 的引用也带上。所以你查到最后会发现问题经常出在“打开者没有正确释放”而不是 gendisk 自己的 bug。另外分享一个实用技巧在调试循环创建删除磁盘的驱动时可以在系统启动参数里加slub_debugFZPU开 SLUB 的 sanity check这样一旦有人释放后继续访问 gendisk内核会立刻报告 use-after-free定位速度会快很多。线上就不能这么干性能代价太大但开发阶段这招特别有效。写到这struct gendisk的里里外外基本都过了一遍。我个人在实际操作中的体会是gendisk 本身不复杂复杂的是它牵扯到设备模型、请求队列、分区表、引用计数这几套机制每一套单独看都不难但一旦交叉在一起就成了“看源码觉得懂一写驱动就崩”的典型结构。所以如果你正在学我建议别只读genhd.h去把block/genhd.c里的device_add_disk、del_gendisk这两条路径完整读一遍再结合本文的驱动骨架自己跑一跑效果比看十篇文章都强。最后提醒一句写任何块设备驱动前先想清楚你要不要支持分区不支持就老老实实minors 1支持分区就精心设计次设备号布局别等用户拿fdisk分区分出一堆奇怪 bug 再来后悔。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑