Go底层总结(1):编译过程和理解可执行文件

以一个hello world的例子为例,说明下go语言编译的过程。

package main

import "fmt"

func main() {
	fmt.Println("hello world")
}

编译阶段

go程序不能直接运行,大体要经过词法分析,语法分析,语义分析来生成中间代码,也就是编译的过程,之后会形成汇编代码,然后再由汇编器把汇编代码1v1翻译成机器指令,最后要由链接器把多个文件链接在一起生成最终的可执行文件。

go编译

go编译器的代码位于src/cmd/compile路径下,链接器位于src/cmd/link路径下。

词法分析

扫描器位于src/cmd/compile/scanner.go下面

词法分析就是把源代码一堆字符里面去掉空格tab等没用的东西,提取token的过程,或者说.go 文件被输入到扫描器(Scanner),它使用一种类似于有限状态机的算法,将源代码的字符系列分割成一系列的记号(Token)。

记号一般分为这几类:关键字、标识符、字面量(包含数字、字符串)、特殊符号(如加号、等号)。

代码位于src/cmd/compile/internal/syntax/scanner.go:88 func (s *scanner) next() {

func (s *scanner) next() {
	nlsemi := s.nlsemi
	s.nlsemi = false

redo:
	// skip white space
	s.stop()
	startLine, startCol := s.pos()
	for s.ch == ' ' || s.ch == '\t' || s.ch == '\n' && !nlsemi || s.ch == '\r' {
		s.nextch()
	}

	// token start
	s.line, s.col = s.pos()
	s.blank = s.line > startLine || startCol == colbase
	s.start()
	if isLetter(s.ch) || s.ch >= utf8.RuneSelf && s.atIdentChar(true) {
		s.nextch()
		s.ident()
		return
	}

	switch s.ch {
	case -1:
		if nlsemi {
			s.lit = "EOF"
			s.tok = _Semi
			break
		}
		s.tok = _EOF

	case '\n':
		s.nextch()
		s.lit = "newline"
		s.tok = _Semi

	case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
		s.number(false)

	case '"':
		s.stdString()

	case '`':
		s.rawString()

	case '\'':
		s.rune()
   ...

可以看出,scanner就是把字符一个一个取出来,遇到空格跳过,然后switch一个个判断是否是数字,是符号,是关键词等,

识别出来之后调用相应的函数。

语法分析

语法分析是把token按照语法生成一棵以表达式为节点的语法树

比如slice[i] = i * (2 + 6) 可以生成

整个语句被看作是一个赋值表达式,左子树是一个数组表达式,右子树是一个乘法表达式;数组表达式由 2 个符号表达式组成;乘号表达式则是由一个符号表达式和一个加号表达式组成;加号表达式则是由两个数字组成。符号和数字是最小的表达式,它们不能再被分解,通常作为树的叶子节点。

语法分析的过程可以检测一些形式上的错误,例如:括号是否缺少一半,+ 号表达式缺少一个操作数等。

语义分析

语法分析完成后,我们并不知道语句的具体意义是什么。像上面的 * 号的两棵子树如果是两个指针,这是不合法的,但语法分析检测不出来,语义分析就是干这个事。

编译期所能检查的是静态语义,可以认为这是在“代码”阶段,包括变量类型的匹配、转换等。例如,将一个浮点值赋给一个指针变量的时候,明显的类型不匹配,就会报编译错误。而对于运行期间才会出现的错误:不小心除了一个 0 ,语义分析是没办法检测的。

语义分析阶段完成之后,会在每个节点上标注上类型:

语义分析完成

Go 语言编译器在这一阶段检查常量、类型、函数声明以及变量赋值语句的类型,然后检查哈希中键的类型。实现类型检查的函数通常都是几千行的巨型 switch/case 语句。

类型检查是 Go 语言编译的第二个阶段,在词法和语法分析之后我们得到了每个文件对应的抽象语法树,随后的类型检查会遍历抽象语法树中的节点,对每个节点的类型进行检验,找出其中存在的语法错误。

在这个过程中也可能会对抽象语法树进行改写,这不仅能够去除一些不会被执行的代码对编译进行优化提高执行效率,而且也会修改 make、new 等关键字对应节点的操作类型。

例如比较常用的 make 关键字,用它可以创建各种类型,如 slice,map,channel 等等。到这一步的时候,对于 make 关键字,也就是 OMAKE 节点,会先检查它的参数类型,根据类型的不同,进入相应的分支。如果参数类型是 slice,就会进入 TSLICE case 分支,检查 len 和 cap 是否满足要求,如 len <= cap。最后节点类型会从 OMAKE 改成 OMAKESLICE。

中间代码生成

go语言里面有一些是像make可以操作 map 结构体 切片等,还有像close等等,都是需要调用标准库里面对应的函数。

builtin mapping

例如对于 map 的操作 m[i],在这里会被转换成 mapacess 或 mapassign。

链接过程

编译过程是针对单个文件进行的,文件与文件之间不可避免地要引用定义在其他模块的全局变量或者函数,这些变量或函数的地址只有在此阶段才能确定。

链接过程就是要把编译器生成的一个个目标文件链接成可执行文件。最终得到的文件是分成各种段的,比如数据段、代码段、BSS段等等,运行时会被装载到内存中。各个段具有不同的读写、执行属性,保护了程序的安全运行。

整个编译过程查看

可以通过执行go build -x main.go,这样会把编译的命令都打印出来。

如果说为了防止内联和编译器优化,可以关闭掉,防止debug跟代码不一致,打断点找不到代码位置,方法是go build -gcflags "-N -l" main.go

理解可执行文件

可执行文件在linux windows mac上面的结构是不一样的。

Linux windows MacOS
ELF PE Mach-O

以linux操作系统为例,ELF(executable and Linked Format)由三部分构成

  • ELF header
  • Section header
  • Section