图片


作者简介

图片


肖 玮

2016年至今一直在 arm 开源软件部门担任主任工程师,领导 Golang 针对 arm64 架构的功能实现(enabling)和性能优化工作,同时也是 Golang 汇编器(asm)和编译器(compile)针对 arm64 架构改进的主要贡献者之一。在加入 arm 之前一直供职于 Intel 开发者工具事业部,长期从事针对 X86 架构的动态二进制翻译器(DBT)和编译器产品等相关工作。








1.Toolchain

2.Compile

3.Asm

4.Link

5.Others



   讲到 Arm 大家首先会想到的就是手机的处理器, 不管是安卓还是苹果手机,他们所采用的处理器绝大部分都是由 Arm 设计。那么问题来了,为什么我会出现在一个 Golang 的会议上呢? 因为 Golang 看起来比较后端。原因是 Arm 这几年除了继续聚焦于移动处理器,也开始在服务器市场发力。一讲到服务器、云计算肯定少不了我们的 Golang。因为移动计算的需求,Arm 处理器的功耗一直是很低的,除此之外 Arm 服务器和英特尔服务器有什么区别呢?首先价格便宜。还有一些别的方面的差异,例如我最近远程登录过一台合作伙伴的 Arm 服务器,有两百多个核。以前一个服务如果要消耗一两百个核的话,需要好几台 X86 服务器来支撑,现在只需要一台 Arm 服务器就够了。虽然 Arm 单核性能没有 X86 好,但是省电,在性能要求不那么高的场合,例如存储服务,Arm 的服务器就很有优势。  


1.Toolchain

1.1 Go toolchain overview



图片



   Golang 有好几种工具链。绝大部分用户使用的工具链其实是 GC 工具链,它源自于 plan9,该系统虽然不是主流系统但现在还存活着,有自己的一整套工具链,包括编译器、汇编器和链接器等等。所以 GC 工具链里有很多 plan9 的影子。第二个工具链就是 GCcgo,它基于 GCc 编译器工具链,对于一些比较老的架构例如 sparc 有很好的支持,其实 Golang 很多核心开发者以前就是 GCC 的开发者,他们很钟爱以前的编译器,所以肯定会让 GCC 支持 Go 语言,但它有一个问题,许可证基于 GPL,这对一些开源项目可能会存在问题。第三个工具链 llgo 是基于 LLVM 的,它在 LLVM 上面做了一个 Golang 的前端,但是这个项目现在几乎没有人维护了。去年的时候,谷歌Golang组又起了一个 GoLLVM 的项目,试图走得更远一点。Golang 核心开发人员现在对这个项目比较保守,一直说只是做实验,短期可能不会变成官方的工具链。


1.2 Go toolchain example



图片



  大家用的最多的可能是 Go build 或者是 Go install,这些工具会调用一些其他的工具,比如 compile 和 asm。asm 就是做一些机器码的生成,最后把各个包(package) 给 link 做一个拼装,生成最终可执行的代码。


1.3 Go toolchain workflow



图片



   上图所示,Go 的工具链除了刚才提的三个工具,其实还有很多别的工具。比如有 cgo,nm 等等。还有 objdump,就是进行反汇编,从机器码回到可读的汇编语言。由于时间关系今天主要给大家介简单介绍一下 compile、asm 和 link。这些工具都是和目标机器的类型相关的,如果你的目标机器是 X86 而不是 Arm,他们生成的可执行文件是不一样的。


2.  Compile

2.1  Go compiler  overview


我对 Go 编译器的理解分为经典的三个阶段:前端、中端和后端。



图片



   

第一个阶段是前端,用户的 Go 程序会被前端生成抽象语法树 (AST),并 AST上面会做一些初步的工作,例如类型检查。

第二个阶段是中端,Go 编译器形成跟目标无关的中间表示,也就是基于 SSA 的中间表示,在这个 SSA 上面做一些和目标机器无关的优化会比较方便,例如一些图的优化会比较直观。

第三个阶段是后端,Go 编译器会生成目标机器的机器指令。


2.1.1  Front end



图片



   再回到前端,这是用户最容易感受到的一个阶段,例如如果你写的 Go 程序不规范,语法上有问题,就会被前端的语法规则检查报错。如果你表达是两边的类型写得不匹配,前端也会报错。还有inlie,你的函数比较小,你去 call 它的话不会有 call 指令,前端会做 inline 优化,相当于把它贴过来了。但是 inline 争议比较多,做的不太好可能会有副作用。我最近看到谷歌的工程师在讨论这一点,他们会在最近一两个版本对这一块进行改进,允许一些非叶子函数做简单的 inline。


2.1.2 Middle end



图片



   第二阶段中端跟 Go 语言关系不大,做的也是跟目标机器无关的事情,最常做的就是一些简单的优化。上图是中端现在支持的所有 pass。我们拿到一个函数,后要一个 pass,一个 pass 去做遍历,把没用的节点和结构删掉,或者把有用的信息填进去。其实有一个 pass:opt 现在做得不是很好,不管你编译 Go程序时,优化开关有没有打开,其实都会运行 opt pass。现在 Golang 的调试体验不佳,可能跟这个 pass 有点关系,因为有一些编译优化是你无法彻底关掉的。谈到调试的话我注意到谷歌的工程师其实在不断地改进用户体验,不管是我们这里说的中端部分还是最终的调试信息,他们一直都在改进。



图片



   举一个更详细的例子,比如说在 Go 语言写了一个整数除以常数,但是最终生成的指令并没有真的做除法,而是把除法变成了加减乘除的简单运算,如上图所示,保证左边和右边的结果一样。从运行速度来看,不管是 X86 平台、Arm 平台或者其他的平台,都是右边的更快。不管你最终目标机器是什么体系结构,中端都会对这个常数除法表达式执行这个优化。


2.1.3 Back end



图片



   第二阶段结束之后,我们会得到语义上完全一致的 SSA 提供给第三阶段:后端。对于后端,大家最有感性认识的当属寄存器分配了,你的程序具体落地到处理器上执行时,对于不同的处理器,其寄存器数和名字完全是不一样的。



图片

  还有机器指令的选择。还是以前面的常数除法为例,上图左边是机器无关的表示,右边是 Arm64 目标机器上的最终后端生成的结果,注意红色的那三条指令。虽然中端的输出是一样的,但是不同的目标机器到后端这里得到的结果就会完全不一样。


2.2  Generate prog



图片



   上面 Prog 这段代码是从 Golang 工具链代码里面摘出来的,描述了一条具体的机器指令。我在这个“机器”上面加了引号,因为它是针对机器的汇编语言,离真正的机器指令还有一定的差距。最后总结一下编译器的三个阶段,前端处理跟语言相关的信息,中端处理跟机器无关的优化,后端处理跟机器有关的指令选择和寄存器的分配。顺便谈一下和 GCc 编译器的一些不同点,它已经达到 2-300 个 pass,而 Golang 编译器只有几十个 pass,所以 Golang 编译器相对于GCc 编译器还是很弱的,其实这里有很多的原因。一个原因就是谷歌可能更追求编译的速度,你做的事情越复杂,编译的速度越慢。还有二制代码文件的大小,有的时候要用空间换时间,例如我可能为了在最终运行的时候快一点,要进行一些循环展开,就让你的循环体几倍的增长。相比较于 GCc 编译器,Go 编译器到目前为止比较高级的编译优化都没有做。


3.Asm

3.1 Go assembler overview



图片



   第二个工具是汇编器,对于 Golang 的汇编语言很多朋友可能用的不是特别多,除非一些性能要求比较高的场合,也许你会写 Golang 的汇编程序。Golang 的汇编语言来源于 plan9 的汇编语言。一条汇编指令对应一个 prog,前面我们讲过编译器可以直接生成 prog,如果手写汇编语言,就会经过比较经典的词法和语法分析最后构成一个 prog 的链。进到这个阶段,汇编器就要干活了。首先进行简单的优化,因为 Golang 的汇编语言是个抽象的汇编语言,有一点接近高级语言。除此之外还有很多机器的信息,比如做一些机器相关的优化,还会做预处理,会选择最终的指令,还会生成最终的目标文件,就是跟运行时相关的语言信息和生成 meta 数据,最终生成我们说的 goobj 的文件。


Go arm64 assembly example



图片



     这张图描述了一段非常简单的 Arm64 的汇编代码。里面做了一个加法,有一些 MOVD 指令,为什么会有这条指令呢?我推测当初 plan9 那帮人设计的Golang 汇编语言时,他们想做抽象。例如我们做一个 MOVD 指令,真正落地到各个体系结构时大家会发现既使是 MOVD 也有很多方式。对于 Arm 这种体系结构,MOV 能做两件事,要么进行无符号的扩展,要么进行有符号的扩展。但是 X86是不同,MOVD 就比较麻烦了,要处理三个语义。最终由于体系结构的巨大差异,出现了很多种类型的 MOVD 汇编语言指令,最后 plan9 不得不承认自己是一个准抽象的汇编语言。对于 Arm,如果你将值要MOV回到内存里面去,汇编器会将 MOVD 翻译成 str,反之就是 ldr,这些将汇编指令翻译成具体机器指令的事情都是汇编器干的事。

   Golang 对每一个 Goroutine 的栈大小是可变的,一开始栈的大小是 2k,随着函数调用越来越深,大概消耗到了 1K 多一点的时候,栈会进行动态的增长。怎么做这个动态的增长呢?汇编器会在每个函数开头插入几条指令来检查栈的剩余空间大小,也就是查看当前的栈够不够用,不够用就会跳到下面去。下面就会做函数调用,调用到指定的地方,那里有 runtime 提供好的函数进行内存管理,管理单纯的堆栈,把里面的指针调好。有了足够多的栈空间,这时你的函数就可以做任何复杂的操作了。这是 Go 汇编器做简单事情的介绍,通过具体的例子让大家看起来比较直观。

   总结上述内容,Golang 的汇编语言翻译成最终的机器码,这些机器码在 Arm的机器上运行就可以按照设定好的规则进行计算或者读写内存。


3.2  Goobj



图片









   最后讲讲关于 Golang 的 Goobj 形式。大家都知道,GCc 编译 C 文件也会生成 ELF obj 文件,他们的概念是一样的,但是千万不要用分析 ELF obj 文件格式的工具来分析 Goobj 文件。如上图所示,Goobj 和 ELF obj 的文件结构一开始都差不多,但在最前面有一些 header,而右边是我们说的程序的表头,开始就不一样了。所以特别提醒大家对于 Golang 生成的 obj 文件要用自己的工具去分析。

   汇编器也简单介绍完了,它会生成一些 goobj 格式的目标文件。


4.Link

4.1  Go link overview



图片




   最后一个阶段是链接:link。link 大家平时用得更少并且它也是 Golang 工具链里面有很多瑕疵的地方。

   首先介绍一下基本概念,我们写了一个 Go 程序,有很多 Go 文件,可能会被编译器编成目标文件,然后打包成 pkg,因为 Golang 是包管理模式,如果 Go程序调用 C 程序,工具链还会调用 cgo,生成额外的 C 文件,并被本地的编译器处理,生成的目标文件也会被打包成一个 pkg 文件,最后这些包的文件都会给我们的链接器,生成最终的可执行程序。

   这个过程当中,我们连接器 link 干了很多事情,你连接的模式是内部连接的时候,也有可能是偷懒什么都不干,就调用了本地的连接器,这个过程和 GCc的链接过程没有什么太大的区别,都是生成可执行代码文件,link 就是干这个事情的。但是 Golang 的 link 比传统的 link 更加复杂,复杂的原因在于 Go 可以和C 混在一块用,就意味着 Go 的 link 既要处理自己的编译器生成的文件,还要负责你本地安装的 C 语言工具链生成的文件,这种情况下文件格式就有很多了,C 语言工具链生成的 pkg 都要由我们的 Go link 来处理,这个是蛮难的,而且这里面的一些标准一直都在变。比如说今天写的 link 明天还是不是符合这个标准都很难说。因此 Golang 工具链偷懒了,就直接调用了外部的 link 做这个事情。


4.2 Go link workflow



图片



  上图是我前段时间总结的关于 Golang link 的工作流程。一个正常的链接器一般都会做三件事情,首先把各个部分都收集起来,然后是把一些相似的部分挑出来放在一块,例如将代码都放在 text 段。最重要的就是地址重定位,例如 A 函数调用 B 函数,但是 A 和 B 是在不同的文件里面,这个时候彼此不知道,A不知道 B 到底在哪里,放到一块以后,链接器知道 B 在第几个字节,会把这个B 的地址写到 A 的调用点,这就是地址重定位的过程。

   Go 链接器区别传统链接器在于 Go 语言,里面有很多 Golang 运行时的信息。例如当我的栈消耗到一定阶段的时候,会被 Go 的运行时抢走,我们先把栈拉大,里面会涉及比较复杂的问题,其中一个问题跟垃圾回收有关,我的指针原来指向的位置变了,要先找到指针的位置在哪里,这个过程有跟 Golang 的语言特性息息相关,这些东西在传统的编译器里面不需要考虑。

   链接器最后生成每天都会用到的可执行文件。关于 Golang 链接器就简单介绍到这,如果大家对里面的细节有兴趣,欢迎线下讨论。


5.Others



图片



   最后一页分享一些我对 Golang 比较热门话题的看法。

1.VGo: Go 开发的程序越来越多,但一些老代码可能还不能轻易下线,而其基于的第三方库又在不停的升,。这个时候版本控制就很有必要,所以 VGo += Package Versioning。

2.第二个有意思的事情是去年有人开始开发基于 WebAssembly 的后端,生成出来的文件不在物理的 CPU 上运行,而是在浏览器上跑,这意味着 Go 语言可能也可以开发前端应用。

3.关于安全点。Go 语言是自己做一些垃圾回收的,函数刚进入的时候执行流可能会被调度器抢掉。这些都是在函数的入口。如果你这个函数一直霸占着不放,调度器是无法抢占你的 CPU。这点 JAVA 做得比较好,即使一直在循环里做操作,调度器仍然能抢占你的 CPU,Go 语言也想做这样,这样的话可调度性会更好而且垃圾回收能更加密集一点。

最后还有一些跟体系结构相关的优化,现在函数调用的时候 Go ABI 不管是什么机器都通过内存传参,但 Arm 的寄存器非常多,这样可以考虑优化成寄存器传参,究竟要不要打开这样的优化有许多问题需要考虑,因为一旦打开,首先你调试体验就会变得很差,参数和指针找出来也变得麻烦,这一问题已经讨论一年多了,但还没有结论。另外就是关于 inline,如果做得比较激进的话,性能会有比较大幅度的提升,但同样也会带来很多寄存器传参类似的问题。

2018年的 Gopher Meetup 将在深圳开启巡回第一站,这一次邀请了很多新的讲师给大家一起交流分享Go的使用经验〜

点击阅读原文报名参加

本文使用 文章同步助手 同步