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!
常见原因:
foo没有EXPORT_SYMBOL。foo所在的模块没有先加载(且不是 built-in)。- 模块与内核版本/配置不匹配(
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 中加载模块
三种方式,按推荐顺序:
-
把模块放进 initramfs(最稳定):
make -C "KSRC" O="KBUILD" INSTALL_MOD_PATH="$ROOTFS" modules_install
重新打包 initramfs 后启动 QEMU
-
9p 共享目录(改代码不用重打包):
guest 中
mount -t 9p -o trans=virtio,version=9p2000.L hostshare /mnt/host
insmod /mnt/host/modules/04-hello/hello_module.ko -
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的符号,也违反内核的明确技术约定。
正确做法:
- 用 GPL 兼容许可证 发布驱动(
MODULE_LICENSE("GPL"))。 - 如果确实需要闭源,走厂商认可的边界:把专有逻辑放到用户空间,或通过明确的、稳定的 ABI(sysfs/netlink/字符设备 ioctl/virtio 等)通信。
- 需要"内核内部功能但不想直接链接"时,考虑 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不可绕过,污染内核的技术手段也已被大幅封堵。