You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

24 KiB

24 | Go语言编译器把它当作教科书吧

你好我是宫文学。今天这一讲我来带你研究一下Go语言自带的编译器它可以被简称为gc。

我之所以要来带你研究Go语言的编译器一方面是因为Go现在确实非常流行很多云端服务都用Go开发Docker项目更是巩固了Go语言的地位另一方面我希望你能把它当成编译原理的教学参考书来使用。这是因为

  • Go语言的编译器完全用Go语言本身来实现它完全实现了从前端到后端的所有工作而不像Java要分成多个编译器来实现不同的功能模块不像Python缺少了后端也不像Julia用了太多的语言。所以你研究它所采用的编译技术会更方便。
  • Go编译器里基本上使用的都是经典的算法经典的递归下降算法、经典的SSA格式的IR和CFG、经典的优化算法、经典的Lower和代码生成因此你可以通过一个编译器就把这些算法都贯穿起来。
  • 除了编译器你还可以学习到一门语言的其他构成部分的实现思路包括运行时垃圾收集器、并发调度机制等、标准库和工具链甚至连链接器都是用Go语言自己实现的从而对实现一门语言所需要做的工作有更完整的认识。
  • 最后Go语言的实现继承了从Unix系统以来形成的一些良好的设计哲学因为Go语言的核心设计者都是为Unix的发展做出过重要贡献的极客。因此了解了Go语言编译器的实现机制会提高你的软件设计品味。

扩展每种语言都有它的个性而这个个性跟语言设计者的背景密切相关。Go语言的核心设计者是Unix领域的极客包括Unix的创始人和C语言的共同发明人之一Ken Tompson。Rob Pike也是Unix的核心作者。

Go语言的作者们显然希望新的语言体现出他们的设计哲学和口味。比如致力于像Unix那样的简洁和优雅并且致力于让Go再次成为一款经典作品。

所以在已经研究了多个高级语言的编译器之后我们可以拿Go语言的编译器把整个编译过程再重新梳理和印证一遍。

好了,现在就开始我们今天探索的旅途吧。

首先我们来看看Go语言编译器的前端。

重要提示照例你要下载Go语言的源代码本讲采用的是1.14.2版本。并且你最好使用一个IDE便于跟踪调试编译器的执行过程。
Go的源代码中附带的介绍编译器的文档,写得很好、很清晰,你可以参考一下。

词法分析和语法分析

Go的编译器的词法分析和语法分析功能的实现是在cmd/compile/internal/syntax目录下。

**词法分析器是scanner.go。**其实大部分编程语言的词法分析器的算法,都已经很标准了,我们在Java编译器里就曾经分析过。甚至它们处理标识符和关键字的方式也都一致都是先作为标识符识别出来然后再查表挑出关键字来。Go的词法分析器并没有像V8那样在不遗余力地压榨性能它跟你平常编码的方式是很一致的非常容易阅读。

**语法分析器是parser.go。**它是一个标准的手写的递归下降算法。在解析二元表达式的时候Go的语法分析器也是采用了运算符优先级算法这个已经是我们第N次见到这个算法了所以你一定要掌握不过每个编译器的实现都不大一样而Go的实现方式相当的简洁你可以去自己看一下或者用调试器来跟踪一下它的执行过程。

图1用IDE工具Goland跟踪调试编译过程

Go的AST的节点是在nodes.go中定义的它异常简洁可以说简洁得让你惊讶。你可以欣赏一下。

**Go的语法分析器还有一个很有特色的地方就是对错误的处理。**它在处理编译错误时,有一个原则,就是不要遇到一个错误就停止编译,而是要尽可能跳过当前这个出错的地方,继续往下编译,这样可以一次多报几个语法错误。

parser.go的处理方式是当语法分析器在处理某个产生式的时候如果发现了错误那就记录下这个错误并且往下跳过一些Token直到找到一个Token是属于这个产生式的Follow集合的。这个时候编译器就认为找到了这个产生式的结尾。这样分析器就可以跳过这个语法单元继续处理下面的语法单元。

比如,在解析函数声明语句如果Go的语法分析器没有找到函数名称就报错“expecting name or (”,然后往后找到“{”或者“;”,这样就跳过了函数名称的声明部分,继续去编译后面的函数体部分。

在cmd/compile/internal/syntax目录下还有词法分析器和语法分析器的测试程序你可以去运行测试一下。

最后如果你还想对Go语言的语法分析有更加深入地了解我建议你去阅读一下Go语言的规范它里面对于每个语法单元都有EBNF格式的语法规则定义比如对语句的定义。你通过看代码、看语言规范积累语法规则的第一手经验以后再看到一段程序你的脑子里就能反映出它的语法规则并且能随手画出AST了这是你学习编译原理需要建立的硬功夫。比如说这里我节选了一段Go语言的规范中针对语句的部分语法规则。

Statement =
	Declaration | LabeledStmt | SimpleStmt |
	GoStmt | ReturnStmt | BreakStmt | ContinueStmt | GotoStmt |
	FallthroughStmt | Block | IfStmt | SwitchStmt | SelectStmt | 
    ForStmt | DeferStmt .

SimpleStmt = EmptyStmt | ExpressionStmt | SendStmt | IncDecStmt |    
    Assignment | ShortVarDecl .

在了解了Go语言编译器的语法分析工作以后接下来我们再来看看它的语义分析阶段。

语义分析类型检查和AST变换

语义分析的程序是在cmd/compile/internal/gc目录下注意gc的意思是Go Compiler不是垃圾收集的意思。在入口代码main.go中你能看到整个编译过程的主干步骤。

语义分析的主要程序是在typecheck.go中。**这里你要注意,**不要被“typecheck”的名称所误导它其实不仅是做类型检查还做了名称消解Name Resolution和类型推导。

你已经知道名称消解算法的特点是分阶段完成。举个例子在给表达式“a=b”中的变量b做引用消解之前编译器必须先处理完b的定义比如“var b Person”这样才知道符号b指的是一个Person对象。

另外,在前面学习Java编译器的时候你已经知道对方法体中的本地变量的消解必须放在最后才能保证变量的使用总是引用到在它前面的变量声明。Go的编译器也是采用了相同的实现思路你可以借此再回顾一下这个知识点加深认识。

在语义分析阶段Go的编译器还做了一些AST变换的工作。其中就有内联优化逃逸分析这两项工作。在我们之前解析的编译器当中这两项工作都是基于专门做优化的IR比如Sea of Nodes来做的而在Go的编译器里却可以基于AST来做这两项优化。你看是不是真实世界中的编译器才能让你如此开阔眼界

你可以用“-m”参数来编译程序它会打印出与内联和逃逸方面有关的优化。你可以带上多个“-m”参数打印出嵌套层次更深的算法步骤的决策。

go build -gcflags '-m -m' hello.go 

好了现在我们借gc编译器又复习了一遍语义分析中的一些关键知识点名称消解算法要分阶段在语义分析阶段会对AST做一些变换。我们继续来看gc编译器下一步的处理工作。

生成SSA格式的IR

gc编译器在做完语义分析以后下一步就是生成IR了。并且gc的IR也是SSA格式的。你可以通过gc来进一步了解如何生成和处理SSA格式的IR。

首先我们来看看Go语言的IR是什么样子的。针对下面的示例代码foo.go我们来看下它对应的SSA格式的IR

package main

func Foo(a int) int {
   var b int
   if (a > 10) {
      b = a
   } else {
      b = 10
   }
   return b
}

在命令行中输入下面的命令让gc打印出为foo函数生成的IR。在当前目录下你能看到一个ssa.html文件你可以在浏览器中打开它。

GOSSAFUNC=Foo go build -gcflags '-S' foo.go

在这个文件当中你能看到编译器对IR做了多步的处理也能看到每次处理后所生成的IR。

gc的IR是基于控制流图CFG的。一个函数会被分成多个基本块基本块中包含了一行行的指令。点击某个变量你能看到它的定义和使用情况def-use链图中显示成绿色。你还能看到图中灰色的变量根据定义和使用关系会发现它们没有被使用所以是死代码可以删除。

图2foo示例程序各个处理阶段的IR

针对第一个阶段Start阶段我来给你解释一下每行指令的含义可参考genericOps.go帮助你了解Go语言的IR的设计特点。

你可以参考代码库中介绍SSA的文档里面介绍了Go的SSA的几个主要概念。

下面我来给你解读一下。

首先是Value**。**Value是SSA的最主要构造单元它可以定义一次、使用多次。在定义一个Value的时候需要一个标识符ID作为名称、产生该Value的操作码Op)、一个类型(Type,就是代码中<>里面的值),以及一些参数。

操作码有两类。一类是机器无关的,其定义在genericOps.go一类是机器相关的它是面向特定的CPU架构的其定义在XXXOps.go中。比如AMD64Ops.go中是针对AMD64架构CPU的操作码信息。

在做Lower处理时编译器会把机器无关的操作码转换为机器相关的操作码有利于后序生成目标代码。机器无关的优化和机器相关的优化分别作用在采用这两类不同操作码的IR上。

Value的类型信息通常就是Go语言中的类型。但有几个类型是只会在SSA中用到的特殊类型,就像上面语句中的,即内存(TypeMem)类型以及TypeFlags也就是CPU的标志位类型。

这里我要特别讲一下内存类型。内存类型代表的是全局的内存状态。如果一个操作码带有一个内存类型的参数,那就意味着该操作码依赖该内存状态。如果一个操作码的类型是内存类型,则意味着它会影响内存状态。

SSA的介绍文档中有一个例子能帮助你理解内存类型的用法。

在这个例子中程序首先会向地址a写入3这个值。这个时候内存状态就修改了从v1到了v10。接着把地址a的值写入地址b内存状态又发生了一次修改。在IR中第二行代码依赖第一行代码的内存状态v10因此就导致这行代码只能出现在定义了v10之后。

// *a = 3       //向a地址写入3
// *b = *a      //向b地址写入a的值
v10 = Store <mem> {int} v6 v8 v1
v14 = Store <mem> {int} v7 v8 v10

这里你需要注意对内存的读和写各种IR一般都是使用Load和Store这两个词汇是一类比较特殊的指令。其他的Value我们都可以认为它们是在寄存器中的是计算过程中的临时变量所以它们在代码中的顺序只受数据流中依赖关系的制约。而一旦中间有读写内存的操作那么代码顺序就会受到一定的限制。

我们可以跟在Graal编译器中学到的知识印证一下。当你读写一个Java对象的属性的时候也会涉及内存的读写这些操作对应的IR节点在顺序上也是受到限制的我们把它们叫做固定节点。

此外Value结构中还包含了两个辅助信息字段AuxInt和Aux。AuxInt是一个整型值比如在使用Const64指令中AuxInt保存了常量的值而Aux则可能是个复杂的结构体用来保存每个操作码的个性化的信息。

在IR中你还能看到基本块Block**),这是第二个重要的数据结构。**Go编译器中的基本块有三种简单Plain基本块它只有一个后继基本块退出Exit基本块它的最后一个指令是一个返回指令还有if基本块它有一个控制值并且它会根据该值是true还是false跳转到不同的基本块。

第三个数据结构是函数(Func**)。**函数是由多个基本块构成的。它必须有一个入口基本块Entry Block但可以有0到多个退出基本块就像一个Go函数允许包含多个Return语句一样。

现在你已经知道了Go的IR的关键概念和相关的数据结构了。Go的IR在运行时就是保存在Value、Block、Func等内存结构中就像AST一样。它不像LLVM的bitcode还有文本格式、二进制格式可以保存在文件中。

那么接下来编译器就可以基于IR来做优化了。

基于SSA格式的IR做优化

SSA格式的IR对编译器做优化很有帮助。

以死代码删除为例Value结构中有一个Uses字段记录了它的使用数。如果它出现在另一个Value的操作码的参数里或者是某个基本块的控制变量那么使用数就会加1而如果Uses字段的值是0那就证明这行代码没什么用是死代码可以删掉。

而你应该记得,在第7讲中曾提到过我们需要对一个函数的所有基本块都扫描一遍甚至多遍才能知道某个变量的活跃性从而决定是否可以删除掉它。那相比起来采用SSA格式可以说简单太多了。

基于这样的IR来做优化就是对IR做很多遍Pass的处理。在cmd/compile/internal/ssa/compile.go的代码里列出了所有这些Pass有将近50个。你能看到每个处理步骤执行的是哪个优化函数你还可以在ssa.html中看到每个Pass之后IR都被做了哪些修改。

图3compiler.go中的Pass

这些处理算法都是在cmd/compile/internal/ssa目录下。比如cse.go里面是消除公共子表达式的算法,而nilcheck.go是被用来消除冗余的nil检查代码。

有些算法还带了测试程序(如cse_test.gonilcheck_test.go。你可以去阅读一下看看测试程序是如何构造测试数据的并且你还可以通过Debugger来跟踪测试程序的执行过程从而理解相关优化算法是如何实现的这是一个很有效的学习方式。

另外gc还有一些比较简单的优化算法它们是基于一些规则对IR做一些重写rewrite。Go的编译器使用了自己的一种DSL来描述这些重写规则针对机器无关的操作码的重写规则是在generic.rules文件中而针对机器有关的操作码的重写规则是在XXX.rules中比如AMD64.rules

我们来看几个例子在generic.rules中有这样一个机器无关的优化规则它是把x*1的运算优化为x。

图4把x*1的运算优化为x的规则

在AMD64.rules中有一个机器相关的优化规则这个规则是把MUL指令转换为LEA指令LEA指令比MUL指令消耗的时钟周期更少。

(MUL(Q|L)const [ 3] x) -> (LEA(Q|L)2 x x)

generic.rules中的规则会被rulegen.go解析并生成Go代码rewritegeneric.go。而AMD64.rules中的规则被解析后会生成rewriteAMD64.go。其中Lower的过程也就是把机器无关的操作码转换为机器相关的操作码它也是用这种重写规则实现的。

通过gc这种基于规则做指令转换的方法你应该产生一个感悟也就是在写软件的时候我们经常要设计自己的DSL让自己的软件更具灵活性。比如gc要增加一个新的优化功能只需要增加一条规则就行了。我们还可以再拿Graal编译器印证一下。你还记得Graal在生成LIR的时候要进行指令的选择那些选择规则是用注解来生成的而那些注解规则也是一种DSL。

好了,谈完了优化,我们继续往下看。

生成机器码

最后编译器就可以调用gc/ssa.go中的genssa方法,来生成汇编码了。

在ssa.html的最右边一栏就是调用genssa方法以后生成的汇编代码采用的是Go编译器特有的格式其中有些指令如PCDATA和FUNCDATA是用来与垃圾收集器配合的

你可能会问,**编译器在生成机器码之前,不是还要做指令选择、寄存器分配、指令排序吗?**那我们看看gc是如何完成这几项任务的。

寄存器分配regalloc.go作为一个Pass已经在生成机器码之前执行了。它采用的是线性扫描算法Linear Scan Register Allocator

**指令选择会分为两部分的工作。**一部分工作是在优化算法中已经做了一些指令选择我们前面提到的重写规则就蕴含了根据IR的模式来生成合适的指令的规则另一部分工作则放到了汇编器当中。

这就是Go的编译器与众不同的地方。原来gc生成的汇编代码是一种“伪汇编”它是一种半抽象的汇编代码。在生成特定CPU的机器码的时候它还会做一些转换这个地方可以完成另一些指令选择的工作。

至于指令排序我没看到过在gc编译器中的实现。我请教了谷歌的一位研究员他给我的信息是像AMD64这样的CPU已经能够很好地支持乱序执行了所以指令重排序给gc编译器的优化工作带来的好处很有限。

而gc目前没有做指令排序还有一个原因就是指令重排序算法的实现代价比较高而gc的一个重要设计目标就是要求编译速度要快。

扩展Go语言的另外两个编译器gccgo和GoLLVM都具备指令重排序功能。

课程小结

这一讲我给你介绍了gc编译器的主要特点。之所以能压缩在一讲里面是因为你已经见识了好几款编译器渐渐地可以触类旁通、举一反三了。

在gc里面你能看到很多可以借鉴的成熟实践

  • 语法分析:递归下降算法,加上针对二元表达式的运算符优先级算法;
  • 语义分析分阶段的名称消解算法以及对AST的转换
  • 优化采用了SSA格式的IR、控制流图CFG、多个Pass的优化框架以及通过DSL支持的优化规则。

所以在这一讲的开头我还建议你把Go语言的编译器作为你学习编译原理的“教学参考书”建议你在图形化的IDE界面里来跟踪调试每一个功能这样你就能很方便去观察它的算法执行过程。

本讲的思维导图如下:

一课一思

在gc编译器里面内联优化是基于AST去做的。那么它为什么没有基于SSA格式的IR来做呢这两种不同的实现会有什么差异欢迎你在留言区发表你的看法。

参考资料

  1. Introduction to the Go compiler 官方文档介绍了gc的主要结构。
  2. Introduction to the Go compilers SSA backend 官方文档介绍了gc的SSA。
  3. Go compiler internals: adding a new statement to Go - Part 1Part2。在这两篇博客里作者做了一个实验如果往Go里面增加一条新的语法规则需要做哪些事情。你能贯穿性地了解一个编译器的方法。
  4. Go compiler: SSA optimization rules description language这篇博客详细介绍了gc编译器的SSA优化规则描述语言的细节。
  5. A Primer on Go AssemblyA Quick Guide to Gos Assembler 。gc编译器采用的汇编语言是它自己的一种格式是“伪汇编”。这两篇文章中有Go汇编的细节。