应用程序从源代码到可执行文件都经历了哪些过程呢?
可分为两步:编译和链接。而编译又可分为三部,分别是预处理,编译,汇编,大体执行流程入下图所示:
到现在为止,我们把编译器看作一个黑盒子,它能够把源程序映射为在语义上等价的目标程序。如果把这个盒子稍微打开一点,我们就会看到这个映射过程由两个部分组成:分析部分和综合部分。
分析部分把源程序分解为多个组成要素,并在这些要素之上加上语法结构。然后,它使用这个结构来创建该源程序的一个中间表示。如果分析部分检查出源程序没有按照正确的语法构成,或者语义上不一致,它就必须提供有用的信息,使得用户可以按此进行改正。分析部分还会收集有关源程序的信息,并把信息存放在一个称为符号表的数据结构中。符号表将和中间表示形式一起传送给综合部分。
综合部分根据中间表示和符号表中的信息来构造用户期待的目标程序。分析部分经常被称为编译器的前端,而综合部分称为后端。
如果我们更加详细地研究编译过程,会发现它顺序执行了一组步骤。每个步骤把源程序的一种表示方式转换成另一种表示方式。一个典型的编译程序分解为多个步骤的方式如下图所示:
1. 预编译
预编译过程主要处理那些源代码文件中的以“#”开始的预编译命令。
- 将所有的“#define”删除,并且展开所有的宏定义。
- 处理所有条件预编译指令,比如“#if”、“#ifdef”、“#elif”、“#else”、“#endif”。
- 处理“#include”预编译指令,将被包含的文件插入到该编译指令的位置。注意,这个过程是递归进行的,也就是说被包含的文件可能还包含其他文件。
- 删除所有的注释“//”、“/* */”
- 添加行号和文件名标识,比如#2 “hello.c” 2,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号。
- 保留所有的#pragma编译器指令,因为编译器须要使用它们。
2. 编译
编译过程一般分为6步:扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化。
2.1 扫描
预处理完成后的源代码被输入到扫描器(Scanner),扫描器的任务很简单,它只是简单地进行语法分析,运用一种类似于 有限状态机(Finite State Machine) 的算法可以很轻松地将源代码的字符序列分割成一系列的记号(Token)。也就是会进行词法分析,这里会把代码切成一个个 Token,比如关键字、大小括号,等于号还有字符串等。
另外对于一些有预处理的语言,比如C语言,它的宏替换和文件包含等工作一般不归入编译器的范围而交给一个独立的预处理器。
语法分析器读入组成的源程序的字符流,并且将它们组织成为有意义的词素的序列。对于每个词素,语法分析器产生如下形式的语法单元(token)作为输出:
<token-name,attribute-value>
这个词法单元被传送给下一个步骤,即语法分析。
2.2 语法分析
在这个语法单元中,第一个分量 token-name
是一个由语法分析步骤使用的抽象符号,而第二个分 attribute-value
指向符号表中关于这个词法单元的条目。符号表条目信息会被语义分析和代码生成步骤使用。
语法分析: 一个文法的语言的另一个定义是指任何能够由某棵语法分析树生成的符号串的集合。为一个给定的终结符号串构建一棵语法分析树的过程称为对符号串进行语法分析。
stmt -> if(expr) stmt else stmt
其中的箭头(->)可以读作“可以具有如下形式”。这样的规则则称为“产生式”。在一个产生式中,像关键字if和括号这样的词法元素称为
终结符号
。像 expr 和 stmt 这样的变量表示终结符号的序列,它们称为非终结符号
。
验证语法是否正确,如果出现了表达式不合法,比如各种括号不匹配、表达式中缺少操作符等,编译器就会报告语法分析阶段的错误。然后将所有节点组成抽象语法树 AST
。
2.3 语义分析
语义分析器使用语法树和符号表中的信息来检查源程序是否和语言定义的语义一致。它同时也收集类型信息,并把这些信息存放在语法树或符号表中,以便在随后的中间代码生成过程中使用。
语义分析的一个重要部分是类型检查。编译器检查每个运算符是否具有匹配的运算分量。比如,很多程序设计语言的定义中要求一个数组的下标必须是整数。如果用一个浮点数作为数组的下标,编译器就必须报告错误。
语法分析仅仅是完成了对表达式的语法层面的分析,但是它并不了解这个语句是否真正有意义。比如C语言里面两个指针做乘法运算是没有意义的,但是这个语句在语法上是合法的;比如同样一个指针和一个浮点数做乘法运算是否合法等。编译器所能分析的语义是静态语义(Static Semantic),所谓静态语义是指在编译可以确定的语义,与之对应的是动态语义(Dynamic Semantic) 就是只有在运行期才能确定的语义。
静态语义通常包括声明和类型的匹配,类型的转换。动态语义一般指运行期出现的语义相关的问题,比如将0作为除数是一个运行期语义错误。
2.4 生成 IR(中间代码)
在把一个源程序翻译成目标代码的过程中,一个编译器可能构造出一个或多个中间表示。这些中间表示可以有多种形式。语法树是一种中间表示形式,它们通常在语法分析和语义分析中使用。
在源程序的语法分析和语义分析完成之后,很多编译器生成一个明确的低级的或类机器语言的中间表示。我们可以把这个表示看作是某个抽象机器的程序。该中间表示应该具有两个重要的性质:它应该易于生成,且能够被轻松地翻译为目标机器上的语言。
中间代码使得编译器可以被分为前端和后端。编译器前端负责产生机器无关的中间代码,编译器后端将中间代码转换成目标机器代码。这样对于一些可以跨平台的编译器而言,它们可以针对不同的平台使用同一个前端和针对不同机器平台的数个后端。
完成这些步骤后就可以开始IR中间代码的生成了,CodeGen 会负责将语法树自顶向下遍历逐步翻译成 LLVM IR,IR 是编译过程的前端的输出后端的输入。
OC
中通过clang
编译器(clang可以参考这篇文章iOS-底层原理 31:LLVM编译流程 & Clang插件开发),编译成IR,然后再生成可执行文件.o(即机器码)swift
中通过swiftc
编译器,编译成IR,然后再生成可执行文件
下面是Swift中的编译流程,其中SIL(Swift Intermediate Language),是Swift编译过程中的中间代码,主要用于进一步分析和优化Swift代码。如下图所示,SIL位于在AST和LLVM IR之间
注意:这里需要说明一下,Swift与OC的区别在于 Swift生成了高级的SIL
3. 汇编
将编译完的汇编代码文件翻译成机器指令,并生成可重定位目标程序的.o文件,该文件为二进制文件,字节编码是机器指令。
汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。所以汇编器的汇编过程相对于编译器来讲比较简单,它没有复杂的语法,也没有语义,也不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译即可。
生成汇编
clang -S -fobjc-arc main.m -o main.
生成目标文件
clang -fmodules -c main.m -o main.o
目标文件:
目标文件就是源代码编译后但未进行链接的那些中间文件,它跟可执行文件的内容与结构很相似,所以一般跟可执行文件格式一起采用一种格式存储。
不光是可执行文件按照可执行文件格式存储。动态链接库及静态链接库文件都是按照可执行文件格式存储。
有一个问题:如果目标代码中有变量定义在其他模块,该怎么办?事实上,定义在其他模块的全局变量和函数在最终运行时的绝对地址都要在最终链接的时候才能确定。所以现代的编译器可以将一个源代码文件编译成一个未链接的目标文件,然后由链接器最终将这些目标文件链接起来形成可执行文件。让我们带着这个问题,走进链接的世界。
4. 链接
4.1 静态链接
比如我们在程序模块main.c中使用另外一个模块func.c中的函数foo()。我们在mian.c模块中每一处调用foo的时候都必须确切知道foo这个函数的地址,但是由于每个模块都是单独编译的,在编译器编译main.c的时候它并不知道foo函数的地址,所以它暂时把这些调用foo的指令的目标地址搁置,等待最后链接的时候由链接器去将这些指令的目标地址修正。如果没有链接器,须要我们手工把每个调用foo的指令进行修正,填入正确的foo函数地址。当func.c模块被重新编译,foo函数的地址有可能改变时,我们在main.c中所有使用到foo的地址指令将要全部重新调整。这些繁琐的工作将成为程序员的噩梦。使用链接器,你可以直接引用其他模块的函数和全局变量而无须知道它们的地址,因为链接器在链接的时候,会根据你所引用的符号foo,自动去相应 的func.c模块查找foo的地址,然后将main.c模块中所有引用到foo的指令重新修正,让它们的目标地址为真正的foo函数的地址,这就是静态链接的最基本过程和作用。
一般来说,整个链接过程分两步:
第一步 空间与地址分配
扫描所有的输入目标文件,获得它们的各个段的长度、属性和位置,并且将输入目标文件中的符号表中所有到符号定义和符号引用收集起来,统一放到一个全局符号表。这一步中,链接器将能够获得所有输入目标文件的段长度,并且将它们合并,计算出输出文件中各个段合并后的长度与位置,并建立映射关系。
- 符号(Symbol) 这个概念随着汇编语言的普及迅速被使用,它用来表示一个地址,这个地址可能是一段子程序(后来发展成函数)的起始地址,也可以是一个变量的起始地址。
第二步 符号解析与重定位
使用上面第一步中收集到的所有信息,读取输入文件汇总段的数据、重定位信息,并且进行符号解析与重定位、调整代码中的地址等。事实上第二步是链接过程的核心,特别是重定位过程。
- 重新计算各个目标的地址的过程被叫做重定位(Relocation)
4.2 动态链接
在Linux下,动态链接器(Dynamic Linker)ld.so实际上是一个共享对象,操作系统通过通过映射的方式将它加载到进程的地址空间中。操作系统在加载完动态链接器之后,就将控制权交给动态链接器的入口地址(与可执行文件一样,共享对象也有入口地址)。当动态链接器得到控制权之后,它开始执行一系列自身初始化操作,然后根据当前的环境参数,开始对可执行文件进行动态链接工作。当所有动态链接工作完成以后,动态链接器会将控制权转交到可执行文件的入口地址,程序开始正式执行。
空间浪费是静态链接的一个问题,另一个问题是静态链接对程序的更新、部署和发布也会带来很多麻烦。为了解决这两个问题最简单的办法就是把程序的模块相互分割开来,形成独立的文件,而不将它们静态地链接在一起。简单地讲,就是不对那些组成程序的目标文件进行链接,等到程序要运行的才进行链接。也就是说,把链接这个过程推迟到了运行时再进行,这就是动态链接的基本思想。
当程序模块 Program1.c
被编译称为 Program1.o
时,编译器还不知道 foobar()
函数的地址,这个内容我们已在静态链接中解释过了。当链接器将 Program1.o
链接成为可执行文件是,这时候链接器必须确定 Program1.o
中所引用的的 foobar()
函数的性质。如果 foobar()
是一个定义于其他静态目标模块中的函数,那么链接器将会按照静态链接的规则,将 Program1.o
中的 foobar()
地址引用重定位;如果 foobar()
是一个定义在某个动态共享对象中的函数,那么链接器就会将这个符号的引用标记为一个动态链接的符号,不对它进行地址重定位,把这个过程留到装载时再进行。
那么链接器如何知道 foobar()
的引用是一个静态符号还是一个动态符号?这实际上就是我们要用到 Lib.so
的原因。 Lib.so
中保存了完整的符号信息(因为运行时进行动态链接还需要使用符号信息),把 Lib.so
也作为链接输入文件之一,链接器在解析符号时就知道: foobar()
是一个定义在 Lib.so
的动态符号。这样链接器就可以对 foobar()
的引用做特殊的处理,使它成为一个对动态符号的引用。
在一个程序被分割成多个模块以后,这些模块之间最后如何组合形成一个单一的程序是须解决的问题。模块之间如何组合的问题可以归结为模块之间如何通信的问题,最常见的属于静态语言的C/C++模块之间的通信有两种方式,一种是模块间的函数调用,另外一种是模块间的变量访问。函数访问须知道目标函数的地址,变量访问也须知道目标变量的地址,所以这两种方式都可以归结为一种方式,那就是模块间的符号的引用。模块间依靠符号来通信类似于拼图版,定义符号的模块多出一块区域,引用符号的模块刚好少了那一块区域,两者一拼接刚好完美组合。这个模块的拼接过程就是链接(Linking)。
把每个源代码模块独立地编译,然后按照需要将它们“组装”起来,这个组装模块的过程就是链接(Linking)。链接的主要内容就是把各个模块之间的相互引用的部分处理好,使得各个模块之间能够正确地衔接。链接过程主要包括了地址和空间分配(Address and Storge Allocation)、符号决议(Symbol Resolution)和重定位(Relocation) 等这些步骤。
5. 生成可执行文件
dyld
链接,将每个模块的源代码经过编译器编译成目标文件(Object File,一般扩展名为.o或.obj
)和引用的动态库(.so、.framework、.dylib)
和静态库(.a、.lib)
一起链接形成最终可执行文件。