一、以 Hello World开篇
Hello World对程序员而言肯定是如雷贯耳。但是简单的事物背后往往包含这个复杂的机制,如果深入思考Hello world就会发现很多问题。C语言中的Hello World往往是这样写的:
#include <stdio.h>
int main(){
printf("Hello World");
return 0;
}
但是你是否想过以下问题:
1、程序为什么要被编译之后才能运行?
2、编译器在把C语言程序转换成可以执行的机器码的过程中做了什么?
3、最后编译出来的可执行文件里面是什么?除了机器码还有什么?如何存放的?
4、#include <stdio.h>的包含意味着什么?又是如何实现的?
5、什么是编译器,它以什么为分界线分为前端和后端?编译器和解释器有什么区别,为什么会有解释型语言一说?
6、以及由此延伸出的一些相关问题:Swift 是静态语言,为什么还有运行时库?OC中的Runtime和运行时库是什么关系?
7、什么是ABI ?ABI稳定对一门语言的发展有何影响 ?为什么 Swift 打包的 App 会平白无故的多出几Mb ?
8、........
等等,还有很多问题,这些问题实际上和编译都脱离不了干系。读完本篇文章,你的这些疑惑都能得到解答。除此之外,你还将掌握一些主流语言的基本知识。另外,继该篇文章之后,笔者打算后期再写一篇文章动手试试LLVM。欢迎关注。。。。。。
二、编译总过程预览
相信读者对编译的整个流程组成部分应该相对比较熟悉。整个流程包括预处理(Prepressing)、编译(Compilation)、汇编(Assembly)和链接(Linking)。
GCC编译hello world程序过程分解预编译
首先是源代码文件hello.c和相关头文件,如 stdio.h 被编译器 cpp预编译到一个 .i 文件。预编译过程主要是处理那些源代码文件中以 # 开始的预编译指令。比如#include 、#include 等。经预编译后的 .i 文件不包含任何宏定义,因为所有的宏已经被展开,并且包含的文件已经被插入到 .i 文件中。
编译
编译过程就是把预处理的文件经过一系列的词法分析
、语法分析
、语义分析
、生成中间代码
、生成目标代码
优化后生产相应的汇编文件代码。
编译器以中间代码为界限,又可以分前端和后端。比如 clang 就是一个前端工具,而 LLVM 则负责后端处理。另一个知名工具 GCC(GNU Compile Collection)则是一个套装,包揽了前后端的所有任务。
前端主要负责预处理、词法分析、语法分析,最终生成语言无关的中间代码。后端主要负责目标代码的生成和优化。后面我会重点介绍编译的整个过程的每一步。这里暂时简单提一下。
汇编
汇编器将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。所以汇编起的过程相对于编译器而言是比较简单的。因为没有复杂的语法,也没有予以,所以就不需要做指令优化,只是根据汇编指令和机器指令的对照便一一翻译就可以了。到这一步,经过预编译、编译和汇编就可直接输出目标文件
。
链接
在一个目标文件中,不可能所有变量和函数都定义在同一个文件内部。不同文件之间要做相应的链接处理。
三、编译器做了些什么
最直白的来说,编译器就是将高级语言翻译成机器语言的一个工具。先来看一下编译器的整个流程。从该流程图我们可以看到编译器被分为前端和后端,在前端和后端之间的过度是中间代码。其中编译器前端包含词法分析
、语法分析
、语义分析
、中间代码生成
(严格意义来说在此四个步骤之前还有预编译操作);编译器后端主要是代码优化
、目标代码生成
以及目标代码优化
,编译器的大致自责就是如此。编译器在整个编译过程中输入源是源代码,输出的是中间代码。
3.1 词法分析
首先是源代码程序被输入到扫描器,扫描器的任务很简单,它只是简单的进行词法分析。运用有限状态机的算法可以很轻松的将源代码的字符序列分割成一系列的记号。记号一般分为如下几类:关键字、标识符、字面量(数字和字符串等)以及特殊符号(如+,= .....)。
lex程序可以实现词法分析,它会按照用户之前扫描害的词法规则将输入的字符串分割成一个个记号。因为这样一个程序的存在,编译器开发者就无需为每一个编译器开发一个独立的词法扫描器,而是根据需要改变词法规则就可以了。
3.2 语法分析
这一步骤语法分析器将由扫描器产生的记号进行语法分析,从而产生语法树。简单的讲,由语法分析器生成的语法树就是以表达式为节点的树。
int fun(int a, int b) {
int c = 0;
c = a + b;
return c;
}
以如上代码为例,它的语法树形式如下:
语法树将字符串格式的源代码转化为树状的数据结构,更容易被计算机理解和处理。如前面词法分析的 lex 一样,语法分析同样有现成的工具,其中有一个叫做Yet Another Compiler Compiler 简称 yacc 的工具。它可以根据用户给定的语法规则对输入的记号序列进行解析,从而构建出一棵语法树。针对不同的语言,一般编译器开发者只需要改变语法规则,根本不需要为每个编译器重新写一个语法分析器,所以它又被称为
编译器的编译器
。
3.3 语义分析
语义分析有语义分析器完成。语义分析之前的语法分析仅仅只是完成了对表达式成眠的语法层面分析,但是它并不能确定这个语句是否真正有意义。如OC中两个 Person 对象实例直接做加减乘除运算,实际上是没有意义的,但是在语法分析上确实合情合理。这里要说明一下编译器分析的语义都是静态语义,静态语义是指在编译器件可以确定的语义,与之对应的动态语义只能在运行期间才能被确定。
静态语义分析通常包括声明、类型匹配、类型转换等。如一个浮点类型赋值给整形变量,其中就隐含了浮点类型转换为整型的语义;动态语义分析是指运行期间出现的相关语义问题。
经过语义分析之后,在语法分析生成的语法树的基础上进一步对表达式做一些标识。如:有些某些类型需要做隐式转化,语义分析器会在之前的语法树中插入相应的转换节点。
3.4 生成中间代码
3.4.1 生成中间代码的意义
理论上来说,中间代码是可以直接被省略的,因为抽象语法树可以直接转为目标代码(汇编代码)。然而不同的 CPU 架构采用的汇编语法并不一样,如: Intel 架构和 AT&T 架构的汇编码中,源操作数和目标操作数位置恰好相反参考链接。中间代码可以理解为抽象的代码,一方面它和语言无关,同时也和 CPU 无关,它仅仅只是描述了代码要做的事情,可以将其理解为是全世界通用的语言,任何语言都可以转换为世界语言,而世界语言又能被任何人翻译理解。要知道,中间代码的存在使得编译器被分为前端
和后端
。其中编译器前端主要负责产生与机器无关的中间代码,编译器后端主要是将中间代码转换成目标机器代码。因为这意味着针对那些跨平台的编译器而言,可以针对不同的平台使用同一个前端和针对不同机器平台的多个后端。
3.4.2 生成中间代码的过程
生成中间代码主要包含以下步骤,以下是用 GCC 编译器为实例说明。
- 1、语法树转高端 gimple(简化树)
该步骤主要是处理寄存器和栈,比如拿c = a + b
来说通常显示执行a + b
,然后将该结果保存到寄存器中,最后再将其赋值给c
。此外,调用函数时会进入到此函数自己的栈中,建栈操作需要在gimple中声明。 - 2、高端 gimple 转低端 gimple
该步骤主要是把变量定义、语句执行和返回语句区分开来,分别存储,这样可以很好的计算一个函数所需要的栈空间。同时在这一步骤中,return
语句会被统一处理转换成goto
语句,返回值同意放在最后处理。
if (1 > 0) {
return 1;
}
else {
return 0;
}
//上述代码将会被转换成如下形式:
if (1 > 0) {
goto a;
}
else {
goto b;
}
a:
return 1;
b:
return 0;
- 3、低端 gimple 经过 cfa 转 ssa 再转中间代码。
该步骤主要是进行各种相关的优化。
3.5 目标代码生成与优化
经过上面生成中间代码步骤之后,这一步骤属于编译器后端。该步骤主要的任务是生成并优化目标代码,目标代码亦称为汇编代码(其实和汇编代码非常接近)。编译器后端主要包括目标代码生成器和目标代码优化去。
代码生成器将中间代码转换成目标机器代码,此过程依赖目标机器,应为不同的机器有不同的寄存器、整数数据类型和浮点数据类型等。
目标代码优化器主要是对目标代码进行优化,如:选择合适的寻址方式、使用位移代替乘法运算、删除多余的指令等。
3.6 编译过程小结
编译器的结构实际上是异常复杂的,主要在于三个因素。
- 1、高级编程语言本身就异常复杂。
就拿C++来说,至今没有一个编译器能够比较完整的支持C++标准语言所规定的所有语言特性。 - 2、计算机的 CPU 也同样异常复杂。
- 3、要求编译器要支持多种硬件平台,即要求编译器能生成与多种 CPU 匹配的代码。
四、汇编
汇编过程中输入源是汇编代码,输出是二进制机器码(后缀为 .o 的目标文件)。输出的二进制机器码可以直接被 CPU 识别并执行。汇编过程相对于编译器过程而言相对简单些,因为没有复杂的语法、没有语义、不需要做指令优化,根据汇编指令和机器指令的对照表一一翻译即可。
由于汇编更接近机器语言,能够直接对硬件进行操作,生成的程序与其他的语言相比具有更高的运行速度,占用更小的内存,因此在一些对于时效性要求很高的程序、许多大型程序的核心模块以及工业控制方面大量应用。
五、链接
5.1 链接的简单介绍
大型软件往往有成千上万的模块,模块之前相互依赖但又独立。一个程序被分割成多个模块之后,这些模块又是通过何种形式组合成一个完整的程序?模块之间如何组合的问题实际上就是模块之间的通信问题。
链接过程主要包括了:
- 地址和空间的分配(Address and Storage Alloction)
- 符号决议(Symbol Resolution)Ps:"决议"更倾向于静态链接,而"绑定"更倾向于动态链接。
- 重定位(Relocation)
让我们来看看什么是重定位。假设有个全局变量叫做 var ,它在目标文件A里面。我们在目标文件B里面要访问这个全局变量。由于在编译目标文件B的时候,编译器并不知道变量var的目标地址,所以编译器在没法确定的情况下,将目标地址设置为0,等待链接器在目标文件A和B连接起来的时候将其修正。这个地址修正的过程被叫做重定位,每个被修正的地方叫一个重定位入口。
链接器就是靠着重定位表来知道哪些地方需要被重定位的。每个可能存在重定位的段都会有对应的重定位表。在链接阶段,链接器会根据重定位表中,需要重定位的内容,去别的目标文件中找到地址并进行重定位。
5.2静态链接的缺点
-
静态链接这种方法的确很简单,原理上很容易理解,实践上很难实现,但是静态链接对于计算机内存和磁盘的空间浪费非常严重。特别是多进程操作系统的情况下,静态链接极大的浪费了内存和空间。想象一下每个程序内部除了都保留着print()函数、scanf()函数、strlen()等这样的公用函数库,还有数量相当可观的其他库以及它们所需要的辅助数据结构。
-
空间浪费是静态链接的一个问题,另一个问题是静态链接对程序的更新、部署和发布也会带来很多麻烦。比如Program1所使用的Lib.o是由一个第三方厂商提供的,当该厂商更新了Lib.o的时候(比如修复了lib.o里面包含的一个bug),那么Program1的厂商就需要拿到最新版的Lib.o,然后将其与Program1.o链接后将新的Program1整个发布给用户。这样做的缺点很明显,即一旦程序中有任何模块更新,整个程序就要重新链接、发布给用户。
基于上述两个问题,就引出了一个名词,动态链接。
5.3 动态链接
要解决上述两个问题,就是不对哪些组成程序的目标文件进行链接,等到程序要运行时才进行链接。也就是说,把链接这个过程推迟到了运行时再进行,这就是动态链接(Dynamic Linking)的基本思想。所谓的动态链接表示重定位发生在运行时而非编译后。
虽然动态链接可以解决上述的两个问题,但是在性能上要略微比静态链接差一些。笔者之前也写过一篇Swift性能分析的文章,其中也涉及到一些关于OC和Swift语言动态链接相关的点。
六、编译器和解释器
6.1 解释型语言
编译器和解释器解释器是一条一条的解释执行源语言,不需要编译直接由解释器执行,对应的语言称为解释型语言也称作脚本语言。比如 Php,Ruby,JavaScript、Python 等就是典型的解释性语言。
源代码 ---> 解释器 ---> 执行
解释型语言同编译型语言相比,编译器是把源代码整个编译成目标代码,执行时不在需要再去编译器,直接在支持目标代码的平台上运行,所以执行效率比解释执行快很多。比如C语言代码被编译成二进制代码(exe程序),在windows平台上执行。
6.2 解释型语言和编译型语言的共同点
两者的共同点很简单,一句话总结:都需要转换成二进制才能执行。
6.3 解释型语言和编译型语言的不同点
- 1、运行的时候是否需要编译器
编译型语言运行的是最终的二进制代码了,所以不再需要编译器;但是解释型语言边解释、边运行,所以运行时候还有部分代码没有解释好举个例子:在浏览器里,要看 html 效果,要通过带有内置编译工具的软件去查看(如:浏览器或者模拟浏览器的工具)。 - 2、执行速度
毫无疑问边翻译边执行的解释型语言的速度会比编译型语言运行速度要慢。但是CPU的运行速度如果很快,你可能看不出来,偶尔会看到“有点卡”的效果。 - 3、可移植性对比
编译型语言运行二进制内容,一旦 CPU 指令改变,之前的二进制文件可能运行不了了。即在其他硬件平台上运行,可能出错,如果想在其他平台运行就需要编译出新的二进制文件,所以编译型语言可移植性差;解释型语言在需要的时候才开始编译、运行,所以具有可移植性,在很多平台都能运行起来。 - 4、升级上对比
编译型语言的二进制文件如果要升级,需要重新下载一个新的二进制文件了。如QQ的升级,就是要重新下载、安装、覆盖;
而解释型的语言,只要重新写好源代码即可。如网站平台升级,用户只要重新刷新即可。 - 5、 应用领域
编译型语言应用领域通常是安装软件,如:桌面或手机上安装软件;
解释型的语言的应用领域通常是互联网,网站等,刷新就可以看到最新效果。
七、额外扩充(运行时、ABI )
7.1 关于运行时
7.1.1 Runtime
如果你是一个 iOS 开发者,想必都听过并用过 runtime。但其实 runtime 并非是 Objective-C 的专利,绝大多数语言都有这个概念。所以说 runtime 让 Objective-C 具有动态性这句话是错误的。如果要认清楚这一点,感觉有必要先认清楚运行时库,要知道 runtime 就是运行时库的一部分。
7.1.2 运行时库概念
以 C 语言为例说明运行时库的概念。在 C 语言中 glibc 这个动态链接库通常会被很多操作依赖,包括字符串处理(strlen、strcpy)、信号处理、socket、线程、IO、动态内存分配等等。由于每个程序都依赖于运行时库,这些库一般都是动态链接的。这样一来,运行时库可以存储在操作系统中,很多程序共享一个动态库,这样就可以节省内存占用空间和应用程序大小。
7.1.3 Swift运行时库
参照上述 C 语言的运行石库,就很容易理解 Swift运行时库的概念了。一方面,swift 是绝对的静态语言,另一方面,swift 毫无疑问的带有自己的运行时库。按照常理来说类似字符串、数组、print 函数都应该是运行时库中的一部分。然而,Swift 依然没有稳定自己的 ABI ,导致每个程序都必须自带运行时库,这也就是为什么目前 swift 开发的 app 普遍会增加几 Mb 包大小的原因。
7.2 ABI 简单概念
7.2.1 什么是ABI?
ABI 是 Application Binary Interface的缩写,它是一个规范。简单的说它就是编译后的 API (API 描述了在应用程序级别,模块之间的调用约定)。 通过这个规范,所有被独立编译的二进制实体才能被链接在一起并执行。这些二进制实体必须在一些很低层的细节上达成一致,例如:如何调用函数,如何在内存中表示数据,甚至是如何存储以及访问数据。要重点知道,ABI 是平台相关的,因为它关注的这些底层细节会受到不同的硬件架构以及操作系统的的影响。
为了更好的理解什么是ABI,如下举个详细的列子说明。
比如模块 A 有两个整数 a 和 b,它们的内存布局如下:
其他模块调用 A 模块的 b 变量,可以通过初始地址加偏移量的方式获取b变量。如果后来模块 A 新增了一个整数 c (该过程可以看做是手机系统更新(伴随着运行时库更新)),它的内存布局可能又会变成如下这种形式。如果还是通过初始地址加偏移量的方式获取变量,那么此时获取的是 a 变量,而不再是之前的 b 变量。如果把模块 A 看做是 Swift 运行时库,假设现在该运行时库已经内置于操作系统中并与手机上不同的应用程序动态链接在一些。如果每次更新系统,就会出现某些 App 崩溃的情况。如何定义好 A 模块获取变量的规则,其中的规则就是所谓的 ABI 。
7.2.2 什么是ABI稳定?
ABI 稳定就是将 ABI 锁定在某种形式下,使之后的相关编译器可以遵守这种二进制实体,这种二进制实体可以是库也可以是程序。一旦稳定了 ABI ,基本便是它会伴随着这个平台一生一世,甚至是走到灭忙。
对 ABI 做出的每一个决定都会对一门编程语言产生长远的影响,甚至可能会约束一门语言后期的发展和进化。如:Swift 语言一直尚未申明ABI的稳定,但只要申明了某个平台的 ABI 已经稳定,那么任何有缺陷的设计将永远伴随着这个平台。
7.2.3 ABI稳定了会怎样?
ABI 稳定之后,OS 发行商就可以把 Swift 标准库和运行时作为操作系统的一部分嵌入,由于这些标准库和运行时可以支持用更老或更新版本 Swift 构建的应用程序,这样,开发者就无需在分发应用的同时,还要带上一份自己构建应用时使用的标准库和运行时拷贝。这使得工具和操作系统可以更好的进行集成。 然而目前 Swift 还是一门年轻的语言,ABI 尚未稳定,暂时还未和 iOS 系统硬件绑定,所以在开发移动端应用的时候会发现 app 普遍会增加几 Mb 包大小。
八、总结
简单的做个小结,本文显示总的介绍的整个编译过程,之后针对编辑中的每个步骤做了进一步的说明。最后相继介绍了编译型和解释型语言的区别、runtime、运行时库、什么是 ABI 以及 ABI 稳定的意义。
网友评论
??????
printf("Hello World");