内核模块基础:模块加载与卸载、模块参数传递、模块依赖与符号导出

说实话,很多刚接触驱动开发的朋友,第一个坎儿就是搞不懂内核模块到底是个什么东西。我当年也一样,觉得它跟普通应用程序差不多,结果一跑就崩,连个报错都看不懂。

其实说白了,内核模块就是一段可以在系统运行时动态加载到内核的代码。它不像应用程序那样有 main 函数,也不依赖 libc 库。它直接运行在内核空间,拥有最高权限——当然,搞砸了也是最高级别的崩溃。

核心概念:内核模块是 Linux 内核实现"按需加载"的机制。驱动、文件系统、网络协议都可以做成模块,需要时 insmod,不用时 rmmod,不用重新编译整个内核。

2.1 最简单的内核模块:Hello World

先看一个最基础的模块代码。我习惯把它当作驱动开发的"起点仪式"。

#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;
}

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");

注意几个关键点:

  • module_init 和 module_exit 是入口和出口宏,不是函数声明
  • __init 和 __exit 是属性标记,告诉内核这些函数只在初始化/卸载时使用,之后可以释放内存
  • printk 是内核空间的"printf",日志级别用 KERN_INFO 等宏控制
  • MODULE_LICENSE 必须声明,否则内核会报"tainted"警告

我的习惯:每个模块我都会加上 MODULE_VERSION,方便后期排查固件版本问题。这个字段虽然不强制,但在生产环境中特别有用。

2.2 模块的加载与卸载

模块写好了,怎么跑起来?我常用的命令就这几个:

操作 命令 说明
加载模块 insmod hello.ko 直接插入,不处理依赖
加载模块(带依赖) modprobe hello 自动解决依赖关系
卸载模块 rmmod hello 移除模块
查看已加载模块 lsmod 列出所有模块及使用计数
查看模块信息 modinfo hello.ko 显示作者、描述、参数等

这里有个坑——我曾经在调试时用 insmod 加载了一个依赖其他模块的驱动,结果直接报 "Unknown symbol"。后来才意识到,insmod 不会帮你拉依赖,必须用 modprobe。

注意:rmmod 卸载模块时,如果模块被其他模块引用(lsmod 中 Used by 不为 0),卸载会失败。强制卸载可以用 rmmod -f,但我不建议在生产环境这么做,容易导致内核 panic。

2.3 模块参数传递

驱动写死了参数值,每次改都要重新编译?太傻了。内核模块支持参数传递,运行时通过 insmod 或 modprobe 传入。

#include <linux/moduleparam.h>

static int debug_level = 0;
static char *device_name = "default_dev";

module_param(debug_level, int, 0644);
module_param(device_name, charp, 0644);

MODULE_PARM_DESC(debug_level, "Debug level (0-3)");
MODULE_PARM_DESC(device_name, "Device name string");

加载时传参:

insmod mydriver.ko debug_level=2 device_name="my_uart"

参数类型支持:

类型 宏 示例
整型 module_param(name, int, perm) module_param(count, int, 0)
长整型 module_param(name, long, perm) module_param(interval, long, 0)
字符串 module_param(name, charp, perm) module_param(name, charp, 0)
布尔型 module_param(name, bool, perm) module_param(enable, bool, 0)
数组 module_param_array(name, type, num, perm) module_param_array(ports, int, &num, 0)

perm 参数是文件权限,如果设为 0,模块参数不会出现在 /sys/module/ 下。我一般调试时设 0644,方便运行时通过 sysfs 修改。

避坑指南:我曾经在项目里用 charp 传了一个路径字符串,结果忘记检查长度,导致缓冲区溢出。建议对字符串参数做长度校验,或者用 module_param_string 指定最大长度。

2.4 模块依赖与符号导出

驱动之间经常有依赖关系。比如一个 I2C 控制器驱动,会被多个 I2C 设备驱动依赖。怎么实现?靠符号导出。

内核用 EXPORT_SYMBOL 和 EXPORT_SYMBOL_GPL 把函数或变量暴露给其他模块。

// 在控制器驱动中导出
#include <linux/export.h>

int i2c_controller_register(struct i2c_adapter *adap)
{
    // 注册逻辑
}
EXPORT_SYMBOL(i2c_controller_register);

// 在设备驱动中引用
extern int i2c_controller_register(struct i2c_adapter *adap);

两个导出宏的区别:

宏 谁可以用 典型场景
EXPORT_SYMBOL 所有模块(含非GPL) 通用接口,如内存分配
EXPORT_SYMBOL_GPL 仅GPL许可的模块 核心机制,如文件系统接口

模块依赖关系可以用 MODULE_SOFTDEP 声明:

MODULE_SOFTDEP("pre: i2c_core");

这样 modprobe 在加载当前模块前,会先加载 i2c_core。

注意:符号导出不是随便用的。过度导出会导致内核接口混乱,维护困难。我见过一个项目,所有驱动函数都 EXPORT_SYMBOL,最后没人敢改任何接口。建议只导出必要的、稳定的接口。

2.5 知识结构总览

下面这张图把本章的核心逻辑串起来了,你可以对照着理解模块的完整生命周期。

内核模块生命周期与依赖关系图 内核模块生命周期与依赖关系 1. 编写模块代码 module_init / module_exit 2. 编译为 .ko Makefile + Kbuild 3. 加载模块 insmod / modprobe 内核验证模块 检查许可证、版本、依赖 执行 module_init 注册设备、分配资源 模块进入运行态 lsmod 可见 模块依赖与符号导出 EXPORT_SYMBOL EXPORT_SYMBOL_GPL MODULE_SOFTDEP 执行 module_exit 释放资源、注销设备 内核移除模块 从模块列表删除 模块消失 rmmod 完成 rmmod

2.6 实战要点总结

最后,我把本章最实用的几个点列出来,你写驱动时可以直接对照:

  • 模块入口/出口必须配对——只写 init 不写 exit,卸载时资源泄漏
  • 参数权限设 0 还是 0644——调试阶段用 0644,方便 sysfs 调参;发布前改成 0
  • 符号导出要克制——只导出稳定接口,内部函数用 static 隐藏
  • 依赖声明用 MODULE_SOFTDEP——比在 init 里手动检查更优雅
  • printk 日志级别别乱用——KERN_DEBUG 调试用,KERN_ERR 报错用,KERN_INFO 正常信息

我的经验:刚开始写模块时,我总喜欢把所有函数都 EXPORT_SYMBOL,觉得"万一别人要用呢"。结果三个月后自己都分不清哪些是公开接口。后来我定了个规矩:只有被其他 .ko 直接调用的函数才导出,其他一律 static。这个习惯一直用到现在。