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 是不错的选择。

相关文章
相关标签/搜索