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 更安全?我简单解释一下:
- 地址合法性检查:这两个函数会先检查用户空间的地址范围是否合法,防止用户程序传入非法指针导致内核崩溃。
- 缺页处理:用户空间的内存可能被换出到磁盘(swap),直接访问会触发缺页异常。这两个函数能正确处理这种情况。
- 权限检查:确保用户程序有权限访问该内存区域。
说白了,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 本章知识体系
下面这张图把本章的核心逻辑串起来了,你可以对照着理解:
从这张图可以看得很清楚:用户空间的测试程序通过系统调用进入 VFS 层,VFS 根据设备文件对应的 file_operations 跳转到我们实现的驱动函数。在 read 和 write 函数内部,通过 copy_to_user 和 copy_from_user 完成跨空间的数据搬运。