Contents

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_createdevice_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/v4l2charMKDEV(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。在随后的 readwrite 函数中,通过 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() 系统调用。其中有两个核心参数:inodefile

  • struct inode *inodep:它代表的是文件系统中的物理文件节点。在磁盘(或内存文件系统)上,每个文件都有唯一的 inode。它包含了文件的元数据,如主次设备号。在驱动中,我们常用它来通过 imajoriminor 宏获取设备号,判断用户到底是在打开哪一个物理设备。

  • 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

总结流程图

当用户程序运行时的流程如下:

  1. 打开设备:用户执行 open("/dev/v4l2char", ...) -> 内核调用 v4l2_char_open -> 分配私有 buffer 并绑定到文件描述符。

  2. 写入数据:用户执行 write(fd, "hello", 5) -> 内核调用 v4l2_char_write -> 获取互斥锁 -> copy_from_user 存入内核 buffer -> 释放锁。

  3. 读取数据:用户执行 read(fd, buf, 5) -> 内核调用 v4l2_char_read -> 获取互斥锁 -> copy_to_user 拷贝回用户空间 -> 释放锁。

  4. 关闭设备:用户执行 close(fd) -> 内核调用 v4l2_char_release -> kfree 释放私有资源,确保无内存泄漏