4. 字符设备驱动框架

字符设备是Linux驱动开发中最基础的设备类型,其核心框架围绕file_operations结构体、设备号、cdev接口三大要素构建。本章将系统讲解字符设备驱动的完整骨架,包括设备号的分配与释放、cdev的初始化与添加、以及驱动与VFS(虚拟文件系统)的交互机制。

4.1 字符设备驱动核心架构总览

字符设备驱动的本质是内核模块通过注册cdev结构体,将设备号与file_operations回调函数绑定到VFS层。用户空间的open/read/write/ioctl等系统调用,最终会通过VFS跳转到驱动实现的对应函数。

字符设备驱动核心架构图 用户空间 (User Space) 应用程序:open() / read() / write() / ioctl() VFS (虚拟文件系统) 系统调用分发层 内核驱动层 (Kernel Driver) cdev 结构体 设备号 (dev_t) file_operations 硬件层 (Hardware):GPIO / UART / I2C / SPI
关键理解:字符设备驱动是内核模块与VFS之间的桥梁。驱动开发者只需实现file_operations中的回调函数,并正确注册cdev,即可让用户空间通过标准文件I/O接口访问硬件。

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

file_operations是字符设备驱动的核心数据结构,定义在<linux/fs.h>中。它是一组函数指针的集合,每个指针对应一个系统调用。驱动开发者根据设备特性实现其中部分或全部回调。

成员名称 函数原型 说明
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) 设备控制命令接口,替代旧的 ioctl(无大内核锁)
llseek loff_t (*llseek)(struct file *, loff_t, int) 修改文件读写位置,支持 SEEK_SET/SEEK_CUR/SEEK_END
mmap int (*mmap)(struct file *, struct vm_area_struct *) 将设备内存映射到用户空间,用于高性能数据交换
典型实现示例:以下代码展示了一个最小化的file_operations定义,仅实现open/release/read/write四个基本回调。
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/uaccess.h>

#define DEVICE_NAME "my_char_dev"
#define BUF_SIZE 1024

static char device_buffer[BUF_SIZE];
static int open_count = 0;

static int my_open(struct inode *inode, struct file *filp) {
    open_count++;
    printk(KERN_INFO "Device opened %d times\n", open_count);
    return 0;
}

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

static ssize_t my_read(struct file *filp, char __user *buf,
                       size_t count, loff_t *f_pos) {
    size_t available = BUF_SIZE - *f_pos;
    size_t to_read = count < available ? count : available;

    if (to_read == 0) return 0;

    if (copy_to_user(buf, device_buffer + *f_pos, to_read)) {
        return -EFAULT;
    }

    *f_pos += to_read;
    return to_read;
}

static ssize_t my_write(struct file *filp, const char __user *buf,
                        size_t count, loff_t *f_pos) {
    size_t available = BUF_SIZE - *f_pos;
    size_t to_write = count < available ? count : available;

    if (to_write == 0) return -ENOSPC;

    if (copy_from_user(device_buffer + *f_pos, buf, to_write)) {
        return -EFAULT;
    }

    *f_pos += to_write;
    return to_write;
}

static struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .release = my_release,
    .read    = my_read,
    .write   = my_write,
};
安全注意:在read/write回调中,必须使用copy_to_user()和copy_from_user()进行用户空间与内核空间的数据拷贝,严禁直接解引用用户空间指针,否则会导致内核崩溃或安全漏洞。

4.3 主次设备号:设备的唯一标识

每个字符设备在Linux内核中由设备号唯一标识。设备号是dev_t类型(32位无符号整数),其中高12位为主设备号,低20位为次设备号。

设备号 (dev_t) 位域结构 32位 dev_t 主设备号 (12位) 范围: 0 ~ 4095 次设备号 (20位) 范围: 0 ~ 1048575 主设备号 → 驱动类型(如GPIO、UART) 次设备号 → 同一驱动下的不同实例(如GPIO0、GPIO1)

4.3.1 设备号操作宏

内核提供一组宏用于设备号的编码与解码,避免手动位操作:

宏/函数 功能 示例
MKDEV(major, minor) 将主次设备号合并为 dev_t dev_t dev = MKDEV(240, 0);
MAJOR(dev) 从 dev_t 提取主设备号 int major = MAJOR(dev);
MINOR(dev) 从 dev_t 提取次设备号 int minor = MINOR(dev);

4.3.2 静态分配 vs 动态分配

分配方式 函数 优点 缺点
静态分配 register_chrdev_region() 设备号固定,便于udev规则匹配 需手动指定主设备号,可能冲突
动态分配 alloc_chrdev_region() 内核自动分配未使用的主设备号,避免冲突 设备号不固定,需通过/proc/devices查看
推荐实践:在正式产品中优先使用动态分配,配合udev规则或设备树自动创建设备节点。静态分配仅用于教学或与旧版内核兼容的场景。

4.4 cdev接口:字符设备的内核抽象

cdev结构体是内核用于管理字符设备的核心对象,定义在<linux/cdev.h>中。每个字符设备在内核中对应一个cdev实例,它封装了设备号与file_operations的绑定关系。

4.4.1 cdev结构体定义

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.4.2 cdev操作流程

cdev 生命周期与操作流程 ① 分配设备号 alloc_chrdev_region() ② 初始化cdev cdev_init() ③ 添加cdev cdev_add() ④ 设备运行中 用户空间可访问 ⑤ 删除cdev cdev_del() + unregister_chrdev_region()

4.4.3 完整注册与注销代码模板

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

#define DEVICE_NAME "my_cdev"
#define MINOR_COUNT 1

static dev_t dev_num;
static struct cdev *my_cdev;

/* file_operations 定义(此处省略具体实现) */
static 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, MINOR_COUNT, DEVICE_NAME);
    if (ret < 0) {
        printk(KERN_ERR "Failed to allocate device number\n");
        return ret;
    }
    printk(KERN_INFO "Allocated major=%d minor=%d\n",
           MAJOR(dev_num), MINOR(dev_num));

    /* 2. 分配并初始化cdev */
    my_cdev = cdev_alloc();
    if (!my_cdev) {
        printk(KERN_ERR "Failed to allocate cdev\n");
        unregister_chrdev_region(dev_num, MINOR_COUNT);
        return -ENOMEM;
    }
    cdev_init(my_cdev, &my_fops);
    my_cdev->owner = THIS_MODULE;

    /* 3. 将cdev添加到内核 */
    ret = cdev_add(my_cdev, dev_num, MINOR_COUNT);
    if (ret < 0) {
        printk(KERN_ERR "Failed to add cdev\n");
        cdev_del(my_cdev);
        unregister_chrdev_region(dev_num, MINOR_COUNT);
        return ret;
    }

    printk(KERN_INFO "Character device registered successfully\n");
    return 0;
}

static void __exit my_exit(void) {
    /* 逆序释放:先删除cdev,再释放设备号 */
    cdev_del(my_cdev);
    unregister_chrdev_region(dev_num, MINOR_COUNT);
    printk(KERN_INFO "Character device unregistered\n");
}

module_init(my_init);
module_exit(my_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Embedded Linux Expert");
MODULE_DESCRIPTION("Minimal character device driver example");
关键点:
  • cdev_alloc() 动态分配cdev,cdev_init() 用于静态分配的cdev。两者选其一,不要混用。
  • cdev_add() 之后设备立即可用,因此必须在最后一步调用。
  • 卸载时严格按照逆序释放:先 cdev_del(),再 unregister_chrdev_region()。

4.5 设备注册与注销的完整流程

综合以上内容,字符设备驱动的注册与注销遵循对称设计原则:初始化时按顺序执行,清理时逆序执行。

阶段 步骤 函数/操作 说明
注册 1 alloc_chrdev_region() 动态分配设备号(推荐)
2 cdev_alloc() 或 cdev_init() 分配并初始化cdev结构体
3 设置 owner 和 ops 绑定模块所有权和操作函数集
4 cdev_add() 将cdev注册到内核,设备立即可用
注销 1 cdev_del() 从内核移除cdev,阻止新访问
2 unregister_chrdev_region() 释放设备号
注册与注销对称流程图 注册:alloc_chrdev_region 注册:cdev_init + cdev_add 注销:cdev_del 注销:unregister_chrdev_region 逆序
常见错误:
  • 顺序错误:先调用 cdev_add() 再分配设备号,会导致内核崩溃。
  • 遗漏注销:模块卸载时未调用 cdev_del(),下次加载时会因设备号冲突而失败。
  • 次设备号数量不匹配:cdev_add() 的count参数必须与 alloc_chrdev_region() 的count一致。

4.6 本章小结

字符设备驱动框架是嵌入式Linux驱动开发的基石。掌握以下核心要点即可快速上手:

  • file_operations:驱动与VFS的契约,实现open/read/write/release等回调。
  • 设备号:主设备号标识驱动类型,次设备号标识设备实例;优先使用动态分配。
  • cdev接口:内核管理字符设备的抽象,通过cdev_init/cdev_add完成注册。
  • 对称设计:注册与注销严格逆序,确保资源正确释放。
下章预告:第5章将基于本章的字符设备框架,实现具体的GPIO控制驱动,包括GPIO的申请、配置、读写操作,以及用户空间的测试程序编写。