
内核编程入门:从模块加载到第一个内核模块
内核模块让 Linux 可以在系统运行期间加载或移除一部分内核代码。设备驱动、文件系统和部分网络功能都可能以模块形式提供。对初学者来说,一个最小模块可以帮助理解内核代码的入口、日志、构建流程与加载机制。
模块代码运行在内核态,不是普通应用程序。一个空指针、越界访问或死锁,都可能造成系统异常甚至崩溃。本文会在隔离的测试环境中,从编译到卸载写出一个可运行的“Hello”模块,并说明它适合做什么、不适合做什么,以及遇到问题时如何排查。
一、模块适合什么场景
模块适用于需要在内核中运行、又希望不必每次修改后都重新构建和启动整个内核的功能。例如,开发设备驱动、验证内核接口、实现或测试特定内核扩展,以及学习内核构建和模块生命周期。
模块不是让普通功能“跑得更快”的通用办法。若需求可以由用户态程序、系统服务或现有内核接口解决,通常应优先选择这些方案。用户态代码更容易调试、隔离和升级;把功能放入内核,会扩大故障影响范围,也会带来兼容性与安全维护成本。
此外,模块只能使用内核提供的接口。它不能像普通程序那样调用 libc、创建普通进程线程或通过标准输入输出交互。内核 API 也不承诺像稳定的用户态 ABI 一样长期不变:不同内核版本、发行版补丁和配置都可能影响模块的构建或运行。
二、准备一套匹配的构建环境
外部模块需要针对目标内核的构建目录和配置进行编译。最重要的前提是:编译所用的内核构建树必须对应正在运行、或计划加载模块的那个内核版本。只安装“某个版本”的头文件并不够。
可先查看运行内核版本和构建目录链接:
uname -r
ls -l /lib/modules/$(uname -r)/build
在 Debian 或 Ubuntu 上,通常可以安装当前运行内核对应的头文件及构建工具:
sudo apt update
sudo apt install build-essential linux-headers-$(uname -r)
如果发行版没有提供与当前内核匹配的头文件,或使用的是自行编译的内核,应改用该内核对应的构建目录。不要为了消除构建错误,随意把模块对着另一个内核版本编译后再强行加载。
建议在虚拟机或专用测试机器中进行实验,并准备快照或可恢复的启动方式。不要在重要生产系统上试验未经验证的内核代码。
三、编写第一个模块
创建一个工作目录,并在其中保存下面的两个文件:
mkdir -p ~/kernel-lab/hello
cd ~/kernel-lab/hello
第一个文件是 hello.c:
#include <linux/init.h>
#include <linux/module.h>
static int __init hello_init(void)
{
pr_info("hello_module: loaded\n");
return 0;
}
static void __exit hello_exit(void)
{
pr_info("hello_module: unloaded\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Example");
MODULE_DESCRIPTION("A minimal Linux kernel module");
第二个文件是 Makefile。注意,命令行前需要使用真正的制表符,而不是若干空格:
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
这里的 obj-m 告诉内核构建系统把 hello.o 构建为外部模块。make -C 让构建过程进入指定的内核构建目录,M= 则指出外部模块源码所在目录。这个写法调用的是内核自身的构建系统,而不是直接用普通编译命令拼接头文件路径。
四、编译并检查产物
在模块目录执行:
make
成功后,目录中会出现 hello.ko,以及若干用于构建和符号版本信息的中间文件。可以使用以下命令查看模块元数据:
modinfo ./hello.ko
也可以只查看常见字段:
modinfo -F name ./hello.ko
modinfo -F vermagic ./hello.ko
modinfo -F license ./hello.ko
vermagic 包含用于判断内核构建兼容性的版本信息;它有助于排查不匹配问题,但并不能证明模块一定安全或一定兼容。模块还可能受内核配置、导出符号和发行版补丁等因素影响。
五、加载、观察与卸载
在自己的测试环境里,可以用 insmod 加载指定文件。加载和卸载需要管理员权限:
sudo insmod ./hello.ko
确认模块是否出现在已加载模块列表中:
lsmod | grep '^hello_module\\b'
lsmod 展示的是内核已加载模块的信息。实际模块名通常来自模块文件或源码构建结果;若不确定名称,也可直接查看完整列表:
lsmod
模块日志通过内核日志设施写出,而不是打印到当前终端。可尝试查看最近的内核日志:
sudo dmesg | tail -n 30
某些系统限制普通用户读取内核日志,因此示例使用 sudo。在使用 systemd 的系统上,也可以用 journalctl -k 查看内核日志。若系统启用了日志访问限制,能否读取仍取决于当前权限与策略。
卸载模块:
sudo rmmod hello_module
此时应能在日志中看到模块加载和卸载时写出的消息。实际模块名可用 modinfo -F name ./hello.ko 或 lsmod 确认。
本例的 hello_init 是模块加载时调用的初始化函数,返回 0 表示初始化成功;若返回负的错误码,加载会失败。hello_exit 在模块卸载时调用,负责释放资源。它的返回类型是 void,不能用返回错误码的方式阻止卸载。
module_init 和 module_exit 将对应函数注册为入口与退出处理函数。模块初始化函数并不等于普通程序的 main:它由内核的模块加载流程调用,运行环境和可用操作都不同。
六、添加模块参数
模块参数可用于在加载模块时提供简单配置。例如,下面的版本增加一个只读的字符串参数和一个整数参数:
#include <linux/init.h>
#include <linux/module.h>
static char *message = "hello";
static int repeat = 1;
module_param(message, charp, 0444);
MODULE_PARM_DESC(message, "Message printed during module initialization");
module_param(repeat, int, 0444);
MODULE_PARM_DESC(repeat, "Number of messages to print (1 to 5)");
static int __init hello_init(void)
{
int i;
if (repeat < 1 || repeat > 5) {
pr_err("hello_module: repeat must be between 1 and 5\n");
return -EINVAL;
}
for (i = 0; i < repeat; i++)
pr_info("hello_module: %s\n", message);
return 0;
}
static void __exit hello_exit(void)
{
pr_info("hello_module: unloaded\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Example");
MODULE_DESCRIPTION("A minimal Linux kernel module with parameters");
重新编译后,可在加载时传入参数:
make
sudo insmod ./hello.ko message="hello from test" repeat=3
模块的参数在初始化函数执行前解析。因此,示例在 hello_init 中检查 repeat 的范围;若超出范围,初始化失败并返回 -EINVAL。加载失败后应查看内核日志确认原因。
这里的 0444 是模块参数的权限位,表示相关 sysfs 参数文件可读、不可写。它限制模块加载后的 sysfs 写入,不会自动限制传给加载命令的参数值。因此,即使参数文件是只读的,用户仍可能在加载时传入超范围值;代码必须自行验证。
以本例的模块名为例,参数通常可在以下目录查看,具体路径以系统实际名称为准:
cat /sys/module/hello_module/parameters/message
cat /sys/module/hello_module/parameters/repeat
对于需要在运行时变更的参数,应根据需求明确设置权限,并实现正确的同步、校验和并发处理。不要把较宽的写权限当作方便的默认值。模块参数也不是访问控制机制:它不能代替权限检查、输入验证或安全策略。
七、常见错误与排查方法
1. 找不到内核构建目录或头文件
如果构建时报 /lib/modules/.../build 不存在,或无法找到内核构建所需文件,先检查 uname -r 与已安装的内核头文件版本,再检查 build 链接是否指向预期目录。使用自定义内核时,应使用该内核的构建树,而不是套用发行版的另一套头文件。
2. 头文件版本与运行内核不一致
构建可能报版本不匹配,或加载时提示 Invalid module format。可通过 modinfo -F vermagic 查看模块的版本信息,并通过 uname -r 确认当前运行内核。应针对正确的内核重新构建;不要把禁用版本检查或强制加载当成常规修复办法。
即便版本字符串看起来一致,也不能保证模块一定能加载。内核配置、符号版本信息和发行版补丁等因素,都可能导致兼容性问题。
3. 加载失败或提示未知符号
Unknown symbol 通常意味着模块依赖的符号不可用,可能是依赖模块未加载、接口未导出,或构建时使用的内核环境与运行环境不一致。先查看日志,再检查依赖和构建目标。手动调用 insmod 不会像 modprobe 那样自动处理模块依赖;对于已安装并能被模块管理器识别的模块,通常使用 modprobe 更合适。
4. 符号名称不一致
卸载时报模块不存在,常见原因是把文件名、源码对象名和内核显示的模块名混为一谈。用 lsmod 查看实际加载名称,或用 modinfo 查询模块名,再将正确的名称传给 rmmod。
5. 模块正在使用,无法卸载
内核会避免卸载仍被引用的模块。驱动可能正在服务设备或被其他模块依赖,文件系统模块也可能正在使用中。先停止相关使用者并检查模块依赖,再尝试卸载。不要在不了解引用关系时强行移除模块。
6. 安全启动或模块签名限制
部分发行版或系统配置会要求内核模块通过签名验证;在启用安全启动或强制签名策略的环境中,未被信任的模块可能无法加载。可以查看内核日志确认是否存在签名或密钥相关错误。应遵循组织或发行版的模块签名流程,不要为了加载实验代码而擅自关闭安全启动或签名策略。
八、内核模块的安全边界
拥有加载模块的权限,通常意味着能够执行高权限的内核代码。因此,模块加载权限应严格控制,不能把“只有管理员能执行”理解成“代码就可信”。模块可能读取或修改内核管理的数据、影响设备和系统行为;代码中的缺陷也可能危及整台机器。
模块的 MODULE_LICENSE("GPL") 用于声明许可信息。它与代码的安全性不是一回事,也不是访问控制开关。使用 GPL-compatible 的许可声明通常有助于模块使用相应的内核导出接口;某些非 GPL 声明可能导致内核标记为 tainted,或无法使用仅向 GPL 模块导出的符号。具体行为取决于内核与相关接口,不能通过改一个字符串来改变代码实际的许可证。
内核 taint(污染)标记用于记录内核处于某些可能影响调试或支持的状态,例如加载了特定类别的外部或非 GPL 模块。它是诊断信息,不等同于恶意软件判定,也不会自动证明模块有问题。查看日志时应结合具体 taint 标记和系统策略判断。
实践中,尽量减少模块的功能与权限暴露面,对所有外部输入进行范围与格式检查;共享数据时考虑并发、锁和生命周期;卸载时释放注册过的设备、回调、定时器和内存等资源。若模块注册了异步回调或工作队列,必须确保退出路径先停止并同步这些活动,再释放它们可能访问的数据。
九、从示例继续学习
上面的模块只打印日志,不注册设备、不访问硬件,也不提供外部接口,因此适合用来熟悉编译、加载和卸载流程。继续学习时,可以先阅读正在使用的内核版本中的相关文档和示例,再逐步接触字符设备、platform driver、设备树或内核提供的子系统接口。
每增加一个功能,都要明确其资源由谁创建、失败时如何回滚、卸载时如何释放,以及并发访问如何同步。驱动开发尤其不能只验证“能加载”:还应测试设备拔出、重复加载、错误输入、并发操作和异常退出等路径。
对于日常开发,建议把源码放在版本控制中,在可恢复的虚拟机里测试,并记录内核版本、配置和构建命令。出现异常时,优先查看内核日志、构建信息和官方内核文档;如果系统已经不稳定,应通过可恢复的启动环境移除实验模块,而不是继续在故障状态下盲目操作。
至此,你已经完成了一个最小内核模块的编写、构建、加载、参数传递与卸载。真正重要的不只是让模块成功运行,还包括理解它运行在内核态所带来的兼容性、权限和故障风险,并在这些边界内进行测试。








