动态链接实现之后,内核能加载 PIE 可执行文件了。这章把同样的 ELF 重定位思路用到内核自身:实现内核模块,让内核在运行时动态加载/卸载功能。
验证:
/ # insmod hello.ko
hello from module!
/ # rmmod hello
bye from module!
.ko 文件是什么
.ko 是一种特殊的 ELF 文件,类型是 ET_REL(可重定位目标文件)——也就是还没有链接的 .o 文件。
和普通可执行文件的区别:
| 普通 ELF | 内核模块(.ko) |
|---|---|
| 已链接,地址固定 | 未链接,需要重定位 |
| 用户态运行 | 内核态运行 |
main() 入口 |
module_init / module_exit |
| 依赖 libc | 依赖内核导出符号 |
内核符号表(ksyms)
模块代码需要调用内核函数(比如 kprintf)。内核把对外开放的函数注册到一张符号表里:
static ksym_t ksyms[] = {
{ "kprintf", (uint64_t)kprintf },
{ "kmalloc", (uint64_t)kmalloc },
{ "kfree", (uint64_t)kfree },
{ NULL, 0 }
};
uint64_t ksym_lookup(const char *name) {
for (int i = 0; ksyms[i].name; i++)
if (strcmp(ksyms[i].name, name) == 0)
return ksyms[i].addr;
return 0;
}
加载流程
insmod 的工作分四步:
1. 读取 .ko 文件,解析 ELF 头,找到所有 section。
2. 分配内核内存,把 .text、.data、.bss 复制进去。
3. 应用重定位:遍历 .rela.text 等重定位表,把所有对外部符号的引用填上实际地址。
switch (rela_type) {
case R_X86_64_64:
// 绝对地址:*target = sym_addr + addend
*(uint64_t *)target = sym_addr + rela->r_addend;
break;
case R_X86_64_PC32:
case R_X86_64_PLT32:
// 相对地址(函数调用):*target = sym_addr + addend - target_addr
*(int32_t *)target = (int32_t)(sym_addr + rela->r_addend - (uint64_t)target);
break;
}
对于外部符号(SHN_UNDEF),从 ksym_lookup 查地址;模块内部符号,用 section 基址加偏移。
4. 调用 module_init,注册到内核模块链表。
卸载流程
rmmod 找到对应模块,调用 module_exit,然后释放代码和数据内存:
if (mod->exit) mod->exit();
kfree(mod->text);
// 从链表摘除
模块源码
// hello_module.c
void module_init_func(void) {
kprintf("hello from module!\n");
}
void module_exit_func(void) {
kprintf("bye from module!\n");
}
编译时必须用 -mcmodel=kernel,告诉编译器代码运行在内核地址空间(高地址),生成适合 PC32 重定位的调用指令:
musl-gcc -c -fno-pie -mcmodel=kernel -ffreestanding -nostdlib hello_module.c -o hello.ko
一个坑:PC32 重定位的地址限制
x86-64 的 R_X86_64_PC32 重定位只有 32 位有符号偏移,最大覆盖 ±2GB。内核代码通常在 0xFFFF800000000000 附近,如果模块代码也在这个地址范围内,PC32 的相对偏移就在 ±2GB 以内,没问题。
如果模块代码被加载到低地址(比如 0x400000),而内核符号在高地址,偏移就超过 2GB,PC32 溢出,重定位失败。所以必须把模块代码分配到内核地址空间,或者用 -mcmodel=kernel 生成可正确重定位的代码。