第一章:内核模块编写——从Hello World到实战
各位同学,咱们今天聊聊Linux内核模块。说实话,我刚开始接触内核模块时,觉得这东西挺神秘的。一个能在系统运行时动态加载卸载的程序,听起来就很酷。但真正上手后才发现,内核模块和普通应用程序的差别,远比你想象的大。
1.1 内核模块的本质
内核模块,说白了就是一段可以在内核空间运行的程序。它不像应用程序那样有main函数,而是通过module_init和module_exit这两个宏来指定入口和出口。
我当年第一次写内核模块时,犯了个低级错误——直接在模块里调用了printf。结果编译报错,我还纳闷了半天。后来才明白,内核空间用的是printk,不是printf。这个坑,我估计不少新手都踩过。
核心区别:
- 应用程序:运行在用户空间,有完整的libc支持
- 内核模块:运行在内核空间,只能使用内核导出的API
- 应用程序崩溃:只会影响自身进程
- 内核模块崩溃:直接导致系统宕机(我经历过,很酸爽)
1.2 第一个内核模块: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 World!\n");
printk(KERN_INFO "Module loaded successfully\n");
return 0;
}
static void __exit hello_exit(void)
{
printk(KERN_INFO "Goodbye, Kernel World!\n");
printk(KERN_INFO "Module unloaded\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple Hello World module");
MODULE_VERSION("1.0");
这段代码看着简单,但有几个关键点我得强调一下:
__init和__exit宏:告诉内核这些函数只在初始化/退出时使用,之后可以释放内存KERN_INFO:日志级别,决定消息显示在哪个终端MODULE_LICENSE:不声明GPL的话,某些内核符号你根本用不了
我的小技巧:调试时多用KERN_DEBUG级别,配合dmesg命令查看。生产环境记得改成KERN_INFO或更低级别,不然日志刷屏会拖慢系统。
1.3 Makefile的写法
写内核模块,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
这里有个坑:KDIR指向的内核源码树必须和你当前运行的内核版本一致。我曾经因为升级了内核忘了更新源码树,编译出来的模块死活加载不上,折腾了大半天。
1.4 模块的加载与卸载
编译完成后,你会得到.ko文件。操作命令很简单:
# 加载模块
sudo insmod hello.ko
# 查看模块信息
lsmod | grep hello
# 查看内核日志
dmesg | tail
# 卸载模块
sudo rmmod hello
警告:千万不要在生产环境随意insmod/rmmod未经验证的模块。我有个同事在客户现场演示时,加载了一个有内存泄漏的模块,结果系统跑了半小时就挂了。场面一度非常尴尬。
1.5 模块参数传递
实际项目中,模块往往需要接收参数。比如指定设备号、缓冲区大小等。内核提供了module_param宏:
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"
参数权限0644表示可以通过sysfs在运行时修改。嗯,这个特性在调试时特别有用。
1.6 避坑指南
这些年写内核模块,我总结了几条血泪教训:
- 内存分配:用kmalloc不要用malloc,而且记得检查返回值。我见过太多人忽略这个导致空指针崩溃
- 锁的使用:内核模块可能运行在中断上下文,这时候不能睡眠。用spin_lock还是mutex_lock,得想清楚
- 日志输出:printk是有开销的。高频调用路径上别打太多日志,否则性能直接腰斩
- 版本兼容:不同内核版本API可能变化。我维护的一个驱动,从4.19升到5.10,改了七八处接口
我曾经踩过的坑:有一次写一个网络过滤模块,在中断处理函数里用了printk。结果每个网络包进来都打印一行日志,千兆网卡直接变成了百兆。查了两天才发现是日志刷屏导致的性能瓶颈。
1.7 实战建议
如果你刚开始学内核模块,我建议你:
- 先从最简单的Hello World开始,确保编译、加载、卸载流程走通
- 加几个参数,试试不同传参方式
- 尝试在模块里创建/proc文件或sysfs节点,和用户空间交互
- 最后再碰硬件相关的部分,比如GPIO、中断
记住,内核模块调试比应用程序难得多。没有gdb,没有valgrind,出问题只能靠printk和oops信息。所以写代码时一定要谨慎,多检查边界条件。
好了,第一章就到这里。下一章咱们聊聊字符设备驱动框架,那才是真正和硬件打交道的开始。