内核模块编程入门:Hello World模块、模块的加载与卸载、模块参数传递、模块依赖与符号导出

说实话,很多初学者一上来就被Linux内核源码的庞大给吓住了。其实驱动开发没那么玄乎,咱们从最简单的内核模块开始,一步步来。

内核模块,说白了就是一段可以在运行时动态加载到内核的代码。它不像应用程序那样跑在用户空间,而是直接在内核空间执行。我刚开始学的时候,觉得这东西挺神奇的——不用重新编译整个内核,就能给系统加功能。

核心概念:内核模块是Linux内核支持的一种动态加载机制。它允许系统在运行时扩展内核功能,而不需要重新编译和重启系统。

Hello World模块——你的第一个内核程序

先写一个最简单的模块。它什么都不做,只是加载时打印一句话,卸载时再打印一句。但别小看它,这个骨架是所有复杂模块的基础。

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>

static int __init hello_init(void)
{
    printk(KERN_INFO "Hello, World! 模块已加载\n");
    return 0;
}

static void __exit hello_exit(void)
{
    printk(KERN_INFO "再见!模块已卸载\n");
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("一个简单的Hello World模块");

这里有几个关键点:

  • module_init 和 module_exit 是宏,告诉内核模块的入口和出口函数在哪
  • printk 是内核空间的打印函数,相当于用户空间的printf
  • KERN_INFO 是日志级别,表示这是一条信息性消息
  • __init 和 __exit 是属性标记,告诉内核这些函数只在初始化/卸载时使用,之后可以释放内存

小技巧:printk的输出默认不会显示在终端上。你可以用 dmesg 命令查看内核日志,或者用 tail -f /var/log/kern.log 实时跟踪。

编写Makefile——编译模块的必备工具

模块不能像普通程序那样用gcc直接编译。它需要内核的编译系统。我见过不少新手在这卡住,其实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
  • KDIR 指向当前运行内核的源码树或头文件目录
  • -C 切换到内核目录执行make,M= 指定模块源码所在目录

模块的加载与卸载

编译完成后,你会得到一个 hello.ko 文件。这就是内核模块文件。操作它只需要三个命令:

命令 作用 说明
insmod hello.ko 加载模块 直接插入模块到内核,不处理依赖
rmmod hello 卸载模块 从内核移除模块(不加.ko后缀)
lsmod 查看已加载模块 列出所有模块及其依赖关系
modprobe hello 智能加载模块 自动处理依赖,推荐使用

注意:千万不要在生产环境随意加载未经验证的模块。我曾经有一次在调试时加载了一个有内存泄漏的模块,结果系统跑了三天后OOM(内存耗尽),直接宕机了。教训深刻啊。

模块参数传递

很多时候,我们希望模块在加载时能接受一些参数,比如设备编号、缓冲区大小等。内核提供了 module_param 宏来实现这个功能。

#include <linux/moduleparam.h>

static int count = 1;
static char *name = "default";

module_param(count, int, 0644);
module_param(name, charp, 0644);

MODULE_PARM_DESC(count, "循环次数");
MODULE_PARM_DESC(name, "设备名称");

static int __init param_init(void)
{
    int i;
    for (i = 0; i < count; i++) {
        printk(KERN_INFO "参数示例: name = %s, count = %d\n", name, count);
    }
    return 0;
}

加载时传参:

insmod param.ko count=3 name="mydevice"

这里 0644 是文件权限,表示在sysfs中这个参数文件的访问权限。你想想看,有了参数传递,一个模块就能适应不同的场景,不用为每个配置重新编译。

模块依赖与符号导出

实际项目中,模块之间经常有依赖关系。比如一个I2C总线驱动,会被多个设备驱动使用。这时候就需要符号导出。

假设我们有一个基础模块,它导出一个函数:

// base_module.c
#include <linux/module.h>

void base_function(void)
{
    printk(KERN_INFO "这是基础模块的函数\n");
}
EXPORT_SYMBOL(base_function);

MODULE_LICENSE("GPL");

另一个模块可以调用它:

// client_module.c
extern void base_function(void);

static int __init client_init(void)
{
    printk(KERN_INFO "客户端模块开始调用基础函数\n");
    base_function();
    return 0;
}

module_init(client_init);
MODULE_LICENSE("GPL");

加载顺序必须是:先加载base_module.ko,再加载client_module.ko。用 modprobe 可以自动处理这个顺序。

关键点:使用 EXPORT_SYMBOL 导出的符号,会被记录在内核符号表中。其他模块可以通过 extern 声明来使用。但要注意,只有GPL许可的模块才能使用 EXPORT_SYMBOL_GPL 导出的符号。

本章知识体系

下面这张图把本章的核心内容串起来了:

内核模块编程核心知识体系 Linux内核模块 Hello World模块 加载与卸载 模块参数传递 依赖与符号导出 module_init module_exit insmod/rmmod modprobe module_param sysfs接口 EXPORT_SYMBOL 依赖管理 模块是内核功能的“插件”,动态加载,无需重启系统 参数传递让模块更灵活,符号导出实现模块间协作

避坑指南

做内核模块开发,有几个坑我踩过,分享给你:

  • 内核版本不匹配:模块必须与当前运行的内核版本完全一致。用 uname -r 查看版本,确保KDIR指向正确的内核头文件。我曾经在升级内核后忘了更新头文件,结果模块加载时直接报 Invalid module format,排查了半天。
  • printk滥用:调试时用printk很方便,但生产环境一定要关掉或降低日志级别。否则大量打印会拖慢系统,甚至填满日志分区。
  • 内存泄漏:模块加载后,分配的内存如果不释放,会一直占用。卸载模块前,一定要确保所有资源都释放干净。
  • 符号冲突:如果两个模块导出了同名符号,后加载的那个会失败。命名时加个前缀是个好习惯。

调试建议:刚开始写模块时,多用 printk 打印关键信息。配合 dmesg -w 实时查看日志,调试效率会高很多。等模块稳定了,再把这些调试信息去掉或改成动态开关。

嗯,到这里,内核模块编程的基础知识就讲完了。从Hello World到参数传递,再到模块依赖,这些是后续所有驱动开发的地基。别急,慢慢来,先把今天的内容动手实践一遍,把那个Hello World模块跑起来。相信我,当你在 dmesg 里看到自己写的第一行内核日志时,那种成就感还是很爽的。