從.Go文本文件到可執(zhí)行文件
Go 是一門編譯型語言,我們平時所編寫的 *.go 文本文件稱為源文件,源文件里面的內(nèi)容就是我們的源代碼。
源代碼要想在目標機器上運行,就必須使用 Go compiler (縮寫 gc ,指代 Go 編譯器)將其先編譯成操作系統(tǒng)能夠直接識別的二進制機器碼文件,或說可執(zhí)行文件。后續(xù)由操作系統(tǒng)加載該文件,并在 CPU 中直接運行機器碼。這也是編譯型語言運行效率高的主要原因。
Go compiler 是用什么實現(xiàn)的
編譯器本身也是一個程序,它的作用就是把一個以某種語言(源語言)編寫的程序 翻譯 成等價的另一個語言(目標語言)編寫的程序。
而編譯器這個程序本身的編寫與編程語言是沒有關(guān)系的,任何一種圖靈完備的語言都可以編寫任何一種形式語言的編譯器。
最開始的 Go compiler (Go 1.4 以及之前)是由 C 和匯編共同編寫的,等到 2015 年時 Google 開始公布實現(xiàn) Go 1.5 自舉的計劃[1]。
首先使用 Go 語言編寫一個和之前用 C 語言編寫的 Go compiler 一樣功能的程序出來,再用之前用 C 語言實現(xiàn)好的 Go compiler 來編譯這個新寫的程序,這樣就得到一個用 Go 語言實現(xiàn)的 Go compiler 。后續(xù)的 Go 程序就都可以直接使用這個新的用 Go 語言實現(xiàn)的 Go compiler 來編譯了。
這種用源語言自身來實現(xiàn)源語言的編譯器的做法就叫自舉。所以在 Go 1.5 及之后的 Go compiler 就是用 Go 語言自身實現(xiàn)的了。
一個編譯器的結(jié)構(gòu)
如果我們把編譯器這個黑盒稍微打開,根據(jù)完成的任務不同,可以將編譯器的組成部分劃分為前端(Front End)與后端(Back End)。
編譯前端負責分析(analysis)部分,把源程序分解為多個組成要素,并在這些要素之上加上語法結(jié)構(gòu),然后利用這個結(jié)構(gòu)創(chuàng)建出源程序的中間表示形式,最后還將源程序的信息存放在一個稱為符號表的數(shù)據(jù)結(jié)構(gòu)中并與中間表示形式一起傳送給綜合部分。
可見,如果我們編寫了一段語法錯誤的代碼,在前端就會被攔截下并給予提示了。這和我們 Web 開發(fā)中的參數(shù)校驗部分也有一點相似之處。
前端工作結(jié)束,則來到編譯后端,后端則負責綜合(synthesis)部分,根據(jù)前端傳送過來的中間表示和符號表中的信息來構(gòu)造用戶期待的目標程序。
在編譯前端和后端之間,往往還存在著多個可選的、與機器無關(guān)的優(yōu)化步驟,負責將中間表示形式進一步優(yōu)化轉(zhuǎn)換,以便后續(xù)后端可以生成出更好的目標程序。
編譯器的結(jié)構(gòu)
Go compiler 的多階段流程
以當前最新的穩(wěn)定版本 Go 1.18.4 為例,Go compiler 的源碼位置在:https://github.com/golang/go/tree/go1.18.4/src/cmd/compile
后續(xù)的所有代碼示例和源碼引用也是基于此版本。
我們將編譯器的結(jié)構(gòu)對應到 Go compiler 上,也可以將 Go compiler 的各階段流程劃分為編譯前端和編譯后端(優(yōu)化器部分也放在了編譯后端)。
其中 Go compiler 的前端包括:詞法分析、語法分析、類型檢查。
后端包括:中間代碼生成、代碼優(yōu)化、Walk 遍歷和替換、通用 SSA 生成、機器碼生成。
Go compiler 的多階段流程
詞法分析
編譯器的第一個步驟稱為詞法分析(lexical analysis)或掃描(scanning)。對應的源碼位置在 cmd/compile/internal/syntax/scanner.go 。其作用便是把我們的源代碼“翻譯”為詞法單元 token 。
token 在 go/token/token.go 中被定義為了一種枚舉值,實質(zhì)就是用 iota 聲明的整數(shù),好處便是在后續(xù)的操作中可以被更加高效地處理。
所有的 token 主要被分為四類:特殊類型、基礎類型、運算符和關(guān)鍵字。
若存在詞法錯誤,將統(tǒng)一返回 ILLEGAL ,簡化詞法分析時的錯誤處理。
其中以 _beg 和 _end 為后綴的私有常量,用來表示 token 的值域范圍。
最后所有的 token 都會被放進一個 var tokens = [...]string 數(shù)組中,做了一個下標(token 值)和 token 面值的映射。
值得一提的是,詞法分析除了在編譯器中使用,在 go 標準庫 go/scanner 中也提供了出來,我們可以用來測試看看一段源代碼翻譯成 token 后的樣子。
翻譯成 token 后的輸出結(jié)果如下:
有個小細節(jié)的地方:當遇到 \n 換行符時,會翻譯成一個 ; 分號,這也是 Go 語言為什么不需要 ; 結(jié)尾。
語法分析
編譯器的第二個步驟稱為語法分析(syntax analysis)或解析(parsing)。語法分析將把第一步驟的 token 轉(zhuǎn)化成使用 AST 抽象語法樹(abstract syntax tree)來表示的程序語法結(jié)構(gòu)。
語法分析的源碼位于 cmd/compile/internal/syntax/parser.go 。我們同樣可以借助標準庫 go/parser 來進行測試。
經(jīng)過語法分析構(gòu)建出來的每個語法樹都是相應源文件的精確表示,其節(jié)點對應于源文件的各種元素,例如表達式、聲明和語句。并且語法樹還會包括位置信息,用于錯誤報告和創(chuàng)建調(diào)試信息。
到目前階段為止,都還只是對源代碼進行字符串層面的處理。從源代碼到 token 再到 AST 。
類型檢查
在編譯原理中,完成 AST 的構(gòu)建,就會來到語義分析(semantic analyzer)階段,主要包括類型檢查(type checking)和自動類型轉(zhuǎn)換(coercion)。
而在 Go compiler 中,這一階段被直接合稱為類型檢查,其源碼定義在 cmd/compile/internal/types2 。
在此階段,類型檢查會遍歷 AST 的節(jié)點,對每個節(jié)點的類型進行檢查,比如檢查每個運算符是否具有匹配的運算分量,數(shù)組的下標是否正整數(shù)等等。另外類型檢查階段也會進行類型推導,例如使用簡短變量聲明 i := 1 ,會自動推導出變量 i 的類型是 int。
總之,對類型系統(tǒng)的處理都是類型檢查階段完成的。
類型檢查的總體流程可以查看 cmd/compile/internal/types2/check.go :
中間代碼生成
一旦類型檢查階段完成,意味著編譯前端工作完成,到這里代碼已經(jīng)沒有語法錯誤的問題了。按理是可以直接翻譯成機器碼了,但是在此之前,還需要先翻譯成介于源代碼和目標機器碼中間的中間代碼(IR, Intermediate Representation)。
使用中間代碼來銜接前、后端的好處很大。因為中間代碼不會包含與任何源代碼相關(guān)的信息,也不會包含與特定目標機器相關(guān)的信息,我們可以基于中間代碼來進行一些和機器無關(guān)的優(yōu)化工作。
另外,有了中間代碼,后端編譯還可以得到復用,比如我現(xiàn)在想要創(chuàng)建一門新的語言,只需要編寫編譯器前端,構(gòu)造出相同的中間代碼,編譯器后端就可以直接使用現(xiàn)成的了,不必重復構(gòu)建。
其實這種做法就是平時我們程序設計常提到的解耦。
回到中間代碼生成本身,在這一階段中,會基于前面構(gòu)造出的 AST 再生成一顆 IR Tree 。
因為我們是基于 Go 1.18.4 來分析,Go Team 在此已經(jīng)增加了許多新的語言特性(包括泛型),以往的很多模塊也被重新重構(gòu),代碼結(jié)構(gòu)更加清晰。但是難免還會關(guān)聯(lián)到之前一些舊版本的包(在之前 IR Tree 的生成與類型檢查是同時完成的)。
在 IR 生成階段,主要會涉及以下幾個目錄:
- cmd/compile/internal/types
- cmd/compile/internal/ir
- cmd/compile/internal/typecheck
- cmd/compile/internal/noder
IR 生成的入口位置在 cmd/compile/internal/noder/irgen.go :
func (g *irgen) generate(noders []*noder) 中的參數(shù) noders 就是類型檢查的輸出結(jié)果 AST 。
到這里我們小結(jié)一下,編譯前端完成了 源代碼 -> token -> AST 的翻譯工作,而現(xiàn)在的中間代碼生成階段完成了 AST -> 基于 AST 的 IR Tree 的翻譯工作。所以為什么說編譯器的工作就是在翻譯。
代碼優(yōu)化
終于來到了代碼優(yōu)化,這個階段可謂是八股文的重災區(qū)。即使不懂編譯流程,也聽過了這些名詞:死代碼消除(dead code elimination)、函數(shù)內(nèi)聯(lián)(function call inlining)、逃逸分析(escape analysis)。
以上這些便是對 IR 的進一步優(yōu)化過程。其源碼位置分別在:
- cmd/compile/internal/deadcode
- cmd/compile/internal/inline
- cmd/compile/internal/escape
代碼優(yōu)化的目的就是讓代碼的執(zhí)行效率更高,例如可能存在一些代碼,雖然具備語義上的價值,但程序在運行時可能永遠不會執(zhí)行到,死代碼消除就會去除這些無用代碼(就像 if 里的條件為 true )。
如果程序中存在大量的小函數(shù)的調(diào)用,函數(shù)內(nèi)聯(lián)就會直接用函數(shù)體替換掉函數(shù)調(diào)用來減少因為函數(shù)調(diào)用而造成的額外上下文切換開銷。
最后逃逸分析可以判斷變量應該使用棧內(nèi)存還是堆內(nèi)存,為 Go 的自動內(nèi)存管理奠定基礎。
Walk 遍歷和替換
經(jīng)歷過代碼優(yōu)化的 IR ,將迎來它生命旅游的最后一站:Walk ,源碼在 cmd/compile/internal/walk。
Walk 會遍歷函數(shù)把復雜的語句分解為單獨的、更簡單的語句,并且還可能會引入臨時變量來對某些表達式和語句進行重新排序。還會將高層次的 Go 結(jié)構(gòu)替換為更原始的結(jié)構(gòu)。
例如, n += 2 將被替換為 n = n + 2 ;switch 選擇分支將替換為二分查找或跳轉(zhuǎn)表;map 和 chan 的 make 操作將替換為運行時調(diào)用的 makemap 和 makechan 等。
通用 SSA 生成
Walk 是基于 AST 的 IR 的最后一程,意味著來到這里,又要再次翻譯了。
而這次 IR 將被轉(zhuǎn)換為靜態(tài)單賦值(SSA)(Static Single Assignment)形式,這是一種具有特定屬性的較低級別的中間表示,可以更輕松地實現(xiàn)優(yōu)化并最終生成機器代碼。
SSA 的規(guī)則定義在:cmd/compile/internal/ssa ,而 IR 轉(zhuǎn)換為 SSA 的代碼位于:cmd/compile/internal/ssagen 。
其翻譯的入口在 func Compile(fn *ir.Func, worker int) 函數(shù)。
我們可以通過在編譯過程加上 GOSSAFUNC=函數(shù)名 環(huán)境變量來查看 SSA 的生成過程。
還是以這段代碼為例:
根據(jù)提示,會生成 ssa.html 文件:
可以從中看到 SSA 為了盡最大可能地提升執(zhí)行效率,會經(jīng)歷 多輪轉(zhuǎn)換 后才生成最終的 SSA 。
機器碼生成
來到最后一步,也是從 .go 文本文件到可執(zhí)行文件的最終謎團,把 SSA 翻譯成特定目標機器(目標 CPU 架構(gòu))的機器碼。
首先需要把 SSA 降級(lower),針對具體目標架構(gòu),進行 多輪轉(zhuǎn)換 來執(zhí)行代碼優(yōu)化,包括死代碼消除(和之前的代碼優(yōu)化中的不同)、將數(shù)值移到離它們的用途更近的地方、刪除多余局部變量、寄存器分配等等。
這個降級操作最后的結(jié)果,其實就是我們上面的 ssa.html 文件最后的 genssa :
把 SSA 多輪轉(zhuǎn)換得到了 genssa 之后(此時已經(jīng)很接近匯編了),會先繼續(xù)把 genssa 翻譯成匯編代碼(Plan9),然后才調(diào)用匯編器(cmd/internal/obj)將它們轉(zhuǎn)換為機器代碼并寫出最終的目標文件。目標文件中還會包含著反射數(shù)據(jù)、導出數(shù)據(jù)和調(diào)試信息。這一步就需要十分了解 CPU 指令集架構(gòu)了。
最后程序如果使用了其他程序或庫,還需要使用靜態(tài)鏈接或動態(tài)鏈接引用進來。在沒有使用 CGO 時,Go 默認會使用靜態(tài)鏈接,當然也可以在 go build 時指定。
最后
編譯原理是一門十分復雜的系統(tǒng),每一個階段單獨拎出來,其涉及的知識體系都夠嚇人的了。。。
本文也只是蜻蜓點水般的過一遍,關(guān)于其中的一些細節(jié)我也并不太清楚,但是也讓我明白了一點道理:不管是多復雜多底層的系統(tǒng),也都是通過分層來解耦、復用,并且也是不斷迭代優(yōu)化來完成的。
另外,知道了 Go 語言編譯過程中的代碼優(yōu)化,也能讓我們在平時的代碼編寫中結(jié)合對應的特性編寫出更加高性能的代碼,例如盡量在棧上分配對象,減少變量逃逸到堆上也可以提高 GC 效率等。這些后面再單獨寫篇文章來介紹。
本文部分內(nèi)容參考自經(jīng)典龍書《編譯原理》以及《Golang 編譯器代碼淺析》[2] ,如果想要了解更多編譯領(lǐng)域的知識,推薦大家進行閱讀。
對于最后機器碼生成中提到的 Plan9 匯編可以閱讀曹大的《plan9 assembly 完全解析》[3]。
本文轉(zhuǎn)載自微信公眾號「gopher云原生」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請聯(lián)系gopher云原生公眾號。