编译过程<1>(编译和链接)
前言
一个c语言文件是怎么转换成 可执行文件的呢? 中间有什么过程呢? 编译器 起着什么作用呢?
如何转换可执行文件?
我们编译的时候要么用 ide 帮我编译程序,要么自己敲命令,我们后者为例,使用gcc 编译一个c文件
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) ") 1\. 预处理 (Preprocessing)
在预处理阶段,预处理器会处理源代码文件,执行诸如宏展开、头文件包含等操作,并生成一个经过预处理的中间文件
比如代码里面用到头文件 stdio.h #include <stdio.h> ,预编译的时候则会找到对应的 这个文件所在的目录; 如果有用到宏定义,会将代码中使用到的宏替换成对应的字符
预编译可以单独执行, gcc -E 即可以看到,原本的c语言文件 预处理后是什么样子的
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源码中的注释也没有
#....
...
# 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) ") 2 编译 (Compiling)
编译器接收预处理后的文件,并将其转换为汇编语言,这个过程也是最为复杂一环,需要编译器的来做一些 词法分析, 词法分析 等相关工作(后面会分析);
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 会输出编译器执行过程中不同阶段的时间统计:
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) ") 3 汇编 (Assembling)
汇编器接收汇编语言代码,并将其转换为机器码,生成目标文件(xxx.o);每条汇编命令都有相对于的机器码,只需逐条翻译即可;
可以使用 as 命令,也可以使用 gcc -c 命令
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) ") 4 链接 (Linking)
链接器接收一个或多个目标文件,将它们合并在一起,并解析它们之间的符号引用, 生成可执行文件
hrp@ubuntu:~$ gcc hello.o -o hello
hrp@ubuntu:~$ ./hello
你好,世界!
hrp@ubuntu:~$
经历了这4个步骤就完成了从c语言文本到可执行文件的转换
编译器具体做了什么?
简单的来说,编译器的工作就是把自然语言转换为机器语言,可以理解就是一个 翻译官,而且这个翻译官做了翻译的被背后边很多优化! 翻译的过程可以分为以下六个步骤
词法分析(Lexical Analysis) 词法分析(Lexical Analysis)
词法分析器会跟据不同语言的此法规则,将源代码分解成一个个的词法单元(tokens),如关键字、标识符、运算符;
从左往右逐个字符地扫描源程序,产生一个个的单词符号。也就是说,它会对输入的字符流进行处理,再输出单词流。执行词法分析的程序即 词法分析器,或者说扫描器。
语法分析(Syntax Analysis) 语法分析(Syntax Analysis)
语法分析器根据词法分析器得到的词法单元,构建出抽象语法树(Abstract Syntax Tree,AST),表示程序的结构。
语义分析(Semantic Analysis) 语义分析(Semantic Analysis)
语义分析器检查代码的语义正确性,包括类型匹配、变量声明、函数调用等(检查你的代码有咩有按照规则来写)
优化(Optimization) 优化(Optimization)
优化器对生成的抽象语法树进行各种优化操作,如优化一些汇编指令、循环优化等,以提高程序的执行效率
代码生成(Code Generation) 代码生成(Code Generation)
代码生成器将优化后的抽象语法树转换成目标代码,通常是汇编代码或者机器代码。
链接(Linking) 链接(Linking)
如果编译器生成的是目标文件而不是可执行文件,那么链接器会将多个目标文件链接成一个可执行文件。链接器还负责解析外部函数和变量的引用,将其与其他文件中的定义进行连接。
前面为五个步骤是生成了 目标文件,目标文件只要语法正确都可以生成
比如 hello.c 没有main 函数,但是也可以生成目标文件
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呢?
为了解决这个问题,编译器最后会把 多个模块根据一定的规则,以及主函数需要调用哪些模块的代码,将其组合到一起,最后形成可执行文件,这做法也就是 链接
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!
链接
背景
在汇编语言出现之前,当时没有内存之类的存储介质,程序是以打孔的形式存放在纸带上,计算机在运行程序时会直接从纸带的开头开始读取,每读取到一条指令就执行一条指令。
然而,程序经常需要进行修改。例如,插入几条指令在第四条指令和第五条指令之间,这会导致第五条指令后面的指令位置发生变化,整个程序的地址都需要重新计算。 工程师必须重新计算并修改纸带上的指令位置,这个过程称为重定位。 如果程序很大,涉及到多个纸带,那么即使是一个简单的修改也会导致大量的工作量。
后来人们发明了汇编语言,它更易读,开发人员不再需要记住每个机器码。
此外,汇编语言还允许使用符号来表示地址。例如, 可以用符号“foo”代表地址0x001,这样可以通过写” jump foo” 来跳转到地址0x0001的位置,有了这个 语法糖 之后,执行函数,只要知道函数代码的地址,直接用 jump 跳转过去在执行,原本在第五条指令前插入指令,会影响后面的代码,假如第五条指令后的内容,使用 jump XXX 的方式来执行,只要 foo 地址不变化,那我无论在前面加入多少条都不会影响后面的寻址结果,foo 只是符号,就算 foo 目标地址发生了改动,只需要 把foo 指令修正到新的地址即可,这样就拜托了手动跳转地址的工作。
有了汇编语言之后,编程变得更加容易;一个项目可以被切割成许多模块,这些模块之间既相互依赖又相互独立。那么,模块之间如何组合?如何相互调用?模块间的变量如何访问?这时候链接器就发挥了重要作用,接下来我们将介绍它的功能和作用。
链接的定义
链接(linking)是将各种代码和数据部分收集起来并组合成为一个单一文件的过程,这个文件可被加载(或被拷贝)到存储器并执行; 此外外 链接可以执行于编译时(compile time),也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载器(loader)加载到存储器并执行时;甚至执行于运行时(run time),这也就是校招面试经常问到的静态编译和动态编译;
链接 解决的问题是什么呢?
- 解决了符号引用问题:
在编写程序时,经常会引用其他文件中定义的函数、变量或者常量。链接的一个重要作用是将这些引用与其定义进行关联,以确保程序能够正确地调用这些函数、访问这些变量和常量。
- 合并多个目标文件:
当程序由多个源文件编译而成时,每个源文件都会生成一个目标文件。链接器负责将这些目标文件合并成一个可执行文件或者共享库。一些大型项目几十万行代码,总不能每个功能都写在同一个文件中吧?
- 解决外部依赖问题:
在编写程序时,可能会依赖于第三方库或者系统提供的标准库。链接器会将这些外部依赖项与程序的目标文件进行链接,以确保程序能够正确地调用这些外部函数和访问这些外部变量。
- 优化代码大小和性能:
链接器还可以对目标文件进行优化,例如去除未使用的代码、合并相似的代码块等,以减小程序的体积(动态编译)并提高执行效率。
如何链接的(先简单介绍下)
链接的本质是拼接各个模块;比如 main 函数中调用了 hello.c 中的hello() 函数, main 函数必须知道 hello() 的地址,而模块是各自编译的,main 并不知 hello函数地址,于是 就先随便置一个地址,等链接的时候再替换,举个例子:
先直接将main单独 编译成 obj 文件,反汇编,此时 callq 调用的函数只是个 偏移量
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 文件
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 的地址,此时的地址才是真正调用的地址,这个过程也叫重定位;
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