C++ Object File (ELF/PE) 结构:理解符号表、重定位表与代码段

哈喽,各位好!今天咱们要聊点底层的东西,但是别害怕,保证有趣!我们要扒一扒C++编译后产生的“尸体”——也就是目标文件(Object File)的结构。具体来说,我们要解剖一下ELF(Linux下的常用格式)和PE(Windows下的常用格式)目标文件,重点关注符号表、重定位表和代码段这几个关键部位。

目标文件是个啥?

想象一下,你写了一堆C++代码,比如main.cppfoo.cppbar.cpp。每个.cpp文件会被编译器分别编译成一个目标文件(比如main.ofoo.obar.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:

  1. .text (代码段): 存放编译后的机器码,也就是程序的指令。
  2. .data (已初始化数据段): 存放已经初始化的全局变量和静态变量。
  3. .bss (未初始化数据段): 存放未初始化的全局变量和静态变量。实际上在文件中它不占空间,只是记录了需要分配的空间大小。
  4. .symtab (符号表): 存放了程序中定义的符号的信息,比如函数名、变量名、全局变量名等。符号表是链接器进行符号解析的关键。
  5. .rel.text (重定位表): 存放了需要进行地址重定位的信息。在编译时,有些地址是不知道的,需要链接器在链接时进行修正。
  6. .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

可以看到,addmain函数的汇编指令都在.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_varuninitialized_global_varaddmain都在符号表中。 符号类型为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::coutstd::endlglobal_varadd都需要进行重定位。链接器会根据符号表中的信息,将这些符号的地址填充到相应的call指令中。

链接过程:把“尸体”拼起来

链接器的工作就是把多个目标文件和库文件链接成一个可执行文件或共享库。链接过程主要包括以下几个步骤:

  1. 符号解析: 链接器会遍历所有目标文件的符号表,找到所有未定义的符号,然后在其他目标文件或库文件中查找这些符号的定义。
  2. 地址分配: 链接器会为每个段分配虚拟地址空间。
  3. 重定位: 链接器会根据重定位表的信息,修正目标文件中的地址。

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 字符串表,存放符号名称字符串

一些幽默的比喻

  • 目标文件: 就像一堆零件,每个零件都有自己的功能,但还不能直接用。
  • 符号表: 就像零件的说明书,告诉我们每个零件叫什么名字,有什么作用。
  • 重定位表: 就像零件的安装说明,告诉我们哪些零件需要调整位置才能装配到一起。
  • 链接器: 就像一个组装大师,把这些零件按照说明书组装成一个完整的产品。

希望通过这次“解剖”,大家对目标文件有了更深入的了解。以后再遇到链接错误,就可以尝试从目标文件的角度去分析问题了。记住,了解底层原理,才能更好地掌控你的代码!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注