2. 内核模块基础

Linux 内核模块(Loadable Kernel Module,LKM)是可以在系统运行期间动态插入或移出内核的二进制代码。它让驱动开发者无需重新编译整个内核、也无需重启系统,就能验证字符设备、平台设备等驱动逻辑。本节围绕模块生命周期、参数传递、符号导出、日志调试与构建脚本五个关键点展开,为后续字符设备与平台驱动开发打下工程基础。

一、模块的加载与卸载:生命周期入口

每个内核模块都必须提供初始化入口和退出入口。初始化函数在 insmod 或 modprobe 时被调用,负责注册设备号、申请资源、创建类或注册平台驱动;退出函数在 rmmod 时被调用,负责逆序释放资源。二者通过宏 module_init() 与 module_exit() 注册,并配合 MODULE_LICENSE() 声明许可证,否则内核会标记为污染(tainted)。

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

static int __init demo_init(void)
{
    pr_info("demo: module init\n");
    return 0;              /* 返回 0 表示加载成功 */
}

static void __exit demo_exit(void)
{
    pr_info("demo: module exit\n");
}

module_init(demo_init);
module_exit(demo_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("driver-lab");
MODULE_DESCRIPTION("kernel module lifecycle demo");

加载与卸载的常用命令如下:

sudo insmod demo.ko          # 加载模块
lsmod | grep demo            # 查看已加载模块
sudo rmmod demo              # 卸载模块
modinfo demo.ko              # 查看模块元信息
dmesg | tail -20             # 查看内核日志
初始化函数应返回 int,成功返回 0,失败返回负的 errno(如 -ENOMEM、-EBUSY)。退出函数返回 void,且必须与初始化过程严格对称,避免资源泄漏。

二、模块参数:让驱动可配置

模块参数允许在加载时向模块传递配置值,常用于指定主设备号、缓冲区大小、调试开关等。内核提供 module_param()、module_param_named()、module_param_array() 等宏,并通过 MODULE_PARM_DESC() 添加说明。参数类型支持 int、uint、long、charp、bool 等。

static int major = 0;              /* 0 表示动态分配 */
static int buf_size = 256;
static char *dev_name = "mychar";
static bool debug_en = false;

module_param(major, int, 0644);
MODULE_PARM_DESC(major, "major device number, 0 for dynamic");
module_param(buf_size, int, 0644);
MODULE_PARM_DESC(buf_size, "ring buffer size in bytes");
module_param(dev_name, charp, 0644);
module_param(debug_en, bool, 0644);

加载时传参方式为 insmod demo.ko major=240 buf_size=512 debug_en=1。权限位 0644 决定参数是否在 /sys/module/demo/parameters/ 下可读写,便于运行时查看与调整。

三、符号导出:模块之间的依赖

当模块 A 需要调用模块 B 中的函数或变量时,B 必须使用 EXPORT_SYMBOL() 或 EXPORT_SYMBOL_GPL() 导出符号。前者对所有模块开放,后者仅允许 GPL 兼容模块使用。导出后,A 在编译时通过 KBUILD_EXTRA_SYMBOLS 或先加载 B 再加载 A 的方式解析符号。

/* provider.c:导出符号 */
int calc_checksum(const char *buf, int len)
{
    int sum = 0;
    while (len--)
        sum += *buf++;
    return sum;
}
EXPORT_SYMBOL_GPL(calc_checksum);

/* consumer.c:使用符号 */
extern int calc_checksum(const char *buf, int len);
符号导出会扩大内核接口面,应尽量使用 EXPORT_SYMBOL_GPL 并保持接口稳定。若卸载 provider 时 consumer 仍在使用,内核会拒绝卸载并提示 Module provider is in use。

四、printk 调试:内核日志分级与输出

printk() 是内核最基础的日志输出手段,其级别由 KERN_* 宏控制,数值越小优先级越高。常用级别包括 KERN_EMERG、KERN_ALERT、KERN_CRIT、KERN_ERR、KERN_WARNING、KERN_NOTICE、KERN_INFO、KERN_DEBUG。现代内核推荐使用 pr_info()、pr_err()、dev_info() 等封装宏。

pr_info("demo: major=%d, buf_size=%d\n", major, buf_size);
pr_err("demo: register_chrdev failed, ret=%d\n", ret);
dev_dbg(&pdev->dev, "probe enter, irq=%d\n", irq);

日志可通过 dmesg -w 实时观察,也可用 dmesg -T 显示时间戳。若输出过多,可调整 /proc/sys/kernel/printk 中的控制台日志级别,或使用 dynamic_debug 按文件、函数、行号精细开关调试信息。

五、Makefile 编写:模块构建规则

内核模块使用 Kbuild 体系构建,Makefile 需指定目标对象与内核源码路径。若模块由多个源文件组成,使用 obj-m += demo.o 与 demo-objs := a.o b.o 组合。

obj-m += demo.o
# demo-objs := main.o helper.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

install:
	sudo insmod demo.ko
uninstall:
	sudo rmmod demo

执行 make 后生成 demo.ko,同时产生 .mod.c、.mod.o、Module.symvers 等中间文件。交叉编译时只需替换 KDIR 为对应架构的内核构建目录,并指定 ARCH 与 CROSS_COMPILE。

六、关键要点对照表

主题 核心接口/命令 典型用途 常见误区
加载/卸载 module_init/exit、insmod/rmmod 注册与释放资源 退出函数未逆序释放,导致泄漏
模块参数 module_param、MODULE_PARM_DESC 加载时配置主设备号、缓冲区 权限位设置不当,sysfs 不可读
符号导出 EXPORT_SYMBOL_GPL 模块间函数复用 滥用导出,破坏内核封装
printk 调试 pr_info、dev_dbg、dmesg 定位 probe、中断、寄存器问题 日志级别过高,刷屏影响性能
Makefile obj-m、M=$(PWD) 编译生成 .ko 内核路径错误或未清理中间文件

七、案例与复盘:一个可调试的模块骨架

下面给出一个综合示例:模块加载时打印参数,导出校验函数,卸载时清理。通过 dmesg 可观察完整生命周期。

static int __init demo_init(void)
{
    pr_info("demo: init, major=%d, buf_size=%d, name=%s\n",
            major, buf_size, dev_name);
    return 0;
}

static void __exit demo_exit(void)
{
    pr_info("demo: exit\n");
}

module_init(demo_init);
module_exit(demo_exit);
MODULE_LICENSE("GPL");

复盘时重点关注三点:第一,加载失败时是否返回了正确的负 errno,便于 insmod 给出可读错误;第二,参数是否在 /sys/module/demo/parameters/ 下可见;第三,卸载后 lsmod 是否已无该模块,且 dmesg 中 init/exit 成对出现。掌握这些基础后,后续字符设备与平台驱动的注册、probe、remove 流程才能稳定落地。

内核模块:构建 → 加载 → 运行 → 卸载 全链路 ① 构建阶段 Makefile + Kbuild obj-m += demo.o 生成 demo.ko ② 加载阶段 insmod / modprobe module_init() 执行 解析模块参数 ③ 运行阶段 注册设备/平台驱动 导出符号供依赖 printk 输出日志 ④ 卸载 rmmod module_exit() 释放资源 模块参数 major / buf_size / debug_en 符号导出 EXPORT_SYMBOL_GPL printk dmesg 模块间依赖与调试闭环 provider.ko 导出 calc_checksum consumer.ko 调用导出符号 dmesg -w 实时观察日志 加载顺序:先 provider 后 consumer;卸载顺序相反,否则 rmmod 报 “Module is in use”