首页 / 资讯中心 / 文章详情

Linux内核模块GPL符号限制解析:从Unknown symbol到桥接方案

Linux内核模块GPL符号限制解析:从Unknown symbol到桥接方案 ★ FEATURED ARTICLE
内核模块开发最容易被新手踩到的坑就是加载一个 .ko 时撞上 GPL-only 符号。上周一个合作厂商的闭源驱动在我内核上一加载就报了一串 Unknown symboldmesg 里跟着 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目录存在说明头文件就绪。写一个最简单的 Makefileobj-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于是返回-EINVALerr -22。这个实验告诉你加载器层面的许可证检查是真实生效的不是纸上谈兵。3.3 实验二用 GPL 跳板模块转发现在我们基于同样的目标模块加一层桥接。在gpl_target.c里增加一个普通导出的包装函数int bridge_hello(const char *name) { return gpl_hello(name); } EXPORT_SYMBOL(bridge_hello);调用模块改为引用bridge_helloextern 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被 rmmodcaller 模块再调用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_GPLUnknown symbol xxx但 err 不是 -22符号未导出或依赖模块未加载grep /proc/kallsyms 看符号是否存在modinfo 查看依赖disagrees about version of symbolCONFIG_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 校验机制应该有帮助希望你在自己的模块项目里少走这些弯路。
阅读完成 · 觉得有帮助?
咨询建站