5深入理解Linux内核(第3版)

内核符号

符号管理

内核符号表模块动态加载

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第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 内核符号表

内核符号表是一个数据结构,记录了所有导出符号的名称和地址。当模块被加载时,加载器通过符号表解析模块中未定义的符号引用。

c
/* 内核符号表的核心数据结构 */
/* 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);
bash
# 查看内核导出的符号
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系列宏控制哪些符号可以被模块使用。未导出的符号只能在内核核心代码内部使用。

c
/* 符号导出的宏定义 */
/* 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"
 */
bash
# 查看内核导出了多少符号
cat /proc/kallsyms | wc -l
# 通常超过 100,000 个符号

# 查看哪些是 GPL 符号
# (需要通过源码或 System.map 区分)
grep "EXPORT_SYMBOL_GPL" kernel/sched/core.c

2.3 模块的加载过程

当使用insmod或modprobe加载模块时,内核执行以下步骤:

1. 读取模块文件(.ko)
     ↓
2. 检查模块格式和版本信息
     ↓
3. 分配内存,将模块代码和数据复制到内核空间
     ↓
4. 解析模块中的符号引用
   ├── 查找内核导出的符号
   ├── 查找已加载模块导出的符号
   └── 修改模块代码中的符号地址(重定位)
     ↓
5. 执行模块的初始化函数(module_init)
     ↓
6. 将模块添加到已加载模块链表
c
/* 内核模块加载的核心结构 */
/* 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,   /* 还未完全形成 */
};
c
/* 一个简单的内核模块,展示符号导出 */
/* 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,加载时进行校验。

c
/* 版本控制机制 */
/* 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. 如果不匹配,拒绝加载
 *
 * 这确保了模块编译时的内核接口
 * 与加载时的内核接口一致
 */
bash
# 查看模块的版本信息
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_multiply

2.5 /proc/kallsyms

/proc/kallsyms是内核提供给用户空间的符号表视图。它列出了内核中所有符号的名称、地址和类型。

bash
# /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_fault

2.6 模块依赖管理

模块之间可能存在依赖关系。modprobe工具能够自动处理这些依赖,按正确的顺序加载模块。

bash
# 查看已加载模块的依赖关系
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
c
/* 模块依赖在内核中的实现 */
/* 每个模块维护一个引用计数 */
/* 当模块A依赖模块B时,B的引用计数增加 */
/* 只有当引用计数为0时,模块才能被卸载 */

/* 获取模块引用 */
int try_module_get(struct module *module);

/* 释放模块引用 */
void module_put(struct module *module);

/* 检查模块是否可以安全卸载 */
/* 如果 refcount > 0,rmmod 会失败 */

三、实战案例

3.1 编写带符号导出的模块对

c
/* 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);
bash
# 编译和测试
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_lib

3.2 使用 kallsyms 进行内核调试

bash
# 查找特定函数的地址
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 分析模块的符号依赖

bash
# 使用 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中的所有符号,统计各类型符号的数量。

编写一个脚本,分析当前系统所有已加载模块的依赖关系,生成依赖树。


5/10内核符号
0%