golang编译器漫谈(2)编译器目标文件

上一篇Golang编译器漫谈(1)编译器和连接器我们谈到Golang编译器生成的目标文件其实是分为两部分,编译目标文件和链接目标文件。本篇我们就重点看一下其中的编译目标文件。
假设存在A、B两个package。A中存在语句import "B"。当A进行编译时就需要B的编译目标文件,从中获取可以使用的函数定义,变量等信息。 ## 目标文件结构 为了更好的解释编译目标文件我们编写一个简单的package,包含两个函数,Hellocalc。其中Hello是导出函数,plus是私有函数。
package clac

import "fmt"

func Hello(x, y int){
	fmt.Println("hello")
	plus(x, y)
}

func plus(a, b int) {
	fmt.Printf("%d + %d = %d\n", a, b, a+b)
}
对其进行编译和解压:
$ go tool compile clac.go
$ ar x clac.o
$ ls
__.PKGDEF  _go_.o
使用16进制编译器打开_.PKGDEF文件,文件内容如下:
87654321  00 11 22 33 44 55 66 77 88 99 aa bb cc dd ee ff   0123456789abcdef
========  == == == == == == == == == == == == == == == ==   ================
00000000  67 6F 20 6F 62 6A 65 63 74 20 6C 69 6E 75 78 20   go object linux
00000010  61 6D 64 36 34 20 67 6F 31 2E 31 33 2E 35 20 58   amd64 go1.13.5 X
00000020  3A 66 72 61 6D 65 70 6F 69 6E 74 65 72 0A 0A 0A   :framepointer...
00000030  24 24 42 0A 69 00 56 17 2B 2F 68 6F 6D 65 2F 73   $$B.i.V.+/home/s
00000040  65 6E 67 68 6F 6F 2F 50 72 6F 6A 65 63 74 2F 67   enghoo/Project/g
00000050  63 6C 65 61 72 6E 2F 74 65 73 74 73 2F 63 6C 61   clearn/tests/cla
00000060  63 2E 67 6F 01 78 01 79 00 0F 3C 61 75 74 6F 67   c.go.x.y..<autog
00000070  65 6E 65 72 61 74 65 64 3E 04 63 6C 61 63 09 2E   enerated>.clac..
00000080  69 6E 69 74 74 61 73 6B 05 48 65 6C 6C 6F 46 7F   inittask.HelloF.
00000090  0A 00 02 00 2C 01 00 2E 01 00 00 30 30 30 00 56   ....,......000.V
000000a0  7F 02 31 07 30 01 30 41 0B 02 46 11 50 00 00 0A   ..1.0.0A..F.P...
000000b0  24 24 0A                                          $$.
上一篇中Golang编译器漫谈(1)编译器和连接器中我们已经知道,写入_.PKGDEF文件的函数为dumpCompilerObj,定义如下:
func dumpCompilerObj(bout *bio.Writer) {
	printObjHeader(bout)
	dumpexport(bout)
}
函数首先通过printObjHeader函数写入目标文件头部信息。包括操作系统、CPU架构等信息。此部分为单纯的字符串输出。有兴趣的读者可以自行阅读。对应于目标文件中是0x00-0x2e部分的内容:
go object linux amd64 go1.13.5 X:framepointer\n\n
第三行的dumpexport函数是一个包裹函数,本质上是调用iexport,并在iexport输出内容前后加入”\n$$B\n””\n$$\n”进行内容区分。
func dumpexport(bout *bio.Writer) {
	// The linker also looks for the $$ marker - use char after $$ to distinguish format.
	exportf(bout, "\n$$B\n") // indicate binary export format
	off := bout.Offset()
	iexport(bout.Writer)
	size := bout.Offset() - off
	exportf(bout, "\n$$\n")


	if Debug_export != 0 {
		fmt.Printf("export data size = %d bytes\n", size)
	}
}
可以从16进制编译器输出的内容可以看到_.PKGDEF除了目标文件头部外,剩下的内容均被”\\n$$B\\n””\\n$$\\n”所包裹。也就是iexport函数是我们探索的重点。为了更好的理解iexport所做的工作。我们先来了解下该函数所使用的数据序列化方式。

编译器目标文件序列化

保存目标文件的过程其实是保存一系列结构化的数据到连续的文件中。在应用程序开发过程中我们往往使用json、xml等格式,这些格式拥有良好的可读性适非常适合应用程序开发,但是这些格式需要从头到尾全部扫描才能重新建立原始结构。但是在编译过程中一个程序往往只需要导入包中一两个函数,为此扫描一个庞大的包可能是性能上的浪费。为此编译器使用了一种特殊的结构来保存数据。这种结构可以快速的查找某一个定义的同时不需要遍历整个包。除此之外还可以将重复的字符串只保存一份,并且使用的地方只保留一份地址引用来节省空间。

整数的序列化

对于无符号整数使用varint编码,这种编码方式生成的序列长度可变,小的数字使用较少的空间,数字越大占用空间越大,如:0-127占用1字节的空间128-16383占用2字节空间等等。
具体编码方式为:将储存数据的字节最高1位设置为标志位(most significant bit, MSB),1代表还有后续字节,0代表数据结束。剩余的7位空间用小端序存储数据。以23456为例如图所示。
notion image
对于有符号整数采用zigzag编码,转换为无符号整数,再使用varint编码进行编码。具体编码规则为,正整数使用公式2*x,负整数使用公式2*(^x) + 1转换为正整数。之后,正数从0开始排列为:0, 2, 4, 6, 8….,负数从-1开始排列为:1, 2, 3, 5, 7, 9….。 序列化的具体代码可以查看Go语言标准库binary中的PutUvarintPutVarint,分别表示无符号数和有符号数。 我们也可以知道。-64是这种编码当中使用一个字节能表示的最小的整数,编码后为:7f

字符串的序列化

对于字符串的序列化相对简单,首先使用整数序列化的方式写入字符串的长度,随后写入完整的字符串,比如对于字符串abcd首先写入长度0x4,之后写入abcd对应的字节0x61,0x62,0x63,0x64
对于编译目标文件,文件中可能需要出现大量重复的字符串,为此文件中专门开辟了一段空间,存储所有不重复的字符串,组成字符串表。在后续的内容中,如果需要引用字符串,则直接使用对应的偏移来替代。如_.PKGDEF文件中,我们已知从0x38开始的86字节的空间为字符串表,提取出来如下所示。
87654321  00 11 22 33 44 55 66 77 88 99 aa bb cc dd ee ff   0123456789abcdef
========  == == == == == == == == == == == == == == == ==   ================
00000000  2B 2F 68 6F 6D 65 2F 73 65 6E 67 68 6F 6F 2F 50   +/home/senghoo/P
00000010  72 6F 6A 65 63 74 2F 67 63 6C 65 61 72 6E 2F 74   roject/gclearn/t
00000020  65 73 74 73 2F 63 6C 61 63 2E 67 6F 01 78 01 79   ests/clac.go.x.y
00000030  00 0F 3C 61 75 74 6F 67 65 6E 65 72 61 74 65 64   ..<autogenerated
00000040  3E 04 63 6C 61 63 09 2E 69 6E 69 74 74 61 73 6B   >.clac..inittask
00000050  05 48 65 6C 6C 6F                                 .Hello
从偏倚地址0 开始分析,偏移0的内容2b为后续字符串长度,内容为/home/senghoo/Project/gclearn/tests/clac.go之后偏移2c开始是另一个字符串。
将所有此字符串表中的字符串提取出结果如下:
+--------+--------+----------------≥-----------------------------+
| offset | length |                   content                   |
+--------+--------+---------------------------------------------+
| 0x00   | 0x2B   | /home/senghoo/Project/gclearn/tests/clac.go |
| 0x2C   | 0x01   | x                                           |
| 0x2E   | 0x01   | y                                           |
| 0x30   | 0x00   |                                             |
| 0x31   | 0x0F   | <autogenerated>                             |
| 0x41   | 0x04   | calc                                        |
| 0x46   | 0x09   | .inittask                                   |
| 0x50   | 0x05   | Hello                                       |
+--------+--------+---------------------------------------------+
可以看到这里包含我们文件的路径、包名、导出函数名等信息。私有函数名plus不在此列表中,也间接说明导出内容不包括私有函数。相关生成代码可以参考stringOff函数
接下来的代码中,如果需要引用字符串会直接写入偏移地址。如使用Hello则直接写入0x50。也可以注意到0x30代表的是一个空字符串。 ### 文件位置序列化 编译错误、运行时错误等错误信息中我们可以看到错误信息中包含源文件名称和行号信息。
$ go run dz.go
panic: test panic

goroutine 1 [running]:
main.main()
	/home/senghoo/Project/gclearn/tests/dz.go:4 +0x39
exit status 2
因此,目标文件中需要保存源代码中的行号信息。位置信息包含文件名和对应行号两个信息。 位置信息编码过程根据当前写入的位置对应的源文件是否和上一次写入位置相同分为两种情况。相关源代码如下所示:
// src/cmd/compile/internal/gc/iexport.go:509
func (w *exportWriter) pos(pos src.XPos) {
	p := Ctxt.PosTable.Pos(pos)
	file := p.Base().AbsFilename()
	line := int64(p.RelLine())

	// When file is the same as the last position (common case),
	// we can save a few bytes by delta encoding just the line
	// number.
	//
	// Note: Because data objects may be read out of order (or not
	// at all), we can only apply delta encoding within a single
	// object. This is handled implicitly by tracking prevFile and
	// prevLine as fields of exportWriter.

	if file == w.prevFile {
		delta := line - w.prevLine
		w.int64(delta)
		if delta == deltaNewFile {
			w.int64(-1)
		}
	} else {
		w.int64(deltaNewFile)
		w.int64(line) // line >= 0
		w.string(file)
		w.prevFile = file
	}
	w.prevLine = line
}

和上一次文件不同

当表示一个新文件开始或与上一次表达的文件发生变化为这种情况,对应源代码中为if语句的else部分。这种情况下首先写入一个魔术数字-64,根据 整数的序列化 一节可知,这是可以用单子节表示的最小整数。其次写入文件的绝对行号,再根据 字符串的序列化 一节所表述的字符串写入方法写入文件路径。对应于我们的目标文件,0x8f开始的三个字节内容就是这类情况。数据内容为:7F0A007F表示开始一个新的文件,0A根据整型序列化规则表示整数5,最后的00表示字符串,查询字符串序列化中的表格可知00表示字符串/home/senghoo/Project/gclearn/tests/clac.go与我们预期的结果一致。

和上一次文件相同

和上一次文件相同时处理比较简单,直接写入当前写入位置对于上一次写入位置的相对偏移地址。如上一次写入位置为行号5,现在写入位置为行号8,则相对的位置为8-5=3。则可以根据整数序列化规则写入06表示偏移地址为3。这种情况有个特殊状况,因为写入的是相对偏移,所以数值可以是负数。可以正好和代表和上一次文件不同情景下的魔术数字-64冲突。这种情况下,在写入-64后多写入一个-1,区分两种情况(和上一次文件不同情境下因为写入的是绝对地址,所以不可能是负数)。

编译器目标文件导出的内容

有了序列化相关的知识,我们继续来探索编译器目标文件。编译器目标文件具体内容导出是iexport函数完成的。函数定义如下:
func iexport(out *bufio.Writer) {
	// Mark inline bodies that are reachable through exported types.
	// (Phase 0 of bexport.go.)
	{
		// TODO(mdempsky): Separate from bexport logic.
		p := &exporter{marked: make(map[*types.Type]bool)}
		for _, n := range exportlist {
			sym := n.Sym
			p.markType(asNode(sym.Def).Type)
		}
	}

	p := iexporter{
		allPkgs:     map[*types.Pkg]bool{},
		stringIndex: map[string]uint64{},
		declIndex:   map[*Node]uint64{},
		inlineIndex: map[*Node]uint64{},
		typIndex:    map[*types.Type]uint64{},
	}

	for i, pt := range predeclared() {
		p.typIndex[pt] = uint64(i)
	}
	if len(p.typIndex) > predeclReserved {
		Fatalf("too many predeclared types: %d > %d", len(p.typIndex), predeclReserved)
	}

	// Initialize work queue with exported declarations.
	for _, n := range exportlist {
		p.pushDecl(n)
	}

	// Loop until no more work. We use a queue because while
	// writing out inline bodies, we may discover additional
	// declarations that are needed.
	for !p.declTodo.empty() {
		p.doDecl(p.declTodo.popLeft())
	}

	// Append indices to data0 section.
	dataLen := uint64(p.data0.Len())
	w := p.newWriter()
	w.writeIndex(p.declIndex, true)
	w.writeIndex(p.inlineIndex, false)
	w.flush()

	// Assemble header.
	var hdr intWriter
	hdr.WriteByte('i')
	hdr.uint64(iexportVersion)
	hdr.uint64(uint64(p.strings.Len()))
	hdr.uint64(dataLen)

	// Flush output.
	io.Copy(out, &hdr)
	io.Copy(out, &p.strings)
	io.Copy(out, &p.data0)
}
目标文件结构一节中我们已经提到过iexport 导出的内容被”\n$$B\n””\n$$\n”所包裹,也就是编译目标文件中0x34到0xAE的部分。iexport19行之前的部分在进行一些初始化工作。21-27行处理内置数据类型,将这些类型直接插入到已知数据类型表中,避免遇见这些类型时导出到目标文件中。29-38行使用广度优先的方式遍历所有导出内容,调用doDecl。这个函数会将导出信息写入p.data0中,将字符串表写入p.strings,关于这个函数的具体内容下面的下面的小节会详细描述。现在我们继续分析iexport函数的工作。
// Append indices to data0 section.
	dataLen := uint64(p.data0.Len())
	w := p.newWriter()
	w.writeIndex(p.declIndex, true)
	w.writeIndex(p.inlineIndex, false)
	w.flush()
iexport首先获取了doDecl写入的导出信息的长度,再往p.data0中写入了两种索引表,这里需要注意的是:执行完这部分代码p.data0中包含了导出信息和两个索引表,但是dataLen变量只表示导出信息的长度。
// Assemble header.
	var hdr intWriter
	hdr.WriteByte('i')
	hdr.uint64(iexportVersion)
	hdr.uint64(uint64(p.strings.Len()))
	hdr.uint64(dataLen)

	// Flush output.
	io.Copy(out, &hdr)
	io.Copy(out, &p.strings)
	io.Copy(out, &p.data0)
接下来生成了一个头部数据,分别包括一个字符'i'、导出版本号、字符串表长度、导出信息长度。 最后将头部、字符串表、包含了导出信息和两个索引表的p.data0写入到文件中。
回到我们的编译目标文件,可以看到头部数据开始的位置0x34的4字节为:69 00 56 17。69表示头部开始字节i,00表示版本号为0,56和17分别表示86字节的字符串表和23字节的导出信息。头部信息之后紧跟着就是86字节的字符串表。字符串表的内容我们已经在字符串的序列化一节讨论过。下面一节详细讨论doDecl函数和导出信息的内容。

doDecl函数和导出信息

doDecl函数源代码可以看出,函数本身由一个大switch语句构成。switch语句的每个分子代表一种类型,对于不同类型执行不同的操作。对于每个类型首先使用tag函数写入一个字符作为Tag,其次再使用pos函数写入定义所在的位置(参考文件位置序列化),最后写入一些与具体类型有关的数据。所有的导出类型如下所示:
|| 类型     	| Tag 	| 条件                              	|
|----------	|-----	|-----------------------------------|
| 全局变量 	| V   	| n.Op==ONAME && n.Class()==PEXTERN |
| 全局函数 	| F   	| n.Op==ONAME && n.Class()==PFUNC   |
| 全局常数 	| C   	| n.Op==OLITERAL                    |
| 别名     	| A   	| n.Op==OTYPE && IsAlias(n.Sym)     |
| 类型定义 	| T   	| n.Op==OTYPE                       |
有了这些表的内容我们可以继续进行分析,从字符串表后17字节为导出信息。导出信息包括两项内容,分别是0x8E开始的函数和0x9F开始的全局变量,其中我们以0x9F开始的全局变量为例说明解析过程(有兴趣的读者可以自行尝试分析函数部分,有了上面的背景知识分析过程应该不会很困难,开始写作此部分时Go1.14版本处于beta状态,1.14版本中导出版本号从0变为了1。iexport函数变化不大,但是类型的具体导出实现发生了变化,日后新版本稳定后,有机会再根据1.14再单独针对于一些类型做详细的说明)。 全局变量导出后的信息在0X9F,长度为6字节,
具体内容如下:
notion image
Tag位置中56代表字母V的ascii编码。 Pos信息7F开头表示是一个新文件,行号为1。文件名称在字符串表的偏移地址为31,查询字符串表可知代表<autogenerated> 。从名称可知这是一个编译过程中自动生成的虚拟文件,具体作用以后的文章中会说明。 Typ位置的07说明是第7个类型,这个数值小于predeclReserved变量,说明是内置类型。predeclared函数中定义了所有内置类型,这里07代表types.Types[TUINT8],是一个无符号8位整数。 最后对于全局变量varExt输出名称,但是这里是一个特殊的变量,变量名称为空。

总结

至此我们分析完了编译目标文件中的主要信息,可以看到编译目标文件只包含所有包导出的变量、函数、类型定义等信息,不包括代码的具体实现。类似于C语言中的头文件。通过Golang编译器漫谈(1)编译器和连接器我们已知,当其他包import当前包进行编译时也只需要编译目标文件。这个行为和C语言中编译时只需要include的头文件的行为一致。只有到链接过程,才需要具体的实现的链接目标文件。下一篇文章起我们将目光聚焦在链接目标文件。