动态链接实现之后,内核能加载 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 生成可正确重定位的代码。