文章目录
- 编译器介绍 --- 原理篇
- 概述
- 前端
- Lexer
- Parser
- Semantic Analysis
- Translate
- 汇合点
- IR (intermediate representation)
- 后端
- Instruction Selection
- Liveness Analysis
- Register Allocation
- 拓展
- Code Emission
概述
首先我们需要对编译器有一个宏观的把握,可以想象,编译器本身一定是一个很庞大的软件,所以编译器大概率是分为多个模块的(模块化!),每个模块只负责某一方面的特定功能。整个编译器可以用如下这个图(我们的教授形象的称之为,compiler mountain)来展示。
整个编译器大体上可以被分为"前端"和"后端"两大部分:
- “前端” — “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语言,但是相关知识都是通用的,只不过相对简化了一些。同时由于阅读学习时候使用的是英文教材,所以很多专有名词会使用英文来表示。
前端
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而不是直接处理?简化!抽象!
- 简化:一个程序里面有些内容是编译器不关心的,比如注释,所以就可以在词法分析这一步上,将这些(对编译器而言)无用的东西全部去掉;
- 抽象:利用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的结果就是如下,一个等号节点,子节点分别为等号左右两边的内容。
我们之所以要把源程序转换为树的结构,是为了方便后序进行各种分析(不需要再对字符串进行操作)。
然后我们再来看看实现,仔细研究一下你会发现,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的话总共有个排列组合,但是引入IR之后,前端只需要考虑如何将源程序转化为IR,后端只需要考虑如何将IR转化为目标ISA,前后端解耦合使得只有个排列组合。
后端
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树,我们的目标就是将其转化为尽可能少的汇编语言 (提高性能):
我们先将树的右半部分简化成一个单独的寄存器,得到下图上面的那个简化版的树。此时我们有两个选项 (都是可行的,性能有差异),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树转换为尽可能少的汇编程序。
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 (用汇编语言表示):
有了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:
![在这里插入图片描述](
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是一个无向图
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的整体实现流程如下图所示:
以下为每个阶段的作用和介绍:
首先定义一些术语:
-
trivial
— 即一个数 (可以是degree,也可以是数量),小于颜色的数量 (亦即目标架构可以使用的寄存器数目) -
significant
—trivial
的对应,数大于颜色的数量 -
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
- 注意这里不包括所有
isPreColored
和isMoved
是true
的节点
- 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指令。
至此,整个编译器各个组件的作用和部分原理就已经介绍完毕了,本篇文章由于讲的是偏原理性的东西,会比较抽象,下一篇会用一个简单的程序作为例子,带大家一起看一看编译器每个阶段的实际输出是长什么样的,看看一个程序是如何从高级语言一步步被翻译成汇编语言的。