Skip to content

内核模块

1. 前言

本文我们来探讨下是如何让我们的小内核支持与“内核模块”类似的功能的。

严格来说,我们实现的功能只是动态链接,只不过这个动态链接是直接链接到了内核而已,且自身在内核空间执行。为了下文的称呼方便,我们还是以“内核模块”来称呼。

那么首先,回想我们在Linux环境下写内核模块的时候,我们是先写代码的时候把内核头文件也引用进来,然后哐哐一堆内核函数使用,最后进行编译,使用命令插入内核即可。

另外,我们似乎还听说过一个“导出”的功能?基础的导出功能由EXPORT_SYMBOL提供。这个听说也是为了使用其他地方的函数。那么,这个和把符号声明引入进来(典型的就是引用头文件),这两种方式有什么区别呢?而且话说起来,如果我们使用没有被导出的符号,就算我们有声明,那编译结果又会怎样呢?如果能编译通过,能跑起来吗?

为了下文叙述方便,我们把使用外部的符号简化为使用外部的函数,当然,外部的变量属于外部的符号(前提是函数和变量都不用static修饰)。

不过我们一般更多的使用函数。因此下文就直接使用“外部函数”代替“外部符号”,且不使用static修饰,引用外部的函数通过引用头文件进入。这仅仅是为了叙述方便而已

关于“引用头文件”和“导出”,我说一下自己的理解:

函数名:本质上就是地址。执行函数,就是跳去那个地址执行代码。因此,编译函数,一定且必须对每个函数确定具体的地址(动态链接可以暂时留空,但后面运行时也需要绑定,为了方便,我们的阐述侧重于静态编译)。

  • “引用头文件”:这个玩意儿是为了使用外部的函数A,告诉编译器,编译(这里是转成汇编文件那个狭小的编译)的时候给A那里留个位置,我现在暂时不知道函数A的地址,后面等链接的时候再补上。整个过程发生在编译(这个“编译”指的是整个流程,不是那个狭小的“编译”,下文的“编译”可以根据上下文推出啥意思,后面就不重复了)阶段,由链接器实现重定位。
  • “导出”:内核自身是一个完备的实体,在运行过程而不是编译过程中,需要有第三方模块动态插入,那么第三方模块必须要知道我引用内核函数的地址。也就是说,内核会把某些函数的地址记账下来,到时候有人有需要就来查这个表,然后告诉他那个地址是多少,这样的函数(被记账的函数)就称为是被导出的函数。这样,第三方模块被插入内核,确定未定位的函数地址后才能快乐地运行。对于没有导出的符号,也就是内核没有记录地址,那么就算引用了头文件,或者显式extern某些内核符号,看似来似乎解决了“IDE”给你说的找不到符号的报错提示。但至少在Linux上是编译不通过的。

我们的小内核仅仅实现了插入、移除模块的功能,无法处理模块之间的相互依赖关系,但这对于我们来说已经完全够用。

2. 内核准备 1

也就是说,如果一个内核模块需要运行,只能使用被导出的函数。

我们需要知道被导出函数的地址。我们的内核功能比较少,无法在运行过程中给出函数地址。

在我们的实现中,我们在编译阶段就把被导出函数的地址给保存了下来。

如何保存呢?

c
// include/core/export.h
struct kernel_symbol
{
    const char *name;	// 导出符号名
    void *addr;			// 导出地址
} __attribute__((packed));

/* 导出符号宏 */
#define EXPORT_SYMBOL(sym)          \
	extern typeof(sym) sym;                                          \
    __attribute__((used, section("__ksymtab"))) static const struct  \
    kernel_symbol __ksymtab_##sym = {.name = #sym, .addr = (void *)&sym}

通过EXPORT_SYMBOL函数,{name,addr}会被打包放在__ksymtab节中,这样就实现了符号对应{name,addr}的保存。

保存在哪里呢?我们选择单独找一个段给保存下来,见如下:

c
// boot/kernel.ld
__ksymtab : {
      __start___ksymtab = .;
      KEEP(*(__ksymtab))
      __stop___ksymtab = .;
    }:__ksymtab

该段的起始地址作为extern参数,在编译器在编译后给出传递给代码运行。

然后内核在启动的时候,根据起始地址读取这一段的信息就可以构建内核中的符号表信息,下面是示例代码:

c
// kernel/core/export.h
struct ksym
{
    struct kernel_symbol *ksp;
    hash_node_t node;
};

// kernel/module/module.c
static void kmods_hash_init()
{
    struct ksym *ks = NULL;
    // 初始化哈希表
    hash_init(&Kmods.ht, KMODS_HASN_SIZE, "Kmods");
    // 遍历所有符号,挨个插入到哈希表中
    for (struct kernel_symbol *sym = __start___ksymtab; sym < __stop___ksymtab; sym++) {
        ks = alloc_ksym(sym);
        hash_add_head(&Kmods.ht, ksym_hash(ks), &ks->node);
    }
}

3. 编译模块

由于我们没有实现在内核运行环境上实现编译器,因此必须要借助 Linux 环境手动编译后写入镜像启动。问题来了,模块需要引用被导出的符号,在运行的时候可以导出供编译器使用,但问题就是没有编译器。。

因此,我们的解决方案是:

我们实现了一个python脚本(tools/sym/ksymtab_asm.py),用于在Linux中把内核导出的内核符号形成一个汇编文件,然后编译让其作为一个.so库而存在。对于我们自己的模块代码,编译的时候带上这个库。但是注意:汇编代码中只有符号,没有地址。

汇编文件类似如下:

assembly
.global kmalloc
.global kfree
.global printk
.global gen_disk_read
.global gen_disk_write
.global bio_list_make
.global blk_read
.global blk_write
.global blk_write_count
.global blk_read_count
.global blk_set_private
.global register_block
.global unregister_block
.global get_free_pages
...omit...

结合编写的模块代码,编译后就类似于一个需要动态链接的可执行文件。这就是为什么本文前言中所说“严格来说,我们实现的功能只是动态链接,只不过这个动态链接是直接链接到了内核函数而已“。

模块代码使用特殊的module_initmodule_exit宏,这两个宏会把包裹的函数被放在特定的程序段中,以便后面内核能够顺利找到执行。关于这部分,就不展开解释了。参见 module/mod.ld还有include/core/module.h中。

内核模块由于被插入到内核的虚存位置的不确定性,必须采用位置无关代码,也就是GCC在编译的加上-fPIC,简单来说就是程序里面的跳转都是相对跳转的,比如跳到相对当前位置后1000个字节处,严禁采用绝对地址跳转。不能像用户程序那样有个基地址然后根据这个跳。

4. 内核准备 2

书接上文,我们编译出来结果是一个需要动态链接的可执行文件。那么相当于我们后面需要在内核中实现动态链接。因此,我们必须要了解如何进行动态链接查找RISCV手册中关于动态链接的部分。

对于如何进行或者说学习动态链接,这里就不献丑了,推荐《CSAPP》第七章-链接582922F9,主要是了解如何处理符号重定位部分。

对于RISCV的具体架构而言,还是老老实实查文档吧。

推荐一个文档链接

https://github.com/riscv-non-isa/riscv-elf-psabi-doc/blob/master/riscv-elf.adoc#relocations

官方的文档报告也有,不过我太懒了,找不到了。。。。

具体而言,我们实现了这个函数:

c
// kernel/module/module.c
// 应用重定位
static int kmod_apply_relocations(struct kmod *km) 
{
    ElfParser *p = km->km_parser;
    // 依次处理每一个待定位符号
    for (int i = 0; i < p->rela_count; i++) {
        switch (type) {
        case R_RISCV_JUMP_SLOT: {
            ...
        }
        case R_RISCV_RELATIVE: {
            ...
        }
        case R_RISCV_64:{
            ...
        }
       	...
    }
}

5. 插入模块

这里简单描述下插入模块的步骤就好了,因为前面已经完事具备了。对应的函数名字也都看得懂:

c

// kernel/module/module.c
static int insmod(const char *path)
{
    // 阶段1:ELF解析
    ElfParser parser;
    struct kmod *mod = NULL;
    if (elf_parser_init(&parser, path) < 0)
        return -1;
    if (elf_parse_dynamic_sections(&parser) < 0)
        goto error;

    // 阶段2:模块管理
    mod = kmod_create(path, &parser);
    if (kmod_alloc_memory(mod) < 0)
        goto error;
    if (kmod_load_code(mod) < 0)
        goto error;
    if (kmod_apply_relocations(mod) < 0)
        goto error;
    if (kmod_initialize(mod) < 0)
        goto error;

    kmod_add_global(mod);
    // 清理资源
    elf_parser_destroy(&parser);

    // 执行模块的入口函数
    mod->km_init();
    return 0;

error:
    elf_parser_destroy(&parser);
    kmod_destroy(mod);
    return -1;
}

6. 移除模块

这个就很简单了,移除相关的管理后,执行该模块的出口函数即可。

c

static int rmmod(const char *path)
{
    struct kmod *k = find_module(path);
    if (!k)
        return 0;
    kmod_rm_global(k);
    k->km_exit();	// 模块的出口函数
    
    // 回收虚存等相关资源
    ...
}