Go 語言編譯過程概述

Golang 是一門須要編譯才能運行的編程語言,也就說代碼在運行以前須要經過編譯器生成二進制機器碼,隨後二進制文件才能在目標機器上運行,若是咱們想要了解 Go 語言的實現原理,理解它的編譯過程就是一個沒有辦法繞過的事情。前端

這一節會先對 Go 語言編譯的過程進行概述,從頂層介紹編譯器執行的幾個步驟,隨後的章節會分別剖析各個步驟完成的工做和實現原理,同時也會對一些須要預先掌握的知識進行介紹和準備,確保後面的章節可以被更好的理解。git

目錄

預備知識

想要深刻了解 Go 語言的編譯過程,須要提早了解一下編譯過程當中涉及的一些術語和專業知識。這些知識其實在咱們的平常工做和學習中比較難用到,可是對於理解編譯的過程和原理仍是很是重要的。這一小節會簡單挑選幾個常見而且重要的概念提早進行介紹,減小後面章節的理解壓力。github

抽象語法樹

抽象語法樹(AST)是源代碼語法的結構的一種抽象表示,它用樹狀的方式表示編程語言的語法結構。抽象語法樹中的每個節點都表示源代碼中的一個元素,每一顆子樹都表示一個語法元素,例如一個 if else 語句,咱們能夠從 2 * 3 + 7 這一表達式中解析出下圖所示的抽象語法樹。golang

abstract-syntax-tree

做爲編譯器經常使用的數據結構,抽象語法樹抹去了源代碼中不重要的一些字符 - 空格、分號或者括號等等。編譯器在執行完語法分析以後會輸出一個抽象語法樹,這棵樹會輔助編譯器進行語義分析,咱們能夠用它來肯定結構正確的程序是否存在一些類型不匹配或不一致的問題。web

靜態單賦值

靜態單賦值(SSA)是中間代碼的一個特性,若是一箇中間代碼具備靜態單賦值的特性,那麼每一個變量就只會被賦值一次,在實踐中咱們一般會用添加下標的方式實現每一個變量只能被賦值一次的特性,這裏如下面的代碼舉一個簡單的例子:編程

x := 1
x := 2
y := x

根據分析,咱們其實可以發現上述的代碼其實並不須要第一個將 1 賦值給 x 的表達式,也就是這一表達式在整個代碼片斷中是沒有做用的:後端

x1 := 1
x2 := 2
y1 := x2

從使用 SSA 的『中間代碼』咱們就能夠很是清晰地看出變量 y1 的值和 x1 是徹底沒有任何關係的,因此在機器碼生成時其實就能夠省略第一步,這樣就能減小須要執行的指令來優化這一段代碼。瀏覽器

根據 Wikipedia 對 SSA 的介紹來看,在中間代碼中使用 SSA 的特性可以爲整個程序實現如下的優化:bash

  1. 常數傳播(constant propagation)
  2. 值域傳播(value range propagation)
  3. 稀疏有條件的常數傳播(sparse conditional constant propagation)
  4. 消除無用的程式碼(dead code elimination)
  5. 全域數值編號(global value numbering)
  6. 消除部分的冗餘(partial redundancy elimination)
  7. 強度折減(strength reduction)
  8. 寄存器分配(register allocation)

從 SSA 的做用咱們就能看出,由於它的主要做用就是代碼的優化,因此是編譯器後端(主要負責目標代碼的優化和生成)的一部分;固然,除了 SSA 以外代碼編譯領域還有很是多的中間代碼優化方法,優化編譯器生成的代碼是一個很是古老而且複雜的領域,這裏就不會展開介紹了。數據結構

指令集架構

最後要介紹的一個預備知識就是指令集的架構了,不少開發者都會遇到在生產環境運行的結果和本地不一樣的問題,致使這種狀況的緣由其實很是複雜,不一樣機器使用不一樣的指令也是可能的緣由之一。

咱們大多數開發者都會使用 x86_64 的 Macbook 做爲工做上主要使用的硬件,在命令行中輸入 uname -m 就可以得到當前機器上硬件的信息:

$ uname -m
x86_64

x86_64 是目前比較常見的指令集架構之一,除了 x86_64 以外,還包含其餘類型的指令集架構,例如 amd6四、arm64 以及 mips 等等,不一樣的處理器使用了大不相同的機器語言,因此不少編程語言爲了在不一樣的機器上運行須要將源代碼根據架構翻譯成不一樣的機器代碼。

複雜指令集計算機(CISC)和精簡指令集計算機(RISC)是目前的兩種 CPU 區別,它們的在設計理念上會有一些不一樣,從名字咱們就能看出來這兩種不一樣的設計有什麼區別,複雜指令集經過增長指令的數量減小須要執行的質量數,而精簡指令集能使用更少的指令完成目標的計算任務;早期的 CPU 爲了減小機器語言指令的數量使用複雜指令集完成計算任務,這二者以前的區別其實就是設計上的權衡,咱們會在後面的章節 機器碼生成 中詳細介紹指令集架構,固然各位讀者也能夠自行搜索和學習。

編譯原理

Go 語言編譯器的源代碼在 cmd/compile 目錄中,目錄下的文件共同構成了 Go 語言的編譯器,學過編譯原理的人可能據說過編譯器的前端和後端,編譯器的前端通常承擔着詞法分析、語法分析、類型檢查和中間代碼生成幾部分工做,而編譯器後端主要負責目標代碼的生成和優化,也就是將中間代碼翻譯成目標『機器』可以運行的機器碼。

complication-process

Go 的編譯器在邏輯上能夠被分紅四個階段:詞法與語法分析、類型檢查和 AST 轉換、通用 SSA 生成和最後的機器代碼生成,在這一節咱們會使用比較少的篇幅分別介紹這四個階段作的工做,後面的章節會具體介紹每個階段的具體內容。

詞法與語法分析

全部的編譯過程其實都是從解析代碼的源文件開始的,詞法分析的做用就是解析源代碼文件,它將文件中的字符串序列轉換成 Token 序列,方便後面的處理和解析,咱們通常會把執行詞法分析的程序稱爲詞法解析器(lexer)。

而語法分析的輸入就是詞法分析器輸出的 Token 序列,這些序列會按照順序被語法分析器進行解析,語法的解析過程就是將詞法分析生成的 Token 按照語言定義好的文法(Grammar)自下而上或者自上而下的進行規約,每個 Go 的源代碼文件最終會被概括成一個 SourceFile 結構:

SourceFile = PackageClause ";" { ImportDecl ";" } { TopLevelDecl ";" } .

標準的 Golang 語法解析器使用的就是 LALR(1) 的文法,語法解析的結果其實就是上面介紹過的抽象語法樹(AST),每個 AST 都對應着一個單獨的 Go 語言文件,這個抽象語法樹中包括當前文件屬於的包名、定義的常量、結構體和函數等。

golang-files-and-ast

若是在語法解析的過程當中發生了任何語法錯誤,都會被語法解析器發現並將消息打印到標準輸出上,整個編譯過程也會隨着錯誤的出現而被停止。

咱們會在這一章後面的小節 詞法與語法分析 中介紹 Go 語言的文法和它的詞法與語法解析過程。

類型檢查

當拿到一組文件的抽象語法樹 AST 以後,Go 語言的編譯器會對語法樹中定義和使用的類型進行檢查,類型檢查分別會按照順序對不一樣類型的節點進行驗證,按照如下的順序進行處理:

  1. 常量、類型和函數名及類型;
  2. 變量的賦值和初始化;
  3. 函數和閉包的主體;
  4. 哈希鍵值對的類型;
  5. 導入函數體;
  6. 外部的聲明;

經過對每一棵抽象節點樹的遍歷,咱們在每個節點上都會對當前子樹的類型進行驗證保證當前節點上不會出現類型錯誤的問題,全部的類型錯誤和不匹配都會在這一個階段被發現和暴露出來。

類型檢查的階段不止會對樹狀結構的節點進行驗證,同時也會對一些內建的函數進行展開和改寫,例如 make 關鍵字在這個階段會根據子樹的結構被替換成 makeslice 或者 makechan 等函數。

golang-keyword-make

咱們其實可以看出類型檢查不止作了驗證類型的工做,還作了對 AST 進行改寫,處理 Go 語言內置關鍵字的活,因此,這一過程在整個編譯流程中仍是很是重要的,沒有這個步驟不少關鍵字其實就沒有辦法工做,後面的章節 類型檢查 會介紹這一步驟。

中間代碼生成

當咱們將源文件轉換成了抽象語法樹、對整棵樹的語法進行解析並進行類型檢查以後,就能夠認爲當前文件中的代碼基本上不存在沒法編譯或者語法錯誤的問題了,Go 語言的編譯器就會將輸入的 AST 轉換成中間代碼。

Go 語言編譯器的中間代碼使用了 SSA(Static Single Assignment Form) 的特性,若是咱們在中間代碼生成的過程當中使用這種特性,就可以比較容易的分析出代碼中的無用變量和片斷並對代碼進行優化。

在類型檢查以後,就會經過一個名爲 compileFunctions 的函數開始對整個 Go 語言項目中的所有函數進行編譯,這些函數會在一個編譯隊列中等待幾個後端工做協程的消費,這些 Goroutine 會將全部函數對應的 AST 轉換成使用 SSA 特性的中間代碼。

中間代碼生成 這一章節會詳細介紹中間代碼的生成過程並簡單介紹 Golang 是如何在中間代碼中使用 SSA 的特性的,在這裏就不展開介紹其餘的內容了。

機器碼生成

Go 語言源代碼的 cmd/compile/internal 中包含了很是多機器碼生成相關的包,不一樣類型的 CPU 分別使用了不一樣的包進行生成 amd6四、arm、arm6四、mips、mips6四、ppc6四、s390x、x86 和 wasm,也就是說 Go 語言可以在上述的 CPU 指令集類型上運行,其中比較有趣的就是 WebAssembly 了。

做爲一種在棧虛擬機上使用的二進制指令格式,它的設計的主要目標就是在 Web 瀏覽器上提供一種具備高可移植性的目標語言。Go 語言的編譯器既然可以生成 WASM 格式的指令,那麼就可以運行在常見的主流瀏覽器中。

$ GOARCH=wasm GOOS=js go build -o lib.wasm main.go

咱們可使用上述的命令將 Go 的源代碼編譯成可以在瀏覽器上運行的『彙編語言』,除了這種新興的指令以外,Go 語言還支持了幾乎所有常見的 CPU 指令集類型,也就是說它編譯出的機器碼可以在使用上述指令集的機器上運行。

機器碼生成 一節會詳細介紹將中間代碼翻譯到不一樣目標機器的過程,在這個章節中也會簡單介紹不一樣的指令集架構的區別。

編譯器入口

Go 語言的編譯器入口在 src/cmd/compile/internal/pc 包中的 main.go 文件,這個 600 多行的 Main 函數就是 Go 語言編譯器的主程序,這個函數會先獲取命令行傳入的參數並更新編譯的選項和配置,隨後就會開始運行 parseFiles 函數對輸入的全部文件進行詞法與語法分析獲得文件對應的抽象語法樹:

func Main(archInit func(*Arch)) {
    // ...

    lines := parseFiles(flag.Args())

接下來就會分九個階段對抽象語法樹進行更新和編譯,就像咱們在上面介紹的,整個過程會經歷類型檢查、SSA 中間代碼生成以及機器碼生成三個部分:

  1. 檢查常量、類型和函數的類型;
  2. 處理變量的賦值;
  3. 對函數的主體進行類型檢查;
  4. 決定如何捕獲變量;
  5. 檢查內聯函數的類型;
  6. 進行逃逸分析;
  7. 將閉包的主體轉換成引用的捕獲變量;
  8. 編譯頂層函數;
  9. 檢查外部依賴的聲明;

瞭解了剩下的編譯過程以後,咱們從新回到詞法和語法分析後的具體流程,在這裏編譯器會對生成語法樹中的節點執行類型檢查,除了常量、類型和函數這些頂層聲明以外,它還會對變量的賦值語句、函數主體等結構進行檢查:

for i := 0; i < len(xtop); i++ {
        n := xtop[i]
        if op := n.Op; op != ODCL && op != OAS && op != OAS2 && (op != ODCLTYPE || !n.Left.Name.Param.Alias) {
            xtop[i] = typecheck(n, ctxStmt)
        }
    }

    for i := 0; i < len(xtop); i++ {
        n := xtop[i]
        if op := n.Op; op == ODCL || op == OAS || op == OAS2 || op == ODCLTYPE && n.Left.Name.Param.Alias {
            xtop[i] = typecheck(n, ctxStmt)
        }
    }

    for i := 0; i < len(xtop); i++ {
        n := xtop[i]
        if op := n.Op; op == ODCLFUNC || op == OCLOSURE {
            typecheckslice(Curfn.Nbody.Slice(), ctxStmt)
        }
    }

    checkMapKeys()

    for _, n := range xtop {
        if n.Op == ODCLFUNC && n.Func.Closure != nil {
            capturevars(n)
        }
    }

    escapes(xtop)

    for _, n := range xtop {
        if n.Op == ODCLFUNC && n.Func.Closure != nil {
            transformclosure(n)
        }
    }

類型檢查會對傳入節點的子節點進行遍歷,這個過程會對 make 等關鍵字進行展開和重寫,類型檢查結束以後並無輸出新的數據結構,只是改變了語法樹中的一些節點,同時這個過程的結束也意味着源代碼中已經不存在語法錯誤和類型錯誤,中間代碼和機器碼也均可以正常的生成了。

initssaconfig()

    peekitabs()

    for i := 0; i < len(xtop); i++ {
        n := xtop[i]
        if n.Op == ODCLFUNC {
            funccompile(n)
        }
    }

    compileFunctions()

    for i, n := range externdcl {
        if n.Op == ONAME {
            externdcl[i] = typecheck(externdcl[i], ctxExpr)
        }
    }

    checkMapKeys()
}

在主程序運行的最後,會將頂層的函數編譯成中間代碼並根據目標的 CPU 架構生成機器碼,不過這裏其實也可能會再次對外部依賴進行類型檢查以驗證正確性。

總結

Go 語言的編譯過程實際上是很是有趣而且值得學習的,經過對 Go 語言四個編譯階段的分析和對編譯器主函數的梳理,咱們可以對 Golang 的實現有一些基本的理解,掌握編譯的過程以後,Go 語言對於咱們來說也再也不是一個黑盒,因此學習其編譯原理的過程仍是很是讓人着迷的。

相關文章

Reference

相關文章
相關標籤/搜索