当前位置: 首页 > 科技观察

走进Golang的编译原理

时间:2023-03-20 20:24:21 科技观察

为了学好Golang的底层知识,我折腾了编译器相关的知识。以下内容不会增加你的生产技能点,但可以增加你的能力指数。知道gobuild当我们输入gobuild时,我们写的源代码文件发生了什么?最终变成可执行文件。这个命令会编译go的代码,今天就来看看go的编译过程吧!首先我们来了解下gocode源文件分类Command源码文件:简单的说就是包含main函数的文件,一般一个项目一个文件,没想到项目测试源码文件是这样的需要两个命令源文件:只是我们写的单元测试代码都是以_test.go结尾的库源代码文件:没有上述特性的都是库源代码文件,像我们使用的很多第三方包都属于这部分。gobuild命令用于编译它们的命令源代码文件和它所依赖的库源代码文件。下表是一些常用的选项,这里集中介绍如下。选项说明-a重建所有的命令源文件和库源文件,即使是最新的-n打印出编译期涉及的所有命令,但并不真正执行它们,非常方便我们学习-race启用竞争条件-x打印编译期间使用的名称。它和-n的区别在于它不仅打印而且执行。下面用一个helloworld程序来演示上述命令选项。如果对上面的代码执行gobuild-n,我们看一下输出信息:下面来分析一下整个执行过程。这部分是编译的核心。可执行文件a.out会通过compile、buildid、link这三个命令编译出来。然后用mv命令将a.out移动到当前文件夹,改成和工程文件同名(这里也可以自己指定名字)。在文章的后半部分,我们主要讲一下compile、buildid、link这三个命令所涉及的编译过程。编译原理这是go编译器的源码路径。如上图所示,整个编译器可以分为:编译前端和编译后端;现在让我们看看编译器在每个阶段做了什么。让我们从前端开始。词法分析词法分析简单来说就是把我们写的源代码翻译成Token,这是什么意思呢?为了理解Golang从源码翻译成Token的过程,我们用一段代码来看看翻译的一一对应关系。图片中重要的地方我都标注了,这里多说几句。让我们看看上面的代码并想象一下。如果我们要自己实现这个“翻译工作”,程序是如何识别Token的呢?首先,我们将Go的token类型分为几类:变量名、字面量、运算符、分隔符和关键字。我们需要把一堆源码按照规则切分,其实就是分词。看上面的示例代码,我们可以大致制定一个规则如下:识别空格,如果是空格,就可以分词;遇到(,)、'<','>'等特殊运算符算作分词;遇到"或者数字字面量算作分词。通过上面的简单分析可以看出,Token的源码其实不是很复杂,自己写代码就可以实现。当然还有很多一般的词法分析器都是通过正规的方法实现的,比如Golang,早期是用lex,后来版本改用go来实现,语法分析是词法分析后,得到的是Token序列,会作为文法分析器的输入,经过处理后生成AST结构作为输出,所谓文法分析就是将Token转化为可识别的程序文法结构,而AST是对这种文法的抽象,表示有有两种构造这棵树的方法,1.自上而下的方法会先构造根节点,然后开始扫描Token,遇到STRING或者其他类型的时候就知道这个是类型声明,而func表示是函数声明。继续扫描直到程序结束。2.自下而上与前面的方法相反。它首先构造子树,然后将它们组装成一棵完整的树。Go语言用于语法分析最重要的是自底向上构造AST。我们来看看Go语言通过Token构建的树长什么样子。我已经在文本中标记了所有有趣的地方。你会发现AST树的每个节点都对应着一个Token的实际位置。树构造完成后,我们可以看到不同的类型由对应的结构表示。如果这里有语法和词法错误,将不会被解析出来。因为到目前为止都是关于字符串处理的。语义分析编译器在语法分析之后调用语义分析阶段,Go的这个阶段称为类型检查;但是看了下面Go自己的文档,其实我们做的并没有太大区别,还是按照主流的规范。写一下过程。那么语义分析(类型检查)到底是做什么的呢?AST生成后,语义分析会把它作为输入,一些相关的操作会直接在这棵树上重写。首先,在Golang文档中提到,会进行类型检查和类型推断,检查类型是否匹配,是否进行隐式转换(go没有隐式转换)。正如下面的文字所说:AST是类型检查。第一步是名称解析和类型推断,确定哪个对象属于哪个标识符,以及每个表达式具有什么类型。类型检查包括某些额外检查,例如“已声明和未使用”以及确定函数是否终止。就是做名称检查和类型推断,标记每个对象属于哪个标识符,每个表达式是什么类型。类型检查还有一些其他检查要做,比如“声明未使用”和确定函数是否中止。某些转换在AST上确实完成了。有些节点是根据类型信息进行细化的,比如字符串加法从算术加法节点类型中拆分出来。其他一些ex样本是死代码消除、函数调用内联和逃逸分析。其他一些示例包括死代码消除、函数调用内联和逃逸分析。以上两段来自golangcompile。这里我再说一件事。我们在调试代码的时候经常需要禁止内联,其实就是这个阶段的操作。#编译时禁止内联gobuild-gcflags'-N-l'-N禁止编译优化-l禁止内联,禁止内联也可以在一定程度上减少可执行程序的体积经过语义分析,可以说明我们没有问题与代码结构和语法。所以编译器前端主要是解析出编译器后端可以处理的正确的AST结构。接下来,让我们看看编译器后端要做什么。机器只能理解二进制并运行它,所以编译器后端的任务很简单就是如何将AST翻译成机器码。既然中间代码生成已经获得了AST,那么机器需要运行的就是二进制了。为什么不直接翻译成二进制呢?其实到目前为止,从技术上来说,完全没有问题。但是,我们有多种操作系统和不同类型的CPU,每种类型的位数可能不同;寄存器可以使用的指令也不同,如复杂指令集和简化指令集;在兼容之前,我们需要替换一些底层功能。比如我们使用make初始化slice,会根据传入的类型替换为:maskslice64或者maskslice。当然中间代码的时候也会替换painc,channel等函数的替换生成过程。这部分替换操作可以在这里查看。中间代码的另一个价值是提高后端编译的复用性。比如我们定义了一组中间码应该是什么样子,那么后端的机器码生成就相对固定了。每种语言只需要做自己的编译器前端即可。这就是为什么您可以看到现在开发新语言更快的原因。编译大部分是可重用的。而对于接下来的优化工作来说,中间代码的存在有着非同寻常的意义。因为平台太多了,如果有中间代码,我们可以把一些常用的优化放在这里。中间代码也有多种格式。例如,Golang使用SSA特性的中间代码(IR)。这种形式的中间代码最重要的特点是变量总是在变量被使用之前被定义,并且每个变量只被赋值一次。代码优化在go的编译文档中,我没有找到独立的代码优化步骤。但是,根据我们上面的分析,我们可以看到,代码优化的过程其实遍及了编译器的每一个阶段。每个人都会尽力而为。通常,除了用高效代码替换低效代码外,我们还有以下处理:并行,充分利用当前多核计算机的特性流水线,有时cpu在处理一个命令的同时,也可以处理bcommand同时选择,为了让cpu完成某些操作,需要用到指令,但是不同指令的效率是有很大差别的。在这里,将执行指令优化以利用寄存器和高速缓存。我们都知道cpu从寄存器取数据最快,从高速Cache取数据次之。这里我们将充分利用机器码来生成优化过的中间代码,在这个阶段首先会转化为汇编代码(Plan9),而汇编语言只是机器码的文本表示,机器并不能真正执行。所以这个阶段会调用汇编器,汇编器会根据我们在执行编译时设置的架构调用相应的代码生成目标机器码。这里比较有意思的是,Golang总是说它的汇编器是跨平台的。事实上,他还编写了多个代码来翻译最终的机器代码。因为在入口的时候,它会根据我们设置的GOARCH=xxx参数进行初始化处理,最后调用对应架构写的具体方法生成机器码。这种处理上层逻辑一致,底层逻辑不一致的方法很常见,值得学习。让我们简单地做一下。先看入口函数cmd/compile/main.go:main()vararchInits=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,}funcmain(){//从上图中选择对应的架构根据参数ProcessarchInit,ok:=archInits[objabi.GOARCH]if!ok{...}//将对应的cpu架构传递给内部gc.Main(archInit)}然后在cmd/internal/obj/调用plist.go中对应架构的方法进行处理funcFlushplist(ctxt*Link,plist*Plist,newprogProgAlloc,myimportpathstring){...for_,s:=rangetext{mkfwd(s)linkpatch(ctxt,s,newprog)//架构对应的方法进行自己的机器码翻译teDWARF(plist.Curfn,s,myimportpath)}}整个过程结束后,可以看到编译器后端还有很多工作要做。您需要了解特定的指令集和CPU架构才能正确翻译机器码。同时,它不能只是正确的。一种语言的效率高低,很大程度上取决于编译器后端的优化。尤其是我们即将进入AI时代,越来越多的芯片厂商诞生,我估计未来对这方面人才的需求会越来越旺盛。综上所述,我想总结一下学习编译器古老知识给我带来的一些收获:知道了整个编译由几个阶段组成,每个阶段做了什么;我不打算知道;甚至像编译器这样复杂低级的东西也可以分解,让每个阶段都变得简单,可以独立复用,这对我在应用开发中有一定的意义;分层是对划分的指责,但是有些东西还是需要全局做的,比如优化,其实每个阶段都会做;对我们进行系统设计也有一定的参考意义;我了解到Golang暴露的很多方法其实都是语法糖(比如:make,pain等),编译器会帮我翻译。一开始我以为是在运行时在go代码层面完成的,类似于工厂模式。现在回想起来,我真的很幼稚;5.准备下一步学习了Go的运行机制和Plan9的编写,已经做了一些基本的准备工作。