编译过程<1>(编译和链接)

Last updated on 7 months ago

前言

一个c语言文件是怎么转换成 可执行文件的呢? 中间有什么过程呢? 编译器 起着什么作用呢?

如何转换可执行文件?

我们编译的时候要么用 ide 帮我编译程序,要么自己敲命令,我们后者为例,使用gcc 编译一个c文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
hrp@ubuntu:~$ cat hello.c 
#include <stdio.h>
int main() {
printf("你好,世界!\n");
//这是注释!!!
return 0;

}

hrp@ubuntu:~$ gcc hello.c -o hello
hrp@ubuntu:~$ ./hello
你好,世界!
hrp@ubuntu:~$

一个很简单的代码,使用 gcc 编译就可以到 二进制可执行文件,并执行;那这之间发生了什么呢?

整个编译流程可以分为4个部分

1. 预处理 (Preprocessing)

在预处理阶段,预处理器会处理源代码文件,执行诸如宏展开、头文件包含等操作,并生成一个经过预处理的中间文件

比如代码里面用到头文件 stdio.h #include <stdio.h> ,预编译的时候则会找到对应的 这个文件所在的目录; 如果有用到宏定义,会将代码中使用到的宏替换成对应的字符

预编译可以单独执行, gcc -E 即可以看到,原本的c语言文件 预处理后是什么样子的

1
2
3
4
5
6
7
8
9
10
11
12
hrp@ubuntu:~$ cat hello.c 
#include <stdio.h>
#define PI 3.14159

int main() {
int i = PI * PI;
printf("你好,世界!\n");
return 0;
}
hrp@ubuntu:~$
hrp@ubuntu:~$ gcc -E hello.c -o hello.i

  • **查看 hello.i 发现 #include <stdio.h> 已经变成了 绝对路径 “/usr/include/stdio.h” **
  • 删除了define PI的宏 也替换成了3.14159
  • 删除了注释,c源码中的注释也没有
1
2
3
4
5
6
7
8
9
10
11
#....
...
# 868 "/usr/include/stdio.h" 3 4
# 2 "hello.c" 2
# 5 "hello.c"
int main() {
int 10i = 3.14159 * 3.14159;
printf("你好,世界!\n");
return 0;
}

2 编译 (Compiling)

编译器接收预处理后的文件,并将其转换为汇编语言,这个过程也是最为复杂一环,需要编译器的来做一些 词法分析词法分析等相关工作(后面会分析);

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
hrp@ubuntu:~$  gcc -S hello.i -o hello.s && cat hello.s 
.file "hello.c"
.text
.section .rodata
.LC0:
.string "\344\275\240\345\245\275\357\274\214\344\270\226\347\225\214\357\274\201"
.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 $9, -4(%rbp)
leaq .LC0(%rip), %rdi
call puts@PLT
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0"
.section .note.GNU-stack,"",@progbits
hrp@ubuntu:~$

预编译和编译这两个操作可以合并,是用gcc 自带的程序 (cc1)生成 汇编文件 , cc1 会输出编译器执行过程中不同阶段的时间统计:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
hrp@ubuntu:~$ /usr/lib/gcc/x86_64-linux-gnu/7/cc1 hello.c 
main
Analyzing compilation unit
Performing interprocedural optimizations
<*free_lang_data> <visibility> <build_ssa_passes> <opt_local_passes> <targetclone> <free-inline-summary> <whole-program> <inline>Assembling functions:
<materialize-all-clones> <simdclone> main
Execution times (seconds)
phase setup : 0.00 ( 0%) usr 0.00 ( 0%) sys 0.00 ( 0%) wall 1179 kB (68%) ggc
phase parsing : 0.00 ( 0%) usr 0.04 (100%) sys 0.05 (83%) wall 488 kB (28%) ggc
phase opt and generate : 0.00 ( 0%) usr 0.00 ( 0%) sys 0.01 (17%) wall 56 kB ( 3%) ggc
dump files : 0.00 ( 0%) usr 0.00 ( 0%) sys 0.01 (17%) wall 0 kB ( 0%) ggc
lexical analysis : 0.00 ( 0%) usr 0.03 (75%) sys 0.05 (83%) wall 0 kB ( 0%) ggc
parser (global) : 0.00 ( 0%) usr 0.01 (25%) sys 0.00 ( 0%) wall 320 kB (18%) ggc
TOTAL : 0.00 0.04 0.06 1733 kB

3 汇编 (Assembling)

汇编器接收汇编语言代码,并将其转换为机器码,生成目标文件(xxx.o);每条汇编命令都有相对于的机器码,只需逐条翻译即可;

可以使用 as 命令,也可以使用 gcc -c 命令

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
hrp@ubuntu:~$ 
hrp@ubuntu:~$ as hello.s -o hello.o
// 对于 目标文件可以用 objdump 反汇编查看
hrp@ubuntu:~$ objdump -d hello.o
hello.o: file format elf64-x86-64
Disassembly of section .text:

0000000000000000 <main>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 83 ec 10 sub $0x10,%rsp
8: c7 45 fc 09 00 00 00 movl $0x9,-0x4(%rbp)
f: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 16 <main+0x16>
16: e8 00 00 00 00 callq 1b <main+0x1b>
1b: b8 00 00 00 00 mov $0x0,%eax
20: c9 leaveq
21: c3 retq
hrp@ubuntu:~$
hrp@ubuntu:~$ gcc -c hello.s -o hello.o
hrp@ubuntu:~$ objdump -d hello.o

hello.o: file format elf64-x86-64
Disassembly of section .text:

0000000000000000 <main>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 83 ec 10 sub $0x10,%rsp
8: c7 45 fc 09 00 00 00 movl $0x9,-0x4(%rbp)
f: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 16 <main+0x16>
16: e8 00 00 00 00 callq 1b <main+0x1b>
1b: b8 00 00 00 00 mov $0x0,%eax
20: c9 leaveq
21: c3 retq
hrp@ubuntu:~$

4 链接 (Linking)

链接器接收一个或多个目标文件,将它们合并在一起,并解析它们之间的符号引用, 生成可执行文件

1
2
3
4
5
hrp@ubuntu:~$ gcc hello.o -o hello
hrp@ubuntu:~$ ./hello
你好,世界!
hrp@ubuntu:~$

经历了这4个步骤就完成了从c语言文本到可执行文件的转换

编译器具体做了什么?

简单的来说,编译器的工作就是把自然语言转换为机器语言,可以理解就是一个翻译官,而且这个翻译官做了翻译的被背后边很多优化! 翻译的过程可以分为以下六个步骤

词法分析(Lexical Analysis)

词法分析器会跟据不同语言的此法规则,将源代码分解成一个个的词法单元(tokens),如关键字、标识符、运算符;

从左往右逐个字符地扫描源程序,产生一个个的单词符号。也就是说,它会对输入的字符流进行处理,再输出单词流。执行词法分析的程序即词法分析器,或者说扫描器。

语法分析(Syntax Analysis)

语法分析器根据词法分析器得到的词法单元,构建出抽象语法树(Abstract Syntax Tree,AST),表示程序的结构。

语义分析(Semantic Analysis)

语义分析器检查代码的语义正确性,包括类型匹配、变量声明、函数调用等(检查你的代码有咩有按照规则来写)

优化(Optimization)

优化器对生成的抽象语法树进行各种优化操作,如优化一些汇编指令、循环优化等,以提高程序的执行效率

代码生成(Code Generation)

代码生成器将优化后的抽象语法树转换成目标代码,通常是汇编代码或者机器代码。

链接(Linking)

如果编译器生成的是目标文件而不是可执行文件,那么链接器会将多个目标文件链接成一个可执行文件。链接器还负责解析外部函数和变量的引用,将其与其他文件中的定义进行连接。

前面为五个步骤是生成了 目标文件,目标文件只要语法正确都可以生成

比如 hello.c 没有main 函数,但是也可以生成目标文件

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
hrp@ubuntu:~$ cat hello.c 
#include <stdio.h>

void print_hello_1() {
printf("print_hello_1 function!\n");
}

void print_hello_2() {
printf("print_hello_1 function!\n");
}
hrp@ubuntu:~$
hrp@ubuntu:~$ gcc -c hello.c -o hello.o
hrp@ubuntu:~$ objdump -d hello.o
hello.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <print_hello_1>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # b <print_hello_1+0xb>
b: e8 00 00 00 00 callq 10 <print_hello_1+0x10>
10: 90 nop
11: 5d pop %rbp
12: c3 retq
0000000000000013 <print_hello_2>:
13: 55 push %rbp
14: 48 89 e5 mov %rsp,%rbp
17: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 1e <print_hello_2+0xb>
1e: e8 00 00 00 00 callq 23 <print_hello_2+0x10>
23: 90 nop
24: 5d pop %rbp
25: c3 retq

我需要调用 hello.c 里面的 print_hello_1 函数应该怎么做呢?(其实这种场景很常见,一个项目中可能会分为很多功能模块,主函数会调用其他模块的函数)

我在 main.c 文件里面调用了 print_hello_1 函数,这main.c 文件也是可以生成目标文件,可以可能到有 cllaq 跳转指令 ,callq 指令的作用是跳转到目标函数的地址,从反汇编文件看, 是跳转到了 目标地址为 e 的函数,目前只是单独编译 main.c 的文件,还没有涉及到 hello.c 文件;

最后 gcc -c 生成可执行文件,执行结果符合预期,调用了 print_hello_1 , 那问题来了,main.o 中 callq e <main+0xe> ,是怎么跳转到 hello.o 中的print_hello_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
27
28
hrp@ubuntu:~$ cat main.c
#include <stdio.h>
// 声明外部函数
extern void print_hello();

int main() {
print_hello_1();
return 0;
}

hrp@ubuntu:~$ gcc -c main.c -o main.o -w
hrp@ubuntu:~$ objdump -d main.o
main.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: b8 00 00 00 00 mov $0x0,%eax
// 在 callq
9: e8 00 00 00 00 callq e <main+0xe>
e: b8 00 00 00 00 mov $0x0,%eax
13: 5d pop %rbp
14: c3 retq
hrp@ubuntu:~$
hrp@ubuntu:~$ gcc -c main.o hello.o -o main
hrp@ubuntu:~$ ./main
print_hello_1 function!

链接

背景

在汇编语言出现之前,当时没有内存之类的存储介质,程序是以打孔的形式存放在纸带上,计算机在运行程序时会直接从纸带的开头开始读取,每读取到一条指令就执行一条指令。

image-20240311002322477

然而,程序经常需要进行修改。例如,插入几条指令在第四条指令和第五条指令之间,这会导致第五条指令后面的指令位置发生变化,整个程序的地址都需要重新计算。工程师必须重新计算并修改纸带上的指令位置,这个过程称为重定位。 如果程序很大,涉及到多个纸带,那么即使是一个简单的修改也会导致大量的工作量。

image-20240311002259982

后来人们发明了汇编语言,它更易读,开发人员不再需要记住每个机器码。

此外,汇编语言还允许使用符号来表示地址。例如, 可以用符号“foo”代表地址0x001,这样可以通过写”jump foo” 来跳转到地址0x0001的位置,有了这个语法糖之后,执行函数,只要知道函数代码的地址,直接用 jump 跳转过去在执行,原本在第五条指令前插入指令,会影响后面的代码,假如第五条指令后的内容,使用 jump XXX 的方式来执行,只要 foo 地址不变化,那我无论在前面加入多少条都不会影响后面的寻址结果,foo 只是符号,就算 foo 目标地址发生了改动,只需要 把foo 指令修正到新的地址即可,这样就拜托了手动跳转地址的工作。

image-20240311005739608

有了汇编语言之后,编程变得更加容易;一个项目可以被切割成许多模块,这些模块之间既相互依赖又相互独立。那么,模块之间如何组合?如何相互调用?模块间的变量如何访问?这时候链接器就发挥了重要作用,接下来我们将介绍它的功能和作用。

链接的定义

链接(linking)是将各种代码和数据部分收集起来并组合成为一个单一文件的过程,这个文件可被加载(或被拷贝)到存储器并执行; 此外外 链接可以执行于编译时(compile time),也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载器(loader)加载到存储器并执行时;甚至执行于运行时(run time),这也就是校招面试经常问到的静态编译和动态编译;

image-20240405092321892

链接 解决的问题是什么呢?

  • 解决了符号引用问题

    在编写程序时,经常会引用其他文件中定义的函数、变量或者常量。链接的一个重要作用是将这些引用与其定义进行关联,以确保程序能够正确地调用这些函数、访问这些变量和常量。

  • 合并多个目标文件

    当程序由多个源文件编译而成时,每个源文件都会生成一个目标文件。链接器负责将这些目标文件合并成一个可执行文件或者共享库。一些大型项目几十万行代码,总不能每个功能都写在同一个文件中吧?

  • 解决外部依赖问题

    在编写程序时,可能会依赖于第三方库或者系统提供的标准库。链接器会将这些外部依赖项与程序的目标文件进行链接,以确保程序能够正确地调用这些外部函数和访问这些外部变量。

  • 优化代码大小和性能

    链接器还可以对目标文件进行优化,例如去除未使用的代码、合并相似的代码块等,以减小程序的体积(动态编译)并提高执行效率。

如何链接的(先简单介绍下)

链接的本质是拼接各个模块;比如 main 函数中调用了 hello.c 中的hello() 函数, main 函数必须知道 hello() 的地址,而模块是各自编译的,main 并不知 hello函数地址,于是 就先随便置一个地址,等链接的时候再替换,举个例子:

先直接将main单独 编译成 obj 文件,反汇编,此时 callq 调用的函数只是个 偏移量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
hrp@ubuntu:~$ cat main.c 
// main.c
#include "hello.h"
int main() {
hello();
return 0;
}

hrp@ubuntu:~$ gcc -c main.c
hrp@ubuntu:~$ objdump -d main.o
0000000000000000 <main>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: b8 00 00 00 00 mov $0x0,%eax
9: e8 00 00 00 00 callq e <main+0xe>
e: b8 00 00 00 00 mov $0x0,%eax
13: 5d pop %rbp
14: c3 retq
hrp@ubuntu:~$

生成 hello.o 文件

1
2
3
4
5
6
7
8
9
10
11
hrp@ubuntu:~$ gcc -c hello.c 

hrp@ubuntu:~$ objdump -d hello.o
0000000000000000 <hello>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # b <hello+0xb>
b: e8 00 00 00 00 callq 10 < +0x10>
10: 90 nop
hrp@ubuntu:~$

main.o 和 hello.o 链接起来(gcc 背后会调用ld 命令 );反汇编可以可能到是,两个 obj 文件的函数拼接成一个文件,并且更新了 main中 callq 的地址,此时的地址才是真正调用的地址,这个过程也叫重定位;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
hrp@ubuntu:~$ gcc hello.o main.o -o main
hrp@ubuntu:~$ ./main
ddHello, world!

hrp@ubuntu:~$ objdump -d main
.....
000000000000068a <hello>:
68a: 55 push %rbp
68b: 48 89 e5 mov %rsp,%rbp
68e: 48 8d 3d bf 00 00 00 lea 0xbf(%rip),%rdi # 754 <_IO_stdin_used+0x4>
695: e8 b6 fe ff ff callq 550 <puts@plt>
69a: 90 nop
69b: 5d pop %rbp
69c: c3 retq

000000000000069d <main>:
69d: 55 push %rbp
69e: 48 89 e5 mov %rsp,%rbp
6a1: 48 8d 3d ba 00 00 00 lea 0xba(%rip),%rdi # 762 <_IO_stdin_used+0x12>
6b7: e8 ce ff ff ff callq 68a <hello>
6bc: b8 00 00 00 00 mov $0x0,%eax
6c1: 5d pop %rbp
6c2: c3 retq