内容简介:今天我们一起来研究 Go 1.11 的编译器,以及它将 Go 程序代码编译成可执行文件的过程。以便了解我们日常使用的工具是如何工作的。注意:下面使用的代码包(任何编译器的第一步都是将源代码文本分解成
今天我们一起来研究 Go 1.11 的编译器,以及它将 Go 程序代码编译成可执行文件的过程。以便了解我们日常使用的 工具 是如何工作的。
首先,编译器的三个阶段:
-
逐行扫描源代码,将之转换为一系列的
token
,交给parser
解析。 -
parser
,它将一系列token
转换为AST(抽象语法树)
,用于下一步生成代码。 -
最后一步,代码生成,会利用上一步生成的
AST
并根据目标机器平台的不同,生成目标机器码。
注意:下面使用的代码包( go/scanner , go/parser , go/token , go/ast )主要是让我们可以方便地对 Go 代码进行解析和生成,做出更有趣的事情。但是 Go 本身的编译器并不是用这些代码包实现的。
扫描代码,进行词法分析
任何编译器的第一步都是将源代码文本分解成 token
,由扫描程序(也称为词法分析器)完成。 token
可以是关键字,字符串,变量名,函数名等等。每一个有效的词都由 token
表示。
在 Go 中,我们写在代码上的 "package"
, "main"
, "func"
这些都是 token
。
token
由代码中的位置,类型和原始文本组成。我们可以使用 go/scanner 和 go/token 包在 Go 程序中自己执行扫描程序。这意味着我们可以像编译器那样扫描检视自己的代码。
下面,我们将通过一个打印 Hello World 的示例来展示 token
。
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)标记。
输出:
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
表示 token
是 package
,第三列 "package"
表示源代码文本。
我们可以看到在 Scanner
执行过程中将 \n
换行符标记成了 ;
分号,像在 C 语言中是用分号表示一行结束的。这就解释了为什么 Go 不需要分号:它们是在词法分析阶段由 Scanner
智能地解释的。
语法分析
源代码扫描完成后,扫描结果将被传递给语法分析器。语法分析是编译的一个阶段,它将 token
转换为 抽象语法树(AST)
。
AST
是源代码的结构化表示。在 AST
中,我们将能够看到程序结构,比如函数和常量声明。
我们使用 go/parser
和 go/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函数
由三个部分组成: Name
、 Type
和 Body
。 Name
是值为 main
的标识符。由 Type
字段指定的声明将包含参数列表和返回类型(如果我们指定了的话)。正文由一系列语句组成,里面包含了程序的所有行,在本例中只有一行 fmt.Println("Hello, world!")
。
我们的一条 fmt.Println
语句由 AST
中很多部分组成。
该语句是一个 ExprStmt
表达式语句(expression statement)
,例如,它可以像这里一样是一个函数调用,它可以是字面量,可以是一个二元运算(例如加法和减法),当然也可以是一元运算(例如自增++,自减--,否定!等)等等。
同时,在函数调用的参数中可以使用任何表达式。
然后, ExprStmt
又包含一个 CallExpr
,它是我们实际的函数调用。里面又包括几个部分,其中最重要的部分是 Fun
和 Args
。
Fun
包含对函数调用的引用,在这种情况下,它是一个 SelectorExpr
,因为我们从 fmt
包中选择 Println
标识符。
但是至此,在 AST
中,编译器还不知道 fmt
是一个包,它也可能是 AST
中的一个变量。
Args
包含一个表达式列表,它是函数的参数。这里,我们将一个文本字符串传递给函数,因而它由一个类型为 STRING
的 BasicLit
表示。
显然, 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
生成的 SSA
;
lower
阶段将非机器特定的 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 始终会执行, b2
和 b3
是条件块,满足条件才执行。
我们来看 b1
结尾处的 If v19 → b2 b3 (likely)
。单击该行中的 v19
可以查看它定义的位置。可以看到它定义为 IsSliceInBounds <bool> v14 v15
,通过 Go 编译器源代码
,我们知道 IsSliceInBounds
的作用是检查 0 <= arg0 <= arg1
。然后单击 v14
和 v15
看看在哪定义的,我们会看到 v14 = Const64 <int> [0]
,Const64 是一个常量 64 位整数。 v15 定义一样,放在 args1 的位置。所以,实际执行的是 0 <= 0 <= 1
,这显然是正确的。
编译器也能够证明这一点,当我们查看
opt
阶段(“机器无关优化”)时,我们可以看到它已经重写了 v19
为 ConstBool <bool> [true]
。结果就是,在 opt deadcode
阶段, b3
条件块被删除了,因为永远也不会执行到 b3
。
下面来看一下 Go 编译器在把 SSA
转换为 机器特定的SSA
之后所做的另一个更简单的优化,基于amd64体系结构的机器代码。下面,我们将比较
lower
和
lowered 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 是不错的选择。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:- Go 程序到机器码的编译之旅
- [译] Go 程序到机器码的编译之旅
- Android ART JVM中和机器码编译有关的小知识
- 不依赖OS编译器,不依赖库,用汇编/机器码直接编程
- 指令集架构、机器码与 Go 语言
- Golang获取机器码(MachineCode、PhysicalId)
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。