4、设备号管理:主设备号与次设备号、静态申请与动态分配、查看已注册设备号

设备号,说白了就是内核给每个字符设备发的「身份证」。没有这个号,应用程序根本找不到你的设备。我刚开始写驱动时,觉得设备号就是个数字嘛,随便填一个就行。结果呢?跟别人冲突了,设备死活打不开。嗯,从那以后我再也不敢小看设备号管理了。

4.1 主设备号与次设备号:一对搭档

Linux 用 dev_t 类型来表示设备号。这个类型其实是个 32 位的整数,高 12 位是主设备号,低 20 位是次设备号。

  • 主设备号:标识设备对应的驱动程序。说白了,就是告诉内核「这个设备归哪个驱动管」。
  • 次设备号:标识同一个驱动下的不同设备实例。比如你插了两个 USB 转串口,主设备号相同,次设备号不同。

我见过不少新手,以为次设备号没用,随便填 0。其实不然。我记得有个项目,需要在同一个驱动里管理 8 个传感器,每个传感器对应一个次设备号。这样应用程序打开 /dev/sensor0 到 /dev/sensor7,驱动根据次设备号就知道操作哪个硬件。

核心数据结构:

typedef __u32 __kernel_dev_t;
typedef __kernel_dev_t dev_t;

// 主设备号:高12位
#define MAJOR(dev)  ((unsigned int) ((dev) >> MINORBITS))
// 次设备号:低20位
#define MINOR(dev)  ((unsigned int) ((dev) & MINORMASK))
// 合成设备号
#define MKDEV(ma, mi)  (((ma) << MINORBITS) | (mi))

你想想看,主设备号一共就 12 位,也就是 0~4095。其中 0 和 4095 是保留的,实际可用的主设备号范围是 1~4094。内核社区有个文档叫 Documentation/admin-guide/devices.txt,里面记录了哪些主设备号已经被分配了。

4.2 静态申请:老派但可控

静态申请,就是你自己指定一个主设备号,然后告诉内核:「我要用这个号,你别给别人。」

函数原型很简单:

int register_chrdev_region(dev_t from, unsigned count, const char *name);
  • from:起始设备号,用 MKDEV(主, 次) 生成
  • count:要申请的连续设备号个数
  • name:设备名称,会在 /proc/devices 里显示

举个例子:

dev_t dev_num;
int ret;

dev_num = MKDEV(240, 0);  // 主设备号240,起始次设备号0
ret = register_chrdev_region(dev_num, 4, "my_driver");
if (ret < 0) {
    printk("设备号申请失败!\n");
    return ret;
}
// 成功申请了 240,0 ~ 240,3 四个设备号

注意:静态申请最大的坑就是「撞号」。我曾经在一个项目里用了 240 这个主设备号,结果客户的内核里有个第三方驱动也用了 240。加载模块时直接报错,查了半天才发现是设备号冲突。所以,除非你确定这个号没人用,否则别用静态申请。

什么时候适合静态申请?我个人习惯是在以下场景用:

  • 设备号已经在内核社区注册了(比如你写的是标准驱动)
  • 嵌入式设备,你知道整个系统里没有其他驱动用这个号
  • 教学或实验环境,图省事

4.3 动态分配:现代驱动的首选

动态分配,就是让内核帮你挑一个空闲的主设备号。你只管说「我要几个设备号」,内核给你分配好了告诉你。

函数原型:

int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name);
  • dev:输出参数,分配到的起始设备号
  • baseminor:起始次设备号,一般填 0
  • count:要申请的个数
  • name:设备名称

代码示例:

dev_t dev_num;
int ret;

ret = alloc_chrdev_region(&dev_num, 0, 4, "my_driver");
if (ret < 0) {
    printk("动态分配设备号失败\n");
    return ret;
}
printk("分配到的设备号:主=%d, 次=%d\n", 
       MAJOR(dev_num), MINOR(dev_num));

我的建议:现在写新驱动,99% 的情况都用动态分配。内核会从 234 开始往上分配主设备号,基本不会冲突。而且用户空间可以通过 /sys/class/... 或者 udev 规则自动创建设备节点,根本不需要关心主设备号是多少。

动态分配的好处很明显:

  • 避免冲突,内核帮你管理
  • 代码可移植性好,换个内核也不用改
  • 配合 udev/mdev 自动创建设备节点,用户体验好

4.4 查看已注册设备号

怎么知道当前系统里哪些设备号已经被占用了?两个地方可以看:

1. /proc/devices

这个文件列出了所有已注册的字符设备和块设备的主设备号:

$ cat /proc/devices
Character devices:
  1 mem
  4 /dev/vc/0
  4 tty
  4 ttyS
  5 /dev/tty
  5 /dev/console
  5 /dev/ptmx
 10 misc
 13 input
 29 fb
 89 i2c
 90 mtd
116 alsa
128 ptm
136 pts
180 usb
189 usb_device
...
Block devices:
  8 sd
  9 md
 11 sr
 65 sd
 66 sd
...

2. /sys/class/... 下的设备信息

如果你知道设备属于哪个 class,可以看 sysfs:

$ ls -l /sys/class/misc/device_name/dev
crw-rw-rw- 1 root root 10, 50 12月 12 10:00 /sys/class/misc/device_name/dev

这里 10 是主设备号,50 是次设备号。

调试小技巧:我经常在驱动初始化时加一行打印:

printk("设备号注册成功:%d:%d\n", MAJOR(dev_num), MINOR(dev_num));

然后用 dmesg | tail 查看。这样不用去翻 /proc/devices,直接就知道分配到了哪个号。

4.5 释放设备号

有申请就有释放。模块卸载时,一定要把设备号还回去:

void unregister_chrdev_region(dev_t from, unsigned count);

示例:

static int __init my_init(void)
{
    // 动态分配
    alloc_chrdev_region(&dev_num, 0, 1, "my_dev");
    // ... 其他初始化
    return 0;
}

static void __exit my_exit(void)
{
    // 释放设备号
    unregister_chrdev_region(dev_num, 1);
    // ... 其他清理
}

module_init(my_init);
module_exit(my_exit);

我曾经踩过的坑:有一次写驱动,忘了释放设备号。模块卸载后重新加载,发现设备号被占用了,alloc_chrdev_region 返回 -EBUSY。查了半天才发现是上次卸载时没调用 unregister_chrdev_region。所以,一定要在 exit 函数里释放设备号,这是基本功。

4.6 知识体系图

下面这张图总结了设备号管理的核心流程:

设备号管理核心流程 设备号 dev_t (32位) 主设备号(12位) | 次设备号(20位) 选择方式 静态申请 register_chrdev_region() 动态分配 alloc_chrdev_region() 设备号注册成功 查看方式:/proc/devices | /sys/class/.../dev 卸载时释放:unregister_chrdev_region()

4.7 总结一下

设备号管理其实就三件事:

  1. 理解主次设备号:主设备号找驱动,次设备号找设备实例
  2. 选择申请方式:新驱动用动态分配,特殊场景用静态
  3. 记得释放:有借有还,再借不难

我个人习惯是:所有新写的驱动都用 alloc_chrdev_region 动态分配。只有在给 legacy 硬件写驱动,或者需要固定设备号做兼容时,才用静态申请。你想想看,内核社区那么多驱动,动态分配能避免 99% 的冲突问题,何乐而不为呢?

最后一个小建议:写驱动时,把设备号相关的操作封装成函数。比如 mydev_alloc_chrdev() 和 mydev_free_chrdev()。这样代码清晰,也方便复用。我早期写的驱动都是散落在 init 和 exit 函数里,后来维护起来特别痛苦。封装一下,清爽多了。


可可精品资料库