一 编译过程中发生了什么
编译就是把源代码变成目标代码的过程
1,如果源代码编译后要在操作系统上运行,那么目标则是汇编代码,再通过汇编和链接的形式形成可执行文件,然后通过加载起加载到操作系统执行。
2,编译后在解释器执行,目标可以不是汇编代码,是一种解释器可以理解的中间形式代码即可。
前端:词法分析 -> 语法分析 -> 语义分析
后端:生成中间代码 -> 优化 -> 生成目标代码
词法分析(Lexical Analysis)
首先,编译器要读入源代码。
识别 token,它分为关键字,标志符,字面量,操作符号等多个种类。把字符串转换为 token 的过程,就是词法分析。
语法分析
下一步,我们需要让编译器像理解自然语言一样,理解它的语法结构,这就是第二步语法分析。
把 token 转换为一个体现语法规则的,树状的数据结构,这个数据结构叫做 AST 抽象语法树。
语义分析
操作符被定义了语义。语言标准中有大量篇幅在做语义定义。ECMAScript 中 semantic(语义的)这个词出现了 657 次。
int a = 10; //全局变量
int foo(int a){ //参数里有另一个变量a
int b = a + 3; //这里的a指的是哪一个?
return b;
}
把“a+3”中的 a,和正确的变量定义关联的过程,叫做引用消解,语义分析的重要特点,就是上下文相关的分析。
语义分析的阶段,编译器还会识别出数据的类型。同样是加法,对于整形和浮点型数据,其计算方法也是不一样的.
语义分析获得的一些信息(引用消解类型,类型信息等),会附加到 AST 上。这样的 AST 叫做带有标注信息的 AST(Annotated AST/Decorated AST)。
总的来说,语义分析阶段,编译器会做语义理解和语义检查这两方面工作。词法分析和语义分析,统称编译器的前端,他完成对源码的理解工作。
生成代码的工作,叫做后端工作,目标为汇编代码,它是汇编器(Assembler)所能理解的语言,跟机器码有直接对应关系。汇编器器将汇编代码转换为机器码。
熟练掌握汇编代码是困难的。不同架构的 CPU,需要生成不同的汇编代码。所以我们通常会加一个环节:先翻译成中间代码(Inrermediate Representation,IR)。
中间代码(IR)
IR,是出于源代码和目标代码之间的一种表达式。
使用 IR 的两个原因:
1,很多解释型的语言,可以直接执行 IR,比如 python 和 java。这样,编译器生成 IR 就完事了,没有必要生成最终汇编代码。
2,生成代码的时候,需要做大量优化。很多优化没有必要基于汇编来做,而可以基于 IR,用统一算法完成。
优化(optimization)
为什么需要优化?
1,源语言和目标语言的差异。
如:
Class Person{
private String name;
public String getName(){
return name;
}
public void setName(String newName){
this.name = newName
}
}
如果在程序中用“person.getName()”获取 Person 的 name 字段,会开销很大,涉及函数调用。汇编中,实现一次函数调用会做下面一大堆事情:
#调用者的代码
保存寄存器1 #保存现有寄存器的值到内存
保存寄存器2
...
保存寄存器n
把返回地址入栈
把person对象的地址写入寄存器,作为参数
跳转到getName函数的入口
#_getName 程序
在person对象的地址基础上,添加一个偏移量,得到name字段的地址
从该地址获取值,放到一个用于保存返回值的寄存器
跳转到返回地
保存恢复寄存器的值,保存和读取返回地址等等,涉及好几次读写内存操作,花费大量始终周期。
简化方法就是跳过方法调用。直接根据对象地址计算 name 属性地址,然后直接从内存取值。这种方法叫内联。java jit 编译器的重要工作之一就是实现内敛优化。
2,纠正程序员的非最优代码。如下,没必要对 bar()进行调用,因为它固定返回 101.
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
采用 LLVM 这样的工具的话,我们还可以让多种语言的前端生成相同的中间代码,这样就能复用中后端程序了。
生成目标代码
编译器最后一阶段工作,生成目标代码,即汇编代码。三个重要工作。
1,选择合适的指令,生成性能最高的代码。
2,优化寄存器分配,频繁访问的变量放到寄存器,因为访问寄存器比访问内存快 100 倍
3,不改变运行结果的情况下,对指令做重新排序,从而充分利用 CPU 多个功能部件的并行计算能力。
二 词法解析:用两种方式构造有限自动机
如何实现一个正则表达式工具,从而实现任意的词法解析。
词法分析的原理
输入:字符串。输出:Token。
词法分析英文:tokenizer。
一个计算模型,有限自动机(finite-state automaton,fsa),或叫有限状态自动机。假设你做一个电商系统,订单状态的迁移就是一个状态机。
它的状态数量是有限的。当它收到一个新字符时,会导致状态的迁移。
词法分析的过程,其实就是对一个字符串进行模式匹配的过程,字符串的模式匹配可以用,正则表达式工具。
# ps 将某个进程显示出来。grep 正则查找命令.-e : 显示所有进程 -f : 全格式
ps -ef | grep 's[a-h]'
其中 “s[a-h]”就是一个正则表达式。
正则表达式也可以用来描述词法规则,我们把它正则文法。
IntLiteral : [0-9]+; //至少有一个数字
Id : [A-Za-z][A-Za-z0-9]*; //以字母开头,后面可以是字符或数字
与普通的正则表达式工具不同的是,词法分析要用到很多词法规则,每个词法规则都需要用“Token 类型:正则表达式”这样一种格式,匹配一种 Token。
多条词法存在时,可能出现词法冲突,比如,int 关键字其实也符合标识符的词法规则。
Int : int; //int关键字
For : for; //for关键字
Id : [A-Za-z][A-Za-z0-9]*; //以字母开头,后面可以是字符或数字
所以,词法规则也分优先级。
从正则表达式生成有限自动机
只写出词法规则,自动生成有限状态机。
词法分析器生成工具 lex(及 GUN 版本的 flex)也能够基于规则自动生成词法分析器。
思路:把一个正则表达式翻译成 NFA,然后把 NFA 转换成 DFA。
先说说 DFA,它是“Deterministic Finite Automaton”的缩写,即确定的有限自动机。它的特点是:该状态机在任何一个状态,基于输入的字符,都能做一个确定的状态转换。前面例子中的有限自动机,都属于 DFA。
再说说 NFA,它是“Nondeterministic Finite Automaton”的缩写,即不确定的有限自动机。它的特点是:该状态机中存在某些状态,针对某些输入,不能做一个确定的转换。
NFA 可能会存在试探不成功退回的过程,叫做回溯(backtracking)。
NFA 算法特点,极端情况下回溯可能特别多如“s*”这种语句。
有方法让 NFA 转 DFA,子集构造法。正则 -》 NFA -》 DFA来达到目的。