2. 内核模块基础:模块的编写、编译、加载与卸载

Linux 内核模块是设备驱动的主要载体。与直接编译进内核的代码不同,模块允许在运行时动态加载和卸载,从而减小内核体积并提高开发效率。本章将系统讲解模块的编写规范、Makefile 构建、加载/卸载流程、参数传递与模块依赖管理。

2.1 模块的编写:最小框架

一个完整的内核模块至少包含两个核心函数:模块初始化入口(module_init)和模块卸载出口(module_exit)。此外,还需要通过 MODULE_LICENSE 声明许可证,否则内核会标记为“污染”。

关键点: 模块运行在内核空间,不能链接标准 C 库(如 glibc),只能使用内核导出的 API(如 printk、kmalloc)。
/* hello.c — 最小内核模块示例 */
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.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 World module");
模块代码关键元素说明
元素作用注意事项
__init / __exit标记函数在初始化/卸载后释放内存仅用于模块,非模块驱动不要使用
module_init()注册模块加载入口必须且只能有一个
module_exit()注册模块卸载出口必须且只能有一个
MODULE_LICENSE声明许可证,影响内核符号导出推荐使用 "GPL"
printk()内核日志输出,类似用户态 printf日志级别用 KERN_INFO 等宏

2.2 模块的编译:Makefile 编写

内核模块的编译依赖内核源码树(或内核头文件包),不能使用普通的 gcc 命令。标准做法是使用 kbuild 系统,通过 make -C /lib/modules/$(uname -r)/build M=$(PWD) modules 进行编译。

# Makefile — 编译 hello.ko
obj-m := hello.o

# 如果模块由多个源文件组成,使用如下格式:
# hello-objs := file1.o file2.o
# obj-m := hello.o

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

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

clean:
    $(MAKE) -C $(KDIR) M=$(PWD) clean
提示: 如果交叉编译,需要指定 ARCH 和 CROSS_COMPILE 变量,例如:make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf-。

编译完成后,会生成 hello.ko(内核模块文件)、hello.mod.c(模块信息)、Module.symvers(符号版本表)等文件。

2.3 模块的加载与卸载:insmod / rmmod

加载模块使用 insmod 或 modprobe,卸载使用 rmmod。查看已加载模块使用 lsmod,查看模块信息使用 modinfo。

模块管理命令速查
命令功能示例
insmod加载指定 .ko 文件,不自动处理依赖insmod hello.ko
rmmod卸载指定模块rmmod hello
modprobe智能加载/卸载,自动处理依赖modprobe hello
lsmod列出所有已加载模块lsmod | grep hello
modinfo查看模块详细信息modinfo hello.ko
注意: 加载模块需要 root 权限。如果 insmod 失败,使用 dmesg | tail 查看内核日志定位错误。
# 加载模块
sudo insmod hello.ko
# 查看内核日志
dmesg | tail -5
# 预期输出: [ 1234.5678] Hello, Kernel Module!

# 卸载模块
sudo rmmod hello
dmesg | tail -5
# 预期输出: [ 1235.6789] Goodbye, Kernel Module!

2.4 模块参数:向模块传递配置

内核模块支持通过 module_param() 宏定义参数,在加载时通过 insmod 或 modprobe 传递。参数类型支持 int、charp(字符串指针)、bool、int array 等。

/* param_demo.c — 模块参数示例 */
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>

static int count = 1;
static char *name = "default";

module_param(count, int, 0644);
module_param(name, charp, 0644);

MODULE_PARM_DESC(count, "Number of times to print");
MODULE_PARM_DESC(name, "Name to greet");

static int __init param_init(void)
{
    int i;
    for (i = 0; i < count; i++)
        printk(KERN_INFO "Hello %s! (count=%d)\n", name, count);
    return 0;
}

static void __exit param_exit(void)
{
    printk(KERN_INFO "Param module unloaded.\n");
}

module_init(param_init);
module_exit(param_exit);

MODULE_LICENSE("GPL");
# 带参数加载
sudo insmod param_demo.ko count=3 name="DriverDev"
dmesg | tail -3
# 输出:
# [  ...] Hello DriverDev! (count=3)
# [  ...] Hello DriverDev! (count=3)
# [  ...] Hello DriverDev! (count=3)
module_param 参数说明
参数含义示例
name变量名,与 C 变量一致count
type参数类型:int, charp, bool, long 等int
permsysfs 权限位,0 表示不暴露0644(所有用户可读)

2.5 模块依赖:符号导出与依赖管理

当一个模块使用另一个模块导出的符号(函数或变量)时,就形成了模块依赖。内核通过 EXPORT_SYMBOL 和 EXPORT_SYMBOL_GPL 宏导出符号供其他模块使用。

/* export_demo.c — 导出符号供其他模块使用 */
#include <linux/module.h>
#include <linux/kernel.h>

void my_print(const char *msg)
{
    printk(KERN_INFO "my_print: %s\n", msg);
}
EXPORT_SYMBOL(my_print);

static int __init export_init(void)
{
    printk(KERN_INFO "Export module loaded.\n");
    return 0;
}

static void __exit export_exit(void)
{
    printk(KERN_INFO "Export module unloaded.\n");
}

module_init(export_init);
module_exit(export_exit);
MODULE_LICENSE("GPL");
/* import_demo.c — 使用其他模块导出的符号 */
#include <linux/module.h>
#include <linux/kernel.h>

extern void my_print(const char *msg);

static int __init import_init(void)
{
    my_print("Hello from import module!");
    return 0;
}

static void __exit import_exit(void)
{
    printk(KERN_INFO "Import module unloaded.\n");
}

module_init(import_init);
module_exit(import_exit);
MODULE_LICENSE("GPL");
依赖加载顺序: 必须先加载 export_demo.ko,再加载 import_demo.ko。使用 modprobe 可以自动处理依赖,但需要将模块安装到标准路径(/lib/modules/$(uname -r)/)并运行 depmod 生成依赖文件。
# 手动加载依赖模块
sudo insmod export_demo.ko
sudo insmod import_demo.ko
dmesg | tail -2
# 输出:
# [  ...] Export module loaded.
# [  ...] my_print: Hello from import module!

# 查看模块依赖
lsmod | grep import_demo
# 输出: import_demo          16384  0
# 注意:依赖关系不会在 lsmod 中直接显示,需查看 /proc/modules 或使用 modinfo

2.6 模块生命周期与内核日志

下图展示了内核模块从加载到卸载的完整生命周期,以及各阶段对应的内核日志输出。

内核模块生命周期与日志输出 insmod hello.ko module_init() 模块运行中 rmmod hello module_exit() dmesg 日志输出 [ ...] Hello, Kernel Module! [ ...] (模块运行中...) [ ...] Goodbye, Kernel Module!

2.7 常见问题与避坑指南

  • 模块加载失败: 使用 dmesg | tail -20 查看具体错误,常见原因包括符号未定义、版本魔术不匹配、内存不足。
  • 模块卸载失败: 模块被其他模块依赖、有打开的设备文件、或模块代码中有资源未释放(如定时器、工作队列)。使用 lsmod 查看引用计数,若不为 0 则无法卸载。
  • printk 不输出: 检查日志级别,使用 KERN_ERR 或 KERN_ALERT 确保输出到控制台;或通过 dmesg 查看。
  • Makefile 编译错误: 确认内核头文件已安装(apt install linux-headers-$(uname -r)),且 KDIR 路径正确。
  • 模块参数不生效: 检查 module_param 的 perm 权限位,若为 0 则无法通过 sysfs 查看,但加载时仍可传递。
开发效率建议: 在模块开发阶段,建议在 ~/.bashrc 中添加别名:alias mkmod='make -C /lib/modules/$(uname -r)/build M=$(pwd) modules',简化编译命令。