Contents

V4l2loopback实现笔记(一):基础内核模块

本文旨在记录复现 v4l2loopback 功能的学习过程。为了深入理解 v4l2loopback(虚拟视频设备)的工作原理,本文决定从零开始复现其核心功能作为整个项目的起点,首先通过编写一个名为 hello_v4l2 的基础内核模块,来探索 Linux 内核开发的核心概念。本文内容主要涵盖了内核模块开发中最基础但也最重要的几个技术点:生命周期管理、参数传递、日志输出以及内存管理。


元数据

MODULE_LICENSE("GPL");
MODULE_AUTHOR("zs");
MODULE_DESCRIPTION("Basic kernel module for learning");
MODULE_VERSION("0.1");

这段代码定义了模块的元数据,包括许可证、作者、描述和版本信息。这些信息对于内核模块的管理和识别非常重要。其中,MODULE_LICENSE("GPL")声明了模块的许可证类型,这不仅是法律声明,更是技术限制。Linux 内核中许多核心 API(标记为 EXPORT_SYMBOL_GPL)只允许 GPL 兼容的模块调用。如果在 v4l2loopback 复现中不声明 GPL,很可能无法使用 V4L2 子系统的核心函数。


初始化与清理函数:模块声明周期管理

不同于普通 C 语言程序从 main() 函数开始,内核模块有两个特定的入口和出口点。

  • 入口点:module init 该函数在模块加载时被调用,负责初始化模块所需的资源。初始化函数前有一个特殊的修饰符__init,该符号是一个编译器指令,告诉链接器,把这个函数放在内核二进制的一个特殊段(Section)中(通常是 .init.text)。初始化函数只在模块加载的一瞬间运行一次,之后就再也不会被用到了。通过标记 __init,一旦初始化完成,内核就会释放这部分代码所占用的内存,将其回收给系统使用。

  • 卸载点:module_exit 该函数在模块卸载时被调用,负责释放模块占用的资源。清理函数前有一个特殊的修饰符__exit,该符号也是一个编译器指令,告诉链接器,把这个函数放在内核二进制的一个特殊段(Section)中(通常是 .exit.text)。如果模块被编译为内置模块(即直接编译进内核,而不是作为可加载模块),那么这个清理函数就永远不会被调用。通过标记 __exit,内核可以选择性地忽略这部分代码,从而节省内存。


状态封装:内核中的面向对象编程思想

struct hello_state {
    int initialized;
    unsigned long sample_counter;
    // ...
};
static struct hello_state *global_state = NULL;

虽然 C 语言并非面向对象语言,但在内核开发中广泛采用了对象封装的思想。我们将模块运行所需的所有数据(如计数器、配置、锁、等待队列等)封装在一个结构体中,进行统一的上下文管理。目前的 global_state 虽然是全局变量,但这种设计赋予了代码极佳的可扩展性——如果未来需要驱动多个摄像头设备,只需实例化多个 hello_state 即可,无需对代码逻辑进行重构。


内核态内存管理基础

global_state = kmalloc(sizeof(struct hello_state), GFP_KERNEL);
if (!global_state) {
    return -ENOMEM;
}

在内核空间中,我们无法使用 malloc,取而代之的是 kmalloc,它直接从 Slab 分配器获取物理上连续的内存。这对于后续可能涉及的 DMA(直接内存访问)操作至关重要。

代码中的 GFP_KERNEL 是内存分配的标志(Flags)。它表示当前分配允许睡眠(Sleep),即当物理内存不足时,内核可以挂起当前进程,执行页面回收或交换以腾出空间。需要注意的是,如果在中断处理函数或持有自旋锁(Spinlock)的原子上下文中,严禁使用 GFP_KERNEL,否则会导致死锁,此时应使用 GFP_ATOMIC

此外,内核编程必须养成防御性的习惯。内核不会像用户空间程序那样在崩溃后优雅退出,因此必须检查 kmalloc 的返回值。如果分配失败,应返回标准的错误码(如 -ENOMEM),以便用户空间的 insmod 命令能接收到正确的错误提示。同时,在 __exit 函数中必须显式调用 kfree,因为内核不会自动回收模块泄漏的内存,遗忘释放将导致这部分内存在系统重启前无法被再次使用。


模块参数定义与用户态交互

通过模块参数可以在不重新编译代码的情况下改变模块的行为

static int debug_level = 1;
module_param(debug_level, int, 0644);

通过 module_param 宏,我们将 C 语言变量 debug_level 暴露给了外部。在加载模块时,可以通过 insmod hello_v4l2.ko debug_level=3 来传递参数。

宏中的权限设置 0644 定义了在 /sys/module/hello_v4l2/parameters/ 下生成文件的访问权限:6 (rw-) 表示 Root 用户可读写,4 (r--) 表示其他用户只读。这意味着我们甚至可以在模块运行时,通过 echo 2 > /sys/module/hello_v4l2/parameters/debug_level 动态修改变量值。在驱动调试阶段,这是一种非常实用的动态开启或关闭详细日志的方法。


日志打印机制

内核开发中使用 printk 替代 printf,日志会被写入内核的环形缓冲区(Ring Buffer),并通过 dmesg 命令查看。

使用时需要注意日志级别(如 KERN_INFO, KERN_WARNING, KERN_ERR)。这不仅标识了消息的重要性,还决定了消息是直接打印在控制台终端,还是仅记录在缓冲区中。只有当日志级别高于 console_loglevel 时,信息才会直接显示。在后续接入 V4L2 子系统后,将改用更规范的 v4l2_infov4l2_err 等封装宏,以便日志能自动带上设备前缀信息,便于追踪。