3. 内核模块编译
内核模块的编译与普通用户态程序有本质区别。它必须依赖内核源码树提供的编译框架(Kbuild),并遵循特定的Makefile语法。本章将深入讲解如何为外部模块编写Makefile、理解Kbuild系统的工作原理、完成模块的编译与加载,并掌握模块参数传递这一关键接口。
核心概念: 外部模块(Out-of-tree module)是指不位于内核源码树内的模块代码。Kbuild系统通过递归解析Makefile,自动处理依赖、编译选项和链接,开发者只需声明目标对象即可。
3.1 Kbuild系统基础
Kbuild(Kernel Build)是Linux内核的编译系统,基于GNU Make构建。它为模块编译提供了标准化的变量和规则。理解Kbuild的核心变量是编写模块Makefile的前提。
| Kbuild变量 | 作用 | 示例 |
|---|---|---|
obj-m |
指定需要编译为模块的目标文件(.o) | obj-m := mymodule.o |
obj-y |
指定需要编译进内核镜像的目标文件 | obj-y := core.o |
module-objs |
当模块由多个源文件组成时,列出所有目标文件 | mymodule-objs := file1.o file2.o |
ccflags-y |
为当前目录下的所有目标添加编译标志 | ccflags-y := -DDEBUG -Wall |
KBUILD_EXTMOD |
由make命令行传入,指向外部模块源码目录 | make M=/path/to/module |
关键规则: Kbuild系统通过
obj-m识别需要编译的模块。如果模块由单个源文件hello.c构成,则只需obj-m := hello.o;如果由多个源文件构成,则需要使用模块名-objs变量列出所有.o文件。
3.2 编写模块Makefile
一个标准的外部模块Makefile通常包含两部分:自定义变量声明和Kbuild调用规则。下面是一个单文件模块的完整示例。
# Makefile - 单文件模块示例
# 模块名称:hello
# 指定内核源码树路径(适用于已安装内核头文件的环境)
KERNELDIR ?= /lib/modules/$(shell uname -r)/build
# 当前模块源码路径
PWD := $(shell pwd)
# 默认目标:编译模块
all:
$(MAKE) -C $(KERNELDIR) M=$(PWD) modules
# 清理目标
clean:
$(MAKE) -C $(KERNELDIR) M=$(PWD) clean
# 安装模块到标准路径
install:
$(MAKE) -C $(KERNELDIR) M=$(PWD) modules_install
# 声明Kbuild目标(被-C调用时解析)
obj-m := hello.o
常见错误: 初学者容易忘记在Makefile末尾添加
obj-m声明,或者将obj-m放在all目标之前。Kbuild系统只在-C切换目录后解析Makefile,因此obj-m必须位于Makefile中,且不能被任何目标规则遮蔽。
对于多文件模块,需要使用模块名-objs变量。假设模块chardev由main.c和ioctl.c组成:
# Makefile - 多文件模块示例
KERNELDIR ?= /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
obj-m := chardev.o
chardev-objs := main.o ioctl.o
all:
$(MAKE) -C $(KERNELDIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KERNELDIR) M=$(PWD) clean
3.3 编译与加载外部模块
完成Makefile编写后,编译和加载模块的步骤非常直接。以下是标准操作流程:
- 编译模块: 在模块源码目录执行
make,生成.ko文件。 - 查看模块信息: 使用
modinfo hello.ko查看模块的作者、描述、依赖和参数。 - 加载模块: 使用
sudo insmod hello.ko将模块插入内核。 - 验证加载: 使用
lsmod | grep hello确认模块已加载。 - 卸载模块: 使用
sudo rmmod hello从内核中移除模块。 - 查看内核日志: 使用
dmesg | tail查看模块打印的日志信息。
调试技巧: 如果编译报错“找不到内核头文件”,请先安装对应内核版本的开发包:
sudo apt-get install linux-headers-$(uname -r)。如果加载模块时提示“Invalid module format”,通常是因为模块编译时使用的内核版本与当前运行内核版本不一致。
3.4 模块参数传递
模块参数允许在加载模块时向内核模块传递配置值,无需重新编译。Linux内核提供了module_param和module_param_array宏来声明参数。
| 宏 | 用途 | 参数类型 |
|---|---|---|
module_param(name, type, perm) |
声明一个标量参数 | byte, short, ushort, int, uint, long, ulong, charp, bool, invbool |
module_param_array(name, type, nump, perm) |
声明一个数组参数 | 与标量类型相同,nump 指向数组长度变量 |
MODULE_PARM_DESC(name, desc) |
为参数添加描述信息 | 字符串 |
参数权限(perm)说明:
0:参数在sysfs中不可见,仅能在加载时通过insmod/modprobe命令行传递。S_IRUGO(0444):所有用户可读,可在/sys/module/<模块名>/parameters/下查看。S_IWUSR|S_IRUGO(0644):所有者可写,其他用户可读,支持运行时修改。
下面是一个包含多种参数类型的模块示例:
// param_demo.c - 模块参数传递示例
#include <linux/module.h>
#include <linux/moduleparam.h>
#include <linux/init.h>
// 标量参数
static int count = 1;
module_param(count, int, S_IRUGO);
MODULE_PARM_DESC(count, "操作次数,默认1");
// 字符串参数
static char *name = "default_dev";
module_param(name, charp, S_IRUGO);
MODULE_PARM_DESC(name, "设备名称");
// 布尔参数
static bool enable_debug = false;
module_param(enable_debug, bool, S_IRUGO);
MODULE_PARM_DESC(enable_debug, "是否启用调试输出");
// 数组参数
static int arr[4];
static int arr_len;
module_param_array(arr, int, &arr_len, S_IRUGO);
MODULE_PARM_DESC(arr, "整数数组,最多4个元素");
static int __init param_init(void)
{
int i;
printk(KERN_INFO "参数演示模块加载\n");
printk(KERN_INFO "count = %d\n", count);
printk(KERN_INFO "name = %s\n", name);
printk(KERN_INFO "enable_debug = %d\n", enable_debug);
printk(KERN_INFO "数组长度 = %d\n", arr_len);
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 "参数演示模块卸载\n");
}
module_init(param_init);
module_exit(param_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Kernel Expert");
MODULE_DESCRIPTION("模块参数传递示例");
加载时传递参数:
# 加载模块并传递参数
sudo insmod param_demo.ko count=5 name="mydevice" enable_debug=1 arr=10,20,30
# 查看模块参数(加载后)
cat /sys/module/param_demo/parameters/count
cat /sys/module/param_demo/parameters/name
最佳实践: 使用
modprobe加载模块时,参数可以放在/etc/modprobe.d/目录下的配置文件中,例如options param_demo count=10 name="usb_dev"。这样无需每次手动输入参数。