跳转至

Day 8–9 · 内核模块基础设施

预计时长:1.5 小时 / 天,共 3 小时
类型:实验


做什么

写一个最小内核模块,彻底搞清楚模块的生命周期、符号导出、版本控制机制。重点不是 Hello World 本身,而是理解 module_init 背后的机制,以及 WSL2 环境下加载模块的特殊问题。


要了解什么

1. module_init / module_exit 的真实机制

module_init(my_init_func);
module_exit(my_exit_func);

这两个宏不是函数调用,是声明。展开后大致是:

// module_init 展开(简化)
static initcall_t __initcall_my_init_func __used \
    __attribute__((__section__(".initcall6.init"))) = my_init_func;

insmod 时,内核读取模块 ELF 的特定 section,找到 init 函数指针,调用它。rmmod 时调用 exit 函数。如果没有 module_exit,该模块不可卸载。

内建模块obj-y,编进内核)的 module_init 函数在内核启动时按优先级依次调用,你在 initcall_debug 输出中看到的就是这些。

2. MODULE_LICENSE 的实际作用

MODULE_LICENSE("GPL");
MODULE_LICENSE("GPL v2");
MODULE_LICENSE("Dual MIT/GPL");

这不只是声明,有实际效果:

  • 只有 GPL 兼容许可证的模块才能访问标记为 EXPORT_SYMBOL_GPL 的符号
  • 加载非 GPL 模块后,内核会打印 kernel tainted 警告,意味着这个内核状态不再"纯洁"
  • 内核开发者可能拒绝分析被 tainted 内核产生的 bug 报告

大量重要的内核接口(如 usb_register_driveri2c_add_driver)都是 EXPORT_SYMBOL_GPL,实际上强迫驱动采用 GPL 许可。

3. printkpr_* 系列

// 旧式写法(仍然有效)
printk(KERN_DEBUG "value = %d\n", val);
printk(KERN_INFO "module loaded\n");
printk(KERN_ERR "failed: %d\n", ret);

// 现代写法(推荐,自动添加模块名前缀)
pr_debug("value = %d\n", val);
pr_info("module loaded\n");
pr_err("failed: %d\n", ret);

// 在驱动中绑定设备(推荐,带设备路径前缀)
dev_info(&pdev->dev, "probe success\n");
dev_err(&pdev->dev, "failed to request IRQ %d\n", irq);

日志级别(数字越小越紧急): KERN_EMERG(0) > KERN_ALERT(1) > KERN_CRIT(2) > KERN_ERR(3) > KERN_WARNING(4) > KERN_NOTICE(5) > KERN_INFO(6) > KERN_DEBUG(7)

4. WSL2 加载内核模块的特殊性

WSL2 运行的是微软定制内核(5.15.x 或更高),但 WSL2 的头文件可能不完整。你有两个选择:

方案 A(推荐):在 QEMU 虚拟机里加载模块(最接近真实嵌入式场景)
方案 B:使用 WSL2 自身内核头文件加载到 WSL2 内核(测试基本逻辑,但有限制)

方案 B 的头文件获取:

# 查看当前 WSL2 内核版本
uname -r

# 安装对应头文件
sudo apt install linux-headers-$(uname -r)
# 如果找不到,说明 WSL 内核版本太新,改用方案 A

5. modinfoModule.symvers

modinfo my_module.ko
# 输出:filename, license, author, description, vermagic, depends, parm 等

vermagic 字段包含内核版本 + 编译器版本,加载时严格匹配,这就是为什么你不能把 ARM 编译的模块直接 insmod 到 x86 内核。


练习

练习 1:最小模块

mkdir -p ~/labs/hello_module
cd ~/labs/hello_module

创建 hello.c

// hello.c — 最小内核模块
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>

MODULE_LICENSE("GPL");
MODULE_AUTHOR("你的名字");
MODULE_DESCRIPTION("Hello World kernel module");
MODULE_VERSION("0.1");

// module_param 示例:允许 insmod 时传参
static int count = 1;
module_param(count, int, 0644);
MODULE_PARM_DESC(count, "Number of greetings (default: 1)");

static int __init hello_init(void)
{
    int i;
    pr_info("hello: module loaded, count=%d\n", count);
    for (i = 0; i < count; i++)
        pr_info("hello: greeting %d/%d\n", i + 1, count);
    return 0;  // 非零返回值会导致 insmod 失败,errno 被设置
}

static void __exit hello_exit(void)
{
    pr_info("hello: module unloaded\n");
    // void 返回,不能失败
}

module_init(hello_init);
module_exit(hello_exit);

创建 Makefile(注意缩进必须是 TAB):

# 在目标内核源码树下编译
KDIR ?= ~/kernel/linux-stable
ARCH ?= arm
CROSS_COMPILE ?= arm-linux-gnueabihf-

obj-m += hello.o

all:
    make -C $(KDIR) M=$(PWD) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) modules

clean:
    make -C $(KDIR) M=$(PWD) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) clean
# 编译
make

# 查看模块信息
arm-linux-gnueabihf-objdump -d hello.ko | head -30
modinfo hello.ko  # 注意:这是用宿主机 modinfo 读取,会报 vermagic 不匹配,但能看元信息

练习 2:在 QEMU 中加载模块

# 把模块打包进 initramfs
cp ~/labs/hello_module/hello.ko ~/kernel/initramfs/

cd ~/kernel/initramfs
find . | cpio -H newc -o | gzip > ../rootfs.cpio.gz

# 启动 QEMU
cd ~/kernel
qemu-system-arm -M virt -cpu cortex-a7 -m 256M \
  -kernel linux-stable/arch/arm/boot/zImage \
  -initrd rootfs.cpio.gz \
  -append "console=ttyAMA0 rdinit=/init" \
  -nographic

在 QEMU 的 shell 里:

# 加载模块
insmod /hello.ko count=3

# 查看日志
dmesg | tail -10

# 查看模块是否在列表里
lsmod

# 带参数查看详情
cat /proc/modules | grep hello

# 卸载
rmmod hello
dmesg | tail -3

  • [ ] 验证 count=3 参数生效,dmesg 里有 3 条 greeting
  • [ ] 卸载后,检查 dmesg 是否打印了 exit 消息

练习 3:符号导出实验

创建 ~/labs/sym_export/ 目录,写两个模块:

module_a.c(提供符号):

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

MODULE_LICENSE("GPL");

int my_add(int a, int b)
{
    pr_info("my_add called: %d + %d\n", a, b);
    return a + b;
}
EXPORT_SYMBOL_GPL(my_add);  // 只有 GPL 模块才能使用

static int __init module_a_init(void) { return 0; }
static void __exit module_a_exit(void) {}
module_init(module_a_init);
module_exit(module_a_exit);

module_b.c(使用符号):

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

MODULE_LICENSE("GPL");

extern int my_add(int a, int b);  // 声明外部符号

static int __init module_b_init(void)
{
    int result = my_add(3, 4);
    pr_info("my_add(3, 4) = %d\n", result);
    return 0;
}
static void __exit module_b_exit(void) {}
module_init(module_b_init);
module_exit(module_b_exit);

  • [ ] 先 insmod module_a.ko,再 insmod module_b.ko,观察结果
  • [ ] 尝试先 insmod module_b.ko(不加载 a),看报什么错
  • [ ] 把 module_b 的 LICENSE 改成 "Proprietary",重新编译加载,看内核的 tainted 警告

延伸阅读

资料 具体位置 说明
《Linux 设备驱动开发详解》宋宝华 第 2 章 内核模块基础,中文写得很清楚
Linux Device Drivers Corbet 等 Ch.2 "Building and Running Modules" LDD3,免费在线:https://lwn.net/Kernel/LDD3/
Linux Device Driver Development John Madieu Ch.2 更新的实践,基于 5.x 内核
内核源码 include/linux/module.h MODULE_* 宏的定义,必读
内核文档 Documentation/kbuild/modules.rst 模块编译的官方说明