3、内核符号表:EXPORT_SYMBOL的使用、符号查找与依赖、模块间调用实战
模块开发做到一定规模,你一定会遇到这个问题:两个模块之间怎么互相调用函数?
说白了,内核里的每个模块都是独立的二进制对象。默认情况下,A 模块里定义的函数,B 模块根本看不见。这时候就需要内核符号表登场了。
3.1 什么是内核符号表
内核维护着一张全局符号表,里面记录了所有公开的函数和变量的地址。模块加载时,动态链接器会查这张表,把符号引用解析成实际地址。
你可以把它理解成一张「电话簿」——你只有把号码登记上去,别人才能打给你。
| 概念 | 说明 |
|---|---|
| 符号 (Symbol) | 函数名或全局变量名 |
| 符号表 (Symbol Table) | 内核维护的符号名称→地址映射表 |
| 导出 (Export) | 主动将符号加入全局表,允许其他模块使用 |
| 未导出 (Static) | 仅模块内部可见,外部无法引用 |
3.2 EXPORT_SYMBOL 的使用
内核提供了两个宏来导出符号:EXPORT_SYMBOL 和 EXPORT_SYMBOL_GPL。区别很简单——后者只允许 GPL 许可的模块使用。
我个人习惯,如果函数是给内部团队用的,就用 EXPORT_SYMBOL_GPL。如果是通用工具函数,用 EXPORT_SYMBOL 就行。
3.2.1 基本用法
// module_a.c
#include <linux/module.h>
#include <linux/kernel.h>
int my_add(int a, int b)
{
return a + b;
}
EXPORT_SYMBOL(my_add);
int my_sub(int a, int b)
{
return a - b;
}
EXPORT_SYMBOL_GPL(my_sub);
MODULE_LICENSE("GPL");
编译后,用 nm 命令就能看到符号被标记为 T(全局导出):
$ nm module_a.ko | grep my_
0000000000000020 T my_add
0000000000000010 T my_sub
cat /proc/kallsyms | grep my_ 可以实时查看内核中已加载的导出符号。调试时非常有用。
3.2.2 导出全局变量
函数能导出,全局变量当然也可以。不过我得提醒你——直接导出变量意味着其他模块可以随意修改它,风险不小。
// module_a.c
int shared_counter = 0;
EXPORT_SYMBOL(shared_counter);
我在项目中遇到过几次因为导出变量被意外修改导致的诡异 bug。后来我定了个规矩:优先导出访问函数,而不是直接导出变量。
3.3 符号查找与依赖
模块 B 要调用模块 A 的 my_add,怎么做?
// module_b.c
#include <linux/module.h>
#include <linux/kernel.h>
extern int my_add(int a, int b); // 声明外部符号
static int __init my_init(void)
{
int result = my_add(3, 5);
pr_info("3 + 5 = %d\n", result);
return 0;
}
static void __exit my_exit(void)
{
pr_info("module_b exit\n");
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
加载时要注意顺序——必须先加载 module_a,再加载 module_b:
# insmod module_a.ko
# insmod module_b.ko
反过来会怎样?加载失败,内核报错:Unknown symbol my_add。
3.4 模块间调用实战
咱们来一个完整的实战例子。假设有两个模块:
- calc_provider:提供数学运算函数
- calc_user:调用这些函数
3.4.1 calc_provider 模块
// calc_provider.c
#include <linux/module.h>
#include <linux/kernel.h>
int calc_add(int x, int y)
{
return x + y;
}
EXPORT_SYMBOL(calc_add);
int calc_mul(int x, int y)
{
return x * y;
}
EXPORT_SYMBOL_GPL(calc_mul);
static int __init provider_init(void)
{
pr_info("calc_provider loaded\n");
return 0;
}
static void __exit provider_exit(void)
{
pr_info("calc_provider unloaded\n");
}
module_init(provider_init);
module_exit(provider_exit);
MODULE_LICENSE("GPL");
3.4.2 calc_user 模块
// calc_user.c
#include <linux/module.h>
#include <linux/kernel.h>
extern int calc_add(int x, int y);
extern int calc_mul(int x, int y);
static int __init user_init(void)
{
int a = 10, b = 20;
pr_info("calc_add(%d, %d) = %d\n", a, b, calc_add(a, b));
pr_info("calc_mul(%d, %d) = %d\n", a, b, calc_mul(a, b));
return 0;
}
static void __exit user_exit(void)
{
pr_info("calc_user unloaded\n");
}
module_init(user_init);
module_exit(user_exit);
MODULE_LICENSE("GPL");
3.4.3 Makefile
obj-m += calc_provider.o
obj-m += calc_user.o
all:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
3.4.4 加载与验证
# make
# insmod calc_provider.ko
# insmod calc_user.ko
# dmesg | tail -5
[ 1234.5678] calc_provider loaded
[ 1234.5679] calc_add(10, 20) = 30
[ 1234.5679] calc_mul(10, 20) = 200
[ 1234.5680] calc_user unloaded
3.5 符号依赖关系图
下面这张图展示了模块间符号依赖的完整流程:
3.6 查看导出符号的几种方法
| 方法 | 命令 | 说明 |
|---|---|---|
| 查看所有内核符号 | cat /proc/kallsyms |
显示所有已加载符号及其地址 |
| 查看模块导出符号 | nm module.ko | grep T |
只显示全局导出符号 |
| 查看模块依赖 | modinfo module.ko |
显示模块使用的外部符号 |
| 查看模块间依赖关系 | lsmod |
显示模块列表及被其他模块引用的次数 |
Unknown symbol,但 /proc/kallsyms 里明明有这个符号。后来发现是符号的许可证不匹配。Provider 用了 EXPORT_SYMBOL_GPL,而 User 模块的 MODULE_LICENSE 写的是 "Proprietary"。内核直接拒绝了这次引用。
3.7 避坑指南
做模块间调用,有几个坑我踩过,写出来给你参考:
- 循环依赖:模块 A 依赖 B,B 又依赖 A。加载时死锁,谁也起不来。设计阶段就要避免。
- 符号重名:两个模块导出同名函数,后加载的模块会覆盖前一个。调用结果不可预期。
- 卸载顺序:前面说过了,Provider 先于 User 卸载,内核直接 panic。
- 许可证不匹配:
EXPORT_SYMBOL_GPL的符号,非 GPL 模块无法引用。
EXPORT_SYMBOL 导出了。结果另一个团队直接调用了我的内部函数,绕过了我设计的接口层。后来改接口时,他们的模块也跟着崩了。从那以后,我坚持「能导函数就不导变量,能导接口就不导实现」。
3.8 小结
内核符号表是模块间通信的桥梁。EXPORT_SYMBOL 和 EXPORT_SYMBOL_GPL 是登记电话簿的两种方式。模块依赖关系决定了加载和卸载的顺序,搞反了就是内核崩溃。
嗯,这一章的内容就这些。记住一句话:导出要谨慎,依赖要清晰,顺序不能乱。