在深入阅读runtime和标准库的源码时候,发现底层有大片代码都会与汇编打交道,所以这篇文章主要是介绍golang使用到的汇编。
go汇编语言是一个不可忽视的技术。因为哪怕只懂一点点汇编,也便于更好地理解计算机原理,也更容易理解Go语言中动态栈/接口等高级特性的实现原理。
本文涉及到计算机架构体系相关的情况时,请假设我们是运行在 linux/amd64 平台上。
伪汇编Go 编译器会输出一种抽象可移植的汇编代码,这种汇编并不对应某种真实的硬件架构。之后 Go 的汇编器使用这种伪汇编,为目标硬件生成具体的机器指令。
伪汇编这一个额外层可以带来很多好处,最主要的一点是方便将 Go 移植到新的架构上。相关的信息可以参考文后列出的 Rob Pike 的 The Design of the Go Assembler。
go 汇编语言的一个简单实例思考下面这行代码:
//go:noinline
func add(a, b int32) (int32, bool) {
return a + b, true
}
func main() { add(10, 32) }
//go:noinline 编译器指令
将这段代码编译到汇编:
"".add STEXT nosplit size=20 args=0x10 locals=0x0
0x0000 00000 (test1.go:5) TEXT "".add(SB), NOSPLIT|ABIInternal, $0-16
0x0000 00000 (test1.go:5) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (test1.go:5) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (test1.go:5) FUNCDATA $2, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (test1.go:6) PCDATA $0, $0
0x0000 00000 (test1.go:6) PCDATA $1, $0
0x0000 00000 (test1.go:6) MOVL "".b+12(SP), AX
0x0004 00004 (test1.go:6) MOVL "".a+8(SP), CX
0x0008 00008 (test1.go:6) ADDL CX, AX
0x000a 00010 (test1.go:6) MOVL AX, "".~r2+16(SP)
0x000e 00014 (test1.go:6) MOVB $1, "".~r3+20(SP)
0x0013 00019 (test1.go:6) RET
0x0000 8b 44 24 0c 8b 4c 24 08 01 c8 89 44 24 10 c6 44 .D$..L$....D$..D
0x0010 24 14 01 c3 $...
"".main STEXT size=65 args=0x0 locals=0x18
0x0000 00000 (test1.go:9) TEXT "".main(SB), ABIInternal, $24-0
0x0000 00000 (test1.go:9) MOVQ (TLS), CX
0x0009 00009 (test1.go:9) CMPQ SP, 16(CX)
0x000d 00013 (test1.go:9) JLS 58
0x000f 00015 (test1.go:9) SUBQ $24, SP
0x0013 00019 (test1.go:9) MOVQ BP, 16(SP)
0x0018 00024 (test1.go:9) LEAQ 16(SP), BP
0x001d 00029 (test1.go:9) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d 00029 (test1.go:9) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d 00029 (test1.go:9) FUNCDATA $2, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d 00029 (test1.go:10) PCDATA $0, $0
0x001d 00029 (test1.go:10) PCDATA $1, $0
0x001d 00029 (test1.go:10) MOVQ $137438953482, AX
0x0027 00039 (test1.go:10) MOVQ AX, (SP)
0x002b 00043 (test1.go:10) CALL "".add(SB)
0x0030 00048 (test1.go:11) MOVQ 16(SP), BP
0x0035 00053 (test1.go:11) ADDQ $24, SP
0x0039 00057 (test1.go:11) RET
0x003a 00058 (test1.go:11) NOP
0x003a 00058 (test1.go:9) PCDATA $1, $-1
0x003a 00058 (test1.go:9) PCDATA $0, $-1
0x003a 00058 (test1.go:9) CALL runtime.morestack_noctxt(SB)
0x003f 00063 (test1.go:9) JMP 0
0x0000 64 48 8b 0c 25 00 00 00 00 48 3b 61 10 76 2b 48 dH..%....H;a.v+H
0x0010 83 ec 18 48 89 6c 24 10 48 8d 6c 24 10 48 b8 0a ...H.l$.H.l$.H..
0x0020 00 00 00 20 00 00 00 48 89 04 24 e8 00 00 00 00 ... ...H..$.....
0x0030 48 8b 6c 24 10 48 83 c4 18 c3 e8 00 00 00 00 eb H.l$.H..........
0x0040 bf .
rel 5+4 t=16 TLS+0
rel 44+4 t=8 "".add+0
rel 59+4 t=8 runtime.morestack_noctxt+0
接下来一行一行地对这两个函数进行解析来帮助我们理解编译器在编译期间都做了什么事情。
函数 add
0x0000 00000 (test1.go:5) TEXT "".add(SB), NOSPLIT|ABIInternal, $0-16
- 0x0000: 当前指令相对于当前函数的偏移量。
- TEXT “”.add: TEXT 指令声明了 “”.add 是 .text 段(程序代码在运行期会放在内存的 .text 段中)的一部分,并表明跟在这个声明后的是函数的函数体。在链接期,"" 这个空字符会被替换为当前的包名: 也就是说,"".add 在链接到二进制文件后会变成 main.add。
- (SB): SB 是一个虚拟寄存器,保存了静态基地址(static-base) 指针,即我们程序地址空间的开始地址。"".add(SB) 表明我们的符号位于某个固定的相对地址空间起始处的偏移位置 (最终是由链接器计算得到的)。换句话来讲,它有一个直接的绝对地址: 是一个全局的函数符号。
objdump 这个工具能帮我们确认上面这些结论:
ytlou@ytlou-mac ~/Desktop/golang/golang_study/study/basic/assembly $ objdump -j .text -t test1 | grep 'main.add'
00000000010512e0 l F __TEXT,__text main.add
- NOSPLIT: 向编译器表明不应该插入 stack-split 的用来检查栈需要扩张的前导指令。
在我们 add 函数的这种情况下,编译器自己帮我们插入了这个标记: 它足够聪明地意识到,由于 add 没有任何局部变量且没有它自己的栈帧,所以一定不会超出当前的栈;因此每次调用函数时在这里执行栈检查就是完全浪费 CPU 循环了。 - $0-16: $0 代表即将分配的栈帧大小;而 $16 指定了调用方传入的参数大小。
Go 的调用规约要求每一个参数都通过栈来传递,这部分空间由 caller 在其栈帧(stack frame)上提供。
调用其它函数之前,caller 就需要按照参数和返回变量的大小来对应地增长(返回后收缩)栈。
Go 编译器没有 PUSH/POP 族的指令: 栈的增长和收缩是通过在栈指针寄存器 SP 上分别执行减法和加法指令来实现的
与大多数最近的编译器做法一样,Go 工具链总是在其生成的代码中,使用相对栈指针(stack-pointer)的偏移量来引用参数和局部变量。这样使得我们可以用帧指针(frame-pointer)来作为一个额外的通用寄存器,这一点即使是在那些寄存器数量较少的平台上也是一样的(例如 x86)。
“”.b+12(SP) 和 “”.a+8(SP) 分别指向栈的低 12 字节和低 8 字节位置(记住: 栈是向低位地址方向增长的!)。
.a 和 .b 是分配给引用地址的任意别名;尽管 它们没有任何语义上的含义 ,但在使用虚拟寄存器和相对地址时,这种别名是需要强制使用的。
最后,有两个重点需要指出:
- 第一个变量 a 的地址并不是 0(SP),而是在 8(SP);这是因为调用方通过使用 CALL 伪指令,把其返回地址保存在了 0(SP) 位置。
- 参数是反序传入的;也就是说,第一个参数和栈顶距离最近。
0x0008 00008 (test1.go:6) ADDL CX, AX
0x000a 00010 (test1.go:6) MOVL AX, "".~r2+16(SP)
0x000e 00014 (test1.go:6) MOVB $1, "".~r3+20(SP)
"".~r2+16(SP)
stacks 和 Splits
Stacks
由于 Go 程序中的 goroutine 数目是不可确定的,并且实际场景可能会有百万级别的 goroutine,runtime 必须使用保守的思路来给 goroutine 分配空间以避免吃掉所有的可用内存。
也由于此,每个新的 goroutine 会被 runtime 分配初始为 2KB 大小的栈空间(Go 的栈在底层实际上是分配在堆空间上的)。
随着一个 goroutine 进行自己的工作,可能会超出最初分配的栈空间限制(就是栈溢出的意思)。为了防止这种情况发生,runtime 确保 goroutine 在超出栈范围时,会创建一个相当于原来两倍大小的新栈,并将原来栈的上下文拷贝到新栈上。这个过程被称为 栈分裂(stack-split),这样使得 goroutine 栈能够动态调整大小。
Splits
为了使栈分裂正常工作,编译器会在每一个函数的开头和结束位置插入指令来防止 goroutine 爆栈。
像我们本章早些看到的一样,为了避免不必要的开销,一定不会爆栈的函数会被标记上 NOSPLIT 来提示编译器不要在这些函数的开头和结束部分插入这些检查指令。
基本指令 寄存器通用寄存器
应用代码层面会用到的通用寄存器主要是: rax, rbx, rcx, rdx, rdi, rsi, r8~r15 这 14 个寄存器,虽然 rbp 和 rsp 也可以用,不过 bp 和 sp 会被用来管理栈顶和栈底,最好不要拿来进行运算。
plan9 中使用寄存器不需要带 r 或 e 的前缀,例如 rax,只要写 AX 即可:
伪寄存器
Go 的汇编还引入了 4 个伪寄存器,援引官方文档的描述:
- FP: Frame pointer: arguments and locals.
- PC: Program counter: jumps and branches.
- SB: Static base pointer: global symbols.
- SP: Stack pointer: top of stack.
官方的描述稍微有一些问题,我们对这些说明进行一点扩充:
- FP: 使用形如 symbol+offset(FP) 的方式,引用函数的输入参数。例如 arg0+0(FP),arg1+8(FP),使用 FP 不加 symbol 时,无法通过编译,在汇编层面来讲,symbol 并没有什么用,加 symbol 主要是为了提升代码可读性。另外,官方文档虽然将伪寄存器 FP 称之为 frame pointer,实际上它根本不是 frame pointer,按照传统的 x86 的习惯来讲,frame pointer 是指向整个 stack frame 底部的 BP 寄存器。假如当前的 callee 函数是 add,在 add 的代码中引用 FP,该 FP 指向的位置不在 callee 的 stack frame 之内,而是在 caller 的 stack frame 上。具体可参见之后的 栈结构 一章。
- PC: 实际上就是在体系结构的知识中常见的 pc 寄存器,在 x86 平台下对应 ip 寄存器,amd64 上则是 rip。除了个别跳转之外,手写 plan9 代码与 PC 寄存器打交道的情况较少。
- SB: 全局静态基指针,一般用来声明函数或全局变量,在之后的函数知识和示例部分会看到具体用法。
- plan9 的这个 SP 寄存器指向当前栈帧的局部变量的开始位置,使用形如 symbol+offset(SP) 的方式,引用函数的局部变量。offset 的合法取值是 [-framesize, 0),注意是个左闭右开的区间。假如局部变量都是 8 字节,那么第一个局部变量就可以用 localvar0-8(SP) 来表示。这也是一个词不表意的寄存器。与硬件寄存器 SP 是两个不同的东西,在栈帧 size 为 0 的情况下,伪寄存器 SP 和硬件寄存器 SP 指向同一位置。手写汇编代码时,如果是 symbol+offset(SP) 形式,则表示伪寄存器 SP。如果是 offset(SP) 则表示硬件寄存器 SP。务必注意。对于编译输出(go tool compile -S / go tool objdump)的代码来讲,目前所有的 SP 都是硬件寄存器 SP,无论是否带 symbol。
我们这里对容易混淆的几点简单进行说明:
- 伪 SP 和硬件 SP 不是一回事,在手写代码时,伪 SP 和硬件 SP 的区分方法是看该 SP 前是否有 symbol。如果有 symbol,那么即为伪寄存器,如果没有,那么说明是硬件 SP 寄存器。
- SP 和 FP 的相对位置是会变的,所以不应该尝试用伪 SP 寄存器去找那些用 FP + offset 来引用的值,例如函数的入参和返回值。
- 官方文档中说的伪 SP 指向 stack 的 top,是有问题的。其指向的局部变量位置实际上是整个栈的栈底(除 caller BP 之外),所以说 bottom 更合适一些。
- 在 go tool objdump/go tool compile -S 输出的代码中,是没有伪 SP 和 FP 寄存器的,我们上面说的区分伪 SP 和硬件 SP 寄存器的方法,对于上述两个命令的输出结果是没法使用的。在编译和反汇编的结果中,只有真实的 SP 寄存器。
- FP 和 Go 的官方源代码里的 frame pointer 不是一回事,源代码里的 frame pointer 指的是 caller BP 寄存器的值,在这里和 caller 的伪 SP 是值是相等的。
-----------------
current func arg0
----------------- <----------- FP(pseudo FP)
caller ret addr
+---------------+
| caller BP(*) |
----------------- <----------- SP(pseudo SP,实际上是当前栈帧的 BP 位置)
| Local Var0 |
-----------------
| Local Var1 |
-----------------
| Local Var2 |
----------------- -
| ........ |
-----------------
| Local VarN |
-----------------
| |
| |
| temporarily |
| unused space |
| |
| |
-----------------
| call retn |
-----------------
| call ret(n-1)|
-----------------
| .......... |
-----------------
| call ret1 |
-----------------
| call argn |
-----------------
| ..... |
-----------------
| call arg3 |
-----------------
| call arg2 |
|---------------|
| call arg1 |
----------------- <------------ hardware SP 位置
| return addr |
+---------------+
图上的 caller BP,指的是 caller 的 BP 寄存器值,有些人把 caller BP 叫作 caller 的 frame pointer,实际上这个习惯是从 x86 架构沿袭来的。Go 的 asm 文档中把伪寄存器 FP 也称为 frame pointer,但是这两个 frame pointer 根本不是一回事。
此外需要注意的是,caller BP 是在编译期由编译器插入的,用户手写代码时,计算 frame size 时是不包括这个 caller BP 部分的。是否插入 caller BP 的主要判断依据是:
- 函数的栈帧大小大于 0
- 下述函数返回 true
func Framepointer_enabled(goos, goarch string) bool {
return framepointer_enabled != 0 && goarch == "amd64" && goos != "nacl"
}
如果编译器在最终的汇编结果中没有插入 caller BP(源代码中所称的 frame pointer)的情况下,伪 SP 和伪 FP 之间只有 8 个字节的 caller 的 return address,而插入了 BP 的话,就会多出额外的 8 字节。也就说伪 SP 和伪 FP 的相对位置是不固定的,有可能是间隔 8 个字节,也有可能间隔 16 个字节。并且判断依据会根据平台和 Go 的版本有所不同。
图上可以看到,FP 伪寄存器指向函数的传入参数的开始位置,因为栈是朝低地址方向增长,为了通过寄存器引用参数时方便,所以参数的摆放方向和栈的增长方向是相反的,即:
FP
high ----------------------> low
argN, ... arg3, arg2, arg1, arg0
假设所有参数均为 8 字节,这样我们就可以用 symname+0(FP) 访问第一个 参数,symname+8(FP) 访问第二个参数,以此类推。用伪 SP 来引用局部变量,原理上来讲差不多,不过因为伪 SP 指向的是局部变量的底部,所以 symname-8(SP) 表示的是第一个局部变量,symname-16(SP)表示第二个,以此类推。当然,这里假设局部变量都占用 8 个字节。
图的最上部的 caller return address 和 current func arg0 都是由 caller 来分配空间的。不算在当前的栈帧内。
因为官方文档本身较模糊,我们来一个函数调用的全景图,来看一下这些真假 SP/FP/BP 到底是个什么关系:
caller
+------------------+
| |
+----------------------> --------------------
| | |
| | caller parent BP |
| BP(pseudo SP) --------------------
| | |
| | Local Var0 |
| --------------------
| | |
| | ....... |
| --------------------
| | |
| | Local VarN |
--------------------
caller stack frame | |
| callee arg2 |
| |------------------|
| | |
| | callee arg1 |
| |------------------|
| | |
| | callee arg0 |
| ----------------------------------------------+ 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
argsize 和 framesize 计算规则
argsize
在函数声明中:
TEXT pkgname·add(SB),NOSPLIT,$16-32
前面已经说过 $16-32 表示 $framesize-argsize。Go 在函数调用时,参数和返回值都需要由 caller 在其栈帧上备好空间。callee 在声明时仍然需要知道这个 argsize。argsize 的计算方法是,参数大小求和+返回值大小求和,例如入参是 3 个 int64 类型,返回值是 1 个 int64 类型,那么这里的 argsize = sizeof(int64) * 4。
不过真实世界永远没有我们假设的这么美好,函数参数往往混合了多种类型,还需要考虑内存对齐问题。
如果不确定自己的函数签名需要多大的 argsize,可以通过简单实现一个相同签名的空函数,然后 go tool objdump 来逆向查找应该分配多少空间。
framesize函数的 framesize 就稍微复杂一些了,手写代码的 framesize 不需要考虑由编译器插入的 caller BP,要考虑:
- 局部变量,及其每个变量的 size。
- 在函数中是否有对其它函数调用时,如果有的话,调用时需要将 callee 的参数、返回值考虑在内。虽然 return address(rip)的值也是存储在 caller 的 stack frame 上的,但是这个过程是由 CALL 指令和 RET 指令完成 PC 寄存器的保存和恢复的,在手写汇编时,同样也是不需要考虑这个 PC 寄存器在栈上所需占用的 8 个字节的。
- 原则上来说,调用函数时只要不把局部变量覆盖掉就可以了。稍微多分配几个字节的 framesize 也不会死。
- 在确保逻辑没有问题的前提下,你愿意覆盖局部变量也没有问题。只要保证进入和退出汇编函数时的 caller 和 callee 能正确拿到返回值就可以。
math.go:
package main
import "fmt"
func add(a, b int) int // 汇编函数声明
func sub(a, b int) int // 汇编函数声明
func mul(a, b int) int // 汇编函数声明
func main() {
fmt.Println(add(10, 11))
fmt.Println(sub(99, 15))
fmt.Println(mul(11, 12))
}
math.s:
#include "textflag.h" // 因为我们声明函数用到了 NOSPLIT 这样的 flag,所以需要将 textflag.h 包含进来
// func add(a, b int) int
TEXT ·add(SB), NOSPLIT, $0-24
MOVQ a+0(FP), AX // 参数 a
MOVQ b+8(FP), BX // 参数 b
ADDQ BX, AX // AX += BX
MOVQ AX, ret+16(FP) // 返回
RET
// func sub(a, b int) int
TEXT ·sub(SB), NOSPLIT, $0-24
MOVQ a+0(FP), AX
MOVQ b+8(FP), BX
SUBQ BX, AX // AX -= BX
MOVQ AX, ret+16(FP)
RET
// func mul(a, b int) int
TEXT ·mul(SB), NOSPLIT, $0-24
MOVQ a+0(FP), AX
MOVQ b+8(FP), BX
IMULQ BX, AX // AX *= BX
MOVQ AX, ret+16(FP)
RET
// 最后一行的空行是必须的,否则可能报 unexpected EOF
go build
benchmark测试用例:
package main
import "testing"
var Result int
func BenchmarkAddNative(b *testing.B) {
var r int
for i := 0; i < b.N; i++ {
r = int(i) + int(i)
}
Result = r
}
func BenchmarkAddAsm(b *testing.B) {
var r int
for i := 0; i < b.N; i++ {
r = add(int(i), int(i))
}
Result = r
}
我们看一下benchmark 的执行结果:
~/Desktop/golang/golang_study/study/basic/assembly/math $ go test -bench=. .
goos: darwin
goarch: amd64
pkg: study_golang/study/basic/assembly/math
BenchmarkAddNative-12 1000000000 0.256 ns/op
BenchmarkAddAsm-12 741027513 1.58 ns/op
PASS
ok study_golang/study/basic/assembly/math 1.630s
我们可以看到go原生的自加其实比使用汇编写的代码要快的多,这是因为 Go 现在还不支持汇编函数内联,所以调用汇编函数执行自加会有一些函数调用的性能损耗,所以自加汇编函数实现有更高的负载。
如果我将BenchmarkAddNative禁用内联,并将自加单独抽出来:
package main
import "testing"
var Result int
func addf(a, b int) int{
return a+b
}
func BenchmarkAddNative(b *testing.B) {
var r int
for i := 0; i < b.N; i++ {
r = addf(i,i)
}
Result = r
}
func BenchmarkAddAsm(b *testing.B) {
var r int
for i := 0; i < b.N; i++ {
r = add(int(i), int(i))
}
Result = r
}
执行结果:
~/Desktop/golang/golang_study/study/basic/assembly/math $ go test -gcflags=-l -bench=.
goos: darwin
goarch: amd64
pkg: study_golang/study/basic/assembly/math
BenchmarkAddNative-12 726956421 1.58 ns/op
BenchmarkAddAsm-12 753173751 1.61 ns/op
PASS
ok study_golang/study/basic/assembly/math 2.694s
在go1.13中,有一些包的里面的函数被定义为 原生函数,这些函数会在编译时候被替代 成汇编代码,而不是以汇编函数的方式调用。比如:
math/bits
sync/atomic
go atomic.add 原子实现
atomic.AddInt64
sync/atomic/doc.go
func AddInt64(addr *int64, delta int64) (new int64)
sync/atomic/asm.s
TEXT ·AddInt64(SB),NOSPLIT,$0
JMP runtime∕internal∕atomic·Xadd64(SB)
runtime∕internal/atomic/asm_amd64.s
TEXT runtime∕internal∕atomic·Xadd64(SB), NOSPLIT, $0-24
MOVQ ptr+0(FP), BX //拿到变量的地址
MOVQ delta+8(FP), AX //拿到增量
MOVQ AX, CX // 将增量保存在CX
LOCK // 锁总线,多CPU排他执行指令。
XADDQ AX, 0(BX) // 将变量原有的值赋值给AX,增量赋值给变量地址指向的值,然后求和保存到变量地址指向的地址。
ADDQ CX, AX // 重新计算add之后的值保存到AX用于 return.
MOVQ AX, ret+16(FP)
RET
这里先解释一下两个汇编指令:
- LOCK:是一个指令前缀,其后必须跟一条“读-改-写”的指令,比如XADDQ、XCHG、CMPXCHG等。 这条指令表明封锁总线,对CPU缓存的访问将是排他的。
- XADDQ:先交换再累加.( 结果在第二个操作数里 )
参考文献:
Golang同步机制的实现
x86汇编指令集大全(带注释)
Go 系列文章3 :plan9 汇编入门
Go compiler intrinsics