本篇文章主要涉及到go语言编译过程的概述,包含编译原理简介、go编译过程以及go build和go run的执行逻辑。
一、编译原理简介
和c语言类似,go语言是一门需要编译才能运行的编程语言。也就是说,需要将go语言源码编译为可执行文件。
在《深入了解计算机系统》一书中,我们可以了解到,c源码编译过程为:
c语言作为高级语言的鼻祖,其他编译语言的编译过程都是大同小异。
接下来我们在介绍go语言编译过程之前,先来了解一些编译知识。
1、 抽象语法树
抽象语法树(Abstract Syntax Tree、AST)是源代码语法结构的一种抽象表示,它用树状的方式表示编程语言的语法结构。
编译器通过语法分析精简代码后生成一个抽象语法树。
2、 静态单赋值
静态单赋值(Static Single Assignment、SSA)是中间代码的特性。静态单赋值主要作用的是对代码进行优化。
x := 1x := 2y := 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文件:
SourceFile = PackageClause ";" { ImportDecl ";" } { TopLevelDecl ";" } .
而语法分析会将词法分析输入的token序列转换为有意义的语法树:
"json.go": SourceFile {PackageName: "json",ImportDecl: []Import{"io",},TopLevelDecl: ...}
将token序列转换为抽象语法树(AST)的过程使用语法解析器完成,每一个AST都对应着一个单独的go源文件,抽象语法树包含当前文件所属的包名、定义的常量、结构体和函数。语法解析发生任何语法错误,都会终止编译报错结束。
1.2 类型检查
当生成抽象语法树之后,就会进入类型检查阶段,类型检查会按照以下顺序对语法树进行遍历检查:
- 1、常量、类型和函数名及类型
- 2、变量的赋值和初始化
- 3、函数和闭包的主体
- 4、哈希键值对的类型
- 5、导入函数体
- 6、外部的声明
类型检查阶段会保证抽象语法树每个节点都没有类型错误,如果有,就会终止编译报错结束。
在该阶段,还会对go内置函数进行内部展开,如make函数会展开为runtime.makechan,runtime.makeslice等内部实现。
1.3 通用SSA生成和机器码生成
在通过词法语法分析生成抽象语法树AST,并通过类型检查确认源文件没有预发和类型错误后,会进入到中间代码的生成阶段。
go原因的中间代码的生成是通过SSA特性进行生成的,通过SSA会分析出无用的代码并进行优化。
编译器通过src/cmd/compile/internal/gc/pgen.go:358 compileFunctions()起多个协程将go项目中的全部函数进行编译生成中间代码。
在不同的机器需要生成不同的机器码,go语言中生成机器码的源码包在src/cmd/compile/internal中,里面包含了不同的机器码生成的代码。
2、具体编译过程
go编译器的入口在:
// src/cmd/compile/internal/gc/main.go:148func Main(archInit func(*Arch)) {......}
Main()函数中:该函数的释义为:Main解析命令行中指定的标志和Go源文件参数,类型检查已解析的Go包,编译到机器码,最后将编译后的机器码定义写入磁盘。
2.1 编译准备工作
初始化编译变量、初始化内部编译包、获取参数、检查版本、定义输出文件格式等等
2.2 词法分析和语法分析
2.2.1 入口
// src/cmd/compile/internal/gc/main.go:578timings.Start("fe", "parse")lines := parseFiles(flag.Args())timings.Stop()timings.AddEvent(int64(lines), "lines")
词法分析和语法分析的核心调用为:
// src/cmd/compile/internal/syntax/syntax.go:79p.init(base, src, errh, pragh, mode) // 词法分析初始化p.next() // 词法处理return p.fileOrNil(), p.first // 返回抽象语法树
2.2.2 词法分析
p.init()最终初始化了词法分析器scanner:
// src/cmd/compile/internal/syntax/scanner.go:46func (s *scanner) init(src io.Reader, errh func(line, col uint, msg string), mode uint) {s.source.init(src, errh)s.mode = modes.nlsemi = false}
scanner结构体主要包含源文件、启用的模式和当前被扫描到的token
// src/cmd/compile/internal/syntax/scanner.go:30type scanner struct {sourcemode uintnlsemi bool // if set '\n' and EOF translate to ';'// current token, valid after calling next()line, col uintblank bool // line is blank up to coltok token // token结构体中定义了全部的token类型lit string // valid if tok is _Name, _Literal, or _Semi ("semicolon", "newline", or "EOF"); may be malformed if bad is truebad bool // valid if tok is _Literal, true if a syntax error occurred, lit may be malformedkind LitKind // valid if tok is _Literalop Operator // valid if tok is _Operator, _AssignOp, or _IncOpprec int // valid if tok is _Operator, _AssignOp, or _IncOp}
编译器通过p.next()对源码文件进行词法分析,该函数是一个几百行的switch-case语句。主要处理逻辑为:next()函数每次都通过nextch()函数获取到最近的未被解析的字符,然后根据switch-case匹配不同的逻辑,空行则跳过,数字则执行number�()函数等。
当通过p.next()进行词法分析完毕生成Token后,就开始进行语法分析了。
2.2.3 语法分析
语法分析主要在p.fileOrNil()函数中实现。
语法分析返回一个File结构
// src/cmd/compile/internal/syntax/nodes.go:36// package PkgName; DeclList[0], DeclList[1], ...type File struct {Pragma PragmaPkgName *NameDeclList []DeclLines uintnode}
p.fileOrNil()函数体定义了import、顶层声明(常量、类型、变量、函数、方法)
最后生成的抽象语法树格式为:
SourceFile = PackageClause ";" { ImportDecl ";" } { TopLevelDecl ";" } .
2.3 类型检查
当词法分析和语法分析结束后,编译器接着会拿生成的抽象语法树进行类型检查。
注意:类型检查和SSA编译存在交叉
分别会进行以下检查:
- 1、常量、类型以及函数的名称和类型检查
- 2、变量赋值检查,此时会检查接口的实现
- 3、检查函数体
- 4、决定如何捕获变量
- 5、检查函数内联
- 6、逃逸分析 - 即栈变量逃逸到堆上的分析
- 7、转换闭包体以正确引用捕获的变量
- 8、为SSA汇编做准备-
_initssaconfig()_ - 9、编译顶层函数 -
_compileFunctions() - SSA阶段_ - 10、检查外部声明
类型检查的核心逻辑在typecheck()和typecheck1()函数中:
// src/cmd/compile/internal/gc/typecheck.go:202func typecheck(n *Node, top int) (res *Node) {}// src/cmd/compile/internal/gc/typecheck.go:326func typecheck1(n *Node, top int) (res *Node) {}
在typecheck1()我们将能看到不同所有操作类型的执行逻辑,如前面讲过的make函数的展开就在此处进行:
case OMAKE:.......switch t.Etype {default:......case TSLICE:......n.Op = OMAKESLICEcase TMAP:......n.Op = OMAKEMAPcase TCHAN:......n.Op = OMAKECHAN}
如上,OMAKE会根据具体操作展开为OMAKESLICE、OMAKEMAP、OMAKECHAN,方便后续处理。
2.4 中间代码生成
// Prepare for SSA compilation.// This must be before peekitabs, because peekitabs// can trigger function compilation.initssaconfig()timings.Start("be", "compilefuncs")fcount = 0for i := 0; i < len(xtop); i++ {n := xtop[i]if n.Op == ODCLFUNC {funccompile(n)fcount++}}timings.AddEvent(fcount, "funcs")compileFunctions()
2.4.1 初始化配置
首先初始化ssa.Types该结构体包含go所有基本类型对应的指针。
types_ := ssa.NewTypes()
然后通过types.NewPtr根据类型生成指向这些类型的指针,然后根据编译器的配置将生成的指针类型缓存在当前类型中,优化类型指针的获取效率。
_ = types.NewPtr(types.Types[TINTER]) // *interface{}_ = types.NewPtr(types.NewPtr(types.Types[TSTRING])) // **string...... // *int64_ = types.NewPtr(types.Errortype) // *error
�然后加载ssaConfig参数为:目标机器的cpu架构,ssa.Types和debug配置
ssaConfig = ssa.NewConfig(thearch.LinkArch.Name, *types_, Ctxt, Debug.N == 0)
NewConfig()函数主要根据不同的cpu架构进行一些寄存器、指针大小等设置。
接下来设置一些可能会使用的运行时函数
2.4.2 遍历替换
func walk(fn *Node)func walkappend(n *Node, init *Nodes, dst *Node) *Node...func walkrange(n *Node) *Nodefunc walkselect(sel *Node)func walkselectcases(cases *Nodes) []*Nodefunc walkstmt(n *Node) *Nodefunc walkstmtlist(s []*Node)func walkswitch(sw *Node)
接着遍历抽象语法树中的函数,通过一些列walk()函数,将关键字和内建函数转换成函数调用。
2.4.3 SSA生成
在经过系列函数walk()处理后,抽象语法树就不再改变,然后就会通过compileSSA()函数生成SSA文件。
// src/cmd/compile/internal/gc/pgen.go:318func compileSSA(fn *Node, worker int) {f := buildssa(fn, worker)......pp := newProgs(fn, worker)defer pp.Free()genssa(f, pp)......pp.Flush()}
中间代码的生成过程是从 AST 抽象语法树到 SSA 中间代码的转换过程,在这期间会对语法树中的关键字再进行改写,改写后的语法树会经过多轮处理转变成最后的 SSA 中间代码。
2.5 机器码生成
最后go编译器将会根据不同指令集对中间代码生成相应的机器码。
三、最后
通过对go编译过程的梳理,我们能够对go语言的实现有一些基本的了解,当我们了解了编译过程后,能帮助我们更好的理解业务的开发方式。
