4. 字符设备驱动基础:file_operations结构体、主次设备号、cdev_init与cdev_add

字符设备是嵌入式Linux驱动中最基础、最常用的设备类型,其核心交互模型为“用户空间通过文件I/O操作访问硬件”。本章将深入剖析字符设备驱动的三大支柱:file_operations结构体、设备号管理机制以及cdev内核对象的初始化与注册流程。

学习目标:理解字符设备驱动的抽象层次,掌握从定义操作函数到注册设备到内核的完整链路,能够独立编写一个具备基本读写功能的字符设备驱动模板。

4.1 file_operations结构体:驱动与VFS的契约

file_operations是字符设备驱动的“函数跳转表”,它定义了用户空间系统调用(如open、read、write)如何映射到驱动层的具体实现函数。该结构体位于<linux/fs.h>,是驱动开发者与虚拟文件系统(VFS)之间的二进制接口契约。

核心概念:VFS不关心底层硬件细节,只通过file_operations指针调用驱动提供的回调函数。驱动只需填充该结构体,即可“挂载”到Linux文件系统体系中。

4.1.1 关键成员与典型实现

成员名称 系统调用触发 驱动职责 典型返回值
owner 模块引用计数 设为THIS_MODULE,防止模块在使用中被卸载 —
open open() 初始化设备、分配资源、递增使用计数 0(成功)或负错误码
release close() 释放资源、递减使用计数、关闭硬件 0
read read() 从硬件读取数据并拷贝到用户空间 读取字节数(≥0)
write write() 从用户空间接收数据并写入硬件 写入字节数(≥0)
unlocked_ioctl ioctl() 处理自定义控制命令(无大内核锁) 0或命令特定值
llseek lseek() 修改文件读写位置(字符设备通常不支持) 新位置或负错误码
// 典型的file_operations定义示例
static int mydrv_open(struct inode *inode, struct file *filp) {
    pr_info("Device opened\n");
    return 0;
}

static int mydrv_release(struct inode *inode, struct file *filp) {
    pr_info("Device closed\n");
    return 0;
}

static ssize_t mydrv_read(struct file *filp, char __user *buf,
                           size_t count, loff_t *f_pos) {
    // 从内核缓冲区拷贝数据到用户空间
    if (copy_to_user(buf, kernel_buf, count))
        return -EFAULT;
    return count;
}

static ssize_t mydrv_write(struct file *filp, const char __user *buf,
                            size_t count, loff_t *f_pos) {
    // 从用户空间拷贝数据到内核缓冲区
    if (copy_from_user(kernel_buf, buf, count))
        return -EFAULT;
    return count;
}

// 定义file_operations结构体实例
static const struct file_operations mydrv_fops = {
    .owner   = THIS_MODULE,
    .open    = mydrv_open,
    .release = mydrv_release,
    .read    = mydrv_read,
    .write   = mydrv_write,
};
⚠️ 关键约束:read和write回调中,用户空间缓冲区指针buf标记为__user,禁止直接解引用。必须使用copy_to_user()/copy_from_user()内核API进行安全数据拷贝,否则会导致内核崩溃或安全漏洞。

4.1.2 file_operations与VFS交互流程

用户空间系统调用 → VFS → file_operations 调用链路 用户程序 read(fd, buf, len) VFS 层 sys_read() 驱动层 mydrv_read() 系统调用 fops->read file_operations 结构体在VFS中的角色 1. 用户程序调用 read() → 触发软中断进入内核态 2. VFS 根据文件描述符 fd 找到对应的 struct file 对象 3. struct file 中的 f_op 指针指向驱动注册的 file_operations 4. VFS 调用 f_op->read() → 执行驱动自定义的读取逻辑 5. 驱动通过 copy_to_user() 将数据返回用户空间

4.2 主次设备号:设备的“身份证”

设备号是内核识别字符设备的唯一标识,由主设备号(major)和次设备号(minor)组成,类型为dev_t(32位无符号整数,高12位为主设备号,低20位为次设备号)。

4.2.1 设备号分配策略

分配方式 API 适用场景 优点 缺点
静态分配 register_chrdev_region() 已知固定主设备号(如已分配的设备) 设备号固定,用户空间可预测 可能与其他驱动冲突
动态分配 alloc_chrdev_region() 大多数驱动开发场景 内核自动分配,无冲突风险 设备号不固定,需通过/proc/devices查看
// 动态分配设备号示例
dev_t dev_num;
int major, minor;

// 动态分配一个设备号,次设备号从0开始,数量为1
if (alloc_chrdev_region(&dev_num, 0, 1, "mydrv") < 0) {
    pr_err("Failed to allocate device number\n");
    return -EIO;
}

major = MAJOR(dev_num);  // 提取主设备号
minor = MINOR(dev_num);  // 提取次设备号
pr_info("Allocated major=%d, minor=%d\n", major, minor);

// 模块卸载时释放
unregister_chrdev_region(dev_num, 1);
💡 最佳实践:优先使用alloc_chrdev_region()动态分配。用户空间可通过cat /proc/devices查看动态分配的主设备号,然后使用mknod手动创建设备节点。在设备树和自动设备节点管理场景中,动态分配是标准做法。

4.2.2 设备号与设备节点的关系

设备号 → 设备节点 → 驱动 映射关系 /dev/mydrv0 主=250 次=0 内核设备号表 dev_t = 0x00FA0000 mydrv 驱动 cdev + fops mknod 记录 主设备号匹配 设备号解析流程 1. 用户打开 /dev/mydrv0 → VFS 读取 inode 中的设备号 (250, 0) 2. 内核根据主设备号 250 查找已注册的 cdev 链表 3. 找到对应驱动后,调用其 file_operations 中的 open 函数 4. 次设备号传递给驱动,用于区分同一驱动下的不同硬件实例

4.3 cdev_init与cdev_add:驱动注册的核心步骤

cdev(字符设备)是内核用于管理字符设备驱动的核心数据结构。驱动必须完成初始化和注册两个步骤,才能将file_operations与设备号绑定,使设备对用户空间可见。

4.3.1 cdev生命周期管理

步骤 API 功能 注意事项
1. 分配cdev cdev_alloc() 或静态定义 获取struct cdev对象 静态定义需确保生命周期
2. 初始化cdev cdev_init() 关联file_operations,设置owner 必须在cdev_add之前调用
3. 注册cdev cdev_add() 将cdev加入内核设备表,设备号生效 成功后设备立即可用
4. 注销cdev cdev_del() 从内核设备表移除cdev 在模块卸载时调用
// 完整的字符设备驱动注册模板
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/slab.h>

#define DEVICE_NAME "mydrv"
#define CLASS_NAME  "mydrv_class"

static struct cdev my_cdev;
static dev_t dev_num;

// file_operations 定义(略,见4.1.1节)

static int __init mydrv_init(void) {
    int ret;

    // 步骤1:动态分配设备号
    ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
    if (ret < 0) {
        pr_err("Failed to allocate device number\n");
        return ret;
    }
    pr_info("Allocated major=%d minor=%d\n", MAJOR(dev_num), MINOR(dev_num));

    // 步骤2:初始化cdev,关联file_operations
    cdev_init(&my_cdev, &mydrv_fops);
    my_cdev.owner = THIS_MODULE;

    // 步骤3:注册cdev到内核
    ret = cdev_add(&my_cdev, dev_num, 1);
    if (ret < 0) {
        pr_err("Failed to add cdev\n");
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }

    pr_info("mydrv driver loaded successfully\n");
    return 0;
}

static void __exit mydrv_exit(void) {
    // 步骤4:注销cdev
    cdev_del(&my_cdev);
    // 释放设备号
    unregister_chrdev_region(dev_num, 1);
    pr_info("mydrv driver unloaded\n");
}

module_init(mydrv_init);
module_exit(mydrv_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Embedded Linux Expert");
MODULE_DESCRIPTION("A simple character device driver example");

4.3.2 cdev_init与cdev_add的底层机制

cdev_init → cdev_add 注册流程 cdev_init() 初始化 kobject 关联 fops 指针 cdev_add() 设备号绑定 插入 cdev_map 哈希表 内核设备表 chrdevs[] 设备可用 cdev_init 与 cdev_add 内部行为 cdev_init(struct cdev *cdev, const struct file_operations *fops) • 将 cdev->ops 指向 fops 结构体 • 初始化 cdev->kobj(内核对象基类) • 设置 cdev->owner = THIS_MODULE(若未手动设置) cdev_add(struct cdev *cdev, dev_t dev, unsigned int count) • 验证 dev 到 dev+count-1 的设备号区间未被占用 • 将 cdev 插入内核的 cdev_map(主设备号索引的哈希表) • 调用 kobject_add() 将 cdev 加入设备模型层次结构 ⚠ 成功返回0后,设备立即可被用户空间打开访问

4.4 完整驱动注册流程总结

将上述三个核心要素串联起来,字符设备驱动的注册遵循以下六步法:

  1. 定义 file_operations:实现 open/release/read/write 等回调函数,填充结构体。
  2. 分配设备号:使用 alloc_chrdev_region() 动态获取 dev_t。
  3. 初始化 cdev:调用 cdev_init() 将 file_operations 绑定到 cdev 对象。
  4. 注册 cdev:调用 cdev_add() 将设备号与 cdev 关联并加入内核。
  5. 创建设备节点(用户空间操作):通过 mknod 或 udev 自动创建 /dev/xxx。
  6. 注销清理:模块卸载时依次调用 cdev_del() 和 unregister_chrdev_region()。
关键检查点:在cdev_add()调用之前,驱动对用户空间完全不可见;一旦cdev_add()成功返回,任何用户程序都可以通过设备节点打开并操作该设备。因此,所有初始化工作(硬件复位、缓冲区分配等)必须在cdev_add()之前完成,否则可能出现设备已打开但驱动尚未就绪的竞态条件。

4.4.1 常见错误与调试

错误现象 可能原因 解决方案
insmod成功但open返回-ENODEV cdev_add()未调用或失败 检查cdev_add返回值,确认设备号未被占用
open成功但read/write返回-EFAULT copy_to_user/copy_from_user参数错误 检查用户空间缓冲区指针是否标记__user,地址是否有效
模块卸载时系统崩溃 设备仍在被使用,cdev_del后资源释放顺序错误 确保所有open计数归零后再卸载,使用try_module_get/module_put
/proc/devices看不到设备 alloc_chrdev_region未调用或失败 检查alloc_chrdev_region返回值,确认名称参数正确
⚠️ 安全提示:字符设备驱动运行在内核空间,任何空指针解引用、缓冲区溢出、用户地址直接访问都会导致内核Oops或panic。建议在开发阶段启用CONFIG_DEBUG_KERNEL和CONFIG_DEBUG_SLAB内核配置选项,并使用dmesg实时监控内核日志。