链接装载与库(二)目标文件

原图

目标文件从结构上讲,它是已经编译后的可执行文件格式,只是还没有经过链接的过程,其中可能有些符号或有些地址还没有被调整。

目标文件的格式

现在 PC 平台流行的可执行文件格式主要是 Windows 下的 PE(Portable Executable)和 Linux 的 ELF(Executable Linkable Format),它们都是 COFF(Common fileformat)格式的变种。

ELF 文件标准里面把系统中采用 ELF 格式的文件归为如下表所列举的 4 类。

ELF 文件类型 说明 实例
可重定位文件 Relocatable File 这类文件包含了代码和数据,可以被用来链接成可执行文件或共享目标文件,静态链接库也可以归为这一类 Linux 的。o Windows 的。obj
可执行文件 Executable File 这类文件包含了可以直接执行的程序,它的代表就是 ELF 可执行文件,它们一般都没有扩展名 比如/bin/bash 文件 Windows 的。exe
共享目标文件 Shared Object File 这种文件包含了代码和数据,可以在以下两种情况下使用。一种是链接器可以使用这种文件跟其他的可重定位文件和共享目标文件链接,产生新的目标文件。第二种是动态链接器可以将几个这种共享目标文件与可执行文件结合,作为进程映像的一部分来运行 Linux 的。so,lib/glibc-2.5.so Windows 的 DLL
核心转储文件 Core Dump File 当进程意外终止时,系统可以将该进程的地址空间的内容及终止时的一些其他信息转储到核心转储文件 Linux 下的 core dump

我们可以在 Linux 下使用 file 命令来查看相应的文件格式:

1
2
3
4
5
6
file foobar.o
foobar.o:ELF 32-bit LSB relocatable,Intel 80386,version 1 (SYSV),notstripped
file /bin/bash
/bin/bash:ELF 32-bit LSB executable,Intel 80386,version 1 (SYSV),forGNU/Linux 2.6.8,dynamically linked (uses shared libs),stripped
$fi1e/1ib/1d-2.6.1.8o
/lib/libc-2.6.1.so:ELF 32-bit LSB shared object,Intel 80386,version 1(SYSV),for GNU/Linux 2.6.8,stripped

目标文件是什么样的

目标文件中的内容至少有编译后的机器指令代码、数据。除了这些内容以外,目标文件中还包括了链接时所须要的一些信息,比如符号表、调试信息、字符串等。一般目标文件将这些信息按不同的属性,以“段”(Segment)的形式存储。

让我们来看一个简单的程序被编译成目标文件后的结构,如图所示。

假设图 3-1 的可执行文件(目标文件)的格式是 ELF,从图中可以看到,ELF 文件的开头是一个“文件头”,它描述了整个文件的文件属性,包括文件是否可执行、是静态链接还是动态链接及入口地址(如果是可执行文件)、目标硬件、目标操作系统等信息,文件头还包括一个段表(Section Table),段表其实是一个描述文件中各个段的数组。段表描述了文件中各个段在文件中的偏移位置及段的属性等。

一般 C 语言的编译后执行语句都编译成机器代码,保存在。text 段;已初始化的全局变量和局部静态变量都保存在。data 段;未初始化的全局变量和局部静态变量一般放在一个叫“.bss”的段里。.bss 段只是为未初始化的全局变量和局部静态变量预留位置而已,它并没有内容,所以它在文件中也不占据空间。

数据和指令分段的好处有很多,主要有如下几个方面:

  • 一方面是当程序被装载后,数据和指令分别被映射到两个虚存区域。这两个虚存区域的权限可以被分别设置成可读写和只读,这样可以防止程序的指令被有意或无意地改写。
  • 另外一方面是对于现代的 CPU 来说,它们有着极为强大的缓存(Cache)体系。指令区和数据区的分离有利于提高程序的局部性,所以程序的指令和数据被分开存放对 CPU 的缓存命中率提高有好处。
  • 第三个原因,其实也是最重要的原因,就是当系统中运行着多个该程序的副本时,它们的指令都是一样的,所以内存中只须要保存一份该程序的指令部分。

挖掘 SimpleSection.o

我们就以 SimpleSection.c 编译出来的目标文件作为分析对象,这个程序是经过精心挑选的,具有一定的代表性而又不至于过于繁琐和复杂。图 3-1 中的程序代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/*
simpleSection.c
Linux:
gcc -c Simplesection.c
Windows:
cl Simplesection.c /c /Za
*/
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;
}

我们使用 GCC 来编译这个文件(参数-c 表示只编译不链接):

1
gcc -c Simplesection.c

我们得到了一个 2.1k 字节的 SimpleSection.o 目标文件。我们可以使用 binutils 的工具 objdump 来查看 object 内部的结构,它可以用来查看各种目标文件的结构和内容。运行以下命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
vooxle@liushuai:~$ objdump -h Simplesection.o

Simplesection.o: file format elf64-x86-64

Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000005f 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000008 0000000000000000 0000000000000000 000000a0 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000004 0000000000000000 0000000000000000 000000a8 2**2
ALLOC
3 .rodata 00000004 0000000000000000 0000000000000000 000000a8 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .comment 0000002b 0000000000000000 0000000000000000 000000ac 2**0
CONTENTS, READONLY
5 .note.GNU-stack 00000000 0000000000000000 0000000000000000 000000d7 2**0
CONTENTS, READONLY

Linux 还有一个很不错的工其叫 readelf,它是专门针对 ELF 文件格式的解析器,很多时候它对 ELF 文件的分析可以跟 objdump 相互对照,所以我们下面会经常用到这个工具。参数“-h”就是把 ELF 文件的各个段的基本信息打印出来。我们也可以使用“objdump -x”把更多的信息打印出来,但是“-x”输出的这些信息又多又复杂,对于不熟悉 ELF 和 objdump 的读者来说可能会很陌生。

从上面的结果来看,SimpleSection.o 的段的数量比我们想象中的要多,除了最基本的代码段、数据段和 BSS 段以外,还有 3 个段分别是只读数据段(.rodata)、注释信息段(.comment)和堆栈提示段(.note.GNU-stack),每个段的第 2 行中的“CONTENTS”、“ALLOC”等表示段的各种属性,“CONTENTS”表示该段在文件中存在。我们可以看到 BSS 段没有“CONTENTS”,表示它实际上在 ELF 文件中不存在内容。“.note.GNU-stack”段虽然有“CONTENTS”,但它的长度为 0,这是个很古怪的段,我们暂且忽略它,认为它在 ELF 文件中也不存在。那么 ELF 文件中实际存在的也就是“.text”、“.data”、“.rodata”和“.comment'”这 4 个段了。它们在 ELF 中的结构如图 3-3 所示:

了解了这几个段在 SimpleSection.o 的基本分布,接着将逐个来看这几个段,看看它们包含了什么内容。有一个专门的命令叫做“size”,它可以用来查看 ELF 文件的代码段、数据段和 BSS 段的长度:

1
2
3
vooxle@liushuai:~$ size Simplesection.o
text data bss dec hex filename
219 8 4 231 e7 Simplesection.o

代码段

objdump 的“-s”参数可以将所有段的内容以十六进制的方式打印出来,“-d”参数可以将所有包含指令的段反汇编。我们将 objdump 输出中关于代码段的内容提取出来,分析一下关于代码段的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
vooxle@liushuai:~$ objdump -x -s -d Simplesection.o

Simplesection.o: file format elf64-x86-64
Simplesection.o
architecture: i386:x86-64, flags 0x00000011:
HAS_RELOC, HAS_SYMS
start address 0x0000000000000000

Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000005f 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000008 0000000000000000 0000000000000000 000000a0 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000004 0000000000000000 0000000000000000 000000a8 2**2
ALLOC
3 .rodata 00000004 0000000000000000 0000000000000000 000000a8 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .comment 0000002b 0000000000000000 0000000000000000 000000ac 2**0
CONTENTS, READONLY
5 .note.GNU-stack 00000000 0000000000000000 0000000000000000 000000d7 2**0
CONTENTS, READONLY
6 .note.gnu.property 00000020 0000000000000000 0000000000000000 000000d8 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
7 .eh_frame 00000058 0000000000000000 0000000000000000 000000f8 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
SYMBOL TABLE:
0000000000000000 l df *ABS* 0000000000000000 Simplesection.c
0000000000000000 l d .text 0000000000000000 .text
0000000000000000 l d .data 0000000000000000 .data
0000000000000000 l d .bss 0000000000000000 .bss
0000000000000000 l d .rodata 0000000000000000 .rodata
0000000000000004 l O .data 0000000000000004 static_var.2324
0000000000000000 l O .bss 0000000000000004 static_var2.2325
0000000000000000 l d .note.GNU-stack 0000000000000000 .note.GNU-stack
0000000000000000 l d .note.gnu.property 0000000000000000 .note.gnu.property
0000000000000000 l d .eh_frame 0000000000000000 .eh_frame
0000000000000000 l d .comment 0000000000000000 .comment
0000000000000000 g O .data 0000000000000004 global_init_var
0000000000000004 O *COM* 0000000000000004 global_uninit_var
0000000000000000 g F .text 0000000000000028 func1
0000000000000000 *UND* 0000000000000000 _GLOBAL_OFFSET_TABLE_
0000000000000000 *UND* 0000000000000000 printf
0000000000000028 g F .text 0000000000000037 main

Contents of section .text:
0000 f30f1efa 554889e5 4883ec10 897dfc8b ....UH..H....}..
0010 45fc89c6 488d3d00 000000b8 00000000 E...H.=.........
0020 e8000000 0090c9c3 f30f1efa 554889e5 ............UH..
0030 4883ec10 c745f801 0000008b 15000000 H....E..........
0040 008b0500 00000001 c28b45f8 01c28b45 ..........E....E
0050 fc01d089 c7e80000 00008b45 f8c9c3 ...........E...
Contents of section .data:
0000 54000000 55000000 T...U...
Contents of section .rodata:
0000 25640a00 %d..
Contents of section .comment:
0000 00474343 3a202855 62756e74 7520392e .GCC: (Ubuntu 9.
0010 332e302d 31377562 756e7475 317e3230 3.0-17ubuntu1~20
0020 2e303429 20392e33 2e3000 .04) 9.3.0.
Contents of section .note.gnu.property:
0000 04000000 10000000 05000000 474e5500 ............GNU.
0010 020000c0 04000000 03000000 00000000 ................
Contents of section .eh_frame:
0000 14000000 00000000 017a5200 01781001 .........zR..x..
0010 1b0c0708 90010000 1c000000 1c000000 ................
0020 00000000 28000000 00450e10 8602430d ....(....E....C.
0030 065f0c07 08000000 1c000000 3c000000 ._..........<...
0040 00000000 37000000 00450e10 8602430d ....7....E....C.
0050 066e0c07 08000000 .n......

Disassembly of section .text:

0000000000000000 <func1>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 48 83 ec 10 sub $0x10,%rsp
c: 89 7d fc mov %edi,-0x4(%rbp)
f: 8b 45 fc mov -0x4(%rbp),%eax
12: 89 c6 mov %eax,%esi
14: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 1b <func1+0x1b>
17: R_X86_64_PC32 .rodata-0x4
1b: b8 00 00 00 00 mov $0x0,%eax
20: e8 00 00 00 00 callq 25 <func1+0x25>
21: R_X86_64_PLT32 printf-0x4
25: 90 nop
26: c9 leaveq
27: c3 retq

0000000000000028 <main>:
28: f3 0f 1e fa endbr64
2c: 55 push %rbp
2d: 48 89 e5 mov %rsp,%rbp
30: 48 83 ec 10 sub $0x10,%rsp
34: c7 45 f8 01 00 00 00 movl $0x1,-0x8(%rbp)
3b: 8b 15 00 00 00 00 mov 0x0(%rip),%edx # 41 <main+0x19>
3d: R_X86_64_PC32 .data
41: 8b 05 00 00 00 00 mov 0x0(%rip),%eax # 47 <main+0x1f>
43: R_X86_64_PC32 .bss-0x4
47: 01 c2 add %eax,%edx
49: 8b 45 f8 mov -0x8(%rbp),%eax
4c: 01 c2 add %eax,%edx
4e: 8b 45 fc mov -0x4(%rbp),%eax
51: 01 d0 add %edx,%eax
53: 89 c7 mov %eax,%edi
55: e8 00 00 00 00 callq 5a <main+0x32>
56: R_X86_64_PLT32 func1-0x4
5a: 8b 45 f8 mov -0x8(%rbp),%eax
5d: c9 leaveq
5e: c3 retq

“Contents of section.text”就是。text 的数据以十六进制方式打印出来的内容,最左面一列是偏移量,中间 4 列是十六进制内容,最右面一列是。text 段的 ASCII 码形式。对照下面的反汇编结果,可以很明显地看到,.text 段里所包含的正是 SimpleSection.c 里两个函数 func1() 和 main() 的指令。.text 段的第一个字节“55”就是“func1()”函数的第一条“push %rbp”指令,而最后一个字节 0xc3 正是 main 函数的最后一条指令“retq”。

数据段和只读数据段

.data 段保存的是那些已经初始化了的全局静态变量和局部静态变量。前面的 SimpleSection.c 代码里面一共有两个这样的变量,分别是 global_init_varabal 与 static_var。这两个变量每个 4 字节,一共刚好 8 个字节,所以“.data”这个段的大小为 8 个字节。 SimpleSection.c 里面我们在调用“printf”的时候,用到了一个字符串常量“%d”,它是一种只读数据,所以它被放到了“.rodata”段,我们可以从输出结果看到“.rodata”这个段的 4 个字节刚好是这个字符串常量的 ASCII 字节序,最后以、0 结尾。 单独设立“.rodata”段有很多好处,不光是在语义上支持了 C++的 const 关键字,而且操作系统在加载的时候可以将“.rodata”段的属性映射成只读,这样对于这个段的任何修改操作都会作为非法操作处理,保证了程序的安全性。 另外值得一提的是,有时候编译器会把字符串常量放到“.data”段,而不会单独放在“.rodata”段。

BSS 段

.bss 段存放的是未初始化的全局变量和局部静态变量,如上述代码中 global_uninit_var 和 static_var2 就是被存放在。bss 段,其实更准确的说法是。bss 段为它们预留了空间。但是我们可以看到该段的大小只有 4 个字节,这与 global_uninit_var 和 static_var2 的大小的 8 个字节不符。 其实我们可以通过符号表(Symbol Table)看到,只有 static_var2 被存放在了。bss 段,而 global_uninit_var 却没有被存放在任何段,只是一个未定义的“COMMON 符号”。这其实是跟不同的语言与不同的编译器实现有关,有些编译器会将全局的未初始化变量存放在目标文件。bss 段,有些则不存放,只是预留一个未定义的全局变量符号,等到最终链接成可执行文件的时候再在。bss 段分配空间。

其他段

除了。text、.data、.bss 这 3 个最常用的段之外,ELF 文件也有可能包含其他的段,用来保存与程序相关的其他信息。下表中列举了 ELF 的一些常见的段:

常用的段名 说明
.rodatal Read only Data,这种段里存放的是只读数据,比如字符串常量、全局 const 变量。跟“.rodata”一样
.comment 存放的是编译器版本信息,比如字符串:”GCC:(GNU)4.2.0
.debug 调试信息
.dynamic 动态链接信息
.hash 符号哈希表
.line 调试时的行号表,即源代码行号与编译后指令的对应表
.note 额外的编译器信息。比如程序的公司名、发布版本号等
.strtab String Table. 字符串表,用于存储 ELF 文件中用到的各种字符串
.symtab Symbol Table. 符号表
.shstrtab Section String Table. 段名表
.plt .got 动态链接的跳转表和全局入口表
.init .fini 程序初始化与终结代码段

这些段的名字都是由“.”作为前缀,表示这些表的名字是系统保留的,应用程序也可以使用一些非系统保留的名字作为段名。比如我们可以在 ELF 文件中插入一个“music”的段,里面存放了一首 MP3 音乐,当 ELF 文件运行起来以后可以读取这个段播放这首 MP3。但是应用程序自定义的段名不能使用“.”作为前缀,否则容易跟系统保留段名冲突。

Q:如果我们要将一个二进制文件,比如图片、MP3 音乐、词典一类的东西作为目标文件中的一个段,该怎么做? A:可以使用 objcopy 工具,比如我们有一个图片文件“image.jpg”,大小为 0x82100 字节:

1
2
objcopy -I binary -o elf32-1386 -B i386 image.jpg image.o
objdump -ht image.o

ELF 文件结构描述

下图描述的是 ELF 目标文件的总体结构,我们省去了 ELF-些繁琐的结构,把最重要的结构提取出来:

文件头

我们可以用 readelf 命令来详细查看 ELF 文件,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
vooxle@liushuai:~$ 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: 1184 (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

从上面输出的结果可以看到,ELF 的文件头中定义了 ELF 魔数、文件机器字节长度、数据存储方式、版本、运行平台、ABI 版本、ELF 重定位类型、硬件平台、硬件平台版本、入口地址、程序头入口和长度、段表的位置和长度及段的数量等。这些数值中有关描述 ELF 目标平台的部分,与我们常见的 32 位 Intel 的硬件平台基本上一样。

段表

前文中我们使用了“objudump -h”来查看 ELF 文件中包含的段,结果是 SimpleSection 里面看到了总共有 6 个段,分别是“.code”、“.data”、“.bss”、“.rodata”、“.comment'”和“.note.GNU-stack”。实际上的情况却有所不同,“objdump -h”命令只是把 ELF 文件中关键的段显示了出来,而省略了其他的辅助性的段,比如:符号表、字符串表、段名字符串表、重定位表等。我们可以使用 readelf 工具来查看 ELF 文件的段,它显示出来的结果才是真正的段表结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
vooxle@liushuai:~$ readelf -S Simplesection.o
There are 14 section headers, starting at offset 0x4a0:

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
000000000000005f 0000000000000000 AX 0 0 1
[ 2] .rela.text RELA 0000000000000000 00000380
0000000000000078 0000000000000018 I 11 1 8
[ 3] .data PROGBITS 0000000000000000 000000a0
0000000000000008 0000000000000000 WA 0 0 4
[ 4] .bss NOBITS 0000000000000000 000000a8
0000000000000004 0000000000000000 WA 0 0 4
[ 5] .rodata PROGBITS 0000000000000000 000000a8
0000000000000004 0000000000000000 A 0 0 1
[ 6] .comment PROGBITS 0000000000000000 000000ac
000000000000002b 0000000000000001 MS 0 0 1
[ 7] .note.GNU-stack PROGBITS 0000000000000000 000000d7
0000000000000000 0000000000000000 0 0 1
[ 8] .note.gnu.propert NOTE 0000000000000000 000000d8
0000000000000020 0000000000000000 A 0 0 8
[ 9] .eh_frame PROGBITS 0000000000000000 000000f8
0000000000000058 0000000000000000 A 0 0 8
[10] .rela.eh_frame RELA 0000000000000000 000003f8
0000000000000030 0000000000000018 I 11 9 8
[11] .symtab SYMTAB 0000000000000000 00000150
00000000000001b0 0000000000000018 12 12 8
[12] .strtab STRTAB 0000000000000000 00000300
000000000000007c 0000000000000000 0 0 1
[13] .shstrtab STRTAB 0000000000000000 00000428
0000000000000074 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),
l (large), p (processor specific)

对于 SimpleSection.o 来说,段表就是有 11 个元素的数组。ELF 段表的这个数组的第一个元素是无效的段描述符,它的类型为“NULL”,除此之外每个段描述符都对应一个段。也就是说 SimpleSection.o 共有 13 个有效的段。

重定位表

我们注意到,SimpleSection.o 中有一个叫做“.rela.text”的段,它的类型(sh_type)为“RELA”,也就是说它是一个重定位表(Relocation Table)。正如我们最开始所说的,链接器在处理目标文件时,须要对目标文件中某些部位进行重定位,即代码段和数据段中那些对绝对地址的引用的位置。

比如 SimpleSection.o 中的“.rela.text”就是针对“.text”段的重定位表,因为“.text”段中至少有一个绝对地址的引用,那就是对“printf”函数的调用;而“.data”段则没有对绝对地址的引用,它只包含了几个常量,所以 SimpleSection.o 中没有针对“.data”段的重定位表“.rela.data”。

字符串表

ELF 文件中用到了很多字符串,比如段名、变量名等,比如下表这个字符串表。

偏移 +0 +1 +2 +3 +4 +5 +6 +7 +8 +9
+0 \0 h e l l o w o r l
+10 d \0 M y v a r i a b
+20 l e \0

常见的段名为“.strtab”或“.shstrtab”。这两个字符串表分别为字符串表(String Table)和段表字符串表(Section Header String Table)。

符号

在链接中,我们将函数和变量统称为符号(Symbol),函数名或变量名就是符号名(Symbol Name)。

链接过程中很关键的一部分就是符号的管理,每一个目标文件都会有一个相应的符号表(Symbol Table),这个表里面记录了目标文件中所用到的所有符号。每个定义的符号有一个对应的值,叫做符号值(Symbol Value),对于变量和函数来说,符号值就是它们的地址。除了函数和变量之外,还存在其他几种不常用到的符号,我们将符号表中所有的符号进行分类,它们有可能是下面这些类型中的一种:

  • 定义在本目标文件的全局符号,可以被其他目标文件引用。比如 SimpleSection.o 里面的“func1”、“main”和“global_init_var”。
  • 在本目标文件中引用的全局符号,却没有定义在本目标文件,这一般叫做外部符号(External Symbol),也就是我们前面所讲的符号引用。比如 SimpleSection.o 里面的“printf”。
  • 段名,这种符号往往由编译器产生,它的值就是该段的起始地址。比如 SimpleSection.o 里面的“.text”、“.data”等。
  • 局部符号,这类符号只在编译单元内部可见。比如 SimpleSection.o 里面的“static_var”和“static_var2”。调试器可以使用这些符号来分析程序或崩溃时的核心转储文件。这些局部符号对于链接过程没有作用,链接器往往也忽略它们。
  • 行号信息,即目标文件指令与源代码中代码行的对应关系,它也是可选的。

对于我们来说,最值得关注的就是全局符号,即上面分类中的第一类和第二类。因为链接过程只关心全局符号的相互“粘合”,局部符号、段名、行号等都是次要的,它们对于其他目标文件来说是“不可见”的,在链接过程中也是无关紧要的。我们可以使用很多工具来查看 ELF 文件的符号表,比如 readelf、objdump、nm 等,比如使用“nm”来查看“SimpleSection.o”的符号结果如下:

1
2
3
4
5
6
7
8
9
vooxle@liushuai:~$ nm Simplesection.o
U _GLOBAL_OFFSET_TABLE_
0000000000000000 T func1
0000000000000000 D global_init_var
0000000000000004 C global_uninit_var
0000000000000028 T main
U printf
0000000000000004 d static_var.2324
0000000000000000 b static_var2.2325

特殊符号

当我们使用 ld 作为链接器来链接生产可执行文件时,它会为我们定义很多特殊的符号,这些符号并没有在你的程序中定义,但是你可以直接声明并且引用它,我们称之为特殊符号。其实这些符号是被定义在 ld 链接器的链接脚本中的,链接器会在将程序最终链接成可执行文件的时候将其解析成正确的值,注意,只有使用 ld 链接生产最终可执行文件的时候这些符号才会存在。

  • _executable_start,该符号为程序起始地址,注意,不是入口地址,是程序的最开始的地址。
  • etext 或_etext 或 etext,该符号为代码段结束地址,即代码段最末尾的地址。
  • _edata 或 edata,该符号为数据段结束地址,即数据段最末尾的地址。
  • _end 或 end,该符号为程序结束地址。
  • 以上地址都为程序被装载时的虚拟地址。

我们可以在程序中直接使用这些符号。

符号修饰与函数签名

C++符号修饰
C++允许多个不同参数类型的函数拥有一样的名字,就是所谓的函数重载;另外 C++还在语言级别支持名称空间,即允许在不同的名称空间有多个同样名字的符号。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int func(int);
float func(float);
class C {
int func(int);
class C2 {
int func(int);
};
};
namespace N {
int func(int);
class C {
int func(int);
};
}

我们引入一个术语叫做函数签名(Function Signature),函数签名包含了一个函数的信息,包括函数名、它的参数类型、它所在的类和名称空间及其他信息,函数签名用于识别不同的函数。C++编译器和链接器都使用符号来识别和处理函数和变量,所以对于不同函数签名的函数,即使函数名相同,编译器和链接器都认为它们是不同的函数。上面的 6 个函数签名在 GCC 编译器下,相对应的修饰后名称如下表所示。

GCC 的基本 C++名称修饰方法如下:所有的符号都以“_Z”开头,对于嵌套的名字(在名称空间或在类里面的),后面紧跟“N”,然后是各个名称空间和类的名字,每个名字前是名字字符串长度,再以“E”结尾。比如 N::C::func 经过名称修饰以后就是_ZN1N1C4funcE。对于一个函数来说,它的参数列表紧跟在“E”后面,对于 int 类型来说,就是字母“i”。所以整个 N::C::func(int)函数签名经过修饰为_ZN1N1C4funcEi。binutils 里面提供了一个叫“c++filt”的工具可以用来解析被修饰过的名称,比如:

1
2
c++filt _ZN1N1C4funcEi
N:C:func(int)

签名和名称修饰机制不光被使用到函数上,C++中的全局变量和静态变量也有同样的机制。对于全局变量来说,它跟函数一样都是一个全局可见的名称,它也遵循上面的名称修饰机制。

由于不同的编译器采用不同的名字修饰方法,必然会导致由不同编译器编译产生的目标文件无法正常相互链接,这是导致不同编译器之间不能互操作的主要原因之一。

extern “C"

1
void *memset (void *int, size_t);

在 C++语言中,编译器会认为这个 memset 函数是一个 C++函数,将 memset 的符号修饰成_ZmemsetPvii,这样链接器就无法与 C 语言库中的 memset 符号进行链接。所以对于 C++来说,必须使用 extern“C"来声明 memset 这个函数。但是 C 语言又不支持 extern“C”语法。幸好我们有一种很好的方法可以解决上述问题,就是使用 C++的宏“_cplusplus”,C++编译器会在编译 C++的程序时默认定义这个宏,具体代码如下:

1
2
3
4
5
6
7
8
9
#ifdef cplusplus
extern "C"{
#endif

void *memset (void *int,size_t);

#ifdef cplusplus
}
#endif

上面这段代码中的技巧几乎在所有的系统头文件里面都被用到。

弱符号与强符号

对于 C/C++语言来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号。我们也可以通过 GCC 的“attribute(weak)”来定义任何一个强符号为弱符号。注意,强符号和弱符号都是针对定义来说的,不是针对符号的引用。比如我们有下面这段程序:

1
2
3
4
5
6
7
8
extern int ext;
int weak;
int strong = 1:
__attribute__((weak) weak2 = 2;
int main()
{
return 0;
}

上面这段程序中,“weak”和“weak2”是弱符号,“strong”和“main”是强符号,而“ext”既非强符号也非弱符号,因为它是一个外部变量的引用。针对强弱符号的概念,链接器就会按如下规则处理与选择被多次定义的全局符号:

  • 规则 1:不允许强符号被多次定义。
  • 规则 2:如果一个符号在某个目标文件中是强符号,在其他文件中都是弱符号,那么选择强符号。
  • 规则 3:如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的一个。

弱引用和强引用:

  • 目前我们所看到的对外部目标文件的符号引用在目标文件被最终链接成可执行文件时,它们须要被正确决议,如果没有找到该符号的定义,链接器就会报符号未定义错误,这种被称为强引用(Strong Reference)。
  • 与之相对应还有一种弱引用(Weak Reference),在处理弱引用时,如果该符号有定义,则链接器将该符号的引用决议;如果该符号未被定义,则链接器对于该引用不报错。

在 GCC 中,我们可以通过使用“attribute(weakref)”这个扩展关键字来声明对一个外部函数的引用为弱引用,比如下面这段代码:

1
2
3
4
5
6
__attribute__((weakref))void foo():

int main()
{
foo():
}

我们可以将它编译成一个可执行文件,GCC 并不会报链接错误。但是当我们运行这个可执行文件时,会发生运行错误。因为当 main 函数试图调用 foo 函数时,foo 函数的地址为 0,于是发生了非法地址访问的错误。一个改进的例子是:

1
2
3
4
5
6
__attribute__((weakref) void foo();
int main()
{
if(foo)
foo();
}

这种弱符号和弱引用对于库来说十分有用,比如库中定义的弱符号可以被用户定义的强符号所覆盖,从而使得程序可以使用自定义版本的库函数。

调试信息

如果我们在 GCC 编译时加上“-g”参数,编译器就会在产生的目标文件里面加上调试信息,我们通过 readelf 等工具可以看到,目标文件里多了很多“debug”相关的段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
vooxle@liushuai:~$ gcc -c -g Simplesection.c
vooxle@liushuai:~$ ls
Document Simplesection.c Simplesection.o nfs_rootfs workspace
vooxle@liushuai:~$ readelf -S Simplesection.o
There are 22 section headers, starting at offset 0x1570:

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
000000000000005f 0000000000000000 AX 0 0 1
[ 2] .rela.text RELA 0000000000000000 00000d40
0000000000000078 0000000000000018 I 19 1 8
[ 3] .data PROGBITS 0000000000000000 000000a0
0000000000000008 0000000000000000 WA 0 0 4
[ 4] .bss NOBITS 0000000000000000 000000a8
0000000000000004 0000000000000000 WA 0 0 4
[ 5] .rodata PROGBITS 0000000000000000 000000a8
0000000000000004 0000000000000000 A 0 0 1
[ 6] .debug_info PROGBITS 0000000000000000 000000ac
00000000000003a0 0000000000000000 0 0 1
[ 7] .rela.debug_info RELA 0000000000000000 00000db8
0000000000000678 0000000000000018 I 19 6 8
[ 8] .debug_abbrev PROGBITS 0000000000000000 0000044c
0000000000000130 0000000000000000 0 0 1
[ 9] .debug_aranges PROGBITS 0000000000000000 0000057c
0000000000000030 0000000000000000 0 0 1
[10] .rela.debug_arang RELA 0000000000000000 00001430
0000000000000030 0000000000000018 I 19 9 8
[11] .debug_line PROGBITS 0000000000000000 000005ac
000000000000012e 0000000000000000 0 0 1
[12] .rela.debug_line RELA 0000000000000000 00001460
0000000000000018 0000000000000018 I 19 11 8
[13] .debug_str PROGBITS 0000000000000000 000006da
0000000000000314 0000000000000001 MS 0 0 1
[14] .comment PROGBITS 0000000000000000 000009ee
000000000000002b 0000000000000001 MS 0 0 1
[15] .note.GNU-stack PROGBITS 0000000000000000 00000a19
0000000000000000 0000000000000000 0 0 1
[16] .note.gnu.propert NOTE 0000000000000000 00000a20
0000000000000020 0000000000000000 A 0 0 8
[17] .eh_frame PROGBITS 0000000000000000 00000a40
0000000000000058 0000000000000000 A 0 0 8
[18] .rela.eh_frame RELA 0000000000000000 00001478
0000000000000030 0000000000000018 I 19 17 8
[19] .symtab SYMTAB 0000000000000000 00000a98
0000000000000228 0000000000000018 20 17 8
[20] .strtab STRTAB 0000000000000000 00000cc0
000000000000007c 0000000000000000 0 0 1
[21] .shstrtab STRTAB 0000000000000000 000014a8
00000000000000c3 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),
l (large), p (processor specific)

这些段中保存的就是调试信息。现在的 ELF 文件采用一个叫 DWARF(Debug With Arbitrary Record Format)的标准的调试信息格式,现在该标准已经发展到了第三个版本,即 DWARF3,由 DWARF 标准委员会由 2006 年颁布。在 Linux 下,我们可以使用“strip”命令来去掉 ELF 文件中的调试信息:

1
$ strip foo

参考文献

《程序员的自我修养》