前兩節介紹的 詞法與語法分析 以及 類型檢查 兩個部分都屬於編譯器前端,它們負責對源代碼進行分析並檢查其中存在的詞法和語法錯誤,經過這兩個階段生成的抽象語法樹已經不存在任何的結構上的錯誤了,從這一節開始就進入了編譯器後端的工作 — 中間代碼生成 和 機器碼生成 了,這裏會介紹 Go 語言編譯的中間代碼生成階段。
中間代碼 是一種應用於抽象機器的編程語言,它設計的目的主要是幫助我們分析計算機程序,在編譯的過程中,編譯器會在將語言的源代碼轉換成目標機器上機器碼的過程中,先把源代碼轉換成一種中間的表述形式,這裏要介紹的就是 Go 語言如何將抽象語法樹轉換成 SSA 表示的中間代碼。
中間代碼生成
Go 語言編譯器的中間代碼具有靜態單賦值(SSA)的特性,我們在介紹 Go 語言編譯過程 中曾經介紹過靜態單賦值,對這個特性不瞭解的讀者可以回到上面的文章閲讀相應的部分,當然也可以自行搜索學習相關的知識,不過在這裏哪怕對 SSA 一無所知,也不會影響對這一節的理解。
我們再來回憶一下編譯階段入口的主函數中關於中間代碼生成的部分,在這一段代碼中會初始化 SSA 生成的配置,在配置初始化結束之後會調用 funccompile 對函數進行編譯:
func Main(archInit func(*Arch)) {
// ...
initssaconfig()
for i := 0; i len(xtop); i++ {
n := xtop[i]
if n.Op == ODCLFUNC {
funccompile(n)
}
}
compileFunctions()
}
這一節將分別介紹配置的初始化以及函數編譯兩部分內容,我們會以 initssaconfig 和 funccompile 這兩個函數作為入口來分析中間代碼生成的具體過程和實現原理。
配置初始化
我們從 initssaconfig 函數開始介紹配置初始化的過程,這個函數的執行過程總共可以被分成三個部分,首先是初始化一個新的 Types 結構體:
func initssaconfig() {
types_ := ssa.NewTypes()
_ = types.NewPtr(types.Types[TINTER])
_ = types.NewPtr(types.NewPtr(types.Types[TSTRING]))
_ = types.NewPtr(types.NewPtr(types.Idealstring))
_ = types.NewPtr(types.NewSlice(types.Types[TINTER]))
_ = types.NewPtr(types.NewPtr(types.Bytetype))
_ = types.NewPtr(types.NewSlice(types.Bytetype))
// ...
_ = types.NewPtr(types.Errortype)
當前結構體中存儲了指向所有 Go 語言中基本類型的指針,比如 Bool、Int8、以及 String 等,除了生成這些類型之外還會使用 NewPtr 為其中的一些類型生成指向這些類型的指針:
golang-type-and-pointe
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)
該函數會根據傳入的 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[:]
c.gpRegMask = gpRegMaskAMD64
c.fpRegMask = fpRegMaskAMD64
c.FPReg = framepointerRegAMD64
c.LinkReg = linkRegAMD64
c.hasGReg = false
case "amd64p32":
case "386":
case "arm":
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")
Deferreturn = sysfunc("deferreturn")
Duffcopy = sysvar("duffcopy")
Duffzero = sysvar("duffzero")
// ...
這些方法會在對應的 runtime 包結構體 Pkg 中創建一個新的符號 obj.LSym,表示上述的方法已經被註冊到運行時 runtime 包中,我們在後面的中間代碼生成中直接使用這些方法。
遍歷和替換
在生成中間代碼之前,我們還需要對抽象語法樹中節點的一些元素進行替換,這個替換的過程就是通過 walk 和很多以 walk 開頭的相關函數實現的,簡單展示幾個相關函數的簽名:
func walk(fn *Node)func walkappend(n *Node, init *Nodes, dst *Node) *Nodefunc walkAppendArgs(n *Node, init *Nodes)func walkclosure(clo *Node, init *Nodes) *Nodefunc walkCall(n *Node, init *Nodes)func walkcompare(n *Node, init *Nodes) *Nodefunc walkcompareInterface(n *Node, init *Nodes) *Nodefunc walkcompareString(n *Node, init *Nodes) *Nodefunc walkexpr(n *Node, init *Nodes) *Nodefunc walkexprlist(s []*Node, init *Nodes)func walkexprlistcheap(s []*Node, init *Nodes)func walkexprlistsafe(s []*Node, init *Nodes)func walkprint(nn *Node, init *Nodes) *Nodefunc walkinrange(n *Node, init *Nodes) *Nodefunc walkpartialcall(n *Node, init *Nodes) *Nodefunc walkrange(n *Node) *Nodefunc walkselect(sel *Node)func walkselectcases(cases *Nodes) []*Nodefunc walkstmt(n *Node) *Nodefunc walkstmtlist(s []*Node)func walkswitch(sw *Node)
這些函數會將一些關鍵字和內建函數轉換成真正的函數調用,panic、recover 這兩個內建函數就會被在上述方法中被轉換成 gopanic 和 gorecover 兩個真正存在的函數。
golang-keyword-and-builtin-mapping
上面是從關鍵字或內建函數到其他實際存在函數的映射,包括管道、哈希相關的操作、用於創建結構體對象的 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)
// ...
上面的代碼只是讓編譯器能夠找到對應符號的函數定義而已,真正的函數實現都在另一個 runtime 包中,Go 語言的主程序在執行時會調用 runtime 中的函數,也就是説關鍵字和內置函數的功能其實是由語言的編譯器和運行時共同完成的。
Channel
接下來,我們可以簡單瞭解一下幾個管道操作在遍歷節點時是如何轉換成運行時對應方法的,首先介紹向管道中發送消息或者從管道中接受消息,在編譯器中會分別使用 OSEND 和 ORECV 表示這兩個不同的操作:
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 節點修改當前的抽象語法樹。
在中間代碼生成的階段遇到 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 幾個函數就能理解管道的發送、接受和關閉的實現了。
編譯
經過 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.go
package hello
func hello(a int) int {
c := a + 2
return c
}
我們可以使用如下的命令來獲取上述代碼在生成最後中間代碼期間經歷的 N 個版本的 SSA 中間代碼以及最後的彙編代碼:
$ GOSSAFUNC=hello go build hello.go
generating SSA for hello
buildssa-enter
. AS l(3)
. . NAME-hello.~r1 a(true) g(1) l(3) x(8) class(PPARAMOUT) int
buildssa-body
. DCL l(4)
. . NAME-hello.c a(true) g(3) l(4) x(0) class(PAUTO) tc(1) used int
. AS l(4) colas(true) tc(1)
. . NAME-hello.c a(true) g(3) l(4) x(0) class(PAUTO) tc(1) used int
. . ADD l(4) tc(1) int
. . . NAME-hello.a a(true) g(2) l(3) x(0) class(PPARAM) tc(1) used int
. . . LITERAL-2 l(4) tc(1) int
. RETURN l(5) tc(1)
. RETURN-list
. . AS l(5) tc(1)
. . . NAME-hello.~r1 a(true) g(1) l(3) x(8) class(PPARAMOUT) int
. . . NAME-hello.c a(true) g(3) l(4) x(0) class(PAUTO) tc(1) used int
buildssa-exit
// ...
這個命令會首先打印出 hello 函數對應的抽象語法樹,它會分別輸出當前函數的 Enter、NBody 和 Exit 三個屬性,打印這些屬性的工作其實就由下面的函數完成的,因為函數太複雜所以在這裏我們已經省略了:
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 以及相關函數將 AST 表示的中間代碼轉換成基於 SSA 的中間代碼,第二個階段會調用 ssa 包的 Compile 函數對 SSA 中間代碼進行多輪的轉換。
AST 到 SSA
stmtList 方法的主要功能就是為傳入數組中的每一個節點調用 stmt 方法,在這個方法中編譯器會根據節點操作符的不同將當前 AST 轉換成 SSA 中間代碼:
func (s *state) stmt(n *Node) {
// ...
switch n.Op {
case OCALLFUNC:
if isIntrinsicCall(n) {
s.intrinsicCall(n)
return
}
fallthrough
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 sym *types.Sym
fn := n.Left
switch n.Op {
case OCALLFUNC:
sym = fn.Sym
case OCALLMETH:
// ...
case OCALLINTER:
// ...
}
dowidth(fn.Type)
stksize := fn.Type.ArgWidth()
s.stmtList(n.List)
t := n.Left.Type
args := n.Rlist.Slice()
for i, n := range args {
f := t.Params().Field(i)
s.storeArg(n, f.Type, argStart+f.Offset)
}
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())
// ...
}
call.AuxInt = stksize
s.vars[&memVar] = call
res := n.Left.Type.Results()
fp := res.Field(0)
return s.constOffPtrSP(types.NewPtr(fp.Type), fp.Offset+Ctxt.FixedFrameSize())
}
首先,從 AST 到 SSA 的轉化過程中,編譯器會生成將函數調用的參數放到棧上的中間代碼,處理參數之後才會生成一條運行函數的命令 ssa.OpStaticCall;如果這裏使用的是 defer 關鍵字,就會插入 deferproc 函數,使用 go 創建新的 Goroutine 時會插入 newproc 函數符號,在遇到其他情況時會插入表示普通函數對應的符號。
在上述方法中生成的 SSA 中間代碼其實就是如下的形式:
compiling hello
hello func(int) intb1:v1 = InitMem <mem>v2 = SP <uintptr>v3 = SB <uintptr> DEADv4 = 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 {~r1} v1
v11 = Store {int} v5 v9 v10
Ret v11
這裏的 SSA 中間代碼其實就是使用 GOSSAFUNC=hello go build hello.go 命令生成的,也是將 AST 轉換成 SSA 的過程。
多輪轉換
雖然我們在 stmt 以及相關方法中生成了 SSA 中間代碼,但是這些中間代碼卻仍然需要編譯器進行優化以去掉無用代碼並對操作數進行精簡,也就是上述過程返回的中間代碼需要經過 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) intb1: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 {~r1} v2 v9 v10
Ret v11
經過將近 50 輪處理的 SSA 中間代碼相比處理之前已經有了非常大的改變,執行效率和過程也會有比較大的提升,多輪的處理已經包含了一些機器特定的修改,包括根據目標架構對代碼進行改寫,不過這裏就不會展開介紹每一輪處理的具體內容了。
總結
中間代碼的生成過程其實就是從 AST 抽象語法樹到 SSA 中間代碼的轉換過程,在這期間會對語法樹中的關鍵字在進行一次更新,更新後的語法樹會經過多輪處理轉變成最後的 SSA 中間代碼,這裏的代碼大都是巨長的 switch 語句和複雜的函數以及調用棧,分析和閲讀起來也非常困難。
很多 Go 語言中的關鍵字和內置函數都是在這個階段被轉換成運行時包中方法的,作者在後面的章節會從具體的語言關鍵字和內置函數的角度介紹一些數據結構和函數的實現。
Reference
- Chapter II: Interfaces
- Introduction to the Go compiler
- 中間代碼