因为上次碰到了模块ABI改变导致的模块加载异常问题,虽然通过分析发现了问题,也认为解决方法是模块跟着ABI走,重新编译,但是认为模块的实现还是要看的。虽然能够想清楚大概的实现方式,但是细节还是要深究一下。很明显内核模块涉及到内核的方方面面,我认为应该从以下几点进行分析:

  • 内核模块的资源管理,地址空间以及内存
  • 内核模块的编译框架
  • 内核模块的relocation机制
  • 内核模块的格式细节,即内核模块这个ELF本身,以RISC-V为例
  • 内核模块与架构相关代码如何交互,以RISC-V为例
  • 内核模块相关的接口,以及内核如何管理内核模块
  • 内核模块的签名机制
  • 内核模块符号导出机制

这次分析是对我综合能力的一次考验,但是感觉花够时间应该能够完成。下面列出个人认为内核模块主要涉及的知识点:

  • ELF文件格式,编译链接原理,relocation原理。
  • 内核地址空间管理,链接脚本
  • Kbuild源码分析能力
  • 特定平台下的Code Model和寻址方式

内核模块的生成机制

内核模块本身实际上是一个relocatable的ELF文件,其实就是常说的.o文件。这一类文件的特点是relocatable,也就是其中有用于relocation的section和符号表。一般情况下,链接器根据.o文件中的relocation段中的relocation和符号表中的内容决定如何填充代码段中的占位,然后生成一个可执行程序。而对于内核模块,内核会自行根据relocatable模块的relocation段和符号表的内容,自行进行relocate操作,本质上就是自行实现了runtime链接。原先在分析kdump工作原理时见到了类似的实现。

事实上,内核模块的生成原理并不复杂,仅仅简单是生成一个relocatable的ELF格式文件,这个文件由两部分组成:

  • 模块源码本身生成的.o文件
  • modpost生成的<module>.mod.c文件编译后的.o文件

在内核头文件中,与模块相关的定义一般有两套,一套用于生成vmlinux,另一套用于模块。这二者之间通过MODULE宏是否有定义进行区分。简单来说,内核用到个各个section在模块中也是适用的,模块的生成是比较简单的,真正复杂的处理在模块装载时体现。在内核的scripts/mod文件夹下,存放着modpost工具的源码,用于编译出modpost工具。modpost工具生成的<module_name>.mod.c类似如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <linux/module.h>
#define INCLUDE_VERMAGIC
#include <linux/build-salt.h>
#include <linux/vermagic.h>
#include <linux/compiler.h>

BUILD_SALT;

MODULE_INFO(vermagic, VERMAGIC_STRING);
MODULE_INFO(name, KBUILD_MODNAME);

__visible struct module __this_module
__section(".gnu.linkonce.this_module") = {
    .name = KBUILD_MODNAME,
    .init = init_module,
#ifdef CONFIG_MODULE_UNLOAD
    .exit = cleanup_module,
#endif
    .arch = MODULE_ARCH_INIT,
};

#ifdef CONFIG_RETPOLINE
MODULE_INFO(retpoline, "Y");
#endif

MODULE_INFO(depends, "wmi");

MODULE_ALIAS("wmi:8C5DA44C-CDC3-46B3-8619-4E26D34390B7");

MODULE_INFO(srcversion, "E60E97D36266BD3884F3208");

这其中的一些机制细节,可以在模块装载流程分析时明了。除此之外,内核模块还可能携带签名,内核模块签名并不携带在ELF文件本身里面,而是计算完签名之后,添加到ELF文件尾部。

内核模块的装载流程

涉及内核模块的系统调用有三个:

  • init_module
  • finit_module
  • delete_module

其中,finit_moduleinit_module的文件描述符形式,可以勉强算作一个。因此,想要理解内核如何装载模块,完成了哪些工作,需要分析init_module函数。之所以称勉强算作一个,是因为finit_module有一个额外的参数,支持传入flags,即:

  • MODULE_INIT_IGNORE_MODVERSIONS
  • MODULE_INIT_IGNORE_VERMAGIC

init_module则不允许。二者的本质都是在做完权限检查(CAP_SYS_MODULE和modules_disabled内核命令行)之后,分配空间存放内核模块ELF文件,然后调用load_module函数。load_modules的参数有三个,其中最关键的是load_info结构体。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
struct load_info {
        const char *name;
        /* pointer to module in temporary copy, freed at end of load_module() */
        struct module *mod;
        Elf_Ehdr *hdr;
        unsigned long len;
        Elf_Shdr *sechdrs;
        char *secstrings, *strtab;
        unsigned long symoffs, stroffs, init_typeoffs, core_typeoffs;
        struct _ddebug *debug;
        unsigned int num_debug;
        bool sig_ok;
#ifdef CONFIG_KALLSYMS
        unsigned long mod_kallsyms_init_off;
#endif
        struct {
                unsigned int sym, str, mod, vers, info, pcpu;
        } index;
};

在传入load_module时,只有hdrlen字段,被填写为了存放ELF文件内容的地址与长度。对模块装载流程的分析转变成了对load_module函数的分析。

合法性检查

合法性检查不多说,主要有如下几点:

  • 模块签名检查,由mod_sig_check函数完成。这里多说一句,模块签名是加在模块ELF文件尾部的,模块最尾部有一个magic string,即一个特殊的字符串,内核通过该字符串确定模块是否带有签名。除此之外模块的ELF文件部分不会因为签名而变动。
  • ELF文件合法性检查,简单检查ELF的文件结构是否合法。
  • 获取模块名字之后,检查其是否在module_blacklist内核命令行参数,如果是,则拒绝加载。

setup_load_info

随后函数调用setup_load_info,从前面看到传入的load_info还是空的,这个函数负责填充它。注意该函数并不是填充所有的字段,而是主要填充index字段,其他部分后面会继续处理。之所以是叫index,是因为其内部字段记录的都是模块ELF文件的section header的索引。函数的主要行为如下:

  • 遍历搜索.modinfo段,并将其索引保存在index.info字段。从这里可以看出这个section的内容为多个KEY=VALUENULL结尾字符串。
  • .modinfo段中可以找到name关键字,将其值作为模块名称保存在load_info->name中。
  • 遍历搜索模块ELF文件的符号表以及字符串表,将其索引保存至index.symindex.str。并将load_info->strtab指向ELF文件的字符串表位置。
  • 遍历搜索.gnu.linkonce.this_module段,并将其索引保存在index.mod中。并将load_info->mod指向该段的首地址。如果前面从.modinfo段中没有找到模块名称,则可以从这个struct module结构体中获取。
  • 如果调用load_module函数时设置了MODULE_INIT_IGNORE_MODVERSIONS标志,则将index.vers设置为0。反之,则遍历查找__versions段,并设置索引。
  • 遍历查找.data..percpu段,并设置index.pcpu索引。

rewrite_section_headers

该函数对模块ELF文件的section header做一些简单处理,包含如下操作:

  • 将第一个section的sh_addr设置为0
  • 将其他所有的section的sh_addr指向其当前位于的虚拟地址
  • unset掉__versions段和.modinfo段的SHF_ALLOC标志

稍微做一下解释,在ELF标准中,一个section的sh_addr属性记录section的第一个字节应该被装载的虚拟地址。内核认为模块的第一个section是特殊的,遍历搜索section时的代码都会跳过第一个,即0号section。最后内核遍历section的代码也会跳过所有没有SHF_ALLOC标志的section,即无视没有标为SHF_ALLOC的section。但是很明显,我们最终没有必要将__versions.modinfo装载至内存中,所以这里unset掉这个标志。

check_modstruct_versions

详见模块版本机制分析。

layout_and_allocate

函数首先调用check_modinfo检查模块的一些基本信息,包括:

  • .modinfo中的vermagic,如果MODULE_INIT_IGNORE_VERMAGIC标志设置时,不检查这个vermagic。否则,当vermagic不匹配时阻止加载。
  • .modinfo中的intree。如果没有找到的话,说明这个模块是out of tree的,照例给个警告
  • .modinfo中的staging。如果找到的话,说明模块处于staging阶段,照例给个警告
  • .modinfo中的livepatch。找到的话,如果内核支持live patch,那么标记一下,并给个警告,否则禁止加载
  • .modinfo中的retpoline。检查模块是否启用retpoline,这个是Spectre V2的检查,目前只有x86有。
  • .modinfo中的license。检查并设置模块的license,如果与GPL2不兼容,则打出一条警告。

随后函数调用一个平台相关的函数module_frob_arch_sections,该函数一般会进行一些架构相关的操作,比如处理GOT和PLT,我们后面分析RISC-V的实现。在CONFIG_STRICT_MODULE_RWX配置选项开启时,函数还会检查是否存在即可写又可执行的section,如果存在即阻止模块加载。

由于模块会对.data..pcpu段进行特殊处理,所以这里unset掉了其SHF_ALLOC标志。同时,函数设置了.data..ro_after_init__jump_label段的SHF_RO_AFTER_INIT标志。

随后函数连续调用了三个功能比较大的函数:

  • layout_sections
  • layout_symtab
  • move_module

其本质上就是准备好接下来需要装载的section的信息,然后申请空间,进行装载,我们拆开来看。

layout_sections

这个函数本质上就是进行最终装载到内存中的sections的布局工作,本质上就是选出需要的section,然后计算出其应该处于的位置,这个位置本质上就是个偏移量。这个操作是ELF文件装载最常见的操作,对于一个section,首先将上一个排放好的section的结尾偏移量做一个对齐操作,即为该section的开头,在将这个开头偏移量加上section的大小,即为section的结尾偏移量。这个操作可以总结成如下helper函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
/* Update size with this section: return offset. */
static long get_offset(struct module *mod, unsigned int *size,
                       Elf_Shdr *sechdr, unsigned int section)
{
        long ret;

        *size += arch_mod_section_prepend(mod, section);
        ret = ALIGN(*size, sechdr->sh_addralign ?: 1);
        *size = ret + sechdr->sh_size;
        return ret;
}

/* Additional bytes needed by arch in front of individual sections */
unsigned int __weak arch_mod_section_prepend(struct module *mod,
                                             unsigned int section)
{
        /* default implementation just returns zero */
        return 0;
}

回到layout_sections函数,函数一共初始化了两个struct module_layout,分别名为core_layoutinit_layout。其中init_layout对应着模块的.init段,后面可以进行释放操作。而core_layout即为模块代码空间本身的layout。struct module_layout的结构如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
struct module_layout {
        /* The actual code + data. */
        void *base;
        /* Total size. */
        unsigned int size;
        /* The size of the executable code.  */
        unsigned int text_size;
        /* Size of RO section of the module (text+rodata) */
        unsigned int ro_size;
        /* Size of RO after init section */
        unsigned int ro_after_init_size;

#ifdef CONFIG_MODULES_TREE_LOOKUP
        struct mod_tree_node mtn;
#endif
};

layout_sections函数使用一组mask对所有的section进行两次(是否为.init段),成功匹配的section会被加入对应layout中。这组mask如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
        static unsigned long const masks[][2] = {
                /*
                 * NOTE: all executable code must be the first section
                 * in this array; otherwise modify the text_size
                 * finder in the two loops below
                 */
                { SHF_EXECINSTR | SHF_ALLOC, ARCH_SHF_SMALL },
                { SHF_ALLOC, SHF_WRITE | ARCH_SHF_SMALL },
                { SHF_RO_AFTER_INIT | SHF_ALLOC, ARCH_SHF_SMALL },
                { SHF_WRITE | SHF_ALLOC, ARCH_SHF_SMALL },
                { ARCH_SHF_SMALL | SHF_ALLOC, 0 }
        };

分别对应于:可执行,只读数据,.data..ro_after_init__jump_label(INIT后只读数据),可写数据。注意每个mask有两部分,前一部分是匹配项,必须匹配上,后一部分是不匹配项,必须不匹配上。这组mask最后会将有ARCH_SHF_SMALL标志的section放到layout最后面,而ARCH_SHF_SMALL由架构自行定义。

函数使用了一个trick,即利用section header中的sh_entsize作为临时区域存储偏移量。这是因为sh_entsize一般是用于保存拥有固定元素大小的类似数组的section的元素大小的字段,而这里处理的section很明显没有这样的section。同时,sh_entsize的MSB一位作为一个标志位使用,存储了该section到底是属于core_layout还是init_layout这一信息。

layout_symtab

该函数仅当CONFIG_KALLSYMS时有定义,否则为空函数。函数工作总结如下:

  • 将模块ELF文件的符号表放到init_layout的末尾
  • 找出core symbol,并在core_layout上预留其对应的位置。这里还预留了core symbol使用的字符串表中字符串的空间
  • 将字符串表放到init_layout末尾
  • init_layout末尾预留一个struct mod_kallsyms的空间

move_module

该函数简单通过module_alloc函数申请core_layoutinit_layout所占的空间,然后设置对应的core_layout->baseinit_layout->base。根据前面所做的标记得到需要拷贝的section的源地址与目标地址,进行拷贝,很明显SHT_NOBITS标记的section(例如.bss)是不用拷贝的。拷贝完成后,函数更新section header的sh_addr字段,指向拷贝后的地址。

percpu_modalloc

1
2
3
4
5
6
7
        mod->percpu = __alloc_reserved_percpu(pcpusec->sh_size, align);
        if (!mod->percpu) {
                pr_warn("%s: Could not allocate %lu bytes percpu data\n",
                        mod->name, (unsigned long)pcpusec->sh_size);
                return -ENOMEM;
        }
        mod->percpu_size = pcpusec->sh_size;

函数很简单,唯一需要记住的就是模块的percpu数据的处理方式。Linux直接通过__alloc_reserved_percpu接口申请了模块需要的percpu数据区域,并保存在struct module结构体中。

find_module_sections

该函数查找特定的section,并初始化struct module的特定字段,比较多,这里不一一列举。

simplify_symbols

这个函数的目的是解决所有没有定义的symbol,也就是st_shndx定义为SHN_UNDEF的符号。简单来说,函数遍历符号表,找到SHN_UNDEF类型的符号,然后要求内核resolve该符号。得到该符号的地址后,函数将其填入symbol的st_value字段内。内核符号的机制后面尽心分析,这里只需要知道要求内核对符号进行了解析,并返回了地址。

除此之外,这个函数还对percpu变量进行了特殊处理。

apply_relocations

该函数遍历所有的section,然后对以下三种进行处理:

  • 有SHF_RELA_LIVEPATCH标志的
  • SHT_REL类型
  • SHT_RELA类型

这里会调用架构相关的函数apply_relocateapply_relocate_add对这个可执行程序根据relocation的类型进行relocate操作。这个操作比较常规,就是linker比较常见的行为,后面分析RISC-V下的实现。总之,relocation完成之后,core_layout上的relocation完成了relocate操作,代码段可以正常执行了,对symbol的引用被指向了正确的地址。

do_module_init

在处理完一些杂项之后,do_module_init函数将完整最后的操作。简单来说,这个函数将会调用mod->init函数指针,也就是模块自己实现init_module函数。之后,完成一些通知操作,并释放init_layout所占用的空间。

这里可以思考一个问题,即mod->init指针是谁设置的?从前面看到mod,即一个struct module类型的变量是被定义在.gnu.linkonce.this_module段中的。整个load_module函数操作过程中,mod指针都是指向这个段的,由于模块ELF是一个relocatable的ELF文件,所以进行relocation时,__this_module上的init指针会被relocation重写,指向init_module装载后的虚拟地址。使得我们调用mod->init时,可以调用模块自己实现的init_module函数。

内核符号管理机制

内核提供了一套机制用于控制模块可以访问的符号。这里有一点需要注意,一旦模块被加载并执行,他是有权力访问整个虚拟地址空间的,内核只能控制其对符号对应地址的请求。可以想象,内核维护了一张表,表中记录了内核向模块导出的接口(符号),以及该符号目前对应的虚拟地址。这张表应该是动态的,因为有KASLR等机制会随机化内核的地址偏移量。

符号的注册

我们最常见的符号注册机制就是EXPORT_SYMBOL宏,这个宏的实现是比较直观的,但是细节很多,可以只抓原理。一般情况下,其定义如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#define ___EXPORT_SYMBOL(sym, sec, ns)                                          \
        extern typeof(sym) sym;                                                 \
        extern const char __kstrtab_##sym[];                                    \
        extern const char __kstrtabns_##sym[];                                  \
        __CRC_SYMBOL(sym, sec);                                                 \
        asm("   .section \"__ksymtab_strings\",\"aMS\",%progbits,1      \n"     \
            "__kstrtab_" #sym ":                                        \n"     \
            "   .asciz  \"" #sym "\"                                    \n"     \
            "__kstrtabns_" #sym ":                                      \n"     \
            "   .asciz  \"" ns "\"                                      \n"     \
            "   .previous                                               \n");   \
        __KSYMTAB_ENTRY(sym, sec)

#endif

#define __KSYMTAB_ENTRY(sym, sec)                                       \
        static const struct kernel_symbol __ksymtab_##sym               \
        __attribute__((section("___ksymtab" sec "+" #sym), used))       \
        __aligned(sizeof(void *))                                       \
        = { (unsigned long)&sym, __kstrtab_##sym, __kstrtabns_##sym }

struct kernel_symbol {
        unsigned long value;
        const char *name;
        const char *namespace;
};
#endif

#ifdef DEFAULT_SYMBOL_NAMESPACE
#include <linux/stringify.h>
#define _EXPORT_SYMBOL(sym, sec)        __EXPORT_SYMBOL(sym, sec, __stringify(DEFAULT_SYMBOL_NAMESPACE))
#else
#define _EXPORT_SYMBOL(sym, sec)        __EXPORT_SYMBOL(sym, sec, "")
#endif

#define EXPORT_SYMBOL(sym)              _EXPORT_SYMBOL(sym, "")
#define EXPORT_SYMBOL_GPL(sym)          _EXPORT_SYMBOL(sym, "_gpl")
#define EXPORT_SYMBOL_NS(sym, ns)       __EXPORT_SYMBOL(sym, "", #ns)
#define EXPORT_SYMBOL_NS_GPL(sym, ns)   __EXPORT_SYMBOL(sym, "_gpl", #ns)

可以看到EXPORT_SYMBOL也是利用section操作实现的。简单来说,当调用EXPORT_SYMBOL时:

  • 生成__kstrtab_##sym__kstrtabns_##sym 两个symbol,其内容位于__ksymtab_strings段中。
  • 定义一个名为 struct kernel_symbol,放入__ksymtab+#sym或者__ksymtab_gpl+#sym段中,这个结构体的内容是symbol位于的地址,以及其两个定义在__ksymtab_strings中的两个字符串。后面看一下KASLR的实现,应该会比较有意思。同样,连接脚本里的合并section操作也是个细节。
  • 如果CONFIG_MODVERSIONS有定义,则__CRC_SYMBOL会为symbol定义一个CRC值,用以验证symbol的版本,后面分析。

回到链接脚本的实现,可以发现与__ksymtab有关的段都定义在RODATA宏里。首先是__ksymtab

1
2
3
4
5
6
        /* Kernel symbol table: Normal symbols */                       \
        __ksymtab         : AT(ADDR(__ksymtab) - LOAD_OFFSET) {         \
                __start___ksymtab = .;                                  \
                KEEP(*(SORT(___ksymtab+*)))                             \
                __stop___ksymtab = .;                                   \
        }                                                               \

可以看到,前面定义的__ksymtab+#sym段被排序,并合并成了__ksymtab段。同理,其他的section也是类似的方式合并。

符号的查找

前面看到符号是通过find_symbol函数实现的,下面来分析这个函数。这个函数其实很直观了,符合我们的认知,函数简单从两个地方查找symbol:

  • 内核镜像本身
  • 所有加载到内核的模块中

由于模块和内核镜像储存的符号的机制几乎一样,所以搜索代码是一样的,只是参数的区别。前面看到连接成镜像时,所有的symbol是根据符号名称进行排序的,所以我们可以简单使用二分法对特定的值进行查找,确定整个__ksymtab中是否存在这个符号。