3. 字符设备驱动基础(二):实现 open/read/write/close 接口

上一章我们把字符设备驱动的基本骨架搭好了,能注册、能卸载。但说实话,那会儿的设备还不能真正干活——应用程序调用 open() 会返回什么?啥也没有。今天我们就来给它装上「四肢」:实现 open、read、write、close 这几个最常用的接口。

我个人习惯把这四个接口称为驱动的「四大金刚」。你想想看,用户程序操作一个设备文件,无非就是打开它、读数据、写数据、用完关掉。把这四个接口实现好,一个能用的字符设备驱动就算成型了。

3.1 file_operations 结构体:驱动的「函数跳转表」

在 Linux 内核里,驱动和 VFS(虚拟文件系统)之间的契约就是 file_operations 结构体。说白了,它就是一张函数指针表,告诉内核:「当用户调用 read 时,请执行我这个函数;调用 write 时,请执行我那个函数。」

我们来看一下这个结构体的核心成员:

成员名 对应系统调用 典型用途
.open open() 初始化设备、递增使用计数
.release close() 释放资源、递减使用计数
.read read() 从设备拷贝数据到用户空间
.write write() 从用户空间拷贝数据到设备
.unlocked_ioctl ioctl() 设备控制命令(后面章节讲)

嗯,这里要注意:在老版本内核里还有 .ioctl,但现代内核(包括我们 T113 用的 5.x 内核)已经改用 .unlocked_ioctl 了。这个坑我踩过,移植老驱动时编译报错,查了半天才发现是结构体成员名变了。

3.2 实现 open 和 release 接口

先写最简单的两个函数。open 通常做两件事:记录私有数据、递增模块引用计数。release 则反过来。

static int mydrv_open(struct inode *inode, struct file *filp)
{
    // 记录设备结构体指针到 filp->private_data
    filp->private_data = container_of(inode->i_cdev, 
                                       struct mydrv_dev, cdev);
    // 递增模块使用计数
    try_module_get(THIS_MODULE);
    
    printk(KERN_INFO "mydrv: device opened\n");
    return 0;
}

static int mydrv_release(struct inode *inode, struct file *filp)
{
    // 递减模块使用计数
    module_put(THIS_MODULE);
    
    printk(KERN_INFO "mydrv: device closed\n");
    return 0;
}
小技巧: 把设备结构体指针存在 filp->private_data 里,后续的 read/write 函数就能直接拿到设备上下文,不用每次都去全局变量里找。这是我早期做项目时一位老工程师教我的,非常实用。

3.3 实现 read 和 write 接口——核心难点

read 和 write 的难点不在于逻辑,而在于「数据怎么跨过内核态和用户态这道墙」。内核空间不能直接访问用户空间的指针,反之亦然。为什么?因为两个空间的虚拟地址映射机制不同,直接访问轻则返回错误数据,重则导致内核崩溃。

我曾经在一个项目中,新手同事直接在内核里用 memcpy 去拷贝用户空间传进来的 buffer,结果系统直接 oops 了。嗯,从那以后我每次讲驱动都会强调:用户空间和内核空间的数据拷贝,必须用内核提供的专用函数。

3.3.1 copy_to_user 和 copy_from_user

这两个函数就是专门干这个活的:

  • copy_to_user(to, from, n):从内核空间拷贝 n 字节到用户空间
  • copy_from_user(to, from, n):从用户空间拷贝 n 字节到内核空间

它们的返回值是「未拷贝成功的字节数」。如果返回 0,说明全部拷贝成功;如果返回非 0,说明拷贝过程中出错了。

static ssize_t mydrv_read(struct file *filp, char __user *buf,
                          size_t count, loff_t *f_pos)
{
    struct mydrv_dev *dev = filp->private_data;
    int ret;
    
    // 限制读取长度不超过内核缓冲区大小
    if (count > MYDRV_BUF_SIZE)
        count = MYDRV_BUF_SIZE;
    
    // 从内核缓冲区拷贝数据到用户空间
    ret = copy_to_user(buf, dev->buffer, count);
    if (ret) {
        printk(KERN_ERR "mydrv: copy_to_user failed, %d bytes left\n", ret);
        return -EFAULT;
    }
    
    *f_pos += count;
    return count;
}

static ssize_t mydrv_write(struct file *filp, const char __user *buf,
                           size_t count, loff_t *f_pos)
{
    struct mydrv_dev *dev = filp->private_data;
    int ret;
    
    if (count > MYDRV_BUF_SIZE)
        count = MYDRV_BUF_SIZE;
    
    // 从用户空间拷贝数据到内核缓冲区
    ret = copy_from_user(dev->buffer, buf, count);
    if (ret) {
        printk(KERN_ERR "mydrv: copy_from_user failed, %d bytes left\n", ret);
        return -EFAULT;
    }
    
    *f_pos += count;
    return count;
}
重要提醒: 用户空间指针 buf 前面有个 __user 宏,这不是装饰品!它告诉内核和代码检查工具(如 sparse)这个指针来自用户空间,不能直接解引用。如果你去掉这个宏,编译时可能不会报错,但运行时就等着出问题吧。

3.4 把接口挂到 file_operations 上

函数写好了,得告诉内核这些函数是干什么的。回到我们的 file_operations 结构体:

static struct file_operations mydrv_fops = {
    .owner   = THIS_MODULE,
    .open    = mydrv_open,
    .release = mydrv_release,
    .read    = mydrv_read,
    .write   = mydrv_write,
};

就这么简单。内核在注册字符设备时,会把 cdev 结构体和这个 file_operations 关联起来。当用户程序调用系统调用时,VFS 层就会通过这个跳转表找到对应的驱动函数。

3.5 用户空间与内核空间数据拷贝的底层原理

你可能会问:为什么 copy_to_user 和 copy_from_user 比 memcpy 更安全?我简单解释一下:

  1. 地址合法性检查:这两个函数会先检查用户空间的地址范围是否合法,防止用户程序传入非法指针导致内核崩溃。
  2. 缺页处理:用户空间的内存可能被换出到磁盘(swap),直接访问会触发缺页异常。这两个函数能正确处理这种情况。
  3. 权限检查:确保用户程序有权限访问该内存区域。

说白了,memcpy 是「裸奔」,而 copy_to_user 是「全副武装」。在驱动开发中,永远不要用 memcpy 去操作用户空间的指针。

3.6 驱动测试:从用户空间验证

驱动写好了,怎么测?写一个简单的用户空间测试程序:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>

#define DEVICE_PATH "/dev/mydrv0"

int main()
{
    int fd;
    char write_buf[] = "Hello from userspace!";
    char read_buf[64] = {0};
    int ret;
    
    // 1. 打开设备
    fd = open(DEVICE_PATH, O_RDWR);
    if (fd < 0) {
        perror("open");
        return -1;
    }
    printf("Device opened successfully\n");
    
    // 2. 写入数据
    ret = write(fd, write_buf, strlen(write_buf));
    if (ret < 0) {
        perror("write");
        close(fd);
        return -1;
    }
    printf("Wrote %d bytes: %s\n", ret, write_buf);
    
    // 3. 读取数据
    lseek(fd, 0, SEEK_SET);  // 把文件位置重置到开头
    ret = read(fd, read_buf, sizeof(read_buf));
    if (ret < 0) {
        perror("read");
        close(fd);
        return -1;
    }
    printf("Read %d bytes: %s\n", ret, read_buf);
    
    // 4. 关闭设备
    close(fd);
    printf("Device closed\n");
    
    return 0;
}

编译这个测试程序:arm-linux-gnueabihf-gcc test_app.c -o test_app,然后拷贝到 T113 开发板上运行。如果一切正常,你会看到类似这样的输出:

Device opened successfully
Wrote 22 bytes: Hello from userspace!
Read 22 bytes: Hello from userspace!
Device closed
验证要点:
  • 如果 open 返回 -1,检查设备文件权限和 mknod 的主次设备号是否正确
  • 如果 write 成功但 read 返回空,检查 f_pos 是否在 read 前重置了
  • 如果 read/write 返回 -EFAULT,说明 copy_to_user 或 copy_from_user 失败了

3.7 本章知识体系

下面这张图把本章的核心逻辑串起来了,你可以对照着理解:

字符设备驱动 open/read/write/close 数据流 用户空间 测试程序 open("/dev/mydrv0") read(fd, buf, n) write(fd, buf, n) close(fd) VFS 层 sys_open → mydrv_open sys_read → mydrv_read sys_write → mydrv_write sys_close → mydrv_release 内核驱动 file_operations open: 记录私有数据 read: copy_to_user write: copy_from_user release: 释放资源 数据拷贝核心机制 write 流程 用户 buf → copy_from_user → 内核 buf read 流程 内核 buf → copy_to_user → 用户 buf ⚠ 必须使用专用函数,禁止 memcpy 直接操作用户指针 ⚠ copy_to_user/copy_from_user 返回未拷贝字节数 ⚠ 用户空间指针必须加 __user 标记 ⚠ 注意 f_pos 的维护,否则读写位置会乱

从这张图可以看得很清楚:用户空间的测试程序通过系统调用进入 VFS 层,VFS 根据设备文件对应的 file_operations 跳转到我们实现的驱动函数。在 read 和 write 函数内部,通过 copy_to_user 和 copy_from_user 完成跨空间的数据搬运。