编译器介绍 --- 原理篇

编译器介绍 — 原理篇

这学期在学编译器,谨以此博客记录一下所学知识,并且也作为一个编译器的简单入门教程。

系列文章

  • 编译器介绍 — 原理篇
  • 编译器介绍 — 例子篇

文章目录

  • 编译器介绍 --- 原理篇
    • 概述
    • 前端
      • Lexer
      • Parser
      • Semantic Analysis
      • Translate
    • 汇合点
      • IR (intermediate representation)
    • 后端
      • Instruction Selection
      • Liveness Analysis
      • Register Allocation
        • 拓展
      • Code Emission

概述

首先我们需要对编译器有一个宏观的把握,可以想象,编译器本身一定是一个很庞大的软件,所以编译器大概率是分为多个模块的(模块化!),每个模块只负责某一方面的特定功能。整个编译器可以用如下这个图(我们的教授形象的称之为,compiler mountain)来展示。

编译器介绍 --- 原理篇_第1张图片
整个编译器大体上可以被分为"前端"和"后端"两大部分:

  • “前端” — “source language specific”,也就是和源代码使用的语言紧密相关的
    • 负责分析 (analysis),检查词法,语法,语义等错误,并产生IR树
    • 包含4大模块:词法分析 (Lexical analysis),语法分析 (Syntax analysis),语义分析 (Semantic analysis),翻译 (Translate)
  • 汇合点 — IR(intermediate representation)树
    • 前后端交汇的地方,与源程序语言无关,也与目标ISA架构无关
  • “后端” — “ISA specific”,ISA代表Instruction Set Architecture,也就是和我们最后编译生成的汇编语言相关的(如:MIPS,x86等不同架构)
    • 负责综合 (synthesis),将IR树转化为符合目标架构的汇编程序
    • 包含3大模块:指令选择 (Instruction Selection),实时变量分析 (Liveness Analysis),寄存器分配 (Register Allocation)

注:本系列文章使用的教材为"Modern Compiler Implementation in ML",编译器开发语言为SML,目标编译语言为Tiger(该书提出的一个简单语言),虽然不是C语言,但是相关知识都是通用的,只不过相对简化了一些。同时由于阅读学习时候使用的是英文教材,所以很多专有名词会使用英文来表示。

相关资料:

  • 教材 + 相关代码
  • tiger语言总结
  • Lexer specification
  • MIPS directive
  • SML/NJ错误信息总结

前端

Lexer

  • 输入输出:source program -> "Lexer" -> Tokens
  • 作用:将源程序分解成一系列的tokens(如:程序里面的str转换为Tokens.STR)
  • 介绍:Lexer又叫词法分析器,负责将源程序Tokenize的同时进行词法分析 (Lexical analysis),词法错误(如关键词为var但是写成了val)将会在这个阶段被捕捉并报告
  • 相关知识点:正则表达式 (regular expression),有限状态机 (FSA, finite state automaton)

Tokens就是一个程序的最小组成单元,比如a = b + c这行代码,就会转化为ID(a) ASSIGN ID(b) PLUS ID(c)这5个tokens。那么为什么要将一个程序转化为一列的token而不是直接处理?简化!抽象!

  1. 简化:一个程序里面有些内容是编译器不关心的,比如注释,所以就可以在词法分析这一步上,将这些(对编译器而言)无用的东西全部去掉;
  2. 抽象:利用token作为一个中间变量,就可以将两个组件(lexer和parser)连接起来,lexer不需要考虑后端所有的处理,只需要负责将源程序转化为一系列的tokens,同样的parser不需要去考虑源程序的结构等信息,只需要处理lexer生成的tokens;从而使得每个组件只负责一个特定的功能,并且不同组件间有一个合适的“交流”方式

Parser

  • 输入输出:Tokens -> "Parser" -> AST
  • 作用:解析程序的语法结构(如:哪些token一起构成了一个表达式)
  • 介绍:Parser又叫语法分析器,负责将一系列的tokens组合成有意义的程序语句并同时进行语法分析 (Syntax Analysis),语法错误 (如:括号不匹配,漏了分号等)将会在这个阶段被捕捉并报告
  • 相关知识点:上下文无关文法 (CFG, Context Free Grammar)

首先我们来看一下什么是AST (Abstract Syntax Tree,抽象语法树),针对a = b + c这条语句,将其转换为AST的结果就是如下,一个等号节点,子节点分别为等号左右两边的内容。
编译器介绍 --- 原理篇_第2张图片
我们之所以要把源程序转换为树的结构,是为了方便后序进行各种分析(不需要再对字符串进行操作)。

然后我们再来看看实现,仔细研究一下你会发现,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是以下几种情况之一:

  • terminal — 它是被定义语言中的一个token
  • non-terminal — 出现在了某些production的左手边

注意不能有任何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即可。

Semantic Analysis

  • 输入输出:AST -> "Semantic Analysis" -> AST
  • 作用:分析程序的含义 (如:表达式是否合法,a+b里面ab是否均为int)
  • 介绍:Semantic Analyser又叫语义分析器,各种语义错误将会在这个阶段被捕捉并报告,该阶段主要有两个功能:
    • “逃逸"分析 (escape analysis) — 由于tiger语言允许嵌套定义函数,所以存在一种情况就是函数f2里面使用了一个在函数f1里面定义的变量a,这种情况我们称变量a"逃逸”,针对"逃逸"的变量,我们需要将其保存在frame里面而不是寄存器里面
    • 语义分析 (semantic analysis) — 分析程序是否存在语义错误,如:在循环外写break,将字符串赋值给一个int型变量等
  • 相关知识点:类型论 (Type Theory)

注意:Semantic Analysis是捕获程序静态错误(语法语义等错误,并不包含死循环,数组越界等动态错误)的最后一个阶段,也就是说只要一个程序通过了该阶段,那么我们可以保证该程序一定是一个语法上正确的程序(即可以编译成一个正确的汇编语言程序)。

Translate

  • 输入输出:AST -> "translate" -> IR
  • 作用:将与源程序语言相关的AST"翻译"成与源程序语言无关的IR
  • 介绍:Translate负责将AST转换为IR,保留了一定的原有特性(如仍是"树"结构),也将部分特性转换为和目标汇编一样(如不再有多种类型,所有变量均为32比特整型)
特性 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 (intermediate representation)

  • 作用:连接编译器前后端
  • 介绍:IR是编译器前后端的汇合点,起到桥梁的作用,IR拥有自己的一套完整的抽象语法,该语法与前端的源程序和后端的ISA架构均无关

乍一看IR似乎显得比较多余,我们要先把AST转化为IR再把IR转化为具体的汇编指令,那么为什么不直接把AST转化为汇编指令呢?答案是,可以但会降低可拓展性。

针对我们这种情况 — 将tiger程序转化为MIPS,只有一种源语言和一种ISA,IR确实不是必要的,直接把AST转化为汇编指令甚至会更简单一点。那我们为什么还需要IR呢?可拓展性 & 解耦合,一个编译器可能面对将多种源语言,同时也可能需要将其转化为多种ISA(如:MIPS,x86等),假设我们有m种源语言,n种目标ISA,没有IR的话总共有 m ∗ n m * n mn个排列组合,但是引入IR之后,前端只需要考虑如何将源程序转化为IR,后端只需要考虑如何将IR转化为目标ISA,前后端解耦合使得只有 m + n m + n m+n个排列组合。
编译器介绍 --- 原理篇_第3张图片

后端

Instruction Selection

  • 输入输出:IR -> "Canonicalize" -> "Instruction Selection" -> Infinite Registers MIPS
  • 作用:将IR树"规范化"后转换为对应的MIPS汇编程序
  • 介绍:
    • Canonicalize的代码书本已经给出,主要作用有3个
      • 线性化 (linearize) — 去掉所有的SEQ和ESEQ,并使得所有CALL指令的父指令均为EXP或者MOVE (即不存在一个CALL指令含有另一个CALL指令作为参数)
      • 模块化 (basic blocks) — 将程序代码模块化,每个模块最开始添加一个LABEL,并且模块一定以JUMP或CJUMP结尾
      • 重新排列 (trace schedule) — 将模块化后的程序进行重新排列,使得每一个CJUMP指令后面紧接着的一定是false LABEL (便于后面将其改为汇编语言"跳转 或 继续执行"的形式)
    • 指令选择 (instruction selection)
      • 方法:maximum munch — 将尽可能大的子树转化为一条指令 (见例子)
      • 注意有两个问题我们我们并不需要在这个阶段考虑 (会在register allocation阶段解决)
        • 寄存器数量问题 — 我们可以假设有无穷多个寄存器,所以每次遇到新的变量可以直接声明一个新的寄存器保存 (所以叫infinite registers MIPS)
        • move指令必要性问题 — 我们不需要考虑move指令有没有必要,会不会影响性能,比如每次调用一个函数,我们都默认会将返回值移动到一个新的寄存器里面move t168, $v0

下面我们借助一个例子来理解一下什么是"maximum munch",下面这是一颗IR树,我们的目标就是将其转化为尽可能少的汇编语言 (提高性能):

编译器介绍 --- 原理篇_第4张图片
我们先将树的右半部分简化成一个单独的寄存器,得到下图上面的那个简化版的树。此时我们有两个选项 (都是可行的,性能有差异),1) 单独处理每一个节点,如左下,遇到一个CONST就先用一个li指令将其加载到一个寄存器内;2) 使用maximum munch,将尽可能大的子树转化为一条指令,如右下,整个子树可以转换为一个sw指令。

编译器介绍 --- 原理篇_第5张图片

  • 左下结果
li  t100, 5         # 1
add t101, t100, 168 # 2
sw  t169, t168      # 3
  • 右下结果 — sw t169, 5(t168)

我们可以看到两种方式最后都实现了相同的功能,但是后一种方式明显性能会更好。使用相同的方法,将一开始的例子进行如下的划分:

编译器介绍 --- 原理篇_第6张图片

并按顺序转换为汇编程序:

addi t170, $zero, 6   # 1
lw   t100, 8(t169)    # 2
mul  t101, t100, t170 # 3
sw   t101, 5(t168)    # 4

总结,从这个阶段开始,做的事情就是与ISA相关的了,需要我们对目标汇编语言的语法和语句有所了解,灵活运用使得能将一个完整的IR树转换为尽可能少的汇编程序。

Liveness Analysis

  • 输入输出:Infinite Registers MIPS -> "Liveness Analysis" -> Interference Graph
  • 作用:分析每个变量/寄存器的生命周期,从而产生一个相交图 (interference graph)
  • 介绍:Liveness分析主要是为了下一阶段的寄存器分配做准备,最后生成一个相交图,图中每个节点均代表一个寄存器 (亦即程序中的一个变量),如果一条指令写入了某个寄存器t100,则我们会在t100和该指令所有的live-out变量 (即后面的指令可能会使用到的变量)间都添加一条线,因为变量t100和所有的live-out变量不能同时保存在同一个寄存器内
  • 相关知识点:数据流分析 (Dataflow Analysis)

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 (用汇编语言表示):
编译器介绍 --- 原理篇_第7张图片
有了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的信息是从后往前传递的)。这也就是我们所说的"数据流分析"。

  • L i v e O u t [ B ] = ⋃ S ∈ s u c c e s s o r [ B ] L i v e I n [ S ] LiveOut[B] = \bigcup_{S \in successor[B]} LiveIn[S] LiveOut[B]=Ssuccessor[B]LiveIn[S]
  • L i v e I n [ B ] = u s e [ B ] ∪ ( L i v e O u t [ B ] − d e f [ B ] ) LiveIn[B] = use[B] \cup (LiveOut[B] - def[B]) LiveIn[B]=use[B](LiveOut[B]def[B])

根据上述方法,我们可以计算出每一个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为例:
编译器介绍 --- 原理篇_第8张图片
从最后一条指令开始,用同样的公式,计算出每一条指令的live-out,然后在每一条指令定义/写入的变量和该条指令的所有live-out变量 (不包括他自己,假如是move指令的话,也不包括src)之间添加一个连接。遍历完该block之后我们就得到了如下的一个igraph,可以看到t175和t171之间有一条连线,而和t174之间没有连线,意味着t175和t174可以被映射到同一个寄存器 (因为他们两个的生存周期没有相互重叠),但是和t171不行。

编译器介绍 --- 原理篇_第9张图片

注意:CFG是一个有向图,而interference graph是一个无向图

Register Allocation

  • 输入输出:Interference Graph -> "Register Allocation" -> Allocation Map
  • 作用:根据Liveness analysis生成的相交图,判断每一个寄存器是否溢出 (spill),若溢出则在stack frame上面分配一个地址,否则分配一个真正的寄存器
  • 介绍:该阶段主要负责三个功能
    • 寄存器分配 (register allocation) — 这是最基础也是最主要的功能,作用是将前面阶段产生的infinite registers MIPS转换为实际的合法MIPS程序,这就需要将其中的所有寄存器,映射到机器上面的实际寄存器。注意只要两个变量不是同时存在 (live),那么它们可以被保存到同一个寄存器当中,而这个live的信息,我们已经在liveness analysis里面分析过;
    • “合并 (coalesce)” — 前面的阶段为了方便起见,我们会产生很多的move指令,但其实很多move指令是可以去掉的,比如两条指令addi t100, $zero, 5 & move $v0, t100其实可以合并为一条addi $v0, $zer0, 5从而去掉多余的move指令
    • “溢出 (spill)” — 一些很复杂的程序需要的寄存器数量可能会超过机器拥有的数量,这种情况我们称之为"溢出 (spill)",此时需要将程序的某些变量值保存到内存里面,而不是寄存器里面,同时改写源程序
      • 在每条使用该变量的指令前面插入一条lw指令
      • 在每条写入改变量的指令后面插入一条sw指令
      • 改写完成之后,重新进行寄存器分配
  • 相关知识点:图着色 (Graph Coloring)

图着色 (K-coloring)问题就是:给定一个无向图和一定数量的颜色 (K个),问是否能够给图里的每一个节点分配一个颜色,使得每个节点与其所有相邻节点的颜色各不相同

具体到register allocation的整体实现流程如下图所示:

编译器介绍 --- 原理篇_第10张图片

以下为每个阶段的作用和介绍:

首先定义一些术语:

  • trivial — 即一个数 (可以是degree,也可以是数量),小于颜色的数量 (亦即目标架构可以使用的寄存器数目)
  • significanttrivial的对应,数大于颜色的数量
  • spill — “溢出”,亦即需要将一个变量保存到frame里面而不是register里面
  • igraph — interference graph, 相交图
  • color — 图着色里面的颜色,对应到这里就是具体寄存器的名字 (字符串)

下面是具体流程分析:

  • build — 即利用liveness analysis,产生一个igraph,并设置好每个node的相关属性
    • interference graph的每个节点会有两个属性:
      • isPreColored — 目标架构的所有寄存器都是pre-color的,如MIPS的32个寄存器 (caller-save, callee-save, arguments, return value etc.)
      • isMoved — 该节点是否和任意move指令相关
  • simplify — 将igraph中所有"trivial degree"的节点移除,并放到stack里面(保存相邻信息)
    • trivial degree指的是该节点的连线数量 (degree)小于颜色的数量
    • 之所以可以这样做是因为,假如一个节点的degree小于颜色的数量 (即相邻节点数量小于可用颜色的数量),那么我们只要能成功color其相邻节点,那么该节点一定可以被成功color
    • 注意这里不包括所有isPreColoredisMovedtrue的节点
      • simplify的意义是我们暂时还不知道该节点是什么颜色,后面会分配颜色,但是所有的机器寄存器已经有了固定的名字/颜色,我们不可以重命名,所以pre-colored的节点都不能simplify;
      • move相关的节点我们想尽可能的将他们合并成一个节点,而不是分开单独上色,所以暂时也不能simplify
    • simplify阶段不断的循环直到没有节点可以simplify (因为我们remove掉一个节点后,所有相邻节点的degree都会减一,即可能有更多的节点可以simplify,所以需要不断的循环),simplify阶段结束后:
      • 如果只剩下pre-colored节点,那么第一阶段完成,跳转到rebuild阶段
      • 如果还有除了pre-colored节点之外的节点剩余,那么跳转到coalesce阶段,尝试合并move节点
  • coalesce — 尝试将两个move相关的节点合并成一个,合并的意思就是这两个节点可以是同一个颜色 (如如果我们发现move t100, t101这个指令是多余的,那么就可以把t100和t101都分配到$t0,然后去掉这个move指令)
    • 合并两个节点需要十分小心,不然可能会不经意间引入额外的"溢出",比如两个节点各自只有20个相邻节点,是可以正常color的,但是合并之后有40个相邻节点导致不能color从而溢出,而从性能的角度出发,我们希望能尽可能的避免"溢出"情况的出现 (起码避免由于我们的实现引起的额外"溢出")。判断两个节点合并后会不会引入额外的"溢出"是一个NP complete问题,但是我们有两个保守的heuristic可以帮忙进行判断;保守的意思就是说,假如我们说可以合并那么合并一定是安全的 (不会引入额外的"溢出");假如我们说不可以合并,则合并不一定是不安全的。
      • Brigs: 如果节点a和b合并后的ab节点,拥有trivial个significant degree的相邻节点,那么a & b可以进行合并;因为所有trivial degree的相邻节点都可以被simplify掉,然后剩下的节点数目比可用颜色数目少,所以保证一定可以color合并后的节点;
      • George: 如果节点a的所有相邻节点,要么1) 也和节点b相邻,或者2) 是trivial degree的;那么节点a & b可以进行合并;因为合并后所有trivial degree的相邻节点可以被simplify掉,剩余节点本身就与b相邻,所以保证不会引入新的"溢出";
      • 注意这两个的描述,Brigs是无顺序的,即Brigs(a, b) = Brigs(b, a),但是George是有顺序的,即George(a, b) ?= George(b, a);而只要这两个heuristic有任意一个返回true,那么我们就可以合并a,b两个节点
    • 同时注意,有两种情况是一定不能合并的:
      • 两个pre-colored的节点,因为两个机器寄存器不可能被合并成一个
      • 两个节点间本来就有一条连线,亦即两个节点彼此interfere
    • coalesce阶段,只要合并了任意两个节点,就返回simplify阶段 (因为合并后可能有更多的其他节点可以simplify);假如没有任何两个节点可以合并,则进到下一个阶段,unfreeze
  • unfreeze — 选择任意一个move edge,去掉它
    • 进入这个阶段,整个程序处于一种freeze的状态 — 既没有节点可以简化,也没有节点可以合并;此时我们就任意选取一个move edge,将其去掉 (其含义就是,这两个节点不可能被映射到同一个寄存器上)。
    • 一旦去掉任意一个move edge之后,就返回simplify阶段;但假如该阶段发现已经没有move edge了,那么就到下一个阶段,potential spill。
  • potential spill — 选择"任意"一个节点,将其去掉(放到stack里面)并标记为potential spill
    • 进入这个阶段就说明程序可能较为复杂,可能需要用到的寄存器数量比实际可用的更多,所以我们选取一个节点,将其标记为potential spill
    • 注意几点:
      • 这里说"任意"选取,其实也不是完全"任意",我们更希望选择一个访问次数较少的变量,而不是一个较多的 (比如我们会希望选取一个和循环无关的变量),因为每一次读写都会涉及到内存操作,会降低程序速度
      • 这里说的是potential,因为现阶段实际并不确定该节点是否一定会spill (要到rebuild阶段才能最后确定)
    • potential spill阶段去掉选取的节点后,返回simplify阶段继续尝试简化igraph
  • rebuild — 根据stack里面的变量,重建整个相交图,并给每一个节点上色
    • 从stack里面逐个节点pop出来,并重新加到igraph里面去,同时根据所有相邻节点的颜色,给该节点分配一个不同的颜色
    • 正常情况下,我们可以重复该过程直到给每个节点都分配了一个颜色,但假如遇到一个节点无法分配颜色 (即相邻节点已经使用完了所有可用颜色),那么就到actual spill阶段
    • 重构完整个igraph之后,假如存在没有颜色的节点 (spill),那么就到rewrite阶段;反之,register allocation完成
  • actual spill — 将该节点标记为spill (不是potential了)
    • 将不能color的节点标记为spill之后 (亦即没有颜色),返回rebuild阶段,继续color剩余节点
  • rewrite — 重写整个程序
    • 假设节点t100 (寄存器t100)被标记为spill,那么在每个读取了t100寄存器的指令前面加入一个lw指令,在每个写入了t100寄存器的指令后面加入一个sw指令
    • 重写完程序后,重新回到build阶段,重跑整个流程
拓展

整个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分配就不会的情况。

Code Emission

在上一阶段,我们已经成功获得了一个map,其中key为程序 (infinite registers MIPS)里面使用到的所有寄存器,value为映射到的实际寄存器值,如 t100 -> $a0。在这个阶段我们就只需要做一个简单的替换即可,将所有t100换为$a0,同时对move指令检查一下src和dst是否被映射到了同一个机器寄存器,假如是的话,直接去掉该条move指令。


至此,整个编译器各个组件的作用和部分原理就已经介绍完毕了,本篇文章由于讲的是偏原理性的东西,会比较抽象,下一篇会用一个简单的程序作为例子,带大家一起看一看编译器每个阶段的实际输出是长什么样的,看看一个程序是如何从高级语言一步步被翻译成汇编语言的。

你可能感兴趣的:(专业学习,编译器)