3. 字符设备驱动框架:cdev结构体、设备号注册与注销、file_operations接口

字符设备是Linux驱动模型中最基础、最常用的设备类型,其核心特征是按字节流顺序访问,典型的字符设备包括串口、GPIO、I2C、SPI以及各类传感器。本章将深入剖析字符设备驱动的三大支柱:cdev结构体(内核中字符设备的抽象)、设备号(用户空间与内核空间的桥梁)以及file_operations接口(系统调用与驱动行为的映射表)。

核心要点:字符设备驱动的本质是“注册+回调”——驱动向内核注册设备号与操作函数,当用户程序通过系统调用访问设备节点时,内核自动回调驱动中对应的函数。

3.1 字符设备驱动整体架构

字符设备驱动的完整生命周期包含三个关键阶段:设备号分配、cdev初始化与注册、file_operations实现。下图展示了字符设备驱动在Linux内核中的位置与数据流关系:

字符设备驱动整体架构图 用户空间 应用程序(open/read/write/ioctl/close) VFS 虚拟文件系统层 内核空间 cdev 结构体 设备号 + ops指针 file_operations open/read/write/ioctl 设备号 主设备号:次设备号 硬件设备(寄存器/外设) 说明:用户程序通过VFS系统调用,内核根据设备号查找cdev,再通过file_operations回调驱动函数,最终操作硬件。

3.2 设备号:主设备号与次设备号

设备号是字符设备在内核中的唯一标识,由主设备号(major)和次设备号(minor)两部分组成,类型为 dev_t(32位无符号整数)。

组成部分 位数 作用 分配方式
主设备号(major) 高12位 标识设备驱动程序类型 静态分配(register_chrdev_region)或动态分配(alloc_chrdev_region)
次设备号(minor) 低20位 区分同一驱动下的不同设备实例 由驱动开发者自行规划
最佳实践:推荐使用动态分配(alloc_chrdev_region),避免与系统中已有设备号冲突。动态分配时,主设备号由内核自动分配,驱动只需关注次设备号的规划。

3.2.1 设备号操作宏

Linux内核提供了一组宏,用于在 dev_t 与主/次设备号之间进行转换:

#include <linux/types.h>   /* dev_t */
#include <linux/kdev_t.h>  /* MAJOR/MINOR/MKDEV */

dev_t devno = MKDEV(major, minor);  /* 将主次设备号合并为dev_t */
int major = MAJOR(devno);           /* 从dev_t中提取主设备号 */
int minor = MINOR(devno);           /* 从dev_t中提取次设备号 */

3.2.2 设备号注册与注销

设备号的注册与注销是字符设备驱动的“入场”与“退场”操作,必须在模块加载/卸载函数中成对出现:

#include <linux/fs.h>

/* 静态分配:已知主设备号 */
int register_chrdev_region(dev_t first, unsigned int count, const char *name);

/* 动态分配:由内核分配主设备号 */
int alloc_chrdev_region(dev_t *dev, unsigned int firstminor, unsigned int count, const char *name);

/* 注销设备号(两者通用) */
void unregister_chrdev_region(dev_t first, unsigned int count);
函数 适用场景 返回值 注意事项
register_chrdev_region 已知主设备号(如调试或固定设备) 0成功,负值失败 需确保主设备号未被占用
alloc_chrdev_region 不关心主设备号(推荐) 0成功,负值失败 通过 MAJOR(*dev) 获取分配的主设备号
常见错误:忘记在模块卸载时调用 unregister_chrdev_region,会导致设备号泄漏,下次加载模块时可能分配失败。务必在 module_exit 中释放。

3.3 cdev结构体:字符设备的内核抽象

struct cdev 是内核用于管理字符设备的核心数据结构,定义在 <linux/cdev.h> 中。每个字符设备在内核中对应一个 cdev 实例。

struct cdev {
    struct kobject kobj;          /* 内嵌的内核对象,用于引用计数 */
    struct module *owner;         /* 指向所属模块,通常设为 THIS_MODULE */
    const struct file_operations *ops;  /* 指向设备操作函数表 */
    struct list_head list;        /* 用于将cdev链接到系统链表 */
    dev_t dev;                    /* 设备号 */
    unsigned int count;           /* 关联的次设备号数量 */
};

3.3.1 cdev的初始化与注册

cdev的初始化有两种方式,推荐使用 cdev_init 配合 cdev_add 的两步法:

#include <linux/cdev.h>

/* 方式一:两步初始化(推荐) */
struct cdev my_cdev;

/* 步骤1:初始化cdev,绑定file_operations */
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;

/* 步骤2:将cdev添加到内核,关联设备号 */
int ret = cdev_add(&my_cdev, devno, count);
if (ret < 0) {
    /* 错误处理 */
}

/* 方式二:一步初始化(较少用) */
struct cdev *cdev_alloc(void);  /* 动态分配cdev */
cdev->ops = &my_fops;
cdev->owner = THIS_MODULE;
函数 功能 调用时机
cdev_init 初始化cdev结构体,绑定ops 设备号注册之后,cdev_add之前
cdev_add 将cdev注册到内核,使设备可用 所有初始化完成后
cdev_del 从内核移除cdev 模块卸载时,unregister_chrdev_region之前

3.3.2 cdev的注销

在模块卸载时,必须按照与注册相反的顺序释放资源:

/* 先移除cdev */
cdev_del(&my_cdev);

/* 再注销设备号 */
unregister_chrdev_region(devno, count);
关键顺序:设备号注册 → cdev_init → cdev_add → (设备使用中) → cdev_del → 设备号注销。顺序颠倒会导致内核崩溃或资源泄漏。

3.4 file_operations接口:系统调用到驱动函数的映射

struct file_operations 是字符设备驱动的“函数跳转表”,定义了用户空间系统调用与内核驱动函数之间的映射关系。当用户程序调用 open()、read()、write()、ioctl() 等函数时,VFS层通过该结构体找到对应的驱动函数并执行。

3.4.1 核心成员函数

系统调用 file_operations成员 函数原型 典型用途
open() .open int (*open)(struct inode *, struct file *) 初始化设备、递增引用计数
release() .release int (*release)(struct inode *, struct file *) 释放资源、递减引用计数
read() .read ssize_t (*read)(struct file *, char __user *, size_t, loff_t *) 从设备读取数据到用户空间
write() .write ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *) 从用户空间写入数据到设备
ioctl() .unlocked_ioctl long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long) 设备控制命令(设置参数、获取状态)
llseek() .llseek loff_t (*llseek)(struct file *, loff_t, int) 调整文件读写位置
注意:在Linux 2.6.36之后,.ioctl 已被移除,统一使用 .unlocked_ioctl(无需大内核锁)和 .compat_ioctl(32位兼容)。驱动中只需实现 .unlocked_ioctl 即可。

3.4.2 典型file_operations实现模板

#include <linux/fs.h>
#include <linux/uaccess.h>  /* copy_to_user / copy_from_user */

/* 设备打开 */
static int my_open(struct inode *inode, struct file *filp)
{
    /* 获取设备私有数据 */
    struct my_device *dev = container_of(inode->i_cdev, struct my_device, cdev);
    filp->private_data = dev;  /* 保存到file结构体 */
    return 0;
}

/* 设备释放 */
static int my_release(struct inode *inode, struct file *filp)
{
    return 0;
}

/* 从设备读取数据 */
static ssize_t my_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos)
{
    struct my_device *dev = filp->private_data;
    size_t len = min(count, dev->data_len);
    
    if (copy_to_user(buf, dev->data, len))
        return -EFAULT;
    
    *f_pos += len;
    return len;
}

/* 向设备写入数据 */
static ssize_t my_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos)
{
    struct my_device *dev = filp->private_data;
    size_t len = min(count, sizeof(dev->data));
    
    if (copy_from_user(dev->data, buf, len))
        return -EFAULT;
    
    dev->data_len = len;
    *f_pos += len;
    return len;
}

/* 设备控制命令 */
static long my_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
    struct my_device *dev = filp->private_data;
    
    switch (cmd) {
    case MY_IOCTL_SET:
        /* 处理设置命令 */
        break;
    case MY_IOCTL_GET:
        /* 处理获取命令 */
        break;
    default:
        return -ENOTTY;  /* 不支持的命令 */
    }
    return 0;
}

/* 文件操作结构体 */
static const struct file_operations my_fops = {
    .owner          = THIS_MODULE,
    .open           = my_open,
    .release        = my_release,
    .read           = my_read,
    .write          = my_write,
    .unlocked_ioctl = my_ioctl,
};

3.5 完整注册流程与SVG流程图

字符设备驱动的完整注册流程可以归纳为以下步骤,每一步都对应一个内核API调用:

字符设备驱动注册与注销完整流程 注册流程(module_init) 注销流程(module_exit) ① alloc_chrdev_region() ② cdev_init() 绑定fops ③ cdev_add() 注册到内核 ④ 设备就绪,等待用户访问 ① cdev_del() 从内核移除 ② unregister_chrdev_region() ③ 设备号释放,资源回收 ④ 模块安全卸载 顺序相反 关键注意事项 1. 注册与注销必须严格逆序,否则可能导致内核崩溃。 2. cdev_add 之后,设备立即可被用户访问,因此所有初始化必须在 cdev_add 之前完成。 3. 每个 cdev_add 必须对应一个 cdev_del,每个 alloc_chrdev_region 必须对应一个 unregister_chrdev_region。 4. 建议在 cdev_add 失败时,立即调用 unregister_chrdev_region 回滚设备号分配。 5. 使用 container_of 宏从 inode->i_cdev 获取设备私有结构体,是驱动开发的标准做法。

3.6 完整示例代码

以下是一个完整的字符设备驱动框架示例,整合了设备号分配、cdev注册和file_operations实现:

#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/uaccess.h>
#include <linux/slab.h>

#define DEVICE_NAME "my_char_dev"
#define CLASS_NAME  "my_class"

/* 设备私有结构体 */
struct my_device {
    struct cdev cdev;
    char data[256];
    size_t data_len;
};

static struct my_device *my_dev;
static dev_t dev_num;

/* file_operations 实现 */
static int my_open(struct inode *inode, struct file *filp)
{
    struct my_device *dev = container_of(inode->i_cdev, struct my_device, cdev);
    filp->private_data = dev;
    return 0;
}

static int my_release(struct inode *inode, struct file *filp)
{
    return 0;
}

static ssize_t my_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos)
{
    struct my_device *dev = filp->private_data;
    size_t len = min(count, dev->data_len - (size_t)*f_pos);
    
    if (len == 0) return 0;
    if (copy_to_user(buf, dev->data + *f_pos, len))
        return -EFAULT;
    
    *f_pos += len;
    return len;
}

static ssize_t my_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos)
{
    struct my_device *dev = filp->private_data;
    size_t len = min(count, sizeof(dev->data) - (size_t)*f_pos);
    
    if (copy_from_user(dev->data + *f_pos, buf, len))
        return -EFAULT;
    
    dev->data_len = *f_pos + len;
    *f_pos += len;
    return len;
}

static const struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .release = my_release,
    .read    = my_read,
    .write   = my_write,
};

/* 模块初始化 */
static int __init my_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 device number: major=%d, minor=%d\n",
            MAJOR(dev_num), MINOR(dev_num));

    /* 2. 分配设备结构体 */
    my_dev = kzalloc(sizeof(*my_dev), GFP_KERNEL);
    if (!my_dev) {
        unregister_chrdev_region(dev_num, 1);
        return -ENOMEM;
    }

    /* 3. 初始化cdev并注册 */
    cdev_init(&my_dev->cdev, &my_fops);
    my_dev->cdev.owner = THIS_MODULE;
    ret = cdev_add(&my_dev->cdev, dev_num, 1);
    if (ret < 0) {
        pr_err("Failed to add cdev\n");
        kfree(my_dev);
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }

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

/* 模块卸载 */
static void __exit my_exit(void)
{
    cdev_del(&my_dev->cdev);
    kfree(my_dev);
    unregister_chrdev_region(dev_num, 1);
    pr_info("Character device driver unloaded\n");
}

module_init(my_init);
module_exit(my_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Kernel Developer");
MODULE_DESCRIPTION("A simple character device driver example");
编译与测试:将上述代码保存为 my_char_dev.c,编写Makefile编译为内核模块。加载后使用 cat /proc/devices 查看设备号,使用 mknod /dev/mydev c major 0 创建设备节点,然后通过 echo "hello" > /dev/mydev 和 cat /dev/mydev 测试读写功能。

3.7 本章小结

字符设备驱动框架是Linux驱动开发的基础,理解其核心三要素至关重要:

  • 设备号:用户空间与内核空间的桥梁,推荐使用动态分配避免冲突。
  • cdev结构体:字符设备在内核中的抽象,通过 cdev_init + cdev_add 注册,cdev_del 注销。
  • file_operations接口:系统调用到驱动函数的映射表,必须实现 .owner = THIS_MODULE。