我们知道,从实验代码的撰写构建到最终变成一个完整的可执行文件之间有一个编译的过程,对于初学者而言,这个步骤往往都是直接依靠代码环境平台间接完成的,而我们作为代码的开发者却没能接触到这个过程中更为核心的问题。
要想了解清楚这个编译过程,我们就要先搞清楚常说的“编译”到底包含多少具体步骤。以下转载不周山笔记对于此过程的详细介绍:
预处理器:将 C 语言代码(da.c)转化成 da.i 文件(gcc –E),对应于预处理命令 cpp
编译器:C 语言代码(da.c, wang.c)经过编译器的处理(gcc -0g -S)成为汇编代码(da.s, wang.s)
汇编器:汇编代码(da.s, wang.s)经过汇编器的处理(gcc 或 as)成为对象程序(da.o, wang.o)
链接器:对象程序(da.o, wang.o)以及所需静态库(lib.a)经过链接器的处理(gcc 或 ld)最终成为计算机可执行的程序
加载器:将可执行程序加载到内存并进行执行,loader 和 ld-linux.so
根据以上对于各种处理器对于代码程序的处理,这个过程已经变得很清晰,在此我用自己的理解简单地对这些处理器展开简单的描述。
以main.c以及sum.c为例,代码块如下:
/* main.c */
/* $begin main */
int sum(int *a, int n);
int array[2] = {1, 2};
int main()
{
int val = sum(array, 2);
return val;
}
/* $end main */
/* sum.c */
/* $begin sum */
int sum(int *a, int n)
{
int i, s = 0;
for (i = 0; i < n; i++) {
s += a[i];
}
return s;
}
/* $end sum */
从文件类型上看,这个处理器将.c文件转化成.i文件。内容上,它是将文件内部所定义的宏定义/头文件引用/特殊符号/条件编译等指令(也就是一般.c文件开头的#代码部分)进行相关处理:
宏定义指令,如 #define a b 这种伪指令,预编译所要做的是将程序中的所有 a 用 b 替换,但作为字符串常量的 a 则不被替换。还有 #undef,则将取消对某个宏的定义,使以后该串的出现不再被替换
条件编译指令,如 #ifdef, #ifndef, #else, #elif, #endif等。这些伪指令的引入使得程序员可以通过定义不同的宏来决定编译程序对哪些代码进行处理。预编译程序将根据有关的文件,将那些不必要的代码过滤掉
头文件包含指令,如 #include “FileName” 。该指令将头文件中的定义统统都加入到它所产生的输出文件中,以供编译程序对之进行处理
特殊符号,预编译程序可以识别一些特殊的符号。例如在源程序中出现的 LINE 标识将被解释为当前行号(十进制数),FILE 则被解释为当前被编译的C源程序的名称。预编译程序对于在源程序中出现的这些串将用合适的值进行替换
进行命令操作之后代码如下(gcc -E main.c -o main.i 命令语句能够生成可见的main.i文件进而查看):
zhaoxiaoan@zhaoxiaoan:~/桌面/Computer_Systems/chap7_code$ gcc -E main.c
# 1 "main.c"
# 1 ""
# 1 ""
# 31 ""
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "" 2
# 1 "main.c"
int sum(int *a, int n);
int array[2] = {1, 2};
int main()
{
int val = sum(array, 2);
return val;
}
从这个main.c gcc 成 .i文件后,因为源代码并没有头文件的缘故,所以并没有引入其它代码或者说替换宏定义出现的场所。但是当我在这个文件最前面加上#includ
这个处理器主要是将程序员所撰写的源代码进一步向机器代码转变,让机器能够更好地理解代码内容,而最终转换成汇编语言代码。运行gcc -S main.i -o main.s之后打开main.s文件可见如下汇编语言:
.file "main.c"
.text
.globl array
.data
.align 8
.type array, @object
.size array, 8
array:
.long 1
.long 2
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
movl $2, %esi
leaq array(%rip), %rdi
call sum@PLT
movl %eax, -4(%rbp)
movl -4(%rbp), %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 7.4.0-1ubuntu1~18.04.1) 7.4.0"
.section .note.GNU-stack,"",@progbits
源代码变成汇编代码之后,要想最终变成程序文件,还需要进一步优化并且真正转化为机器语言,成为对象程序,也就是二进制的目标文件。在其他博客上看到这样一段说明:
只编译不链接形成.o文件。里面包含了对各个函数的入口标记,描述,当程序要执行时还需要链接(link).链接就是把多个.o文件链成一个可执行文件。如 GCC 编译器就可以指定 -c选项进行输出。打开是乱码。
成为对象程序之后可以再次反编译看到优化之后的汇编代码
链接部分要做的事情就是把多个.o文件链成一个可执行文件。
zhaoxiaoan@zhaoxiaoan:~/桌面/Computer_Systems/chap7_code$ gcc main.o sum.o -o prog
zhaoxiaoan@zhaoxiaoan:~/桌面/Computer_Systems/chap7_code$ ./prog
可执行程序加载到内存并进行执行。
除此之外,还可以一步到位完成整个编译过程:
zhaoxiaoan@zhaoxiaoan:~/桌面/Computer_Systems/chap7_code$ gcc -Og -o prog main.c sum.c
zhaoxiaoan@zhaoxiaoan:~/桌面/Computer_Systems/chap7_code$ ./prog
之后反汇编(objdump -dx prog)可以看到这样一段汇编代码:
00000000000005fa :
5fa: 48 83 ec 08 sub $0x8,%rsp
5fe: be 02 00 00 00 mov $0x2,%esi
603: 48 8d 3d 06 0a 20 00 lea 0x200a06(%rip),%rdi # 201010
60a: e8 05 00 00 00 callq 614
60f: 48 83 c4 08 add $0x8,%rsp
613: c3 retq
0000000000000614 :
614: b8 00 00 00 00 mov $0x0,%eax
619: ba 00 00 00 00 mov $0x0,%edx
61e: eb 09 jmp 629
620: 48 63 ca movslq %edx,%rcx
623: 03 04 8f add (%rdi,%rcx,4),%eax
626: 83 c2 01 add $0x1,%edx
629: 39 f2 cmp %esi,%edx
62b: 7c f3 jl 620
62d: f3 c3 repz retq
62f: 90 nop
重点讲述链接过程:
链接分为符号解析和重定位两个部分:
第一步:符号解析 Symbol resolution
我们在代码中会声明变量及函数,之后会调用变量及函数,所有的符号声明都会被保存在符号表(symbol table)中,而符号表会保存在由汇编器生成的 object 文件中(也就是 .o 文件)。符号表实际上是一个结构体数组,每一个元素包含名称、大小和符号的位置。
在 symbol resolution 阶段,链接器会给每个符号应用一个唯一的符号定义,用作寻找对应符号的标志。
第二步:重定位 Relocation
这一步所做的工作是把原先分开的代码和数据片段汇总成一个文件,会把原先在 .o 文件中的相对位置转换成在可执行程序的绝对位置,并且据此更新对应的引用符号(才能找到新的位置)
首先,符号(包括全局变量以及函数,局部变量存储在栈中而不在符号表中)可以分为以下三种:
使用nm命令查看prog文件符号表如下:
zhaoxiaoan@zhaoxiaoan:~/桌面/Computer_Systems/chap7_code$ nm prog
0000000000201010 D array
0000000000201018 B __bss_start
0000000000201018 b completed.7697
w __cxa_finalize@@GLIBC_2.2.5
0000000000201000 D __data_start
0000000000201000 W data_start
0000000000000520 t deregister_tm_clones
00000000000005b0 t __do_global_dtors_aux
0000000000200df8 t __do_global_dtors_aux_fini_array_entry
0000000000201008 D __dso_handle
0000000000200e00 d _DYNAMIC
0000000000201018 D _edata
0000000000201020 B _end
00000000000006d4 T _fini
00000000000005f0 t frame_dummy
0000000000200df0 t __frame_dummy_init_array_entry
000000000000084c r __FRAME_END__
0000000000200fc0 d _GLOBAL_OFFSET_TABLE_
w __gmon_start__
00000000000006e4 r __GNU_EH_FRAME_HDR
00000000000004b8 T _init
0000000000200df8 t __init_array_end
0000000000200df0 t __init_array_start
00000000000006e0 R _IO_stdin_used
w _ITM_deregisterTMCloneTable
w _ITM_registerTMCloneTable
00000000000006d0 T __libc_csu_fini
0000000000000660 T __libc_csu_init
U __libc_start_main@@GLIBC_2.2.5
00000000000005fa T main
0000000000000560 t register_tm_clones
00000000000004f0 T _start
000000000000061b T sum
0000000000201018 D __TMC_END__
其中第一列是当前符号的实际地址(在执行到不同符号时按照该地址查询其数据值),第二列是当前符号的类型(需要按照符号表对照类型),第三列是当前符号的名称。
使用readelf命令符-s参数查看具体符号表:
zhaoxiaoan@zhaoxiaoan:~/桌面/Computer_Systems/chap7_code$ readelf -s main.o
Symbol table '.symtab' contains 12 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS main.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 6
6: 0000000000000000 0 SECTION LOCAL DEFAULT 7
7: 0000000000000000 0 SECTION LOCAL DEFAULT 5
8: 0000000000000000 8 OBJECT GLOBAL DEFAULT 3 array
9: 0000000000000000 33 FUNC GLOBAL DEFAULT 1 main
10: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _GLOBAL_OFFSET_TABLE_
11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND sum
这个表看来就比较清晰,Name是符号名称,Ndx表示符号所在节以及是否在本文件定义,Bind表示是全局符号还是局部符号等,Type则表明符号类型,Size是符号大小,Value表示符号存储地址。
在此引入全局符号中强符号和弱符号的概念:
并且有以下规则需要遵守:
根据不周山笔记,我们做以下实验进行讨论:
/* main.c */
/* $begin main */
int sum(int *a, int n);
int array[2] = {1, 2};
int main()
{
int val = sum(array, 2);
return val;
}
/* $end main */
就main.c文件源代码进行讨论,我们根据强弱符号定义可以很清楚地判定:
全局变量array数组已初始化定义,所以是强符号。
val是局部变量,并不会被链接器解析。
/* sum.c */
/* $begin sum */
int sum(int *a, int n)
{
int i, s = 0;
for (i = 0; i < n; i++) {
s += a[i];
}
return s;
}
/* $end sum */
sum.c文件中:
i和s以及数组a都只是局部变量,不会被链接器解析。
因为当两个名称相同且同时初始化的全局变量在被链接的不同目标文件中被同时定义时,链接就会出错。而同时出现几个名称相同的弱符号定义时,数据上可能会出现问题,例如分别定义了double型和int型的同一个名称的弱符号,内存数据极可能造成数据错误。所以一般不推荐使用全局变量。
目标文件总共分为以下三种:
可重定位目标文件 Relocatable object file (.o file)
每个 .o 文件都是由对应的 .c 文件通过编译器和汇编器生成,包含代码和数据,可以与其他可重定位目标文件合并创建一个可执行或共享的目标文件
可执行目标文件 Executable object file (a.out file)
由链接器生成,可以直接通过加载器加载到内存中充当进程执行的文件,包含代码和数据
共享目标文件 Shared object file (.so file)
在 windows 中被称为 Dynamic Link Libraries(DLLs),是类特殊的可重定位目标文件,可以在链接(静态共享库)时加入目标文件或加载时或运行时(动态共享库)被动态的加载到内存并执行
其中,可重定位目标文件也就是汇编之后的二进制.o文件,而链接就是将各种可重定位目标文件链接成可执行目标文件或者共享目标文件。
这里我们只讨论链接生成可执行目标文件的过程以及其中关于ELF的一些区别。
那么,链接是如何将可重定位目标文件转化为可执行目标文件的呢?这里我们就要先知道可重定位目标文件目标文件和可执行目标文件的一些区别。
我们看一下实际操作结果:
zhaoxiaoan@zhaoxiaoan:~/桌面/Computer_Systems/chap7_code$ objdump -s -d main.o > main.o.txt
将main.o可重定位目标文件反汇编之后得到的汇编代码如下:
Disassembly of section .text:
0000000000000000 :
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 83 ec 10 sub $0x10,%rsp
8: be 02 00 00 00 mov $0x2,%esi
d: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 14
14: e8 00 00 00 00 callq 19
19: 89 45 fc mov %eax,-0x4(%rbp)
1c: 8b 45 fc mov -0x4(%rbp),%eax
1f: c9 leaveq
20: c3 retq
上面有一段prog文件的反汇编代码,二者相互比对即可简单说明这个虚拟内存的问题。
所以,在一定程度上我们可以将二者理解成未加工的汽车零件和加工完成可以上路的汽车。
二者的区别当然不仅于此,更重要的区别在于ELF上。
ELF文件在流程上,是从ELF头开始,去向它所指向的节头表,而节头表中则存储着ELF中所有节的名字以及相对于ELF头的相对偏移地址,进而执行所有的节部分。
ELF头中则存放着一些基本信息文件,实际操作及所读取信息如下:
未链接之前的main.o可重定位目标文件:
zhaoxiaoan@zhaoxiaoan:~/桌面/Computer_Systems/chap7_code$ readelf -h main.o
ELF 头:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
类别: ELF64
数据: 2 补码,小端序 (little endian)
版本: 1 (current)
OS/ABI: UNIX - System V
ABI 版本: 0
类型: REL (可重定位文件)
系统架构: Advanced Micro Devices X86-64
版本: 0x1
入口点地址: 0x0
程序头起点: 0 (bytes into file)
Start of section headers: 720 (bytes into file)
标志: 0x0
本头的大小: 64 (字节)
程序头大小: 0 (字节)
Number of program headers: 0
节头大小: 64 (字节)
节头数量: 12
字符串表索引节头: 11
将main.o和sum.o文件链接生成prog可执行目标文件:
zhaoxiaoan@zhaoxiaoan:~/桌面/Computer_Systems/chap7_code$ readelf -h prog
ELF 头:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
类别: ELF64
数据: 2 补码,小端序 (little endian)
版本: 1 (current)
OS/ABI: UNIX - System V
ABI 版本: 0
类型: DYN (共享目标文件)
系统架构: Advanced Micro Devices X86-64
版本: 0x1
入口点地址: 0x4f0
程序头起点: 64 (bytes into file)
Start of section headers: 6472 (bytes into file)
标志: 0x0
本头的大小: 64 (字节)
程序头大小: 56 (字节)
Number of program headers: 9
节头大小: 64 (字节)
节头数量: 28
字符串表索引节头: 27
其余还有一些区别。如在可执行目标文件中,ELF头中e_entry字段会给出执行入口地址;其中程序头表还会描述ELF节是如何映射到具体存储段。而在可重定位目标文件e_entry则为0,它的使命就是链接而不可被直接执行;它并不会有此映射描述。
链接部分到此打止。