4、内核模块编程入门:模块的编写、编译(Makefile)、加载与卸载(insmod/modprobe/rmmod)、模块参数与模块依赖

Linux内核采用宏内核架构,但通过可加载模块(Loadable Kernel Module, LKM)机制实现了运行时的功能扩展。模块编程是Linux驱动开发的基石,驱动工程师需要掌握模块的编写规范、编译体系、加载/卸载流程以及模块间的协作方式。本章从零开始构建一个完整的字符设备模块,深入剖析Makefile的编写逻辑,对比insmod与modprobe的差异,并通过模块参数与依赖管理建立对内核模块生命周期的系统认知。

内核模块生命周期与依赖关系总览 模块源码与编译 hello.c (模块源码) Makefile (Kbuild体系) hello.ko (编译产物) 加载与卸载 insmod hello.ko modprobe hello rmmod hello 参数与依赖 module_param(name, type, perm) depmod / modules.dep 符号依赖 (EXPORT_SYMBOL) 内核空间与用户空间交互 用户空间 (User Space) 应用程序 (open/read/write/ioctl) 系统调用接口 (syscall) VFS 虚拟文件系统 内核空间 (Kernel Space) 字符设备驱动 (file_operations) 模块注册 (register_chrdev) 硬件操作 (ioremap/GPIO/中断) 硬件设备 寄存器/内存映射 中断控制器 DMA/总线控制器 insmod 直接加载 .ko 文件;modprobe 自动解析依赖并加载;rmmod 卸载模块

4.1 内核模块的核心原理与文件结构

内核模块本质上是一个ELF格式的目标文件(通常为 .ko 后缀),它在运行时被动态链接到内核地址空间。与用户态程序不同,模块没有 main() 函数入口,而是通过两个关键宏来声明生命周期回调:module_init() 指定模块加载时的初始化函数,module_exit() 指定卸载时的清理函数。这两个宏在 <linux/module.h> 中定义,编译器会将它们放入特定的ELF段(.init.text 和 .exit.text),内核在加载和卸载时通过段地址找到对应函数。

一个最小模块的源码结构如下:

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

static int __init hello_init(void)
{
    printk(KERN_INFO "Hello, kernel module!\n");
    return 0;  // 0表示成功,负数表示失败
}

static void __exit hello_exit(void)
{
    printk(KERN_INFO "Goodbye, kernel module!\n");
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");           // 必须声明许可证
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple hello module");
MODULE_VERSION("1.0");

其中 __init 和 __exit 是内核提供的属性标记。带有 __init 的函数在模块加载完成后,其占用的内存会被释放(如果模块编译进内核而非动态加载,则初始化完成后整个 .init 段都会被回收)。MODULE_LICENSE("GPL") 不是可选项——如果缺失或声明非GPL兼容许可证,内核将拒绝使用某些 EXPORT_SYMBOL_GPL 导出的符号,并且加载时会出现 tainted 标记。

经验提醒:在开发初期建议使用 printk 配合 KERN_INFO 级别输出调试信息。查看模块日志使用 dmesg 命令,或 tail -f /var/log/kern.log(Ubuntu/Debian)。生产环境应使用 pr_info/pr_err 等封装宏,它们会自动添加模块名前缀。

4.2 Makefile 编写与 Kbuild 编译体系

Linux内核模块的编译不直接使用 gcc,而是通过内核源码树中的 Kbuild 系统。Kbuild 负责处理内核头文件路径、编译选项、符号版本信息(CONFIG_MODVERSIONS)以及模块间依赖。一个标准的模块 Makefile 如下:

# Makefile
obj-m := hello.o          # 目标模块名(去掉.ko后缀)

KDIR := /lib/modules/$(shell uname -r)/build
PWD  := $(shell pwd)

all:
    $(MAKE) -C $(KDIR) M=$(PWD) modules

clean:
    $(MAKE) -C $(KDIR) M=$(PWD) clean

关键点解析:

  • obj-m:告诉Kbuild将 hello.o 编译为可加载模块(obj-y 表示编译进内核镜像)。
  • KDIR:指向当前运行内核的构建目录。如果安装了 linux-headers-$(uname -r) 包,该路径即存在。
  • -C $(KDIR):切换到内核源码目录执行make,M=$(PWD) 指定模块源码所在目录。
  • modules:Kbuild的目标,表示生成所有 obj-m 指定的模块。

当模块由多个源文件组成时,需要使用 模块名-objs 变量:

obj-m := mydriver.o
mydriver-objs := main.o helper.o io.o

此时Kbuild会先将 main.c、helper.c、io.c 分别编译为目标文件,再链接为 mydriver.ko。如果模块需要传递额外的编译选项(如定义宏),使用 ccflags-y:

ccflags-y := -DDEBUG -I$(src)/include
编译场景 Makefile 关键配置 说明
单文件模块 obj-m := hello.o 最简单形式,适用于入门
多文件模块 obj-m := mydrv.o
mydrv-objs := a.o b.o
按功能拆分源码,便于维护
跨平台编译 ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- 交叉编译时在make命令行传入
外部头文件 ccflags-y += -I$(src)/include 模块自带私有头文件目录
常见错误:如果编译时出现 error: unknown type name 'bool' 或找不到 linux/module.h,通常是因为KDIR路径错误或未安装内核头文件。在Ubuntu上执行 sudo apt install linux-headers-$(uname -r) 即可解决。另外,不要在Makefile中直接使用 -I/usr/include,那会引入用户态头文件导致冲突。

4.3 模块加载与卸载:insmod、modprobe、rmmod

模块编译完成后生成 .ko 文件,接下来需要将其加载到内核中。Linux提供了三个核心命令:insmod、modprobe 和 rmmod。它们的使用场景和底层机制有显著差异。

insmod 是最直接的加载命令,它通过 init_module() 系统调用将指定的 .ko 文件加载到内核。insmod 不会自动处理依赖关系——如果模块引用了其他模块导出的符号,必须先手动加载被依赖的模块。此外,insmod 需要指定完整的文件路径(除非当前目录下能找到)。

modprobe 是更智能的加载工具,它依赖 depmod 生成的依赖数据库(/lib/modules/$(uname -r)/modules.dep)。当执行 modprobe hello 时,它会:

  1. 在标准模块目录中查找 hello.ko(无需指定路径);
  2. 解析 modules.dep 文件,检查该模块依赖的其他模块;
  3. 按依赖顺序依次加载所有依赖模块;
  4. 最后加载目标模块。

rmmod 用于卸载模块,对应 delete_module() 系统调用。卸载时内核会检查模块的引用计数(refcnt),如果模块正被其他模块或进程使用(例如设备文件被打开),卸载将失败并返回 EBUSY。可以使用 lsmod 查看当前已加载模块及其引用计数。

# 加载模块(方式一:insmod,需指定路径)
sudo insmod ./hello.ko

# 加载模块(方式二:modprobe,自动处理依赖)
sudo modprobe hello

# 查看模块信息
lsmod | grep hello
modinfo hello.ko   # 显示模块的license、author、参数等信息

# 卸载模块
sudo rmmod hello

# 查看内核日志
dmesg | tail -20
实战场景:假设你开发了一个 i2c_sensor.ko 模块,它依赖内核中的 i2c-core 模块。如果使用 insmod i2c_sensor.ko,系统会报错 Unknown symbol in module。正确做法是:
1. 先执行 sudo modprobe i2c-dev 加载I2C核心模块;
2. 再 sudo insmod i2c_sensor.ko;
或者直接 sudo modprobe i2c_sensor(前提是已将模块安装到标准目录并运行 sudo depmod -a 更新依赖数据库)。

4.4 模块参数:向内核模块传递配置

模块参数允许在加载时向模块传递配置值,避免硬编码。内核提供了 module_param() 宏来声明参数:

// param_demo.c
#include <linux/moduleparam.h>

static int count = 1;
static char *name = "default";
static int arr[4] = {0, 0, 0, 0};
static int arr_argc = 0;

module_param(count, int, 0644);
MODULE_PARM_DESC(count, "Number of iterations");
module_param(name, charp, 0644);
MODULE_PARM_DESC(name, "Device name");
module_param_array(arr, int, &arr_argc, 0644);
MODULE_PARM_DESC(arr, "An array of integers");

// 在初始化函数中使用这些参数
static int __init param_init(void)
{
    int i;
    pr_info("count = %d, name = %s\n", count, name);
    for (i = 0; i < arr_argc; i++)
        pr_info("arr[%d] = %d\n", i, arr[i]);
    return 0;
}

加载时通过 参数名=值 的方式传递:

sudo insmod param_demo.ko count=5 name="mydev" arr=1,2,3,4

module_param 的第三个参数是sysfs权限位。如果设置为 0644,模块加载后会在 /sys/module/param_demo/parameters/ 目录下生成对应的属性文件,允许在运行时查看甚至修改参数值(取决于权限)。设置为 0 则不在sysfs中暴露。

参数类型 宏 示例 注意事项
基本类型 module_param(name, type, perm) int, bool, charp, long charp 表示字符指针,内核会复制字符串
数组 module_param_array(name, type, &num, perm) int arr[4] 第三个参数返回实际传入的元素个数
字符串 module_param_string(name, buf, len, perm) char buf[64] 固定长度缓冲区,更安全
描述 MODULE_PARM_DESC(name, desc) — 在 modinfo 中显示
风险提示:模块参数如果声明为 int 类型,加载时传入非法值(如字符串)会导致加载失败。更严重的是,如果参数用于数组索引或内存大小计算,未经验证的参数值可能引发内核崩溃。生产代码中务必对参数进行范围检查,并在 module_init 中返回 -EINVAL 拒绝非法配置。

4.5 模块依赖与符号导出

模块之间可以通过 符号导出 实现依赖。当一个模块需要调用另一个模块提供的函数时,被依赖模块必须使用 EXPORT_SYMBOL 或 EXPORT_SYMBOL_GPL 导出该符号。内核维护一个全局符号表,模块加载时通过 resolve_symbol 机制解析未定义的符号。

// exporter.c
#include <linux/module.h>

int my_add(int a, int b)
{
    return a + b;
}
EXPORT_SYMBOL(my_add);   // 导出符号,所有模块可用

int my_mul(int a, int b)
{
    return a * b;
}
EXPORT_SYMBOL_GPL(my_mul);  // 仅GPL模块可用

module_init(exporter_init);
module_exit(exporter_exit);
MODULE_LICENSE("GPL");
// importer.c
extern int my_add(int a, int b);  // 声明外部符号

static int __init importer_init(void)
{
    pr_info("my_add(3, 4) = %d\n", my_add(3, 4));
    return 0;
}
module_init(importer_init);
module_exit(importer_exit);
MODULE_LICENSE("GPL");

加载顺序必须保证:先加载 exporter.ko,再加载 importer.ko。如果使用 modprobe,需要先运行 sudo depmod -a 生成依赖关系,modprobe 会自动按顺序加载。查看模块间的依赖关系:

# 查看已加载模块的依赖
lsmod | grep importer

# 查看模块的符号表
nm exporter.ko | grep my_add

# 查看内核中已导出的符号
cat /proc/kallsyms | grep my_add
案例拆解:某嵌入式项目需要开发一个温度传感器驱动 tmp117.ko,它依赖I2C核心模块和GPIO模块。

初始现象:直接 insmod tmp117.ko 报错 Unknown symbol i2c_transfer。

分析过程:通过 nm tmp117.ko | grep U 查看未定义符号,发现 i2c_transfer 和 gpio_set_value 未解析。使用 grep i2c_transfer /proc/kallsyms 确认该符号在内核中已导出,但当前未加载对应模块。

解决步骤:
1. sudo modprobe i2c-core 加载I2C核心;
2. sudo modprobe gpio-mockup 加载GPIO模拟模块(或实际GPIO驱动);
3. sudo insmod tmp117.ko 成功加载。

复盘:使用 modprobe 替代 insmod 可以自动处理依赖,但前提是模块已安装到 /lib/modules/$(uname -r)/extra/ 或 updates/ 目录,并执行 depmod -a 更新依赖数据库。

4.6 常见误区与调试技巧

模块编程初学者常犯以下错误,这里逐一剖析:

  • 忘记声明MODULE_LICENSE:内核会标记模块为 tainted,并且无法使用 EXPORT_SYMBOL_GPL 导出的符号。某些内核功能(如 kprobes)也会拒绝在tainted模块上工作。
  • 在模块中使用用户态库函数:如 printf、malloc、strcpy 等。内核空间没有这些函数,应使用 printk、kmalloc、strscpy 等内核API。
  • 忽略返回值检查:module_init 返回非零值表示初始化失败,内核会卸载模块并释放资源。但如果在初始化过程中已经注册了设备或申请了资源,必须在返回错误前手动清理。
  • 卸载时未释放资源:module_exit 中必须释放所有申请的资源(内存、中断、设备号、IO区域等),否则会造成资源泄漏,甚至导致内核崩溃。
  • 并发访问未加锁:模块中的全局变量可能被多个进程或中断上下文同时访问,需要使用 spinlock 或 mutex 保护。
调试建议:
1. 使用 printk 时加上 KERN_DEBUG 级别,并通过 echo 8 > /proc/sys/kernel/printk 临时提高内核日志级别;
2. 使用 objdump -d hello.ko 查看模块反汇编,确认代码段正确;
3. 使用 strace insmod hello.ko 跟踪系统调用,确认加载失败的具体原因;
4. 在 /var/log/messages 或 journalctl -k 中查看内核日志,定位 Unknown symbol 或 Invalid module format 等错误。

模块编程是Linux驱动开发的第一道门槛。掌握模块的编写规范、Makefile的Kbuild体系、加载/卸载工具链、参数传递和依赖管理,是后续编写字符设备、平台驱动、中断处理等复杂驱动的基础。建议读者在虚拟机中反复练习本章的示例,并尝试修改参数、添加依赖,观察内核日志的变化,逐步建立对内核模块运行机制的直觉。