2. 内核模块基础:模块的加载与卸载、module_init/module_exit宏、模块许可证与作者信息、模块参数传递

Linux 内核模块是设备驱动开发的核心载体。与用户态程序不同,内核模块可以在运行时动态加载到内核空间,无需重新编译整个内核或重启系统。本章将系统讲解内核模块的基本结构、生命周期管理、元信息声明以及参数传递机制,为后续中断处理和 DMA 传输打下坚实基础。

核心概念:内核模块本质上是一段可以在内核空间动态加载和卸载的代码。它通过 module_init() 和 module_exit() 宏注册入口和出口函数,由内核在模块加载/卸载时自动调用。

2.1 模块的加载与卸载

内核模块的生命周期由两个关键操作控制:加载(insmod)和卸载(rmmod)。加载时内核执行模块的初始化函数,完成资源申请、设备注册等准备工作;卸载时执行清理函数,释放已占用的资源。

下图展示了内核模块从加载到卸载的完整生命周期流程:

内核模块生命周期流程图 insmod 加载 rmmod 卸载 module_init() module_exit() 模块运行中 说明: 加载时内核自动调用 module_init() 注册的初始化函数;卸载时调用 module_exit() 注册的清理函数。 模块运行期间,内核通过符号表和引用计数管理模块的生命周期。

模块加载与卸载的常用命令如下表所示:

命令 功能 示例
insmod 加载内核模块(需指定完整路径) insmod ./hello.ko
rmmod 卸载内核模块 rmmod hello
modprobe 智能加载模块(自动处理依赖) modprobe hello
lsmod 列出当前已加载的所有模块 lsmod | grep hello
modinfo 查看模块的详细信息 modinfo hello.ko
注意事项:使用 insmod 加载模块时,必须提供模块文件的绝对路径或相对路径。而 rmmod 只需指定模块名称(不含 .ko 后缀)。如果模块正在被其他模块或进程使用,卸载操作会失败,内核会返回 "Module is in use" 错误。

2.2 module_init / module_exit 宏

module_init() 和 module_exit() 是 Linux 内核提供的两个关键宏,用于向内核注册模块的入口和出口函数。这两个宏的定义位于 <linux/module.h> 头文件中。

下面是一个完整的最小内核模块示例,展示了这两个宏的标准用法:

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>

static int __init hello_init(void)
{
    printk(KERN_INFO "Hello, Kernel Module Loaded!\n");
    return 0;  // 返回0表示初始化成功
}

static void __exit hello_exit(void)
{
    printk(KERN_INFO "Goodbye, Kernel Module Unloaded!\n");
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Embedded Expert <expert@example.com>");
MODULE_DESCRIPTION("A simple Hello World kernel module");
MODULE_VERSION("1.0");

关键要点说明:

  • __init 和 __exit 标记:__init 告诉内核该函数仅在初始化阶段使用,初始化完成后可以释放占用的内存;__exit 标记的函数在模块卸载时调用,如果模块被编译进内核(非模块方式),该函数会被忽略。
  • 返回值约定:初始化函数返回 0 表示成功,返回负数(如 -ENOMEM、-EIO)表示失败,内核会根据返回值决定是否保留模块。
  • printk 日志级别:KERN_INFO 是日志级别宏,用于控制消息的输出优先级,可通过 dmesg 命令查看。

下图展示了 module_init 和 module_exit 在内核中的注册与回调机制:

module_init / module_exit 注册与回调机制 模块代码 module_init(hello_init) module_exit(hello_exit) 内核符号表 __initcall_hello_init __exitcall_hello_exit 内核回调 do_init_module() free_module() 宏展开后,函数指针被放入特定的 ELF section(.initcall 和 .exitcall),内核在加载/卸载时遍历这些 section 并回调注册的函数。

2.3 模块许可证与作者信息

Linux 内核要求所有模块声明其许可证信息,这是为了遵守 GNU GPL 协议。内核通过许可证信息决定是否允许模块使用某些 GPL-only 的符号(函数和变量)。

常用的模块元信息宏如下表所示:

宏 用途 示例值
MODULE_LICENSE() 声明模块许可证 "GPL", "GPL v2", "BSD", "Proprietary"
MODULE_AUTHOR() 声明模块作者 "John Doe <john@example.com>"
MODULE_DESCRIPTION() 模块功能描述 "A sample device driver"
MODULE_VERSION() 模块版本号 "1.0", "2.1.3"
MODULE_ALIAS() 为模块创建别名 "char-major-10-30"
MODULE_DEVICE_TABLE() 声明设备 ID 表 USB、PCI 设备表
许可证影响:如果模块声明为 "Proprietary" 或非 GPL 兼容许可证,则无法调用内核中标记为 EXPORT_SYMBOL_GPL() 导出的符号。这会影响对某些核心内核 API 的访问,例如部分内存管理函数和调试接口。

使用 modinfo 命令可以查看已编译模块的元信息:

$ modinfo hello.ko
filename:       /home/user/hello.ko
license:        GPL
author:         Embedded Expert <expert@example.com>
description:    A simple Hello World kernel module
version:        1.0
depends:        
retpoline:      Y
name:           hello
vermagic:       5.15.0-generic SMP mod_unload modversions

2.4 模块参数传递

内核模块支持在加载时通过命令行参数传递配置值,这使得模块具有更好的灵活性和可配置性。模块参数使用 module_param() 和 module_param_array() 宏声明。

参数声明的基本语法如下:

#include <linux/moduleparam.h>

// 声明一个整型参数
static int irq_num = 10;
module_param(irq_num, int, 0644);
MODULE_PARM_DESC(irq_num, "IRQ number for the device");

// 声明一个字符串参数
static char *device_name = "my_device";
module_param(device_name, charp, 0644);
MODULE_PARM_DESC(device_name, "Device name string");

// 声明一个整型数组参数
static int dma_channels[4] = {0, 1, 2, 3};
static int num_channels = 4;
module_param_array(dma_channels, int, &num_channels, 0644);
MODULE_PARM_DESC(dma_channels, "DMA channel numbers array");

参数类型与对应的加载方式如下表所示:

C 类型 module_param 类型标识 加载示例
int int insmod hello.ko irq_num=17
long long insmod hello.ko base_addr=0x1000
char * charp insmod hello.ko device_name="uart0"
int[] int (配合 module_param_array) insmod hello.ko dma_channels=1,2,3
bool bool insmod hello.ko debug=1

参数权限位(上述示例中的 0644)控制参数在 sysfs 文件系统中的可见性和可写性:

  • 0:参数完全不在 sysfs 中暴露,用户空间不可见。
  • 0444:参数在 sysfs 中只读可见。
  • 0644:参数在 sysfs 中可读,root 用户可写(运行时修改)。

下图展示了模块参数从用户空间传递到内核模块的完整路径:

模块参数传递路径 用户空间 insmod hello.ko irq=17 内核参数解析层 parse_args() / param_ops 模块变量 static int irq_num sysfs 接口 /sys/module/hello/parameters/ 参数传递支持两种方式:加载时通过 insmod 命令行传递,或运行时通过 sysfs 文件系统修改(需权限位 > 0)。 内核使用 param_ops 结构体完成字符串到具体类型的转换。
工程实践建议:在设备驱动开发中,建议将硬件资源相关的参数(如 IRQ 号、DMA 通道、内存基地址)设计为模块参数。这样同一份驱动代码可以通过不同参数适配不同硬件平台,提高代码复用性。同时,为每个参数添加 MODULE_PARM_DESC 描述,方便其他开发者通过 modinfo 了解参数含义。

2.5 完整示例:带参数的可配置模块

下面是一个综合示例,将本章所学的所有知识点整合在一起:

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/moduleparam.h>

// 模块参数
static int irq = 10;
static char *name = "default_dev";
static int buffer_size = 1024;

module_param(irq, int, 0644);
module_param(name, charp, 0644);
module_param(buffer_size, int, 0444);

MODULE_PARM_DESC(irq, "IRQ number (default: 10)");
MODULE_PARM_DESC(name, "Device name string (default: default_dev)");
MODULE_PARM_DESC(buffer_size, "DMA buffer size in bytes (default: 1024)");

static int __init configurable_init(void)
{
    printk(KERN_INFO "Configurable Module Loaded\n");
    printk(KERN_INFO "  IRQ: %d\n", irq);
    printk(KERN_INFO "  Device: %s\n", name);
    printk(KERN_INFO "  Buffer: %d bytes\n", buffer_size);
    return 0;
}

static void __exit configurable_exit(void)
{
    printk(KERN_INFO "Configurable Module Unloaded\n");
}

module_init(configurable_init);
module_exit(configurable_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Embedded Expert <expert@example.com>");
MODULE_DESCRIPTION("Configurable module with parameters");
MODULE_VERSION("2.0");

加载测试:

# 使用默认参数加载
sudo insmod configurable.ko

# 使用自定义参数加载
sudo insmod configurable.ko irq=33 name="spi_dev" buffer_size=4096

# 查看参数值
cat /sys/module/configurable/parameters/irq
cat /sys/module/configurable/parameters/name

# 运行时修改参数(需 root 权限)
echo 44 | sudo tee /sys/module/configurable/parameters/irq
运行时修改参数的风险:虽然 sysfs 允许在模块运行时修改参数值,但驱动代码需要自行处理参数变化带来的影响。如果驱动在初始化时已经根据参数值配置了硬件寄存器,运行时修改参数不会自动重新配置硬件,可能导致驱动状态与硬件状态不一致。建议仅在调试阶段使用运行时修改功能。