3. 内核模块开发入门

内核模块是Android内核可动态加载的代码单元,它运行在内核态,拥有与内核核心代码相同的特权。与用户态程序不同,内核模块没有main函数,也不能调用标准C库;它通过模块初始化函数和模块退出函数向内核注册自己的生命周期入口。本章从最小可运行模块出发,串联Makefile编写、编译、加载与卸载的完整闭环,为后续签名与加载工程打下基础。

3.1 核心原理:模块的生命周期与入口

一个内核模块在编译后得到.ko(Kernel Object)文件。加载时,内核调用模块的初始化函数;卸载时,调用退出函数。这两个函数通过宏module_init()和module_exit()注册。模块还必须声明许可证,否则内核会标记为“污染”(tainted),在Android的GKI(Generic Kernel Image)环境下可能直接拒绝加载。

模块代码中常用的头文件包括<linux/module.h>、<linux/kernel.h>和<linux/init.h>。打印日志使用pr_info()或printk(),日志会进入内核环形缓冲区,可通过dmesg查看。注意:内核模块中不能使用printf,因为用户态C库不可用。

模块三要素:初始化函数、退出函数、许可证声明。缺少退出函数时模块可以加载,但无法正常卸载;缺少许可证声明时,内核会记录污染标志。

3.2 编写第一个Hello World模块

下面是最小可运行模块的完整代码。它只做两件事:加载时打印一条信息,卸载时打印另一条信息。

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

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Android Kernel Course");
MODULE_DESCRIPTION("A minimal Hello World kernel module");

static int __init hello_init(void)
{
    pr_info("hello: module loaded, Hello World!\n");
    return 0;
}

static void __exit hello_exit(void)
{
    pr_info("hello: module unloaded, Goodbye!\n");
}

module_init(hello_init);
module_exit(hello_exit);

其中__init和__exit是内核标记宏,用于把函数放入特定的内存段,加载完成后初始化段可被释放,从而节省内存。返回值0表示初始化成功,非零表示失败,内核会回滚加载。

3.3 Makefile编写:连接源码与内核构建系统

内核模块不能像普通程序那样直接调用gcc编译,必须借助内核源码树中的Kbuild系统。Makefile的核心是obj-m变量和M=参数。

# 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.c编译为可加载模块;-C $(KDIR)切换到内核构建目录,读取内核的顶层Makefile和配置;M=$(PWD)告诉Kbuild回到当前目录处理外部模块。Android内核开发中,KDIR通常指向AOSP内核源码的out/.../kernel或预编译内核头文件目录,而不是宿主机的/lib/modules。

交叉编译Android内核模块时,还需指定ARCH=arm64和CROSS_COMPILE=aarch64-linux-android-,并确保使用的内核源码与目标设备内核版本、配置一致,否则加载时会出现“version magic”不匹配。

3.4 编译、加载与卸载流程

完整操作流程如下:

  1. 在模块目录执行make,生成hello.ko。
  2. 使用insmod hello.ko加载模块,或使用modprobe(需模块位于标准路径)。
  3. 执行dmesg | tail查看加载日志。
  4. 使用lsmod | grep hello确认模块已在内核中。
  5. 使用rmmod hello卸载模块,再次查看日志。
$ make
$ sudo insmod hello.ko
$ dmesg | tail -n 3
[ 1024.123456] hello: module loaded, Hello World!
$ lsmod | grep hello
hello                  16384  0
$ sudo rmmod hello
$ dmesg | tail -n 3
[ 1030.654321] hello: module unloaded, Goodbye!

在Android设备上,通常通过adb push把.ko推送到/data/local/tmp,再以root权限执行insmod。但Android 11及以上对模块加载有严格限制,未签名或签名不匹配的模块会被内核拒绝,这正是后续章节要解决的核心问题。

内核模块开发闭环:源码 → 编译 → 加载 → 卸载 hello.c module_init / module_exit MODULE_LICENSE("GPL") pr_info 打印日志 Makefile obj-m += hello.o -C $(KDIR) M=$(PWD) ARCH / CROSS_COMPILE Kbuild 编译 make modules 生成 hello.ko insmod 加载 调用 hello_init() dmesg 查看日志 rmmod 卸载 调用 hello_exit() 释放模块内存 Linux / Android 内核 模块链表 · 符号表 · 版本校验 · 签名校验 lsmod 查看已加载模块 关键约束:内核版本一致 · 配置一致 · 许可证声明 · Android GKI 需签名匹配 日志统一通过 dmesg 查看,模块状态通过 lsmod 确认

3.5 常见误区与复盘

初学者在第一个模块上最容易踩的坑集中在以下几类:

现象 常见原因 排查方法
insmod 报 “Invalid module format” 内核版本或配置不匹配,version magic 不一致 对比 uname -r 与编译内核版本,检查 vermagic
insmod 报 “Operation not permitted” Android 未关闭模块签名校验,或 SELinux 限制 查看 dmesg 中的签名拒绝日志,确认 GKI 策略
dmesg 无输出 日志级别过低,或 pr_info 被过滤 使用 dmesg -w 实时观察,检查 printk 级别
rmmod 报 “Module is in use” 模块引用计数不为零,或退出函数未正确注册 检查 lsmod 第三列计数,确认 module_exit 存在
编译报 “No such file or directory” KDIR 指向错误,内核头文件未准备 确认内核源码已编译,build 链接存在
在Android真机上直接加载未签名模块,即使编译完全正确,也可能被内核拒绝。这不是代码错误,而是安全机制生效。后续章节将围绕模块签名、密钥管理和GKI加载策略展开,届时再回头审视本章的加载流程,会更容易理解“为什么能编译却加载不了”。

复盘本章:Hello World模块虽小,却完整覆盖了内核模块开发的四个关键动作——写入口、配Makefile、编译成.ko、加载并验证。掌握这条最小闭环后,后续任何复杂模块都只是在这个骨架上增加功能代码和依赖声明。真正需要长期关注的是内核版本一致性和签名校验,它们决定了模块能否从“编译成功”走到“加载成功”。