这篇文章上次修改于 614 天前,可能其部分内容已经发生变化,如有疑问可询问作者。
视频链接:CS143 编译器
1 词法分析
识别 token,例如关键字、标识符、数字、操作符等。
- 正则文法
有限自动机
- 确定性有限自动机 DFA,每个输入只对应一个状态,转换过程中没有 epsilon。解析速度快,占用空间大。
- 不确定性有限自动机 NFA。解析速度慢,占用空间小。
- NFA 可以转为 DFA,需要找到每个状态的 epsilon 闭包。
- 实现:一个表格,行是状态,列是输入。可以进行状态压缩。
2 语法分析
生成程序的解析树。
歧义:会生成两种解析树
- 不常用:改写语法,但是没有自动的方式去改写,只能手工转换,而且会使语法更加复杂和难以阅读。
- 常用:通过一些方式消除歧义,比如定义优先级和结和性声明。
- 上下文无关文法 CFG:可以表达嵌套,例如 ((()))
- AST:简化的解析树,只保留所需。
多种解析方式
- 递归下降:容易实现,但是一旦成功匹配非终结符 X 的某条生成式,便无法再回溯去尝试 X 的其它生成式。可以改写语法,将左公因式提取出来。
- 最左推导:存在无限递归问题,可以改写语法,改为右递归语法。
- LL(k):从左到右查看 token,最左推导。先求出 first set 和 follow set,然后构建解析表格。大多数 CFG 都不是 LL(k) 文法,因为可能存在一个表格中有多个选择的情况。
LR(k):从左到右查看 token,最右推导。自底向上,移动规约,过程中需要识别 handle(有效 item),可以通过 NFA 和栈识别 handle 可行前缀,将 NFA 转为 DFA。
- 问题:存在 reduce/reduce 冲突,shift/reduce 冲突
SLR(simple LR),对 LR(0) 的改进,在 shift 或 reduce 时加入一些引导提示,以减少冲突状态。
- 对于 X -> b.,下一个输入符号为 t,当 t 属于 follow(X),则 reduce。
- 如果还冲突,如 S -> SaS,则不是 SLR 语法。可以通过声明优先级等解决。
- 只使用 DFA 和 input,没有用到 stack symbol。
- SLR(1) 不常用,LR(1) 会更强大一些,将向前看的能力内置到 item 中。
- LALR(1) 是对 LR(1) 的优化。
3 语义分析
编译器前端的最后一关,可捕获前面两关无法捕获到的错误,因为有些语言不是上下文无关的,例如,(e1: int ^ e2: int) => e1 + e2: int
- 可以进行一些检查,例如:所有标识符都已经被声明了;类型;继承关系;类和类中的方法都只被定义了一次;保留关键字没有被无用;等。
- 作用域:静态、动态;类、方法等
- 大多数的语义分析都是在一个 AST 上进行递归下降分析。
- 符号表:在分析 AST 时追踪标识符。可通过栈实现一个简单的符号表。
类型:定义了哪些操作在哪些类型上是有效的。
- 在 AST 上进行自底向上计算而得。
分类:
- 静态:编译时检查,检测多数错误
- 动态:运行时检查,dynamic_type(E) <= static_type(E),比如多态
- 无类型(比如机器码)
- 类型检查:检查类型是否正确。
- 类型推断:补充缺失的类型。
- 类型环境:是标识 -> 类型的函数,可以给表达式中的变量一个类型。
- 子类型:是另一个类型的子类型,比如多态
- self type:在多态的情况下,返回自身
- no type:对于所有类型,No_type <= C。用于当一个类型没被定义时,其类型为 No_type,避免发生更多级联错误。但是在实现时,会导致类型体系不再是树,所以还是用 Object,而不是 No_type。
4 运行时系统 & 代码生成
运行时组织
- 运行时资源管理
- 编译时和运行时之间的关联
- 存储组织:低地址 -> 高地址,code,data。data:static data(存放全局变量、静态变量),stack(存放 AR),heap(存放生命周期长于作用域的变量等)
- 程序的运行是在 OS 的控制之下的。当一个代码运行时:OS 分配空间,加载代码到空间,OS 跳转到程序入口,如 main。
- 两个假设:运行是顺序的;当一个程序被调用后,控制总是会回到调用之前的地方。
- 活动:P 的调用过程被称为 P 的活动。活动树依赖于运行时行为。因为活动树可以嵌套,所以用栈跟踪比较好。
- 活动记录 AR:如果 F 调用 G,那么 G 的活动记录包含 F 和 G 的信息,因为需要回到 F。AR 包括 result,arg,return address。将 result 放在第一个位置,调用者就可以通过自身栈的固定位移找到它。
- AR 布局和代码生成必须一起设计。因为在编译时,生成的代码需要正确地访问 AR。
- 对齐:大多数机器是 32 或 64 bit,一个 word 中有 4 或 8 字节。word 对齐可以提高访问速度。
栈机:唯一的存储是栈。相对于寄存器机,程序更紧凑,因为所有的操作都在栈顶,所以指令中不需要出现操作数。
- n-register 栈机:将栈顶的 n 项存到寄存器。
- 1-register 栈机中的 register 称为 accumulator,还可存储返回结果。
- 代码生成:使用栈机、accumulator、MIPS 指令集。
- MIPS:accumulator 保存在 $a0。栈存储在内存中,向低地址增长。栈的下一个位置保存在 $sp 中,栈顶是 $sp + 4。$fp,frame pointer,指向当前活动,栈底,这样参数等就可以有一个固定位移。
MIPS 指令:
- lw reg1 offset(reg2):load 32-bit word reg1 <- reg2 + offset
- add reg1 reg2 reg3:reg1 <- reg2 + reg3
- sw reg1 offset(reg2):store 32-bit word reg1 -> reg2 + offset
- adddiu reg1 reg2 imm:reg1 <- reg2 + imm
- li reg imm:reg <- imm
- sub reg1 reg2 reg3:reg1 <- reg2 - reg3
- beq reg1 reg2 label:如果 reg1 = reg2,则跳转到 label
- b label:无条件跳转到 label
- jal label:跳转到 label,将下一条指令的保存保存到 $ra
- jr reg:跳转到寄存器 reg 中包含的位置
代码生成模板
cgen(i)
li $a0 i
cgen(e1 + e2)
cgen(e1) sw $a0 0($sp) // 将 e1 的结果保存到栈中 addiu $sp $sp -4 // push cgen(e2) lw $t1 4($sp) // 将 e1 的结果从栈中取出 add $a0 $t1 $a0 addiu $sp $sp 4 // pop,恢复栈
cgen(if e1 = e2 then e3 else e4)
sw $a0 0($sp) // 将 e1 的结果保存到栈中 addiu $sp $sp -4 cgen(e2) lw $t1 4($sp) // 将 e1 的结果从栈中取出 addiu $sp $sp 4 // pop beq $a0 $t1 true_branch // 比较 e1 和 e2 false_branch: cgen(e4) b end_if true_branch: cgen(e3) end_if:
cgen(f(e1, ..., en))
sw $fp 0($sp) // 保存 old fp addiu $sp $sp -4 cgen(en) sw $a0 0($sp) // 保存 en 结果 addiu $sp $sp -4 ... cgen(e1) sw $a0 0($sp) // 保存 e1 结果 addiu $sp $sp -4 jal f_entry // 调用者将返回地址保存到 $ra,跳转到 f_entry
cgen(def f(x1, ..., xn) = e)
f_entry: move $fp $sp // 保存 old fp sw $ra 0($sp) // 保存返回地址 addiu $sp $sp -4 cgen(e) // 函数体 lw $ra 4($sp) // 加载返回地址 addiu $sp $sp z // 恢复栈,z = 4 * n + 8,包含返回地址和 old fp lw $fp 0($sp) // 恢复 fp jr $ra // 跳转到返回地址
cgen(xi),xi 是第 i 个参数
lw $a0 z($fp) // z = 4 * i
- 可以通过递归遍历 AST 生成代码。
例子:def sumto(x) = if x = 0 then 0 else x + sumto(x-1),以下代码没有经过优化
sumto_entry: move $fp $sp // 保存 old fp sw $ra 0($sp) // 保存返回地址 addiu $sp $sp -4 // 保存 x lw $a0 4($fp) // 加载 x 到 a0 sw $a0 0($sp) // 保存 x 到栈 addiu $sp $sp -4 // 加载 0 li $a0 0 // 加载 x lw $t1 4($sp) // 加载 x 到 t1 addiu $sp $sp 4 // if x = 0 beq $a0 $ t1 true1 false1: // 保存 x lw $a0 4($fp) sw $a0 0($sp) addiu $sp $sp -4 // sumto(x-1) sw $ fp 0($sp) // 保存 old fp addiu $sp $sp -4 // 保存 x lw $a0 4($fp) sw $a0 0($sp) addiu $sp $sp -4 li $a0 1 lw $t1 4($sp) // 加载 x sub $a0 $t1 $a0 // x - 1 addiu $sp $sp 4 sw $a0 0($sp) // 保存参数 x - 1 addiu $sp $sp -4 jal sumto_entry lw $t1 4($sp) // 加载 x add $a0 $t1 $a0 // x + sumto(x-1) addiu $sp $sp 4 // pop b endif1 true1: li $a0 0 endif1: lw $ra 4($sp) // 加载返回地址 addiu $sp $ sp 12 // 恢复栈 lw $fp 0($sp) // 恢复 fp jr $ra // 跳转到韩辉地址
- 临时变量:保存在 AR 中,拥有固定地址,可以预先分配,避免不断的 push 和 pop 栈。
临时变量数量
- NT:next unused temporary
- NT(e1 + e2) = max(NT(e1), NT(e2)) + 1),e1 的临时变量空间可以被 e2 复用。
- NT(if e1 = e2 then e3 else e4) = max(NT(e1), 1 + NT(e2), NT(e3), NT(e4))
可以重写 cgen(e1 + e2, nt)
cgen(e1, nt) sw $a0 nt($fp) // 直接将结果保存到目标位置 cgen(e2, nt + 4) lw $t1 nt($fp) add $a0 $t1 $a0
对象布局:
- 每个属性都在 object 内有着固定位移。
- Cool object:Class Tag,Object Size,Dispatch Ptr(指向方法表),Attribute 1,Attribute 2
- 子类可以继承父类的布局,并添加额外的属性。也可以重写父类中的方法,但是该方法仍然有着与父类相同的位移。
中间代码,较高级别的汇编语言
- 使用寄存器,但是寄存器的数量是无限的
- 使用类似汇编语言的控制结构
- 使用较高级别的操作码,比如 push 会对应多条汇编指令
例子:igen(e1 + e2, t)
igen(e1, t1) igen(e2, t2) t = t1 + t2
5 优化
- 时机:AST、中间语言、汇编语言
- basic block:最大数量的连续指令,块内无 label,无 jump
- control flow graph:basic block 作为 node;当 block A 的最后一条指令执行后继续执行 block B 的第一条指令时,block A 到 block B 有一条边。
- 优化目的:提升程序的资源利用,包括执行时间、代码大小、网络消息发送等。
- 优化粒度:局部优化 --作用于 basic block;全局优化 -- 作用于 control flow graph(函数内);程序间优化 -- 作用于函数间
局部优化:
- 数学简化:一些指令可被删除、简化
- 拷贝传播
- 常量折叠:一些计算可在编译时完成
- 删除公共子表达式
- 删除无用代码
- 常量传播:x = 3; q = x + y => x = 3; q = 3 + y
- 一个优化可能会开启另一个优化;可以重复优化直到没有提升。
- 窥孔优化可以有效提升汇编。”窥孔“是一个较短序列的指令。优化器会讲该序列替换为另一个等价序列。例如:addiu $a $ b 0 -> move $a $ b
- 数据流分析:基于 control flow graph
- 常量传播:从前往后分析
- liveness 分析:从后往前分析,分析结束后,可删除无用代码。
6 高级话题
寄存器分配:在同一时间 live 的变量不可分配同一个寄存器。
- 可构建一个无向图,节点是临时变量,如果两个节点在同一时间 live,则连一条边,然后采用图着色算法分析。
- 如果颜色不够分,则选出一个候选节点放在内存中,比如放在栈中。
- 选择候选节点策略:最多冲突;最少定义和使用;避免位于循环内。
- 管理缓存:光靠编译器比较难做到,还需要靠程序员,比如写循环时,将内循环的变量赋值给外循环,可以提高缓存利用率。
自动内存管理 / 垃圾回收
- 如何知道一个对象不会再被用到?程序只能使用它可以找到的对象。一个对象 x 是可达的,仅当寄存器包含指向 x 的指针,或者另一个可被找到的对象 y 包含了指向 x 的指针。可以从寄存器开始并跟随这些指针来找到所有可达对象。不可达对象即为垃圾。
- 可以使用计数器来跟踪对象的被引用次数。栈中保存了 AR,也可以知道哪些对象被引用。
- 垃圾回收步骤:为新对象分配所需空间;当空间将被用完时,计算哪些对象也许还会被用到,释放那些不会被用到对象的空间。
方式一:标记 & 清除
- 标记阶段:跟踪可达对象。每个对象都有 mark bit。问题:需要空间来构建 todo 列表,todo 列表的大小又不可预知。解决:使用反向指针,将指针反向指向其父,从而将 free 列表保存在 free 对象中。
- 清除阶段:回收垃圾对象。
方式二:停止 & 拷贝
- 内存被分为两部分,old space 用来分配,new space为 GC 保留。当 old space 满了后,将所有可达对象拷贝到 new space,然后 old space 和 new space 互换。
- 最快的 GC,但是一些语言不允许拷贝,如 C/C++
方式三:引用计数
- 优点:实现容易;垃圾回收时无需太多停顿。缺点:无法回收 circular 结构;每次赋值时操作引用计数比较慢。
没有评论