Linux 主从设备号

本文直接基于 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,会看到一类“文件”长得非常奇怪:

1
2
3
4
crw-rw-rw- 1 root root    1,   3  /dev/null
crw-rw-rw- 1 root root    1,   5  /dev/zero
crw-rw-rw- 1 root tty     5,   0  /dev/tty
brw-rw---- 1 root disk    8,   0  /dev/sda

第一个字母 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:

1
2
3
typedef u32 __kernel_dev_t;
...
typedef __kernel_dev_t		dev_t;

一个 u32 怎么装下两个数字?bit 复用:高 12 位是主号,低 20 位是从号。20 位来自内核 对“从号上限”的定义 MINORBITS(后面会看到历史原因):

1
2
3
4
5
6
7
dev_t (u32, 32 bit)
┌──────────────────────────┬────────────────────────────┐
│      主号 major (12 bit)  │       从号 minor (20 bit)   │
│    dev >> MINORBITS(20)   │    dev & MINORMASK(0xFFFFF) │
└──────────────────────────┴────────────────────────────┘
    理论范围 0 ~ 4095            理论范围 0 ~ 1048575
    字符驱动实际 < 512            MINORMASK = 2^20 - 1

例如动态分配得到的 dev_t = 250609672:

1
2
250609672 = 239 × 2^20 + 8
          = 239 : 8            (十进制写法:主号 239,从号 8)

MAJOR / MINOR / MKDEV:拆包与打包

负责这个“拆包/打包”的是三个宏,定义在 include/linux/kdev_t.h:7-12:

1
2
3
4
5
6
#define MINORBITS	20
#define MINORMASK	((1U << MINORBITS) - 1)

#define MAJOR(dev)	((unsigned int) ((dev) >> MINORBITS))
#define MINOR(dev)	((unsigned int) ((dev) & MINORMASK))
#define MKDEV(ma,mi)	(((ma) << MINORBITS) | (mi))
  • 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__ 分支内):

1
2
3
#define MAJOR(dev)	((dev)>>8)
#define MINOR(dev)	((dev) & 0xff)
#define MKDEV(ma,mi)	((ma)<<8 | (mi))

这是 Linux 2.4 时代的遗产:当时 dev_t 是 16 位,主号、从号各 8 位。2001 年 2.5 内核把 dev_t 扩成 32 位后,从号位宽从 8 涨到 20,但老宏被保留给那些“直接 include 内核头文件算 设备号”的远古用户态程序——注释里写得明明白白:

1
2
3
4
/*
Some programs want their definitions of MAJOR and MINOR and MKDEV
from the kernel sources. These must be the externally visible ones.
*/

如果哪位读者在项目里看到 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 里就躺着这些约定,比如:

1
2
3
4
5
6
7
8
#define UNNAMED_MAJOR		0
#define MEM_MAJOR		1
#define TTY_MAJOR		4
#define TTYAUX_MAJOR		5
#define LOOP_MAJOR		7
#define SCSI_DISK0_MAJOR	8
#define MISC_MAJOR		10
#define INPUT_MAJOR		13

文件头部还有一行注释指向权威文档:完整的保留主号分配表在 Documentation/admin-guide/devices.rst (维护者版的“设备号白皮书”,新增驱动申请静态主号前必读)。

驱动代码里怎么利用“主号选驱动、从号选实例”呢?最常见的是在 open() 里读 iminor(inode) 拿到本次打开的从号,用它做数组下标/路由——我们的实验模块就是这么做的。

主号花名册:major.h 与 /proc/devices

运行中的内核实际占用情况,看 /proc/devices(本机实测,实验前):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
Character devices:
  1 mem
  4 /dev/vc/0
  4 tty
  4 ttyS
  5 /dev/tty
  5 /dev/console
  5 /dev/ptmx
  7 vcs
 10 misc
 13 input
 ...
229 hvc
240 intel_mid_scu
241 nvme-generic
242 nvme
243 virtio-portsdev
244 bsg
245 watchdog
246 ptp
247 pps
248 rtc
249 dax
250 dimmctl
251 ndctl
252 tpm
253 pwm
254 gpiochip

Block devices:
  1 ramdisk
  7 loop
  8 sd
 11 sr
 65 sd
 ...
254 device-mapper
259 blkext

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
static DEFINE_MUTEX(chrdevs_lock);                       /* char_dev.c:30 */

#define CHRDEV_MAJOR_HASH_SIZE 255

static struct char_device_struct {                       /* char_dev.c:34 */
	struct char_device_struct *next;
	unsigned int major;
	unsigned int baseminor;
	int minorct;
	char name[64];
	struct cdev *cdev;		/* will die */
} *chrdevs[CHRDEV_MAJOR_HASH_SIZE];                      /* char_dev.c:41 */

/* index in the above */
static inline int major_to_index(unsigned major)
{
	return major % CHRDEV_MAJOR_HASH_SIZE;            /* char_dev.c:46 */
}
  • 一张 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 源码里只被读写过 两处:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
	/* fs/char_dev.c:292 —— 唯一的写入点,在 __register_chrdev() 里 */
	err = cdev_add(cdev, MKDEV(cd->major, baseminor), count);
	...
	cd->cdev = cdev;                       /* “记住:这个 cdev 是我建的” */

	/* fs/char_dev.c:341-342 —— 唯一的读取点,在 __unregister_chrdev() 里 */
	cd = __unregister_chrdev_region(major, baseminor, count);
	if (cd && cd->cdev)
		cdev_del(cd->cdev);              /* “找到并销毁它” */
	kfree(cd);

除此之外,打开设备(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), 它完成参数校验、冲突检测和链表插入。逐段看:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
static struct char_device_struct *
__register_chrdev_region(unsigned int major, unsigned int baseminor,
			   int minorct, const char *name)
{
	struct char_device_struct *cd, *curr, *prev = NULL;
	int ret;
	int i;

	if (major >= CHRDEV_MAJOR_MAX) {                  /* ① 主号越界 */
		pr_err("CHRDEV \"%s\" major requested (%u) is greater than the maximum (%u)\n",
		       name, major, CHRDEV_MAJOR_MAX-1);
		return ERR_PTR(-EINVAL);
	}

	if (minorct > MINORMASK + 1 - baseminor) {        /* ② 从号段越界 */
		pr_err("CHRDEV \"%s\" minor range requested (%u-%u) is out of range of maximum range (%u-%u) for a single major\n",
			name, baseminor, baseminor + minorct - 1, 0, MINORMASK);
		return ERR_PTR(-EINVAL);
	}

	cd = kzalloc(sizeof(struct char_device_struct), GFP_KERNEL);   /* ③ 分配节点 */
	if (cd == NULL)
		return ERR_PTR(-ENOMEM);

	mutex_lock(&chrdevs_lock);                          /* ④ 加锁 */

	if (major == 0) {                                   /* ⑤ 主号 0 = 动态 */
		ret = find_dynamic_major();
		if (ret < 0) {
			pr_err("CHRDEV \"%s\" dynamic allocation region is full\n",
			       name);
			goto out;
		}
		major = ret;
	}

	ret = -EBUSY;
	i = major_to_index(major);                          /* ⑥ 冲突检测 */
	for (curr = chrdevs[i]; curr; prev = curr, curr = curr->next) {
		if (curr->major < major)
			continue;
		if (curr->major > major)
			break;

		if (curr->baseminor + curr->minorct <= baseminor)   /* 本段在请求段之前 */
			continue;
		if (curr->baseminor >= baseminor + minorct)         /* 本段在请求段之后 */
			break;

		goto out;                                   /* 区间重叠 -> EBUSY */
	}

	cd->major = major;
	cd->baseminor = baseminor;
	cd->minorct = minorct;
	strscpy(cd->name, name, sizeof(cd->name));          /* ⑦ 填字段 */

	if (!prev) {                                        /* ⑧ 按序插入链表 */
		cd->next = curr;
		chrdevs[i] = cd;
	} else {
		cd->next = prev->next;
		prev->next = cd;
	}
	...

拆解:

  • ① 主号校验: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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
static int find_dynamic_major(void)
{
	int i;
	struct char_device_struct *cd;

	for (i = ARRAY_SIZE(chrdevs)-1; i >= CHRDEV_MAJOR_DYN_END; i--) {
		if (chrdevs[i] == NULL)
			return i;
	}

	for (i = CHRDEV_MAJOR_DYN_EXT_START;
	     i >= CHRDEV_MAJOR_DYN_EXT_END; i--) {
		for (cd = chrdevs[major_to_index(i)]; cd; cd = cd->next)
			if (cd->major == i)
				break;

		if (cd == NULL)
			return i;
	}

	return -EBUSY;
}

动态主号的来源分两段(常量在 include/linux/fs.h:2983-2988):

1
2
3
4
5
6
#define CHRDEV_MAJOR_MAX 512
/* Marks the bottom of the first segment of free char majors */
#define CHRDEV_MAJOR_DYN_END 234
/* Marks the top and bottom of the second segment of free char majors */
#define CHRDEV_MAJOR_DYN_EXT_START 511
#define CHRDEV_MAJOR_DYN_EXT_END 384
  • 第一段:从 254 往下扫到 234。注意这里直接检查哈希桶 chrdevs[i] 是否为空—— 对 i < 255 的主号来说,桶 i 恰好只装“主号 ≡ i (mod 255)”的注册项,桶空基本等价于 “主号 i 空闲”,所以这是一个廉价的近似判断;
  • 第二段(扩展区):从 511 往下扫到 384,这次老老实实遍历桶内链表确认主号是否被占;
  • 两段都满才返回 -EBUSY(注册表 235254 + 384511 全被占满时)。

方向是从高往低找。

对应到维护文档的约定:主号 234254 与 384511 是“给驱动动态分配”的区间,其余 主号要么保留、要么静态分配(写死在 major.h / 各自驱动里)。

register_chrdev_region():固定主号与跨号拆分

公开 API register_chrdev_region()(fs/char_dev.c:200-223)允许一次申请一段 “连续 dev_t”,这段可以跨越主号边界:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int register_chrdev_region(dev_t from, unsigned count, const char *name)
{
	struct char_device_struct *cd;
	dev_t to = from + count;
	dev_t n, next;

	for (n = from; n < to; n = next) {
		next = MKDEV(MAJOR(n)+1, 0);      /* 下一个主号的开头 */
		if (next > to)
			next = to;
		cd = __register_chrdev_region(MAJOR(n), MINOR(n),
			       next - n, name);
		if (IS_ERR(cd))
			goto fail;
	}
	return 0;
fail:
	to = n;
	for (n = from; n < to; n = next) {
		next = MKDEV(MAJOR(n)+1, 0);
		kfree(__unregister_chrdev_region(MAJOR(n), MINOR(n), next - n));
	}
	return PTR_ERR(cd);
}

核心是那个 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 的一个内联函数:

1
2
3
4
5
static inline int register_chrdev(unsigned int major, const char *name,
				  const struct file_operations *fops)
{
	return __register_chrdev(major, 0, 256, name, fops);
}

而 __register_chrdev()(fs/char_dev.c:268-300)是“注册号段 + 创建 cdev”的打包封装:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
int __register_chrdev(unsigned int major, unsigned int baseminor,
		      unsigned int count, const char *name,
		      const struct file_operations *fops)
{
	struct char_device_struct *cd;
	struct cdev *cdev;
	int err = -ENOMEM;

	cd = __register_chrdev_region(major, baseminor, count, name);
	if (IS_ERR(cd))
		return PTR_ERR(cd);

	cdev = cdev_alloc();
	if (!cdev)
		goto out2;

	cdev->owner = fops->owner;
	cdev->ops = fops;
	kobject_set_name(&cdev->kobj, "%s", name);

	err = cdev_add(cdev, MKDEV(cd->major, baseminor), count);
	if (err)
		goto out;

	cd->cdev = cdev;

	return major ? 0 : cd->major;
	...
}

它一次性注册 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:

1
2
3
4
5
6
7
8
struct cdev {
	struct kobject kobj;
	struct module *owner;
	const struct file_operations *ops;
	struct list_head list;
	dev_t dev;
	unsigned int count;
} __randomize_layout;

cdev 把三个东西绑在一起:一段起始设备号 dev、覆盖的从号个数 count、 驱动操作表 ops。挂接函数 cdev_add()(fs/char_dev.c:479-504)把这段映射插入 cdev_map:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
int cdev_add(struct cdev *p, dev_t dev, unsigned count)
{
	int error;

	p->dev = dev;
	p->count = count;

	if (WARN_ON(dev == WHITEOUT_DEV)) { ... }

	error = kobj_map(cdev_map, dev, count, NULL,
			 exact_match, exact_lock, p);
	...
}

cdev_map 是全局唯一的 kobj_map(chrdev_init() 里创建,char_dev.c:674-677), 它的数据结构在 drivers/base/map.c:19-30:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
struct kobj_map {
	struct probe {
		struct probe *next;
		dev_t dev;
		unsigned long range;
		struct module *owner;
		kobj_probe_t *get;
		int (*lock)(dev_t, void *);
		void *data;
	} *probes[255];
	struct mutex *lock;
};

又是 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):

1
2
3
4
		case S_IFCHR: case S_IFBLK:
			error = vfs_mknod(idmap, path.dentry->d_inode,
					  dentry, mode, new_decode_dev(dev));
			break;

用户态传进来的 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):

1
2
3
4
5
6
	switch (mode & S_IFMT) {
	default:
		inode->i_op = &shmem_special_inode_operations;
		init_special_inode(inode, mode, dev);
		break;
	...

init_special_inode()(fs/inode.c:2552)干了两件关键事:

1
2
3
4
5
6
	switch (inode->i_mode & S_IFMT) {
	case S_IFCHR:
		inode->i_fop = &def_chr_fops;      /* 字符节点先统一挂"通用打开器" */
		inode->i_rdev = rdev;              /* 设备号存入 inode */
		break;
	...

从此,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):

1
2
3
4
const struct file_operations def_chr_fops = {
	.open = chrdev_open,
	.llseek = noop_llseek,
};

chrdev_open()(fs/char_dev.c:373-424)的核心逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
static int chrdev_open(struct inode *inode, struct file *filp)
{
	const struct file_operations *fops;
	struct cdev *p;
	struct cdev *new = NULL;
	int ret = 0;

	spin_lock(&cdev_lock);
	p = inode->i_cdev;                    /* 第一次打开时 i_cdev 为空 */
	if (!p) {
		struct kobject *kobj;
		int idx;
		spin_unlock(&cdev_lock);
		kobj = kobj_lookup(cdev_map, inode->i_rdev, &idx);   /* 按设备号查 cdev */
		if (!kobj)
			return -ENXIO;
		new = container_of(kobj, struct cdev, kobj);
		spin_lock(&cdev_lock);
		/* Check i_cdev again in case somebody beat us to it while
		   we dropped the lock. */
		p = inode->i_cdev;
		if (!p) {
			inode->i_cdev = p = new;          /* 结果缓存在 inode 上 */
			list_add(&inode->i_devices, &p->list);
			new = NULL;
		} else if (!cdev_get(p))
			ret = -ENXIO;
	} else if (!cdev_get(p))
		ret = -ENXIO;
	spin_unlock(&cdev_lock);
	cdev_put(new);
	if (ret)
		return ret;

	ret = -ENXIO;
	fops = fops_get(p->ops);              /* 拿到驱动自己的 fops */
	if (!fops)
		goto out_cdev_put;

	replace_fops(filp, fops);             /* 把 file 的 f_op 换成驱动的 */
	if (filp->f_op->open) {
		ret = filp->f_op->open(inode, filp);   /* 最终调用驱动的 open */
		if (ret)
			goto out_cdev_put;
	}
	...

这就是“打开路由”的真相:

  1. 用 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)——也就是从号在段内的偏移;
  2. 查到的 kobj 用 container_of 还原成 struct cdev,缓存到 inode->i_cdev(第二次 open 不再查表);
  3. 用 cdev_get() 把模块引用计数 +1(try_module_get,char_dev.c:348-359)—— 这就是“设备文件被打开时模块不能 rmmod”的机制(实验第 8 步验证);
  4. 把 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() 注册设备:

1
2
3
	dev = device_create(demo_class, NULL,
			    MKDEV(dyn_major, DYN_BASEMINOR + i),
			    NULL, "demo-dyn%d", i);

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

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
// SPDX-License-Identifier: GPL-2.0
/*
 * devnum_demo.c —— 主设备号 / 从设备号 (dev_t) 教学演示模块
 *
 * 目标内核: 6.18.33.2 (WSL2, microsoft-standard)。兼容现代主线 API:
 *   - register_chrdev_region() / alloc_chrdev_region() / unregister_chrdev_region()
 *   - cdev_init() / cdev_add() / cdev_del()
 *   - class_create() (6.4+ 无 owner 参数) / device_create() (devtmpfs 自动建节点)
 *
 * 演示内容:
 *   1) alloc_chrdev_region(): 动态分配主号(从 254 向下扫), 指定 baseminor/count
 *   2) register_chrdev_region(): 固定主号 + 小号段, 以及
 *      重叠段返回 -EBUSY、同主号不重叠段可共存、跨主号自动拆分、
 *      主号越界/小号越界返回 -EINVAL 等边界行为
 *   3) cdev_add(): 让 (主,从) 段真正可被 open()
 *   4) class/device_create(): 现代 sysfs + devtmpfs 自动建 /dev 节点
 *
 * 用法:
 *   sudo insmod devnum_demo.ko            # fixmajor 默认 236, 可 fixmajor=0 关闭固定主号部分
 *   sudo mknod /dev/demo-fix0 c 236 0     # 老式手动建节点
 *   cat /proc/devices | grep demo
 *   ls -l /dev/demo-dyn* /dev/demo-fix0   # 对比 ls 列出的主:从号
 */
#include <linux/module.h>
#include <linux/init.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/kdev_t.h>
#include <linux/slab.h>
#include <linux/uaccess.h>

#define DEVNAME "devnum_demo"

#define FIX_BASEMINOR	0
#define FIX_COUNT	3	/* fixmajor:0 .. fixmajor:2 */
#define DYN_BASEMINOR	8
#define DYN_COUNT	4	/* 动态主号 : 8 .. 11 */

/* 一组连续小号 = 一个"逻辑设备"(注册段 + 一个 cdev) */
struct demo_dev {
	dev_t		first;		/* 段内第一个设备号 dev_t */
	unsigned int	baseminor;	/* 段起始小号 */
	unsigned int	count;		/* 段内小号个数 */
	const char	*tag;		/* 打印用标识 */
	struct cdev	cdev;
	atomic_t	open_cnt;
};

static struct demo_dev dyn_dev = { .tag = "dynamic" };	/* alloc_chrdev_region 动态主号 */
static struct demo_dev fix_dev = { .tag = "fixed" };	/* register_chrdev_region 固定主号 */
static bool dyn_active, fix_active;
static struct class *demo_class;

static int fixmajor = 236;
module_param(fixmajor, int, 0444);
MODULE_PARM_DESC(fixmajor, "register_chrdev_region 要占用的固定主号(0 = 跳过固定主号部分)");

/* ------------------------- fops ------------------------- */

static int demo_open(struct inode *inode, struct file *filp)
{
	struct demo_dev *dd = container_of(inode->i_cdev, struct demo_dev, cdev);
	unsigned int idx = iminor(inode) - dd->baseminor;

	/* chrdev_open 已经用 cdev_map 按 (主,从) 号路由到正确的 cdev,
	 * 走到这里说明该小号一定落在 cdev_add(dev, count) 区间内 */
	atomic_inc(&dd->open_cnt);
	filp->private_data = dd;
	pr_info("%s: open %-7s dev=%u:%u (iminor=%u, idx=%u, open_cnt=%d)\n",
		DEVNAME, dd->tag, imajor(inode), iminor(inode),
		iminor(inode), idx, atomic_read(&dd->open_cnt));
	return 0;
}

static int demo_release(struct inode *inode, struct file *filp)
{
	struct demo_dev *dd = filp->private_data;

	if (dd)
		atomic_dec(&dd->open_cnt);
	return 0;
}

static ssize_t demo_read(struct file *filp, char __user *buf,
			 size_t len, loff_t *off)
{
	struct demo_dev *dd = filp->private_data;
	char tmp[192];
	int n;

	if (!dd || *off)
		return 0;

	n = snprintf(tmp, sizeof(tmp),
		     "demo(%s): first dev_t=%u  major=%u minor=%u  (baseminor=%u count=%u)\n"
		     "          打开次数 open_cnt=%d\n",
		     dd->tag, (unsigned int)dd->first,
		     MAJOR(dd->first), MINOR(dd->first),
		     dd->baseminor, dd->count, atomic_read(&dd->open_cnt));
	if (n > (int)len)
		n = len;
	if (copy_to_user(buf, tmp, n))
		return -EFAULT;
	*off = n;
	return n;
}

static const struct file_operations demo_fops = {
	.owner		= THIS_MODULE,
	.open		= demo_open,
	.release	= demo_release,
	.read		= demo_read,
};

/* ------------------------- 工具函数 ------------------------- */

static void demo_dev_init(struct demo_dev *dd, dev_t first,
			  unsigned baseminor, unsigned count)
{
	dd->first = first;
	dd->baseminor = baseminor;
	dd->count = count;
	atomic_set(&dd->open_cnt, 0);
	cdev_init(&dd->cdev, &demo_fops);
	dd->cdev.owner = THIS_MODULE;
}

/* 打印一次 register_chrdev_region 的返回码 */
static int try_register(dev_t from, unsigned count, const char *name)
{
	int ret = register_chrdev_region(from, count, name);

	pr_info("  register_chrdev_region(%s, %u:%u, count=%u) -> %d\n",
		name, MAJOR(from), MINOR(from), count, ret);
	return ret;
}

/* ------------------------- init / exit ------------------------- */

static int __init devnum_demo_init(void)
{
	dev_t dyn_first, d1, d2;
	unsigned dyn_major;
	int ret, i;

	pr_info("== %s: MINORBITS=%d, MINORMASK=0x%x, CHRDEV_MAJOR_MAX=%d\n",
		DEVNAME, MINORBITS, MINORMASK, CHRDEV_MAJOR_MAX);

	/* ---------- (1) alloc_chrdev_region: 动态主号 ---------- */
	ret = alloc_chrdev_region(&dyn_first, DYN_BASEMINOR, DYN_COUNT,
				  "demo-dyn");
	if (ret < 0) {
		pr_err("%s: alloc_chrdev_region(dyn) failed: %d\n", DEVNAME, ret);
		return ret;
	}
	dyn_major = MAJOR(dyn_first);
	pr_info("(1) alloc_chrdev_region(&dev, baseminor=%u, count=%u, \"demo-dyn\")\n"
		"    -> dev_t=%u  = %u:%u\n", DYN_BASEMINOR, DYN_COUNT,
		(unsigned int)dyn_first, dyn_major, MINOR(dyn_first));

	/* ---------- (2) 连续 alloc 两次: 观察动态主号从高到低扫 ---------- */
	ret = alloc_chrdev_region(&d1, 0, 1, "demo-order-A");
	pr_info("(2) alloc#2 -> dev_t=%u = %u:%u (ret=%d)\n",
		(unsigned int)d1, MAJOR(d1), MINOR(d1), ret);
	ret = alloc_chrdev_region(&d2, 0, 1, "demo-order-B");
	pr_info("    alloc#3 -> dev_t=%u = %u:%u (ret=%d)\n",
		(unsigned int)d2, MAJOR(d2), MINOR(d2), ret);
	unregister_chrdev_region(d1, 1);
	unregister_chrdev_region(d2, 1);
	pr_info("    unregister #2/#3 -> 主号 %u、%u 归还\n",
		MAJOR(d1), MAJOR(d2));

	/* ---------- (3) register_chrdev_region: 固定主号 ---------- */
	if (fixmajor > 0) {
		dev_t base = MKDEV(fixmajor, FIX_BASEMINOR);

		ret = try_register(base, FIX_COUNT, "demo-fixed");
		if (ret < 0) {
			pr_err("%s: 固定主号 %u 被占用, 请换 fixmajor=\n", DEVNAME, fixmajor);
			unregister_chrdev_region(dyn_first, DYN_COUNT);
			return ret;
		}
		pr_info("(3) 固定主号段 %u:%u..%u 注册成功\n",
			fixmajor, FIX_BASEMINOR, FIX_BASEMINOR + FIX_COUNT - 1);

		/* 4a: 与已有段重叠 -> -EBUSY */
		ret = try_register(MKDEV(fixmajor, 1), 4, "demo-ovl");
		pr_info("     ^ 与 %u:0..2 重叠, 预期 -16 (EBUSY)\n", fixmajor);
		/* 4b: 同主号、不重叠的小号段 -> 允许共存 */
		ret = try_register(MKDEV(fixmajor, 200), 1, "demo-disj");
		pr_info("     ^ 与已有段不重叠, 预期 0; 随后立即释放\n");
		if (!ret)
			unregister_chrdev_region(MKDEV(fixmajor, 200), 1);
		/* 4c: 一段跨越主号边界 -> register_chrdev_region 内部自动拆成两段 */
		ret = try_register(MKDEV(fixmajor, MINORMASK - 1), 4,
				   "demo-xmajor");
		pr_info("     ^ %u:1048574 + count=4 会跨到 %u:0..1, 预期 0\n",
			fixmajor, fixmajor + 1);
		if (!ret) {
			ret = try_register(MKDEV(fixmajor + 1, 0), 1,
					   "demo-xmajor-probe");
			pr_info("     ^ 再占 %u:0 应失败(预期 -16): 证明第二段确实被占了\n",
				fixmajor + 1);
			unregister_chrdev_region(MKDEV(fixmajor, MINORMASK - 1), 4);
			pr_info("     一次 unregister 会把两段(两个主号)都释放\n");
		}
		/* 4d: 主号 >= 512 -> -EINVAL */
		ret = try_register(MKDEV(CHRDEV_MAJOR_MAX, 0), 1, "demo-toobig");
		pr_info("     ^ 主号 %d >= CHRDEV_MAJOR_MAX(512), 预期 -22 (EINVAL)\n",
			CHRDEV_MAJOR_MAX);
		/* 4e: 小号段越界 -> -EINVAL。
		 * 注意: 走 register_chrdev_region() 时越界范围会被"跨主号拆分"消化掉
		 * (见上面 4c), 所以这里改用不做拆分的 alloc_chrdev_region() 触发 */
		{
			dev_t tmp;

			ret = alloc_chrdev_region(&tmp, MINORMASK, 2, "demo-minoor");
			pr_info("     ^ alloc_chrdev_region(baseminor=%u(MINORMASK), count=2) "
				"小号段越界, 预期 -22 (EINVAL), 实得 %d\n",
				(unsigned int)MINORMASK, ret);
		}
	} else {
		pr_info("(3) fixmajor=0: 跳过固定主号部分\n");
	}

	/* ---------- (5) cdev_add: 让设备号段真正可被打开 ---------- */
	demo_dev_init(&dyn_dev, dyn_first, DYN_BASEMINOR, DYN_COUNT);
	ret = cdev_add(&dyn_dev.cdev, dyn_first, DYN_COUNT);
	if (ret < 0) {
		pr_err("%s: cdev_add(dyn) failed: %d\n", DEVNAME, ret);
		unregister_chrdev_region(dyn_first, DYN_COUNT);
		return ret;
	}
	dyn_active = true;
	pr_info("(5) cdev_add(dyn): %u:%u..%u -> %d\n", dyn_major,
		DYN_BASEMINOR, DYN_BASEMINOR + DYN_COUNT - 1, ret);

	if (fixmajor > 0) {
		demo_dev_init(&fix_dev, MKDEV(fixmajor, FIX_BASEMINOR),
			      FIX_BASEMINOR, FIX_COUNT);
		ret = cdev_add(&fix_dev.cdev, MKDEV(fixmajor, FIX_BASEMINOR),
			       FIX_COUNT);
		if (ret < 0) {
			pr_err("%s: cdev_add(fixed) failed: %d\n", DEVNAME, ret);
			cdev_del(&dyn_dev.cdev);
			dyn_active = false;
			unregister_chrdev_region(dyn_first, DYN_COUNT);
			unregister_chrdev_region(MKDEV(fixmajor, FIX_BASEMINOR),
						 FIX_COUNT);
			return ret;
		}
		fix_active = true;
		pr_info("    cdev_add(fixed): %u:%u..%u -> %d\n", fixmajor,
			FIX_BASEMINOR, FIX_BASEMINOR + FIX_COUNT - 1, ret);
	}

	/* ---------- (6) 现代路径: class + device_create ----------
	 * 每个 device 带上 devt; udev 收到 uevent 后/devtmpfs 自动建节点,
	 * 不需要手写 mknod, /sys/class/demo_dyn/ 下也有 dev=主:从 文件
	 */
	demo_class = class_create("demo_dyn");
	if (IS_ERR(demo_class)) {
		pr_err("%s: class_create failed\n", DEVNAME);
		goto err_class;
	}
	for (i = 0; i < DYN_COUNT; i++) {
		struct device *dev;

		dev = device_create(demo_class, NULL,
				    MKDEV(dyn_major, DYN_BASEMINOR + i),
				    NULL, "demo-dyn%d", i);
		if (IS_ERR(dev)) {
			pr_err("%s: device_create demo-dyn%d failed\n",
			       DEVNAME, i);
			goto err_devs;
		}
	}
	pr_info("(6) device_create x%d -> /dev/demo-dyn0..%d 由 devtmpfs 自动生成\n",
		DYN_COUNT, DYN_COUNT - 1);

	pr_info("== %s ready\n", DEVNAME);
	pr_info("   固定主号: %u:%u..%u   (手动 mknod 演示)\n",
		fixmajor, FIX_BASEMINOR, FIX_BASEMINOR + FIX_COUNT - 1);
	pr_info("   动态主号: %u:%u..%u  (device_create 自动节点)\n",
		dyn_major, DYN_BASEMINOR, DYN_BASEMINOR + DYN_COUNT - 1);
	return 0;

err_devs:
	while (i-- > 0)
		device_destroy(demo_class,
			       MKDEV(dyn_major, DYN_BASEMINOR + i));
	class_destroy(demo_class);
	demo_class = NULL;
err_class:
	if (fix_active) {
		cdev_del(&fix_dev.cdev);
		fix_active = false;
	}
	if (dyn_active) {
		cdev_del(&dyn_dev.cdev);
		dyn_active = false;
	}
	unregister_chrdev_region(dyn_first, DYN_COUNT);
	if (fixmajor > 0)
		unregister_chrdev_region(MKDEV(fixmajor, FIX_BASEMINOR),
					 FIX_COUNT);
	return -EIO;
}

static void __exit devnum_demo_exit(void)
{
	int i;
	unsigned dyn_major = MAJOR(dyn_dev.first);

	if (demo_class) {
		for (i = 0; i < DYN_COUNT; i++)
			device_destroy(demo_class,
				       MKDEV(dyn_major, DYN_BASEMINOR + i));
		class_destroy(demo_class);
		pr_info("== %s: device_destroy x%d + class_destroy -> /dev/demo-dyn* 自动消失\n",
			DEVNAME, DYN_COUNT);
	}
	if (dyn_active) {
		cdev_del(&dyn_dev.cdev);
		dyn_active = false;
	}
	if (fix_active) {
		cdev_del(&fix_dev.cdev);
		fix_active = false;
	}
	unregister_chrdev_region(dyn_dev.first, DYN_COUNT);
	if (fixmajor > 0)
		unregister_chrdev_region(MKDEV(fixmajor, FIX_BASEMINOR),
					 FIX_COUNT);
	pr_info("== %s: cdev_del x2 + unregister_chrdev_region x2 -> 主号 %u、%u 全部归还\n",
		DEVNAME, dyn_major, fixmajor);
}

module_init(devnum_demo_init);
module_exit(devnum_demo_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("devnum demo for blog");
MODULE_DESCRIPTION("major/minor dev_t teaching demo (6.18.33.2)");
MODULE_VERSION("1.0");

配套文件也在 ~/devnumlab/:Makefile(标准外部模块编译), test_devnum.sh(一键跑完整流程并输出全部观察结果),ghost_clean.ko 的源码 ghost_clean.c(用途见「坑与教训」一节)。

Makefile 全文:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 编译 6.18.33.2 (WSL2) 内核模块
obj-m += devnum_demo.o
obj-m += ghost_clean.o

KDIR ?= /lib/modules/$(shell uname -r)/build

all:
	$(MAKE) -C $(KDIR) M=$(CURDIR) modules

clean:
	$(MAKE) -C $(KDIR) M=$(CURDIR) clean

编译与加载

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ make
make -C /lib/modules/6.18.33.2-microsoft-standard-WSL2/build M=/home/charles/devnumlab modules
  CC [M]  devnum_demo.o
  MODPOST Module.symvers
  CC [M]  devnum_demo.mod.o
  LD [M]  devnum_demo.ko

$ sudo insmod ./devnum_demo.ko && echo OK
insmod exit: 0

$ cat /proc/modules | grep devnum_demo
devnum_demo 16384 0 - Live 0xffffffffc0442000 (O)

(O) 表示模块带 taint(Out-of-tree/未签名),WSL2 上属正常现象。

dmesg:注册全程日志

insmod 后,模块 init 里编号 (1)~(6) 的实验结果全部进了内核日志:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
== devnum_demo: MINORBITS=20, MINORMASK=0xfffff, CHRDEV_MAJOR_MAX=512
(1) alloc_chrdev_region(&dev, baseminor=8, count=4, "demo-dyn")
    -> dev_t=250609672  = 239:8
(2) alloc#2 -> dev_t=249561088 = 238:0 (ret=0)
    alloc#3 -> dev_t=248512512 = 237:0 (ret=0)
    unregister #2/#3 -> 主号 238、237 归还
  register_chrdev_region(demo-fixed, 236:0, count=3) -> 0
(3) 固定主号段 236:0..2 注册成功
  register_chrdev_region(demo-ovl, 236:1, count=4) -> -16
     ^ 与 236:0..2 重叠, 预期 -16 (EBUSY)
  register_chrdev_region(demo-disj, 236:200, count=1) -> 0
     ^ 与已有段不重叠, 预期 0; 随后立即释放
  register_chrdev_region(demo-xmajor, 236:1048574, count=4) -> 0
     ^ 236:1048574 + count=4 会跨到 237:0..1, 预期 0
  register_chrdev_region(demo-xmajor-probe, 237:0, count=1) -> -16
     ^ 再占 237:0 应失败(预期 -16): 证明第二段确实被占了
     一次 unregister 会把两段(两个主号)都释放
CHRDEV "demo-toobig" major requested (512) is greater than the maximum (511)
  register_chrdev_region(demo-toobig, 512:0, count=1) -> -22
     ^ 主号 512 >= CHRDEV_MAJOR_MAX(512), 预期 -22 (EINVAL)
CHRDEV "demo-minoor" minor range requested (1048575-1048576) is out of range of maximum range (0-1048575) for a single major
     ^ alloc_chrdev_region(baseminor=1048575(MINORMASK), count=2) 小号段越界, 预期 -22 (EINVAL), 实得 -22
(5) cdev_add(dyn): 239:8..11 -> 0
    cdev_add(fixed): 236:0..2 -> 0
(6) device_create x4 -> /dev/demo-dyn0..3 由 devtmpfs 自动生成
== devnum_demo ready

逐条对照源码解读:

  • (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:注册表一览

1
2
3
4
5
6
7
8
9
$ cat /proc/devices | grep -E "^(Character|Block)|demo| mem$|ramdisk| vcs$| loop$"
Character devices:
  1 mem
  7 vcs
236 demo-fixed
239 demo-dyn
Block devices:
  1 ramdisk
  7 loop

236 demo-fixed、239 demo-dyn 正是注册时传给 API 的 name——/proc/devices 遍历 chrdevs[] 打印的就是它。同时能看到字符区 1 mem 与块区 1 ramdisk、7 vcs 与 7 loop 的同号共存,印证“字符/块注册表独立”。

节点对比:mknod 与 device_create

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
$ ls -l /dev/demo-dyn*                 # device_create + devtmpfs 自动生成
crw------- 1 root root 239,  8 /dev/demo-dyn0
crw------- 1 root root 239,  9 /dev/demo-dyn1
crw------- 1 root root 239, 10 /dev/demo-dyn2
crw------- 1 root root 239, 11 /dev/demo-dyn3

$ stat -c '%n: %F  major=%t minor=%T' /dev/demo-dyn0 /dev/demo-dyn3
/dev/demo-dyn0: character special file  major=ef minor=8
/dev/demo-dyn3: character special file  major=ef minor=b

$ sudo cat /sys/class/demo_dyn/demo-dyn0/dev /sys/class/demo_dyn/demo-dyn3/dev
239:8
239:11

$ sudo mknod /dev/demo-fix0 c 236 0    # 老式:手动 mknod
$ sudo mknod /dev/demo-fix1 c 236 1
$ sudo mknod /dev/demo-fix2 c 236 2
$ ls -l /dev/demo-fix0 /dev/demo-fix1 /dev/demo-fix2
crw-r--r-- 1 root root 236, 0 /dev/demo-fix0
crw-r--r-- 1 root root 236, 1 /dev/demo-fix1
crw-r--r-- 1 root root 236, 2 /dev/demo-fix2

观察点:

  • 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() 要解决的问题。

读写:从号路由到驱动

1
2
3
4
5
6
7
8
9
$ sudo cat /dev/demo-dyn0
demo(dynamic): first dev_t=250609672  major=239 minor=8  (baseminor=8 count=4)
          打开次数 open_cnt=1
$ sudo cat /dev/demo-dyn3
demo(dynamic): first dev_t=250609672  major=239 minor=8  (baseminor=8 count=4)
          打开次数 open_cnt=1
$ cat /dev/demo-fix0
demo(fixed): first dev_t=247463936  major=236 minor=0  (baseminor=0 count=3)
          打开次数 open_cnt=1

每次 cat 背后的内核日志更能说明“路由”过程:

1
2
3
4
5
6
7
devnum_demo: open dynamic dev=239:8 (iminor=8, idx=0, open_cnt=1)
devnum_demo: open dynamic dev=239:9 (iminor=9, idx=1, open_cnt=1)
devnum_demo: open dynamic dev=239:10 (iminor=10, idx=2, open_cnt=1)
devnum_demo: open dynamic dev=239:11 (iminor=11, idx=3, open_cnt=1)
devnum_demo: open fixed   dev=236:0 (iminor=0, idx=0, open_cnt=1)
devnum_demo: open fixed   dev=236:1 (iminor=1, idx=1, open_cnt=1)
devnum_demo: open fixed   dev=236:2 (iminor=2, idx=2, open_cnt=1)
  • 四个 /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。

反例:段外小号打开失败

1
2
3
$ sudo mknod /dev/demo-fix-bad c 236 7
$ cat /dev/demo-fix-bad
cat: /dev/demo-fix-bad: No such device or address    # ENXIO

从号 7 既不在注册段 236:0..2、也不在 cdev_add 的区间里——chrdev_open() 里 kobj_lookup(cdev_map, inode->i_rdev, &idx) 查不到,返回 -ENXIO。

rmmod:打开的文件钉住模块

1
2
3
4
5
6
$ exec 3</dev/demo-fix0        # 打开一个 fd 不放
$ sudo rmmod devnum_demo
rmmod: ERROR: Module devnum_demo is in use           # 卸载失败!
$ exec 3>&-                    # 关闭 fd
$ sudo rmmod devnum_demo       # 这次成功
rmmod exit: 0

原因在源码里:chrdev_open() 查到 cdev 后调用 cdev_get() → try_module_get() (fs/char_dev.c:353),文件打开期间模块引用计数不为零,rmmod 自然被拒。

rmmod 之后:节点去哪了

1
2
3
4
5
6
$ ls -l /dev/demo-dyn0
ls: cannot access '/dev/demo-dyn0': No such file or directory   # 自动节点被 devtmpfs 删除
$ ls -l /dev/demo-fix0
crw-r--r-- 1 root root 236, 0 /dev/demo-fix0                    # mknod 节点残留
$ cat /dev/demo-fix0
cat: /dev/demo-fix0: No such device or address                  # 打开 ENXIO

对应源码:模块 exit 里 device_destroy() 让 devtmpfs 删除自动节点;手动 mknod 的 inode 无人负责清理。dmesg 中的卸载日志:

1
2
== devnum_demo: device_destroy x4 + class_destroy -> /dev/demo-dyn* 自动消失
== devnum_demo: cdev_del x2 + unregister_chrdev_region x2 -> 主号 239、236 全部归还

设备号泄漏

之前,有写一段用 register_chrdev_region(MKDEV(236,1048575), 2) 做 “从号越界”测试的代码,测试完忘了 unregister。结果 rmmod 之后:

1
2
3
$ cat /proc/devices | grep minoor
236 demo-minoor
237 demo-minoor

模块都卸载了,/proc/devices 里却永久残留着两条“幽灵”记录——因为模块卸载只执行 exit 函数里写的 unregister_chrdev_region(),内核不会自动清扫 chrdevs[]。 这两段号直到重启前都无法再被任何驱动使用(再注册会 -EBUSY),这就是设备号泄漏。

恢复手段:unregister_chrdev_region()只按(major, baseminor, minorct)` 三元组精确匹配,不校验注册者,所以可以写一个一次性模块“替别人”释放:

1
2
3
4
5
6
7
8
9
static int __init ghost_clean_init(void)
{
	/* 当初 register_chrdev_region(MKDEV(236,1048575), 2) 被拆成两段:
	 * (236,1048575,1) + (237,0,1)。反向 unregister 也要给原始 count=2,
	 * unregister_chrdev_region() 内部会同样跨主号拆分匹配。 */
	unregister_chrdev_region(MKDEV(236, 1048575), 2);
	pr_info("ghost_clean: unregistered leaked ranges 236:1048575 + 237:0\n");
	return -EPERM;	/* 一次性任务, 主动卸载 */
}
1
2
3
实测可被清掉:
$ sudo insmod ./ghost_clean.ko
$ cat /proc/devices | grep minoor    # 空 = 已清理

它同时带出一个知识点:“拆成几段”和“一次注册”是一对——注册用 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。

总结

把整条链路浓缩成一张图:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
mknod /dev/demo-fix0 c 236 0                # 用户态: 两个十进制数字
        │
        ▼  sys_mknod → do_mknodat → new_decode_dev(dev)      (fs/namei.c:4379)
   dev_t = MKDEV(236, 0) = 247463936        # 内核: 一个 u32
        │
        ▼  vfs_mknod → shmem_mknod → init_special_inode      (mm/shmem.c:3112)
   inode->i_rdev = dev_t                    # 设备号住进 inode
   inode->i_fop  = def_chr_fops             # open 先走通用打开器
        │
        ▼  open("/dev/demo-fix0")
   chrdev_open(): kobj_lookup(cdev_map, i_rdev)  → cdev      (fs/char_dev.c:386)
                  (cdev 由驱动 cdev_add(MKDEV(236,0), 3) 挂入)
        │
        ▼  replace_fops(filp, 驱动自己的 ops) → demo_open()
   iminor(inode) = 0                        # 驱动按从号路由实例
        │
        ▼  ls -l /dev/demo-fix0             # statx: MAJOR/MINOR(i_rdev)
   "236, 0"                                 # 十进制主:从

全部要点:

  • 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 是“考古模式”;
  • 设备号是全局有限资源:注册/释放必须成对,泄漏的号段在重启前无法复用。
Licensed under CC BY-NC-SA 4.0
使用 Hugo 构建
主题 Stack 由 Jimmy 设计