前言
一直好奇程序的编译过程到底做了哪些工作,后来学会在Ubuntu上使用gcc
编译程序,知道了生成可执行文件需要分为预编译、编译、汇编和链接4个步骤,逐渐了解了其中的细节,但是过一段时间之后总是记不太清楚了,所以总结一下增强记忆,同时方便日后查找使用。
编译方式
一步到位
使用gcc
命令可以一步将main.c
源文件编译生成最终的可执行文件main_direct
1 | gcc main.c –o main_direct |
分步执行
gcc
的编译流程通常认为包含以下四个步骤,实际上就是将上面的命令分成4步执行,这也是gcc
命令实际的操作流程,生成的可执行文件main
与上面单条命令生成的可执行文件main_direct
是一模一样的
- 预处理,生成预编译文件(.i文件):
gcc –E main.c –o main.i
- 编译,生成汇编代码(.s文件):
gcc –S main.i –o main.s
- 汇编,生成目标文件(.o文件):
gcc –c main.s –o main.o
- 链接,生成可执行文件(executable文件):
gcc main.o –o main
编译流程
这里的编译是指将源文件(.c)生成可执行文件(executable)的这个完整的过程,而不是上面提到的四个步骤中的第二步,为了弄清楚编译过程究竟都做了哪些工作,接下来我们可以分步骤来看一下gcc
编译.c
文件的过程,了解了每一步的内容,也就明白了整个编译流程,先给出源文件 mian.c
的源代码。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// calc sum
int sum(int a, int b)
{
return a + b;
}
int main()
{
int b = 1;
int c = sum(A, b);
printf("sum = %d\n", c);
return 0;
}
预处理
预处理又叫预编译,是完整编译过程的第一个阶段,在正式的编译阶段之前进行。预处理阶段将根据已放置在文件中的预处理指令来修改源文件的内容,对于C
语言来说预处理的可执行程序叫做 cpp
,全称为C Pre-Processor
(C预处理器),是一个与 C
编译器独立的小程序,预编译器并不理解 C
语言语法,它仅是在程序源文件被编译之前,实现文本替换的功能。简单来说,预处理就是将源代码中的预处理指令根据语义预先处理,并且进行一下清理、标记工作,然后将这些代码输出到一个 .i
文件中等待进一步操作。
一般地,C/C++
程序的源代码中包含以 #
开头的各种编译指令,被称为预处理指令,其不属于 C/C++
语言的语法,但在一定意义上可以说预处理扩展了 C/C++
。根据ANSI C 定义,预处理指令主要包括:文件包含、宏定义、条件编译和特殊控制等4大类。
预处理阶段主要做以下几个方面的工作:
文件包含:
#include
是C
程序设计中最常用的预处理指令,格式有尖括号#include <xxx.h>
和双引号#include "xxx.h"
之分,分别表示从系统目录下查找和优先在当前目录查找,例如常用的#include <stdio.h>
指令,就表示使用stdio.h
文件中的全部内容,替换该行指令。添加行号和文件名标识: 比如在文件
main.i
中就有类似# 2 "main.c" 2
的内容,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号。宏定义展开及处理: 预处理阶段会将使用
#define A 100
定义的常量符号进行等价替换,文中所有的宏定义符号A
都会被替换成100
,还会将一些内置的宏展开,比如用于显示文件全路径的__FILE__
,另外还可以使用#undef
删除已经存在的宏,比如#undef A
就是删除之前定义的宏符号A
。条件编译处理: 如
#ifdef
,#ifndef
,#else
,#elif
,#endif
等,这些条件编译指令的引入使得程序员可以通过定义不同的宏来决定编译程序对哪些代码进行处理,将那些不必要的代码过滤掉,防止文件重复包含等。清理注释内容:
// xxx
和/*xxx*/
所产生的的注释内容在预处理阶段都会被删除,因为这些注释对于编写程序的人来说是用来记录和梳理逻辑代码的,但是对编译程序来说几乎没有任何用处,所以会被删除,观察main.i
文件也会发现之前的注释都被删掉了。特殊控制处理: 保留编译器需要使用
#pragma
编译器指令,另外还有用于输出指定的错误信息,通常来调试程序的#error
指令。
编译
编译过程是整个程序构建的核心部分,也是最复杂的部分之一,其工作就是把预处理完生成的 .i
文件进行一系列的词法分析、语法分析、语义分析以及优化后产生相应的汇编代码文件,也就是 .s
文件,这个过程调用的处理程序一般是 cc
或者 ccl
。汇编语言是非常有用的,因为它给不同高级语言的不同编译器提供了可选择的通用的输出语言,比如 C
和 Fortran
编译产生的输出文件都是汇编语言。
词法分析: 主要是使用基于有线状态机的Scanner分析出token,可以通过一个叫做 lex 的可执行程序来完成词法扫描,按照描述好的词法规则将预处理后的源代码分割成一个个记号,同时完成将标识符存放到符号表中,将数字、字符串常量存放到文字表等工作,以备后面的步骤使用。
语法分析: 对有词法分析产生的token采用上下文无关文法进行分析,从而产生语法树,此过程可以通过一个叫做
yacc
的可执行程序完成,它可以根据用户给定的语法规则对输入的记号序列进行解析,从而构建一棵语法树,如果在解析过程中出现了表达式不合法,比如括号不匹配,表达式中缺少操作符、操作数等情况,编译器就会报出语法分析阶段的错误。语义分析: 此过程由语义分析器完成,编译器
cc
所能分析的语义都是静态语义,是指在编译期间可以确定的语义,通常包括声明和类型的匹配,类型的转换等。比如将一个浮点型的表达式赋值给一个整型的表达式时,语义分析程序会发现这个类型不匹配,编译器将会报错。而动态语义一般指在运行期出现的语义相关问题,比如将0作为除数是一个运行期语义错误。语义分析过程会将所有表达式标注类型,对于需要隐式转换的语法添加转换节点,同时对符号表里的符号类型做相应的更新。代码优化: 此过程会通过源代码优化器会在源代码级别进行优化,针对于编译期间就可以确定的表达式(例如:100+1)给出确定的值,以达到优化的目的,此外还包括根据机器硬件执行指令的特点对指令进行一些调整使目标代码比较短,执行效率更高等操作。
汇编
汇编过程是整个程序构建中的第三步,是将编译产生的汇编代码文件转变成可执行的机器指令。相对来说比较简单,每个汇编语句都有相对应的机器指令,只需根据汇编代码语法和机器指令的对照表翻译过来就可以了,最终生成目标文件,也就是 .o
文件,完成此工作的可执行程序通常是 as
。目标文件中所存放的也就是与源程序等效的目标的机器语言代码,通常至少包含代码段和数据段两个段,并且还要包含未解决符号表,导出符号表和地址重定向表等3个表。汇编过程会将extern
声明的变量置入未解决符号表,将static声明的全局变量不置入未解决符号表,也不置入导出符号表,无法被其他目标文件使用,然后将普通变量及函数置入导出符号表,供其他目标文件使用。
代码段: 包含主要是程序的指令。该段一般是可读和可执行的,但一般却不可写。
数据段: 主要存放程序中要用到的各种全局变量或静态的数据,一般数据段都是可读,可写,可执行的。
未解决符号表: 列出了在本目标文件里有引用但不存在定义的符号及其出现的地址。
导出符号表: 列出了本目标文件里具有定义,并且可以提供给其他目标文件使用的符号及其在出现的地址。
地址重定向表: 列出了本目标文件里所有对自身地址的引用记录。
链接
链接过程是程序构建过程的最后一步,通常调用可执行程序 ld
来完成,可以简单的理解为将目标文件和库文件打包组装成可执行文件的过程,其主要内容就是把各个模块之间相互引用的部分都处理好,将一个文件中引用的符号同该符号在另外一个文件中的定义连接起来,使得各个模块之间能够正确的衔接,成为一个能够被操作系统装入执行的统一整体。
虽然汇编之后得到的文件已经是机器指令文件,但是依然无法立即执行,其中可能还有尚未解决的问题,比如源代码 main.c
中的 printf
这个函数名就无法解析,需要链接过程与对应的库文件对接,完成的重定位,将函数符号对应的地址替换成正确的地址。前面提到的库文件其实就是一组目标文件的包,它们是一些最常用的代码编译成目标文件后打成的包。比如 printf
的头文件是 stdio.h
,而它的实现代码是放在动态库 libc.so.6
中的,链接的时候就要引用到这个库文件。
从原理上讲,连接的的工作就是把一些指令对其他符号地址的引用加以修正,主要包括了地址和空间分配、符号决议和重定位等步骤,根据开发人员指定的链接库函数的方式不同,链接过程可分为静态链接和动态链接两种,链接静态的库,需要拷贝到一起,链接动态的库需要登记一下库的信息。
静态链接: 函数的代码将从其所在地静态链接库中被拷贝到最终的可执行程序中。这样该程序在被执行时,代码将被装入到该进程的虚拟地址空间中,静态链接库实际上是一个目标文件的集合,其中的每个文件含有库中的一个或者一组相关函数的代码,最终生成的可执行文件较大。
动态链接: 函数的代码被放到动态链接库或共享对象的某个目标文件中。链接处理时只是在最终的可执行程序中记录下共享对象的名字以及其它少量的登记信息。在这样该程序在被执行时,动态链接库的全部内容将被映射到运行时相应进程的虚地址空间,根据可执行程序中记录的信息找到相应的函数代码。这种连接方法能节约一定的内存,也可以减小生成的可执行文件体积。
总结
gcc
编译器的工作过程:源文件-->
预处理-->
编译-->
汇编-->
链接-->
可执行文件gcc
编译过程文件变化:main.c
–>main.i
–>mian.s
–>main.o
–>main
- 通过上面分阶段的解释编译过程,我们也明白了
gcc
其实只是一个后台程序的包装,它会根据阶段要求来调用cpp
、cc
、as
、ld
等命令
源代码
整个编译过程产生的中间文件及最终结果可以通过传送门—— gcc编译项目 来获得,其中还有gcc
和g++
分别调用的对比,查看生成的文件可以发现,同样的源代码使用gcc
和g++
生成的文件是不一样的,总的来说使用g++
编译生成的可执行文件要大一些。