Go 程序是如何編譯成目標機器碼的

今天咱們一塊兒來研究 Go 1.11 的編譯器,以及它將 Go 程序代碼編譯成可執行文件的過程。以便了解咱們平常使用的工具是如何工做的。
本文還會帶你瞭解 Go 程序爲何這麼快,以及編譯器在這中間起到了什麼做用。

首先,編譯器的三個階段:

  1. 逐行掃描源代碼,將之轉換爲一系列的 token,交給 parser 解析。
  2. parser,它將一系列 token 轉換爲 AST(抽象語法樹),用於下一步生成代碼。
  3. 最後一步,代碼生成,會利用上一步生成的 AST 並根據目標機器平臺的不一樣,生成目標機器碼。

注意:下面使用的代碼包(go/scannergo/parsergo/tokengo/ast)主要是讓咱們能夠方便地對 Go 代碼進行解析和生成,作出更有趣的事情。可是 Go 自己的編譯器並非用這些代碼包實現的。html

掃描代碼,進行詞法分析

任何編譯器的第一步都是將源代碼文本分解成 token,由掃描程序(也稱爲詞法分析器)完成。token 能夠是關鍵字,字符串,變量名,函數名等等。每個有效的詞都由 token 表示。
在 Go 中,咱們寫在代碼上的 "package""main""func" 這些都是 tokenlinux

token 由代碼中的位置,類型和原始文本組成。咱們可使用 go/scanner 和 go/token 包在 Go 程序中本身執行掃描程序。這意味着咱們能夠像編譯器那樣掃描檢視本身的代碼。
下面,咱們將經過一個打印 Hello World 的示例來展現 tokengit

package main

import (
    "fmt"
    "go/scanner"
    "go/token"
)

func main() {
    src := []byte(`
package main

import "fmt"

func main() {
    fmt.Println("Hello, world!")
}
`)

    var s scanner.Scanner
    fset := token.NewFileSet()
    file := fset.AddFile("", fset.Base(), len(src))
    s.Init(file, src, nil, 0)

    for {
        pos, tok, lit := s.Scan()
        fmt.Printf("%-6s%-8s%q\n", fset.Position(pos), tok, lit)

        if tok == token.EOF {
            break
        }
    }
}

首先經過源代碼字符串建立 token 集合並初始化 scan.Scanner,它將逐行掃描咱們的源代碼。
接下來循環調用 Scan() 並打印每一個 token 的位置,類型和文本字符串,直到遇到文件結束(EOF)標記。github

輸出:golang

2:1   package "package"
2:9   IDENT   "main"
2:13  ;       "\n"
4:1   import  "import"
4:8   STRING  "\"fmt\""
4:13  ;       "\n"
6:1   func    "func"
6:6   IDENT   "main"
6:10  (       ""
6:11  )       ""
6:13  {       ""
7:2   IDENT   "fmt"
7:5   .       ""
7:6   IDENT   "Println"
7:13  (       ""
7:14  STRING  "\"Hello, world!\""
7:29  )       ""
7:30  ;       "\n"
8:1   }       ""
8:2   ;       "\n"
8:3   EOF     ""

以第一行爲例分析這個輸出,第一列 2:1 表示掃描到了源代碼第二行第一個字符,第二列 package 表示 tokenpackage,第三列 "package" 表示源代碼文本。
咱們能夠看到在 Scanner 執行過程當中將 \n 換行符標記成了 ; 分號,像在 C 語言中是用分號表示一行結束的。這就解釋了爲何 Go 不須要分號:它們是在詞法分析階段由 Scanner 智能地解釋的。express

語法分析

源代碼掃描完成後,掃描結果將被傳遞給語法分析器。語法分析是編譯的一個階段,它將 token 轉換爲 抽象語法樹(AST)
AST 是源代碼的結構化表示。在 AST 中,咱們將可以看到程序結構,好比函數和常量聲明。segmentfault

咱們使用 go/parsergo/ast 來打印完整的 AST瀏覽器

package main

import (
    "go/ast"
    "go/parser"
    "go/token"
    "log"
)

func main() {
    src := []byte(`
package main

import "fmt"

func main() {
    fmt.Println("Hello, world!")
}
`)

    fset := token.NewFileSet()

    file, err := parser.ParseFile(fset, "", src, 0)
    if err != nil {
        log.Fatal(err)
    }

    ast.Print(fset, file)
}

輸出:函數

0  *ast.File {
     1  .  Package: 2:1
     2  .  Name: *ast.Ident {
     3  .  .  NamePos: 2:9
     4  .  .  Name: "main"
     5  .  }
     6  .  Decls: []ast.Decl (len = 2) {
     7  .  .  0: *ast.GenDecl {
     8  .  .  .  TokPos: 4:1
     9  .  .  .  Tok: import
    10  .  .  .  Lparen: -
    11  .  .  .  Specs: []ast.Spec (len = 1) {
    12  .  .  .  .  0: *ast.ImportSpec {
    13  .  .  .  .  .  Path: *ast.BasicLit {
    14  .  .  .  .  .  .  ValuePos: 4:8
    15  .  .  .  .  .  .  Kind: STRING
    16  .  .  .  .  .  .  Value: "\"fmt\""
    17  .  .  .  .  .  }
    18  .  .  .  .  .  EndPos: -
    19  .  .  .  .  }
    20  .  .  .  }
    21  .  .  .  Rparen: -
    22  .  .  }
    23  .  .  1: *ast.FuncDecl {
    24  .  .  .  Name: *ast.Ident {
    25  .  .  .  .  NamePos: 6:6
    26  .  .  .  .  Name: "main"
    27  .  .  .  .  Obj: *ast.Object {
    28  .  .  .  .  .  Kind: func
    29  .  .  .  .  .  Name: "main"
    30  .  .  .  .  .  Decl: *(obj @ 23)
    31  .  .  .  .  }
    32  .  .  .  }
    33  .  .  .  Type: *ast.FuncType {
    34  .  .  .  .  Func: 6:1
    35  .  .  .  .  Params: *ast.FieldList {
    36  .  .  .  .  .  Opening: 6:10
    37  .  .  .  .  .  Closing: 6:11
    38  .  .  .  .  }
    39  .  .  .  }
    40  .  .  .  Body: *ast.BlockStmt {
    41  .  .  .  .  Lbrace: 6:13
    42  .  .  .  .  List: []ast.Stmt (len = 1) {
    43  .  .  .  .  .  0: *ast.ExprStmt {
    44  .  .  .  .  .  .  X: *ast.CallExpr {
    45  .  .  .  .  .  .  .  Fun: *ast.SelectorExpr {
    46  .  .  .  .  .  .  .  .  X: *ast.Ident {
    47  .  .  .  .  .  .  .  .  .  NamePos: 7:2
    48  .  .  .  .  .  .  .  .  .  Name: "fmt"
    49  .  .  .  .  .  .  .  .  }
    50  .  .  .  .  .  .  .  .  Sel: *ast.Ident {
    51  .  .  .  .  .  .  .  .  .  NamePos: 7:6
    52  .  .  .  .  .  .  .  .  .  Name: "Println"
    53  .  .  .  .  .  .  .  .  }
    54  .  .  .  .  .  .  .  }
    55  .  .  .  .  .  .  .  Lparen: 7:13
    56  .  .  .  .  .  .  .  Args: []ast.Expr (len = 1) {
    57  .  .  .  .  .  .  .  .  0: *ast.BasicLit {
    58  .  .  .  .  .  .  .  .  .  ValuePos: 7:14
    59  .  .  .  .  .  .  .  .  .  Kind: STRING
    60  .  .  .  .  .  .  .  .  .  Value: "\"Hello, world!\""
    61  .  .  .  .  .  .  .  .  }
    62  .  .  .  .  .  .  .  }
    63  .  .  .  .  .  .  .  Ellipsis: -
    64  .  .  .  .  .  .  .  Rparen: 7:29
    65  .  .  .  .  .  .  }
    66  .  .  .  .  .  }
    67  .  .  .  .  }
    68  .  .  .  .  Rbrace: 8:1
    69  .  .  .  }
    70  .  .  }
    71  .  }
    72  .  Scope: *ast.Scope {
    73  .  .  Objects: map[string]*ast.Object (len = 1) {
    74  .  .  .  "main": *(obj @ 27)
    75  .  .  }
    76  .  }
    77  .  Imports: []*ast.ImportSpec (len = 1) {
    78  .  .  0: *(obj @ 12)
    79  .  }
    80  .  Unresolved: []*ast.Ident (len = 1) {
    81  .  .  0: *(obj @ 46)
    82  .  }
    83  }

分析這個輸出,在 Decls 字段中,包含了代碼中全部的聲明,例如導入、常量、變量和函數。在本例中,咱們只有兩個:導入fmt包主函數
爲了進一步理解它,咱們能夠看看下面這個圖,它是上述數據的表示,但只包含類型,紅色表明與節點對應的代碼:工具

圖片描述

main函數由三個部分組成:NameTypeBodyName 是值爲 main 的標識符。由 Type 字段指定的聲明將包含參數列表和返回類型(若是咱們指定了的話)。正文由一系列語句組成,裏面包含了程序的全部行,在本例中只有一行fmt.Println("Hello, world!")

咱們的一條 fmt.Println 語句由 AST 中不少部分組成。
該語句是一個 ExprStmt表達式語句(expression statement),例如,它能夠像這裏同樣是一個函數調用,它能夠是字面量,能夠是一個二元運算(例如加法和減法),固然也能夠是一元運算(例如自增++,自減--,否認!等)等等。
同時,在函數調用的參數中可使用任何表達式。

而後,ExprStmt 又包含一個 CallExpr,它是咱們實際的函數調用。裏面又包括幾個部分,其中最重要的部分是 FunArgs
Fun 包含對函數調用的引用,在這種狀況下,它是一個 SelectorExpr,由於咱們從 fmt 包中選擇 Println 標識符。
可是至此,在 AST 中,編譯器還不知道 fmt 是一個包,它也多是 AST 中的一個變量。

Args 包含一個表達式列表,它是函數的參數。這裏,咱們將一個文本字符串傳遞給函數,於是它由一個類型爲 STRINGBasicLit 表示。

顯然,AST 包含了許多信息,咱們不只能夠分析出以上結論,還能夠進一步檢查 AST 並查找文件中的全部函數調用。下面,咱們將使用 go/ast 包中的 Inspect 函數來遞歸地遍歷樹,並分析全部節點的信息。

package main

import (
    "fmt"
    "go/ast"
    "go/parser"
    "go/printer"
    "go/token"
    "os"
)

func main() {
    src := []byte(`
package main

import "fmt"

func main() {
    fmt.Println("Hello, world!")
}
`)

    fset := token.NewFileSet()

    file, err := parser.ParseFile(fset, "", src, 0)
    if err != nil {
        fmt.Println(err)
    }

    ast.Inspect(file, func(n ast.Node) bool {
        call, ok := n.(*ast.CallExpr)
        if !ok {
            return true
        }

        printer.Fprint(os.Stdout, fset, call.Fun)
        
        return false
    })
}

輸出:

fmt.Println

上面代碼的做用是查找全部節點以及它們是否爲 *ast.CallExpr 類型,上面也說過這種類型是函數調用。若是是,則使用 go/printer 包打印 Fun 中存在的函數的名稱。

構建出 AST 後,將使用 GOPATH 或者在 Go 1.11 及更高版本中的 modules 解析全部導入。而後,執行類型檢查,並作一些讓程序運行更快的初級優化。

代碼生成

在解析導入並作了類型檢查以後,咱們能夠確認程序是合法的 Go 代碼,而後就走到將 AST 轉換爲(僞)目標機器碼的過程。

此過程的第一步是將 AST 轉換爲程序的低級表示,特別是轉換爲 靜態單賦值(SSA)表單。這個中間表示不是最終的機器代碼,但它確實表明了最終的機器代碼。 SSA 具備一組屬性,會使應用優化變得更容易,其中最重要的是在使用變量以前老是定義變量,而且每一個變量只分配一次。

在生成 SSA 的初始版本以後,將執行一些優化。這些優化適用於某些代碼,可使處理器執行起來更簡單且更快速。例如,能夠作 死碼消除。還有好比能夠刪除某些 nil 檢查,由於編譯器能夠證實這些檢查永遠不會出錯。

如今經過最簡單的例子來講明 SSA 和一些優化過程:

package main

import "fmt"

func main() {
    fmt.Println(2)
}

如你所見,此程序只有一個函數和一個導入。它會在運行時打印 2。可是,此例足以讓咱們瞭解SSA。

爲了顯示生成的 SSA,咱們須要將 GOSSAFUNC 環境變量設置爲咱們想要跟蹤的函數,在本例中爲main 函數。咱們還須要將 -S 標識傳遞給編譯器,這樣它就會打印代碼並建立一個HTML文件。咱們還將編譯Linux 64位的文件,以確保機器代碼與您在這裏看到的相同。
在終端執行下面的命令:

GOSSAFUNC=main GOOS=linux GOARCH=amd64 go build -gcflags -S main.go

會在終端打印出全部的 SSA,同時也會生成一個交互式的 ssa.html 文件,咱們用瀏覽器打開它。

圖片描述

當你打開 ssa.html 時,將顯示不少階段,其中大部分都已摺疊。start 階段是從 AST 生成的SSAlower 階段將非機器特定的 SSA 轉換爲機器特定的 SSA,最後的 genssa 就是生成的機器代碼。

start 階段的代碼以下:

b1:
    v1  = InitMem <mem>
    v2  = SP <uintptr>
    v3  = SB <uintptr>
    v4  = ConstInterface <interface {}>
    v5  = ArrayMake1 <[1]interface {}> v4
    v6  = VarDef <mem> {.autotmp_0} v1
    v7  = LocalAddr <*[1]interface {}> {.autotmp_0} v2 v6
    v8  = Store <mem> {[1]interface {}} v7 v5 v6
    v9  = LocalAddr <*[1]interface {}> {.autotmp_0} v2 v8
    v10 = Addr <*uint8> {type.int} v3
    v11 = Addr <*int> {"".statictmp_0} v3
    v12 = IMake <interface {}> v10 v11
    v13 = NilCheck <void> v9 v8
    v14 = Const64 <int> [0]
    v15 = Const64 <int> [1]
    v16 = PtrIndex <*interface {}> v9 v14
    v17 = Store <mem> {interface {}} v16 v12 v8
    v18 = NilCheck <void> v9 v17
    v19 = IsSliceInBounds <bool> v14 v15
    v24 = OffPtr <*[]interface {}> [0] v2
    v28 = OffPtr <*int> [24] v2
If v19 → b2 b3 (likely) (line 6)

b2: ← b1
    v22 = Sub64 <int> v15 v14
    v23 = SliceMake <[]interface {}> v9 v22 v22
    v25 = Copy <mem> v17
    v26 = Store <mem> {[]interface {}} v24 v23 v25
    v27 = StaticCall <mem> {fmt.Println} [48] v26
    v29 = VarKill <mem> {.autotmp_0} v27
Ret v29 (line 7)

b3: ← b1
    v20 = Copy <mem> v17
    v21 = StaticCall <mem> {runtime.panicslice} v20
Exit v21 (line 6)

這個簡單的程序就已經產生了至關多的 SSA(總共35行)。然而,不少都是引用,能夠消除不少(最終的SSA版本有28行,最終的機器代碼版本有18行)。

每一個 v 都是一個新變量,能夠點擊來查看它被使用的位置。b 是塊,這裏有三塊:b1,b2,b3。b1 始終會執行,b2b3 是條件塊,知足條件才執行。
咱們來看 b1 結尾處的 If v19 → b2 b3 (likely)。單擊該行中的 v19 能夠查看它定義的位置。能夠看到它定義爲 IsSliceInBounds <bool> v14 v15,經過 Go 編譯器源代碼,咱們知道 IsSliceInBounds 的做用是檢查 0 <= arg0 <= arg1。而後單擊 v14v15 看看在哪定義的,咱們會看到 v14 = Const64 <int> [0],Const64 是一個常量 64 位整數。 v15 定義同樣,放在 args1 的位置。因此,實際執行的是 0 <= 0 <= 1,這顯然是正確的。

編譯器也可以證實這一點,當咱們查看 opt 階段(「機器無關優化」)時,咱們能夠看到它已經重寫了 v19ConstBool <bool> [true]。結果就是,在 opt deadcode 階段,b3 條件塊被刪除了,由於永遠也不會執行到 b3

下面來看一下 Go 編譯器在把 SSA 轉換爲 機器特定的SSA 以後所作的另外一個更簡單的優化,基於amd64體系結構的機器代碼。下面,咱們將比較 lowerlowered deadcode
lower

b1:
    BlockInvalid (6)
b2:
    v2 (?) = SP <uintptr>
    v3 (?) = SB <uintptr>
    v10 (?) = LEAQ <*uint8> {type.int} v3
    v11 (?) = LEAQ <*int> {"".statictmp_0} v3
    v15 (?) = MOVQconst <int> [1]
    v20 (?) = MOVQconst <uintptr> [0]
    v25 (?) = MOVQconst <*uint8> [0]
    v1 (?) = InitMem <mem>
    v6 (6) = VarDef <mem> {.autotmp_0} v1
    v7 (6) = LEAQ <*[1]interface {}> {.autotmp_0} v2
    v9 (6) = LEAQ <*[1]interface {}> {.autotmp_0} v2
    v16 (+6) = LEAQ <*interface {}> {.autotmp_0} v2
    v18 (6) = LEAQ <**uint8> {.autotmp_0} [8] v2
    v21 (6) = LEAQ <**uint8> {.autotmp_0} [8] v2
    v30 (6) = LEAQ <*int> [16] v2
    v19 (6) = LEAQ <*int> [8] v2
    v23 (6) = MOVOconst <int128> [0]
    v8 (6) = MOVOstore <mem> {.autotmp_0} v2 v23 v6
    v22 (6) = MOVQstore <mem> {.autotmp_0} v2 v10 v8
    v17 (6) = MOVQstore <mem> {.autotmp_0} [8] v2 v11 v22
    v14 (6) = MOVQstore <mem> v2 v9 v17
    v28 (6) = MOVQstoreconst <mem> [val=1,off=8] v2 v14
    v26 (6) = MOVQstoreconst <mem> [val=1,off=16] v2 v28
    v27 (6) = CALLstatic <mem> {fmt.Println} [48] v26
    v29 (5) = VarKill <mem> {.autotmp_0} v27
Ret v29 (+7)

在HTML中,某些行是灰色的,這意味着它們將在下一個階段中被刪除或修改。
例如,v15 (?) = MOVQconst <int> [1] 顯示爲灰色。點擊 v15,咱們看到它在其餘地方都沒有使用,而 MOVQconst 基本上與咱們以前看到的 Const64 相同,只針對amd64的特定機器。咱們把 v15 設置爲1。可是,v15 在其餘地方都沒有使用,因此它是無用的(死的)代碼而且能夠消除。

Go 編譯器應用了不少這類優化。所以,雖然 AST 生成的初始 SSA 可能不是最快的實現,但編譯器將SSA優化爲更快的版本。 HTML 文件中的每一個階段都有可能發生優化。

若是你有興趣瞭解 Go 編譯器中有關 SSA 的更多信息,請查看 Go 編譯器的 SSA 源代碼
這裏定義了全部的操做以及優化。

結論

Go 是一種很是高效且高性能的語言,由其編譯器及其優化支撐。要了解有關 Go 編譯器的更多信息,源代碼的 README 是不錯的選擇。

相關文章
相關標籤/搜索