哈喽,各位好!今天咱们要聊点底层的东西,但是别害怕,保证有趣!我们要扒一扒C++编译后产生的“尸体”——也就是目标文件(Object File)的结构。具体来说,我们要解剖一下ELF(Linux下的常用格式)和PE(Windows下的常用格式)目标文件,重点关注符号表、重定位表和代码段这几个关键部位。
目标文件是个啥?
想象一下,你写了一堆C++代码,比如main.cpp
、foo.cpp
、bar.cpp
。每个.cpp
文件会被编译器分别编译成一个目标文件(比如main.o
、foo.o
、bar.o
)。这些.o
文件就是咱们今天要研究的对象。目标文件里包含了编译后的机器码、数据,还有一些关键的“说明书”,告诉链接器怎么把这些零散的代码拼成一个完整的程序。
目标文件的常见格式:ELF和PE
ELF (Executable and Linkable Format) 和 PE (Portable Executable) 是两种常见的可执行文件、共享库和目标文件的格式。
- ELF: 主要用于类Unix系统,比如Linux、BSD。
- PE: 主要用于Windows。
虽然细节上有所不同,但它们的基本结构和概念是相似的。我们先以ELF为例,然后简单说说PE的区别。
ELF文件结构概览
一个ELF文件通常包含以下几个部分:
- ELF Header: 文件的“头部”,包含了ELF文件的基本信息,比如文件类型、目标架构、入口点地址等。
- Program Header Table: (可选) 描述了如何将文件加载到内存中运行。对于可执行文件来说是必须的,但对于目标文件来说是可选的。
- Section Header Table: 描述了文件中各个段(Section)的信息,比如段的名称、大小、地址、类型等。这个表对于链接器来说至关重要。
- Sections: 包含了实际的代码、数据、符号表、重定位表等。
关键Section解剖
我们重点关注以下几个Section:
- .text (代码段): 存放编译后的机器码,也就是程序的指令。
- .data (已初始化数据段): 存放已经初始化的全局变量和静态变量。
- .bss (未初始化数据段): 存放未初始化的全局变量和静态变量。实际上在文件中它不占空间,只是记录了需要分配的空间大小。
- .symtab (符号表): 存放了程序中定义的符号的信息,比如函数名、变量名、全局变量名等。符号表是链接器进行符号解析的关键。
- .rel.text (重定位表): 存放了需要进行地址重定位的信息。在编译时,有些地址是不知道的,需要链接器在链接时进行修正。
- .strtab (字符串表): 存放了符号表中符号名称的字符串。
代码示例:一个简单的C++程序
先来一个简单的C++程序example.cpp
:
#include <iostream>
int global_var = 10; // 已初始化全局变量
int uninitialized_global_var; // 未初始化全局变量
int add(int a, int b) {
return a + b;
}
int main() {
int local_var = 5;
std::cout << "Hello, world!" << std::endl;
int sum = add(global_var, local_var);
std::cout << "Sum: " << sum << std::endl;
return 0;
}
使用g++ -c example.cpp -o example.o
编译成目标文件example.o
。
1. 代码段 (.text)
代码段包含了add
函数和main
函数的机器码。可以使用objdump -d example.o
命令查看反汇编代码。
objdump -d example.o
example.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <_Z3addii>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 89 7d fc mov %edi,-0x4(%rbp)
7: 89 75 f8 mov %esi,-0x8(%rbp)
a: 8b 45 fc mov -0x4(%rbp),%eax
d: 8b 55 f8 mov -0x8(%rbp),%edx
10: 01 d0 add %edx,%eax
12: 5d pop %rbp
13: c3 ret
0000000000000014 <main>:
14: 55 push %rbp
15: 48 89 e5 mov %rsp,%rbp
18: 48 83 ec 20 sub $0x20,%rsp
1c: c7 45 f4 05 00 00 00 movl $0x5,-0xc(%rbp)
23: bf 00 00 00 00 mov $0x0,%edi ; std::cout
28: e8 00 00 00 00 call 2d <main+0x19> ; 假设这是一个外部函数的调用,需要重定位
2d: 48 8d 35 00 00 00 00 lea 0x0(%rip),%rsi # 34 <main+0x20>
34: be 00 00 00 00 mov $0x0,%esi
39: ba 00 00 00 00 mov $0x0,%edx
3e: e8 00 00 00 00 call 43 <main+0x2f> ; 假设这是一个外部函数的调用,需要重定位
43: 8b 05 00 00 00 00 mov 0x0(%rip),%eax # 49 <main+0x35>
49: 89 c6 mov %eax,%esi
4b: 8b 45 f4 mov -0xc(%rbp),%eax
4e: 89 d0 mov %edx,%eax
50: e8 00 00 00 00 call 55 <main+0x41> ; 调用 add
55: 89 45 f8 mov %eax,-0x8(%rbp)
58: bf 00 00 00 00 mov $0x0,%edi
5d: 48 8d 35 00 00 00 00 lea 0x0(%rip),%rsi # 64 <main+0x50>
64: be 00 00 00 00 mov $0x0,%esi
69: ba 00 00 00 00 mov $0x0,%edx
6e: e8 00 00 00 00 call 73 <main+0x5f> ; 假设这是一个外部函数的调用,需要重定位
73: b8 00 00 00 00 mov $0x0,%eax
78: c9 leave
79: c3 ret
可以看到,add
和main
函数的汇编指令都在.text
段中。注意看call
指令,后面的地址都是00 00 00 00
。这是因为这些函数(比如std::cout
)是在其他地方定义的,编译器不知道它们的实际地址,所以留了个空,需要链接器来填充,这就是重定位。
2. 数据段 (.data 和 .bss)
.data
段存放了已初始化的全局变量global_var
,.bss
段存放了未初始化的全局变量uninitialized_global_var
。
可以使用objdump -s example.o
查看数据段的内容。
objdump -s example.o
example.o: file format elf64-x86-64
Contents of section .data:
0000 0a000000 ....
Contents of section .bss:
可以看到,.data
段中存放了global_var
的值 0a 00 00 00
(10的十六进制表示),而.bss
段是空的,因为它只是预留了空间。
3. 符号表 (.symtab)
符号表是目标文件中最重要的部分之一。它记录了程序中定义的各种符号,比如函数名、变量名、全局变量名等。链接器就是通过符号表来解析符号引用的。
可以使用objdump -t example.o
查看符号表。
objdump -t example.o
example.o: file format elf64-x86-64
SYMBOL TABLE:
0000000000000000 l df *ABS* 0000000000000000 example.cpp
0000000000000000 l d .text 0000000000000000 .text
0000000000000000 l d .data 0000000000000000 .data
0000000000000000 l d .bss 0000000000000000 .bss
0000000000000000 l d .rodata 0000000000000000 .rodata
0000000000000000 l d .comment 0000000000000000 .comment
0000000000000000 g O .data 0000000000000000 global_var
0000000000000000 g O .bss 0000000000000004 uninitialized_global_var
0000000000000000 g F .text 0000000000000000 _Z3addii
0000000000000014 g F .text 0000000000000000 main
w *UND* 0000000000000000 _GLOBAL_OFFSET_TABLE_
U *UND* 0000000000000000 std::cout
U *UND* 0000000000000000 std::basic_ostream<char, std::char_traits<char> >& std::endl<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&)
U *UND* 0000000000000000 std::ostream::operator<<(std::basic_ostream<char, std::char_traits<char> >& (*)(std::basic_ostream<char, std::char_traits<char> >&))
U *UND* 0000000000000000 std::ostream::operator<<(int)
U *UND* 0000000000000000 std::basic_ostream<char, std::char_traits<char> >& std::ostream::operator<<(char const*)
符号表中的每一行描述了一个符号。重要的列包括:
- Value: 符号的地址。对于函数来说,是函数的起始地址;对于变量来说,是变量的地址。
- Size: 符号的大小。
- Type: 符号的类型。
F
表示函数,O
表示对象(变量)。 - Bind: 符号的绑定属性。
g
表示全局符号,l
表示局部符号。 - Ndx: 符号所在的段。
- Name: 符号的名称。
可以看到,global_var
、uninitialized_global_var
、add
和main
都在符号表中。 符号类型为UND
的表示未定义,需要链接器在其他目标文件或库中查找。
4. 重定位表 (.rel.text)
重定位表记录了需要进行地址重定位的信息。在编译时,有些地址是不知道的,比如外部函数的地址、全局变量的地址等。编译器会在重定位表中记录这些需要修正的地方,链接器在链接时会根据符号表的信息来修正这些地址。
可以使用objdump -r example.o
查看重定位表。
objdump -r example.o
example.o: file format elf64-x86-64
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000024 R_X86_64_PC32 std::cout-0x4
000000000000002d R_X86_64_PC32 std::basic_ostream<char, std::char_traits<char> >& std::endl<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&)-0x4
000000000000003e R_X86_64_PC32 std::ostream::operator<<(std::basic_ostream<char, std::char_traits<char> >& (*)(std::basic_ostream<char, std::char_traits<char> >&))-0x4
0000000000000043 R_X86_64_PC32 global_var-0x4
0000000000000050 R_X86_64_PC32 _Z3addii-0x4
000000000000005d R_X86_64_PC32 std::ostream::operator<<(char const*)-0x4
000000000000006e R_X86_64_PC32 std::ostream::operator<<(int)-0x4
重定位表中的每一行描述了一个需要重定位的地方。重要的列包括:
- OFFSET: 需要重定位的地址在段内的偏移量。
- TYPE: 重定位的类型。
R_X86_64_PC32
表示使用PC相对地址进行重定位。 - VALUE: 重定位引用的符号的名称。
可以看到,std::cout
、std::endl
、global_var
和add
都需要进行重定位。链接器会根据符号表中的信息,将这些符号的地址填充到相应的call
指令中。
链接过程:把“尸体”拼起来
链接器的工作就是把多个目标文件和库文件链接成一个可执行文件或共享库。链接过程主要包括以下几个步骤:
- 符号解析: 链接器会遍历所有目标文件的符号表,找到所有未定义的符号,然后在其他目标文件或库文件中查找这些符号的定义。
- 地址分配: 链接器会为每个段分配虚拟地址空间。
- 重定位: 链接器会根据重定位表的信息,修正目标文件中的地址。
PE文件结构简介
PE文件(Portable Executable)是Windows操作系统下可执行文件、动态链接库等文件的格式。虽然细节上与ELF不同,但基本概念是相似的。
PE文件主要包含以下几个部分:
- DOS Header: 为了兼容DOS系统,PE文件会包含一个DOS Header。
- PE Header: PE文件的头部,包含了PE文件的基本信息,比如文件类型、目标架构、入口点地址等。
- Section Table: 描述了文件中各个段的信息,比如段的名称、大小、地址、类型等。
- Sections: 包含了实际的代码、数据、资源等。
PE文件也有符号表和重定位表,但格式和ELF有所不同。可以使用工具如dumpbin
来查看PE文件的结构。
总结
目标文件是程序编译后的中间产物,包含了编译后的机器码、数据,以及符号表、重定位表等关键信息。链接器利用这些信息将多个目标文件和库文件链接成一个可执行文件或共享库。理解目标文件的结构对于深入理解程序的编译、链接和加载过程非常有帮助。
表格总结
Section Name | Description |
---|---|
.text | 代码段,存放机器码 |
.data | 已初始化数据段,存放已初始化的全局变量和静态变量 |
.bss | 未初始化数据段,存放未初始化的全局变量和静态变量 |
.symtab | 符号表,存放符号信息 |
.rel.text | 重定位表,存放重定位信息 |
.strtab | 字符串表,存放符号名称字符串 |
一些幽默的比喻
- 目标文件: 就像一堆零件,每个零件都有自己的功能,但还不能直接用。
- 符号表: 就像零件的说明书,告诉我们每个零件叫什么名字,有什么作用。
- 重定位表: 就像零件的安装说明,告诉我们哪些零件需要调整位置才能装配到一起。
- 链接器: 就像一个组装大师,把这些零件按照说明书组装成一个完整的产品。
希望通过这次“解剖”,大家对目标文件有了更深入的了解。以后再遇到链接错误,就可以尝试从目标文件的角度去分析问题了。记住,了解底层原理,才能更好地掌控你的代码!