普通视图

发现新文章,点击刷新页面。
昨天以前SketchK's Blog

编译系统(Compilation System)和编译流程(Compilation pipeline)到底是什么

作者 SketchK
2020年6月7日 21:45

编译到底是什么一文中,我们了解了编译在计算机编程中扮演的角色和作用,这次我们将探讨编译系统是由哪些角色组成的!

回顾

从某种程度上来说:

编译,其实就是把源代码变成目标代码的过程。如果源代码编译后要在操作系统上运行,那目标代码就是汇编代码,我们再通过汇编和链接的过程形成可执行文件,然后通过加载器加载到操作系统里执行。如果编译后是在解释器里执行,那目标代码就可以不是汇编代码,而是一种解释器可以理解的中间形式的代码即可。

组成编译系统的基本元素

通常来说,编译系统是由 4 个部分组成

  • 预处理(preprocessor):负责引用 header file,libraries 的文件并组成完整的代码,以 C 语言为例,就是根据 # 开头的指令,例如 #include <stdio.h> 插入 stdio.h 的内容

  • 编译器(compiler):把重新组合好的代码再转交给 compiler,并转化成汇编语言(assembly language),使用汇编语言最重要的原因是不同的高级语言都可以变成汇编语言,汇编码也比机器码好 debug,PS:汇编语言会因硬件的架构而有所不同。

  • 汇编器(assembler):将汇编语言转换为机器码(machine code),并打包成重新定位的目标文件(object file)

  • 链接器(linker):负责合并所有的 object file 并产生可执行的文件,它可以被加载到内存中执行。

我们再把流程绘制成如下的图示:

IMAGE

编译器的组成

知道了编译系统后,我们再探究下编译系统里面编译器(compiler)环节。

按照龙书里的说法,我们可以将编译器里做的事情分为两个阶段:

  1. 分析(Analysis): 又称为 Compiler 的前端处理(front-end),分析与解构原始代码,并将其整理成中间代码(intermediate representation)与符号表(symbol table)并传给下一个阶段,当中如果发现任何问题就会提示报错
  2. 生成(Synthesis):又称为 compiler 的后端处理(back-end),根据符号表与中间代码产出目标代码

为了理解编译器的作用,我举一个很简单的例子。这里有一段 C 语言的程序,我们一起来看看它的编译过程。

1
2
3
4
int foo(int a){
int b = a + 3;
return b;
}

这段源代码,如果把它编译成汇编代码,大致是下面这个样子:

1
2
3
4
5
6
7
8
9
10
11
12
    .section    __TEXT,__text,regular,pure_instructions
.globl _foo ## -- Begin function foo
_foo: ## @foo
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp)
movl -4(%rbp), %eax
addl $3, %eax
movl %eax, -8(%rbp)
movl -8(%rbp), %eax
popq %rbp
retq

你可以看出,源代码和目标代码之间的差异还是很大的。那么,我们怎么实现这个翻译呢?

其实,编译和把英语翻译成汉语的大逻辑是一样的。前提是你要懂这两门语言,这样你看到一篇英语文章,在脑子里理解以后,就可以把它翻译成汉语。编译器也是一样,你首先需要让编译器理解源代码的意思,然后再把它翻译成另一种语言。

表面上看,好像从英语到汉语,一下子就能翻译过去。但实际上,大脑一瞬间做了很多个步骤的处理,包括识别一个个单词,理解语法结构,然后弄明白它的意思。同样,编译器翻译源代码,也需要经过多个处理步骤,

所以,我们将编译器的两个工作环节进行更细致的划分

  • 分析 (analysis):
    • 词法分析器 (lexical analyzer),也称 scanner,建立 symbol table
    • 语法分析器 (syntax analyzer),也称 parser
    • 语义分析器 (semantic analyzer)
    • 生成中间码 (intermediate code generator)
    • 中间码最佳化 (code optimizer) (optional & machine-independent)
  • 生成 (synthesis):
    • 目标代码生成器 (code generator)
    • 目标代码最佳化 (machine-independent code optimizer) (optional & machine-independent)

流程图如下:

因为 Symbol Table 会在各个步骤都会使用到,因此将其独立画出来

IMAGE

当然国外也有人将其这样划分

IMAGE

现代语言,在语法解析过程,是可以不依赖符号表的,所以符号表一般都推迟到语义分析阶段才去建立,例如 Java 语言。
当然也有个别语言,在语法解析阶段需要查一下符号表(也就是获取上下文信息),才能知道某个标识符应该位于哪个语法中。所以,这个时候符号表会提前建立,在词法分析的时候就开始建立。

词法分析

首先,编译器要读入源代码。

在编译之前,源代码只是一长串字符而已,这显然不利于编译器理解程序的含义。所以,编译的第一步,就是要像读文章一样,先把里面的单词和标点符号识别出来。程序里面的单词叫做 Token,它可以分成关键字、标识符、字面量、操作符号等多个种类。把字符串转换为 Token 的这个过程,就叫做词法分析。

IMAGE

把字符串转换为 Token(注意:其中的空白字符,代表空格、tab、回车和换行符,EOF 是文件结束符)

语法分析

识别出 Token 以后,离编译器明白源代码的含义仍然有很长一段距离。下一步,我们需要让编译器像理解自然语言一样,理解它的语法结构。这就是第二步,语法分析。

上语文课的时候,老师都会让你给一个句子划分语法结构。比如说:“我喜欢又聪明又勇敢的你”,它的语法结构可以表示成下面这样的树状结构。

IMAGE

那么在编译器里,语法分析阶段也会把 Token 串,转换成一个体现语法规则的、树状的数据结构,这个数据结构叫做抽象语法树(AST,Abstract Syntax Tree)。我们前面的示例程序转换为 AST 以后,大概是下面这个样子:

IMAGE

这样的一棵 AST 反映了示例程序的语法结构。比如说,我们知道一个函数的定义包括了返回值类型、函数名称、0 到多个参数和函数体等。这棵抽象语法树的顶部就是一个函数节点,它包含了四个子节点,刚好反映了函数的语法。

再进一步,函数体里面还可以包含多个语句,如变量声明语句、返回语句,它们构成了函数体的子节点。然后,每个语句又可以进一步分解,直到叶子节点,就不可再分解了。而叶子节点,就是词法分析阶段生成的 Token(图中带边框的节点)。对这棵 AST 做深度优先的遍历,你就能依次得到原来的 Token。

语义分析

生成 AST 以后,程序的语法结构就很清晰了,编译工作往前迈进了一大步。但这棵树到底代表了什么意思,我们目前仍然不能完全确定。

比如说,表达式“a+3”在计算机程序里的完整含义是:“获取变量 a 的值,把它跟字面量 3 的值相加,得到最终结果。”但我们目前只得到了这么一棵树,完全没有上面这么丰富的含义。

IMAGE

这就好比西方的儿童,很小的时候就能够给大人读报纸。因为他们懂得发音规则,能念出单词来(词法分析),也基本理解语法结构(他们不见得懂主谓宾这样的术语,但是凭经验已经知道句子有不同的组成部分),可以读得抑扬顿挫(语法分析),但是他们不懂报纸里说的是什么,也就是不懂语义。这就是编译器解读源代码的下一步工作,语义分析。

那么,怎样理解源代码的语义呢

实际上,语言的设计者在定义类似“a+3”中加号这个操作符的时候,是给它规定了一些语义的,就是要把加号两边的数字相加。你在阅读某门语言的标准时,也会看到其中有很多篇幅是在做语义规定。在 ECMAScript(也就是 JavaScript)标准 2020 版中,Semantic 这个词出现了 657 次。下图是其中加法操作的语义规则,它对于如何计算左节点、右节点的值,如何进行类型转换等,都有规定。

IMAGE

ECMAScript 标准中加法操作的语义规则

所以,我们可以在每个 AST 节点上附加一些语义规则,让它能反映语言设计者的本意。

  • add 节点:把两个子节点的值相加,作为自己的值;
  • 变量节点(在等号右边的话):取出变量的值;
  • 数字字面量节点:返回这个字面量代表的值。

这样的话,如果你深度遍历 AST,并执行每个节点附带的语义规则,就可以得到 a+3 的值。这意味着,我们正确地理解了这个表达式的含义。运用相同的方法,我们也就能够理解一个句子的含义、一个函数的含义,乃至整段源代码的含义。

这也就是说,AST 加上这些语义规则,就能完整地反映源代码的含义。这个时候,你就可以做很多事情了。比如,你可以深度优先地遍历 AST,并且一边遍历,一边执行语法规则。那么这个遍历过程,就是解释执行代码的过程。你相当于写了一个基于 AST 的解释器。

不过在此之前,编译器还要做点语义分析工作。那么这里的语义分析是要解决什么问题呢?

给你举个例子,如果我把示例程序稍微变换一下,加一个全局变量的声明,这个全局变量也叫 a。那你觉得“a+3”中的变量 a 指的是哪个变量?

1
2
3
4
5
int a = 10;       //全局变量
int foo(int a){ //参数里有另一个变量a
int b = a + 3; //这里的a指的是哪一个?
return b;
}

我们知道,编译程序要根据 C 语言在作用域方面的语义规则,识别出“a+3”中的 a,所以这里指的其实是函数参数中的 a,而不是全局变量的 a。这样的话,我们在计算“a+3”的时候才能取到正确的值。

而把“a+3”中的 a,跟正确的变量定义关联的过程,就叫做引用消解(Resolve)。这个时候,变量 a 的语义才算是清晰了。

变量有点像自然语言里的代词,比如说,“我喜欢又聪明又勇敢的你”中的“我”和“你”,指的是谁呢?如果这句话前面有两句话,“我是春娇,你是志明”,那这句话的意思就比较清楚了,是“春娇喜欢又聪明又勇敢的志明”。

引用消解需要在上下文中查找某个标识符的定义与引用的关系,所以我们现在可以回答前面的问题了,语义分析的重要特点,就是做上下文相关的分析。

在语义分析阶段,编译器还会识别出数据的类型。比如,在计算“a+3”的时候,我们必须知道 a 和 3 的类型是什么。因为即使同样是加法运算,对于整型和浮点型数据,其计算方法也是不一样的。

语义分析获得的一些信息(引用消解信息、类型信息等),会附加到 AST 上。这样的 AST 叫做带有标注信息的 AST(Annotated AST/Decorated AST),用于更全面地反映源代码的含义。

IMAGE

好了,前面我所说的,都是如何让编译器更好地理解程序的语义。不过在语义分析阶段,编译器还要做很多语义方面的检查工作。

在自然语言里,我们可以很容易写出一个句子,它在语法上是正确的,但语义上是错误的。比如,“小猫喝水”这句话,它在语法和语义上都是对的;而“水喝小猫”这句话,语法是对的,语义上则是不对的。

计算机程序也会存在很多类似的语义错误的情况。比如说,对于“int b = a+3”的这个语句,语义规则要求,等号右边的表达式必须返回一个整型的数据(或者能够自动转换成整型的数据),否则就跟变量 b 的类型不兼容。如果右边的表达式“a+3”的计算结果是浮点型的,就违背了语义规则,就要报错。

总结起来,在语义分析阶段,编译器会做语义理解和语义检查这两方面的工作。词法分析、语法分析和语义分析,统称编译器的前端,它完成的是对源代码的理解工作。

做完语义分析以后,接下来编译器要做什么呢

本质上,编译器这时可以直接生成目标代码,因为编译器已经完全理解了程序的含义,并把它表示成了带有语义信息的 AST、符号表等数据结构。

生成目标代码的工作,叫做后端工作。做这项工作有一个前提,就是编译器需要懂得目标语言,也就是懂得目标语言的词法、语法和语义,这样才能保证翻译的准确性。这是显而易见的,只懂英语,不懂汉语,是不可能做英译汉的。通常来说,目标代码指的是汇编代码,它是汇编器(Assembler)所能理解的语言,跟机器码有直接的对应关系。汇编器能够将汇编代码转换成机器码。

熟练掌握汇编代码对于初学者来说会有一定的难度。但更麻烦的是,对于不同架构的 CPU,还需要生成不同的汇编代码,这使得我们的工作量更大。所以,我们通常要在这个时候增加一个环节:先翻译成中间代码(Intermediate Representation,IR)。

中间代码

中间代码(IR),是处于源代码和目标代码之间的一种表示形式。

我们倾向于使用 IR 有两个原因。

第一个原因,是很多解释型的语言,可以直接执行 IR,比如 Python 和 Java。这样的话,编译器生成 IR 以后就完成任务了,没有必要生成最终的汇编代码。

第二个原因更加重要。我们生成代码的时候,需要做大量的优化工作。而很多优化工作没有必要基于汇编代码来做,而是可以基于 IR,用统一的算法来完成。

优化

那为什么需要做优化工作呢?这里又有两大类的原因。

第一个原因,是源语言和目标语言有差异。源语言的设计目的是方便人类表达和理解,而目标语言是为了让机器理解。在源语言里很复杂的一件事情,到了目标语言里,有可能很简单地就表达出来了。

比如“I want to hold your hand and with you I will grow old.” 这句话挺长的吧?用了 13 个单词,但它实际上是诗经里的“执子之手,与子偕老”对应的英文。这样看来,还是中国文言文承载信息的效率更高。

同样的情况在编程语言里也有。以 Java 为例,我们经常为某个类定义属性,然后再定义获取或修改这些属性的方法:

1
2
3
4
5
6
7
8
9
Class Person{
private String name;
public String getName(){
return name;
}
public void setName(String newName){
this.name = newName
}
}

如果你在程序里用“person.getName()”来获取 Person 的 name 字段,会是一个开销很大的操作,因为它涉及函数调用。在汇编代码里,实现一次函数调用会做下面这一大堆事情:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#调用者的代码
保存寄存器1 #保存现有寄存器的值到内存
保存寄存器2
...
保存寄存器n

把返回地址入栈
把person对象的地址写入寄存器,作为参数
跳转到getName函数的入口

#_getName 程序
在person对象的地址基础上,添加一个偏移量,得到name字段的地址
从该地址获取值,放到一个用于保存返回值的寄存器
跳转到返回地

你看了这段伪代码,就会发现,简单的一个 getName() 方法,开销真的很大。保存和恢复寄存器的值、保存和读取返回地址,等等,这些操作会涉及好几次读写内存的操作,要花费大量的时钟周期。但这个逻辑其实是可以简化的。

怎样简化呢?就是跳过方法的调用。我们直接根据对象的地址计算出 name 属性的地址,然后直接从内存取值就行。这样优化之后,性能会提高好多倍。

这种优化方法就叫做内联(inlining),也就是把原来程序中的函数调用去掉,把函数内的逻辑直接嵌入函数调用者的代码中。在 Java 语言里,这种属性读写的代码非常多。所以,Java 的 JIT 编译器(把字节码编译成本地代码)很重要的工作就是实现内联优化,这会让整体系统的性能提高很大的一个百分比!

总结起来,我们在把源代码翻译成目标代码的过程中,没有必要“直译”,而是可以“意译”。这样我们完成相同的工作,对资源的消耗会更少。

第二个需要优化工作的原因,是程序员写的代码不是最优的,而编译器会帮你做纠正。比如下面这段代码中的 bar() 函数,里面就有多个地方可以优化。甚至,整个对 bar() 函数的调用,也可以省略,因为 bar() 的值一定是 101。这些优化工作都可以在编译期间完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
int bar(){
int a = 10*10; //这里在编译时可以直接计算出100这个值,这叫做“常数折叠”
int b = 20; //这个变量没有用到,可以在代码中删除,这叫做“死代码删除”


if (a>0){ //因为a一定大于0,所以判断条件和else语句都可以去掉
return a+1; //这里可以在编译器就计算出是101
}
else{
return a-1;
}
}
int a = bar(); //这里可以直接换成 a=101

综上所述,在生成目标代码之前,需要做的优化工作可以有很多,这通常也是编译器在运行时,花费时间最长的一个部分。

IMAGE

而采用中间代码来编写优化算法的好处,是可以把大部分的优化算法,写成与具体 CPU 架构无关的形式,从而大大降低编译器适配不同 CPU 的工作量。并且,如果采用像 LLVM 这样的工具,我们还可以让多种语言的前端生成相同的中间代码,这样就可以复用中端和后端的程序了。

生成目标代码

编译器最后一个阶段的工作,是生成高效率的目标代码,也就是汇编代码。这个阶段,编译器也有几个重要的工作。

第一,是要选择合适的指令,生成性能最高的代码。

第二,是要优化寄存器的分配,让频繁访问的变量(比如循环变量)放到寄存器里,因为访问寄存器要比访问内存快 100 倍左右。

第三,是在不改变运行结果的情况下,对指令做重新排序,从而充分运用 CPU 内部的多个功能部件的并行计算能力。

目标代码生成以后,整个编译过程就完成了。

编译器流程总结

IMAGE

Swift 编译器的不同之处

我们可以在 Swift 官网看到一篇名为 Swift Compiler 的文章,它从比较高的层面讲述了 Swift 编译器的主要流程,这里我们直接通过翻译的方式来介绍这些步骤:

  • 解析(Parsing):解析器是一个简易的递归下降解析器(在 lib/Parse 中实现),并带有完整手动编码的词法分析器。这个分析器会生成 AST,但不包含任何语义信息或者类型信息,并且会忽略源码上的语法错误。

  • 语义分析(Semantic Analysis):语义分析阶段(在 lib/Sema 中实现)负责获取已解析的 AST(抽象语法树)并将其转换为格式正确且类型检查完备的 AST,以及在源代码中提示出现语义问题的警告或错误。语义分析包含类型推断,如果可以成功推导出类型,则表明此时从已经经过类型检查的最终 AST 生成代码是安全的。

  • Clang 导入器(Clang Importer):Clang 导入器(在 lib/ClangImporter 中实现)负责导入 Clang 模块,并将导出的 C 或 Objective-C API 映射到相应的 Swift API 中。最终导入的 AST 可以被语义分析引用。

  • SIL 生成(SIL Generation):Swift 中间语言(Swift Intermediate Language,SIL)是一门高级且专用于 Swift 的中间语言,适用于对 Swift 代码的进一步分析和优化。SIL 生成阶段(在 lib/SILGen 中实现)将经过类型检查的 AST 弱化为所谓的「原始」SIL。SIL 的设计在 docs/SIL.rst 有所描述。

  • SIL 保证转换(SIL Guaranteed Transformations):SIL 保证转换阶段(在 lib/SILOptimizer/Mandatory 中实现)负责执行额外且影响程序正确性的数据流诊断(比如使用未初始化的变量)。这些转换的最终结果是「规范」SIL。

  • SIL 优化(SIL Optimizations):SIL 优化阶段(在 lib/Analysis、lib/ARC、lib/LoopTransforms 以及 lib/Transforms 中实现)负责对程序执行额外的高级且专用于 Swift 的优化,包括(例如)自动引用计数优化、去虚拟化、以及通用的专业化。

  • LLVM IR 生成(LLVM IR Generation):IR 生成阶段(在 lib/IRGen 中实现)将 SIL 弱化为 LLVM LR,此时 LLVM 可以继续优化并生成机器码。

从原文的内容里,我们可以看到 Swift 编译器主要是在前端部分增加了一些环节,主要是在语义分析和中间代码生成的过程中增加了几个步骤:

IMAGE

这一部分后续会继续完善,目前的水平和实践还只能到解释这么多

今天的内容就先到这了,希望通过这篇文章你能对编译系统,编译流程和编译环节做的事情有了一个初步的概念!

参考文献

编译到底是什么?

作者 SketchK
2020年5月25日 01:05

因为工作原因,最近要做包管理工具方面的开发,需要对 Compiler 有一些最基本的理解,写这篇文章的目的有两个:

  • 为了记录和整理自己的近期的学习内容,方便日后查阅
  • 抛开大段代码和抽象概念,通过通俗易懂的写作方式来加深自己对这些概念的理解

废话不多说,我们一起看看内容吧!

需要了解的概念

在看了不少关于编译相关的文章之后,我发现下面的词汇是大量出现的。

知道这些词汇代表的意思,以及对应的层次,能够更好地看懂别人所要表达的意思。

高级语言代码 High-Level Code

高级语言代码,自然是指由高级编程语言编写代码,对计算机的细节有更高层次的抽象。

相对于低级编程语言(low-level programming language)更接近自然语言(人类的语言),集成一系列的自动工具(垃圾回收,内存管理等),会让程序员更快乐的编写出更简洁,更易读的程序代码。

低级语言代码 Low-Level Code

低级语言代码,指由低级编程语言编写的代码,相对高级语言,少了更多的抽象概念,更加接近于汇编或者机器指令。但是这也意味着代码的可移植性很差。

在我看来,高与低,只是一组相对词而已。越高级的语言,性能、自由度越不及低级语言。但是在抽象、可读可写性、可移植性越比低级语言优秀。
在以前的年代,C/C++语言相对汇编语言,机器指令来说,肯定是高级语言。

而到了今天,我们更多人对C语言偏向认知为「低级语言」。
或许未来世界的开发者,看我们现在所熟悉的Java、PHP、Python、ECMAScript等等,都是「low」到爆的语言。

汇编语言 Assembly Language

汇编语言作为一门低级语言,对应于计算机或者其他可编程的硬件。它和计算机的体系结构以及机器指令是强关联的。换句话说,就是不同的汇编语言代码对应特定的硬件,所以不用谈可移植性了。

相对于需要编译和解释的高级语言代码来说,汇编代码只需要翻译成机器码就可以执行了。所以汇编语言也往往被称作象征性机器码(symbolic machine code)

字节码 Byte Code

字节码严格来说不算是编程语言,而是高级编程语言为了种种需求(可移植性、可传输性、预编译等)而产生的中间码(Intermediate Code)。它是由一堆指令集组成的代码,例如在 javac 编译过后的 java 源码产生的就是字节码。

源码在编译的过程中,是需要进行「词法分析 → 语法分析 → 生成目标代码」等过程的,在预编译的过程中,就完成这部分工作,生成字节码。
然后在后面交由解释器(这里通常指编程语言的虚拟机)解释执行,省去前面预编译的开销。

机器码 Machine Code

机器码是一组可以直接被 CPU 执行的指令集,每一条指令都代表一个特定的任务,或者是加载,或者是跳转,亦或是计算操作等等。所有可以直接被 CPU 执行的程序,都是由这么一系列的指令组成的。

机器码可是看作是编译过程中,最低级的代码,因外再往下就是交由硬件来执行了。
当然机器码也是可以被编辑的,但是以人类难以看懂的姿势存在,可读性非常差。

建立模糊的印象

如果要用一种现实生活中的职业来形容编译器的作用,我想翻译官是一个不错的选择。不论是同声传译,还是各个节目或者动漫的专业字幕组,反正只要能够把 A 语言流畅的翻译成 B 语言的都算。

但翻译的工作并不是那么简单,需要理解某种语言的文字,语法才能进行,当然更专业的人还能使用精简的句子传达意境。总之,这里的 ”翻译“ 其实不仅仅是翻译,还要再经过编辑,这也就就是 “compile“ ,编译的意思。

编译器 Compiler

在有了一个模糊的印象后,我们在聚焦到 compiler 上,compiler 就是计算机编程语言里的翻译官,不同的 compiler 会编译成不同的语言,有可能是转换成机器语言(machine code), byte code, 甚至是另外一种语言,如图:

IMAGE

最终产出的 target program 是能够被直接执行的,所以程序的编译到执行应该是这样的:

IMAGE

这种方式也叫做提前编译,Ahead-Of-Time Compilation(AOT),wiki 传送门:点我

直译器 Interpreter

还有另外一种语言处理的工具:直译器(Interpreter),相较于上图,compiler 是编译 source code 后产出可执行的代码,由使用者输入 input 后,再得到 output。而直译器是 source code 与 input 一起给出,直接编译并执行,产出 output,而使用直译器的语言有耳熟能详的 Python,它的架构如下:

IMAGE

另外 compiler 与 interpreter 在速度上也有一定的差异,compiler 产生的 target program 执行的比 interpreter 快。但 interpreter 的纠错能力又比较好,因为它是一行行的检查与执行程序中的代码。

关于 Interpreter,有的翻译叫做直译器,有的叫做解释器,wiki 传送门:点我)

编译器与直译器的异同

表现 Behavior

  • 编译器把源代码转换成其他的更低级的代码(例如二进制码、机器码),但是不会执行它。
  • 直译器会读取源代码,并且直接生成指令让计算机硬件执行,不会输出另外一种代码。

性能 Performance

  • 编译器会事先用比较多的时间把整个程序的源代码编译成另外一种代码,后者往往较前者更加接近机器码,所以执行的效率会更加高。时间是消耗在预编译的过程中。
  • 直译器会一行一行的读取源代码,解释,然后立即执行。这中间往往使用相对简单的词法分析、语法分析,压缩解释的时间,最后生成机器码,交由硬件执行。直译器适合比较低级的语言。但是相对于预编译好的代码,效率往往会更低。如何减少解释的次数和复杂性,是提高直译器效率的难题。

Compilation + Interpretation

再来,就势必要提及赫赫有名的 Java,为什么呢?Java 是一个结合 Compilation 和 Interpretation 的程序语言,这是什么意思呢?

就是 Java 会先编译成 byte code,接着再直译成机器码,这样的好吃是,Java 经历过一次编译,就可以通过虚拟机(Virtual machine)在不同的机器上直接执行。

沿用文章开始的翻译人员例子,byte code 就像是目前的通用国际语言 - 英语。只要将 A 语言翻译成英文,且 B 国人人能直接把英语翻译成自己的语言(当然前提是大家都会英文),此时,大家的交流就没有任何障碍了,整体的架构如下:

IMAGE

这种方式也叫做即时编译,Just-In-Time Compilation(JIT),wiki 传送门:点我

结合到实际

IMAGE

从左往右看,

  • 以 Java 为例,我们在文本编译器写好了 Java 代码,交由编译器编译成 Java Bytecode。然后 Bytecode 交由 JVM 来执行,这时候 JVM 充当了直译器的角色,在解释 Bytecode 成 Machine Code 的同时执行它,返回结果。

  • 以 BASIC 语言(早期的可以由计算机直译的语言) 为例,通过文本编译器编写好,不用经历编译的过程,就可以直接交由操作系统内部来进行解释然后执行。

  • 以 C 语言为例,我们在文本编译器编写好源代码,然后运行 gcc hello.c 编译出 hello.out 文件,该文件由一系列的机器指令组成的机器码,可以直接交由硬件来执行。

从抽象里看本质

无论是编译 (Compiler),还是直译 (Interpreter),甚至是即时编译。
本质还是人与计算机的交流形式,人的语言最终转换成机器语言。

一句 Hello World,经过一些列的编译和直译,最终转换成一系列包含机器指令的那些 0 和 1,机器傻傻执行完之后,告诉你结果。

就这么一个过程,我们就需要很多的翻译官。
有些翻译官可以做到同声传译(直译),有些翻译官却只能把我们的意图记下来再全部翻译(编译)给计算机。

而往往一个翻译官能力有限,也只能把你的语言,翻译成另外一种低级点的语言,再由另外懂这个语言的翻译官来翻译更接近计算机能读得懂的语言。

总结

这篇文章从一些与编译相关的常见概念说起,通俗的描述了编译原理范畴内的编译器与直译器:

  • 编译 Compile:把整个程序源代码翻译成另外一种代码,然后等待被执行,发生在运行之前,产物是另一份代码。
  • 直译 Interpret:把程序源代码一行一行的读懂然后执行,发生在运行时,产物是运行结果。

同时我们还用一些常见的计算机编程语言作为例子,浅显的解释了它们的编译过程。

希望通过这篇文章,你能对编译在计算机领域里扮演的角色和功能形成一个清晰的认知。

❌
❌