3、第一个内核模块:Hello World

说实话,写第一个内核模块的感觉,跟写第一个用户态程序完全不一样。你想想看,用户态程序跑在应用层,出错了大不了崩掉一个进程。内核模块要是写崩了,那就是整个系统挂掉,直接黑屏或者 kernel panic。

我记得我第一次写内核模块时,手都在抖。但别怕,咱们从最简单的 Hello World 开始,一步步来。

3.1 什么是内核模块?

内核模块,说白了就是一段可以动态加载到内核中的代码。它不需要重新编译整个内核,也不需要重启系统。你写好了,用 insmod 命令插进去,它就成了内核的一部分。

我习惯把内核模块比作「乐高积木」。内核是底座,模块就是一块块积木。想加功能了,插一块上去;不想要了,拔下来就行。当然,拔的时候得小心,别把底座带坏了。

内核模块 vs 用户态程序
对比项 内核模块 用户态程序
运行空间 内核空间(Ring 0) 用户空间(Ring 3)
入口函数 module_init() main()
出口函数 module_exit() return 0
标准库支持 ❌ 不能使用 libc ✅ 完全支持
调试方式 printk() + dmesg printf() + gdb
崩溃后果 系统挂掉 进程退出

3.2 编写第一个内核模块

先看代码,再解释。这是最经典的 Hello World 内核模块,我在 RK3568 上验证过,直接能用。

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

static int __init hello_init(void)
{
    printk(KERN_INFO "Hello World from RK3568!\n");
    printk(KERN_INFO "Module loaded successfully.\n");
    return 0;
}

static void __exit hello_exit(void)
{
    printk(KERN_INFO "Goodbye! Module unloaded.\n");
}

module_init(hello_init);
module_exit(hello_exit);

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

这段代码看着简单,但有几个关键点我得强调一下:

  • 头文件:module.h 是必须的,kernel.h 提供 printk,init.h 提供 __init 和 __exit 宏
  • __init 和 __exit:这两个宏告诉内核,初始化函数和退出函数在运行完后可以释放掉,节省内存。我刚开始写时没加,也能跑,但内核会给你警告
  • printk:内核版的 printf,但它有日志级别。KERN_INFO 是信息级别,还有 KERN_ERR、KERN_WARNING 等
  • MODULE_LICENSE:必须写,不写的话内核会报「tainted kernel」。GPL 是最常用的
我的习惯:printk 里我总会加上模块名,比如 "hello: xxx"。这样 dmesg 里一搜就能找到自己的日志,不会跟其他模块的混在一起。

3.3 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

解释一下这几行:

  • obj-m := hello.o:告诉内核,我要编译一个名为 hello 的模块。hello.o 由 hello.c 生成
  • KDIR:指向内核源码树的路径。$(shell uname -r) 获取当前内核版本号,/lib/modules/xxx/build 是个软链接,指向实际的内核源码
  • -C $(KDIR):切换到内核源码目录,读取它的 Makefile
  • M=$(PWD):告诉内核,我的模块源码在当前目录
注意:在 RK3568 上开发时,如果你用的是交叉编译环境,KDIR 要指向板子对应的内核源码,而不是 PC 本机的。我曾经在这上面栽过跟头,编译出来的模块 insmod 时直接报 "Invalid module format"。

3.4 编译与加载

编译很简单,直接在源码目录下执行 make:

$ make
make -C /lib/modules/5.10.160/build M=/home/user/hello modules
make[1]: Entering directory '/home/user/kernel'
  CC [M]  /home/user/hello/hello.o
  MODPOST /home/user/hello/Module.symvers
  LD [M]  /home/user/hello/hello.ko
make[1]: Leaving directory '/home/user/kernel'

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

加载和卸载的命令:

# 加载模块
$ sudo insmod hello.ko

# 查看已加载的模块
$ lsmod | grep hello

# 卸载模块
$ sudo rmmod hello

# 或者用 modprobe(会自动处理依赖)
$ sudo modprobe hello
$ sudo modprobe -r hello

insmod 和 modprobe 的区别?我简单说一下:

  • insmod:直接加载,不处理依赖。适合我们这种简单的模块
  • modprobe:会分析模块依赖,自动加载依赖模块。但需要先把模块放到 /lib/modules/xxx/ 下

3.5 查看内核日志

模块加载和卸载时,printk 输出的信息会写到内核日志缓冲区。查看方式有几种:

# 查看所有内核日志
$ dmesg

# 查看最后几行
$ dmesg | tail -10

# 实时监控(类似 tail -f)
$ dmesg -w

# 清空日志
$ sudo dmesg -c

加载模块后,你应该能看到类似这样的输出:

[ 1234.567890] Hello World from RK3568!
[ 1234.567895] Module loaded successfully.

卸载时:

[ 2345.678901] Goodbye! Module unloaded.
调试小技巧:我习惯开两个终端,一个跑 dmesg -w 实时看日志,另一个做 insmod/rmmod 操作。这样每次加载卸载,日志立刻就能看到,不用来回切。

3.6 完整流程与知识结构

下面这张图把整个流程串起来了,从源码到加载再到日志查看,一目了然。

内核模块 Hello World 完整流程 ① 编写 hello.c ② 编写 Makefile ③ make 编译 生成 hello.ko ④ insmod hello.ko ⑤ rmmod hello ⑥ dmesg 查看日志 [ 1234.567890] Hello World from RK3568! [ 1234.567895] Module loaded successfully. [ 2345.678901] Goodbye! Module unloaded. 核心流程:编写源码 → 编写 Makefile → 编译 → 加载 → 卸载 → 查看日志

3.7 常见问题与避坑

写内核模块,坑不少。我把自己踩过的坑列出来,你们遇到了直接对照:

问题 现象 原因 解决方法
Invalid module format insmod 报错,模块加载失败 内核版本不匹配,或编译配置不同 确认 KDIR 指向正确的内核源码
Unknown symbol insmod 报错,提示符号找不到 模块依赖其他模块,或内核配置未开启 用 modprobe 替代 insmod,或检查内核配置
Permission denied insmod 需要 root 权限 加载内核模块需要特权 加 sudo
dmesg 看不到输出 模块加载成功,但没日志 printk 级别太高,被内核过滤了 用 KERN_ALERT 或调整 /proc/sys/kernel/printk
模块卸载时卡死 rmmod 后系统无响应 exit 函数中有死循环或资源未释放 检查 exit 函数,确保能正常返回
我曾经踩过的坑:有一次我在 RK3568 上编译模块,忘了设置交叉编译器,直接用 PC 的 gcc 编译。结果 insmod 时报 "Invalid module format",折腾了半天才发现是编译器的问题。记住,ARM 板子一定要用交叉编译器,x86 的 gcc 编译出来的模块 ARM 内核不认识。

3.8 本章小结

到这里,你已经完成了第一个内核模块的编写、编译、加载和卸载。虽然只是个 Hello World,但整个流程跟写一个真正的驱动完全一样。说白了,驱动开发就是在这个框架上不断加功能。

记住几个关键点:

  • module_init 和 module_exit 是模块的入口和出口
  • Makefile 要用内核的构建系统,别自己写链接脚本
  • printk 是内核的调试利器,dmesg 是查看日志的必备工具
  • 交叉编译时,编译器、内核源码、目标板三者必须匹配

嗯,这一章就到这。代码不多,但每一步都很关键。建议你亲手敲一遍,别复制粘贴。敲的过程中你会注意到很多细节,比如头文件的顺序、宏的写法,这些在以后写复杂驱动时都会用到。