2. Linux内核模块编程基础:模块的加载与卸载、模块参数传递、模块依赖与符号导出、内核模块的Makefile编写
Linux内核模块(Loadable Kernel Module, LKM)是嵌入式Linux驱动开发的核心机制。它允许在运行时动态地向内核添加或移除功能,而无需重新编译整个内核或重启系统。本章将深入讲解模块编程的四大基石:模块的加载与卸载流程、模块参数传递方法、模块间的依赖与符号导出规则,以及标准化的Makefile编写规范。
2.1 模块的加载与卸载
每个内核模块都必须实现两个核心入口函数:module_init() 和 module_exit()。前者在模块加载时被内核调用,后者在模块卸载时被调用。这两个函数通过特定的宏注册到内核模块框架中。
2.1.1 模块生命周期与核心API
| 阶段 | 用户态命令 | 内核态回调 | 典型操作 |
|---|---|---|---|
| 加载 | insmod mymodule.ko |
module_init(init_func) |
注册设备、分配内存、初始化硬件 |
| 运行 | — | 模块代码常驻内核 | 处理系统调用、中断、文件操作 |
| 卸载 | rmmod mymodule |
module_exit(exit_func) |
注销设备、释放内存、关闭硬件 |
| 查看 | lsmod |
— | 列出已加载模块及依赖关系 |
insmod 用于加载指定路径的 .ko 文件,不自动解析依赖;modprobe 则会自动加载依赖模块,推荐在生产环境中使用。
2.1.2 最小模块代码示例
下面是一个完整的最小内核模块,包含加载和卸载函数,以及模块信息声明。
#include <linux/module.h> // 所有模块都需要
#include <linux/kernel.h> // printk 和 KERN_INFO
#include <linux/init.h> // __init 和 __exit 宏
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 Driver Expert");
MODULE_DESCRIPTION("A simple Hello World kernel module");
MODULE_VERSION("1.0");
2.1.3 模块加载与卸载流程图
下图展示了从用户态命令到内核态回调的完整流程,以及模块在内核中的状态转换。
module_init 返回负数(如 -ENODEV),模块加载会被内核拒绝,且不会调用 module_exit。务必在 init 函数中做好错误处理,确保资源在失败时全部回滚。
2.2 模块参数传递
内核模块支持在加载时通过命令行传递参数,这使得模块的行为可以在不重新编译的情况下灵活调整。参数通过 module_param() 和 module_param_array() 宏声明。
2.2.1 参数声明与类型
| 宏 | 数据类型 | 用户态传递示例 | 说明 |
|---|---|---|---|
module_param(name, type, perm) |
byte, short, ushort, int, uint, long, ulong, charp, bool, invbool |
insmod mymod.ko count=10 name="test" |
声明单个参数 |
module_param_array(name, type, nump, perm) |
数组类型 | insmod mymod.ko arr=1,2,3,4 |
声明数组参数,nump 指向数组长度变量 |
MODULE_PARM_DESC(name, desc) |
— | — | 为参数添加描述文本,可通过 modinfo 查看 |
2.2.2 参数权限位 (perm)
perm 参数控制参数在 /sys/module/<name>/parameters/ 下的文件权限:
- 0:参数完全对用户空间不可见,仅内核内部使用。
- S_IRUGO (0444):所有用户可读,不可写。
- S_IWUSR | S_IRUGO (0644):所有者可读写,其他用户只读。
- S_IRUGO | S_IWUSR | S_IWGRP (0664):所有者和组可读写,其他只读。
2.2.3 带参数的模块示例
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
static int count = 1;
static char *name = "default_device";
static int arr[4];
static int arr_len;
module_param(count, int, S_IRUGO);
module_param(name, charp, S_IRUGO);
module_param_array(arr, int, &arr_len, S_IRUGO);
MODULE_PARM_DESC(count, "Number of devices to initialize (default: 1)");
MODULE_PARM_DESC(name, "Device name string");
MODULE_PARM_DESC(arr, "Array of integer parameters");
static int __init param_init(void)
{
int i;
printk(KERN_INFO "Module loaded with count=%d, name=%s\n", count, name);
for (i = 0; i < arr_len; i++) {
printk(KERN_INFO "arr[%d] = %d\n", i, arr[i]);
}
return 0;
}
static void __exit param_exit(void)
{
printk(KERN_INFO "Module unloaded\n");
}
module_init(param_init);
module_exit(param_exit);
MODULE_LICENSE("GPL");
加载命令示例:
sudo insmod param_module.ko count=5 name="mydev" arr=10,20,30
2.3 模块依赖与符号导出
内核模块之间可以存在依赖关系:一个模块可以导出符号(函数或变量),供其他模块使用。这种机制实现了模块间的代码复用和解耦。
2.3.1 符号导出宏
| 宏 | 作用 | 使用场景 |
|---|---|---|
EXPORT_SYMBOL(symbol) |
导出符号,所有模块均可使用 | 通用驱动库、核心功能接口 |
EXPORT_SYMBOL_GPL(symbol) |
仅导出给 GPL 许可的模块 | 保护专有代码,强制 GPL 兼容 |
2.3.2 模块依赖关系图
下图展示了一个典型的模块依赖场景:core_driver 导出底层硬件操作函数,sub_driver_a 和 sub_driver_b 分别依赖它实现不同功能。
2.3.3 符号导出示例
core_driver.c — 导出符号:
#include <linux/module.h>
#include <linux/kernel.h>
void hw_init(void)
{
printk(KERN_INFO "Hardware initialized\n");
}
EXPORT_SYMBOL(hw_init);
void hw_write(int data)
{
printk(KERN_INFO "Writing data: %d\n", data);
}
EXPORT_SYMBOL_GPL(hw_write);
static int __init core_init(void) { return 0; }
static void __exit core_exit(void) { }
module_init(core_init);
module_exit(core_exit);
MODULE_LICENSE("GPL");
sub_driver_a.c — 使用导出的符号:
#include <linux/module.h>
#include <linux/kernel.h>
extern void hw_init(void); // 声明外部符号
static int __init sub_init(void)
{
hw_init(); // 调用 core_driver 导出的函数
printk(KERN_INFO "Sub driver A initialized\n");
return 0;
}
static void __exit sub_exit(void) { }
module_init(sub_init);
module_exit(sub_exit);
MODULE_LICENSE("GPL");
cat /proc/kallsyms | grep hw_init 可以查看内核中所有已导出的符号及其地址。如果符号未导出,该命令不会显示。
2.4 内核模块的Makefile编写
编写正确的 Makefile 是内核模块开发的基本功。Linux 内核提供了一套 Kbuild 系统,开发者只需遵循特定格式即可自动完成编译。
2.4.1 标准Makefile模板
# 指定内核源码树路径(根据实际环境修改)
KERNEL_DIR ?= /lib/modules/$(shell uname -r)/build
# 模块名称(最终生成 .ko 文件)
obj-m := mymodule.o
# 如果模块由多个源文件组成,使用如下格式:
# mymodule-objs := file1.o file2.o
# 默认编译目标
all:
$(MAKE) -C $(KERNEL_DIR) M=$(PWD) modules
# 清理编译产物
clean:
$(MAKE) -C $(KERNEL_DIR) M=$(PWD) clean
# 安装模块到系统目录
install:
$(MAKE) -C $(KERNEL_DIR) M=$(PWD) modules_install
2.4.2 Makefile关键变量解析
| 变量/语法 | 含义 | 示例 |
|---|---|---|
obj-m |
指定要编译为模块的目标文件 | obj-m := mydriver.o |
obj-y |
指定要编译进内核(非模块) | obj-y := built-in.o |
<module>-objs |
多源文件模块的目标文件列表 | mymod-objs := a.o b.o |
ccflags-y |
传递给编译器的额外标志 | ccflags-y := -DDEBUG -Wall |
KERNEL_DIR |
内核源码树路径,包含已配置的 .config |
/lib/modules/5.10.0/build |
M=$(PWD) |
告诉 Kbuild 当前模块源码所在目录 | 必须与 -C 配合使用 |
2.4.3 多文件模块的Makefile示例
假设模块 complex_driver 由 main.c、ioctl.c 和 utils.c 组成:
KERNEL_DIR ?= /lib/modules/$(shell uname -r)/build
obj-m := complex_driver.o
# 指定组成模块的目标文件
complex_driver-objs := main.o ioctl.o utils.o
# 添加调试信息
ccflags-y := -DDEBUG -g -O0
all:
$(MAKE) -C $(KERNEL_DIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KERNEL_DIR) M=$(PWD) clean
2.4.4 交叉编译Makefile(嵌入式场景)
在嵌入式开发中,需要指定交叉编译工具链和架构:
# 交叉编译工具链前缀
CROSS_COMPILE ?= arm-linux-gnueabihf-
# 目标架构
ARCH ?= arm
# 嵌入式板卡的内核源码树路径
KERNEL_DIR ?= /home/user/linux-5.10
obj-m := my_embedded_driver.o
all:
$(MAKE) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) \
-C $(KERNEL_DIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KERNEL_DIR) M=$(PWD) clean
- 确保
KERNEL_DIR指向已为嵌入式目标板配置并编译过的内核源码树。 CROSS_COMPILE前缀必须与工具链名称完全匹配,例如arm-linux-gnueabihf-gcc对应前缀arm-linux-gnueabihf-。- 编译生成的 .ko 文件只能在相同架构的内核上加载,不可在 x86 主机上使用。
2.5 本章小结
本章系统性地介绍了 Linux 内核模块编程的四个核心基础:
- 加载与卸载:通过
module_init和module_exit管理模块生命周期,使用insmod/rmmod操作。 - 参数传递:利用
module_param系列宏实现运行时配置,提高模块灵活性。 - 符号导出与依赖:通过
EXPORT_SYMBOL实现模块间代码复用,注意加载顺序。 - Makefile 编写:掌握 Kbuild 系统语法,支持单文件、多文件及交叉编译场景。
这些基础知识将贯穿整个驱动开发课程,是后续学习字符设备驱动、中断处理、并发控制等内容的前提。建议读者在真实的内核环境中动手实践每个示例,加深理解。