vx公众号:CurryCoder的程序人生
业精于勤,荒于嬉;行成于思,毁于随
1.问题引入
学过C语言的小伙伴们,基本上都知道从一个xxx.c的源文件到最后生成的可执行文件,需要经过预处理、编译、汇编、链接这几个步骤。但是,这几个步骤详细的过程我一直没搞清楚,本文将深度剖析这几个步骤。例如,在Windows/Linux系统中,一个C源文件从编写完成到最终被CPU执行,中间要经历一系列复杂而又漫长的过程,如下图所示:
2.编译
编译就是将程序员先的高级语言源代码如xxx.c/xxx.cpp源文件转化成对应的目标文件过程。一般来说,高级语言的编译详细流程需要经过预处理、编译和汇编这几步。
2.1 预处理预处理过程主要是对源代码做了如下操作:(1).删除所有的代码注释信息 (2).删除所有的#define,并展开所有的宏定义 (3).插入所有的#include头文件的内容到xxx.c/xxx.cpp源文件中的对应位置(4).其他信息......例如,gcc编译器可以使用gcc -E test.c -o test.i命令对源文件test.c进行预编译,并且把预编译的结果输出到test.i文件中。
[njust@njust Make_Tutorials]$ ls
test.c
[njust@njust Make_Tutorials]$ cat test.c
#include <stdio.h>
#define PI 3.14
int main() {
printf("hello world!\n");
return 0;
}
[njust@njust Make_Tutorials]$ gcc -E test.c -o test.i
[njust@njust Make_Tutorials]$ ls
test.c test.i
2.2 编译
编译就是将预处理后的文件进行词法分析、语法分析、语义分析并优化后生成相应的汇编文件。例如,使用命令gcc -S test.i -o test.s来编译预处理阶段生成的文件,或者也可以使用命令gcc -S test.c -o hello.s将预处理与编译两个步骤合二为一。
[njust@njust Make_Tutorials]$ ls
test.c test.i
[njust@njust Make_Tutorials]$ gcc -S test.i -o test.s
[njust@njust Make_Tutorials]$ ls
test.c test.i test.s
[njust@njust Make_Tutorials]$ gcc -S test.c -o test.s
[njust@njust Make_Tutorials]$ ls
test.c test.i test.s
汇编阶段所生成的文件叫做目标文件,目标文件的结构与可执行文件的结构是一致的,它们之间只存在一些细微的差异。目标文件是无法被执行的,它还需要经过链接这一步操作后才能生成可执行文件,最终被执行。
3.目标文件的格式
Linux系统中的目标文件格式叫做ELF(Executable Linkable Format),ELF的格式如下图所示:
ELF header是ELF文件中最重要的一个部分,header中保存了如下的内容:
(1).ELF的magic number
(2).文件机器字节长度
(3).操作系统平台
(4).硬件平台
(5).程序的入口地址
** (6).段表的位置和长度**
(7).段的数量
(8).其他信息......
从header中我们可以获取很多有用的信息,其中一种重要的信息就是段表的位置和长度。通过这个信息我们可以从ELF文件中获取到段表(Section Header Table),在ELF中段表的重要性仅次于header。段表中保存了ELF文件中所有的段的基本属性(包括每个段的段名、段在ELF文件中的偏移、段的长度及段的读写权限等),段表决定了整个ELF文件的结构。
既然段表决定了所有的段的基本属性,那么ELF文件中的段究竟是个啥呢?其实段只是对ELF文件内不同类型数据的一种分类。例如,我们把所有的代码(指令)放在同一个段中,并且给这个段起名为.text;把所有已初始化的数据放在.data段;把所有未初始化的数据放在.bss段;把所有只读的数据放在.rodata段,.......等等。
为什么又要将数据(指令在ELF文件中也算是一种数据,它是ELF文件的数据之一)分成不同的类型,然后分别存放在不同的段中呢?除了便于进行区分外,还有如下几个原因:
(1).便于给段设置读写权限,有的段只需要设置只读权限;
(2).方便CPU缓存的生效;
(3).有利于节省内存,例如程序有多个副本情况下,此时只需要一份代码段即可;
用如下的hello.c程序为例,深入的分析一下ELF文件中的段信息,文件的内容如下所示:
[njust@njust Make_Tutorials]$ cat hello.c
int printf(const char *format, ...);
int global_var_init_a = 84;
int global_var_uninit_b;
void bar(int i) {
printf("%d\n", i);
}
int main() {
static int static_var_a = 85;
static int static_var_b;
int a = 1;
int b;
bar(static_var_a + static_var_b + a + b);
return a;
}
使用命令gcc -c hello.c -o hello.o将源文件hello.c编译成目标文件hello.o。然后,再使用objdump命令查看ELF文件的内部结构,-h表示显示ELF文件的头部信息得到如下结果:
[njust@njust Make_Tutorials]$ gcc -c hello.c -o hello.o
[njust@njust Make_Tutorials]$ ls
hello.c hello.o test.c test.i test.s
[njust@njust Make_Tutorials]$ objdump -h hello.o
hello.o: 文件格式 elf64-x86-64
节:
Idx Name Size VMA LMA File off Algn
0 .text 00000054 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000008 0000000000000000 0000000000000000 00000094 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000004 0000000000000000 0000000000000000 0000009c 2**2
ALLOC
3 .rodata 00000004 0000000000000000 0000000000000000 0000009c 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .comment 0000002e 0000000000000000 0000000000000000 000000a0 2**0
CONTENTS, READONLY
5 .note.GNU-stack 00000000 0000000000000000 0000000000000000 000000ce 2**0
CONTENTS, READONLY
6 .eh_frame 00000058 0000000000000000 0000000000000000 000000d0 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
从上面的输出结果可以看到显示了7个段,每个段都有一些属性,下面解释一下一些重要属性的含义:
(1).Size:段的大小; (2).VMA:段的虚拟地址,因为目标文件还没有执行链接操作,因此虚拟地址为0;(3).LMA:段被加载的地址,值为0(原因同上); (4).File off:段在ELF文件中的偏移地址; (5).CONTENTS:段存在于ELF文件中;需要重点关注的是.text、.data、.bss和.rodata这几个段,这几个段的详细信息如下所示: .text段:保存程序中的所有指令信息,objdump的-s参数表示将段的内容以十六进制的方式打印出来,而-d参数会对所有包含指令的段进行反汇编。于是,使用命令objdump -s -d hello.o就可以获取代码段的详细信息;
[njust@njust Make_Tutorials]$ objdump -s -d hello.o
hello.o: 文件格式 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 0000008b ....E...........
0050 45fcc9c3 E...
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 352d3434 2900 Hat 4.8.5-44).
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 33000000 00410e10 8602430d ....3....A....C.
0050 066e0c07 08000000 .n......
Disassembly of section .text:
0000000000000000 <bar>:
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 <bar+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 <main+0x15>
36: 8b 05 00 00 00 00 mov 0x0(%rip),%eax # 3c <main+0x1b>
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 <main+0x2e>
4f: 8b 45 fc mov -0x4(%rbp),%eax
52: c9 leaveq
53: c3 retq
.data段:保存已初始化的全局变量和局部静态变量; .bss段:保存未初始化的全局变量和局部静态变量;.rodata段:保存只读数据,例如字符串常量,被const修饰的变量;
4.重定位表与符号表
在ELF文件中还有两个很重要的段,它们分别是重定位表与符号表。它们对后续的链接阶段很重要。
4.1 重定位表
简单理解,编译器将所有需要被重定位的数据存放在重定位表中,这样链接器就能知道目标文件中哪些数据是需要被重定位的。例如,我们有两个源文件bar.c和foo.c,文件内容如下所示:
[njust@njust Make_Tutorials]$ cat bar.c
extern int shared;
int main() {
int a = 100;
swap(&a,&shared);
}
[njust@njust Make_Tutorials]$ cat foo.c
int shared = 1;
void swap(int *a, int *b) {
*a ^= *b ^= *a ^= *b;
}
可以使用命令objdump -r bar.o来获取重定位表的信息。此外,还可以使用命令readelf -S bar.o来详细了解一个ELF文件。
[njust@njust Make_Tutorials]$ objdump -r bar.o
bar.o: 文件格式 elf64-x86-64
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000014 R_X86_64_32 shared
0000000000000021 R_X86_64_PC32 swap-0x0000000000000004
RELOCATION RECORDS FOR [.debug_info]:
OFFSET TYPE VALUE
0000000000000006 R_X86_64_32 .debug_abbrev
000000000000000c R_X86_64_32 .debug_str+0x000000000000002d
0000000000000011 R_X86_64_32 .debug_str+0x0000000000000022
0000000000000015 R_X86_64_32 .debug_str+0x0000000000000007
0000000000000019 R_X86_64_64 .text
0000000000000029 R_X86_64_32 .debug_line
000000000000002e R_X86_64_32 .debug_str+0x000000000000008a
0000000000000038 R_X86_64_64 .text
000000000000005b R_X86_64_32 .debug_str+0x0000000000000028
0000000000000070 R_X86_64_32 .debug_str
RELOCATION RECORDS FOR [.debug_aranges]:
OFFSET TYPE VALUE
0000000000000006 R_X86_64_32 .debug_info
0000000000000010 R_X86_64_64 .text
RELOCATION RECORDS FOR [.debug_line]:
OFFSET TYPE VALUE
0000000000000029 R_X86_64_64 .text
RELOCATION RECORDS FOR [.eh_frame]:
OFFSET TYPE VALUE
0000000000000020 R_X86_64_PC32 .text
[njust@njust Make_Tutorials]$ readelf -S bar.o
共有 20 个节头,从偏移量 0x678 开始:
节头:
[号] 名称 类型 地址 偏移量
大小 全体大小 旗标 链接 信息 对齐
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000000 00000040
0000000000000027 0000000000000000 AX 0 0 1
[ 2] .rela.text RELA 0000000000000000 00000450
0000000000000030 0000000000000018 I 17 1 8
[ 3] .data PROGBITS 0000000000000000 00000067
0000000000000000 0000000000000000 WA 0 0 1
[ 4] .bss NOBITS 0000000000000000 00000067
0000000000000000 0000000000000000 WA 0 0 1
[ 5] .debug_info PROGBITS 0000000000000000 00000067
000000000000007b 0000000000000000 0 0 1
[ 6] .rela.debug_info RELA 0000000000000000 00000480
00000000000000f0 0000000000000018 I 17 5 8
[ 7] .debug_abbrev PROGBITS 0000000000000000 000000e2
000000000000006f 0000000000000000 0 0 1
[ 8] .debug_aranges PROGBITS 0000000000000000 00000151
0000000000000030 0000000000000000 0 0 1
[ 9] .rela.debug_arang RELA 0000000000000000 00000570
0000000000000030 0000000000000018 I 17 8 8
[10] .debug_line PROGBITS 0000000000000000 00000181
000000000000003b 0000000000000000 0 0 1
[11] .rela.debug_line RELA 0000000000000000 000005a0
0000000000000018 0000000000000018 I 17 10 8
[12] .debug_str PROGBITS 0000000000000000 000001bc
000000000000008f 0000000000000001 MS 0 0 1
[13] .comment PROGBITS 0000000000000000 0000024b
000000000000002e 0000000000000001 MS 0 0 1
[14] .note.GNU-stack PROGBITS 0000000000000000 00000279
0000000000000000 0000000000000000 0 0 1
[15] .eh_frame PROGBITS 0000000000000000 00000280
0000000000000038 0000000000000000 A 0 0 8
[16] .rela.eh_frame RELA 0000000000000000 000005b8
0000000000000018 0000000000000018 I 17 15 8
[17] .symtab SYMTAB 0000000000000000 000002b8
0000000000000180 0000000000000018 18 13 8
[18] .strtab STRTAB 0000000000000000 00000438
0000000000000018 0000000000000000 0 0 1
[19] .shstrtab STRTAB 0000000000000000 000005d0
00000000000000a8 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)
上面的输出结果中,以.rela开头的就是重定位段,上面的.rela.text就存放了需要被重定位的指令信息,如果是需要被重定位的数据则对应的段名为.rela.data。
上面的操作都是针对目标文件bar.o进行的,对目标文件foo.o执行上述命令可以发现它既不存在数据段的重定位表,也不存在代码段的重定位表。这是因为foo.c中的变量shared和函数swap()都已经明确知道了自己的地址,所以不需要重定位。
但是,bar.c文件则不一样,因为bar.c中变量shared和函数swap()都没有定义在当前的文件中,因此编译后产生的目标文件不存在它们的地址信息,所以编译器需要把它们放在重定位表中,等到链接的时候再到其他目标文件中找到对应的符号信息后对其进行重定位。
4.2 符号表(.symtab)
目标文件中的某些部分是在链接阶段需要使用到的"粘合剂",这些部分称为"符号",符号就保存在符号表中。符号表中保存的符号很多,其中最重要的就是定义在本目标文件中并且可以被其它目标文件所引用的符号、在本目标文件中引用的全局符号,这两个符号呈现互补的关系。使用命令readelf -s可以查看符号表的内容。具体的信息如下所示:
Num:符号表数组中的坐标
Value:符号值
Size:符号大小
Type:符号类型
Bind:绑定信息
Name:符号的名称
[njust@njust Make_Tutorials]$ readelf -s bar.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 bar.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: 0000000000000000 0 SECTION LOCAL DEFAULT 7
7: 0000000000000000 0 SECTION LOCAL DEFAULT 8
8: 0000000000000000 0 SECTION LOCAL DEFAULT 10
9: 0000000000000000 0 SECTION LOCAL DEFAULT 12
10: 0000000000000000 0 SECTION LOCAL DEFAULT 14
11: 0000000000000000 0 SECTION LOCAL DEFAULT 15
12: 0000000000000000 0 SECTION LOCAL DEFAULT 13
13: 0000000000000000 39 FUNC GLOBAL DEFAULT 1 main
14: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND shared
15: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND swap
[njust@njust Make_Tutorials]$ readelf -s foo.o
Symbol table '.symtab' contains 15 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS foo.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1
3: 0000000000000000 0 SECTION LOCAL DEFAULT 2
4: 0000000000000000 0 SECTION LOCAL DEFAULT 3
5: 0000000000000000 0 SECTION LOCAL DEFAULT 4
6: 0000000000000000 0 SECTION LOCAL DEFAULT 6
7: 0000000000000000 0 SECTION LOCAL DEFAULT 7
8: 0000000000000000 0 SECTION LOCAL DEFAULT 9
9: 0000000000000000 0 SECTION LOCAL DEFAULT 11
10: 0000000000000000 0 SECTION LOCAL DEFAULT 13
11: 0000000000000000 0 SECTION LOCAL DEFAULT 14
12: 0000000000000000 0 SECTION LOCAL DEFAULT 12
13: 0000000000000000 4 OBJECT GLOBAL DEFAULT 2 shared
14: 0000000000000000 74 FUNC GLOBAL DEFAULT 1 swap
命令nm也可以对符号进行查看,其中D表示该符号是已经初始化的变量,T表示该符号是指令,U表示符号尚未定义;
[njust@njust Make_Tutorials]$ nm bar.o
0000000000000000 T main
U shared
U swap
[njust@njust Make_Tutorials]$ nm foo.o
0000000000000000 D shared
0000000000000000 T swap
[njust@njust Make_Tutorials]$ nm result
0000000000601004 D __bss_start
0000000000601004 D _edata
0000000000601008 D _end
00000000004000e8 T main
0000000000601000 D shared
000000000040010f T swap
通过上面的举例,我们知道重定位表与符号表之间是一种相互合作的关系,链接器首先会根据重定位表找到该目标文件中需要被重定位的符号,然后再根据符号表去其他的目标文件中找到匹配的上的符号。最后,对本目标文件中的符号进行重定位。
5.(静态)链接
现代计算机的内存和磁盘空间已经足够大,同时动态链接对内存和磁盘的节省十分有限,所以我们已经可以忽略动态链接在节省使用空间上的优势。此外,由于没有了对动态链接库的依赖,不需要考虑动态链接库的不同版本,静态链接的文件可以做到链接即可执行,减少了运维和部署上的复杂度,是非常的方便的,在有些新发明的语言(例如 go语言)中链接过程默认已经开始使用静态链接。
5.1 静态链接过程可细分为两步:
(1).扫描所有的目标文件,获取它们每个段的长度、位置和属性,并把每个目标文件中的符号表的符号定义与符号引用集中存放在一个全局符号表中,建立起可执行文件到目标文件的段映射关系;
(2).读取目标文件中的段数据,并解析符号表信息。根据符号表信息进行重定位、调整代码中的地址等操作;
使用命令gcc -c bar.c foo.c -zexecstack -fno-stack-protector -g编译源代码得到目标文件bar.o和foo.o,然后使用命令ld bar.o foo.o -e main -o result链接bar.o和foo.o目标文件得到可执行文件result。
[njust@njust Make_Tutorials]$ ls
bar.c foo.c hello.c hello.o test.c test.i test.s
[njust@njust Make_Tutorials]$ gcc -c bar.c foo.c -zexecstack -fno-stack-protector -g
[njust@njust Make_Tutorials]$ ls
bar.c bar.o foo.c foo.o hello.c hello.o test.c test.i test.s
[njust@njust Make_Tutorials]$ ld bar.o foo.o -e main -o result
[njust@njust Make_Tutorials]$ ls
bar.c bar.o foo.c foo.o hello.c hello.o result test.c test.i test.s
[njust@njust Make_Tutorials]$ cat bar.c
结合重定位表与符号表的知识,我们可以知道链接器最终需要完成的工作有三个:
(1).合并不同目标文件中的同类型段;
(2).对目标文件中的符号引用,在其它的目标文件中找到引用的符号;
(3).对目标文件中的变量进行重定位;
5.2 静态库的链接
操作系统一般都自带有一些库文件,linux中最有名的就是libc静态库,它一般位于/usr/bin/libc.a中,libc.a是一个压缩文件,它当中包含了printf.o、scanf.o、malloc.o、read.o等库文件。当使用标准库中的文件时,链接器会对用户目标文件和标准库文件进行链接,得到最终的可执行文件。
** 6.装载**
完成链接步骤后,得到一个可执行文件,在可执行文件中包含了很多段,但是一旦这些段加载到内存中后,我们就不需要再关心它们到底是什么类型的数据了,只需要关心这些数据在内存中的读写权限。可执行文件被加载到内存中的数据可分为:可读不可写和可读可写。 现代操作系统均采用分页的方式来管理内存,所以操作系统只需要读取可执行文件的文件头,之后建立起可执行文件到虚拟内存的映射关系,不需要真正的将程序载入内存。在程序的运行过程中,CPU发现有些内存页在物理内存中并不存在时,会触发缺页异常,此时CPU将控制权限转交给操作系统的异常处理函数,操作系统负责将此内存页的数据从外存(磁盘)上读取到物理内存中。数据读取完毕之后,操作系统让CPU jmp到触发了缺页异常的那条指令处继续执行,此时指令执行就不会再有缺页异常了。忽略物理内存地址以及缺页异常的影响,一旦操作系统创建进程(fork) 并载入了可执行文件(exec),那么虚拟内存的分布应该如下图所示。可以看到ELF文件中的多个段在内存中被合并为三个段。
上图中,除了三个保存了ELF文件中的数据的段之外,还有其他几部分。如下表所示:
| 名称 | 描述 |
| Kernel Space | 内核空间,用户进程无权访问 |
| Stack | 实现函数调用 |
| Heap | 保存程序运行时产生的全局变量 |
| Memory Map | 磁盘空间到内存的映射 |
7.运行
操作系统jmp到进程的第一条指令并不是main方法,而是别的代码。那些代码负责初始化main方法执行所需要的环境并调用main方法执行,运行这些代码的函数被称为入口函数或者入口点(Entry Point)。一个程序的执行过程如下:
(1).操作系统在创建进程之后,jmp到这个进程的入口函数
(2).入口函数对程序运行环境进行初始化,包括堆、I/O、线程、全局变量的构造等
(3).入口函数在完成初始化之后,调用main函数,开始执行程序的主体
(4).main函数执行完毕之后返回到入口函数,入口函数进行清理工作,最后通过系统调用结束进程