Linux 设备驱动程序(3)- 字符驱动(2)

在开始之前,还是需要在对字符设备之间的数据结构进行回顾和详解,这个是字符设备驱动的核心

1、核心数据结构详解

1. struct cdev(字符设备核心对象)

这是 Linux 内核中字符设备的抽象,代表了整个字符设备对象。它的主要职责是将设备号与文件操作接口绑定在一起。其核心成员包括:

  • kobj:内嵌的内核对象,用于引用计数和生命周期管理。
  • owner:指向拥有该设备的内核模块(通常为 THIS_MODULE),防止模块在使用中被卸载。
  • ops:指向 file_operations 结构体的指针,定义了设备的各种操作接口。
  • dev:设备号(dev_t 类型),包含主设备号和次设备号。
  • count:该驱动管理的连续次设备号数量。

2. struct file_operations(文件操作接口集)

这是驱动与用户程序的接口纽带,是一组函数指针的集合。它将用户空间的标准系统调用(如 openreadwriteioctl 等)映射到驱动程序中具体实现的底层硬件操作函数。

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. cdevfile_operations 的关联

在驱动初始化阶段,开发者通过 cdev_init() 函数,将自定义的 file_operations 结构体指针赋值给 struct cdevops 成员。这使得字符设备对象拥有了具体的操作能力。

2. inodecdev 的关联

当应用程序调用 open("/dev/xxx") 打开设备节点时,内核 VFS 层会找到该文件对应的 struct inode。内核从 inode 中提取出主次设备号,并以此作为索引,在内核全局的字符设备映射表(如 cdev_map)中查找,最终定位到对应的 struct cdev 对象,并将其地址赋值给 inodei_cdev 成员。

3. filefile_operations 的关联

在成功找到 cdev 后,内核会将 cdev 中记录的 file_operations 操作接口地址,拷贝到本次打开操作生成的 struct filef_op 成员中。

4. 最终交互链路的形成

完成上述关联后,VFS 层向应用程序返回一个文件描述符(fd)。此后,应用程序通过该 fd 发起的任何 readwrite 等系统调用,内核都会通过 fd 找到 struct file,进而通过 f_op 找到并执行驱动程序中实现的具体函数,从而完成对底层硬件的控制。

在 Linux 上编写字符设备驱动,本质上就是围绕这些数据结构做"填空题"。开发者初始化 cdev 并绑定 file_operations,将其注册到内核;当用户空间打开设备节点时,内核自动完成 inodefilecdev 的关联,最终打通应用层到底层硬件的交互通道。

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 关键机制:closerelease 的区别

理解这两个方法,必须明确 close 系统调用与 release 驱动方法之间的关系:

并不是每一次 close() 都会触发 release()

  • 内核为每个打开的文件维护一个引用计数器
  • 当应用程序使用 fork()dup() 复制文件描述符时,并不会创建新的文件结构,仅仅是增加了引用计数。
  • 只有当文件的引用计数降为 0 时(即所有打开该文件的副本都被关闭),内核才会真正销毁文件结构,并调用驱动的 release 方法。

这种机制保证了:对于驱动的每一次 open 调用,内核只会匹配调用一次 release 方法,从而避免了资源被过早释放或重复释放的问题。

3、读和写

在 Linux 字符设备驱动开发中,readwrite 是应用程序与底层硬件进行数据交互的核心通道。它们的函数原型非常相似,主要区别在于数据的流向以及缓冲区的修饰符。

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. 关键注意事项

  1. 并发与同步 :如果设备可能被多个进程同时读写,必须在 readwrite 中使用互斥锁(如 mutex_lock / mutex_unlock)来保护共享资源,防止数据竞争。
  2. 阻塞与非阻塞 :当设备没有数据可读(或缓冲区满无法写入)时,驱动需要判断文件是否以非阻塞模式(O_NONBLOCK)打开。如果是阻塞模式,应让进程睡眠等待;如果是非阻塞模式,应直接返回 -EAGAIN
  3. 应用层行为 :用户空间调用 write 时,实际写入的字节数可能少于请求的 count(例如被信号中断或设备缓冲区满)。因此,在编写用户态程序时,通常需要循环调用 write 直到所有数据发送完毕。

4、readv和 writev

readvwritev 是 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 结构体数组的指针。
  • iovcntiov 数组中元素的个数(即缓冲区的数量)。

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 的优势

  1. 减少系统调用开销 :将多次 read/write 合并为一次,减少了用户态与内核态之间的切换开销。
  2. 避免额外的数据拷贝:无需在用户空间分配一个大缓冲区来合并数据,或者将大缓冲区拆分,直接操作原始的不连续内存。
  3. 保证原子性 :对于普通文件,writev 的写入操作是原子的(要么全部写入成功,要么全部失败),不会与其他进程的写入操作发生交叉混合。
  4. 适用结构化 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 结构体中提供 readvwritev 方法。如果您不提供,内核会使用默认的 readwrite 方法来模拟它们(即循环调用您的 read/write)。但对于某些设备(如磁带驱动),直接实现 writev 可以将所有缓冲区的内容作为设备上的单个记录写入,从而获得更高的效率和正确的语义。

相关推荐
浊酒南街1 小时前
列表和元组知识总结
linux·python
ScilogyHunter1 小时前
BusyBox完全指南
linux·busybox
ScilogyHunter1 小时前
QEMU完全指南
linux·qemu
2301_777998341 小时前
磁盘与文件系统
linux
牟同學1 小时前
Ubuntu 18.04 升级至 22.04 LTS 完整指南
linux·ubuntu
qq_163135751 小时前
Linux 【05-rmdir命令超详细教程】
linux
qq_163135752 小时前
Linux 【02-cd命令超简教程】
linux
ShirleyWang0122 小时前
win11运行ubuntu报错
linux·运维·ubuntu
加油码2 小时前
Linux 进程详解:从进程状态、调度到程序替换
linux·服务器