CppCon 2018 学习:THE BITS BETWEEN THE BITS HOW WE GET TO HOW WE GET TO main()
你正在使用 C 和 C++ 编译一个非常简单的程序,并使用 -Os
优化标志来减小生成的可执行文件的大小。以下是你的例子的解释:
程序代码
你编写了一个最小的程序:
int main() {}
这个程序什么也不做,仅仅是一个空的 main
函数。
编译命令
你使用了 GCC 和 G++ 并且加上了 -Os
优化标志:
- GCC (C 编译器):
这个命令将$ gcc -Os empty.c -o c/empty
empty.c
编译成一个名为c/empty
的可执行文件,并且使用-Os
标志进行大小优化。 - G++ (C++ 编译器):
同样的,这个命令将$ g++ -Os empty.cpp -o cpp/empty
empty.cpp
编译成一个名为cpp/empty
的 C++ 可执行文件,也使用了-Os
优化标志。
检查文件大小
你使用 ls -l
命令查看两个可执行文件的大小:
$ ls -l c/empty cpp/empty
输出结果如下:
7976 c/empty
7976 cpp/empty
解释
- 空程序:这两个程序本质上是空的,因为
main
函数没有任何实际的代码。编译后的可执行文件的大小主要由 链接过程 决定,也就是编译器会在程序中插入一些最基本的运行时初始化代码和启动代码,即使程序本身没有做任何事。 - 优化(
-Os
):-Os
标志告诉编译器优化代码的大小。这就是为什么两个可执行文件的大小都非常小(7976 字节)。然而,即便如此,两个可执行文件的大小也不会是零,因为它们依然包含了运行程序所必需的启动代码、库文件和符号表等。 - 大小相同:C 编译器和 C++ 编译器编译出来的两个文件大小相同,说明它们生成的最小运行时开销差不多。虽然 C++ 的可执行文件通常会包含一些额外的运行时库(比如 C++ 的标准库和异常处理机制),但在这个简单的程序中,这些额外的代码并不足以让文件大小产生明显差异。
结论
两个程序都使用了针对程序大小的优化,且由于它们的代码非常简单,生成的可执行文件大小都非常小且几乎相同。即使在 C 和 C++ 之间有一些运行时差异(如 C++ 的运行时库),由于程序的简洁性,这些差异对文件大小的影响很小。
xiaqiu@xz:~/test/CppCon/day226/code$ ls -l c/empty cpp/empty
-rwxr-xr-x 1 xiaqiu xiaqiu 15776 Jul 5 20:28 c/empty
-rwxr-xr-x 1 xiaqiu xiaqiu 15776 Jul 5 20:28 cpp/empty
xiaqiu@xz:~/test/CppCon/day226/code$
你现在正在检查一个名为 cpp/empty
的可执行文件的内容,并且使用了几个工具(objdump
和 readelf
)来分析文件的结构和组成。让我们来逐步解释这些工具的输出结果。
objdump
输出:
$ objdump --no-show-raw-insn -dC cpp/empty
objdump
是一个用来分析可执行文件的工具。通过 -dC
选项,你可以查看程序的反汇编代码,并且 --no-show-raw-insn
会屏蔽掉原始的机器指令,只显示反汇编信息。
在这个命令中,输出显示了 cpp/empty
文件中 .init
和 .text
区段的反汇编代码。以下是其中的一部分:
.init 区段:
00000000004003b0 <_init>:4003b0: sub $0x8,%rsp4003b4: mov 0x200c3d(%rip),%rax # 600ff8 <__gmon_start__>4003bb: test %rax,%rax4003be: je 4003c2 <_init+0x12>4003c0: callq *%rax4003c2: add $0x8,%rsp4003c6: retq
- 这段代码涉及程序初始化(
_init
)。这里是程序的入口点之一,通常用于设置运行时环境或其他初始化任务。 sub $0x8,%rsp
:调整栈指针。mov 0x200c3d(%rip),%rax
:将一个全局数据地址加载到寄存器rax
中。callq *%rax
:如果rax
不为零,调用rax
中的地址。通常,这里是指__gmon_start__
,它与程序的性能分析有关(例如,程序是否启用了 gmon)。- 最后,通过
retq
指令返回。
.text 区段:
.text
区段通常是程序的执行代码。由于你没有提供 .text
区段的详细内容,我们可以推测它包含了程序实际执行的代码。在这个简单的程序中,由于 main
函数是空的,它的反汇编代码会非常简单,通常会涉及一些必要的程序启动和退出代码。
readelf
输出:
$ readelf -a cpp/empty
readelf
是另一个用来分析 ELF 文件格式的工具,它可以提供文件的详细信息。
输出的关键部分:
ELF 头部:
ELF Header:Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: EXEC (Executable file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x4003e0 Start of program headers: 64 (bytes into file) Start of section headers: 6248 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes)
- Magic:
7f 45 4c 46
是 ELF 文件格式的标识符,告诉工具这是一个 ELF 文件。 - Class: 这是一个 64 位的 ELF 文件 (
ELF64
)。 - Data: 使用的是补码表示的 Little Endian 格式。
- Type: 文件类型是可执行文件(
EXEC
)。 - Machine: 文件是为 AMD x86-64 架构编译的。
- Entry point address: 程序的入口点地址为
0x4003e0
,这是程序开始执行的地方。 - Program header 和 section header 分别是程序头和区段头的起始位置,用来描述程序如何加载到内存中。
总结:
这些工具的输出告诉我们关于 cpp/empty
可执行文件的多种信息:
- 程序使用了标准的初始化和启动代码。
- 文件格式为 ELF64,适用于 64 位的 Linux 系统。
- 程序非常简单,主要包含启动代码,并没有执行什么实际的操作。
- 使用
readelf
提供了文件的头信息,告诉我们这是一个有效的可执行文件,并且包含了程序头和区段头信息,用于加载到内存中。
ELF (Executable and Linkable Format) 是一种常见的文件格式,用于可执行文件、目标代码、共享库和核心转储。在 Linux 和其他类 Unix 操作系统中,ELF 文件被广泛使用。下面是 ELF 文件的主要结构和组成部分的简要概述。
ELF 文件格式的结构
ELF 文件由以下几部分组成:
- ELF Header (ELF 头部)
ELF 文件的起始部分是 ELF Header,包含了关于文件的元数据,如文件类型、目标机器、入口点地址等信息。其主要字段包括:- Magic Number(魔术数字):用于标识文件为 ELF 格式。
- Class:标识文件类型(如 32-bit 或 64-bit)。
- Data Encoding:数据的编码方式(大端或小端)。
- Version:文件格式的版本。
- Entry Point Address:程序的入口点地址。
- Program Header Table Offset:指向程序头表的偏移量。
- Section Header Table Offset:指向节头表的偏移量。
- Flags:与目标机器相关的标志。
- Size:头部的大小。
- Program Header Table (程序头表)
程序头表告诉操作系统如何创建进程映像,特别是如何将文件的内容加载到内存中。它由多个表项组成,每个表项描述一个加载到内存中的段或其他类型的区域。
每个表项的基本字段包括:- Type:表明该段的类型(如:可执行段、只读数据段等)。
- Offset:该段在 ELF 文件中的偏移。
- Virtual Address:该段在内存中的虚拟地址。
- Physical Address:该段在物理内存中的地址(大多数情况下与虚拟地址相同)。
- File Size:该段在文件中的大小。
- Memory Size:该段在内存中的大小。
- Sections (节)
ELF 文件被划分为不同的节(section),每个节存储不同类型的数据。常见的节包括:- .text:包含程序的执行代码,是最常见的节之一。
- .rodata:只读数据节,通常存放常量字符串等数据。
- .data:包含程序的初始化全局变量和静态变量。
- .bss:包含未初始化的全局变量和静态变量,程序加载时会分配内存。
- .symtab:符号表,存储程序中使用的变量和函数符号。
- .strtab:字符串表,存储符号表中的字符串数据。
每个节都有自己的标头,包含该节的元数据,如大小、偏移量和类型。
- Section Header Table (节头表)
节头表包含每个节的详细信息,如节的类型、大小、偏移量等。通过节头表,程序可以知道每个节的存储位置及其属性。
ELF 文件的组成部分图示
ELF Header↓
Program Header Table ←─ 指向如何将 ELF 文件加载到内存的指示↓
Sections├── .text ← 执行代码├── .rodata ← 只读数据├── .data ← 初始化的全局变量├── .bss ← 未初始化的全局变量├── .symtab ← 符号表└── .strtab ← 字符串表↓
Section Header Table ←─ 包含节的元数据,如大小、偏移量、类型等
关键节和段
- .text 节
存放程序的机器码或可执行代码。该节是最重要的,因为它包含程序的实际执行指令。 - .data 节
存放程序中的初始化全局变量。通过此节,操作系统能够将这些变量加载到进程的地址空间中。 - .bss 节
存放未初始化的全局变量。它不包含任何实际数据,操作系统在程序加载时会根据该节的大小分配内存空间。 - .rodata 节
存放只读数据,如常量字符串、常量数值等。 - .symtab 节
存放符号表,符号表包含程序中所有符号(如函数名、变量名等)。这是调试信息的一部分,对于调试器或动态链接器至关重要。 - .strtab 节
存放字符串表,通常与符号表一起使用,用于存储符号的名称字符串。
ELF 文件的用途
- 可执行文件:用于程序执行,包含代码、数据和必要的元数据。
- 目标文件:编译后的代码文件,未完全链接,需要链接器处理。
- 共享库:动态链接库文件,包含可执行程序运行时所需的共享代码。
- 核心转储:程序崩溃时的内存镜像,供调试使用。
总结
ELF 文件格式是一个非常灵活且强大的文件格式,广泛应用于类 Unix 系统。它通过头部、程序头表、节头表和各个具体的节和段,组织了程序的执行代码、数据和其他必要的信息,确保程序能够被正确加载、执行以及调试。理解 ELF 文件格式对于深入理解操作系统如何管理程序的加载和执行至关重要。
在 ELF 文件格式中,**Section(节)**是组织程序中各类数据和代码的基本单元。每个节都包含特定类型的数据或代码,具有不同的属性和功能。下面是 ELF 文件中常见的几个节:
常见的 ELF 节
- .text — 代码节 (Code Section)
- 功能:包含程序的实际执行代码(即机器指令)。当程序运行时,操作系统会将
.text
节中的指令加载到内存中并执行。 - 特点:通常是只读的,防止程序被修改。
- 举例:存放程序的
main
函数和其他函数的机器码。
例子:
.text 0000000000001000 <main>:1000: endbr641004: xor %eax,%eax1006: ret
- 功能:包含程序的实际执行代码(即机器指令)。当程序运行时,操作系统会将
- .rodata — 只读数据节 (Read-Only Data Section)
- 功能:存储程序中的常量和只读数据。由于这些数据在程序运行时不能修改,因此通常将其放置在
.rodata
节中。 - 特点:这是一个只读区域,在程序执行期间,操作系统会把这些数据加载到内存中,但不能修改它们。
- 举例:字符串字面量、常量数组。
例子:
.rodata 0000000000002000 <.LC0>:2000: 48 65 6c 6c 6f 0a hello.\n
- 功能:存储程序中的常量和只读数据。由于这些数据在程序运行时不能修改,因此通常将其放置在
- .data — 可读写数据节 (Read/Write Data Section)
- 功能:存储程序中的全局变量和静态变量,特别是那些已初始化的变量。与
.bss
不同,.data
中的数据在程序执行前是已知的并且已经初始化。 - 特点:该节是可读写的,程序可以在运行时修改这些数据。
- 举例:已初始化的全局变量,如整数、数组等。
例子:
.data 0000000000003000 <var>:3000: 01 00 00 00 # 变量的初值,比如整数 1
- 功能:存储程序中的全局变量和静态变量,特别是那些已初始化的变量。与
- .bss — 零初始化数据节 (Block Started by Symbol)
- 功能:存储未初始化的全局变量和静态变量。程序在加载时会为
.bss
中的变量分配内存,但这些变量在程序启动时的值为零。 - 特点:
.bss
节本身不占用文件空间,因为它仅在程序运行时占用内存。因此,.bss
节的大小由未初始化的变量的数量和大小决定。 - 举例:未初始化的全局变量,如
int counter;
。
例子:
.bss 0000000000004000 <counter>:4000: 00 00 00 00 # 默认值为 0
- 功能:存储未初始化的全局变量和静态变量。程序在加载时会为
节的总结
节名 | 用途 | 读/写属性 |
---|---|---|
.text | 存储程序代码,包含执行的机器指令 | 只读 |
.rodata | 存储只读数据,如常量字符串、常量数组等 | 只读 |
.data | 存储已初始化的全局变量和静态变量 | 可读写 |
.bss | 存储未初始化的全局变量和静态变量,加载时会初始化为零 | 可读写 |
各节的内存分配和加载行为
- .text 节通常位于程序的开头,并且是只读的,这样可以防止程序代码被修改。
- .rodata 节用于存储程序中的常量数据,也通常是只读的。加载到内存后,数据是不可更改的。
- .data 节用于存储已初始化的全局变量和静态变量,这些变量可以在程序执行期间被修改。
- .bss 节并不存储实际数据,而是指示程序需要为某些未初始化的变量分配内存并将它们初始化为零。
这些节是 ELF 文件格式中非常重要的组成部分,它们确保程序在加载和执行时能够正确管理不同类型的数据。
如果你需要更深入的理解或想要了解如何通过编译器控制这些节的行为,随时告诉我!
SECTIONS 是 ELF 文件格式中的核心部分,每个节(Section)都有不同的功能和特性。具体来说:
1. .text — 代码段 (Code Section)
- 功能:存储程序的可执行代码(即机器指令)。所有的函数体和代码逻辑都包含在
.text
节中。 - 特点:
- 只读:因为它存放的是代码,通常是不可修改的。
- 执行时加载:程序加载到内存时,
.text
节会被加载到内存的某个区域,以便 CPU 执行。
- 例子:存放
main()
函数和其他函数的指令集。
示例:
.text
0000000000001000 <main>:1000: xor %eax,%eax1002: ret
2. .rodata — 只读数据段 (Read-Only Data Section)
- 功能:存储程序中的常量数据,这些数据在程序执行过程中不能修改。
- 特点:
- 只读:例如字符串字面量、常量数组等都放在
.rodata
节中,避免修改。 - 节省内存:可以通过将常量放在共享区域来节省内存。
- 只读:例如字符串字面量、常量数组等都放在
- 例子:字符串字面量(如
"Hello, World!"
)和常量数值。
示例:
.rodata
0000000000002000 <.LC0>:2000: 48 65 6c 6c 6f 2c 20 57 6f 72 6c 64 21 0a 00
3. .data — 数据段 (Data Section)
- 功能:存储已经初始化的全局变量和静态变量。
- 特点:
- 可读写:这些数据在程序执行过程中可以修改。
- 内存分配:程序启动时,操作系统为这些变量分配内存。
- 例子:初始化的全局变量,如
int counter = 5;
。
示例:
.data
0000000000003000 <counter>:3000: 05 00 00 00 00 00 00 00
4. .bss — 未初始化数据段 (Block Started by Symbol)
- 功能:存储未初始化的全局变量和静态变量。程序加载时,这些变量会被自动初始化为零。
- 特点:
- 仅分配内存:
.bss
节本身并不占用磁盘空间,它仅在内存中为变量分配空间,并将其初始化为零。 - 节省空间:没有存储实际数据,因此
.bss
节通常非常小。
- 仅分配内存:
- 例子:未初始化的全局变量,如
int counter;
。
示例:
.bss
0000000000004000 <counter>:4000: 00 00 00 00 00 00 00 00
总结:这些节的作用
- .text:存放代码(只读,不可修改)。
- .rodata:存放只读常量数据(不可修改)。
- .data:存放已初始化的变量(可修改)。
- .bss:存放未初始化的变量(在程序启动时自动初始化为零)。
每个节的存在使得程序可以更有效地在内存中进行管理,也有助于编译器和操作系统在加载和执行时优化程序的性能和内存使用。
这个程序展示了一个更复杂的 C++ 程序,涉及了类的构造函数、析构函数以及静态成员变量的使用。我们通过逐步分析代码,看看如何从代码走到 main()
函数的执行:
程序解析
#include <iostream>
struct Foo { static int numFoos; // 静态成员变量// 构造函数Foo() { numFoos++; // 每次创建对象时 numFoos 增加} // 析构函数~Foo() { numFoos--; // 每次销毁对象时 numFoos 减少}
};
// 静态成员变量初始化
int Foo::numFoos = 0; // numFoos 的初始值为 0
// 全局对象
Foo globalFoo;
int main() { std::cout << "numFoos = " << Foo::numFoos << "\n";
}
代码步骤解析
- 类定义:
- 你定义了一个
Foo
结构体,其中包含一个静态成员变量numFoos
,该变量用于统计Foo
对象的数量。每次创建Foo
对象时,numFoos
增加 1;每次销毁Foo
对象时,numFoos
减少 1。 - 静态成员变量:
numFoos
是一个静态变量,意味着它是所有Foo
类型的对象共享的,而不是每个对象有一个单独的副本。
- 你定义了一个
- 静态成员变量的初始化:
int Foo::numFoos = 0;
这行代码是静态成员变量的初始化。在Foo
类型的任何对象创建之前,numFoos
必须初始化为某个值。这里初始化为0
,表示一开始没有创建任何Foo
对象。
- 全局对象:
Foo globalFoo;
你创建了一个全局对象globalFoo
。这个对象在程序开始时自动创建,并且它会在main()
执行之前构造。- 当
globalFoo
被创建时,Foo
的构造函数会被调用,numFoos
增加 1。
main()
函数:- 在
main()
函数中,输出numFoos
的值,表示当前创建的Foo
对象的数量。由于globalFoo
在main()
之前已经被创建,因此numFoos
的值会是1
。
- 在
程序执行流程
- 程序开始:
- 程序加载后,
globalFoo
作为全局对象在程序开始时创建。此时Foo
类型的构造函数被调用,numFoos
增加 1。因此,numFoos
变为 1。
- 程序加载后,
main()
函数执行:std::cout
输出当前的numFoos
值。由于globalFoo
在此时已经创建,numFoos
的值为 1。- 输出:
numFoos = 1
- 程序结束:
- 程序结束时,
globalFoo
对象的析构函数被调用,numFoos
减少 1。 - 因此,
numFoos
最终回到 0。
- 程序结束时,
程序输出
numFoos = 1
总结
- 静态成员变量
numFoos
是类的所有对象共享的,用于统计Foo
类的实例数量。 - 在全局对象
globalFoo
的构造过程中,numFoos
被增加。此时,main()
函数输出numFoos
的值。 - 程序结束时,
globalFoo
被销毁,numFoos
减少。
xiaqiu@xz:~/test/CppCon/day226/code$ g++ -O0 -g global.cpp -o global
xiaqiu@xz:~/test/CppCon/day226/code$ ./global
numFoos = 1
xiaqiu@xz:~/test/CppCon/day226/code$
#0 Foo::Foo (this=0x601050 <global>) at global.cpp:6
#1 0x000000000040079d in __static_initialization_and_destruction_0 (
__initialize_p=1, __priority=65535) at global.cpp:14
#2 0x00000000004007b3 in _GLOBAL__sub_I_global.cpp(void) () at global.cpp:18
#3 0x000000000040082d in __libc_csu_init ()
#4 0x00007ffff70c1b28 in __libc_start_main (main=0x400702 <main()>, argc=1,
... at ../csu/libc-start.c:266
#5 0x000000000040064a in _start ()
在你提供的堆栈信息中,展示了程序在初始化期间的调用栈。以下是对这些信息的逐步分析,帮助你理解每个函数的执行顺序以及它们的作用。
1. Foo::Foo
- 构造函数
#0 Foo::Foo (this=0x601050 <global>) at global.cpp:6
- 说明:这是
Foo
类的构造函数Foo::Foo
被调用的地方。构造函数用于创建一个Foo
类型的对象。 this=0x601050
:表示this
指针,即当前正在被创建的Foo
对象的地址。这里的地址0x601050
是global
对象的地址。global.cpp:6
:表示构造函数在global.cpp
文件的第 6 行执行。
2. __static_initialization_and_destruction_0
- 静态初始化函数
#1 0x000000000040079d in __static_initialization_and_destruction_0 (__initialize_p=1, __priority=65535) at global.cpp:14
- 说明:这个函数是编译器自动生成的,用于处理静态对象的初始化。在 C++ 中,静态变量和全局变量的初始化在
main()
函数之前发生,因此会调用这个函数来初始化静态和全局变量。 __initialize_p=1
:表示这个函数正在执行初始化操作,而不是销毁操作。__priority=65535
:表示这个静态初始化的优先级。global.cpp:14
:在global.cpp
文件的第 14 行执行。
3. _GLOBAL__sub_I_global.cpp
- 全局构造函数
#2 0x00000000004007b3 in _GLOBAL__sub_I_global.cpp(void) () at global.cpp:18
- 说明:这个函数是由编译器生成的,主要用于在程序启动时执行全局变量的构造函数。在这个例子中,它会调用
Foo
类的构造函数。 global.cpp:18
:在global.cpp
文件的第 18 行执行。
4. __libc_csu_init
- C 库初始化函数
#3 0x000000000040082d in __libc_csu_init ()
- 说明:
__libc_csu_init
是 C 标准库中的初始化函数。它会初始化 C 库需要的资源,并调用全局构造函数。 - 它在程序加载时自动执行,通常用于设置程序运行的环境。
5. __libc_start_main
- 启动程序的主函数
#4 0x00007ffff70c1b28 in __libc_start_main (main=0x400702 <main()>, argc=1, ... at ../csu/libc-start.c:266
- 说明:
__libc_start_main
是程序启动时由操作系统调用的 C 库函数。它负责设置程序的运行环境,并最终调用用户定义的main()
函数。 main=0x400702 <main()>
:表示最终将调用的main
函数的地址。
6. _start
- 程序的入口点
#5 0x000000000040064a in _start ()
- 说明:
_start
是程序的真实入口点,是由操作系统提供的启动代码。它是 C 程序的启动函数,在main()
函数之前执行,负责进行一些底层初始化(如栈、堆等的设置),然后调用__libc_start_main
来启动程序。
总结流程
Foo::Foo
构造函数被调用,创建了全局对象globalFoo
,并初始化静态成员numFoos
。- 接下来是
__static_initialization_and_destruction_0
函数,它确保静态变量和全局对象的初始化工作完成。 - 然后
_GLOBAL__sub_I_global.cpp
函数执行,这个函数调用了Foo
类型的构造函数,进一步初始化了全局对象。 __libc_csu_init
是 C 库的初始化函数,初始化底层系统资源。- 最终,程序通过
__libc_start_main
进入main()
函数。
在程序的整个初始化过程中,main()
函数执行之前,所有的全局和静态对象都会先被初始化,而这些初始化函数会在调用main()
之前依次执行。
打印调用栈c++23
#include <iostream>
#include <stacktrace> // C++23 标准库,支持捕获调用栈信息
#include <format> // C++20 格式化输出
#include <string>
#include <sstream>
class StacktraceUtil {
public:// 打印风格枚举,支持两种输出格式:// GDB: 类似 gdb 调试器的简洁风格// Detailed: 更详细的格式,包含更多信息enum class PrintStyle { GDB, Detailed };// 默认打印当前调用栈,用户可指定打印风格和前缀static void print_stacktrace(PrintStyle style = PrintStyle::GDB,const std::string& prefix = "") {auto trace = std::stacktrace::current(); // 获取当前调用栈print_stacktrace(trace, style, prefix);}// 根据传入的调用栈和打印风格进行打印static void print_stacktrace(const std::stacktrace& trace, PrintStyle style = PrintStyle::GDB,const std::string& prefix = "") {if (!prefix.empty()) {std::cout << prefix << "\n"; // 输出前缀(比如“异常时调用栈”)}switch (style) {case PrintStyle::GDB:print_gdb_style(trace);break;case PrintStyle::Detailed:print_detailed_style(trace);break;}}// 获取当前调用栈字符串,方便日志或者其他用途static std::string get_stacktrace_string(PrintStyle style = PrintStyle::GDB) {auto trace = std::stacktrace::current();return get_stacktrace_string(trace, style);}// 将指定调用栈转换为字符串static std::string get_stacktrace_string(const std::stacktrace& trace,PrintStyle style = PrintStyle::GDB) {std::ostringstream oss;// 临时重定向 std::cout 到字符串流中auto old_cout = std::cout.rdbuf();std::cout.rdbuf(oss.rdbuf());print_stacktrace(trace, style);std::cout.rdbuf(old_cout); // 恢复 std::coutreturn oss.str();}// 打印异常信息及异常发生时的调用栈static void print_exception_stacktrace(const std::exception& exception,PrintStyle style = PrintStyle::GDB) {std::cout << std::format("Exception: {}\n", exception.what());print_stacktrace(style, "Stacktrace at exception point:");}
private:// 以 GDB 风格打印调用栈,包含帧编号、地址、函数名,若有源文件信息也打印static void print_gdb_style(const std::stacktrace& trace) {for (std::size_t i = 0; i < trace.size(); ++i) {const auto& entry = trace[i];std::cout << std::format("#{} ", i);if (!entry.source_file().empty()) {std::cout << std::format("0x{:x} in {} () at {}:{}\n",reinterpret_cast<std::uintptr_t>(entry.native_handle()),entry.description(), entry.source_file(),entry.source_line());} else {std::cout << std::format("0x{:x} in {} ()\n",reinterpret_cast<std::uintptr_t>(entry.native_handle()),entry.description());}}}// 详细打印调用栈,每帧都分行显示地址、函数、源文件和行号static void print_detailed_style(const std::stacktrace& trace) {std::cout << std::format("Total frames: {}\n", trace.size());for (std::size_t i = 0; i < trace.size(); ++i) {const auto& entry = trace[i];std::cout << std::format("Frame #{}: \n", i);std::cout << std::format(" Address: 0x{:x}\n",reinterpret_cast<std::uintptr_t>(entry.native_handle()));std::cout << std::format(" Function: {}\n", entry.description());if (!entry.source_file().empty()) {std::cout << std::format(" Source: {}:{}\n", entry.source_file(),entry.source_line());}std::cout << "\n";}}
};
// RAII 风格的函数作用域进入退出打印辅助类
class StacktraceGuard {
private:std::string function_name_; // 当前函数名std::stacktrace entry_trace_; // 构造时的调用栈快照
public:// 构造时打印 ENTER 信息,并保存当前调用栈explicit StacktraceGuard(const std::string& func_name): function_name_(func_name), entry_trace_(std::stacktrace::current()) {std::cout << std::format("ENTER: {}\n", function_name_);}// 析构时打印 EXIT 信息~StacktraceGuard() { std::cout << std::format("EXIT: {}\n", function_name_); }// 打印函数进入时保存的调用栈void print_entry_trace(StacktraceUtil::PrintStyle style = StacktraceUtil::PrintStyle::GDB) {std::cout << std::format("Entry stacktrace for {}:\n", function_name_);StacktraceUtil::print_stacktrace(entry_trace_, style);}
};
// 宏,方便在函数开始处快速声明 Guard
#define STACKTRACE_GUARD() StacktraceGuard guard(__PRETTY_FUNCTION__)
// 测试主程序,只打印当前调用栈
int main() { StacktraceUtil::print_stacktrace(); }
上面是实现一个调用栈打印的工具函数
这段代码是对 Linux/glibc 程序启动时初始化流程中的一个关键函数的简化版本,__libc_csu_init
,它来自 glibc 的 csu/elf-init.c
,主要作用是执行编译器和链接器放在 .init_array
段的初始化函数。
具体解释
// 定义一个函数指针类型,指向接受 (int, char**, char**) 参数且返回 void 的函数
typedef void (*init_func)(int, char **, char **);
// 声明两个外部符号,分别指向 ELF 文件中 .init_array 段的开始和结束
// 该段包含了所有需要在程序启动时调用的初始化函数指针数组
extern init_func __init_array_start[];
extern init_func __init_array_end[];
// __libc_csu_init 是 C 运行时启动时调用的初始化函数
// 它负责调用 .init_array 段中的所有初始化函数
int __libc_csu_init(int argc, char **argv, char **envp) {// 计算 .init_array 中函数指针的数量const size_t size = __init_array_end - __init_array_start;// 遍历所有初始化函数指针,依次调用每个函数// 并将命令行参数 argc, argv 以及环境变量 envp 传递给它们for (size_t i = 0; i < size; i++)(*__init_array_start[i])(argc, argv, envp);
}
__init_array_start
和__init_array_end
是链接器定义的符号,分别指向.init_array
段的开始和结束地址。.init_array
是 ELF 格式二进制文件里一段专门放初始化函数指针的区域,这些函数是编译器和链接器在编译时自动收集的,比如静态构造函数(__attribute__((constructor))
标记的函数),全局对象的构造函数,或者其他初始化代码。__libc_csu_init
的作用就是遍历这段数组,依次调用所有初始化函数,传递给它们命令行参数argc
,argv
和环境变量envp
。
这个函数是谁调用的?
__libc_csu_init
不是你直接调用的。它通常被 C 运行时启动代码调用,比如在_start
函数或__libc_start_main
内部。- 简单来说,当你执行一个程序时,操作系统加载 ELF 文件,跳转到
_start
,之后由 libc 的启动代码(glibc)完成初始化,最终调用__libc_csu_init
来完成程序中静态对象的构造和其他初始化工作。 - 执行完初始化后,程序才会进入
main()
。
你为什么会看到这个?
当你用工具调试程序的启动流程(比如用 gdb 查看调用栈),你会看到像 __libc_csu_init
这样的符号,因为它负责程序构造前的初始化调用。
总结
__libc_csu_init
是程序启动时自动运行的一段初始化代码。- 它调用了 ELF
.init_array
段中的所有初始化函数。 - 它由 libc 启动代码调用,间接在
main
之前执行。 - 是理解 Linux ELF 程序启动流程和静态初始化关键的一环。
如果你想深入,可以研究 glibc 的启动代码_start
和__libc_start_main
,它们是更上层的启动入口。
__init_array_start
和 __init_array_end
是什么,它们的作用,以及 .init_array
段到底在干嘛。
什么是 __init_array_start
和 __init_array_end
?
- 它们是链接器自动生成的符号,不是你写的变量。
- 链接器在把目标文件合并成最终可执行文件时,会把所有放在
.init_array
段的内容集中起来,并在该段的开始和结尾定义这两个符号。 - 这两个符号标记了
.init_array
这个函数指针数组的起始地址和结束地址。
.init_array
段是什么?
.init_array
是 ELF 格式可执行文件中的一个特殊段,用来存放程序启动时需要执行的初始化函数指针。- 比如,C++ 中的全局对象构造函数、用
__attribute__((constructor))
标记的函数,都会被编译器放进.init_array
。 - 程序启动时,运行时系统会遍历这个
.init_array
数组,依次调用里面的函数,完成全局对象构造等初始化工作。
你贴的这段汇编代码是什么意思?
.section .init_array,"aw" # 声明.init_array段,设置可写可分配属性
.align 8 # 8字节对齐
.quad _GLOBAL__sub_I_Foo::numFoos # 把 Foo 的全局构造函数指针放入.init_array
.text # 切回代码段
- 它告诉链接器,把名为
_GLOBAL__sub_I_Foo::numFoos
(通常是某个全局对象构造函数)的函数指针放到.init_array
段里。 - 链接器会把所有类似的函数指针集中到一起,最终形成一个数组,开始和结尾由
__init_array_start
和__init_array_end
标记。
整个流程是怎样的?
- 编译阶段,编译器把所有需要程序启动时执行的初始化函数地址放到
.init_array
。 - 链接阶段,链接器把
.init_array
中的所有指针汇总,同时生成__init_array_start
和__init_array_end
。 - 运行时启动代码(比如
__libc_csu_init
)会遍历这个区间,调用所有的初始化函数,确保全局对象构造函数被执行。 - 初始化完成后才调用
main()
。
总结
__init_array_start
和__init_array_end
是 ELF 文件中.init_array
段的开始和结束标记。.init_array
中存的是初始化函数指针,程序启动时由 libc 的初始化代码逐个调用。- 你的代码段是把某个全局对象的构造函数放进这个数组,让程序启动时自动执行。
如果你想,我可以帮你用readelf -S
、readelf -s
或objdump
等工具,实际查看你生成的 ELF 文件里的.init_array
段内容和这些符号。需要的话告诉我!
链接器(Linker)干了什么?
链接器的作用非常关键,它是把程序的各个部分拼接起来,生成最终可执行文件的工具,主要职责包括:
- 解析各个
.o
(目标文件)之间的符号引用
比如hello.o
里调用了getMessage()
,而getMessage()
实际实现放在message.o
,链接器负责把这两个函数对应起来。 - 决定可执行文件的布局
包括代码段、数据段、符号表、重定位表等在内的布局安排。 - 写入元数据
比如程序入口点、动态链接器路径、节头表等。
示例程序(hello world)
程序分成两个文件:
hello.cpp
:调用getMessage()
,然后打印message.cpp
:实现getMessage()
,返回字符串
编译步骤:
g++ -Os -o hello.o hello.cpp
g++ -Os -o message.o message.cpp
g++ -Os -o hello message.o hello.o
- 先把两个
.cpp
分别编译成.o
文件(目标文件),这是编译器产生的机器码但还没有链接。 - 然后用链接器把两个
.o
文件合成最终的可执行文件hello
。
运行:
./hello
Hello world
什么是目标文件(Object Files)?
.o
文件是编译器产出的中间文件,包含机器码、符号表、重定位信息等。- 不是完整的程序,还需要链接器把各个
.o
文件合成一个完整的程序。
用file
命令可以查看:
file hello hello.o message.o
结果显示:
hello
: ELF 64位可执行文件,动态链接hello.o
,message.o
: ELF 64位可重定位文件(relocatable),还不是最终程序
用 objdump
看目标文件内容
objdump -dC hello.o
-d
:反汇编机器码,查看汇编代码-C
:对C++符号名进行解码(demangle),让名字更友好
这样可以看到每个目标文件中实际包含的汇编指令和函数符号。
总结
- 编译器先把源代码编译成目标文件(
.o
),每个文件相对独立,符号可能未解决。 - 链接器负责把所有
.o
文件合成一个可执行文件,解决符号引用。 - 最终生成的程序才能运行。
解释这个 objdump
输出,帮你理解“对象文件里有什么”。
对象文件里有什么?—— 通过 objdump
看汇编和重定位信息
你用的是:
objdump -dC hello.o
这条命令会把目标文件里的 .text
段(代码段)反汇编出来,带上函数名(C++符号名解码)。
1. 基本的反汇编输出(-dC)
0000000000000000 <greet()>:0: 55 push %rbp1: 48 89 e5 mov %rsp,%rbp4: e8 00 00 00 00 callq 9 <greet()+0x9>9: 48 89 c6 mov %rax,%rsic: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax # 13 <greet()+0x13>13: 48 89 c7 mov %rax,%rdi16: e8 00 00 00 00 callq 1b <greet()+0x1b>1b: 48 8d 35 00 00 00 00 lea 0x0(%rip),%rsi # 22 <greet()+0x22>22: 48 89 c7 mov %rax,%rdi25: e8 00 00 00 00 callq 2a <greet()+0x2a>
这是函数 greet()
的汇编指令:
- 前几条指令是典型的函数栈帧开辟和恢复指令。
callq
指令调用其他函数,但现在偏移都是0
,这说明调用的地址还没确定。
2. 加上 --reloc
参数看重定位条目
objdump --reloc -dC hello.o
会在反汇编里显示重定位信息,比如:
4: e8 00 00 00 00 callq 9 <greet()+0x9>5: R_X86_64_PLT32 getMessage()-0x4
c: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax # 13 <greet()+0x13>f: R_X86_64_REX_GOTPCRELX std::cout-0x4
16: e8 00 00 00 00 callq 1b <greet()+0x1b>17: R_X86_64_PLT32 std::basic_ostream<char, std::char_traits<char> >& std…
重点:
- R_X86_64_PLT32:这个重定位表明这里调用的函数
getMessage()
地址还未确定,链接器稍后会帮你把它改成正确的地址(通过跳转表 PLT)。 - R_X86_64_REX_GOTPCRELX:表示这里访问的是全局变量(比如
std::cout
),它的地址也需要链接器调整(通过全局偏移表 GOT)。 - 这些“重定位项”是对象文件告诉链接器:“这个地方需要你帮我填正确地址”。
总结
- 对象文件包含机器码 + 符号信息 + 重定位表
- 机器码里存在未解决的函数调用和变量访问地址,表现为调用指令的偏移是 0。
- 重定位表(relocation)告诉链接器需要修改的地址和符号,链接器会把正确地址写进去,生成最终可执行文件。
- 只有经过链接的程序才能正常调用所有函数和访问所有变量。
符号表(Symbols)解释
在 objdump --syms -C
输出中,符号表显示了目标文件中的所有符号及其相关信息。符号表是一个非常重要的部分,它帮助链接器解决不同目标文件中的函数、变量、段和其他符号的引用。
命令解释
objdump --syms -C hello.o
输出的是 hello.o
文件中的符号表,带有符号的名称以及它们在文件中的位置、类型等信息。
分析输出
1. 对于 hello.o
文件的符号表
SYMBOL TABLE:
00000000 l df *ABS* 00000000 hello.cpp
00000000 l d .text 00000000 .text
00000000 l d .data 00000000 .data
00000000 l d .bss 00000000 .bss
00000000 l d .rodata 00000000 .rodata
00000000 l O .rodata 00000001 std::piecewise_construct
00000000 l O .bss 00000001 std::__ioinit
0000003d l F .text 00000049 __static_initialization_and_destruction_0(int, i…
00000086 l F .text 00000015 _GLOBAL__sub_I_hello.cpp
00000000 l d .init_array 00000000 .init_array
00000000 l d .note.GNU-stack 00000000 .note.GNU-stack
00000000 l d .eh_frame 00000000 .eh_frame
这里是输出的一部分符号表,包含了不同类型的符号,按行逐项分析:
00000000 l df *ABS* 00000000 hello.cpp
:hello.cpp
文件是一个源文件标记符号,表示源文件本身是一个符号,它的类型为ABS
(绝对符号)。这意味着这个符号表示一个源文件路径,而不是程序代码中的某个函数或变量。.text
,.data
,.bss
,.rodata
, 等段符号:l d .text 00000000 .text
: 表示.text
段的符号,这个段包含代码。符号类型为d
,表示该符号是段定义。.data
,.bss
,.rodata
: 这些符号标记了.data
、.bss
、.rodata
等段的位置,它们分别是数据段、未初始化数据段和只读数据段。- 这些段通常包含程序中声明的变量,
.text
中存放程序代码,.data
存放已初始化的全局变量,.bss
存放未初始化的全局变量,.rodata
存放只读数据(如字符串常量)。
00000000 l O .rodata 00000001 std::piecewise_construct
: 这里显示的是std::piecewise_construct
符号,位于.rodata
段,类型为O
(外部符号),表示它是一个外部符号,它的地址是 0x00000001。00000000 l O .bss 00000001 std::__ioinit
: 同样是外部符号,标记了std::__ioinit
变量,位于.bss
段。.bss
段包含未初始化的全局变量,这里标明了std::__ioinit
是该段的一个变量。
0000003d l F .text 00000049 __static_initialization_and_destruction_0(int, i…)
: 这是一个函数符号,__static_initialization_and_destruction_0
,位于.text
段,偏移量是 0x3d,函数的实际代码长度为 0x49 字节。- 该函数负责初始化和析构静态对象等任务。
00000086 l F .text 00000015 _GLOBAL__sub_I_hello.cpp
: 这是另一个函数符号,_GLOBAL__sub_I_hello.cpp
,也是位于.text
段的一个函数,通常与 C++ 的全局对象初始化和析构相关。.init_array
,.note.GNU-stack
,.eh_frame
:这些符号表示特定的系统相关段:.init_array
:包含初始化函数的数组,通常会包含一些启动代码,如 C++ 全局构造函数。.note.GNU-stack
:表示栈保护信息。.eh_frame
:包含异常处理信息。
2. 对于 message.o
文件的符号表
SYMBOL TABLE:
00000000 l df *ABS* 00000000 message.cpp
00000000 l d .text 00000000 .text
00000000 l d .data 00000000 .data
00000000 l d .bss 00000000 .bss
00000000 l d .rodata 00000000 .rodata
00000000 l d .note.GNU-stack 00000000 .note.GNU-stack
00000000 l d .eh_frame 00000000 .eh_frame
00000000 l d .comment 00000000 .comment
00000000 g F .text 00000008 getMessage()
00000000 g F .text 00000008 getMessage()
:
这是getMessage()
函数的符号,位于.text
段,偏移量是 0x8。g
表示全局符号,F
表示它是一个函数。getMessage()
是一个外部可访问的函数,它的地址是由链接器来解决的。
其他行和hello.o
的符号表类似,表示段和其他信息。
符号表总结
- 符号表存储了目标文件中的所有符号信息,包含函数、变量、段等。
- 每个符号都有类型标记:
F
表示函数,O
表示外部符号,d
表示段定义。 - 全局符号(如
getMessage()
)和 局部符号(如.text
,.data
)有不同的作用,后者主要帮助链接器找到符号的地址。
Linker 的工作流程
在链接阶段,链接器(Linker) 的作用非常重要,它的主要工作包括:
- 读取输入文件:链接器会处理传递给它的所有目标文件(
.o
文件)和库文件。 - 标识符号:链接器会从这些文件中识别出函数和变量的符号,确定它们的地址和位置。
- 应用重定位:如果目标文件中存在需要修正的地址(例如,调用其他文件中的函数),链接器会调整这些地址。
链接过程中的各个步骤
以你提供的示例为基础,这里简单地总结一下链接器如何工作:
输入文件
message.o
:包含getMessage()
函数和只读数据(例如字符串"Hello world"
)。hello.o
:包含greet()
函数以及main()
函数。
程序段(Sections)
.text
:包含程序的执行代码。- 在
message.o
中,定义了getMessage()
。 - 在
hello.o
中,定义了greet()
和main()
。
- 在
.rodata
:只读数据段,通常用来存储常量,比如字符串"Hello world"
。
程序头(Program Headers)
程序头是用于描述程序执行时各个部分如何被加载到内存中的信息:
.text
:代码段,包含执行的函数(如greet()
,getMessage()
和main()
)。.rodata
:包含只读数据(如"Hello world"
字符串)。
链接器脚本(Linker Scripts)
链接器脚本定义了如何将不同的段放置到内存中,如何做重定位,以及其他的链接细节。链接器脚本允许用户对链接过程进行更多的控制。通过脚本,用户可以指定目标文件中段的顺序、如何处理符号,甚至指定一些特定的优化。
查看链接器的输出
运行以下命令:
$ g++ -o /dev/null -x c /dev/null -Wl,--verbose
这将告诉链接器显示详细的链接过程,包括它如何处理输入文件、如何应用重定位和如何生成程序头等。具体来看,-Wl,--verbose
命令会将详细的链接器信息打印出来,这对调试链接问题非常有帮助。
-o /dev/null
:表示链接器的输出会丢弃,不会生成实际的可执行文件。-x c
:将输入源文件视为 C 源代码。/dev/null
:不使用实际的源文件。-Wl,--verbose
:这个参数将告诉链接器输出详细的信息,包括其如何处理目标文件的各个段、符号、重定位等。
链接器输出示例
当执行上面的命令时,你可能会看到如下信息:
attempt to open /usr/lib/gcc/x86_64-linux-gnu/9/../../../../lib/x86_64-linux-gnu/crt1.o succeeded
attempt to open /usr/lib/gcc/x86_64-linux-gnu/9/../../../../lib/x86_64-linux-gnu/crt1.o failed
...
linking shared library /lib/x86_64-linux-gnu/libc.so
...
这些信息会展示链接器如何找到和处理目标文件中的每个段,并应用适当的重定位。
总结
- 链接器负责将各个目标文件中的代码和数据段组合成一个可执行文件或共享库。
- 它会读取符号(如函数名和变量),并解决符号的引用,比如将
greet()
中的getMessage()
连接到message.o
中定义的符号。 - 重定位是在链接过程中必须做的,它确保程序中的地址引用正确指向实际的位置。
- 链接器脚本允许开发者自定义链接过程,控制符号的地址和段的排列顺序等。
链接器脚本解析:
链接器脚本(Linker Script)在链接过程中的作用是控制如何将目标文件的各个部分合并到最终的可执行文件中。它帮助我们控制段的排列顺序、如何管理符号、如何处理重定位等。
你提供的脚本片段是 GNU 链接器脚本的一部分,它负责组织 .init_array
段,并为一些特定的符号设置位置。我们逐行分析这些内容。
脚本解析
1. .init_array
段
.init_array
{: PROVIDE_HIDDEN (__init_array_start = .);KEEP (*(SORT_BY_INIT_PRIORITY(.init_array.*) SORT_BY_INIT_PRIORITY(.ctors.*)))KEEP (*(.init_array EXCLUDE_FILE (*crtbegin.o *crtbegin?.o *crtend.o *crtend?.o ) .ctors))PROVIDE_HIDDEN (__init_array_end = .);
}
.init_array
:这个段用于存储初始化函数,通常是在程序启动时执行的代码(例如,全局构造函数)。它会在程序启动时按照指定的优先级调用函数。PROVIDE_HIDDEN (__init_array_start = .);
:这行设置了符号__init_array_start
,它标识了.init_array
段的起始位置。PROVIDE_HIDDEN
用于提供一个符号并使其在链接过程中保持隐藏。KEEP
:指示链接器“保持”指定的段,这意味着即使某些段没有在程序中显式使用,它们也不会被删除。例如:*(SORT_BY_INIT_PRIORITY(.init_array.*))
和SORT_BY_INIT_PRIORITY(.ctors.*)
将.init_array
和.ctors
段按照初始化优先级进行排序。
EXCLUDE_FILE
:这部分排除了特定的文件,通常这些文件与 C 运行时有关,例如crtbegin.o
和crtend.o
,这些文件在程序初始化和销毁时非常重要,但它们本身不应该出现在.init_array
中。PROVIDE_HIDDEN (__init_array_end = .);
:这行设置了__init_array_end
符号,表示.init_array
段的结束位置。
2. 重要段和符号
.ctors
:它是一个 C++ 特有的段,存储全局对象的构造函数指针。KEEP (*(.ctors))
表示链接器将保留这个段中的内容。crtbegin.o
和crtend.o
:这两个文件通常由 C 运行时提供,分别用于在程序开始和结束时设置一些必要的初始化工作。例如,crtbegin.o
可能包含与main()
函数的调用顺序和环境设置有关的代码,crtend.o
则在程序结束时负责调用全局析构函数。
总结:
.init_array
段:该段用于存储程序初始化时调用的函数。在 C/C++ 程序中,通常用于构造函数和程序的初始化任务。链接器脚本通过PROVIDE_HIDDEN
符号和KEEP
指令来定义该段的位置和如何排序。- 排序和优先级:脚本会通过
SORT_BY_INIT_PRIORITY
来控制.init_array
中函数的调用顺序。 - 排除特定文件:使用
EXCLUDE_FILE
排除了与 C 运行时有关的文件,如crtbegin.o
和crtend.o
,这些文件由运行时自行处理,而不应该出现在初始化函数列表中。 - 符号定义:通过
PROVIDE_HIDDEN
创建了__init_array_start
和__init_array_end
符号,它们分别标识.init_array
段的开始和结束位置。
1. 编译器与链接器之间的工作流程
- 编译器:
- “Static init” 函数:每个翻译单元(Translation Unit,简称 TU)都会有一个静态初始化函数。该函数会在程序启动时执行。
- 初始化函数指针:编译器将指向这些静态初始化函数的指针放入
init_array
中,确保程序启动时能够执行这些初始化工作。
- 链接器:
- 聚合
init_array
:链接器会将所有目标文件中的init_array
合并在一起。链接器脚本会定义符号(如__init_array_start
和__init_array_end
),指向init_array
的开始和结束位置。 - 符号管理:链接器根据脚本中的定义来确定
init_array
的正确布局。
- 聚合
- C 运行时:
- 遍历
init_array
并调用:程序在启动时,C 运行时会遍历init_array
,依次调用其中的函数。这些函数一般是由编译器生成的静态初始化函数,用来完成各个翻译单元的初始化工作。
- 遍历
2. 可以做的优化和链接器选项
- 自定义链接器脚本:你可以自己编写链接器脚本,来控制段的布局、符号的解析方式、以及其他更多的低级操作。
- 去除未使用的节:通过链接器选项
-Wl,--gc-sections
,链接器可以丢弃那些没有使用的代码段和数据段,从而减少可执行文件的大小。 - 编译器优化选项:
-ffunction-sections
:每个函数被放置到独立的段中,这使得链接器可以根据实际使用情况丢弃未用到的函数。-fdata-sections
:每个数据段被放置到独立的段中,类似于函数段,链接器也可以丢弃未用到的数据。
3. 动态链接与静态链接
- 动态链接:
- 你通过编译生成了一个共享库
libhello.so
,然后链接到主程序中。动态链接允许程序在运行时加载共享库中的代码,减少了可执行文件的体积。 g++ -shared -o libhello.so message.o
:生成共享库libhello.so
。g++ -Os -o hello.o hello.cpp -L. -lhello
:在链接时,通过-L
指定共享库的路径,并通过-l
指定库文件(libhello.so
)。- 运行时,程序会从
libhello.so
中加载函数getMessage()
并执行。
- 你通过编译生成了一个共享库
- 静态链接:
- 静态链接会将所有的库代码直接嵌入到可执行文件中,这通常会导致生成的可执行文件更大,但运行时不依赖于外部共享库。
4. ELF 头与动态头信息
- 通过
readelf --dynamic --program-headers
命令,可以查看 ELF 文件中的动态部分和程序头信息。你提供的 ELF 文件信息显示了程序的入口点(Entry Point)以及各个段的信息。最重要的几个段包括:- PHDR:程序头段,用于描述文件中的各个段。
- INTERP:指示程序使用的动态链接器,这里是
/lib64/ld-linux-x86-64.so.2
。 - LOAD:加载段,表示程序在内存中的加载位置。
通过动态链接,程序会根据这些信息加载共享库和执行动态链接的过程。
结论:
我们通过这些步骤了解了:
- 编译器和链接器 如何处理初始化函数(静态初始化)以及如何管理符号和段。
- 优化选项(如
-ffunction-sections
和-Wl,--gc-sections
)如何帮助我们减少最终可执行文件的大小。 - 动态链接与静态链接 的区别以及如何利用动态链接来减少可执行文件的体积并共享库中的代码。
- ELF 文件结构:动态链接相关的信息如何存储在 ELF 文件的动态头和程序头中。
代码考古学 - 第二部分概述
这部分内容主要探讨了 动态链接(Dynamic Linking)过程中的一些关键概念和技术,包括符号解析、调试技术、以及高级优化方法。
1. PLT (Procedure Linkage Table) 解析
- PLT (过程链接表) 用于动态链接。在程序运行时,当需要调用一个共享库中的函数时,程序并不会直接跳转到这个函数的地址,而是跳转到 PLT 中的入口。PLT 负责处理符号的解析,即找到共享库中对应函数的实际地址。
示例:getMessage()@plt
- 在
getMessage()
函数的 PLT 部分,初始的跳转地址是一个指向 GOT(Global Offset Table)的地址,具体是0x200962(%rip)
,最终解析到真实的函数地址。 - 下面是 PLT 部分的 disassembly:
0x4006b0: jmpq *0x200962(%rip)
:跳转到动态链接符号的地址。0x4006b6: pushq $0x0
:推入一个参数。0x4006bb: jmpq 0x4006a0
:执行跳转到一个已解析的函数地址。
最终,0x601018
地址保存了真实的函数地址,类似于:0x601018: .quad 0x4006b6
,即真实函数地址。
动态链接的解析
- 一开始,程序中的 PLT 使用一个间接跳转。后续会在 GOT 中更新地址,指向实际的函数,减少了运行时的开销。
- 例子中,
getMessage()
最初是跳转到0x4006b6
,后来通过动态链接解析,变为:0x601018: .quad 0x7ffff7bd35d5
,最终跳转到实际的getMessage()
函数。
2. 调试工具和方法
- LD_BIND_NOW(或
-Wl,-znow
):- 这个选项强制链接器在程序启动时就解析所有的符号,而不是在函数第一次被调用时才解析。这可以帮助调试动态链接的问题。
- ldd 和 LD_DEBUG:
ldd
用于显示可执行文件或共享库的依赖关系。LD_DEBUG
提供了更详细的动态链接过程的调试信息,可以帮助你追踪符号解析的过程。
- LD_PRELOAD:
- 这个环境变量允许你在程序运行时加载一个共享库,通常用于覆盖程序中原有的库函数。这对于调试和注入自定义代码非常有用。
3. 需要更多时间解决的问题
- 弱引用(Weak References):动态链接中可能会出现弱符号引用问题,这些符号在运行时可能不一定能够正确解析。
- ODR 违反(One Definition Rule Violations):在 C++ 中,ODR 违反是指同一个符号在多个翻译单元中有不同的定义,可能导致链接错误或不确定的行为。
- LTO(Link Time Optimization):链接时优化,可以在链接阶段进行更多的优化,减少代码冗余,但这也可能增加调试的复杂度。
4. 更多阅读和学习资源
- 博客和文章:
- Ian Lance Taylor 和 Honza Hubička 都有博客,提供了关于编译器、链接器和优化的深入分析。你可以通过阅读他们的博客来更好地理解这些复杂的编译与链接技术。
- Honza Hubička’s Blog: hubicka.blogspot.com
- Ian Lance Taylor’s Blog: airs.com/blog
总结:
这部分内容深入探讨了动态链接和 PLT 的工作原理,以及如何利用调试工具(如 LD_DEBUG
和 LD_PRELOAD
)进行调试。动态链接是现代程序中常用的技术,它能显著减少程序的大小和内存使用,同时也带来了一些复杂性,特别是在符号解析和库加载时的调试。