2. 字符设备驱动框架

字符设备是 Linux 驱动中最基础的设备类型,其驱动框架围绕 file_operations、dev_t(主次设备号)和 cdev 结构体展开。理解这三者的关系,是编写任何字符设备驱动的第一步。

2.1 核心数据结构总览

字符设备驱动的用户态接口通过 VFS(虚拟文件系统)映射到内核驱动。下图展示了应用层 open/read/write/close 系统调用如何通过 file_operations 回调进入驱动层:

字符设备驱动整体架构图 用户空间 应用程序:open("/dev/mydev", O_RDWR) → read() → write() → close() 内核空间 VFS 层 sys_open → 查找 inode → 获取 cdev → 调用 file_operations.open 驱动层:file_operations 结构体实例 .owner = THIS_MODULE .open = my_open, .release = my_release .read = my_read, .write = my_write 硬件层:GPIO 寄存器 / I2C 控制器

2.2 file_operations 结构体

file_operations 是驱动与 VFS 之间的契约。驱动开发者通过填充该结构体的函数指针,将系统调用映射到具体的硬件操作。

成员 函数原型 说明
.owner struct module *owner 通常设为 THIS_MODULE,防止模块被卸载时仍有打开的文件
.open int (*open)(struct inode *, struct file *) 设备打开时调用,初始化硬件或递增引用计数
.release int (*release)(struct inode *, struct file *) 设备关闭时调用,释放资源、递减引用计数
.read ssize_t (*read)(struct file *, char __user *, size_t, loff_t *) 从设备读取数据,需使用 copy_to_user 安全拷贝
.write ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *) 向设备写入数据,需使用 copy_from_user 安全拷贝
.unlocked_ioctl long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long) 设备控制命令接口,用于配置 GPIO 方向、I2C 速率等
.llseek loff_t (*llseek)(struct file *, loff_t, int) 文件定位操作,字符设备通常设为 no_llseek 或 default_llseek
实战要点:在 GPIO 驱动中,.read 通常返回引脚电平值(0 或 1);.write 设置引脚输出电平;.unlocked_ioctl 用于配置方向(输入/输出)和上下拉。在 I2C 驱动中,.read/.write 封装 I2C 总线传输协议。

2.3 主次设备号

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

  • 主设备号:标识设备对应的驱动。同一类设备共享主设备号(如所有 GPIO 设备可共用同一主设备号)。
  • 次设备号:标识同一驱动下的不同设备实例。例如,GPIO 芯片的 8 个引脚可分别使用次设备号 0~7。

设备号分配有两种方式:

方式 函数 适用场景
静态分配 register_chrdev_region(dev_t from, unsigned count, const char *name) 已知主设备号(如调试或平台固定分配),需在 /proc/devices 中确认未占用
动态分配 alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name) 推荐方式,内核自动分配未使用的主设备号,避免冲突
推荐做法:产品级驱动始终使用 alloc_chrdev_region 动态分配。主设备号由内核管理,驱动无需关心具体数值。次设备号从 0 开始连续分配,每个设备实例对应一个次设备号。

2.4 cdev 结构体:注册与注销

cdev 是内核中表示字符设备的对象。驱动必须将 cdev 注册到内核,VFS 才能通过设备号找到对应的 file_operations。

注册流程分为以下步骤:

  1. 分配设备号:调用 alloc_chrdev_region 或 register_chrdev_region。
  2. 初始化 cdev:调用 cdev_init(&cdev, &fops),关联 file_operations。
  3. 添加 cdev 到内核:调用 cdev_add(&cdev, devno, count),使设备号与 cdev 绑定。
  4. 注销时逆序操作:先 cdev_del,再 unregister_chrdev_region。
cdev 注册与注销流程图 注册流程(模块加载) 1. alloc_chrdev_region() 2. cdev_init(&cdev, &fops) 3. cdev_add(&cdev, devno, count) 4. 设备节点创建(见 2.5 节) 注销流程(模块卸载) 1. 删除设备节点(用户态操作) 2. cdev_del(&cdev) 3. unregister_chrdev_region(devno, count)

2.5 设备节点创建

设备节点是用户空间访问驱动的入口文件,位于 /dev 目录下。节点创建有两种方式:

方式 命令/机制 特点
手动创建 mknod /dev/mydev c 240 0 调试阶段使用,主设备号需手动指定,不灵活
自动创建 udev + class_create + device_create 产品级方案,模块加载时内核自动通知 udev 创建设备节点

自动创建设备节点的典型代码片段:

/* 在模块加载函数中 */
static struct class *my_class;
static struct device *my_device;

/* 1. 创建设备类 */
my_class = class_create(THIS_MODULE, "my_device_class");

/* 2. 创建设备节点,内核自动生成 /dev/mydev0 */
my_device = device_create(my_class, NULL, devno, NULL, "mydev%d", 0);

/* 在模块卸载函数中 */
device_destroy(my_class, devno);   /* 销毁设备 */
class_destroy(my_class);           /* 销毁类 */
常见错误:忘记调用 device_destroy 和 class_destroy 会导致 /dev 下残留僵尸节点,下次加载模块时 udev 可能无法正确创建新节点。务必在注销函数中逆序释放。

2.6 完整驱动框架代码模板

以下是一个最小字符设备驱动的完整框架,整合了上述所有要素:

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

#define DEVICE_NAME "my_gpio"
#define CLASS_NAME  "gpio_class"

static dev_t dev_num;
static struct cdev my_cdev;
static struct class *my_class;
static struct device *my_device;

/* file_operations 回调实现 */
static int my_open(struct inode *inode, struct file *file) {
    pr_info("Device opened\n");
    return 0;
}

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

static ssize_t my_read(struct file *file, char __user *buf,
                       size_t len, loff_t *offset) {
    char data = '1';  /* 模拟 GPIO 高电平 */
    if (copy_to_user(buf, &data, 1))
        return -EFAULT;
    return 1;
}

static ssize_t my_write(struct file *file, const char __user *buf,
                        size_t len, loff_t *offset) {
    char data;
    if (copy_from_user(&data, buf, 1))
        return -EFAULT;
    pr_info("Received data: %c\n", data);
    return len;
}

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

/* 模块初始化 */
static int __init my_driver_init(void) {
    /* 1. 动态分配设备号 */
    alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);

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

    /* 3. 添加 cdev 到内核 */
    cdev_add(&my_cdev, dev_num, 1);

    /* 4. 自动创建设备节点 */
    my_class = class_create(THIS_MODULE, CLASS_NAME);
    my_device = device_create(my_class, NULL, dev_num, NULL, DEVICE_NAME);

    pr_info("Driver loaded, major=%d minor=%d\n",
            MAJOR(dev_num), MINOR(dev_num));
    return 0;
}

/* 模块退出 */
static void __exit my_driver_exit(void) {
    device_destroy(my_class, dev_num);
    class_destroy(my_class);
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
    pr_info("Driver unloaded\n");
}

module_init(my_driver_init);
module_exit(my_driver_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Driver Developer");
MODULE_DESCRIPTION("Minimal GPIO Character Device Driver");
编译与测试:将上述代码保存为 my_gpio_drv.c,编写 Makefile 编译为内核模块。加载后执行 ls -l /dev/my_gpio 确认节点存在,使用 echo 1 > /dev/my_gpio 和 cat /dev/my_gpio 测试读写。

2.7 本章小结

  • file_operations 是驱动与 VFS 的接口契约,必须实现 .open/.release/.read/.write 等回调。
  • 设备号 由主设备号(驱动标识)和次设备号(实例标识)组成,推荐使用 alloc_chrdev_region 动态分配。
  • cdev 注册 三步曲:cdev_init → cdev_add → 设备节点创建;注销时逆序操作。
  • 设备节点 通过 class_create + device_create 自动创建,产品级驱动必须使用此方式。