第一章:内核模块编写——从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 实战建议

如果你刚开始学内核模块,我建议你:

  1. 先从最简单的Hello World开始,确保编译、加载、卸载流程走通
  2. 加几个参数,试试不同传参方式
  3. 尝试在模块里创建/proc文件或sysfs节点,和用户空间交互
  4. 最后再碰硬件相关的部分,比如GPIO、中断

记住,内核模块调试比应用程序难得多。没有gdb,没有valgrind,出问题只能靠printk和oops信息。所以写代码时一定要谨慎,多检查边界条件。

好了,第一章就到这里。下一章咱们聊聊字符设备驱动框架,那才是真正和硬件打交道的开始。