一、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 存成模板,每次写新模块直接复制一份。

小技巧: 如果你在嵌入式开发中,交叉编译时记得指定 ARCH 和 CROSS_COMPILE 环境变量。比如: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

注意: 不要在生产环境随意加载未经验证的模块。内核崩溃可不是闹着玩的,我见过有人加载了一个有内存泄漏的模块,跑了三天系统直接 OOM。

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 本章知识体系

下面这张图总结了内核模块的核心流程,我画的时候特意把「加载-使用-卸载」这个闭环突出了一下:

Linux内核模块核心流程 1. 编写模块代码 module_init / module_exit 2. 编译生成 .ko Makefile + Kbuild 3. insmod 加载 执行 init 函数 4. 模块运行与使用 参数传递 / 符号导出 / 功能调用 5. rmmod 卸载 执行 exit 函数,释放资源 关键要素 MODULE_LICENSE MODULE_AUTHOR module_param EXPORT_SYMBOL printk / dmesg insmod → init → 运行 → exit → rmmod 动态加载,无需重启系统

1.8 小结

这一章我们走通了内核模块的完整流程:从编写、编译,到加载、卸载,再到参数传递和符号导出。说白了,这些就是内核模块开发的「基本功」。你想想看,后面要学的 Platform 驱动、设备树,哪个不是建立在模块基础之上的?

我个人建议你动手写一个最简单的模块,编译、加载、看日志、卸载,走一遍流程。只有亲手做过,才能真正理解「模块是内核的一部分」这句话的含义。

我的习惯: 每次写新模块,我都会先写一个空壳——只有 init 和 exit,打印一句话。确认框架跑通了,再往里填业务逻辑。这样出了问题,很容易定位是框架问题还是业务问题。

公众号:蓝海资料掘金营,微信deep3321