V4l2loopback实现笔记(二):字符设备交互
本文继续记录复现 v4l2loopback 功能的学习过程。在上一篇文章中,我们介绍了如何编写一个基础的内核模块,涵盖了模块的元数据、初始化与清理函数等核心概念。本文将进一步探讨内核模块与用户空间的交互,重点介绍字符设备的注册与使用。这是理解 v4l2loopback 工作原理的关键一步,因为 v4l2loopback 作为一个虚拟视频设备,必须通过字符设备接口与用户空间进行数据交换。
字符设备简介
在 Linux 内核的世界里,设备驱动主要分为字符设备、块设备(Block Device)和网络设备(Network Device)三大类。其中,字符设备是最基本且最常见的机制,它允许用户空间程序通过文件操作接口与内核模块进行交互。
字符设备以字节流的形式传输数据,适用于频繁读写的小数据量操作。在 Linux 系统中,“一切皆文件”的理念得到了充分体现:字符设备通常表现为 /dev/ 目录下的一个文件节点(如 /dev/ttyS0 或 /dev/video0)。用户程序可以通过标准系统调用(open, read, write, close, ioctl),像操作普通文件一样操作硬件。每个字符设备都由一个主设备号(Major Number)和一个次设备号(Minor Number)唯一标识。
字符设备的核心特性包括:
- 字节流通信:数据以流的形式传输,没有固定的“块”大小限制,读写长度灵活。
- 顺序访问:数据通常按顺序读写,适合串行传输。
- 无系统缓存:这是其与块设备的最大区别。当调用
write()时,内核通常直接调用驱动程序的函数将数据发往硬件,不经过文件系统缓存(Page Cache),确保了操作的实时性。 - 复杂的控制操作:除了读写数据,字符设备通常需要配置(例如:设置串口波特率、设置摄像头的分辨率)。这些操作不适合用 read/write 完成,因此字符设备严重依赖 ioctl (Input/Output Control) 接口来进行带外数据(Out-of-band)传输。
字符设备的创建和注册
要让用户能够通过 /dev/v4l2char 访问代码,必须经历从内核逻辑对象到用户空间文件节点的映射过程。这一过程展示了一个驱动程序如何在系统中“挂名”,并最终在用户空间(/dev)生成一个可操作的文件节点。
首先,注册主设备号:用于标识驱动程序。在驱动的初始化函数中,通过register_chrdev注册函数让内核分配一个主设备号,并将其与驱动程序的文件操作结构体(file_operations)关联起来。
major_number = register_chrdev(0, DEVICE_NAME, &fops);这里传入 0 表示让内核动态分配一个可用的主设备号,返回值 major_number 必须保存,用于后续注销。通过 fops 结构体(file_operations),内核将用户空间的系统调用(如 read, write, open)路由到自定义的函数上。
然后,为了让/dev目录下自动创建设备文件,使用class_create 和 device_create创建设备类和设备节点,然后由 udev 守护进程自动生成对应的设备文件。
v4l2char_class = class_create(CLASS_NAME);
v4l2char_device = device_create(v4l2char_class, NULL, MKDEV(major_number, 0), NULL, DEVICE_NAME);class_create 在 /sys/class/ 下创建一个分类(这里是 v4l2),相当于给设备归类。device_create 发送事件给 udev 守护进程,udev 会自动在 /dev 目录下创建 v4l2char 设备文件。这样用户加载模块后,直接就能看到 /dev/v4l2char。MKDEV(major_number, 0) 将主号和次号(此处为0,静态定义)合并。当这行代码执行成功,就能在 Linux 终端输入 ls /dev/v4l2_loopback 看到它。
其中,udev是Linux 的设备管理守护进程,负责动态创建和删除 /dev 目录下的设备文件。通过 device_create 发送事件后,udev 会根据规则自动生成对应的设备节点,简化了用户操作。
文件操作实现 (fops)
用户通过对/dev/v4l2char文件操作实现硬件交互。那么,怎么实现执行逻辑呢?在内核中,file_operations 结构体定义了字符设备的所有操作接口,它本质上是一个函数指针跳转表,将 VFS(虚拟文件系统)的系统调用(read, write, open, release)路由到自定义的 C 函数上,驱动程序的“入口点表”。通过 register_chrdev 将主设备号与这个结构体绑定。
/* Function prototypes */
static int v4l2_char_open(struct inode *, struct file *);
static int v4l2_char_release(struct inode *, struct file *);
static ssize_t v4l2_char_read(struct file *, char *, size_t, loff_t *);
static ssize_t v4l2_char_write(struct file *, const char *, size_t, loff_t *);
/* File operations structure */
static struct file_operations fops = {
.open = v4l2_char_open,
.read = v4l2_char_read,
.write = v4l2_char_write,
.release = v4l2_char_release,
};
major_number = register_chrdev(0, DEVICE_NAME, &fops);绑定后,当应用层发起 read(),内核 VFS(虚拟文件系统)根据主设备号查找到该驱动,并调用 fops.read 指向的 v4l2_char_read。
为了支持多用户并发访问,我们需要为每个打开的文件维护独立的状态信息。每个打开的文件在内核中由 struct file 表示,其 private_data 指针允许我们存储特定的上下文信息。
/* Per-open data structure */
struct v4l2_char_private_data {
char *buffer;
int buffer_size;
int data_length;
};
/* ... */
private_data = kmalloc(sizeof(struct v4l2_char_private_data), GFP_KERNEL);
filep->private_data = private_data;在 open 函数中,使用 kmalloc 为当前打开的文件分配一个私有数据结构体,并将其地址赋值给 filep->private_data。在随后的 read 和 write 函数中,通过 filep->private_data 访问该私有数据结构体,实现每个用户的独立数据存储。这种设计确保了即便多个进程同时打开 /dev/v4l2char,它们的数据流也是相互隔离、互不干扰的。
文件操作逻辑
打开和释放
open 负责初始化资源(申请内存),release 负责回收资源(释放内存)。这保证了内核不会发生内存泄漏。
static int v4l2_char_open(struct inode *inodep, struct file *filep)
{
struct v4l2_char_private_data *private_data;
/* Increment open count */
open_count++;
printk(KERN_INFO "v4l2char: Device opened %d time(s)\n", open_count);
/* Allocate private data for this file descriptor */
private_data = kmalloc(sizeof(struct v4l2_char_private_data), GFP_KERNEL);
if (!private_data) {
printk(KERN_ERR "v4l2char: Failed to allocate private data\n");
return -ENOMEM;
}
/* Allocate buffer for this instance */
private_data->buffer = kmalloc(buffer_size, GFP_KERNEL);
if (!private_data->buffer) {
printk(KERN_ERR "v4l2char: Failed to allocate buffer\n");
kfree(private_data);
return -ENOMEM;
}
private_data->buffer_size = buffer_size;
private_data->data_length = 0;
/* Store private data in file pointer */
filep->private_data = private_data;
return 0;
}
/* Release function */
static int v4l2_char_release(struct inode *inodep, struct file *filep)
{
struct v4l2_char_private_data *private_data = (struct v4l2_char_private_data *)filep->private_data;
/* Free the allocated buffer and private data */
if (private_data) {
if (private_data->buffer) {
kfree(private_data->buffer);
}
kfree(private_data);
}
open_count--;
printk(KERN_INFO "v4l2char: Device closed, %d user(s) still connected\n", open_count);
return 0;
}在驱动的 file_operations 结构体中,这两个函数分别对应用户态的 open() 和 close() 系统调用。其中有两个核心参数:inode 和 file。
struct inode *inodep:它代表的是文件系统中的物理文件节点。在磁盘(或内存文件系统)上,每个文件都有唯一的inode。它包含了文件的元数据,如主次设备号。在驱动中,我们常用它来通过imajor或iminor宏获取设备号,判断用户到底是在打开哪一个物理设备。struct file *filep:它代表的是一个打开的文件描述符。每当open被调用一次,内核就会创建一个新的struct file实例。它描述的是一种“访问状态”,比如文件的读写权限、当前的偏移量(f_pos),以及最重要的——私有数据指针private_data。
读逻辑
static ssize_t v4l2_char_read(struct file *filep, char *buffer, size_t len, loff_t *offset)
{
struct v4l2_char_private_data *private_data = (struct v4l2_char_private_data *)filep->private_data;
int bytes_to_read;
int error_count = 0;
/* Acquire mutex for synchronized access */
if (mutex_lock_interruptible(&buffer_mutex)) {
return -ERESTARTSYS;
}
/* Determine how many bytes to read */
bytes_to_read = private_data->data_length - *offset;
if (bytes_to_read <= 0) {
mutex_unlock(&buffer_mutex);
return 0; // End of file
}
if (bytes_to_read > len) {
bytes_to_read = len;
}
/* Copy data to user space */
error_count = copy_to_user(buffer, &(private_data->buffer[*offset]), bytes_to_read);
mutex_unlock(&buffer_mutex);
if (error_count == 0) {
*offset += bytes_to_read;
printk(KERN_INFO "v4l2char: Sent %d characters to user\n", bytes_to_read);
return bytes_to_read;
} else {
printk(KERN_ERR "v4l2char: Failed to send %d characters to user\n", error_count);
return -EFAULT; // Error sending data to user
}
}在读逻辑 (v4l2_char_read) 中,程序首先根据 data_length 和当前偏移量 *offset 计算剩余可读字节数,防止越界。其中,data_length是缓冲区总数据量,*offset是当前读取位置(由内核(VFS层) 根据 用户态系统调用的上下文 自动传递并维护的),len 是用户请求读取的字节数。如果 bytes_to_read 超过 len,则进行截断。
由于安全和硬件保护机制,内核不能直接用 memcpy 操作用户传来的指针。因此必须调用 copy_to_user。该函数负责将内核空间的数据安全地复制到用户空间,传入参数为用户空间地址、内核空间地址和要复制的字节数,返回的参数表示未成功复制的字节数。如果返回值为 0,表示全部数据成功复制。
写逻辑
static ssize_t v4l2_char_write(struct file *filep, const char *buffer, size_t len, loff_t *offset)
{
struct v4l2_char_private_data *private_data = (struct v4l2_char_private_data *)filep->private_data;
int space_left;
int error_count = 0;
/* Acquire mutex for synchronized access */
if (mutex_lock_interruptible(&buffer_mutex)) {
return -ERESTARTSYS;
}
/* Calculate available space */
space_left = private_data->buffer_size - private_data->data_length;
if (space_left <= 0) {
mutex_unlock(&buffer_mutex);
return -ENOSPC; // No space left
}
/* Limit write to available space */
if (len > space_left) {
len = space_left;
}
/* Copy data from user space */
error_count = copy_from_user(&(private_data->buffer[private_data->data_length]), buffer, len);
if (error_count == 0) {
private_data->data_length += len;
mutex_unlock(&buffer_mutex);
printk(KERN_INFO "v4l2char: Received %zu characters from user\n", len);
return len;
} else {
mutex_unlock(&buffer_mutex);
printk(KERN_ERR "v4l2char: Failed to receive %d characters from user\n", error_count);
return -EFAULT; // Error getting data from user
}
}写逻辑 (v4l2_char_write) 与之类似。驱动程序先计算缓冲区的剩余空间 space_left,随后通过 copy_from_user 将用户数据存入内核空间。若操作成功,则更新数据长度。
并发保护
为了防止数据竞争,我们在读写操作中引入了互斥锁 (Mutex)。通过 mutex_lock_interruptible 尝试获取锁,这种方式允许线程在等待锁时响应中断信号(返回 -ERESTARTSYS)。操作完成后,通过 mutex_unlock 释放资源,确保了共享缓冲区在多线程环境下的数据一致性。
用户空间程序测试
为了验证字符设备是否工作正常,编写一个简单的 C 程序。这个程序将模拟一个典型的用户应用,执行“打开设备、写入数据、回读数据、关闭设备”的完整生命周期。
/*
* test_char_device.c - 字符设备测试程序
*/
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
#include <time.h>
#define DEVICE_PATH "/dev/v4l2char"
int main() {
int fd;
char write_msg[256];
char read_buffer[256];
int ret;
printf("Testing character device: %s\n", DEVICE_PATH);
// 1. 打开设备:触发内核中的 v4l2_char_open
fd = open(DEVICE_PATH, O_RDWR);
if (fd < 0) {
perror("Failed to open device");
return -1;
}
printf("Device opened successfully\n");
// 2. 写入数据:触发 v4l2_char_write
// 我们在消息中加入时间戳,以便区分多次运行的结果
snprintf(write_msg, sizeof(write_msg), "Hello from userspace! Timestamp: %ld", time(NULL));
ret = write(fd, write_msg, strlen(write_msg));
if (ret < 0) {
perror("Failed to write to device");
close(fd);
return -1;
}
printf("Wrote %d bytes: %s\n", ret, write_msg);
// 3. 重置偏移量:为了能从头读取刚才写入的内容
// 这一步会改变 filep->f_pos,影响 read 函数中的 offset 参数
lseek(fd, 0, SEEK_SET);
// 4. 读取数据:触发 v4l2_char_read
memset(read_buffer, 0, sizeof(read_buffer));
ret = read(fd, read_buffer, sizeof(read_buffer) - 1);
if (ret < 0) {
perror("Failed to read from device");
close(fd);
return -1;
}
printf("Read %d bytes: %s\n", ret, read_buffer);
// 5. 关闭设备:触发 v4l2_char_release
close(fd);
printf("Device closed\n");
return 0;
}编译并运行该程序得到输出:
Testing character device: /dev/v4l2char
Device opened successfully
Wrote 43 bytes: Hello from userspace! Timestamp: 1769433491
Read 43 bytes: Hello from userspace! Timestamp: 1769433491
Device closed内核输出日志:
[34264.098616] v4l2char: Device opened 1 time(s)
[34264.098628] v4l2char: Received 43 characters from user
[34264.098630] v4l2char: Sent 43 characters to user
[34264.098632] v4l2char: Device closed, 0 user(s) still connected总结流程图
当用户程序运行时的流程如下:
打开设备:用户执行
open("/dev/v4l2char", ...)-> 内核调用v4l2_char_open-> 分配私有buffer并绑定到文件描述符。写入数据:用户执行
write(fd, "hello", 5)-> 内核调用v4l2_char_write-> 获取互斥锁 ->copy_from_user存入内核buffer-> 释放锁。读取数据:用户执行
read(fd, buf, 5)-> 内核调用v4l2_char_read-> 获取互斥锁 ->copy_to_user拷贝回用户空间 -> 释放锁。关闭设备:用户执行
close(fd)-> 内核调用v4l2_char_release->kfree释放私有资源,确保无内存泄漏