04-Linux 内核模块

4.1 内核模块简介

内核模块(.ko)是可以运行时动态加载/卸载的内核代码。它的价值:

  • 减小内核体积:不常用的驱动编译成模块,按需加载。
  • 加快开发迭代:改驱动不用重编内核、不用重启机器。
  • 便于发行版适配:同一内核支持多种硬件。
  • 便于实验:本手册几乎所有实验都用模块完成。

模块不是什么:

  • 不是独立程序:++没有 main(),没有独立地址空间,运行在内核态,崩溃即内核崩溃++。
  • 不是沙箱:模块拥有内核的全部权限。
  • 不是"想调什么就能调什么":++只能用已导出 的符号(EXPORT_SYMBOL)++。

模块与内核的边界:

4.2 内核模块程序结构

最小模块(hello_module.c):

cpp 复制代码
#include <linux/module.h>
#include <linux/init.h>
#include <linux/kernel.h>

static int __init hello_init(void)
{
        pr_info("hello_module: loaded, jiffies=%lu\n", jiffies);
        return 0;
}

static void __exit hello_exit(void)
{
        pr_info("hello_module: unloaded\n");
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Kevin");
MODULE_DESCRIPTION("Minimal kernel module for Linux 7.2.5 study");
MODULE_VERSION("1.0");

结构要点:

元素 作用
#include <linux/module.h> 必需,提供 module_init/module_exit/MODULE_*
__init / __exit 段属性:init 代码在初始化后释放,exit 代码在编入内核时不保留
module_init(fn) 注册加载函数(返回 int,0 表示成功)
module_exit(fn) 注册卸载函数(返回 void)
MODULE_LICENSE 必须,否则内核标记为专有模块(taint)并影响部分符号使用
MODULE_AUTHOR/DESCRIPTION/VERSION modinfo 可见,便于维护

7.2.5 的补充 :include/linux/cleanup.h 提供了 __free()、guard()、scoped_guard() 等自动清理机制,写模块时可用于简化错误路径;但++不要在模块的 init/exit 函数里依赖复杂的清理链,保持"申请-释放"成对可读更安全++。

4.3 模块加载函数

cpp 复制代码
static int __init my_init(void)
{
        int ret;

        ret = register_chrdev_region(my_dev, 1, "mymod");
        if (ret)
                return ret;

        /* ... */
        return 0;          /* 0 = 成功;负 errno = 失败,模块不会加载 */
}

module_init(my_init);

规则:

  • 返回 0 成功 ,返回负 errno 失败;++失败时内核会调用你的清理逻辑吗?不会------你必须自己回滚已经申请的资源++。
  • __init 函数在模块中不会被释放(模块是动态加载的),但加上 __init 无害且能节省 built-in 情况下的内存。
  • 加载顺序:++insmod 会先做 ELF 校验、符号解析、重定位、签名检查,再调用 init++。
  • 若 init 返回 -ENODEV,insmod 会打印 insmod: ERROR: could not insert module ...: No such device。

多设备/自动加载的场景 :++现代驱动通常不写"加载函数里扫描硬件",而是注册 driver,由设备模型在设备出现时回调 probe()++:

cpp 复制代码
module_platform_driver(my_platform_driver);   /* 展开为 module_init/module_exit */
module_i2c_driver(my_i2c_driver);
module_spi_driver(my_spi_driver);
module_pci_driver(my_pci_driver);

4.4 模块卸载函数

cpp 复制代码
static void __exit my_exit(void)
{
        unregister_chrdev_region(my_dev, 1);
}

module_exit(my_exit);

规则:

  • 卸载前必须确保没有任何人在使用你的模块 :内核通过模块引用计数(refcount)保证。若 rmmod 时计数不为 0,会报 Module mymod is in use。
  • 卸载函数不能"部分失败":返回值是 void,必须完成全部清理。
  • 不要在卸载函数里做可能永久阻塞的操作;del_timer_sync()、flush_workqueue()、cancel_work_sync() 是常用但必须小心死锁(不要在持有对方所需锁时调用)。
  • 卸载后内核会释放模块内存,任何仍然指向模块代码的指针都是悬空的------这正是引用计数存在的意义。

4.5 模块参数

cpp 复制代码
static int count = 1;
module_param(count, int, 0444);
MODULE_PARM_DESC(count, "Number of devices to create (default 1)");

static char *name = "globalmem";
module_param(name, charp, 0644);
MODULE_PARM_DESC(name, "Device name");

static int sizes[4];
static int nsizes;
module_param_array(sizes, int, &nsizes, 0444);
MODULE_PARM_DESC(sizes, "Per-device sizes in bytes");

static bool debug;
module_param(debug, bool, 0644);
MODULE_PARM_DESC(debug, "Enable verbose logging");

权限位(第三参数):

值 含义
0 不在 sysfs 中暴露,只能用 insmod mymod.ko count=4 或 modprobe 配置
0444 只读属性
0644 读写属性(必须自己保证并发安全)
S_IRUGO / S_IWUSR 旧式宏,仍可用,但新代码推荐八进制字面量

用户空间访问:

cpp 复制代码
# 加载时传参
insmod ./mymod.ko count=4 debug=1

# 查看参数
ls /sys/module/mymod/parameters/
cat /sys/module/mymod/parameters/count

# 运行时修改(需要 0644 权限)
echo 8 > /sys/module/mymod/parameters/count
modinfo ./mymod.ko            # 看 parm/parmtype 行

7.2.5 注意事项:

  • 可写参数必须在你的代码里用锁保护,内核不会替你同步。
  • 如果参数在模块运行中途被改变,必须保证语义合法(例如不能把 buffer 大小改小到已有数据之外)。
  • 复杂配置请用 sysfs 属性 / debugfs / configfs / netlink,不要滥用模块参数。

4.6 导出符号

模块 A 想调用模块 B 的函数,B 必须导出符号:

cpp 复制代码
/* 在 B 模块中 */
static int internal_helper(void) { ... }      /* 私有 */

int my_exported_func(int x)                    /* 导出给任何模块 */
{
        return internal_helper() + x;
}
EXPORT_SYMBOL(my_exported_func);

void my_gpl_func(void) { ... }
EXPORT_SYMBOL_GPL(my_gpl_func);                /* 只给 GPL 兼容模块 */

7.2.5 的符号命名空间(原书没有的知识点,大型子系统必备):

cpp 复制代码
/* 定义方 */
EXPORT_SYMBOL_NS(my_func, MY_SUBSYSTEM);

/* 使用方:必须显式声明导入哪个命名空间 */
MODULE_IMPORT_NS("MY_SUBSYSTEM");

命名空间的目的是:让子系统内部接口"半私有化",避免被无关模块滥用。使用方忘记 MODULE_IMPORT_NS() 会出现类似错误:

复制代码
ERROR: modpost: module mymod uses symbol my_func from namespace MY_SUBSYSTEM, but does not import it.

符号解析与 modpost:

cpp 复制代码
ERROR: modpost: "foo" [./bar.ko] undefined!

常见原因:

  1. foo 没有 EXPORT_SYMBOL。
  2. foo 所在的模块没有先加载(且不是 built-in)。
  3. 模块与内核版本/配置不匹配(vermagic 不同)。

检查已导出符号:

cpp 复制代码
grep -w foo /proc/kallsyms            # guest 中
nm vmlinux | grep " T foo"            # 宿主中
modprobe --dump-modversions foo.ko    # 查看模块依赖的符号版本

4.7 模块声明与描述

cpp 复制代码
MODULE_LICENSE("GPL");                    /* 必须:GPL / GPL v2 / Dual BSD/GPL / Proprietary */
MODULE_AUTHOR("Kevin <kevin@example.com>");
MODULE_DESCRIPTION("Global memory character driver");
MODULE_VERSION("1.2.3");
MODULE_ALIAS("platform:globalmem");       /* 别名,配合 udev/modprobe 自动加载 */
MODULE_SOFTDEP("pre: crc32c");            /* 软依赖:加载前先尝试加载 crc32c */
MODULE_FIRMWARE("mydrv/fw.bin");          /* 声明需要的固件,便于打包 */
MODULE_DEVICE_TABLE(of, my_of_match);     /* 生成 modalias,实现设备热插拔自动加载 */
MODULE_DEVICE_TABLE(i2c, my_i2c_id);
MODULE_DEVICE_TABLE(platform, my_platform_id);

MODULE_DEVICE_TABLE 是自动加载的关键 :它把设备匹配表写进模块的 __mod_<bus>_device_table 段,depmod 处理后写入 /lib/modules/$(uname -r)/modules.alias。当内核发现新设备并发出 uevent 时,udev 根据 modalias 自动 modprobe 对应模块。

cpp 复制代码
# 观察 modalias 与自动加载
cat /sys/bus/i2c/devices/0-0050/modalias
grep i2c /lib/modules/$(uname -r)/modules.alias | head

查看模块信息:

cpp 复制代码
modinfo ./globalmem.ko
lsmod
cat /proc/modules
ls /sys/module/globalmem/
ls /sys/module/globalmem/sections/      # 各段地址(需要权限)
cat /sys/module/globalmem/refcnt

4.8 模块的使用计数

内核维护每个模块的引用计数,防止"正在被使用的代码被卸载"。

cpp 复制代码
/* 典型场景:文件操作期间防止模块被卸载 */
static int my_open(struct inode *inode, struct file *filp)
{
        if (!try_module_get(THIS_MODULE))
                return -ENODEV;
        ...
        return 0;
}

static int my_release(struct inode *inode, struct file *filp)
{
        ...
        module_put(THIS_MODULE);
        return 0;
}

7.2.5 的现实情况 :大多数情况下你不需要手工写 try_module_get():

  • 设备驱动通过 struct device_driver.owner = THIS_MODULE 注册后,设备打开/绑定期间内核自动持有引用。
  • 字符设备通过 cdev_add + fops->owner = THIS_MODULE,VFS 会在打开时自动 try_module_get()。
  • 只有自己创建的 kthread、自建 workqueue、定时器回调、以及暴露给其他模块的入口才需要手工管理。

观察引用计数:

cpp 复制代码
cat /sys/module/globalmem/refcnt
rmmod globalmem                 # 若 refcnt != 0,会失败
# 正确的卸载流程:先关闭所有使用者,再 rmmod

4.9 模块的编译

4.9.1 树内模块(built-in 或模块)

把驱动放进 drivers/ 下,按第 3 章的 Kconfig/Kbuild 规则添加,然后:

cpp 复制代码
make -C "$KSRC" O="$KBUILD" modules            # 只编译模块
make -C "$KSRC" O="$KBUILD" modules_install    # 安装到 /lib/modules/...

4.9.2 外部模块(本手册推荐方式)

目录结构:

cpp 复制代码
/home/works/study/modules/04-hello/
├── Makefile
└── hello_module.c

Makefile:

cpp 复制代码
obj-m := hello_module.o
# 多文件模块:
# obj-m := mymod.o
# mymod-y := main.o fileops.o irq.o

# 只在本 Makefile 被 Kbuild 直接调用时才展开
KSRC ?= /home/works/study/linux-7.2.5
KBUILD ?= /home/works/study/build/linux-7.2.5-x86_64

all:
	$(MAKE) -C $(KSRC) O=$(KBUILD) M=$(PWD) modules
clean:
	$(MAKE) -C $(KSRC) O=$(KBUILD) M=$(PWD) clean

编译:

cpp 复制代码
cd /home/works/study/modules/04-hello
make
ls -l hello_module.ko
modinfo hello_module.ko

7.2.5 的新写法:-f 代替 -C (Linux 6.13 起,见 Documentation/kbuild/modules.rst)。它不会切换工作目录,产物直接落在当前目录:

复制代码
# 旧写法(仍然可用)
make -C "$KSRC" O="$KBUILD" M="$PWD" modules
# 新写法(6.13+)
make -f "$KBUILD/Makefile" M="$PWD" modules

把模块产物放到单独目录(MO=):

复制代码
make -C "$KSRC" O="$KBUILD" M="$PWD" MO="$PWD/build" modules

跨模块依赖(KBUILD_EXTRA_SYMBOLS):模块 A 依赖模块 B 的导出符号,而 B 也在源码树外时:

复制代码
make -C "$KSRC" O="$KBUILD" M="$PWD" \
     KBUILD_EXTRA_SYMBOLS=/path/to/B/Module.symvers modules

模块签名 (发行版内核常见,CONFIG_MODULE_SIG):

bash 复制代码
# 生成密钥(内核树内)
scripts/sign-file sha256 private_key.pem public_key.pem mymod.ko

# 若内核启用了强制签名(CONFIG_MODULE_SIG_FORCE),未签名模块无法加载:
#   insmod: ERROR: could not insert module mymod.ko: Key was rejected by service

模块版本(CONFIG_MODVERSIONS) :7.2.5 中如果启用 CONFIG_GENDWARFKSYMS(配合 CONFIG_DEBUG_INFO),符号版本改用 DWARF 信息 计算,而不是旧的 genksyms 源码解析。这对 Rust 模块是必需的(详见 Documentation/kbuild/gendwarfksyms.rst)。对驱动开发者的影响是:

  • 模块与内核必须"同源同配置"编译,否则报 disagrees about version of symbol xxx。
  • 想绕过版本检查只能靠 force 加载(危险,不要在生产上做)。

4.9.3 在 QEMU 中加载模块

三种方式,按推荐顺序:

  1. 把模块放进 initramfs(最稳定):

    make -C "KSRC" O="KBUILD" INSTALL_MOD_PATH="$ROOTFS" modules_install

    重新打包 initramfs 后启动 QEMU

  2. 9p 共享目录(改代码不用重打包):

    guest 中

    mount -t 9p -o trans=virtio,version=9p2000.L hostshare /mnt/host
    insmod /mnt/host/modules/04-hello/hello_module.ko

  3. virtiofs / 网络传输(进阶)。

4.10 使用模块"绕开"GPL

这是原书中争议最大的一节,必须把技术事实 和法律边界讲清楚。

技术事实:

  • EXPORT_SYMBOL_GPL() 导出的符号只允许声明了 GPL 兼容许可证的模块使用。

  • 模块的许可证由 MODULE_LICENSE() 决定。写 MODULE_LICENSE("Proprietary") 或完全不写 MODULE_LICENSE(),内核会把该模块标记为 tainted(污染):

    cat /proc/sys/kernel/tainted # 非 0 表示内核已被污染
    dmesg | grep -i taint

  • 被污染的内核,社区在收到 bug 报告时会要求你先复现于未污染内核。

为什么不能"绕开":

  • 从 4.0 到 7.2.5,EXPORT_SYMBOL_GPL 的范围持续扩大;很多关键接口(如 kallsyms_lookup_name、部分 tracepoint 相关接口、很多核心子系统的内部 API)都是 GPL-only 或干脆不再导出。
  • 历史上有人用 kallsyms_lookup_name() 或直接读取 /proc/kallsyms 来"抓"符号地址,从而调用未导出函数。内核已采取措施:kallsyms_lookup_name() 在 5.7 之后不再导出 ,非特权读取 /proc/kallsyms 只得到 0 地址。
  • 更本质的是:这不是技术问题,而是许可证问题 。Linux 采用 GPLv2,内核社区(以及多数法务)认为与内核链接的模块属于衍生作品,必须 GPL 兼容。绕过的做法会让你的产品处于法律风险中,并且一旦涉及 EXPORT_SYMBOL_GPL 的符号,也违反内核的明确技术约定。

正确做法:

  1. 用 GPL 兼容许可证 发布驱动(MODULE_LICENSE("GPL"))。
  2. 如果确实需要闭源,走厂商认可的边界:把专有逻辑放到用户空间,或通过明确的、稳定的 ABI(sysfs/netlink/字符设备 ioctl/virtio 等)通信。
  3. 需要"内核内部功能但不想直接链接"时,考虑 BPF (BPF_PROG_TYPE_*、kprobe/tracepoint 程序)这类官方支持的可编程接口。

符号命名空间与 GPL 的关系(7.2.5):

复制代码
EXPORT_SYMBOL_NS_GPL(subsys_helper, MY_NS);   /* 命名空间 + GPL-only */
MODULE_IMPORT_NS("MY_NS");                    /* 使用方声明 */

命名空间是技术隔离,不改变许可证规则。

4.11 总结

  • 模块 = 运行时可加载的内核代码;有 init/exit 成对、有引用计数、有签名与版本校验。
  • 模块参数、导出符号、模块声明是模块的"对外接口";MODULE_DEVICE_TABLE 决定能否自动加载。
  • 外部模块编译在 7.2.5 中既可用传统 -C,也可用 6.13 引入的 -f;M=/MO=/KBUILD_EXTRA_SYMBOLS 是常用变量。
  • 模块版本检查在 7.2.5 中可能由 CONFIG_GENDWARFKSYMS 通过 DWARF 计算,模块必须与内核同源同配置编译。
  • GPL 边界必须遵守:EXPORT_SYMBOL_GPL 不可绕过,污染内核的技术手段也已被大幅封堵。