解读可重定位文件的每一个字节

目标文件

目标文件(Object File)是源代码经过编译包含了机器代码的二进制文件。在 Unix 平台上的目标文件遵循 ELF(Executable Linkable Format)文件格式存储。下面四种类型的文件使用 ELF 格式存储:

本文基于可重定位文件来分析目标文件的结构。可重定位文件的文件结构较为简单,对此有初步印象后可以更好地理解其他类型的目标文件。

分析准备

真正了不起的程序员对自己程序的每一个字节都了如指掌。 ——佚名

在分析目标文件前,这里有一份有代表性的源代码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);

  return a;
}

创建目标文件

将上面的源代码保存为 SimpleSection.c,通过下面的命令编译。

gcc -c SimpleSection.c -O1

本文使用的 GCC 版本是 gcc version 15.2.1 20250813 (GCC),Target x86_64-pc-linux-gnu。不同平台和编译器得到的目标文件内容可能会有所差异。

GCC 编译参数中的 -c 表示仅编译不链接,-O1 表示对代码进行一定程度的优化。编译后会获得与源代码同名但文件扩展名是 .o 的目标文件 SimpleSection.o

经过编译,大小为 285 字节的 C 源代码文件产生了一个 1712 字节的目标文件,本文的目标就是解读目标文件中每个字节的作用。

分析工具

目标文件是二进制文件,如果用常用的文本编辑器如 Vim、Emacs 打开,将会看到一长串不知所云的乱码。至于为什么是这样的乱码,这与字符编码规则有关,不在本文所讨论的范围之中。

分析二进制文件有专门的工具,如 readelfobjdump,一般包含在 binutils 软件包中。

ELF 文件格式

典型的 ELF 可重定位文件包括三个部分:文件头(ELF file header)、段(Sections)、段表(Section header table)。这里先大致介绍三个部分的结构,后面会对这三个部分的数据展开介绍。

ELF 文件头结构

文件头在目标文件的最前面,描述了 ELF 的基本属性。在可执行程序运行前,装载程序会检查要运行的程序是否能够运行,首先就需要检查文件头,判断文件头是合法的 ELF 文件后才会进入后续步骤。在 Linux 中,与 ELF 文件格式相关的定义在 /usr/include/elf.h 中。

这里以 64 位 ELF 文件头结构为例。Elf64_Ehdr 结构由 8 个 16 位、2 个 32 位、3 个 64位整数和 1 个 16 字节数组成员构成,整个文件头占用 64 字节。

#define EI_NIDENT (16)

typedef uint16_t Elf64_Half;
typedef uint32_t Elf64_Word;
typedef uint64_t Elf64_Addr;
typedef uint64_t Elf64_Off;

typedef struct
{
  unsigned char e_ident[EI_NIDENT]; /* Magic number and other info */
  Elf64_Half    e_type;         /* Object file type */
  Elf64_Half    e_machine;      /* Architecture */
  Elf64_Word    e_version;      /* Object file version */
  Elf64_Addr    e_entry;        /* Entry point virtual address */
  Elf64_Off     e_phoff;        /* Program header table file offset */
  Elf64_Off     e_shoff;        /* Section header table file offset */
  Elf64_Word    e_flags;        /* Processor-specific flags */
  Elf64_Half    e_ehsize;       /* ELF header size in bytes */
  Elf64_Half    e_phentsize;        /* Program header table entry size */
  Elf64_Half    e_phnum;        /* Program header table entry count */
  Elf64_Half    e_shentsize;        /* Section header table entry size */
  Elf64_Half    e_shnum;        /* Section header table entry count */
  Elf64_Half    e_shstrndx;     /* Section header string table index */
 } Elf64_Ehdr;

ELF 段

在文件头之后,是目标文件的各个段。段是目标文件所承载的主要数据,与程序运行息息相关。编译器按照计算机运行指令的特点,将代码中不同性质的部分划分为不同的段。默认情况下,代码部分会放在 .text 代码段中,已初始化的全局变量和静态变量放在 .data 数据段中,未初始化或初始化为 0 的全局变量和静态变量放在 .bss 段中。这样的组织方式,可以使链接器链接多个目标文件时非常便利地将相同性质的段合并到一个文件中来处理。这些段的信息,包括段名称、占用空间等信息存放在段表中。

ELF 段表结构

在 ELF 文件的最后,是描述段组织方式的段表。段表的结构也定义在 /usr/include/elf.h 中。这里以 64 位 ELF 文件的段表结构为例。段表就是若干个段表结构体的组合,每一个段用一个段表结构来描述,一项占用 64 字节。

typedef uint64_t Elf64_Xword;
typedef struct
{
  Elf64_Word    sh_name;        /* Section name (string tbl index) */
  Elf64_Word    sh_type;        /* Section type */
  Elf64_Xword   sh_flags;       /* Section flags */
  Elf64_Addr    sh_addr;        /* Section virtual addr at execution */
  Elf64_Off     sh_offset;      /* Section file offset */
  Elf64_Xword   sh_size;        /* Section size in bytes */
  Elf64_Word    sh_link;        /* Link to another section */
  Elf64_Word    sh_info;        /* Additional section information */
  Elf64_Xword   sh_addralign;       /* Section alignment */
  Elf64_Xword   sh_entsize;     /* Entry size if section holds table */
} Elf64_Shdr;

文件头

64 位的 ELF 文件头占用 64 字节。为查看文件的原始数据,可以使用 xxd 命令,-l 参数指定要输出的数据长度。输出第一列是位置偏移(16 进制),一行展示 16 个字节,中间是具体数据的 16 进制表示,右边是数据中的每一个字节在 ASCII 字符集中对应的符号,只能显示可打印字符。

$ xxd -l 64 SimpleSection.o     
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000  .ELF............
00000010: 0100 3e00 0100 0000 0000 0000 0000 0000  ..>.............
00000020: 0000 0000 0000 0000 3003 0000 0000 0000  ........0.......
00000030: 0000 0000 4000 0000 0000 4000 0e00 0d00  ....@.....@.....

一般情况下,右侧对应的 ASCII 符号没有特别含义,只有少数情况下字符能够解析到有对应其含义的字符串。例如,ELF 文件头中的第 2 至 4 字节数据就对应 ELF 这个三个字符。为了了解这些数据对应的含义,需要使用 readelf 来解析这些数据。

通过 readelf -h SimpleSection.o 读取文件头,得到下面的内容。

$ readelf -h SimpleSection.o
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:                              REL (Relocatable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x0
  Start of program headers:          0 (bytes into file)
  Start of section headers:          816 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           0 (bytes)
  Number of program headers:         0
  Size of section headers:           64 (bytes)
  Number of section headers:         14
  Section header string table index: 13

文件头魔数

文件最前面的 16 个字节对应前面的文件头结构中的 16 字节字符数组。前 4 个字符是 7f 45 4c 467f 是控制字符,后面三个刚好对应 ELF。这四个字节被称为 ELF 魔数。当一个可执行文件被执行时,操作系统首先会检查文件的魔数,数值正确才会加载,这可以避免不是可执行文件的文件被当作可执行文件错误执行。对操作系统来说,可执行文件与其他类型文件一样都是数据。数据在可执行文件中是机器指令,在常规文件中就只是数据,只有在数据能够对应合法指令序列时才能正确执行。如果任意的数据都当作指令来执行,不知道会发生怎样的混乱。这就像人类文字在合理的语法下才是有意义的文字,胡乱组合文字就只是呓语。

在魔数之后的数据代表 ELF 文件类型。第五字节 02 代表 64 位 ELF 文件,第六字节 01 代表字节序(小端),第七、八字节 01 00 是 ELF 的版本后,这些数值对应的信息在 readelf 的输出中有标注(Class,Data,OS/ABI,ABI Version)。第九字节之后的数据没有具体定义,可以当作扩展使用,一般是 0。

其他文件头信息

在魔数之后的数据(17 字节起),可对照文件头定义了解其含义。例如 e_type 值为 0001,表示可重定位文件。注意,数据是以小端字节序存放,分析时要转换字节序。

其中比较重要的信息有:

段表

虽然文件头之后就是各个段的信息,但需要先了解在文件最后的段表才能知道每个段的属性和长度。

段表原始数据

根据前面的计算,段表占用的是目标文件最后的 896 字节。xxd-s 参数可以指定从文件特定偏移位置开始的数据,数值为负数表示从文件结尾开始的偏移。

$ xxd -s -896 SimpleSection.o

输出内容占用篇幅较大,在每一个段表项(64字节)后添加了换行以便区分。

00000330: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000340: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000350: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000360: 0000 0000 0000 0000 0000 0000 0000 0000  ................

00000370: 2000 0000 0100 0000 0600 0000 0000 0000   ...............
00000380: 0000 0000 0000 0000 4000 0000 0000 0000  ........@.......
00000390: 3400 0000 0000 0000 0000 0000 0000 0000  4...............
000003a0: 0100 0000 0000 0000 0000 0000 0000 0000  ................

000003b0: 1b00 0000 0400 0000 4000 0000 0000 0000  ........@.......
000003c0: 0000 0000 0000 0000 3802 0000 0000 0000  ........8.......
000003d0: 4800 0000 0000 0000 0b00 0000 0100 0000  H...............
000003e0: 0800 0000 0000 0000 1800 0000 0000 0000 ................

000003f0: 2600 0000 0100 0000 0300 0000 0000 0000  &...............
00000400: 0000 0000 0000 0000 7400 0000 0000 0000  ........t.......
00000410: 0400 0000 0000 0000 0000 0000 0000 0000  ................
00000420: 0400 0000 0000 0000 0000 0000 0000 0000  ................

00000430: 2c00 0000 0800 0000 0300 0000 0000 0000  ,...............
00000440: 0000 0000 0000 0000 7800 0000 0000 0000  ........x.......
00000450: 0400 0000 0000 0000 0000 0000 0000 0000  ................
00000460: 0400 0000 0000 0000 0000 0000 0000 0000  ................

00000470: 3100 0000 0100 0000 3200 0000 0000 0000  1.......2.......
00000480: 0000 0000 0000 0000 7800 0000 0000 0000  ........x.......
00000490: 0400 0000 0000 0000 0000 0000 0000 0000  ................
000004a0: 0100 0000 0000 0000 0100 0000 0000 0000  ................

000004b0: 4000 0000 0100 0000 3000 0000 0000 0000  @.......0.......
000004c0: 0000 0000 0000 0000 7c00 0000 0000 0000  ........|.......
000004d0: 1c00 0000 0000 0000 0000 0000 0000 0000  ................
000004e0: 0100 0000 0000 0000 0100 0000 0000 0000  ................

000004f0: 4900 0000 0100 0000 0000 0000 0000 0000  I...............
00000500: 0000 0000 0000 0000 9800 0000 0000 0000  ................
00000510: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000520: 0100 0000 0000 0000 0000 0000 0000 0000  ................

00000530: 5900 0000 0700 0000 0200 0000 0000 0000  Y...............
00000540: 0000 0000 0000 0000 9800 0000 0000 0000  ................
00000550: 3000 0000 0000 0000 0000 0000 0000 0000  0...............
00000560: 0800 0000 0000 0000 0000 0000 0000 0000  ................

00000570: 7100 0000 0100 0000 0200 0000 0000 0000  q...............
00000580: 0000 0000 0000 0000 c800 0000 0000 0000  ................
00000590: 4800 0000 0000 0000 0000 0000 0000 0000  H...............
000005a0: 0800 0000 0000 0000 0000 0000 0000 0000  ................

000005b0: 6c00 0000 0400 0000 4000 0000 0000 0000  l.......@.......
000005c0: 0000 0000 0000 0000 8002 0000 0000 0000  ................
000005d0: 3000 0000 0000 0000 0b00 0000 0900 0000  0...............
000005e0: 0800 0000 0000 0000 1800 0000 0000 0000  ................

000005f0: 0100 0000 0200 0000 0000 0000 0000 0000  ................
00000600: 0000 0000 0000 0000 1001 0000 0000 0000  ................
00000610: d800 0000 0000 0000 0c00 0000 0400 0000  ................
00000620: 0800 0000 0000 0000 1800 0000 0000 0000  ................

00000630: 0900 0000 0300 0000 0000 0000 0000 0000  ................
00000640: 0000 0000 0000 0000 e801 0000 0000 0000  ................
00000650: 4a00 0000 0000 0000 0000 0000 0000 0000  J...............
00000660: 0100 0000 0000 0000 0000 0000 0000 0000  ................

00000670: 1100 0000 0300 0000 0000 0000 0000 0000  ................
00000680: 0000 0000 0000 0000 b002 0000 0000 0000  ................
00000690: 7b00 0000 0000 0000 0000 0000 0000 0000  {...............
000006a0: 0100 0000 0000 0000 0000 0000 0000 0000  ................

段表解读

借助 readelf 可以将段表解析为可读的数据,-S 指定解析段表。

$ readelf -S SimpleSection.o 
There are 14 section headers, starting at offset 0x330:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000000000  00000040
       0000000000000034  0000000000000000  AX       0     0     1
  [ 2] .rela.text        RELA             0000000000000000  00000238
       0000000000000048  0000000000000018   I      11     1     8
  [ 3] .data             PROGBITS         0000000000000000  00000074
       0000000000000004  0000000000000000  WA       0     0     4
  [ 4] .bss              NOBITS           0000000000000000  00000078
       0000000000000004  0000000000000000  WA       0     0     4
  [ 5] .rodata.str1.1    PROGBITS         0000000000000000  00000078
       0000000000000004  0000000000000001 AMS       0     0     1
  [ 6] .comment          PROGBITS         0000000000000000  0000007c
       000000000000001c  0000000000000001  MS       0     0     1
  [ 7] .note.GNU-stack   PROGBITS         0000000000000000  00000098
       0000000000000000  0000000000000000           0     0     1
  [ 8] .note.gnu.pr[...] NOTE             0000000000000000  00000098
       0000000000000030  0000000000000000   A       0     0     8
  [ 9] .eh_frame         PROGBITS         0000000000000000  000000c8
       0000000000000048  0000000000000000   A       0     0     8
  [10] .rela.eh_frame    RELA             0000000000000000  00000280
       0000000000000030  0000000000000018   I      11     9     8
  [11] .symtab           SYMTAB           0000000000000000  00000110
       00000000000000d8  0000000000000018          12     4     8
  [12] .strtab           STRTAB           0000000000000000  000001e8
       000000000000004a  0000000000000000           0     0     1
  [13] .shstrtab         STRTAB           0000000000000000  000002b0
       000000000000007b  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  D (mbind), l (large), p (processor specific)

这个目标文件共 14 个段,除了第一个段是用于占位的无效段表项之外,共有 13 个有效段,这和与前面文件头所描述的段表数量相匹配。

对照前面提到的ELF 段表结构分析各个字段的作用。段表第一列是段表序号,段表结构中没有这个字段,实际是按段表中项目出现顺序给出的序号。第二列起都有两行,第二列上面是段名称,对应段表结构中的 sh_name。其中存储了段名在段表字符串表中的索引,readelf 是通过该索引读取段表名称字符串才能在解析结果显示段的名称。下面是段的大小,总计 741 字节(.bss 段实际长度为 0)。这和前面的计算结果差了 11 字节,对齐填充部分将解释这个问题。

第三列上面是段的类型,对应段表结构中的 sh_type,常见的有 PROGBITS,表示段中存放的是程序,代码段、数据段都是这个类型。SYMTAB 表示段的内容为符号表,RELA 表示段包含了重定位信息,是重定位表。NOTE 表示段存放提示性信息,NULL 表示该段无效。第三列下面是 EntSize,对应段表结构中的 sh_entsize,表示所指示段中每个项大小,仅在每个项大小固定时有效,否则为 0。

第四列上面的 Address 表示段的虚拟地址,对应段表结构中的 sh_addr。在尚未链接的可重定位文件中,这个值都是 0,因为可重定位文件不能加载,但共享库文件、可执行文件的这个字段是有值的。下面的 Flags 表示段的标志位,对应段表结构中的 sh_flagsLinkInfo 表示段的链接信息,分别对应段表结构中的 sh_linksh_info。段的标志位常标记段在虚拟地址空间的的属性,段表下方的提示性信息给出了各个标记的含义。如 AX 标记的代码段需要在内存中分配空间并且有可执行权限,WA 标记的数据掉表示需要在内存中分配空间且段可写入。与链接相关的重定位表符号表会有链接信息,重定位表标注其使用的字符串表的序号和所作用的段的序号,上面的字符串表下标是 11,因此两个重定位表都在这个这个字段标注了 11。符号表会标注其所使用的字符串表的序号。

最后一列 Offset 表示段在文件中的偏移量,对应段表结构中的 sh_offset。偏移量仅在段出现在文件中才有意义,有的段长度为 0,并没有出现在段中,但在段表中占用一项。Align 标注段的对齐字节数,对应段表结构中的 sh_addralign,表示该段的偏移值必须是标注数值的整数倍。

更为详细的信息可在 /usr/include/elf.h 中查看。下面按照段表列出的顺序依次分析每个段的内容。

了解段表之后,就知道这个目标文件有哪些段了,本节按段表标注的段的序号分析各个段的内容。

.text 代码段

代码段就是 C 源代码经过编译、汇编得到的二进制机器代码。这里先借助 readelf 来查看原始数据。

$ readelf -j .text SimpleSection.o  

Hex dump of section '.text':
 NOTE: This section has relocations against it, but these have NOT been applied to this dump.
  0x00000000 4883ec08 89fe488d 3d000000 00b80000 H.....H.=.......
  0x00000010 0000e800 00000048 83c408c3 4883ec08 .......H....H...
  0x00000020 bf560000 00e80000 0000b801 00000048 .V.............H
  0x00000030 83c408c3                            ....

使用 readelf -x 1 SimpleSection.o 可以得到相同的输出,其中 -x 参数等同于 -j 参数,后面的参数可以是段名,也可以是段的序号。这里还提示这个段有一个对应的重定位段,但尚未应用到输出中。重定位段在后面介绍。

在使用 xxd 查看代码段范围内的原始数据。xxd 只是输出二进制数据,不会附加任何解读。可以发现两者输出的原始数据一致。

$ xxd -s 64 -l 52 SimpleSection.o
00000040: 4883 ec08 89fe 488d 3d00 0000 00b8 0000  H.....H.=.......
00000050: 0000 e800 0000 0048 83c4 08c3 4883 ec08  .......H....H...
00000060: bf56 0000 00e8 0000 0000 b801 0000 0048  .V.............H
00000070: 83c4 08c3    

不过我们不是机器,也不是当初编写机器指令的程序员,为了能看懂这些信息,需要借助 objdump 将机器指令反汇编出来得到人类可读的汇编代码。-d 参数表示 --disassemble 反汇编。汇编语言是机器语言的人类可读助记,相比直接使用机器语言编程更为友好。

$ objdump -d SimpleSection.o

SimpleSection.o:     file format elf64-x86-64


Disassembly of section .text:

0000000000000000 <func1>:
   0:   48 83 ec 08             sub    $0x8,%rsp
   4:   89 fe                   mov    %edi,%esi
   6:   48 8d 3d 00 00 00 00    lea    0x0(%rip),%rdi        # d <func1+0xd>
   d:   b8 00 00 00 00          mov    $0x0,%eax
  12:   e8 00 00 00 00          call   17 <func1+0x17>
  17:   48 83 c4 08             add    $0x8,%rsp
  1b:   c3                      ret

000000000000001c <main>:
  1c:   48 83 ec 08             sub    $0x8,%rsp
  20:   bf 56 00 00 00          mov    $0x56,%edi
  25:   e8 00 00 00 00          call   2a <main+0xe>
  2a:   b8 01 00 00 00          mov    $0x1,%eax
  2f:   48 83 c4 08             add    $0x8,%rsp
  33:   c3                      ret

从反汇编结果可以看到,代码段共有两个函数,对应前面源代码中的 func1main 函数。注意源代码中的 printf 并不存在于这个文件,代码中只声明了这个函数的名称、参数表和返回值,以确保代码在编译过程中能够通过 C 的语法分析。

func1 函数

理解反汇编的内容需要一些汇编语言的知识,这里只做简单介绍。

反汇编输出中,第一列代表指令在代码段中的偏移,func1 函数代码的偏移是 0,说明这个函数在代码段的头部。第一行,也就是第一条汇编指令占用 4 个字节,具体含义是将代表栈指针的 %rsp 寄存器值减去 8。这是为了满足 x86-64 平台上的 System V ABI 规范2,栈指针需要 16 字节对齐,也就是栈顶地址必须是 16 的整数倍。这里先减去 8 字节,后面的 call 指令会再向栈写入 8 字节的指令返回地址。这个配合保证了后续函数调用时,对每个函数来说,栈顶指针都是 16 的倍数。

第二行指令将存放于寄存器 %edifunc1 函数的第一个参数移动到 %esi 寄存器。%esi 寄存器是函数调用时第二个参数的保存位置,而 %edi 寄存器用于存放第一个参数。很明显,在源代码中,func1 的唯一一个参数当作了 printf 的第二个参数。

第三行指令是将一个地址传递到 %rdi 寄存器,作为 printf 的第一个参数。%rdi 寄存器是 %edi 寄存器在传递 64 位数据时的名称。这里使用的地址是 0,这是因为目标文件尚未链接,在代码中使用 0 作为占位符。在链接阶段,链接器将会把实际的 printf 第一参数——一个格式字符串的地址替换掉这个占位符。在源代码中,这个格式字符串就是 %d\n

第四行将数值 0 赋值给 %eax 寄存器。%eax 寄存器通常充当存放函数返回值的寄存器。这里的用法是指出调用的向量寄存器的数量,向量寄存器一般用于浮点数计算,这里没有使用,因此是 0。

第五行就是调用 printf了。和前面一样的原因,printf 的地址需要在链接时确定,这里也使用了 0 作为占位符。第六行是函数调用的返回位置。在函数调用返回后,返回地址会出栈,使得栈顶地址变为 8 的倍数,因此需要再减去 8。这是为了与第一行对应,保证后续的函数在执行时栈顶地址能对齐到 16 的倍数。第七行是返回指令。

main 函数

main 函数紧接前面的 ret 指令,起始地址是代码段的第 0x1c 字节。第二行是把立即数 0x56 赋值给 %edi 寄存器,0x56 对应十进制的 86。分析源代码可以发现,编译器已经将 func1 的参数中的 4 变量表达式计算完成,参数就是 85 + 1。另外两个变量因为没有赋值,初始值为 0。随后调用 func1 函数,再将 1 赋值给 %eax 寄存器作为函数返回值。

main 函数中没有提到的指令可参照前面 func1 函数的说明。

至此,代码段的内容就分析完成了。接下来是 .rela.text 段。

.rela.text 段

.rela.text 段是代码段的重定位段,虽然是段表中的第二项,但其数据并不与代码段相邻,而在整个文件的第 0x238 字节起的 0x48 个字节。因为与代码段关系密切,所以在段表中紧接代码段。

重定位段也可以叫作重定位表,记录了在代码中引用的定义在其他目标文件中的符号(全局变量或函数)。这些符号在虽然在本文件中使用,但因为其定义在其他地方,使用时并不确定这些符号的地址,因此在编译时使用了默认地址作为标记。目标文件链接时,链接器会通过重定位表找到这个目标文件引用的符号的定义,也即找到这些符号的地址,并替换默认的地址标记。如果链接器不能在其他目标文件中找到符号的定义,链接器就会提示 undefined reference to xxx 错误,链接也随即停止。

这是 xxd 显示的原始数据。这里指定文件偏移值时使用了 16 进制数,xxd 也接受 16 进制表示的偏移量和长度,这在处理二进制数据会更方便。

$ xxd -s 0x238 -l 0x48 SimpleSection.o
00000238: 0900 0000 0000 0000 0200 0000 0300 0000  ................
00000248: fcff ffff ffff ffff 1300 0000 0000 0000  ................
00000258: 0400 0000 0500 0000 fcff ffff ffff ffff  ................
00000268: 2600 0000 0000 0000 0400 0000 0400 0000  &...............
00000278: fcff ffff ffff ffff                      ........

可以用 objdump -r SimpleSection.o 读取目标文件中的重定位段,结果中会包括代码段的重定位段和其他段的重定位段。这里仍使用 readelf 来展示代码段的重定位段的解析结果。输出中也提示了这个段的起始位置在 0x238,其中有三个重定位项。额外增加的 -W 参数表示在显示数值是使用 16 个数字表示,默认是 12 个。使用默认值 12 在分析 Info 时可能会造成一些困惑,因为这个字段在原始数据中占用 8 个字节,需要 16 个十六进制数字才能完整表达。

$ readelf -Wj 2 SimpleSection.o

Relocation section '.rela.text' at offset 0x238 contains 3 entries:
    Offset             Info             Type               Symbol's Value  Symbol's Name + Addend
0000000000000009  0000000300000002 R_X86_64_PC32          0000000000000000 .LC0 - 4
0000000000000013  0000000500000004 R_X86_64_PLT32         0000000000000000 printf - 4
0000000000000026  0000000400000004 R_X86_64_PLT32         0000000000000000 func1 - 4

这是重定位段的结构定义,一个重定位项占用 24 字节,三个共 72 字节。

typedef int64_t  Elf64_Sxword;
typedef struct
{
  Elf64_Addr    r_offset;       /* Address */
  Elf64_Xword   r_info;         /* Relocation type and symbol index */
  Elf64_Sxword  r_addend;       /* Addend */
} Elf64_Rela;

重定位表的三个结构体成员分别对应解析结果的 OffsetInfoAddend 列,表示一个需要重定位符号在代码段中的偏移(重定位入口)、重定位的类型以及重定位偏移量。例如,其中的第一项表示这个需要重定位的符号在代码段的第 0x9 字节起的位置,也就是前面介绍 func1 函数第三条代码时提到的提供给 printf 的格式化字符串。这个符号的重定位类型通过 r_info 的低 32 位给出的数值确定(0x00000002),这里解析为 R_X86_64_PC32,表示这个符号在链接时使用相对地址修正方式。注意到这个字符串虽然定义在本文件内,但也出现在了重定位表中。这是为了支持位置无关代码(PIC),这在动态链接中较为常见,这里不做展开。

readelf 给出解析中的 Sym. Value 列和 Sym. Name 列表示所引用符号值(在虚拟内存空间中的地址)和符号名称(变量名或函数名),这两列在重定位结构体中并没有定义,是 readelf 通过 r_info 的高 32 位给出的数值确定的(0x00000003)。这个数值代表这个需要重定位的符号在符号表中的索引(符号表在后面介绍),从符号表中可以读取符号名称和符号值。这里的字符串常量符号名称由汇编器确定,这里是 .LC0。该符号值就是 func1 第三条机器代码中的后四个字节(0x00000000)。

重定位类型

相对地址修正方式 R_X86_64_PC32 是在链接时比较常见的地址修正方式,这里做简要介绍。

为了能让代码访问到符号,CPU 必须知道这个符号的地址(符号值),这个地址是运行时的进程虚拟地址空间中的地址。第一个重定位符号值在链接前是 0,链接完成后必须给出一个确定值,才能让 CPU 在运行时访问。

假设这个格式化字符串在目标文件中的偏移是 0x00112233,想象将这个值作为符号值。那么 CPU 在执行这条指令时,就会从 0x00112233 这个地址去获取字符串的值。显然,这个地址在运行时不一定能访问,它只是符号相对于目标文件中的偏移值,偏移值不能当作内存地址来访问。

注意到,这里都使用了偏移值作为指令、符号、段的位置参照。假设代码段的位置为 0,其他位置与代码段起始的差值就是这些位置的偏移值。在程序运行时,代码段和其他段都会被装载入内存,这块内存的起始地址并不是 0,而是操作系统给定的固定值。代码段的实际内存地址就是这个起始内存地址,其他部分的偏移值加上这个起始地址就是这些部分的实际内存地址。

因此,访问目标符号需要知道目标符号与重定位入口之间偏移值,偏移值加上 CPU 正在执行的指令的地址就是目标符号的地址。通过前面的分析,已知格式化字符串在目标文件中的偏移和重定位入口地址,两者之差就是重定位入口需要跳转的偏移,当前指令地址加上这个偏移就能找到符号的实际地址。实际上这个过程要更复杂一些。

CPU 执行完一条指令需要经过五个阶段,1. 取指令(Fetch),2. 解码(Decode),3. 执行(Execute),4. 内存读写(Memory),5. 写回(Write Back)。下一条要执行的指令存放在 PC 寄存器中,取指令完成后,PC 寄存器(也叫 %rip 寄存器)的值即更新为下一条指令的地址。在后面的阶段,CPU 无法获得当前执行的指令地址。也就是说,我们在访问符号时,不能直接将当前指令地址与符号的偏移值的和作为符号的地址来访问,因为当前指令地址不能获得。我们只能通过下一条指令的地址来迂回地访问目标符号,这需要指令的配合,使链接器只需要给出正确的偏移值,CPU 就能按照下一条指令地址来计算符号的实际地址。

记目标符号的实际地址为 \(S\),r_addend 值记为 \(A\),r_offset 记为 \(P\)。那么重定位入口与目标符号的地址差值就是 \(S - P\)。要通过下一条指令地址来计算当前指令地址(实际上是重定位入口的地址),将前面的差值减去表示重定位入口地址的数据长度即可,这个长度就是 \(A\),在这里是 4 字节。注意到 func1 函数第三条指令后面的参数就是 4 个字节长度。也就是说,链接器计算 \(S + A - P\),将其结果覆盖到这4 个字节(符号值)即可。\(A\) 在 r_addend 中表示为负数,原始数据中是 0xfcff ffff ffff ffff(小端, -4)。指令在获得这个值后,将其与 PC 相加,就得到了目标符号的实际内存地址。

后两个重定位入口是函数,重定位类型有所不同,是 R_X86_64_PLT32。这个类型常见于动态链接时延迟加载函数地址,具体过程更加复杂,但重定位的基本原理相同,这里不做具体介绍。

另外,objdump 还提供了将重定位入口标注到反汇编结果的选项 -r

$ objdump -r -d SimpleSection.o

SimpleSection.o:     file format elf64-x86-64


Disassembly of section .text:

0000000000000000 <func1>:
   0:   48 83 ec 08             sub    $0x8,%rsp
   4:   89 fe                   mov    %edi,%esi
   6:   48 8d 3d 00 00 00 00    lea    0x0(%rip),%rdi        # d <func1+0xd>
                        9: R_X86_64_PC32        .LC0-0x4
   d:   b8 00 00 00 00          mov    $0x0,%eax
  12:   e8 00 00 00 00          call   17 <func1+0x17>
                        13: R_X86_64_PLT32      printf-0x4
  17:   48 83 c4 08             add    $0x8,%rsp
  1b:   c3                      ret

000000000000001c <main>:
  1c:   48 83 ec 08             sub    $0x8,%rsp
  20:   bf 56 00 00 00          mov    $0x56,%edi
  25:   e8 00 00 00 00          call   2a <main+0xe>
                        26: R_X86_64_PLT32      func1-0x4
  2a:   b8 01 00 00 00          mov    $0x1,%eax
  2f:   48 83 c4 08             add    $0x8,%rsp
  33:   c3                      ret

.data 段

.data 段是数据段,存放代码中已初始化的全局变量和静态变量。依据段表的信息,数据段起始与 0x74 字节,长 4 字节。

xxd 的输出如下,也可以使用 readelf -j 3 SimpleSection.o 查看数据段。

xxd -s 0x74 -l 0x4 SimpleSection.o
00000074: 5400 0000                                T...

数据段相对比较简单,readefl 的输出类似,也不能提供具体的解析,只能通过原始数据映射到 ASCII 返回来显示。在这里,只有一个 4 字节的数据,值为 0x54,刚好对应 ASCII 范围中的大写字母 T

数据段存放已初始化的全局变量和静态变量,对照源代码,符合条件的有全局变量 global_init_var 和局部静态变量 static_var0x54 对应十进制值就是 global_init_var 在代码中定义的值 84。但 static_var 却没有在这里出现,这是因为源代码在编译时使用了 -O1 优化选项。static_var 在源代码第 14 行中作为 func1 函数参数的表达式中的一员,编译器在编译阶段发现这个表达式可以直接计算得到结果,而且只使用了一次,因此没有让它成为一个变量。可以在代码段反汇编的结果中看到,main 函数的第二行,使用了一个值为 0x56 的立即数,也就是表达式 static_var + static_var2 + a + b 的结果,86。

如果不使用编译优化,通过 gcc -c SimpleSection.c 编译后,再使用 readelf -j 3 SimpleSection.o 查看数据段,有如下输出。

Hex dump of section '.data':
  0x00000000 54000000 55000000                   T...U...

0x540x55 分别时全局变量 global_init_var 和局部静态变量 static_var 的初始值。

.bss 段

.bss 段中存放未初始化或初始化为 0 的全局变量和静态变量。由于这些变量没有初始化,这个段在目标文件中实际上是空的,占用 0 字节的空间,这可以在一定程度上减少文件的大小。在运行时,这些未初始化的的变量会在内存中用 0 来初始化。.bss 段的名称最早来自 IBM704 汇编语言中的 block started by symbol,现在可把它当作是 Better Save Space! 的缩写以方便记忆。

在段表中可以发现 .bss 段的起始位置是 0x78,长度是 0x4。但 .bss 之后的段的起始位置也是 0x78,说明 .bss 段确实没有占用目标文件空间。段表中显示的长度是程序运行时在这个段中保存的变量所占用的内存空间大小的总和。

$ readelf -j 4 SimpleSection.o   
readelf: Info: Unable to display section 4 - it has no contents

使用 readefl 尝试解析这个段时,提示这个段没有实际内容。源代码中符合条件应当保存在 .bss 段中的变量有 global_uninit_varstatic_var2。实际上,由于编译时使用了优化选项,静态变量 static_var2 并没有出现在编译后的程序中,后面检查符号表时可以发现。如果不使用编译优化,得到的目标文件的 .bss 仍为空,但段表中显示的长度就是 0x8,这是两个整形数字在内存中占用的空间。

.rodata.str1.1 段

.rodata.str1.1 段存放的是程序中使用的只读数据,例如字符串常量和用 const 修饰的常量。示例程序中只使用了一个字符串常量,也就是 printf 的格式化字符串 %d\n。只读数据在运行时只能读取不能修改。例如,如果在程序中尝试修改一个字符串常量,那么程序将以 segment fault 错误终止。

xxd 的输出如下,也可以使用 readelf -j 5 SimpleSection.o 查看。

$ xxd -s 0x78 -l 4 SimpleSection.o  
00000078: 2564 0a00                                %d..

将给出的 16 进制数值对照 ASCII 码表后可以发现,其对应的字符就是 %d\n。最后一个 0x00 是字符串的终止符 \0,因为不是可打印字符,右边没有显示。字符串常量还有一个特点,如果程序中用到了两个或多个相同的字符串,即便指向他们的变量名不同,或者只是格式化字符串,他们只会在只读数据段中出现一次。

.comment 段

.comment 段是编译器存放自身版本信息的段。通过这个段可以方便地获知一个程序是由哪个编译器编译得到了,在一些时候可能会有帮助。

xxd 的输出如下,也可以使用 readelf -j6 SimpleSection.o 查看。

$ xxd -s 0x7c -l 0x1c SimpleSection.o
0000007c: 0047 4343 3a20 2847 4e55 2920 3135 2e32  .GCC: (GNU) 15.2
0000008c: 2e31 2032 3032 3530 3831 3300            .1 20250813.

.note.GNU-stack 段

.note.GNU-stack 段用于标记进程的栈是否可执行。如果这个段为空,说明栈不可执行。一般来说,进程的栈是用于存放数据,可读可写。如果栈可执行,就有可能被恶意程序利用栈溢出的漏洞,执行存放在栈中的程序指令。例如,gets 函数没有限制输入字符串的长度,会接收超出缓冲区大小的数据。如果恶意程序构造特定序列,可能让计算机执行攻击者指定的代码。

$ readelf -j7 SimpleSection.o       
Section '.note.GNU-stack' has no data to dump.

readelf 提示这个段为空。虽然这个段没有数据,这并不意味着可以去掉这个段。链接器在链接时会检查这个段,如果没有出现,可能会给出一个警告。

.note.gnu.property 段

.note.gnu.property 段是用于存放和目标文件相关的属性、系统信息的段。

xxd 的输出如下。

xxd -s 0x98 -l 0x30 SimpleSection.o
00000098: 0400 0000 2000 0000 0500 0000 474e 5500  .... .......GNU.
000000a8: 0200 01c0 0400 0000 0100 0000 0000 0000  ................
000000b8: 0100 01c0 0400 0000 0100 0000 0000 0000  ................

readelf 可以解析这些信息。

$ readelf -j8 SimpleSection.o

Displaying notes found in: .note.gnu.property
  Owner                Data size        Description
  GNU                  0x00000020       NT_GNU_PROPERTY_TYPE_0
      Properties: x86 ISA used: x86-64-baseline
        x86 feature used: x86

.note.gnu.property 段属于存放段提示性信息的 NOTE 类型段。这个类型的段以下面的结构作为头, 12 字节。用 4 个字节分布表示提示信息名称字符串的长度、信息属性长度、信息类型。

typedef struct
{
  Elf64_Word n_namesz;          /* Length of the note's name.  */
  Elf64_Word n_descsz;          /* Length of the note's descriptor.  */
  Elf64_Word n_type;            /* Type of the note.  */
} Elf64_Nhdr;

这里信息的名称是 GNU,信息长度 0x00000020 字节,信息类型 0x00000005 对应 NT_GNU_PROPERTY_TYPE_0。信息属性有两项,类型值为 0xc00100020xc0010001,对应 x86 ISA usedx86 feature used,内容值都是 0x1。这个段主要说明了用到的指令集功能。

.eh_frame 段

.eh_frame 存放栈回溯函数表(unwind function table),常用于支持异常处理,其名称是 Exception Handling Frame 的缩写。在 C++ 中,异常发生时,需要根据调用栈恢复状态,这个段能提供异常处理所需要的数据。

xxd 的输出如下,也可以使用 readelf -j9 SimpleSection.o 查看。

$ xxd -s 0xc8 -l 0x48 SimpleSection.o
000000c8: 1400 0000 0000 0000 017a 5200 0178 1001  .........zR..x..
000000d8: 1b0c 0708 9001 0000 1400 0000 1c00 0000  ................
000000e8: 0000 0000 1c00 0000 0044 0e10 570e 0800  .........D..W...
000000f8: 1400 0000 3400 0000 0000 0000 1800 0000  ....4...........
00000108: 0044 0e10 530e 0800                      .D..S...

借助 readelf 可以将段的内容解析出来。

readelf --debug-dump=frames SimpleSection.o
Contents of the .eh_frame section:


00000000 0000000000000014 00000000 CIE
  Version:               1
  Augmentation:          "zR"
  Code alignment factor: 1
  Data alignment factor: -8
  Return address column: 16
  Augmentation data:     1b
  DW_CFA_def_cfa: r7 (rsp) ofs 8
  DW_CFA_offset: r16 (rip) at cfa-8
  DW_CFA_nop
  DW_CFA_nop

00000018 0000000000000014 0000001c FDE cie=00000000 pc=0000000000000000..000000000000001c
  DW_CFA_advance_loc: 4 to 0000000000000004
  DW_CFA_def_cfa_offset: 16
  DW_CFA_advance_loc: 23 to 000000000000001b
  DW_CFA_def_cfa_offset: 8
  DW_CFA_nop

00000030 0000000000000014 00000034 FDE cie=00000000 pc=000000000000001c..0000000000000034
  DW_CFA_advance_loc: 4 to 0000000000000020
  DW_CFA_def_cfa_offset: 16
  DW_CFA_advance_loc: 19 to 0000000000000033
  DW_CFA_def_cfa_offset: 8
  DW_CFA_nop

.eh_frame 段中包含两个部分 CIE (Common Information Entry) 和 FDE(Frame Description Entry)。CIE 提供通用部分信息,FDE 提供每个函数的记录。通过 FDE 中的 pc 范围,可以发现 FDE 针对的就是代码段中两个函数。可以在参考资料中找到更多关于这个段和异常处理的介绍,这里不展开描述。尽管异常处理在 C++ 中更为常见,C 也提供了 setjmp/longjmp 的机制在发生异常时恢复状态,因此 C 代码编译得到的目标文件也会有这个段。

.rela.eh_frame 段

.rela.eh_frame.eh_frame 的重定位表。

使用 xxd 获得这个段的原始数据如下。

$ xxd -s 0x280 -l 0x30 SimpleSection.o
00000280: 2000 0000 0000 0000 0200 0000 0200 0000   ...............
00000290: 0000 0000 0000 0000 3800 0000 0000 0000  ........8.......
000002a0: 0200 0000 0200 0000 1c00 0000 0000 0000  ................

作为一个重定位段,readelf 也可以解析其内容。

$ readelf -j10 SimpleSection.o

Relocation section '.rela.eh_frame' at offset 0x280 contains 2 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000000020  000200000002 R_X86_64_PC32     0000000000000000 .text + 0
000000000038  000200000002 R_X86_64_PC32     0000000000000000 .text + 1c

与代码段的重定位段功能相似,Offset 表示重定位入口在 eh_frame 段中的偏移,Info 表示重定位类型。因为 eh_frame 段中使用的函数地址也是相对于代码段起始位置的偏移,因此也要将这些偏移修改成 CPU 能访问到的符号地址。

.symtab 段

.symtab 段称为符号表(Symbol Table),存放目标文件中所有使用到的符号(Symbol)。链接的过程实际上就是确定目标文件中各个符号的实际地址。

使用 xxd 获得这个段的原始数据如下。

$ xxd -s 0x110 -l 0xd8 SimpleSection.o
00000110: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000120: 0000 0000 0000 0000 0100 0000 0400 f1ff  ................
00000130: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000140: 0000 0000 0300 0100 0000 0000 0000 0000  ................
00000150: 0000 0000 0000 0000 1100 0000 0000 0500  ................
00000160: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000170: 1600 0000 1200 0100 0000 0000 0000 0000  ................
00000180: 1c00 0000 0000 0000 1c00 0000 1000 0000  ................
00000190: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000001a0: 2300 0000 1200 0100 1c00 0000 0000 0000  #...............
000001b0: 1800 0000 0000 0000 2800 0000 1100 0400  ........(.......
000001c0: 0000 0000 0000 0000 0400 0000 0000 0000  ................
000001d0: 3a00 0000 1100 0300 0000 0000 0000 0000  :...............
000001e0: 0400 0000 0000 0000                      ........

借助 readelf 解析符号表,也可以使用 readelf -s SimpleSection.o 获得相同内容。

$ readelf -j11 SimpleSection.o

Symbol table '.symtab' contains 9 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 NOTYPE  LOCAL  DEFAULT    5 .LC0
     4: 0000000000000000    28 FUNC    GLOBAL DEFAULT    1 func1
     5: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND printf
     6: 000000000000001c    24 FUNC    GLOBAL DEFAULT    1 main
     7: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    4 global_uninit_var
     8: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    3 global_init_var

符号表中每个符号的结构固定,由下面的结构体定义。每个符号表项占用 24 字节,9 个符号组成的符号表共占用 216 字节。

typedef uint16_t Elf64_Section;
typedef struct {
  Elf64_Word st_name;     /* Symbol name (string tbl index) */
  unsigned char st_info;  /* Symbol type and binding */
  unsigned char st_other; /* Symbol visibility */
  Elf64_Section st_shndx; /* Section index */
  Elf64_Addr st_value;    /* Symbol value */
  Elf64_Xword st_size;    /* Symbol size */
} Elf64_Sym;

符号包括函数和变量,函数名或变量名是符号的名称(Symbol Name),符号表中符号的值(Symbol Value)是符号的地址。处理函数和变量,符号表中还会存放编译器产生的段名(如 .text)、局部变量和行号信息。还是因为启用了编译优化,局部变量没有出现在这个符号表中,如果不使用编译优化,符号表内容如下。

$ readelf -s SimpleSection.O0.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.O0.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    39 FUNC    GLOBAL DEFAULT    1 func1
    11: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND printf
    12: 0000000000000027    51 FUNC    GLOBAL DEFAULT    1 main

可以看到,main 函数中的两个静态变量也出现在其中。但这两个变量都被加上了后缀,这是符号修饰的结果,用来避免符号名称冲突。

符号表中第一列是符号序号,其中序号为 0 的符号总是未定义的。第二列是符号值,对应结构体成员 st_value。如果符号是函数或变量,该值表示符号在所在段中的偏移。第三列是符号大小,对应结构体成员 st_size。第四列和第五列对应结构体成员 st_info,这个成员定义为无符号整数,占用 8 位,其中低 4 为对应第四列符号类型,高 4 位对应第五列符号绑定信息。

符号类型的 4 位数值可以代表 16 中符号类型,常见的有五种。

符号绑定信息这里介绍两种。

第六列对应结构体成员 st_other,表示符号的可见性,可以通过编译参数 -fvisibility=hidden__attribute__((visibility("hidden"))) 控制。例如,如果不希望某个函数对其他编译单元可见,可以使用这个功能。更多信息可以在参考资料中找到,这里不展开描述。

第七列表示符号所属的段,对应结构体成员 st_shndx。如果符号定义在本目标文件,展示的就是符号所在段的序号;如果是定义在外部的符号,其值就是 UNDABS 值表示符号是一个绝对的值,例如文件名就是这个类型。

第八列是符号的名称,对应结构体成员 st_name。虽然上表中展示了具体字符串名称,但其实际存储的数据是这个符号在字符串表中的索引(下一节介绍),readelf 通过访问字符串表将具体字符串值显示了出来。对于 SECTION 类型的段,名称就是段名,而段名存储在段表字符串表中,其索引标记在段表结构中。

.strtab

.strtab 段是字符串表,存放了程序中用到的字符串,包括段名(在段表字符串表中存储)、符号名(变量和函数名)。

使用 xxd 获得这个段的原始数据如下,右侧可以看到数据对应的 ASCII 符号,也就是前面出现过的变量和符号名称。

$ xxd -s 0x1e8 -l 0x4a SimpleSection.o
000001e8: 0053 696d 706c 6553 6563 7469 6f6e 2e63  .SimpleSection.c
000001f8: 002e 4c43 3000 6675 6e63 3100 7072 696e  ..LC0.func1.prin
00000208: 7466 006d 6169 6e00 676c 6f62 616c 5f75  tf.main.global_u
00000218: 6e69 6e69 745f 7661 7200 676c 6f62 616c  ninit_var.global
00000228: 5f69 6e69 745f 7661 7200                 _init_var.

也可以使用 readelf 来解析,其结果更为清晰。

$ readelf -j12 SimpleSection.o

String dump of section '.strtab':
  [     1]  SimpleSection.c
  [    11]  .LC0
  [    16]  func1
  [    1c]  printf
  [    23]  main
  [    28]  global_uninit_var
  [    3a]  global_init_var

每个字符串有一个偏移值(索引)来标记,在符号表结构的符号名称成员中存储的就是这个偏移值。对照 xxd 给出的原始数据,可以发现,每个字符串都以 0 结尾,这也符合 C 语言处理字符串的规则。字符串表中的一个字节是 0,代表空字符串,符号表中第一个未定义符号就是以这个空字符串为名称。

.shstrtab

.shstrtab 段是段表字符串表,存放的是段表所使用到的字符串,也就是段的名称。段名由编译器生成。

使用 xxd 获得这个段的原始数据如下,右侧可以看到数据对应的 ASCII 字符都是前面段表中的段名称。

$ xxd -s 0x2b0 -l 0x7b SimpleSection.o
000002b0: 002e 7379 6d74 6162 002e 7374 7274 6162  ..symtab..strtab
000002c0: 002e 7368 7374 7274 6162 002e 7265 6c61  ..shstrtab..rela
000002d0: 2e74 6578 7400 2e64 6174 6100 2e62 7373  .text..data..bss
000002e0: 002e 726f 6461 7461 2e73 7472 312e 3100  ..rodata.str1.1.
000002f0: 2e63 6f6d 6d65 6e74 002e 6e6f 7465 2e47  .comment..note.G
00000300: 4e55 2d73 7461 636b 002e 6e6f 7465 2e67  NU-stack..note.g
00000310: 6e75 2e70 726f 7065 7274 7900 2e72 656c  nu.property..rel
00000320: 612e 6568 5f66 7261 6d65 00              a.eh_frame.

readelf 会像解析字符串表那样给出各个字符串在此段中的偏移值。

$ readelf -j13 SimpleSection.o 

String dump of section '.shstrtab':
  [     1]  .symtab
  [     9]  .strtab
  [    11]  .shstrtab
  [    1b]  .rela.text
  [    26]  .data
  [    2c]  .bss
  [    31]  .rodata.str1.1
  [    40]  .comment
  [    49]  .note.GNU-stack
  [    59]  .note.gnu.property
  [    6c]  .rela.eh_frame

回顾段表的结构,其中的段名称中存放的数值就是对应段的名称字符串在此段中的偏移。注意到其中并没有出现 .text 这个单独的字符串项,这个字符串与 .rela.text 共用,代码段的名称存放的偏移值是 0x20 就是字符串 .text 开始的位置。.eh_frame 段同理。

字节对齐

本文中解析原始数据时使用了 xxd 指定了开始位置偏移和长度,这是为了观察每个段的偏移是否连续。前面段表解读根据段表中每段长度计算了一个总大小 741,但是这与其他文件头信息 中根据计算得到的 752 字节差了 11 字节。这差的 11 字节用于满足各个段的对齐要求。下标记录了文件各个部分开始位置偏移、长度和结束位置偏移(每个部分的结尾不包含在内)。

段名 段起始偏移 长度 段结束偏移(不包含)
文件头 0 0x40 0x40
.text 0x40 0x34 0x74
.data 0x74 0x4 0x78
.bss 0x78 0 0x78
.rodata.str1.1 0x78 0x4 0x7c
.comment 0x7c 0x1c 0x98
.note.GNU-stack 0x98 0 0x98
.note.gnu.property 0x98 0x30 0xc8
.eh_frame 0xc8 0x48 0x110
.symtab 0x110 0xd8 0x1e8
.strtab 0x1e8 0x4a 0x232*
.rel.text 0x238 0x48 0x280
.rela.eh_frame 0x280 0x30 0x2b0
.shstrtab 0x2b0 0x7b 0x32b*
段表 0x330 0x380 0x6b0
EOF 0x6b0 0 0x6b0

可以发现,用 * 标记的两个段的结尾并没有后后面部分的起始位置相接。回顾段表的对齐字段,其中 .rel.text 段的对齐属性为 8,这意味着这个段的起始位置的偏移值必须是 8 的整数倍。而 .startab 段的最后一个字节的偏移是 0x231,距离这个值最近的 8 的倍数是 0x238.rel.text 的起始地址在不多浪费空间的前题下是 0x238。因此,在 .strtab 段的后面需要填充 6 个字节,填充数值一般是 0。

同样,位于最后一段的的段表字符串表的最后一个字节是 0x32a,虽然后面接着的不是一个段而是段表,看似段表与字节对齐无关,这里却也填充了 5 个字节。回顾ELF 段表结构中提到的段表数据结构,一个段表项占用 64 字节,一个段表项中最大的结构体成员占用 8 字节。字节对齐的要求的是任何数据结构中基础数据类型的起始位置的偏移都要是其大小的整数倍,因此段表结构体所占用位置的起始偏移必须是 8 的整数倍。距离 0x32a 最近的 8 的倍数是 0x330,因此需要额外填充 5 个字节。

下面用 xxd 展示了填充部分所在的位置,第 0x2320x237 的 6 字节和第 0x32b0x32f 的 5 字节。

$ xxd -s 0x220 -l 0x30 SimpleSection.o 
00000220: 7200 676c 6f62 616c 5f69 6e69 745f 7661  r.global_init_va
00000230: 7200 0000 0000 0000 0900 0000 0000 0000  r............... 6 bytes padding
00000240: 0200 0000 0300 0000 fcff ffff ffff ffff  ................

$ xxd -s 0x320 -l 0x20 SimpleSection.o
00000320: 612e 6568 5f66 7261 6d65 0000 0000 0000  a.eh_frame...... 5 bytes padding
00000330: 0000 0000 0000 0000 0000 0000 0000 0000  ................

总结

至此,本文遍历一个从 C 源代码编译得到的目标文件中的每一个字节。如果需要完全理解各个部分的作用还需要了解链接,特别是动态链接的过程,以及可执行文件的装载、进程虚拟地址空间、异常处理、程序调试、汇编语言、操作系统等主题,每个主题都值得深入研究。

计算机的发展就是从一些基础的出题出发,在解决一类新问题时延伸出一门门新的工程技术。从电报机到门电路,再到组合逻辑电路,从打孔纸带到真空电子管,再到晶体管和半导体,从手工焊接电路板到光刻机,这些技术以物理和数学原理为支撑,最终设计出通电以后就可以自动执行预期逻辑的计算机。人类科学和技术的智慧结晶最终成为我们手中的小玩意,让我们时不时就要拿出来把玩一下,与其每一次简单的交互都看似理所当然。

ELF 文件格式是支撑众多程序运行的一个基础标准,了解它就能对写程序、运行程序有字节级别的理解。认识到各个部分是那么恰到好处地组合在一起之后,难道不会对人类的设计哲学发出一声赞叹吗?

man

参考资料

脚注

  1. 源代码来自 程序员的自我修养——链接、装载与库 

  2. 应用程序二进制接口规范 System V ABI。