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、设备节点创建和释放时都要使用它。

三、设备号分配策略与生命周期

设备号分配策略的核心是“先申请、后使用、最后释放”。一个完整的生命周期如下:

  1. 确定设备数量:根据实际支持的次设备数量确定 count,例如 4 个 LED 设备就申请 4 个连续次设备号。
  2. 选择注册方式:能确定主设备号时用 register_chrdev_region;不确定时用 alloc_chrdev_region。
  3. 保存 devno:把申请到的 dev_t 保存到驱动私有结构体中。
  4. 关联 cdev:调用 cdev_init 和 cdev_add,把 file_operations 与设备号绑定。
  5. 创建节点:通过 mknod 或 udev 在 /dev 下生成设备节点。
  6. 释放资源:模块卸载时先 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 节点,这样既安全又便于部署。

如果驱动需要支持多个次设备,建议在 file_operations 的 open 中通过 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 和节点创建都需要它,丢失后无法正确关联。
设备号是全局资源,调试阶段可以临时使用静态主设备号,但量产驱动应使用动态分配,并在卸载路径中严格释放。任何“申请了但没释放”的路径,都是内核模块的隐患。
字符设备号管理:申请 → 绑定 → 使用 → 释放 dev_t(32位) MAJOR 高12位 MINOR 低20位 MKDEV(major, minor) 合成 dev_t MAJOR / MINOR 反向提取 register_chrdev_region() 静态申请:主设备号已知 适合固定板级、测试环境 失败原因:设备号冲突 alloc_chrdev_region() 动态申请:内核分配主设备号 适合通用驱动、量产产品 返回 devno,需保存使用 cdev_init() + cdev_add() 把 file_operations 与 devno 绑定 卸载:cdev_del() → unregister_chrdev_region() /dev 节点 mknod / udev

掌握设备号管理后,字符设备驱动才真正具备“被系统识别”的基础。后续的 file_operations 实现、并发控制、阻塞与非阻塞访问,都建立在这一步之上。把申请与释放路径写对称,是驱动稳定性的第一道保障。