Go语言SSA包解读
1.背景
中间代码是指一种应用于抽象机器的编程语言,它设计的目的,是用来帮助我们分析计算机程序。在编译的过程中,编译器会在将源代码转换成目标机器上机器码的过程中,先把源代码转换成一种中间的表述形式。
Go语言中提供了SSA包以将源代码转换成静态单赋值形式的中间代码,本文就是对于SSA包源码的解读。本文主要参考了https://draveness.me/golang/docs/。
2.源码解读
编译阶段入口的主函数Main中关于中间代码生成的源码如下:
func Main(archInit func(*Arch)) {
// ...
initssaconfig()
for i := 0; i < len(xtop); i++ {
n := xtop[i]
if n.Op == ODCLFUNC {
funccompile(n)
}
}
compileFunctions()
}
可以看到在进入Main函数后,有两个主要函数,initssaconfig()对SSA生成进行初始化的配置。之后会调用funccompile()对函数进行编译。下面分贝对这两个方法的源码进行解读以得到中间代码生成的过程。
2.1配置初始化
这部分主要是SSA中间码生成之前的准备工作,在这个过程中我们会缓存可能用到的类型指针、初始化 SSA 配置和一些之后会调用的运行时函数,例如:用于处理 defer
关键字的 deferproc
、用于创建 Goroutine 的 newproc
和扩容切片的 growslice
等,除此之外还会根据当前的目标设备初始化特定的 ABI2。我们以 initssaconfig
作为入口开始分析配置初始化的过程。
func initssaconfig() {
types_ := ssa.NewTypes()
if thearch.SoftFloat {
softfloatInit()
}
// Generate a few pointer types that are uncommon in the frontend but common in the backend.
// Caching is disabled in the backend, so generating these here avoids allocations.
_ = types.NewPtr(types.Types[TINTER]) // *interface{}
_ = types.NewPtr(types.NewPtr(types.Types[TSTRING])) // **string
_ = types.NewPtr(types.NewPtr(types.Idealstring)) // **string
_ = types.NewPtr(types.NewSlice(types.Types[TINTER])) // *[]interface{}
_ = types.NewPtr(types.NewPtr(types.Bytetype)) // **byte
_ = types.NewPtr(types.NewSlice(types.Bytetype)) // *[]byte
_ = types.NewPtr(types.NewSlice(types.Types[TSTRING])) // *[]string
_ = types.NewPtr(types.NewSlice(types.Idealstring)) // *[]string
_ = types.NewPtr(types.NewPtr(types.NewPtr(types.Types[TUINT8]))) // ***uint8
_ = types.NewPtr(types.Types[TINT16]) // *int16
_ = types.NewPtr(types.Types[TINT64]) // *int64
_ = types.NewPtr(types.Errortype) // *error
initssaconfig函数的源码分为三部分,第一部分调用NewTypes初始化一个新的Types结构体并调用NewPtr缓存类型的信息,Types结构体中存储了所有Go语言中基本类型对应的指针,如上面代码所示。
NewPtr
函数的主要作用就是根据类型生成指向这些类型的指针,同时它会根据编译器的配置将生成的指针类型缓存在当前类型中,优化类型指针的获取效率:
func NewPtr(elem *Type) *Type {
if t := elem.Cache.ptr; t != nil {
if t.Elem() != elem {
Fatalf("NewPtr: elem mismatch")
}
return t
}
t := New(TPTR)
t.Extra = Ptr{Elem: elem}
t.Width = int64(Widthptr)
t.Align = uint8(Widthptr)
if NewPtrCacheEnabled {
elem.Cache.ptr = t
}
return t
}
配置初始化的第二步就是根据当前的 CPU 架构初始化 SSA 配置 ssaConfig
,我们会向 NewConfig
函数传入目标机器的 CPU 架构、上述代码初始化的 Types
结构体、上下文信息和 Debug 配置:
ssaConfig = ssa.NewConfig(thearch.LinkArch.Name, *types_, Ctxt, Debug['N'] == 0)
NewConfig
会根据传入的 CPU 架构设置用于生成中间代码和机器码的函数,当前编译器使用的指针、寄存器大小、可用寄存器列表、掩码等编译选项:
func NewConfig(arch string, types Types, ctxt *obj.Link, optimize bool) *Config {
c := &Config{arch: arch, Types: types}
c.useAvg = true
c.useHmul = true
switch arch {
case "amd64":
c.PtrSize = 8
c.RegSize = 8
c.lowerBlock = rewriteBlockAMD64
c.lowerValue = rewriteValueAMD64
c.registers = registersAMD64[:]
...
case "arm64":
...
case "wasm":
default:
ctxt.Diag("arch %s not implemented", arch)
}
c.ctxt = ctxt
c.optimize = optimize
// ...
return c
}
所有的配置项一旦被创建,在整个编译期间都是只读的并且被全部编译阶段共享,也就是中间代码生成和机器码生成这两部分都会使用这一份配置完成自己的工作。在 initssaconfig
方法调用的最后,会初始化一些编译器会用到的 Go 语言运行时的函数:
assertE2I = sysfunc("assertE2I")
assertE2I2 = sysfunc("assertE2I2")
assertI2I = sysfunc("assertI2I")
assertI2I2 = sysfunc("assertI2I2")
deferproc = sysfunc("deferproc") #defer关键字运行时函数
Deferreturn = sysfunc("deferreturn")
这些函数会在对应的 runtime 包结构体 Pkg
中创建一个新的符号 obj.LSym
,表示上述的方法已经被注册到运行时 runtime 包中,我们在后面的中间代码生成中直接使用这些方法,我们在这里看到的 deferproc
和 deferreturn
就是 Go 语言用于实现 defer
关键字的运行时函数。
2.2遍历和替换
SSA是由抽象语法树(AST)转化而来的,在转化之前需要对AST中节点的一些元素进行替换,这个替换过程通过walk和很多以walk开头的相关函数实现的,举例如下:
func walk(fn *Node)
func walkappend(n *Node, init *Nodes, dst *Node) *Node
...
func walkrange(n *Node) *Node
func walkselect(sel *Node)
func walkselectcases(cases *Nodes) []*Node
func walkstmt(n *Node) *Node
func walkstmtlist(s []*Node)
func walkswitch(sw *Node)
这些用于遍历抽象语法树的函数会将一些关键字和内建函数转换成函数调用,例如: panic
、recover
这两个内建函数就会被在 walkXXX
中被转换成 gopanic
和 gorecover
两个真正存在的函数,而关键字 new
也会在这里被转换成对 newobject
函数的调用。
图1 关键字和操作符运行时函数的映射
图1是从关键字或内建函数到其他实际存在的运行时函数的映射,包括Channel,hash相关操作,用于创建结构体对象的make,new关键字以及一些控制流中的关键字select等。转换后的全部函数都属于运行时runtime包,我们能在 src/cmd/compile/internal/gc/builtin/runtime.go
文件中找到函数对应的签名和定义。
func makemap64(mapType *byte, hint int64, mapbuf *any) (hmap map[any]any)
func makemap(mapType *byte, hint int, mapbuf *any) (hmap map[any]any)
func makemap_small() (hmap map[any]any)
func mapaccess1(mapType *byte, hmap map[any]any, key *any) (val *any)
...
func makechan64(chanType *byte, size int64) (hchan chan any)
func makechan(chanType *byte, size int) (hchan chan any)
...
src/cmd/compile/internal/gc/builtin/runtime.go
文件中代码的作用只是让编译器能够找到对应符号的函数定义而已,真正的函数实现都在另一个 src/runtime
包中。简单总结一下,编译器会将 Go 语言关键字转换成 runtime
包中的函数,也就是说关键字和内置函数的功能是由语言的编译器和运行时共同完成的。
我们简单了解一下遍历节点时几个 Channel 操作是如何转换成运行时对应方法的,首先介绍向 Channel 中发送消息或者从 Channel 中接受消息两个操作,编译器会分别使用 OSEND
和 ORECV
表示发送和接收消息两个操作,在 walkexpr
函数中会根据节点类型的不同进入不同的分支:
func walkexpr(n *Node, init *Nodes) *Node {
...
case OSEND:
n1 := n.Right
n1 = assignconv(n1, n.Left.Type.Elem(), "chan send")
n1 = walkexpr(n1, init)
n1 = nod(OADDR, n1, nil)
n = mkcall1(chanfn("chansend1", 2, n.Left.Type), nil, init, n.Left, n1)
...
}
当遇到 OSEND
操作时,会使用 mkcall1
创建一个操作为 OCALL
的节点,这个节点中包含当前调用的函数 chansend1
和几个参数,新的 OCALL
节点会替换当前的 OSEND
节点,这也就完成了对 OSEND
子树的改写。
图 2 改写后的 Channel 发送操作
在中间代码生成的阶段遇到 ORECV
操作时,编译器的处理与遇到 OSEND
时相差无几,我们也只是将 chansend1
换成了 chanrecv1
,其他的参数没有发生太大的变化:
n = mkcall1(chanfn("chanrecv1", 2, n.Left.Type), nil, &init, n.Left, nodnil())
使用 close
关键字的 OCLOSE
操作也会在 walkexpr
函数中被转换成调用 closechan
的 OCALL
节点:
func walkexpr(n *Node, init *Nodes) *Node {
...
case OCLOSE:
fn := syslook("closechan")
fn = substArgTypes(fn, n.Left.Type)
n = mkcall1(fn, nil, init, n.Left)
...
}
对于 Channel 的这些内置操作都会在编译期间就转换成几个运行时执行的函数,很多人都想要了解 Channel 底层的实现,但是并不知道函数的入口,经过这里的分析我们就知道chanrecv1
、chansend1
和 closechan
几个函数分别实现了 Channel 的发送、接受和关闭操作。
2.3 SSA 生成
经过 walk
系列函数的处理之后,AST 的抽象语法树就不再会改变了,Go 语言的编译器会使用 compileSSA
函数将抽象语法树转换成中间代码,我们可以先看一下该函数的简要实现:
func compileSSA(fn *Node, worker int) {
f := buildssa(fn, worker)
pp := newProgs(fn, worker)
genssa(f, pp)
pp.Flush()
}
buildssa
就是用来生成具有 SSA 特性的中间代码的函数,我们可以使用命令行工具来观察当前中间代码的生成过程,假设我们有以下的 Go 语言源代码,其中只包含一个非常简单的 hello
函数:
package hello
func hello(a int) int {
c := a + 2
return c
}
我们可以使用 GOSSAFUNC
环境变量构建上述代码并获取从源代码到最终的中间代码经历的几十次迭代,所有的数据都被存储到了 ssa.html
文件中:
$ GOSSAFUNC=hello go build hello.go
# command-line-arguments
dumped SSA to ./ssa.html
这个文件中包含源代码对应的抽象语法树、几十个版本的中间代码以及最终生成的 SSA,在这里截取文件中的一部分为大家展示一下,让各位读者对这个文件中的内容有更具体的印象:
如上图所示,其中最左侧就是源代码,中间是源代码生成的抽象语法树,最右侧是生成的第一轮中间代码,后面还有几十轮,感兴趣的读者可以自己尝试编译一下。hello
函数对应的抽象语法树会包含当前函数的 Enter
、NBody
和 Exit
三个属性,输出这些属性的工作是由下面的函数 buildssa
完成的,你能从这个简化的逻辑中看到上述输出的影子:
func buildssa(fn *Node, worker int) *ssa.Func {
name := fn.funcname()
var astBuf *bytes.Buffer
var s state
fe := ssafn{
curfn: fn,
log: printssa && ssaDumpStdout,
}
s.curfn = fn
s.f = ssa.NewFunc(&fe)
s.config = ssaConfig
s.f.Type = fn.Type
s.f.Config = ssaConfig
...
s.stmtList(fn.Func.Enter)
s.stmtList(fn.Nbody)
ssa.Compile(s.f)
return s.f
}
ssaConfig
就是我们在这里的第一小节初始化的结构体,其中包含了与 CPU 架构相关的函数和配置,随后的中间代码生成其实也分成两个阶段,第一个阶段是使用 stmtList
以及相关函数将抽象语法树转换成中间代码,第二个阶段会调用 src/cmd/compile/internal/ssa
包的 Compile
函数对 SSA 中间代码进行多轮的迭代和转换。
AST 到 SSA
stmtList
方法的主要功能就是为传入数组中的每一个节点调用 stmt
方法,在这个方法中编译器会根据节点操作符的不同将当前 AST 转换成对应的中间代码:
func (s *state) stmt(n *Node) {
...
switch n.Op {
case OCALLMETH, OCALLINTER:
s.call(n, callNormal)
if n.Op == OCALLFUNC && n.Left.Op == ONAME && n.Left.Class() == PFUNC {
if fn := n.Left.Sym.Name; compiling_runtime && fn == "throw" ||
n.Left.Sym.Pkg == Runtimepkg && (fn == "throwinit" || fn == "gopanic" || fn == "panicwrap" || fn == "block" || fn == "panicmakeslicelen" || fn == "panicmakeslicecap") {
m := s.mem()
b := s.endBlock()
b.Kind = ssa.BlockExit
b.SetControl(m)
}
}
case ODEFER:
s.call(n.Left, callDefer)
case OGO:
s.call(n.Left, callGo)
...
}
}
从上面节选的代码中我们会发现,在遇到函数调用、方法调用、使用 defer 或者 go 关键字时都会执行 call
生成调用函数的 SSA 节点,这些在开发者看来不同的概念在编译器中都会被实现成静态的函数调用,上层的关键字和方法其实都是语言为我们提供的语法糖:
func (s *state) call(n *Node, k callKind) *ssa.Value {
...
var call *ssa.Value
switch {
case k == callDefer:
call = s.newValue1A(ssa.OpStaticCall, types.TypeMem, deferproc, s.mem())
case k == callGo:
call = s.newValue1A(ssa.OpStaticCall, types.TypeMem, newproc, s.mem())
case sym != nil:
call = s.newValue1A(ssa.OpStaticCall, types.TypeMem, sym.Linksym(), s.mem())
..
}
...
}
首先,从 AST 到 SSA 的转化过程中,编译器会生成将函数调用的参数放到栈上的中间代码,处理参数之后才会生成一条运行函数的命令 ssa.OpStaticCall
:
- 如果这里使用的是 defer 关键字,就会插入
deferproc
函数; - 如果使用 go 创建新的 Goroutine 时会插入
newproc
函数符号; - 在遇到其他情况时会插入表示普通函数对应的符号;
src/cmd/compile/internal/gc/ssa.go
这个拥有将近 7000 行代码的文件包含用于处理不同节点的各种方法,编译器会根据节点类型的不同在一个巨型 switch 语句处理不同的情况,这也是我们在编译器这种独特的场景下才能看到的现象。
compiling hello
hello func(int) int
b1:
v1 = InitMem <mem>
v2 = SP <uintptr>
v3 = SB <uintptr> DEAD
v4 = LocalAddr <*int> {a} v2 v1 DEAD
v5 = LocalAddr <*int> {~r1} v2 v1
v6 = Arg <int> {a}
v7 = Const64 <int> [0] DEAD
v8 = Const64 <int> [2]
v9 = Add64 <int> v6 v8 (c[int])
v10 = VarDef <mem> {~r1} v1
v11 = Store <mem> {int} v5 v9 v10
Ret v11
上述代码就是在这个过程生成的,你可以看到中间代码主体中的每一行其实都定义了一个新的变量,这也就是我们在前面提到的具有静态单赋值(SSA)特性的中间代码,如果你使用 GOSSAFUNC=hello go build hello.go
命令亲自尝试一下会对这种中间代码有更深的印象。
多轮转换
虽然我们在 stmt
以及相关方法中生成了 SSA 中间代码,但是这些中间代码仍然需要编译器进行优化以去掉无用代码并对操作数进行精简,编译器对中间代码的优化过程都是由 src/cmd/compile/internal/ssa
包的 Compile
函数执行的:
func Compile(f *Func) {
if f.Log() {
f.Logf("compiling %s\n", f.Name)
}
phaseName := "init"
for _, p := range passes {
f.pass = &p
p.fn(f)
}
phaseName = ""
}
这是删除了很多打印日志和性能分析功能的 Compile
函数,SSA 需要经历的多轮处理也都保存在了 passes
变量中,这个变量中存储了每一轮处理的名字、使用的函数以及表示是否必要的 required
字段:
var passes = [...]pass{
{name: "number lines", fn: numberLines, required: true},
{name: "early phielim", fn: phielim},
{name: "early copyelim", fn: copyelim},
...
{name: "loop rotate", fn: loopRotate},
{name: "stackframe", fn: stackframe, required: true},
{name: "trim", fn: trim},
}
目前的编译器总共引入了将近 50 个需要执行的过程,我们能在 GOSSAFUNC=hello go build hello.go
命令生成的文件中看到每一轮处理后的中间代码,例如最后一个 trim
阶段就生成了如下的 SSA 代码:
pass trim begin
pass trim end [738 ns]
hello func(int) int
b1:
v1 = InitMem <mem>
v10 = VarDef <mem> {~r1} v1
v2 = SP <uintptr> : SP
v6 = Arg <int> {a} : a[int]
v8 = LoadReg <int> v6 : AX
v9 = ADDQconst <int> [2] v8 : AX (c[int])
v11 = MOVQstore <mem> {~r1} v2 v9 v10
Ret v11
经过将近 50 轮处理的中间代码相比处理之前已经有了非常大的改变,执行效率会有比较大的提升,多轮的处理已经包含了一些机器特定的修改,包括根据目标架构对代码进行改写,不过这里就不会展开介绍每一轮处理的具体内容了。
3. 小结
中间代码的生成过程其实就是从 AST 抽象语法树到 SSA 中间代码的转换过程,在这期间会对语法树中的关键字在进行一次改写,改写后的语法树会经过多轮处理转变成最后的 SSA 中间代码,这里的代码大都是巨型的 switch 语句、复杂的函数以及调用栈,阅读和分析起来也非常困难。
很多 Go 语言中的关键字和内置函数都是在这个阶段被转换成运行时包中方法的,作者在后面的章节会从具体的语言关键字和内置函数的角度介绍一些数据结构和内置函数的实现。