Linux在RISC-V平台下的模块实现
文章目录
因为上次碰到了模块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类似如下:
| |
这其中的一些机制细节,可以在模块装载流程分析时明了。除此之外,内核模块还可能携带签名,内核模块签名并不携带在ELF文件本身里面,而是计算完签名之后,添加到ELF文件尾部。
内核模块的装载流程
涉及内核模块的系统调用有三个:
- init_module
- finit_module
- delete_module
其中,finit_module是init_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结构体。
| |
在传入load_module时,只有hdr和len字段,被填写为了存放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=VALUE的NULL结尾字符串。 - 从
.modinfo段中可以找到name关键字,将其值作为模块名称保存在load_info->name中。 - 遍历搜索模块ELF文件的符号表以及字符串表,将其索引保存至
index.sym和index.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函数:
| |
回到layout_sections函数,函数一共初始化了两个struct module_layout,分别名为core_layout与init_layout。其中init_layout对应着模块的.init段,后面可以进行释放操作。而core_layout即为模块代码空间本身的layout。struct module_layout的结构如下:
| |
layout_sections函数使用一组mask对所有的section进行两次(是否为.init段),成功匹配的section会被加入对应layout中。这组mask如下:
| |
分别对应于:可执行,只读数据,.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_layout与init_layout所占的空间,然后设置对应的core_layout->base与init_layout->base。根据前面所做的标记得到需要拷贝的section的源地址与目标地址,进行拷贝,很明显SHT_NOBITS标记的section(例如.bss)是不用拷贝的。拷贝完成后,函数更新section header的sh_addr字段,指向拷贝后的地址。
percpu_modalloc
| |
函数很简单,唯一需要记住的就是模块的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_relocate和apply_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宏,这个宏的实现是比较直观的,但是细节很多,可以只抓原理。一般情况下,其定义如下:
| |
可以看到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:
| |
可以看到,前面定义的__ksymtab+#sym段被排序,并合并成了__ksymtab段。同理,其他的section也是类似的方式合并。
符号的查找
前面看到符号是通过find_symbol函数实现的,下面来分析这个函数。这个函数其实很直观了,符合我们的认知,函数简单从两个地方查找symbol:
- 内核镜像本身
- 所有加载到内核的模块中
由于模块和内核镜像储存的符号的机制几乎一样,所以搜索代码是一样的,只是参数的区别。前面看到连接成镜像时,所有的symbol是根据符号名称进行排序的,所以我们可以简单使用二分法对特定的值进行查找,确定整个__ksymtab中是否存在这个符号。
文章作者 crab2313
上次更新 2022-05-14 (c17a111)