认识目标文件的结构

1.目标文件简介

目标文件是源代码编译但未链接的中间文件,Windows 的 .obj 和 Linux 的 .o 文件。

Windows 的目标文件采用 PE 格式,Linux 采用 ELF 格式,两种格式均是基于通用目标文件格式(COFF,Common Object File Format)变化而来,所以二者大致相同。本文以 Linux 的 ELF 格式的目标文件为例,进行介绍。

目标文件一般包含编译后的机器指令代码、数据、调试信息,还有链接时所需要的一些信息,比如重定位信息和符号表等,而且一般目标文件会将这些不同的信息按照不同的属性,以“节(section)”也叫“段(segment)”的形式进行存储,本文统称为“段”。

首先将如下具有代表性又不会过于复杂的 C 源码通过 gcc 只编译不链接生成目标文件 test.o,然后对目标文件 test.o 进行分析。

//
//@file: test.c
//

int printf(const char* format, ...);

int gInitVar = 84; 
int gUninitVar;

void foo(int i)
{
    printf("%d\n", i); 
}

int main()
{
    static int staVar = 85; 
    static int staVar1;

    int a = 1;
    int b;

    foo(staVar + staVar1 + a + b); 

    return 0;
}

通过命令 gcc -c test.c -o test.o 编译生成目标文件 test.o。

2.ELF 目标文件的结构

通过 readelf -S 命令可以查看目标文件test.o的所有段的段头信息,实际上是读取段表的内容。

readelf -S test.o 
There are 13 section headers, starting at offset 0x198:

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	0000000000000056  0000000000000000  AX       0     0     4
  [ 2] .rela.text        RELA             0000000000000000  000006a0	0000000000000078  0000000000000018          11     1     8
  [ 3] .data             PROGBITS         0000000000000000  00000098	0000000000000008  0000000000000000  WA       0     0     4
  [ 4] .bss              NOBITS           0000000000000000  000000a0	0000000000000004  0000000000000000  WA       0     0     4
  [ 5] .rodata           PROGBITS         0000000000000000  000000a0	0000000000000004  0000000000000000   A       0     0     1
  [ 6] .comment          PROGBITS         0000000000000000  000000a4	000000000000002d  0000000000000001  MS       0     0     1
  [ 7] .note.GNU-stack   PROGBITS         0000000000000000  000000d1	0000000000000000  0000000000000000           0     0     1
  [ 8] .eh_frame         PROGBITS         0000000000000000  000000d8	0000000000000058  0000000000000000   A       0     0     8
  [ 9] .rela.eh_frame    RELA             0000000000000000  00000718	0000000000000030  0000000000000018          11     8     8
  [10] .shstrtab         STRTAB           0000000000000000  00000130	0000000000000061  0000000000000000           0     0     1
  [11] .symtab           SYMTAB           0000000000000000  000004d8	0000000000000180  0000000000000018          12    11     8
  [12] .strtab           STRTAB           0000000000000000  00000658	0000000000000045  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), l (large), I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown)
  O (extra OS processing required) o (OS specific), p (processor specific)

从上面的输出我们可以各个段在文件中的偏移位置,可以推断出ELF目标文件的结构大致如下。

ELF Header
.text
.data
.bss
.rodata
.comment
.shstrtab
section header table
.symtab
.strtab
.rela.text
other sections

从上至下主要包含:
(1)ELF Header,ELF文件头描述目标文件整体信息,包含 ELF 文件版本,目标机器型号、程序入口地址等;
(2).text,代码段存放程序的机器指令;
(3).data,初始化数据段存放已初始化的全局变量与局部静态变量;
(4).bss,未初始化数据段存放未初始化的全局变量与局部静态变量;
(5).rodata,只读数据段存放程序中只读变量,如const修饰的常量和字符串常量;
(6).comment,注释信息段存放编译器版本信息,比如字符串"GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-4)"
(7).shstrtab,段表字符串表,用于存放段的名称字符串;
(8)section header table,段表存放所有段的基本信息,表中的每一项为段头,即段的基本信息;
(9).symtab,符号表记录了目标文件中使用的所有符号,比如变量和函数名,对于变量和函数而言,符号对应的值为它们所在的地址。符号用于链接器链接时找到符号地址;
(10).strtab,字符串表用于存放目标文件中用到的字符串,比如变量名等。因为字符串的长度往往是不定的,所以用固定的结构来表示比较困难。常见的做法就是把字符串集中起来存放到一个表。然后使用字符串在表中的偏移来引用字符串;
(11).rela.text,代码段重定位表存放目标文件未定义的指令在链接时所需的重定位信息。

除了上面提到的段外,ELF文件也有可能包含其他的段,用来保存与程序相关的其他信息。

段名说明
.hash符号哈希表
.line调试时的行号表,即源代码行号与编译后指令的对应表
.dynamic动态链接信息
.debug调试信息
.plt和.got动态链接的跳转表和全局入口表
.init程序初始化代码段。该段的内容为可执行指令,是程序的初始化代码,在main函数之前被调用,比如C++全局对象的构造
.fini程序终结代码段。该段的内容为可执行指令,是程序的终止前需要执行的代码,在main函数正常退出时被调用,比如C++全局对象的析构
.rodata1Read Only Data,只读数据段,存放字符串常量,全局 const 变量,该段和 .rodata 一样

下面以目标文件 test.o 为例,讲解 Linux 下 ELF 目标文件的具体组成部分。

3.ELF 文件头(ELF Header)

通过命令 readelf -h 可以查看 ELF 目标文件的头信息。

readelf -h test.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:          408 (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:         13
  Section header string table index: 10

通过输出结果可以看出,ELF目标文件头包含了如下信息:
(1)魔数(Magic)。前四个字节7f、45、4c、46分别对应ASCII字符的Del(删除)、字母E、L、F。这四个字节被称为ELF文件的魔数,操作系统在加载可执行文件时会确认魔数是否正确,如果不正确则拒绝加载。 第五个字节标识ELF文件是32位(01)还是64位(02)的。第六个字节表示字节序是小端(01)还是大端(02)。第七个字节指示ELF文件的版本号,一般是01。 后九个字节ELF标准未做定义。一般为00。
(2)类别(Class),为 ELF64,如果是 32 位的目标文件,则类别为 ELF32。我们可以使用编译命令 gcc -m32 -c test.c -o test32.o生成32位的目标文件。
(3)数据存储方式(Data),为小端字节序。
(4)ELF 文件版本(Version),表示 ELF 文件版本号,一般为 1。
(5)运行平台与应用程序二进制接口(OS/ABI),为UNIX - System V。其它的还有 UNIX - Linux 与 UNIX - GNU。其中 ABI 为 GNU 和 Linux 两种是相同的,只是使用不同版本的 readelf 会现实不同的结果。而 System V 则是最古老的,也是兼容性最好的。
(6)应用程序二进制接口版本(ABI Version),为 0。
(7)类型(Type),为可重定位文件(REL,Relocatable file),包括目标文件.o与静态链接库.a。其它的还有DYN(共享目标文件,.so文件)和 EXEC(可执行文件)。
(8)硬件平台(Machine),为 Intel 80386。
(9)ELF 文件版本(Version),这个与上面的 Version 是同一个意思,一般为常数 1。
(10)入口地址(Entry point address),规定 ELF 程序的入口虚拟地址,操作系统在加载完该程序后从这个地址开始执行进程的指令。可重定位文件一般没有入口地址,这个值为 0。
(11)程序头起始地址(Start of program headers),为 0 字节。
(12)段表起始地址(Start of section headers),表示段表在文件中的偏移地址,为 408 字节。
(13)标志(Flags),ELF 文件标志位,一般为 0。
(14)文件头大小(Size of this header),为 52 字节。
(15)程序头大小( Size of program headers),为 0 字节。
(16)程序头数量( Number of program headers),为0。
(17)段头大小(Size of section headers),段表描述符的大小,即一个段头的大小,等于 sizeof(Elf64_Ehdr),这里为 64 字节。
(18)段头数量(Number of section headers),这个值等于 ELF 文件中拥有的段的数量,这里表示有13个段。
(19)段表字符串表段头索引(Section header string table index),表示段表字符串表段头在段表中的偏移为10。

ELF文件头结构及相关常数的定义在 /usr/include/elf.h 里,因为 ELF 文件有 32 位和 64 位版本,所以头文件中对应也有两种结构,分别是 Elf32_Ehdr 和 Elf64_Ehdr。其成员与上面输出的头信息对应关系如下:

typedef struct
{
  unsigned char e_ident[EI_NIDENT];		//Magic,Class,Data,Version,OS/ABI,ABI Version
  Elf64_Half    e_type;					//Type
  Elf64_Half    e_machine;				//Machine
  Elf64_Word    e_version;				//Version
  Elf64_Addr    e_entry;				//Entry point address
  Elf64_Off     e_phoff;           		//Start of program headers
  Elf64_Off     e_shoff;           		//Start of section headers
  Elf64_Word    e_flags;           		//Flags
  Elf64_Half    e_ehsize;          		//Size of this header
  Elf64_Half    e_phentsize;        	//Size of program headers
  Elf64_Half    e_phnum;           		//Number of program headers
  Elf64_Half    e_shentsize;    		//Size of section headers
  Elf64_Half    e_shnum;				//Number of section headers
  Elf64_Half    e_shstrndx;        		//Section header string table index
} Elf64_Ehdr;

4.代码段(.text)

代码段存放程序的机器指令,我们通过命令 objdump -S 可以反汇编代码段的内容。

objdump -S test.o

test.o:     file format elf64-x86-64

Disassembly of section .text:

0000000000000000 <foo>:
   0:	55                   	push   %rbp
   1:	48 89 e5             	mov    %rsp,%rbp
   4:	48 83 ec 10          	sub    $0x10,%rsp
   8:	89 7d fc             	mov    %edi,-0x4(%rbp)
   b:	8b 45 fc             	mov    -0x4(%rbp),%eax
   e:	89 c6                	mov    %eax,%esi
  10:	bf 00 00 00 00       	mov    $0x0,%edi
  15:	b8 00 00 00 00       	mov    $0x0,%eax
  1a:	e8 00 00 00 00       	callq  1f <foo+0x1f>
  1f:	c9                   	leaveq
  20:	c3                   	retq

0000000000000021 <main>:
  21:	55                   	push   %rbp
  22:	48 89 e5             	mov    %rsp,%rbp
  25:	48 83 ec 10          	sub    $0x10,%rsp
  29:	c7 45 fc 01 00 00 00 	movl   $0x1,-0x4(%rbp)
  30:	8b 15 00 00 00 00    	mov    0x0(%rip),%edx        # 36 <foo+0x36>
  36:	8b 05 00 00 00 00    	mov    0x0(%rip),%eax        # 3c <foo+0x3c>
  3c:	01 c2                	add    %eax,%edx
  3e:	8b 45 fc             	mov    -0x4(%rbp),%eax
  41:	01 c2                	add    %eax,%edx
  43:	8b 45 f8             	mov    -0x8(%rbp),%eax
  46:	01 d0                	add    %edx,%eax
  48:	89 c7                	mov    %eax,%edi
  4a:	e8 00 00 00 00       	callq  4f <foo+0x4f>
  4f:	b8 00 00 00 00       	mov    $0x0,%eax
  54:	c9                   	leaveq 
  55:	c3                   	retq   

从上面可以看到,代码段包含的是test.c中两个函数foo()与main()的指令。代码段.text的第一个字节0x55就是函数foo()的第一条"push %rbp"指令,即帧指针的压栈操作。最后一个字节0xc3是main()函数的最后一条指令"ret"。

5.初始化数据段(.data)

.data段保存了已经初始化的全局变量与局部静态变量。源码 test.c 中有初始化的全局变量 int gInitVar和局部静态变量static int staVar,所以这两个变量的值存放在.data段,因为是两个int变量,所以.data段的大小是4字节。使用命令 objdump -s 可以查看目标文件所有非空段的内容。

objdump -s test.o

test.o:     file format elf64-x86-64

Contents of section .text:
 0000 554889e5 4883ec10 897dfc8b 45fc89c6  UH..H....}..E...
 0010 bf000000 00b80000 0000e800 000000c9  ................
 0020 c3554889 e54883ec 10c745fc 01000000  .UH..H....E.....
 0030 8b150000 00008b05 00000000 01c28b45  ...............E
 0040 fc01c28b 45f801d0 89c7e800 000000b8  ....E...........
 0050 00000000 c9c3                        ......          
Contents of section .data:
 0000 54000000 55000000                    T...U...        
Contents of section .rodata:
 0000 25640a00                             %d..            
Contents of section .comment:
 0000 00474343 3a202847 4e552920 342e382e  .GCC: (GNU) 4.8.
 0010 35203230 31353036 32332028 52656420  5 20150623 (Red 
 0020 48617420 342e382e 352d3429 00        Hat 4.8.5-4).   
Contents of section .eh_frame:
 0000 14000000 00000000 017a5200 01781001  .........zR..x..
 0010 1b0c0708 90010000 1c000000 1c000000  ................
 0020 00000000 21000000 00410e10 8602430d  ....!....A....C.
 0030 065c0c07 08000000 1c000000 3c000000  .\..........<...
 0040 00000000 35000000 00410e10 8602430d  ....5....A....C.
 0050 06700c07 08000000                    .p...... 

从输出结果可以看到,段.data的内容分别是0x54与0x55,刚好是两个初始化变量的值 84 与 85。

6.未初始化数据段(.bss)

.bss 段存放的是未初始化全局变量与未初始化的局部静态变量,如 test.c 中的未初始化的全局变量 int gUninitVar 与局部静态变量 static int staVar1,其实更准确的说是 .bss 段为它们预留了空间。

从命令 readelf -S test.o 的输出结果可以看到,.bss 段的大小是4个字节,这与 gUninitVar 和 staVar1 的8字节大小不符。其实通过符号表(Symbol Table)(下面会详细介绍)可以看到,只有 staVar1 被放在了.bss段,而 gUninitVar 并没有被放在任何段,只是一个未定义的 COMMON 符号。这其实和不同语言与编译器的实现有关,有些编译器会将全局未初始化变量放在 .bss 段,有些则不放,只是预留一个未定义的全局变量符号,等到最终链接成可执行文件的时候再在 .bss 段分配空间。

为什么编译器把未初始化的全局变量标记为一个 COMMON 符号,而不直接把它当作未初始化的局部静态变量,为其在 .bss 段分配空间呢?原因是现在的编译器和链接器支持弱符号机制,即允许同一个弱符号定义在多个目标文件中,因为未初始化的全局变量属于弱符号,编译时无法确定符号大小,所以此时无法在 .bss 段为未初始化的全局变量分配空间。

说到 COMMON 符号,需要了解一下什么是 COMMON 块机制。COMMON 块机制最早来源于 Fortran,早期的 Fortran 没有动态分配空间的机制,程序员必须事先声明它所需要的内存空间,这种空间称为 COMMON 块,当不同的目标文件需要的 COMMON 块空间大小不一致时,以最大的那块为准。

对于弱符号的处理,编译器与链接器采用了与 COMMON 块一样的机制,处理措施如下:
(1)如果目标文件中的同一个弱符号的类型不同,编译器与链接器则采用了 COMMON 块(Common Block)机制来确定最终参与链接的符号,即如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的那一个;

(2)如果一个符号在某个目标文件中是强符号,在其他目标文件中都是弱符号,最终选择强符号,此时如果弱符号大小大于强符号,链接器会报警告。

(3)总体看来,未初始化的全局变量最终还是被放在 .bss 段。

以 test.c 中的未初始全局化变量 gUninitVar 为例,使用命令 readelf -s 查看其在符号表中各个字段的取值:

readlef -s
   Num:    Value          Size Type    Bind   Vis      Ndx Name
   ...
    12: 0000000000000004     4 OBJECT  GLOBAL DEFAULT  COM gUninitVar
   ...

可以看到,它是一个全局的数据对象,类型为 COM,这是一个典型的弱符号。

GCC 的编译选项 -fno-common 也允许我们把所有未初始化的全局变量不以 COMMON 块的形式处理,或者在定义变量时使用扩展属性 __attribute__

int gUninitVar __attribute__((nocommon));

一旦一个未初始化的全局变量不是以 COMMON 块的形式存在,那么它相当于一个强符号,如果其他目标文件中还有同名的强符号变量,链接时会发生符号重定义错误。

7.只读数据段.rodata

.rodata段存放的是只读数据,一般是程序里面的只读变量(比如const修饰的变量)和字符串常量。比如源码文件test.c中在调用printf()时,用到了格式化字符串常量"%d\n",存放在.rodata段。从命令 objdump -s 的输出结果,可以看到.rodata段的内容为0x25640a00,占用4个字节,分别表示字符%、d、\n与空字符\0。

单独设立.rodata段的好处有很多,比如语义上支持了C的const常量,而且操作系统在加载的时候可以将.rodata段的内容映射为只读区,这样对于这个段的任何修改都会被判为非法,保证了程序的安全性。

8.段表(Section Header Table)

ELF 文件中有各种各样的段,段表保存了这些段的基本属性。段表是 ELF 文件中除了文件头以外最重要的结构,它描述了 ELF 各个段的信息,比如每个段的段名、类型、长度、在文件中的偏移等,编译器、链接器和装载器都是通过访问段表来获取各个段的属性。段表在 ELF 文件中的位置由 ELF 文件头的 e_shoff 成员决定,比如 test.o 中,段表偏移为408字节。我们可以使用命令 readelf -S 查看段表内容,前文已经使用过并输出其结果。

段表的实际结构比较简单,它是一个以结构体 Elf32_Shdr 或 Elf64_Shdr 为元素的数组,每个元素对应一个段,数组元素个数等于段的数量。Elf32_Shdr 与 Elf64_Shdr 又被称为段描述符(Section Descriptor)。对于 test.o,段表有13个 Elf64_Shdr 元素,第一个为无效的段描述符,它的类型为 NULL,所以 test.o 共有12个有效的段。

Elf32_Shdr 或 Elf64_Shdr 被定义在 /usr/include/elf.h,以 Elf64_Shdr 为例,其定义如下:

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;

中文释义如下:

成员含义
sh_name段名,是一个字符串,它位于名叫.shstrtab的段表字符串表中,sh_name是段名字符串在.shstrtab的偏移
sh_type段的类型,详见下文“段的类型”
sh_flags段的标志位,详见下文“段的标志位”
sh_addr段虚拟地址。如果该段可以被加载,则sh_addr 为该段被加载后在进程地址空间中的虚拟地址,否则 sh_addr 为 0
sh_offset段的偏移,如果该段存在于文件中,则表示该段在文件中的偏移
sh_size段的长度
sh_link 与 sh_info段链接信息。详见下文“段的链接信息”
sh_addralign段地址对齐。有些段要求地址对齐,比如段起始位置包含一个double变量,因为 Intel x86_64 系统要求浮点数的存储地址必须是本身的整数倍,那么该段的 sh_addr 必须是8的整数倍。由于地址对齐均是2的整数,所以 sh_addralign 为 2 的指数,比如段地址对齐是8,那么sh_addralign取值3。如果 sh_addralign 为 0,表示不需要对齐
sh_entsize有些段包含固定大小的项,比如符号表,它包含的每个符号所占的大小都是一样的,对于这种段,sh_entsize表示每个项的大小。如果为0,则表示该段不包含固定大小的项

从文件头中可以看到,段表元素大小等于 64B=sizeof(Elf64_Shdr),元素个数等于 13,所以段表大小等于 64*13=832B,这个数值刚好等于 .symtab 在文件中的偏移 0x4d8 减去段表的偏移 0x198。

段的类型(sh_type),段的名字只是在编译和链接过程中有意义,但它不能真正地表示段的类型。我们也可以将一个数据段命名为“.text”,对于编译器和链接器来说,主要决定段的属性的是段的类型(sh_type)和段的标志位(sh_flags)。段的类型相关常量以SHT_开头,定义在 /usr/include/elf.h,列举如下:

常量含义
SHT_NULL0无效段
SHT_PROGBITS1程序数据。代码段和数据段都是这种类型
SHT_SYMTAB2符号表
SHT_STRTAB3字符串表
SHT_RELA4重定位表。该段包含了重定位信息
SHT_HASH5符号表的哈希表
SHT_DYNAMIC6动态链接信息
SHT_NOTE7提示性信息
SHT_NOBITS8表示该段在文件中没有内容,比如.bss段
SHT_REL9该段包含了重定位信息
SHT_SHLIB10保留
SHT_DYNSYM11动态链接的符号表

段的标志位(sh_flag)表示该段在进程虚拟地址空间中的属性,比如是否可写,是否可执行等。相关常量以SHF_开头,定义在 /usr/include/elf.h,如下表:

常量含义
SHF_WRITE(1 << 0)表示该段在进程空间中可写
SHF_ALLOC(1 << 1)表示该段在进程空间中须要分配空间。有些包含指示或控制信息的段不须要在进程空间中为其分配空间,它们一般不会有这个标识。像代码段、数据段和.bss段都会有这个标志位
SHF_EXECINSTR(1 << 2)表示该段在进程空间可以被执行,一般指代码段

段的链接信息(sh_link、sh_info) 如果段的类型是与链接相关的(不论是动态链接或静态链接),比如重定位表、符号表等,那么 sh_link 和 sh_info 这两个成员所包含的意义如下表所示。对于其他类型的段,这两个成员没有意义。

sh_typesh_linksh_info
SHT_DYNAMIC该段所使用的字符串表在段表中的下标0
SHT_HASH该段所使用的符号表在段表中的下标0
SHT_RELA、SHT_REL该段所使用的相应符号表在段表中的下标该重定位表所作用的段在段表中的下标
SHT_SYMTAB、SHT_DYNSYM操作系统相关的操作系统相关的
otherSHN_UNDEF0

9.符号表(.symtab)

9.1 符号简介

链接过程的本质就是要把多个不同的目标文件之间像拼图一样拼起来,相互拼合实际上是目标文件之间对地址的引用,即对函数和变量的地址的引用。比如目标文件B用到了目标文件A中的函数foo,那么称目标文件A定义了函数foo,目标文件B引用了函数foo。定义与引用这两个概念同样适用于变量。每个函数和变量都有自己独一的名字,才能避免链接过程中不同变量和函数之间的混淆。在链接中,我们将函数和变量统称为符号(Symbol),函数或变量名就是符号名(Symbol Name)。

符号是链接的粘合剂,没有符号无法完成链接。每一个目标文件都会有一个相应的符号表(Symbol Table),这个表里记录了目标文件中所用到的所有符号。每个定义的符号有一个对应的值叫做符号值(Symbol Value),对于变量和函数来说,符号值就是它们的地址。

除了函数和变量之外,还存在其它几种不常用到的符号。符号表中所有符号可以分为如下几种:
(1)全局符号。定义在本目标文件,可以被其它目标文件引用。比如 test.o 中的 gInitVar、gUninitVar 与 foo;
(2)外部符号(External Symbol)。在本目标文件中引用的全局符号,却没有定义在本目标文件。比如 test.o 中的 printf;
(3)段名。其值为该段的起始地址。比如 test.o 的 .text、.data等;
(4)局部符号。这类符号只在编译单元内部可见,链接器往往会忽略它们,因为没用。比如 test.o 中的 staVar 与 staVar1;
(5)行号信息。即目标文件指令与源代码中代码行的对应关系,它是可选的。

对于链接而言,只关心全局符号,局部符号、段名、行号等都是次要的,它们对于其它目标文件来说是不可见的。我们可以使用很多工具查看 ELF 文件的符号表,比如 readelf、objdump 和 nm 等,比如使用 nm 查看 test.o 的结果如下:

nm test.o
0000000000000000 T foo
0000000000000000 D gInitVar
0000000000000004 C gUninitVar
0000000000000021 T main
                 U printf
0000000000000000 b staVar1.1731
0000000000000004 d staVar.1730

9.2 符号表结构

ELF 文件中的符号表往往是一个段,段名一般叫 .symtab。它是一个 Elf64_Sym 结构的数组,每个 Elf64_Sym 结构对应一个符号,Elf64_Sym 定义在 /usr/include/elf.h。

typedef struct
{
  Elf64_Word    st_name;                //符号名,是一个下标值,表示该符号名在字符串表中的下标
  unsigned char st_info;                //符号类型与绑定信息
  unsigned char st_other;               //符号可见性
  Elf64_Section st_shndx;               //符号所在段
  Elf64_Addr    st_value;               //符号值
  Elf64_Xword   st_size;                //符号大小
} Elf64_Sym;

(1)符号类型和绑定信息(st_info)
该成员低 4 位表示符号的类型(Symbol Type),高 28 位表示符号绑定信息(Symbol Binding)。

符号类型有:

宏定义名说明
STT_NOTYPE0未知类型符号
STT_OBJECT1该符号是个数据对象,比如变量、数组等
STT_FUNC2该符号是个函数或其他可执行代码
STT_SECTION3该符号表示一个段,这种符号必须是STB_LOCAL的
STT_FILE4该符号表示文件名,一般都是该目标文件所对应的源文件名,它一定是 STB_LOCAL 类型的,并且它的 st_shndx 一定是SHN_ABS

符号绑定信息取值如下:

宏定义名说明
STB_LOCAL0局部符号,其它目标文件不可见
STB_GLOBAL1全局符号,外部可见
STB_WEAK2弱引用

(2)符号所在段(st_shndx)
如果符号定义在本目标文件中,那么这个成员表示符号所在的段在段表中的下标。但是如果符号不是定义在本目标文件中,或者对于有些特殊符号,sh_shndx 的值有些特殊。

宏定义名说明
SHN_ABS0xfff1表示该符号包含了一个绝对的值,比如表示文件名的符号就属于这种类型的
SHN_COMMON0xfff2表示该符号是一个 COMMON 块类型的符号,一般来说,未初始化的全局符号就是这种类型的
SHN_UNDEF0表示该符号未定义。这个符号表示该符号在本目标文件被引用到,但是定义在其他目标文件中

(3)符号值(st_value)
在目标文件中,每一个符号都有一个对应的值,不同类型的符号其值具有不同的意义。主要分为如下几种:
(a)在目标文件中,如果有符号的定义并且该符号不是 COMMON 块类型的,则 st_value 表示该符号在其所属段中的偏移。比如 test.o 中全局变量 gInitVar 在其所属 .data 段中的偏移;
(b)在目标文件中,如果符号是 COMMON 块类型的,则 st_value 表示该符号的对齐属性。比如 test.o 中全局未初始化变量 gUninitVar 的符号值;
(c)在可执行文件中,st_value 表示符号的虚拟地址,这个虚拟地址对动态链接器十分有用。

9.3 符号表实例剖析

使用 readelf -s 可以查看符号表的内容。

readelf -s test.o
Symbol table '.symtab' contains 16 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS test.c
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1 
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    3 
     4: 0000000000000000     0 SECTION LOCAL  DEFAULT    4 
     5: 0000000000000000     0 SECTION LOCAL  DEFAULT    5 
     6: 0000000000000004     4 OBJECT  LOCAL  DEFAULT    3 staVar.1730
     7: 0000000000000000     4 OBJECT  LOCAL  DEFAULT    4 staVar1.1731
     8: 0000000000000000     0 SECTION LOCAL  DEFAULT    7 
     9: 0000000000000000     0 SECTION LOCAL  DEFAULT    8 
    10: 0000000000000000     0 SECTION LOCAL  DEFAULT    6 
    11: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    3 gInitVar
    12: 0000000000000004     4 OBJECT  GLOBAL DEFAULT  COM gUninitVar
    13: 0000000000000000    33 FUNC    GLOBAL DEFAULT    1 foo
    14: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND printf
    15: 0000000000000021    53 FUNC    GLOBAL DEFAULT    1 main

输出格式与 Elf64_Sym 成员基本一一对应。第一列 Num 表示符号表数组的下标,共有16个符号;第二列 Value 表示符号值,ji st_value;第三列Size为符号大小,即st_size;第四列和第五列,分别为符号类型与绑定信息,即对应 st_info 的低4位和高28位;第六列 Vis 在C/C++未使用,可忽略;第七列 Ndx 即 st_shndx,表示该符号所属段的头在段表中的偏移。最后一列为符号名称。

(1)foo 和 main 函数都是定义在 test.c 中,它们都属于代码段,所以 Ndx 为 1,即 test.o 里面,.text段头在段表中的下标是 1 ,从命令 readelf -S 的输出结果可以看出。他们是函数,所以类型是 STT_FUNC;它们是全局可见的,所以是 STB_GLOBAL;Size 表示函数指令所占的字节数;Value 表示函数相对于代码段起始位置的偏移量。
(2)printf 这个符号只在 test.c 中被引用,未被定义,所以它的 Ndx 是 SHN_UNDEF。
(3)gInitVar 是已初始化的全局变量,它被定义在 .bss 段,即下标为 3。
(4)gUninitVar 是未初始化的全局变量,它是一个 SHN_COMMON 类型的符号,它本身并没有存在于 .bss 段。
(5)staVar.1730 和 staVar1.1731 是两个局部静态变量,它们的绑定属性是 STB_LOCAL,即只是编译单元内部可见。
(6)对于类型是 STT_SECTION 类型的符号,它们表示下标为 Ndx 的段的段名。它们的符号名没有显示,其实它们的符号名就是它们的段名。比如 2 号符号的 Ndx 为 1,那么它表示 .text 段的段名,我们可以使用 objdump -t 来查看。
(7)test.c 表示编译单元的源文件名。

10.字符串表(.strtab)

ELF文件中用到了很多字符串,比如段名、变量名等。因为字符串的长度往往是不定的,所以用固定的结构来表示它比较困难。一种很常见的做法是把字符串集中起来存放到一个表,然后使用字符串在表中的偏移来引用字符串。假设有下面这个字符串表。

偏移0123456789
0\0helloworl
10d\nMyvariab
20le\0

那么偏移与它们对应的字符串如下表所示:

偏移字符串
0空字符串
1helloworld
6world
12Myvariable

通过这种方法,在ELF文件中引用字符串只须给出一个数字下标即可,不用考虑字符串长度的问题。一般字符串表在ELF文件中也以段的形式保存,常见的段名为 .strtab 或 .shstrtab。这两个字符串表分别为字符串表(String Table)和段表字符串表(Section Header String Table)。顾名思义,字符串表用来保存普通的字符串,比如符号的名字;段表字符串表用来保存段表中用到的字符串,最常见的是段名(sh_name)。

接着我们再回头看这个ELF文件头中的 “e_shstrndx” 的含义,它是"Section Header String Table Index"的缩写。我们知道段表字符串表本身也是ELF文件中的一个普通的段,它的名字往往叫做.shstrtab。那么这个e_shstrndx就表示.shstrtab在段表中的下标,即段表字符串表在段表中的下标。前面的 test.o 中,e_shstrndx的值为10,我们再对照 readelf -S 的输出结果,可以看到 .shstrtab 这个段刚好位于段表中的下标为 10 的位置上。由此可见,分析ELF文件头,可以得到段表和段表字符串表的位置,从而解析整个ELF文件。

11.代码段重定位表(.rela.text)

通过命令 readelf -S的输出可以看到,test.o有一个段 .rela.text,其类型为 RELA,也就是说它是一个重定位表,也叫作重定位段,用于链接器在处理目标文件时,重定位代码段中那些对绝对地址的引用的位置。比如 .text 段中对外部 printf() 函数的调用。通过命令 objdump -S 反汇编代码段的内容,可以发现在函数 foo() 中对外部函数 printf() 的调用,其二进制指令是 e8 00 00 00 00,0xe8 表示近址相对位移调用指令,后面的四个字节 0x00000000 表示 printf() 的地址。由于编译阶段地址无法确定,所以留空,待链接阶段根据重定位表的内容完成地址的修正。

0000000000000000 <foo>:
   0:	55                   	push   %rbp
   1:	48 89 e5             	mov    %rsp,%rbp
   4:	48 83 ec 10          	sub    $0x10,%rsp
   8:	89 7d fc             	mov    %edi,-0x4(%rbp)
   b:	8b 45 fc             	mov    -0x4(%rbp),%eax
   e:	89 c6                	mov    %eax,%esi
  10:	bf 00 00 00 00       	mov    $0x0,%edi
  15:	b8 00 00 00 00       	mov    $0x0,%eax
  1a:	e8 00 00 00 00       	callq  1f <foo+0x1f>
  1f:	c9                   	leaveq
  20:	c3                   	retq

链接器是怎么知道哪些指令与这些指令中的哪些部分要被调整?如何进行调整。比如上面对 printf() 的地址修正。事实上 ELF 文件中,重定位表(Relocation Table)专门用来保存这些与重定位相关的信息。比如 .rela.text 就是保存了代码段中须要重定位的信息。如果代码段中有要被重定位的地方,也会有相对应的重定位表 .rel.data。 可以使用命令 objdump -r 查看目标文件的重定位表。

test.o:     file format elf64-x86-64

RELOCATION RECORDS FOR [.text]:
OFFSET           TYPE              VALUE 
0000000000000011 R_X86_64_32       .rodata
000000000000001b R_X86_64_PC32     printf-0x0000000000000004
0000000000000032 R_X86_64_PC32     .data
0000000000000038 R_X86_64_PC32     .bss-0x0000000000000004
000000000000004b R_X86_64_PC32     foo-0x0000000000000004


RELOCATION RECORDS FOR [.eh_frame]:
OFFSET           TYPE              VALUE 
0000000000000020 R_X86_64_PC32     .text
0000000000000040 R_X86_64_PC32     .text+0x0000000000000021

每个要被重定位的地方叫重定位入口(Relocation Entry),OFFSET 表示该入口在所在段中的偏移位置,TYPE 表示重定位入口的类型,VALUE 表示重定位入口的符号名称。重定位表项由结构 Elf64_Rel 表示。

typedef struct
{
  Elf64_Addr    r_offset;               /* Address */
  Elf64_Xword   r_info;                 /* Relocation type and symbol index */
} Elf64_Rel;

其中 r_offset 就是重定位入口在段中的偏移位置,r_info 表示重定位入口的类型、符号名称与被修正位置的值。重定位入口的类型有两种:

宏定义重定位修正方法
R_X86_64_3210绝对寻址修正 S + A
R_X86_64_PC322相对寻址修正 S + A - P
A = 保存在被修正位置的值
P = 被修正的位置(相对于段开始的偏移量或者虚拟地址)。该值可通过 r_offset 计算得到
S = 符号的实际地址

重定位表也是 ELF 的一个段,这个段的类型(Type)就是 RELA,它的 Link 表示它在段表中的下标,Info 表示它作用于哪个段。比如 .rela.text 的 Info 等于 1 ,表示作用于 .text 段,因为 .text 段在段表的下标是 1。


参考文献

[1] 俞甲子,石凡,等.程序员的自我修养——链接、装载与库[M].北京:电子工业出版社,2009-04.C3.1目标文件格式.P61-95
[2] 俞甲子,石凡,等.程序员的自我修养——链接、装载与库[M].北京:电子工业出版社,2009-04.C4.2符号解析与重定位.P103-110
[3] 俞甲子,石凡,等.程序员的自我修养——链接、装载与库[M].北京:电子工业出版社,2009-04.C4.3 COMMON 块.P111-112

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值