一、Linux内核模块基础
说实话,很多刚接触内核开发的朋友,第一个问题就是:「我怎么给内核加功能,又不用每次都重新编译整个内核?」
答案就是——内核模块。
内核模块,说白了就是一段可以在运行时动态加载到内核的代码。它不像应用程序那样跑在用户空间,而是直接成为内核的一部分。我刚开始学的时候,也觉得这东西挺玄乎,但用顺手了就会发现,它其实是内核开发最基础、也最实用的技能。
1.1 什么是内核模块
内核模块(Kernel Module)是一段可以在系统运行时动态加载和卸载的代码。它通常用于实现设备驱动、文件系统、网络协议等。
我个人习惯把模块比作「乐高积木」——内核是底座,模块就是一块块积木,想加就插上去,想拆就拔下来。你想想看,如果每次加个驱动都要重新编译内核、重启系统,那开发效率得多低?
- 动态加载/卸载,无需重启系统
- 节省内存,按需加载
- 便于开发和调试
- 内核版本兼容性需要关注
1.2 最简单的内核模块
我们先写一个最简模块。嗯,这里要注意,虽然代码很短,但结构是固定的,我建议你把这个模板背下来。
#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()指定模块卸载时执行的函数MODULE_LICENSE()声明许可证,GPL是必须的,否则内核会报「tainted」
我在项目中遇到过有人忘了写 MODULE_LICENSE,结果模块加载时内核直接打了一堆警告,虽然功能没影响,但看着就难受。
1.3 编译模块:Makefile 怎么写
编译内核模块和编译普通程序不一样。你不能直接用 gcc,得用内核提供的构建系统。
obj-m := hello.o
all:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
这里的关键是 -C 指定内核源码树路径,M=$(PWD) 告诉内核构建系统当前模块的目录。我个人习惯把这段 Makefile 存成模板,每次写新模块直接复制一份。
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf-
1.4 加载与卸载
编译完成后,你会得到 .ko 文件。操作很简单:
# 加载模块
sudo insmod hello.ko
# 查看模块是否加载
lsmod | grep hello
# 查看内核日志
dmesg | tail
# 卸载模块
sudo rmmod hello
我曾经犯过一个低级错误:加载模块后忘了看 dmesg,以为模块没工作,折腾了半天才发现日志里早就打印了「Hello」。所以我的建议是——加载完模块,第一件事就是 dmesg | tail。
1.5 模块参数
有时候我们希望模块的行为可以动态调整,比如指定缓冲区大小、调试级别等。这时候就需要模块参数。
#include <linux/moduleparam.h>
static int debug_level = 0;
static char *device_name = "default";
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");
加载时可以传参:
sudo insmod mymodule.ko debug_level=2 device_name="mydev"
参数类型支持 int、charp、bool、数组等。我个人习惯把调试级别做成参数,这样调试时开高等级,发布时关掉,不用改代码。
1.6 符号导出
模块之间可以互相调用函数。你写的函数如果想被其他模块使用,需要显式导出。
#include <linux/export.h>
int my_add(int a, int b)
{
return a + b;
}
EXPORT_SYMBOL(my_add);
其他模块就可以这样用:
extern int my_add(int a, int b);
static int __init test_init(void)
{
int result = my_add(3, 4);
printk("Result: %d\n", result);
return 0;
}
嗯,这里要注意:EXPORT_SYMBOL 导出的符号对所有模块可见。如果你只想对 GPL 模块可见,用 EXPORT_SYMBOL_GPL。我一般用 GPL 版本,毕竟开源精神嘛。
mydrv_ 开头。
1.7 本章知识体系
下面这张图总结了内核模块的核心流程,我画的时候特意把「加载-使用-卸载」这个闭环突出了一下:
1.8 小结
这一章我们走通了内核模块的完整流程:从编写、编译,到加载、卸载,再到参数传递和符号导出。说白了,这些就是内核模块开发的「基本功」。你想想看,后面要学的 Platform 驱动、设备树,哪个不是建立在模块基础之上的?
我个人建议你动手写一个最简单的模块,编译、加载、看日志、卸载,走一遍流程。只有亲手做过,才能真正理解「模块是内核的一部分」这句话的含义。
公众号:蓝海资料掘金营,微信deep3321