从module_init看内核模块
开篇
module_init是linux内核提供的一个宏, 可以用来在编写内核模块时注册一个初始化函数, 当模块被加载的时候, 内核负责执行这个初始化函数. 在编写设备驱动程序时, 使用这个宏看起来理所应当, 没什么特别的, 但毕竟我还是一个有点追求的程序员嘛:P, 这篇文章是我学习module_init相关源码的一个记录, 主要就回答了下面的3个问题, 篇幅略长, 做好准备.
问题1
内核模块是什么?
问题2
内核模块是怎么被加载的?
问题3
内核怎么获取到module_init注册的初始化函数?
注: 以下回答是个人学习总结, 仅供参考.
回答1
编译好内核模块的代码, 会得到一个".ko"文件, 这个就是内核模块了. 实际上, ".ko"就是一个普通的ELF文件, 只不过可以使用insmod让内核去动态加载它. 查阅ELF格式标准可知, 主要有三种类型的ELF文件, 包括:
- relocatable file
- excutable file
- shared object file
以上三种类型的ELF, 基本上可以简单对应编译得到的".o", "a.out", ".so". 这里讨论的".ko"模块文件, 属于relocatable file类型, 可以在系统里找一个内核模块文件验证一下.
junan@ZEN2:/lib/modules/5.19.0-50-generic/kernel/drivers/char$ ls
agp applicom.ko hangcheck-timer.ko hw_random ipmi lp.ko mwave nvram.ko pcmcia ppdev.ko tlclk.ko tpm uv_mmtimer.ko xillybus
junan@ZEN2:/lib/modules/5.19.0-50-generic/kernel/drivers/char$ file lp.ko
lp.ko: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), BuildID[sha1]=98c89bd841e31b1140e61559c0bf312eb5128f5c, not stripped
使用gcc编译一个c文件, 可以得到对应的.o文件, 前面说过.o文件属于relocatable类型的ELF, 如果有多个.o文件, 可以使用链接器把它们"合并"成一个.o, 就像这样:
junan@ZEN2:~$ ls
a.c a.o b.c b.o Desktop Documents Downloads Music Pictures Public snap Templates Videos
junan@ZEN2:~$ file a.o b.o
a.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
b.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
junan@ZEN2:~$ ld -r a.o b.o -o c.o
junan@ZEN2:~$ file c.o
c.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
经过ld链接之后, 得到了"c.o", 这个"c.o"相当于合并了"a.o"和"b.o", 这可以从它们各自包含的符号中看出来:
junan@ZEN2:~$ nm a.o b.o c.o
a.o:
U add
0000000000000000 T call_add
b.o:
0000000000000000 T add
c.o:
000000000000001a T add
0000000000000000 T call_add
同样的道理, 一个设备驱动, 可能包含多个源文件, 但是编译之后最终可以合并成一个".ko"文件. 总结下来, ".ko"也是一种普通的ELF文件, 可以被内核动态加载和卸载.
回答2
一个内核模块, 除了自己实现一些功能外, 通常还要引用其他人提供的api, 包括:
- 内核本身提供的api
- 其他模块提供的api
内核仅仅把".ko"文件读到自己的地址空间中, 是远远不够的, 他要像链接器一样, 帮我们的内核模块正确地处理这些符号引用关系, 并且调用我们使用module_init注册的模块初始化函数. 下面分析一下这部分的内核源码. 我们的".ko"文件是使用insmod命令才加载进内核的, insmod命令实际上是一个符号链接. 和insmod一样, rmmod也指向/bin/kmod, 当kmod被执行的时候, 可以通过args[0]区分出是执行insmod还是rmmod, 或者其他的功能.
junan@ZEN2:~$ which insmod | xargs ls -l
lrwxrwxrwx 1 root root 9 7月 22 23:20 /usr/sbin/insmod -> /bin/kmod
在内核代码中, 专门为模块的加载和卸载提供了两个系统调用, 准确说是三个, 其中两个用于加载模块, 一个用于卸载模块. linux代码中使用SYSCALL_DEFINEx这个宏定义一个系统调用的入口, 其中x代表系统调用的参数个数, 看一下内核代码就可以找到和模块的加载以及卸载相关的syscall函数, 使用正则表达式或者其他工具能很快在内核代码中找到这三个系统调用的定义.
首先是init_module和finit_module:
SYSCALL_DEFINE3(init_module, void __user *, umod,
unsigned long, len, const char __user *, uargs)
{
// ...
return load_module(&info, uargs, 0);
}
SYSCALL_DEFINE3(finit_module, int, fd, const char __user *, uargs, int, flags)
{
// ...
return load_module(&info, uargs, flags);
}
以上两个syscall负责模块的加载, 最终都调用了load_module去真正加载模块.
然后是delete_module:
SYSCALL_DEFINE2(delete_module, const char __user *, name_user,
unsigned int, flags)
{
// ...
}
这三个syscall定义在"kernel/module.c"文件中, 我使用的内核版本是5.4.250, 下面可以写一个什么都不做的内核模块, 通过gdb调试的方法, 看一下这几个syscall是怎么被调用的. 环境的准备包括:
- qemu: 启动编译好的内核镜像, 以及gdb server, 等待gdb的连接
- rootfs: 内核正常启动, 需要一个根文件系统, 使用busybox制作
- kernel Image: 编译好的内核镜像
- ko文件: 编译好的没有实际功能的内核模块
关于怎么建立内核的调试环境, 会在其他文章中说明, 这里仅通过调试内核的方法, 记录一下以上三个syscall的调用过程. 当你还不知道insmod和rmmod需要使用到这三个系统调用, 怎么样才能知道这两个命令依赖什么系统调用呢? 答案是可以使用strace去定位一个进程运行过程中用到了哪些syscall, 比如, 我的系统里有一个名字叫做lp的ko模块:
junan@ZEN:~$ lsmod | grep lp
lp 28672 0
drm_display_helper 184320 1 i915
cec 81920 2 drm_display_helper,i915
drm_kms_helper 200704 2 drm_display_helper,i915
先把这个模块卸载, 看看使用了什么syscall:
junan@ZEN:~$ sudo strace rmmod lp
...
...
close(3) = 0
openat(AT_FDCWD, "/sys/module/lp/refcnt", O_RDONLY|O_CLOEXEC) = 3
read(3, "0\n", 31) = 2
read(3, "", 29) = 0
close(3) = 0
delete_module("lp", O_NONBLOCK) = 0
exit_group(0) = ?
+++ exited with 0 +++
能看到倒数第3行, 调用了delete_module.
再重新加载这个lp模块:
junan@ZEN:/usr/lib/modules/5.19.0-50-generic/kernel/drivers/char$ sudo strace insmod lp.ko
...
...
getcwd("/usr/lib/modules/5.19.0-50-generic/kernel/drivers/char", 4096) = 55
newfstatat(AT_FDCWD, "/usr/lib/modules/5.19.0-50-generic/kernel/drivers/char/lp.ko", {st_mode=S_IFREG|0644, st_size=72553, ...}, 0) = 0
openat(AT_FDCWD, "/usr/lib/modules/5.19.0-50-generic/kernel/drivers/char/lp.ko", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1", 6) = 6
lseek(3, 0, SEEK_SET) = 0
newfstatat(3, "", {st_mode=S_IFREG|0644, st_size=72553, ...}, AT_EMPTY_PATH) = 0
mmap(NULL, 72553, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f4675dac000
finit_module(3, "", 0) = 0
munmap(0x7f4675dac000, 72553) = 0
close(3) = 0
exit_group(0) = ?
+++ exited with 0 +++
倒数第5行, finit_module被调用, 用来加载lp模块. 使用这种方式, 先分析一下执行过程, 能够帮助你大致确定它是怎么实现的. 接下来开始调试内核, 看一下syscall的调用过程:
以下是对视频中调试过程的详细记录:
-
使用qemu加载了一个编译好的内核镜像.
- 使用arm平台
- 挂载本机目录到qemu虚拟机, 目录下包含一个等待测试的ko模块
- 开启调试选项, qemu启动之后等待外部gdb的连接
-
启动vscode远程调试的配置, 开始调试内核代码
- 先关闭所有断点, 加载和卸载testko模块, 得到正确的输出
- 之后开启断点, 分别break在init_module和finit_module两个syscall上
-
重新插入testko模块
- init_module系统调用上的断点命中
- 调用load_module函数
- 加载模块到内核
- 完成链接, 处理符号的引用关系
- 调用testko注册的初始化函数, 得到初始化函数的输出
所以, 内核模块在加载时, 需要使用init_module/finit_module系统调用, 经syscall进入内核之后, 内核会把我们的模块加载到自己的地址空间中, 然后完成原本链接器需要做的工作, 这时, 模块中引用的其他符号, 已经得到了真实的地址, 在模块加载的最后阶段, 内核调用do_one_initcall去调用我们注册的模块初始化函数. 以上就是内核模块加载的基本过程, 模块卸载的过程类似.
回答3
先找到module_init宏的实现代码, 在include/linux/module.h文件中, 能找到这个宏的定义:
#ifndef MODULE
/**
* module_init() - driver initialization entry point
* @x: function to be run at kernel boot time or module insertion
*
* module_init() will either be called during do_initcalls() (if
* builtin) or at module insertion time (if a module). There can only
* be one per module.
*/
#define module_init(x) __initcall(x);
/**
* module_exit() - driver exit entry point
* @x: function to be run when driver is removed
*
* module_exit() will wrap the driver clean-up code
* with cleanup_module() when used with rmmod when
* the driver is a module. If the driver is statically
* compiled into the kernel, module_exit() has no effect.
* There can only be one per module.
*/
#define module_exit(x) __exitcall(x);
#else /* MODULE */
/*
* In most cases loadable modules do not need custom
* initcall levels. There are still some valid cases where
* a driver may be needed early if built in, and does not
* matter when built as a loadable module. Like bus
* snooping debug drivers.
*/
#define early_initcall(fn) module_init(fn)
#define core_initcall(fn) module_init(fn)
#define core_initcall_sync(fn) module_init(fn)
#define postcore_initcall(fn) module_init(fn)
#define postcore_initcall_sync(fn) module_init(fn)
#define arch_initcall(fn) module_init(fn)
#define subsys_initcall(fn) module_init(fn)
#define subsys_initcall_sync(fn) module_init(fn)
#define fs_initcall(fn) module_init(fn)
#define fs_initcall_sync(fn) module_init(fn)
#define rootfs_initcall(fn) module_init(fn)
#define device_initcall(fn) module_init(fn)
#define device_initcall_sync(fn) module_init(fn)
#define late_initcall(fn) module_init(fn)
#define late_initcall_sync(fn) module_init(fn)
#define console_initcall(fn) module_init(fn)
/* Each module must use one module_init(). */
#define module_init(initfn) \
static inline initcall_t __maybe_unused __inittest(void) \
{ return initfn; } \
int init_module(void) __copy(initfn) __attribute__((alias(#initfn)));
/* This is only required if you want to be unloadable. */
#define module_exit(exitfn) \
static inline exitcall_t __maybe_unused __exittest(void) \
{ return exitfn; } \
void cleanup_module(void) __copy(exitfn) __attribute__((alias(#exitfn)));
#endif
可以看到根据是否定义了MODULE, module_init有两个不同的实现, 对于驱动程序的内核模块来说, 走到的是#else分支的定义, 这一点能够从编译ko时详细的编译命令中确认:
junan@ZEN:~/Documents/github/blogcodes/01-testko$ ARCH=$ARCH CROSS_COMPILE=$CROSS_COMPILE KDIR=$KDIR make -nB V=1
...
arm-linux-gnueabihf-gcc -Wp,-MD,/home/junan/Documents/github/blogcodes/01-testko/.testko.o.d -nostdinc -isystem /usr/lib/gcc-cross/arm-linux-gnueabihf/11/include -I../arch/arm/include -I./arch/arm/include/generated -I../include -I./include -I../arch/arm/include/uapi -I./arch/arm/include/generated/uapi -I../include/uapi -I./include/generated/uapi -include ../include/linux/kconfig.h -include ../include/linux/compiler_types.h -D__KERNEL__ -mlittle-endian -Wall -Wundef -Werror=strict-prototypes -Wno-trigraphs -fno-strict-aliasing -fno-common -fshort-wchar -fno-PIE -Werror=implicit-function-declaration -Werror=implicit-int -Werror=return-type -Wno-format-security -std=gnu89 -fno-dwarf2-cfi-asm -fno-ipa-sra -mabi=aapcs-linux -mfpu=vfp -funwind-tables -marm -Wa,-mno-warn-deprecated -D__LINUX_ARM_ARCH__=7 -march=armv7-a -msoft-float -Uarm -fno-delete-null-pointer-checks -Wno-frame-address -Wno-format-truncation -Wno-format-overflow -Wno-address-of-packed-member -O2 -fno-allow-store-data-races -Wframe-larger-than=1024 -fstack-protector-strong -Wimplicit-fallthrough -Wno-unused-but-set-variable -Wno-unused-const-variable -fomit-frame-pointer -fno-var-tracking-assignments -g -gdwarf-4 -Wdeclaration-after-statement -Wvla -Wno-pointer-sign -Wno-stringop-truncation -Wno-zero-length-bounds -Wno-array-bounds -Wno-stringop-overflow -Wno-restrict -Wno-maybe-uninitialized -fno-strict-overflow -fno-merge-all-constants -fmerge-constants -fno-stack-check -fconserve-stack -Werror=date-time -Werror=incompatible-pointer-types -Werror=designated-init -fmacro-prefix-map=../= -Wno-packed-not-aligned -DMODULE -DKBUILD_BASENAME='\''"testko"'\'' -DKBUILD_MODNAME='\''"testko"'\'' -c -o /home/junan/Documents/github/blogcodes/01-testko/testko.o /home/junan/Documents/github/blogcodes/01-testko/testko.c
...
从上面截取的编译命令中, 应该能找到"-DMODULE", 虽然有点多, 但仔细看还是能找到的哈, 实在没看到可以Ctrl+F搜索一下. gcc的-D参数相当于帮你在代码里#define了一个宏, 所以, 在包含module.h头文件时, module_init确实会走到#else那个分支, 在这个分支里, module_init定义了一个static inline的函数, 并且, 声明了一个名字叫做init_module的函数, 千万不要把这个函数名和之前视频里调试的syscall名字搞混了:
int init_module(void) __copy(initfn) __attribute__((alias(#initfn)));
注意, 这个函数带了一个alias属性(详细介绍, 参考官方文档), 这样init_module函数就变成了我们传递进来的initfn函数的别名, 也就是说如果调用init_module, 实际上会调用initfn. 接下来的问题是, 加载模块的时候, 内核如何能够得到模块初始化函数的地址呢? 你如果自己编译一个内核模块代码, 会发现编译完成之后, 除了".ko"还会生成很多其他东西, 比如: xxx.mod.c, 内核的构建系统给你生成了一个c文件, 看一下里面的内容:
junan@ZEN:~/Documents/github/blogcodes/01-testko$ cat testko.mod.c
#include <linux/build-salt.h>
#include <linux/module.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, "");
这个文件中定义了一个struct module类型的变量__this_module, 并且.init成员已经被填上了模块初始化函数的并名"init_module", .exit成员也是这样. 这样当内核把模块载入自己的地址空间, 完成链接器的工作之后, 这个.init字段就指向真实的模块初始化函数地址了, 之前的调试视频里可以看到有这样的判断:
...
if(mod->init != NULL)
ret = do_one_initcall(mod->init);
...
这里的"mod"是一个struct module*, 它实际上就是xxx.mod.c中的__this_module, 看一下代码:
static struct module *layout_and_allocate(struct load_info *info, int flags)
{
struct module *mod;
unsigned int ndx;
int err;
...
/* Determine total sizes, and put offsets in sh_entsize. For now
this is done generically; there doesn't appear to be any
special cases for the architectures. */
layout_sections(info->mod, info);
layout_symtab(info->mod, info);
/* Allocate and move to the final place */
err = move_module(info->mod, info);
if (err)
return ERR_PTR(err);
/* Module has been copied to its final place now: return it. */
mod = (void *)info->sechdrs[info->index.mod].sh_addr;
kmemleak_load_module(mod, info);
return mod;
}
总结下来, 关于内核怎么获取到我们注册的初始化函数这个问题:
- 实现模块初始化函数
- 使用module_init宏传递上述初始化函数
- 这个宏会声明一个名字叫做"init_module"的函数, 这个函数是真实的模块初始化函数的别名
- 编译模块时, 内核的构建系统生成xxx.mod.c文件, 在这个文件里
- 定义一个struct module类型的变量, __this_module
- 用"init_module"填充.init成员
- 顺便说一下, THIS_MODULE宏实际上就会展开成__this_module
- 内核载入模块
- 做一些链接器的工作(这个地方实际上有点复杂, 之后有需要可以仔细研究, 暂时就理解为内核帮你把引用的一些符号的地址都算好了, 并在引用的地方填上正确的值)
- 获取到__this_module的地址, 判断.init成员是否为空, 不为空就调用模块初始化函数
注: 当module_init宏的定义走到第一个分支时, 之后再写一篇讨论一下.
结尾
这篇文章主要讨论了3个问题:
- 内核模块是什么?
- 模块是怎么被内核加载的?
- 内核是怎么找到模块初始化函数的?
读完之后, 你应该对这3个问题有了自己的体会. 除此之外, 你还应该解到一些的技术, 它们能够帮你找到一些问题的答案:
- strace跟踪系统调用
- 使用qemu+gdb调试内核源码
- gcc的一些扩展语法, 比如给函数或者变量加属性
第1点, 比较简单, 就是一个命令的使用问题, 但是背后的原理应该不简单;
第2点, 是一个环境搭建的问题, 有时间可以发个视频详细介绍一下;
第3点, 读源码的过程中碰到了, 不懂的话查一下官方手册, 写一点代码验证一下, 就知道怎么用了. 实际上, 除了attribute, 还有很多方式能够告诉编译器, 你要做什么.
建了个QQ群: 838923389. 有想法的老铁可以加一下, 一起交流linux内核的使用和学习经验. 后续也会在b站发一些技术视频, 老铁们觉得有需要可以先关注一下, 视频和文章肯定会给各位带来一些启发和帮助.