链接过程的本质就是要把多个不同的目标文件之间相互粘到一起, 当然这些目标文件之间必须有固定的规则. 在链接中, 目标文件之间相互拼合实际上是目标文件之间对地址的引用, 即对函数和变量的地址的引用
比如目标文件 B 要用到目标文件 A 中的函数 foo
, 那么我们就称目标文件 A 定义(define)了函数 foo, 目标文件 B 引用(reference)了目标文件 A 中的函数 foo, 这两个概念也同样适用于变量
每一个函数或变量都有自己独特的名字, 这样才能避免链接过程中不同变量和函数之间的混淆.
在链接中, 我们将函数和变量统称为符号(Symbol), 函数名或变量名 称为 符号名(symbol name). 每一个目标文件都会有一个相应的符号表(symbol table), 每一个定义的符号都有一个对应的值叫做符号值(symbol value).
对于变量和函数来说, 符号值就是它们的地址
这里需要注意的是符号值是符号的地址, 并不是a=1这种实际的变量值. 变量值我们会单独保存在数据段中
依然以前文的代码为例
int printf(const char *format, ...);
int global_init_var = 84;
int global_uninit_var;
void func1(int i) {
printf("%d\n",i);
}
int main(void) {
static int static_var = 85;
static int static_var2;
int a = 1;
int b;
func1(static_var + static_var2 + a + b);
}
该文件编译得到的符号表中的符号一定是下面的类型之一:
func1
, main
global_init_var
printf
.text
.data
static_var
static_var2
, 局部符号只在编译单元内部可见, 也就是说被 static 修饰的变量或函数只在当前文件可以被使用, 其他文件无法使用. 调试器可以使用这些符号来分析, 但链接器会忽略这些局部符号局部非静态变量比如 a, b 是不会进入符号表中的, 它们会在栈中分配内存空间.
对于我们来说最值得关注的就是全局符号, 也就是1和2. 因为链接的过程只关心全局符号的相互粘合, 局部符号,段名行号都是次要的, 它们对于其他目标文件来说都是不可见的, 在链接过程中也是无关紧要的
我们可以使用很多工具来查看一个 ELF 文件的符号表
objdump -t SimpleSection.o
readelf -s SimpleSection.o
nm SimpleSection.o
0000000000000000 T func1
0000000000000000 D global_init_var
0000000000000000 B global_uninit_var
000000000000002b T main
U printf
0000000000000004 d static_var.1
0000000000000004 b static_var2.0
符号字母 | 含义 |
---|---|
T | 代码符号,表示该符号是一个函数或者可执行代码. |
t | 本地代码符号,表示该符号是本地的函数或代码,仅在当前目标文件中可见. |
D | 数据符号,表示该符号是一个初始化的全局变量或静态变量. |
d | 本地数据符号,表示该符号是本地的已初始化数据,仅在当前目标文件中可见. |
B | BSS符号,表示该符号是一个未初始化的全局变量或静态变量. |
b | 本地BSS符号,表示该符号是本地的未初始化数据,仅在当前目标文件中可见. |
U | 未定义符号,表示该符号在当前目标文件中未定义,需要在链接时从其它目标文件或库文件中解析. |
R | 重定位符号,表示该符号需要在链接时进行重定位. |
r | 本地重定位符号,表示该符号是本地的需要进行重定位的符号,仅在当前目标文件中可见. |
C | 弱符号,表示该符号是一个弱符号,其定义可以被覆盖. |
W | 弱符号,表示该符号是一个弱符号,其引用可以不被解析,或者可以被重复解析. |
S | 特殊符号,表示该符号是一个特殊符号(例如,汇编代码中的符号). |
V | 可变符号,表示该符号是一个可变符号(例如,C++虚函数). |
I | 间接符号,表示该符号是一个间接符号,需要进一步解析. |
ELF 文件中可能会出现多个符号表, 需要遍历所有段并判断其类型属于 SHT_SYMTAB
或 SHT_DYNSYM
这里的 SHT_SYMTAB 对应静态符号表, SHT_DYNSYM 对应动态符号表
其段表字段值 sh_link
指向对应的字符串表(通常是 .strtab), st_info
的低4位用于符号类型, 高4位用于符号绑定信息
比如使用 readelf -S
查看一个可执行文件, 其中 .symtab 的 link 指向 .strtab, .dynsym 的 link 指向 .dynstr
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 6] .dynsym DYNSYM 00000000000003d8 000003d8
00000000000002e8 0000000000000018 A 7 1 8
[ 7] .dynstr STRTAB 00000000000006c0 000006c0
000000000000014a 0000000000000000 A 0 0 1
...
[28] .symtab SYMTAB 0000000000000000 00008040
00000000000008d0 0000000000000018 29 25 8
[29] .strtab STRTAB 0000000000000000 00008910
00000000000005bc 0000000000000000 0 0 1
在段表中搜索到字符表段之后就可以借助其 offset 定位到这个段了. 字符表段是一个长度为 n 的数组, 数组中的每一个元素都是 Elf64_Sym
, 用于表示一个符号, 其结构体如下所示
typedef struct {
uint32_t st_name; // 符号名
unsigned char st_info; // 符号类型和绑定信息
unsigned char st_other; // 暂未使用
uint16_t st_shndx; // 符号所在的段
Elf64_Addr st_value; // 符号值
uint64_t st_size; // 符号大小
} Elf64_Sym;
其中符号名 st_name 为该符号在字符串表中的偏移量, 可以通过 .strtab + st_name 找到符号名
$ readelf -s SimpleSection.o
Symbol table '.symtab' contains 13 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS SimpleSection.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 .text
3: 0000000000000000 0 SECTION LOCAL DEFAULT 3 .data
4: 0000000000000000 0 SECTION LOCAL DEFAULT 4 .bss
5: 0000000000000000 0 SECTION LOCAL DEFAULT 5 .rodata
6: 0000000000000004 4 OBJECT LOCAL DEFAULT 3 static_var.1
7: 0000000000000004 4 OBJECT LOCAL DEFAULT 4 static_var2.0
8: 0000000000000000 4 OBJECT GLOBAL DEFAULT 3 global_init_var
9: 0000000000000000 4 OBJECT GLOBAL DEFAULT 4 global_uninit_var
10: 0000000000000000 43 FUNC GLOBAL DEFAULT 1 func1
11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf
12: 000000000000002b 57 FUNC GLOBAL DEFAULT 1 main
2/3/4/5 号符号的属性是 SECTION 代表这是一个段, 这里的对应的字符串并不是保存在 .strtab 中的, 需要使用 st_shndx 作为索引值在段表中查询对应段的名字, 也就是说在
.shstrtab
中才可以找到符号的名字, 对应的代码实现如下所> 示. 您可以在 readelf.c 中查看完整代码char *symbol_name; // 对于 st_name 的值不为0的符号或者 ABS, 去对应的 .strtab 中找 if (symtabs[j].st_name || symtabs[j].st_shndx == SHN_ABS) { symbol_name = (char *)(ELF_file_data->addr + strtab->sh_offset + symtabs[j].st_name); } else { // 为 0 说明是一个特殊符号, 用 symbol_ndx 去段表字符串表中找 symbol_name = (char *)(ELF_file_data->addr + ELF_file_data->shstrtab_offset + ELF_file_data->shdr[symtabs[j].st_shndx].sh_name); }
同时注意到有一列信息是
Vis
, Vis 在 C/C++ 中未使用, 用于控制符号可见性, Rust/Swift 使用到了, 这里默认 DEFAULT 即可但是在可执行文件中确实存在 HIDDEN ?
.dynsym 中显示函数名而不是 [...]@GLIBC_2.2.5 (2) ?
需要注意的是默认情况下,未初始化的全局变量会被放置在 BSS 段中,而不是 .common 块中
要将变量放置在 .common 块中,需要使用 __attribute__((common))
属性或者编写链接器脚本来显式地指定.但是,这种方法并不常用,因为common 块是一种非标准的方式来管理未初始化的全局变量,不同的编译器和链接器可能会有不同的行为.
int printf(const char *format, ...);
__attribute__((common)) int global_init_var;
__attribute__((common)) int global_uninit_var;
void func1(int i) {
printf("%d\n",i);
}
int main(void) {
static int static_var = 85;
static int static_var2;
int a = 1;
int b;
func1(static_var + static_var2 + a + b);
}
这里我们还是以之前的 SimpleSection.o 为例
Symbol table '.symtab' contains 13 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS SimpleSection.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 .text
3: 0000000000000000 0 SECTION LOCAL DEFAULT 3 .data
4: 0000000000000000 0 SECTION LOCAL DEFAULT 4 .bss
5: 0000000000000000 0 SECTION LOCAL DEFAULT 5 .rodata
6: 0000000000000004 4 OBJECT LOCAL DEFAULT 3 static_var.1
7: 0000000000000004 4 OBJECT LOCAL DEFAULT 4 static_var2.0
8: 0000000000000000 4 OBJECT GLOBAL DEFAULT 3 global_init_var
9: 0000000000000000 4 OBJECT GLOBAL DEFAULT 4 global_uninit_var
10: 0000000000000000 43 FUNC GLOBAL DEFAULT 1 func1
11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf
12: 000000000000002b 57 FUNC GLOBAL DEFAULT 1 main
SYMBOL TABLE:
0000000000000000 l df *ABS* 0000000000000000 SimpleSection.c
0000000000000000 l d .text 0000000000000000 .text
0000000000000000 l d .data 0000000000000000 .data
0000000000000000 l d .bss 0000000000000000 .bss
0000000000000000 l d .rodata 0000000000000000 .rodata
0000000000000004 l O .data 0000000000000004 static_var.1
0000000000000004 l O .bss 0000000000000004 static_var2.0
0000000000000000 g O .data 0000000000000004 global_init_var
0000000000000000 g O .bss 0000000000000004 global_uninit_var
0000000000000000 g F .text 000000000000002b func1
0000000000000000 *UND* 0000000000000000 printf
000000000000002b g F .text 0000000000000039 main
对于符号值来说有以下几种情况
这种情况是最常见的情况, 例如对于 func1
, 其位于1号段也就是 .text
段, 偏移量是 0. static_var
定义在 3 号 .data
偏移量是 0x4, 这个偏移量是因为在它前面还有一个 global_init_var
当我们使用 ld 作为链接器来链接生产可执行文件的时候, 它会为我们定义很多特殊的符号. 这些符号并没有在你的程序中定义, 但是可以直接声明并且使用它们
其实这些符号是被定义为 ld 链接器的脚本当中的, 在后文链接过程控制这一节会回顾这个问题
#include <stdio.h>
extern char __executable_start[]; // 程序的起始地址, 不是入口地址
extern char etext[], _etext[], __etext[]; // 代码段的结束地址
extern char edata[], _edata[]; // 数据段的结束地址
extern char end[], _end[]; // 程序结束地址
int main() {
printf("Execuatable Start %p\n", __executable_start);
printf("Text End %p %p %p\n", etext, _etext, __etext);
printf("Data End %p %p\n", edata, _edata);
printf("Execuatable End %p %p\n", end, _end);
return 0;
}
Execuatable Start 0x5577aa444000
Text End 0x5577aa445205 0x5577aa445205 0x5577aa445205
Data End 0x5577aa448010 0x5577aa448010
Execuatable End 0x5577aa448018 0x5577aa448018
20 世纪 70 年代以前, 编译器编译源代码产生目标文件的时候符号名和相应的变量名和函数名是一样的. 后来 UNIX 平台和 C 语言发明之后, 存在了相当多使用汇编写的库和目标文件, 这样就产生了一个问题, 如果一个 C 程序想要使用这些库就不可以使用冲突的符号名. 同样, 如果 C 的目标文件要用到一个使用 Fortran 语言编写的目标文件, 也必须避免命名冲突
为了防止类似的符号名冲突, UNIX 下的 C 语言就规定源代码文件中的所有全局变量和函数经过编译之后, 相对应的符号名前加下划线, Fortran 编译之后前后都加下划线, 也就是如果一个函数 foo
, C 编译之后就变成了 _foo
, Fortran 就变成了 _foo_
这种方式确实可以暂时减少符号冲突的概率, 但是并没有从根本上解决. 当程序很大的时候, 多个模块也有可能因为命名规范不严格造成命名冲突.
因此像 C++ 这样后来设计的语言也开始考虑到了这个问题, 增加了命名空间的方法来解决多模块的符号冲突的问题
现在的 Linux 下的 GCC 编译器默认情况下已经去掉了 C 语言符号前加 _
, Windows 下的编译器还保持这样的传统. 例如 Visual C++ 编译器, cygwin, mingw. GCC 编译器也可以通过参数 -fleading-underscore
-fno-leading-underscore
来打开/关闭是否在 C 符号前加下划线
众所周知C++拥有类/继承/虚机制/重载/命名空间等强大的特性, 这也使得符号的管理更为复杂. 为了支持这些复杂的特性, 人们发明了 符号修饰(Name Decoration) 和 符号改编(Name Mangling)的机制
#include <iostream>
int func(int);
float func(float);
class C {
public:
int func(int);
class C2 {
public:
int func(int);
};
};
namespace N {
int func(int);
class C {
public:
int func(int);
};
};
int main() {
func(10);
float x = 10;
func(x);
C c;
c.func(10);
C::C2 c2;
c2.func(10);
(N::func(10));
N::C nc;
nc.func(10);
return 0;
}
注意这里需要调用一次, 单纯的声明不调用 g++ 似乎不会将其计入符号表
上面的 C++ 代码定义了 6 个同名函数 func, 只不过它们的返回类型和参数以及所在的命名空间不相同. 这里引入一个术语叫做 函数签名(Function Signature), 对于上面的 6 个函数每个都有一个独一无二的函数签名, 编译器在生成这个签名的时候会考虑 返回类型/参数/命名空间/函数名 等多种情况
g++ -c func.cpp
readelf -s func.o
可以看到原有的函数名被
Symbol table '.symtab' contains 21 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS func.cpp
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 .text
3: 0000000000000000 0 SECTION LOCAL DEFAULT 4 .bss
4: 0000000000000000 1 OBJECT LOCAL DEFAULT 4 _ZStL8__ioinit
5: 0000000000000096 86 FUNC LOCAL DEFAULT 1 _Z41__static_ini[...]
6: 00000000000000ec 25 FUNC LOCAL DEFAULT 1 _GLOBAL__sub_I_main
7: 0000000000000000 0 SECTION LOCAL DEFAULT 7 .rodata
8: 0000000000000000 150 FUNC GLOBAL DEFAULT 1 main
9: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _Z4funci
10: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _Z4funcf
11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _ZN1C4funcEi
12: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _ZN1C2C24funcEi
13: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _ZN1N4funcEi
14: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _ZN1N1C4funcEi
15: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND __stack_chk_fail
16: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _ZNSt8ios_base4I[...]
17: 0000000000000000 0 NOTYPE GLOBAL HIDDEN UND __dso_handle
18: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _GLOBAL_OFFSET_TABLE_
19: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _ZNSt8ios_base4I[...]
20: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND __cxa_atexit
其中 9-14 行为对应的符号名
函数名 | C++ 符号名 | 行号 |
---|---|---|
int func(int) | _Z4funci | 9 |
float func(float) | _Z4funcf | 10 |
int C::func(int) | _ZN1C4funcEi | 11 |
int C::C2::func(int) | _ZN1C2C24funcEi | 12 |
int N::func(int) | _ZN1N4funcEi | 13 |
int N::C::func(int) | _ZN1N1C4funcEi | 14 |
上面只是一个比较简单的例子, 实际上 cpp 的名称修饰十分复杂, 您可参考 cpp-name-mangling 部分展开详细阅读
我们可以使用 binutils 中的 c++filt 工具来解析一个被修饰过的名称, 例如
$ c++filt _ZN1N1C4funcEi
N::C::func(int)
$ c++filt _ZN3foo3barE
foo::bar
值得注意的是, 函数和变量的类型并没有被加入到修饰后的名称中, 所以不论这个变量的类型是什么它的名称都是一样的. 这种机制可以很好的避免命名冲突
不同编译器厂商的名称修饰方法可能不同, 因此不同编译器编译产生的文件很有可能无法正常互相链接, 后面的 C++ABI 和 COM 节会详细讨论这个问题
$ nm -C func.o
U _GLOBAL_OFFSET_TABLE_
00000000000000ec t _GLOBAL__sub_I_main
0000000000000096 t __static_initialization_and_destruction_0(int, int)
U func(float)
U func(int)
U C::C2::func(int)
U C::func(int)
U N::C::func(int)
U N::func(int)
U std::ios_base::Init::Init()
U std::ios_base::Init::~Init()
0000000000000000 b std::__ioinit
U __cxa_atexit
U __dso_handle
U __stack_chk_fail
0000000000000000 T main
C++ 为了和 C 兼容, 在符号的管理上 C++ 有一个用来声明或者定义一个 C 的符号的 extern "C" 关键字用法
extern "C" {
int func(int);
int var;
}
在 extern "C" 内部的代码会被当作 C 语言代码处理, 所以这时候 C++ 的名称修饰机制不会起作用. 我们可以做一个很有意思的小实验, 尝试如下代码
#include <stdio.h>
namespace myname {
int var = 42;
}
extern "C" int _ZN6myname3varE;
int main() {
printf("%d\n", _ZN6myname3varE);
return 0;
}
我们手动定义了以恶搞全局变量 var 并根据所掌握的 GCC 名称修饰规则手动声明一个外部符号
$ g++ manualnamemangling.cpp -o a
$ ./a
42
很多时候我们会碰到有些头文件声明了一些C语言的函数和全局变量,但是这个头文件可能会被C语言代码或C++代码包含.比如很常见的,我们的C语言库函数中的string.h中声明了memset这个函数,它的原型如下:
void *memset (void *, int, size_t);
如果不加任何处理,当我们的C语言程序包含 string.h 的时候,并且用到了memset 这个函数,编译器会将memset符号引用正确处理;
但是在C++语言中,编译器会认为这个memset 函数是一个C++函数,将memset 的符号修饰成 _Z6memsetPvii
,这样链接器就无法与C语言库中的memset符号进行链接.所以对于C++来说,必须使用 extern "C"
来声明memset 这个函数.但是C语言又不支持extern "C" 语法,如果为了兼容C语言和C++语言定义两套头文件,未免过于麻烦.幸好我们有一种很好的方法可以解决上述问题,就是使用C++的宏 _cplusplus
,C++编译器会在编译C++的程序时默认定义这个宏,我们可以使用条件宏来判断当前编译单元是不是C++代码.具体代码如下:
#ifdef __cplusplus
extern "C" {
#endif
void *memset (void * , int, size_t ) ;
#ifdef __cplusplus
}
#endif
如果当前编译单元是 C++ 代码, 那么 memset 会在 extern "C" 中被声明, 如果是 C 代码则直接声明. 上面这段代码的技巧几乎在所有系统头文件中都被用到了
在编程中经常会遇到的一种错误情况就是符号重复定义, 当多个目标文件中含有相同的名字全局符号定义, 那么当链接器在链接这些目标文件的时候就会出现符号重复定义的错误
对于 C/C++ 语言来说, 编译器默认函数和初始化的全局变量为强符号, 未初始化的全局变量为弱符号
这里有一点需要注意, 笔者使用的gcc版本是11.3.0, ld 的版本是 2.38, 对于下方的例子是无法编译通过的, 会存在两个强符号冲突! 但是如果切换到 gcc-9 版本则没有这个问题, 对于clang10.0.1版本及以下同样可以通过编译,clang11.0.0及以上存在链接报错, 笔者在知乎提问了这个问题并且得到了解答: C编译器版本导致强弱符号重定义的问题?, 需要使用 -fcommon 参数
// strong_weak_symbol.c
extern int ext;
int weak_symbol;
int strong_symbol = 1;
int __attribute__((weak)) weak_symbol2 = 2;
int main() {
return 0;
}
// strong_symbol.c
int weak_symbol = 20;
对于 strong_weak_symbol.o
来说, 使用 readelf -s
结果如下所示
Symbol table '.symtab' contains 7 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS strong_weak_symbol.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 .text
3: 0000000000000004 4 OBJECT GLOBAL DEFAULT COM weak_symbol
4: 0000000000000000 4 OBJECT GLOBAL DEFAULT 2 strong_symbol
5: 0000000000000004 4 OBJECT WEAK DEFAULT 2 weak_symbol2
6: 0000000000000000 15 FUNC GLOBAL DEFAULT 1 main
而 strong_symbol.o
的结果如下
Symbol table '.symtab' contains 3 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS strong_symbol.c
2: 0000000000000000 4 OBJECT GLOBAL DEFAULT 2 weak_symbol
可以注意到 strong_weak_symbol.c
中未初始化的全局变量 weak_symbol
定义在 COMMON 段, 作为一个弱符号. 而 strong_symbol.c
中的 weak_symbol
是一个已初始化的全局变量. 此时弱符号会被强符号所覆盖
针对强弱符号, 链接器会按照如下的规则处理被多次定义的全局符号:
上面的例子实际上对应的是第二种情况
需要注意的是, extern 修饰的符号既不是强符号也不是弱符号, 他甚至都不会进入符号表作为一个未定义符号.
我们看到的对外部目标文件的符号引用在目标文件被最终链接成可执行文件的时候, 它们需要被正确决议.
对于未找到定义的弱引用, 链接器默认其值为0或一个特殊的值
在 GCC 中可以使用 __attribute__((weakref("tar"))) static void foo();
来声明 foo 是一个到 tar 的弱引用
这里的相当混乱和奇怪, 实操下来和书上写的不太一样, 笔者也不是很清楚...
#include <stdio.h>
#include <pthread.h>
int pthread_create(pthread_t*, const pthread_attr_t*, void *(*)(void*), void *) __attribute__ ((weak));
int main() {
if (pthread_create) {
printf("This is multi-thread version\n");
} else {
printf("This is single-thread version\n");
}
return 0;
}
上面的代码理论上来说应该是根据弱引用的方法判断当前程序是链接到了单线程的Glibc还是多线程的Glibc, 根据是否使用了了 -lpthread 选项
gcc pthread.c -o pt
gcc pthread.c -lpthread -o pt
目标文件中也可以保存调试信息, 几乎所有现代的编译器都成支持源代码级别的调试, 比如可以在函数里面设置断点, 监视变量变化, 单步执行等等
前提是编译器必须提前将源代码和目标代码之间的关系
有些高级的编译器和调试器甚至支持查看 STL 容器里面的内容, 即程序员在调试的过程中可以直接观察 STL 容器中成员的值
现在 ELF 采用的是一个叫做 DWARF(debug with arbitary record format) 的标准调试信息格式
有关调试信息的部分笔者将会在 "调试信息" 这一节展开, 作为扩展部分