4. 字符设备核心数据结构

字符设备驱动的核心是内核如何标识、注册和暴露设备给用户空间。本章围绕三个关键要素展开:设备号(dev_t)、设备号分配函数以及字符设备对象(struct cdev)。理解这三者的关系是编写任何字符设备驱动的基础。

核心概念速览: 每个字符设备在内核中由 设备号 唯一标识;设备号由 主设备号(驱动类型)和 次设备号(设备实例)组成;struct cdev 是内核中代表字符设备的对象,必须与设备号绑定后才能向系统注册。

4.1 设备号:dev_t 与主/次设备号

内核使用 dev_t 类型(在 <linux/types.h> 中定义)来保存设备号。它是一个 32 位无符号整数,其中高 12 位表示主设备号,低 20 位表示次设备号。

概念 位宽 取值范围 作用
主设备号 (major) 12 位 0 ~ 4095 标识设备对应的驱动程序
次设备号 (minor) 20 位 0 ~ 1048575 标识由同一驱动管理的具体设备实例

内核提供了两个宏来从 dev_t 中提取主、次设备号,以及用主、次设备号组合成 dev_t:

#include <linux/kdev_t.h>

// 从 dev_t 中提取主设备号
unsigned int major = MAJOR(dev);

// 从 dev_t 中提取次设备号
unsigned int minor = MINOR(dev);

// 用主、次设备号组合成 dev_t
dev_t dev = MKDEV(major, minor);
经验提示: 在驱动代码中始终使用 MAJOR() 和 MINOR() 宏来操作设备号,不要直接对 dev_t 进行位运算,以保证代码在不同内核版本间的可移植性。

4.2 设备号分配策略:静态 vs 动态

驱动开发者面临两种设备号分配方式:静态分配(手动指定)和动态分配(内核自动分配)。下表对比了两种方式的优劣:

分配方式 函数 优点 缺点 适用场景
静态分配 register_chrdev_region() 设备号固定,便于用户空间创建节点 容易与其它驱动冲突,需向内核社区申请 广泛使用的标准驱动(如 tty、块设备)
动态分配 alloc_chrdev_region() 避免冲突,自动分配空闲设备号 设备号不固定,需通过 /proc/devices 查看 大多数自定义驱动、实验性驱动

4.2.1 静态分配:register_chrdev_region()

当驱动需要固定的、已知的设备号时使用。调用前必须确保主设备号未被占用。

#include <linux/fs.h>

int register_chrdev_region(dev_t first, unsigned int count, const char *name);

// 参数说明:
// first  - 要分配的设备号范围的起始值
// count  - 连续设备号的数量(通常为 1)
// name   - 设备名称(出现在 /proc/devices 中)
// 返回值:0 成功,负值失败

使用示例:

dev_t dev;
int major = 240;  // 假设 240 未被使用

dev = MKDEV(major, 0);
if (register_chrdev_region(dev, 1, "my_device") < 0) {
    pr_err("无法注册设备号 %d\n", major);
    return -EBUSY;
}

4.2.2 动态分配:alloc_chrdev_region()

这是推荐的分配方式,内核自动从空闲区域分配设备号。

int alloc_chrdev_region(dev_t *dev, unsigned int baseminor, unsigned int count, const char *name);

// 参数说明:
// dev        - 输出参数,指向分配到的设备号起始值
// baseminor  - 起始次设备号(通常为 0)
// count      - 连续设备号的数量
// name       - 设备名称
// 返回值:0 成功,负值失败

使用示例:

dev_t dev;

if (alloc_chrdev_region(&dev, 0, 1, "my_device") < 0) {
    pr_err("动态分配设备号失败\n");
    return -ENODEV;
}
pr_info("分配到的设备号:主=%d 次=%d\n", MAJOR(dev), MINOR(dev));
重要提醒: 无论使用哪种分配方式,在模块卸载时都必须调用 unregister_chrdev_region(dev, count) 释放设备号,否则会导致资源泄漏。

4.3 字符设备核心结构:struct cdev

struct cdev 是内核中表示字符设备的核心对象。它封装了设备号、文件操作接口以及内核内部链表信息。

4.3.1 struct cdev 结构体定义

#include <linux/cdev.h>

struct cdev {
    struct kobject kobj;          // 内嵌的内核对象(用于 sysfs)
    struct module *owner;         // 指向所属模块(通常设为 THIS_MODULE)
    const struct file_operations *ops;  // 文件操作函数指针表
    struct list_head list;        // 内核链表节点
    dev_t dev;                    // 设备号
    unsigned int count;           // 设备号数量
};

4.3.2 cdev 的初始化与注册流程

使用 struct cdev 需要遵循固定的三步流程:

  1. 分配 cdev 对象:静态定义或使用 cdev_alloc() 动态分配
  2. 初始化 cdev:调用 cdev_init() 绑定 file_operations
  3. 注册 cdev:调用 cdev_add() 将 cdev 与设备号关联并加入系统
// 步骤1:定义 cdev 对象(静态方式)
static struct cdev my_cdev;

// 步骤2:初始化 cdev,绑定文件操作
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;

// 步骤3:将 cdev 添加到系统,关联设备号
if (cdev_add(&my_cdev, dev, 1) < 0) {
    pr_err("cdev_add 失败\n");
    unregister_chrdev_region(dev, 1);
    return -ENODEV;
}
关键顺序: 必须先分配设备号(alloc_chrdev_region 或 register_chrdev_region),再调用 cdev_add。卸载时顺序相反:先 cdev_del,再 unregister_chrdev_region。

4.4 完整流程:设备号分配与 cdev 注册

下面的 SVG 图展示了从设备号分配到 cdev 注册的完整数据流与函数调用关系:

设备号分配与 cdev 注册完整流程 alloc_chrdev_region() register_chrdev_region() 或 dev_t dev cdev_init(&cdev, &fops) cdev.owner = THIS_MODULE struct cdev 对象 cdev_add() 注册到内核 注册完成 ⚠ 卸载顺序: cdev_del(&cdev) → unregister_chrdev_region(dev, count) 设备号分配必须在 cdev_add 之前完成 cdev_init 只初始化对象,不涉及设备号 cdev_add 将 cdev 与 dev_t 绑定并加入内核字符设备链表

4.5 完整代码模板:字符设备注册

以下是一个完整的字符设备注册代码框架,整合了动态设备号分配、cdev 初始化和注册:

#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>

static dev_t dev_num;
static struct cdev my_cdev;

// 文件操作函数(简化)
static int my_open(struct inode *inode, struct file *filp) {
    return 0;
}

static struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    // ... 其他操作
};

static int __init my_init(void) {
    int ret;

    // 1. 动态分配设备号
    ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
    if (ret < 0) {
        pr_err("设备号分配失败\n");
        return ret;
    }
    pr_info("主设备号: %d\n", MAJOR(dev_num));

    // 2. 初始化 cdev
    cdev_init(&my_cdev, &my_fops);
    my_cdev.owner = THIS_MODULE;

    // 3. 注册 cdev
    ret = cdev_add(&my_cdev, dev_num, 1);
    if (ret < 0) {
        pr_err("cdev_add 失败\n");
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }

    pr_info("字符设备注册成功\n");
    return 0;
}

static void __exit my_exit(void) {
    // 卸载顺序:先删除 cdev,再释放设备号
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
    pr_info("字符设备注销成功\n");
}

module_init(my_init);
module_exit(my_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("字符设备注册示例");

4.6 常见错误与避坑指南

  • 顺序错误: 先 cdev_add 再分配设备号 → 内核崩溃。必须严格遵循:设备号分配 → cdev_init → cdev_add。
  • 设备号泄漏: 在 cdev_add 失败后忘记调用 unregister_chrdev_region → 设备号被永久占用。
  • cdev 重复添加: 对同一个 cdev 对象多次调用 cdev_add → 内核链表混乱。
  • 主设备号冲突: 使用 register_chrdev_region 时指定已被占用的主设备号 → 注册失败。
  • 次设备号溢出: baseminor + count 超过 1048575 → 分配失败。
调试技巧: 模块加载后执行 cat /proc/devices 查看已注册的设备号列表;执行 ls -l /dev 查看设备节点。如果设备号已分配但节点未创建,可使用 mknod /dev/mydevice c major minor 手动创建。