走进Golang之编译器原理

为了学好Golang底层知识装逼,折腾了一下编译器相关知识。下面的内容并不会提高你的生产技能点,但能够提升你的装逼指数。请按需阅读!html


本文目录速览:前端

认识 go build

当咱们敲下 go build 的时候,咱们的写的源码文件究竟经历了哪些事情?最终变成了可执行文件。git

这个命令会编译go代码,今天就来一块儿看看go的编译过程吧!github

首先先来认识如下go的代码源文件分类golang

  • 命令源码文件:简单说就是含有 main 函数的那个文件,一般一个项目一个该文件,我也没想过须要两个命令源文件的项目
  • 测试源码文件:就是咱们写的单元测试的代码,都是以 _test.go 结尾
  • 库源码文件:没有上面特征的就是库源码文件,像咱们使用的不少第三方包都属于这部分

go build 命令就是用来编译这其中的 命令源码文件 以及它依赖的 库源码文件。下面表格是一些经常使用的选项在这里集中说明如下。express

可选项 说明
-a 将命令源码文件与库源码文件所有从新构建,即便是最新的
-n 把编译期间涉及的命令所有打印出来,但不会真的执行,很是方便咱们学习
-race 开启竞态条件的检测,支持的平台有限制
-x 打印编译期间用到的命名,它与 -n 的区别是,它不只打印还会执行

接下来就用一个 hello world 程序来演示如下上面的命令选项。segmentfault

go的演示代码

若是对上面的代码执行 go build -n 咱们看一下输出信息:后端

编译代码

来分析下整个执行过程缓存

编译器编译过程

这一部分是编译的核心,经过 compilebuildidlink 三个命令会编译出可执行文件 a.out

而后经过 mv 命令把 a.out 移动到当前文件夹下面,并改为跟项目文件同样的名字(这里也能够本身指定名字)。

文章的后面部分,咱们主要讲的就是 compilebuildid、 link 这三个命令涉及的编译过程。

编译器原理

这是go编译器的源码路径

编译器流程

如上图所见,整个编译器能够分为:编译前端与编译后端;如今咱们看看每一个阶段编译器都作了些什么事情。先来从前端部分开始。

词法分析

词法分析简单来讲就是将咱们写的源代码翻译成 Token,这是个什么意思呢?

为了理解 Golang 从源代码翻译到 Token 的过程,咱们用一段代码来看一下翻译的一一对应状况。

源码到token

图中重要的地方我都进行了注释,不过这里仍是有几句话多说一下,咱们看着上面的代码想象如下,若是要咱们本身来实现这个“翻译工做”,程序要如何识别 Token 呢?

首先先来给Go的token类型分个类:变量名、字面量、操做符、分隔符以及关键字。咱们须要把一堆源代码按照规则进行拆分,其实就是分词,看着上面的例子代码咱们能够大概制定一个规则以下:

  1. 识别空格,若是是空格能够分一个词;
  2. 遇到 ()、'<'、'>' 等这些特殊运算符的时候算一个分词;
  3. 遇到 " 或者 数字字面量算分词。

经过上面的简单分析,其实能够看出源代码转 Token 其实没有很是复杂,彻底能够本身写代码实现出来。固然也有不少经过正则的方式实现的比较通用的词法分析器,像 Golang 早期就用的是 lex,在后面的版本中才改用了用go来本身实现。

语法分析

通过词法分析后,咱们拿到的就是 Token 序列,它将做为语法分析器的输入。而后通过处理后生成 AST 结构做为输出。

所谓的语法分析就是将 Token 转化为可识别的程序语法结构,而 AST 就是这个语法的抽象表示。构造这颗树有两种方法。

  1. 自上而下

这种方式会首先构造根节点,而后就开始扫描 Token,遇到 STRING 或者其它类型就知道这是在进行类型申明,func 就表示是函数申明。就这样一直扫描直到程序结束。

  1. 自下而上

这种是与上一种方式相反的,它先构造子树,而后再组装成一颗完整的树。

go语言进行语法分析使用的是自下而上的方式来构造 AST,下面咱们就来看一下go语言经过 Token 构造的这颗树是什么样子。

go的AST树

这其中有意思的地方我所有用文字标注出来了。你会发现其实每个 AST 树的节点都与一个 Token 实际位置相对应。

这颗树构造后,咱们能够看到不一样的类型是由对应的结构体来进行表示的。这里若是有语法、词法错误是不会被解析出来的。由于到目前为止说白了都是进行的字符串处理。

语义分析

编译器里边都把语法分析后的阶段叫作 语义分析,而go的这个阶段叫 类型检查;可是我看了如下go本身的文档,其实作的事情没有太大差异,咱们仍是按照主流规范来写这个过程。

那么语义分析(类型检查)究竟要作些什么呢?

AST 生成后,语义分析将使用它做为输入,而且的有一些相关的操做也会直接在这颗树上进行改写。

首先就是 Golang 文档中提到的会进行类型检查,还有类型推断,查看类型是否匹配,是否进行隐式转化(go没有隐式转化)。以下面的文字所说:

The AST is then type-checked. The first steps are name resolution and type inference, which determine which object belongs to which identifier, and what type each expression has. Type-checking includes certain extra checks, such as "declared and not used" as well as determining whether or not a function terminates.

大意是:生成AST以后是类型检查(也就是咱们这里说的语义分析),第一步是进行名称检查和类型推断,签订每一个对象所属的标识符,以及每一个表达式具备什么类型。类型检查也还有一些其它的检查要作,像“声明未使用”以及肯定函数是否停止。

Certain transformations are also done on the AST. Some nodes are refined based on type information, such as string additions being split from the arithmetic addition node type. Some other examples are dead code elimination, function call inlining, and escape analysis.

这一段是说:AST也会进行转换,有些节点根据类型信息进行精简,好比从算术加法节点类型中拆分出字符串加法。其它一些例子像dead code的消除,函数调用内联和逃逸分析。

上面两段文字来自 golang compile

这里多说一句,咱们经常在debug代码的时候,须要禁止内联,其实就是操做的这个阶段。

# 编译的时候禁止内联
go build -gcflags '-N -l'

-N 禁止编译优化
-l 禁止内联,禁止内联也能够必定程度上减少可执行程序大小

通过语义分析以后,就能够说明咱们的代码结构、语法都是没有问题的。因此编译器前端主要就是解析出编译器后端能够处理的正确的AST结构。

接下来咱们看看编译器后端又有哪些事情要作。

机器只可以理解二进制并运行,因此编译器后端的任务简单来讲就是怎么把AST翻译成机器码。

中间码生成

既然已经拿到AST,机器运行须要的又是二进制。为何不直接翻译成二进制呢?其实到目前为止从技术上来讲已经彻底没有问题了。

可是,
咱们有各类各样的操做系统,有不一样的CPU类型,每一种的位数可能不一样;寄存器可以使用的指令也不一样,像是复杂指令集与精简指令集等;在进行各个平台的兼容以前,咱们还须要替换一些底层函数,好比咱们使用make来初始化slice,此时会根据传入的类型替换为:makeslice64 或者 makeslice。固然还有像painc、channel等等函数的替换也会在中间码生成过程当中进行替换。这一部分的替换操做能够在这里查看

中间码存在的另一个价值是提高后端编译的重用,好比咱们定义好了一套中间码应该是长什么样子,那么后端机器码生成就是相对固定的。每一种语言只须要完成本身的编译器前端工做便可。这也是你们能够看到如今开发一门新语言速度比较快的缘由。编译是绝大部分均可以重复使用的。

并且为了接下来的优化工做,中间代码存在具备非凡的意义。由于有那么多的平台,若是有中间码咱们能够把一些共性的优化都放到这里。

中间码也是有多种格式的,像 Golang 使用的就是SSA特性的中间码(IR),这种形式的中间码,最重要的一个特性就是最在使用变量以前老是定义变量,而且每一个变量只分配一次。

代码优化

在go的编译文档中,我并没找到独立的一步进行代码的优化。不过根据咱们上面的分析,能够看到其实代码优化过程遍及编译器的每个阶段。你们都会力所能及的作些事情。

一般咱们除了用高效代码替换低效的以外,还有以下的一些处理:

  • 并行性,充分利用如今多核计算机的特性
  • 流水线,cpu有时候在处理a指令的时候,还能同时处理b指令
  • 指令的选择,为了让cpu完成某些操做,须要使用指令,可是不一样的指令效率有很是大的差异,这里会进行指令优化
  • 利用寄存器与高速缓存,咱们都知道cpu从寄存器取是最快的,从高速缓存取次之。这里会进行充分的利用

机器码生成

通过优化后的中间代码,首先会在这个阶段被转化为汇编代码(Plan9),而汇编语言仅仅是机器码的文本表示,机器还不能真的去执行它。因此这个阶段会调用汇编器,汇编器会根据咱们在执行编译时设置的架构,调用对应代码来生成目标机器码。

这里比有意思的是,Golang 总说本身的汇编器是跨平台的。其实他也是写了多分代码来翻译最终的机器码。由于在入口的时候他会根据咱们所设置的 GOARCH=xxx 参数来进行初始化处理,而后最终调用对应架构编写的特定方法来生成机器码。这种上层逻辑一致,底层逻辑不一致的处理方式很是通用,很是值得咱们学习。咱们简单来一下这个处理。

首先看入口函数 cmd/compile/main.go:main()

var archInits = map[string]func(*gc.Arch){
    "386":      x86.Init,
    "amd64":    amd64.Init,
    "amd64p32": amd64.Init,
    "arm":      arm.Init,
    "arm64":    arm64.Init,
    "mips":     mips.Init,
    "mipsle":   mips.Init,
    "mips64":   mips64.Init,
    "mips64le": mips64.Init,
    "ppc64":    ppc64.Init,
    "ppc64le":  ppc64.Init,
    "s390x":    s390x.Init,
    "wasm":     wasm.Init,
}

func main() {
    // 从上面的map根据参数选择对应架构的处理
    archInit, ok := archInits[objabi.GOARCH]
    if !ok {
        ......
    }
    // 把对应cpu架构的对应传到内部去
    gc.Main(archInit)
}

而后在 cmd/internal/obj/plist.go 中调用对应架构的方法进行处理

func Flushplist(ctxt *Link, plist *Plist, newprog ProgAlloc, myimportpath string) {
    ... ...
    for _, s := range text {
        mkfwd(s)
        linkpatch(ctxt, s, newprog)
        // 对应架构的方法进行本身的机器码翻译
        ctxt.Arch.Preprocess(ctxt, s, newprog)
        ctxt.Arch.Assemble(ctxt, s, newprog)

        linkpcln(ctxt, s)
        ctxt.populateDWARF(plist.Curfn, s, myimportpath)
    }
}

整个过程下来,能够看到编译器后端有不少工做须要作的,你须要对某一个指令集、cpu的架构了解,才能正确的进行翻译机器码。同时不能仅仅是正确,一个语言的效率是高仍是低,也在很大程度上取决于编译器后端的优化。特别是即将进入AI时代,愈来愈多的芯片厂商诞生,我估计之后对这方面人才的需求会变得愈来愈旺盛。

总结

总结一下学习编译器这部分古老知识带给个人几个收获:

  1. 知道整个编译由几个阶段构成,每一个阶段作什么事情;可是更深刻的每一个阶段实现的一些细节还不知道,也不打算知道;
  2. 就算是编译器这种复杂,很底层的东西也是能够经过分解,让每个阶段独立变得简单、可复用,这对我在作应用开发有一些意义;
  3. 分层是为了划分指责,可是某些事情还须要全局的去作,好比优化,其实每个阶段都会去作;对于咱们设计系统也是有必定参考意义的;
  4. 了解到 Golang 对外暴露的不少方法实际上是语法糖(如:make、painc etc.),编译器会帮我忙进行翻译,最开始我觉得是go代码层面在运行时去作的,相似工厂模式,如今回头来看本身真是太天真了;
  5. 对接下来准备学习Go的运行机制、以及Plan9汇编进行了一些基础准备。

本文的不少信息都来自下面的资料。

相关文章
相关标签/搜索