目标文件到底有什么?
本系列文章主要是讲述c++编译链接的那点事,这个对于刚入门的程序员来说是必须修炼的内功之一。第一篇文章将主要配合示例来说明c++编译后的.o文件究竟有些什么?
前言
在了解.o文件之前,有必要理清楚基本的编译流程, 如下图所示:
- 预处理阶段:预处理器主要是处理源代码中以”#”开始的命令,比如头文件等等,生成.i中间文件。预处理的命令:
$gcc -E hello.c -o hello.i
- 编译阶段: 编译器会将.i文件翻译成.s文件,具体过程是通过一系列的词法分析,语义分析,以及优化后生成相应的汇编文件。其处理命令为:
$gcc -S hello.i -o hello.s
- 汇编阶段: 汇编器是将汇编代码翻译成机器语言指令,并打包成一个可重定位目标程序,并将结果保留在目标文件中。它的字节编码是机器语言指令,而不是字符。
$gcc -c hello.s -o hello.o
- 链接阶段: 链接器会把所有的.o文件进行链接,并生成最终的可执行文件。那么为什么不直接在汇编过程中就完成链接过程呢?为什么需要链接?
这里先给出一个粗略的回答:首先,我们要明白链接器主要作用是符号解析和地址的重定位,这个具体是什么意思,后续系列会慢慢涉及到。其次,由于链接器处理了符号的解析和地址的重定位,那么使得我们在构造大型软件工程时,自然会思考我们可否借鉴建房子的过程,一块一块砖垒起来,水泥就是中间的粘合剂。于是,写代码就能够分模块完成,最后由链接器完成类似水泥的工作就行了。链接器使得每个模块的分离编译成为可能,当我们需要改变其中一个模块时,只需要重新编译和链接,而不必再编译其他模块文件了,大大节约了时间成本。
总之,编译的基本流程如上所述,在链接实现的过程中,时间点可以是在编译时链接,可以是加载时链接,甚至是运行时链接。这三种的不同会在下面以及后续文章慢慢涉及到。
目标文件的神秘面纱
从上述编译的流程我们可以大概猜到,目标文件至少是包括相关的机器指令代码和数据的,除此之外,也会包括链接时所需要的一些信息,比如符号表、调试信息等等。一般而言,目标文件是以节(section) 为单位进行组织的,一个典型的可重定位目标文件如下:
可重定位目标文件 |
---|
ELF头: 描述生成该文件的系统的字的大小和字节顺序、头大小、目标文件类型、机器类型、节头部表的文件偏移、节头部表中的条目大小和数量 |
.text : 编译后的机器代码 |
.rodata : 只读数据,一般是程序里面的只读变量和字符串常量 |
.data : 初始化后的全局变量和初始化局部静态变量 |
.bss : 未初始化的全局变量和未初始化的局部静态变量 |
.symtab : 符号表,里面记录了程序中的函数和全局变量相关信息 |
.rel.text : 重定位.text的表,里面记录了.text的位置信息等等,方便链接器进行符号的重定位 |
.rel.data : 重定位全局变量的表 |
.strtab : 字符串表,单纯记录了相关符号名称 |
代码示例分析
源代码SimpleSection.c如下:
int printf(const char* format, ...);
int g_init_var = 25; //初始化的全局变量
int g_uninit_var; //未初始化的全局变量
void callPrintf(int i) {
printf("%d\n", i);
}
int main(void) {
static int static_var = 70; //局部的初始化静态变量
static int static_var_uninit; //局部的未初始化的静态变量
int a = 1; //局部的初始化变量
int b; //局部的未初始化的变量
callPrintf(static_var + static_var_uninit + a + b);
return a;
}
我们先编译生成SimpleSection.o, 然后使用下面的命令查看
objdump -h SimpleSection.o
注意到.data起始位置是从0x00000094而不是0x00000091开始的,原因在于.text和.data的对齐方式是4字节,即必须要被4整除:91=9*16+1; 94=9*16+4,可以明白94能被4整除,所以.data是从0x00000094开始的。
根据偏移量可画出相应的图如下(注意地址是向上增长的):
为了看清楚每个section具体里面是什么,我们采用下面命令:
objdump -s -d SimpleSection.o
得到如下的内容:
SimpleSection.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 10c745f8 01000000 .UH..H....E.....
0030 8b150000 00008b05 00000000 8d040203 ................
0040 45f80345 fc89c7e8 00000000 8b45f8c9 E..E.........E..
0050 c3 .
Contents of section .data:
0000 19000000 46000000 ....F...
Contents of section .rodata:
0000 25640a00 %d..
Contents of section .comment:
0000 00474343 3a202847 4e552920 342e342e .GCC: (GNU) 4.4.
0010 36203230 31313037 33312028 52656420 6 20110731 (Red
0020 48617420 342e342e 362d3429 00 Hat 4.4.6-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 30000000 00410e10 8602430d ....0....A....C.
0050 066b0c07 08000000 .k......
Disassembly of section .text:
0000000000000000 <callPrintf>:
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 <callPrintf+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 f8 01 00 00 00 movl $0x1,-0x8(%rbp)
30: 8b 15 00 00 00 00 mov 0x0(%rip),%edx # 36 <main+0x15>
36: 8b 05 00 00 00 00 mov 0x0(%rip),%eax # 3c <main+0x1b>
3c: 8d 04 02 lea (%rdx,%rax,1),%eax
3f: 03 45 f8 add -0x8(%rbp),%eax
42: 03 45 fc add -0x4(%rbp),%eax
45: 89 c7 mov %eax,%edi
47: e8 00 00 00 00 callq 4c <main+0x2b>
4c: 8b 45 f8 mov -0x8(%rbp),%eax
4f: c9 leaveq
50: c3 retq
可以看到,“Contents of section .text”长度确实为51个字节,其反汇编形成的正好是函数callPrintf( )和main( )。在数据段.data中,内容为“19000000 46000000”,为8个字节。注意到数据段储存的是初始化的全局变量和局部静态变量。我们的代码中有g_init_var = 25, static_var = 70, 都属于int,而int所占字节为4,所以两个变量占据8个字节。由于机器是小端储存的形式,00000019对应25, 00000046对应70。
重头戏节头表和段表
在基本了解了ELF文件的轮廓后,我们需要更加细致地研究下其中某些重要的表,这些表中会记录更多信息,以方便链接器进行链接。
- 文件头(ELF Header)
文件头记录了描述整个文件的基本属性值,比如ELF文件版本、目标机器型号、程序入口地址等等。我们可以通过如下命令查看文件头的信息:
readelf -h SimpleSection.o
得到示例代码的如下信息:
我着重描述下几个重要的属性:程序入口地址,这个规定了ELF程序的入口虚拟地址,操作系统在加载完该程序后从这个地址开始执行进程的指令。Start of section headers, 段表在文件中的偏移值,该示例是400,也就是段表是从文件的第401个字节开始的。Size of section headers: 每个段表描述符的大小; Number of section headers: 段表的个数。
- 段表: 段表是除了文件头外最重要的结构,主要描述了ELF中的各个段的信息,比如每个段的段名,段的长度,段在文件中的偏移、读写权限等等。ELF文件的段结构就是由段表来决定的,编译器、链接器和装载器都是根据段表来定位和访问各个段的属性等等。并且通过文件头和段表信息,我们可以画出ELF更加细致的结构。
通过如下命令查看示例代码的段表结构:
readelf -S SimpleSection.o
结果如下所示:
There are 13 section headers, starting at offset 0x190:
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
0000000000000051 0000000000000000 AX 0 0 4
[ 2] .rela.text RELA 0000000000000000 000006b8
0000000000000078 0000000000000018 11 1 8
[ 3] .data PROGBITS 0000000000000000 00000094
0000000000000008 0000000000000000 WA 0 0 4
[ 4] .bss NOBITS 0000000000000000 0000009c
0000000000000004 0000000000000000 WA 0 0 4
[ 5] .rodata PROGBITS 0000000000000000 0000009c
0000000000000004 0000000000000000 A 0 0 1
[ 6] .comment PROGBITS 0000000000000000 000000a0
000000000000002d 0000000000000001 MS 0 0 1
[ 7] .note.GNU-stack PROGBITS 0000000000000000 000000cd
0000000000000000 0000000000000000 0 0 1
[ 8] .eh_frame PROGBITS 0000000000000000 000000d0
0000000000000058 0000000000000000 A 0 0 8
[ 9] .rela.eh_frame RELA 0000000000000000 00000730
0000000000000030 0000000000000018 11 8 8
[10] .shstrtab STRTAB 0000000000000000 00000128
0000000000000061 0000000000000000 0 0 1
[11] .symtab SYMTAB 0000000000000000 000004d0
0000000000000180 0000000000000018 12 11 8
[12] .strtab STRTAB 0000000000000000 00000650
0000000000000067 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings)
I (info), L (link order), G (group), x (unknown)
O (extra OS processing required) o (OS specific), p (processor specific)
readelf 相比于objdump,可以查看到更加细致的结构。我们注意到第一个段类型为NULL,表示无效的,所以实际上SimpleSection.o只有12个有效的段。结合所有的节头表,得到完整的ELF结构如下:
- 重定位表:在上述分析中,我们看到有个段表叫做.rela.text,那么它的作用是什么呢?我们知道,链接器在处理目标文件时,需要对目标文件某些部分进行重定位,比如在我们的源码中,main函数中调用了callPrintf函数,这里表明.text中有一处对于该绝对地址的引用。
最后在详解静态链接时,我们再看看符号表里面的内容,为以后分析静态链接做准备。命令如下:
readelf -s SimpleSection.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 SimpleSection.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 static_var.1601
7: 0000000000000000 4 OBJECT LOCAL DEFAULT 4 static_var_uninit.1602
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 g_init_var
12: 0000000000000004 4 OBJECT GLOBAL DEFAULT COM g_uninit_var
13: 0000000000000000 33 FUNC GLOBAL DEFAULT 1 callPrintf
14: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf
15: 0000000000000021 48 FUNC GLOBAL DEFAULT 1 main