4. 字符设备驱动框架:cdev结构体、设备号分配与file_operations接口实现

字符设备是Linux驱动开发中最基础的设备类型,其核心在于通过cdev结构体将设备与file_operations操作集绑定,并分配唯一的设备号供用户空间访问。本章将深入剖析这三要素的实现原理与编码规范。

核心要点:字符设备驱动的注册本质是“设备号 + cdev + file_operations”三元组的建立过程。设备号是用户空间访问的门牌号,cdev是内核中设备对象的载体,file_operations是驱动行为的函数指针表。

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

cdev(character device)是内核用于描述字符设备的核心数据结构,定义在<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;           /* 关联的次设备号数量 */
};
编程提示:cdev结构体通常作为私有数据结构的一部分动态分配,而非直接定义在全局区。推荐使用 cdev_alloc() 分配,或使用 cdev_init() 初始化栈/堆中的cdev。
cdev结构体与内核对象关系图 struct cdev dev_t 设备号 file_operations *ops struct module *owner 说明:cdev通过kobj嵌入设备模型,通过ops关联驱动行为, 通过dev_t与用户空间建立文件访问通道。

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

设备号由主设备号(major)和次设备号(minor)组成,类型为dev_t(32位无符号整数)。主设备号标识设备驱动类型,次设备号标识同一驱动下的不同设备实例。

分配方式 核心函数 优点 缺点 适用场景
静态分配 register_chrdev_region() 设备号固定,用户空间可预知 需手动申请,可能冲突 广泛使用的标准设备(如tty、misc)
动态分配 alloc_chrdev_region() 自动分配,避免冲突 设备号不固定,需通过udev或sysfs获取 大多数自定义驱动

4.2.1 静态分配示例

#define MY_MAJOR 240
#define MY_MINOR 0
#define DEVICE_COUNT 4

dev_t devno = MKDEV(MY_MAJOR, MY_MINOR);
int ret = register_chrdev_region(devno, DEVICE_COUNT, "my_device");
if (ret < 0) {
    pr_err("Failed to register chrdev region\n");
    return ret;
}

4.2.2 动态分配示例

dev_t devno;
int ret = alloc_chrdev_region(&devno, 0, 4, "my_device");
if (ret < 0) {
    pr_err("Failed to alloc chrdev region\n");
    return ret;
}
pr_info("Allocated major=%d minor=%d\n", MAJOR(devno), MINOR(devno));
避坑指南:静态分配的主设备号应避免使用已占用的号码(如1-10、13、14等系统保留号)。动态分配后,务必通过 MAJOR() 和 MINOR() 宏提取设备号,并在模块卸载时调用 unregister_chrdev_region() 释放。
设备号分配流程对比 静态分配 MKDEV(major, minor) register_chrdev_region() 设备号固定 动态分配 alloc_chrdev_region() MAJOR()/MINOR()提取 设备号动态 选择建议 • 教学/实验:静态分配 • 产品驱动:动态分配 • 需配合udev自动创建设备节点 • 卸载时调用unregister_chrdev_region()

4.3 file_operations接口实现

file_operations 结构体是驱动与VFS(虚拟文件系统)之间的桥梁,定义了用户空间通过系统调用操作设备文件时内核调用的驱动函数。每个成员都是一个函数指针,驱动只需实现需要的操作。

struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .release = my_release,
    .read    = my_read,
    .write   = my_write,
    .llseek  = my_llseek,
    .unlocked_ioctl = my_ioctl,
};
成员 对应系统调用 典型实现职责 返回值
open open() 初始化设备、递增使用计数、分配私有数据 0成功,负值错误码
release close() 清理资源、递减使用计数 0成功
read read() 从设备拷贝数据到用户空间 读取字节数,0表示EOF
write write() 从用户空间拷贝数据到设备 写入字节数
llseek lseek() 修改文件读写位置 新位置偏移量
unlocked_ioctl ioctl() 设备特定控制命令处理 0成功,负值错误码

4.3.1 典型实现模板

static int my_open(struct inode *inode, struct file *filp)
{
    struct my_device *dev;
    dev = container_of(inode->i_cdev, struct my_device, cdev);
    filp->private_data = dev;  /* 将设备结构体存入filp */
    pr_info("Device opened\n");
    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 to_read = min(count, dev->data_len - *f_pos);
    if (to_read == 0) return 0;

    if (copy_to_user(buf, dev->data + *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)
{
    struct my_device *dev = filp->private_data;
    size_t to_write = min(count, MAX_DATA_SIZE - *f_pos);

    if (copy_from_user(dev->data + *f_pos, buf, to_write)) {
        return -EFAULT;
    }
    *f_pos += to_write;
    dev->data_len = *f_pos;
    return to_write;
}
关键注意:用户空间与内核空间的数据拷贝必须使用 copy_to_user() / copy_from_user(),不能直接使用 memcpy()。这两个函数会检查用户空间地址的合法性,并在访问前进行必要的权限校验。

4.4 完整注册流程:cdev_init + cdev_add

将cdev结构体与file_operations绑定并注册到系统的标准流程如下:

  1. 分配/初始化cdev:使用 cdev_init() 或 cdev_alloc()
  2. 设置owner和ops:将file_operations赋值给cdev->ops
  3. 分配设备号:静态或动态方式获取dev_t
  4. 注册cdev:调用 cdev_add() 将cdev加入系统
  5. 创建设备节点:手动mknod或通过udev自动创建
static int __init my_driver_init(void)
{
    dev_t devno;
    int ret;

    /* 1. 动态分配设备号 */
    ret = alloc_chrdev_region(&devno, 0, 1, "my_device");
    if (ret < 0) return ret;

    /* 2. 初始化cdev并绑定ops */
    cdev_init(&my_cdev, &my_fops);
    my_cdev.owner = THIS_MODULE;

    /* 3. 注册cdev到系统 */
    ret = cdev_add(&my_cdev, devno, 1);
    if (ret < 0) {
        unregister_chrdev_region(devno, 1);
        return ret;
    }

    pr_info("My device registered: major=%d minor=%d\n",
            MAJOR(devno), MINOR(devno));
    return 0;
}

static void __exit my_driver_exit(void)
{
    dev_t devno = my_cdev.dev;
    cdev_del(&my_cdev);               /* 从系统移除cdev */
    unregister_chrdev_region(devno, 1); /* 释放设备号 */
    pr_info("My device unregistered\n");
}
字符设备驱动完整注册流程 ① cdev_init() ② 设置owner & ops ③ alloc_chrdev_region() ④ cdev_add() ⑤ 创建设备节点 卸载流程(反向) 1. 删除设备节点 2. cdev_del() 3. unregister_chrdev_region() 注意事项 • cdev_add()后立即生效 • 失败时需回滚已分配资源 • 设备节点名与驱动名一致 • 权限通常设为 0666

4.5 用户空间验证

驱动加载后,用户空间可通过以下方式验证:

# 查看设备号
cat /proc/devices | grep my_device

# 手动创建设备节点(主设备号假设为240)
sudo mknod /dev/my_device c 240 0
sudo chmod 666 /dev/my_device

# 测试读写
echo "Hello GPIO" > /dev/my_device
cat /dev/my_device
生产环境建议:使用 class_create() 和 device_create() 自动创建设备节点,配合udev在驱动加载时自动生成 /dev/ 下的设备文件,无需手动mknod。这部分内容将在后续章节详细展开。

通过以上内容,你已经掌握了字符设备驱动的核心框架:cdev结构体的初始化、设备号的静态/动态分配策略、file_operations接口的实现方法,以及完整的注册与注销流程。这是编写任何字符设备驱动(包括GPIO驱动)的基础骨架。