Go 语言编译过程概述

Golang 是一门须要编译才能运行的编程语言,也就说代码在运行以前须要经过编译器生成二进制机器码,随后二进制文件才能在目标机器上运行,若是咱们想要了解 Go 语言的实现原理,理解它的编译过程就是一个没有办法绕过的事情。前端

这一节会先对 Go 语言编译的过程进行概述,从顶层介绍编译器执行的几个步骤,随后的章节会分别剖析各个步骤完成的工做和实现原理,同时也会对一些须要预先掌握的知识进行介绍和准备,确保后面的章节可以被更好的理解。git

目录

预备知识

想要深刻了解 Go 语言的编译过程,须要提早了解一下编译过程当中涉及的一些术语和专业知识。这些知识其实在咱们的平常工做和学习中比较难用到,可是对于理解编译的过程和原理仍是很是重要的。这一小节会简单挑选几个常见而且重要的概念提早进行介绍,减小后面章节的理解压力。github

抽象语法树

抽象语法树(AST)是源代码语法的结构的一种抽象表示,它用树状的方式表示编程语言的语法结构。抽象语法树中的每个节点都表示源代码中的一个元素,每一颗子树都表示一个语法元素,例如一个 if else 语句,咱们能够从 2 * 3 + 7 这一表达式中解析出下图所示的抽象语法树。golang

abstract-syntax-tree

做为编译器经常使用的数据结构,抽象语法树抹去了源代码中不重要的一些字符 - 空格、分号或者括号等等。编译器在执行完语法分析以后会输出一个抽象语法树,这棵树会辅助编译器进行语义分析,咱们能够用它来肯定结构正确的程序是否存在一些类型不匹配或不一致的问题。web

静态单赋值

静态单赋值(SSA)是中间代码的一个特性,若是一个中间代码具备静态单赋值的特性,那么每一个变量就只会被赋值一次,在实践中咱们一般会用添加下标的方式实现每一个变量只能被赋值一次的特性,这里如下面的代码举一个简单的例子:编程

x := 1
x := 2
y := x

根据分析,咱们其实可以发现上述的代码其实并不须要第一个将 1 赋值给 x 的表达式,也就是这一表达式在整个代码片断中是没有做用的:后端

x1 := 1
x2 := 2
y1 := x2

从使用 SSA 的『中间代码』咱们就能够很是清晰地看出变量 y1 的值和 x1 是彻底没有任何关系的,因此在机器码生成时其实就能够省略第一步,这样就能减小须要执行的指令来优化这一段代码。浏览器

根据 Wikipedia 对 SSA 的介绍来看,在中间代码中使用 SSA 的特性可以为整个程序实现如下的优化:bash

  1. 常数传播(constant propagation)
  2. 值域传播(value range propagation)
  3. 稀疏有条件的常数传播(sparse conditional constant propagation)
  4. 消除无用的程式码(dead code elimination)
  5. 全域数值编号(global value numbering)
  6. 消除部分的冗余(partial redundancy elimination)
  7. 强度折减(strength reduction)
  8. 寄存器分配(register allocation)

从 SSA 的做用咱们就能看出,由于它的主要做用就是代码的优化,因此是编译器后端(主要负责目标代码的优化和生成)的一部分;固然,除了 SSA 以外代码编译领域还有很是多的中间代码优化方法,优化编译器生成的代码是一个很是古老而且复杂的领域,这里就不会展开介绍了。数据结构

指令集架构

最后要介绍的一个预备知识就是指令集的架构了,不少开发者都会遇到在生产环境运行的结果和本地不一样的问题,致使这种状况的缘由其实很是复杂,不一样机器使用不一样的指令也是可能的缘由之一。

咱们大多数开发者都会使用 x86_64 的 Macbook 做为工做上主要使用的硬件,在命令行中输入 uname -m 就可以得到当前机器上硬件的信息:

$ uname -m
x86_64

x86_64 是目前比较常见的指令集架构之一,除了 x86_64 以外,还包含其余类型的指令集架构,例如 amd6四、arm64 以及 mips 等等,不一样的处理器使用了大不相同的机器语言,因此不少编程语言为了在不一样的机器上运行须要将源代码根据架构翻译成不一样的机器代码。

复杂指令集计算机(CISC)和精简指令集计算机(RISC)是目前的两种 CPU 区别,它们的在设计理念上会有一些不一样,从名字咱们就能看出来这两种不一样的设计有什么区别,复杂指令集经过增长指令的数量减小须要执行的质量数,而精简指令集能使用更少的指令完成目标的计算任务;早期的 CPU 为了减小机器语言指令的数量使用复杂指令集完成计算任务,这二者以前的区别其实就是设计上的权衡,咱们会在后面的章节 机器码生成 中详细介绍指令集架构,固然各位读者也能够自行搜索和学习。

编译原理

Go 语言编译器的源代码在 cmd/compile 目录中,目录下的文件共同构成了 Go 语言的编译器,学过编译原理的人可能据说过编译器的前端和后端,编译器的前端通常承担着词法分析、语法分析、类型检查和中间代码生成几部分工做,而编译器后端主要负责目标代码的生成和优化,也就是将中间代码翻译成目标『机器』可以运行的机器码。

complication-process

Go 的编译器在逻辑上能够被分红四个阶段:词法与语法分析、类型检查和 AST 转换、通用 SSA 生成和最后的机器代码生成,在这一节咱们会使用比较少的篇幅分别介绍这四个阶段作的工做,后面的章节会具体介绍每个阶段的具体内容。

词法与语法分析

全部的编译过程其实都是从解析代码的源文件开始的,词法分析的做用就是解析源代码文件,它将文件中的字符串序列转换成 Token 序列,方便后面的处理和解析,咱们通常会把执行词法分析的程序称为词法解析器(lexer)。

而语法分析的输入就是词法分析器输出的 Token 序列,这些序列会按照顺序被语法分析器进行解析,语法的解析过程就是将词法分析生成的 Token 按照语言定义好的文法(Grammar)自下而上或者自上而下的进行规约,每个 Go 的源代码文件最终会被概括成一个 SourceFile 结构:

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

标准的 Golang 语法解析器使用的就是 LALR(1) 的文法,语法解析的结果其实就是上面介绍过的抽象语法树(AST),每个 AST 都对应着一个单独的 Go 语言文件,这个抽象语法树中包括当前文件属于的包名、定义的常量、结构体和函数等。

golang-files-and-ast

若是在语法解析的过程当中发生了任何语法错误,都会被语法解析器发现并将消息打印到标准输出上,整个编译过程也会随着错误的出现而被停止。

咱们会在这一章后面的小节 词法与语法分析 中介绍 Go 语言的文法和它的词法与语法解析过程。

类型检查

当拿到一组文件的抽象语法树 AST 以后,Go 语言的编译器会对语法树中定义和使用的类型进行检查,类型检查分别会按照顺序对不一样类型的节点进行验证,按照如下的顺序进行处理:

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

经过对每一棵抽象节点树的遍历,咱们在每个节点上都会对当前子树的类型进行验证保证当前节点上不会出现类型错误的问题,全部的类型错误和不匹配都会在这一个阶段被发现和暴露出来。

类型检查的阶段不止会对树状结构的节点进行验证,同时也会对一些内建的函数进行展开和改写,例如 make 关键字在这个阶段会根据子树的结构被替换成 makeslice 或者 makechan 等函数。

golang-keyword-make

咱们其实可以看出类型检查不止作了验证类型的工做,还作了对 AST 进行改写,处理 Go 语言内置关键字的活,因此,这一过程在整个编译流程中仍是很是重要的,没有这个步骤不少关键字其实就没有办法工做,后面的章节 类型检查 会介绍这一步骤。

中间代码生成

当咱们将源文件转换成了抽象语法树、对整棵树的语法进行解析并进行类型检查以后,就能够认为当前文件中的代码基本上不存在没法编译或者语法错误的问题了,Go 语言的编译器就会将输入的 AST 转换成中间代码。

Go 语言编译器的中间代码使用了 SSA(Static Single Assignment Form) 的特性,若是咱们在中间代码生成的过程当中使用这种特性,就可以比较容易的分析出代码中的无用变量和片断并对代码进行优化。

在类型检查以后,就会经过一个名为 compileFunctions 的函数开始对整个 Go 语言项目中的所有函数进行编译,这些函数会在一个编译队列中等待几个后端工做协程的消费,这些 Goroutine 会将全部函数对应的 AST 转换成使用 SSA 特性的中间代码。

中间代码生成 这一章节会详细介绍中间代码的生成过程并简单介绍 Golang 是如何在中间代码中使用 SSA 的特性的,在这里就不展开介绍其余的内容了。

机器码生成

Go 语言源代码的 cmd/compile/internal 中包含了很是多机器码生成相关的包,不一样类型的 CPU 分别使用了不一样的包进行生成 amd6四、arm、arm6四、mips、mips6四、ppc6四、s390x、x86 和 wasm,也就是说 Go 语言可以在上述的 CPU 指令集类型上运行,其中比较有趣的就是 WebAssembly 了。

做为一种在栈虚拟机上使用的二进制指令格式,它的设计的主要目标就是在 Web 浏览器上提供一种具备高可移植性的目标语言。Go 语言的编译器既然可以生成 WASM 格式的指令,那么就可以运行在常见的主流浏览器中。

$ GOARCH=wasm GOOS=js go build -o lib.wasm main.go

咱们可使用上述的命令将 Go 的源代码编译成可以在浏览器上运行的『汇编语言』,除了这种新兴的指令以外,Go 语言还支持了几乎所有常见的 CPU 指令集类型,也就是说它编译出的机器码可以在使用上述指令集的机器上运行。

机器码生成 一节会详细介绍将中间代码翻译到不一样目标机器的过程,在这个章节中也会简单介绍不一样的指令集架构的区别。

编译器入口

Go 语言的编译器入口在 src/cmd/compile/internal/pc 包中的 main.go 文件,这个 600 多行的 Main 函数就是 Go 语言编译器的主程序,这个函数会先获取命令行传入的参数并更新编译的选项和配置,随后就会开始运行 parseFiles 函数对输入的全部文件进行词法与语法分析获得文件对应的抽象语法树:

func Main(archInit func(*Arch)) {
    // ...

    lines := parseFiles(flag.Args())

接下来就会分九个阶段对抽象语法树进行更新和编译,就像咱们在上面介绍的,整个过程会经历类型检查、SSA 中间代码生成以及机器码生成三个部分:

  1. 检查常量、类型和函数的类型;
  2. 处理变量的赋值;
  3. 对函数的主体进行类型检查;
  4. 决定如何捕获变量;
  5. 检查内联函数的类型;
  6. 进行逃逸分析;
  7. 将闭包的主体转换成引用的捕获变量;
  8. 编译顶层函数;
  9. 检查外部依赖的声明;

了解了剩下的编译过程以后,咱们从新回到词法和语法分析后的具体流程,在这里编译器会对生成语法树中的节点执行类型检查,除了常量、类型和函数这些顶层声明以外,它还会对变量的赋值语句、函数主体等结构进行检查:

for i := 0; i < len(xtop); i++ {
        n := xtop[i]
        if op := n.Op; op != ODCL && op != OAS && op != OAS2 && (op != ODCLTYPE || !n.Left.Name.Param.Alias) {
            xtop[i] = typecheck(n, ctxStmt)
        }
    }

    for i := 0; i < len(xtop); i++ {
        n := xtop[i]
        if op := n.Op; op == ODCL || op == OAS || op == OAS2 || op == ODCLTYPE && n.Left.Name.Param.Alias {
            xtop[i] = typecheck(n, ctxStmt)
        }
    }

    for i := 0; i < len(xtop); i++ {
        n := xtop[i]
        if op := n.Op; op == ODCLFUNC || op == OCLOSURE {
            typecheckslice(Curfn.Nbody.Slice(), ctxStmt)
        }
    }

    checkMapKeys()

    for _, n := range xtop {
        if n.Op == ODCLFUNC && n.Func.Closure != nil {
            capturevars(n)
        }
    }

    escapes(xtop)

    for _, n := range xtop {
        if n.Op == ODCLFUNC && n.Func.Closure != nil {
            transformclosure(n)
        }
    }

类型检查会对传入节点的子节点进行遍历,这个过程会对 make 等关键字进行展开和重写,类型检查结束以后并无输出新的数据结构,只是改变了语法树中的一些节点,同时这个过程的结束也意味着源代码中已经不存在语法错误和类型错误,中间代码和机器码也均可以正常的生成了。

initssaconfig()

    peekitabs()

    for i := 0; i < len(xtop); i++ {
        n := xtop[i]
        if n.Op == ODCLFUNC {
            funccompile(n)
        }
    }

    compileFunctions()

    for i, n := range externdcl {
        if n.Op == ONAME {
            externdcl[i] = typecheck(externdcl[i], ctxExpr)
        }
    }

    checkMapKeys()
}

在主程序运行的最后,会将顶层的函数编译成中间代码并根据目标的 CPU 架构生成机器码,不过这里其实也可能会再次对外部依赖进行类型检查以验证正确性。

总结

Go 语言的编译过程实际上是很是有趣而且值得学习的,经过对 Go 语言四个编译阶段的分析和对编译器主函数的梳理,咱们可以对 Golang 的实现有一些基本的理解,掌握编译的过程以后,Go 语言对于咱们来说也再也不是一个黑盒,因此学习其编译原理的过程仍是很是让人着迷的。

相关文章

Reference

相关文章
相关标签/搜索