[Linux]学习笔记系列 -- [kernel]kallsyms


title: kallsyms

categories:

  • linux
  • kernel
    tags:
  • linux
  • kernel
    abbrlink: dd631879
    date: 2025-10-06 11:49:09

文章目录

https://github.com/wdfk-prog/linux-study

kernel/kallsyms.c 内核符号表(Kernel Symbols) 运行时内核符号解析

历史与背景

这项技术是为了解决什么特定问题而诞生的?

kallsyms(Kernel All Symbols)机制的诞生是为了解决在内核运行时动态解析符号地址 的核心需求。一个符号(Symbol)是程序中的一个构建块,通常指代一个函数名或变量名。内核在运行时,更倾向于直接使用内存地址(如 0xffffffff81c33580)而不是符号名(如 schedule)。 然而,在很多场景下,将地址转换回人类可读的符号名是至关重要的:

  • 内核调试与错误分析 :当内核发生严重错误(Kernel Panic)或"oops"时,它会打印出当时的寄存器状态和函数调用栈(Call Trace)。 如果调用栈仅仅是一串十六进制地址,那么对于开发者来说几乎是无用的。kallsyms 机制使得内核能够在崩溃时,当场将这些地址解析成具体的函数名和偏移量,极大地简化了调试过程。
  • 动态模块加载 :内核模块(Loadable Kernel Modules, LKM)在加载时需要链接到内核主镜像中的函数和变量。 kallsyms 提供了一个运行时接口,让模块加载器可以查找这些符号的地址,从而完成动态链接。
  • 性能分析与追踪 :高级的内核追踪工具,如 ftrace 和 kprobes,允许开发者在指定的内核函数入口或特定地址设置探测点。 kallsyms 使得这些工具可以通过函数名(而不是写死的地址)来动态设置追踪点,增强了灵活性和易用性。
它的发展经历了哪些重要的里程碑或版本迭代?

内核符号表的实现在历史上经历了重要的演进:

  1. 早期的 ksyms :在Linux 2.5.47版本之前,内核通过 /proc/ksyms 文件导出一个符号列表。 这个列表通常只包含被明确使用 EXPORT_SYMBOL 宏导出的符号,主要用于满足模块加载的需求。
  2. kallsyms 的引入 :为了提供更全面的符号信息以支持更强大的调试功能,kallsyms 机制被引入。与 ksyms 不同,kallsyms 旨在包含内核中所有的非栈符号,而不仅仅是导出的符号。
  3. /proc/kallsyms 的出现 :从Linux 2.5.71开始,/proc/kallsyms 取代了 /proc/ksyms,成为了在用户空间查看运行时内核符号表的标准接口。 它不仅包含了内核主镜像的符号,还能动态地反映已加载模块的符号信息。
  4. 压缩与优化 :为了减小符号表在内核镜像中占用的空间,内核引入了多种压缩技术。kallsyms.c 中的代码实现了对这些压缩符号表的解压和查找逻辑。
目前该技术的社区活跃度和主流应用情况如何?

kallsyms 是Linux内核中一项非常基础、成熟且不可或缺的技术。它是内核可观察性(Observability)和可调试性的基石。所有主流的Linux发行版都会在内核编译时启用 CONFIG_KALLSYMS 选项。它被以下工具和子系统广泛依赖:

  • 内核错误报告(Oops/Panic)。
  • 模块加载器
  • 性能分析工具perfftraceeBPF
  • 动态探测工具kprobesjprobesuprobes

核心原理与设计

它的核心工作原理是什么?

kernel/kallsyms.c 的核心是在内核中实现了一套高效的符号查找机制,并向外提供接口。

  1. 构建时的数据准备 :在内核编译的最后阶段,一个名为 kallsyms 的主机程序(scripts/kallsyms.c)会扫描 vmlinux 文件,提取出所有的符号(地址、类型、名称)。然后,它将这些符号信息处理成一个紧凑的、经过压缩的数据块,并将其链接到最终的内核镜像中,成为一个名为 __kallsyms 的特殊数据段(section)。 这个数据块包含了多个数组,如 kallsyms_addresses(符号地址数组)、kallsyms_names(压缩后的符号名数组)等。
  2. 运行时的数据访问 :当内核启动后,这个 __kallsyms 数据段就位于内核的内存空间中。kernel/kallsyms.c 中的函数就是用来操作这片内存区域的。
  3. 地址到符号的转换 :当需要将一个地址(例如,指令指针寄存器的值)转换为符号名时,内核会调用 kallsyms_lookup() 等内部函数。这个函数会在已排序的 kallsyms_addresses 数组中进行高效的二分查找,找到小于或等于给定地址的最近的一个符号地址。然后,它会计算出偏移量,并从 kallsyms_names 中解压出对应的符号名称。
  4. 符号到地址的转换 :当需要将一个符号名转换为地址时(例如,kprobes 需要在 schedule 函数上设置探测点),内核会调用 kallsyms_lookup_name() 函数。 这个函数会遍历压缩的符号名列表,逐个解压并与目标名称进行比较,直到找到匹配的符号,然后返回其对应的地址。
  5. 通过 /proc/kallsyms 导出 :当用户空间的程序读取 /proc/kallsyms 文件时,会触发 kallsyms.c 中实现的 seq_file 接口。 这些接口函数会遍历内核主镜像和所有已加载模块的符号表,并将它们格式化为"地址 类型 名称 [模块名]"的文本行,输出给用户。
它的主要优势体现在哪些方面?
  • 运行时可用性 :无需外部文件(如 System.map),内核自身就具备了解析符号的能力,这使得在任何正在运行的系统上进行调试和追踪都成为可能。
  • 动态性:能够实时反映内核模块加载和卸载带来的符号变化,这是静态文件无法做到的。
  • 完整性:可以包含内核中几乎所有的全局符号,而不仅仅是导出的符号,为深度调试和性能分析提供了更全面的信息。
  • 空间效率:通过复杂的压缩算法,显著减小了符号表在内核镜像中所占的空间。
它存在哪些已知的劣势、局限性或在特定场景下的不适用性?
  • 安全风险 :暴露详细的内核符号地址信息会给攻击者提供便利,尤其是在绕过KASLR(内核地址空间布局随机化)等安全机制时。 为了缓解这个问题,大多数系统默认配置 kernel.kptr_restrict=1,使得非特权用户读取 /proc/kallsyms 时看到的地址都是零。
  • 性能开销:虽然查找算法已经高度优化,但在运行时进行符号查找仍然存在一定的CPU开销。此外,在内核镜像中嵌入符号表会增加其体积。
  • 编译时依赖 :该功能需要在内核编译时启用(CONFIG_KALLSYMS=y),如果一个内核在编译时未开启此选项,那么它在运行时将完全丧失符号解析能力。

使用场景

在哪些具体的业务或技术场景下,它是首选解决方案?请举例说明。
  • 内核崩溃分析 :当服务器出现内核panic时,运维人员从 dmesgkdump 的日志中看到的第一件事就是一个带有符号名的调用栈。这是kallsyms最直接、最关键的应用。
  • 动态内核追踪 :一个性能工程师想要分析某个特定内核函数的行为,他可以使用perf工具:perf probe scheduleperf在底层通过kallsyms机制找到schedule函数的地址,并设置一个kprobe探测点。
  • 编写内核模块 :一个驱动开发者需要调用一个非导出的内核函数(通常不推荐,但有时为了调试或特定目的需要)。他可以在模块中调用kallsyms_lookup_name("function_name")来动态获取该函数的地址。
  • 安全审计 :安全研究人员可以通过分析/proc/kallsyms的输出来了解内核的内存布局,并验证KASLR是否有效工作。
是否有不推荐使用该技术的场景?为什么?

在绝大多数情况下,启用 kallsyms 是利远大于弊的。不推荐禁用它的主要原因是,一旦禁用,将极大地增加内核故障排查的难度。唯一的考虑可能是:

  • 极度资源受限的嵌入式系统:在内存(RAM/ROM)极其有限(例如,只有几百KB)的微控制器上,内核符号表所占用的空间可能无法承受。
  • 安全要求极高的"安全内核" :在某些需要最大化"攻击面"最小化的安全操作系统中,可能会选择禁用kallsyms,以防止任何形式的内核内存布局信息泄露,并配合使用其他离线调试手段。

对比分析

请将其 与 其他相似技术 进行详细对比。

kallsyms 的主要对比对象是静态的 System.map 文件。

特性 /proc/kallsyms (由 kallsyms.c 提供) System.map 文件
功能概述 一个运行时动态 的内核符号表接口,反映了当前正在运行的内核的真实状态。 一个在内核编译时 生成的静态文本文件,记录了该次编译出的内核镜像的符号表。
实现方式 符号数据被压缩并链接到内核镜像中,由kallsyms.c中的代码在运行时解析和提供。 在编译内核后,通过nm等工具从vmlinux文件中提取符号信息并保存为一个普通的文本文件。
数据来源 内核自身内存中的 __kallsyms 数据段,以及已加载模块的动态符号信息。 编译时生成的 vmlinux ELF 文件。
动态性 。当内核模块被加载或卸载时,其符号会动态地出现在/proc/kallsyms中或从中消失。 。完全静态,无法反映内核模块的加载情况。
准确性 。它总是准确反映当前运行内核的符号信息。即使KASLR开启,它也显示随机化后的真实地址。 可能不准确 。如果System.map文件与当前运行的内核版本不匹配,其信息就是错误的。 此外,它不反映KASLR导致的地址随机化,其地址是编译时的链接地址。
可用性 。只要内核在运行且配置开启,它就一定可用。 不确定。文件可能不存在,或者版本不匹配。 在内核无法启动的场景下,它是唯一可用的符号参考。
主要用途 运行时调试、动态追踪、模块加载、内核错误自诊断。 内核启动失败时的早期调试、交叉引用、以及为一些老旧工具提供符号信息。

kallsyms_init: 内核符号表 /proc 接口创建

本代码片段的核心功能是在 Linux 内核初始化期间,通过 /proc 虚拟文件系统,创建一个名为 kallsyms 的只读文件。这个文件(/proc/kallsyms)向用户空间提供了一个内核符号表的实时视图,其中包含了内核中所有非静态函数和全局变量的名称及其在内存中的地址。这是内核调试、性能分析(profiling)和动态追踪(tracing)等高级功能的基石。

实现原理分析

该机制的实现完全建立在 Linux 的 /proc 文件系统框架之上,这是一种在内存中动态生成文件内容的机制。

  1. Proc 文件注册 (proc_create) : kallsyms_init 函数的核心是调用 proc_create。这个函数向 /proc 文件系统注册一个新的文件节点。

    • "kallsyms" : 指定了在 /proc 根目录下创建的文件名。
    • 0444 : 定义了文件的访问权限为八进制的 0444,即对所有用户(属主、属组、其他)都只有读取权限。这是一种安全措施,防止用户空间程序意外或恶意地修改内核符号信息。
    • NULL : 表示该文件直接创建在 /proc 的根目录下。
    • &kallsyms_proc_ops : 这是最关键的参数。它是一个指向 proc_ops 结构体的指针。该结构体包含了一系列函数指针,它们定义了当用户空间进程对此文件执行各种操作(如 open, read, lseek, release)时,内核应该调用哪些具体的处理函数。
  2. Sequence File 接口 : kallsyms_proc_ops 结构体将文件的读操作(.proc_read)指向了 seq_readseq_read 是内核提供的一个通用函数,它与 seq_file(Sequence File)接口协同工作。seq_file 是一种为生成内容可能很大的虚拟文件而设计的优化机制。当 kallsyms_open 被调用时,它会初始化一个 seq_file 实例,并提供一组迭代器函数,seq_read 则利用这些迭代器函数逐条(sequence by sequence)地遍历内核的符号表,并将其格式化输出到用户缓冲区,从而避免了一次性在内核中分配巨大缓冲区来存储整个符号表的开销。

代码分析

c 复制代码
// 定义一个常量 proc_ops 结构体,用于关联 /proc/kallsyms 文件的操作。
static const struct proc_ops kallsyms_proc_ops = {
	// 当文件被打开时,调用 kallsyms_open 函数。
	.proc_open	= kallsyms_open,
	// 当文件被读取时,调用通用的 seq_read 函数(配合seq_file接口)。
	.proc_read	= seq_read,
	// 当对文件进行定位(lseek)时,调用通用的 seq_lseek 函数。
	.proc_lseek	= seq_lseek,
	// 当文件被关闭/释放时,调用 seq_release_private 函数来释放私有数据。
	.proc_release	= seq_release_private,
};

// kallsyms_init: /proc/kallsyms 接口的初始化函数。
static int __init kallsyms_init(void)
{
	// 在/proc文件系统的根目录下创建一个名为"kallsyms"的文件。
	// - "kallsyms": 文件名。
	// - 0444: 文件的权限为只读(所有者、组、其他用户均为只读)。
	// - NULL: 父目录为/proc的根。
	// - &kallsyms_proc_ops: 指定与此文件关联的操作函数集。
	proc_create("kallsyms", 0444, NULL, &kallsyms_proc_ops);
	// 返回0表示初始化成功。
	return 0;
}
// 将 kallsyms_init 注册为一个设备初始化调用。
// 这使得该函数会在内核启动过程中的一个较早阶段被自动执行。
device_initcall(kallsyms_init);

内核符号表 /proc 接口的实现与高效迭代 (kallsyms_open, get_symbol_offset)

本代码片段展示了当用户空间程序打开 /proc/kallsyms 文件时,内核如何初始化一个高效的迭代器来遍历内核符号表。其核心功能是利用一种经过优化的两级查找算法(get_symbol_offset),在经过特殊压缩的符号名称数据流中快速定位到任意指定的符号,为 seq_file 框架提供了高效的遍历能力。

实现原理分析

该实现中最关键和精妙的部分是 get_symbol_offset 函数所采用的符号查找算法,它旨在以空间换时间,大幅优化遍历性能。

  1. seq_file 与私有迭代器 (kallsyms_open) : kallsyms_open 函数遵循了 seq_file 框架的标准实践。它调用 __seq_open_private 来分配一个私有的 kallsym_iter 结构体。这个结构体将作为此特定文件句柄的"游标",保存当前遍历的位置(pos)、在压缩数据流中的字节偏移量(nameoff)等状态。这种方式使得每个打开 /proc/kallsyms 的进程都有自己独立的遍历状态。

  2. 压缩的符号名称流 : 内核为了节省空间,并不会以简单的 C 字符串数组来存储所有符号名。相反,kallsyms_names 是一个连续的字节数组,其中的每个符号都以 [<长度>][<名称数据>] 的格式紧凑地存储。

  3. 两级查找优化 (get_symbol_offset) : 直接从头开始扫描这个压缩流来查找第 N 个符号会非常慢。get_symbol_offset 通过一个两级查找策略来解决这个问题:

    • 一级查找(粗粒度定位) : 内核在编译时会额外生成一个 kallsyms_markers 数组。这个数组是一个标记点索引,kallsyms_markers[i] 存储了第 i * 256 个符号在 kallsyms_names 压缩流中的起始字节偏移量。因此,get_symbol_offset 函数首先通过 pos >> 8(即 pos / 256)计算出应该使用哪个标记点,直接跳转到距离目标位置最近的一个检查点,从而跳过前面大量的符号。
    • 二级查找(细粒度扫描) : 从标记点位置开始,函数再通过一个循环,对剩余的最多 255 个符号(由 pos & 0xFFpos % 256 决定)进行逐个扫描。它读取每个符号的长度字节,然后向后跳转"长度+1"个字节,直到到达目标符号。
  4. 可变长度编码: 为了进一步节省空间,符号的长度使用了可变长编码。如果长度小于 128,就用一个字节表示。如果长度大于等于 128,第一个字节的最高位(MSB)会被置 1 作为标志,而真正的长度值(减去 1)则由第一个字节的低 7 位和第二个字节的 8 位共同组成一个 15 位的数值。这是一种针对大量短符号名进行的有效空间优化。

代码分析

c 复制代码
// kallsyms_open: 当 /proc/kallsyms 文件被打开时,内核调用的函数。
// @inode: 文件的inode对象。
// @file: 打开的文件对象。
// 返回值: 成功返回0,失败返回负数错误码。
static int kallsyms_open(struct inode *inode, struct file *file)
{
	/*
	 * 我们将迭代器保存在 m->private 中,因为通常情况下是从上次
	 * 离开的位置继续 s_start,这样可以避免对每个符号都
	 * 调用 get_symbol_offset。
	 */
	struct kallsym_iter *iter;
	// 调用 seq_file 框架的辅助函数来打开一个私有序列文件。
	// 这会为本次文件打开分配一个 kallsym_iter 结构体,并将其地址存放在 file->private_data 中。
	iter = __seq_open_private(file, &kallsyms_op, sizeof(*iter));
	// 如果内存分配失败,返回错误。
	if (!iter)
		return -ENOMEM;
	// 重置迭代器,使其从符号表的起始位置(位置0)开始。
	reset_iter(iter, 0);

	/*
	 * 与其在每次调用 s_show() 时都进行检查,不如在打开文件时
	 * 就把结果缓存起来。
	 */
	// 根据当前进程的凭证,判断是否应该显示符号的地址值,并将结果缓存。
	iter->show_value = kallsyms_show_value(file->f_cred);
	return 0;
}

// reset_iter: 重置或初始化一个 kallsyms 迭代器到新的位置。
// @iter: 指向要重置的迭代器结构体。
// @new_pos: 新的逻辑位置(即第 new_pos 个符号)。
static void reset_iter(struct kallsym_iter *iter, loff_t new_pos)
{
	iter->name[0] = '\0'; // 清空当前符号名称的缓存。
	// 调用 get_symbol_offset 计算新位置在压缩符号流中的字节偏移量。
	iter->nameoff = get_symbol_offset(new_pos);
	iter->pos = new_pos; // 更新迭代器的逻辑位置。
	// 如果重置到起始位置0,则也重置其他模块(如 ftrace、bpf)的符号位置计数器。
	if (new_pos == 0) {
		iter->pos_mod_end = 0;
		iter->pos_ftrace_mod_end = 0;
		iter->pos_bpf_end = 0;
	}
}

// get_symbol_offset: 根据符号的逻辑索引号,计算其在压缩符号名称流中的字节偏移量。
// @pos: 符号的逻辑索引号(例如,第 1000 个符号)。
// 返回值: 该符号在 kallsyms_names 数组中的字节偏移量。
static unsigned int get_symbol_offset(unsigned long pos)
{
	const u8 *name;
	int i, len;

	/*
	 * 使用我们拥有的最近的标记点。我们每隔256个位置就有一个标记点,
	 * 这应该足够近了。
	 */
	// 第一级查找:通过 pos >> 8(即 pos / 256)找到对应的标记点,
	// 直接跳转到 kallsyms_names 数组中的一个粗略位置。
	name = &kallsyms_names[kallsyms_markers[pos >> 8]];

	/*
	 * 顺序扫描所有符号,直到我们搜索的点。每个符号都以
	 * [<长度>][<长度对应的数据字节>] 的格式存储,所以我们只需
	 * 对每个希望跳过的符号,将长度加到当前指针上即可。
	 */
	// 第二级查找:从标记点开始,顺序扫描余下的符号(最多255个)。
	// pos & 0xFF 即 pos % 256。
	for (i = 0; i < (pos & 0xFF); i++) {
		len = *name; // 第一个字节是长度。

		/*
		 * 如果最高有效位(MSB)为1,它是一个"大"符号,所以我们需要
		 * 查看下一个字节(并且也要跳过它)。
		 */
		// 检查是否为大符号(长度超过127)。
		if ((len & 0x80) != 0)
			// 如果是,则长度由2个字节共同编码。
			// 第一个字节的低7位是低位,第二个字节构成高位。
			len = ((len & 0x7F) | (name[1] << 7)) + 1;

		// 将指针移动到下一个符号的长度字节。
		name = name + len + 1;
	}

	// 返回最终计算出的指针相对于 kallsyms_names 起始地址的偏移量。
	return name - kallsyms_names;
}

内核符号表 seq_file 接口实现与动态内容生成

本代码片段是 Linux 内核 /proc/kallsyms 文件内容生成机制的核心。它通过实现标准的 seq_file(Sequence File)接口,定义了一套完整的迭代器操作 (kallsyms_op),用于遍历并格式化输出系统内的所有符号。这不仅包括内核主体的静态符号,还动态地涵盖了已加载模块、Ftrace、BPF 程序以及 Kprobes 探针等多种来源的符号,最终将它们统一、有序地呈现给用户空间。

实现原理分析

此机制的核心是 seq_file 框架的标准化应用,它将一个可能非常庞大的、动态生成的数据集(内核符号表)抽象为一个可顺序读取的文件。

  1. 迭代器模型 (s_start, s_next, s_stop):

    • s_start: 当读取操作开始或发生文件定位(lseek)时被调用。它的职责是根据请求的起始位置 pos,通过调用 update_iter 来正确地"定位"迭代器 iter
    • s_next: 当 seq_file 框架需要下一条记录时调用。它简单地将逻辑位置 pos 加一,然后再次调用 update_iter 来获取下一个符号的信息并更新迭代器状态。
    • s_stop: 在读取会话结束时调用,用于清理。在此实现中,由于迭代器资源由 seq_file 的私有数据机制管理,故此函数为空。
  2. 分层更新逻辑 (update_iter, update_iter_mod):

    • update_iter : 这是一个顶层分发函数。它首先判断请求的位置 pos 是否在核心内核符号的范围内 (kallsyms_num_syms)。
      • 核心符号处理 : 如果是,它会进行一次关键的性能优化:检查请求的位置是否恰好是上一次位置的下一个 (pos != iter->pos)。如果是连续读取,它只需调用 get_ksymbol_core 来解析压缩流中的下一个符号即可,效率极高。如果不是(发生了 lseek),则必须调用 reset_iter 来执行一次代价较高的两级查找以重新定位。
      • 扩展符号处理 : 如果 pos 超出了核心符号范围,它将调用 update_iter_mod 来处理来自其他来源的符号。
    • update_iter_mod : 这个函数负责处理模块、Ftrace、BPF 等动态添加的符号。它像一个责任链一样工作:按顺序检查当前 pos 是否落在某个扩展符号区域内(通过与 iter->pos_..._end 边界变量比较)。一旦找到所属区域,就调用相应的 get_ksymbol_...() 函数来填充迭代器,并返回。这种设计使得向 kallsyms 添加新的符号源变得模块化和可扩展。
  3. 格式化输出 (s_show) : 这是最终将迭代器中的数据转换为用户可见文本的函数。它会检查迭代器中的 iter->name 是否有效,然后根据符号的类型(是否来自模块、是否为导出的全局符号)选择不同的格式化字符串,通过 seq_printf 输出。

代码分析

c 复制代码
// update_iter_mod: 当遍历位置超出核心内核符号范围后,更新迭代器以处理扩展符号(模块、Ftrace等)。
// @iter: 指向kallsyms迭代器的指针。
// @pos: 当前的逻辑遍历位置。
// 返回值: 如果成功获取到一个符号则返回1,否则返回0或负值。
/*
 * 每个附加的kallsyms段的结束位置(最后一个+1)会在段被添加时
 * 记录在 iter->pos_..._end 中,因此可以用来决定接下来
 * 应该调用哪个 get_ksymbol_...() 函数。
 */
static int update_iter_mod(struct kallsym_iter *iter, loff_t pos)
{
	iter->pos = pos;

	// 检查当前位置是否在模块符号范围内,并尝试获取一个模块符号。
	if ((!iter->pos_mod_end || iter->pos_mod_end > pos) &&
	    get_ksymbol_mod(iter))
		return 1;

	// 检查当前位置是否在Ftrace模块符号范围内,并尝试获取一个符号。
	if ((!iter->pos_ftrace_mod_end || iter->pos_ftrace_mod_end > pos) &&
	    get_ksymbol_ftrace_mod(iter))
		return 1;

	// 检查当前位置是否在BPF符号范围内,并尝试获取一个符号。
	if ((!iter->pos_bpf_end || iter->pos_bpf_end > pos) &&
	    get_ksymbol_bpf(iter))
		return 1;

	// 最后尝试获取一个kprobe符号。
	return get_ksymbol_kprobe(iter);
}

// update_iter: 更新迭代器到指定的位置。
// @iter: 指向kallsyms迭代器的指针。
// @pos: 目标逻辑位置。
// 返回值: 如果位置有效且成功更新则返回1,否则返回0。
static int update_iter(struct kallsym_iter *iter, loff_t pos)
{
	// 检查位置是否超出了核心内核符号的范围。
	if (pos >= kallsyms_num_syms)
		// 如果是,则调用专门处理模块等扩展符号的函数。
		return update_iter_mod(iter, pos);

	// 如果请求的位置不是迭代器当前的位置(发生了seek),则重置迭代器。
	if (pos != iter->pos)
		reset_iter(iter, pos);

	// 调用get_ksymbol_core来解析并获取核心符号的信息,更新nameoff。
	iter->nameoff += get_ksymbol_core(iter);
	// 将迭代器的逻辑位置加1。
	iter->pos++;

	return 1;
}

// s_next: seq_file的next回调,用于移动到序列中的下一个元素。
// @m: 指向seq_file对象的指针。
// @p: 当前位置的内部表示(此处未使用)。
// @pos: 指向当前逻辑位置的指针。
// 返回值: 成功则返回非NULL(通常是m->private),到达末尾则返回NULL。
static void *s_next(struct seq_file *m, void *p, loff_t *pos)
{
	// 将逻辑位置加1。
	(*pos)++;

	// 更新迭代器到新的位置,如果到达文件末尾,则返回NULL。
	if (!update_iter(m->private, *pos))
		return NULL;
	return p;
}

// s_start: seq_file的start回调,用于开始一个序列或定位到指定位置。
// @m: 指向seq_file对象的指针。
// @pos: 指向目标逻辑位置的指针。
// 返回值: 成功则返回非NULL,失败或到达末尾则返回NULL。
static void *s_start(struct seq_file *m, loff_t *pos)
{
	// 更新迭代器到指定位置,如果位置无效,则返回NULL。
	if (!update_iter(m->private, *pos))
		return NULL;
	// 返回迭代器对象作为后续操作的句柄。
	return m->private;
}

// s_stop: seq_file的stop回调,在序列遍历结束时调用。
// @m: 指向seq_file对象的指针。
// @p: 句柄(此处未使用)。
static void s_stop(struct seq_file *m, void *p)
{
	// 无需特殊清理,资源由seq_file私有数据机制管理。
}

// s_show: seq_file的show回调,用于格式化输出当前元素。
// @m: 指向seq_file对象的指针。
// @p: 句柄(此处未使用,直接使用m->private)。
// 返回值: 成功返回0,失败返回错误码。
static int s_show(struct seq_file *m, void *p)
{
	void *value;
	struct kallsym_iter *iter = m->private;

	// 某些调试符号可能没有名称,忽略它们。
	if (!iter->name[0])
		return 0;

	// 根据打开文件时缓存的权限决定是否显示符号地址。
	value = iter->show_value ? (void *)iter->value : NULL;

	// 检查符号是否来自一个内核模块。
	if (iter->module_name[0]) {
		char type;

		// 如果符号是导出的,则类型字符大写(全局);否则小写(局部)。
		type = iter->exported ? toupper(iter->type) :
					tolower(iter->type);
		// 按"地址 类型 名称 [模块名]"的格式输出。
		seq_printf(m, "%px %c %s\t[%s]\n", value,
			   type, iter->name, iter->module_name);
	} else
		// 如果是核心内核符号,则按"地址 类型 名称"的格式输出。
		seq_printf(m, "%px %c %s\n", value,
			   iter->type, iter->name);
	return 0;
}

// 定义kallsyms的seq_operations结构体,将回调函数与操作关联起来。
static const struct seq_operations kallsyms_op = {
	.start = s_start,
	.next = s_next,
	.stop = s_stop,
	.show = s_show
};

kallsyms 迭代器实现:模块化符号源的获取

本代码片段是 Linux 内核 /proc/kallsyms 功能的核心实现部分,它展示了系统如何从多个不同的、动态的符号源中获取符号信息。每个 get_ksymbol_... 函数都专门负责一个特定的符号"域"(如内核模块、Ftrace、BPF 等)。它们共同构成了一个可扩展的责任链,使得 kallsyms 能够将来自不同内核子系统的符号,统一、无缝地整合到同一个符号表中,呈现给用户。

实现原理分析

此机制的核心是一种基于相对索引的模块化迭代器填充 策略。/proc/kallsyms 的迭代器在遍历时使用一个全局的、连续递增的位置索引 (iter->pos)。当这个索引超出了核心内核符号的范围后,系统会按预定顺序调用本片段中的函数,每个函数都尝试将这个全局索引映射到自己内部的符号列表。

  1. 责任链与边界标记:

    • 调用逻辑(在上一段分析的 update_iter_mod 中)形成了一条责任链:先尝试从内核模块获取符号,失败后再尝试 Ftrace,再 BPF,依此类推。
    • 每个函数在无法找到更多符号时(即其内部接口返回错误),会执行一个关键操作:iter->pos_..._end = iter->pos;。它将当前的全局位置 pos 记录为自己这个符号域的"末端边界"。这个边界标记会通知上层调用者,下次处理更大的 pos 值时,可以直接跳过对这个已遍历完毕的域的查询,从而高效地转向下一个符号域。
  2. 相对索引计算:

    • 每个 get_ksymbol_... 函数在调用其后端的符号获取接口时,都使用了相对索引 。例如,get_ksymbol_mod 使用 iter->pos - kallsyms_num_syms 作为传递给 module_get_kallsym 的索引。这意味着它请求的是"模块符号列表中的第 N 个符号",而不是全局 kallsyms 列表中的第 pos 个。同样,get_ksymbol_bpf 使用 iter->pos - iter->pos_ftrace_mod_end,是基于上一个符号域的结束位置来计算自己在 BPF 符号列表中的相对索引。
    • 这种设计极大地解耦了各个符号源。每个子系统(模块、BPF等)只需提供一个能通过从 0 开始的索引来查找其内部符号的接口,而无需关心全局的符号布局。
  3. 统一的迭代器接口 : 所有这些函数都操作于同一个 struct kallsym_iter *iter 结构体。它们成功获取到符号后,会将符号的地址、类型、名称、所属模块名等信息填充到这个迭代器的相应字段中。这使得上层的 s_show 函数可以用统一的方式来格式化并输出来自任何源的符号。

代码分析

c 复制代码
/**
 * @brief 从已加载的内核模块中获取一个符号。
 * @param iter 指向kallsyms迭代器的指针。
 * @return 成功获取符号则返回1,否则返回0。
 */
static int get_ksymbol_mod(struct kallsym_iter *iter)
{
	// 调用模块子系统的接口来获取符号。
	// 使用全局位置减去核心符号总数,得到在模块符号列表中的相对索引。
	int ret = module_get_kallsym(iter->pos - kallsyms_num_syms,
				     &iter->value, &iter->type,
				     iter->name, iter->module_name,
				     &iter->exported);
	// 如果获取失败(已到达模块符号列表的末尾)。
	if (ret < 0) {
		// 将当前位置记录为模块符号域的结束边界。
		iter->pos_mod_end = iter->pos;
		return 0;
	}

	return 1;
}

/**
 * @brief 从Ftrace的动态符号中获取一个符号。
 * @param iter 指向kallsyms迭代器的指针。
 * @return 成功获取符号则返回1,否则返回0。
 * @note ftrace_mod_get_kallsym() 也可能获取为ftrace目的分配的页面中的符号。
 *       在这种情况下,"__builtin__ftrace"被用作模块名,尽管它并非一个真正的模块。
 */
static int get_ksymbol_ftrace_mod(struct kallsym_iter *iter)
{
	// 调用Ftrace子系统的接口来获取符号。
	// 使用全局位置减去上一个域(模块)的结束位置,得到相对索引。
	int ret = ftrace_mod_get_kallsym(iter->pos - iter->pos_mod_end,
					 &iter->value, &iter->type,
					 iter->name, iter->module_name,
					 &iter->exported);
	// 如果获取失败。
	if (ret < 0) {
		// 将当前位置记录为Ftrace符号域的结束边界。
		iter->pos_ftrace_mod_end = iter->pos;
		return 0;
	}

	return 1;
}

/**
 * @brief 从BPF程序中获取一个符号。
 * @param iter 指向kallsyms迭代器的指针。
 * @return 成功获取符号则返回1,否则返回0。
 */
static int get_ksymbol_bpf(struct kallsym_iter *iter)
{
	int ret;

	// BPF符号统一属于"bpf"伪模块。
	strscpy(iter->module_name, "bpf", MODULE_NAME_LEN);
	// BPF符号默认不是导出的。
	iter->exported = 0;
	// 调用BPF子系统的接口来获取符号。
	// 使用全局位置减去上一个域(Ftrace)的结束位置,得到相对索引。
	ret = bpf_get_kallsym(iter->pos - iter->pos_ftrace_mod_end,
			      &iter->value, &iter->type,
			      iter->name);
	// 如果获取失败。
	if (ret < 0) {
		// 将当前位置记录为BPF符号域的结束边界。
		iter->pos_bpf_end = iter->pos;
		return 0;
	}

	return 1;
}

/**
 * @brief 从Kprobes中获取一个符号。
 * @param iter 指向kallsyms迭代器的指针。
 * @return 成功获取符号则返回1,否则返回0。
 * @note 这会使用"__builtin__kprobes"作为模块名,尽管它不是一个真正的模块。
 */
static int get_ksymbol_kprobe(struct kallsym_iter *iter)
{
	// Kprobe符号统一属于"__builtin__kprobes"伪模块。
	strscpy(iter->module_name, "__builtin__kprobes", MODULE_NAME_LEN);
	iter->exported = 0;
	// 调用Kprobe子系统的接口来获取符号,并直接判断返回值。
	// 使用全局位置减去上一个域(BPF)的结束位置,得到相对索引。
	return kprobe_get_kallsym(iter->pos - iter->pos_bpf_end,
				  &iter->value, &iter->type,
				  iter->name) < 0 ? 0 : 1;
}

/**
 * @brief 从核心内核的静态符号表中获取一个符号。
 * @param iter 指向kallsyms迭代器的指针。
 * @return 在压缩符号流中处理过的字节数。
 */
static unsigned long get_ksymbol_core(struct kallsym_iter *iter)
{
	unsigned off = iter->nameoff; // 获取当前在压缩流中的偏移量。

	iter->module_name[0] = '\0'; // 核心符号没有模块名。
	// 根据逻辑位置(行号)获取符号的地址。
	iter->value = kallsyms_sym_address(iter->pos);

	// 根据压缩流偏移量获取符号的类型字符。
	iter->type = kallsyms_get_symbol_type(off);

	// 从压缩流中解压出符号的名称。
	off = kallsyms_expand_symbol(off, iter->name, ARRAY_SIZE(iter->name));

	// 返回本次解析消耗的字节数,用于更新下一次的偏移量。
	return off - iter->nameoff;
}

kallsyms 符号解压与地址查找

本代码片段揭示了 Linux 内核 kallsyms 子系统为节省内存而采用的一套复杂而高效的符号信息存储与检索机制。其核心功能是:通过 get_symbol_offset 在一个预排序的符号列表中进行两级快速查找;通过 kallsyms_sym_address 计算出符号的运行时地址;并通过 kallsyms_expand_symbol 将一种基于"字典-令牌"的高度压缩的名称数据解压为人类可读的字符串。

实现原理分析

此机制通过多种数据压缩和空间换时间的技巧,实现了在有限内存中存储大量符号信息的目标。

  1. 符号地址的相对存储 (kallsyms_sym_address) : 内核并不为每个符号存储一个完整的 32 位或 64 位地址。相反,它存储一个全局的 kallsyms_relative_base 基地址,以及一个 kallsyms_offsets 数组,其中每个元素都是一个相对于该基地址的 32 位偏移量。这在 64 位系统上尤其能节省大量空间。kallsyms_sym_address 的工作就是简单地将基地址与指定索引的偏移量相加,得到最终的符号地址。

  2. 符号名称的两级查找定位 (get_symbol_offset) : 内核所有符号的名称被压缩后存放在一个巨大的字节数组 kallsyms_names 中。为了能快速定位到第 N 个符号的压缩数据,内核采用了一个两级查找策略:

    • 一级索引(Markers) : kallsyms_markers 数组是一个稀疏索引。kallsyms_markers[i] 存储了第 i * 256 个符号在 kallsyms_names 中的起始偏移量。这允许函数通过 pos >> 8 立即跳转到离目标非常近的位置,跳过大量不相关的符号。
    • 二级扫描 : 从标记点开始,函数再对最多 255 个符号(pos & 0xFF)进行线性扫描,通过解析每个符号的变长长度字段来逐个跳过,最终精确定位到目标符号。
  3. 名称的字典-令牌压缩与解压 (kallsyms_expand_symbol): 这是最精妙的部分。符号名称本身并非以 ASCII 码存储,而是被高度压缩了:

    • 字典(Token Table) : 内核在编译时会分析所有符号名,并抽取出最高频出现的字符串片段(如 "spin_lock", "irq", "device "),形成一个 kallsyms_token_table
    • 令牌(Tokens) : kallsyms_names 中存储的不再是字符,而是一系列"令牌索引"。
    • 解压过程 : kallsyms_expand_symbol 在解压时,会读取这些令牌索引,然后用这些索引去 kallsyms_token_indexkallsyms_token_table 中查找对应的字符串片段,最后将这些片段拼接起来,还原出原始的符号名。
  4. 符号类型的嵌入式存储 (kallsyms_get_symbol_type) : 为了进一步节省空间,符号的类型字符(如 'T', 't', 'd')并没有被单独存储。它被巧妙地编码为符号名称的第一个令牌的第一个字符kallsyms_get_symbol_type 函数正是利用了这一点,它只解压第一个令牌并返回其首字符,从而以极低的成本获取到符号类型。

代码分析

c 复制代码
/**
 * @brief 将一个压缩的符号数据解压成未压缩的字符串。
 * @param off 符号在压缩流 kallsyms_names 中的起始偏移量。
 * @param result 用于存储解压后字符串的缓冲区。
 * @param maxlen 结果缓冲区的最大长度。
 * @return 压缩流中下一个符号的起始偏移量。
 * @note 如果解压后的字符串超过maxlen,它将被截断。
 */
static unsigned int kallsyms_expand_symbol(unsigned int off,
					   char *result, size_t maxlen)
{
	int len, skipped_first = 0;
	const char *tptr;
	const u8 *data;

	// 从第一个字节获取压缩符号的长度。
	data = &kallsyms_names[off];
	len = *data;
	data++;
	off++;

	// 如果最高有效位(MSB)为1,表示这是一个"大"符号,需要一个额外的字节来表示长度。
	if ((len & 0x80) != 0) {
		len = (len & 0x7F) | (*data << 7);
		data++;
		off++;
	}

	// 更新偏移量,使其指向压缩流中下一个符号的开始位置。
	off += len;

	// 遍历压缩符号数据中的每一个字节(令牌索引)。
	while (len) {
		// 使用令牌索引从令牌表中间接找到对应的字符串片段。
		tptr = &kallsyms_token_table[kallsyms_token_index[*data]];
		data++;
		len--;

		// 遍历字符串片段中的每个字符。
		while (*tptr) {
			// 第一个令牌的第一个字符是符号类型,需要跳过。
			if (skipped_first) {
				if (maxlen <= 1)
					goto tail; // 缓冲区不足,跳转到末尾处理。
				*result = *tptr;
				result++;
				maxlen--;
			} else
				skipped_first = 1;
			tptr++;
		}
	}

tail:
	// 为结果字符串添加空终止符。
	if (maxlen)
		*result = '\0';

	// 返回下一个符号的偏移量。
	return off;
}

/**
 * @brief 获取符号的类型信息。
 * @param off 符号在压缩流中的起始偏移量。
 * @return 符号的类型字符(如 'T', 't' 等)。
 * @note 类型被编码为符号名称的第一个令牌的第一个字符。
 */
static char kallsyms_get_symbol_type(unsigned int off)
{
	/*
	 * 只获取第一个编码字节,在令牌表中查找它,
	 * 然后返回该令牌的第一个字符。
	 */
	return kallsyms_token_table[kallsyms_token_index[kallsyms_names[off + 1]]];
}


/**
 * @brief 根据符号的逻辑索引号,计算其在压缩符号名称流中的字节偏移量。
 * @param pos 符号的逻辑索引号(例如,第1000个符号)。
 * @return 该符号在 kallsyms_names 数组中的字节偏移量。
 */
static unsigned int get_symbol_offset(unsigned long pos)
{
	const u8 *name;
	int i, len;

	// [一级查找] 使用 markers 数组进行粗粒度定位,快速跳转到附近。
	name = &kallsyms_names[kallsyms_markers[pos >> 8]];

	// [二级查找] 从标记点开始,顺序扫描余下的符号(最多255个)。
	for (i = 0; i < (pos & 0xFF); i++) {
		len = *name; // 第一个字节是长度。

		// 处理可变长度编码。
		if ((len & 0x80) != 0)
			len = ((len & 0x7F) | (name[1] << 7)) + 1;

		// 将指针移动到下一个符号的起始处。
		name = name + len + 1;
	}

	// 返回最终计算出的精确偏移量。
	return name - kallsyms_names;
}

/**
 * @brief 获取指定索引号的符号的运行时内存地址。
 * @param idx 符号的逻辑索引号。
 * @return 符号的绝对内存地址。
 */
unsigned long kallsyms_sym_address(int idx)
{
	// 地址 = 内核相对基地址 + 符号的32位偏移量。
	return kallsyms_relative_base + (u32)kallsyms_offsets[idx];
}

kernel/ksyms_common.c

kallsyms_show_value: 内核符号地址的权限控制

本代码片段定义了决定是否在 /proc/kallsyms 的输出中显示内核符号地址(即内存地址值)的核心安全策略。其主要功能是通过 kallsyms_show_value 函数,根据系统范围的配置参数(kptr_restrictsysctl_perf_event_paranoid)以及发起读取请求的进程所拥有的能力(CAP_SYSLOG),来动态地决定是否向该进程暴露内核指针。

实现原理分析

此安全策略的实现是一种典型的、分层级的权限检查模型,它允许系统管理员根据安全需求在不同的严格等级之间进行选择。

  1. 核心控制旋钮 (kptr_restrict) : kallsyms_show_value 函数的行为主要由 kptr_restrict 这个 sysctl 参数驱动,它定义了三种不同的策略:

    • kptr_restrict = 2 (最严格) : 这是 switch 语句的 default 情况。在此模式下,函数总是返回 false,即使用户是 root(拥有所有能力),也无法看到内核地址。地址会被显示为 0000000000000000
    • kptr_restrict = 1 (默认) : 在此模式下,函数会通过 security_capable 检查当前进程是否拥有 CAP_SYSLOG 能力。通常只有 root 用户或被特意授权的进程才拥有此能力。检查通过则返回 true,允许显示地址。
    • kptr_restrict = 0 (最宽松) : 在此模式下,权限检查的逻辑通过 fallthrough 关键字被扩展了。它首先调用 kallsyms_for_perf() 进行一个额外的检查。
  2. 为性能分析工具开放特例 (kallsyms_for_perf) : 这个辅助函数是为了方便 perf 等性能分析工具的使用而设计的。这些工具需要内核符号地址来进行正确的符号解析,但运行它们的用户可能不是 root。此函数会检查另一个 sysctl 参数 sysctl_perf_event_paranoid

    • 如果 sysctl_perf_event_paranoid <= 1,意味着系统管理员允许非特权用户进行性能分析,此时函数返回 true
    • 结合 kptr_restrict = 0,这意味着即使用户没有 CAP_SYSLOG,只要 perf 的偏执等级设置得足够低,他也能看到内核符号地址。
    • 如果 kallsyms_for_perf() 返回 falseswitch 语句会因为 fallthrough 而继续执行 case 1 的逻辑,即退回到检查 CAP_SYSLOG

综上,整个决策流程是:首先看 kptr_restrict 的等级,如果等级允许,再根据具体情况检查 perf 配置或 CAP_SYSLOG 能力。

代码分析

c 复制代码
// SPDX-License-Identifier: GPL-2.0-only
/*
 * ksyms_common.c: 从 kernel/kallsyms.c 中分离出的部分
 * 包含一些独立于 KALLSYMS 配置的通用函数定义。
 */
#include <linux/kallsyms.h>
#include <linux/security.h>

// kallsyms_for_perf: 检查系统配置是否允许为性能分析工具(如perf)显示符号。
// 返回值: 1 表示允许, 0 表示不允许。
static inline int kallsyms_for_perf(void)
{
// 仅在内核编译时配置了PERF_EVENTS时,此逻辑才有效。
#ifdef CONFIG_PERF_EVENTS
	// 引用在别处定义的perf事件偏执等级sysctl变量。
	extern int sysctl_perf_event_paranoid;

	// 如果perf的偏执等级小于等于1(即允许用户进行内核分析),则返回1。
	if (sysctl_perf_event_paranoid <= 1)
		return 1;
#endif
	// 默认或在更高偏执等级下,返回0。
	return 0;
}

/*
 * 如果我们已经启用了内核分析并且明确地不处于偏执状态(即kptr_restrict
 * 已清除,并且sysctl_perf_event_paranoid未设置),我们甚至向普通用户
 * 显示kallsyms信息。
 *
 * 否则,要求拥有CAP_SYSLOG能力(假设kptr_restrict没有设置到
 * 连这也阻止的程度)。
 */
// kallsyms_show_value: 判断是否应该向特定进程显示内核符号的地址值。
// @cred: 指向当前进程凭证结构体的常量指针。
// 返回值: true 表示应该显示, false 表示不应显示(显示为0)。
bool kallsyms_show_value(const struct cred *cred)
{
	// 根据系统sysctl参数kptr_restrict的值来决定策略。
	switch (kptr_restrict) {
	case 0: // 最宽松的模式。
		// 检查是否满足为perf工具显示的条件。
		if (kallsyms_for_perf())
			return true; // 如果满足,则直接允许。
		fallthrough; // 否则,继续执行case 1的检查逻辑。
	case 1: // 默认模式。
		// 检查当前进程是否拥有CAP_SYSLOG能力。
		// security_capable返回0表示拥有该能力。
		if (security_capable(cred, &init_user_ns, CAP_SYSLOG,
				     CAP_OPT_NOAUDIT) == 0)
			return true; // 如果拥有,则允许。
		fallthrough; // 否则,继续执行default的逻辑。
	default: // 包括case 2及以上,最严格的模式。
		// 总是拒绝显示地址。
		return false;
	}
}
相关推荐
emiya_saber3 小时前
Linux 进程调度管理
linux·运维·服务器
!chen3 小时前
CPP 学习笔记 语法总结
c++·笔记·学习
不脱发的程序猿3 小时前
嵌入式Linux:线程同步(读写锁)
linux·嵌入式
yangzhi_emo3 小时前
配置dns主从服务
linux·运维·服务器
现在,此刻3 小时前
李沐深度学习笔记D3-线性回归
笔记·深度学习·线性回归
SongYuLong的博客4 小时前
Linux开源代码汇总
linux·运维·服务器
JiMoKuangXiangQu4 小时前
Linux 内存管理 (5):buddy 内存分配简要流程
linux·内存管理·buddy 分配器
铭哥的编程日记4 小时前
【Linux网络】应用层协议HTTP
linux·运维·http
d111111111d4 小时前
STM32外设学习--DMA直接存储器读取(AD扫描程序,DMA搬运)--学习笔记。
笔记·stm32·单片机·嵌入式硬件·学习