4. 设备号管理
字符设备驱动要能被用户空间访问,第一步就是让内核知道“这个驱动占用了哪些设备号”。设备号是字符设备在系统中的身份标识,由主设备号(MAJOR)和次设备号(MINOR)共同组成。主设备号用于标识“哪一类设备、由哪个驱动管理”,次设备号用于标识“同一驱动下的第几个具体设备实例”。内核通过 dev_t 类型保存设备号,用户空间则通过 mknod 或 udev 在 /dev 下生成对应节点。
一、dev_t、MAJOR、MINOR 与 MKDEV 的关系
Linux 内核用 dev_t 表示设备号。在 32 位内核中,dev_t 通常是 32 位整数:高 12 位保存主设备号,低 20 位保存次设备号。因此主设备号范围是 0~4095,次设备号范围是 0~1048575。内核提供三个宏来操作设备号:
MAJOR(dev):从 dev_t 中提取主设备号。MINOR(dev):从 dev_t 中提取次设备号。MKDEV(major, minor):把主设备号和次设备号合成一个 dev_t。
#include <linux/kdev_t.h>
dev_t dev = MKDEV(240, 0);
unsigned int major = MAJOR(dev); /* 240 */
unsigned int minor = MINOR(dev); /* 0 */
这三个宏是设备号管理的基础。驱动中凡是需要“从设备号取主次号”或“由主次号构造设备号”的地方,都应使用它们,而不是手工移位,否则在不同内核版本或不同架构上容易出错。
二、两种注册方式:register_chrdev_region 与 alloc_chrdev_region
内核提供两个主要接口来申请字符设备号区域:
| 接口 | 适用场景 | 特点 |
|---|---|---|
register_chrdev_region() |
已知主设备号,如固定分配、嵌入式板级指定 | 静态申请,主设备号由驱动作者指定,失败通常因冲突 |
alloc_chrdev_region() |
主设备号未知,由内核动态分配 | 动态申请,内核返回可用主设备号,适合通用驱动 |
静态注册的典型写法如下:
#define MY_MAJOR 240
#define MY_MINOR 0
#define MY_DEV_CNT 4
dev_t devno;
int ret;
devno = MKDEV(MY_MAJOR, MY_MINOR);
ret = register_chrdev_region(devno, MY_DEV_CNT, "my_chrdev");
if (ret < 0) {
printk(KERN_ERR "register_chrdev_region failed: %d\n", ret);
return ret;
}
动态注册则不需要预先指定主设备号:
dev_t devno;
int ret;
ret = alloc_chrdev_region(&devno, 0, MY_DEV_CNT, "my_chrdev");
if (ret < 0) {
printk(KERN_ERR "alloc_chrdev_region failed: %d\n", ret);
return ret;
}
printk(KERN_INFO "major=%u minor=%u\n", MAJOR(devno), MINOR(devno));
动态分配成功后,内核会把实际分配到的设备号写入 devno。驱动应保存这个 devno,后续在 cdev_add、设备节点创建和释放时都要使用它。
三、设备号分配策略与生命周期
设备号分配策略的核心是“先申请、后使用、最后释放”。一个完整的生命周期如下:
- 确定设备数量:根据实际支持的次设备数量确定 count,例如 4 个 LED 设备就申请 4 个连续次设备号。
- 选择注册方式:能确定主设备号时用 register_chrdev_region;不确定时用 alloc_chrdev_region。
- 保存 devno:把申请到的 dev_t 保存到驱动私有结构体中。
- 关联 cdev:调用 cdev_init 和 cdev_add,把 file_operations 与设备号绑定。
- 创建节点:通过 mknod 或 udev 在 /dev 下生成设备节点。
- 释放资源:模块卸载时先 cdev_del,再 unregister_chrdev_region。
static dev_t my_devno;
static struct cdev my_cdev;
static int __init my_init(void)
{
int ret;
ret = alloc_chrdev_region(&my_devno, 0, MY_DEV_CNT, "my_chrdev");
if (ret)
return ret;
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
ret = cdev_add(&my_cdev, my_devno, MY_DEV_CNT);
if (ret) {
unregister_chrdev_region(my_devno, MY_DEV_CNT);
return ret;
}
return 0;
}
static void __exit my_exit(void)
{
cdev_del(&my_cdev);
unregister_chrdev_region(my_devno, MY_DEV_CNT);
}
注意释放顺序:先删除 cdev,再释放设备号。如果顺序反了,可能出现设备号已释放但 cdev 仍被访问的窗口,造成内核异常。
四、案例:动态分配与静态分配的对比
假设要开发一个支持 4 路 ADC 的字符设备驱动。若采用静态方式,可以指定主设备号 240,次设备号 0~3;若采用动态方式,内核可能分配主设备号 511,次设备号 0~3。用户空间看到的节点分别是:
# 静态方式
mknod /dev/adc0 c 240 0
mknod /dev/adc1 c 240 1
mknod /dev/adc2 c 240 2
mknod /dev/adc3 c 240 3
# 动态方式,假设内核分配 major=511
mknod /dev/adc0 c 511 0
mknod /dev/adc1 c 511 1
mknod /dev/adc2 c 511 2
mknod /dev/adc3 c 511 3
动态方式的优势是避免冲突,适合产品化驱动;静态方式的优势是设备号固定,便于脚本和测试环境直接写死节点。实际工程中,推荐优先使用 alloc_chrdev_region,再通过 udev 规则自动创建 /dev 节点,这样既安全又便于部署。
iminor(inode) 获取次设备号,再映射到具体硬件通道,而不是为每个次设备单独写一套驱动。
五、常见误区与复盘
- 误区一:只申请设备号,不注册 cdev。 设备号只是“占位”,真正让内核找到 file_operations 的是 cdev_add。两者缺一不可。
- 误区二:忘记释放设备号。 模块卸载时不调用 unregister_chrdev_region,会导致设备号泄漏,下次加载可能失败。
- 误区三:count 与实际次设备数不一致。 申请 4 个却只创建 2 个节点,或申请 1 个却访问 minor=3,都会导致 open 失败或访问越界。
- 误区四:手工移位构造 dev_t。 应使用 MKDEV、MAJOR、MINOR,保证可移植性。
- 误区五:动态分配后不保存 devno。 后续 cdev_add 和节点创建都需要它,丢失后无法正确关联。
掌握设备号管理后,字符设备驱动才真正具备“被系统识别”的基础。后续的 file_operations 实现、并发控制、阻塞与非阻塞访问,都建立在这一步之上。把申请与释放路径写对称,是驱动稳定性的第一道保障。