本篇文章主要涉及到go语言编译过程的概述,包含编译原理简介、go编译过程以及go build和go run的执行逻辑。

一、编译原理简介

和c语言类似,go语言是一门需要编译才能运行的编程语言。也就是说,需要将go语言源码编译为可执行文件。
在《深入了解计算机系统》一书中,我们可以了解到,c源码编译过程为:
1641966988030-62d63dee-fe27-4249-a28e-12cfe528618d.png
c语言作为高级语言的鼻祖,其他编译语言的编译过程都是大同小异。
接下来我们在介绍go语言编译过程之前,先来了解一些编译知识。

1、 抽象语法树

抽象语法树(Abstract Syntax Tree、AST)是源代码语法结构的一种抽象表示,它用树状的方式表示编程语言的语法结构。
image.png
编译器通过语法分析精简代码后生成一个抽象语法树。

2、 静态单赋值

静态单赋值(Static Single Assignment、SSA)是中间代码的特性。静态单赋值主要作用的是对代码进行优化。

  1. x := 1
  2. x := 2
  3. y := x

如以上源代码,可以发现第一行的x变量的赋值是无用的,通过静态单赋值特性,就可以将x :=1优化调。

3、 指令集

计算机指令集分为复杂指令集(CISC)和精简指令集(RISC),这两种指令集是遵循不同设计理念实现的。

  • 复杂指令集:通过增加指令的类型减少需要执行的指令数
  • 精简指令集:使用更少的指令类型完成目标的计算任务

我们需要知道的是:两种指令集没有好坏之分,仅仅是不同的设计理念而已。

4、 代码编译器

编译器分为前端和后端。
编译器的前端主要负责词法分析、语法分析、类型检查和中间代码生成几个部分的工作。而编译器的后端主要负责目标代码的生成和优化,也就是将中间代码翻译成目标机器能够识别的机器码。

二、Go编译过程

通过上边编译原理的简介,我们基本了解了抽象语法树AST、静态单赋值SSA、计算机指令集和代码编译器的知识,接下来我们分析go语言的编译过程。

1、go编译器

go语言编译器源代码在src/cmd/compile目录中。
go编译器在逻辑上分为4个部分:词法分析和语法分析、AST转换、类型检查、通用SSA生成和最后的机器码生成。

1.1 词法分析和语法分析

词法分析通过词法解析器将go源代码按照规定的顺序生成token序列,然后将token序列交付语法分析器处理。
词法分析器会将go源代码归纳为一个SourceFile文件:

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

而语法分析会将词法分析输入的token序列转换为有意义的语法树:

  1. "json.go": SourceFile {
  2. PackageName: "json",
  3. ImportDecl: []Import{
  4. "io",
  5. },
  6. TopLevelDecl: ...
  7. }

将token序列转换为抽象语法树(AST)的过程使用语法解析器完成,每一个AST都对应着一个单独的go源文件,抽象语法树包含当前文件所属的包名、定义的常量、结构体和函数。语法解析发生任何语法错误,都会终止编译报错结束。

1.2 类型检查

当生成抽象语法树之后,就会进入类型检查阶段,类型检查会按照以下顺序对语法树进行遍历检查:

  • 1、常量、类型和函数名及类型
  • 2、变量的赋值和初始化
  • 3、函数和闭包的主体
  • 4、哈希键值对的类型
  • 5、导入函数体
  • 6、外部的声明

类型检查阶段会保证抽象语法树每个节点都没有类型错误,如果有,就会终止编译报错结束。
在该阶段,还会对go内置函数进行内部展开,如make函数会展开为runtime.makechan,runtime.makeslice等内部实现。
image.png

1.3 通用SSA生成和机器码生成

在通过词法语法分析生成抽象语法树AST,并通过类型检查确认源文件没有预发和类型错误后,会进入到中间代码的生成阶段。
go原因的中间代码的生成是通过SSA特性进行生成的,通过SSA会分析出无用的代码并进行优化。
编译器通过src/cmd/compile/internal/gc/pgen.go:358 compileFunctions()起多个协程将go项目中的全部函数进行编译生成中间代码。
image.png
在不同的机器需要生成不同的机器码,go语言中生成机器码的源码包在
src/cmd/compile/internal中,里面包含了不同的机器码生成的代码。

2、具体编译过程

go编译器的入口在:

  1. // src/cmd/compile/internal/gc/main.go:148
  2. func Main(archInit func(*Arch)) {
  3. ......
  4. }

Main()函数中:该函数的释义为:Main解析命令行中指定的标志和Go源文件参数,类型检查已解析的Go包,编译到机器码,最后将编译后的机器码定义写入磁盘。

2.1 编译准备工作

初始化编译变量、初始化内部编译包、获取参数、检查版本、定义输出文件格式等等

2.2 词法分析和语法分析

2.2.1 入口

  1. // src/cmd/compile/internal/gc/main.go:578
  2. timings.Start("fe", "parse")
  3. lines := parseFiles(flag.Args())
  4. timings.Stop()
  5. timings.AddEvent(int64(lines), "lines")

词法分析和语法分析的核心调用为:

  1. // src/cmd/compile/internal/syntax/syntax.go:79
  2. p.init(base, src, errh, pragh, mode) // 词法分析初始化
  3. p.next() // 词法处理
  4. return p.fileOrNil(), p.first // 返回抽象语法树

2.2.2 词法分析

p.init()最终初始化了词法分析器scanner:

  1. // src/cmd/compile/internal/syntax/scanner.go:46
  2. func (s *scanner) init(src io.Reader, errh func(line, col uint, msg string), mode uint) {
  3. s.source.init(src, errh)
  4. s.mode = mode
  5. s.nlsemi = false
  6. }

scanner结构体主要包含源文件、启用的模式和当前被扫描到的token

  1. // src/cmd/compile/internal/syntax/scanner.go:30
  2. type scanner struct {
  3. source
  4. mode uint
  5. nlsemi bool // if set '\n' and EOF translate to ';'
  6. // current token, valid after calling next()
  7. line, col uint
  8. blank bool // line is blank up to col
  9. tok token // token结构体中定义了全部的token类型
  10. lit string // valid if tok is _Name, _Literal, or _Semi ("semicolon", "newline", or "EOF"); may be malformed if bad is true
  11. bad bool // valid if tok is _Literal, true if a syntax error occurred, lit may be malformed
  12. kind LitKind // valid if tok is _Literal
  13. op Operator // valid if tok is _Operator, _AssignOp, or _IncOp
  14. prec int // valid if tok is _Operator, _AssignOp, or _IncOp
  15. }

编译器通过p.next()对源码文件进行词法分析,该函数是一个几百行的switch-case语句。主要处理逻辑为:
next()函数每次都通过nextch()函数获取到最近的未被解析的字符,然后根据switch-case匹配不同的逻辑,空行则跳过,数字则执行number�()函数等。
当通过p.next()进行词法分析完毕生成Token后,就开始进行语法分析了。

2.2.3 语法分析

语法分析主要在p.fileOrNil()函数中实现。
语法分析返回一个File结构

  1. // src/cmd/compile/internal/syntax/nodes.go:36
  2. // package PkgName; DeclList[0], DeclList[1], ...
  3. type File struct {
  4. Pragma Pragma
  5. PkgName *Name
  6. DeclList []Decl
  7. Lines uint
  8. node
  9. }

p.fileOrNil()函数体定义了import、顶层声明(常量、类型、变量、函数、方法)
最后生成的抽象语法树格式为:

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

2.3 类型检查

当词法分析和语法分析结束后,编译器接着会拿生成的抽象语法树进行类型检查。
注意:类型检查和SSA编译存在交叉
分别会进行以下检查:

  • 1、常量、类型以及函数的名称和类型检查
  • 2、变量赋值检查,此时会检查接口的实现
  • 3、检查函数体
  • 4、决定如何捕获变量
  • 5、检查函数内联
  • 6、逃逸分析 - 即栈变量逃逸到堆上的分析
  • 7、转换闭包体以正确引用捕获的变量
  • 8、为SSA汇编做准备-_initssaconfig()_
  • 9、编译顶层函数 - _compileFunctions() - SSA阶段_
  • 10、检查外部声明

类型检查的核心逻辑在typecheck()typecheck1()函数中:

  1. // src/cmd/compile/internal/gc/typecheck.go:202
  2. func typecheck(n *Node, top int) (res *Node) {
  3. }
  4. // src/cmd/compile/internal/gc/typecheck.go:326
  5. func typecheck1(n *Node, top int) (res *Node) {
  6. }

typecheck1()我们将能看到不同所有操作类型的执行逻辑,如前面讲过的make函数的展开就在此处进行:

  1. case OMAKE:
  2. .......
  3. switch t.Etype {
  4. default:
  5. ......
  6. case TSLICE:
  7. ......
  8. n.Op = OMAKESLICE
  9. case TMAP:
  10. ......
  11. n.Op = OMAKEMAP
  12. case TCHAN:
  13. ......
  14. n.Op = OMAKECHAN
  15. }

如上,OMAKE会根据具体操作展开为OMAKESLICE、OMAKEMAP、OMAKECHAN,方便后续处理。

2.4 中间代码生成

  1. // Prepare for SSA compilation.
  2. // This must be before peekitabs, because peekitabs
  3. // can trigger function compilation.
  4. initssaconfig()
  5. timings.Start("be", "compilefuncs")
  6. fcount = 0
  7. for i := 0; i < len(xtop); i++ {
  8. n := xtop[i]
  9. if n.Op == ODCLFUNC {
  10. funccompile(n)
  11. fcount++
  12. }
  13. }
  14. timings.AddEvent(fcount, "funcs")
  15. compileFunctions()

中间代码的生成逻辑在编译器中分为两个部分:

2.4.1 初始化配置

首先初始化ssa.Types该结构体包含go所有基本类型对应的指针。

  1. types_ := ssa.NewTypes()

然后通过types.NewPtr根据类型生成指向这些类型的指针,然后根据编译器的配置将生成的指针类型缓存在当前类型中,优化类型指针的获取效率。

  1. _ = types.NewPtr(types.Types[TINTER]) // *interface{}
  2. _ = types.NewPtr(types.NewPtr(types.Types[TSTRING])) // **string
  3. ...... // *int64
  4. _ = types.NewPtr(types.Errortype) // *error

�然后加载ssaConfig参数为:目标机器的cpu架构,ssa.Typesdebug配置

  1. ssaConfig = ssa.NewConfig(thearch.LinkArch.Name, *types_, Ctxt, Debug.N == 0)

NewConfig()函数主要根据不同的cpu架构进行一些寄存器、指针大小等设置。
接下来设置一些可能会使用的运行时函数

2.4.2 遍历替换

  1. func walk(fn *Node)
  2. func walkappend(n *Node, init *Nodes, dst *Node) *Node
  3. ...
  4. func walkrange(n *Node) *Node
  5. func walkselect(sel *Node)
  6. func walkselectcases(cases *Nodes) []*Node
  7. func walkstmt(n *Node) *Node
  8. func walkstmtlist(s []*Node)
  9. func walkswitch(sw *Node)

接着遍历抽象语法树中的函数,通过一些列walk()函数,将关键字和内建函数转换成函数调用。
image.png

2.4.3 SSA生成

在经过系列函数walk()处理后,抽象语法树就不再改变,然后就会通过compileSSA()函数生成SSA文件。

  1. // src/cmd/compile/internal/gc/pgen.go:318
  2. func compileSSA(fn *Node, worker int) {
  3. f := buildssa(fn, worker)
  4. ......
  5. pp := newProgs(fn, worker)
  6. defer pp.Free()
  7. genssa(f, pp)
  8. ......
  9. pp.Flush()
  10. }

中间代码的生成过程是从 AST 抽象语法树到 SSA 中间代码的转换过程,在这期间会对语法树中的关键字再进行改写,改写后的语法树会经过多轮处理转变成最后的 SSA 中间代码。

2.5 机器码生成

最后go编译器将会根据不同指令集对中间代码生成相应的机器码。

三、最后

通过对go编译过程的梳理,我们能够对go语言的实现有一些基本的了解,当我们了解了编译过程后,能帮助我们更好的理解业务的开发方式。

参考博客:https://draveness.me/