内核模块
1. 前言
本文我们来探讨下是如何让我们的小内核支持与“内核模块”类似的功能的。
严格来说,我们实现的功能只是动态链接,只不过这个动态链接是直接链接到了内核而已,且自身在内核空间执行。为了下文的称呼方便,我们还是以“内核模块”来称呼。
那么首先,回想我们在Linux环境下写内核模块的时候,我们是先写代码的时候把内核头文件也引用进来,然后哐哐一堆内核函数使用,最后进行编译,使用命令插入内核即可。
另外,我们似乎还听说过一个“导出”的功能?基础的导出功能由EXPORT_SYMBOL提供。这个听说也是为了使用其他地方的函数。那么,这个和把符号声明引入进来(典型的就是引用头文件),这两种方式有什么区别呢?而且话说起来,如果我们使用没有被导出的符号,就算我们有声明,那编译结果又会怎样呢?如果能编译通过,能跑起来吗?
为了下文叙述方便,我们把使用外部的符号简化为使用外部的函数,当然,外部的变量属于外部的符号(前提是函数和变量都不用
static修饰)。不过我们一般更多的使用函数。因此下文就直接使用“外部函数”代替“外部符号”,且不使用
static修饰,引用外部的函数通过引用头文件进入。这仅仅是为了叙述方便而已。
关于“引用头文件”和“导出”,我说一下自己的理解:
函数名:本质上就是地址。执行函数,就是跳去那个地址执行代码。因此,编译函数,一定且必须对每个函数确定具体的地址(动态链接可以暂时留空,但后面运行时也需要绑定,为了方便,我们的阐述侧重于静态编译)。
- “引用头文件”:这个玩意儿是为了使用外部的函数
A,告诉编译器,编译(这里是转成汇编文件那个狭小的编译)的时候给A那里留个位置,我现在暂时不知道函数A的地址,后面等链接的时候再补上。整个过程发生在编译(这个“编译”指的是整个流程,不是那个狭小的“编译”,下文的“编译”可以根据上下文推出啥意思,后面就不重复了)阶段,由链接器实现重定位。 - “导出”:内核自身是一个完备的实体,在运行过程而不是编译过程中,需要有第三方模块动态插入,那么第三方模块必须要知道我引用内核函数的地址。也就是说,内核会把某些函数的地址记账下来,到时候有人有需要就来查这个表,然后告诉他那个地址是多少,这样的函数(被记账的函数)就称为是被导出的函数。这样,第三方模块被插入内核,确定未定位的函数地址后才能快乐地运行。对于没有导出的符号,也就是内核没有记录地址,那么就算引用了头文件,或者显式extern某些内核符号,看似来似乎解决了“IDE”给你说的找不到符号的报错提示。但至少在Linux上是编译不通过的。
我们的小内核仅仅实现了插入、移除模块的功能,无法处理模块之间的相互依赖关系,但这对于我们来说已经完全够用。
2. 内核准备 1
也就是说,如果一个内核模块需要运行,只能使用被导出的函数。
我们需要知道被导出函数的地址。我们的内核功能比较少,无法在运行过程中给出函数地址。
在我们的实现中,我们在编译阶段就把被导出函数的地址给保存了下来。
如何保存呢?
// 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}的保存。
保存在哪里呢?我们选择单独找一个段给保存下来,见如下:
// boot/kernel.ld
__ksymtab : {
__start___ksymtab = .;
KEEP(*(__ksymtab))
__stop___ksymtab = .;
}:__ksymtab该段的起始地址作为extern参数,在编译器在编译后给出传递给代码运行。
然后内核在启动的时候,根据起始地址读取这一段的信息就可以构建内核中的符号表信息,下面是示例代码:
// 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库而存在。对于我们自己的模块代码,编译的时候带上这个库。但是注意:汇编代码中只有符号,没有地址。
汇编文件类似如下:
.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_init和module_exit宏,这两个宏会把包裹的函数被放在特定的程序段中,以便后面内核能够顺利找到执行。关于这部分,就不展开解释了。参见 module/mod.ld还有include/core/module.h中。
内核模块由于被插入到内核的
虚存位置的不确定性,必须采用位置无关代码,也就是GCC在编译的加上-fPIC,简单来说就是程序里面的跳转都是相对跳转的,比如跳到相对当前位置后1000个字节处,严禁采用绝对地址跳转。不能像用户程序那样有个基地址然后根据这个跳。
4. 内核准备 2
书接上文,我们编译出来结果是一个需要动态链接的可执行文件。那么相当于我们后面需要在内核中实现动态链接。因此,我们必须要了解如何进行动态链接查找RISCV手册中关于动态链接的部分。
对于如何进行或者说学习动态链接,这里就不献丑了,推荐《CSAPP》第七章-链接
,主要是了解如何处理符号重定位部分。
对于RISCV的具体架构而言,还是老老实实查文档吧。
推荐一个文档链接
https://github.com/riscv-non-isa/riscv-elf-psabi-doc/blob/master/riscv-elf.adoc#relocations
官方的文档报告也有,不过我太懒了,找不到了。。。。
具体而言,我们实现了这个函数:
// 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. 插入模块
这里简单描述下插入模块的步骤就好了,因为前面已经完事具备了。对应的函数名字也都看得懂:
// 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. 移除模块
这个就很简单了,移除相关的管理后,执行该模块的出口函数即可。
static int rmmod(const char *path)
{
struct kmod *k = find_module(path);
if (!k)
return 0;
kmod_rm_global(k);
k->km_exit(); // 模块的出口函数
// 回收虚存等相关资源
...
}