驱动框架搭建:file_operations 结构体详解

字符设备驱动的核心,说白了就是 file_operations 这个结构体。它像一张函数跳转表,把用户空间的系统调用和内核空间的驱动函数一一对应起来。我刚开始学驱动时,总觉得这玩意儿很玄乎,后来写多了才发现——它就是驱动和用户程序之间的「翻译官」。

核心认知:file_operations 是字符设备驱动的灵魂。没有它,用户程序调 open/read/write,内核根本不知道找谁干活。

2.1 file_operations 结构体成员

这个结构体定义在 linux/fs.h 里,成员非常多。但实际开发中,我们只用实现需要的几个。我见过有人把全部成员都列出来,结果大部分是 NULL——没必要,反而显得乱。

成员名 函数原型 说明
owner struct module *owner 指向模块自身,防止模块被卸载时还在用
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 *) 从设备读数据
write ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *) 向设备写数据
unlocked_ioctl long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long) 设备控制命令(新内核用这个)

我的习惯:每次写驱动,先把 file_operations 结构体定义好,哪怕有些函数先写空实现。这样框架清晰,后面填代码就行。

2.2 核心接口实现

open 和 release

open 是驱动和设备的「握手」环节。我一般在这里做三件事:递增使用计数、初始化硬件状态、分配私有数据。release 就是反着来——递减计数、释放资源。

static int my_open(struct inode *inode, struct file *filp)
{
    struct my_dev *dev;
    
    dev = container_of(inode->i_cdev, struct my_dev, cdev);
    filp->private_data = dev;
    
    // 递增模块使用计数
    try_module_get(THIS_MODULE);
    
    printk(KERN_INFO "Device opened\n");
    return 0;
}

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

我曾经踩过的坑:release 里忘了调用 module_put,结果模块卸载时 refcount 不为 0,rmmod 死活卸不掉。查了半天才发现是计数没减回去。

read 和 write

这两个接口是驱动和用户空间的数据通道。有个关键点:用户空间的指针不能直接在内核态用,必须用 copy_to_user 和 copy_from_user。为什么?因为内核有权限检查,直接访问用户指针会出大问题。

static ssize_t my_read(struct file *filp, char __user *buf, 
                       size_t count, loff_t *f_pos)
{
    struct my_dev *dev = filp->private_data;
    size_t available = dev->data_len - *f_pos;
    size_t to_read = min(count, available);
    
    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_dev *dev = filp->private_data;
    size_t to_write = min(count, DEV_BUF_SIZE - *f_pos);
    
    if (to_write == 0)
        return -ENOSPC;
    
    if (copy_from_user(dev->data + *f_pos, buf, to_write))
        return -EFAULT;
    
    *f_pos += to_write;
    return to_write;
}

避坑指南:copy_to_user 和 copy_from_user 一定要检查返回值。非零表示拷贝失败,这时候必须返回 -EFAULT。我见过有人忽略返回值,结果数据没拷过去,驱动还返回成功——用户程序拿到一堆垃圾数据。

unlocked_ioctl

ioctl 是驱动的「瑞士军刀」。读、写、控制、配置,什么都能干。新内核用 unlocked_ioctl 替代了老的 ioctl,区别在于调用时没有大内核锁。你想想看,这其实更合理——每个驱动自己管好自己的并发就行。

static long my_ioctl(struct file *filp, unsigned int cmd, 
                     unsigned long arg)
{
    struct my_dev *dev = filp->private_data;
    int val;
    
    switch (cmd) {
    case MY_IOCTL_SET_VAL:
        if (copy_from_user(&val, (int __user *)arg, sizeof(val)))
            return -EFAULT;
        dev->value = val;
        break;
        
    case MY_IOCTL_GET_VAL:
        if (copy_to_user((int __user *)arg, &dev->value, sizeof(val)))
            return -EFAULT;
        break;
        
    default:
        return -ENOTTY;
    }
    
    return 0;
}

关键点:ioctl 的 cmd 编号要按 Linux 规范来生成,用 _IO/_IOR/_IOW/_IOWR 宏。乱写编号容易和其他驱动冲突,调试时特别痛苦。

2.3 模块加载与卸载

模块的加载和卸载,就像设备的「开机」和「关机」。我习惯把初始化逻辑拆成几步:注册设备号、初始化 cdev、添加设备到系统。卸载就是反着来。

static int __init my_init(void)
{
    dev_t dev_num;
    int ret;
    
    // 1. 分配设备号
    ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
    if (ret < 0) {
        printk(KERN_ERR "Failed to alloc chrdev region\n");
        return ret;
    }
    
    // 2. 初始化 cdev
    cdev_init(&my_cdev, &my_fops);
    my_cdev.owner = THIS_MODULE;
    
    // 3. 添加 cdev 到系统
    ret = cdev_add(&my_cdev, dev_num, 1);
    if (ret < 0) {
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }
    
    printk(KERN_INFO "My device driver loaded\n");
    return 0;
}

static void __exit my_exit(void)
{
    dev_t dev_num = MKDEV(major, 0);
    
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
    
    printk(KERN_INFO "My device driver unloaded\n");
}

module_init(my_init);
module_exit(my_exit);

我曾经犯过的错:cdev_add 失败后,忘了 unregister_chrdev_region。结果设备号被占着,下次 insmod 直接报错。记住:每一步失败都要回滚之前成功的操作。

2.4 整体框架图

下面这张图展示了字符设备驱动的完整调用链路。从用户程序到内核,再到硬件,每一步都清晰可见。

字符设备驱动框架调用链路 用户程序 (open/read/write/ioctl) VFS 系统调用接口 file_operations 结构体 .open → my_open | .read → my_read | .write → my_write | .unlocked_ioctl → my_ioctl 驱动具体实现函数 my_open | my_release | my_read | my_write | my_ioctl 硬件寄存器 / 内存操作 用户程序 → VFS → file_operations 跳转 → 驱动函数 → 硬件操作,这就是字符设备驱动的完整链路

2.5 完整代码模板

把上面这些拼起来,就是一个可用的字符设备驱动框架。我每次写新驱动,都是从这个模板开始改的。

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

#define DEV_BUF_SIZE 1024

struct my_dev {
    char data[DEV_BUF_SIZE];
    size_t data_len;
    struct cdev cdev;
};

static struct my_dev my_device;

// file_operations 实现
static int my_open(struct inode *inode, struct file *filp) { /* ... */ }
static int my_release(struct inode *inode, struct file *filp) { /* ... */ }
static ssize_t my_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos) { /* ... */ }
static ssize_t my_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos) { /* ... */ }
static long my_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { /* ... */ }

// 定义 file_operations
static struct file_operations my_fops = {
    .owner = THIS_MODULE,
    .open = my_open,
    .release = my_release,
    .read = my_read,
    .write = my_write,
    .unlocked_ioctl = my_ioctl,
};

// 模块初始化与退出
module_init(my_init);
module_exit(my_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("Simple char device driver");

我的建议:模板写好之后,先编译加载试试。能成功 insmod 和 rmmod,说明框架没问题。然后再一个一个填函数实现,这样调试起来心里有底。

嗯,到这里字符设备驱动的框架就搭好了。你想想看,其实核心就是三件事:定义 file_operations、实现接口函数、写好加载卸载。后面的章节我们会在这个框架上继续加功能,比如设备树匹配、中断处理、DMA 传输等等。但不管多复杂,底子都是今天讲的这些东西。