从零构建一个 Linux 内核模块并动态加载

Linux 内核模块(Loadable Kernel Module,LKM)是 Linux 系统最具灵活性的设计之一。它允许我们在不重新编译内核、不重启系统的前提下,动态地向内核添加功能。无论是设备驱动、文件系统还是网络协议,都可以通过模块的形式实现。本文将从零开始,带你编写、编译并加载一个最简单的内核模块,理解其背后的机制。

一、内核模块是什么

内核模块是一段可以在运行时被动态加载到内核空间的目标代码。加载后,它便成为内核的一部分,拥有与内核代码同等的权限——可以直接访问硬件、操作内核数据结构、注册回调函数。

与用户态程序不同,内核模块有几个显著特点:

  • 运行在内核空间:没有内存保护,一个空指针解引用就可能导致整个系统崩溃(俗称 kernel panic)。
  • 没有标准库可用:无法使用 printf、malloc 等用户态函数,只能使用内核提供的 API,如 printk、kmalloc。
  • 有明确的入口和出口:每个模块都必须定义初始化和清理函数。

正是这些限制,使得编写内核模块需要格外谨慎,但也正是它赋予了开发者直接操控系统的能力。

二、准备工作

在开始之前,你需要确保系统具备编译内核模块的条件。以 Ubuntu/Debian 为例:

sudo apt update
sudo apt install build-essential linux-headers-$(uname -r)

其中 linux-headers-$(uname -r) 会安装与当前运行内核版本匹配的头文件,这是编译模块的必要依赖。你可以通过以下命令验证:

ls /lib/modules/$(uname -r)/build

如果该目录存在且包含 Makefile,说明环境就绪。

三、编写第一个模块

创建一个工作目录,并新建文件 hello_module.c

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

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple hello world kernel module");
MODULE_VERSION("1.0");

static int __init hello_init(void)
{
    printk(KERN_INFO "hello_module: loaded successfully\n");
    return 0;
}

static void __exit hello_exit(void)
{
    printk(KERN_INFO "hello_module: unloaded\n");
}

module_init(hello_init);
module_exit(hello_exit);

逐段解析这段代码:

  • 头文件linux/init.h 提供 module_initmodule_exit 宏;linux/module.h 包含模块相关的宏定义;linux/kernel.h 提供 printk 等内核函数。
  • MODULE_LICENSE:声明许可证。使用 GPL 可以让模块访问内核中仅对 GPL 代码开放的内核符号,缺少这一行会在加载时收到内核污染警告。
  • hello_init:模块加载时调用的函数,返回 0 表示成功,返回负值表示失败并阻止加载。__init 标记告诉内核该函数仅在初始化阶段使用,之后可以释放其占用的内存。
  • hello_exit:模块卸载时调用,负责清理资源。
  • module_init / module_exit:将上述两个函数注册为模块的入口和出口。

四、编写 Makefile

内核模块的编译方式与普通程序不同,需要借助内核的构建系统。在同一目录下创建 Makefile

obj-m += hello_module.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_module.o 告诉内核构建系统将这个目标编译为可加载模块(.ko 文件)。
  • -C $(KDIR) 切换到内核源码目录,使用内核自身的 Makefile。
  • M=$(PWD) 指明模块源码所在的外部目录。
  • 注意命令前必须是 Tab 缩进,而非空格,这是 Makefile 的硬性要求。

五、编译与加载

执行编译:

make

如果一切顺利,你会看到 hello_module.ko 文件生成。接下来加载模块:

sudo insmod hello_module.ko

查看内核日志,确认模块已加载:

dmesg | tail -n 5

你应该能看到 hello_module: loaded successfully 的输出。用 lsmod 也可以看到模块已被列入内核模块列表:

lsmod | grep hello

卸载模块:

sudo rmmod hello_module

再次查看 dmesg,会看到 hello_module: unloaded

六、深入理解加载过程

当你执行 insmod 时,内核实际完成了以下工作:

  1. 读取 .ko 文件:这是一个 ELF 格式的目标文件,包含代码段、数据段以及模块元信息。
  2. 分配内核内存:将模块代码和数据复制到内核空间。
  3. 符号解析:解析模块引用的外部符号(如 printk),将其绑定到内核中的实际地址。
  4. 调用初始化函数:执行 module_init 注册的函数。
  5. 注册到模块链表:模块成为内核的一部分,出现在 /proc/moduleslsmod 输出中。

rmmod 则执行相反的过程,调用清理函数并释放内存。

七、常见问题与注意事项

  • printk 日志级别KERN_INFO 只是日志级别之一。如果 dmesg 看不到输出,可能是控制台日志级别过滤所致,可通过 dmesg -n 8 调整。
  • 模块版本不匹配:如果头文件版本与运行内核不一致,加载时会报 invalid module format 错误。
  • 内核污染:未声明 GPL 许可证的模块会标记内核为“污染”状态,在生产环境中应避免。
  • 安全性:内核模块拥有最高权限,错误代码可能导致系统崩溃或安全漏洞,务必在虚拟机或测试环境中先行验证。

八、小结

本文从零完成了一个 Linux 内核模块的编写、编译、加载和卸载全过程。虽然这个模块只做了一件事——打印一行日志,但它涵盖了内核模块开发的所有基本要素:入口/出口函数、模块元信息、内核构建系统以及动态加载机制。

掌握这些基础后,你可以进一步探索更复杂的主题,例如字符设备驱动、procfs/sysfs 接口、内核定时器、中断处理等。内核模块是理解 Linux 内核运作方式的一把钥匙,而今天你已經拿到了它。

未经允许不得转载:任鹏个人博客 » 从零构建一个 Linux 内核模块并动态加载

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏