Go程序是怎样跑起来的
⽬录
刚开始写这篇⽂章的时候,⽬标⾮常⼤,想要探索 Go 程序的⼀⽣:编码、编译、汇编、链接、运⾏、退出。它的每⼀步具体如何进⾏,⼒图弄清 Go 程序的这⼀⽣。
在这个过程中,我⼜复习了⼀遍《程序员的⾃我修养》。这是⼀本讲编译、链接的书,⾮常详细,值得⼀看!数年前,我第⼀次看到这本书的书名,就⾮常喜欢。因为它模仿了周星驰喜剧之王⾥出现的⼀本书 ——《演员的⾃我修养》。⼼向往之!
在开始本⽂之前,先推荐⼀位头条⼤佬的博客——《⾯向信仰编程》,他的 Go 编译系列⽂章,⾮常有深度,直接深⼊编译器源代码,我是看了很多遍了。博客链接可以从参考资料⾥获取。
理想很⼤,实现的难度也是⾮常⼤。为了避免砸了“深度解密”这个牌⼦,这次起了个更温和的名字,嘿嘿。
引⼊
我们从⼀个Hello World的例⼦开始:
package main
import "fmt"
func main() {
fmt.Println("hello world")
}
当我⽤我那价值 1800 元的 cherry 键盘潇洒地敲完上⾯的 hello world 代码时,保存在硬盘上的⽂件就是⼀个字节序列了,每个字节代表⼀个字符。
⽤ vim 打开 ⽂件,在命令⾏模式下,输⼊命令:
:%!xxd
就能在 vim ⾥以⼗六进制查看⽂件内容:
最左边的⼀列代表地址值,中间⼀列代表⽂本对应的 ASCII 字符,最右边的列就是我们的代码。再在终端⾥执⾏man ascii:
和 ASCII 字符表⼀对⽐,就能发现,中间的列和最右边的列是⼀⼀对应的。也就是说,刚刚写完的 ⽂件都是由 ASCII 字符表⽰的,它被称为⽂本⽂件,其他⽂件被称为⼆进制⽂件。
当然,更深⼊地看,计算机中的所有数据,像磁盘⽂件、⽹络中的数据其实都是⼀串⽐特位组成,取决于如何看待它。在不同的情景下,⼀个相同的字节序列可能表⽰成⼀个整数、浮点数、字符串或者是机器指令。
⽽像 这个⽂件,8 个 bit,也就是⼀个字节看成⼀个单位(假定源程序的字符都是 ASCII 码),最终解释成⼈类能读懂的 Go 源码。
Go 程序并不能直接运⾏,每条 Go 语句必须转化为⼀系列的低级机器语⾔指令,将这些指令打包到⼀起,并以⼆进制磁盘⽂件的形式存储起来,也就是可执⾏⽬标⽂件。
从源⽂件到可执⾏⽬标⽂件的转化过程:
完成以上各个阶段的就是 Go 编译系统。你肯定知道⼤名⿍⿍的 GCC(GNU Compile Collection),中⽂名为 GNU 编译器套装,它⽀持像C,C++,Java,Python,Objective-C,Ada,Fortran,Pascal,能够为很多不同的机器⽣成机器码。
可执⾏⽬标⽂件可以直接在机器上执⾏。⼀般⽽⾔,先执⾏⼀些初始化的⼯作;到 main 函数的⼊⼝,执⾏⽤户写的代码;执⾏完成
后,main 函数退出;再执⾏⼀些收尾的⼯作,整个过程完毕。
在接下来的⽂章⾥,我们将探索编译和运⾏的过程。
编译链接概述
Go 源码⾥的编译器源码位于src/cmd/compile路径下,链接器源码位于src/cmd/link路径下。
编译过程
我⽐较喜欢⽤ IDE(集成开发环境)来写代码, Go 源码⽤的 Goland,有时候直接点击 IDE 菜单栏⾥的“运⾏”按钮,程序就跑起来了。这实际上隐含了编译和链接的过程,我们通常将编译和链接合并到⼀起的过程称为构建(Build)。
编译过程就是对源⽂件进⾏词法分析、语法分析、语义分析、优化,最后⽣成汇编代码⽂件,以.s作为⽂件后缀。
之后,汇编器会将汇编代码转变成机器可以执⾏的指令。由于每⼀条汇编语句⼏乎都与⼀条机器指令相对应,所以只是⼀个简单的⼀⼀对应,⽐较简单,没有语法、语义分析,也没有优化这些步骤。
编译器是将⾼级语⾔翻译成机器语⾔的⼀个⼯具,编译过程⼀般分为 6 步:扫描、语法分析、语义分析、源代码优化、代码⽣成、⽬标代码优化。下图来⾃《程序员的⾃我修养》:
词法分析
通过前⾯的例⼦,我们知道,Go 程序⽂件在机器看来不过是⼀堆⼆进制位。我们能读懂,是因为 Goland 按照 ASCII 码(实际上是 UTF-8)把这堆⼆进制位进⾏了编码。例如,把 8个 bit 位分成⼀组,对应⼀个字符,通过对照 ASCII 码表就可以查出来。
当把所有的⼆进制位都对应成了 ASCII 码字符后,我们就能看到有意义的字符串。它可能是关键字,例如:package;可能是字符串,例如:“Hello World”。
词法分析其实⼲的就是这个。输⼊是原始的 Go 程序⽂件,在词法分析器看来,就是⼀堆⼆进制位,根本不知道是什么东西,经过它的分析后,变成有意义的记号。简单来说,词法分析是计算机科学中将字符序列转换为标记(token)序列的过程。
我们来看⼀下上给出的定义:
词法分析(lexical analysis)是计算机科学中将字符序列转换为标记(token)序列的过程。进⾏词法分析的程序或者函数叫作词法分析器(lexical analyzer,简称lexer),也叫扫描器(scanner)。词法分析器⼀般以函数的形式存在,供语法分析器调⽤。
.go⽂件被输⼊到扫描器(Scanner),它使⽤⼀种类似于有限状态机的算法,将源代码的字符系列分割成⼀系列的记号(Token)。
记号⼀般分为这⼏类:关键字、标识符、字⾯量(包含数字、字符串)、特殊符号(如加号、等号)。
例如,对于如下的代码:
slice[i] = i * (2 + 6)
总共包含 16 个⾮空字符,经过扫描后,
记号类型
slice标识符
[左⽅括号
i标识符
]右⽅括号
=赋值
i标识符
*乘号
(左圆括号
2数字
+加号
6数字
记号类型
)右圆括号
上⾯的例⼦源⾃《程序员的⾃我修养》,主要讲解编译、链接相关的内容,很精彩,推荐研读。
Go 语⾔(本⽂的 Go 版本是 1.9.2)扫描器⽀持的 Token 在源码中的路径:
src/cmd/compile/internal/
感受⼀下:
var tokstrings = [...]string{
// source control
_EOF: "EOF",
// names and literals
_Name:    "name",
_Literal: "literal",
// operators and operations
_Operator: "op",
_AssignOp: "op=",
_IncOp:    "opop",
_Assign:  "=",
_Define:  ":=",
_Arrow:    "<-",
_Star:    "*",
// delimitors
_Lparen:    "(",
_Lbrack:    "[",
_Lbrace:    "{",
_Rparen:    ")",
_Rbrack:    "]",
_Rbrace:    "}",
_Comma:    ",",
_Semi:      ";",
_Colon:    ":",
_Dot:      ".",
_DotDotDot: "...",
/
/ keywords
_Break:      "break",
_Case:        "case",
_Chan:        "chan",
_Const:      "const",
_Continue:    "continue",
_Default:    "default",
_Defer:      "defer",
_Else:        "else",
_Fallthrough: "fallthrough",
_For:        "for",
_Func:        "func",
_Go:          "go",
_Goto:        "goto",
_If:          "if",
_Import:      "import",
_Interface:  "interface",
_Map:        "map",
_Package:    "package",
_Range:      "range",
_Return:      "return",
_Select:      "select",
_Struct:      "struct",
_Switch:      "switch",
_Type:        "type",
_Var:        "var",
}
还是⽐较熟悉的,包括名称和字⾯量、操作符、分隔符和关键字。
⽽扫描器的路径是:
src/cmd/compile/internal/
其中最关键的函数就是 next 函数,它不断地读取下⼀个字符(不是下⼀个字节,因为 Go 语⾔⽀持 Unicode 编码,并不是像我们前⾯举得ASCII 码的例⼦,⼀个字符只有⼀个字节),直到这些字符可以构成⼀个 Token。
func (s *scanner) next() {
// ……
redo:
// skip white space
c := s.getr()
for c == ' ' || c == '\t' || c == '\n' && !nlsemi || c == '\r' {
c = s.getr()
}
// token start
s.line, s.col = s.source.line0, l0
if isLetter(c) || c >= utf8.RuneSelf && s.isIdentRune(c, true) {
s.ident()
return
}
switch c {
// ……
case '\n':
s.lit = "newline"
case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
s.number(c)
// ……
default:
<(fmt.Sprintf("invalid character %#U", c))
goto redo
return
assignop:go语言字符串转数组
if c == '=' {
return
}
s.ungetr()
}
代码的主要逻辑就是通过c := s.getr()获取下⼀个未被解析的字符,并且会跳过之后的空格、回车、换⾏、tab 字符,然后进⼊⼀个⼤的switch-case语句,匹配各种不同的情形,最终可以解析出⼀个 Token,并且把相关的⾏、列数字记录下来,这样就完成⼀次解析过程。
当前包中的词法分析器 scanner 也只是为上层提供了 next ⽅法,词法解析的过程都是惰性的,只有在上层的解析器需要时才会调⽤ next 获取最新的 Token。
语法分析
上⼀步⽣成的 Token 序列,需要经过进⼀步处理,⽣成⼀棵以表达式为结点的语法树。
⽐如最开始的那个例⼦,slice[i] = i * (2 + 6),得到的⼀棵语法树如下:
整个语句被看作是⼀个赋值表达式,左⼦树是⼀个数组表达式,右⼦树是⼀个乘法表达式;数组表达式由 2 个符号表达式组成;乘号表达式则是由⼀个符号表达式和⼀个加号表达式组成;加号表达式则是由两个数字组成。符号和数字是最⼩的表达式,它们不能再被分解,通常作为树的叶⼦节点。
语法分析的过程可以检测⼀些形式上的错误,例如:括号是否缺少⼀半,+号表达式缺少⼀个操作数等。
语法分析是根据某种特定的形式⽂法(Grammar)对 Token 序列构成的输⼊⽂本进⾏分析并确定其语法结构的⼀种过程。
语义分析
语法分析完成后,我们并不知道语句的具体意义是什么。像上⾯的*号的两棵⼦树如果是两个指针,这是不合法的,但语法分析检测不出来,语义分析就是⼲这个事。
编译期所能检查的是静态语义,可以认为这是在“代码”阶段,包括变量类型的匹配、转换等。例如,将⼀个浮点值赋给⼀个指针变量的时候,明显的类型不匹配,就会报编译错误。⽽对于运⾏期间才会出现的错误:不⼩⼼除了⼀个 0 ,语义分析是没办法检测的。
语义分析阶段完成之后,会在每个节点上标注上类型:
Go 语⾔编译器在这⼀阶段检查常量、类型、函数声明以及变量赋值语句的类型,然后检查哈希中键的类型。实现类型检查的函数通常都是⼏千⾏的巨型 switch/case 语句。
类型检查是 Go 语⾔编译的第⼆个阶段,在词法和语法分析之后我们得到了每个⽂件对应的抽象语法树,随后的类型检查会遍历
抽象语法树中的节点,对每个节点的类型进⾏检验,出其中存在的语法错误。
在这个过程中也可能会对抽象语法树进⾏改写,这不仅能够去除⼀些不会被执⾏的代码对编译进⾏优化提⾼执⾏效率,⽽且也会修改 make、new 等关键字对应节点的操作类型。
例如⽐较常⽤的 make 关键字,⽤它可以创建各种类型,如 slice,map,channel 等等。到这⼀步的时候,对于 make 关键字,也就是OMAKE 节点,会先检查它的参数类型,根据类型的不同,进⼊相应的分⽀。如果参数类型是 slice,就会进⼊ TSLICE case 分⽀,检查 len 和 cap 是否满⾜要求,如 len <= cap。最后节点类型会从 OMAKE 改成 OMAKESLICE。
中间代码⽣成
我们知道,编译过程⼀般可以分为前端和后端,前端⽣成和平台⽆关的中间代码,后端会针对不同的平台,⽣成不同的机器码。
前⾯词法分析、语法分析、语义分析等都属于编译器前端,之后的阶段属于编译器后端。
编译过程有很多优化的环节,在这个环节是指源代码级别的优化。它将语法树转换成中间代码,它是语法树的顺序表⽰。
中间代码⼀般和⽬标机器以及运⾏时环境⽆关,它有⼏种常见的形式:三地址码、P-代码。例如,最基本的三地址码是这样的:
x = y op z
表⽰变量 y 和变量 z 进⾏ op 操作后,赋值给 x。op 可以是数学运算,例如加减乘除。
前⾯我们举的例⼦可以写成如下的形式:
t1 = 2 + 6
t2 = i * t1
slice[i] = t2
这⾥ 2 + 6 是可以直接计算出来的,这样就把 t1 这个临时变量“优化”掉了,⽽且 t1 变量可以重复利⽤,因此 t2 也可以“优化”掉。优化之后:
t1 = i * 8
slice[i] = t1
Go 语⾔的中间代码表⽰形式为 SSA(Static Single-Assignment,静态单赋值),之所以称之为单赋值,是因为每个名字在 SSA 中仅被赋值⼀次。。
这⼀阶段会根据 CPU 的架构设置相应的⽤于⽣成中间代码的变量,例如编译器使⽤的指针和寄存器的⼤⼩、可⽤寄存器列表等。中间代码⽣成和机器码⽣成这两部分会共享相同的设置。
在⽣成中间代码之前,会对抽象语法树中节点的⼀些元素进⾏替换。这⾥引⽤《⾯向信仰编程》编译原理相关博客⾥的⼀张图:
例如对于 map 的操作 m[i],在这⾥会被转换成 mapacess 或 mapassign。
Go 语⾔的主程序在执⾏时会调⽤ runtime 中的函数,也就是说关键字和内置函数的功能其实是由语⾔的编译器和运⾏时共同完成的。
中间代码的⽣成过程其实就是从 AST 抽象语法树到 SSA 中间代码的转换过程,在这期间会对语法树中的关键字在进⾏⼀次更
新,更新后的语法树会经过多轮处理转变最后的 SSA 中间代码。
⽬标代码⽣成与优化
不同机器的机器字长、寄存器等等都不⼀样,意味着在不同机器上跑的机器码是不⼀样的。最后⼀步的⽬的就是要⽣成能在不同 CPU 架构上运⾏的代码。
为了榨⼲机器的每⼀滴油⽔,⽬标代码优化器会对⼀些指令进⾏优化,例如使⽤移位指令代替乘法指令等。
这块实在没能⼒深⼊,幸好也不需要深⼊。对于应⽤层的软件开发⼯程师来说,了解⼀下就可以了。
链接过程
编译过程是针对单个⽂件进⾏的,⽂件与⽂件之间不可避免地要引⽤定义在其他模块的全局变量或者函数,这些变量或函数的地址只有在此阶段才能确定。
链接过程就是要把编译器⽣成的⼀个个⽬标⽂件链接成可执⾏⽂件。最终得到的⽂件是分成各种段的,⽐如数据段、代码段、BSS段等等,运⾏时会被装载到内存中。各个段具有不同的读写、执⾏属性,保护了程序的安全运⾏。
这部分内容,推荐看《程序员的⾃我修养》和《深⼊理解计算机系统》。
Go 程序启动

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。