在开始之前,还是需要在对字符设备之间的数据结构进行回顾和详解,这个是字符设备驱动的核心
1、核心数据结构详解
1. struct cdev(字符设备核心对象)
这是 Linux 内核中字符设备的抽象,代表了整个字符设备对象。它的主要职责是将设备号与文件操作接口绑定在一起。其核心成员包括:
kobj:内嵌的内核对象,用于引用计数和生命周期管理。owner:指向拥有该设备的内核模块(通常为THIS_MODULE),防止模块在使用中被卸载。ops:指向file_operations结构体的指针,定义了设备的各种操作接口。dev:设备号(dev_t类型),包含主设备号和次设备号。count:该驱动管理的连续次设备号数量。
2. struct file_operations(文件操作接口集)
这是驱动与用户程序的接口纽带,是一组函数指针的集合。它将用户空间的标准系统调用(如 open、read、write、ioctl 等)映射到驱动程序中具体实现的底层硬件操作函数。
3. struct inode(文件静态信息)
用于描述文件系统中的静态文件信息(如文件类型、访问权限等)。对于设备文件,inode 结构体中记录了设备号(i_rdev),并且包含一个 i_cdev 指针,用于指向内核中对应的 struct cdev 对象。
4. struct file(文件动态信息)
每当应用程序在用户空间调用 open() 打开一个设备文件时,VFS 层就会分配一个 struct file 结构体。它描述的是文件的动态信息(如文件指针偏移量 f_pos、打开权限等)。其内部的 f_op 成员保存了该文件对应的操作函数接口地址。
5. dev_t(设备号类型)
内核使用 32 位的 dev_t 类型来表示设备编号。其中高 12 位表示主设备号(用于标识设备的类别和对应的驱动程序),低 20 位表示次设备号(用于标识具体的设备实例)。
数据结构之间的关联机制
这些结构体并非孤立存在,它们通过特定的机制在"应用层打开设备"的过程中紧密关联:
1. cdev 与 file_operations 的关联
在驱动初始化阶段,开发者通过 cdev_init() 函数,将自定义的 file_operations 结构体指针赋值给 struct cdev 的 ops 成员。这使得字符设备对象拥有了具体的操作能力。
2. inode 与 cdev 的关联
当应用程序调用 open("/dev/xxx") 打开设备节点时,内核 VFS 层会找到该文件对应的 struct inode。内核从 inode 中提取出主次设备号,并以此作为索引,在内核全局的字符设备映射表(如 cdev_map)中查找,最终定位到对应的 struct cdev 对象,并将其地址赋值给 inode 的 i_cdev 成员。
3. file 与 file_operations 的关联
在成功找到 cdev 后,内核会将 cdev 中记录的 file_operations 操作接口地址,拷贝到本次打开操作生成的 struct file 的 f_op 成员中。
4. 最终交互链路的形成
完成上述关联后,VFS 层向应用程序返回一个文件描述符(fd)。此后,应用程序通过该 fd 发起的任何 read 或 write 等系统调用,内核都会通过 fd 找到 struct file,进而通过 f_op 找到并执行驱动程序中实现的具体函数,从而完成对底层硬件的控制。
在 Linux 上编写字符设备驱动,本质上就是围绕这些数据结构做"填空题"。开发者初始化 cdev 并绑定 file_operations,将其注册到内核;当用户空间打开设备节点时,内核自动完成 inode、file 与 cdev 的关联,最终打通应用层到底层硬件的交互通道。
2、open 和 release
2.1 open 方法
open 方法提供给驱动来做任何的初始化来准备后续的操作. 在大部分驱动中, open 应当进行下面的工作:
• 检查设备特定的错误(例如设备没准备好, 或者类似的硬件错误)
• 如果它第一次打开, 初始化设备
• 如果需要, 更新 f_op 指针.
• 分配并填充要放进 filp->private_data 的任何数据结构
但是, 事情的第一步常常是确定打开哪个设备. 记住 open 方法的原型是
int (*open)(struct inode *inode, struct file *filp);
inode 参数有我们需要的信息,以它的 i_cdev 成员的形式里面包含我们之前建立的cdev 结构,唯一的问题是通常我们不想要 cdev 结构本身, 我们需要的是包含 cdev 结构的 scull_dev 结构内核已经为我们实现了这个技巧, 以 container_of 宏的形式, 在 <linux/kernel.h> 中定义
container_of(pointer, container_type, container_field);
这个宏使用一个指向 container_field 类型的成员的指针, 它在一个 container_type 类型的结构中, 并且返回一个指针指向包含结构. 在 scull_open, 这个宏用来找到适当的设备结构
代码示例:
static int my_open(struct inode *inode, struct file *filp) {
// 1. 通过 container_of 宏,从 inode 中的 cdev 成员找到包含它的自定义设备结构体
struct my_device *dev = container_of(inode->i_cdev, struct my_device, cdev);
// 2. 将设备结构体指针保存到 file 的私有数据中,供后续 read/write 使用
filp->private_data = dev;
// 3. (可选) 增加模块引用计数,防止设备打开时模块被卸载
// try_module_get(THIS_MODULE);
printk(KERN_INFO "Device opened\n");
return 0; // 返回 0 表示成功
}
2.2 release 方法:设备的清理与释放
当用户空间的应用程序调用 close() 系统调用关闭设备文件时,内核会调用 release 方法。它的作用与 open 完全相反,负责清理和释放资源。
核心任务:
- 释放私有数据 :释放
open方法中分配在filp->private_data中的内存或资源。 - 关闭设备:在最后一次关闭时,执行关闭硬件电源、释放中断或 IO 端口等操作。
代码示例:
static int my_release(struct inode *inode, struct file *filp) {
// 1. 从 file 的私有数据中取出设备结构体
struct my_device *dev = filp->private_data;
// 2. (可选) 释放动态分配的私有数据(如果是动态分配的话)
// kfree(dev);
// 3. (可选) 减少模块引用计数
// module_put(THIS_MODULE);
printk(KERN_INFO "Device closed\n");
return 0; // 返回 0 表示成功
}
2.3 关键机制:close 与 release 的区别
理解这两个方法,必须明确 close 系统调用与 release 驱动方法之间的关系:
并不是每一次 close() 都会触发 release()。
- 内核为每个打开的文件维护一个引用计数器。
- 当应用程序使用
fork()或dup()复制文件描述符时,并不会创建新的文件结构,仅仅是增加了引用计数。 - 只有当文件的引用计数降为 0 时(即所有打开该文件的副本都被关闭),内核才会真正销毁文件结构,并调用驱动的
release方法。
这种机制保证了:对于驱动的每一次 open 调用,内核只会匹配调用一次 release 方法,从而避免了资源被过早释放或重复释放的问题。
3、读和写
在 Linux 字符设备驱动开发中,read 和 write 是应用程序与底层硬件进行数据交互的核心通道。它们的函数原型非常相似,主要区别在于数据的流向以及缓冲区的修饰符。
1. 函数原型与参数解析
// 读操作:从设备读取数据到用户空间
ssize_t read(struct file *filp, char __user *buff, size_t count, loff_t *offp);
// 写操作:从用户空间写入数据到设备
ssize_t write(struct file *filp, const char __user *buff, size_t count, loff_t *offp);
参数含义:
filp:指向设备文件结构体(struct file)的指针。可以通过filp->private_data获取在open方法中保存的设备私有数据结构。buff:指向用户空间的内存缓冲区。注意__user宏,它提示该指针指向用户空间,内核不能直接解引用。write方法中带有const修饰,表示数据只读。count:用户期望读取或写入的数据长度(字节数)。offp:文件读写位置的偏移量指针。对于需要位置指示器控制的设备(如 Flash),读写后通常需要更新此偏移量。
返回值:
- 成功时,返回实际读取或写入的字节数。
- 失败时,返回负的错误码(如
-EFAULT、-EINVAL等)。
2. 核心机制:跨空间数据拷贝
由于内核空间和用户空间的内存是隔离的,驱动中绝对不能 直接使用 C 库的 memcpy 函数。必须使用内核提供的安全拷贝函数:
copy_to_user(void __user *to, const void *from, unsigned long n):用于read操作,将内核空间的数据拷贝到用户空间。copy_from_user(void *to, const void __user *from, unsigned long n):用于write操作,将用户空间的数据拷贝到内核空间。
这两个函数会自动检查用户空间地址的合法性。如果拷贝失败(例如用户传入的指针无效),它们会返回未成功拷贝的字节数 ;如果完全成功,则返回 0。
3. 代码实现示例
以下是一个典型的基于内存缓冲区的字符设备读写实现:
// 读操作实现
static ssize_t my_read(struct file *filp, char __user *buff, size_t count, loff_t *offp) {
struct my_device *dev = filp->private_data;
int ret;
// 1. 边界检查:如果请求读取的长度大于实际拥有的数据,则截断
if (*offp >= dev->data_len) return 0; // 读到末尾返回0
if (count > dev->data_len - *offp) {
count = dev->data_len - *offp;
}
// 2. 安全拷贝:将内核缓冲区数据拷贝到用户空间
ret = copy_to_user(buff, dev->buffer + *offp, count);
if (ret) {
return -EFAULT; // 拷贝失败,返回错误码
}
// 3. 更新偏移量并返回实际读取的字节数
*offp += count;
return count;
}
// 写操作实现
static ssize_t my_write(struct file *filp, const char __user *buff, size_t count, loff_t *offp) {
struct my_device *dev = filp->private_data;
int ret;
// 1. 边界检查:防止内核缓冲区溢出
if (count > BUF_SIZE - dev->data_len) {
count = BUF_SIZE - dev->data_len;
}
// 2. 安全拷贝:将用户空间数据拷贝到内核缓冲区
ret = copy_from_user(dev->buffer + dev->data_len, buff, count);
if (ret) {
return -EFAULT; // 拷贝失败,返回错误码
}
// 3. 更新数据长度和偏移量
dev->data_len += count;
*offp += count;
return count;
}
4. 关键注意事项
- 并发与同步 :如果设备可能被多个进程同时读写,必须在
read和write中使用互斥锁(如mutex_lock/mutex_unlock)来保护共享资源,防止数据竞争。 - 阻塞与非阻塞 :当设备没有数据可读(或缓冲区满无法写入)时,驱动需要判断文件是否以非阻塞模式(
O_NONBLOCK)打开。如果是阻塞模式,应让进程睡眠等待;如果是非阻塞模式,应直接返回-EAGAIN。 - 应用层行为 :用户空间调用
write时,实际写入的字节数可能少于请求的count(例如被信号中断或设备缓冲区满)。因此,在编写用户态程序时,通常需要循环调用write直到所有数据发送完毕。
4、readv和 writev
readv 和 writev 是 Linux/Unix 系统中用于实现分散/聚集 I/O(Scatter/Gather I/O) 的系统调用。它们允许程序在一次系统调用中,从多个不连续的内存缓冲区读取或写入数据,从而避免了传统 read/write 在处理复杂数据结构时需要频繁拷贝或多次调用的问题。
1. 核心概念与原型
这两个函数的原型定义在 <sys/uio.h> 头文件中:
#include <sys/uio.h>
// 分散读取:将文件中的数据依次读入多个缓冲区
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
// 聚集写入:将多个缓冲区中的数据拼接后一次性写入文件
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);
参数说明:
fd:文件描述符。iov:指向iovec结构体数组的指针。iovcnt:iov数组中元素的个数(即缓冲区的数量)。
iovec 结构体:
每个 iovec 描述了一块要传送的数据,包含一个指向缓冲区的指针和该缓冲区的长度:
struct iovec {
void *iov_base; /* 缓冲区的起始地址 */
size_t iov_len; /* 缓冲区的长度(字节数) */
};
2. 功能详解
readv(分散读取) :从文件描述符fd中读取数据,并按照iov数组的顺序,依次将数据填入iov[0]、iov[1]等缓冲区中。如果读取的数据不足以填满所有缓冲区,则只填满部分。writev(聚集写入) :将iov[0]、iov[1]等缓冲区中的数据按顺序拼接("聚集")起来,作为一个连续的字节流一次性写入到文件描述符fd中。
3. 相比传统 read/write 的优势
- 减少系统调用开销 :将多次
read/write合并为一次,减少了用户态与内核态之间的切换开销。 - 避免额外的数据拷贝:无需在用户空间分配一个大缓冲区来合并数据,或者将大缓冲区拆分,直接操作原始的不连续内存。
- 保证原子性 :对于普通文件,
writev的写入操作是原子的(要么全部写入成功,要么全部失败),不会与其他进程的写入操作发生交叉混合。 - 适用结构化 I/O:特别适合处理网络协议(如 HTTP 请求头+数据体)或固定格式的文件读写。
4. 代码示例
以下是一个使用 writev 将两个独立字符串一次性写入文件的示例:
#include <stdio.h>
#include <string.h>
#include <sys/uio.h>
#include <fcntl.h>
int main() {
char *header = "Header: ";
char *body = "This is the body content.";
struct iovec iov[2];
// 设置第一个缓冲区
iov[0].iov_base = header;
iov[0].iov_len = strlen(header);
// 设置第二个缓冲区
iov[1].iov_base = body;
iov[1].iov_len = strlen(body);
int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) {
perror("open");
return 1;
}
// 一次性将两个缓冲区的内容写入文件
ssize_t nwritten = writev(fd, iov, 2);
if (nwritten == -1) {
perror("writev");
close(fd);
return 1;
}
printf("Successfully wrote %ld bytes.\n", nwritten);
close(fd);
return 0;
}
5. 返回值与注意事项
- 返回值 :成功时返回实际读取或写入的总字节数 ;失败时返回
-1并设置errno。 - 部分读写 :与
read/write类似,readv/writev也可能只处理部分数据。因此,必须检查返回值,如果实际处理的字节数小于请求的总字节数,可能需要循环调用以处理剩余数据。 - 文件偏移量:这两个函数都会改变打开文件句柄的当前文件偏移量。
- 数量限制 :Linux 中
iovcnt的最大值通常被限制为1024(IOV_MAX)。
6. 在字符设备驱动中的实现
如果您在开发 Linux 字符设备驱动,可以在 file_operations 结构体中提供 readv 和 writev 方法。如果您不提供,内核会使用默认的 read 和 write 方法来模拟它们(即循环调用您的 read/write)。但对于某些设备(如磁带驱动),直接实现 writev 可以将所有缓冲区的内容作为设备上的单个记录写入,从而获得更高的效率和正确的语义。