哈佛-CS153-编译器笔记-全-
哈佛 CS153 编译器笔记(全)
001:课程概述与编译器简介 🚀

在本节课中,我们将学习什么是编译器,了解其基本架构和主要工作阶段,并熟悉本课程的整体安排与要求。

什么是编译器?
我们编写程序时,从源代码开始。源代码是文本文件,通常是人类可读的。而计算机最终执行的是目标代码,这是一种低级、不易于人类阅读且表达能力有限的代码。

编译器就是将我们从高级语言(源代码)带到低级语言(目标代码)的程序。
编译器架构概览
上一节我们定义了编译器,本节中我们来看看编译器内部的基本工作流程。
编译过程通常分为多个阶段。下图展示了典型的编译器架构:

以下是编译器的主要工作阶段:
- 解析:将源代码的字符序列转换为一种数据结构,即更结构化的程序表示。
- 精化:主要为类型检查。为程序的数据结构添加更多信息,例如推断表达式的类型,并确保程序类型正确。
- 降级:将高级语言逐步转换为一种或多种中间语言,使其越来越接近底层的汇编代码。
- 优化:在中间表示上应用各种转换,旨在提高程序运行速度。这是一个迭代过程。
- 代码生成:将中间表示转换为实际的、机器可执行的汇编指令。
编译器通常分为前端和后端。前端处理从解析到降级为某种中间表示的过程。后端则负责将该中间表示进一步编译,执行优化和代码生成等。例如,LLVM编译器允许不同语言使用各自的前端,编译到一个共同的中间表示,然后由一个共同的后端生成汇编代码。
编译器简史
了解了现代编译器架构后,我们简单回顾一下它的发展历程。
- 在20世纪50年代之前,计算机直接用低级汇编语言编程。
- 50年代初,Grace Hopper为UNIVAC机开发了A-0系统,并后来对COBOL语言的设计做出了重大贡献。
- 50年代末,IBM构建了Fortran编译器。Fortran是C语言的前身。
- 60年代,为Lisp语言创建了第一个自举编译器(即用Lisp语言本身编写的Lisp编译器)。
- 70年代,高级语言和编译技术真正兴起,许多核心思想在此时期及之后得到发展。
如今,我们正处于编程语言的黄金时代,有成千上万种语言,为特定目的快速创建和部署语言变得非常令人兴奋。
编译器各阶段详解
上一节我们概述了历史,现在让我们更深入地看看编译器每个阶段的具体任务。
前端:解析与精化
解析通常分为两个步骤:
- 词法分析:将字符串分解成称为词法单元的块。例如,识别
while、else等关键字。 - 语法分析:接收词法单元流,并将其转换为抽象语法树这种数据结构。
精化阶段主要是类型检查,例如确定变量引用、推断子表达式类型、确保操作数类型正确,以及进行一些安全性和安全性检查。
降级与优化

降级是将高级语言特性转换为更简单的低级语言表示的过程。例如:
- 将各种控制流结构(如
for、while循环)编译为只包含goto语句的语言。 - 处理作为值传递的函数等特性。
- 将数组越界检查等运行时检查显式化。

优化阶段将昂贵的操作序列重写为成本更低的序列。例如:
- 常量折叠:将表达式
3 + 4在编译时简化为7。 - 循环不变代码外提:将循环内不变的计算移到循环外部。
- 并行化:如果循环迭代互不依赖,可编译为并行执行。
代码生成
代码生成阶段将中间代码转换为目标代码,涉及:
- 寄存器分配:将中间表示中的变量映射到处理器有限的寄存器上。
- 指令选择与调度:选择具体的低级指令,并可能调度指令顺序以提高效率。


课程介绍与安排
前面我们深入探讨了技术细节,现在把焦点拉回,看看本课程的具体情况。
为什么学习编译器?
- 关键基础设施:编译器是我们编程乃至整个世界运行的关键基础设施。
- 理解语言特性:帮助我们理解语言特性的设计与实现,以及为何添加某些特性可能困难。
- 软硬件协同设计:对于计算机体系结构感兴趣的同学,编译器知识至关重要。
- 终极抽象:编程语言是计算的终极抽象,编译器是实现这一抽象的核心。
学习目标与预备知识
本课程将学习词法分析、语法分析、解释器、以x86为编译目标,以及GCC、LLVM等编译工具。这将是一个实现密集型课程,通过编写复杂代码来提升编程能力。
建议先修课程为CS51和CS61。本课程可视为这两门课的结合:从CS51的高级抽象思想到CS61的低级系统细节。课程主要使用OCaml语言实现编译器。
课程工具与评分
我们将使用Canvas网站、Ed讨论板和Gradescope提交作业。主要编程语言为OCaml 4.14.1。课程无固定章节,但会有大量办公时间。
评分比例如下:
- 项目作业:70%(共6个项目,外加一个嵌入式伦理作业,权重不等)
- 期末考试:20%
- 课程参与:10%
项目作业通常有2-3周时间完成。请注意管理时间,尽早开始。我们提供了充足的延迟提交额度(共10天,单次作业最多用3天),但这并非用于应对特殊情况,如有特殊情况请直接联系教师。
学术诚信与合作学习
作业需独立完成,但鼓励通过学习小组进行高水平概念讨论和低层次细节调试(例如如何解决某个语法错误)。关键在于用语言交流思想,而非共享代码。
关于生成式AI工具(如ChatGPT)的使用,原则类似:可用于理解高级概念或查询低层次语法示例,但不应让其生成作业的核心代码部分。使用时请在提交的代码中注明。

学习支持与课堂期望
我们将随机组建学习小组(可自愿退出),以促进同伴学习。课堂环境力求包容与支持。设备使用方面,教室将分为“可使用设备”和“无设备”区域,请根据自身情况选择就坐。
期望大家能跟上课程进度、照顾好自己、积极参与,并妥善管理时间。
总结
本节课中我们一起学习了编译器的基本定义、核心架构与历史沿革,并详细了解了从解析到代码生成的各个阶段。同时,我们也明确了本课程的目标、安排、学术要求以及提供的学习支持。编译器是连接高级思想与机器执行的桥梁,希望本课程能帮助你揭开这层神秘面纱,并构建出属于自己的桥梁。

附:首次作业“Hello OCaml”已发布,请查阅Canvas网站。请在本周五前完成课程背景调查。一个展示如何使用OCaml数据结构和解释器表示简单语言的示例文件也已提供,供有兴趣的同学探索。
002:课程介绍与编译器概述
在本节课中,我们将学习编译器课程的基本介绍,包括课程规定、工具使用以及编译器在计算机科学中的核心作用。
课程规定与工具
上一节我们进行了简单的开场,本节中我们来看看课程的具体规定和所需工具。

教室分为两个区域。一侧允许使用电子设备,另一侧是设备禁用区。请根据个人学习习惯选择座位。
为了帮助我尽快认识大家,教室后方提供了姓名牌。请用大字清晰填写,课后可将其放回后方盒子中。
办公室时间已于今日开始。课程网站(Canvas)上提供了包含办公室时间的谷歌日历。每周一至周五均设有办公室时间。
编译器的作用与重要性
现在,让我们进入核心主题:编译器。
编译器是一种将高级编程语言(如C、Java、Python)编写的源代码,翻译成计算机处理器能够直接理解和执行的低级机器代码的程序。
我们可以用一个核心公式来描述编译器的基本功能:
编译器(源代码) → 目标代码
这个过程对于计算机科学至关重要。没有编译器,我们用人类可读的语言编写的程序将无法在机器上运行。
编译的主要阶段
以下是编译器工作的几个主要阶段,每个阶段都将源代码逐步转化为更接近机器码的形式。
- 词法分析:将字符流(源代码)转换为有意义的词素序列。
- 语法分析:根据语法规则,将词素序列组合成语法树。
- 语义分析:检查语法树是否符合语言规范(如类型检查)。
- 中间代码生成:将语法树转换为一种与机器无关的中间表示形式。
- 代码优化:对中间代码进行优化,以提高最终代码的效率。
- 代码生成:将优化后的中间代码转换为特定目标机器的机器代码。
总结

本节课中我们一起学习了编译器课程的基本框架和规定,并初步探讨了编译器的定义、重要性及其工作的主要阶段。编译器作为连接高级编程思想与底层机器执行的桥梁,是理解计算机如何运行程序的关键。在接下来的课程中,我们将深入探讨上述每一个阶段。
003:从简单语言到X86汇编入门
在本节课中,我们将要学习如何用OCaml表示一个简单的编程语言,并初步探索C程序如何被编译成机器代码。我们将重点了解X86机器模型的基础知识,并介绍一个简化的X86子集——X86 Light。




从简单语言到抽象语法树




上一讲我们概述了课程内容和政策。本节中,我们来看看如何用OCaml表示一个简单的编程语言。







假设我们有一个简单的语言,它包含表达式和命令。表达式是在整数和程序变量上进行算术运算。命令则是执行操作,例如修改内存或控制程序流程。

为了描述这个语言,我们需要定义两件事:语法和语义。语法定义了哪些字符序列是合法的程序。语义定义了程序的含义,即程序应该执行什么计算。

语法定义
语法通常使用巴科斯-诺尔范式来描述。以下是一个例子:
<EXP> ::= X
| <EXP> + <EXP>
| <EXP> * <EXP>
| - <EXP>
| ( <EXP> )
<CMD> ::= skip
| X = <EXP>
| ifnot0 <EXP> { <CMD> } else { <CMD> }
| while <EXP> { <CMD> }
| <CMD> ; <CMD>
在这个定义中,<EXP>和<CMD>被称为非终结符,它们代表一组语法元素。竖线|表示不同的可能选项。这个定义是递归的,例如,一个表达式可以是另一个表达式加上另一个表达式。
抽象语法树
当我们实际实现编译器时,我们会使用与BNF描述非常相似的数据结构来表示程序,这就是抽象语法树。它与具体的字符序列不同,它以树状结构展示了程序的层次,例如循环体、条件语句的分支等。
在OCaml中,我们可以用代数数据类型来定义这些AST。以下是一个示例:

type var = string




type exp =
| Var of var
| Add of exp * exp
| Mul of exp * exp
| Neg of exp
| Int of int


type cmd =
| Skip
| Assign of var * exp
| IfNotZero of exp * cmd * cmd
| While of exp * cmd
| Seq of cmd * cmd
OCaml的数据类型定义与BNF语法声明非常相似,每个构造函数对应一种可能的表达式或命令形式。这种简洁的表示使得OCaml成为实现编译器的优秀语言。
语义解释
定义了AST后,我们可以考虑如何实现程序的语义。一种典型的方法是定义操作语义,即描述程序如何执行。
我们可以编写一个解释器。对于表达式,解释函数接收一个表达式和一个状态(变量到整数的映射),并返回一个整数值。对于命令,解释函数接收一个状态和一个命令,并返回执行命令后的新状态。
以下是指令解释的核心思路:
- 解释表达式:根据状态计算表达式的值。
- 解释命令:根据命令修改状态(内存)。
通过这种方式,我们可以运行程序并观察其结果,例如计算一个数的阶乘。




从C程序到机器代码







现在让我们转换视角,思考一个C程序如何变成机器代码。





一个C程序(如一个简单的求和函数)是文本,机器无法直接执行。我们使用编译器(如GCC)将其转换为更低级的表示。




编译流程
编译过程通常包含几个步骤:
- 编译:编译器(如
gcc)将C源文件(.c)转换为汇编文件(.s)。汇编文件仍然是文本,但包含了机器指令的助记符。 - 汇编:汇编器(如
gas)将汇编文件转换为目标文件(.o),即机器代码(字节序列)。但目标文件可能包含对其他地方定义的变量或函数的引用。 - 链接:链接器将多个目标文件(包括标准库)组合在一起,解析这些引用,最终生成可执行文件。


通常,我们直接调用gcc从.c文件生成可执行文件,但也可以要求它输出中间产物(如汇编代码)以便学习。
汇编语言简介
汇编语言非常简单。它的数据类型极少(主要是整数和浮点数),没有高级的聚合数据结构(如数组、结构体),这些都需要在汇编层面手动构造。它的操作也很原始,包括:
- 寄存器和内存上的算术运算。
- 在寄存器和内存之间读写数据。
- 控制流跳转(包括条件跳转)。
高级语言中的复杂操作(如函数调用、数据结构操作)都需要由一系列这样的低级指令构建而成。
需要区分汇编代码(文本)和机器代码(比特位)。汇编器负责将文本指令转换为机器可执行的比特位。汇编器还可能处理一些伪指令,以方便编程。

X86架构与X86 Light







我们将要学习的是X86汇编语言,它由英特尔公司开发,已有近50年历史。为了保持向后兼容,X86指令集变得非常庞大和复杂,属于复杂指令集计算机。它有数百条甚至近千条不同的指令,并使用变长编码(1到17字节不等),这使得处理器设计非常复杂。


与之相对的是精简指令集计算机,它通常指令数量少,编码长度固定,处理器设计更简单。
在本课程中,我们将使用一个简化的X86子集,称为X86 Light。它只使用64位有符号整数,只有大约20条指令,但这足以让我们将通用编程语言编译到这个子集上。
X86机器模型
理解X86指令集,需要一个简单的机器模型。关键组成部分包括:
- 内存:按地址寻址。在64位版本中,地址是8字节。内存布局通常包括:低地址存放代码和静态数据,高地址向下增长的是栈(用于函数调用管理),中间向上增长的是堆(用于动态内存分配)。
- 处理器:包含寄存器(芯片上的小块高速内存)、算术逻辑单元(执行运算)、控制单元和条件标志位(如溢出标志、零标志等)。
- 指令指针:一个特殊的寄存器,存放下一条要执行的指令在内存中的地址。处理器循环执行“取指-解码-执行”的过程。
X86-64有16个通用寄存器,如%rax、%rbx等,其中一些有惯用途(如%rax常用于存放返回值)。栈指针%rsp等寄存器有特殊用途。指令指针%rip只能通过控制流指令间接修改。
指令与操作数
让我们看一条具体指令:movq。它的格式是movq 源, 目的,作用是将值从源复制到目的。
关键点在于操作数可以被解释为值或位置:
- 当作为源时,操作数是值(立即数、寄存器内容、内存地址的内容)。
- 当作为目的时,操作数是位置(寄存器、内存地址)。
我们使用AT&T语法:源在前,目的在后;立即数用$前缀,寄存器用%前缀;指令助记符后的q表示操作64位数据。




操作数类型包括:
- 立即数:常量值。
- 寄存器。
- 标签:象征性的内存地址(由链接器解析)。
- 内存地址(间接寻址):最复杂,形式为
位移(基址寄存器, 索引寄存器, 比例因子),计算出的地址为:基址 + 索引 * 比例因子 + 位移。这种灵活的形式非常适合高效地访问数组和结构体。在X86 Light中,我们暂不使用索引和比例因子。
数据表示与运算
X86使用二进制补码表示有符号整数。它的一个关键特性是可能发生溢出——运算结果无法用给定的位数表示。
对于无符号数,当结果超出表示范围时发生溢出。对于有符号数,溢出分为正溢出(结果太大)和负溢出(结果太小)。同一串比特位,作为有符号数解释可能溢出,作为无符号数解释则可能不溢出,反之亦然。处理器会设置不同的条件标志位来记录这两种溢出情况。
X86的算术指令包括negq(取负)、addq、subq、imulq(乘法)等。逻辑和位操作指令包括notq(按位取反)、andq、orq、xorq以及移位指令。
移位指令需要注意:
shlq:逻辑左移,右侧补0。shrq:逻辑右移,左侧补0。sarq:算术右移,左侧复制原最高位(符号位),这可以保证对有符号数右移等价于除以2。

总结
本节课中我们一起学习了:
- 如何使用BNF定义简单语言的语法,并用OCaml的代数数据类型实现对应的抽象语法树。
- 如何为AST编写解释器来定义语言的操作语义。
- C程序通过编译器、汇编器、链接器转换为可执行文件的完整流程。
- 汇编语言的基本特征及其与高级语言的区别。
- X86架构的复杂性,以及我们为课程简化的X86 Light子集。
- X86机器模型的核心组成部分:内存、寄存器、ALU、控制单元和指令指针。
- 汇编指令的基本格式、操作数类型(特别是复杂的内存间接寻址模式),以及数据表示(补码、溢出)和基本运算指令。

下一讲,我们将继续深入X86机器模型,重点学习栈的概念以及控制流指令。
004:X86汇编深入与OCaml表示





在本节课中,我们将继续深入学习X86汇编语言,特别是其简化版本X86 Light,并探讨如何在OCaml程序中表示X86代码。我们还将介绍函数调用约定,这是后续学习的重要基础。
X86 Light内存模型与寻址
上一节我们介绍了X86处理器的基本架构。本节中,我们来看看X86 Light的内存模型和寻址方式。
X86 Light使用64位地址,可寻址的内存总大小为 2^64 字节。内存是字节可寻址的,但在我们的模型中,要求所有指针都是四字对齐的,即内存地址必须能被8整除。这意味着每次读取8个字节(64位),下一个可读地址是当前地址加8。
一个有用的机器指令是加载有效地址 LEAQ。其语法为:
LEAQ IND, DEST
LEAQ 并不实际访问内存,它只是根据间接操作数(如 基址 + 位移)计算内存地址,并将计算出的地址(一个指针)存入目标寄存器 DEST。


栈操作指令
X86架构中,栈从高内存地址开始,向低内存地址增长。栈指针寄存器 RSP 指向栈顶。

以下是直接操作栈的两个指令:



- PUSH SRC:将栈指针
RSP递减一个四字(8字节),然后在新的栈顶位置存入源操作数SRC的值。 - POP DEST:从栈顶读取值,存入目标操作数
DEST,然后将栈指针RSP递增一个四字。



条件标志与控制流



处理器包含多个标志位,在X86 Light中我们主要关注三个:
- 溢出标志 (OF):当有符号整数运算结果超出64位寄存器表示范围时置1。
- 符号标志 (SF):当运算结果为负数时置1(等于结果的最高位)。
- 零标志 (ZF):当运算结果为零时置1。

通过比较两个操作数 SRC1 和 SRC2(计算 SRC1 - SRC2 并设置标志位),我们可以根据这些标志判断它们的关系。
以下是基于标志位的条件判断逻辑:
- 相等 (SRC1 == SRC2):
ZF == 1 - 不相等 (SRC1 != SRC2):
ZF == 0 - 小于 (SRC1 < SRC2):
SF != OF - 小于等于 (SRC1 <= SRC2):
(SF != OF) OR (ZF == 1) - 大于 (SRC1 > SRC2):
(SF == OF) AND (ZF == 0) - 大于等于 (SRC1 >= SRC2):
SF == OF
X86汇编代码由带标签的代码块组成。标签代表代码位置,可作为跳转目标。
控制流指令分为两类:
- 无条件跳转:
JMP LABEL,总是跳转到指定标签。 - 条件跳转:通常是一个两步过程。
- 首先使用
CMPQ SRC2, SRC1指令进行比较(注意顺序:计算SRC2 - SRC1)。 - 然后根据条件跳转,例如
JE LABEL(相等时跳转)、JNE LABEL(不相等时跳转)、JL LABEL(小于时跳转)等。
- 首先使用
此外,SETcc DEST 指令(如 SETE)可以根据条件码,将比较结果的布尔值(1或0)设置到目标操作数 DEST 的低字节中。
对于函数调用,有两个特殊指令:
- CALLQ LABEL:将当前指令指针
RIP压入栈中,然后跳转到标签LABEL处执行。 - RETQ:从栈顶弹出返回地址,并跳转到该地址,从而返回到调用者。
在OCaml中表示X86代码
了解了X86指令后,我们来看看如何在OCaml中表示它们。以下是核心数据结构的定义:


首先,定义标签和四字类型:
type label = string
type quad = int64
定义操作数类型,包括立即数、寄存器和间接寻址:
type arg =
| Imm of imm (* 立即数 *)
| Reg of reg (* 寄存器 *)
| Ind1 of imm (* 仅位移:Imm *)
| Ind2 of reg (* 仅基址:Reg *)
| Ind3 of imm * reg (* 基址+位移:Imm(Reg) *)
定义寄存器类型,包括特殊寄存器和通用寄存器:
type reg =
| RIP | RAX | RBX | RCX | RDX | RSI | RDI | RBP
| RSP | R8 | R9 | R10 | R11 | R12 | R13 | R14 | R15
定义X86 Light支持的所有操作码:
type opcode =
| Movq | Pushq | Popq
| Leaq | Incq | Decq | Negq | Notq
| Addq | Subq | Imulq | Xorq | Orq | Andq
| Shlq | Shrq | Sarq
| Cmpq
| Sete | Setne | Setl | Setle | Setg | Setge
| Je | Jne | Jl | Jle | Jg | Jge
| Jmp | Callq | Retq
一条指令由操作码和操作数列表组成:
type instr = opcode * arg list
一个带标签的代码块包含标签名、全局标识和指令列表:
type 'a labeled = { lbl : label; global : bool; value : 'a }

一个完整的汇编程序由代码段(文本段)和数据段组成:
type prog = { text : instr list labeled list;
data : data labeled list }
and data = Quad of quad | Asciz of string


利用这些定义,我们可以编写OCaml代码来构建和打印汇编程序,甚至可以将其编译成可执行文件,正如课堂演示的阶乘计算程序一样。
函数调用约定与栈帧
最后,我们简要回顾函数调用约定。大多数现代语言使用栈来管理函数调用,存储局部变量和返回地址。
调用约定规定了:
- 参数传递:前几个参数通过寄存器传递,多余的参数按从右到左的顺序压入栈中。
- 返回值:通常通过
RAX寄存器返回。 - 寄存器保存:分为调用者保存寄存器和被调用者保存寄存器。调用者在调用前需保存前者;被调用者若使用后者,必须在返回前恢复其值。
- 栈帧结构:每个函数调用都有一个栈帧,位于栈指针
RSP和基址指针RBP之间。典型布局如下(从高地址到低地址):- 调用者的栈帧
- 参数构造区
- 返回地址(由
CALL指令压入) - 旧的
RBP(被调用者压入) - 被调用者保存的寄存器
- 局部变量
- 当前栈帧(
RSP指向此处)


64位X86(System V AMD64 ABI)调用约定与32位类似,但细节更多,例如栈对齐要求和“红区”概念。


总结


本节课中我们一起学习了X86 Light汇编语言的几个关键部分:内存寻址方式、栈操作指令、条件标志与控制流实现。我们还深入探讨了如何在OCaml中用数据类型表示X86指令和程序结构,并通过实例看到了从OCaml表示到可执行文件的完整流程。最后,我们回顾了函数调用约定和栈帧管理的基本概念,为后续实现编译器后端打下了基础。
005:中间表示(IR)与编译策略
在本节课中,我们将学习编译过程中的一个核心概念:中间表示。我们将通过对比两种不同的编译策略,来理解为什么需要中间表示,以及一个好的中间表示应具备哪些特性。

课程概述与公告
首先,我们回顾一下课程相关的公告。感谢大家完成并提交了作业1。作业的评分将在本周五或更早返回给大家。作业2的截止日期是9月27日,建议尽早开始。作业3将于下周发布。
关于学习小组,所有填写了表格的同学都已被分配。小组的目的是促进学习,通过向他人解释概念来巩固自己的理解。在作业2结束后,会有一个关于学习小组体验的评估。
学术诚信政策适用于人类和AI。高层次的概念讨论是允许的,低层次的帮助(如解释编译器错误)也是可以的。但禁止交换或展示大段代码。如有疑问,请随时联系课程团队。
回顾:直接编译到汇编
上一节我们介绍了X86汇编。现在,我们尝试将一种简单的高级表达式语言直接编译成X86汇编代码。
我们的源语言包含64位整数常量、变量(仅限x1到x8)、加法、乘法和取反操作。我们首先定义一个静态检查函数,确保程序中的变量都在有效范围内。
编译的核心思想是维护一个不变式:表达式的当前计算结果始终存储在寄存器RAX中,而中间结果则被压入栈中。

以下是编译函数的关键部分:
- 编译变量:将变量值从预定义的位置(寄存器或栈)移动到
RAX。 - 编译常量:将立即数移动到
RAX。 - 编译取反:先编译子表达式(结果在
RAX),然后对RAX执行取反操作。 - 编译加法/乘法:
- 编译左子表达式(结果在
RAX)。 - 将
RAX(左结果)压栈。 - 编译右子表达式(结果在
RAX)。 - 将栈顶(左结果)弹出到寄存器
R10。 - 对
R10和RAX执行运算,结果存回RAX。
- 编译左子表达式(结果在
我们通过一个运行时C程序来调用生成的汇编函数,并成功计算了表达式结果。
这种直接编译的方法虽然可行,但存在几个问题:
- 效率不高:生成的代码可能不是最优的。
- 扩展性差:变量数量被硬编码为8个,难以支持更多变量或更复杂的语言特性(如结构体、函数)。
- 可读性差:汇编代码难以阅读和优化。
- 目标依赖:编译逻辑与X86架构紧密耦合,更换目标架构(如RISC-V)需要重写大部分编译器。
- 控制流单一:难以处理条件语句、循环等复杂控制流。




引入中间表示(IR)
为了解决上述问题,我们引入了中间表示。IR是介于高级源代码和低级汇编之间的一种程序表示形式。
IR的核心优势在于:
- 隐藏目标架构细节:允许以机器无关的方式进行代码生成和优化。
- 简化优化:在结构更清晰的IR上实施优化(如循环优化、函数内联)比在汇编上更容易。
- 支持多后端:可以共享同一个前端和优化器,只需为不同目标架构(X86, RISC-V, JVM字节码)编写不同的后端。




在实际编译器中,通常会使用一系列中间表示,从高级到低级逐步降低抽象层次。这使得编译器的每个阶段(称为“遍”)都可以相对简单和专注。
什么是好的中间表示?
一个好的IR需要在多个方面取得平衡:
- 易于翻译:既要容易从高级语言翻译过来(作为目标),也要容易翻译到低级语言或机器码(作为来源)。
- 接口狭窄:通常指令集越少越好,这简化了向下一级的翻译和某些优化。
- 捕获适当信息:在编译的特定阶段,IR应包含对该阶段优化有用的结构信息。例如,在高级IR中保留显式的
while循环结构,有利于进行循环优化;而在低级IR中,循环可能已被展开为跳转指令。
IR可以根据抽象层次分类:
- 高级IR:接近抽象语法树,保留高级语言结构(如循环、函数),便于进行高级优化。
- 中级IR:可能移除结构化控制流,但尚未指定具体寄存器和内存位置。常用形式包括:
- 四元式/三地址码:形式如
a = b op c。 - 静态单赋值形式:每个变量只被赋值一次,简化数据流分析。
- 栈基IR:通过压栈和弹栈操作进行计算。
- 四元式/三地址码:形式如
- 低级IR:接近汇编代码,可能包含机器相关的伪指令,便于进行寄存器分配、指令选择等低级优化。
实践:基于栈的中间表示


现在,让我们回到代码,看看另一种编译策略:使用基于栈的中间表示。
我们定义一个新的IR,它只有五种指令:
PushC (n):将常量n压栈。PushV (x):将变量x的值压栈。Add:弹出栈顶两个元素,相加后结果压栈。Mul:弹出栈顶两个元素,相乘后结果压栈。Neg:弹出栈顶元素,取反后结果压栈。

编译过程分为两步:
- 扁平化:将源表达式翻译成IR指令列表。这是一个非常简单的递归过程,其不变式是:生成的指令序列会计算表达式,并将结果留在栈顶。
- 编译IR到汇编:将每条IR指令编译为X86汇编序列,使用X86的硬件栈来实现IR的抽象栈。
以下是编译IR指令的核心逻辑:
PushC n:生成pushq $n。PushV x:将变量x的值压栈。Add/Mul:生成popq %rax; popq %r10; (addq/mulq) %r10, %rax; pushq %rax。Neg:生成popq %rax; negq %rax; pushq %rax。
最后,从栈顶弹出结果到RAX,作为函数返回值。
两种策略的对比
我们比较一下直接编译和通过栈IR编译这两种策略:
- 直接编译:
- 潜在优势:可能生成更高效的代码(例如,直接使用寄存器中的变量值)。
- 劣势:编译器逻辑更复杂,与目标架构耦合,难以扩展和维护。
- 栈IR编译:
- 优势:编译器前端(扁平化)和后端(IR到汇编)都非常简单、清晰、易于编写。分离了关注点,易于扩展语言特性(如支持更多变量)。
- 劣势:生成的汇编代码效率较低,因为每次使用变量都需要内存访问(压栈/弹栈)。
这个对比揭示了编译器设计中的核心权衡:性能、编译器实现的简易性、可维护性和可扩展性。现代编译器(如LLVM)采用复杂的IR和多次转换,正是在这些维度上寻求最佳平衡。
总结


本节课我们一起学习了中间表示在编译器中的核心作用。我们首先回顾了将简单表达式语言直接编译到X86汇编的方法,并指出了其局限性。然后,我们引入了中间表示的概念,探讨了其优势以及不同层次IR的特点。最后,我们通过实现一个基于栈的IR编译策略,实践了将编译过程分离为前端(到IR)和后端(IR到汇编)两个更简单阶段的方法,并对比了不同编译策略的优缺点。理解这些权衡对于设计和实现编译器至关重要。
006:中间表示的设计与演进
在本节课中,我们将学习编译器设计中一个核心概念——中间表示。我们将从最简单的算术表达式语言开始,逐步添加更多语言特性,并观察中间表示如何随之演进,最终引出LLVM的设计思想。这个过程将帮助我们理解为何现代编译器(如LLVM)采用其特定的设计。
课程公告与学术诚信
在开始今天的内容之前,有几个重要的公告。
作业2已发布,截止日期为9月27日星期三。关于学习小组,几乎所有同学都已分组。如果你还没有与组员通过邮件联系,请迈出第一步,尝试在本周安排一次小组会议。我们将在作业3发布时组建新的学习小组。


关于学术诚信,这是一个友好的提醒:请在集成开发环境中禁用Copilot或其他生成式AI工具。学术诚信政策的目标是将生成式AI视为与同学同等对待。高层级讨论(使用文字而非代码)是可以的,低层级问题(如API使用、语法错误含义)也是允许的。但共享或接受大段代码则不被允许。在IDE中启用Copilot并允许其根据注释和代码上下文建议大段代码,很容易越界。你可以在项目设置中为CS153项目禁用Copilot,而在其他项目中继续使用。当然,你仍然可以在项目上下文之外使用生成式AI来询问一般性问题,例如“如何使用Ocaml的Set模块”,这可以帮助你避免语法错误,同时不涉及具体作业的解决方案。此政策同样适用于你提交的测试用例。使用生成式AI的价值在于帮助你思考问题,而不是直接生成答案。
中间表示的基本问题与动机
上一讲我们讨论了中间表示的作用。本节中,我们来看看设计中间表示时的一个基本问题。
一个根本性问题是,我们最终需要将计算表示为扁平的结构。在汇编层级,我们不能有嵌套的表达式。这意味着,在我们的中间表示的某个层级,必须消除嵌套表达式。
解决这个问题的关键思想是为中间值提供名称。在周一的课程中,我们使用的中间表示使用了栈,中间值被压入栈中并在需要时弹出。但这里我们将采用另一种方法:为这些中间值提供名称。
此外,我们将使计算序列和操作顺序变得明确。而在高级语言中,操作顺序通常是隐式的,基于左结合或右结合规则,有时甚至未指定。例如,在C语言中,二元操作的操作数求值顺序并未严格规定。
给定一个表示为抽象语法树的嵌套表达式,我们将其翻译成以下形式:
let t0 = add 1 (var x)
let t1 = mul (var x) (var x)
let t2 = add t0 t1
ret t2
我们为中间值(t0, t1, t2)提供了名称,并且明确了子表达式的求值顺序。需要注意的是,这些临时变量一旦被赋值就不会被修改,它们只是为中间值提供名称,不同于高级编程语言中可以更新的可变变量。
第一个IR:简单算术表达式
让我们通过一系列中间表示来具体了解。首先,我们定义源语言。
源语言是简单的算术表达式,包含变量、常量、加法、乘法和取反操作。以下是一个表达式的AST示例。
我们使用的中间表示将包含指令。我们使用构造函数 let,类似于Ocaml的let表达式。它接受一个元组:唯一标识符(临时值的名称)、二元操作(加或乘)以及两个操作数。操作数可以是常量、变量,或者是引用之前计算的临时结果的ID。
UID(唯一标识符)目前只是整数。我们有一个方便的函数来生成一个新的、唯一的ID。一个程序则简单地是一个指令列表,以及一个包含整个表达式结果的操作数(通常是一个UID)。


编译表达式函数接收一个表达式E,返回一个计算E所需的指令列表,以及一个包含结果的操作数。对于变量和常量,很简单,不需要指令,操作数就是变量或常量本身。对于加法、乘法和取反,我们使用一个工具函数 compile_binop。



compile_binop 接收二元操作和两个子表达式E1和E2。它递归调用 compile_expr 处理E1和E2,得到计算E1和E2的指令列表及其结果操作数。为了计算 E1 op E2,我们返回的指令序列是:计算E1的指令,接着是计算E2的指令,然后是一条新指令,该指令对E1和E2的结果(res1和res2)执行二元操作,并将结果存入一个新的临时变量(使用新的UID)。





第二个IR:添加命令(赋值)




上一节我们处理了纯表达式。本节中,我们来看看如何扩展源语言以包含命令,从而支持可变状态。




在这个语言中,我们不仅有表达式,还有命令。命令有三种:skip(空操作)、assignment(赋值,x = E)和sequence(顺序执行,C1; C2)。这引入了可变内存的概念,我们可以写入和修改变量。
对于中间表示,我们不仅需要计算表达式和存储临时结果,还需要表示变量的加载和存储。因此,在指令中,除了 let,我们还要添加 load 和 store。load 读取变量的内容并存入一个临时变量,store 将临时变量的值存入一个变量。现在,操作数只允许常量和临时变量,变量需要通过显式的 load 指令来获取。




程序现在只是一个指令列表。计算的结果可以理解为最终的内存状态,或者存入某个众所周知的变量。




编译表达式部分基本不变,只是在编译变量时,需要显式插入一条 load 指令。编译命令则很简单:skip 生成空指令列表;赋值 x = E 先生成计算E的指令,然后追加一条 store 指令,将E的结果存入变量x;顺序执行 C1; C2 则是将编译C1和C2得到的指令列表连接起来。




第三个IR:引入控制流(条件与循环)
之前的IR可以处理顺序执行。本节中,我们将为源语言添加更复杂的控制流结构,如条件分支和循环,并学习如何用控制流图来表示它们。
源语言现在增加了条件命令 ifnot0 和循环命令 whilenot0。ifnot0 E C1 C2 表示如果表达式E非零则执行C1,否则执行C2。whilenot0 E C 表示只要E非零就重复执行C。
我们的中间表示需要能够表示这些控制流结构。之前使用的简单指令列表已不足以描述分支和跳转。因此,我们将引入控制流图。
控制流图是一种用节点和边来表示程序的图形化方法。边代表控制流,节点代表语句(或基本块)。例如,一个包含赋值、循环和条件语句的程序可以转化为一个CFG,其中节点是命令,边表示执行完一个命令后可能跳转到哪个命令。
为了使表示更高效,我们将节点从单个命令扩展为基本块。一个基本块是一个指令序列,其特性是:只能从块的开头进入,必须执行完块内的所有指令,并且块的最后一条指令要么无条件跳转到另一个基本块,要么是一个条件分支,要么是函数返回。
在CFG中,我们为每个基本块分配一个唯一的标签(名称),并在基本块的末尾使用显式的跳转指令(如 branch 或 cond_br)来指明下一个要执行的基本块是哪个。这样,箭头信息就隐含在跳转指令中,我们只需列出带标签的基本块序列即可。
在我们的中间表示实现中,控制流图由一个特定的入口块和一个带标签的基本块列表组成。每个基本块包含一个指令列表和一个终结指令(return、branch 或 cond_br)。
从源语言到控制流图的编译策略
理解了控制流图的表示后,本节我们来看看如何将包含控制流的源程序编译成这种中间表示。
编译的基本思路是分两步:
- 发射元素流:遍历源程序,生成一个“元素”流。元素可以是标签、指令或基本块终结符。
- 构建控制流图:从后向前处理这个元素流,将其组装成带标签的基本块列表。
我们通过一个列表来构建这个元素流,但为了便于从末尾开始处理,我们实际上以相反的顺序构建列表(即最后发射的元素在列表头部)。
编译具体结构时:
skip:发射空序列。assignment:编译表达式E,将其指令列表转为元素,然后追加一条store元素。sequence:递归编译C1和C2,连接它们的元素流。ifnot0 E C1 C2:- 编译E,得到计算E的指令和结果。
- 创建四个新标签:
guard(条件判断后)、nz_branch(非零分支)、z_branch(零分支)、merge(合并点)。 - 发射元素:计算E的指令、比较E是否为0的指令、基于比较结果的
cond_br指令。 - 发射
nz_branch标签,接着是编译C1的元素流,然后是一条跳转到merge标签的branch指令。 - 发射
z_branch标签,接着是编译C2的元素流,然后是一条跳转到merge标签的branch指令。 - 发射
merge标签。后续编译的指令将接在merge之后。
whilenot0 E C:- 编译E和C。
- 创建三个新标签:
entry(循环入口)、body(循环体)、exit(循环退出)。 - 发射元素:一条跳转到
entry的branch指令、entry标签、计算E和比较的指令、基于结果的cond_br(为0跳exit,否则跳body)、body标签、编译C的元素流、一条跳回entry的branch指令、exit标签。
构建控制流图(build_cfg)的函数通过遍历(折叠)元素流来工作。它维护一个累加器,包含当前正在构建的基本块的指令列表、可选的终结符,以及已构建好的带标签基本块列表。当遇到标签时,如果当前块有终结符,就将其保存为一个完整的基本块;如果没有指令,可能是一个空块(可忽略);否则报错。这个过程从元素流末尾(即列表头部)开始,向前处理,最终生成完整的CFG。
后续IR演进与LLVM概览
通过上述步骤,我们已经能够将包含复杂控制流的程序编译成基于基本块和显式跳转的中间表示。本节中,我们简要看看如何在此基础上进一步演进,使其更接近现代编译器如LLVM的设计。
我们可以继续扩展中间表示:
- 添加函数:在源语言和IR中引入函数定义和调用指令。程序变为一个函数声明列表,每个声明包含函数名、参数列表和控制流图。
- 区分标识符:将标识符明确区分为全局标识符(用于函数名、全局变量名)和临时UID(用于中间结果)。这使结构更清晰。
- 统一指令格式:让每条指令(包括
store)都关联一个UID。虽然对store而言这个UID可能没有实际意义,但这种统一性是LLVM等设计的选择,使得所有值(包括指令)都有明确的定义点,便于分析和优化。
经过这些演进后得到的中间表示(IR5)已经非常接近LLVM的核心思想。LLVM使用类似的基于基本块和SSA(静态单赋值)形式的表示,每个值(包括指令结果)都有唯一的名字,并且控制流通过显式跳转明确表示。
总结
本节课中,我们一起学习了编译器中间表示的设计与演进。我们从最简单的算术表达式IR开始,逐步添加了变量赋值、控制流(条件分支和循环)等特性,并引入了控制流图、基本块、显式跳转等关键概念来应对这些复杂性。我们详细探讨了将高级语言结构编译成基于基本块的IR的策略。最终,我们看到了如何通过进一步抽象(如区分全局/临时标识符、统一指令格式)使IR更接近现代编译器基础设施(如LLVM)的设计。理解这个演进过程对于掌握编译器如何桥接高级语言与低级机器代码至关重要。


下节课我们将深入探讨LLVM本身。祝大家作业2顺利,别忘了联系你的学习小组。今晚CS之夜办公室时间在Winthrop食堂,我们周一见。
007:LLVM与结构化数据表示

在本节课中,我们将要学习LLVM(低级虚拟机)的基本概念,并探讨如何在机器层面表示高级语言中的结构化数据(如结构体和数组)。理解这些内容对于后续的编译器实现至关重要。
LLVM简介 🐉
LLVM是一个开源的编译器基础设施项目。它最初由伊利诺伊大学香槟分校的Chris Lattner在其硕士论文中创建,现已发展成为一个被广泛使用的工业级工具。LLVM的核心是一个类型化的SSA(静态单赋值)中间表示,它连接了多种前端(如C、C++、Swift)和后端(如x86、ARM、PowerPC)。
LLVM的中间表示具有文本形式,便于人类阅读和理解。其设计理念与我们上节课探讨的中间表示非常接近。
LLVM IR示例与解析
以下是LLVM IR的一个简单示例,它计算一个整数的阶乘。
define i64 @factorial(i64 %n) {
entry:
%1 = alloca i64
%acc = alloca i64
store i64 %n, i64* %1
store i64 1, i64* %acc
br label %start

start:
%2 = load i64, i64* %1
%3 = icmp sgt i64 %2, 0
br i1 %3, label %then, label %else
then:
%4 = load i64, i64* %acc
%5 = load i64, i64* %1
%6 = mul i64 %4, %5
store i64 %6, i64* %acc
%7 = load i64, i64* %1
%8 = sub i64 %7, 1
store i64 %8, i64* %1
br label %start

else:
%9 = load i64, i64* %acc
ret i64 %9
}
上一节我们介绍了LLVM的基本概念,本节中我们来看看其代码的具体构成。
以下是该示例的关键组成部分解析:
- 函数定义:
define i64 @factorial(i64 %n)定义了一个返回i64类型、名为factorial的函数,它接受一个i64类型的参数%n。 - 基本块:代码被组织成带标签的基本块(如
entry、start、then、else)。每个基本块以一条终结指令(如br或ret)结束。 - 局部变量(虚拟寄存器):以
%开头的标识符(如%1、%acc)是局部变量,遵循SSA形式,即每个变量在其生命周期内只被赋值一次。 - 内存分配与访问:
alloca指令在栈上分配内存,返回一个指针。store和load指令用于向该内存写入和读取值。可变变量需要通过内存位置来实现。 - 显式类型:LLVM IR是强类型的,每个值和操作都有明确的类型注释(如
i64表示64位整数,i64*表示指向i64的指针)。
控制流图与存储类别
LLVM IR中的基本块序列构成了一个控制流图。为了确保图的良构性,需要满足一些约束,例如每个标签必须唯一,且跳转目标必须在同一函数内定义。
在LLVM中,有多种存储类别:
- 局部变量(
%uid):也称为虚拟寄存器,通常最终会被分配到物理寄存器。 - 全局变量(
@name):具有全局作用域,通常存储在内存的数据段。 - 栈分配存储:通过
alloca指令创建,其生命周期与函数调用一致。 - 堆分配存储:通过类似
malloc的调用创建。
alloca指令的典型用法如下,它分配指定类型的内存并返回指针:
%ptr = alloca i64 ; 分配一个i64大小的栈空间,地址存入%ptr
store i64 153, i64* %ptr ; 将值153存入该地址
%val = load i64, i64* %ptr ; 从该地址加载值到%val
结构化数据的低级表示
理解了LLVM的基本结构后,我们接下来看看编译器如何将高级语言中的结构化数据(如C语言中的struct和数组)映射到低级的内存表示。这有助于理解LLVM中相关操作的由来。
结构体的内存布局
一个C语言结构体在内存中被表示为一段连续的区域。例如:
struct Point { int x; int y; };
struct Rect { struct Point ll; struct Point lr; struct Point ul; struct Point ur; };
struct Point占用两个连续的int(假设为4字节)空间。struct Rect则包含四个Point,因此连续存放,总共占用8个int的空间。
访问嵌套字段(如square.ul.y)时,编译器会根据基地址和各个字段的偏移量计算出目标地址。计算这个地址本身不需要任何内存访问,它只是基地址加上一个编译时确定的常量偏移量(例如,跳过ll和lr两个Point,再跳过ul的x字段)。
对齐与填充
为了性能,数据在内存中需要满足特定的对齐要求。编译器可能会在结构体字段之间插入“填充”字节,以确保每个字段都从其类型所需大小的倍数地址开始。例如,一个包含char和int的结构体可能需要填充,以使int对齐到4字节边界。这会影响结构体的总大小和字段偏移量。
结构体赋值与参数传递
在C语言中,结构体赋值是“按值复制”,即复制所有字段的内容。这同样适用于将结构体作为参数传递或作为返回值,这可能导致大量数据的拷贝。作为优化,通常可以传递指向结构体的指针(按引用传递),以避免复制开销。

需要注意的是,返回指向栈上局部变量的指针是危险的,因为该内存会在函数返回后失效,后续访问属于未定义行为。

数组的内存布局
数组在内存中也占据连续的空间。对于一维数组arr[i],其地址计算为基地址 + i * 元素大小。
对于多维数组,C语言采用行主序存储。例如,一个int m[4][3]的数组,在内存中先连续存储第0行的3个元素,接着是第1行,以此类推。因此,访问m[i][j]的地址计算公式为:基地址 + i * (3 * sizeof(int)) + j * sizeof(int)。
其他语言可能采用列主序(如Fortran),或者使用指针数组(如OCaml中数组的数组)来实现多维数组。不同的布局策略对缓存友好性和某些操作(如交换行)的效率有显著影响。
总结


本节课中我们一起学习了LLVM中间表示的基本语法和设计思想,包括其SSA形式、类型系统、基本块和控制流图。随后,我们探讨了高级语言中结构体和数组在机器层面的内存表示方式,涉及连续布局、对齐填充、地址计算以及不同的多维数组实现策略。理解这些底层表示是后续进行编译器代码生成和优化的基础。下节课我们将更深入地探讨LLVM如何具体处理这些内存访问操作。
008:数据结构的编译与LLVM表示
在本节课中,我们将学习如何将C语言和类似语言中的高级数据结构(如数组、字符串、枚举和结构体)编译成汇编代码。我们还将深入了解LLVM中间表示(IR)如何对这些结构进行建模,特别是通过getelementptr指令进行地址计算。理解这些概念对于完成作业3(将LLVM IR编译为x86汇编)至关重要。
数组边界检查的实现
上一节我们讨论了数组在内存中的布局。本节中,我们来看看如何实现安全的数组访问,即边界检查。
在像ML、Java、C#这样的语言中,运行时需要检查数组访问是否越界。C和C++则不强制执行此类检查。对于需要边界检查的语言,一个关键问题是如何实现。
一种常见方法是将数组的大小存储在数组内容开始之前的一个字(word)中。这种设计有几个优点:
- 兼容性:数组的值仍然是一个指向内容起始处的指针,与C语言的表示兼容。
- 局部性:大小与数组数据存储在一起,可能带来更好的缓存行为。
- 计算简便:索引计算保持直接。
以下是实现边界检查的伪汇编思路。假设RAX寄存器持有指向数组基地址的指针,ECX寄存器持有要访问的索引i:
- 从
RAX - 8(前一个字)加载数组大小到RDX。 - 比较
ECX(索引)和RDX(大小)。 - 如果索引超出范围,跳转到错误处理例程。
- 否则,继续正常的数组访问计算:
有效地址 = RAX + i * 元素大小。
当然,这比直接访问增加了开销(一次额外的加载、比较和跳转)。现代编译器和硬件通过以下方式缓解:
- 循环优化:编译器可以将循环内的边界检查外提(hoist)到循环开始处。
- 分支预测:硬件会预测
边界检查通过是常见路径,并推测性地执行后续指令。
然而,这种推测执行也导致了像 Spectre和Meltdown 这样的安全漏洞,在追求性能和安全之间产生了权衡。
C语言中的字符串
字符串在C语言中表示为字符数组,并且是空终止(null-terminated) 的,即最后一个字符是\0字节。
字符串常量通常作为全局数据存储,常被放在只读的文本段(text segment)。这带来一个需要注意的行为:指向字符串常量的指针指向的是只读内存。
char *p = "foo"; // p 指向只读内存区域
p[0] = 'b'; // 错误!尝试修改只读内存,会导致段错误(Segmentation Fault)
若要修改字符串,必须先创建副本,通常使用malloc在堆上分配内存并进行复制:
char *p = malloc(4 * sizeof(char)); // 为"foo"和空终止符分配空间
strncpy(p, "foo", 4); // 安全地复制字符串
p[0] = 'b'; // 现在可以修改
对于局部变量声明的字符数组,无论大小是否静态已知,通常都会在栈上分配。
标签数据类型与枚举
许多语言支持标签数据类型。在C或Java中,这体现为枚举(enum) 类型。
enum day { Sun, Mon, Tue, Wed, Thu, Fri, Sat };
常见的实现方式是为每个标签关联一个整数(例如,Sun=0, Mon=1...)。在C语言中,程序员可以指定这些整数值。
在ML等语言中,代数数据类型(Algebraic Data Types)的构造器可以携带数据:
type foo = Bar of int | Baz of int * foo
这类类型的典型实现是一个对(pair):
- 第一个元素是标签(tag),一个整数,指示使用的是哪个数据构造器(例如,Bar=0, Baz=1)。
- 第二个元素是数据。其解释取决于标签。例如:
Bar 3表示为(0, 3)Baz (4, f)表示为(1, (4, *f)),其中*f是指向之前创建的foo类型值的指针。
在ML中,一个foo类型的值总是一个指向(tag, data)对的指针。
Switch语句的编译
标签数据类型的一个关键用途是switch语句(或ML中的模式匹配)。我们先看C语言的switch。
编译switch语句有几种策略:
- 级联if语句:将每个
case编译为一个比较和条件跳转。实现简单,但对于大量case,效率低下(最坏情况需进行O(n)次比较)。 - 跳转表:如果
case值密集(例如,枚举值从0到n-1),可以创建一个地址数组(跳转表)。执行时,直接用开关值作为索引查找跳转地址,只需O(1)时间。 - 二分查找/哈希表:对于稀疏但大量的
case值,可以构建一个(值,地址)对表,并使用二分查找或哈希表。
实际编译器中常使用启发式方法混合这些策略。
模式匹配的编译
ML中的模式匹配比C的switch更强大,允许嵌套匹配和变量绑定。一种编译策略是扁平化:将嵌套模式转换为一系列只检查顶层构造器的switch,并在匹配后继续匹配内部数据。这本质上产生了嵌套的switch语句。
优化模式匹配编译是一个深入的研究领域,许多优化可以在源码级别或高级IR上进行,例如重排匹配顺序以提升效率。
LLVM IR 中的类型
现在,让我们回到LLVM IR,看看它如何表示这些结构化数据。LLVM IR是强类型的。
以下是LLVM中一些核心类型:
void:类似C中的void,用于表示不返回值的函数。- 整数类型:
i64,i32,i8,i1(用于布尔值)。 - 数组类型:
[<N> x <ty>]。例如,[42 x i64]。数组大小N用于地址计算,LLVM本身不插入边界检查。未知大小用[0 x <ty>]表示。 - 函数类型:
<ret_ty> (<arg_ty>, <arg_ty>, ...)。参数不能是void类型。 - 结构体类型:
{ <ty>, <ty>, ... }。注意:LLVM结构体类型只有字段类型列表,没有字段名,访问时通过索引(0, 1, 2...)。 - 指针类型:
<ty>* - 命名类型:允许定义递归类型,如链表节点:
%Node = type { i32, %Node* }
GetElementPtr (GEP) 指令
这是LLVM中最关键也最易误解的指令之一,用于计算结构体或数组内部元素的地址。必须牢记:GEP只进行指针运算,不访问内存。
语法是:
<result> = getelementptr <ty>, <ty>* <ptrval>, <ty> <idx>, <ty> <idx>, ...
其抽象含义是:给定一个指向类型<ty>的指针<ptrval>(可将其视为指向一个<ty>数组的指针),通过一系列索引计算出一个新指针,指向该结构内部的某个子元素。
关键点:
- 第一个索引:解释
<ptrval>指向的是一个<ty>数组,并索引到该数组的第i个元素。即使你只有一个元素,通常也使用索引0。 - 后续索引:根据当前计算出的类型(可能是结构体或数组),继续索引到其内部。
- 结果:是一个指针,指向最终计算出的元素。
示例分析:
考虑C代码:&s[1].z.b[5][13],其中s是指向结构体数组的指针。假设结构体定义已翻译为LLVM类型。
对应的GEP指令可能类似于:
%ptr = getelementptr %struct.ST, %struct.ST* %s, i32 1, i32 2, i32 1, i32 5, i32 13
解读:
i32 1:索引到s数组的第1个元素(跳过第0个)。i32 2:索引到该ST结构体的第2个字段(假设z是第2个字段,索引从0开始)。i32 1:索引到RT结构体的第1个字段(假设b是第1个字段)。i32 5:索引到二维数组b的第5行。i32 13:索引到该行(一维数组)的第13列。
这个GEP指令的结果%ptr是一个指向那个i32元素的指针。要获取该元素的值,还需要一条load指令。
类型转换与 Bitcast
有时源语言特性(如子类型)无法直接用LLVM类型表达。LLVM提供了bitcast指令作为“逃生舱口”。
bitcast将值从一种类型转换为另一种类型,不改变任何位(bits)。它要求源类型和目标类型位数相同。
安全示例:将一个指向{i32, i32, i32}(三维点)的指针,转换为指向{i32, i32}(二维点)的指针是安全的,因为任何对二维点的操作(读/写前两个整数)在三维点上同样有效。
%p1 = alloca { i32, i32, i32 } ; 在栈上分配一个三维点
%p2 = bitcast { i32, i32, i32 }* %p1 to { i32, i32 }* ; 安全地转换指针类型
不安全示例:反向转换(二维点指针转为三维点指针)则不安全,因为程序可能尝试访问或修改不存在的第三个整数所在的内存,而那部分内存可能被用于其他目的,导致数据损坏。
因此,bitcast需要由编译器开发者谨慎使用,以确保其安全性符合源语言的语义。
作业3简介
作业3要求你将LLVM Light(一个LLVM子集)编译成x86-64汇编代码。
提供的材料包括:
- LLVM Light规范:详细说明了作业中需要支持的LLVM指令、类型和操作。
- 参考工具:
clang:可以将LLVM编译成汇编,供你参考。lli(LLVM解释器):可以直接执行LLVM代码,帮助你理解程序预期行为。- 一个驱动脚本,可以运行你的编译器、参考编译器或解释器。
强烈建议尽早开始作业3,因为它具有挑战性。请仔细阅读文档,并随时在课程论坛上提问。
总结

本节课我们一起学习了:
- 数组边界检查的实现策略及其性能与安全的权衡。
- C语言字符串的表示(空终止)及其只读常量带来的注意事项。
- 标签数据类型(如枚举和代数数据类型)在内存中的表示方式(标签+数据)。
- Switch语句的多种编译策略(级联if、跳转表等)。
- 模式匹配编译的基本思路(扁平化为嵌套switch)。
- LLVM IR的核心类型系统,包括数组、结构体、指针和命名类型。
getelementptr(GEP) 指令的核心概念:它仅进行地址计算,不访问内存,并且总是隐式地将指针视为数组的起始。bitcast指令的用途与风险,它用于处理LLVM类型系统无法直接表达的转换。- 作业3的总体目标和可用资源。


理解这些数据结构的低级表示和LLVM的建模方式,是构建编译器后端(将IR映射到机器代码)的基础。
009:词法分析入门 🧠
在本节课中,我们将要学习编译过程中的第一步:词法分析。我们将了解如何将人类可读的源代码文本,转换成为编译器可以处理的、结构化的数据单元。
课程概述
上一节我们介绍了代码生成,即如何将中间表示转换为汇编代码。本节中,我们将跳回到编译器流水线的起点,开始学习词法分析。
词法分析是解析过程的一部分。解析的任务是获取源代码文本,并将其转换为机器可以操作和使用的数据结构。解析本身又分为两个主要部分:
- 词法分析:将字符序列转换为“词法单元”。
- 语法分析:将词法单元序列转换为数据结构(通常是抽象语法树)。
今天,我们将重点探讨第一部分:词法分析。
什么是词法分析?
当我们拿到一个源程序时,它本质上是一个字符序列。例如:
if (price > 500) then tax = 0.0
词法分析的作用,就是将这个字符序列分解成被称为 “词法单元” 的块。每个词法单元都对应于语言语法中有意义的基本单位。
在上面的例子中:
if是一个关键字词法单元。price是一个标识符词法单元。>是一个运算符词法单元。500是一个数字词法单元。
语法分析则接收这个词法单元序列,并将其转换为树状数据结构,以展示程序的嵌套和分组结构。
词法单元类型
一种语言会将不同的词法项分类为词法单元类型。常见的类型包括:
以下是主要的词法单元类型:
- 标识符:变量、函数等实体的名称,例如
price,last_in_14th。 - 数字:表示整数常量,例如
73,0,82。 - 实数:表示浮点数,格式多样。
- 关键字:例如
if,then。 - 标点符号和运算符:例如
,,!=,(。
我们使用术语 “保留字” 来指代那些不能用作标识符的词法单元。例如,在 C、Java、C++ 中,if, void, return, while 等关键字有特殊含义,不能用作变量名。
非词法单元内容
当词法分析器遍历字符序列时,有些内容并不会被转换为词法单元,而是会被丢弃。
以下是不被视为词法单元的内容:
- 空白字符:如空格、制表符。它们通常用于分隔词法单元,但本身不构成词法单元(Python 等语言除外,其缩进具有语法意义)。
- 注释:会被词法分析器直接忽略。
- 预处理指令:例如 C/C++ 中的
#include、宏定义。这些通常在词法分析开始前,由预处理器处理。
需要注意的是,像括号 ()、花括号 {} 和分号 ; 这样的符号,虽然最终在抽象语法树中可能不直接体现结构,但它们在语法分析阶段至关重要,用于确定程序结构,因此它们是词法单元。
词法分析示例
让我们通过一个具体例子来理解词法分析的期望输出。
对于程序:if (price > 500) then tax = 0.0
词法分析会将其转换为以下词法单元序列:
IF(关键字)LPAREN(左括号)ID("price")(标识符,附带数据"price")GT(大于号)NUM(500)(数字,附带数据500)RPAREN(右括号)THEN(关键字)ID("tax")(标识符)EQ(等号)REAL(0.0)(实数)EOF(文件结束符)
这个序列清晰地展示了如何将文本“块化”为有意义的单元。
词法分析中的挑战
并非所有字符序列都能成功转换为词法单元流。
情况一:非法字符序列
例如:1XAB
如果语言规定标识符不能以数字开头,且 1XAB 也不是有效的数字格式,那么词法分析将在此处失败并报告错误。实用的词法分析器还会报告错误发生的行号和位置。
情况二:拼写错误
例如:if (price > 500) thn tax = 0.0
这里 thn 是一个有效的标识符。因此,词法分析会成功,输出 ID("thn") 词法单元。这个错误(缺少关键字 then)将在后续的语法分析阶段被捕获,因为词法单元序列不符合语言的语法规则。
这说明了词法分析和语法分析的分工:词法分析只关心能否将字符分组为有效的词法单元类型,不关心这些词法单元在语法上是否构成有效程序。
从概念到实现:识别词法单元
从概念上讲,词法分析是一个黑盒,输入字符串,输出词法单元序列。但如何实现呢?让我们从一个简单问题开始:如何判断一个字符序列是否是“数字”?
这本质上是一个集合成员判定问题:给定所有可能数字的集合(一个无限集),判断一个字符串是否属于该集合。在计算机科学中,我们使用正则表达式来有限地描述这样的无限集合。
正则表达式
一个正则表达式表示一个字符串的集合。这是核心思想。
基本语法与含义:
∅:空集,不匹配任何字符串。ε:只匹配空字符串。a(字面量):匹配单个字符a。R1 R2(连接):匹配一个属于R1的字符串后接一个属于R2的字符串。R1 | R2(选择):匹配一个属于R1或 属于R2的字符串。R*(克林星号):匹配零个或多个连续出现的、属于R的字符串。
扩展语法(便捷表示):
[0-9]:字符类,匹配0到9的任意一个数字。R?:等价于ε | R,匹配零次或一次R。R+:等价于R R*,匹配一次或多次R。
示例:
(0|1)*0:描述所有以0结尾的二进制字符串。(b*(ab+)*a?:描述所有不包含连续两个a的、由a和b组成的字符串。(a|b)*aa(a|b)*:描述所有至少包含连续两个a的、由a和b组成的字符串。
使用正则表达式定义词法单元
我们可以用正则表达式来形式化地定义词法单元类型:
- 关键字
IF:正则表达式为IF。 - 标识符:以字母开头,后跟零个或多个字母数字字符。正则表达式为
[a-zA-Z][a-zA-Z0-9]*。 - 整数:一个或多个数字。正则表达式为
[0-9]+。 - 实数:至少包含一位数字和一个小数点。正则表达式为
([0-9]+”.”[0-9]*)|([0-9]*”.”[0-9]+)。
“最长匹配”原则:
考虑字符串 IFFY。它既匹配关键字 IF,也匹配标识符模式。词法分析器应采用最长可能匹配,因此 IFFY 应被识别为一个标识符,而不是关键字 IF 后跟标识符 FY。这个原则对于处理像 7<x 这样没有空格分隔的序列也至关重要。
实现匹配:确定有限自动机
正则表达式定义了集合,但我们如何高效地判断一个字符串是否匹配某个正则表达式呢?答案是使用 DFA。
一个 DFA 由以下部分组成:
- 一个有限的状态集合。
- 一个起始状态。
- 一组接受状态。
- 一个转移函数:给定当前状态和输入字符,决定下一个状态。
工作原理:
DFA 从起始状态开始,逐个读取输入字符,并根据转移函数改变状态。当输入耗尽时,如果 DFA 处于某个接受状态,则字符串被接受(匹配);否则被拒绝。
我们可以为每个词法单元类型的正则表达式构建一个 DFA。但更有效的方法是,将所有词法单元类型的 DFA 合并成一个大的 DFA。这个合并后的 DFA 在读取字符时,不仅能判断是否构成有效词法单元,还能通过最终所处的接受状态来区分是哪种词法单元类型。
从正则表达式到 DFA
如何自动从正则表达式得到 DFA 呢?这个过程分为两步:
- 正则表达式 → NFA:首先将正则表达式转换为非确定有限自动机。NFA 允许从一个状态通过同一个字符转移到多个状态,也允许不消耗字符的
ε转移。正则表达式的每个操作符(连接、选择、星号)都有对应的 NFA 构造规则,可以递归地组合。 - NFA → DFA:然后通过“子集构造法”将 NFA 转换为 DFA。DFA 的每个状态对应 NFA 的一个状态集合。这个算法可以消除不确定性,得到一个确定性的自动机。
最终得到的 DFA 就可以用来高效地实现词法分析器。
词法分析器生成工具
在实际开发中,我们很少手动构建 DFA。而是使用 词法分析器生成工具,例如 OCamlLex。
我们只需要:
- 编写一个
.mll文件。 - 在其中用正则表达式定义各种词法单元模式。
- 为每个模式指定匹配成功时要执行的 OCaml 代码动作(例如,返回某种词法单元)。
- 运行
ocamllex工具处理该文件。
工具会自动完成以下工作:
- 将所有正则表达式合并。
- 构建 NFA 并转换为优化的 DFA。
- 生成对应的 OCaml 代码,实现一个高效的、基于 DFA 的词法分析器函数。
生成器还帮助我们处理诸如跳过空白字符、跟踪行号、报告错误位置等繁琐但重要的细节。
课程总结
本节课中我们一起学习了编译器的第一个关键阶段——词法分析。我们了解到它的任务是将源代码字符流转换为有意义的词法单元序列。我们探讨了如何使用正则表达式来形式化地描述词法单元,并引入了确定有限自动机作为高效实现匹配的机制。最后,我们介绍了如何使用词法分析器生成工具来自动化这一过程,这将在我们后续的作业和项目实践中发挥重要作用。


下一节课,我们将进入语法分析,学习如何将词法单元序列组织成结构化的抽象语法树。
010:语法分析(Parsing)与上下文无关文法(CFG)
在本节课中,我们将学习如何将词法分析(Lexing)得到的令牌(Token)序列,转换为表示程序结构的抽象语法树(AST)。这个过程称为语法分析或解析(Parsing)。我们将重点介绍上下文无关文法(CFG)的概念,它是定义编程语言语法的核心工具,并初步探讨递归下降(Recursive Descent)解析的实现方法。
从词法分析到语法分析
上一节我们介绍了如何通过词法分析器将字符流转换为令牌流。本节中,我们来看看如何进一步处理这个令牌流。
语法分析的核心任务有两个:
- 判断有效性:确认令牌序列是否符合编程语言的语法规则。
- 提取结构:根据语法规则,构建出能反映程序层次和含义的结构化表示,通常是抽象语法树(AST)。
这类似于分析一个英文句子:我们既要判断“The cat caught the ball”是否符合语法,也要提取其含义——主语是“The cat”,动词是“caught”,宾语是“the ball”。
正则表达式的局限性
我们可能会问:能否像词法分析一样,使用正则表达式来定义整个语言的语法?对于简单的模式,例如“数字之和”,这是可行的。
公式:digits (‘+’ digits)*
然而,当语言结构包含嵌套或配对元素时,例如带括号的算术表达式,正则表达式就无能为力了。考虑以下尝试定义表达式(expr)的规则:
expr -> digitsexpr -> ‘(‘ sum ‘)’sum -> expr ‘+’ expr
如果我们尝试将 sum 的定义展开到 expr 中,会得到 expr -> expr ‘+’ expr,这导致了无限递归,无法用有限状态自动机表示。问题的关键在于,正则表达式缺乏表达递归结构的能力。
上下文无关文法(CFG)
为了描述具有嵌套结构的语言,我们需要更强大的工具:上下文无关文法。CFG 本质上是允许递归的正则表达式。
一个 CFG 由一组产生式(Productions)组成。每个产生式将一个非终结符(Non-terminal)映射到一个由终结符(Terminals,即令牌)和非终结符组成的序列。
公式:Non-terminal -> sequence of (Terminals and Non-terminals)
以下是定义一个简单语句和表达式语言的 CFG 示例:
S -> S ‘;’ SS -> ID ‘:=’ ES -> ‘print’ LE -> NUME -> IDE -> ‘(’ S ‘,’ E ‘)’E -> E ‘+’ EL -> EL -> L ‘,’ E
其中,S(语句)、E(表达式)、L(列表)是非终结符;ID、NUM、‘:=’、‘+’ 等是终结符。S 是开始符号。
推导与语法分析树
我们如何用 CFG 证明一个令牌序列是有效的?通过推导(Derivation)。
- 从开始符号
S开始。 - 重复以下步骤,直到字符串中只包含终结符:
- 在当前字符串中选择一个非终结符。
- 找到一个以该非终结符为左侧的产生式。
- 用该产生式的右侧替换这个非终结符。
推导过程可以直观地表示为语法分析树(Parse Tree)。树根是开始符号,叶子节点是终结符(输入令牌),内部节点是非终结符,子节点代表用于替换该非终结符的产生式右侧。
语法分析树直接体现了程序的层次结构,是连接语法和语义(程序含义)的桥梁。构建语法分析树既是语法验证的过程,也是语义提取的过程。
歧义文法
一个重要的问题是,同一个句子是否可能对应多棵不同的语法分析树?答案是肯定的,这样的文法称为歧义文法。
考虑简单的算术表达式文法:E -> E ‘-’ E | NUM。对于输入序列 6 - 7 - 8,可以构建出两棵分析树:
(6 - 7) - 8(左结合,结果为 -9)6 - (7 - 8)(右结合,结果为 7)
这导致了语义的不确定性,在编程语言中通常是不希望的。我们可以通过改造文法来消除歧义,强制规定运算符的结合性和优先级。
消除歧义:优先级与结合性
以下是改造后的无歧义算术表达式文法,它明确了乘除优先于加减,且所有运算符都是左结合:
E -> E ‘+’ T | E ‘-’ T | T(表达式)T -> T ‘*’ F | T ‘/’ F | F(项)F -> NUM | ID | ‘(’ E ‘)’(因子)
这个文法通过引入额外的非终结符(T, F)来分层级地定义运算符的优先级。E 的产生式确保了 +/- 在最后被组合(优先级最低),并且是左结合的(因为 E 在产生式左侧递归)。
语法分析方法概览
给定一个令牌序列,如何构建其语法分析树?主要有两大类方法:
- 自顶向下分析:从开始符号出发,尝试用产生式进行推导,逐步匹配输入令牌。
- 自底向上分析:从输入令牌出发,尝试反向使用产生式(将右侧归约为左侧),最终规约到开始符号。
在实现上,编译器编写者有几种选择:
- 手动编写递归下降解析器:为每个非终结符编写一个递归函数。优点是错误信息容易定制,缺点是代码冗长,且难以处理左递归文法。
- 解析器组合子:在函数式语言中流行的高阶函数方法,用于组合和生成解析器。
- 使用解析器生成工具(如 ANTLR, Yacc):输入文法描述,自动生成解析代码。这是最常用、最高效的方式。
递归下降解析初探
让我们通过代码看一个简单的递归下降解析器框架。其核心思想是定义一个“语法”类型,它接收令牌列表,并返回所有可能的解析结果(值 + 剩余令牌)列表。
代码:
type ‘a grammar = token list -> (‘a * token list) list
解析器函数会尝试所有可能的解析路径,并过滤出那些消耗完所有输入令牌的成功结果。

我们可以为文法定义构造子,例如:
char:匹配特定字符。epsilon:匹配空字符串。alt:两个语法的选择(r1或r2)。concat:两个语法的连接(r1然后r2)。star:零次或多次重复。


然而,直接实现类似 E -> E ‘+’ T 这样的左递归产生式会导致无限递归和栈溢出。为了解决这个问题,通常需要先将文法改写为无左递归的形式,例如引入新的非终结符 E’:
E -> T E’E’ -> ‘+’ T E’ | ‘-’ T E’ | ε

这种改写能消除立即的左递归,但可能会使生成的语法分析树结构不那么直观,需要后续处理才能转换成理想的 AST。




本节课中我们一起学习了语法分析的基本目标,认识了上下文无关文法(CFG)作为定义语言语法的强大工具,理解了推导、语法分析树和歧义性的概念,并初步了解了通过改造文法来强制运算符优先级和结合性。最后,我们概览了不同的语法分析方法,并探讨了手动实现递归下降解析器的基本思路及其对左递归文法的处理挑战。在接下来的课程中,我们将深入探讨更高效的解析算法和工具。
011:LL(1)语法分析与预测分析表构建 🧠

在本节课中,我们将学习一种称为LL(1)的语法分析技术。这是一种自顶向下的分析方法,其核心思想是仅通过查看输入流中的下一个(或前K个)符号,就能确定应该使用哪个语法产生式。我们将通过构建一个预测分析表来实现这一目标。
上一节我们介绍了递归下降语法分析器,它需要尝试所有可能性,效率较低。本节中我们来看看如何通过预测来避免回溯。
概述:构建预测分析表
为了构建预测分析表,我们需要计算三个关键集合:FIRST集、FOLLOW集和判断非终结符是否可推导出空串的NULLABLE集。这些集合将帮助我们决定,在面对一个非终结符和下一个输入符号时,应该选择哪个产生式。
以下是计算这些集合的基本规则:
1. NULLABLE集
一个非终结符X是NULLABLE的,如果:
- 存在产生式
X -> ε(空串)。 - 或者存在产生式
X -> Y1 Y2 ... Yn,并且所有Yi都是NULLABLE的非终结符。

2. FIRST集
对于任意符号串γ(由终结符和非终结符组成),FIRST(γ) 是所有可能作为由γ推导出的串的第一个终结符的集合。
计算规则:
- 如果γ以终结符
t开头,则t ∈ FIRST(γ)。 - 如果γ以非终结符
X开头,则FIRST(X) ⊆ FIRST(γ)。 - 如果
X是NULLABLE的,那么还需要考虑X后面的符号。
更形式化地,对于产生式 X -> γ:
- 若
γ = tδ(t为终结符),则t ∈ FIRST(X)。 - 若
γ = Yδ(Y为非终结符),则FIRST(Y) ⊆ FIRST(X)。如果Y是NULLABLE的,则FIRST(δ)也需要加入FIRST(X)。

3. FOLLOW集
对于非终结符X,FOLLOW(X) 是所有可能紧跟在由X推导出的串后面的终结符的集合。
计算规则(对于产生式 A -> α X β):
FIRST(β)中的终结符(除了ε)属于FOLLOW(X)。- 如果
β是NULLABLE的,或者β为空(即产生式为A -> α X),那么FOLLOW(A)中的终结符也属于FOLLOW(X)。

我们将通过迭代应用这些规则,直到没有新元素加入任何集合为止(即达到不动点)。

示例:算术表达式语法
让我们用一个具体的算术表达式语法来演示整个过程。该语法已经过处理,消除了左递归,并确保了运算符优先级(乘除高于加减)。
语法规则:
S -> E EOF
E -> T E'
E' -> + T E' | - T E' | ε
T -> F T'
T' -> * F T' | / F T' | ε
F -> id | num | ( E )
其中,非终结符为 {S, E, E', T, T', F},终结符为 {id, num, (, ), +, -, *, /, EOF}。
步骤1:计算NULLABLE集
以下是判断非终结符是否为NULLABLE的过程:
E'和T'都有ε产生式,因此它们是NULLABLE。S,E,T,F的所有产生式都至少包含一个终结符或不可为空的非终结符,因此它们不是NULLABLE。
所以,NULLABLE = {E', T'}。

步骤2:计算FIRST集
我们初始化所有非终结符的FIRST集为空,然后根据规则迭代添加元素。




以下是初始添加和传播的过程:
- 从终结符开始:
E' -> + T E'⇒+ ∈ FIRST(E')E' -> - T E'⇒- ∈ FIRST(E')T' -> * F T'⇒* ∈ FIRST(T')T' -> / F T'⇒/ ∈ FIRST(T')F -> id⇒id ∈ FIRST(F)F -> num⇒num ∈ FIRST(F)F -> ( E )⇒( ∈ FIRST(F)
- 传播(因产生式以非终结符开头):
T -> F T',且F不是NULLABLE ⇒FIRST(F) ⊆ FIRST(T)⇒FIRST(T) = {id, num, (}E -> T E',且T不是NULLABLE ⇒FIRST(T) ⊆ FIRST(E)⇒FIRST(E) = {id, num, (}S -> E EOF,且E不是NULLABLE ⇒FIRST(E) ⊆ FIRST(S)⇒FIRST(S) = {id, num, (}


最终FIRST集:
FIRST(F) = {id, num, (}FIRST(T) = FIRST(E) = FIRST(S) = {id, num, (}FIRST(E') = {+, -}FIRST(T') = {*, /}
步骤3:计算FOLLOW集

我们初始化所有非终结符的FOLLOW集为空(除了约定FOLLOW(S)包含EOF)。然后根据规则迭代添加。







以下是FOLLOW集的构建过程:
- 规则1(
A -> α X β,将FIRST(β)加入FOLLOW(X)):S -> E EOF⇒EOF ∈ FOLLOW(E)E -> T E'⇒FIRST(E') = {+, -} ⊆ FOLLOW(T)T -> F T'⇒FIRST(T') = {*, /} ⊆ FOLLOW(F)F -> ( E )⇒) ∈ FOLLOW(E)
- 规则2(
β可为空,将FOLLOW(A)加入FOLLOW(X)):E -> T E',且E'是NULLABLE ⇒FOLLOW(E) ⊆ FOLLOW(T)⇒FOLLOW(T) = {+, -, EOF, )}T -> F T',且T'是NULLABLE ⇒FOLLOW(T) ⊆ FOLLOW(F)⇒FOLLOW(F) = {*, /, +, -, EOF, )}E'在产生式E -> T E'中 ⇒FOLLOW(E) ⊆ FOLLOW(E')⇒FOLLOW(E') = {EOF, )}T'在产生式T -> F T'中 ⇒FOLLOW(T) ⊆ FOLLOW(T')⇒FOLLOW(T') = {+, -, EOF, )}







最终FOLLOW集:
FOLLOW(S) = {}(通常不特别关注)FOLLOW(E) = {EOF, )}FOLLOW(E') = {EOF, )}FOLLOW(T) = {+, -, EOF, )}FOLLOW(T') = {+, -, EOF, )}FOLLOW(F) = {*, /, +, -, EOF, )}


构建预测分析表







现在我们有了所有必要的信息,可以构建LL(1)预测分析表M了。表的行是非终结符,列是终结符(包括EOF)。每个单元格M[X, t]包含当我们要解析非终结符X且下一个输入符号是t时,应该使用的产生式。


构建规则如下:
- 对每个产生式
X -> γ:- 对每个终结符
t ∈ FIRST(γ),将X -> γ加入M[X, t]。
- 对每个终结符
- 如果
γ是NULLABLE的(即ε ∈ FIRST(γ)),那么:- 对每个终结符
t ∈ FOLLOW(X),将X -> γ(即X -> ε)加入M[X, t]。
- 对每个终结符




如果最终每个单元格最多只有一个产生式,则该语法是LL(1)的,并且这个表可以用于确定性的预测分析。






根据上述规则,为我们示例语法构建的预测分析表如下:



| 非终结符 | id | num | ( | ) | + | - | * | / | EOF |
|---|---|---|---|---|---|---|---|---|---|
| S | S->E EOF | S->E EOF | S->E EOF | ||||||
| E | E->T E' | E->T E' | E->T E' | ||||||
| E' | E'->ε | E'->+T E' | E'->-T E' | E'->ε | |||||
| T | T->F T' | T->F T' | T->F T' | ||||||
| T' | T'->ε | T'->ε | T'->ε | T'->*F T' | T'->/F T' | T'->ε | |||
| F | F->id | F->num | F->( E ) |
可以看到,每个单元格最多只有一个产生式,因此该语法是LL(1)的。
使用预测分析表进行语法分析
有了预测分析表,语法分析过程就变得非常直接。我们维护一个解析栈,初始时包含开始符号S和EOF。然后,我们查看栈顶元素和下一个输入符号:
- 如果栈顶是终结符,并且与输入符号匹配,则弹出栈顶并消耗输入符号。
- 如果栈顶是非终结符
X,输入符号是t,则查找表M[X, t]。如果表项为空,则报告错误;否则,弹出X,并将对应产生式右部的符号逆序压入栈中。 - 当栈顶和输入都是
EOF时,分析成功完成。
示例: 解析输入串 ( id + num ) EOF。
过程简述如下(栈顶在右):
- 栈:
S EOF,输入:(。查表M[S, (]得S->E EOF。弹出S,压入EOF E。栈变为:EOF E EOF。 - 栈顶
E是非终结符,输入(。查表M[E, (]得E->T E'。弹出E,压入E' T。栈:EOF E' T EOF。 - 栈顶
T,输入(。查表M[T, (]得T->F T'。弹出T,压入T' F。栈:EOF E' T' F EOF。 - 栈顶
F,输入(。查表M[F, (]得F->( E )。弹出F,压入) E (。栈:EOF E' T' ) E ( EOF。 - 栈顶
(是终结符,与输入(匹配。弹出(,消耗输入。栈:EOF E' T' ) E EOF,输入指向id。 - 栈顶
E,输入id。查表M[E, id]得E->T E'...(后续步骤类似,依次展开T,F,匹配id,处理T'(选择ε),处理E'(选择+T E'),匹配+,展开T,F,匹配num,处理T'(ε),匹配),处理剩余的T'和E'(均选择ε),最后匹配EOF)。

通过这个过程,我们无需回溯就成功构建了输入串的语法分析树(推导过程)。
LL(K) 与更强大的语法分析器
LL(1)是更一般的LL(K)分析的特例,其中K表示向前看K个符号。K越大,能处理的语法范围越广,但分析表也会呈指数级增长。有些语法不是任何LL(K)的(例如,存在左递归或二义性的语法)。
即使是无二义性的语法,也可能不是LL(K)的。对于这些语法,我们需要更强大的分析技术,例如LR分析。LR分析是一种自底向上的方法,通常能处理比LL分析更广泛的语法类别,也是许多实际编译器工具(如Yacc,Bison)所采用的技术。我们将在下一讲中介绍LR分析。

总结
本节课中我们一起学习了LL(1)预测语法分析。
- 我们理解了通过计算FIRST集、FOLLOW集和NULLABLE集,可以预先确定在给定下一个输入符号时应选择哪个产生式。
- 我们一步步演示了如何为给定的算术表达式语法计算这些集合。
- 我们利用这些集合构建了预测分析表,该表使得语法分析过程无需回溯,高效且确定。
- 最后,我们了解了LL分析的局限性,并引出了更强大的LR分析技术。

掌握这些概念对于理解和使用自动化语法分析器生成工具至关重要。
012:LR语法分析
在本节课中,我们将要学习LR语法分析,这是一种自底向上的语法分析技术。我们将了解其核心概念、工作原理,并通过一个具体示例来理解如何构建和使用LR分析器。
概述
LR语法分析是一种强大的自底向上分析方法,广泛用于编译器构建。其名称含义如下:
- L:从左到右扫描输入。
- R:构造一个最右推导。
- k:向前查看k个输入符号以做出决策。
本节课我们将重点学习LR(0)分析,即不进行向前查看。我们将学习如何构建一个确定有限自动机(DFA)来指导分析过程,理解“移进”和“归约”操作,并了解分析过程中可能出现的冲突。
支持资源与课程安排
在开始之前,请注意课程提供的支持资源。如果你在课程中遇到困难,请随时联系我或你的宿舍管理员。社区中有很多人可以提供帮助。
关于课程安排,请注意周三将有一场关于开源编译器伦理问题的嵌入式伦理讲座。讲座将以讨论为基础,鼓励亲自参加以更好地参与互动。讲座前有一些建议的阅读材料。
作业三已发布,一周后截止。考虑到这是学期的繁忙时期,我们提供了灵活性。同时,我们今天将涵盖作业四所需的所有材料,因此作业四也将很快发布,你将有大约三周时间完成。
作业四要求你实现一个简单的类C命令式语言Oat的编译器,将其编译到LLVM Lite。在作业五中,你将处理功能更丰富的Oat v2。由于LLVM Lite与LLVM兼容,你可以使用clang将其进一步编译到x86,或者将你在作业三中编写的后端直接用于作业四,从而构建一个从高级语言到x86的完整编译器链。
LR语法分析简介
上一节我们介绍了自顶向下的LL(k)语法分析。本节中,我们来看看自底向上的LR语法分析。
LR分析的基本思想是使用一个栈和一个输入序列。分析器在每一步可以执行两种操作:
- 移进:将输入中的一个符号移到栈顶。
- 归约:当栈顶的符号序列匹配某个产生式的右部时,将这些符号弹出,并将该产生式的左部非终结符压入栈。
LR分析器实际上是以逆序构造最右推导。分析过程从底部的词法单元开始,通过归约操作逐步向上,直到得到文法的开始符号。
LR(0)分析器构造
为了决定何时移进、何时归约以及按哪个产生式归约,我们需要预先构建一个DFA和一个动作表。DFA的状态由项的集合构成。
一个项是一个产生式,并在其右部某处加上一个点“·”,用以指示分析进度。例如,项 E -> E · + E 表示我们已经识别了E,并期望接下来看到+和另一个E。
以下是构建LR(0)自动机的步骤概述:
构建LR(0)自动机
我们通过一个具体文法来演示构建过程。考虑以下表示S表达式的文法:
S' -> S $
S -> ‘(’ L ‘)’
S -> x
L -> S
L -> L ‘,’ S
这里,S‘是新的开始符号,$代表文件结束符。S可以是一个括号括起来的列表L,或者是一个标识符x。列表L可以是一个单独的S,或者是由逗号分隔的L和S。
1. 初始状态与闭包
我们从初始状态开始,它包含开始产生式对应的项,且点在最左边:S' -> · S $。然后,我们计算这个状态的闭包:如果项中点后面是一个非终结符(例如· S),我们就需要加入所有该非终结符产生式对应的新项,且点都在最左边。因此,我们加入S -> · ‘(’ L ‘)’ 和 S -> · x。
2. 状态转移
对于状态中的每个项,我们查看点后面的符号(终结符或非终结符)。对于每个这样的符号X,我们创建一个新状态(或转移到已存在的状态)。新状态包含原项中点在X后移动一位得到的新项,然后同样计算其闭包。
例如,从初始状态(状态1)出发:
- 对于符号
x,我们转移到新状态2,包含项S -> x ·。 - 对于符号
‘(’,我们转移到新状态3,初始包含项S -> ‘(’ · L ‘)’,计算闭包后,会加入L的所有产生式对应的项(L -> · S和L -> · L ‘,’ S),进而又需要加入S的所有产生式对应的项。
重复这个过程,直到没有新状态产生。最终我们会得到一个完整的DFA。
3. 构建动作表
根据DFA的状态,我们可以定义分析动作:
- 接受:如果状态包含形如
S' -> S · $的项,且输入已耗尽,则分析成功。 - 归约:如果状态包含形如
A -> γ ·的项(点在最右端),则动作是按产生式A -> γ进行归约。 - 移进:如果状态有一个标号为终结符
t的 outgoing 边指向状态j,则对于下一个输入符号为t的情况,动作为移进t并转移到状态j。
在我们的示例文法中,最终构建的DFA和动作表能够无冲突地指导分析过程。
LR分析过程示例
让我们使用构建好的DFA和动作表来分析输入字符串 ( x , x )。
分析过程维护一个栈和剩余输入。每一步,我们都从DFA的初态开始,将栈中内容(从底到顶)作为输入流过DFA,最终到达的当前状态决定了下一步动作。
以下是分析步骤的简化演示:
- 栈空,在状态1。动作:移进
(。 - 栈为
(,在状态3。动作:移进x。 - 栈为
( x,在状态2。动作:按S -> x归约。弹出x,压入S。 - 栈为
( S,在状态7。动作:按L -> S归约。弹出S,压入L。 - 栈为
( L,在状态5。动作:移进,。 - 栈为
( L ,,在状态8。动作:移进x。 - 栈为
( L , x,在状态2。动作:按S -> x归约。弹出x,压入S。 - 栈为
( L , S,在状态9。动作:按L -> L , S归约。弹出L , S,压入L。 - 栈为
( L,在状态5。动作:移进)。 - 栈为
( L ),在状态6。动作:按S -> ( L )归约。弹出( L ),压入S。 - 栈为
S,在状态4。输入为空,动作:接受。
这个例子清晰地展示了LR分析器如何利用预先计算的DFA,通过移进和归约操作,自底向上地构建语法树。
冲突与LR(k)分析
在构建动作表时,一个状态可能指示多个动作,这就产生了冲突。主要有两种:
- 移进-归约冲突:一个状态同时要求移进某个符号和按某个产生式归约。
- 归约-归约冲突:一个状态要求按两个或多个不同的产生式进行归约。
LR(0)分析由于没有向前看能力,容易产生冲突。例如,考虑加法表达式的两种文法:
- 左递归文法:
E -> E + num | num(通常无冲突) - 右递归文法:
E -> num + E | num(可能产生移进-归约冲突)
右递归文法在分析到num时,栈顶为E,分析器无法确定应该立即将E归约为表达式,还是移进+继续构造更大的表达式。
解决冲突
为了解决冲突,我们可以:
- 改写文法:消除歧义。例如,著名的“悬空else”问题可以通过重构
if-then-else的文法来解决。 - 使用LR(k)分析:通过向前查看k个输入符号来帮助决策。LR(1)分析器项的形式是
(产生式, 点位置, 向前看符号集)。这大大减少了冲突,但增加了自动机的体积。 - 指定优先级和结合性:在语法分析器生成器(如Yacc/Bison)中,可以为运算符声明优先级和结合性。这些声明会在内部被转换为冲突消解规则(例如,“遇到移进-归约冲突时,若下一个符号是
*则优先移进,若是+则优先归约”)。
在实践中,LALR(1) 分析器最为常用。它通过合并LR(1)自动机中相似的状态来减小体积,虽然表达能力稍弱于LR(1),但对于大多数编程语言文法已经足够。
语法分析器生成器
手动构建LR分析器是繁琐的。实践中,我们使用语法分析器生成器。一个著名的例子是Yacc及其各种变体(如GNU Bison, OCamlYacc)。
这些工具允许你以声明式的方式指定文法的产生式,以及每个产生式对应的语义动作(用于构建AST或执行其他操作)。生成器会自动构建LR分析表(通常是LALR(1)),并生成分析器代码。
例如,在menhir(一个OCaml的语法分析器生成器)中,你可以这样声明运算符的优先级和结合性:
%left PLUS MINUS
%left TIMES DIVIDE
这声明了+和-是左结合的,且优先级低于同样是左结合的*和/。当发生冲突时,生成器会利用这些信息来消解。
总结
本节课中我们一起学习了LR语法分析的核心内容。我们首先了解了LR分析的基本原理,即通过移进和归约操作自底向上构建语法树。然后,我们深入探讨了如何为LR(0)分析构造关键的数据结构——一个基于“项”的DFA和相应的动作表,并通过一个S表达式文法的例子完整演示了构造和分析过程。

我们认识到LR(0)分析可能因缺乏向前看能力而产生移进-归约或归约-归约冲突。为了解决这些问题,可以引入向前看符号形成LR(k)分析,或者使用更实用的LALR(1)分析。最后,我们了解到在实际编译器开发中,通常会借助语法分析器生成器(如Yacc/Bison, menhir)来自动化这一复杂过程,并通过声明运算符优先级和结合性来优雅地处理常见的冲突。


LR语法分析是编译器前端中强大且实用的技术,为后续的语义分析和代码生成奠定了坚实的基础。
013:开源生态系统与责任 🧠

在本节课中,我们将学习开源软件的基本概念、其发展历史,并探讨作为用户和开发者,我们在使用和依赖开源软件时可能承担的责任。我们将通过思想实验和小组讨论,分析开源社区面临的挑战,例如“公地悲剧”以及大型科技公司主导的影响。
开源软件概述
开源软件是指源代码公开,允许用户自由使用、研究、修改和分发的软件。这种模式源于上世纪60-70年代的共享文化,旨在对抗大型公司的软件私有化趋势。



开源的历史与核心自由

上一节我们介绍了开源软件的基本概念,本节中我们来看看它的历史起源和核心原则。
1980年,美国国会通过了《计算机软件版权法案》,允许软件像文学作品一样被版权保护。同年,麻省理工学院程序员理查德·斯托曼因无法获取新打印机的源代码进行修改,创立了自由软件基金会和GNU项目,开启了自由软件运动。
自由软件的核心是“自由如言论,而非免费如啤酒”。以GNU通用公共许可证为例,它保障了以下四种自由:
- 自由0: 可以出于任何目的运行程序。
- 自由1: 可以研究程序如何工作,并修改它。
- 自由2: 可以重新分发拷贝,帮助他人。
- 自由3: 可以分发修改后的版本,让整个社区受益。
一个关键限制是“著佐权”:任何基于GPL软件的分发或修改版本,也必须以GPL许可证发布。这与“开源”许可证(如MIT、Apache许可证)不同,后者允许将开源代码用于专有软件。

开源生态的现状与挑战
了解了开源的理念后,本节中我们来看看当今开源生态的现状及其面临的挑战。
在课堂上进行的调查显示,几乎所有学生都使用过开源软件(如GCC编译器、Linux操作系统、Firefox浏览器),但只有少数人曾为开源项目贡献过代码。然而,据统计,97% 的软件都在某种程度上依赖开源组件。
这种广泛使用与有限贡献之间的不平衡,引出了“公地悲剧”的类比。在思想实验中,如果每个人都过度使用公共草场(开源软件)而不维护(贡献),资源最终会枯竭。
在软件领域,这可能导致:
- 安全漏洞: 如2014年的 Heartbleed 漏洞,它存在于一个由少数志愿者维护的关键加密库OpenSSL中,影响了全球大量网络服务器。
- 维护危机: 项目可能因维护者 burnout 或缺乏资源而停滞,导致依赖它的系统面临风险。

责任探讨:我们是否有义务贡献?
面对这些挑战,我们自然会问:作为开源软件的用户,我们是否有伦理义务进行回馈?
以下是几种可能的贡献策略:
- 个人贡献: 提交代码、修复bug、撰写文档。
- 公司赞助: 大型科技公司提供资金或允许员工投入工作时间。
- 政府支持: 将关键开源基础设施视同公共设施进行资助。
- 基金会模式: 建立非营利组织来管理项目和资金。
目前,企业赞助已成为主流。微软、谷歌、IBM等公司都是开源的主要贡献者。这种转变的原因包括:促进创新、节省成本、获取竞争优势以及推广自家的专有产品。
“类公地悲剧”:企业主导下的新问题
企业深度参与解决了部分可持续性问题,但也带来了新的挑战,即“类公地悲剧”。


当开源项目主要由一两家大公司主导时,虽然软件本身仍是开源的,但其发展方向可能更倾向于与这些公司的专有生态系统集成。这可能导致:
- 社区边缘化: 独立开发者或小公司对项目方向的影响力减弱。
- 隐性绑定: 用户可能被引导至赞助公司的其他专有服务。
- 创新方向偏移: 开发重点可能服务于公司利润而非公共利益。
例如,微软开源的Visual Studio Code编辑器与其Azure云服务深度集成,这有助于微软建立开发者生态。
解决方案的探讨与权衡
那么,如何缓解企业主导带来的问题呢?在小组讨论中,同学们提出了多种设想。
以下是几种设想及其潜在利弊:
- 建立民主治理委员会: 由社区代表和公司代表共同决策项目方向。
- 潜在问题: 决策可能低效;公司可能通过游说或占据多数席位施加过度影响。
- 社区分叉项目: 如果社区不认同主导公司的发展方向,可以创建独立的分支。
- 潜在问题: 可能分散社区力量,且分叉项目同样面临可持续性挑战。
- 政府监管与反垄断: 防止单一公司对关键开源基础设施形成过度控制。
- 潜在问题: 监管可能滞后于技术发展,并可能抑制创新。
这些方案各有利弊,没有完美的答案,需要在自由、效率、可持续性和公平之间取得平衡。
课程总结与作业
本节课中,我们一起学习了开源软件的核心自由与历史,探讨了其可持续发展面临的“公地悲剧”挑战,并分析了企业成为主要贡献者后引发的“类公地悲剧”新问题。我们认识到,在享受开源带来的自由与便利时,也需要思考个人与集体的责任。
作业提示:
请思考以下类比论证:为防止国家森林湖泊的过度捕捞(公地悲剧),政府会要求购买捕鱼许可并设定限额。将此类比应用于开源软件,结论是:政府应该限制个人或公司对开源软件的使用和贡献。你是否同意这个类比和结论?为什么?
请简要阐述你的观点,分析该类比的合理性,并说明你同意或不同意结论的理由。

注:本教程根据哈佛大学编译器课程嵌入式伦理模块讲座内容整理,聚焦于开源生态的伦理讨论。
014:函数编译入门 🚀

在本节课中,我们将要学习如何为函数式编程语言实现编译。我们将从理解函数作为“一等公民”的语义开始,逐步深入到如何将这些概念转化为高效的编译策略。
概述 📋
函数式编程语言的核心特性之一是函数可以作为值来传递和返回。为了理解如何编译这样的语言,我们首先需要明确其运行时语义。我们将从两种不同的语义描述方法入手:替换语义和环境语义。理解这些语义是后续实现编译器的关键基础。
从替换语义到环境语义 🔄
上一节我们介绍了函数式编程的基本概念。本节中我们来看看如何精确地描述其执行过程。
替换语义
替换语义是一种直观但低效的语义描述方式。其核心思想是:当应用一个函数时,我们将函数体中的形式参数替换为实际参数值,然后对替换后的表达式求值。
例如,对于函数 fun x -> x + 1 应用于参数 5,我们先将函数体 x + 1 中的 x 替换为 5,得到 5 + 1,然后求值得 6。
在代码中,这体现为一个递归的求值函数和一个替换函数。
(* 简化的求值函数 *)
let rec eval (e : expr) : value =
match e with
| Int i -> Int i
| Add (e1, e2) ->
let Int i1 = eval e1 in
let Int i2 = eval e2 in
Int (i1 + i2)
| Lambda (x, e_body) -> Lambda (x, e_body) (* 函数本身即是值 *)
| Var x -> error "Unbound variable"
| App (e1, e2) ->
let Lambda (x, e_body) = eval e1 in
let v = eval e2 in
eval (subst v x e_body) (* 关键步骤:替换后求值 *)
(* 替换函数 *)
let rec subst (v : value) (x : string) (e : expr) : expr =
match e with
| Int i -> Int i
| Add (e1, e2) -> Add (subst v x e1, subst v x e2)
| Var y -> if y = x then v else Var y
| App (e1, e2) -> App (subst v x e1, subst v x e2)
| Lambda (y, e_body) ->
if y = x then Lambda (y, e_body) (* 避免捕获 *)
else Lambda (y, subst v x e_body)
替换语义的问题在于效率低下。每次函数应用都需要遍历整个函数体进行替换,然后再次遍历进行求值。
环境语义与闭包
为了解决效率问题,我们引入环境语义。环境(environment)是一个从变量名到值(value)的映射。在求值时,我们携带当前环境,遇到变量时直接从环境中查找其值,而无需进行替换。



对于嵌套函数,关键挑战在于如何处理函数体内引用的、定义在其外部作用域中的变量(自由变量)。解决方案是闭包。
闭包是一个包含函数定义和其创建时环境的对(pair)。它“闭合”了函数,为其所有自由变量提供了绑定。
以下是使用闭包的环境语义求值函数:
type value =
| VInt of int
| VClosure of env * string * expr (* 环境,形参,函数体 *)
and env = (string * value) list
let rec eval_env (env : env) (e : expr) : value =
match e with
| Int i -> VInt i
| Var x -> List.assoc x env (* 从环境中查找 *)
| Add (e1, e2) ->
let VInt i1 = eval_env env e1 in
let VInt i2 = eval_env env e2 in
VInt (i1 + i2)
| Lambda (x, e_body) -> VClosure (env, x, e_body) (* 创建闭包! *)
| App (e1, e2) ->
let VClosure (closure_env, x, e_body) = eval_env env e1 in
let v_arg = eval_env env e2 in
(* 在闭包环境(包含自由变量绑定)的基础上,添加形实参绑定,求值函数体 *)
eval_env ((x, v_arg) :: closure_env) e_body
通过使用环境和闭包,我们将替换操作延迟并分散到了变量查找中,避免了低效的全局替换。
从语义到编译 🛠️
理解了函数在运行时的行为(通过闭包和环境)后,我们就可以设计编译策略了。核心思想是将高级的语义概念转化为低级的运行时数据结构与代码。
闭包转换与 Lambda 提升
编译函数式语言的两个关键步骤是闭包转换和Lambda 提升。
- 闭包转换:将每个函数值显式地表示为一个闭包结构。在类 C 的语言中,这通常是一个包含函数指针和环境指针的结构体。
- Lambda 提升:将所有嵌套函数“提升”为顶级函数。因为这些函数现在通过显式传递的环境参数来访问自由变量,它们不再需要嵌套在语法上。
以下是一个转换示例:
原始代码(类 OCaml):
let add = fun x -> fun y -> y + x in
let inc = add 1 in
inc 5
转换后(类 C):
// 被提升的内部函数,显式接收环境参数
int f_inner(Env* env, int y) {
int x = lookup(env, "x"); // 从环境中查找自由变量 x
return y + x;
}
// 外部函数,也接收环境参数,返回一个闭包
Closure* f_outer(Env* env, int x) {
Env* new_env = extend_env(env, "x", x); // 扩展环境,绑定 x
// 创建闭包:包含函数指针 f_inner 和环境 new_env
Closure* clo = malloc(sizeof(Closure));
clo->code = &f_inner;
clo->env = new_env;
return clo;
}
// 主函数
int main() {
Closure* inc = f_outer(empty_env, 1); // 部分应用,得到闭包
int result = apply(inc, 5); // 应用闭包:调用 clo->code(clo->env, 5)
return result;
}
环境表示与内存管理
环境需要动态扩展(当进入函数时)并且其生命周期可能比创建它的函数调用更长(闭包可能被返回并在其他地方使用)。因此,环境通常需要在堆上分配内存,而不是栈上。
这引出了一个有趣的类比:闭包和对象在本质上非常相似。一个对象可以看作是一个记录字段值(环境)和方法指针(函数指针)的结构体。两者在表达力上是等价的,编译技术也相互借鉴。
总结 🎯
本节课中我们一起学习了函数式语言编译的基础。
- 我们从替换语义出发,理解了函数应用的基本原理,但认识到其效率缺陷。
- 我们引入了环境语义和闭包的概念,这是一种更高效的运行时模型,通过将变量绑定存储在环境中并在使用时查找,避免了昂贵的复制操作。
- 最后,我们探讨了如何基于闭包模型进行编译,即闭包转换(将函数表示为显式的代码与环境对)和Lambda 提升(将嵌套函数转为顶级函数,通过参数显式传递环境)。我们还注意到环境需要在堆上管理,并且闭包与面向对象中的对象有深刻的联系。


下一讲中,我们将更深入地探讨函数编译的细节,特别是环境的具体实现和优化技术。
015:编译函数(续)

在本节课中,我们将继续学习如何编译函数,特别是深入探讨闭包转换和Lambda提升的实现细节。我们将了解如何通过使用德布鲁因索引等技术,高效地表示和访问函数环境中的变量。
上一节我们介绍了闭包转换和Lambda提升的基本概念。本节中,我们来看看如何具体实现这些转换,并优化其性能。
闭包转换与Lambda提升回顾
我们通过将函数值表示为一个对(代码指针,环境)来编译闭包。我们显式地让所有函数都接受一个环境作为额外参数,并通过该环境访问变量。
一旦我们将嵌套函数转换为这种形式,它们就不再直接引用外层函数的变量,因此我们可以将它们提升到顶层,这个过程称为Lambda提升。
实现细节与优化
现在我们已经掌握了闭包转换的高级思想,让我们思考如何高效地实现它。
变量存储优化
目前,在这种简单的闭包转换方法中,我们将所有变量都放入环境,这意味着变量本质上都存储在堆上。这并不理想,因为堆存储访问较慢,且难以优化。
好消息是,我们实际上不需要为函数内的所有变量都分配堆存储。我们可以通过分析来确定哪些变量可能“逃逸”,即可能被嵌套函数(未来会被转换为闭包)使用。
这意味着,在函数式编程语言中编写的许多变量实际上永远不会逃逸。因此,我们可以像处理非函数式语言中的局部变量一样处理它们,无需在环境中分配存储。我们只需要为那些可能被闭包使用的变量执行此操作。
环境的高效实现
我们之前给出的转换示例实际上效率很低。我们使用字符串作为变量名,并在运行时进行字符串比较或哈希计算,这会带来开销。
我们可以避免这种开销,采用一种巧妙的编码方式:使用自然数而不是字符串来命名变量,这被称为德布鲁因索引。
更重要的是,我们采用一种巧妙的方式为变量编号,使得很多时候我们根本不需要显式地提及变量名。
以下是转换为德布鲁因索引形式的方法:
我们有一个新的中间表示,它与我们的迷你语言相同,包含整数、函数定义(lambda项)、函数应用和变量。但变量不再使用字符串标识,而是使用整数索引。
转换过程使用一个递归函数,该函数接受一个将程序变量名映射到其德布鲁因索引的环境。对于整数,转换是直接的。对于变量,我们在环境中查找其名称以获取当前映射的索引。对于应用,我们递归转换子表达式。对于函数定义,我们扩展环境:新定义的变量获得索引0(最内层定义),环境中所有其他变量的索引增加1。
在这种索引方案中,整数索引指的是函数定义的词法深度。索引0的变量由最内层的函数定义绑定,索引1的变量由次内层的函数定义绑定,依此类推。这样,我们甚至不需要函数的参数名,只需通过索引就知道引用的是哪个参数。
处理多个参数
我们可以将德布鲁因索引扩展为允许函数接受多个参数。使用一个索引对,第一个索引表示引用哪个函数定义(词法深度),第二个索引表示需要该函数定义的哪个参数。
对于嵌套环境,我们可以使用数组链表来实现:每个函数定义对应一个数组(存储其参数),并通过指针链接到外层环境。
环境数据结构的权衡
实现环境主要有两种方法:链表和数组。
以下是两种方法的比较:

-
链表环境:
- 优点:创建新环境成本低,只需创建新的头节点并链接到旧环境即可,可以共享大部分环境结构。
- 缺点:遍历环境可能较慢(需要跟随多个指针),内存访问局部性可能较差。
-
数组(扁平)环境:
- 优点:访问速度快(直接索引),内存局部性好(相关变量在内存中相邻)。
- 缺点:创建新环境通常需要复制整个数组或大部分内容,无法像链表那样共享环境结构。
选择哪种方法是一个经验性问题,取决于程序中环境的使用模式(例如,环境共享的频率、变量访问模式等)。
总结

本节课中我们一起学习了编译函数的进阶内容。我们深入探讨了闭包转换和Lambda提升的具体实现步骤,并介绍了用于高效变量访问的德布鲁因索引技术。我们还分析了实现函数环境的两种主要数据结构(链表和数组)及其权衡。掌握这些技术对于将高级函数式语言特性编译到低级目标至关重要。
016:类型检查 🧠

在本节课中,我们将要学习类型检查的核心概念。我们将探讨什么是类型、为什么需要类型系统,以及如何通过类型检查来确保程序在运行前不会出现某些错误。我们将从一个简单的语言模型开始,逐步理解类型检查算法,并学习如何使用推理规则来形式化地描述类型系统。

概述
类型系统是编程语言中用于确保程序正确性的重要工具。它通过在编译阶段分析程序,来排除那些可能导致运行时错误的操作。一个设计良好的类型系统可以提供类型安全性,即“类型良好的程序不会出错”。本节课我们将深入探讨这一概念。
什么是类型?
类型可以被视为对运行时计算的近似。例如,如果一个表达式 E 的类型是 int,这意味着当 E 执行并终止时,它产生的值将是一个整数。虽然我们不知道具体是哪个整数,但这种近似足以让我们避免许多未定义的操作。
类型系统的核心思想是类型可靠性。这意味着,如果一个程序通过了类型检查(即它是“类型良好”的),那么当它执行时,就不会尝试执行任何未定义的操作。这可以排除整类的运行时错误。
一个简单的语言模型
为了具体说明,我们考虑一个简单的语言,它包含以下元素:
- 变量
- 整数
- 加法运算
- 函数定义(Lambda表达式)
- 函数应用
- 数对(Pairs)
该语言的类型只有三种:
int:整数类型T1 -> T2:函数类型,从类型T1映射到类型T2T1 * T2:数对类型,包含一个T1类型的元素和一个T2类型的元素


在这个简单语言中,我们要求函数定义时必须为参数标注类型(例如 fun (x: int) -> x + 1)。



解释器与运行时错误


我们可以为这个语言编写一个解释器来执行程序。解释器在遇到以下情况时会失败(即出现运行时错误):
- 加法运算的操作数不是整数。
- 函数应用时,第一个操作数不是函数(闭包)。
- 尝试使用一个不在当前作用域内的变量。



这些就是类型系统需要尝试在编译时捕获的错误。
类型检查算法
类型检查器的目标是在程序运行前,判断它是否“类型良好”。我们可以将其实现为一个递归函数 type_check,它接收一个类型环境(将变量映射到其类型)和一个表达式,并返回该表达式的类型。如果表达式类型不良,则检查失败。
以下是该函数的核心逻辑:

- 变量:在类型环境中查找变量的类型。
- 整数:类型为
int。 - 加法:递归检查两个操作数,它们必须都是
int类型,结果类型也是int。 - 函数定义:在扩展了参数类型的新环境中检查函数体,结果的函数类型为
T_arg -> T_body。 - 函数应用:递归检查函数表达式和参数表达式。函数表达式的类型必须是
T_arg -> T_ret,且参数表达式的类型必须与T_arg匹配。结果类型为T_ret。
这个算法成功返回类型的地方,对应着解释器能够成功执行并返回值的地方。算法失败(无法返回类型)的地方,则对应着解释器会遇到运行时错误的地方。
类型检查与解释器的差异

虽然类型检查器模拟了解释器的部分行为,但两者存在关键差异:
- 函数体检查时机:解释器在函数被调用时才计算函数体;而类型检查器在函数定义时就必须检查其函数体,无论它是否会被调用。
- 类型注解的需求:为了进行静态类型检查,我们需要函数参数的类型注解。这为类型检查器提供了必要的信息,使其能够在不知道具体运行值时,就能验证函数应用的合法性。
扩展语言与类型系统
我们可以轻松地扩展这个简单的语言和类型系统。例如,添加布尔类型和条件表达式:
- 新增类型:
bool - 新增表达式:
true,false,if E1 then E2 else E3 - 新增类型检查规则:
true和false的类型是bool。- 对于
if E1 then E2 else E3,必须满足:E1的类型是bool。E2和E3的类型必须相同(假设为T)。- 整个条件表达式的类型也是
T。
类型推断与多态性
在上面的简单语言中,程序员必须为函数参数提供类型注解。类型推断的目标是让编译器自动推导出这些类型。Hindley-Milner 是函数式语言(如 OCaml)中最常用的类型推断算法。其核心思想是遍历程序,收集类型必须满足的约束条件(例如,“这个变量在加法中使用,所以它必须是 int”),然后求解这些约束。
然而,当引入多态类型(即可以适用于多种类型的代码,如 'a -> 'a)时,完全的类型推断在理论上变得不可判定。因此,像 OCaml 这样的语言使用了受限的多态形式(如 let-多态或前束多态),在保持表达力的同时,确保了类型推断的可判定性和高效性。
类型安全性与表达能力
类型安全性是类型可靠性的体现,即“类型良好的程序不会出错”。这是一个非常强的性质。值得注意的是,为了确保安全,类型系统通常是保守的。它会拒绝一些实际上运行时不会出错的程序(例如,if true then 0 else "hello" + 1,因为 else 分支类型不良,尽管它永远不会被执行)。
一个极端的例子是简单类型 Lambda 演算,它的类型系统非常严格,以至于所有类型良好的程序都是必定会终止的。更丰富的类型系统(如包含递归或特定形式多态的系统)可以在保持安全性的同时,允许非终止的程序。

此外,类型安全性并不排除所有运行时错误。例如,除法除以零、数组访问越界、解引用空指针等错误,在简单的类型系统中可能无法捕获。这就需要更精细的类型系统(例如,区分“可能为空的指针”和“非空指针”)。
使用推理规则形式化类型系统
推理规则是一种简洁、无歧义地定义语言规则(包括类型规则)的数学工具。我们可以为类型判断定义推理规则。
一个类型判断的形式为 Γ ⊢ e : T,其中:
- Γ 是类型环境(将变量映射到类型)。
- e 是表达式。
- T 是类型。
这个判断的含义是:“在环境 Γ 下,表达式 e 具有类型 T”。


我们可以用推理规则来定义何时这个判断成立。每条规则的形式是:如果某些前提判断成立,那么结论判断也成立。

例如,我们简单语言的推理规则如下:
——————— (T-Int) 假设 I 是整数
Γ ⊢ I : int
(x : T) ∈ Γ
——————— (T-Var)
Γ ⊢ x : T
Γ ⊢ e1 : int Γ ⊢ e2 : int
—————————————————————————————— (T-Add)
Γ ⊢ e1 + e2 : int
Γ, x:T1 ⊢ e : T2
——————————————————————— (T-Fun)
Γ ⊢ (fun x:T1 -> e) : T1 -> T2
Γ ⊢ e1 : T1 -> T2 Γ ⊢ e2 : T1
————————————————————————————————— (T-App)
Γ ⊢ e1 e2 : T2
证明树(或推导树) 是这些规则实例的树形结构,其中每个节点的结论是其子节点前提的实例。类型检查算法本质上就是在尝试为给定的判断构造一棵证明树。


扩展推理规则:数组和元组

我们可以用推理规则轻松描述新语言特性的类型规则。



数组:
Γ ⊢ e1 : int Γ ⊢ e2 : T
—————————————————————————————— (T-NewArray)
Γ ⊢ new T[e1] e2 : T array
Γ ⊢ e1 : T array Γ ⊢ e2 : int
————————————————————————————————— (T-ArrayIndex)
Γ ⊢ e1[e2] : T
(注意:这些规则不检查数组索引是否越界,这通常是运行时检查的范畴)

元组:
Γ ⊢ e1 : T1 ... Γ ⊢ en : Tn
—————————————————————————————————————— (T-Tuple)
Γ ⊢ (e1, ..., en) : T1 * ... * Tn



Γ ⊢ e : T1 * ... * Ti * ... * Tn
—————————————————————————————————— (T-Proj) 假设 1 ≤ i ≤ n
Γ ⊢ e#i : Ti

总结


本节课我们一起学习了类型检查的核心概念。我们了解到类型是对程序运行时行为的静态近似,类型系统的目标是实现类型安全性,从而在编译期排除大量运行时错误。我们通过一个简单语言,剖析了类型检查算法如何工作,并看到了它与解释器的联系与区别。我们还探讨了类型推断、多态性以及类型安全性与程序表达能力之间的关系。最后,我们学习了如何使用推理规则这一强大而精确的数学工具来形式化地定义类型系统,这为理解和实现类型检查器提供了清晰的蓝图。在接下来的课程中,我们将把这些概念应用到更接近实际的语言模型中。
017:类型检查与子类型

在本节课中,我们将学习类型检查的核心概念,特别是如何将其应用于Oat语言,并探讨子类型这一强大思想。我们将看到类型系统如何作为程序行为的近似,以及如何通过推理规则来形式化地定义和实现类型检查。
课程概述与作业提醒
首先,我们回顾一下相关的课程信息。
以下是需要关注的作业信息:
- 作业3调查:请填写作业3的调查问卷,目前已有约三分之二的同学完成。
- 作业4:截止日期为下周一,可以使用最多三天的延迟提交时间。
- 作业3批改:作业3的批改结果将于今天晚些时候返回。
- 嵌入式伦理作业:截止日期为今晚11:59,不接受延迟提交。
- 作业5:将于下周一发布。今天的课程内容与作业5高度相关,下周一我们会更详细地讲解作业5的说明文档。
回顾:类型检查与类型安全
上一节我们介绍了类型检查的核心思想:类型是对程序运行时计算行为的近似。我们有一个函数,它接收一个抽象语法树(AST),如果程序通过类型检查,则成功返回。

类型安全性的概念是:如果一个程序通过了类型检查,那么在执行该程序时,它将避免某些特定的运行时错误。
Oat语言的类型系统
在今天的课程中,我们首先快速了解如何为Oat语言设计类型系统,下周一我们会进行更深入的探讨。
对于作业5,一个关键部分是向Oat语言添加类型检查功能,同时也会添加一些其他语言特性,如结构体(structs)。Oat语言的类型系统需要处理以下几个关键方面:
- 命令式更新:与之前学习的函数式语言不同,Oat这类命令式语言中的变量在执行过程中可以被修改。
- 语句与表达式的区分:语句(如赋值、循环)通常用于执行带有副作用的操作,而表达式则用于计算值。
- 复杂的控制流:包括
while循环、for循环和return语句。 - 全局变量与局部变量。
- 用户声明函数与内置函数的使用。
为了在类型系统中处理这些方面,我们的判断(judgment)会变得更加复杂。例如,对于表达式e具有类型T的判断,其形式如下:
G, L ⊢ e : T
其中,G代表全局变量环境,L代表局部变量环境。
对于语句s,判断形式有所不同,因为语句不直接求值为一个值。其判断形式如下:
G, L, RT ⊢ s ⇒ L'
这个判断表示:在全局环境G、局部环境L和函数返回类型RT下,语句s是良类型的,并且执行后会产生新的局部环境L'。这允许语句引入新的局部变量,并使其在后续语句的范围内可用。
让我们看一下作业4文档中Oat类型系统的一些推理规则示例。
以下是表达式类型判断的一些规则:
- 局部变量:如果变量
x在局部环境L中具有类型T,则表达式x具有类型T。 - 全局变量:如果变量
x在全局环境G中具有类型T,则表达式x具有类型T。 - 整数常量:整数常量具有类型
int。 - 二元操作:例如,加法操作
+要求两个操作数都是int类型,结果也是int类型。

以下是语句类型判断的一些规则:
- 赋值语句:需要检查被赋值的变量具有类型
T,并且赋值的表达式也具有类型T。赋值语句不改变局部环境。 - 返回语句:返回的表达式必须与函数的返回类型
T相匹配。 - 变量声明语句:使用专门的判断来检查变量声明,确保变量名在局部环境中不存在(禁止遮蔽),并且初始化表达式的类型正确,然后将新变量添加到局部环境中。
- If语句:条件表达式必须是布尔类型,然后分别检查
then分支和else分支的语句块。在if语句之后,局部环境与之前相同,这意味着在分支内声明的变量作用域仅限于该分支。
这些推理规则简洁、明确地表达了语言的许多微妙之处,例如变量的作用域和遮蔽规则。更重要的是,这种表达方式非常自然地导向实现。实现类型检查器时,你编写的递归函数将直接反映这些推理规则的结构。
一个自然的问题是:全局环境G从何而来?它来自于对整个程序(即一系列全局变量和函数声明列表)进行类型检查的另一个判断。这个判断首先收集所有全局声明以构建初始的全局环境G,然后使用这个环境来逐一检查每个声明。
类型检查的证明树与编译即判断
使用推理规则进行类型检查,本质上是为具体的程序构造证明树,以证明该程序是良类型的。证明树可能会变得非常庞大,但其核心思想是:通过实例化推理规则并递归地为子表达式/子语句构建子树,最终形成一个完整的证明。
这个思想可以进一步延伸。我们可以将编译过程本身也视为一种“判断变换”。假设源语言有一个判断:在上下文C下,表达式e具有类型T。编译这个表达式时,我们不仅需要e本身,还需要上下文C和类型T等信息。
编译过程可以看作是:我们获取源语言中e是良类型的证明树,然后“遍历”或“变换”这棵树,将其转化为目标语言的代码(例如一系列指令)。这个变换过程需要保持一个不变式:生成的指令序列在执行时,会计算e的值并将其放入某个操作数中,并且该操作数中的值具有类型TY(即T在目标语言中的翻译)。
例如,编译表达式37 + 5时,我们递归地编译子表达式37和5,得到它们的结果和类型,然后生成一条加法指令来组合这些结果。
上下文C的翻译也很重要。在Oat中,由于变量是可变的,我们将所有局部变量和全局变量都翻译为指针。这样,在需要变量值(右值)时,我们通过指针加载值;在需要修改变量(左值)时,我们通过指针存储值。这种统一的处理方式简化了编译。
子类型:将类型视为值的集合
到目前为止,我们一直将类型视为计算的近似。另一种等价的视角是将类型视为标识了一个值的子集。例如,说x具有类型int,意味着如果x求值为一个值,那么这个值将属于整数集合。
基于这种“类型即集合”的观点,我们可以扩展类型系统,使其包含更精细的值集合谓词。例如,除了int(所有整数)类型,我们还可以有:
pos:正整数的集合。neg:负整数的集合。zero:仅包含零的集合。- 对于布尔值,可以有
true类型和false类型。 any:任何值的集合。
这非常强大,因为类型系统现在可以告诉我们更精确的信息,例如“这个表达式总是求值为一个正整数”。
添加这些新类型后,我们需要修改推理规则。有些修改很直接,例如:
- 整数常量
3现在可以具有类型pos。 - 布尔常量
true具有类型true。
对于if表达式,规则可以变得更加精确:
- 如果条件
e1具有类型true,我们知道它总是求值为真,因此只需要检查then分支e2是良类型的,而可以完全忽略else分支e3。 - 类似地,如果
e1具有类型false,则只需要检查else分支。
然而,大多数情况下,条件表达式的类型是bool(即可能是true或false)。那么整个if表达式的结果类型应该是什么?直觉上,它应该是两个分支结果类型所代表集合的并集。在我们的类型集合中,pos和neg的并集是int(所有整数)。true和false的并集是bool。
这引出了子类型的概念。当我们将类型视为集合时,集合间的包含关系自然诱导出类型间的子类型关系。如果类型S代表的集合包含类型T代表的集合,那么T是S的子类型,记作 T <: S。例如,pos <: int,true <: bool。
给定一个子类型层次结构,对于任意两个类型T1和T2,我们可以定义它们的最小上界(least upper bound, LUB 或 join)。LUB是包含T1和T2所有值的最小类型(在子类型层次结构中尽可能低、尽可能精确的类型)。例如:
pos和neg的LUB是int。true和false的LUB是bool。int和bool的LUB是any。
因此,对于条件类型为bool的if表达式,其类型规则可以是:分别推导then分支类型T1和else分支类型T2,整个if表达式的类型是T1和T2的LUB。这给出了对表达式可能结果值集合的最精确近似。
子类型关系应该是可靠的,这意味着形式化的子类型关系必须反映底层的集合包含关系。即,如果 T <: S,那么 [T] ⊆ [S],其中[T]表示类型T所代表的值的集合。
子类型与归入规则
在包含子类型的类型系统中,一个常见的做法是引入一条归入规则:
Γ ⊢ e : T T <: S
-------------------- (Sub)
Γ ⊢ e : S
这条规则表示:如果一个表达式e具有类型T,并且T是S的子类型,那么e也可以被视为具有类型S。这意味着在任何期望类型S值的地方,都可以安全地使用一个类型T的值。
归入规则的好处是它将复杂的子类型关系隔离到一条单独的规则中,使其他类型规则保持简洁。然而,它带来了一个挑战:在寻找证明树时,对于任何语法形式,现在都有多条规则可以匹配(原规则和归入规则)。这可能导致搜索算法陷入无限循环(例如,反复应用自反的子类型关系)。
为了解决这个问题,许多实际的类型系统不采用显式的归入规则,而是将子类型关系直接内嵌到其他规则的 premises(前提)中,确保每个语法形式只有一条规则可以将其作为结论。这种风格被称为算法类型系统,因为它更直接地对应于可实现的类型检查算法。
向下转型与动态检查
子类型虽然强大,但在实际编程语言中,有时我们需要显式地将一个值从超类型“向下转型”为子类型。例如,类型系统可能只知道某个值是int,但程序逻辑需要它是一个pos(正整数)。
为了安全地进行向下转型,我们需要在运行时进行检查。有几种方式可以实现:
- 带检查的转型操作:例如,一个操作
cast_to_pos(e),如果e的值是正数则成功返回pos类型的值,否则抛出异常。 - 类型测试分支:例如,引入一种新的
if结构:if pos x = e1 then e2 else e3。这里会动态检查e1的值是否为正数。如果是,则在then分支e2中,变量x具有更精确的类型pos;否则执行else分支e3,且x没有更精确的类型信息。
这种机制将静态类型系统的精确性与必要的运行时检查结合起来。程序员在无法静态证明属性时进行动态检查,而在可以静态证明时则无需付出运行时开销。例如,如果我们为整数除法定义类型 int → nonzero → int,那么程序员只有在能证明除数非零时才能使用它,否则就必须先进行运行时检查。
课程总结
本节课我们一起学习了以下内容:
- Oat语言的类型系统:探讨了如何为具有可变变量、复杂控制流的命令式语言设计类型判断和推理规则。
- 编译即判断变换:了解了如何将编译过程形式化为对源语言类型证明树的变换,并保持特定的不变式。
- 子类型:深入探讨了将类型视为值集合的观点,从而引出子类型关系、最小上界等概念。
- 子类型的规则:介绍了归入规则及其带来的挑战,以及算法类型系统的替代方案。
- 向下转型:讨论了在需要更精确类型时进行安全向下转型的必要性及实现方式,包括运行时检查。


这些概念为理解和实现作业5中Oat的类型系统(将包含子类型和检查转型)奠定了坚实的基础。下周一我们将进一步探讨作业5的类型系统细节。
018:子类型化深入与作业5介绍 🧩


在本节课中,我们将继续深入探讨类型系统中的子类型化概念。我们将研究更复杂的语言特性,包括函数、记录和引用的子类型化规则。最后,我们将花时间介绍作业5,特别是其中的类型系统部分。
课程公告与作业安排 📢
上一节我们介绍了子类型化的基本概念,本节开始前,我们先了解一些课程安排。
作业4于今天截止。课程允许使用最多三天的延迟提交时间。如果有特殊情况,请直接联系我。
作业5今天发布。其截止日期是11月27日(周一),即感恩节假期后的周一。你可以选择在假期前完成并提交,或者使用延迟提交时间,最晚可于11月30日晚上11:59前提交。
关于作业5的通用建议是:在开始编写代码前,先花时间理清设计思路,明确各个函数的功能和实现方式,这有助于减少调试时间。
子类型化回顾与元组子类型化 🔄
现在,让我们回到子类型化的主题。我们曾将类型视为值的集合,并将子类型关系视为集合的包含关系。
一个思考子类型关系的有效方法是“子类型化基本原则”:如果程序期望一个类型为 S 的值,那么提供一个类型为 T 的值也是安全的,当且仅当 T 是 S 的子类型。
基于此,我们来分析元组的子类型化。假设程序期望一个类型为 S1 * S2 的二元组值。程序能对这个元组做的操作只有提取第一个元素或第二个元素。
因此,为了让类型为 T1 * T2 的元组成为 S1 * S2 的子类型,我们需要确保:当程序提取第一个元素(期望得到 S1)时,它实际得到的是 T1,而 T1 必须是 S1 的子类型。同理,T2 必须是 S2 的子类型。
由此得出元组的子类型化规则:
如果 T1 <: S1 且 T2 <: S2
那么 T1 * T2 <: S1 * S2
例如,pos * neg 是 int * int 的子类型,因为 pos 是 int 的子类型,neg 也是 int 的子类型。
函数子类型化 🔄➡️
理解了元组的子类型化后,我们来看看更复杂的函数子类型化。假设程序期望一个类型为 S1 -> S2 的函数,而我们有一个类型为 T1 -> T2 的函数。何时 T1 -> T2 是 S1 -> S2 的子类型?
我们需要考虑程序将如何使用这个函数:程序会向它传入类型为 S1 的参数,并期望得到类型为 S2 的返回值。
为了让我们的函数 T1 -> T2 能在此处工作,我们需要能够将传入的 S1 类型参数转换为 T1 类型(以供我们的函数使用),并将函数返回的 T2 类型结果转换为 S2 类型(以满足程序期望)。
这要求:
S1必须是T1的子类型(参数类型是“逆变”的)。T2必须是S2的子类型(返回类型是“协变”的)。
因此,函数子类型化规则如下:
如果 S1 <: T1 且 T2 <: S2
那么 T1 -> T2 <: S1 -> S2
注意参数类型的子类型关系方向与返回类型相反。
记录(结构体)子类型化 📋
接下来,我们讨论不可变记录(类似OCaml中的记录,字段创建后不可修改)的子类型化。记录是元组的泛化,使用字段名而非位置来访问。
对于记录类型,主要有两种子类型化方式:
深度子类型化:两个记录类型具有完全相同的字段标签。子类型关系要求每个对应字段的类型都是协变的。即,如果对于每个标签 i,都有 Ti <: Si,那么记录类型 {l1: T1, ..., ln: Tn} 就是 {l1: S1, ..., ln: Sn} 的子类型。这很直观,因为程序只能访问这些已知字段,而子类型记录的每个字段值都是超类型对应字段值的子类型,因此是安全的。
宽度子类型化:子类型记录包含了超类型记录的所有字段,并可能拥有更多额外字段。例如,{x: int, y: int, z: string} 可以是 {x: int, y: int} 的子类型。这也是安全的,因为程序对超类型值所能做的操作(访问字段x和y),同样适用于子类型值。额外的字段不会被程序访问到。
实现考量与可变性 🛠️
以上讨论的是不可变记录。当我们考虑实现时,编译策略会影响语言的设计。
如果我们采用类似C结构体的连续内存布局方式,宽度子类型化是兼容的:子类型结构体开头部分的字段布局与超类型完全一致,额外的字段放在后面即可。然而,深度子类型化要求子类型字段与超类型对应字段占用相同空间,否则字段偏移量计算会出问题。同时结合宽度和深度子类型化在连续内存布局下会带来挑战,因为字段宽度可能不同,导致偏移量计算困难。
另一种实现方式是使用更灵活的数据结构(如字典)来表示记录,通过字段名来查找值。这种方式可以支持更复杂的子类型化(包括字段重排),但通常会带来额外的内存开销和访问成本。
当引入可变性(如可变引用、可变数组)时,子类型化规则需要更加严格。考虑一个例子:假设 nonzero 是 int 的子类型。那么 ref nonzero 应该是 ref int 的子类型吗?答案是否定的。
请看以下问题代码:
let r: ref nonzero = ...
let a: ref int = r // 如果子类型成立,此赋值合法
a := 0 // 合法,因为 a 是 ref int
// 现在 r 和 a 指向同一内存位置
let x = 1 / !r // 错误!对 r 解引用得到 0,导致除零错误。
因此,对于可变引用,我们通常需要不变性:即 ref T 只能是 ref S 的子类型,当且仅当 T 和 S 是相同的类型。可变数组等也是如此。
值得注意的是,Java 在数组类型上错误地允许了协变,为了补偿这一点,它在每次数组更新时都加入了运行时类型检查,如果类型不匹配则抛出异常。这是一个在语言设计(表达性)和实现效率之间的权衡。
空值与非空类型 🎯
许多语言(如Java、C#)的引用类型允许 null 值。null 可以赋值给任何引用类型变量。为了保持类型安全,在解引用时必须检查是否为 null(例如Java抛出 NullPointerException)。
一些现代语言(如Rust、TypeScript、Go)引入了非空类型系统来静态地区分可能为空的引用和绝对非空的引用。这既能消除运行时空指针异常,也能提升性能(避免不必要的空值检查)。
结构类型 vs 名义类型 🏷️
这是语言设计的另一个选择:
- 结构类型:类型的等价性和子类型关系由类型的实际结构决定。例如,OCaml的类型缩写(
type dollars = int)是结构化的:dollars和int在结构上相同,因此等价。 - 名义类型:类型的等价性和子类型关系由类型的声明名称决定。例如,Haskell的
newtype或Java的类和接口。即使两个类有完全相同的方法,如果没有显式的继承或实现声明,它们也不构成子类型关系。

“鸭子类型”常见于动态类型语言,其思想类似于结构类型:“如果它走起来像鸭子,叫起来像鸭子,那么它就是鸭子”。
作业5:Oat V2 类型系统介绍 📚
最后,我们简要介绍作业5。作业5将扩展Oat语言,主要新增特性包括:
- 可变结构体:类似C的结构体,字段可修改。
- 函数指针:支持C风格的函数指针,但不支持闭包。
- 增强的类型系统:区分可空引用类型(
T?)和非空引用类型(T)。这是本次作业的核心。
为了支持这个类型系统,语言增加了新的结构:
- Checked Downcast (
if?): 将一个可能为空的引用安全地转换为非空引用。 - 数组显式初始化器:用于创建元素类型为非空的数组,要求为每个元素提供一个非空的初始值。
作业提供了详细的语法和类型规则PDF。类型判断包括子类型判断、类型良构判断、表达式类型判断、语句类型判断、函数声明判断和程序类型判断。类型检查过程分为多个阶段:首先收集所有结构体类型定义,然后收集函数声明,接着收集全局变量声明,最后利用这些信息进行全面的类型检查。
此外,类型系统还包括一个返回分析,用于确保每个函数在所有路径上都有明确的返回语句。
总结 📝


本节课我们深入探讨了子类型化。我们回顾了子类型化的基本原则,并分析了其在元组、函数、记录和引用等不同语言构造中的应用规则。我们了解到,可变性要求更严格的(不变)子类型规则,而语言设计(如结构类型 vs 名义类型、是否支持非空类型)与编译实现策略密切相关。最后,我们介绍了作业5中将要实现的Oat V2类型系统的主要特性,包括非空类型、结构体和函数指针。理解这些概念对于完成作业和深入理解编译器中的类型系统至关重要。
019:程序优化概述与基础优化技术

在本节课中,我们将要学习程序优化的基本概念,并初步了解一系列常见的编译器优化技术。优化是编译器的核心功能之一,它旨在将程序员编写的高层、清晰但可能低效的代码,转换为功能相同但执行更快的底层代码。
什么是优化?
正如我们所知,目前由我们的编译器生成的代码效率不高。我们鼓励将所有参数和局部变量存储在栈上,每次需要时再从栈中加载到寄存器。这意味着存在大量冗余且不必要的从内存到寄存器,再从寄存器回写内存的移动操作。同时,也可能存在大量不必要的计算。
让我们看一个简单的O程序示例:
int foo(int w) {
int x = 3 + 5;
int y = x * w;
int z = y - 0;
return z * 4;
}
如果使用未经优化的编译器编译,会生成大量涉及栈操作的汇编指令。然而,如果我们手动分析这个函数,会发现它本质上只是将输入参数 w 乘以 32(即 (3+5)*4)。在x86架构上,这可以通过一条算术左移指令高效实现。这表明了优化的巨大潜力:将大量汇编代码精简为少量高效指令。
那么,为什么需要优化呢?拥有一个擅长优化的编译器可以实现关注点分离。程序员在表达计算逻辑时,可以专注于编写高层、模块化、清晰的程序,而无需担心底层的实现细节。我们通常不希望在高层次思考x86汇编代码,这属于错误的抽象层级。理想情况下,程序应与其最终执行的架构无关,以便能够为x86、ARM、RISC-V或其他任何机器进行编译。
此外,现代处理器的指令集更像是处理器的一个接口,处理器内部会以复杂的方式实现这些指令,通常自身也包含一个编译器来将其转换为更低级的操作。很多时候,处理器会假设汇编代码已经应用了各种优化,以便在其常见情况下,其实现能够使代码运行得更快。
优化的目标与注意事项
我们进行优化可能有几个不同的原因:
- 提高执行速度:这是最显而易见的目标。
- 优化内存空间:在资源受限的环境中很重要,也可能通过减少缓存压力间接影响执行速度。
- 优化功耗:对于部署的传感器等需要长时间电池供电的设备至关重要。
关于优化的一些注意事项:
- 代码转换:优化可以在编译器的任何阶段应用,包括高级语言表示、各种中间表示或底层的汇编级别。
- 安全性:转换应该是安全的,即不改变程序的含义。这不仅包括计算结果,还包括在某些特定输入值下的行为。
- 程序分析:为了确保优化安全且值得进行,通常需要进行程序分析。分析帮助我们理解程序行为,识别应用优化的机会,并评估转换是否真的能带来性能提升。
- 启发式方法:术语“优化”有点用词不当。通常无法保证转换一定能提高性能,甚至“最优”代码的概念本身也常常不明确。我们更多是应用基于经验的启发式方法。
接下来,我们将开始逐一了解一系列具体的优化技术。
常量折叠
常量折叠的思想是:如果一个操作的操作数在编译时是已知的常量,那么我们不妨在编译时就执行这个操作,而不是在每次运行时都付出计算成本。
例如:
x = 2 + 3 * y可以转换为x = 5 * y。b && false可以直接计算为false。
这个转换旨在改善什么?
- 减少运行时指令数量:直接生成结果,省去加法指令。
- 减少寄存器使用:无需在运行时为常量
2和3分配寄存器。 - 可能减少代码大小:更少的指令意味着更小的可执行文件,可能改善代码缓存性能。
何时应用? 可以在AST(抽象语法树)级别、中间表示级别进行。在汇编级别进行可能更具挑战性。
安全性考虑:我们需要理解程序语义以确保转换安全。对于整数运算,通常安全,但需注意除零和溢出问题。对于浮点数,由于误差传播和运算顺序的敏感性,情况要复杂得多。语言提供的保证越弱,通常允许的优化就越多(例如C语言中的未定义行为)。
代数简化
代数简化是常量折叠的更一般形式,它利用数学上正确的恒等式进行简化。
例如:
a * 1->aa * 0->0a + 0->ab || false->bb && true->b
此外,还包括重结合律和交换律的应用(例如 a + b + c 的计算顺序)。
为什么进行代数简化?
- 直接减少运行时操作。
- 为其他优化创造机会:例如,
a + 1 + 2通过重结合变成a + (1+2),进而允许常量折叠为a + 3。
安全性考虑:同样取决于语言语义。对于浮点数,重结合可能因精度问题导致不同结果。对于整数,需考虑溢出问题。
强度削弱
强度削弱的思想是用廉价的操作替换昂贵的操作。
典型例子:
- 乘以2的幂:
a * 4->a << 2(左移两位)。 - 乘以常数:
a * 7->(a << 3) - a(因为7 = 8 - 1)。 - 除以常数:可能转换为乘法和移位操作的组合(现代编译器对此有非常复杂的实现)。
何时应用? 通常在非常接近汇编的低级IR(中间表示)中进行,以便基于对目标架构指令成本和周期的了解做出决策。
常量传播
如果已知一个变量的值是常量,那么可以将该变量的使用替换为该常量值。
例如:
int x = 5;
int y = x * 2; // 可替换为 y = 5 * 2;
接着,常量折叠可以将 5 * 2 变为 10。然后,对 y 的后续使用又可以传播常量 10。常量传播和常量折叠通常携手并进。
为了安全地应用常量传播,我们需要进行数据流分析,以确定在程序的某个点上,某个变量是否确实持有常量值,并且在该点之前该值没有被重新定义。
它改善什么? 主要价值在于启用其他优化(如常量折叠、死代码消除)。它也可能减少对变量的读取(可能是内存访问),从而提升效率。
复制传播
如果一个变量被赋值为另一个变量(即复制),我们可以将该变量的使用替换为源变量的名字。
例如:
x = y;
z = x + 1; // 可替换为 z = y + 1;
进行复制传播后,如果 x 不再被使用,那么赋值 x = y 可能变成死代码,从而可以被消除。
安全性考虑:需要进行数据流分析以了解复制的传播范围,并注意语言的作用域规则。
死代码消除
如果一个语句没有副作用(即不产生可观察的影响),并且其结果永远不会被使用,那么可以安全地删除该语句。
例如:
x = y * y; // 假设后面不再使用这个x
// ... 大量代码
x = z * z; // 重新定义x
这里第一个赋值 x = y * y 就是死代码。
安全性关键:必须确保代码是纯的(无副作用)。副作用包括:修改全局变量、可能抛出异常、不终止(无限循环)、执行I/O操作(如打印、网络请求)等。一些语言(如Haskell)因其纯函数特性,使得这类推理更容易。
相关优化:不可达代码消除。如果一个基本块从程序的入口点无法通过任何执行路径到达,那么可以删除它。这主要有助于减小代码大小,从而可能改善代码缓存利用率。
公共子表达式消除
如果一个表达式在多个地方出现,并且每次计算都会得到相同的值,那么可以消除冗余计算,用之前计算的结果替换后续的出现。
例如,计算数组索引:
arr[a + i*4] = ...;
... = arr[a + i*4]; // 再次计算相同的地址
可以优化为:
temp = a + i*4;
arr[temp] = ...;
... = arr[temp];
安全性关键:必须确保在两次计算之间,表达式中涉及的任何值都没有被修改。这包括变量 (a, i) 和可能读取的内存位置。例如,考虑以下不安全情况:
void f(int* a, int* b, int i, int j) {
b[j] = a[i] + 1;
return a[i]; // 看起来是公共子表达式?
}
如果 b 和 a 指向同一个数组(别名),并且 j 等于 i,那么 b[j] = ... 的赋值会改变 a[i] 的值,使得第二次读取 a[i] 得到不同的结果,因此不能进行公共子表达式消除。
并发考虑:在多线程环境中,即使代码本身没有修改变量,其他线程也可能修改它,这取决于语言的内存模型(如 volatile 关键字的作用)。
性能权衡:有时重新计算表达式可能更便宜。保存中间结果可能会占用寄存器或需要额外的存储/加载操作(增加寄存器压力)。
循环不变代码外提
如果一个计算位于循环内部,但该计算的值在每次循环迭代中都不变(即其操作数在循环内不被修改),那么可以将该计算移出循环(提到循环之前)。
例如:
while (b) {
z = y * x; // 假设 y 和 x 在循环内不被修改
// ... 使用 z
}
可以优化为:
z = y * x;
while (b) {
// ... 使用 z
}
安全性关键:
- 无副作用:外提的计算必须是纯的。
- 作用域与可用性:外提后,计算中涉及的变量在循环外必须可用且值相同。
- 执行次数:如果循环可能执行零次,外提会使得原本执行零次的计算执行一次。如果该计算有副作用,这会改变程序行为。如果只是为了性能,有时可以添加条件判断来保护。
优化实例演示
让我们通过一个例子看看多种优化如何协同工作。假设有以下中间表示代码:
a = x * x
b = 3
c = 2
d = a
e = b * c
f = d + e
g = e * f
- 复制传播与常量传播:将
b和c的使用替换为3和2。a = x * x d = a e = 3 * 2 f = d + e g = e * f - 常量折叠:计算
3 * 2得到6。a = x * x d = a e = 6 f = d + e g = e * f - 强度削弱(可选):将
x * x视为x^2,但这里我们保留。 - 公共子表达式消除:
a = x * x和d = a,后续使用d的地方可以直接用a替换(这其实也是复制传播)。同时,常量传播将e替换为6。a = x * x f = a + 6 g = 6 * f - 进一步的复制/常量传播:继续传播已知值。
- 可能的代数简化:例如,如果后续有更多操作,可能进行简化。
这个例子展示了优化如何相互启用和迭代。应用一个优化可能会暴露出应用另一个优化的机会。有时,优化顺序也可能影响最终结果,甚至一个优化可能会禁用另一个潜在的优化。编译器通常以多次迭代和特定顺序应用这些转换,试图在巨大的等效程序空间中,通过局部搜索找到性能更好的点。

总结


本节课中我们一起学习了程序优化的基本概念和一系列基础优化技术。我们了解到,优化是编译器将高层代码转换为高效底层代码的关键过程,其目标包括提升速度、减少内存占用和降低功耗。我们探讨了常量折叠、代数简化、强度削弱、常量传播、复制传播、死代码消除、公共子表达式消除和循环不变代码外提等具体技术。每种优化都有其适用场景、潜在收益以及需要注意的安全性条件(如副作用、别名、并发等)。最重要的是,我们看到了这些优化技术并非孤立存在,它们相互关联、相互促进,编译器通过迭代应用这些转换来逐步改进代码。理解这些基础优化是深入探索更复杂程序分析和优化技术的第一步。
020:优化与数据流分析概述
在本节课中,我们将学习编译器优化的最后几个概念,并开始探讨数据流分析的基础知识,特别是活跃变量分析。这是理解后续寄存器分配等高级主题的关键。
课程概述与作业提醒
以下是关于课程管理的一些提醒。作业五已于今天上午发布,相关文件可在Canvas作业页面下载。完成作业的期限为两周。作业六,即最后一次作业,将于一周后发布,截止日期为12月5日。请注意,作业之间存在时间重叠,因此需要合理安排时间。请记住,你有最多三天的延迟提交额度,建议在规划时也考虑这一点。如果你觉得延迟额度不足,请随时联系。
关于课程管理方面有任何问题吗?很好。今天,我们将首先完成上一讲未讲完的优化概述,然后开始学习数据流分析。
优化概述(续)
上一节我们介绍了一系列优化转换,理解了不同类型的转换需求。这些优化大多是启发式的,我们并不总是能确定它们是否能真正提升性能或其他指标。
接下来要介绍的优化是循环展开。其基本思想是,如果你有一个包含循环体的循环,我们可以用循环体的多个副本替换原来的循环体。例如,原始循环 for i from 0 to 100 对数组元素求和。我们可以创建一个新循环,其循环体包含原始循环体的两个副本,并修改循环条件,使循环只执行50次。这样,我们就在一次迭代中完成了多次迭代的工作。你可以想象将循环展开2次、3次、4次或更多。
这个优化主要试图改善哪个性能指标?通常是时间。它通过减少分支跳转和循环条件检查的次数来提升性能。例如,在展开后的代码中,只有50次跳转,循环条件检查的频率也降低了。现代架构在分支预测方面做得很好,但循环展开仍然可能有益。当然,它也会导致循环体变大,可能影响指令缓存。因此,进行此优化时需要考虑展开哪些循环以及展开多少次。常用的启发式方法包括:只展开包含直线代码和简单循环控制的循环,并且通常使用性能分析运行来实际检查此优化是否有用。
循环展开的另一个好处是,它可能启用其他优化。例如,通过在一个循环体中包含多个迭代的代码,可能会发现一些可以消除的公共子表达式,从而将两次迭代的工作量减少到少于单次迭代工作量的两倍。
关于循环展开有任何问题吗?很好。
下一个优化是函数内联。其思想是,如果我们有一个函数调用,我们可以用函数体替换该调用。这需要进行一些重写,例如将形式参数替换为实际参数,处理局部变量等。
例如,如果有一段代码调用 pow(x),并且有 pow 函数的代码,我们可以用函数体的内联副本替换该调用。在这里,我们声明了一个局部变量 a 来代表形式参数,复制了循环体,并将返回值放入临时变量 temp 中,然后使用 temp 代替函数调用。
这种优化的好处是消除了函数调用的所有开销,例如栈操作、跳转到另一段代码等。当然,它还使我们能够针对特定参数重写或特化函数体,并启用其他优化,如公共子表达式消除、常量传播、常量折叠等。
执行此优化可能需要一些谨慎的代码处理,例如重命名变量以避免变量捕获。此优化通常在编译器流水线的较高层级进行,例如在源AST级别或较高的中间表示级别,因为此时函数的概念清晰且易于操作。
从高层次看,这是一个好主意。但对于递归函数呢?假设有一个函数 f(x, y),在其实现中再次调用 f。如果我们内联它,实际上只是展开了一次调用。如果我们有表达式 f(z, 8+7) 并内联它,我们仍然有这个递归函数调用。显然,我们不能一直内联对 f 的调用,因为每次用函数体副本替换对 f 的调用时,仍然会有一个对 f 的调用。仅仅执行一次展开似乎没有太大好处,因为递归调用可能被执行数十次、数百次,内联似乎作用不大。
然而,我们可以通过一个技巧来获得内联的部分好处。我们可以在内联之前重写递归函数。例如,我们可以重写函数体,使用所谓的循环前置头。假设我们有一个递归函数 f,我们可以将其重写为包含一个局部函数定义 f_prime,f_prime 只是 f 的副本,做完全相同的事情,而 f 是根据 f_prime 定义的。从语义角度看,这似乎没有提供任何价值。但让我们看看内联时会发生什么。
关键点在于,我们注意到在调用 f_prime 时,参数 y 是不变的,因为 f_prime 是局部的。我们能够对 f_prime 进行一些转换。在这里,我们实际上可以提取出 f_prime 中冗余的参数 y,这类似于循环不变代码外提。我们意识到参数 y 在每次调用中都是相同的,因此我们实际上不需要每次都定义它,可以将其提取出来,让它从封闭变量中捕获。然后,当我们进行内联时,我们实际上得到了一个特化的函数,其中 y 被替换为常量 5。这就是针对特定调用的递归函数特化,可能启用额外的优化。然后,我们的闭包转换、lambda提升等正常编译过程将处理这个新函数,使我们基本上获得内联的好处:针对此特定函数调用进行特化(y 被替换为 5),减少调用开销等。
如果我们没有将 f 重写为使用局部函数 f_prime,那么重写的用处就会小一些。我们可能会传播常量 5,但对于递归调用,将失去该值的任何好处。
关于这个重写递归函数以实现特化的技巧,有任何问题吗?是的,在这个特定例子中,似乎如果我们不进行重写,if 语句实际上可以进行常量折叠,意识到 4 < 1 为假并替换它。这有点像展开。在某种程度上,我们已经展开了递归调用,可以将其视为一个循环并进行特化,但我们失去了在函数 f 的调用中提取公共表达式和进行常量传播的机会。
内联提供了减少函数调用开销的好处,也可能允许对该函数调用进行特化,从而启用进一步的优化。对于递归函数,你可能无法消除所有的函数开销,但你仍然通过能够特化这个递归调用以及该递归调用的所有迭代(例如,针对常量参数 5)而获得特化的好处。
根据你的编译器对于函数式语言的智能程度,你可能能够将一些递归函数转换为循环。我们将在稍后的另一个优化中简要讨论这一点。在这里,你希望的是允许函数式语言程序员享受编写递归程序的便利和乐趣,同时获得与编写没有所有函数调用开销的命令式程序相同的性能。有一些编译器技术可以实现这一点,这通常需要对函数进行局部推理,即拥有整个函数体及其调用点,并可以适当地进行转换。因此,拥有函数的本地副本可能允许更强的推理,因为你可以进行这种转换为循环的转换,而不用担心它是一个单独的编译模块,其中内容可能会改变等。
内联函数的缺点是可能增加总代码大小。如果一个函数从四个不同的地方调用,并且你将所有这四个调用点都替换为函数体,那么你的代码现在可能有四个相同的代码副本。即使经过优化可能减少一些,总代码大小也可能变大,从而导致更差的代码缓存性能。
通常,函数内联的一些启发式方法包括:只内联频繁调用的函数调用。也就是说,如果你内联一个函数,而该函数调用点实际上只执行几次,那么减少该函数调用的开销帮助不大。这可以通过使用执行性能分析来查看各个调用点的调用频率,或者使用静态分析来近似估算代码片段的执行次数。常用的启发式方法是:假设每个循环都执行常数次(例如10次),这样你就能意识到最内层的循环是执行最频繁的,这通常是正确的,代码大部分时间都花在深层循环中。
另一个启发式方法是只内联函数体较小的函数。这样,复制后的函数体不会比调用代码大很多。与“只内联频繁调用的函数调用点”相反的是,实际上,如果一个函数在代码中只有一个调用点(不一定是动态执行一次,而是静态只有一个调用点),这也是一个很好的内联候选,因为这样你可能有一个优化可以移除不再被调用的函数,从而使总代码大小不会变大。
关于函数内联还有问题吗?很好。
接下来,我们讨论列表中的最后一个优化:尾调用消除。假设我们有两个递归函数,都实现加法 add(m, n),返回 m 和 n 求和的结果。它们非常相似,但在第一个版本中,有一个对 add 的递归调用,两者都有对 add 的递归调用,但在这个版本中,在 a 返回后,这个函数还有更多工作要做。也就是说,a 需要取递归调用的结果,然后加1并返回该结果。相比之下,第二个函数在进行递归调用后,没有其他事情要做。该函数调用的结果就是该递归调用的结果。这被称为尾调用。
尾调用可以非常高效。其原因是,我们通常能够消除尾调用。让我们看看这个在命令式语言中的等效程序(第二个版本,带尾调用)。你可以想象一个优化,在命令式语言中消除尾调用,并将其转换为循环。也就是说,不是进行递归调用,而是直接跳转到函数的开头。
很酷的是,我们通常能够实现尾调用消除。对于一个递归函数,其思想是用参数的更新替换递归调用,然后直接跳回过程的开头,并删除返回语句。这里发生的是,通过跳转到函数的开头,我们正在重用栈帧。我们拥有函数体的栈布局,不需要为同一个函数创建新的栈帧,而是只更新参数,跳回开头,布局仍然完全相同。另一个好处是参数的值将保留在寄存器中。如果有很多参数,我们不需要将它们放入栈中;即使只有几个,我们也不需要担心调用约定,它们可以留在寄存器分配决定放置它们的任何寄存器中,而不是调用约定所需的寄存器。
当你将尾调用消除与内联结合时,这就是我之前提到的,我们可以使递归函数像 while 循环一样廉价。这样,程序员可以专注于高级别的正确性,而编译器足够智能来处理性能方面的问题。
尾调用消除的思想甚至可以用于非递归函数。如果函数的最后一条语句是函数调用(即使不是调用同一个函数),你也可以重用栈帧。基本上,这种转换意味着你可能需要一些栈操作,可能需要与调用者进行一些协调,但基本思想是,当你最终返回时,可以避免函数调用的部分开销,或者至少避免中间栈帧的开销。
关于尾调用消除有任何问题吗?是的,这超出了范围,但例如 OCaml 的最新版本,它们有 cons 和 @(连接)运算符,在非尾递归版本中,直到现在的 5.x 版本。实际上我没有跟进 OCaml 的最新动态,但 Ed 上有一个很好的帖子链接,核心工作人员和其他人可以分享以帮助我们理解它实际在做什么。你可以想象它做的事情类似于我们看到的转换。想象你保持相同的接口,但实现一个不同的版本,例如使用累加器向下传递累加器或类似的东西。然后你得到一个使用尾递归的递归函数调用实现。这样你就获得了尾递归的好处,你可能能够通过内联获得那种局部性,但即使不能,你也避免了递归调用并重用栈帧。这可能只是 append 的一个实现细节。
我们在上一讲和本讲中实际上涵盖了很多内容,介绍了一系列优化以及它们倾向于在代码的高级、中级或低级哪个阶段进行。
哦,对了,还有一点我想说:编写快速程序。老实说,无论你的编译器有多好,如果你想编写运行得快的程序,你能做的最好的事情实际上是找出正确的算法。因此,无论我们把编译器做得多么智能,你可能仍然需要学习 CS124 或其他课程,或者只是了解算法在做什么以及该算法的成本。同样,选择正确的数据结构将对你大有帮助。这些通常比编译器优化对性能的影响更大。所以,在你完成了选择正确算法、最小化间接性(例如,最小化获取数据所需的跳转次数)等高级工作之后,使用编译器优化来提升性能是很好的。然后,在你完成这些之后,实际上使用性能分析器运行你的程序,找出热点所在。这听起来事后看来很明显,但你想做的是花时间改进程序实际花费大部分时间执行的代码。为了找出这些代码,你需要知道程序运行或可能运行的工作负载,并实际进行分析以查看瓶颈在哪里。很容易陷入几个陷阱:一个是所谓的过早优化,即反复思考并认为“哦,如果我这样做,我可以让它更快”,然后花费大量时间修改代码,而那可能实际上并不是瓶颈,可能不是实际占用大部分执行时间的东西。与此相关的是“在灯亮的地方找钥匙”的想法,而不是在你丢钥匙的地方找。同样,如果你看着某些东西想,“哦,这很容易,我知道如何优化它”,然后花时间去让它更快,但如果它实际上不是你的程序花费时间的地方,那么无论你让它多快,都可能不会对你的程序性能产生显著影响。
数据流分析简介
在我们讨论这些优化的过程中,我们意识到对于其中的许多优化,为了确定是否实际有机会应用它们,以及如何确保我们安全地应用该优化或转换,我们实际上需要了解程序的信息。我们需要知道,例如,一个表达式是否不变,以便进行公共子表达式消除;相同的语法表达式在运行时在这些不同位置是否实际会计算为相同的值?知道一个表达式是否有副作用,知道变量在哪里定义(即变量被赋值),知道变量在哪里使用,知道这些如何关联,因此对于变量的给定使用,哪些定义点可能流向它?知道两个引用值是否可能是彼此的别名?
因此,我们今天剩下的讲座内容(可能会延续到周三的课程)将着眼于用于回答这些问题的算法和数据结构。
我们将研究一些特定的分析。首先,让我们通过讨论寄存器分配来激发我们要看的第一个分析:活跃变量分析。
目前,在我们的 Oat 编译器中,我们只是生成所需数量的临时变量(UID),然后简单地将它们映射到栈上。对于这个 UID,我们将它存储在这里的栈上。这样编译起来简单方便,但当然效率低下。任何时候我们想定义一个 UID,我们计算该临时变量的值,将其放入栈中。任何时候我们想使用 UID,我们从栈中加载它,放入寄存器并使用它。理想情况下,我们希望做的是尽可能多地将这些 UID 映射到寄存器中,完全避免将它们放入栈中。
问题是我们只有有限数量的寄存器。在 64 位 x86 中,有 16 个可用寄存器。很可能我们编写的函数将拥有超过该数量的临时变量。当然,在这 16 个寄存器中,有些是保留的,具有特殊语义。这意味着我们不能简单地将一个 ID 分配给一个寄存器并在整个函数期间保持不变。相反,我们需要做的是让一个寄存器代表多个临时变量、多个 UID。这样,我们就能在保持程序正确性的同时高效地使用寄存器,而无需将东西存储在栈中。
当然,这就引出了一个问题:什么时候这样做是安全的?什么时候我们可以说这两个临时变量可以分配给同一个寄存器,并且一切正常?这个问题的答案依赖于活跃性的概念。
关键的观察是:如果我们有两个临时变量 UID1 和 UID2,如果这两个临时变量的值永远不会在同一时间被需要,我们就可以将它们分配给同一个寄存器。这里“被需要”的意思是:如果一个临时变量的内容(即它的值)将在未来的某个操作中作为源操作数使用,那么它就是被需要的。因此,这种“被需要”的概念是针对特定程序点的。一个临时变量在给定的程序点是活跃的,如果该临时变量的值将在后续操作中作为源操作数使用。如果我们有两个变量,使得当我们遍历函数中的每一个程序点时,没有哪个程序点这两个临时变量(或变量)都是活跃的,那就意味着这些变量的值从不需要在同一时间被需要。如果情况如此,那么我们可以使用同一个寄存器来表示这两个变量。
这个直观上说得通吗?这个想法是:如果我们有两个变量,它们在同一程序点从不同时活跃,那么我们可以为它们使用同一个寄存器。
那么,我们如何找出这种活跃性信息呢?显然,我们可以通过查看变量的作用域来获得一些粗略的活跃性信息。在 Oat 级别,如果我们看这个程序,变量 b 在这个块内部声明。因此,b 只在这个块的作用域内。当我们到达这里时,b 不在作用域内,永远不能被使用,并且我们声明了变量 c。由于作用域规则,变量 b 和 c 永远不会同时活跃,因为它们的作用域不相交,没有程序点可以同时引用 b 和 c。或者更准确地说,当 c 活跃时,b 已超出作用域,因此不活跃。这意味着我们可以将 b 和 c 分配给同一个存储槽,甚至可能是同一个寄存器。这很好。
但老实说,仅仅使用变量的作用域信息太粗糙了。除了变量作用域之外,还有更多机会可以高效地使用栈槽和寄存器。让我们看看这个程序。局部变量 a、b、c 和参数 x 的作用域都重叠,它们在这个块结束前都在作用域内。但是,a、b 和 c 从未同时活跃。让我们看看:a 在这里定义(x + 2),在这一行被使用。但在使用之后,d 用结果定义。从那时起,a 再也没有被使用。所以一旦 b 被定义,a 就不活跃了。类似地,b 在这里被使用,但这次使用之后,b 就不活跃了。c 在这里定义,并在这个返回语句中使用。但在这一点上,b 不活跃。变量 x 也一样,它在这里使用,也在这里使用。但这次使用之后(即在计算 b + x 之后),x 就不活跃了。总之,因为 a、b 和 c 从未同时活跃,这意味着我们的编译器最终可以为它们使用相同的栈槽,或者更激进地,为它们使用相同的寄存器,而不会产生任何问题。没有程序点其中 a、b 或 c 中有多于一个是活跃的。
因此,我们将在接下来的几讲中深入探讨寄存器分配,但寄存器分配的一个关键部分是活跃变量分析,即知道哪个变量在哪个程序点是活跃的。所以我们将开始深入研究活跃变量分析的思想、如何实现它,以及我们如何推广它。
一个变量 v 在一个程序点是活跃的,如果 v 在该程序点之前定义,并在该程序点之后使用。因此,活跃性是根据变量在哪里定义和在哪里使用来定义的。在我们的活跃变量分析中,我们将计算每条语句之间的活跃变量集合。这可能有些保守,因为我们可能声称一个变量是活跃的,而实际上它不是。这可能是因为,例如,一个变量只在 if 语句的 then 分支中使用。也许 if 语句的 then 分支从未执行(守卫条件最终等价于 false,但由于复杂的计算),这意味着该变量从未被使用,但我们的分析无法发现这一点。我们的分析将保守地说:“嘿,看起来这个变量将来可能会被使用。”我们希望分析有用,所以我们将使其比仅仅作用域规则更精确。
我们将介绍这个分析,它是数据流分析的一个例子。还有许多其他数据流分析,其中一些我们将深入研究,如可用表达式、到达定义、常量传播等等。
我们将深入研究活跃变量分析的细节。我说过,我们将计算每条语句之间的活跃性信息。这有点模糊。我们实际将用来计算活跃性信息的是一个控制流图。
回想一下,在控制流图中,这是一个在基本块上的图,其中基本块是一个指令序列,使得该指令序列被完整执行。也就是说,程序执行总是从基本块的开头开始,没有跳转到基本块中间的情况。基本块上的所有指令依次执行,然后基本块的最后一条指令是某种控制流指令:跳转、条件跳转或返回。因此,在我们的控制流图中,节点是基本块,如果 B1 的出口可能跳转到 B2 的开头,则我们有一条从基本块 B1 到 B2 的边。没有悬空的边。即使在 LLVM IR 中我们使用标签,我们也会检查以确保它是一个格式良好的图,如果我们跳转到一个标签,该标签确实定义了一个基本块。
在我今天剩下的关于数据流分析的幻灯片中,我将对“指令”的确切含义有点模糊。控制流和数据流分析的思想适用于汇编代码、LLVM IR、高级源程序等,但你需要小心确切的细节,比如什么是语句、什么是基本块、什么是合适的终止指令等等。但总体思想是成立的,所以我会在这方面稍微宽松一些。
举个例子,在我展示的内容中,我们可能有同一个局部变量被多次定义。这在具有临时变量的 LLVM 中不会发生,因为 LLVM 是 SSA(静态单赋值)形式。事实证明,SSA 形式使数据流分析之类的事情变得更容易一些。因此,当你最终为 LLVM IR 实现数据流分析时,你的框架会比为 Java 源代码做分析时简单一些。
我们将在控制流图上进行活跃变量分析。而且,我们不是在基本块上进行,而是将图的节点视为指令,这样思考起来会更容易一些。也就是说,图的节点不是一个完整的基本块,而是一条指令,我们将有这些贯穿边。从这里到这里的边只是基本块内指令间的顺序执行。
一般来说,在我们的控制流图中,节点将是一条指令。我们可能有多个入边,显示进入该指令的控制流;我们可能有多个出边,显示指令执行后的控制流去向。
我们将计算活跃性信息。我说过我们将在语句之间进行计算。实际上,我们将把它与边关联起来。这样,我们就可以让同一个寄存器用于同一条语句中使用的不同临时变量。例如,如果我们有赋值 a = b + 1 在这条指令处,我们可能有 b 在进入这条指令的边上活跃,因为 b 被使用;a 在这里被定义;a 在这里活跃,因为它被定义并在以后使用;但 b 不再活跃。假设 a 活跃是因为它在以后被使用。关键点在于,通过在边上而不是节点上跟踪活跃性信息,这允许我们说对 a 和 b 使用同一个寄存器。因此,即使同一条指令使用了 a 和 b,但将活跃性与边关联的想法意味着我们可以为这条指令使用同一个寄存器。对于这条指令 mov a, b,如果我们对那个移动指令使用同一个寄存器,那就变成了 mov eax, eax,我们可以直接从代码中删除它。因此,以正确的粒度跟踪活跃性信息的思想使我们能够更好地利用寄存器,这可能使我们能够删除整个无操作指令。
我在这里也稍微绕了一下。根据你正在做的分析,你可能实际上希望将事实与边关联。对于我们这个活跃变量分析,结果证明将信息关联在指令之前和之后是可以的,我们可以为所有这些边使用相同的活跃性信息。当我们深入研究分析细节时,希望这会变得更清楚。
接下来,在介绍分析之前,下一个概念是变量的使用和定义。这是我在上一讲讨论优化时一直在谈论的内容。其思想是,对于任何给定的指令或语句,它使用一些变量集(读取它们的当前值),并且每条指令也定义(写入)一组可能为空的变量。
因此,对于一个节点 s,我们将使用以下符号:use(s) 表示该语句使用的变量集合,def(s) 表示该语句定义的变量集合。例如,对于 a = b + c,它使用变量 b 和 c,所以 use(s) = {b, c},它定义变量 a。对于 a = a + 1,它既使用又只定义 a。
现在,我们可以给出活跃性更正式的定义。一个变量 v 在边 e 上是活跃的,如果存在控制流图中的一个节点 n,使得 n 使用变量 v,并且存在一条从边 e 到节点 n 的有向路径,使得变量 v 在该路径上的任何节点都没有被定义。
这意味着,对于给定的边 e,条件一说明存在一个节点,v 在那里被使用。条件二说明在该路径上,v 没有被重新定义。因此,在边 e 处的变量 v 的当前值,将是未来在节点 n 处将被使用的变量 v 的值。
很好,这就是根据控制流图、根据给定节点的使用和定义集合,对活跃性相当具体、相当正式的定义。现在,我们面临的问题是:给定这个活跃性分析的定义,我们如何实际计算它?一旦我们有了它,我们将面临如何将其用于寄存器分配的问题,这将在接下来的两讲中详细讨论。实际上,一旦我们理解了分析,中间表示语言的选择将影响我们进行分析的方式。
让我们开始思考如何计算活跃性信息。首先,让我们使用一个非常低效的算法来理清思路。这是活跃性的定义。这里有一个简单的算法:对于每个变量 v,找到 v 的一个使用点,然后沿着控制流图向后遍历,直到要么在该路径上找到 v 的定义点,要么回到一个已经访问过的节点。因此,我们基本上是在为变量 v 的一个使用点,沿着边向后探索图,当到达 v 的定义点或已经访问过的节点时终止搜索。我们访问过的每个节点、遍历过的每条边,v 在其上都是活跃的,因为对于我们遍历的每条边,都存在一条到 v 的使用点的路径(这是我们开始的地方),并且没有中间的重定义,因为我们在到达定义点时截断了搜索。
这显然是一个我们可以用来计算边上变量活跃性的算法。这绝对是一个可计算的问题,我们可以使用一些非常简单的图算法来解决它。
这个简单的算法是正确的,但效率低下。我们将为每个变量和每个使用点进行向后搜索,可能会多次探索相同的路径。
我将要介绍的数据流分析的思想是,我们同时为所有变量计算活跃性信息。我们将通过定义必须满足的方程来实现。这些方程,当我呈现它们时,应该是“显而易见”的。我们将从一个非常差的近似开始,然后使用这些约束来改进我们的解,得到一个更好的近似。我们将继续这样做,用这个更好的近似,再使用约束得到更好的解,依此类推,直到无法再进行改进,即方程不再给我们任何改进。此时,我们达到了所谓的不动点。对于那些上过 152 课程的人来说,这是一个不动点计算的例子,我们从结果的差近似开始,逐步变得更好,直到最终稳定下来。这是计算程序属性(数据流分析)的一般框架的一个实例。
让我们深入探讨这些方程是什么。我们的节点将是指令或语句,因此对于每个节点 n,我们将使用以下集合:use(n) 和 def(n),我们已经讨论过,分别是节点 n 使用的变量集合和定义的变量集合。我们想要计算的是这两个集合:in[n] 和 out[n]。in[n] 是进入节点 n 时活跃的变量集合,out[n] 是离开节点 n 时活跃的变量集合。这些 in 和 out 集合本质上将收集入边和出边上的活跃性信息。
因此,out[n] 将是所有出边上的活跃性信息的并集。这就是我所说的,我们实际上不会显式地表示边上的活跃性,而是表示语句之前和之后的活跃性,即语句的 in 和 out 集合。
我们有这四个集合:use(n)、def(n),我们想要计算 in[n] 和 out[n]。我们将通过设置约束(方程)来实现。
首先,我们要确保节点 n 的 in 集合必须包含 n 使用的所有变量。考虑到活跃性的定义,在进入 n 的任何边上,什么变量是活跃的?记住,如果存在一条到变量 v 的使用点的路径,且中间没有 v 的定义,那么 v 就是活跃的。任何被 n 使用的变量都将满足这一点:存在一条从这里到这里的非常简单的路径,其中 n 被使用而没有定义。这是因为从概念上讲,对于一条语句,我们使用变量来计算将要放入新赋值变量的值。
我们还有一个约束:节点 n 的 in 集合将包含 out[n] 中的所有内容,减去任何被 n 定义的变量。让我们思考一下:如果变量 v 在 out[n] 集合中,意味着存在一条从这里到 v 的使用点的路径,且没有经过 v 的定义。如果 v 没有被 n 定义,那么 v 也应该在 in[n] 集合中活跃,因为仍然存在一条从这里到使用点且不经过定义的路径。然而,如果节点 n 定义了变量 v,那么这就是 v 的重定义或定义,因此 v 在这一点上不会活跃。
我们还需要一个连接不同节点的方程。对于节点 n,如果 n' 是 n 的后继节点(即存在从 n 到 n' 的边),我们希望 out[n] 包含 in[n']。直观上这是因为,如果有一个变量 v 在 in[n'] 中,意味着存在一条从刚好在 n' 之前到 v 的使用点的路径,且没有经过中间的定义。因此,也存在一条从刚好在 n 之后到 v 的使用点的路径,且没有经过定义。
就是这样。我们需要为所有节点 n 和 n'(如适用)实例化这些约束。但我们需要的所有约束都将是这三种形式之一。
让我们思考如何进行迭代数据流分析。正如我所说,思想是从一个粗略的猜测(结果的非常差的近似)开始,然后迭代地改进它。我们最初的猜测是将所有东西设置为空集:对于每个节点 n,in[n] 和 out[n] 为空。这个解并不满足所有约束,可能满足一些,但会有约束不满足,比如 in 集合必须包含 n 的所有使用等。
因此,我们将迭代地重新计算 in[n] 和 out[n],当我们有不满足的约束时。你可以想象我们做的是:在每次迭代中,我们遍历所有约束。如果一个约束不满足,我们向 in 集合添加一些东西。如果这个约束不满足,我们将 use(n) 添加到 in[n]。如果这个约束不满足,我们取 out[n] 减去 def(n) 并将所有这些添加到 in[n]。对于这个约束,如果 out[n] 不包含这个 in[n'],我们取集合 in[n'] 并将其添加到 out[n]。因此,在每一轮遍历中,我们通过增加 in 和 out 集合的大小来修复每个被破坏的约束。我们继续这样做。每次迭代我们遍历每一个约束,找到任何不满足的,并适当增加 in[n] 或 out[n] 的大小。如果我们遍历所有约束发现它们都满足了,那就太好了,我们就停止。这意味着我们能够遍历所有约束,它们都满足,没有告诉我们增加任何集合的大小。结果证明,当 in 和 out 满足这些方程时,它们实际上将等于这些东西:in[n] 将等于 use(n) ∪ (out[n] \ def(n)),out[n] 将等于所有后继节点 in 集合的并集。这些包含约束,当我们最终找到最小不动点时,实际上会得到这个等式条件。
我粗略地介绍了这些,让我继续粗略地给你一些伪代码。对于所有 n,我们将 in[n] 和 out[n] 设置为空集,然后我们遍历所有节点 n,确保将内容添加到 out 和 in 集合中,直到没有变化。这等价于遍历每一个约束。
这个算法保证会终止。为什么?每次通过算法,如果没有变化我们就停止。所以如果有变化,意味着至少有一个集合增加了至少一个元素。我们操作的实际实体是变量的集合,对于每个节点 n,我们有 in[n] 和 out[n] 两个集合。在每次迭代中,我们至少增加其中一个集合的至少一个元素,这就是有修改的含义。程序中只有有限数量的变量和有限数量的节点。这意味着我们实际上只能增加一个集合有限次,直到该集合达到其最大可能值。因此,我们将在某个时刻终止。
为什么我们从空集开始?如果我们从所有节点的所有集合都是全集开始,它可能第一次就满足约束,但那将是一个非常糟糕的解。对我们来说,活跃集合越小越好,那将是最精确、对寄存器分配最有用的信息,因为我们希望变量不在同一程序点活跃。事实证明,从空集开始,我们将找到满足方程的最小可能集合。
这是一个一般性问题:对于纯函数式语言,我认为这足以进行数据流分析。但如果我们有一个变量定义有副作用,但其他方面是死的,我们是否需要增强这个过程,或者我们会有一些变通方法?这个分析是找出程序点上变量的活跃性。我暗示过这样一个事实:类似的分析对于死代码消除是需要的,用于意识到这段代码实际上对程序的计算没有用。因此,你可能能够发现,我有一个变量的定义,并且该定义在任何地方都没有被使用。这是否意味着它是死的并且可以被消除?不完全是这样。正如你所说,这取决于副作用。如果语句有副作用,我们可能需要保留它。这完全没问题。也许我们调用了一个函数并将结果存储到一个变量中,但我们从未使用它。所以我们不需要定义该变量,但如果函数有副作用(例如打印某些内容),我们可能仍然需要调用该函数。因此,活跃性可能是死代码分析的一个组成部分,但不是分析的全部。
我们还有大约两分钟。我将开始讲解一个例子,准时结束,下节课继续。
这里有一个未指定语言的程序。这是该程序的控制流图,每个节点是一条语句。x = 1,这是 while 循环的守卫,这是 while 循环的出口,转到 return x,这是 while 循环的主体,这是嵌套在 while 循环内的 if 语句,等等。
我将向你展示每次迭代我们在做什么。思想是,我们最初这些集合是空的,然后我们将根据这些方程遍历并更新它们。在第一次迭代结束时,像节点 8 的 in 集合等于 {z},因为 z 在那里被使用;节点 9 的 in 集合等于 {y},因为 y 在那里被使用,等等。实际上,在第一次迭代中,我们基本上找出了长度为 0 的活跃路径。下一次迭代,我们将传播更多信息,例如从 in 向上传播到一些 out 集合,以及增加 in 集合的大小。这里我只显示发生变化的节点。下一次迭代我们做同样的事情,遍历每一个,执行赋值,我将用红色高亮显示发生变化的集合。我们看到这些集合继续增长,实际上每次迭代就像在探索更长的路径。变量的使用信息沿着路径向后传播,直到到达定义点。本质上,我们正在做那个简单直观的算法,但我们在每次迭代中同时为所有变量和所有使用点进行计算。
在第四次迭代中,只有几个变化。第五次迭代也一样,此时我们已经稳定了。在第六次迭代中遍历时,没有任何变化。这意味着幻灯片上显示的是活跃变量分析的结果。对于每个节点,我们有一个 in 集合和一个 out 集合,你可以检查它满足这些方程。
我们刚好超时了,所以我就讲到这里。在周三的讲座中,我们将继续思考活跃变量分析,思考如何改进这个算法,然后如何将其推广到活跃变量分析之外的其他数据流问题。
非常感谢。希望你们喜欢作业五,祝好运。
总结



本节课中,我们一起学习了编译器优化的最后几个关键概念,包括循环展开、函数内联和尾调用消除,并深入探讨了数据流分析的基础——活跃变量分析。我们了解了如何通过定义和使用集合、控制流图以及迭代不动点计算来求解变量的活跃性信息,这是后续寄存器分配等高级主题的基石。
021:数据流分析与寄存器分配 🧠

在本节课中,我们将要学习数据流分析的通用框架,并探讨如何利用这些分析结果来解决寄存器分配这一核心编译优化问题。我们将从活跃变量分析出发,扩展到更多分析类型,最后深入讲解基于图着色的寄存器分配算法。
数据流分析框架回顾
上一节我们介绍了活跃变量分析,这是一种通过迭代求解方程来计算变量在程序点是否“存活”的技术。本节中,我们来看看如何改进这个基础算法,并理解更通用的数据流分析框架。
改进的活跃变量分析算法

基础算法会反复遍历所有节点,直到结果不再变化。一个关键的观察是,信息只在控制流图中反向传播。因此,如果一个节点的后继节点的 in 集合没有改变,那么这个节点的 out 集合也不会改变,进而其 in 集合也无需重新计算。


基于此,我们可以设计一个更高效的算法,它维护一个工作队列,只包含可能需要更新的节点。
以下是该算法的伪代码描述:
W = 所有节点的队列
while W 不为空:
n = W.dequeue()
old_in = in[n]
# 根据后继节点计算新的 out[n]
out[n] = union(in[succ] for succ in n 的所有后继)
# 根据 out[n] 计算新的 in[n]
in[n] = use[n] ∪ (out[n] - def[n])
if in[n] != old_in:
# 如果 in[n] 改变了,其前驱节点可能需要更新
for pred in n 的所有前驱:
if pred 不在 W 中:
W.enqueue(pred)
这个算法的核心思想是,只有当一个节点的输入信息发生变化时,才需要将其前驱节点加入工作队列进行重新计算。
通用数据流分析
活跃变量分析只是数据流分析的一种。实际上,存在许多其他有用的分析,例如可用表达式分析、到达定值分析、别名分析和常量传播等。




让我们快速了解其中几种分析,并看看它们如何融入数据流框架。
可用表达式分析
一个表达式 E 在程序点 P 可用,如果从函数入口到 P 的所有路径上都至少计算过一次 E,并且在这最后一次计算和 P 之间,E 的自由变量没有被重新赋值。这可用于公共子表达式消除。





到达定值分析
一个变量 V 的定值(即赋值)D 能到达程序点 P,如果存在一条从 D 到 P 的路径,且该路径上没有对 V 的其他赋值。这有助于确定变量使用的值来源,可用于死代码消除等优化。

Gen-Kill 框架
上述分析都可以纳入一个称为 Gen-Kill 的通用框架。在这个框架中,每个指令被视为生成一些事实并杀死一些事实。




- 事实: 每个分析所关心的信息(如“变量x是存活的”)。
- Gen[n]: 执行节点
n后变为真的事实集合。 - Kill[n]: 执行节点
n后不再为真的事实集合。




分析还根据信息传播方向(前向或后向)和组合操作(并集或交集)进行分类。


以下是三种分析的对比:


| 分析类型 | 事实(Facts) | Gen[n] | Kill[n] | 方向 | 组合操作 |
|---|---|---|---|---|---|
| 活跃变量 | 存活的变量 | use[n](使用的变量) |
def[n](定义的变量) |
后向 | 并集 (May) |
| 到达定值 | 能到达的定值 | n 本身(如果它定义了变量) |
其他对同一变量的定值 | 前向 | 并集 (May) |
| 可用表达式 | 可用的表达式 | n 计算的表达式 |
任何包含被 n 定义的变量的表达式 |
前向 | 交集 (Must) |



这个框架非常通用,许多分析都可以通过实例化几个参数(如前向/后向、Gen/Kill集合)快速实现。



超越 Gen-Kill 框架

然而,并非所有分析都完全符合 Gen-Kill 模型,例如常量传播和别名分析。为此,我们需要一个更通用的数据流分析框架。

该框架的核心组件包括:
- 一个格(Lattice)
L: 表示所有可能数据流事实的集合,并定义了偏序关系。 - 流函数(Flow Function)
F_n: 对于每个节点n,描述其输入事实如何转换为输出事实。在 Gen-Kill 中,F_n(in) = Gen[n] ∪ (in - Kill[n])。 - 组合操作符
⊓: 当多个边汇入一个节点时,用于合并来自不同路径的事实。在 Gen-Kill 中,这对应于并集(∪)或交集(∩)。
通用迭代算法的伪代码如下:
for each node n:
in[n] = ⊤ // 初始化为最乐观的假设(格的最大值)
out[n] = ⊤
while 某个 in[n] 或 out[n] 发生变化:
for each node n (按某种顺序,如逆后序):
// 合并所有前驱节点的输出事实
in[n] = ⊓_{p in preds(n)} out[p]
// 应用流函数,计算本节点的输出事实
out[n] = F_n(in[n])
接下来,我们看看如何将常量传播和别名分析实例化到这个框架中。
常量传播分析
- 格: 对于每个变量,其值可以是
UNDEF(未定义)、一个具体整数常量(如CONST(5))、或NAC(非常量)。它们构成一个偏序:UNDEF最乐观,CONST更精确,NAC最悲观。 - 流函数: 对于指令
x = y op z,根据y和z在当前输入映射中的值(UNDEF/CONST/NAC),推导出x的新值。 - 组合操作: 在合并点时,对每个变量进行格的交运算(meet)。例如,如果一个分支中变量是
CONST(0),另一个分支中是CONST(2),则合并后为NAC。
别名分析(指针分析)
- 格: 对于每个指针变量,其别名状态可以是
UNDEF、UNIQUE(唯一指向)或MAYALIAS(可能别名)。UNIQUE比MAYALIAS更精确。 - 流函数:
x = alloc(): 将x标记为UNIQUE。x = load y: 将x标记为MAYALIAS(因为从内存加载的值可能被共享)。store x, y: 将x标记为MAYALIAS(因为存储操作使指针可能“逃逸”)。
- 组合操作: 同样使用格的交运算。例如,
UNIQUE和MAYALIAS合并得到MAYALIAS。
别名分析的结果非常有用。例如,如果我们知道两个指针 p 和 q 是 UNIQUE 且互不别名,那么通过 p 的写入就不会影响从 q 读取的值,这可以启用诸如消除冗余加载等优化。
寄存器分配问题 🎨
数据流分析(尤其是活跃变量分析)为许多编译优化提供了基础信息。现在,我们利用这些信息来解决一个关键问题:寄存器分配。
问题描述: 中间表示(如LLVM IR)使用无限多的临时变量(UID)。但目标机器只有有限数量的物理寄存器。寄存器分配的目标是,将这些临时变量映射到有限的物理寄存器上,同时:
- 保持程序语义不变。
- 尽可能多地使用寄存器(因为访问寄存器比访问内存快得多)。
- 避免不必要的寄存器间移动。
- 遵守调用约定(例如,特定寄存器用于传递参数)。
- 当寄存器不足时,将一些变量“溢出”到内存栈槽中。
简单方案:线性扫描算法
一个直观的解决方案是线性扫描算法,它是一种贪心策略。
算法思路如下:
- 假设已计算出活跃变量信息(
live_out[n])。 - 维护一个从UID到存储位置(寄存器或栈槽)的映射。
- 顺序扫描每一条指令。当遇到定义新UID
X的指令时:- 查看当前
live_out集合中所有UID的存储位置,找出正在被使用的寄存器集合UsedRegs。 - 可用寄存器集合
AvailRegs = AllRegs - UsedRegs。 - 如果
AvailRegs不为空,则从中选一个寄存器分配给X。 - 如果
AvailRegs为空,则不得不将X分配到一个新的栈槽中(即溢出)。
- 查看当前
这个算法简单高效,但因为是贪心的,可能无法得到最优的分配结果,导致不必要的溢出。
基于图着色的寄存器分配


寄存器分配可以规约到图着色问题,这是一个经典的NP完全问题。虽然无法高效获得最优解,但启发式算法在实践中效果很好。


核心思想:
- 节点: 每个临时变量(UID)对应图中的一个节点。
- 边: 如果两个临时变量在程序的任何一点同时活跃(即相互干扰),则在它们之间连一条边。
- 颜色: 每种颜色对应一个物理寄存器。
K着色意味着使用K个寄存器。
目标是为图中每个节点分配一种颜色,使得任何有边相连的两个节点颜色不同。这正好对应了“同时活跃的变量必须放在不同的寄存器中”这一要求。
基于图着色的寄存器分配算法(如Chaitin算法)通常包含四个阶段,循环执行直至成功:
- 构建: 根据活跃变量分析结果构建冲突图。
- 简化:
- 反复寻找图中度数(相邻边数)小于
K的节点,将其从图中移除并压入栈。因为这类节点在着色时总有颜色可用,移除它们不会影响原图是否可K着色。 - 如果找不到度数小于
K的节点,则进入下一阶段。
- 反复寻找图中度数(相邻边数)小于
- 溢出:
- 此时图中节点度数均 >=
K。需要选择一个节点作为溢出候选。 - 选择启发式:通常基于溢出优先级,公式为
SpillPriority = (使用次数估算) / (节点度数)。倾向于溢出度数高但使用不频繁的变量。 - 将选中的候选节点标记并压入栈,然后将其从图中移除。移除后,其邻居度数降低,可能使得简化阶段得以继续。
- 此时图中节点度数均 >=
- 选择:
- 当图被清空后,开始按出栈的逆序为节点分配颜色(寄存器)。
- 对于从简化阶段移除的节点,总能找到可用的颜色。
- 对于从溢出阶段移除的候选节点,尝试为其分配颜色。如果成功,则无需实际溢出;如果失败(所有
K种颜色都被邻居占用),则此变量必须溢出。
- 重写与迭代:
- 如果有变量必须溢出,则修改原始程序:在定义点后插入存储指令将其值保存到栈槽,在使用点前插入加载指令从栈槽读回。这会引入新的临时变量。
- 然后,算法跳回第1阶段(构建),基于修改后的程序重新构建冲突图,开始新一轮的分配。
算法示例:
假设有程序及其冲突图,可用寄存器数 K=3。
- 简化: 移除度数 < 3 的节点 H, C, G。
- 停滞: 剩余节点度数均 >= 3。
- 溢出: 计算后选择节点 D 作为溢出候选,将其移除并压栈。移除 D 后,其邻居 K, J, B 度数降低,简化阶段得以继续,移除所有节点。
- 选择: 逆序着色。当处理到 D 时,发现其三个邻居已占用三种颜色,无颜色可用。
- 重写: 因此 D 必须溢出。在程序中为 D 引入栈访问代码,产生新变量(如 D2)。
- 迭代: 回到构建阶段,为新程序构建包含 D 和 D2 的新冲突图。在新图上,简化阶段可能顺利进行,最终完成着色分配。






本节课中我们一起学习了数据流分析的通用框架,包括 Gen-Kill 模型及其扩展,并了解了如何将常量传播和别名分析实例化到该框架中。随后,我们深入探讨了编译器后端的核心任务——寄存器分配,从简单的线性扫描算法,到基于图着色的经典启发式算法,理解了其通过构建、简化、溢出和选择四个阶段来有效利用有限寄存器的过程。这些分析和优化技术是现代编译器生成高效代码的基石。
022:寄存器分配与合并

在本节课中,我们将继续学习寄存器分配,特别是如何通过一种称为“合并”的技术来优化分配过程,以消除不必要的复制指令。我们还将探讨如何将机器特定的调用约定(如参数传递、返回值、调用者保存和被调用者保存寄存器)整合到寄存器分配算法中。
概述
上一节我们介绍了基于图着色的寄存器分配基本算法。本节中,我们将看看如何扩展这个算法,通过合并(Coalescing)来优化那些包含复制指令(如 x = y)的程序,并学习如何处理预着色节点以满足实际的机器调用约定。
寄存器分配算法回顾
首先,让我们简要回顾一下上节课介绍的基本寄存器分配算法,即“通过简化进行着色”。
该算法包含四个主要阶段:
- 构建干涉图:利用活跃性分析信息,为程序中的每个临时变量(虚拟寄存器)创建一个节点。如果两个变量在程序的任何一点同时活跃(即它们的活跃范围重叠),则在它们之间添加一条边。
- 简化:重复寻找并移除图中度数(邻居数量)小于可用寄存器数量
k的节点。将这些节点压入栈中。这个操作是安全的,因为我们可以保证在后续阶段能为这些低度数的节点找到可用的颜色(寄存器)。 - 潜在溢出:如果图中只剩下度数大于等于
k的节点,则必须选择一个节点进行“潜在溢出”。通常使用一个溢出优先级公式来决定:spill_priority = (use_count + def_count) / degree。选择优先级最高的节点(即使用频繁但度数高的节点),将其从图中移除并压入栈,然后返回简化阶段。 - 选择:当图被清空后,开始按出栈顺序(即与压栈相反的顺序)为节点分配颜色(寄存器)。如果为某个节点分配颜色时,发现其所有邻居已经占用了所有
k种颜色,则此节点实际溢出。需要重写程序,将该变量的值存入内存(栈帧),并在使用时重新加载,然后从头开始整个分配过程。
这个算法能有效地将虚拟寄存器映射到有限的物理寄存器上。
合并寄存器分配
然而,基本算法没有专门处理程序中常见的复制指令(如 a = b)。像复制传播这样的优化虽然能消除这些指令,但可能会延长变量的活跃范围,从而增加寄存器压力,导致更多的溢出。
更好的方法是让寄存器分配器本身尝试将复制指令的源变量和目标变量分配到同一个物理寄存器。这样,复制指令 a = b 在最终代码中就会变成 R1 = R1,成为一个空操作,可以被安全删除。这种技术称为合并。
合并的核心思想
如果两个变量 x 和 y 之间存在复制关系(即 x = y 或 y = x),并且它们在干涉图中没有边相连(即它们的活跃范围不重叠),那么理论上可以将它们分配到同一个寄存器。
在算法中,合并操作意味着将两个节点 x 和 y 合并为一个新节点(例如 xy)。新节点的邻居是原 x 和 y 节点邻居的并集。
安全合并的启发式方法
盲目合并节点可能会将一个原本 k 可着色的图变得不可着色,从而引发不必要的溢出。因此,我们只在确定安全的情况下才进行合并。以下是两种常用的启发式方法:
-
Briggs 启发式:合并节点
x和y是安全的,如果合并后的新节点,其邻居中度数大于等于k的节点数量少于k个。- 原理:在简化阶段,所有度数小于
k的邻居最终都会被移除。如果合并后节点的高度数邻居少于k个,那么它本身最终也能被简化掉,因此不会引起新的溢出。
- 原理:在简化阶段,所有度数小于
-
George 启发式:合并节点
x和y是安全的,如果对于x的每一个邻居t,都满足以下条件之一:t已经与y干涉(即t也是y的邻居)。t的度数小于k。- 注意:这个条件是针对
x的邻居。在实践中,为了对称性,通常也会检查y的邻居是否满足类似条件(即交换x和y的角色)。 - 原理:这确保了合并操作不会显著增加新节点的有效度数,因为要么邻居本来就与两者都干涉,要么邻居是低度数节点,可以被简化掉。
只要满足其中任何一个启发式条件,就可以安全地进行合并。
合并着色算法流程
整合了合并功能的寄存器分配算法流程如下,它比基本算法更复杂,但步骤清晰:
- 构建:构建干涉图,并标记哪些节点是“移动相关”的(即作为复制指令的源或目标)。
- 简化:重复移除非移动相关且度数小于
k的节点,压入栈。只移除非移动相关节点是为了给合并创造机会。 - 合并:检查所有移动相关的节点对。如果根据 Briggs 或 George 启发式判断合并是安全的,则将它们合并为一个节点(继承所有的边和移动关系),然后返回简化阶段。
- 冻结:如果无法继续简化或合并,则选择一个低度数的移动相关节点,冻结它(即移除其移动相关标记,使其变为普通节点),然后返回简化阶段。这相当于放弃对这个节点进行合并的尝试。
- 潜在溢出:如果上述步骤都无法进行,则像基本算法一样,使用溢出优先级选择一个节点进行潜在溢出,移除它并压栈,然后返回简化阶段。
- 选择:当图中只剩下预着色节点(见下文)或变为空时,开始按出栈顺序分配颜色。如果分配失败,则实际溢出,重写程序并回到第1步。
处理机器约定:预着色节点
实际的编译器必须遵守目标机器的调用约定,例如:
- 函数参数通过特定寄存器(如 x86-64 的
RDI,RSI)传入。 - 返回值需放入特定寄存器(如
RAX)。 - 被调用者保存寄存器(Callee-saved)在函数开头需保存,结尾需恢复。
- 调用者保存寄存器(Caller-saved)在函数调用后可能被破坏。
我们可以通过引入预着色节点来整合这些约束。将物理寄存器(如 RAX, RDI)也视为干涉图中的节点,并预先为它们“着色”(即固定其寄存器分配)。
具体方法
以下是整合调用约定的技巧:
- 函数参数与返回值:在函数开头插入从参数寄存器到虚拟寄存器的复制(如
arg1 = RDI),在返回前插入到返回值寄存器的复制(如RAX = result)。这些物理寄存器节点是预着色的。寄存器分配器会尝试通过合并来消除这些复制。 - 被调用者保存寄存器:对于每个被调用者保存寄存器
R,在函数开头插入t = R,在函数结尾插入R = t。这里t是一个新的临时变量,R是预着色节点。- 好处:
t和R是移动相关的,分配器会尝试合并它们。如果成功,则R未被使用,节省了保存/恢复。如果失败,t是一个极佳的溢出候选:它全程活跃(与所有变量干涉),但仅定义和使用一次,将其溢出到栈帧是高效的做法。
- 好处:
- 调用者保存寄存器:在构建干涉图时,将每次函数调用视为定义了所有调用者保存寄存器。这意味着任何在函数调用后仍然活跃的临时变量,都会与这些预着色的调用者保存寄存器节点产生干涉边,从而阻止分配器将该变量分配到这些寄存器中。
关于预着色节点的算法调整:
- 在简化阶段,不能移除预着色节点。
- 不能溢出预着色节点。
- 算法终止的条件变为“图中只剩下预着色节点”。
栈槽分配
寄存器分配不仅适用于寄存器,也适用于栈槽(Stack Slot)。当变量被溢出时,我们需要在栈帧中为它分配空间。同样地,我们希望复用栈空间:如果两个虚拟栈槽(溢出的变量)的活跃范围不重叠,它们可以共享同一个物理栈槽。这本质上也是一个图着色问题,其中颜色代表不同的栈帧偏移地址。一个设计良好的寄存器分配器可以泛化,同时处理寄存器和栈槽的分配。

总结

本节课中我们一起学习了寄存器分配的进阶主题。我们首先回顾了基本的图着色分配算法,然后引入了合并技术,通过将复制指令的源和目标分配到同一寄存器来优化代码。我们讨论了确保合并安全的 Briggs 和 George 启发式方法,并梳理了整合合并功能的完整算法流程。最后,我们探讨了如何使用预着色节点来优雅地处理目标机器的调用约定,包括参数传递、返回值和调用者/被调用者保存寄存器,使得寄存器分配算法能生成符合实际硬件要求的代码。理解这些概念对于构建一个高效、实用的编译器后端至关重要。
023:面向对象编程编译 🧱
在本节课中,我们将要学习如何编译面向对象编程语言。我们将探讨类、对象、继承和方法调用的核心概念,并了解如何通过虚方法表等技术在底层实现这些高级特性。
概述
面向对象编程是一种将代码和数据封装在“对象”中的编程范式。它通过类、继承和多态等机制,为构建大型软件系统提供了强大的抽象能力。本节课我们将深入探讨如何将面向对象的代码(以Java为例)编译成底层的机器指令,特别是如何处理动态方法分派和内存布局。
面向对象编程简介
面向对象编程的核心思想是将数据和对数据进行操作的代码捆绑在一起,形成一个“对象”。这与非面向对象语言(如C语言)不同,在C语言中,数据(结构体)和操作它们的函数通常是分离的。
面向对象语言通常支持以下特性:
- 封装:将对象的实现细节隐藏起来,只暴露必要的接口。
- 继承:允许一个类(子类)复用另一个类(父类)的代码和数据。
- 多态:允许子类对象以父类类型被引用,并在运行时调用正确的方法实现。
面向对象语言主要分为两大类:
- 基于类的语言:如Java、C++、C#、Python。它们使用“类”作为创建对象的蓝图。
- 基于原型的语言:如JavaScript、Lua。它们通过克隆现有对象(原型)来创建新对象。
本节课我们将重点讨论基于类的面向对象语言的编译。
类、对象与方法
上一节我们介绍了面向对象的基本概念,本节中我们来看看其核心构件:类、对象和方法。
类是创建对象的蓝图或模板。它定义了:
- 字段(实例变量):每个对象独有的数据。
- 方法:所有对象共享的操作代码。
对象是类在运行时的实例。每个对象都拥有其类中定义的字段(值可能不同)和方法。
以下是一个简单的Java类示例:
class Vehicle {
int position = 0; // 字段
void move(int x) { // 方法
this.position = this.position + x; // ‘this’指代当前对象
}
}
new Vehicle()表达式会在运行时创建一个Vehicle对象。
继承允许一个类(子类)获取另一个类(父类)的字段和方法。子类可以:
- 继承父类的方法。
- 重写父类的方法,提供新的实现。
- 添加新的字段和方法。



以下是继承的示例:
class Car extends Vehicle { // Car继承自Vehicle
int passengers; // 新字段
void await(Vehicle v) { ... } // 新方法
// 继承了Vehicle的position字段和move方法
}
class Truck extends Vehicle {
@Override
void move(int x) { // 重写了move方法
if (x < 55) {
this.position = this.position + x;
}
}
}
由于继承关系,Car和Truck的对象都可以用在任何期望Vehicle类型的地方,这体现了子类型关系。
方法调用与动态分派
上一节我们看到了方法可以被继承和重写,本节中我们来看看方法调用是如何在运行时确定执行哪段代码的,即动态分派。
考虑以下场景:
interface IntSet { void insert(int n); boolean contains(int n); int size(); }
class IntSet1 implements IntSet { ... } // 一种实现
class IntSet2 implements IntSet { ... } // 另一种实现
IntSet set = foo(); // foo()可能返回IntSet1或IntSet2对象
int s = set.size(); // 关键:调用哪个size()实现?
在编译set.size()时,编译器无法知道set运行时具体是IntSet1还是IntSet2的对象。因此,不能静态地决定调用哪个函数地址。
解决方案是虚方法表(VTable,或Dispatch Table)。
虚方法表(VTable)的工作原理
以下是虚方法表如何实现动态分派的核心机制。
核心思想:
- 每个类都有一个虚方法表,它是一个函数指针数组。
- 表中的每个条目对应类的一个方法,指向该方法的实际实现代码。
- 每个对象在内存布局的起始位置都有一个指针,指向其所属类的虚方法表。
方法调用过程(例如 o.move(10)):
- 通过对象指针
o找到对象的虚方法表指针。 - 从虚方法表中获取对应方法(如
move)的索引(该索引在编译时根据方法声明顺序确定)。 - 通过索引找到函数指针。
- 通过该函数指针调用正确的实现代码,并将对象自身(
this)作为隐含的第一个参数传递。
用伪代码表示这个过程:
// 假设 move 方法在 VTable 中的索引是 1
function_ptr = o->vtable[1] // 1. 通过对象找到VTable,2.&3. 通过索引找到函数指针
call function_ptr(o, 10) // 4. 调用函数,传递‘this’和参数
同一个类的所有对象共享同一个虚方法表。如果方法未被重写,子类虚方法表中的条目直接指向父类的实现;如果被重写,则指向子类自己的实现。
处理继承与VTable布局
上一节介绍了单个类的VTable,本节中我们来看看在继承层次结构中,如何安排VTable的布局以保证动态分派的正确性。
关键要求是:子类的VTable必须与父类的VTable布局兼容。这样,当子类对象被当作父类类型使用时,方法索引才能对应到正确的实现。
解决方案:按类层次结构中的声明顺序排列方法。
- 首先放置根类(如
Object)的方法。 - 然后放置直接子类新声明的方法。
- 依此类推。
例如,对于类 A (有方法 foo), B extends A (新加方法 bar, baz), C extends B (重写 foo, 新加方法 qux):
A的VTable:[A.foo]B的VTable:[A.foo, B.bar, B.baz]// 继承的方法在前,新增在后C的VTable:[C.foo, B.bar, B.baz, C.qux]// 重写的方法替换指针,新增方法追加
这样,无论对象是B还是C,通过B类型引用调用foo(索引0)、bar(索引1)或baz(索引2),都能通过其各自的VTable找到正确的实现。
字段访问与对象布局
除了方法,对象还包含数据字段。编译字段访问与编译结构体字段访问类似。
对象内存布局:
- 第一个字段通常是指向类信息和VTable的指针。
- 随后依次排列从最顶层父类继承下来的字段。
- 最后排列子类自己定义的字段。
例如,一个Car对象(继承自Vehicle)的内存布局可能是:
[ VTable指针 | Vehicle.position | Car.passengers ]
字段偏移量在编译时可以根据其声明顺序计算出来。因此,访问 car.position 就是访问对象起始地址后的一个固定偏移量。
对象创建涉及:
- 在堆上分配足够大小的内存块。
- 初始化VTable指针,指向正确的类信息。
- 调用构造函数(如果有)来初始化字段。
- 返回指向新对象的指针。
编译到LLVM IR
了解了核心机制后,我们来看看如何将面向对象的概念映射到LLVM IR这样的低级中间表示上。
编译过程大致如下:
- 类型检查阶段:构建类层次结构信息。
- 生成LLVM类型:
- 为每个类创建一个LLVM结构体类型(
struct),用于表示对象的内存布局。 - 为每个类的虚方法表创建一个LLVM结构体类型(存放函数指针数组)。
- 为每个类创建一个LLVM结构体类型(
- 生成LLVM函数:
- 将每个方法编译成一个独立的LLVM函数。该函数的第一个参数是显式的
this指针(对应接收者对象)。
- 将每个方法编译成一个独立的LLVM函数。该函数的第一个参数是显式的
- 初始化VTable:
- 为每个类创建一个全局常量,即其VTable,其中填充了指向对应方法实现的函数指针。
- 生成代码:
new表达式:转换为堆分配和初始化。- 方法调用(
obj.method(arg)):转换为通过obj的VTable指针查找函数指针,然后进行间接调用。 - 字段访问:转换为对结构体指针的
getelementptr指令,计算字段偏移。
高级主题与挑战
面向对象编程的编译还涉及许多高级主题和工程挑战。
以下是其中一些重要的扩展和挑战:
- 多重继承:一个类继承自多个父类。这会导致复杂的对象布局(需要包含多个父类的子对象)和VTable查找(需要多个VTable或更复杂的索引方案),并引发“菱形继承”问题。
- 接口(如Java):类可以实现多个接口。通常的解决方案是为每个接口生成一个独立的VTable,对象通过其类信息来查找特定接口的VTable。
- 单独编译:如何在不重新编译子类的情况下修改父类?这要求VTable布局和字段偏移等信息在链接时或运行时才能最终确定,或者强制要求父类的某些修改必须触发子类的重新编译。
- 运行时类型信息(RTTI):支持
instanceof这样的运行时类型检查。每个对象的类信息中需要存储继承层次信息,以便高效地进行子类关系判断。 - 基于原型的语言(如JavaScript):没有类的概念,对象直接继承自另一个对象(原型)。实现上通常采用类似的VTable共享和写时复制技术来优化性能。
- 类型系统:Java中,子类化即子类型化。但方法重写要求参数类型严格不变(不支持参数逆变),某些版本支持返回类型协变。这与函数式语言中的子类型化规则不同。
总结
本节课中我们一起学习了面向对象编程语言的编译原理。
- 我们首先回顾了面向对象的核心概念:类、对象、封装、继承和多态。
- 我们深入探讨了动态方法分派的关键问题,并引入了虚方法表这一核心解决方案。VTable使得运行时根据对象实际类型调用正确方法成为可能。
- 我们了解了如何通过精心安排VTable的布局来支持继承和方法重写。
- 我们讨论了对象的内存布局和字段访问,这与结构体编译类似,但需考虑继承。
- 我们简要概述了如何将这些概念映射到LLVM IR上。
- 最后,我们提及了多重继承、接口、单独编译等高级主题和挑战。


面向对象编程通过将数据与操作绑定,并利用动态分派,为构建模块化、可扩展的大型软件系统提供了强大的范式。理解其底层编译机制,有助于我们写出更高效的代码,并深入理解这些抽象背后的成本。
024:垃圾收集 🗑️

在本节课中,我们将要学习编程语言运行时系统的一个核心组成部分:自动内存管理,即垃圾收集。我们将探讨其基本概念、不同算法及其优缺点,并了解编译器如何与运行时系统协作以实现高效的内存管理。
运行时系统概述
上一节我们介绍了编译器将源代码转换为机器可执行代码的过程。本节中,我们来看看语言实现中另一个至关重要的部分:运行时系统。
运行时系统是语言实现者提供的一套支持程序执行的底层服务。它并非程序本身的一部分,但在程序运行时必须存在。这些服务可能包括:
- 与操作系统交互(如处理信号)。
- 自动内存管理(垃圾收集)。
- 管理并发或并行。
- 对于编译到字节码的语言(如Java),运行时系统还包括虚拟机、即时编译和动态类加载等。
“运行时”是一个名词,指代处理这些任务的系统部分。
自动内存管理与基本概念
自动内存管理与手动内存管理(如C语言中的malloc和free)相对。在自动内存管理中,运行时系统负责按需分配内存,并自动回收不再使用的内存,这个过程称为垃圾收集。
核心挑战在于如何确定一块内存何时“不再使用”。理想情况下,我们希望识别未来计算中绝不会再被访问的内存,但这在一般情况下是不可判定的。因此,运行时系统采用一个更保守但可实现的定义:不可达内存。
如果一块内存在当前程序状态下,无法通过任何指针链从根位置访问到,那么这块内存就是不可达的。既然程序无法访问它,未来也必然无法使用,因此可以安全回收。这可能会漏掉一些实际上不再使用但依然可达的内存,但它是安全的。
标记-清扫算法 🧹
接下来,我们深入探讨垃圾收集的基本算法。我们将从经典的标记-清扫算法开始。
标记-清扫算法的核心思想分为两个阶段:标记阶段和清扫阶段。
可达性与根
算法从根开始。根是程序可以直接访问的位置,包括:
- 寄存器
- 栈(存储局部变量和调用保存的寄存器)
- 全局变量
从这些根出发,算法跟随指针进入堆,标记所有可达的对象。任何未被标记的对象都被视为垃圾,可以在清扫阶段回收。
算法步骤
在对象中,我们使用一个位(标记位)来指示它是否已被标记。
标记阶段:对每个根进行深度优先搜索。
function DFS(x):
if x is a pointer into the heap and record x is not marked:
mark(x) // 将标记位设为1
for each field f in record x:
DFS(f)
在标记阶段结束时,所有从根可达的对象都被标记。
清扫阶段:遍历整个堆。
p = first address in heap
while p < last address in heap:
if record at p is marked:
unmark(p) // 为下次收集准备
else:
add p to free_list // 回收内存
p = p + size(record at p)
清扫阶段会回收未标记的内存,并将其加入空闲列表以供后续分配。
优化:指针反转
深度优先搜索的递归实现可能需要很大的栈空间,最坏情况下可能与堆一样大。一种优化技巧是指针反转。

指针反转的核心思想是利用对象本身来存储遍历状态,从而将显式的栈编码到已分配的对象中。当访问对象X的字段Fi时,我们可以用该字段存储我们是从哪个对象(比如Y的字段Fk)遍历过来的。这样,在回溯时就能恢复原来的指针值。这避免了为遍历分配额外的大量内存。
标记-清扫通常需要暂停所有其他计算(即“停止世界”)来安全地执行。
引用计数算法 🔢
除了标记-清扫,还有另一种思路不同的垃圾收集方法:引用计数。
引用计数的核心思想是为每个对象维护一个引用计数,记录指向它的指针数量。该计数通常存储在对象本身。
- 当创建一个指向对象的新指针时,递增其引用计数。
- 当一个指针被覆盖或销毁时,递减原指向对象的引用计数。
- 当某个对象的引用计数变为零时,意味着没有任何指针指向它,该对象立即成为垃圾,可以被回收。
引用计数的主要优点是内存可以立即被回收,且操作分摊在程序执行过程中。但它有几个显著缺点:
- 性能开销:每次指针赋值都需要更新引用计数,增加了内存访问和计算开销。
- 循环引用问题:如果一组对象形成循环引用,即使它们从根不可达,它们的引用计数也永远不会降为零,从而导致内存泄漏。解决此问题通常需要引入额外的周期检测机制(如偶尔运行标记-清扫)。
- 并发环境下的同步开销。
Python语言就主要使用引用计数,并辅以周期性的标记-清扫来处理循环数据结构。
复制收集算法 📤
标记-清扫算法需要遍历整个堆进行清扫,即使垃圾很少。复制收集是另一种算法,它同时改进了标记和清扫阶段。
复制收集将堆分为两个空间:来源空间和目标空间。程序开始时,所有对象都在来源空间分配。
- 当来源空间快满时,启动垃圾收集。
- 从根开始,遍历所有可达对象,并将它们复制到目标空间。复制是连续的,因此也起到了压缩堆、消除碎片的作用。
- 在复制过程中,需要在来源空间的对象中留下“转发地址”信息,以便更新其他指向该对象的指针。
- 复制完成后,目标空间包含所有存活对象,且排列紧凑。此时,可以一次性释放整个来源空间。
- 交换两个空间的角色,继续在新的来源空间(即之前的目标空间)中分配。
复制收集的优点是清扫阶段代价低廉(直接丢弃半个堆),并且能自然压缩内存。缺点是任何时候都只能使用一半的堆内存,并且复制存活对象有成本。
简单的复制收集通常按广度优先顺序复制,这可能不利于程序后续访问的局部性。有些实现会采用深度优先或混合策略来改善局部性。
分代收集算法 🧓
基于程序行为的观察,我们有了更高效的策略。观察表明:
- 新创建的对象很可能很快死亡(短寿命)。
- 存活时间较长的对象很可能继续存活(长寿命)。
分代收集算法利用了这一特性。它将堆划分为多个“代”(通常是2-3代)。新对象在年轻代(第0代)分配。
- 年轻代进行频繁的垃圾收集(通常使用高效的复制收集)。
- 如果一个对象在年轻代经历了几次收集后仍然存活,它就被提升到老年代(第1代)。
- 老年代进行较少频率的垃圾收集(可能使用标记-清扫或标记-压缩算法)。
这样,垃圾收集的努力主要集中于最可能产生垃圾的区域,从而提高了效率。一个复杂性在于,老年代中的对象可能持有指向年轻代对象的指针。这些指针在收集年轻代时也必须被视为“根”,因此需要额外的机制(如“记忆集”)来记录这种跨代引用。
OCaml语言就采用了两代分代收集:一个频繁收集的次要堆(年轻代)和一个较少收集的主要堆(老年代)。
高级主题与实现考量
之前的算法大多需要暂停程序执行,这可能引起可感知的延迟。为了解决这个问题,发展出了更高级的收集器:
- 增量式收集:将收集工作分解为多个小步骤,穿插在程序执行中,避免长时间停顿。
- 并发收集:垃圾收集器线程与程序线程同时运行。这是最复杂的,但可以极大减少停顿时间。
编译器与运行时的协作
要实现高效的垃圾收集,编译器必须与运行时系统紧密协作。编译器需要为运行时提供关键信息:
- 对象布局描述:运行时需要知道对象的哪些字段是指针,以便正确遍历。在面向对象语言中,类信息或虚函数表指针可以间接提供这些信息。像OCaml这样的语言使用类型擦除和指针标记(例如,用最低有效位区分指针和整数)来识别指针。
- 根位置映射:在垃圾收集可能发生的时刻(如分配内存时或调用可能触发收集的函数时),编译器需要生成指针映射。该映射告诉收集器,在特定的程序点(如调用返回地址),哪些寄存器、栈槽中包含存活的指针。这对于准确找到所有根至关重要,尤其是在处理调用者保存和调用者保存寄存器时。
保守式收集
对于像C/C++这样的遗留语言,编译器没有为垃圾收集提供协作信息。为此,出现了保守式垃圾收集器(如Boehm-Demers-Weiser收集器)。
保守式收集器将任何看起来像是指向堆内有效地址的值都保守地当作指针处理。它可能无法移动内存(因为无法安全区分指针和整数),并且可能导致一些内存泄漏(将整数误判为指针,从而保留本应回收的内存)。它的优势在于无需修改现有代码或编译器即可为遗留程序提供基本的自动内存管理。
总结


本节课中我们一起学习了自动内存管理——垃圾收集的核心概念与多种算法。我们从基础的标记-清扫和引用计数开始,探讨了更高效的复制收集和基于程序行为观察的分代收集。我们还了解了减少收集停顿的增量式和并发式收集,以及实现垃圾收集时编译器与运行时系统协作的重要性。最后,我们看到了如何为不支持协作的语言实现保守式收集。垃圾收集是现代高级语言运行时系统的基石,它平衡了开发效率与执行性能,是语言设计中理论与实践紧密结合的典范。
025:已验证编译与课程总结

在本节课中,我们将要学习一个与之前不同的主题:已验证编译。我们将探讨为什么需要已验证的编译器,理解编译器正确性的定义,并了解如何构建一个带有机器检查证明的编译器。最后,我们将回顾本学期所涵盖的全部内容。
期末考试安排 📅
上一节我们介绍了课程的整体框架,本节中我们来看看本学期的期末考试安排。
期末考试将于12月15日星期五下午2点在科学中心D举行。考试时长为3小时(180分钟),包含约30道选择题和简答题,每题1分,无部分扣分。
考试形式为开卷、开笔记、开笔记本电脑。这意味着你可以参考任何课程材料,包括讲义。我们鼓励你在线查阅笔记,而不是打印大量纸质材料。你可以在考试期间编写和运行程序,但考试内容旨在测试概念,而非实际运行代码的能力。
以下是关于考试的重要规定:
- 禁止使用生成式人工智能(如ChatGPT)参加考试。
- 除访问个人存储在云端(如Dropbox或Google Drive)的文件外,不得使用互联网进行通信或搜索答案。
- 请确保笔记本电脑电量充足,考场会提供电源插座。
为什么需要已验证的编译器?🤔
上一节我们明确了期末考试的要求,本节中我们来看看为什么编译器需要被“验证”。
编译器是复杂的软件,可能存在错误。最严重的情况不是编译器崩溃,而是它成功编译出一个行为与源代码语义不符的程序。这种错误在航空、汽车控制、核电站管理等关键系统中是灾难性的。因此,确保编译器输出的正确性至关重要。
你可能会认为,像GCC和LLVM这样经过广泛使用和测试的主流编译器应该足够可靠。然而,一项使用名为Csmith的模糊测试工具的研究发现,即使在成熟的编译器中,依然存在大量错误。该工具通过生成随机但精心构造的C程序,并比较不同编译器(如GCC和LLVM)的输出行为来发现不一致。研究发现GCC中存在约79个错误,LLVM中存在约202个错误。
这强烈表明,即使是广泛使用的编译器,其正确性也无法仅通过测试来完全保证。这构成了对“已验证编译器”的需求动机。
已验证编译器的愿景 🎯
上一节我们看到了编译器存在错误的现实,本节中我们来看看学术界如何回应这一挑战。
2003年,计算机科学家Tony Hoare提出了一个“重大挑战”:让计算机科学界共同努力,构建一个被形式化证明正确的编译器。这个挑战旨在团结研究社区,完成一项具有革命性、可测试且可行的宏伟目标。
仅仅三年后,CompCert项目发表了相关论文,实现了这个目标的绝大部分。CompCert是一个为C语言子集到PowerPC汇编的编译器,并附带了一个机器检查的证明,确保其语义正确性。
令人印象深刻的是,当Csmith工具被用于测试CompCert时,在其已验证的编译阶段中没有发现任何错误。这与在其他编译器中发现数百个错误形成了鲜明对比。这强有力地证明了在证明框架内开发编译器优化具有切实的好处。
CompCert是第一个此类已验证的编译器,现已在航空工业等领域商业化应用。此后,也出现了其他已验证的编译器和中间表示(如Vellvm)。
定义编译器正确性 ✅
上一节我们看到了已验证编译器的成功实例,本节中我们深入探讨“编译器正确性”究竟如何定义。
直观上,我们希望编译后的程序C能保留源程序S的语义。为此,我们需要一种方式来形式化程序的“含义”或“行为”。
一种常见的方法是通过关系 P ⇓ B 来定义,表示程序P可以产生可观察行为B。行为B可以是一个事件序列,例如输入/输出、程序终止、崩溃、甚至无限循环(发散)。
基于此,我们可以定义语义等价:源程序S和编译程序C语义等价,当且仅当它们能产生完全相同的可观察行为集合。用公式表示为:
∀B. (S ⇓ B) ⇔ (C ⇓ B)
然而,这个定义对于实际编译器而言过于严格。源语言通常包含未定义行为或非确定性(例如,表达式求值顺序),编译器可以安全地消除某些行为或选择一种确定性的实现方式。此外,目标机器的资源限制(如有限内存)也可能导致某些源程序行为无法实现。
因此,我们需要一个更实用、更弱的正确性定义。
适用于已验证编译的正确性定义 🛡️
上一节我们指出了严格语义等价的局限性,本节中我们引入一个更适合已验证编译的、更弱但足够强的正确性定义。
我们只关注安全的源程序,即那些不会出现未定义行为(如崩溃)的程序。对于这类程序,我们要求编译后的程序C的行为是源程序S行为的一个子集。用公式表示为:
∀B. (C ⇓ B) ⇒ (S ⇓ B)
这意味着,编译程序能做的任何事情,源程序也允许做。但源程序可能允许更多行为(例如,不同的非确定性选择),而编译器可以固定其中一种。
如果源语言和目标语言都是确定性的(即对于给定输入,只有唯一行为),那么这个定义等价于:
∀B. (S ⇓ B) ⇒ (C ⇓ B)
即,所有源程序的安全行为,编译程序也必须具备。这个方向在证明上通常更容易处理。
为简洁起见,我们将这种关系记为 S ≈ C。
这个定义之所以“足够好”,是因为它能保证规约的保持性。如果源程序S满足某个功能规约(例如,“正确排序列表”),并且S ≈ C,那么编译程序C也必定满足相同的规约。这正是我们最终关心的事情。
验证与验证:两种证明策略 🔧
上一节我们定义了编译器正确性,本节中我们探讨两种实现证明的策略:验证与验证。
设 comp(S) 是一个编译器函数,它要么成功返回编译代码C,要么失败。
-
已验证编译器:指编译器程序
comp本身附带一个证明,表明:
∀S, C. (comp(S) = OK C) ⇒ (S ≈ C)
这需要对编译器自身的逻辑进行全局证明,难度较大。 -
已验证验证器:指一个验证器函数
validate(S, C),它检查给定的S和C是否满足S ≈ C。它附带一个证明,表明:
∀S, C. (validate(S, C) = true) ⇒ (S ≈ C)
验证器可以保守地返回false(即使两者等价,但无法确定)。这种方法只需针对给定的程序对进行验证,通常比验证整个编译器更容易。
关键洞见是:我们可以用一个已验证的验证器轻松构造出一个已验证的编译器。
新编译器 comp'(S) 的工作流程如下:
- 用原始(未验证的)编译器
comp编译S,得到C。 - 用已验证的验证器
validate(S, C)进行检查。 - 如果验证通过,则输出
C;否则,报错。
由于comp'只在验证器确认正确性后才输出代码,因此它本身就是一个已验证的编译器。
这种方法的好处是组合性。编译器通常由多个阶段(翻译过程)组成。如果我们为每个阶段都构建一个已验证的验证器(或编译器),那么将它们串联起来,就能得到整个编译流程的验证。这大大降低了构建大型已验证编译器的复杂度。
CompCert 架构剖析 🏗️
上一节我们介绍了验证器的强大作用,本节中我们以CompCert为例,看它如何运用这些理念。
CompCert编译器是用Coq证明辅助工具编写的。其架构体现了分阶段验证和验证器策略的思想。
以下是其编译管道的主要阶段:
- 非验证前端:C源代码被解析并简化为C light中间表示。此阶段的代码未经形式化验证(早期Csmith在此发现了错误)。
- 已验证翻译:从C light到C#minor,再到Cminor,然后到RTL。这些阶段是已验证的。
- 寄存器分配(关键案例):从RTL到LTL需要进行寄存器分配。图着色算法非常复杂,难以直接验证。CompCert的解决方案是:
- 使用未经验证的启发式图着色算法生成一个寄存器分配映射。
- 然后,使用一个已验证的验证器来检查该映射是否有效(例如,无冲突分配)。
- 最后,用一个已验证的步骤,根据这个已验证有效的映射来转换程序。
- 后续已验证阶段:从LTL到Linear,再到Mach,最后到PPC抽象语法,这些步骤都是已验证的。
- 非验证后端:一个未经验证的代码打印器将PPC抽象语法转换为实际的PowerPC汇编代码。
这种架构展示了如何将难以验证的复杂优化(如寄存器分配)通过“未验证生成 + 已验证检查”的模式纳入已验证编译框架,是验证器策略的完美体现。
课程总结与回顾 🎓


上一节我们深入了解了已验证编译器的构造,本节中我们将回顾整个学期在CS153课程中所涵盖的广阔领域。
让我们将思绪拉回到学期初,我们首先认识了编译器这个将高级源代码转换为低级机器码的“黑盒”。随后,我们打开这个黑盒,系统性地探索了其中的每一个阶段:
以下是本学期我们学习的主要内容:
- 基础与后端:我们从目标架构开始,回顾了x86汇编、内存布局和调用约定,并实现了汇编解释器。这引出了对LLVM中间表示的深入探讨。
- 前端:我们学习了词法分析和语法分析,包括递归下降、LL和LR分析算法。
- 语言、法律与伦理:我们探讨了开源软件对编译器生态的重要性及其相关责任。
- 函数与类型:我们研究了如何编译函数(包括一等公民函数),深入学习了类型检查、判断、推理规则和子类型。
- 中间表示与优化:我们接触了大量优化转换,并学习了数据流分析这一理解程序行为、支撑优化的关键静态分析技术。
- 寄存器分配:我们深入探讨了寄存器分配的挑战,并学习了图着色等算法与启发式方法。
- 高级特性实现:在课程后期,我们探讨了如何编译面向对象的特性以及垃圾回收机制。
- 前沿主题:最后,我们今天学习了已验证编译,如何为编译器提供形式化正确性证明。
通过一系列作业,你们亲手实践了从高级语言到x86可执行文件的完整编译流程,构建了一个真正的编译器管道。这是值得骄傲的成就。
编译器技术与编程语言理论、计算机体系结构、操作系统和安全性等领域紧密相连。希望本课程为你打下了坚实的基础,并激发了你在这些相关领域进一步探索的兴趣。
感谢大家一学期以来的投入、思考和精彩的互动。祝大家在期末考试以及其他所有期末项目中好运!


本节课中,我们一起学习了已验证编译的概念、动机、正确性定义以及实现策略,并以CompCert为例分析了其架构。最后,我们全面回顾了编译器课程的核心知识体系。

浙公网安备 33010602011771号