MEMORY | INTERRUPT | TIMER | 并发与同步 | 进程管理 | 调度 | uboot | DTB | ARMV8 | ATF | Kernel Data Structure | PHY | LINUX2.6 | 驱动合集 | UART子系统 | USB专题 |

linux内核$(kallsyms.o)详解续篇 --- 内核符号表的生成和查找过程(十)

在内核中维护者一张符号表,记录了内核中所有的符号(函数、全局变量等)的地址以及名字(非栈变量),这个符号表(.tmp_vmlinux2.o)被嵌入到内核镜像中,使得内核可以在运行过程中随时获得一个符号地址对应的符号名。而内核代码中可以通过调用 __print_symbol(const char *fmt, unsigned long address)打印符号名。

接下来我们就来介绍内核符号表的生成和查找过程。

一. System.map和/proc/kallsyms区别联系

1. System.map文件
System.map文件是编译内核时生成的,它记录了内核中的符号列表,以及符号在内存中的虚拟地址。这个文件实际上是通过调用scripts/mksysmap脚本在脚本中又调用nm命令生成的。详细的讲解请移步到linux内核vmlinux的编译过程(七)

System.map中每个条目由三部分组成,例如:

c0008000 T __init_begin

即“地址 符号类型 符号名”

其中符号类型有如下几种:

符号类型名英文解释中文解释
AAbsolute符号的值是绝对值,并且在进一步链接过程中不会被改变
BUninitialised data (.bss)符号在未初始化数据区或区(section)中,即在BSS段中
CComonsymbol符号是公共的。公共符号是未初始化的数据。在链接时,多个公共符号可能具有同一名称。如果该符号定义在其他地方,则公共符号被看作是未定义的引用
DInitialised data符号在已初始化数据区中
GInitialised data for small objects符号是在小对象已初始化数据区中的符号。某些目标文件的格式允许对小数据对象(例如一个全局整型变量)可进行更有效的访问
IIndirectreference to another symbol符号是对另一个符号的间接引用
NDebugging symbol符号是一个调试符号
RReadonly符号在一个只读数据区中
SUninitialised data for small objects符号是小对象未初始化数据区中的符号
TTextcode symbol符号是代码区中的符号
UUndefined symbol符号是外部的,并且其值为0(未定义)
VWeaksymbol弱符号
WWeaksymbol弱符号
-Stabs符号是a.out目标文件中的一个stab符号,用于保存调试信息
?Unknown符号的类型未知,或者与具体文件格式有关

2. /proc/kallsyms文件
/proc/kallsyms文件是在内核启动后生成的,位于文件系统的/proc目录下,实现代码见kernel/kallsyms.c。前提是内核必须打开CONFIG_KALLSYMS编译选项,这一点我已经在linux内核vmlinux的编译过程之 — $(kallsyms.o)详解(九)中有讲到。注意:它和System.map的区别在于它同时包含了内核模块的符号列表。此外内核启动后的/proc/kallsyms文件中的符号表只是给用户态开放了一个可以操作的接口,非人为特意操作,内核一般不会使用它们。

通常情况下我们只需要_stext ~ _etext 和 _sinittext ~ _einittext之间的符号,如果需要将nm命令获得的所有符号都记录下来,则需要开启内核的CONFIG_KALLSYMS_ALL编译选项。

二. 内核符号表
内核在执行过程中,可能需要获得一个地址所在的函数名。比如发生Oops的时候,比如在输出某些调试信息的时候 - - - 使用dump_stack()函数打印栈回溯信息等。

但是内核在查找一个地址对应的函数名时,并没有没有求助于上述两个文件,而是在编译内核时,向vmlinux嵌入了一个符号表(.tmp_vmlinux2.o),这样做可能是为了方便快速的查找且避免文件操作带来的不良影响。

2.1 内核符号表的结构(重难点)
内嵌的符号表是通过内核目录下的scripts/kallsyms工具生成的,工具的源码为相同目录下的kallsyms.c。具体的生成过程请移步到linux内核vmlinux的编译过程之 — $(kallsyms.o)详解(九)。汇编文件 .tmp_kallsyms2.S中包含了6个全局变量:

1. kallsyms_addresses 数组
数组记录了所有内核符号的地址(已经按顺序排列好),v2.6.0 中相同的地址在kallsyms_addresses中只允许出现一次,到后面的版本例如相同的地址可以出现多次,这样就允许同地址函数名的出现。
例如:

.globl kallsyms_addresses
	ALGN
kallsyms_addresses:
	PTR	_text - 0x160000
	PTR	_text - 0x160000
	PTR	_text - 0x160000
	PTR	_text - 0x160000
	PTR	_text - 0x15ffc4
	PTR	_text - 0x15fed8

当查找某个地址时所在的函数时,v2.6.0 采用的是线性法,从头到尾地找,很低效,后来改成了二分法查找,提高了查找的效率。

2. kallsyms_num_syms
统计了内核中所有符号的数量。

3. kallsyms_names数组
也是一个数组,存放所有符号的名称,和kallsyms_addresses 一 一对应。并且使用了偏移索引和高频字符串压缩的方式(后面会进行相应的讲解)。格式如下:

.byte len ascii字符 ascii字符 ...(len个ascii字符)

例如:

.globl kallsyms_names
	ALGN
kallsyms_names:
	.byte 0x04, 0xcd, 0xbc, 0x78, 0x74
	.byte 0x06, 0x54, 0xfd, 0xc7, 0xbc, 0x78, 0x74
	.byte 0x05, 0x54, 0xfd, 0xbc, 0x78, 0x74

4. kallsyms_token_table和kallsyms_token_index
kallsyms_addresses、kallsyms_num_syms和kallsyms_names在前面已经讲过,实际上他们已经可以提供一个[地址 : 符号]的映射关系了,但是内核中几万个符号这样一条一条的存起来会占用大量的空间,所以内核采用一种压缩算法,将所有符号中出现频率较高的字符串记录成一个个的token,然后将原来的符号中和token匹配的子串进行压缩,这样可以实现使用一个字符来代替n个字符,以减小符号存储长度。

因此符号表维护了一个kallsyms_token_table,他有256个元素,对应一个字节的长度。由于符号名的只能出现下划线、数字和字母,那在kallsyms_token_table[256]数组中,除了这些字符的ASCII码对应的位置,还有很多未被使用的位置就可以用来存储压缩串。kallsyms_token_table表的内容如下所示:

.globl kallsyms_token_table
	ALGN
kallsyms_token_table:
	.asciz	"param"
	.asciz	"ion"
	.asciz	"T__"
	.asciz	"ino"
	.asciz	"for"
	.asciz	"lock_"
	.asciz	"tab___"
	.asciz	"ack"
	.asciz	"t__func__.1"
	.asciz	"write"
	...
	.asciz	"a"
	.asciz	"b"
	.asciz	"c"
	.asciz	"d"
	.asciz	"e"
	.asciz	"f"
	.asciz	"g"
	.asciz	"h"
	.asciz	"i"
	.asciz	"j"
	.asciz	"k"
	.asciz	"l"
	.asciz	"m"
	...

那我们在表示一个符号名称时,就可以用0x00来表示“param”,用0x09来表示“write”等。没有被压缩的如0x61仍然表示“a” (不同的配置和内核源码会有一定的区别)。kallsyms_token_index记录每个token首字符在kallsyms_token_table中的偏移值(以字节为单位)。同token table共256条。kallsyms_token_index表的内容如下所示:

.globl kallsyms_token_index
	ALGN
kallsyms_token_index:
	.short	0
	.short	6       //l例如"ion"偏移了字符串"param"大小的字节 
	.short	10
	.short	14
	.short	18
	.short	22
	.short	28
	.short	35
	.short	39
	.short	51
	...

至于kallsyms_token_table表是如何生成的,可以阅读scripts/kallsyms.c的实现,大致就是将所有符号出现的相邻的两个字符出现的次数都记录起来,例如对于“nf_nat_nf_init”,就记录下“nf”、“f_”、“n”、“na”、……,每两个字符组合出现的次数记录在token_profit[0x10000]数组中(两个字符一组,共有2^8 * 2^8 = 0x10000中可能组合),然后挑选出现次数最多的一个组合形成一个token,比如用“g”来表示“nf”,那“nf_nat_nf_init”就被改为“g_nat_g_init”。接下来,再在修改后的所有符号中计算每两个字符的出现次数来挑选出现次数最多的组合,例如用“J”来表示“g”,那“g_nat_g_init”又被改为“Jnat_Jinit”。直到生成最终的token表。

5. kallsyms_markers
把符号名每256个分一组,用一个数组kallsyms_markers记录这些组在kallsyms_names中的偏移量,这样查找就方便多了,不必从头来。

2.2 内核符号的查找过程

1. 举例分析
先不讲函数实现,只是用一个例子来说明内核符号的查找过程。比如我在内核中想打印出0xc0008128 地址所在的符号名。

(1)首先不关注内核怎么做,我们可以先在System.map文件中(注意:System.map和内核启动后的/proc/kallsyms文件中的符号表只是给我们看的,内核不会使用它们)看到这个地址位于为__create_page_tables和__enable_mmu两个符号之间。

c0168000 T _text
...
c000803c t __create_page_tables
c0008128 t __enable_mmu_loc
c0008134 t __enable_mmu
...

(2)在由script/kallsyms工具生成的.tmp_kallsyms2.S文件中,kallsyms_addresses数组存放着所有符号的地址,并且是按照地址升序排列的,所以通过二分查找可以定位到0xc0008128 所在符号的起始地址是下面的这个条目:

kallsyms_addresses:
	PTR	_text - 0x160000
	PTR	_text - 0x160000
	PTR	_text - 0x160000
	PTR	_text - 0x160000
	PTR	_text - 0x15ffc4
	PTR	_text - 0x15fed8
	PTR	_text - 0x15fed8    //0xc0168000  - 0x15fed8  = 0xc0008128 
	...

(3)而这一项在kallsyms_addresses中的索引值index为6,所以现在需要找到kallsyms_names中的第6个符号。我们这时实际上可以在kallsyms_names进行查找了,怎么找呢?我们先看一下kallsyms_names大致的样子:

kallsyms_names:
	.byte 0x04, 0xcd, 0xbc, 0x78, 0x74
	.byte 0x06, 0x54, 0xfd, 0xc7, 0xbc, 0x78, 0x74
	.byte 0x05, 0x54, 0xfd, 0xbc, 0x78, 0x74
	.byte 0x06, 0x02, 0x19, 0x62, 0x65, 0x67, 0xf5
	.byte 0x0a, 0xdb, 0x63, 0xf6, 0x21, 0xde, 0x67, 0xf3, 0xfb, 0xdf, 0x73
	.byte 0x0a, 0xdb, 0xdc, 0x61, 0x62, 0xa5, 0x2a, 0x75, 0x5f, 0xeb, 0x63
	.byte 0x07, 0xdb, 0xdc, 0x61, 0x62, 0xa5, 0x2a, 0x75
	.byte 0x0a, 0xdb, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x2a, 0x75, 0x5f, 0xdd
	...

其中每一行存储一个压缩后的符号,而index和kallsyms_addresses中的index是一一对应的。每一行的内容分为两部分:第一个byte指明符号的长度,后续才是符号自身。虽然我们这里看到的符号是一行一行分开的,但实际上kallsyms_names是一个unsigned char的数组,所以想要找第6个符号,只能这样来找:

  • 从第一个字节开始,获得第一个符号的长度len;
  • 向后移len+1个字节,就达到第二个符号的长度字节,这时记录下已经走过的总长度;
  • 重复前两步的动作,直到走过的总长度为6。

这样找的话,要找到kallsyms_names的第6个符号就要移动6次,这个移动量意识还可以接受。但是如果要寻找最后一个符号,就要移动更多次,时间耗费较多,所以内核通过一个kallsyms_markers数组进行查找。

将kallsyms_names每256个符号分为一组,每一组的第一个字符的位置记录在kallsyms_markers中,这样,我们在找kallsyms_names中的某个条目时,可以快速定义到它位于那个组,然后再在组内寻找,组内移动次数最多为255次。

所以我们先通过(6 >> 8)得到了要找的符号位于第0组,我们看到kallsyms_markers的第0项为:

kallsyms_markers:
	PTR	0
	PTR	2679
	PTR	5202
    ...

这个值指明了kallsyms_names中第0组的起始字符的偏移,所以我们直接找到kallsyms_names[0]位置,即是第0组所有符号的第一个字节。同时我们可以通过(6 && 0xFF)得到要找的符号在第0组组内的序号为6,即第97个符号。

接下来寻找第6个符号就只能通过上面讲到的方法了。

通过上面一系列的查找,我们定位到第0组中第6个符号如下:

.byte 0x07, 0xdb, 0xdc, 0x61, 0x62, 0xa5, 0x2a, 0x75

这个是压缩后的符号,第一个字节0x07是符号长度,所以我们接下来的任务就剩下解压了。

每个字节解压后对应的字符串在kallsyms_token_table中可以找到。

于是在kallsyms_token_table表中寻找第219(0xdb)项、第220(0xdc)项、第97(0x61)项、第98(0x62)项、第165(0xa5)项、第42(0x2a)项、第117(0x75)项,得到的结果分别为:

“t__”, “en”, “a”,“b”, “le_”,“mm”, “u”

由于在压缩的时候将符号类型“t”也压进去了,所以要去掉第一个字符,至此就获得了0xc0008128 地址所在的函数为__enable_mmu 。

2. 源码分析
下面我们来具体分析内核源码的实现(linux-2.6.38)
内核根据2.1提到的六个全局常量来查找一个地址对应的符号名,实现函数为kernel/kallsyms.c中的kallsyms_lookup()。

/*
 * Lookup an address
 * - modname is set to NULL if it's in the kernel.
 * - We guarantee that the returned name is valid until we reschedule even if.
 *   It resides in a module.
 * - We also guarantee that modname will be valid until rescheduled.
 * addr       :符号的地址(链接后的虚拟地址)
 * symbolsize :存放符号的大小
 *  offset    :存放所需查找的地址和小于该地址的符号表地址之间的差值.(前提条件是该地址在符号表中不存在,如果存在则为0)
 *  modname   :???
 *  namebuf   :查找到的字符
 */
const char *kallsyms_lookup(unsigned long addr,
			    unsigned long *symbolsize,
			    unsigned long *offset,
			    char **modname, char *namebuf)
{
	namebuf[KSYM_NAME_LEN - 1] = 0;
	namebuf[0] = 0;

/* 判断符号的地址是不是在 _stext —— _end之间,如果不是返回0*/
	if (is_ksym_addr(addr)) {
		unsigned long pos;
		
		/* 通过对get_symbol_pos函数的分析,pos代表找到匹配的地址在地址表中kallsym_addresses数组中的索引*/
		pos = get_symbol_pos(addr, symbolsize, offset);
		
		/* Grab name 该函数调用完成之后,符号的名称就存放在了namebuf中,并且返回,符号解析结束了*/
		kallsyms_expand_symbol(get_symbol_offset(pos), namebuf);
		if (modname)
			*modname = NULL;
		return namebuf;
	}

	/* See if it's in a module. */
	return module_address_lookup(addr, symbolsize, offset, modname,
				     namebuf);
}

下面对里面涉及的子函数进行详细的说明:
(1) 获取符号地址在符号地址表kallsym_addresses数组中的索引。注意这是个索引(即第多少个符号),而非偏移量。

static unsigned long get_symbol_pos(unsigned long addr,
				    unsigned long *symbolsize,
				    unsigned long *offset)
{
	unsigned long symbol_start = 0, symbol_end = 0;
	unsigned long i, low, high, mid;

	/* This kernel should never had been booted. */
	BUG_ON(!kallsyms_addresses);

	/* Do a binary search on the sorted kallsyms_addresses array. */
	low = 0;
	high = kallsyms_num_syms;

	/* 从这里我们看到:对符号的查询是由的是折半查找的方式 */
	while (high - low > 1) {
		mid = low + (high - low) / 2;
		if (kallsyms_addresses[mid] <= addr)
			low = mid;
		else
			high = mid;
	}

	/* 对重复地址所做的工作
	 * Search for the first aliased symbol. Aliased
	 * symbols are symbols with the same address.
	 */
	while (low && kallsyms_addresses[low-1] == kallsyms_addresses[low])
		--low;

	symbol_start = kallsyms_addresses[low];/* 得到符号的起始地址 */
	
	/* Search for next non-aliased symbol. */
	for (i = low + 1; i < kallsyms_num_syms; i++) {
		if (kallsyms_addresses[i] > symbol_start) {
			symbol_end = kallsyms_addresses[i]; /* 得到符号的结束地址,即下一个符号的起始地址 */
			break;
		}
	}

	/* If we found no next symbol, we use the end of the section. */
	if (!symbol_end) {
		if (is_kernel_inittext(addr))
			symbol_end = (unsigned long)_einittext;
		else if (all_var)
			symbol_end = (unsigned long)_end;
		else
			symbol_end = (unsigned long)_etext;
	}

	if (symbolsize)
		*symbolsize = symbol_end - symbol_start; /* 得到符号的大小 */
	if (offset)
 /* offset的含义就是我们所需查找的地址和小于该地址的符号表地址之间的差值。(前提条件是该地址在符号表中不存在,如果存在则为0)*/
       offset = addr - symbol_start; 
       
/* 返回的地址代表符号在符号地址表kallsym_addresses数组中的索引 */
	return low;
}

(2 得到符号索引值pos后,就继续来查找符号相对kallsyms_names的偏移量。注意偏移量是以字节为单位的

/* 
 * Find the offset on the compressed stream given and index in the
 * kallsyms array.
 * pos :在kallsyms_address中的索引。第xxx个符号,pos =xxx
 */
static unsigned int get_symbol_offset(unsigned long pos)
{
	const u8 *name;
	int i;

	/* linux内核为了便于查询,将kallsyms_names将符号每256个分为一组
	 * Use the closest marker we have. We have markers every 256 positions,
	 * so that should be close enough.
	 */
	name = &kallsyms_names[kallsyms_markers[pos >> 8]];

	/*
	 * Sequentially scan all the symbols up to the point we're searching
	 * for. Every symbol is stored in a [<len>][<len> bytes of data] format,
	 * so we just need to add the len to the current pointer for every
	 * symbol we wish to skip.
	 */
	for (i = 0; i < (pos & 0xFF); i++)
		name = name + (*name) + 1;/*(*name)是当前符号的长度值 */

	return name - kallsyms_names;
}

前提:linux内核为了便于查询,将kallsyms_names将符号每256个分为一组。

  • 首先,我们通过索引值pos查到该符号所属的组;
  • 然后,通过kallsyms_markers数组查询到该组的首个符号在kallsyms_names中的偏移量(以字节为单位)。也就是说,通过name = &kallsyms_names[kallsyms_markers[pos >> 8]];name就指向了该索引值pos所在组的第一个符号的起始字符。—主要为了提高查找效率
  • 紧接着,显然pos并不一定就是该组的第一个符号,那么接下来,我们就要从所找到组的第一个符号的起始字符起,开始查找,直到找到索引值pos指向的符号地址为止,这就是for循环干的事情。每个符号由length+1个字节组成,其中第一个字节就是该符号的长度,接下来的length才是符号的内容。for循环所做的工作就是读取每个符号的第一个字符,获取该符号的长度,然后name向后移动*name+1个字节,这样就指向了下一个符号。
  • 返回符号相对kallsyms_names的偏移量(字节)。

(3) 得到符号相对kallsyms_names的偏移量(字节)后,就要对符号进行解析了。

/*
 * Expand a compressed symbol data into the resulting uncompressed string,
 * given the offset to where the symbol is in the compressed stream.
 */
static unsigned int kallsyms_expand_symbol(unsigned int off, char *result)
{
	int len, skipped_first = 0;
	const u8 *tptr, *data;

	/* Get the compressed symbol length from the first symbol byte. */
	data = &kallsyms_names[off];
	len = *data; /* 符号的第一个字符就是符号的长度,在这里赋值给了变量len */
	data++; /* 将data指向了符号的下一个字节,这就是符号真正的内容了 */

	/*
	 * Update the offset to return the offset for the next symbol on
	 * the compressed stream.
	 */
	off += len + 1; /* 符号长度占一个字节 */

	/*
	 * For every byte on the compressed symbol data, copy the table
	 * entry for that byte.
	 */
	while (len) {
	/* 前面我们介绍过,为了提高查询速度,我们将常用的字符串存储在kallsyms_token_table中,kallsyms_token_index记录每个ascii
	字符的替代串在kallsyms_token_table中的偏移值(以字节为单位)*/
		tptr = &kallsyms_token_table[kallsyms_token_index[*data]];
		data++; /* data指向下一个要解析的字节 */。
		len--;

		while (*tptr) {
		/* 跳过第一个字符,因为它不是真正的符号的内容,而是符号的类型.将该字符串赋给了result。
		然后依次解析每个数据,这样,result中就存放了符号的名字了*/
			if (skipped_first) {
				*result = *tptr;
				result++;
			} else
				skipped_first = 1;
			tptr++;
		}
	}

	*result = '\0';

	/* Return to offset to the next symbol. */
	return off; /* off返回的是下一个符号在kallsyms_names中的偏移值(字节为单位)*/
}

font color=black size=3>2.3 内核模块符号的查找过程
内核模块是在内核启动过程中动态加载到内核中的,所以,不能试图将模块中的符号嵌入到vmlinux中。加载模块时,模块的符号表被存放在该模块的struct module结构中。所有已加载的模块的structmodule结构都放在一个全局链表中。

在查找一个内核模块的符号时,调用的函数入口依然是kallsyms_lookup(),模块符号的实际查找工作在get_ksymbol()函数中完成,在这里我就不再讲解了,有兴趣的可以自己去看看。

参考博客链接地址:
Linux kallsyms 机制分析。注意这篇博文里面有些索引和偏移量的概念说法不是很严谨,注意甄别。
内核符号表的生成和查找过程

posted on 2022-11-02 22:23  BSP-路人甲  阅读(1316)  评论(0编辑  收藏  举报

导航