第二章 内核模块基础:Hello World 与模块生命周期

说实话,我第一次接触内核模块时,心里是有点发怵的。毕竟操作系统的内核,听起来就是个高不可攀的东西。但后来我发现,内核模块其实没那么神秘——它就像给Linux内核插上的一个“插件”,允许我们在不重新编译整个内核的情况下,动态地扩展功能。

这一章,我们就从最基础的Hello World模块开始,一步步把内核模块的编写、编译、加载、卸载整个流程跑通。相信我,当你看到自己写的模块成功加载到内核时,那种成就感是很爽的。

2.1 什么是内核模块?

内核模块,说白了就是一段可以在运行时加载到内核的代码。它不像普通应用程序那样运行在用户空间,而是直接运行在内核空间,拥有最高权限。

我当年刚接触这个概念时,有个比喻让我印象很深:内核就像一家公司的核心管理层,模块就是临时聘请的顾问。顾问可以随时加入(insmod),也可以随时离开(rmmod),但他们在公司内部拥有和正式员工一样的权限。

内核模块有几个关键特点:

  • 动态加载:不需要重启系统,随时加载卸载
  • 内核空间运行:拥有完全的系统控制权
  • 无main函数:入口点是module_init指定的函数
  • 依赖内核符号表:可以调用内核导出的函数

核心要点:内核模块不是应用程序,它没有main(),而是通过module_init()和module_exit()两个宏来注册入口和出口函数。这是写驱动必须记住的第一件事。

2.2 Hello World 内核模块

光说不练假把式。我们直接写一个最简单的内核模块。这个模块的功能很简单:加载时打印"Hello, Kernel!",卸载时打印"Goodbye, Kernel!"。

创建一个文件 hello.c

#include <linux/module.h>    // 所有模块都需要
#include <linux/kernel.h>    // 提供printk
#include <linux/init.h>      // 提供__init和__exit宏

static int __init hello_init(void)
{
    printk(KERN_INFO "Hello, Kernel!\n");
    printk(KERN_INFO "Module loaded successfully.\n");
    return 0;  // 0表示成功,负数表示失败
}

static void __exit hello_exit(void)
{
    printk(KERN_INFO "Goodbye, Kernel!\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 是内核的优化标记。加了 __init 的函数,在初始化完成后,内核会释放它占用的内存。我在项目中就见过有人忘了加这个标记,结果模块占着内存不放,虽然不影响功能,但总觉得不够优雅。
  • printk 是内核版的printf。后面的 KERN_INFO 是日志级别,告诉内核这条消息有多重要。
  • MODULE_LICENSE("GPL") 不是可选的。如果不声明GPL,内核会认为你用了非GPL的代码,某些内核符号就不让你用。我曾经踩过这个坑,模块编译通过但加载时报错,查了半天才发现是许可证的问题。

2.3 模块的编译:Makefile 编写

编译内核模块不能用普通的gcc,得用内核提供的编译系统。这听起来复杂,但其实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

这个Makefile是什么意思呢?我解释一下:

  • obj-m := hello.o 告诉内核编译系统,我们要生成一个名为hello.ko的模块
  • KDIR 指向当前内核的源码树。注意,这里用的是 /lib/modules/$(uname -r)/build,它其实是一个符号链接,指向已安装的内核头文件
  • -C $(KDIR) 切换到内核源码目录,使用内核的Makefile
  • M=$(PWD) 告诉内核编译系统,我们的模块代码在当前目录

个人经验:我建议你在编译前先确认内核头文件是否安装。Ubuntu上可以用 sudo apt install linux-headers-$(uname -r) 来安装。如果头文件没装全,编译时会报一堆莫名其妙的错误,我当年被这个坑过好几次。

编译很简单,在终端执行:

make

如果一切顺利,你会看到类似这样的输出:

make -C /lib/modules/5.15.0-91-generic/build M=/home/user/hello modules
make[1]: Entering directory '/usr/src/linux-headers-5.15.0-91-generic'
  CC [M]  /home/user/hello/hello.o
  MODPOST /home/user/hello/Module.symvers
  LD [M]  /home/user/hello/hello.ko
make[1]: Leaving directory '/usr/src/linux-headers-5.15.0-91-generic'

编译成功后,目录下会多出一个 hello.ko 文件。这个 .ko 就是内核模块文件,相当于用户空间的 .exe

2.4 模块的加载与卸载

模块编译好了,接下来就是见证奇迹的时刻。

加载模块

sudo insmod hello.ko

没有消息就是好消息。如果加载成功,终端不会输出任何内容。那我们的"Hello, Kernel!"去哪了?它被内核日志系统收走了。用 dmesg 命令查看:

dmesg | tail -5

你应该能看到类似这样的输出:

[ 1234.567890] Hello, Kernel!
[ 1234.567891] Module loaded successfully.

卸载模块

sudo rmmod hello

注意,这里用的是 hello 而不是 hello.ko。rmmod会自动查找已加载的名为hello的模块。再用 dmesg 查看:

[ 1234.567890] Hello, Kernel!
[ 1234.567891] Module loaded successfully.
[ 1240.123456] Goodbye, Kernel!
[ 1240.123457] Module unloaded.

避坑指南:我曾经在调试一个驱动时,反复加载卸载模块,结果有一次rmmod后dmesg里没有任何输出。查了半天才发现,原来我忘了加 sudo。加载和卸载模块都需要root权限,这是内核的安全机制,别像我一样犯这种低级错误。

2.5 查看模块信息

模块加载后,怎么知道它有没有正常工作?两个命令帮你搞定。

lsmod:列出所有已加载的模块

lsmod | grep hello

输出类似:

hello                  16384  0

这四列分别表示:模块名、模块大小(字节)、被引用次数、依赖的模块列表。

modinfo:查看模块的详细信息

modinfo hello.ko

输出:

filename:       /home/user/hello/hello.ko
license:        GPL
author:         Your Name
description:    A simple Hello World module
version:        1.0
depends:        
retpoline:      Y
name:           hello
vermagic:       5.15.0-91-generic SMP mod_unload modversions

看到没?我们在代码里用 MODULE_LICENSEMODULE_AUTHOR 等宏声明的信息,全都在这里显示出来了。这就是内核模块的“身份证”。

2.6 本章知识体系

为了让你对整个流程有个直观的认识,我画了一张图:

内核模块开发流程 编写 hello.c module_init / module_exit 编写 Makefile obj-m / KDIR / M=$(PWD) make 编译 生成 hello.ko insmod 加载 sudo insmod hello.ko 验证模块 lsmod / dmesg / modinfo rmmod 卸载 sudo rmmod hello 迭代开发:修改代码 → 重新编译 → 加载测试

这张图把整个流程串起来了。你想想看,从写代码到最终卸载,其实就六个步骤。我刚开始学的时候,就是照着这个流程反复练习,直到闭着眼睛都能写出来。

2.7 几个实用技巧

最后,分享几个我在实际项目中积累的小技巧:

  • 调试信息:用 printk 时,建议加上 KERN_DEBUG 级别,这样可以通过 echo 8 > /proc/sys/kernel/printk 来控制是否显示。我一般开发时开8,发布时改回4。
  • 模块参数:可以用 module_param 宏给模块传参数,这样就不用每次修改代码了。比如 static int debug = 0; module_param(debug, int, 0644);,加载时用 insmod hello.ko debug=1
  • 自动加载:把 .ko 文件放到 /lib/modules/$(uname -r)/ 下,然后运行 depmod,就可以用 modprobe hello 自动加载了。modprobe比insmod智能,它会自动处理依赖关系。

我的习惯:每次写完模块,我都会先跑一遍 modinfo 确认信息正确,再用 insmod 加载。这样能提前发现一些低级错误,比如许可证没写、版本号不对之类的。别问我为什么有这个习惯——问就是吃过亏。

好了,这一章的内容就到这里。你现在应该能自己写一个Hello World内核模块,并且知道怎么编译、加载、卸载它了。下一章我们会深入字符设备驱动的核心概念,到时候这些基础知识都会用上。


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