4. 第一个内核模块:Hello World模块

说实话,学驱动开发最兴奋的时刻,就是看到自己写的代码跑在内核空间里。虽然只是个打印"Hello World"的小模块,但这一步跨过去,你就真正进入了内核开发的大门。

我记得自己第一次写内核模块时,连Makefile都编译不过去,折腾了整整一个下午。后来才发现是交叉编译链的路径写错了。嗯,这些坑我今天都会帮你避开。

4.1 模块代码框架:init/exit

内核模块和用户态程序最大的区别是什么?它没有main函数。内核模块的入口和出口,是通过两个宏来声明的:module_init() 和 module_exit()。

来看一个最基础的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");
    printk(KERN_INFO "当前内核版本: %s\n", UTS_RELEASE);
    return 0;
}

static void __exit hello_exit(void)
{
    printk(KERN_INFO "Hello World: 模块已卸载\n");
}

module_init(hello_init);
module_exit(hello_exit);

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

这里有几个关键点,我重点说一下:

  • __init 和 __exit:这两个宏告诉内核,初始化函数在模块加载后可以释放掉,退出函数在模块卸载后也可以释放。说白了就是省内存。
  • printk:内核空间的打印函数,和printf类似,但多了个日志级别。KERN_INFO是信息级别,还有KERN_DEBUG、KERN_ERR等。
  • MODULE_xxx:这些宏用来描述模块信息,不是必须的,但我建议你每次都写上。为什么?因为modinfo命令会用到它们。
我的习惯:每个模块我都会加上MODULE_VERSION,方便后续版本管理。曾经有个项目,就因为没写版本号,调试时搞不清加载的是哪个版本,浪费了两天时间。

4.2 Makefile编写

写内核模块的Makefile,和写应用程序的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.c
KDIR 内核源码树的路径 build是软链接,指向实际源码
-C 切换到内核源码目录 使用内核的构建系统
M=$(PWD) 指定模块源码所在目录 编译完成后回到当前目录
注意:如果你是在开发板上做交叉编译,KDIR要指向开发板内核的源码路径,而不是PC机的。我曾经犯过这个错,编译出来的模块加载时直接报"Invalid module format"。

4.3 编译与加载

编译过程很简单,直接执行make命令:

$ 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或.elf。

加载和卸载模块的命令:

# 加载模块
sudo insmod hello.ko

# 查看内核日志
dmesg | tail -5
[ 1234.567890] Hello World: 模块已加载
[ 1234.567895] 当前内核版本: 5.15.0-91-generic

# 卸载模块
sudo rmmod hello

# 再次查看日志
dmesg | tail -3
[ 1234.567890] Hello World: 模块已加载
[ 1234.567895] 当前内核版本: 5.15.0-91-generic
[ 1240.123456] Hello World: 模块已卸载

关键区别:

  • insmod:加载模块,需要指定模块文件路径
  • rmmod:卸载模块,只需要模块名(不带.ko)
  • modprobe:更智能的加载方式,会自动处理依赖

4.4 查看模块信息

模块加载后,怎么确认它真的在运行?两个命令搞定:

lsmod:列出已加载的模块

$ lsmod | grep hello
hello                  16384  0

输出信息解读:

  • 第一列:模块名称
  • 第二列:模块占用的内存大小(单位:字节)
  • 第三列:被其他模块引用的次数
  • 第四列:依赖该模块的其他模块列表

modinfo:查看模块详细信息

$ modinfo hello.ko
filename:       /home/user/hello/hello.ko
license:        GPL
author:         Your Name
description:    一个简单的Hello World内核模块
version:        1.0
depends:        
retpoline:      Y
name:           hello
vermagic:       5.15.0-91-generic SMP mod_unload modversions

看到没?我们在代码里写的MODULE_LICENSE、MODULE_AUTHOR这些信息,全都在这里显示出来了。vermagic这一行特别重要,它记录了模块编译时的内核版本和配置,如果和你运行的内核不匹配,模块就加载不了。

避坑指南:我曾经遇到过一个问题,模块编译时用的内核头文件和运行时的内核版本不一致,结果insmod时报"Invalid module format"。后来我养成了一个习惯:每次编译前都先确认KDIR指向的内核版本和uname -r输出一致。

4.5 知识体系图

下面这张图总结了本章的核心知识点和流程关系:

第一个内核模块:Hello World 知识体系 模块代码框架 Makefile编写 编译与加载 module_init module_exit MODULE_LICENSE obj-m KDIR -C / M= insmod rmmod lsmod / modinfo 核心流程:编写代码 → 编写Makefile → make编译 → insmod加载 验证:dmesg查看日志 → lsmod确认加载 → modinfo查看详情

4.6 常见问题与排查

写第一个模块时,你大概率会遇到下面这些问题:

错误现象 可能原因 解决方法
make: Nothing to be done Makefile格式错误,tab被替换成空格 检查all:和clean:下面的命令是否用了Tab缩进
Invalid module format 内核版本或配置不匹配 确认KDIR指向正确的内核源码
insmod: ERROR: could not insert module 模块依赖缺失或权限不足 使用sudo,检查dmesg查看具体错误
rmmod: ERROR: Module hello is in use 有其他模块依赖本模块 先卸载依赖模块,或使用rmmod -f强制卸载
重要提醒:千万不要在生产环境上随意insmod/rmmod未经测试的模块。我曾经在客户现场演示时,加载了一个有内存泄漏的模块,结果系统直接panic了。嗯,场面一度非常尴尬。

好了,到这里你已经掌握了内核模块开发的基本流程。从代码框架到Makefile,从编译到加载验证,这一整套流程在后续所有驱动开发中都会反复用到。把这个Hello World跑通了,后面的路就好走了。