@[toc]

Linux内核kallsyms符号压缩与解压机制

在这里插入图片描述

1. 引言:为何需要kallsyms?

在Linux内核的运行过程中,当发生错误(Oops)、进行性能剖析(Profiling)或使用调试器(Debugger)时,系统需要将内存中的函数地址转换为人类可读的符号名称。例如,将地址0xffffffff810a43c0转换为printk。这个地址到符号的映射表就是kallsyms(Kernel All Symbols)。

然而,内核包含数以万计的符号,如果将所有符号名称作为原始字符串直接存储在内核镜像中,会占用数兆字节的宝贵内存。为了解决这个问题,内核在编译时采用了一种高效的**“查表压缩”**方案,将符号名称字符串压缩成紧凑的字节序列。本文将深入剖析这一压缩数据的结构以及内核在运行时如何对其进行解压,还原出原始的符号名称。

1
2
3
4
5
6
7
8
9
10
11
12
graph TD
subgraph "内核编译时"
A["所有符号名称"] --> B("scripts/kallsyms");
B --> C{"符号压缩"};
C --> D["生成压缩数据表"];
end
subgraph "内核运行时"
E["内存地址, e.g., 0xffffffff810a43c0"] --> F("kallsyms子系统");
F --> G{"符号解压"};
G --> H["符号名称, e.g., 'printk'"];
end
D --> F;

2. 压缩数据的“蓝图”:核心数据结构

要理解解压过程,首先必须了解压缩数据的存储格式。kallsyms的核心由多个紧密相关的数据表构成,它们在内核编译链接后被静态地嵌入到内核镜像中。

  • kallsyms_offsetskallsyms_relative_base: 这两者共同构成了符号地址表。kallsyms_offsets是一个32位无符号整数数组,存储了每个符号相对于基地址kallsyms_relative_base的偏移。通过kallsyms_sym_address(index)函数(其实现为 kallsyms_relative_base + kallsyms_offsets[index]),我们可以得到一个按地址排序的符号地址列表,这是实现快速地址查找(二分查找)的基础。
  • kallsyms_names: 核心的压缩符号数据。这是一个巨大的字节数组,所有符号的名称信息经过压缩后都存储在这里。
  • kallsyms_token_table: “字典表”。这是一个包含数千个常见符号片段(如"irq", "lock", "__", "init"等)的巨大字符串,每个片段以\0结尾。
  • kallsyms_token_index: “字典索引表”。这是一个整数数组,kallsyms_token_index[i]存储了第i个片段在kallsyms_token_table中的起始偏移量。
  • kallsyms_markers: “标记表”。用于加速在kallsyms_names中的查找。kallsyms_markers[i]存储了第 i * 256 个符号在kallsyms_names中的起始偏移量。

它们之间的关系如下图所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
graph TD
subgraph "压缩符号数据 (kallsyms_names)"
A("符号A: [len, T1, T2, T3, ...]");
end

subgraph "字典索引 (kallsyms_token_index)"
B["..."];
C["index[T1] = offset1"];
D["index[T2] = offset2"];
E["..."];
end

subgraph "字典表 (kallsyms_token_table)"
F["... \0 token_x \0 ..."];
G["... \0 token_1 \0 ..."];
H["... \0 token_2 \0 ..."];
I["..."];
end

A -- "使用索引T1" --> C;
A -- "使用索引T2" --> D;
C -- "得到偏移offset1" --> G;
D -- "得到偏移offset2" --> H;

style A fill:#f9f,stroke:#333,stroke-width:2px
style C fill:#ccf,stroke:#333,stroke-width:2px
style G fill:#cfc,stroke:#333,stroke-width:2px

3. 解压核心:kallsyms_expand_symbol

此函数是整个机制的核心,负责将kallsyms_names中的一段压缩数据还原成一个完整的符号字符串。

完整代码与Doxygen注释
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
/**
* @brief kallsyms_expand_symbol - 将一段压缩的符号数据解压成字符串。
*
* 此函数根据“查表压缩”算法,将存储在kallsyms_names中的符号数据展开。
* 压缩的数据格式为:[长度][Token 1][Token 2]...
* 长度本身是变长的,如果最高位为1,则需要两个字节表示。
* 每个Token是一个索引,用于在kallsyms_token_table中查找对应的字符串片段。
* 所有片段(除了第一个片段的首字符,即符号类型)拼接起来构成最终的符号名。
*
* @param off 待解压符号在全局kallsyms_names数组中的起始偏移量。
* @param result 用于存放解压后字符串的输出缓冲区。
* @param maxlen 输出缓冲区的最大长度,防止溢出。
* @return 下一个符号在kallsyms_names中的起始偏移量。
*/
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;

/* 从第一个字节获取压缩后的长度(即Token的数量) */
data = &kallsyms_names[off];
len = *data;
data++;
off++;

/* 如果长度的最高位(MSB)为1,说明这是一个“大符号”,
* 长度由两个字节编码而成(低7位 + 第二个字节左移7位)。
*/
if ((len & 0x80) != 0) {
len = (len & 0x7F) | (*data << 7);
data++;
off++;
}

/* 更新偏移量,使其指向下一个符号的起始位置,作为返回值。*/
off += len;

/* 循环len次,每次处理一个Token。*/
while (len) {
/*
* *data 是一个Token索引。
* 1. kallsyms_token_index[*data] 找到该Token在字典表中的偏移。
* 2. &kallsyms_token_table[...] 获取该Token字符串的指针。
*/
tptr = &kallsyms_token_table[kallsyms_token_index[*data]];
data++;
len--;

/* 将获取到的Token字符串追加到result缓冲区。*/
while (*tptr) {
/*
* 特殊处理:第一个Token的第一个字符是符号类型(如'T', 't'),
* 不属于符号名称,必须跳过。
*/
if (skipped_first) {
if (maxlen <= 1)
goto tail;
*result = *tptr;
result++;
maxlen--;
} else
skipped_first = 1;
tptr++;
}
}

tail:
if (maxlen)
*result = '\0';

/* 返回下一个符号的起始偏移量。*/
return off;
}
执行流程图
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
graph TD
S["开始: kallsyms_expand_symbol(off, ...)"] --> A{"读取第一个字节: 压缩长度(len)"};
A --> B{"len的最高位(MSB)是否为1?"};
B -- "是 (大符号)" --> C["读取第二个字节, 计算真实长度: len = (len & 0x7F) | (byte2 << 7)"];
B -- "否 (普通符号)" --> D["len即为压缩长度"];
C --> E["进入解压循环"];
D --> E;
E --> F{"循环 len 次"};
F -- "循环中" --> G["从数据流中读取一个字节(token_index)"];
G --> H["在 kallsyms_token_index 中查找: offset = kallsyms_token_index[token_index]"];
H --> I["在 kallsyms_token_table 中定位: token_ptr = &kallsyms_token_table[offset]"];
I --> J{"是否为第一个Token的第一个字符?"};
J -- "是" --> K["跳过 (该字符为符号类型)"];
J -- "否" --> L["将 token_ptr 指向的字符串追加到 result 缓冲区"];
K --> M{"循环结束?"};
L --> M;
M -- "否" --> F;
M -- "是" --> N["在 result 缓冲区末尾添加 '\0'"];
N --> O["返回下一个符号的偏移量"];
O --> E_END["结束"];

4. 定位压缩数据:get_symbol_offset

当内核需要查找第pos个符号时,此函数用于在kallsyms_names中快速定位其压缩数据的起始偏移。

完整代码与Doxygen注释
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/**
* @brief get_symbol_offset - 根据符号的全局索引,获取其在压缩数据流中的偏移量。
*
* 为了避免从头线性扫描整个kallsyms_names表,该函数使用kallsyms_markers
* 进行加速。kallsyms_markers是一个标记数组,每隔256个符号记录一个偏移量。
*
* 查找过程分两步:
* 1. 大步跳转:利用 markers 表直接跳转到离目标位置不远的地方。
* 2. 短程扫描:从标记位置开始,线性扫描最多255个符号,找到精确位置。
*
* @param pos 要查找的符号的全局索引 (0 to kallsyms_num_syms-1)。
* @return 该符号在kallsyms_names中的起始偏移量。
*/
static unsigned int get_symbol_offset(unsigned long pos)
{
const u8 *name;
int i, len;

/*
* 使用最近的标记。标记每256个位置有一个,这已经足够近了。
* pos >> 8 相当于 pos / 256,用于在markers数组中找到正确的起点。
*/
name = &kallsyms_names[kallsyms_markers[pos >> 8]];

/*
* 从标记位置开始,顺序扫描剩余的符号,直到目标位置。
* pos & 0xFF 相当于 pos % 256,即需要扫描的符号数量。
* 每个符号的格式是 [<len>][<len> bytes of data],我们只需读取长度
* 并跳过相应字节即可,无需解压。
*/
for (i = 0; i < (pos & 0xFF); i++) {
len = *name;

/*
* 如果是“大符号”(MSB为1),长度由两个字节构成,
* 所以总跳跃长度要额外加1。
*/
if ((len & 0x80) != 0)
len = ((len & 0x7F) | (name[1] << 7)) + 1;

name = name + len + 1;
}

return name - kallsyms_names;
}
执行流程图
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
graph TD
subgraph "kallsyms_names (巨大的字节数组)"
direction LR
S(Start) -- "符号0..255" --> M1("Marker[0]指向的位置");
M1 -- "符号256..511" --> M2("Marker[1]指向的位置");
M2 -- "..." --> END(...);
end

subgraph "查找第600个符号 (pos=600)"
A["开始: get_symbol_offset(600)"] --> B{"计算marker索引: 600 / 256 = 2"};
B --> C["跳转到 kallsyms_names[kallsyms_markers[2]]"];
C --> D{"计算剩余扫描次数: 600 % 256 = 88"};
D --> E["从Marker[2]位置开始, 向后线性扫描88个符号"];
F["扫描时仅读取长度并跳跃, 不解压"];
E --> F;
F --> G["最终指针位置即为第600个符号的偏移"];
end

style M1 fill:#add,stroke:#333,stroke-width:2px
style M2 fill:#add,stroke:#333,stroke-width:2px

5. 地址到符号的桥梁:get_symbol_pos

此函数负责根据一个给定的内存地址,反向查找出它属于哪个符号。

完整代码与Doxygen注释
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
/**
* @brief get_symbol_pos - 根据内存地址查找对应的符号索引。
*
* 此函数在一个按地址排序的符号列表中,查找包含给定地址`addr`的符号。
* 它返回该符号的全局索引`pos`。
*
* 核心操作是二分查找,作用于通过kallsyms_sym_address()动态计算出的
* 地址列表上。这非常高效。
*
* @param addr 要查找的内存地址。
* @param symbolsize (输出) 用于存储找到的符号的大小。
* @param offset (输出) 用于存储`addr`相对于符号起始地址的偏移量。
* @return 找到的符号的全局索引`pos`。
*/
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;

/* 在kallsyms_offsets数组上进行二分查找。*/
low = 0;
high = kallsyms_num_syms;

while (high - low > 1) {
mid = low + (high - low) / 2;
if (kallsyms_sym_address(mid) <= addr)
low = mid;
else
high = mid;
}

/*
* low现在是最后一个地址 <= addr 的符号索引。
* 但可能存在多个符号地址相同(别名),我们需要找到第一个。
*/
while (low && kallsyms_sym_address(low-1) == kallsyms_sym_address(low))
--low;

symbol_start = kallsyms_sym_address(low);

/* 查找下一个不同地址的符号,以确定当前符号的大小。*/
for (i = low + 1; i < kallsyms_num_syms; i++) {
if (kallsyms_sym_address(i) > symbol_start) {
symbol_end = kallsyms_sym_address(i);
break;
}
}

/* 如果没找到下一个符号,使用内核代码段的末尾地址。*/
if (!symbol_end) {
if (is_kernel_inittext(addr))
symbol_end = (unsigned long)_einittext;
else if (IS_ENABLED(CONFIG_KALLSYMS_ALL))
symbol_end = (unsigned long)_end;
else
symbol_end = (unsigned long)_etext;
}

if (symbolsize)
*symbolsize = symbol_end - symbol_start;
if (offset)
*offset = addr - symbol_start;

return low;
}

6. 全流程回顾

当内核需要为一个地址(addr)查找符号名时,整个过程被完美地串联起来:

1
2
3
4
5
6
7
graph TD
A["输入: 内存地址 addr"] --> B("get_symbol_pos");
B -- "在符号地址列表上进行二分查找" --> C["输出: 符号索引 pos"];
C --> D("get_symbol_offset");
D -- "使用 markers 跳转 + 短程扫描" --> E["输出: 压缩数据偏移 off"];
E --> F("kallsyms_expand_symbol");
F -- "查字典表并拼接" --> G["输出: 原始符号字符串"];
  1. 结论
    Linux内核的kallsyms机制是一个精巧的空间换时间设计典范。它通过基于字典的查表压缩算法,极大地减小了符号表在内核镜像中的体积。同时,借助markers等辅助索引结构,它又保证了在需要反向查找符号时,能够以可接受的性能开销(二分查找 + 大步跳转 + 短程扫描)高效地完成解压任务,为内核的调试和可观测性提供了坚实的基础。