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。

注意: 模块依赖是运行时依赖,不是编译时依赖。编译 module_b 只需要头文件声明,不需要 module_a 的源码。但运行时,module_a 必须先于 module_b 加载。

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
关键点: 卸载顺序与加载顺序相反。先卸载 calc_user,再卸载 calc_provider。否则 calc_provider 卸载后,calc_user 里的函数指针变成悬空指针,内核直接 panic。

3.5 符号依赖关系图

下面这张图展示了模块间符号依赖的完整流程:

内核符号依赖关系图 模块间符号依赖流程 模块 A (Provider) 定义: calc_add() EXPORT_SYMBOL(calc_add) 模块 B (User) extern int calc_add() 调用 calc_add(10, 20) 符号引用 内核全局符号表 calc_add → 0xffffffffc0000020 注册符号 解析地址 加载顺序:先加载 Provider,再加载 User 卸载顺序:先卸载 User,再卸载 Provider ⚠ 顺序错误 → Unknown symbol 或 Kernel panic

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 是登记电话簿的两种方式。模块依赖关系决定了加载和卸载的顺序,搞反了就是内核崩溃。

嗯,这一章的内容就这些。记住一句话:导出要谨慎,依赖要清晰,顺序不能乱。