把 .so 变成 .ko:一次 ELF 格式的奇妙之旅
缘起
有一段用户空间代码需要在内核里跑。正常做法是用内核构建系统重新编译,但那样得维护一套 Kbuild,而且代码里的用户空间惯用法改起来很痛苦。
于是就冒出一个念头:能不能直接把编译好的二进制文件"转"成 .ko?
直觉上这应该可行——反正 .ko 就是 ELF 可重定位文件(ET_REL),普通的 .o 编译产物也是 ET_REL。格式骨架一样,差的无非是元数据。
真的上手之后,才发现坑比想象中多得多。
背景知识:四种 ELF,两套世界
先理清几个基本概念。ELF 文件有四种类型:
| 类型 |
说明 |
谁处理重定位 |
| ET_REL (.o, .ko) |
可重定位,未链接 |
链接器 / 内核加载器 |
| ET_DYN (.so) |
动态链接库,位置无关 |
ld.so(用户空间) |
| ET_EXEC |
可执行文件 |
内核 ELF 加载器 |
| ET_CORE |
core dump |
— |
关键认知:.so 是 ET_DYN,.ko 是 ET_REL。它们的 ELF 类型就不同。
为什么 .so 直接转不行
.so 文件是 ET_DYN(动态链接库),结构上和 .ko 有本质差异:
1. 内核直接拒绝
内核加载模块的第一步就检查 ELF 类型——必须是 ET_REL,否则直接返回 -ENOEXEC。无论你怎么处理,ET_DYN 的 .so 连第一道门都过不去。这个检查在 kernel/module.c 的 elf_validity_check() 中:
// kernel/module.c: elf_validity_check()
if (memcmp(info->hdr->e_ident, ELFMAG, SELFMAG) != 0
|| info->hdr->e_type != ET_REL // ← 只接受 ET_REL
|| !elf_check_arch(info->hdr)
|| info->hdr->e_shentsize != sizeof(Elf_Shdr))
return -ENOEXEC;
2. 动态链接段太多
.so 里塞满了动态链接基础设施:.dynamic, .dynsym, .dynstr, .hash, .gnu.hash, .got, .got.plt, .plt.got, .rel.dyn, .rel.plt, .interp……这些段包含了各种不必要的信息,对内核模块加载器毫无意义,必须全部删除。
3. 符号表带了版本后缀
.so 的符号名长这样:
puts@GLIBC_2.2.5
malloc@GLIBC_2.0
内核导出符号可没有这些 @ 后缀。用带后缀的名字去查内核符号表,内核在 simplify_symbols() 里调用 resolve_symbol_wait() 做严格的 strcmp 比对,当然查不到。
4. PLT/GOT 引入的重定位一团乱
.so 里的函数调用默认走 PLT(过程链接表),会生成大量 PLT 相关的重定位条目。内核加载器内部的 apply_relocations() 遍历所有 SHT_RELA 段逐个处理重定位,这些 PLT 重定位条目会被逐一处理,但处理逻辑和用户空间 ld.so 完全不同,结果就是错位。
结论:用 gcc -c -fPIC 编译成 .o(ET_REL),直接从 ET_REL 转 ET_REL。
内核加载模块的完整路径
在讲具体坑之前,先沿着内核源码(以 Linux 5.10 为例)把模块加载的完整路径走一遍。后面所有坑的根因都能在这条链路上找到。
第一步:ELF 合法性校验 —— elf_validity_check()
// kernel/module.c
static int elf_validity_check(struct load_info *info)
{
if (info->len < sizeof(*(info->hdr)))
return -ENOEXEC;
if (memcmp(info->hdr->e_ident, ELFMAG, SELFMAG) != 0
|| info->hdr->e_type != ET_REL // ① 必须是 ET_REL
|| !elf_check_arch(info->hdr) // ② 架构必须匹配
|| info->hdr->e_shentsize != sizeof(Elf_Shdr))
return -ENOEXEC;
// ③ 段头表必须在文件范围内
if (info->hdr->e_shoff >= info->len
|| (info->hdr->e_shnum * sizeof(Elf_Shdr) >
info->len - info->hdr->e_shoff))
return -ENOEXEC;
info->sechdrs = (void *)info->hdr + info->hdr->e_shoff;
// ... 后续还会校验段名字符串表索引的有效性
}
三项硬性检查:ELF 魔数、ET_REL 类型、架构匹配。任何一个不过就直接 -ENOEXEC。这就是为什么 .so 不行,同时也意味着我们不能修改 ELF header 把 ET_DYN 改成 ET_REL 了事——架构检查 elf_check_arch() 在 ARM64 上还会验证段结构的完整性。
第二步:内核元数据校验 —— check_modinfo()
// kernel/module.c
static int check_modinfo(struct module *mod, struct load_info *info, int flags)
{
const char *modmagic = get_modinfo(info, "vermagic"); // 从 .modinfo 提取
int err;
if (flags & MODULE_INIT_IGNORE_VERMAGIC)
modmagic = NULL;
if (!modmagic) {
err = try_to_force_load(mod, "bad vermagic"); // 没有 vermagic → 污染内核
if (err)
return err;
} else if (!same_magic(modmagic, vermagic, info->index.vers)) {
pr_err("%s: version magic '%s' should be '%s'\n",
info->name, modmagic, vermagic); // vermagic 不匹配 → 直接拒载
return -ENOEXEC;
}
if (!get_modinfo(info, "intree")) { // 检查是否为树内模块
if (!test_taint(TAINT_OOT_MODULE))
pr_warn("%s: loading out-of-tree module taints kernel.\n", mod->name);
add_taint_module(mod, TAINT_OOT_MODULE, LOCKDEP_STILL_OK);
}
check_modinfo_retpoline(mod, info); // 检查 retpoline
// ... staging, livepatch 等检查
}
vermagic 的比对逻辑在 same_magic() 中:
// kernel/module.c
static inline int same_magic(const char *amagic, const char *bmagic,
bool has_crcs)
{
if (has_crcs) {
amagic += strcspn(amagic, " ");
bmagic += strcspn(bmagic, " ");
}
return strcmp(amagic, bmagic) == 0; // ← 严格字符串比对
}
如果内核启用了 CONFIG_MODVERSIONS,会跳过 vermagic 里第一个空格之前的内容再做比对(因为那部分是 UTS_RELEASE)。否则就是完整的 strcmp。
第三步:架构特定处理 —— module_frob_arch_sections()
在分配模块内存之前,内核调用架构钩子检查和预处理段结构。ARM64 的实现(arch/arm64/kernel/module-plts.c)尤其值得关注:
// arch/arm64/kernel/module-plts.c
int module_frob_arch_sections(Elf_Ehdr *ehdr, Elf_Shdr *sechdrs,
char *secstrings, struct module *mod)
{
unsigned long core_plts = 0;
unsigned long init_plts = 0;
Elf_Shdr *tramp = NULL;
int i;
for (i = 0; i < ehdr->e_shnum; i++) {
if (!strcmp(secstrings + sechdrs[i].sh_name, ".plt"))
mod->arch.core.plt_shndx = i;
else if (!strcmp(secstrings + sechdrs[i].sh_name, ".init.plt"))
mod->arch.init.plt_shndx = i;
else if (!strcmp(secstrings + sechdrs[i].sh_name,
".text.ftrace_trampoline"))
tramp = sechdrs + i;
}
if (!mod->arch.core.plt_shndx || !mod->arch.init.plt_shndx) {
pr_err("%s: module PLT section(s) missing\n", mod->name);
return -ENOEXEC; // ← .plt 和 .init.plt 缺一不可
}
// ... PLT 条目预分配逻辑
}
ARM64 的模块链接脚本同样印证了这一点(arch/arm64/include/asm/module.lds.h):
// arch/arm64/include/asm/module.lds.h
SECTIONS {
.plt 0 : { BYTE(0) }
.init.plt 0 : { BYTE(0) }
.text.ftrace_trampoline 0 : { BYTE(0) }
}
内核构建工具链生成的 .ko 天然带这三个段(大小可以为 0,内容为 1 字节占位)。自己构建的 .ko 如果缺少这些段,ARM64 的 module_frob_arch_sections 直接返回 -ENOEXEC。x86_64 没有这个硬性要求。
第四步:符号解析 —— simplify_symbols()
// kernel/module.c
static int simplify_symbols(struct module *mod, const struct load_info *info)
{
Elf_Shdr *symsec = &info->sechdrs[info->index.sym];
Elf_Sym *sym = (void *)symsec->sh_addr;
const struct kernel_symbol *ksym;
for (i = 1; i < symsec->sh_size / sizeof(Elf_Sym); i++) {
const char *name = info->strtab + sym[i].st_name;
switch (sym[i].st_shndx) {
case SHN_UNDEF: // ← 关键:未定义符号
ksym = resolve_symbol_wait(mod, info, name);
if (ksym && !IS_ERR(ksym)) {
sym[i].st_value = kernel_symbol_value(ksym);
break; // 在内核符号表中找到了
}
if (!ksym && ELF_ST_BIND(sym[i].st_info) == STB_WEAK)
break; // 弱符号,允许不存在
ret = PTR_ERR(ksym) ?: -ENOENT;
pr_warn("%s: Unknown symbol %s (err %d)\n",
mod->name, name, ret);
break; // 找不到 → 失败
case SHN_ABS:
break; // 绝对符号,不需要重定位
default:
secbase = info->sechdrs[sym[i].st_shndx].sh_addr;
sym[i].st_value += secbase; // 段内符号,加上段基址
break;
}
}
return ret;
}
核心逻辑:
shndx == SHN_UNDEF(值为 0)→ 去内核符号表搜索,找到就把 st_value 填成内核地址
shndx == SHN_ABS → 已经是绝对值,不动
- 其他 → 段内定义的符号,st_value 加上所在段的加载基址
所以 UND 符号的 shndx 必须是 0。如果你在转换时把它改成了 SHN_ABS(0xFFF1),它就不会进入 resolve_symbol_wait 分支,内核不会去查符号表,外部引用全部悬空。
第五步:重定位处理 —— apply_relocations()
// kernel/module.c
static int apply_relocations(struct module *mod, const struct load_info *info)
{
int err = 0;
for (i = 1; i < info->hdr->e_shnum; i++) {
unsigned int infosec = info->sechdrs[i].sh_info; // 目标段索引
if (infosec >= info->hdr->e_shnum)
continue;
if (!(info->sechdrs[infosec].sh_flags & SHF_ALLOC))
continue; // 跳过未分配段
if (info->sechdrs[i].sh_type == SHT_REL)
err = apply_relocate(info->sechdrs, info->strtab,
info->index.sym, i, mod);
else if (info->sechdrs[i].sh_type == SHT_RELA)
err = apply_relocate_add(info->sechdrs, info->strtab,
info->index.sym, i, mod); // ← 处理 RELA
if (err < 0)
break;
}
return err;
}
这段逻辑遍历所有段头,找出类型为 SHT_RELA 的段(重定位段),调用架构特定的 apply_relocate_add() 逐条处理。
.rela.gnu.linkonce.this_module 就是在这里被处理的。 内核遍历到这个段时,把 init_module 和 cleanup_module 的最终地址写入 .gnu.linkonce.this_module 段内对应偏移处。这些偏移正是 struct module 中 init/exit 函数指针的位置。
第六步:发起初始化 —— do_init_module()
// kernel/module.c
static noinline int do_init_module(struct module *mod)
{
int ret = 0;
struct mod_initfree *freeinit;
freeinit = kmalloc(sizeof(*freeinit), GFP_KERNEL);
freeinit->module_init = mod->init_layout.base;
do_mod_ctors(mod);
if (mod->init != NULL) // ← 关键判断
ret = do_one_initcall(mod->init); // 通过函数指针调用
if (ret < 0)
goto fail_free_freeinit;
mod->state = MODULE_STATE_LIVE;
// ... uevent, async 同步, 释放 init 内存
}
内核调用 mod->init 这个函数指针。这个指针的值是在第五步的重定位处理中填入的。如果 .rela.gnu.linkonce.this_module 段不存在或偏移量不正确,mod->init 就是 NULL,内核跳过整个初始化流程,不报任何错误。
同样,模块卸载时(kernel/module.c 的 free_module() 路径):
// kernel/module.c: 模块卸载路径
if (mod->exit != NULL)
mod->exit();
mod->exit 也是通过重定位填入的。两个函数指针,两条重定位,缺一不可。
第七步:CRC 校验 —— check_version()
如果内核开启了 CONFIG_MODVERSIONS,每个外部符号引用都要比对 CRC:
// kernel/module.c
static int check_version(const struct load_info *info,
const char *symname,
struct module *mod,
const s32 *crc)
{
Elf_Shdr *sechdrs = info->sechdrs;
unsigned int versindex = info->index.vers;
struct modversion_info *versions;
if (!crc)
return 1; // 内核没提供 CRC,放行
if (versindex == 0)
return try_to_force_load(mod, symname) == 0; // 模块没 __versions 段
versions = (void *)sechdrs[versindex].sh_addr;
num_versions = sechdrs[versindex].sh_size
/ sizeof(struct modversion_info);
for (i = 0; i < num_versions; i++) {
if (strcmp(versions[i].name, symname) != 0)
continue;
if (versions[i].crc == crcval)
return 1; // CRC 匹配
goto bad_version; // 符号名匹配但 CRC 不匹配 → 失败
}
pr_warn_once("%s: no symbol version for %s\n", info->name, symname);
return 1; // 没找到对应条目,警告但放行
}
模块的 __versions 段存储了 struct modversion_info 数组(64 字节每项:CRC + 符号名)。内核逐个比对 CRC 值,不匹配则加载失败。其中 module_layout 这个符号的 CRC 实质上代表了整个 struct module 的结构签名。
以上就是模块从 insmod 到 init 执行经过的全部内核关卡。接下来看转换过程中的具体坑。
转换的核心:两阶段流水线
搞清楚内核加载路径后,我设计了两阶段流水线:
开发机 目标设备
-------- ---------
.o 文件 reference.ko (任意已有模块)
│ │
▼ ▼
[离线转换] ──► .ko (带占位值) ──► [原位修补] ──► .ko (可加载)
阶段一(离线转换)在开发机上完成 ELF 结构层面的转换:删掉不需要的段、保留需要的段、补充内核元数据段、重新索引符号和重定位。所有不确定的内核参数填入占位值。
阶段二(原位修补)在目标设备上运行。找一个目标设备上已有的、能正常加载的 .ko 作为"参考",从中提取所有内核特定参数,覆写占位值。
这个设计的核心思想是:转换工具不需要知道目标内核的任何细节。 vermagic、struct module 大小、字段偏移、CRC——全部由参考 .ko 提供。
踩坑全记录
以下按排查难度排序。
坑 1:段的白名单与黑名单
转换的第一步:决定哪些段保留、哪些丢弃。
必须丢弃的段:
- 所有动态链接相关的(
.dynamic, .dynsym, .dynstr, .hash, .gnu.hash 等共十余个段)
- GOT/PLT 相关段(
.got, .got.plt, .plt.got, .plt.sec)
- 原始的重定位段(
.rel.*, .rela.*)——因为段索引已变,旧的重定位条目引用的段索引失效,必须删除后基于新段表重新生成
必须保留的段:
.text, .data, .rodata, .bss(基本代码和数据)
.init_array, .eh_frame 等辅助段
.comment, .note.* 等信息段
ARM64 上必须额外创建的空段:
从上面 module_frob_arch_sections() 的源码可以看到,ARM64 直接按段名查找 .plt 和 .init.plt,找不到就返回 -ENOEXEC。ARM64 的链接脚本 .lds.h 也明确定义了这三个空段。所以转换阶段必须生成:
.plt:12 字节,SHT_NOBITS,SHF_EXECINSTR | SHF_ALLOC
.init.plt:12 字节,SHT_NOBITS,SHF_EXECINSTR | SHF_ALLOC
.text.ftrace_trampoline:12 字节,SHT_NOBITS,SHF_EXECINSTR | SHF_ALLOC
缺任何一个,内核直接拒载,错误信息只是 "module PLT section(s) missing",不给具体缺少哪个。
原始重定位段必须删除。删除了部分段、重构了段索引后,旧的重定位条目引用的段索引已失效。如果新旧重定位段并存(比如两套 .rela.text),加载器在处理第二条重定位时发现目标位置已有非零值,会报 "Invalid relocation target, existing value is nonzero"。
坑 2:ET_REL 的地址是段相对的,库给你的可能是绝对的
ET_REL 文件里的地址全部是段相对的:
- 符号的
st_value = 该符号在其所属段内的偏移
- 重定位的
r_offset = 在目标段内的偏移位置
比如符号的 value 应该是类似 0x10 的值("函数入口在 .text 段偏移 0x10 处"),绝对不是 0x7f0000001000 这样的虚拟地址。
对应到内核源码,simplify_symbols() 里的 default 分支:
default:
secbase = info->sechdrs[sym[i].st_shndx].sh_addr; // 段的加载基址
sym[i].st_value += secbase; // st_value + 段基址 = 绝对地址
内核假设 st_value 是段相对偏移,然后加上段加载基址得到最终地址。如果你的 st_value 已经是个绝对 VA,再加上段基址就飞到九霄云外了。
但 ELF 解析库在处理 ET_REL 时,某些 API 返回的却是绝对虚拟地址——内部走的是处理 ET_DYN/ET_EXEC 的逻辑分支。
解决方式是把所有符号值和重定位偏移都显式减掉所在段的虚拟基地址。不能依赖"ET_REL 的段 VA 都是 0"的假设。
坑 3:重定位类型里藏着架构前缀
ELF 解析库(这里用的是 LIEF)在表示重定位类型时,把架构信息编码进了类型值的高位:
| 重定位类型 |
标准值 |
LIEF 返回 |
| R_X86_64_64 |
1 |
0x80000001 |
| R_X86_64_PC32 |
2 |
0x80000002 |
| R_AARCH64_ABS64 |
1 |
0x101 |
如果直接把 LIEF 编码的类型值写回 ELF 的 r_info 字段,接收方按标准解码会得到完全不同的数字。用掩码 0x7FFFFFF(低 27 位,对应 ELF 规范中 r_info 的低位布局)剥离架构前缀即可。
坑 4:UND 符号不要碰
内核模块引用的外部符号——_printk, kmalloc, kfree——在原文件里 shndx = 0(SHN_UNDEF)。
回顾上面的 simplify_symbols() 源码:
case SHN_UNDEF:
ksym = resolve_symbol_wait(mod, info, name); // 只有 shndx==0 才走这里
if (ksym && !IS_ERR(ksym)) {
sym[i].st_value = kernel_symbol_value(ksym);
break;
}
内核根据 shndx == SHN_UNDEF 来判断是否需要在全局符号表里搜索。SHN_UNDEF 的值就是 0。
在转换过程中,段的增删导致段索引需要重新映射。写映射逻辑时,很容易写出:
if (orig_shndx > 0 && orig_shndx < SHN_ABS)
映射到新段索引
else
shndx = SHN_ABS // ← shndx==0 落入了这个分支!
shndx = 0 被改写成了 SHN_ABS(0xFFF1)。内核看到 SHN_ABS,直接走 break 分支,st_value 保持不变——对 UND 符号来说就是 0。外部调用全部悬空,内核不会去符号表里找。
教训:shndx 为 0 时必须原样保持 0。一个 if (orig_shndx == 0) 的提前判断就够。
坑 5:空名字的符号不一定是垃圾
这是最反直觉的一个坑。
写符号过滤逻辑时,很自然会跳过"名字为空、value 为 0、size 为 0"的符号。但有一种叫 STT_SECTION 的符号类型——表示"段本身"。它的名字确实是空的,value 也可以是 0,但它是重定位的重要目标。
什么时候重定位会引用 STT_SECTION?
.rodata 里的字符串常量 → 重定位需要 .rodata 段的基址
.text 里的异常处理表(eh_frame)→ 重定位需要 .text 段的基址
- 任何需要段基址作为重定位计算基准的地方
这些重定位通过 shndx 字段关联到 STT_SECTION 符号,再由 STT_SECTION 符号的 shndx 找到目标段,最终由 simplify_symbols() 的 default 分支加上段基址。
如果 STT_SECTION 符号被当成无效条目清理掉了,引用了它的重定位条目就找不到目标,要么指向符号 0(空符号),要么符号索引越界。
教训:符号的生死不能单靠名字和 value 判断。类型为 STT_SECTION 的必须保留,并建立原始段索引到新符号索引的映射表。
坑 6:符号版本后缀
从 .so 文件提取符号时(即使最终不用 .so 做输入,这个坑也值得记),符号名可能带版本后缀:
puts@GLIBC_2.2.5
__cxa_atexit@GLIBC_2.2.5
这是 GNU 符号版本控制机制。内核的 resolve_symbol_wait() 做的是直接 strcmp,@GLIBC_2.2.5 这种后缀当然对不上。在构建输出符号表时,查找 @ 字符并截断就行。
坑 7:vermagic 精确匹配
从 check_modinfo() 和 same_magic() 的源码可以看出,vermagic 做的是严格字符串比对(或跳过第一个空格前缀后的比对)。vermagic 的构成由 include/linux/vermagic.h 的宏拼装决定:
// include/linux/vermagic.h
#define VERMAGIC_STRING \
UTS_RELEASE " " \
MODULE_VERMAGIC_SMP MODULE_VERMAGIC_PREEMPT \
MODULE_VERMAGIC_MODULE_UNLOAD MODULE_VERMAGIC_MODVERSIONS \
MODULE_ARCH_VERMAGIC \
MODULE_RANDSTRUCT
其中每个宏是否展开取决于对应的 CONFIG 选项:
CONFIG_SMP → "SMP "
CONFIG_PREEMPT_BUILD → "preempt "
CONFIG_MODULE_UNLOAD → "mod_unload "
CONFIG_MODVERSIONS → "modversions "
MODULE_ARCH_VERMAGIC → ARM64 上是 "aarch64",x86_64 上是空串
一个典型的 vermagic 长这样:
6.19.11+kali-amd64 SMP preempt mod_unload
注意末尾的 mod_unload 后面可能有一个空格(取决于宏展开时 "mod_unload " 的尾随空格),这个空格也参与比对。
解决方式:不在转换阶段猜测 vermagic,从参考 .ko 的 .modinfo 段中提取完整的 vermagic 值,完整覆写到目标。
坑 8:struct module 的大小和布局不可预测
struct module 是内核在内存里为每个模块维护的数据结构(定义在 include/linux/module.h,几百行的巨型结构体)。它的大小和字段布局完全取决于内核编译配置:
CONFIG_MODULE_UNLOAD → 控制 exit 相关字段的存在
CONFIG_SYSFS → 插入 sysfs 属性字段
CONFIG_KALLSYMS → 增加符号表相关字段
CONFIG_TRACEPOINTS → 插入 tracepoint 字段
- 等等数十个 CONFIG 选项
同一内核版本、不同 defconfig,sizeof(struct module) 可能差几百到上千字节。
如果一个 .ko 的 .gnu.linkonce.this_module 段大小和目标内核不一致,layout_and_allocate() 在分配模块内存时会按内核自己的 sizeof 来布局,大小对不上会导致段覆盖或越界访问。
解决方式:从参考 .ko 复制整个 .gnu.linkonce.this_module 段数据。参考 .ko 本就是用这个内核的 Kbuild 编译出来的,它的 struct module 一定正确。
模块名也在这个结构体里。但名字字段的偏移同样是内核版本决定的:
| 内核版本/架构 |
名字偏移 |
| x86_64 Linux 6.19 |
24 |
| ARM64 Linux 5.10 (Android 12) |
24 |
不硬编码偏移。在参考的 struct module 数据里搜索参考模块自己的名字字符串,定位到名字字段,然后在那里写入新的模块名。
坑 9:.modinfo 的字段规范
.modinfo 是一个嵌入在 ELF 段里的 key=value\0 格式字符串表。内核通过 get_modinfo(info, "字段名") 查找其中的键值对,比如前面看到的 get_modinfo(info, "vermagic")。
必须包含的字段(结合 check_modinfo() 源码和内核约定):
| 字段 |
用途 |
谁使用 |
vermagic |
内核版本 + 编译选项签名 |
check_modinfo() 做严格比对 |
name |
模块名称 |
check_modinfo() 显示 / modprobe |
license |
许可证(如 GPL) |
内核限制 GPL-only 导出符号访问 |
intree |
标记为树内模块 |
check_modinfo() 检查,OOT 模块会污染内核 |
retpoline |
启用 Retpoline 缓解 |
check_modinfo_retpoline() 检查 |
init |
初始化函数名 |
modprobe、用户空间工具 |
cleanup |
清理函数名 |
modprobe、用户空间工具 |
特别需要强调:init=init_module 和 cleanup=cleanup_module 只是 modprobe 等工具的约定,内核本身不解析这两个字段来找入口函数。 内核唯一找 init/exit 的途径是通过 struct module 里的函数指针(见下一个坑)。
坑 10:init_module 为什么没有被调用 —— 核心坑
这是花了最长时间 debug 的问题。
现象:模块加载成功(insmod 返回 0),卸载也成功,没有错误日志。但 init 函数里的代码就是没执行。把 init 的返回值改成 -1,加载居然还是成功——说明 init 根本没被调用到。
回看 do_init_module() 的源码:
// kernel/module.c
if (mod->init != NULL)
ret = do_one_initcall(mod->init);
if (ret < 0) {
goto fail_free_freeinit;
}
mod->init 的值是从哪里来的?不是符号表查找,不是 .modinfo 的 init= 字段。是 apply_relocations() 在处理 .rela.gnu.linkonce.this_module 段时填入的。
在处理重定位时,内核遍历所有 SHT_RELA 段,遇到 .rela.gnu.linkonce.this_module,执行类似以下操作:
r_offset 0x138: 将符号 init_module 的绝对地址写入 .gnu.linkonce.this_module + 0x138
r_offset 0x4c0: 将符号 cleanup_module 的绝对地址写入 .gnu.linkonce.this_module + 0x4c0
这两个偏移量(0x138 和 0x4c0)正是 struct module 内部 init/exit 函数指针的偏移位置。
真实 .ko 的 .rela.gnu.linkonce.this_module 段内容示例(x86_64 Linux 6.19):
Offset Type Symbol
0x0138 R_X86_64_64 init_module
0x04c0 R_X86_64_64 cleanup_module
ARM64 Android 5.10 上的真实数据:
Offset Type Symbol
0x0190 R_AARCH64_ABS64 init_module
0x03c0 R_AARCH64_ABS64 cleanup_module
不同架构、不同内核版本的偏移量不同。但原理一样:内核靠重定位把函数指针填进 struct module,不是靠名字查找。
所以必须在转换阶段生成 .rela.gnu.linkonce.this_module 段,包含指向 init_module 和 cleanup_module 的两条重定位。偏移量先用已知值初始化(如 x86_64 用 0x138/0x4c0,ARM64 用 0x190/0x3c0),然后在目标修补阶段从参考 .ko 的同名重定位段中提取实际偏移,不匹配就修正。
如果没有这个段或其偏移量是错的,mod->init 就是 NULL,内核静默跳过 init,不报任何错误。
坑 11:重定位目标段的判定与重新索引
重定位条目的 r_offset 标记了"在目标段偏移处写入修正值"。但重定位条目本身需要被分组归属到不同的目标段。
可靠的做法是分两级查找:
- 优先用库提供的"重定位所属段" API(如果库支持)
- 库找不到时,用
r_offset(绝对 VA)去匹配所有保留段的 VA 范围,找到包含它的段
如果两步都找不到目标段——比如重定位指向的是已经删掉的段——就跳过,不写入。
每条重定位的 r_info 字段需要重新计算:高 32 位填入符号在新符号表中的索引(不是原始索引),低 32 位填入剥离架构前缀后的重定位类型。
坑 12:重定位段命名和 ELF 结构
输出的重定位段命名规则:.rela + 目标段名。比如目标段是 .gnu.linkonce.this_module,重定位段就是 .rela.gnu.linkonce.this_module。目标段是 .text,重定位段就是 .rela.text。
每个 SHT_RELA 段的 ELF 段头中:
sh_link → 指向 .symtab(符号表段索引)
sh_info → 指向目标段(被重定位的那个段)
sh_type → SHT_RELA(或 SHT_REL,取决于架构)
apply_relocations() 遍历段时依赖 sh_info 找到目标段、sh_link 找到符号表。这两个索引写错一个,内核要么找不到目标段(跳过)、要么读到错误的符号表(重定位算错)。
坑 13:ARM64 的特殊性总结
ARM64 内核模块有一个 x86_64 没有的硬性段依赖。从 ARM64 的 module_frob_arch_sections() 源码可以直接看到:.plt 和 .init.plt 缺一不可,查找不到直接返回 -ENOEXEC。
另外,在 ARM64 的 module.lds.h 链接脚本中,这三个特殊段在默认链接布局中就必须存在。如果模块不是走内核 Kbuild 编译的(比如我们),必须手搓这三个段。
其他 ARM64 差异:
- struct module init/exit 偏移量不同(Android 12 5.10 上 0x190/0x3c0,而非 0x138/0x4c0)
- vermagic 包含
aarch64 后缀
- 重定位类型 LIEF 编码自带 0x100 前缀
- Android 内核的 printk 导出名可能是
_printk 而非 printk
关键经验总结
-
.o 可以转 .ko,.so 不行。 elf_validity_check() 第一行就检查 e_type != ET_REL,.so 是 ET_DYN,连门都进不去。用 gcc -c -fPIC 编译出的 .o 转 .ko 是最干净的路。
-
不要猜测目标内核的任何参数。 vermagic 的拼装受十几个 CONFIG 宏控制,struct module 的布局受几十个 CONFIG 影响。从目标设备上已有的 .ko 提取,比自己猜准确得多。
-
让内核调用 init 的唯一途径是 .rela.gnu.linkonce.this_module 里的重定位。 别被 .modinfo 里的 init= 误导——那是 modprobe 看的。内核在 apply_relocations() 中填充 mod->init,在 do_init_module() 中调用。重定位是唯一的数据通道。
-
空名字符号不一定是垃圾。 STT_SECTION 符号名空但被重定位引用。删了它,段基址引用全错。
-
UND 符号的 shndx 必须是 0。 内核在 simplify_symbols() 里用 case SHN_UNDEF 分发。改成 SHN_ABS 就不会去符号表搜索了。
-
ET_REL 里所有地址都是段相对的。 库返回的绝对 VA 不能直接用,全部减掉段基址。内核在 simplify_symbols() 的 default 分支做 st_value + secbase,它假设 st_value 是段偏移,不是绝对值。
-
重定位类型编码有坑。 LIEF 在标准类型值上加了架构前缀(x86_64 加 0x80000000,ARM64 加 0x100),写回前用 & 0x7FFFFFF 剥掉。
-
struct module 的名字字段偏移不要硬编码。 用参考模块名在参考 struct 数据里搜索定位。
-
ARM64 多三个必需段。 .plt、.init.plt、.text.ftrace_trampoline,缺一个 module_frob_arch_sections() 就报错。不是可选项。
-
修补工具必须是零依赖的。 目标设备可能没有 libstdc++、没有 Python、没有 cmake。纯 C + <elf.h>,一个 C 编译器就能跑,才能真正做到"放到任何设备上都能用"。