内核模块开发最容易被新手踩到的坑,就是加载一个 .ko 时撞上 GPL-only 符号。上周一个合作厂商的闭源驱动在我内核上一加载就报了一串 Unknown symbol,dmesg 里跟着 GPL-only 相关的提示,最后不得不帮他们做了一层符号桥接才跑通。今天借这个题目系统聊聊 Linux 内核模块里的 MODULE_LICENSE、EXPORT_SYMBOL_GPL,以及社区里流传的各种“绕开 GPL”做法。内容偏实操,适合写过简单模块但没深入过加载机制的人,也适合那些需要在闭源组件里复用内核接口的嵌入式场景。
1. 先搞清楚 MODULE_LICENSE 和 GPL 符号到底是怎么回事
1.1 为什么内核模块要声明许可证
Linux 内核本身以 GPLv2 许可证发布,内核模块是动态加载进内核地址空间的扩展代码,所以在开源社区看来,模块实际上也是内核的一部分。MODULE_LICENSE 这个宏就是模块作者向内核宣告“我这个模块用什么许可证发布”的接口。它并不是一个可有可无的装饰,内核在加载模块时会读取这个字段,并把它用于两个非常关键的决定:
- 决定模块能否引用那些“仅限 GPL”的导出符号。这类符号在导出时额外打上了 GPL-only 的标记。
- 决定内核是否会被标记为 tainted(被污染)。加载非 GPL 模块、或者加载许可证声明不兼容的模块,内核会把这个状态记录在 /proc/sys/kernel/tainted 里。
常见的 MODULE_LICENSE 取值有"GPL"、"GPL v2"、"GPL v2+"、"Dual MIT/GPL"、"Dual BSD/GPL"、"Proprietary"等。内核模块只需要写一行宏就行,内核不会去验证这行字符串是否和模块源码的真实许可证一致。这里就产生了一个微妙的空间:声明本身靠自觉,但技术工具链只认字符串。
很多商业驱动会使用"Dual MIT/GPL"这样的双许可证声明,这是为了避免纯粹的"Proprietary"声明导致部分内核查不到 GPL-only 接口。这只是客观描述社区现象,不代表我建议谁去钻空子。从内核角度讲,它拿到一个许可证字符串,然后按规则决定给你开放多少接口。
1.2 两条导出宏:EXPORT_SYMBOL 和 EXPORT_SYMBOL_GPL 的差异
内核模块之间、模块与内核核心之间要共享函数,靠的是符号导出机制。内核源码里随处可见这两行宏:
EXPORT_SYMBOL(func_name); EXPORT_SYMBOL_GPL(func_name);两者的差异在于使用者的许可证门槛:
EXPORT_SYMBOL导出的符号对所有模块开放,不论这个模块声明的是 GPL、双许可证还是 Proprietary。EXPORT_SYMBOL_GPL导出的符号,只允许声明为“GPL 兼容”的模块引用。如果你用一个MODULE_LICENSE("Proprietary")的模块去引用它,模块加载器会直接拒绝解析该符号,表现就是 insmod 失败,dmesg 里报Unknown symbol xxx (err -22)。
为什么内核要刻意设计两套导出?因为社区并不想完全封死闭源驱动,很多厂商有正当理由维护闭源代码,比如和 NDAs 相关的硬件算法、FPGA 固件逻辑等。如果所有内核接口都只开放给 GPL 模块,整个硬件生态会很难转起来。所以内核社区保留了相当数量的公开导出接口,这些接口被认为是稳定的、可以供闭源驱动调用的公共 API。而那些涉及更深层次内核结构、更需要遵循 GPL 精神的功能,则贴上 GPL-only 的标签,只给 GPL 模块使用。这其实就是一套“分级开放”的策略。
内核里也有不少例外情况,同一个函数在不同版本里的导出标记可能变化。比如某些辅助函数早期是EXPORT_SYMBOL,后来被改成EXPORT_SYMBOL_GPL,结果直接导致一批第三方模块在新内核上编译通过、加载失败。碰到这种问题,首先要做的不是绕过,而是去查你使用的那几个接口当前版本到底属于哪一类导出。
1.3 insmod 时加载器到底做了哪些检查
当你在终端执行insmod xxx.ko时,内核模块加载器并不会直接把二进制内容扔进内核就完事。它会经历一个符号解析和重定位的过程。简单说,模块里如果写了extern int some_func(void),编译后的 .ko 文件里会留下一个未定义的符号引用。加载器需要在内核符号表和其他已加载模块的导出符号表里找到这个符号,然后把调用地址填进去。
在这个解析过程中,加载器会检查三点:
- 符号是否存在。如果这个符号根本没有被任何地方导出,直接失败。
- 符号的导出许可。如果符号属于 GPL-only,而当前请求加载的模块许可证不是 GPL 兼容,直接失败。
- 符号版本号(CRCs)。如果内核开启了 CONFIG_MODVERSIONS,每个导出符号会带一个 CRC 值。调用模块里的符号引用版本如果与内核不一致,也会报 version mismatch,这也是很多人容易误判为 GPL 问题的坑。
所以“绕开 GPL 限制”这个命题,本质上就是在第二步做文章:要么让加载器认为请求方是 GPL 模块,要么绕开加载器的符号解析流程,在运行时再自己去找符号地址。
2. 流传的几种“绕开 GPL”思路,原理和可行性如何
2.1 不声明 MODULE_LICENSE,内核就判断不了?
我见过有人写内核模块时干脆不写 MODULE_LICENSE,觉得这样内核就不知道它是什么许可证,也就没法限制。实际上内核模块加载器在处理未声明许可证的模块时,会把它当作私有模块处理,也就是和"Proprietary"同等对待。这种模块无法使用任何 GPL-only 符号,结果和声明 Proprietary 一模一样。而且因为许可证字段为空,很多调试工具和发行版的内核辅助脚本反而会觉得这个模块异常,误报率更高。
实验验证很容易,一个不写 MODULE_LICENSE 的模块,用modinfo查看时 license 项会显示unknown,加载后系统 tainted 照样被置位。这个做法没有任何收益,建议直接放弃。
2.2 把 MODULE_LICENSE 写成 GPL,然后继续闭源
内核不校验许可证声明的真实性,这确实是一个客观事实。从纯技术角度讲,把MODULE_LICENSE("GPL")写到代码里,加载器就会放行 GPL-only 符号。很多初学者以为这就是“最简绕过方案”。
但它不是没有代价:
- 这是一个法律性质的声明。你公开声明模块是 GPL 的,那模块内的代码就要接受 GPL 赋予用户的各项权利。后面如果发生许可纠纷,这份声明会给公司带来很大风险。
- 内核社区和同行看到你的 GPL 声明,会合理期待你能提供源码。一旦发现声明是假的,对你的声誉影响很大。
- 内核的 taint 机制依然会被触发的条件很多,GPL 声明并不能抹掉所有问题,比如加载了无签名模块、使用了外部编译环境等。
我自己在调试的时候,也曾经图省事想用这种声明让一个内部测试模块跑起来,但后来被法务部门拦住了。公司层面根本不允许在没有许可评估的情况下把这行字符串改掉。所以我的建议是:测试环境随便折腾,生产环境不要碰这种灰色操作。
2.3 GPL 跳板模块:先把 GPL 符号转成普通符号
v这一段要聊的是社区里最常被采用的“绕开”思路:跳板模块。原理很简单,写一个声明为 GPL 的小模块,正常使用那些 GPL-only 符号,然后把这个模块里的普通导出符号暴露给其他非 GPL 模块使用。相当于一个翻译层,把 GPL-only 接口“翻译”成普通接口。
我之前帮厂商排查闭源驱动问题时,就遇到过他们已经在用这种方案的情况。他们加载一个gpl_bridge.ko,里面封装了若干内核函数引用,然后通过EXPORT_SYMBOL把包装函数导出去。非 GPL 驱动只依赖这个桥接模块,单独看它的 .ko 符号引用,根本不会碰到 GPL-only 的东西。
这个方案技术上行得通,而且很稳定,需要注意以下几点:
- 加载顺序有严格要求。必须先加载 GPL 跳板模块,再加载依赖它的非 GPL 模块。如果改了内核启动流程,要用 modules-load.d 之类的方式处理依赖。
- 跳板模块自身要尽量简单,最好只做参数转发,不要塞业务逻辑,否则出问题时很难排查。
- 如果目标内核函数签名变化,跳板模块也要跟着适配,维护成本转移到了自己身上。
- 许可证风险并没有消失,而是集中到了跳板模块上。这个模块用 GPL-only 接口又用普通导出接口把能力转交出去,是否构成对 GPL 义务的规避,是个需要法务评估的问题。我在这里不去下法律结论,但从业者要清楚,这不是一个“零成本绕过”的银弹。
2.4 动态符号解析:通过地址直接调用
这也许是所有“绕开 GPL”方案里最核心、也最常被讨论的一条路。它的思路是:GPL 限制只作用于编译链接阶段和模块加载阶段的符号解析流程。如果你的模块在运行时自己解析目标符号地址,然后用函数指针调用,就完全绕过了加载器那套许可证检查。
关键设施是内核符号表。内核启动后会维护一份全局符号表,通过/proc/kallsyms可以看到。如果内核开启了 CONFIG_KALLSYMS,模块内部也能借助 kallsyms 机制查询符号地址。最常用的入口是kallsyms_lookup_name(const char *name)。
典型流程是:
- 在模块 init 函数里调用
kallsyms_lookup_name,传入目标 GPL-only 符号的名字。 - 拿到一个
unsigned long类型的地址值。 - 把这个地址转成合适的函数指针类型。
- 直接调用。
这个方案最狠的地方在于,模块的 .ko 文件里完全不存在对目标符号的引用,加载器自然也就无从检查许可证。符号解析发生在运行时,内核这时候已经不对“谁在用符号”做审计了。
我在调试环境里试过这个方案,配合 CONFIG_KALLSYMS 的确可以拿到很多未导出函数的地址,而且只要函数签名一致,调用过程很顺。不过它有几个明显的限制:
/proc/kallsyms在非 root 用户下看到的地址是 0,需要 root 权限才能读到真实地址。- 内核如果开启了 KASLR,符号地址不是固定的,必须在运行时动态查询,不能用硬编码。
- 从 Linux 5.7 开始,
kallsyms_lookup_name不再导出给第三方模块。你直接在模块里写 extern 再调用,编译会报“undefined symbol”。关于这一点,我在第 3.4 节会给出具体实验现象和替代思路。
2.5 借助 ftrace/kprobe 等内核机制间接定位
新内核里kallsyms_lookup_name导出被取消后,想再次“曲线”定位符号地址的人开始盯上内核的动态追踪设施,比如 kprobe 和 ftrace。思路是注册一个探针到目标符号,探针回调里能拿到被探测点的地址,然后退出探针、用拿到的地址去做函数指针调用。
这个思路在技术上确实能绕过符号解析限制,因为这些追踪设施本身和符号表交互,它们的内部实现并不依赖 EXPORT_SYMBOL_GPL 那个符号可链接性限制。但我必须提醒你,这类做法通常只适合做调试验证。原因有两点:
- 注册/注销探针会带来额外的运行开销和安全风险,行为像 rootkit,很容易被安全软件盯上。
- 很多追踪接口本身也是有许可证限制的,或者需要内核开启特定配置,生产环境默认配置不一定允许。
后面我会在实验部分给出一点思路,但不建议把 kprobe 方案当作正式产品驱动的长期设计。
3. 亲手实验:从“加载失败”到“调用成功”
3.1 环境准备
为了让你能直接复现,我以 Ubuntu/Debian 类环境为例。首先确认你当前内核版本:
uname -r然后安装对应版本的内核头文件和编译工具链:
sudo apt update sudo apt install gcc make linux-headers-$(uname -r)如果安装完成后/lib/modules/$(uname -r)/build目录存在,说明头文件就绪。写一个最简单的 Makefile:
obj-m += target.o caller_non_gpl.o bridge_caller.o caller_dynamic.o all: make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules clean: make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean实验全程建议在虚拟机里做,不要在生产机器上反复 insmod 和 rmmod,尤其是带自定义内存操作的模块,崩溃一次可能就要重启。
3.2 实验一:静态引用 GPL 符号,复现加载失败
先建一个“目标模块”,里面有一个用EXPORT_SYMBOL_GPL导出的函数。这个模块本身声明为 GPL。
gpl_target.c:
#include <linux/module.h> #include <linux/kernel.h> int gpl_hello(const char *name) { printk(KERN_INFO "gpl_hello called, name = %s\n", name); return 0; } EXPORT_SYMBOL_GPL(gpl_hello); static int __init target_init(void) { printk(KERN_INFO "target module loaded\n"); return 0; } static void __exit target_exit(void) { printk(KERN_INFO "target module unloaded\n"); } module_init(target_init); module_exit(target_exit); MODULE_LICENSE("GPL");再写一个非 GPL 的调用模块,直接引用这个 GPL-only 符号。
caller_non_gpl.c:
#include <linux/module.h> #include <linux/kernel.h> extern int gpl_hello(const char *name); static int __init caller_init(void) { gpl_hello("from non-gpl module"); return 0; } static void __exit caller_exit(void) { printk(KERN_INFO "caller unloaded\n"); } module_init(caller_init); module_exit(caller_exit); MODULE_LICENSE("Proprietary");编译之后,先加载目标模块,再加载调用模块:
make sudo insmod gpl_target.ko sudo insmod caller_non_gpl.ko你会看到第二次 insmod 失败。查看内核日志:
dmesg | tail -20日志里会出现类似这样的内容:
caller_non_gpl: Unknown symbol gpl_hello (err -22)或者在某些版本里是:
caller_non_gpl: disagrees about version of symbol gpl_hello这个现象的关键是:caller_non_gpl.ko里确实存在对gpl_hello的重定位需求,加载器发现这个符号是 GPL-only,而请求方声明是 Proprietary,于是返回-EINVAL(err -22)。这个实验告诉你,加载器层面的许可证检查是真实生效的,不是纸上谈兵。
3.3 实验二:用 GPL 跳板模块转发
现在我们基于同样的目标模块,加一层桥接。在gpl_target.c里增加一个普通导出的包装函数:
int bridge_hello(const char *name) { return gpl_hello(name); } EXPORT_SYMBOL(bridge_hello);调用模块改为引用bridge_hello:
extern int bridge_hello(const char *name); static int __init caller_init(void) { bridge_hello("from non-gpl via bridge"); return 0; }重新编译,先加载gpl_target.ko,再加载 caller 模块。这次加载成功,dmesg 里能看到gpl_hello called, name = from non-gpl via bridge。
这个实验验证了跳板方案的核心逻辑:非 GPL 模块的符号引用只落在普通导出的bridge_hello上,加载器检查时不会发现 GPL-only 符号引用。但需要强调,跳板函数执行时,真正干活的内核函数还是gpl_hello,只是这个引用关系被藏在了gpl_target.ko内部。
从工程角度讲,这种转发层也要做好依赖管理。如果gpl_target.ko被 rmmod,caller 模块再调用bridge_hello就会访问空地址。实战中建议给桥接模块加一个 usecount 计数,或者干脆让 caller 模块在 init 时显式try_module_get持有目标模块引用。
3.4 实验三:通过 kallsyms 动态查找符号地址
最后是动态符号解析实验。这个实验对内核版本有要求:在还开放kallsyms_lookup_name导出的内核(比如 Linux 5.7 之前的版本)上,代码写起来很简洁。
caller_dynamic.c:
#include <linux/module.h> #include <linux/kernel.h> #include <linux/kallsyms.h> typedef int (*hello_fn)(const char *); static int __init caller_init(void) { unsigned long addr; hello_fn fn; addr = kallsyms_lookup_name("gpl_hello"); if (!addr) { printk(KERN_ERR "symbol gpl_hello not found\n"); return -EFAULT; } fn = (hello_fn)addr; fn("from non-gpl via kallsyms"); return 0; } static void __exit caller_exit(void) { printk(KERN_INFO "dynamic caller unloaded\n"); } module_init(caller_init); module_exit(caller_exit); MODULE_LICENSE("Proprietary");编译加载后,日志输出:
gpl_hello called, name = from non-gpl via kallsyms这个实验最值得注意的地方是:caller_dynamic.ko里根本没有gpl_hello这个未定义符号,nm caller_dynamic.ko | grep gpl_hello什么都查不到。加载器自然也就不会触发 GPL-only 检查。这就是动态符号解析“绕过”的本质。
不过在 Linux 5.7 以后的新内核上,编译这个模块会直接报错:
ERROR: "kallsyms_lookup_name" [caller_dynamic.ko] undefined!因为kallsyms_lookup_name的导出被取消了,模块链接阶段就过不去。此时可行的替代思路包括:
- 开启内核调试权限,用 kprobe 注册到目标符号,在探针回调中取出
kprobe.addr,再退出探针后调用函数指针。这个方法对目标符号不受 GPL-only 限制,但注册探针本身对模块权限有要求。 - 通过读取
/proc/kallsyms文本自己解析地址。这是纯用户态逻辑,不需要kallsyms_lookup_name导出,但实现起来要处理文件读取、字符串解析和 root 权限,代码量不小。 - 从可用的导出符号反向推导出
kallsyms_lookup_name的地址。这种做法依赖于同一内核镜像内符号相对距离的稳定性,对版本敏感,非常脆弱,只适合在内核版本锁死的场景里用。
我个人的建议是,如果你在新内核上做实验遇到编译失败,不要死磕动态解析。直接切到老内核虚拟机里验证逻辑,或者改用 kprobe 做调试性验证,不要把大量时间花在反推符号地址这种 hack 上。
4. 常见问题与排查速查表
4.1 “Unknown symbol”到底是谁的问题
遇到Unknown symbol报错时,第一步不是怀疑绕不绕的开,而是先判断到底卡在哪一环。我总结了一张速查表,每次排查按这个顺序走:
| 现象 | 可能原因 | 验证方法 |
|---|---|---|
Unknown symbol xxx (err -22) | GPL-only 符号被非 GPL 模块引用 | 检查导出宏是 EXPORT_SYMBOL 还是 EXPORT_SYMBOL_GPL |
Unknown symbol xxx但 err 不是 -22 | 符号未导出,或依赖模块未加载 | grep /proc/kallsyms 看符号是否存在;modinfo 查看依赖 |
disagrees about version of symbol | CONFIG_MODVERSIONS 开启,CRC 不匹配 | 确认内核头和编译头版本一致,rebuild 模块 |
module loaded but warnings | 模块许可证不被认可 | dmesg 里看 license 相关提示 |
排查命令我给几个常用的:
# 查看模块的导入导出符号 nm <module>.ko | grep ' U ' # 查看目标符号是否在系统符号表里 grep ' gpl_hello' /proc/kallsyms # 查看模块声明的许可证 modinfo <module>.ko如果模块确实是因为 GPL-only 被拒,你会在/proc/kallsyms里看到该符号后面跟着一个G标记,表示 GPL-only。比如:
ffffffffa0123456 t gpl_hello [gpl_target]注意/proc/kallsyms里符号名的后缀[module_name]表示该符号是从哪个模块导出的,不显示的话说明是内核核心符号。
4.2 内核被标记为 tainted 有什么实际影响
很多人看到 dmesg 里出现“module license ‘Proprietary’ taints kernel”就紧张,其实这只是一个状态标记,不会立刻禁掉任何功能。它主要影响三点:
- 内核维护者看到 tainted 标志时,会降低对 bug 回放的信任度。你提交一个 panic 栈,对方看到内核被非 GPL 模块污染,第一反应是可能和第三方驱动有关。
- 部分调试功能,比如某些 kprobe 特性、kdump 机制,在内核被污染后可能会有额外的限制或警告。
- 发行版的技术支持往往会拒绝处理 tainted 内核上的问题。
所以“能用”和“适合生产”是两回事。模块加载能成功,不代表它在一个受支持的内核环境里是受欢迎的。
4.3 新老内核上的可用性差异
kallsyms_lookup_name是一个非常典型的例子,说明内核接口的导出策略也会随版本变化。我做了一个简化版本对照:
| 内核版本 | kallsyms_lookup_name 导出状态 | 非 GPL 模块能否直接调用 |
|---|---|---|
| 4.x 早期 / 5.x 早期 | 普通导出 | 可以 |
| 5.7 及以后 | 不再导出给模块 | 不可以 |
| 6.x 部分版本 | 有恢复导出的讨论,但默认不导出 | 不可以 |
这个变化的意义在于,社区其实在持续收窄“关闭 GPL-only 符号访问”的路径。内核越来越不鼓励你在非 GPL 模块里用动态符号解析去触碰受限接口。如果你的产品是长期维护的,要在新内核上有稳定的内核态行为,最好的路线不是和这些检查对着干,而是把许可证问题梳理清楚,在模块设计阶段就避开 GPL-only 接口依赖。
我在实际项目里踩过几次坑之后,现在的做法是:先把需要用到的内核接口全部理出来,逐个查导出类型。凡是 GPL-only 的接口,要么和法务确认许可证兼容性之后调整模块声明,要么改成在用户态做,要么用标准字符设备接口间接实现。技术校核问题不该靠 hack 解决,否则每次内核升级都在赌命。这篇内容对你理解 Linux 内核模块加载时的 GPL 校验机制应该有帮助,希望你在自己的模块项目里少走这些弯路。