有一段用户空间代码需要在内核里跑。正常做法是用内核构建系统重新编译,但那样得维护一套 Kbuild,而且代码里的用户空间惯用法改起来很痛苦。
于是就冒出一个念头:能不能直接把编译好的二进制文件"转"成 .ko?
直觉上这应该可行——反正 .ko 就是 ELF 可重定位文件(ET_REL),普通的 .o 编译产物也是 ET_REL。格式骨架一样,差的无非是元数据。
真的上手之后,才发现坑比想象中多得多。
先理清几个基本概念。ELF 文件有四种类型:
关键认知:.so 是 ET_DYN,.ko 是 ET_REL。它们的 ELF 类型就不同。
.so 文件是 ET_DYN(动态链接库),结构上和 .ko 有本质差异:
内核加载模块的第一步就检查 ELF 类型——必须是 ET_REL,否则直接返回 -ENOEXEC。无论你怎么处理,ET_DYN 的 .so 连第一道门都过不去。这个检查在 kernel/module.c 的 elf_validity_check() 中:
.so 里塞满了动态链接基础设施:.dynamic, .dynsym, .dynstr, .hash, .gnu.hash, .got, .got.plt, .plt.got, .rel.dyn, .rel.plt, .interp……这些段包含了各种不必要的信息,对内核模块加载器毫无意义,必须全部删除。
.so 的符号名长这样:
内核导出符号可没有这些 @ 后缀。用带后缀的名字去查内核符号表,内核在 simplify_symbols() 里调用 resolve_symbol_wait() 做严格的 strcmp 比对,当然查不到。
.so 里的函数调用默认走 PLT(过程链接表),会生成大量 PLT 相关的重定位条目。内核加载器内部的 apply_relocations() 遍历所有 SHT_RELA 段逐个处理重定位,这些 PLT 重定位条目会被逐一处理,但处理逻辑和用户空间 ld.so 完全不同,结果就是错位。
结论:用 gcc -c -fPIC 编译成 .o(ET_REL),直接从 ET_REL 转 ET_REL。
在讲具体坑之前,先沿着内核源码(以 Linux 5.10 为例)把模块加载的完整路径走一遍。后面所有坑的根因都能在这条链路上找到。
三项硬性检查:ELF 魔数、ET_REL 类型 、架构匹配。任何一个不过就直接 -ENOEXEC。这就是为什么 .so 不行,同时也意味着我们不能修改 ELF header 把 ET_DYN 改成 ET_REL 了事——架构检查 elf_check_arch() 在 ARM64 上还会验证段结构的完整性。
vermagic 的比对逻辑在 same_magic() 中:
如果内核启用了 CONFIG_MODVERSIONS,会跳过 vermagic 里第一个空格之前的内容再做比对(因为那部分是 UTS_RELEASE)。否则就是完整的 strcmp。
在分配模块内存之前,内核调用架构钩子检查和预处理段结构。ARM64 的实现(arch/arm64/kernel/module-plts.c)尤其值得关注:
ARM64 的模块链接脚本同样印证了这一点(arch/arm64/include/asm/module.lds.h):
内核构建工具链生成的 .ko 天然带这三个段(大小可以为 0,内容为 1 字节占位)。自己构建的 .ko 如果缺少这些段,ARM64 的 module_frob_arch_sections 直接返回 -ENOEXEC。x86_64 没有这个硬性要求。
核心逻辑:
所以 UND 符号的 shndx 必须是 0。如果你在转换时把它改成了 SHN_ABS(0xFFF1),它就不会进入 resolve_symbol_wait 分支,内核不会去查符号表,外部引用全部悬空。
这段逻辑遍历所有段头,找出类型为 SHT_RELA 的段(重定位段),调用架构特定的 apply_relocate_add() 逐条处理。
.rela.gnu.linkonce.this_module 就是在这里被处理的。 内核遍历到这个段时,把 init_module 和 cleanup_module 的最终地址写入 .gnu.linkonce.this_module 段内对应偏移处。这些偏移正是 struct module 中 init/exit 函数指针的位置。
内核调用 mod->init 这个函数指针。这个指针的值是在第五步的重定位处理中填入的。如果 .rela.gnu.linkonce.this_module 段不存在或偏移量不正确,mod->init 就是 NULL,内核跳过整个初始化流程,不报任何错误。
同样,模块卸载时(kernel/module.c 的 free_module() 路径):
mod->exit 也是通过重定位填入的。两个函数指针,两条重定位,缺一不可。
如果内核开启了 CONFIG_MODVERSIONS,每个外部符号引用都要比对 CRC:
模块的 __versions 段存储了 struct modversion_info 数组(64 字节每项:CRC + 符号名)。内核逐个比对 CRC 值,不匹配则加载失败。其中 module_layout 这个符号的 CRC 实质上代表了整个 struct module 的结构签名。
以上就是模块从 insmod 到 init 执行经过的全部内核关卡。接下来看转换过程中的具体坑。
搞清楚内核加载路径后,我设计了两阶段流水线:
**阶段一(离线转换)**在开发机上完成 ELF 结构层面的转换:删掉不需要的段、保留需要的段、补充内核元数据段、重新索引符号和重定位。所有不确定的内核参数填入占位值。
**阶段二(原位修补)**在目标设备上运行。找一个目标设备上已有的、能正常加载的 .ko 作为"参考",从中提取所有内核特定参数,覆写占位值。
这个设计的核心思想是:转换工具不需要知道目标内核的任何细节。 vermagic、struct module 大小、字段偏移、CRC——全部由参考 .ko 提供。
以下按排查难度排序。
转换的第一步:决定哪些段保留、哪些丢弃。
必须丢弃的段 :
必须保留的段 :
ARM64 上必须额外创建的空段 :
从上面 module_frob_arch_sections() 的源码可以看到,ARM64 直接按段名查找 .plt 和 .init.plt,找不到就返回 -ENOEXEC。ARM64 的链接脚本 .lds.h 也明确定义了这三个空段。所以转换阶段必须生成:
缺任何一个,内核直接拒载,错误信息只是 "module PLT section(s) missing",不给具体缺少哪个。
原始重定位段必须删除 。删除了部分段、重构了段索引后,旧的重定位条目引用的段索引已失效。如果新旧重定位段并存(比如两套 .rela.text),加载器在处理第二条重定位时发现目标位置已有非零值,会报 "Invalid relocation target, existing value is nonzero"。
ET_REL 文件里的地址全部是段相对 的:
比如符号的 value 应该是类似 0x10 的值("函数入口在 .text 段偏移 0x10 处"),绝对不是 0x7f0000001000 这样的虚拟地址。
对应到内核源码,simplify_symbols() 里的 default 分支:
内核假设 st_value 是段相对偏移,然后加上段加载基址得到最终地址。如果你的 st_value 已经是个绝对 VA,再加上段基址就飞到九霄云外了。
但 ELF 解析库在处理 ET_REL 时,某些 API 返回的却是绝对虚拟地址——内部走的是处理 ET_DYN/ET_EXEC 的逻辑分支。
解决方式是把所有符号值和重定位偏移都显式减掉所在段的虚拟基地址。不能依赖"ET_REL 的段 VA 都是 0"的假设。
ELF 解析库(这里用的是 LIEF)在表示重定位类型时,把架构信息编码进了类型值的高位:
如果直接把 LIEF 编码的类型值写回 ELF 的 r_info 字段,接收方按标准解码会得到完全不同的数字。用掩码 0x7FFFFFF(低 27 位,对应 ELF 规范中 r_info 的低位布局)剥离架构前缀即可。
内核模块引用的外部符号——_printk, kmalloc, kfree——在原文件里 shndx = 0(SHN_UNDEF)。
回顾上面的 simplify_symbols() 源码:
内核根据 shndx == SHN_UNDEF 来判断是否需要在全局符号表里搜索。SHN_UNDEF 的值就是 0。
在转换过程中,段的增删导致段索引需要重新映射。写映射逻辑时,很容易写出:
shndx = 0 被改写成了 SHN_ABS(0xFFF1)。内核看到 SHN_ABS,直接走 break 分支,st_value 保持不变——对 UND 符号来说就是 0。外部调用全部悬空,内核不会去符号表里找。
教训:shndx 为 0 时必须原样保持 0。一个 if (orig_shndx == 0) 的提前判断就够。
这是最反直觉的一个坑。
写符号过滤逻辑时,很自然会跳过"名字为空、value 为 0、size 为 0"的符号。但有一种叫 STT_SECTION 的符号类型——表示"段本身"。它的名字确实是空的,value 也可以是 0,但它是重定位的重要目标。
什么时候重定位会引用 STT_SECTION?
这些重定位通过 shndx 字段关联到 STT_SECTION 符号,再由 STT_SECTION 符号的 shndx 找到目标段,最终由 simplify_symbols() 的 default 分支加上段基址。
如果 STT_SECTION 符号被当成无效条目清理掉了,引用了它的重定位条目就找不到目标,要么指向符号 0(空符号),要么符号索引越界。
教训:符号的生死不能单靠名字和 value 判断。类型为 STT_SECTION 的必须保留,并建立原始段索引到新符号索引的映射表。
从 .so 文件提取符号时(即使最终不用 .so 做输入,这个坑也值得记),符号名可能带版本后缀:
这是 GNU 符号版本控制机制。内核的 resolve_symbol_wait() 做的是直接 strcmp,@GLIBC_2.2.5 这种后缀当然对不上。在构建输出符号表时,查找 @ 字符并截断就行。
从 check_modinfo() 和 same_magic() 的源码可以看出,vermagic 做的是严格字符串比对 (或跳过第一个空格前缀后的比对)。vermagic 的构成由 include/linux/vermagic.h 的宏拼装决定:
其中每个宏是否展开取决于对应的 CONFIG 选项:
一个典型的 vermagic 长这样:
注意末尾的 mod_unload 后面可能有一个空格(取决于宏展开时 "mod_unload " 的尾随空格),这个空格也参与比对。
解决方式:不在转换阶段猜测 vermagic,从参考 .ko 的 .modinfo 段中提取完整的 vermagic 值,完整覆写到目标。
struct module 是内核在内存里为每个模块维护的数据结构(定义在 include/linux/module.h,几百行的巨型结构体)。它的大小和字段布局完全取决于内核编译配置 :
同一内核版本、不同 defconfig,sizeof(struct module) 可能差几百到上千字节。
如果一个 .ko 的 .gnu.linkonce.this_module 段大小和目标内核不一致,layout_and_allocate() 在分配模块内存时会按内核自己的 sizeof 来布局,大小对不上会导致段覆盖或越界访问。
解决方式:从参考 .ko 复制整个 .gnu.linkonce.this_module 段数据。参考 .ko 本就是用这个内核的 Kbuild 编译出来的,它的 struct module 一定正确。
模块名也在这个结构体里。但名字字段的偏移同样是内核版本决定的:
不硬编码偏移。在参考的 struct module 数据里搜索参考模块自己的名字字符串 ,定位到名字字段,然后在那里写入新的模块名。
.modinfo 是一个嵌入在 ELF 段里的 key=value\0 格式字符串表。内核通过 get_modinfo(info, "字段名") 查找其中的键值对,比如前面看到的 get_modinfo(info, "vermagic")。
必须包含的字段 (结合 check_modinfo() 源码和内核约定):
特别需要强调:init=init_module 和 cleanup=cleanup_module 只是 modprobe 等工具的约定,内核本身不解析这两个字段来找入口函数。 内核唯一找 init/exit 的途径是通过 struct module 里的函数指针(见下一个坑)。
这是花了最长时间 debug 的问题。
现象:模块加载成功(insmod 返回 0),卸载也成功,没有错误日志。但 init 函数里的代码就是没执行。把 init 的返回值改成 -1,加载居然还是成功——说明 init 根本没被调用到。
回看 do_init_module() 的源码:
mod->init 的值是从哪里来的 ?不是符号表查找,不是 .modinfo 的 init= 字段。是 apply_relocations() 在处理 .rela.gnu.linkonce.this_module 段时填入的 。
在处理重定位时,内核遍历所有 SHT_RELA 段,遇到 .rela.gnu.linkonce.this_module,执行类似以下操作:
这两个偏移量(0x138 和 0x4c0)正是 struct module 内部 init/exit 函数指针的偏移位置。
真实 .ko 的 .rela.gnu.linkonce.this_module 段内容示例(x86_64 Linux 6.19):
ARM64 Android 5.10 上的真实数据:
不同架构、不同内核版本的偏移量不同。但原理一样:内核靠重定位把函数指针填进 struct module,不是靠名字查找。
所以必须在转换阶段生成 .rela.gnu.linkonce.this_module 段,包含指向 init_module 和 cleanup_module 的两条重定位。偏移量先用已知值初始化(如 x86_64 用 0x138/0x4c0,ARM64 用 0x190/0x3c0),然后在目标修补阶段从参考 .ko 的同名重定位段中提取实际偏移,不匹配就修正。
如果没有这个段或其偏移量是错的,mod->init 就是 NULL,内核静默跳过 init,不报任何错误。
重定位条目的 r_offset 标记了"在目标段偏移处写入修正值"。但重定位条目本身需要被分组归属到不同的目标段。
可靠的做法是分两级查找:
如果两步都找不到目标段——比如重定位指向的是已经删掉的段——就跳过,不写入。
每条重定位的 r_info 字段需要重新计算:高 32 位填入符号在新符号表中的索引(不是原始索引),低 32 位填入剥离架构前缀后的重定位类型。
输出的重定位段命名规则:.rela + 目标段名。比如目标段是 .gnu.linkonce.this_module,重定位段就是 .rela.gnu.linkonce.this_module。目标段是 .text,重定位段就是 .rela.text。
每个 SHT_RELA 段的 ELF 段头中:
apply_relocations() 遍历段时依赖 sh_info 找到目标段、sh_link 找到符号表。这两个索引写错一个,内核要么找不到目标段(跳过)、要么读到错误的符号表(重定位算错)。
ARM64 内核模块有一个 x86_64 没有的硬性段依赖。从 ARM64 的 module_frob_arch_sections() 源码可以直接看到:.plt 和 .init.plt 缺一不可,查找不到直接返回 -ENOEXEC。
另外,在 ARM64 的 module.lds.h 链接脚本中,这三个特殊段在默认链接布局中就必须存在。如果模块不是走内核 Kbuild 编译的(比如我们),必须手搓这三个段。
其他 ARM64 差异:
[培训]《冰与火的战歌:Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。