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 包中,我們在后面的中間代碼生成中直接使用這些方法,我們在這里看到的 deferprocdeferreturn 就是 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)

這些用於遍歷抽象語法樹的函數會將一些關鍵字和內建函數轉換成函數調用,例如: panicrecover 這兩個內建函數就會被在 walkXXX 中被轉換成 gopanicgorecover 兩個真正存在的函數,而關鍵字 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 中接受消息兩個操作,編譯器會分別使用 OSENDORECV 表示發送和接收消息兩個操作,在 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 子樹的改寫。

golang-ocall-node

圖 2 改寫后的 Channel 發送操作

在中間代碼生成的階段遇到 ORECV 操作時,編譯器的處理與遇到 OSEND 時相差無幾,我們也只是將 chansend1 換成了 chanrecv1,其他的參數沒有發生太大的變化:

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

使用 close 關鍵字的 OCLOSE 操作也會在 walkexpr 函數中被轉換成調用 closechanOCALL 節點:

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 底層的實現,但是並不知道函數的入口,經過這里的分析我們就知道chanrecv1chansend1closechan 幾個函數分別實現了 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,在這里截取文件中的一部分為大家展示一下,讓各位讀者對這個文件中的內容有更具體的印象:

ssa-htm

圖 3 SSA 中間代碼生成過程

如上圖所示,其中最左側就是源代碼,中間是源代碼生成的抽象語法樹,最右側是生成的第一輪中間代碼,后面還有幾十輪,感興趣的讀者可以自己嘗試編譯一下。hello 函數對應的抽象語法樹會包含當前函數的 EnterNBodyExit 三個屬性,輸出這些屬性的工作是由下面的函數 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

  1. 如果這里使用的是 defer 關鍵字,就會插入 deferproc 函數;
  2. 如果使用 go 創建新的 Goroutine 時會插入 newproc 函數符號;
  3. 在遇到其他情況時會插入表示普通函數對應的符號;

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 語言中的關鍵字和內置函數都是在這個階段被轉換成運行時包中方法的,作者在后面的章節會從具體的語言關鍵字和內置函數的角度介紹一些數據結構和內置函數的實現。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM