在这个环节是指源代码级别的优化。它将语法树转换成中间代码,它是语法树的顺序表示。
中间代码一般和目标机器以及运行时环境无关,它有几种常见的形式:三地址码、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 程序启动
仍然使用 hello-world 项目的例子。在项目根目录下执行:
go build -gcflags "-N -l" -o hello src/main.go
-gcflags "-N -l"
是为了关闭编译器优化和函数内联,防止后面在设置断点的时候找不到相对应的代码位置。
得到了可执行文件 hello,执行:
[qcrao@qcrao hello-world]$ gdb hello
进入 gdb 调试模式,执行 info files
,得到可执行文件的文件头,列出了各种段:
同时,我们也得到了入口地址:0x450e20。
(gdb) b *0x450e20
Breakpoint 1 at 0x450e20: file /usr/local/go/src/runtime/rt0_linux_amd64.s, line 8.
这就是 Go 程序的入口地址,我是在 linux 上运行的,所以入口文件为 src/runtime/rt0_linux_amd64.s
,runtime 目录下有各种不同名称的程序入口文件,支持各种操作系统和架构,代码为:
TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8
LEAQ 8(SP), SI // argv
MOVQ 0(SP), DI // argc
MOVQ $main(SB), AX
JMP AX
主要是把 argc,argv 从内存拉到了寄存器。这里 LEAQ 是计算内存地址,然后把内存地址本身放进寄存器里,也就是把 argv 的地址放到了 SI 寄存器中。最后跳转到:
TEXT main(SB),NOSPLIT,$-8
MOVQ $runtime·rt0_go(SB), AX
JMP AX
继续跳转到 runtime·rt0_go(SB)
,位置:/usr/local/go/src/runtime/asm_amd64.s
,代码:
TEXT runtime·rt0_go(SB),NOSPLIT,$0
// 省略很多 CPU 相关的特性标志位检查的代码
// 主要是看不懂,^_^
// ………………………………
// 下面是最后调用的一些函数,比较重要
// 初始化执行文件的绝对路径
CALL runtime·args(SB)
// 初始化 CPU 个数和内存页大小
CALL runtime·osinit(SB)
// 初始化命令行参数、环境变量、gc、栈空间、内存管理、所有 P 实例、HASH算法等
CALL runtime·schedinit(SB)
// 要在 main goroutine 上运行的函数
MOVQ $runtime·mainPC(SB), AX // entry
PUSHQ AX
PUSHQ $0 // arg size
// 新建一个 goroutine,该 goroutine 绑定 runtime.main,放在 P 的本地队列,等待调度
CALL runtime·newproc(SB)
POPQ AX
POPQ AX
// 启动M,开始调度goroutine
CALL runtime·mstart(SB)
MOVL $0xf1, 0xf1 // crash
RET
DATA runtime·mainPC+0(SB)/8,$runtime·main(SB)
GLOBL runtime·mainPC(SB),RODATA,$8
参考文献里的一篇文章【探索 golang 程序启动过程】研究得比较深入,总结下:
- 检查运行平台的CPU,设置好程序运行需要相关标志。
- TLS的初始化。
- runtime.args、runtime.osinit、runtime.schedinit 三个方法做好程序运行需要的各种变量与调度器。
- runtime.newproc创建新的goroutine用于绑定用户写的main方法。
- runtime.mstart开始goroutine的调度。
最后用一张图来总结 go bootstrap 过程吧:
main 函数里执行的一些重要的操作包括:新建一个线程执行 sysmon 函数,定期垃圾回收和调度抢占;启动 gc;执行所有的 init 函数等等。
上面是启动过程,看一下退出过程:
当 main 函数执行结束之后,会执行 exit(0) 来退出进程。若执行 exit(0) 后,进程没有退出,main 函数最后的代码会一直访问非法地址:
exit(0)
for {
var x *int32
*x = 0
}
正常情况下,一旦出现非法地址访问,系统会把进程杀死,用这样的方法确保进程退出。
关于