这学期在学编译器,谨以此博客记录一下所学知识,并且也作为一个编译器的简单入门教程。
系列文章
首先我们需要对编译器有一个宏观的把握,可以想象,编译器本身一定是一个很庞大的软件,所以编译器大概率是分为多个模块的(模块化!),每个模块只负责某一方面的特定功能。整个编译器可以用如下这个图(我们的教授形象的称之为,compiler mountain)来展示。
注:本系列文章使用的教材为"Modern Compiler Implementation in ML",编译器开发语言为SML,目标编译语言为Tiger(该书提出的一个简单语言),虽然不是C语言,但是相关知识都是通用的,只不过相对简化了一些。同时由于阅读学习时候使用的是英文教材,所以很多专有名词会使用英文来表示。
相关资料:
source program -> "Lexer" -> Tokens
var
但是写成了val
)将会在这个阶段被捕捉并报告Tokens就是一个程序的最小组成单元,比如a = b + c
这行代码,就会转化为ID(a) ASSIGN ID(b) PLUS ID(c)
这5个tokens。那么为什么要将一个程序转化为一列的token而不是直接处理?简化!抽象!
Tokens -> "Parser" -> AST
首先我们来看一下什么是AST (Abstract Syntax Tree,抽象语法树),针对a = b + c
这条语句,将其转换为AST的结果就是如下,一个等号节点,子节点分别为等号左右两边的内容。
我们之所以要把源程序转换为树的结构,是为了方便后序进行各种分析(不需要再对字符串进行操作)。
然后我们再来看看实现,仔细研究一下你会发现,Parser实际上也是实现类似一个"匹配"操作,如找到ID(b) PLUS ID(c)
这样的结构,然后转化为一个加法节点,到这里,你可能会好奇,既然也是一个“匹配”操作,那么我们可不可以继续使用正则表达式呢?答案是不可以,看以下这个例子:
这是一个正则表达式 (带有简化功能,即用digits代表[0-9]+
)
digits = [0-9]+
sum = (digits "+")* digits
sum
可以用来匹配所有的加法表达式,如1 + 3 + 5
。至此好像并没有什么问题,继续往下看:
digits = [0-9]+
sum = expr "+" expr
expr = "(" sum ")" | digits
还是加法表达式,只不过引入了括号,从而可匹配如(1 + 3)
或 (2 + (4 + 5))
。注意,我们虽然写的是正则表达式,但实际上工具内部是使用FSA(有限状态机)进行实现的,但是FSA不具备检测括号是否匹配的功能,因为一个N个状态的FSA是无法检测嵌套深度超过N的括号的。显然,单纯的“简化”并没有增加正则表达式的能力,也就是说并不能定义更多的"语言",除非支持“递归”,而这个正是我们parser需要的能力。
至此就可以引入我们的新工具 — CFG (Context Free Grammar),用来定义程序的语法。相比较于正则表达式,CFG具备递归的特性,一个CFG (上下文无关文法) 定义了一种"语言",一个文法包含一系列的productions,具有如下的格式:
symbol -> symbol symbol ... symbol
每一个production代表可以用箭头右边的东西替代箭头左边的东西,这里每一个symbol是以下几种情况之一:
注意不能有任何token出现在production的左手边,同时其中一个nonterminal会被识别为起始symbol (通常是第一个production左手边的symbol),亦即从那个开始分析。
以下是一个例子,定义了数字间的乘法和加法:
E -> E + T
| T
T -> T * F
| F
F -> ( E )
| num
为了得到num * num + num + num
这样的一个表达式,我们可以从start symbol E开始,一步步替换:
E
E + T (E -> E + T)
E + T + T (E -> E + T)
T + T + T (E -> T)
T * F + T + T (T -> T * F)
F * F + F + F (T -> F)
num * num + num + num (F -> num)
所以Paser的任务就是根据程序的语法,构建出对应的CFG语句,这里哥大有一个pdf做了很好的总结,我们只需要根据这个翻译成对应的CFG即可。
AST -> "Semantic Analysis" -> AST
注意:Semantic Analysis是捕获程序静态错误(语法语义等错误,并不包含死循环,数组越界等动态错误)的最后一个阶段,也就是说只要一个程序通过了该阶段,那么我们可以保证该程序一定是一个语法上正确的程序(即可以编译成一个正确的汇编语言程序)。
AST -> "translate" -> IR
特性 | AST | IR | Assembly |
---|---|---|---|
类型 | int, string, array… | 32 bit int | 32 bit int |
结构 | tree of AST node | tree of IR node | sequence of instructions |
变量范围 | 嵌套范围 (不同函数嵌套) | 全局范围 | 全局范围 |
可用变量 | ∞ | ∞ | ~25 |
控制语句 | if, else, while… | 2 target jump | 1 target + fallthrough |
乍一看IR似乎显得比较多余,我们要先把AST转化为IR再把IR转化为具体的汇编指令,那么为什么不直接把AST转化为汇编指令呢?答案是,可以但会降低可拓展性。
针对我们这种情况 — 将tiger程序转化为MIPS,只有一种源语言和一种ISA,IR确实不是必要的,直接把AST转化为汇编指令甚至会更简单一点。那我们为什么还需要IR呢?可拓展性 & 解耦合,一个编译器可能面对将多种源语言,同时也可能需要将其转化为多种ISA(如:MIPS,x86等),假设我们有m种源语言,n种目标ISA,没有IR的话总共有 m ∗ n m * n m∗n个排列组合,但是引入IR之后,前端只需要考虑如何将源程序转化为IR,后端只需要考虑如何将IR转化为目标ISA,前后端解耦合使得只有 m + n m + n m+n个排列组合。
IR -> "Canonicalize" -> "Instruction Selection" -> Infinite Registers MIPS
move
指令必要性问题 — 我们不需要考虑move
指令有没有必要,会不会影响性能,比如每次调用一个函数,我们都默认会将返回值移动到一个新的寄存器里面move t168, $v0
下面我们借助一个例子来理解一下什么是"maximum munch",下面这是一颗IR树,我们的目标就是将其转化为尽可能少的汇编语言 (提高性能):
我们先将树的右半部分简化成一个单独的寄存器,得到下图上面的那个简化版的树。此时我们有两个选项 (都是可行的,性能有差异),1) 单独处理每一个节点,如左下,遇到一个CONST就先用一个li
指令将其加载到一个寄存器内;2) 使用maximum munch,将尽可能大的子树转化为一条指令,如右下,整个子树可以转换为一个sw
指令。
li t100, 5 # 1
add t101, t100, 168 # 2
sw t169, t168 # 3
sw t169, 5(t168)
我们可以看到两种方式最后都实现了相同的功能,但是后一种方式明显性能会更好。使用相同的方法,将一开始的例子进行如下的划分:
并按顺序转换为汇编程序:
addi t170, $zero, 6 # 1
lw t100, 8(t169) # 2
mul t101, t100, t170 # 3
sw t101, 5(t168) # 4
总结,从这个阶段开始,做的事情就是与ISA相关的了,需要我们对目标汇编语言的语法和语句有所了解,灵活运用使得能将一个完整的IR树转换为尽可能少的汇编程序。
Infinite Registers MIPS -> "Liveness Analysis" -> Interference Graph
Liveness Analysis主要包含两个阶段,1) 生成CFG (control-flow graph),即控制流图; 2) 根据CFG和live-out信息,产生igraph (interference graph)
1) CFG + live-out
CFG就是控制流图,该图中每一个节点均为一个basic block (即程序只能从一个地方进入该block,另一个地方离开该block,换句话说每一个jump和branch指令都会终结一个basic block),节点间的连线代表了程序的可能执行路径。我们看一个简单的例子:
这是一个简单的tiger程序,声明了两个变量,然后一个if语句
let
var x := 3
var y := 4
in
if x > y then x + 2 else y + 3
end
这是该程序对应的CFG (用汇编语言表示):
有了CFG之后,我们还需要计算每一个basic block的live-out变量,这里用到的一个方法就是"iterate to a fix point (迭代到固定点)",就是我们先假设每一个basic block的live-out一开始都是空集,然后根据最新信息,不断地更新这个结果,直到某一次迭代过程中没有发生任何改变。其中LiveIn和LiveOut的计算公式如下所示:使用一个变量,会产生liveness; 定义一个变量,会杀死liveness。由于最后一个block (含有jr $ra
)没有后续的block,所以其live-out是空集。从哪一个节点开始迭代,最终都会得到同一个结果,但是假如从最后一个节点开始,顺着程序执行的反方向进行迭代,这样的收敛速度会最快 (liveness的信息是从后往前传递的)。这也就是我们所说的"数据流分析"。
根据上述方法,我们可以计算出每一个block的live-out:
![在这里插入图片描述](https://img-blog.csdnimg.cn/20210425231235406.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2tld2VpMTY4,size_16,color_FFFFFF,t_70
2) interference graph
得到了CFG和live-out信息之后,我们就可以先构建一个空的igraph,包含所有需要的节点 (遍历所有指令即可得到),但是没有任何的连接,然后逐block的处理,添加连接。这里以第一个block为例:
从最后一条指令开始,用同样的公式,计算出每一条指令的live-out,然后在每一条指令定义/写入的变量和该条指令的所有live-out变量 (不包括他自己,假如是move指令的话,也不包括src)之间添加一个连接。遍历完该block之后我们就得到了如下的一个igraph,可以看到t175和t171之间有一条连线,而和t174之间没有连线,意味着t175和t174可以被映射到同一个寄存器 (因为他们两个的生存周期没有相互重叠),但是和t171不行。
注意:CFG是一个有向图,而interference graph是一个无向图
Interference Graph -> "Register Allocation" -> Allocation Map
move
指令,但其实很多move
指令是可以去掉的,比如两条指令addi t100, $zero, 5
& move $v0, t100
其实可以合并为一条addi $v0, $zer0, 5
从而去掉多余的move
指令lw
指令sw
指令图着色 (K-coloring)问题就是:给定一个无向图和一定数量的颜色 (K个),问是否能够给图里的每一个节点分配一个颜色,使得每个节点与其所有相邻节点的颜色各不相同
具体到register allocation的整体实现流程如下图所示:
以下为每个阶段的作用和介绍:
首先定义一些术语:
trivial
— 即一个数 (可以是degree,也可以是数量),小于颜色的数量 (亦即目标架构可以使用的寄存器数目)significant
— trivial
的对应,数大于颜色的数量spill
— “溢出”,亦即需要将一个变量保存到frame里面而不是register里面igraph
— interference graph, 相交图color
— 图着色里面的颜色,对应到这里就是具体寄存器的名字 (字符串)下面是具体流程分析:
isPreColored
— 目标架构的所有寄存器都是pre-color的,如MIPS的32个寄存器 (caller-save, callee-save, arguments, return value etc.)isMoved
— 该节点是否和任意move指令相关isPreColored
和isMoved
是true
的节点
move t100, t101
这个指令是多余的,那么就可以把t100和t101都分配到$t0
,然后去掉这个move指令)
Brigs(a, b) = Brigs(b, a)
,但是George是有顺序的,即George(a, b) ?= George(b, a)
;而只要这两个heuristic有任意一个返回true,那么我们就可以合并a,b两个节点lw
指令,在每个写入了t100寄存器的指令后面加入一个sw
指令整个register allocation包括liveness analysis我们做的都是intraprocedure的,亦即是对每一个函数单独做allocation;与之对应的还有一种叫做interprocedure register allocation,这个就是对整个程序,所有函数一起做register allocation。
相比较而言,interprocedure出来的结果会更好,但是实现起来也会更加的复杂。两者间一个主要的区别在于,intraprocedure的话由于每个函数彼此独立,并不知道其他函数的具体实现,亦即是说并不知道其他函数会用到哪些寄存器,为此的解决方案为calling convention,亦即每次调用一个函数,默认该函数会修改所有的a (argument), t (caller-save), v (return value)寄存器,所以假如一个变量的生存周期需要横跨函数调用的话,我们希望将其保存在s (callee-save)寄存器 (而不是t寄存器)里面;
但是interprocedure的话,由于是全局分配,我们可以知道每个函数具体使用了哪些寄存器 (这时候不再有calling convention了,t, s寄存器甚至a寄存器都已经没区别了),假如调用一个函数,并且知道它只会写入t0寄存器,那么就可以将变量保存到t1寄存器里面,因为我们知道t1寄存器肯定不会被修改。因为在interprocedure里面,每个变量相对会和更少的机器寄存器相交,所以可能一个同一个程序,使用intraprocedure分配,会涉及变量溢出,但使用interprocedure分配就不会的情况。
在上一阶段,我们已经成功获得了一个map,其中key为程序 (infinite registers MIPS)里面使用到的所有寄存器,value为映射到的实际寄存器值,如 t100 -> $a0
。在这个阶段我们就只需要做一个简单的替换即可,将所有t100换为$a0,同时对move
指令检查一下src和dst是否被映射到了同一个机器寄存器,假如是的话,直接去掉该条move指令。
至此,整个编译器各个组件的作用和部分原理就已经介绍完毕了,本篇文章由于讲的是偏原理性的东西,会比较抽象,下一篇会用一个简单的程序作为例子,带大家一起看一看编译器每个阶段的实际输出是长什么样的,看看一个程序是如何从高级语言一步步被翻译成汇编语言的。