2、第一个内核模块:Hello World模块编写、Makefile编写、模块编译与加载(insmod/rmmod)、查看模块信息(lsmod/modinfo)

说实话,学内核开发最怕什么?就是环境还没搭好,先被一堆概念砸晕了。

所以我个人习惯,第一件事就是先让代码跑起来。哪怕只是打印一句 "Hello World",也能让你对整个流程有个直观感受。今天我们就来干这件事。

2.1 内核模块的骨架

内核模块和用户态程序不一样。它没有 main 函数。它的入口和出口是固定的两个宏:module_init 和 module_exit。

来看一个最简的模块代码。我把它命名为 hello.c:

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

static int __init hello_init(void)
{
    printk(KERN_INFO "Hello, Kernel World!\n");
    return 0;
}

static void __exit hello_exit(void)
{
    printk(KERN_INFO "Goodbye, Kernel World!\n");
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple Hello World kernel module");

这里有几个关键点,我当年第一次写的时候踩过坑:

  • printk 不是 printf。它没有格式化占位符的检查,写错了也不会报编译警告,但运行时会直接崩。嗯,这个我吃过亏。
  • KERN_INFO 是日志级别。不加的话,默认级别可能不会显示在终端上。
  • __init 和 __exit 是内核的优化标记。加了 __init 的函数,在模块加载完成后会被内核自动释放掉,省内存。
  • MODULE_LICENSE 必须写。不写的话,内核会报 "tainted" 警告,而且某些内核功能(比如一些 GPL-only 的符号)你就用不了。
小技巧: 我习惯在 printk 里加上模块名前缀,比如 "hello: loaded"。这样 dmesg 里一搜就能定位到自己的日志,不会跟内核其他日志混在一起。

2.2 Makefile 怎么写?

内核模块的编译不靠 gcc 直接编译。你得用内核的构建系统——Kbuild。说白了,就是让内核源码树里的 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

解释一下这几行的意思:

  • obj-m := hello.o:告诉 Kbuild,我要编译一个名为 hello.ko 的模块。注意这里写的是 .o,但生成的是 .ko。
  • KDIR:指向当前运行内核的构建目录。这个目录包含了编译模块所需的所有头文件和 Makefile 片段。
  • -C $(KDIR):切换到内核源码目录去执行 make。
  • M=$(PWD):告诉内核 Makefile,我的模块源码在当前目录。
注意: 如果你自己编译了内核,KDIR 要指向你编译好的内核源码树,而不是 /lib/modules/$(uname -r)/build。否则加载时会报 "Invalid module format" 错误。我曾经在交叉编译环境上折腾了一下午,才发现是 KDIR 指错了。

2.3 编译与加载

在终端里执行 make,你会看到类似这样的输出:

make -C /lib/modules/5.15.0-91-generic/build M=/home/user/hello modules
  CC [M]  /home/user/hello/hello.o
  MODPOST /home/user/hello/Module.symvers
  CC [M]  /home/user/hello/hello.mod.o
  LD [M]  /home/user/hello/hello.ko

编译成功后,目录下会多出 hello.ko 文件。这就是我们要的内核模块。

加载和卸载的命令很简单:

操作 命令 说明
加载模块 sudo insmod hello.ko 需要 root 权限。insmod 是 "insert module" 的缩写。
卸载模块 sudo rmmod hello 注意这里写的是模块名 hello,不是文件名 hello.ko。
查看加载日志 dmesg | tail printk 的输出会写到内核日志缓冲区。

加载后,用 dmesg | tail 你应该能看到:

[ 1234.567890] Hello, Kernel World!

卸载后再看:

[ 1235.678901] Goodbye, Kernel World!
为什么用 insmod 而不是 modprobe? 因为 insmod 简单直接,只加载你指定的模块文件,不处理依赖。modprobe 会自动解决依赖关系,但需要先把模块安装到标准路径下。开发阶段用 insmod 更顺手。

2.4 查看模块信息

模块加载后,怎么确认它真的在运行?两个命令就够了。

lsmod:列出当前加载的模块

lsmod 实际上就是读取 /proc/modules 文件的内容。输出格式如下:

Module                  Size  Used by
hello                  16384  0
...
  • Module:模块名。
  • Size:模块占用的内存大小(字节)。
  • Used by:被多少个其他模块引用。0 表示当前没有被使用。

modinfo:查看模块的详细信息

这个命令读取的是模块文件里的 .modinfo 段。即使模块没加载,也能查看:

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

这里有个字段值得注意:vermagic。它记录了编译时内核的版本和配置。如果加载时内核版本不匹配,insmod 会直接拒绝加载,并报 "Invalid module format"。这是内核的一种保护机制。

2.5 知识结构图

下面这张图把整个流程串起来了。从源码到加载运行,每一步都对应一个文件或命令:

内核模块编译加载全流程 hello.c 模块源码 Makefile Kbuild 构建规则 make 调用 Kbuild 编译 hello.ko 内核模块文件 insmod hello.ko 加载到内核 rmmod hello 从内核卸载 lsmod / modinfo dmesg 查看日志

2.6 避坑指南

最后,把我这些年遇到过的几个典型问题列出来,你碰到了可以直接对照:

错误现象 原因 解决办法
Invalid module format 模块编译时的内核版本与当前运行内核不一致 检查 KDIR 是否指向正确的内核源码树
Required key not available 内核启用了模块签名验证 编译内核时关闭 CONFIG_MODULE_SIG,或给模块签名
Unknown symbol 模块引用了未导出的内核符号 检查是否缺少 EXPORT_SYMBOL,或依赖的模块未加载
rmmod: ERROR: Module hello is in use 有其他模块或进程正在引用该模块 用 lsmod 查看 Used by 列,先卸载依赖它的模块
我曾经遇到过一个问题: 在 Ubuntu 上编译好的模块,放到 CentOS 上加载,报 "Invalid module format"。查了半天发现是内核的 CONFIG_PREEMPT 配置不一样。vermagic 里会记录这些配置,差一点都不行。所以,模块一定要在目标机器上编译,或者用相同的内核配置交叉编译。

好了,到这里你已经亲手写了一个内核模块,并且成功加载和卸载了它。虽然只是打印了两行日志,但整个流程——编写、编译、加载、查看、卸载——你已经全部走通了。后面所有的模块开发,都是在这个基础上加功能而已。