$ go version
go version go1.10 linux/amd644
第一章: Go 编译入门
在开始深入研究运行时类库和标准类库之前,学习一些go的抽象汇编语言是有必要的。这份快速指引希望能助你提速。
目录
- 伪汇编
- 拆解一个简单程序
- 分析goroutines, stacks 和 splits
- 总结
- 参考文献
- 这篇文章需要基础的汇编知识
- 如果涉及到机器架构,假定都是 linux/amd64
- 我们将一直使用编译器优化项
- 除了特别说明,引用文字或者注释基本来自于官方文档或者codebase
伪汇编
Go编译器输出一种抽象、简便格式的汇编,这种汇编并不适合任何真实的硬件。Go汇编使用这种伪汇编输出 适用于不同的机器架构。
这种设计有很多好处,其中最主要的是go可以很容易地适应一种新的架构。要看更多信息,Rob Pike 的 《The Design of the Go Assembler》这本书里面有讲到。文末参考文献里面也有罗列。
关于Go汇编,最需要知道的一点是它并不依赖于一个具体的机器。很多东西跟机器有映射,但是有些不是。这是因为编译器组件在执行过程中并不需要汇编校验通过。而是编译器操作是基于一堆半抽象指令集,指令选择有一部分发生在代码生成之后,所以当你看到一个指令MOV 可能不是move 这个指令,有可能是clear 或者load . 或者在某些机器架构中就跟他的名字一样的含义。一般来说 机器相关的操作倾向于跟他们展示的含义一样, 而像跟内存移动、子程序调用与返回等相关的指令则更抽象。这些细节随机器架构调整,我们为这种不精确道歉,到目前位置,并没有一个很好的解决方案。
汇编语言是一种解析半抽象指令描述指令集 的途径,他可以将半抽象指令集转换成输出到链接器的指令。
拆解一个简单程序
看下面这段代码(direct_topfunc_call.go):
//go:noinline
func add(a, b int32) (int32, bool) {
return a + b, true
}
func main() {
add(10, 32)
}
(注意 //go:noinline 是编译器指令,不要省略)
把这个程序编译成汇编:
$ GOOS=linux GOARCH=amd64 go tool compile -S direct_topfunc_call.go
0x0000 TEXT "".add(SB), NOSPLIT, $0-16
0x0000 FUNCDATA $0, gclocals·f207267fbf96a0178e8758c6e3e0ce28(SB)
0x0000 FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 MOVL "".b+12(SP), AX
0x0004 MOVL "".a+8(SP), CX
0x0008 ADDL CX, AX
0x000a MOVL AX, "".~r2+16(SP)
0x000e MOVB $1, "".~r3+20(SP)
0x0013 RET
0x0000 TEXT "".main(SB), $24-0
;; ...省略stack-split 相关的开始部分...
0x000f SUBQ $24, SP
0x0013 MOVQ BP, 16(SP)
0x0018 LEAQ 16(SP), BP
0x001d FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d MOVQ $137438953482, AX
0x0027 MOVQ AX, (SP)
0x002b PCDATA $0, $0
0x002b CALL "".add(SB)
0x0030 MOVQ 16(SP), BP
0x0035 ADDQ $24, SP
0x0039 RET
;; ...省略stack-split 相关的结尾部分...
为了更好地了解汇编器做了什么,我们将逐行分析这两个方法。
分析 add
0x0000 TEXT "".add(SB), NOSPLIT, $0-16
- 0x0000: 当前指令的偏移,程序的开始
- TEXT "".add: TEXT 表明 字符 "".add 符号作为.text端(程序段) 的一部分,指示 之后的指令是function的一部分。
- (SB): SB 是一个存储“静态基地址”指针的虚拟寄存器,即存储我们程序空间的开始地址
- "".add(SB) 标识我们的字符位于距离程序空间开始处固定偏移量的位置。换言之,它有一个绝对地址:这是个全局函数变量。objdump将会验证这一切。
$ objdump -j .text -t direct_topfunc_call | grep 'main.add'
000000000044d980 g F .text 000000000000000f main.add
所有用户定义变量都是以为寄存器FP(参数和本地变量)和SB(全局变量)为基准,找到对应的偏移量而被写入。SB伪寄存器被认为是内存的起始地址,所以foo(SB) 就是foo在内存中的地址。
-
NOSPLIT: 告知编译器不要将其插入stack-split的前导指令, 这用来检查当前的栈是否需要扩大。
在我们这个例子中, 编译器自己设置了一些flag:很容易就能看出来,因为add方法没有本地变量也没有自己的栈帧,它不会超过当前的栈;因此在每次函数调用时践行检查是纯粹浪费CPU指令周期。
"NOSPLIT": 在栈必须分割时去检查不要插入到前导指令。 协程的栈帧,包括协程所调用的那些,必须位于堆栈段中一段空闲空间的顶部。用来保护类似用来栈分配代码的协程,
本章文末,我们会有一个关于go 协程跟栈分配的简单介绍。
- $0-16: $0 标识将要在占空间分配的字节数;而$16 表示传参所占的空间
通常,帧大小后面紧跟着参数大小,被“-”分割(这并不是一个减号,而是一个语法约定)。帧大小 $24-8 标识这个方法有一个24字节的帧空间,如果要调用它需要8字节的参数大小,这参数大小分配在调用方的帧空间中。
0x0000 FUNCDATA $0, gclocals·f207267fbf96a0178e8758c6e3e0ce28(SB)
0x0000 FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
FUNCDATA 和PCDATA 表示了垃圾回收所需要的信息;编译器用这些信息。
关于这部分现在不要关注;当讨论垃圾回收的时候,我们将回过头来了解这部分。
0x0000 MOVL "".b+12(SP), AX
0x0004 MOVL "".a+8(SP), CX
Go调用过程 规定,所有的参数必须加载到栈中,使用调用者预分配的栈空间。
调用方需要适当地扩大或者缩小栈大小,以保证参数可以传递到被调用方法,以及接受来自被调用者的返回。
Go编译器并没有产生PUSH/POP家族的指令,而是通过增大或者缩小硬件栈指针SP来进行栈的缩小或者扩大的。
【更新: 在issue #21: about SP register中我们建一个讨论了这个问题。】
SP伪寄存器是一个用于指示本地变量和传参的伪寄存器。它指向本地栈的顶部,所以引用应该使用范围在 [−framesize, 0) 的负偏移量,比如x-8(SP), y-4(SP) 等等。
虽然官方文档生成“所有的用户定义的变量都是以伪寄存器FP为基础进行偏移(参数和本地变量)”,对手写代码适合。
像很多现代编译器,Go 工具集经常采用以代码生成地址为基准的偏移量,进行引用参数和本地变量。这使得平台可以用很少的指令 将帧指针(frame-pointer) 用作额外的一般用途寄存器(比如像x86)。
如果感兴趣,你可以看下在文末文章中介绍的x86-64 栈帧布局。
【更新:在issue #2: Frame pointer中我们讨论了这个问题】
"".b+12(SP) 和 "".a+8(SP)分别指向栈的低12字节和低8字节的地址。
.a 和 .b 就是一个随意指定的别名;虽然他们没有具体的文法含义,但是当使用虚拟存储器的相对地址时,他们是必须的。虚拟frame-pointer 的文中提到了这一点:
FP 伪寄存器是一个虚拟指针,用来指示方法的参数。编译器维护一份虚拟指针,指向栈中的参数,这些参数的地址是从伪指针之后的偏移量。因此0(FP)是方法的第一个参数, 8(FP)在第二个等等(在64位机器上)。如果以这种方法指示方法的参数,需要在开始处放置一个名字,比如 first_arg+0(FP) 和 second_arg+8(FP).(偏移量的意思是从frame pointer开始的偏移量,到SB的距离,这就是一个变量的偏移量)。编译器遵循这种约定,拒绝类似0(FP)和 8(FP). 具体的名字是预发无关的,但是需要被用来只是变量的名字。
最后,有两点需要注意:
- 第一个参数a并不是位于0(SP), 而是位于8(SP);这是因为调用方通过CALL这个伪指令使用了0(SP)这个地址。
- 参数是以一个相反的顺序传递,比如第一个参数里栈顶更近。
0x0008 ADDL CX, AX
0x000a MOVL AX, "".~r2+16(SP)
0x000e MOVB $1, "".~r3+20(SP)
ADDL 执行了两个长变量(占了4字节的值)的相加操作,并且将结果存储在AX中。
这个结果被移到 "".~r2+16(SP), 这个地址是调用者在栈空间中预占的空间,并且期待在这儿找到它的值。 再强调一下, "".~r2 并没有具体的文法含义。
为了更好地延时go语言是如何处理多返回值的情况,我们也返回了一个固定的布尔类型的true. 机制跟返回第一个值是一样的,只是距离SP的偏移量变了。
0x0013 RET
最后的伪指令 RET 告诉Go编译器插入一些指令,这些指令依托于特定的平台,以便从子协程调用中返回。
像这样的操作会将存储在0(SP)中的返回地址弹出,并且跳回到这个地址。
一个TEXT块的最后一条指令必须是jump指令的一种,通常是一个RET(伪)指令。(如果不是,链接器会自己加入一个”跳回自己”的指令;TEXTs中没有fallthrough)
这儿需要一次性学习好多句法和语言,这儿有一个快速的总结:
声明一个全局方法 "".add (实际链接是 main.add )
;; 不要插入stack-split 序列
;; 0 字节 栈空间,16字节传入参数
;; func add(a, b int32) (int32, bool)
0x0000 TEXT "".add(SB), NOSPLIT, $0-16
;; ...省略 FUNCDATA 相关知识...
0x0000 MOVL "".b+12(SP), AX ;; move second Long-word (4B) argument from caller's stack-frame into AX
0x0004 MOVL "".a+8(SP), CX ;; move first Long-word (4B) argument from caller's stack-frame into CX
0x0008 ADDL CX, AX ;; 计算 AX=CX+AX
0x000a MOVL AX, "".~r2+16(SP) ;; 把结果移动到调用者的栈空间
0x000e MOVB $1, "".~r3+20(SP) ;; 将布尔类型的true移动到调用者的栈空间
0x0013 RET ;; 跳转到 0(SP)中存储地址对应内存中去
最后,这儿有一个当main.add 执行完之后栈的示意图
| +-------------------------+ <-- 32(SP)
| | |
G | | |
R | | |
O | | main.main's saved |
W | | frame-pointer (BP) |
S | |-------------------------| <-- 24(SP)
| | [alignment] |
D | | "".~r3 (bool) = 1/true | <-- 21(SP)
O | |-------------------------| <-- 20(SP)
W | | |
N | | "".~r2 (int32) = 42 |
W | |-------------------------| <-- 16(SP)
A | | |
R | | "".b (int32) = 32 |
D | |-------------------------| <-- 12(SP)
S | | |
| | "".a (int32) = 10 |
| |-------------------------| <-- 8(SP)
| | |
| | |
| | |
\ | / | return address to |
\|/ | main.main + 0x30 |
- +-------------------------+ <-- 0(SP) (TOP OF STACK)
(diagram made with https://textik.com)
分析 main
省略了一些代码,帮你节省滚鼠标的时间,如下是main方法汇编之后的样子:
0x0000 TEXT "".main(SB), $24-0
;; ...omitted stack-split prologue...
0x000f SUBQ $24, SP
0x0013 MOVQ BP, 16(SP)
0x0018 LEAQ 16(SP), BP
;; ...omitted FUNCDATA stuff...
0x001d MOVQ $137438953482, AX
0x0027 MOVQ AX, (SP)
;; ...omitted PCDATA stuff...
0x002b CALL "".add(SB)
0x0030 MOVQ 16(SP), BP
0x0035 ADDQ $24, SP
0x0039 RET
;; ...omitted stack-split epilogue...
0x0000 TEXT "".main(SB), $24-0
其实也没啥:
- "".main (链接之后是main.main)在.text 代码段中中是一个全局方法,它有一个到我们地址空间开始处有固定偏移量的地址。
- 分配了24字节的栈空间 并且没有接收任何参数,也没有任何返回。
0x000f SUBQ $24, SP
0x0013 MOVQ BP, 16(SP)
0x0018 LEAQ 16(SP), BP
如上文所述,Go 调用必须将所有的参数放在栈中。
main这个调用者,通过递减虚拟栈指针,增加了24字节的栈空间(记住:栈是向下增长的,所以SUBQ 指令就是扩大栈空间)。这24字节包含如下部分:
- 8字节(16(SP) - 24(SP))用来存储当前栈指针BP的值,为了 栈展开(stack-unwinding) 和 方便调试(facilitate debugging)。
- 1+3 字节 (12(SP) - 16(SP)) 为了存储第二个返回值(布尔类型)加 3字节的偏移对齐量(amd64 机器架构)
- 4字节(8(SP) - 12(SP))存储第一个返回值(int32)
- 4字节(4(SP) - 8(SP))存储参数b(int32)
- 4字节(0(SP) - 4(SP))存储参数a(int32)
最后, 伴随着栈的增长,LEAQ 计算栈指针的新地址,并将其存储在BP中。
0x001d MOVQ $137438953482, AX
0x0027 MOVQ AX, (SP)
调用者把给 被调用者的参数作为一个Quad word 推送到刚刚扩容栈的栈顶。
137438953482 虽然开始看起来像个垃圾值,实际上这个值对应的就是 10 和 32 这两个 4 字节值,它们两被连接成了一个 8 字节值。
$ echo 'obase=2;137438953482' | bc
10000000000000000000000000000000001010
\____/\______________________________/
32 10
0x002b CALL "".add(SB)
我们使用相对于基地址指针的偏移量来调用add方法, 这相当于直接跳到一个指定的地址。
注意:CALL 将返回地址(一个8字节的值)推到栈顶;所以每次在add函数中引用SP寄存器的时候还需要额外偏移8字节。
比如 "".a 不再位于0(SP), 而是位于 8(SP).
0x0030 MOVQ 16(SP), BP
0x0035 ADDQ $24, SP
0x0039 RET
最后:
1、将帧指针(frame-pointer)下降一个栈帧(stack-frame)的大小(就是“向下”一级)
2、释放我们先前占用的24字节栈空间
3、请求Go汇编器插入一个子协程返回相关的操作
分析goroutines, stacks 和 splits
现在并不是一个深入研究go协程实现的好时机(后面可能会讲),但是随着我们越来越多地研究汇编输出, 跟栈管理相关的指令将会迅速熟悉起来。我们应该快速熟悉这些模式,当我们熟悉了,我们会理解指令在做什么以及为什么这么做。
栈(Stacks)
由于Go程序中的协程数量是不确定的,在实践中会有几百万个,所以在分配栈空间时要保守一些,避免耗光所有可用的内存。
每个协程启动时会分配2kb的栈内存(虽然说是栈空间,其实分配在堆上).
当协程执行其job时,可能因为超出其初始分配的空间大小。为了避免这种情况发生,runtime 保证当一个协程超出其栈空间时,会分配一个2倍大小的空间给它,并将初始空间的内容分配到新空间中。
这个过程被称为栈分裂(stack-split) ,这是协程栈动态大小的一个有效方法。
分裂(Splits)
为了保证stack-splitting 正常工作,编译器会在可能发生栈超出的每个方法开始跟结束插入几条指令。
正如在本文开始我们看到的,为了避免没必要的超出,被标注NOSPLIT的方法不会扩大他的栈空间。有了这个标注,编译器不会插入这些指令。
看一下main方法,这次没有省略 stack-split 前导指令:
0x0000 TEXT "".main(SB), $24-0
;; stack-split prologue
0x0000 MOVQ (TLS), CX
0x0009 CMPQ SP, 16(CX)
0x000d JLS 58
0x000f SUBQ $24, SP
0x0013 MOVQ BP, 16(SP)
0x0018 LEAQ 16(SP), BP
;; ...omitted FUNCDATA stuff...
0x001d MOVQ $137438953482, AX
0x0027 MOVQ AX, (SP)
;; ...omitted PCDATA stuff...
0x002b CALL "".add(SB)
0x0030 MOVQ 16(SP), BP
0x0035 ADDQ $24, SP
0x0039 RET
;; stack-split epilogue
0x003a NOP
;; ...omitted PCDATA stuff...
0x003a CALL runtime.morestack_noctxt(SB)
0x003f JMP 0
如我们所见,stack-split 序列被分成一个prologue (序幕) 和一个epilogue(结束):
- prologue 检查协程是否超出空间,如果是,直接跳到epilogue
- epilogue ,会触发栈增长机制,然后跳转到prologue
这创造了一个反馈循环,这个循环保证了只要有足够的栈空间未被分配,“饥饿"协程就能正常运转。
Prologue
0x0000 MOVQ (TLS), CX ;; store current *g in CX
0x0009 CMPQ SP, 16(CX) ;; compare SP and g.stackguard0
0x000d JLS 58 ;; jumps to 0x3a if SP <= g.stackguard0
TLS 是一个runtime线程持有的虚拟寄存器,保存了指向当前go协程的指针。会跟踪当前协程的运行时状态.
看一下runtime中g协程的定义
type g struct {
stack stack // 16 bytes
// stackguard0 is the stack pointer compared in the Go stack growth prologue.
// It is stack.lo+StackGuard normally, but can be StackPreempt to trigger a preemption.
stackguard0 uintptr
stackguard1 uintptr
// ...omitted dozens of fields...
}
16(CX) 关联了 g.stackguard0,一个阈值。其用途是当跟栈指针比较,判断一个协程是否马上要用完当前的栈空间。
prologue 检查当前SP的值是否小于或者等于stackguard0 的值,如果是,则跳转到Epilogue。
Epilogue
0x003a NOP
0x003a CALL runtime.morestack_noctxt(SB)
0x003f JMP 0
Epilogue 的结构比较直接: 它直接调用runtime的函数,然后跳转到方法的第一个指令prologue去。
在 CALL 之前出现的 NOP 这个指令使 prologue 部分不会直接跳到 CALL 指令位置。在一些平台上,直接跳到 CALL 可能会有一些麻烦的问题;所以在调用位置插一个 noop 的指令并在跳转时跳到这个 NOP 位置是一种最佳实践。
[更新:我们在issue #4: Clarify "nop before call" paragraph.讨论了这个问题]
Minus some subtleties (缺失了一些细节)
我们仅仅覆盖了冰山一角。
像栈扩容有很多细节我们在这儿并不能一一描述。扩容细节非常复杂,将会有一个单独的章节介绍。
到时再回头看这儿。
总结
快速介绍GO汇编 已经给你足够的内容去玩味。
随着我们深入研究本书其他部分的go内核知识,Go汇编将会是我们理解场景背后知识最依赖的工具之一。
如果有任何问题或者建议,不要迟疑,创建一个有关chapter1的issue, prefix!!!
参考文献
- [Official] A Quick Guide to Go's Assembler
- [Official] Go Compiler Directives
- [Official] The design of the Go Assembler
- [Official] Contiguous stacks Design Document
- [Official] The
_StackMin
constant - [Discussion] Issue #2: Frame pointer
- [Discussion] Issue #4: Clarify "nop before call" paragraph
- A Foray Into Go Assembly Programming
- Dropping Down Go Functions in Assembly
- What is the purpose of the EBP frame pointer register?
- Stack frame layout on x86-64
- How Stacks are Handled in Go
- Why stack grows down