这个命令会编译 go 代码,今天就来一起看看 go 的编译过程吧! 首先先来认识一下 go 的代码源文件分类 • 命令源码文件:简单说就是含有 main 函数的那个文件,通常一个项目一个该文件,我也没想过需要两个命令源文件的项目• 测试源码文件:就是我们写的单元测试的代码,都是以 _test.go 结尾• 库源码文件:没有上面特征的就是库源码文件,像我们使用的很多第三方包都属于这部分 go build 命令就是用来编译这其中的 命令源码文件 以及它依赖的 库源码文件。下面表格是一些常用的选项在这里集中说明一下。 接下来就用一个 hello world 程序来演示一下上面的命令选项。
如果对上面的代码执行 go build-n 我们看一下输出信息: 来分析下整个执行过程 这一部分是编译的核心,通过 compile、 buildid、 link 三个命令会编译出可执行文件 a.out。 然后通过 mv 命令把 a.out 移动到当前文件夹下面,并改成跟项目文件一样的名字(这里也可以自己指定名字)。 文章的后面部分,我们主要讲的就是 compile、 buildid、 link 这三个命令涉及的编译过程。
编译器原理
这是 go 编译器的源码路径:https://github.com/golang/go/tree/master/src/cmd/compile 如上图所见,整个编译器可以分为:编译前端与编译后端;现在我们看看每个阶段编译器都做了些什么事情。先来从前端部分开始。
编译器里边都把语法分析后的阶段叫做 语义分析,而 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.
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,机器运行需要的又是二进制。为什么不直接翻译成二进制呢?其实到目前为止从技术上来说已经完全没有问题了。 但是, 我们有各种各样的操作系统,有不同的 CPU 类型,每一种的位数可能不同;寄存器能够使用的指令也不同,像是复杂指令集与精简指令集等;在进行各个平台的兼容之前,我们还需要替换一些底层函数,比如我们使用 make 来初始化 slice,此时会根据传入的类型替换为:makeslice64 或者 makeslice。当然还有像 painc、channel 等等函数的替换也会在中间码生成过程中进行替换。这一部分的替换操作可以在这里查看 中间码存在的另外一个价值是提升后端编译的重用,比如我们定义好了一套中间码应该是长什么样子,那么后端机器码生成就是相对固定的。每一种语言只需要完成自己的编译器前端工作即可。这也是大家可以看到现在开发一门新语言速度比较快的原因。编译是绝大部分都可以重复使用的。 而且为了接下来的优化工作,中间代码存在具有非凡的意义。因为有那么多的平台,如果有中间码我们可以把一些共性的优化都放到这里。 中间码也是有多种格式的,像 Golang 使用的就是 SSA 特性的中间码(IR),这种形式的中间码,最重要的一个特性就是最在使用变量之前总是定义变量,并且每个变量只分配一次。
代码优化
在 go 的编译文档中,我并没找到独立的一步进行代码的优化。不过根据我们上面的分析,可以看到其实代码优化过程遍布编译器的每一个阶段。大家都会力所能及的做些事情。 通常我们除了用高效代码替换低效的之外,还有如下的一些处理: • 并行性,充分利用现在多核计算机的特性• 流水线,cpu 有时候在处理 a 指令的时候,还能同时处理b指令• 指令的选择,为了让 cpu 完成某些操作,需要使用指令,但是不同的指令效率有非常大的差别,这里会进行指令优化• 利用寄存器与高速缓存,我们都知道 cpu 从寄存器取是最快的,从高速缓存取次之。这里会进行充分的利用
整个过程下来,可以看到编译器后端有很多工作需要做的,你需要对某一个指令集、cpu 的架构了解,才能正确的进行翻译机器码。同时不能仅仅是正确,一个语言的效率是高还是低,也在很大程度上取决于编译器后端的优化。特别是即将进入 AI 时代,越来越多的芯片厂商诞生,我估计以后对这方面人才的需求会变得越来越旺盛。
总结
总结一下学习编译器这部分古老知识带给我的几个收获: • 知道整个编译由几个阶段构成,每个阶段做什么事情;但是更深入的每个阶段实现的一些细节还不知道,也不打算知道;• 就算是编译器这种复杂,很底层的东西也是可以通过分解,让每一个阶段独立变得简单、可复用,这对我在做应用开发有一些意义;• 分层是为了划分职责,但是某些事情还需要全局的去做,比如优化,其实每一个阶段都会去做;对于我们设计系统也是有一定参考意义的;• 了解到 Golang 对外暴露的很多方法其实是语法糖(如:make、painc etc.),编译器会帮我忙进行翻译,最开始我以为是 go 代码层面在运行时去做的,类似工厂模式,现在回头来看自己真是太天真了;• 对接下来准备学习 Go 的运行机制、以及 Plan9 汇编进行了一些基础准备。