02-内核符号导出
什么是内核符号?
内核符号表就是内核中 “名字 → 信息(地址、类型、可见性)” 的映射表。名字通常是内核的函数名或全局变量名,符号表让内核本身与可加载模块(.ko)相互找到并链接这些名字。而表项的名字就是内核符号。
内核符号表存在于哪里?
- 构建时:
vmlinux(未压缩的内核镜像)包含完整符号信息。内核构建后会生成System.map,格式为地址 类型 名称,并通常放在/boot/System.map-$(uname -r)。 - 运行时:内核会把符号信息以可查询的形式暴露到
/proc/kallsyms(如果内核启用了CONFIG_KALLSYMS)。
符号行的格式与常见类型
通过head /proc/kallsyms获取符号表的前几行:
80008000 T stext
80008000 T _text
8000808c t __create_page_tables
80008138 t __turn_mmu_on_loc
80008144 t __fixup_smp
800081ac t __fixup_smp_on_up
800081d0 t __fixup_pv_table
80008224 t __vet_atags
80100000 T _stext
80100000 T __turn_mmu_on
其输出格式为:地址 类型字母 名称
常见类型字母:
- T / t:代码段(text,函数),T 表示全局可见(外部),t 表示本地/静态。
- D / d:已初始化的数据段(全局/本地)。
- B / b:BSS(未初始化全局变量)。
- R:只读数据(rodata)。
- U:未定义(undefined;通常出现在模块,表示对某符号有引用但还未解析)。
对于构建好的内核或者内核模块文件,也可以通过readelf -s vmlinux,readelf -s hello.ko来获取他们的符号表,它的输出内容更为丰富。以前面解释的第一个内核模块为例:
readelf --wide -s simplest_drv.ko
Symbol table '.symtab' contains 42 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 00000000 0 NOTYPE LOCAL DEFAULT UND
1: 00000000 0 SECTION LOCAL DEFAULT 1 .note.gnu.build-id
2: 00000000 0 SECTION LOCAL DEFAULT 2 .text
3: 00000000 0 SECTION LOCAL DEFAULT 3 .init.text
4: 00000000 0 SECTION LOCAL DEFAULT 5 .exit.text
...............
18: 00000000 0 FILE LOCAL DEFAULT ABS simplest_drv.c
19: 00000000 0 NOTYPE LOCAL DEFAULT 3 $a
20: 00000000 32 FUNC LOCAL DEFAULT 3 simplest_drv_init
25: 00000000 28 FUNC LOCAL DEFAULT 5 simplest_drv_exit
...............
36: 00000000 192 OBJECT LOCAL DEFAULT 15 ____versions
37: 00000000 512 OBJECT GLOBAL DEFAULT 17 __this_module
38: 00000000 28 FUNC GLOBAL DEFAULT 5 cleanup_module
39: 00000000 32 FUNC GLOBAL DEFAULT 3 init_module
40: 00000000 0 NOTYPE GLOBAL DEFAULT UND printk
41: 00000000 0 NOTYPE GLOBAL DEFAULT UND __aeabi_unwind_cpp_pr1
| 列 | 含义 |
|---|---|
| Num | 符号的索引号(从0开始) |
| Value | 符号的内存地址(VMA),0表示该符号的地址尚未确定 |
| Size | 符号大小(字节) |
| Type | 符号类型,常见的有:NOTYPE - 未指定类型 OBJECT - 数据对象FUNC - 函数或可执行代码SECTION - 关联到节区FILE - 源文件名 |
| Bind | 符号绑定属性:LOCAL - 局部符号(static)GLOBAL - 全局符号(extern)WEAK - 弱引用符号 |
| Vis | 可见性:DEFAULT - 默认可见规则PROTECTED - 保护可见性HIDDEN - 隐藏符号 |
| Ndx | 关联的SECTION索引,特殊值包括:ABS - 绝对值(不重定位) UND - 未定义(外部引用) COM - 未初始化的公共块 |
| Name | 符号名 |
这里解释一下Ndx,当它为数值时,表示当前符号属于Num这一行对应数值的符号,通常类型是SECTION。例如以上面为例:
20: 00000000 32 FUNC LOCAL DEFAULT 3 simplest_drv_init
我们知道simplest_drv_init是模块初始化函数,它被放到了一个特殊的段里面:初始化段。它的Nxd是3,对应为:
3: 00000000 0 SECTION LOCAL DEFAULT 3 .init.text
这行类型为SECTION,也就是一个段,名称为.init.text,正是内核模块初始化段的名字。
内核符号导出与模块加载
在编译,链接内核模块时如果符号属于外部符号,则Nxd会被标记为未定义(UND),且绑定属性Bind为GLOBAL,在加载内核模块时,就会搜索/proc/kallsysms,以获得它的信息(地址、类型、可见性)。如果未搜索到,则模块加载会失败。
以上面的simplest_drv.ko为例,查看其源码:
#include <linux/module.h>
//加载内核模块后的入口函数
static int __init simplest_drv_init(void)
{
printk(KERN_INFO "Simplest driver init\n");
return 0;
}
//卸载内核模块后的清理函数
static void __exit simplest_drv_exit(void)
{
printk(KERN_INFO "Simplest driver exit\n");
}
module_init(simplest_drv_init);
module_exit(simplest_drv_exit);
可以看出,它使用了一个外部函数(也就是一个符号)printk,对应readelf输出的这一行:
40: 00000000 0 NOTYPE GLOBAL DEFAULT UND printk
Bind为GLOBAL说明是一个全局符号,Ndx为UND说明此符号不在该elf文件(simplest_drv.ko)里面。如果在运行对应内核的设备上执行:
grep "\<printk\>" /proc/kallsyms
801ff718 T printk
同理也可以找到另一个外部符号:
grep "\<__aeabi_unwind_cpp_pr1\>" /proc/kallsyms
8011236c T __aeabi_unwind_cpp_pr1
因此此内核模块在加载时,符号搜索会成功,可以正常加载进来。如果我修改该内核模块源码:
int method_add(int a, int b); //引入外部函数
//加载内核模块后的入口函数
static int __init simplest_drv_init(void)
{
printk(KERN_INFO "Simplest driver init\n");
printk(KERN_INFO "call method_add result:%d\n", method_add(1,2)); //调用外部函数
return 0;
}
添加了两行,引入一个外部的函数,并调用它。编译,重新插入新的内核模块:
insmod simplest_drv.ko
[11323.976697] simplest_drv: Unknown symbol method_add (err 0)
insmod: ERROR: could not insert module simplest_drv.ko: Unknown symbol in module
此时会提示无法识别method_add这个符号,插入失败。如果要让这个模块插入成功,则需要另一个模块导出这个这个符号。例如:
#include <linux/module.h>
int method_add(int a, int b)
{
return a + b;
}
EXPORT_SYMBOL(method_add);
static int __init lowermodule_init(void)
{
printk(KERN_INFO "lowermodule driver init\n");
return 0;
}
static void __exit lowermodule_exit(void)
{
printk(KERN_INFO "lowermodule driver exit\n");
}
module_init(lowermodule_init);
module_exit(lowermodule_exit);
MODULE_LICENSE("GPL");
``
编译这个内核模块,并先插入它。
```shell
insmod lower_module.ko
查看符号是否导出:
grep "method_add" /proc/kallsyms
7f008064 r __kcrctab_method_add [lower_module]
7f00805c r __ksymtab_method_add [lower_module]
7f0080db r __kstrtab_method_add [lower_module]
7f008000 T method_add [lower_module]
符号已出现在/proc/kallsyms中,此时:
insmod simplest_drv.ko
[11778.305071] simplest_drv: no symbol version for method_add
[11778.318088] Simplest driver init
[11778.324599] call method_add result:3
插入内核模块simplest_drv.ko也成功了。
内核模块的依赖和依赖的自动处理
依赖
接着上面的例子,使用lsmod查看当前加载的内核模块:
lsmod
Module Size Used by
simplest_drv 1012 0
lower_module 1325 1 simplest_drv
可以看到lower_module这个内核模块Used by另一个内核模块simplest_drv,如果你先去移除lower_module会提示:
rmmod lower_module
rmmod: ERROR: Module lower_module is in use by: simplest_drv
只有一个内核模块不被任何模块依赖时(需要使用里面的符号),才允许移除它。
依赖的自动处理
如果内核模块之间的依赖关系复杂,插入顶层的内核模块时,需要不断的尝试先插入其依赖的模块,这个工作会非常恐怖,所以早有人遇到这种困扰,提供了一个依照内核模块的依赖关系自动插入内核模块的工具modprobe。
不过使用modprobe有一些前置条件。modprobe它依赖一个数据库工作,这个所谓的数据库通常是位于/lib/modules/$(uname -r)/下的modules.dep和modules.dep.bin。
modules.dep是文本形式的依赖规则,格式为:
extra/lower_module.ko:
extra/upper_module.ko: extra/lower_module.ko
这个格式和Makefile的目标,依赖的格式是一样的:
模块:依赖模块1 依赖模块2 ...
多个依赖模块间以空格隔开。它们的base dir就是/lib/modules/$(uname -r)/。modules.dep.bin是其二进制形式,前者便于人阅读,后者通常给程序使用,处理速度快。
modules.dep和``modules.dep.bin是通过执行命令depmod生成的,它会扫描/lib/modules/$(uname -r)/下的所有ko文件,生成依赖关系。所以要想modprobe`能正常工作,需要:
- 拷贝相关的内核模块至
/lib/modules/$(uname -r)/。 - 执行
depmod -a扫描内核模块生成或更新依赖数据库文件modules.dep和modules.dep.bin。 - 执行
modprobe xxxx,自动插入内核模块。注意:xxxx不能带.ko后缀,这点和insmod和rmmod不同。
生成内核模块依赖数据库文件后,还可以执行modinfo xxx查看内核模块详细信息和依赖关系。
自动卸载依赖
执行modprobe xxx是自动解决依赖的插入内核模块,如果想要卸载模块,并且卸载其依赖只需要执行modprobe -r xxx,在这个过程中,如果其依赖的模块还有其他模块在使用,则不卸载(Used by != 0)。
循环依赖
内核模块是不允许循环依赖的,所谓循环依赖就是A依赖B,B依赖A。根据内核模块的加载过程,就知道循环依赖是不允许的。

浙公网安备 33010602011771号