2.4 中间代码生成

前两节介绍的词法与语法分析以及类型检查两个部分都属于编译器前端,它们负责对源代码进行分析并检查其中存在的词法和语法错误,经过这两个阶段生成的抽象语法树已经几乎不存在结构上的错误了,从这一节开始就进入了编译器后端的工作 —— 中间代码生成。

2.4.1 概述

中间代码是指一种应用于抽象机器的编程语言,它设计的目的,是用来帮助我们分析计算机程序。在编译的过程中,编译器会在将源代码转换成目标机器上机器码的过程中,先把源代码转换成一种中间的表述形式1

intermediate-representation

图 2-12 源代码、中间代码和机器码

很多读者可能认为中间代码没有太多价值,我们也可以直接将源代码翻译成目标语言,这样就能省略编译的步骤,这种看起来可行的办法实际上有很多问题,它忽略了编译器需要面对的复杂场景,很多编译器可能需要将一种源代码翻译成多种机器码,想要在高级的编程语言上直接进行翻译是比较困难的,但是我们使用中间代码就可以将我们的问题简化:

  • 将编程语言直接翻译成机器码的过程拆成两个简单步骤 —— 中间代码生成和机器码生成;
  • 中间代码是一种更接近机器语言的表示形式,对中间代码的优化和分析相比直接分析高级编程语言更容易;Go 语言编译器的中间代码具有静态单赋值(SSA)的特性,我们在介绍 Go 语言编译过程中曾经介绍过静态单赋值,对这个特性不了解的读者可以回到上面的章节阅读相关的内容。

我们再来回忆一下编译阶段入口的主函数 Main 中关于中间代码生成的部分,在这一段代码中会初始化 SSA 生成的配置,在配置初始化结束之后会调用 funccompile 对函数进行编译:

  1. func Main(archInit func(*Arch)) {
  2. // ...
  3. initssaconfig()
  4. for i := 0; i < len(xtop); i++ {
  5. n := xtop[i]
  6. if n.Op == ODCLFUNC {
  7. funccompile(n)
  8. }
  9. }
  10. compileFunctions()
  11. }

这一节将分别介绍配置的初始化以及函数编译两部分内容,我们会以 initssaconfigfunccompile 这两个函数作为入口来分析中间代码生成的具体过程和实现原理。

2.4.2 配置初始化

SSA 配置的初始化过程其实就是做中间代码生成之前的准备工作,在这个过程中我们会缓存可能用到的类型指针、初始化 SSA 配置和一些之后会调用的运行时函数,例如:用于处理 defer 关键字的 deferproc、用于创建 Goroutine 的 newproc 和扩容切片的 growslice 等,除此之外还会根据当前的目标设备初始化特定的 ABI2。我们以 initssaconfig 作为入口开始分析配置初始化的过程。

  1. func initssaconfig() {
  2. types_ := ssa.NewTypes()
  3. _ = types.NewPtr(types.Types[TINTER]) // *interface{}
  4. _ = types.NewPtr(types.NewPtr(types.Types[TSTRING])) // **string
  5. _ = types.NewPtr(types.NewPtr(types.Idealstring)) // **string
  6. _ = types.NewPtr(types.NewSlice(types.Types[TINTER])) // *[]interface{}
  7. ..
  8. _ = types.NewPtr(types.Errortype) // *error

这个函数的执行过程总共可以被分成三个部分,首先就是调用 NewTypes 初始化一个新的 Types 结构体并调用 NewPtr 函数缓存类型的信息,Types 结构体中存储了所有 Go 语言中基本类型对应的指针,比如 BoolInt8、以及 String 等。

golang-type-and-pointer

图 2-12 类型和类型指针

NewPtr 函数的主要作用就是根据类型生成指向这些类型的指针,同时它会根据编译器的配置将生成的指针类型缓存在当前类型中,优化类型指针的获取效率:

  1. func NewPtr(elem *Type) *Type {
  2. if t := elem.Cache.ptr; t != nil {
  3. if t.Elem() != elem {
  4. Fatalf("NewPtr: elem mismatch")
  5. }
  6. return t
  7. }
  8. t := New(TPTR)
  9. t.Extra = Ptr{Elem: elem}
  10. t.Width = int64(Widthptr)
  11. t.Align = uint8(Widthptr)
  12. if NewPtrCacheEnabled {
  13. elem.Cache.ptr = t
  14. }
  15. return t
  16. }

配置初始化的第二步就是根据当前的 CPU 架构初始化 SSA 配置 ssaConfig,我们会向 NewConfig 函数传入目标机器的 CPU 架构、上述代码初始化的 Types 结构体、上下文信息和 Debug 配置:

  1. ssaConfig = ssa.NewConfig(thearch.LinkArch.Name, *types_, Ctxt, Debug['N'] == 0)

NewConfig 会根据传入的 CPU 架构设置用于生成中间代码和机器码的函数,当前编译器使用的指针、寄存器大小、可用寄存器列表、掩码等编译选项:

  1. func NewConfig(arch string, types Types, ctxt *obj.Link, optimize bool) *Config {
  2. c := &Config{arch: arch, Types: types}
  3. c.useAvg = true
  4. c.useHmul = true
  5. switch arch {
  6. case "amd64":
  7. c.PtrSize = 8
  8. c.RegSize = 8
  9. c.lowerBlock = rewriteBlockAMD64
  10. c.lowerValue = rewriteValueAMD64
  11. c.registers = registersAMD64[:]
  12. ...
  13. case "arm64":
  14. ...
  15. case "wasm":
  16. default:
  17. ctxt.Diag("arch %s not implemented", arch)
  18. }
  19. c.ctxt = ctxt
  20. c.optimize = optimize
  21. // ...
  22. return c
  23. }

所有的配置项一旦被创建,在整个编译期间都是只读的并且被全部编译阶段共享,也就是中间代码生成和机器码生成这两部分都会使用这一份配置完成自己的工作。在 initssaconfig 方法调用的最后,会初始化一些编译器会用到的 Go 语言运行时的函数:

  1. assertE2I = sysfunc("assertE2I")
  2. assertE2I2 = sysfunc("assertE2I2")
  3. assertI2I = sysfunc("assertI2I")
  4. assertI2I2 = sysfunc("assertI2I2")
  5. deferproc = sysfunc("deferproc")
  6. Deferreturn = sysfunc("deferreturn")
  7. ...

这些函数会在对应的 runtime 包结构体 Pkg 中创建一个新的符号 obj.LSym,表示上述的方法已经被注册到运行时 runtime 包中,我们在后面的中间代码生成中直接使用这些方法,我们在这里看到的 deferprocdeferreturn 就是 Go 语言用于实现 defer 关键字的运行时函数,你能在后面的章节 5.3 defer 中了解更多内容。

2.4.3 遍历和替换

在生成中间代码之前,我们还需要对抽象语法树中节点的一些元素进行替换,这个替换的过程就是通过 walk 和很多以 walk 开头的相关函数实现的,简单展示几个相关函数的签名:

  1. func walk(fn *Node)
  2. func walkappend(n *Node, init *Nodes, dst *Node) *Node
  3. ...
  4. func walkrange(n *Node) *Node
  5. func walkselect(sel *Node)
  6. func walkselectcases(cases *Nodes) []*Node
  7. func walkstmt(n *Node) *Node
  8. func walkstmtlist(s []*Node)
  9. func walkswitch(sw *Node)

这些用于遍历抽象语法树的函数会将一些关键字和内建函数转换成函数调用,例如: panicrecover 这两个内建函数就会被在 walkXXX 中被转换成 gopanicgorecover 两个真正存在的函数,而关键字 new 也会在这里被转换成对 newobject 函数的调用。

golang-keyword-and-builtin-mapping

图 2-13 关键字和操作符和运行时函数的映射

上图是从关键字或内建函数到其他实际存在的运行时函数的映射,包括 Channel、哈希相关的操作、用于创建结构体对象的 makenew 关键字以及一些控制流中的关键字 select 等。转换后的全部函数都属于运行时 runtime 包,我们能在 src/cmd/compile/internal/gc/builtin/runtime.go 文件中找到函数对应的签名和定义。

  1. func makemap64(mapType *byte, hint int64, mapbuf *any) (hmap map[any]any)
  2. func makemap(mapType *byte, hint int, mapbuf *any) (hmap map[any]any)
  3. func makemap_small() (hmap map[any]any)
  4. func mapaccess1(mapType *byte, hmap map[any]any, key *any) (val *any)
  5. ...
  6. func makechan64(chanType *byte, size int64) (hchan chan any)
  7. func makechan(chanType *byte, size int) (hchan chan any)
  8. ...

src/cmd/compile/internal/gc/builtin/runtime.go 文件中代码的作用只是让编译器能够找到对应符号的函数定义而已,真正的函数实现都在另一个 src/runtime 包中。简单总结一下,编译器会将 Go 语言关键字转换成 runtime 包中的函数,也就是说关键字和内置函数的功能是由语言的编译器和运行时共同完成的。

我们简单了解一下遍历节点时几个 Channel 操作是如何转换成运行时对应方法的,首先介绍向 Channel 中发送消息或者从 Channel 中接受消息两个操作,编译器会分别使用 OSENDORECV 表示发送和接收消息两个操作,在 walkexpr 函数中会根据节点类型的不同进入不同的分支:

  1. func walkexpr(n *Node, init *Nodes) *Node {
  2. ...
  3. case OSEND:
  4. n1 := n.Right
  5. n1 = assignconv(n1, n.Left.Type.Elem(), "chan send")
  6. n1 = walkexpr(n1, init)
  7. n1 = nod(OADDR, n1, nil)
  8. n = mkcall1(chanfn("chansend1", 2, n.Left.Type), nil, init, n.Left, n1)
  9. ...
  10. }

当遇到 OSEND 操作时,会使用 mkcall1 创建一个操作为 OCALL 的节点,这个节点中包含当前调用的函数 chansend1 和几个参数,新的 OCALL 节点会替换当前的 OSEND 节点,这也就完成了对 OSEND 子树的改写。

golang-ocall-node

图 2-14 改写后的 Channel 发送操作

在中间代码生成的阶段遇到 ORECV 操作时,编译器的处理与遇到 OSEND 时相差无几,我们也只是将 chansend1 换成了 chanrecv1,其他的参数没有发生太大的变化:

  1. n = mkcall1(chanfn("chanrecv1", 2, n.Left.Type), nil, &init, n.Left, nodnil())

使用 close 关键字的 OCLOSE 操作也会在 walkexpr 函数中被转换成调用 closechanOCALL 节点:

  1. func walkexpr(n *Node, init *Nodes) *Node {
  2. ...
  3. case OCLOSE:
  4. fn := syslook("closechan")
  5. fn = substArgTypes(fn, n.Left.Type)
  6. n = mkcall1(fn, nil, init, n.Left)
  7. ...
  8. }

对于 Channel 的这些内置操作都会在编译期间就转换成几个运行时执行的函数,很多人都想要了解 Channel 底层的实现,但是并不知道函数的入口,经过这里的分析我们就知道chanrecv1chansend1closechan 几个函数分别实现了 Channel 的发送、接受和关闭操作。

2.4.4 SSA 生成

经过 walk 系列函数的处理之后,AST 的抽象语法树就不再会改变了,Go 语言的编译器会使用 compileSSA 函数将抽象语法树转换成中间代码,我们可以先看一下该函数的简要实现:

  1. func compileSSA(fn *Node, worker int) {
  2. f := buildssa(fn, worker)
  3. pp := newProgs(fn, worker)
  4. genssa(f, pp)
  5. pp.Flush()
  6. }

buildssa 就是用来具有 SSA 特性的中间代码的函数,我们可以使用命令行工具来观察当前中间代码的生成过程,假设我们有以下的 Go 语言源代码,其中只包含一个非常简单的 hello 函数:

  1. package hello
  2. func hello(a int) int {
  3. c := a + 2
  4. return c
  5. }

我们可以使用 GOSSAFUNC 环境变量构建上述代码并获取从源代码到最终的中间代码经历的几十次迭代,所有的数据都被存储到了 ssa.html 文件中:

  1. $ GOSSAFUNC=hello go build hello.go
  2. # command-line-arguments
  3. dumped SSA to ./ssa.html

这个文件中包含源代码对应的抽象语法树、几十个版本的中间代码以及最终生成的 SSA,在这里截取文件中的一部分为大家展示一下,让各位读者对这个文件中的内容有更具体的印象:

ssa-htm

图 2-15 SSA 中间代码生成过程

如上图所示,其中最左侧就是源代码,中间是源代码生成的抽象语法树,最右侧是生成的第一轮中间代码,后面还有几十轮,感兴趣的读者可以自己尝试编译一下。hello 函数对应的抽象语法树会包含当前函数的 EnterNBodyExit 三个属性,输出这些属性的工作是由下面的函数 buildssa 完成的,你能从这个简化的逻辑中看到上述输出的影子:

  1. func buildssa(fn *Node, worker int) *ssa.Func {
  2. name := fn.funcname()
  3. var astBuf *bytes.Buffer
  4. var s state
  5. fe := ssafn{
  6. curfn: fn,
  7. log: printssa && ssaDumpStdout,
  8. }
  9. s.curfn = fn
  10. s.f = ssa.NewFunc(&fe)
  11. s.config = ssaConfig
  12. s.f.Type = fn.Type
  13. s.f.Config = ssaConfig
  14. ...
  15. s.stmtList(fn.Func.Enter)
  16. s.stmtList(fn.Nbody)
  17. ssa.Compile(s.f)
  18. return s.f
  19. }

ssaConfig 就是我们在这里的第一小节初始化的结构体,其中包含了与 CPU 架构相关的函数和配置,随后的中间代码生成其实也分成两个阶段,第一个阶段是使用 stmtList 以及相关函数将抽象语法树转换成中间代码,第二个阶段会调用 src/cmd/compile/internal/ssa 包的 Compile 函数对 SSA 中间代码进行多轮的迭代和转换。

AST 到 SSA

stmtList 方法的主要功能就是为传入数组中的每一个节点调用 stmt 方法,在这个方法中编译器会根据节点操作符的不同将当前 AST 转换成对应的中间代码:

  1. func (s *state) stmt(n *Node) {
  2. ...
  3. switch n.Op {
  4. case OCALLMETH, OCALLINTER:
  5. s.call(n, callNormal)
  6. if n.Op == OCALLFUNC && n.Left.Op == ONAME && n.Left.Class() == PFUNC {
  7. if fn := n.Left.Sym.Name; compiling_runtime && fn == "throw" ||
  8. n.Left.Sym.Pkg == Runtimepkg && (fn == "throwinit" || fn == "gopanic" || fn == "panicwrap" || fn == "block" || fn == "panicmakeslicelen" || fn == "panicmakeslicecap") {
  9. m := s.mem()
  10. b := s.endBlock()
  11. b.Kind = ssa.BlockExit
  12. b.SetControl(m)
  13. }
  14. }
  15. case ODEFER:
  16. s.call(n.Left, callDefer)
  17. case OGO:
  18. s.call(n.Left, callGo)
  19. ...
  20. }
  21. }

从上面节选的代码中我们会发现,在遇到函数调用、方法调用、使用 defer 或者 go 关键字时都会执行 call 生成调用函数的 SSA 节点,这些在开发者看来不同的概念在编译器中都会被实现成静态的函数调用,上层的关键字和方法其实都是语言为我们提供的语法糖:

  1. func (s *state) call(n *Node, k callKind) *ssa.Value {
  2. ...
  3. var call *ssa.Value
  4. switch {
  5. case k == callDefer:
  6. call = s.newValue1A(ssa.OpStaticCall, types.TypeMem, deferproc, s.mem())
  7. case k == callGo:
  8. call = s.newValue1A(ssa.OpStaticCall, types.TypeMem, newproc, s.mem())
  9. case sym != nil:
  10. call = s.newValue1A(ssa.OpStaticCall, types.TypeMem, sym.Linksym(), s.mem())
  11. ..
  12. }
  13. ...
  14. }

首先,从 AST 到 SSA 的转化过程中,编译器会生成将函数调用的参数放到栈上的中间代码,处理参数之后才会生成一条运行函数的命令 ssa.OpStaticCall

  • 如果这里使用的是 defer 关键字,就会插入 deferproc 函数;
  • 如果使用 go 创建新的 Goroutine 时会插入 newproc 函数符号;
  • 在遇到其他情况时会插入表示普通函数对应的符号;src/cmd/compile/internal/gc/ssa.go 这个拥有将近 7000 行代码的文件包含用于处理不同节点的各种方法,编译器会根据节点类型的不同在一个巨型 switch 语句处理不同的情况,这也是我们在编译器这种独特的场景下才能看到的现象。
  1. compiling hello
  2. hello func(int) int
  3. b1:
  4. v1 = InitMem <mem>
  5. v2 = SP <uintptr>
  6. v3 = SB <uintptr> DEAD
  7. v4 = LocalAddr <*int> {a} v2 v1 DEAD
  8. v5 = LocalAddr <*int> {~r1} v2 v1
  9. v6 = Arg <int> {a}
  10. v7 = Const64 <int> [0] DEAD
  11. v8 = Const64 <int> [2]
  12. v9 = Add64 <int> v6 v8 (c[int])
  13. v10 = VarDef <mem> {~r1} v1
  14. v11 = Store <mem> {int} v5 v9 v10
  15. Ret v11

上述代码就是在这个过程生成的,你可以看到中间代码主体中的每一行其实都定义了一个新的变量,这也就是我们在前面提到的具有静态单赋值(SSA)特性的中间代码,如果你使用 GOSSAFUNC=hello go build hello.go 命令亲自尝试一下会对这种中间代码有更深的印象。

多轮转换

虽然我们在 stmt 以及相关方法中生成了 SSA 中间代码,但是这些中间代码仍然需要编译器进行优化以去掉无用代码并对操作数进行精简,编译器对中间代码的优化过程都是由 src/cmd/compile/internal/ssa 包的 Compile 函数执行的:

  1. func Compile(f *Func) {
  2. if f.Log() {
  3. f.Logf("compiling %s\n", f.Name)
  4. }
  5. phaseName := "init"
  6. for _, p := range passes {
  7. f.pass = &p
  8. p.fn(f)
  9. }
  10. phaseName = ""
  11. }

这是删除了很多打印日志和性能分析功能的 Compile 函数,SSA 需要经历的多轮处理也都保存在了 passes 变量中,这个变量中存储了每一轮处理的名字、使用的函数以及表示是否必要的 required 字段:

  1. var passes = [...]pass{
  2. {name: "number lines", fn: numberLines, required: true},
  3. {name: "early phielim", fn: phielim},
  4. {name: "early copyelim", fn: copyelim},
  5. ...
  6. {name: "loop rotate", fn: loopRotate},
  7. {name: "stackframe", fn: stackframe, required: true},
  8. {name: "trim", fn: trim},
  9. }

目前的编译器总共引入了将近 50 个需要执行的过程,我们能在 GOSSAFUNC=hello go build hello.go 命令生成的文件中看到每一轮处理后的中间代码,例如最后一个 trim 阶段就生成了如下的 SSA 代码:

  1. pass trim begin
  2. pass trim end [738 ns]
  3. hello func(int) int
  4. b1:
  5. v1 = InitMem <mem>
  6. v10 = VarDef <mem> {~r1} v1
  7. v2 = SP <uintptr> : SP
  8. v6 = Arg <int> {a} : a[int]
  9. v8 = LoadReg <int> v6 : AX
  10. v9 = ADDQconst <int> [2] v8 : AX (c[int])
  11. v11 = MOVQstore <mem> {~r1} v2 v9 v10
  12. Ret v11

经过将近 50 轮处理的中间代码相比处理之前已经有了非常大的改变,执行效率会有比较大的提升,多轮的处理已经包含了一些机器特定的修改,包括根据目标架构对代码进行改写,不过这里就不会展开介绍每一轮处理的具体内容了。

2.4.5 小结

中间代码的生成过程其实就是从 AST 抽象语法树到 SSA 中间代码的转换过程,在这期间会对语法树中的关键字在进行一次改写,改写后的语法树会经过多轮处理转变成最后的 SSA 中间代码,这里的代码大都是巨型的 switch 语句、复杂的函数以及调用栈,阅读和分析起来也非常困难。

很多 Go 语言中的关键字和内置函数都是在这个阶段被转换成运行时包中方法的,作者在后面的章节会从具体的语言关键字和内置函数的角度介绍一些数据结构和内置函数的实现。


wechat-account-qrcode

本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。