Golang编译器漫谈(1)编译器和连接器

编译原理、操作系统、图形学一直被认为是程序员的三大浪漫。其中编译原理被认为是计算机领域的屠龙科技。谷歌、苹果、微软等IT科技巨头都争相发展自己的语言并以此为基础发展自己的生态系统。《Golang编译器漫谈》系列文章以谷歌的Golang编译器作为研究对象,分析一个现代编译器的设计与实现。

go build和go run到底在做什么?

Go程序给我们提供两个命令用以构建并运行程序,他们分别是go rungo build两者的区别仅仅在于如何处理生成的可执行程序。我们可以通过在执行这两个命令的时候添加-x参数来查看命令具体执行了什么操作。

首先准备一个简单的helloword程序,命名为println.go:

执行go run -x println.go,输出内容为:

执行过程可以简单概括为:

  • 1-2行创建了一个临时目录,用于存放临时文件。默认情况下命令结束时自动删除此目录,如果需要保留添加-work参数。
  • 3-6行生成编译配置文件,主要为编译过程需要的外部依赖(如:引用的其他包的函数定义)。
  • 8行执行编译器,生成中间结果$WORK/b001/_pkg_.a
  • 11-19行生成链接配置文件,主要为需要链接的其他依赖。
  • 22行之行链接器,生成最终可执行文件$WORK/b001/exe/println
  • 23行之行可执行文件

再执行go build -x println.go对比之行过程可以看到主要差别有:

  • 编译器参数少了-dwarf=false。此项用于调试信息。
  • 生成完可执行文件后多了写入buildid的过程。
  • 最后拷贝可执行文件到当前目录,而不执行。

可以看到不管是go run还是go build生成可执行文件过程是一样的(少许差别,目前看来不重要),获得可执行文件后go run直接执行可执行文件,go build则拷贝到当前目录。生成可执行文件的过程分为两部分,第一部分执行编译器生成.a为结尾的中间文件,第二部分将中间文件和其他库文件进行进行链接生成可执行文件。

目标文件的内容和格式

上一节中我们知道生成可执行文件分为两步,中间以.a文件进行信息传递(编译、链接配置文件内容大部分也是不同.a文件。)本节我们就研究一下这个.a文件里到底包含了哪些信息。

.a文件由compile命令生成(上一节中为/usr/local/go/pkg/tool/linux_amd64/compile程序,也可以通过go tool compile进行调用),go语言compile命令文档中对该程序作用的描述如下:

Compile, typically invoked as “go tool compile,” compiles a single Go package comprising the files named on the command line. It then writes a single object file named for the basename of the first source file with a .o suffix. The object file can then be combined with other objects into a package archive or passed directly to the linker (“go tool link”). If invoked with -pack, the compiler writes an archive directly, bypassing the intermediate object file.

The generated files contain type information about the symbols exported by the package and about types used by symbols imported by the package from other packages. It is therefore not necessary when compiling client C of package P to read the files of P’s dependencies, only the compiled output of P.

从中我们可以得知此文件叫目标文件(object file),并且文件可以是一个打包文件(类似于未压缩的压缩包)。文件内容由代码导出的函数、变量以及引用的其他包的信息组成。

我们尝试解包文件:

解包后得到__.PKGDEF_go_.o两个文件。为了弄清这两个文件包含的信息需要查看go编译器实现的相关代码,相关代码在src/cmd/compile/internal/gc/obj.go文件中(源码中的文件内容可能随版本更新变化,本系列文章以Go1.13.5版本为准)。

可以看到代码2-8行中打开要写入的目标文件,9行写入ar打包文件的标识符。ar文件 是一种非常简单的打包文件格式,广泛用于linux中静态连接库文件中,文件以 字符串"!<arch>\n"开头。随后跟着60字节的文件头部(包含文件名、修改时间等信息),之后跟着文件内容。因为ar文件格式简单,Go编译器直接在函数中实现了ar打包过程。

随后往打包文件中写入两个文件__.PKGDEF_go_.o。分别对应dumpCompilerObj函数和dumpLinkerObj,可以从函数名称中得知这两个文件分别为编译目标文件链接目标文件

PS: startArchiveEntry用于预留ar文件头信息位置(60字节),finishArchiveEntry用于写入文件头信息,因为文件头信息中包含文件大小,在写入完成之前文件大小未知,所以分两步完成。

编译目标文件的用途

为了验证编译目标文件和链接目标文件的用途我们先定义一个简单的hello包和一个使用hello包的main包:

使用go tool compile命令对hello包进行编译,同时将两种目标文件解压并命名为hello__.PKGDEFhello_go_.o (这里其实可以使用-linkobj一次性完成下列工作,这里为了直观手动进行)。

我们先尝试下直编译main.go。示我们找不到导入的包hello,发生这个错误的原因是编译器需要hello包当中的函数定义等信息来确定函数的调用方法。类似于C语言编程中的头文件(.h文件)

为了解决这个错误,我们手工编写一个第一节出现过的importcfg,内容格式为packagefile <包名>=<路径>。这里我们只使用hello包里解压出来的__.PKGDEF文件并尝试编译:

结果显示成功编译出了main.o,说明编译过程只需要__.PKGDEF文件。编译过程和C语言编译过程类似,编译只需要头文件来查找定义,不需要具体实现代码。

0x4 链接目标文件的用途

接下来我们尝试将main.o链接成elf可执行文件

同样因为找不到依赖包报错。根据报错结果,可以了解到编译器在目录/usr/local/go/pkg/linux_amd64/中尝试查找hello包。我们可以直接将我们的包直接拷贝到查找路径,也可以和编译过程类似,提供一个配置文件来指定每个包的路径。创建一个importcfg.link配置文件,指定hello包的路径为_go_.o。同时需要包含hello.go依赖的fmt的路径,以及fmt依赖的其他包。

尝试链接成可执行文件,并执行可执行文件。

可以看出我们成功的链接为可执行文件,执行结果和预期效果一致。

总结

  1. 编译器编译出来的目标文件(go tool compile的输出)是一个ar打包文件,包含个文件:编译目标文件(.PKGDEF)和链接目标文件(go.o)。
  2. 编译目标文件(.PKGDEF)内容包含package导出的函数、变量等信息,用于编译过程。
  3. 链接目标文件(go.o)内容包含具体的代码实现,用于链接过程。
  4. go编译器和链接器分别使用配置文件指定依赖的其他包的路径。
  5. 编译过程只需要列出直接依赖的包(只包含hello),链接过程需要列出所有依赖(main依赖hello、hello依赖fmt、fmt再依赖很多其他包)。

Leave a reply:

Your email address will not be published.