前言
使用go时间久了,就会想搞明白go的底层是如何运行的,也就是go runtime的实现原理。本文主要描述了go的启动过程。不过会先从前置知识汇编学起,go runtime涉及了很多汇编知识,而go使用的汇编是plan9汇编,了解完汇编的大概知识再一步步的跟踪go的启动流程。
内存 谈谈变量,在编程中我们会定义各种变量,这些变量其实就是一个个的内存地址。
变量的本质就是内存
指针保存了内存地址
指针的本质上就是地址
下面2个图都是进程经典的虚拟内存结构模型
汇编基础 go使用plan9汇编,plan9汇编的操作数类似AT&T,使用go tool objdump/go tool compile -S
可以输出go的汇编代码, plan9的汇编其实是一个伪汇编,go程序会统一编译成plan9汇编,然后plan9汇编在根据不同的系统架构编译成目标的汇编代码。大概就是下面的流程。
go->plan9汇编->目标机器汇编->可执行程序
如何编译汇编
go build -gcflags “-N -l” main.go, 然后go tool objdump -s “main.” main
go tool compile -S -N -l main.go
go build -gcflags=”-N -l -S” main.go
GOSSAFUNC=”main” go build main.go // static single-assignment, 查看 Go 程序编译的整个过程, 最后一步就是汇编代码
以上四种方式都可以输出汇编代码,其中几个参数的含义-l 禁止内联 -N 编译时,禁止优化 -S 输出汇编代码
一些基本概念
栈:进程、线程、goroutine 都有自己的调用栈,先进后出(FILO)
栈帧:可以理解是函数调用时,在栈上为函数所分配的内存区域
调用者:caller,比如:A 函数调用了 B 函数,那么 A 就是调用者
被调者:callee,比如:A 函数调用了 B 函数,那么 B 就是被调者
基本指令 栈调整 plan9中没有push和pop, 栈的调试使用了硬件SP寄存器
1 2 SUBQ $0x18, SP // 对 SP 做减法,为函数分配函数栈帧,栈是从高地址向低地址增长 ADDQ $0x18, SP // 对 SP 做加法,清除函数栈帧
数据搬运 常数在 plan9 汇编用 $num 表示,可以为负数,默认情况下为十进制。可以用 $0x123 的形式来表示十六进制数。
1 2 3 4 MOVB $1, DI // 1 byte MOVW $0x10, BX // 2 bytes MOVD $1, DX // 4 bytes MOVQ $-10, AX // 8 bytes
计算指令 1 2 3 ADDQ AX, BX // BX += AX SUBQ AX, BX // BX -= AX IMULQ AX, BX // BX *= AX
条件跳转/无条件跳转 1 2 3 4 5 6 7 8 // 无条件跳转 JMP addr // 跳转到地址,地址可为代码中的地址,不过实际上手写不会出现这种东西 JMP label // 跳转到标签,可以跳转到同一函数内的标签位置 JMP 2(PC) // 以当前指令为基础,向前/后跳转 x 行 JMP -2(PC) // 同上 // 有条件跳转 JZ target // 如果 zero flag 被 set 过,则跳转
指令集 https://github.com/golang/arch/blob/master/x86/x86.csv
寄存器 通用寄存器 amd64通用寄存器信息
伪寄存器 go汇编有以下几种伪寄存器
FP: Frame pointer: arguments and locals 参数和变量位置
PC: Program counter: jumps and branches. 程序计数器,调整和分支使用
SB: Static base pointer: global symbols. 全局变量等的位置
SP: Stack pointer: top of stack. 栈顶, symbol+offset(SP)形式,则表示伪寄存器 SP。如果是 offset(SP)则表示硬件寄存器 SP, 对于使用go tool 工具生成的汇编,其中的SP一律是硬件寄存器SP
BP: 表示函数调用栈的 起始栈底 (栈的方向从大到小,真 SP 表示栈顶),记录当前函数栈帧的 结束位置
伪寄存器内存模型
变量声明 汇编里的变量一般存储在.rodata或.data段中。一般是程序里的已初始化的全局const、var、static变量/常量。
1 DATA symbol+offset(SB)/width, value
其中 symbol 为变量在汇编语言中对应的标识符,offset 是符号开始地址的偏移量,width 是要初始化内存的宽度大小,value 是要初始化的值。其中当前包中 Go 语言定义的符号 symbol,在汇编代码中对应 ·symbol,其中 “·” 中点符号为一个特殊的 unicode 符号。
使用GLOBAL指令将变量导出可以供其他代码引用
其中 symbol 对应汇编中符号的名字,width 为符号对应内存的大小。用以下命令将汇编中的 ·Id 变量导出:
1 2 3 4 5 6 var const_id int // readonly #include "textflag.h" GLOBL ·const_id(SB),NOPTR|RODATA,$8 DATA ·const_id+0(SB)/8,$9527
我们使用 #include 语句包含定义标志的 “textflag.h” 头文件(和 C 语言中预处理相同)。然后 GLOBL 汇编命令在定义变量时,给变量增加了 NOPTR 和 RODATA 两个标志(多个标志之间采用竖杠分割),表示变量中没有指针数据同时定义在只读数据段。
变量一般也叫可取地址的值,但是 const_id 虽然可以取地址,但是确实不能修改。不能修改的限制并不是由编译器提供,而是因为对该变量的修改会导致对只读内存段进行写,从而导致异常
函数 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // func add(a, b int) int // => 该声明定义在同一个 package 下的任意 .go 文件中 // => 只有函数头,没有实现 TEXT pkgname·add(SB), NOSPLIT, $0-8 MOVQ a+0(FP), AX MOVQ a+8(FP), BX ADDQ AX, BX MOVQ BX, ret+16(FP) RET 参数及返回值大小 | TEXT pkgname·add(SB),NOSPLIT,$32-32 | | | 包名 函数名 栈帧大小(局部变量+可能需要的额外调用函数的参数空间的总大小,但不包括调用其它函数时的 ret address 的大小)
NOSPLIT: 向编译器表明不应该插入 stack-split 的用来检查栈需要扩张的前导指令。在我们 add 函数的这种情况下,编译器自己帮我们插入了这个标记: 它足够聪明地意识到,由于 add 没有任何局部变量且没有它自己的栈帧,所以一定不会超出当前的栈。不然,每次调用函数时,在这里执行栈检查就是完全浪费 CPU 时间了
函数调用关系 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 caller +------------------+ | | +----------------------> -------------------- | | | | | caller parent BP | | BP(pseudo SP) -------------------- | | | | | Local Var0 | | -------------------- | | | | | ....... | | -------------------- caller stack frame | | | | Local VarN | | |------------------| | | | | | callee arg1(n) | | |------------------| | | | | | callee arg0 | | SP(Real Register) ----------------------------------------------+ FP(virtual register) | | | | | | return addr | parent return address | +----------------------> +------------------+--------------------------- <-------------------------------+ | caller BP | | | (caller frame pointer) | | BP(pseudo SP) ---------------------------- | | | | | Local Var0 | | ---------------------------- | | | | Local Var1 | ---------------------------- callee stack frame | | | ..... | ---------------------------- | | | | | Local VarN | | SP(Real Register) ---------------------------- | | | | | | | +--------------------------+ <-------------------------------+ callee
样例 样例一
关于BP的操作的含义可以看一下再论函数 这篇内容介绍
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 package main const f = 11 func add(a, b int) int{ sum := 0 // 不设置该局部变量sum,add栈空间大小会是0 sum = a+b return sum } func main(){ println(add(1,2)) println(f) } go tool compile -N -l -S main.go "".add STEXT nosplit size=60 args=0x18 locals=0x10 funcid=0x0 0x0000 00000 (main.go:6) TEXT "".add(SB), NOSPLIT|ABIInternal, $16-24 ;; 16表示该函数栈大小16字节,24是函数入参和返回值的总大小 0x0000 00000 (main.go:6) SUBQ $16, SP ;; 生成add栈空间 0x0004 00004 (main.go:6) MOVQ BP, 8(SP) ;; 首先是将 BP 寄存器保持到多分配的 8 字节栈空间 0x0009 00009 (main.go:6) LEAQ 8(SP), BP ;; 然后将 8(SP) 地址重新保持到了 BP 寄存器中 0x000e 00014 (main.go:6) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x000e 00014 (main.go:6) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x000e 00014 (main.go:6) MOVQ $0, "".~r2+40(SP) ;;初始化返回值 0x0017 00023 (main.go:7) MOVQ $0, "".sum(SP) ;; 局部变量sum赋为0 0x001f 00031 (main.go:8) MOVQ "".a+24(SP), AX ;; 取参数a 0x0024 00036 (main.go:8) ADDQ "".b+32(SP), AX ;; 等价于AX=a+b 0x0029 00041 (main.go:8) MOVQ AX, "".sum(SP) ;; 赋值局部变量sum 0x002d 00045 (main.go:9) MOVQ AX, "".~r2+40(SP) ;; 设置返回值 0x0032 00050 (main.go:9) MOVQ 8(SP), BP ;; BP 指令则是从栈中恢复之前备份的前 BP 寄存器的值 0x0037 00055 (main.go:9) ADDQ $16, SP ;; 清除add栈空间 0x003b 00059 (main.go:9) RET 0x0000 48 83 ec 10 48 89 6c 24 08 48 8d 6c 24 08 48 c7 H...H.l$.H.l$.H. 0x0010 44 24 28 00 00 00 00 48 c7 04 24 00 00 00 00 48 D$(....H..$....H 0x0020 8b 44 24 18 48 03 44 24 20 48 89 04 24 48 89 44 .D$.H.D$ H..$H.D 0x0030 24 28 48 8b 6c 24 08 48 83 c4 10 c3 $(H.l$.H.... "".main STEXT size=144 args=0x0 locals=0x28 funcid=0x0 0x0000 00000 (main.go:11) TEXT "".main(SB), ABIInternal, $40-0 0x0000 00000 (main.go:11) MOVQ (TLS), CX 0x0009 00009 (main.go:11) CMPQ SP, 16(CX) 0x000d 00013 (main.go:11) PCDATA $0, $-2 0x000d 00013 (main.go:11) JLS 134 0x000f 00015 (main.go:11) PCDATA $0, $-1 0x000f 00015 (main.go:11) SUBQ $40, SP ;; 生成main栈空间 0x0013 00019 (main.go:11) MOVQ BP, 32(SP) 0x0018 00024 (main.go:11) LEAQ 32(SP), BP 0x001d 00029 (main.go:11) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x001d 00029 (main.go:11) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x001d 00029 (main.go:12) MOVQ $1, (SP) ;; add入参:1 0x0025 00037 (main.go:12) MOVQ $2, 8(SP) ;; add入参:2 0x002e 00046 (main.go:12) PCDATA $1, $0 0x002e 00046 (main.go:12) CALL "".add(SB) ;; 调用add函数 0x0033 00051 (main.go:12) MOVQ 16(SP), AX 0x0038 00056 (main.go:12) MOVQ AX, ""..autotmp_0+24(SP) 0x003d 00061 (main.go:12) NOP 0x0040 00064 (main.go:12) CALL runtime.printlock(SB) 0x0045 00069 (main.go:12) MOVQ ""..autotmp_0+24(SP), AX 0x004a 00074 (main.go:12) MOVQ AX, (SP) 0x004e 00078 (main.go:12) CALL runtime.printint(SB) 0x0053 00083 (main.go:12) CALL runtime.printnl(SB) 0x0058 00088 (main.go:12) CALL runtime.printunlock(SB) 0x005d 00093 (main.go:12) NOP 0x0060 00096 (main.go:13) CALL runtime.printlock(SB) 0x0065 00101 (main.go:13) MOVQ $11, (SP) 0x006d 00109 (main.go:13) CALL runtime.printint(SB) 0x0072 00114 (main.go:13) CALL runtime.printnl(SB) 0x0077 00119 (main.go:13) CALL runtime.printunlock(SB) 0x007c 00124 (main.go:14) MOVQ 32(SP), BP 0x0081 00129 (main.go:14) ADDQ $40, SP ;; 清除main栈空间 0x0085 00133 (main.go:14) RET 0x0086 00134 (main.go:14) NOP 0x0086 00134 (main.go:11) PCDATA $1, $-1 0x0086 00134 (main.go:11) PCDATA $0, $-2 0x0086 00134 (main.go:11) CALL runtime.morestack_noctxt(SB) 0x008b 00139 (main.go:11) PCDATA $0, $-1 0x008b 00139 (main.go:11) JMP 0 0x0000 64 48 8b 0c 25 00 00 00 00 48 3b 61 10 76 77 48 dH..%....H;a.vwH 0x0010 83 ec 28 48 89 6c 24 20 48 8d 6c 24 20 48 c7 04 ..(H.l$ H.l$ H.. 0x0020 24 01 00 00 00 48 c7 44 24 08 02 00 00 00 e8 00 $....H.D$....... 0x0030 00 00 00 48 8b 44 24 10 48 89 44 24 18 0f 1f 00 ...H.D$.H.D$.... 0x0040 e8 00 00 00 00 48 8b 44 24 18 48 89 04 24 e8 00 .....H.D$.H..$.. 0x0050 00 00 00 e8 00 00 00 00 e8 00 00 00 00 0f 1f 00 ................ 0x0060 e8 00 00 00 00 48 c7 04 24 0b 00 00 00 e8 00 00 .....H..$....... 0x0070 00 00 e8 00 00 00 00 e8 00 00 00 00 48 8b 6c 24 ............H.l$ 0x0080 20 48 83 c4 28 c3 e8 00 00 00 00 e9 70 ff ff ff H..(.......p... rel 5+4 t=17 TLS+0 rel 47+4 t=8 "".add+0 rel 65+4 t=8 runtime.printlock+0 rel 79+4 t=8 runtime.printint+0 rel 84+4 t=8 runtime.printnl+0 rel 89+4 t=8 runtime.printunlock+0 rel 97+4 t=8 runtime.printlock+0 rel 110+4 t=8 runtime.printint+0 rel 115+4 t=8 runtime.printnl+0 rel 120+4 t=8 runtime.printunlock+0 rel 135+4 t=8 runtime.morestack_noctxt+0 go.cuinfo.packagename. SDWARFCUINFO dupok size=0 0x0000 6d 61 69 6e main ""..inittask SNOPTRDATA size=24 0x0000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 0x0010 00 00 00 00 00 00 00 00 ........ gclocals·33cdeccccebe80329f1fdbee7f5874cb SRODATA dupok size=8 0x0000 01 00 00 00 00 00 00 00 ........
上述样例代码的栈空间用图来表示如下
样例二
.s
文件可以直接使用.go
中定义的全局变量
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 refer.go package mainvar a = 999 func get () int func main () { println (get()) } refer.s #include "textflag.h" TEXT ·get(SB), NOSPLIT, $0 -8 MOVQ ·a(SB), AX MOVQ AX, ret+0 (FP) RET
以上2个文件放到同一目录中,使用go build -gcflags "-N -l" .
可以编译
关于汇编的部分到此未知,这里也仅仅是简单的介绍下,go的汇编不仅于此,更多内容可以查看引用中的一些文章。下面就开始看看go是如何启动的
go生命周期 build 先从初学go时的2个命令说起,go run
, go build
。打开go src,从go的文件目录命名其实就能大概猜出这个目录是做什么事的,所以很明显知道 src/cmd/go
就是跟执行go命令有关的。可以在main.go打个断点,并设置下goland编译参数,设置运行的软件包目录为源码下的cmd/go, 然后再填写程序运行参数,试试go run 和 go build是不是走到这里。其实这里就是用go来编译go src然后用编译后的go src cmd来运行go代码。
从init函数可以看到,go的命令都是在Command结构体定义, 然后再init里初始化
build src/cmd/go/internal/work.runBuild()
run src/cmd/go/internal/work.runRun()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 src/cmd/go /internal/base/base.go type Command struct { Run func (ctx context.Context, cmd *Command, args []string ) UsageLine string Short string Long string Flag flag.FlagSet CustomFlags bool Commands []*Command } src/cmd/go /main.go func init () { base.Go.Commands = []*base.Command{ bug.CmdBug, work.CmdBuild, clean.CmdClean, doc.CmdDoc, envcmd.CmdEnv, fix.CmdFix, fmtcmd.CmdFmt, generate.CmdGenerate, modget.CmdGet, ..... } } func main () { _ = go11tag flag.Usage = base.Usage flag.Parse() log.SetFlags(0 ) args := flag.Args() if len (args) < 1 { base.Usage() } ... } func invoke (cmd *base.Command, args []string ) { .... ctx := maybeStartTrace(context.Background()) ctx, span := trace.StartSpan(ctx, fmt.Sprint("Running " , cmd.Name(), " command" )) cmd.Run(ctx, cmd, args) span.Done() }
runtime start Go 程序启动后需要对自身运行时进行初始化,其真正的程序入口由 runtime 包控制, 以 AMD64 架构上的 Linux 和 macOS 为例,分别位于:src/runtime/rt0_linux_amd64.s 和 src/runtime/rt0_darwin_amd64.s
通过使用objdump可以查看程序入口
1 2 3 4 5 6 7 8 9 10 11 12 ➜ demo2 objdump -f demo demo: file format elf64-x86-64 architecture: i386:x86-64, flags 0x00000112: EXEC_P, HAS_SYMS, D_PAGED start address 0x000000000045ce80 ➜ demo2 objdump -d demo | grep "45ce80" 000000000045ce80 <_rt0_amd64_linux>: 45ce80: e9 db ca ff ff jmpq 459960 <_rt0_amd64>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8 JMP _rt0_amd64(SB) 然后跳转到_rt0_amd64, 这里是大部分ADM64系统的启动入口 // _rt0_amd64 is common startup code for most amd64 systems when using // internal linking. This is the entry point for the program from the // kernel for an ordinary -buildmode=exe program. The stack holds the // number of arguments and the C-style argv. TEXT _rt0_amd64(SB),NOSPLIT,$-8 // SP的前2个值对应argc和argv MOVQ 0(SP), DI // argc LEAQ 8(SP), SI // argv JMP runtime·rt0_go(SB)
其中最核心的就是runtime·rt0_go, 详细的可以看具体的代码,src/runtime/asm_amd64.s#L148 ,这里只总结该过程的流程,其中有涉及的go调度器,这部分单独在讲,这里明白大概意思就行。
runtime main 下面来看看runtime.main的流程, 代码在src/runtime/proc.go
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 func main () { g := getg() ... if goarch.PtrSize == 8 { maxstacksize = 1000000000 } else { maxstacksize = 250000000 } ... if GOARCH != "wasm" { systemstack(func () { newm(sysmon, nil , -1 ) }) } ... gcenable() ... fn := main_main fn() ... exit(0 ) }
然后程序就运行到我们熟知的main.main了, 到此go如何启动的整体流程也就讲完了,当然中间还包括很多细节并未讲到。以及runtime.main是如何调度的,这些就在下一篇有关调度器的章节细细探讨。
引用