本文直接基于
6.18.33.2-microsoft-standard-WSL2内核源码树撰写。涉及的核心文件:include/linux/types.h、include/linux/kdev_t.h、include/uapi/linux/kdev_t.h、include/linux/fs.h、include/uapi/linux/major.h、fs/char_dev.c、include/linux/cdev.h、include/linux/kobj_map.h、drivers/base/map.c、fs/inode.c、fs/namei.c、mm/shmem.c、fs/ramfs/inode.c、fs/stat.c、fs/proc/devices.c、drivers/base/devtmpfs.c。
ls -l
在 Linux 上随便看一眼 /dev,会看到一类“文件”长得非常奇怪:
|
|
第一个字母 c 是字符设备(character device),b 是块设备(block device)。
普通文件的位置上不再是文件大小,而是两个逗号隔开的数字——主设备号(major number)和
从设备号(minor number)。/dev/null 的 1, 3 表示“主号 1、从号 3”。
这两个数字是整个 Linux 设备驱动体系的路由地址:没有它们,open("/dev/null") 就不知道要把
这次打开操作交给内核里的哪个驱动函数。本文要回答的问题就是:
- 这两个数字在内存里到底是什么样的一个量(
dev_t)? - 内核用哪一段代码把它们“翻译”成驱动(
MAJOR/MINOR/MKDEV、cdev_map、chrdev_open)? - 驱动怎么申请/占用一段号(
register_chrdev_region/alloc_chrdev_region/cdev_add)? mknod、/proc/devices、ls -l这些用户态入口和内核的关系?
用一个 236 行不到的演示内核模块,把这些问题全部实测一遍,再回到源码逐行解释。
dev_t
首先,内核里“设备号”根本不是一个结构体,而是一个 32 位无符号整数,类型名 dev_t:
include/linux/types.h:18-21:
|
|
一个 u32 怎么装下两个数字?bit 复用:高 12 位是主号,低 20 位是从号。20 位来自内核
对“从号上限”的定义 MINORBITS(后面会看到历史原因):
|
|
例如动态分配得到的 dev_t = 250609672:
|
|
MAJOR / MINOR / MKDEV:拆包与打包
负责这个“拆包/打包”的是三个宏,定义在 include/linux/kdev_t.h:7-12:
|
|
MAJOR(dev):右移 20 位取出主号(250609672 >> 20 = 239);MINOR(dev):与0xFFFFF(1048575)相与取出从号(250609672 & 1048575 = 8);MKDEV(ma, mi):反向打包((239 << 20) | 8 = 250609672)。
同一个文件里还提供了一组“历史格式”互转函数(kdev_t.h:24-81):old_valid_dev() 判断
主/从号是否都小于 256(即能否放进老的 16 位编码);old_encode_dev / new_encode_dev /
new_decode_dev / huge_encode_dev 用于系统调用边界上的编码转换,后面讲 mknod/stat
时还要用到它们。
一个小知识:
dev_t能表达的主号其实有 12 位(0~4095),但字符设备注册时内核还额外 限制major < 512(CHRDEV_MAJOR_MAX,include/linux/fs.h:2983), 校验代码在fs/char_dev.c的__register_chrdev_region()里,后文会看到。
内核态与用户态:两套宏
有意思的是,在用户态可见的 UAPI 头文件里,还保留着一套 8 位的旧宏:
include/uapi/linux/kdev_t.h(#ifndef __KERNEL__ 分支内):
|
|
这是 Linux 2.4 时代的遗产:当时 dev_t 是 16 位,主号、从号各 8 位。2001 年 2.5 内核把
dev_t 扩成 32 位后,从号位宽从 8 涨到 20,但老宏被保留给那些“直接 include 内核头文件算
设备号”的远古用户态程序——注释里写得明明白白:
|
|
如果哪位读者在项目里看到 dev >> 8 这种写法,请立刻意识到:那是 8 位时代的老黄历,
自己写的驱动一律用 include/linux/kdev_t.h 的 20 位版本。用错了会怎样?比如主号 239
(0xEF),按 8 位算:239 >> 8 = 0,239 & 0xff = 239——主号被算成了 0,直接错乱。
而 mknod 这类系统调用在“用户态传数字”和“内核存 dev_t”之间,用的是另一组编码函数
(new_encode_dev/new_decode_dev,见后文「从 mknod 到 chrdev_open」一节)。
主号管驱动,从号管实例
为什么需要两个号?因为设备分两层组织:
- 主号:标识“谁(哪个驱动)来服务”。一个驱动通常占一个主号,
/proc/devices里 “主号 + 名字”就是这张对照表。 - 从号:标识“这个驱动下的哪一个实例/子设备”。同一个驱动经常要管很多个长得一样、 行为略不同的设备,就用从号区分。
三个经典例子:
| 同一个驱动,从号在干什么 | ||
|---|---|---|
/dev/null |
1:3 |
mem 驱动(主号 1)下几十个“哑设备”之一:null/zero/full/random…各占一个从号 |
/dev/tty0 |
4:0 |
tty 驱动(主号 4)管理所有终端,每个虚拟控制台一个从号 |
/dev/sda |
8:0 |
sd 驱动(主号 8)管理所有 SCSI 磁盘,每个磁盘及其分区各占一个从号(sda1 是 8:1,sdb 是 8:16) |
include/uapi/linux/major.h 里就躺着这些约定,比如:
|
|
文件头部还有一行注释指向权威文档:完整的保留主号分配表在 Documentation/admin-guide/devices.rst (维护者版的“设备号白皮书”,新增驱动申请静态主号前必读)。
驱动代码里怎么利用“主号选驱动、从号选实例”呢?最常见的是在 open() 里读
iminor(inode) 拿到本次打开的从号,用它做数组下标/路由——我们的实验模块就是这么做的。
主号花名册:major.h 与 /proc/devices
运行中的内核实际占用情况,看 /proc/devices(本机实测,实验前):
|
|
linux 系统一切皆文件,共有 7 种 :普通文件、目录、符号链接、字符设备、块设备、FIFO、Socket)。需要分配主设备号的设备类型”:只有 2 种(字符设备、块设备)。
- 字符设备区和块设备区数字可以重复:
1 mem(字符)和1 ramdisk(块)、7 vcs(字符) 和7 loop(块)同时存在。因为字符、块各自维护独立的注册表, 设备节点再靠c/b类型区分,dev_t数值相同也不冲突。。 - 生成逻辑在
fs/proc/devices.c:按主号从 0 到CHRDEV_MAJOR_MAX(512)逐号调用chrdev_show(),而chrdev_show()遍历的就是下一节要讲的注册表chrdevs[](fs/char_dev.c:51-61),所以/proc/devices里的“名字”其实是注册时传的name。
设备号登记处:chrdevs[]
所有字符设备号段的注册信息集中在一个全局哈希表里,位于 fs/char_dev.c:
|
|
- 一张 255 个桶的链表数组,
major % 255定桶; - 每个节点
char_device_struct描述一段连续从号:major主号、baseminor段起始从号、minorct段内从号个数、name注册名; - 链表按
major排序,桶内再按baseminor排序; - 一把全局互斥锁
chrdevs_lock保护整张表。
换句话说,“设备号注册”= 在这张表里插入一个 (major, baseminor, minorct) 段。
baseminor:这个段的起始次设备号;minorct:这个段包含的次设备号数量;
它只负责“声明占用 + 防冲突 + 供 /proc/devices 展示”,本身不提供任何打开设备的能力——
让设备能打开的是另一张表 cdev_map(见「cdev」一节)。
| 表 | 谁在维护 | 作用 |
|---|---|---|
chrdevs[](char_dev.c) |
register_chrdev_region/alloc_chrdev_region |
声明占用、段冲突检测、/proc/devices |
cdev_map(kobj_map,char_dev.c:28) |
cdev_add/cdev_del |
打开时按 (主,从) 号找驱动 fops |
chrdevs[] 的注释 struct cdev *cdev; /* will die */ 正暗示了历史演进:早期一张表
身兼两职,现在职责已经拆开,旧字段只作残留。
cd->cdev 与 “will die”
先摆证据。struct char_device_struct 定义在 fs/char_dev.c 时带 static,是文件私有
类型,全内核只有这一个文件用到;而 cd->cdev 这个指针在整个 6.18 源码里只被读写过
两处:
|
|
除此之外,打开设备(chrdev_open 只走 cdev_map 与 inode->i_cdev)、/proc/devices
展示(chrdev_show 只打印 major/name)、动态分配与冲突检测(find_dynamic_major /
__register_chrdev_region)全都读不到这个字段。
为什么需要一个“全文件只有两处用、还自称会死”的指针?因为它服务的是一种特殊的
cdev 所有权模型。现代两步式写法里,cdev 是驱动自己的东西——嵌在驱动的结构体里
(本实验的 struct demo_dev 就内嵌了 struct cdev cdev),驱动自己调 cdev_add() /
cdev_del(),注册表条目(kzalloc 清零)里的 cd->cdev 永远是 NULL,谁也不碰。
但老式一键 API register_chrdev() 的封装 __register_chrdev()(fs/char_dev.c:268-300)
走的是另一条路:它在基础设施内部 cdev_alloc() + cdev_add() 建好 cdev,驱动
从头到尾拿不到这个指针;卸载时驱动只还给内核一个主号和一个名字
(unregister_chrdev(major, name)),内核必须自己记得“这个主号段的 cdev 是我建的、
长什么样”,否则没法销毁——cd->cdev 就是记在注册记录上的“内部自建 cdev 账本指针”。
理解了所有权模型,/* will die */ 的含义就清楚了:这个字段存在的唯一理由就是服务
注定被淘汰的 register_chrdev() 家族。上游已经把老 API 收敛成一个内联包装
(include/linux/fs.h:3000),并引导驱动改走 register_chrdev_region() + cdev_add()
或驱动模型(cdev_device_add());等某天真把 register_chrdev() 移除,这个字段连同
__unregister_chrdev() 里的条件删除逻辑可以一并删掉。注释敢写 “will die”,是上游对
“过渡期残留”的官方自述——它同时提醒到:一条职责边界:注册表只管“谁占用了哪些号”,
不该管 cdev 的生命周期。这条边界正是 2.5 时代 dev_t 扩到 32 位、引入 cdev 与
分段注册时,从“一张表直接存每个主号的 name + fops”(2.4 及更早的设计)拆分出来的。
顺带一个隐患:
cd->cdev是裸指针,而注册表条目不会随模块卸载自动清理。若驱动用了 老 API 却忘了配对unregister_chrdev(),条目和它指向的 cdev 会一起泄漏——与 「坑与教训」一节的幽灵段同源,只是这边多漏一个 cdev 对象。
__register_chrdev_region():注册一段号
所有注册 API 的终点是同一个内部函数 __register_chrdev_region()(fs/char_dev.c:96-169),
它完成参数校验、冲突检测和链表插入。逐段看:
|
|
拆解:
- ① 主号校验:
major >= 512直接-EINVAL,并打印一条pr_err—— 实验 4d 里register_chrdev_region(MKDEV(512,0), 1, ...)触发,dmesg 里能看到CHRDEV "demo-toobig" major requested (512) is greater than the maximum (511)。 - ② 从号段校验:
baseminor + minorct - 1不得超过MINORMASK(1048575)。 - ⑤ 动态主号:
major == 0表示“请内核帮我挑一个空闲主号”,挑法见下一节。 - ⑥ 冲突检测:桶内链表按主号排好序。对同主号的相邻段比较区间:
- 已有段整体在请求段左边(
baseminor + minorct <= baseminor)→ 继续往后看; - 已有段整体在请求段右边(
baseminor >= baseminor + minorct)→ 到此为止, 不冲突,可以插入(同一主号下互不重叠的多段可以共存); - 否则区间重叠 →
-EBUSY。
- 已有段整体在请求段左边(
find_dynamic_major():动态主号从哪来
alloc_chrdev_region() 以 major == 0 调用注册,内核需要挑一个没人用的主号。挑法在
fs/char_dev.c:65-86:
|
|
动态主号的来源分两段(常量在 include/linux/fs.h:2983-2988):
|
|
- 第一段:从 254 往下扫到 234。注意这里直接检查哈希桶
chrdevs[i]是否为空—— 对i < 255的主号来说,桶i恰好只装“主号 ≡ i (mod 255)”的注册项,桶空基本等价于 “主号 i 空闲”,所以这是一个廉价的近似判断; - 第二段(扩展区):从 511 往下扫到 384,这次老老实实遍历桶内链表确认主号是否被占;
- 两段都满才返回
-EBUSY(注册表 235254 + 384511 全被占满时)。
方向是从高往低找。
对应到维护文档的约定:主号 234
254 与 384511 是“给驱动动态分配”的区间,其余 主号要么保留、要么静态分配(写死在 major.h / 各自驱动里)。
register_chrdev_region():固定主号与跨号拆分
公开 API register_chrdev_region()(fs/char_dev.c:200-223)允许一次申请一段
“连续 dev_t”,这段可以跨越主号边界:
|
|
核心是那个 for 循环:next = MKDEV(MAJOR(n)+1, 0) 把每次迭代截断在当前主号的末尾,
一段跨主号的请求会被自动拆成多个 (major, baseminor, minorct) 段,每段都完整经过
__register_chrdev_region() 的校验与冲突检测;失败时按已注册的子段回滚。
这个拆分带来一个容易踩的坑:“从号段越界”校验实际发生在拆分之后,所以
register_chrdev_region(MKDEV(236, 1048575), 2) 并不会返回 -EINVAL,而是被拆成
236:1048575(1 个)和 237:0(1 个)两段注册成功。真正能触发 -EINVAL
越界校验的是不做拆分的 alloc_chrdev_region(baseminor=1048575, count=2)。
对应的释放函数 unregister_chrdev_region()(char_dev.c:311-322)用同样循环按主号拆分、
逐段删除,删除时只按三元组精确匹配(__unregister_chrdev_region(),char_dev.c:171-189)——
这个“只认三元组、不认注册者”的语义后面「坑与教训」一节还会用到。
老式 register_chrdev():一键 256 从号
很多老教程里的 register_chrdev(major, name, fops) 现在连函数都不算了——它退化成
include/linux/fs.h:3000 的一个内联函数:
|
|
而 __register_chrdev()(fs/char_dev.c:268-300)是“注册号段 + 创建 cdev”的打包封装:
|
|
它一次性注册 256 个从号(0~255),这是 8 位时代的残留——当年主从号都是 8 位,
一个主号天生 256 个从号。如今从号有 20 位,一次占 256 个对多数驱动没问题,但对只需要
几个从号的驱动是浪费,也拿不到内核动态挑的主号之外的选择。所以现代驱动一律推荐用
“两步走”:alloc_chrdev_region()/register_chrdev_region() 注册号段,再
cdev_init()/cdev_add() 挂驱动。
cdev:让号段活起来
注册表只解决了“谁占了哪些号”,要让 open("/dev/xxx") 真正到达你的驱动,需要 cdev
(character device),它的核心定义在 include/linux/cdev.h:
|
|
cdev 把三个东西绑在一起:一段起始设备号 dev、覆盖的从号个数 count、
驱动操作表 ops。挂接函数 cdev_add()(fs/char_dev.c:479-504)把这段映射插入
cdev_map:
|
|
cdev_map 是全局唯一的 kobj_map(chrdev_init() 里创建,char_dev.c:674-677),
它的数据结构在 drivers/base/map.c:19-30:
|
|
又是 255 桶哈希(按 MAJOR % 255),每个 probe 记录 dev + range 区间和回调。
cdev_add 时 kobj_map(cdev_map, dev, count, ...) 把 cdev 的区间按主号哈希挂进去,
并保持同桶按 range 升序。于是 cdev_map 成了另一张“按 (主,从) 号 → cdev”的路由表。
注意此时注册表 chrdevs[] 和路由表 cdev_map 是两张独立的表:只
register_chrdev_region() 不 cdev_add(),/proc/devices 能看到名字,但打开任何该段
节点都会 -ENXIO;反过来只 cdev_add() 不注册,设备能打开,但没有冲突保护和
/proc/devices 条目。正规驱动两件事都做。
从 mknod 到 chrdev_open
现在把从 mknod 到驱动 open() 的完整链路串起来(实验模块对这条链路做了全量实测)。
第一跳:mknod 写入 i_rdev
mknod /dev/demo-fix0 c 236 0 的用户态入口是 sys_mknod,它在 fs/namei.c:4403
封装成 do_mknodat()(fs/namei.c:4346),对字符/块设备分支(fs/namei.c:4379):
|
|
用户态传进来的 dev 是编码过的 u32(new_encode_dev 格式),这里先用
new_decode_dev() 还原成内核 dev_t,再交给 vfs_mknod(),最终落到文件系统的
.mknod 操作。/dev(devtmpfs)的底层是 shmem/ramfs 的 inode 操作
(drivers/base/devtmpfs.c 里按启动配置选择),mm/shmem.c:5279 的
.mknod = shmem_mknod 一路走到 shmem_get_inode() 的默认分支(mm/shmem.c:3110-3112):
|
|
init_special_inode()(fs/inode.c:2552)干了两件关键事:
|
|
从此,struct inode(include/linux/fs.h)里多了两个东西:i_rdev(fs.h:826,存
dev_t)和 i_fop = def_chr_fops。ls -l 能显示 236, 0,正是因为 statx 系统调用
路径里有 stx_rdev_major = MAJOR(stat->rdev) / stx_rdev_minor = MINOR(stat->rdev)
(fs/stat.c:735-736)——内核把 i_rdev 拆回主/从号给用户态;老式 stat() 则是
st_rdev = new_encode_dev(...)(fs/stat.c:494)编码后返回。
第二跳:open 路由到 chrdev_open
任何字符设备节点打开时,最初执行的并不是驱动自己的 open,而是 VFS 挂上的
“通用字符打开器” def_chr_fops(fs/char_dev.c:452-455):
|
|
chrdev_open()(fs/char_dev.c:373-424)的核心逻辑:
|
|
这就是“打开路由”的真相:
- 用
inode->i_rdev(例如239:8)到cdev_map里查:kobj_lookup()找到覆盖该 设备号的probe(drivers/base/map.c:95-133),命中条件就是区间包含:if (p->dev > dev || p->dev + p->range - 1 < dev) continue;,且返回*index = dev - p->dev(map.c:118)——也就是从号在段内的偏移; - 查到的
kobj用container_of还原成struct cdev,缓存到inode->i_cdev(第二次 open 不再查表); - 用
cdev_get()把模块引用计数 +1(try_module_get,char_dev.c:348-359)—— 这就是“设备文件被打开时模块不能 rmmod”的机制(实验第 8 步验证); - 把
struct file的f_op换成驱动自己的 ops(replace_fops),再调用驱动的open()。此后该文件描述符上的 read/write 等全部直达驱动。
查不到对应 cdev(没注册、没 cdev_add 的号段)时返回 -ENXIO——用户态看到
No such device or address,这就是实验第 7 步 236:7 报错的原因。
顺带一提:查不到时
chrdev_open之前还留了一手“模块自动加载”——cdev_map初始时 每桶都挂了一个 base probe(kobj_map_init(base_probe, ...),char_dev.c:674), 兜底逻辑是request_module("char-major-%d-%d", MAJOR(dev), MINOR(dev))(char_dev.c:666-672)。如果某个驱动在 modules.alias 里声明了char-major-N-M别名,udev/modprobe 会在打开瞬间把它自动拉起来。
现代姿势:device_create + devtmpfs
上面的链路里有个繁琐环节:驱动注册了号段、挂了 cdev,还得有人去 mknod 建节点。
现代内核(udev + devtmpfs + sysfs)把这步也自动化了。关键是把设备号交给驱动模型里的
struct device(它有 devt 字段),然后 device_create() 注册设备:
|
|
device_create() 内部调用 device_add(),会发生三件事:
- 在 sysfs 的
/sys/class/<class>/<name>/下生成条目,其中dev文件内容就是"主号:从号"(用户态脚本可用它反推 mknod 参数,busybox mdev 就是这么干的); - 发出 uevent,udev 按规则设置权限、建软链接等;
- devtmpfs 监听到设备注册,直接在 /dev 下自动创建节点——不需要任何人手写 mknod。
实验里 ls -l /dev/demo-dyn* 立刻能看到 239, 8 等四个自动节点,而模块卸载时
device_destroy() 让 devtmpfs 自动删除节点。对比手动 mknod 的 /dev/demo-fix*:
rmmod 后 inode 依然存在(devtmpfs 不知道要删它),只是再打开会 -ENXIO。
两相对照,就能理解为什么现代驱动推荐 cdev_device_add()(fs/char_dev.c:544-561)
把 cdev 和 device 的生命周期绑在一起管理。
动手实验:内核模块实测
devnum_demo.c
|
|
配套文件也在 ~/devnumlab/:Makefile(标准外部模块编译),
test_devnum.sh(一键跑完整流程并输出全部观察结果),ghost_clean.ko 的源码
ghost_clean.c(用途见「坑与教训」一节)。
Makefile 全文:
|
|
编译与加载
|
|
(O) 表示模块带 taint(Out-of-tree/未签名),WSL2 上属正常现象。
dmesg:注册全程日志
insmod 后,模块 init 里编号 (1)~(6) 的实验结果全部进了内核日志:
|
|
逐条对照源码解读:
- (1) 动态主号:
alloc_chrdev_region()返回dev_t=250609672,即239:8。 为什么是 239?find_dynamic_major()从 254 往下扫,而 240~254 全被系统驱动占用, 于是第一个空闲主号是 239。注意我们故意让baseminor=8(不从 0 开始), 为了证明“从号可以任意偏移”。 - (2) 连续分配顺序:第二、第三次 alloc 依次拿到
238:0、237:0——主号从高往低 一个个发,用完立即 unregister 归还。 - (3) 固定主号:
register_chrdev_region(236:0, count=3)成功。 - (4a) 重叠段:再注册
236:1起 4 个,与已有236:0..2重叠 →-16(-EBUSY)。 这正是__register_chrdev_region()第 ⑥ 步区间比较的结果。 - (4b) 同主号不重叠段:
236:200起 1 个 → 成功(之后主动释放),证明注册表 支持同一主号下多个互不重叠的段。 - (4c) 跨主号拆分:
236:1048574起 4 个 → 成功。这段跨越主号边界,被register_chrdev_region()自动拆成236:1048574..1048575与237:0..1两段; 随后再抢237:0得到-EBUSY,反向证明第二段确实被占了。注意一次unregister_chrdev_region()也按同样逻辑把两段都释放。 - (4d)(4e) 越界:主号 512(
>= CHRDEV_MAJOR_MAX)和baseminor=1048575, count=2分别返回-22(-EINVAL)。日志里那两行CHRDEV "..." ...不是我们打印的, 是内核__register_chrdev_region()自己的pr_err(源码第 ① ② 步)。 值得一提:4e 故意用alloc_chrdev_region()而不是register_chrdev_region()—— 后者会把越界范围先拆成多个合法段(见 4c),根本走不到越界校验。 - (5)(6):两段
cdev_add让设备可打开;device_create为动态主号建了 4 个 devtmpfs 自动节点。
/proc/devices:注册表一览
|
|
236 demo-fixed、239 demo-dyn 正是注册时传给 API 的 name——/proc/devices 遍历
chrdevs[] 打印的就是它。同时能看到字符区 1 mem 与块区 1 ramdisk、7 vcs 与
7 loop 的同号共存,印证“字符/块注册表独立”。
节点对比:mknod 与 device_create
|
|
观察点:
ls -l的第 5 列就是主:从号(十进制),它来自 statx 的stx_rdev_major/minor(fs/stat.c:735-736);stat -c %t/%T是十六进制:ef = 239,b = 11;- 自动节点名字
demo-dyn0与从号8并不对应——节点编号 0 的从号是 8, 因为baseminor=8;从号 ≠ 数组下标的证明就在这里; - sysfs 的
dev文件内容就是主:从字符串,可被用户态脚本解析后反哺 mknod (mdev/udev 的常见手法); - 手动 mknod 的节点没有 sysfs 条目、没有权限管理、卸载后无人清理——这正是
device_create()要解决的问题。
读写:从号路由到驱动
|
|
每次 cat 背后的内核日志更能说明“路由”过程:
|
|
- 四个
/dev/demo-dyn*共用同一个主号 239、同一个 cdev(count=4), 驱动用iminor(inode)得到本次打开的从号,idx = iminor - baseminor换算成“段内下标”; iminor()就是从inode->i_rdev取从号(include/linux/fs.h:1130):return MINOR(inode->i_rdev);——设备号在打开链路上的最终落点就是 inode。
反例:段外小号打开失败
|
|
从号 7 既不在注册段 236:0..2、也不在 cdev_add 的区间里——chrdev_open() 里
kobj_lookup(cdev_map, inode->i_rdev, &idx) 查不到,返回 -ENXIO。
rmmod:打开的文件钉住模块
|
|
原因在源码里:chrdev_open() 查到 cdev 后调用 cdev_get() → try_module_get()
(fs/char_dev.c:353),文件打开期间模块引用计数不为零,rmmod 自然被拒。
rmmod 之后:节点去哪了
|
|
对应源码:模块 exit 里 device_destroy() 让 devtmpfs 删除自动节点;手动 mknod 的 inode
无人负责清理。dmesg 中的卸载日志:
|
|
设备号泄漏
之前,有写一段用 register_chrdev_region(MKDEV(236,1048575), 2) 做
“从号越界”测试的代码,测试完忘了 unregister。结果 rmmod 之后:
|
|
模块都卸载了,/proc/devices 里却永久残留着两条“幽灵”记录——因为模块卸载只执行
exit 函数里写的 unregister_chrdev_region(),内核不会自动清扫 chrdevs[]。
这两段号直到重启前都无法再被任何驱动使用(再注册会 -EBUSY),这就是设备号泄漏。
恢复手段:unregister_chrdev_region()只按(major, baseminor, minorct)`
三元组精确匹配,不校验注册者,所以可以写一个一次性模块“替别人”释放:
|
|
|
|
它同时带出一个知识点:“拆成几段”和“一次注册”是一对——注册用
register_chrdev_region(MKDEV(236,1048575), 2)(跨主号拆两段),释放也必须用
count=2 的同样调用,否则只清得掉第一段
类似地,还有几个常见教训:
register_chrdev()老接口一次注册 256 个从号,除非确实需要,否则别用;- 用
alloc_chrdev_region()拿动态主号时,主号是内核挑的,用户态建节点前必须把 主号传出去(/proc/devices、module param、sysfs 均可); - 从号数组下标 ≠ 从号本身——
baseminor可以是任意值,务必用iminor - baseminor做下标,我们的实验特意用baseminor=8演示这一点; - 字符、块主号表独立,同号可并存;但同一张表内冲突是绝对的,跨驱动“借号”
前先查
/proc/devices。
总结
把整条链路浓缩成一张图:
|
|
全部要点:
dev_t是一个 32 位整数:高 12 位主号、低 20 位从号(MINORBITS=20),MAJOR/MINOR/MKDEV就是拆包与打包;- 主号选驱动、从号选实例;字符、块注册表彼此独立,数字可重复;
chrdevs[](注册表)管“占用与冲突”,cdev_map(kobj_map)管“打开路由”, 两张表独立,注册 ≠ 可打开,可打开 ≠ 已注册;register_chrdev_region()会把跨主号的段自动拆分,alloc_chrdev_region()从 254 往下找空闲主号;mknod把数字存进inode->i_rdev,open经def_chr_fops.chrdev_open按 设备号查到 cdev、换成驱动 fops、再调驱动 open;- 现代驱动用
device_create()/cdev_device_add()让 devtmpfs+udev 自动建节点、 自动管理权限,手动 mknod 是“考古模式”; - 设备号是全局有限资源:注册/释放必须成对,泄漏的号段在重启前无法复用。