第5章:内核符号(Kernel Symbols)
一、导读
5.1 本章学习目标
第5章讲解Linux内核的符号管理机制。在内核开发中,符号(函数名和全局变量名)是模块间通信的基础。内核通过符号表组织所有导出的符号,模块之间通过符号引用实现功能调用。理解符号管理机制,对于编写内核模块、调试内核问题、以及分析内核行为都至关重要。
本章的核心学习目标包括:
- 理解内核符号表的结构:内核如何组织和管理所有导出的符号
- 掌握符号导出的机制:
EXPORT_SYMBOL和EXPORT_SYMBOL_GPL宏的作用 - 了解模块的加载过程:
insmod、rmmod、modprobe的工作原理 - 理解内核版本控制机制:模块如何检查与内核版本的兼容性
- 掌握/proc/kallsyms的使用:如何查看内核所有符号及其地址
- 了解模块的依赖管理:模块之间的依赖关系如何维护
5.2 为什么本章如此重要
内核符号管理是Linux内核模块化设计的基础。没有符号表,模块就无法引用内核核心或其他模块中的函数和变量,整个模块化架构将不复存在。
对于内核开发者而言,理解符号导出机制决定了你编写的函数能否被其他模块使用。对于系统管理员而言,理解模块加载和依赖管理是维护系统稳定性的基础。对于安全研究人员而言,/proc/kallsyms提供了内核代码布局的完整视图,是分析内核漏洞的重要工具。
5.3 核心问题引导
在阅读本章时,请带着以下问题思考:
内核模块如何找到它需要调用的内核函数的地址?
EXPORT_SYMBOL和EXPORT_SYMBOL_GPL有什么区别?为什么要区分?
为什么模块编译时需要记录内核版本信息?加载时如何检查版本兼容性?
/proc/kallsyms中的地址在什么情况下会被隐藏(显示为0)?
modprobe和insmod有什么区别?为什么推荐使用modprobe?
模块的依赖关系是如何自动检测和管理的?
二、核心概念详解
2.1 内核符号表
内核符号表是一个数据结构,记录了所有导出符号的名称和地址。当模块被加载时,加载器通过符号表解析模块中未定义的符号引用。
/* 内核符号表的核心数据结构 */
/* include/linux/export.h */
/* 符号表条目 */
struct kernel_symbol {
unsigned long value; /* 符号的地址 */
const char *name; /* 符号的名称 */
};
/* 全局符号表 */
/* kernel/kallsyms.c */
extern const struct kernel_symbol __start___ksymtab[];
extern const struct kernel_symbol __stop___ksymtab[];
/* GPL 符号表 */
extern const struct kernel_symbol __start___ksymtab_gpl[];
extern const struct kernel_symbol __stop___ksymtab_gpl[];
/* 符号查找函数 */
const struct kernel_symbol *find_symbol(const char *name,
struct module **owner,
unsigned int *crc,
bool gplok,
bool warn);# 查看内核导出的符号
cat /proc/kallsyms | head -20
# 0000000000000000 T _text
# ffffffff81000000 T _stext
# ffffffff81000190 T __x86_return_thunk
# ffffffff81001000 t async_page_fault
# ...
# 查看特定符号
cat /proc/kallsyms | grep "schedule$"
# ffffffff81c01234 T schedule
# 查看模块导出的符号
cat /proc/kallsyms | grep "\[ext4\]" | head
# ffffffffc0123000 T ext4_fill_super [ext4]
# ffffffffc0124000 T ext4_init_inode_table [ext4]2.2 符号导出
内核通过EXPORT_SYMBOL系列宏控制哪些符号可以被模块使用。未导出的符号只能在内核核心代码内部使用。
/* 符号导出的宏定义 */
/* include/linux/export.h */
/* 导出符号(任何模块可用) */
#define EXPORT_SYMBOL(sym) \
__EXPORT_SYMBOL(sym, "", "")
/* 导出符号(仅GPL兼容模块可用) */
#define EXPORT_SYMBOL_GPL(sym) \
__EXPORT_SYMBOL(sym, "_gpl", "GPL")
/* 使用示例 */
/* kernel/sched/core.c */
void schedule(void) {
/* ... 调度逻辑 ... */
}
EXPORT_SYMBOL(schedule); /* 任何模块都可以调用 schedule */
/* mm/memory.c */
int get_user_pages(unsigned long start, unsigned long nr_pages,
unsigned int gup_flags, struct page **pages,
struct vm_area_struct *vmas) {
/* ... */
}
EXPORT_SYMBOL_GPL(get_user_pages); /* 仅 GPL 模块可用 */
/*
* 为什么区分 GPL 和非 GPL?
*
* EXPORT_SYMBOL: 任何许可证的模块都可以使用
* EXPORT_SYMBOL_GPL: 只有声明 GPL 兼容许可证的模块可以使用
*
* 这是 Linux 社区保护 GPL 代码的一种机制。
* 如果模块想使用 GPL 符号,必须声明 GPL 兼容许可证:
* MODULE_LICENSE("GPL");
*
* 否则,加载模块时会报错:
* "module: symbol xxx is GPL-only, but module has non-GPL license"
*/# 查看内核导出了多少符号
cat /proc/kallsyms | wc -l
# 通常超过 100,000 个符号
# 查看哪些是 GPL 符号
# (需要通过源码或 System.map 区分)
grep "EXPORT_SYMBOL_GPL" kernel/sched/core.c2.3 模块的加载过程
当使用insmod或modprobe加载模块时,内核执行以下步骤:
1. 读取模块文件(.ko)
↓
2. 检查模块格式和版本信息
↓
3. 分配内存,将模块代码和数据复制到内核空间
↓
4. 解析模块中的符号引用
├── 查找内核导出的符号
├── 查找已加载模块导出的符号
└── 修改模块代码中的符号地址(重定位)
↓
5. 执行模块的初始化函数(module_init)
↓
6. 将模块添加到已加载模块链表/* 内核模块加载的核心结构 */
/* include/linux/module.h */
struct module {
enum module_state state; /* 模块状态 */
struct list_head modules; /* 全局模块链表 */
/* 符号表 */
const struct kernel_symbol *syms;
unsigned int num_syms;
/* GPL 符号表 */
const struct kernel_symbol *gpl_syms;
unsigned int num_gpl_syms;
/* 模块使用的 GPL 符号(从其他模块导入的) */
const unsigned long *gpl_crcs;
/* 初始化函数 */
int (*init)(void);
/* 模块核心区域 */
struct module_layout core_layout;
/* 模块信息 */
const char *name;
struct module_kobject mkobj;
struct module_param_attrs *mp;
/* 模块许可证 */
const char *license;
const char *version;
const char *srcversion;
/* 依赖管理 */
struct module_ref __percpu *refptr;
struct module_kobject kobj;
/* ... */
};
/* 模块状态 */
enum module_state {
MODULE_STATE_LIVE, /* 正常运行 */
MODULE_STATE_COMING, /* 正在加载 */
MODULE_STATE_GOING, /* 正在卸载 */
MODULE_STATE_UNFORMED, /* 还未完全形成 */
};/* 一个简单的内核模块,展示符号导出 */
/* mylib.c */
#include <linux/module.h>
#include <linux/kernel.h>
MODULE_LICENSE("GPL");
/* 导出的函数 */
int my_add(int a, int b) {
return a + b;
}
EXPORT_SYMBOL(my_add);
/* 导出的 GPL 函数 */
int my_multiply(int a, int b) {
return a * b;
}
EXPORT_SYMBOL_GPL(my_multiply);
/* 导出的变量 */
int my_global_counter = 0;
EXPORT_SYMBOL(my_global_counter);
static int __init mylib_init(void) {
printk(KERN_INFO "mylib loaded\n");
return 0;
}
static void __exit mylib_exit(void) {
printk(KERN_INFO "mylib unloaded\n");
}
module_init(mylib_init);
module_exit(mylib_exit);
/* myuser.c - 使用 mylib 导出的符号 */
#include <linux/module.h>
#include <linux/kernel.h>
MODULE_LICENSE("GPL");
/* 声明外部符号 */
extern int my_add(int a, int b);
extern int my_multiply(int a, int b);
extern int my_global_counter;
static int __init myuser_init(void) {
int result = my_add(3, 4);
printk(KERN_INFO "my_add(3, 4) = %d\n", result);
result = my_multiply(5, 6);
printk(KERN_INFO "my_multiply(5, 6) = %d\n", result);
my_global_counter++;
printk(KERN_INFO "counter = %d\n", my_global_counter);
return 0;
}
static void __exit myuser_exit(void) {
printk(KERN_INFO "myuser unloaded\n");
}
module_init(myuser_init);
module_exit(myuser_exit);2.4 内核版本控制
Linux内核使用CRC(循环冗余校验)机制确保模块与内核版本的兼容性。每个导出的符号都有一个CRC值,模块编译时记录依赖的符号CRC,加载时进行校验。
/* 版本控制机制 */
/* include/linux/vermagic.h */
/* 当 CONFIG_MODVERSIONS 启用时 */
/* 每个导出符号都有一个 CRC 值 */
/* 模块编译时,生成版本信息 */
/* .mod.c 文件(自动生成) */
/*
* static const char __module_depends[]
* __used __section("__depends") = "mylib";
*
* static const struct modversion_info __versions[]
* __used __section("__versions") = {
* { 0x12345678, "my_add" },
* { 0xabcdef01, "my_multiply" },
* };
*/
/* 加载时检查:
* 1. 模块记录的符号CRC
* 2. 与内核当前导出的符号CRC比较
* 3. 如果不匹配,拒绝加载
*
* 这确保了模块编译时的内核接口
* 与加载时的内核接口一致
*/# 查看模块的版本信息
modinfo mylib.ko
# filename: /path/to/mylib.ko
# license: GPL
# srcversion: ABCDEF1234567890ABCDEF0
# depends:
# vermagic: 5.15.0-xx-generic SMP mod_unload
# 查看模块的依赖关系
modinfo ext4
# depends: jbd2,mbcache
# 查看模块的符号CRC
modinfo --dump-modversions mylib.ko
# 0x12345678 my_add
# 0xabcdef01 my_multiply2.5 /proc/kallsyms
/proc/kallsyms是内核提供给用户空间的符号表视图。它列出了内核中所有符号的名称、地址和类型。
# /proc/kallsyms 的格式
# 地址 类型 名称 [模块名]
# 类型说明:
# T/t - 代码段(Text),大写表示全局符号
# D/d - 已初始化数据段(Data)
# B/b - BSS段(未初始化数据)
# R/r - 只读数据段(Read-only)
# A - 绝对地址
# U - 未定义(通常在模块中)
# 示例
cat /proc/kallsyms | head -10
# ffffffff81000000 T _stext
# ffffffff81000190 T __x86_return_thunk
# ffffffff81001000 t async_page_fault
# ffffffff81001000 T do_async_page_fault
# ffffffff81002000 T native_safe_halt
# ffffffff81002010 T halt
# 查找特定函数
cat /proc/kallsyms | grep " sys_"
# ffffffff81234567 T __x64_sys_read
# ffffffff81234589 T __x64_sys_write
# ffffffff812345ab T __x64_sys_open
# ...
# 安全限制:
# 如果 kernel.kptr_restrict = 1(默认)
# 非 root 用户看到的地址为 0
cat /proc/kallsyms | head -3
# 0000000000000000 T _stext
# 0000000000000000 T __x86_return_thunk
# 0000000000000000 t async_page_fault
# root 用户可以看到真实地址
sudo cat /proc/kallsyms | head -3
# ffffffff81000000 T _stext
# ffffffff81000190 T __x86_return_thunk
# ffffffff81001000 t async_page_fault2.6 模块依赖管理
模块之间可能存在依赖关系。modprobe工具能够自动处理这些依赖,按正确的顺序加载模块。
# 查看已加载模块的依赖关系
lsmod
# Module Size Used by
# ext4 745472 1
# jbd2 282624 1 ext4
# mbcache 16384 1 ext4
# nvidia 34567890 0
# 查看特定模块的依赖
modinfo ext4 | grep depends
# depends: jbd2,mbcache
# modprobe 自动处理依赖
sudo modprobe ext4
# 自动先加载 jbd2 和 mbcache,再加载 ext4
# 查看模块的依赖树
modprobe --show-depends ext4
# insmod /lib/modules/5.15.0/kernel/fs/jbd2/jbd2.ko
# insmod /lib/modules/5.15.0/kernel/fs/mbcache/mbcache.ko
# insmod /lib/modules/5.15.0/kernel/fs/ext4/ext4.ko/* 模块依赖在内核中的实现 */
/* 每个模块维护一个引用计数 */
/* 当模块A依赖模块B时,B的引用计数增加 */
/* 只有当引用计数为0时,模块才能被卸载 */
/* 获取模块引用 */
int try_module_get(struct module *module);
/* 释放模块引用 */
void module_put(struct module *module);
/* 检查模块是否可以安全卸载 */
/* 如果 refcount > 0,rmmod 会失败 */三、实战案例
3.1 编写带符号导出的模块对
/* counter_lib.c - 一个提供计数器功能的库模块 */
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/atomic.h>
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("A simple counter library module");
static atomic_t global_counter = ATOMIC_INIT(0);
int counter_increment(void) {
return atomic_inc_return(&global_counter);
}
EXPORT_SYMBOL(counter_increment);
int counter_decrement(void) {
return atomic_dec_return(&global_counter);
}
EXPORT_SYMBOL(counter_decrement);
int counter_get(void) {
return atomic_read(&global_counter);
}
EXPORT_SYMBOL(counter_get);
void counter_reset(void) {
atomic_set(&global_counter, 0);
}
EXPORT_SYMBOL(counter_reset);
static int __init counter_lib_init(void) {
printk(KERN_INFO "counter_lib: loaded, initial count = %d\n",
counter_get());
return 0;
}
static void __exit counter_lib_exit(void) {
printk(KERN_INFO "counter_lib: unloaded, final count = %d\n",
counter_get());
}
module_init(counter_lib_init);
module_exit(counter_lib_exit);
/* counter_user.c - 使用 counter_lib 的模块 */
#include <linux/module.h>
#include <linux/kernel.h>
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Counter library user module");
MODULE_DEPENDS("counter_lib");
extern int counter_increment(void);
extern int counter_decrement(void);
extern int counter_get(void);
extern void counter_reset(void);
static int __init counter_user_init(void) {
int i;
printk(KERN_INFO "counter_user: loaded\n");
counter_reset();
for (i = 0; i < 5; i++) {
counter_increment();
}
printk(KERN_INFO "After 5 increments: %d\n", counter_get());
counter_decrement();
counter_decrement();
printk(KERN_INFO "After 2 decrements: %d\n", counter_get());
return 0;
}
static void __exit counter_user_exit(void) {
printk(KERN_INFO "counter_user: unloaded, count = %d\n",
counter_get());
}
module_init(counter_user_init);
module_exit(counter_user_exit);# 编译和测试
make -C /lib/modules/$(uname -r)/build M=$PWD modules
# 加载库模块
sudo insmod counter_lib.ko
dmesg | tail -1
# counter_lib: loaded, initial count = 0
# 加载使用模块
sudo insmod counter_user.ko
dmesg | tail -3
# counter_user: loaded
# After 5 increments: 5
# After 2 decrements: 3
# 查看模块依赖
lsmod | grep counter
# counter_user 16384 0
# counter_lib 16384 1 counter_user
# 尝试卸载库模块(会失败,因为 counter_user 依赖它)
sudo rmmod counter_lib
# rmmod: ERROR: Module counter_lib is in use by counter_user
# 先卸载使用模块,再卸载库模块
sudo rmmod counter_user
sudo rmmod counter_lib3.2 使用 kallsyms 进行内核调试
# 查找特定函数的地址
cat /proc/kallsyms | grep "T schedule$\|T do_page_fault"
# ffffffff81c01234 T schedule
# ffffffff81234567 T do_page_fault
# 分析内核崩溃地址
# 如果内核崩溃在地址 ffffffff81c01300
# 通过 kallsyms 可以确定它在 schedule 函数内(偏移 0x6c)
# 查找系统调用处理函数
cat /proc/kallsyms | grep "__x64_sys_" | head
# ffffffff81234567 T __x64_sys_read
# ffffffff81234589 T __x64_sys_write
# ffffffff812345ab T __x64_sys_openat
# 查看模块的符号地址
cat /proc/kallsyms | grep "\[nvidia\]" | head
# ffffffffc0abc000 T _nv012345rm [nvidia]
# ffffffffc0abc100 T _nv012346rm [nvidia]3.3 分析模块的符号依赖
# 使用 nm 查看模块的符号
nm ext4.ko | grep " U " | head
# U __brelse
# U __bread_gfp
# U clear_page
# U __const_udelay
# U crc16
# U d_alloc_parallel
# 这些 U(未定义)符号需要从内核或其他模块解析
# 查看模块导出的符号
nm ext4.ko | grep " T " | head
# ffffffff c0123000 T ext4_block_zero_page_range
# ffffffff c0123100 T ext4_bread
# ffffffff c0123200 T ext4_check_dir_entry
# 使用 readelf 查看模块的依赖信息
readelf -p __depends ext4.ko 2>/dev/null || true
# 或使用 modinfo
modinfo ext4.ko | grep -E "depends|vermagic"四、本章小结
本章深入讲解了Linux内核的符号管理机制。内核符号表记录了所有导出的函数和变量,模块通过符号引用实现功能调用。EXPORT_SYMBOL和EXPORT_SYMBOL_GPL宏控制符号的可见性,后者限制只有GPL兼容模块才能使用。
模块的加载过程包括:读取模块文件、检查版本信息、分配内存、解析符号引用、执行初始化函数。CRC版本控制确保模块与内核接口的兼容性。/proc/kallsyms提供了内核符号的完整视图,是调试和分析的重要工具。
模块依赖管理通过引用计数实现,确保被依赖的模块不会在使用者之前被卸载。modprobe工具自动处理依赖关系,按正确顺序加载模块。
五、思考与练习
思考题
为什么内核需要区分EXPORT_SYMBOL和EXPORT_SYMBOL_GPL?这种机制对Linux生态系统有什么影响?
如果一个模块引用了一个未导出的内核符号,会发生什么?有什么办法可以绕过这个限制?
/proc/kallsyms中的地址为什么在非root用户下被隐藏为0?这是出于什么安全考虑?
模块的CRC版本控制有什么局限性?在什么情况下即使CRC匹配,模块也可能不兼容?
编程练习
编写两个内核模块:模块A导出一个函数和全局变量,模块B引用模块A的符号。验证加载顺序和依赖关系。
编写一个内核模块,在初始化时遍历/proc/kallsyms中的所有符号,统计各类型符号的数量。
编写一个脚本,分析当前系统所有已加载模块的依赖关系,生成依赖树。