Go是为数不多可以自举(bootstrap、自编译)的语言,即其编译器是用自身语言编写。你可以通过环境变量GOROOT_BOOTSTRAP
使用低版本的Go环境编译一个高版本的Go。
编译器中知名的GCC编译器也可以自举编译,但是编译比较麻烦,没有Go这么易用。而其他流行的语言编译器(解释器)一般都是用其他更高效的语言编写的。如Python是用的C语言,Java虽然编译器是Java写的,但还有一个复杂的运行时虚拟机是用C++写的。
而Go的明显优势是,运行时、编译器全是用自己写的。这样对普通使用者来说,没有什么区别,这也是语言设计者所期望的。对于喜欢深层技术人来说,就很非常心动。主要有以下原因:
也即,学习成本低了一些。很多通用的技术细节,编译器和运行时中都能找到实例。
Go的运行时提供了语言的核心特性,如chan、goroutine、reflection、map、slice、GC等。运行时一方面提供上述特性,使语言更易用、上手;同时,运行时要对语言本身负责,保证其运行的正确性和高性能。所以Go的运行时也比较复杂。出于性能和不同平台的需要,代码中还包含了相当大量的汇编。
runtime本身相当于资源的协调者,拥有全局的视角。所以协调者本身不能和用户的程序(协程)混淆。所以就有了两种栈。user stack
和system stack
。用户的代码运行在user stack
上,调度、GC等运行时的处理大多都在system stack
下进行。
system stack
与普通的user stack
相比有如下特点:
可理解为C语言中的栈。
运行时中能看到一个经常出现的函数systemstack
。这个函数是用来切换不同的栈。与这个函数等价的一个注解是//go:systemstack
,后者用来指定一个函数在system stack
上运行。
system stack
是不可扩容的,反之并不成立,普通的user stack
也可以不扩容。可通过//go:nosplit
来指定函数的栈不需要扩容。一般情况下,编码人员不要指定栈不扩容;这个特性使用时要非常小心,除非你对性能要求特别严苛。因为一旦开启,但栈又需要扩容时,就会出现栈溢出异常。
带来的问题:
栈回溯变得很复杂,需要区分用户栈和系统栈。
为什么这两个事情一起说呢?是因为其实现机制上有一定的重叠。
编译时,编译器为每个没有标记//go:split
和//go:systemstack
的函数,开头和结尾插入runtime.morestack
的调用。
0x00000000004b31e0 <+0>: cmp 0x10(%r14),%rsp
0x00000000004b31e4 <+4>: jbe 0x4b3296
...
0x00000000004b3296 <+182>: call 0x45c8a0
0x00000000004b329b <+187>: nopl 0x0(%rax,%rax,1)
0x00000000004b32a0 <+192>: jmp 0x4b31e0
而morestack正是栈扩容的入口函数。但其职责不止栈扩容。
r14
是Go中用来保存当前运行协程的指针,0x10(%r14)
表示栈的guard值,第1行比较用来判断是否需要扩容。64位系统中,类型为32bit。这个判断还两个特殊用途。
0xfffffeed
时,表示要强制扩容。不需要扩容时,也会触发栈拷贝。这是运行时的一种调试机制。0xfffffade
时,表示要被抢占调度(让出CPU执行)了。安全点:指协程在某指令处中断执行,然后恢复到其他M上执行时,不会影响计算结果的正确性。
AMD64
中,非安全点的指令只有一类:
TLS
相关的MOV
指令。 协程的一部分信息会保存在TLS
中,一旦在保存时切换了,新的线程中TLS内容将不适用于新的协程,引发协程执行异常。抢占类型:
0xfffffade
值,在函数调用时主动让出调度。在go1.15之前唯一的一种方式。SIGURG
信号。设置抢占的时机:
AMD64的ABI规则为:在近跳转调用函数时,将CALL的下一条指令压栈,转去执行被调用函数。函数内可以通过SP的操作选择分配具体的栈空间。
Go函数调用时的用户栈特点如下:
查看反汇编时,可以根据特点2,分割栈上保存的栈帧。栈的设计与编译和运行时都强相关,感兴趣的可以看下参考链接1中rsc给出的设计分析。
在二进制攻防中常用的技术。
目的:通过修改机器码,在没有源代码情况下,修改程序的执行逻辑。
Go里面用这项技术的原因是,编译器生成的汇编我们无法控制。
如下修改是为了保存+145
行中保存到rdx寄存器中的内容。>:
之后到汇编指令中间的就是机器码。
0x00000000004b3264 <+132>: 48 8b 54 24 08 mov 0x8(%rsp),%rdx
0x00000000004b3269 <+137>: 48 8b 02 mov (%rdx),%rax
<= 原位置
0x00000000004b326c <+140>: c6 44 24 07 00 movb $0x0,0x7(%rsp)
0x00000000004b3271 <+145>: 48 8b 54 24 30 mov 0x30(%rsp),%rdx
0x00000000004b3276 <+150>: ff d0 call *%rax <= 新位置
0x00000000004b3278 <+152>: 48 8b 02 mov (%rdx),%rax
0x00000000004b327b <+155>: ff d0 call *%rax
要求:
jne 0x4b330c
工具:可以使用hexedit或者使用Python写个脚本。
原因同上。以go1.17中想要在dwrap
函数中添加一个函数调用为例。
src/cmd/compile/internal/typecheck/builtin/runtime.go
,添加自己的函数声明。src/cmd/compile/internal/typecheck
下,执行mkbuiltin.go
,生成新的builtin.go
包。建议使用其他版本的mkbuiltin.go
,直接build出二进制拷贝到目标目录下执行。src/cmd/compile/internal/walk/order.go
,在1767
行左右,修改添加如下代码:1767 setDX := typecheck.LookupRuntime("SaveDX") // 新增的函数名。
1768 setdx := ir.NewCallExpr(base.Pos, ir.OCALL, setDX, nil) // 生成ir.Node,无参数
1769 typecheck.Stmt(setdx) // 进行类型检查。
1771 fn.Body = []ir.Node{setdx, newcall} // 修改原的Body,newcall前加入setdx的Node。
src/runtime
中添加函数的定义。GOROOT_BOOTSTRAP
方式,使用其他版本的Go编译当前的源码。切换到src
目录下,执行GOROOT_BOOTSTRAP=goroot bash make.bash
,编译完成后,使用新的go去build自己的Go代码,可以通过反汇编看到修改已生效。Dump of assembler code for function main.(*TaskRunner).Schedule.func1·dwrap·1:
0x00000000004b32c0 <+0>: cmp 0x10(%r14),%rsp
0x00000000004b32c4 <+4>: jbe 0x4b3330
0x00000000004b32c6 <+6>: sub $0x28,%rsp
0x00000000004b32ca <+10>: mov %rbp,0x20(%rsp)
0x00000000004b32cf <+15>: lea 0x20(%rsp),%rbp
0x00000000004b32d4 <+20>: mov 0x20(%r14),%r12
0x00000000004b32d8 <+24>: test %r12,%r12
0x00000000004b32db <+27>: jne 0x4b3337
0x00000000004b32dd <+29>: mov 0x8(%rdx),%rax
0x00000000004b32e1 <+33>: mov %rax,0x18(%rsp)
0x00000000004b32e6 <+38>: call 0x460b20
0x00000000004b32eb <+43>: lea 0xa7ce(%rip),%rax # 0x4bdac0
0x00000000004b32f2 <+50>: call 0x40c880
Go自举机制为研究其运行时的实现提供了极大的便利性。通过这些底层的技术细节,可以深入理解Go的实现原理,这在排查与运行时相关的问题时非常方便。
Go运行时同样也比较复杂,语言本身也是程序,所以不可能完全没有问题。高手也会出错,上述技术研究的起源就是1.17中regabi
特性引入的一个问题https://github.com/golang/go/issues/54247。
这个总结也是备忘,后面大概率还有类似的问题出现需要排查。