五一假期在家没事逛论坛的时候,发现了一个宝藏网站,传送门 这个网站可以在线生成多种语言的汇编代码,有这个好东西,那必须拿go实验一番。
很久之前我写过一篇go通过go汇编看多返回值实现的文章传送门。当时写的时候比较早,后来 go 1.17 对函数调用时,传递参数做了修改,简单说就是go1.17之前,函数参数是通过栈空间来传递的,在go1.17时做出了改变,在一些平台上(AMD64)可以像C,C++那样使用寄存器传递参数和函数返回值了。为什么做出这个改变呢,原因就是寄存器更快。虽然内存已经很快了,但是还是没法和寄存器相比。之前为啥不用寄存器,用栈空间,原因是实现简单,不用考虑不同平台,不用架构的区别。
简单总结一下两种方式
- 栈空间:
- 优点:实现简单,不用区分不同的平台,通用性强
- 缺点:效率低
- 寄存器:
- 优点:速度快
- 缺点:通用性差,不同的平台需要单独处理 当然,这里说的通用性差是对于编译器来说的
go汇编基础知识
再来总结一次go汇编的基础知识吧,现在回头看之前总结的还是不全面的 go使用的 plan9 汇编,这个和 AT&T 的汇编差别还是有点大的,我个人感觉plan9汇编比较重要的就是四个寄存器,只要理解了这四个寄存器,汇编就理解了一半了
汇编中个几个术语:
- 栈:进程、线程、goroutine 都有自己的调用栈,先进后出(FILO)
- 栈帧:可以理解是函数调用时,在栈上为函数所分配的内存区域
- 调用者:caller,比如:A 函数调用了 B 函数,那么 A 就是调用者
- 被调者:callee,比如:A 函数调用了 B 函数,那么 B 就是被调者
寄存器 | 说明 |
---|---|
SB(Static base pointer) | global symbols 全局静态指针 |
FP(Frame pointer) | arguments and locals 指向栈帧的开始 |
SP(Stack pointer) | top of stack 指向栈顶 |
PC(Program counter) | jumps and branches 简单说程序计数器 |
简单展开说一下几个寄存器吧
- SB:全局静态指针,即程序地址空间的开始地址。一般用在声明函数、全局变量中。
- FP:指向的是 caller 调用 callee 时传递的第一个参数的位置,可以看作是指向两个函数栈的分割位置;但是FP指向的位置不在 callee 的 stack frame 之内。而是在 caller 的 stack frame 上,指向调用 add 函数时传递的第一个参数的位置;可以在 callee 中用
symbol+offset(FP)
来获取入参的参数值,比如a+8(FP)
。虽然symbol
没有什么具体意义,但是不加编译器会报错。 - SP:这个是最常用的寄存器了,同样也是最复杂的寄存器了。不同的引用方式,代表不同的位置。SP寄存器 分为伪 SP 寄存器和硬件 SP 寄存器。
symbol+offset(SP)
形式,则表示伪寄存器 SP (这个也简称为 BP)。如果是offset(SP)
则表示硬件寄存器 SP。伪 SP 寄存器指向当前栈帧第一个局部变量的结束位置;硬件SP指向的是整个函数栈结束的位置。**有个比较坑的地方:**对于编译输出(go tool compile -S / go tool objdump)的代码来讲,所有的 SP 都是硬件 SP 寄存器,无论是否带 symbol(这一点非常具有迷惑性,需要慢慢理解。往往在分析编译输出的汇编时,看到的就是硬件 SP 寄存器)。 - PC:这个就是计算机常见的 pc 寄存器,在 x86 平台下对应 ip 寄存器,amd64 上则是 rip。这个很少有用到。
通过一个栈帧的图来理解一下这几个寄存器
大体的栈帧就是图中的这样,图中标注的寄存器都是以 callee 函数为基准的
通过图中可知,如果callee函数中没有局部变量的话,SP硬寄存器和SP伪寄存器指向的是同一个地方
伪 FP 寄存器对应的是 caller 函数的帧指针,一般用来访问 callee 函数的入参参数和返回值。伪 SP 栈指针对应的是当前 callee 函数栈帧的底部(不包括参数和返回值部分),一般用于定位局部变量。硬件 SP 是一个比较特殊的寄存器,因为还存在一个同名的 SP 真寄存器,硬件 SP 寄存器对应的是栈的顶部。
在编写 Go 汇编时,当需要区分伪寄存器和真寄存器的时候只需要记住一点:伪寄存器一般需要一个标识符和偏移量为前缀,如果没有标识符前缀则是真寄存器。比如(SP)、+8(SP)没有标识符前缀为真 SP 寄存器,而 a(SP)、b+8(SP)有标识符为前缀表示伪寄存器。
还有一点
如果callee的栈空间大小是0的话, caller BP 是不会被压入栈中的,此时的SP硬件寄存器和伪FP寄存器指向的是同一个位置。
在汇编中,函数的定义
1TEXT "".add(SB), NOSPLIT|ABIInternal, $0-24
TEXT
是一个特殊的指令,定义一个函数
.add
是函数的名
NOSPLIT
向编译器表明不应该插入 stack-split 的用来检查栈需要扩张的前导指令
$0-24
这两个参数,0是声明这个函数需要的栈空间的大小,一般来说就是局部变量需要的空间,单位是位。
24是声明函数传入参数和返回值需要的栈空间的大小,单位也是位。
生成汇编
了解了这些基本概念后,上一段代码,通过汇编看一下不同版本go是如何处理函数传递参数的
先通过一段简单的代码看一下区别
1package main
2
3func main(){
4 add(10,20)
5}
6
7//go:noinline
8func add(a,b int) int{
9 return a+b
10}
//go:noinline
这个是告诉编译器不要对这个函数内联,这个东西叫go的编译指令,go还有你很多别的指令,这里就不展开了。
想想go也挺有意思的,C++是通过 inline
显式的指定要内联,go是告诉编译器不要内联。
先看一下在1.16下的汇编代码
1main_pc0:
2 .file 1 "<source>"
3 .loc 1 5 0
4 TEXT "".main(SB), ABIInternal, $32-0
5 MOVQ (TLS), CX
6 CMPQ SP, 16(CX)
7 PCDATA $0, $-2
8 JLS main_pc64
9 PCDATA $0, $-1
10 SUBQ $32, SP
11 MOVQ BP, 24(SP)
12 LEAQ 24(SP), BP
13 FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
14 FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
15 .loc 1 11 0
16 MOVQ $10, (SP)
17 MOVQ $20, 8(SP)
18 PCDATA $1, $0
19 CALL "".add(SB)
20 .loc 1 12 0
21 MOVQ 24(SP), BP
22 ADDQ $32, SP
23 RET
24 NOP
25 .loc 1 5 0
26 PCDATA $1, $-1
27 PCDATA $0, $-2
28 NOP
29main_pc64:
30 CALL runtime.morestack_noctxt(SB)
31 PCDATA $0, $-1
32 JMP main_pc0
33 .loc 1 21 0
34 TEXT "".add(SB), NOSPLIT|ABIInternal, $0-24
35 FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
36 FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
37 .loc 1 22 0
38 MOVQ "".b+16(SP), AX
39 MOVQ "".a+8(SP), CX
40 ADDQ CX, AX
41 MOVQ AX, "".~r2+24(SP)
42 RET
代码有很多,只关注下面图中标注的
通 MOVQ "".b+16(SP), AX
和 MOVQ "".a+8(SP), CX
可以得知,函数是通过SP寄存器偏移完成传递参数的。
这里要注意,.b+16(SP)
这种写法看着像是使用的是伪SP寄存器,实际上用的是硬件SP寄存器
同样的,在函数调用之前,也会把数值放到栈的指定位置
MOVQ $10, (SP)
, MOVQ $20, 8(SP)
把编译器换成最新的 1.18看一下
1main_pc0:
2 .file 1 "<source>"
3 .loc 1 5 0
4 TEXT "".main(SB), ABIInternal, $24-0
5 CMPQ SP, 16(R14)
6 PCDATA $0, $-2
7 JLS main_pc47
8 PCDATA $0, $-1
9 SUBQ $24, SP
10 MOVQ BP, 16(SP)
11 LEAQ 16(SP), BP
12 FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
13 FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
14 .loc 1 11 0
15 MOVL $10, AX
16 MOVL $20, BX
17 PCDATA $1, $0
18 NOP
19 CALL "".add(SB)
20 .loc 1 12 0
21 MOVQ 16(SP), BP
22 ADDQ $24, SP
23 RET
24main_pc47:
25 NOP
26 .loc 1 5 0
27 PCDATA $1, $-1
28 PCDATA $0, $-2
29 CALL runtime.morestack_noctxt(SB)
30 PCDATA $0, $-1
31 JMP main_pc0
32 .loc 1 21 0
33 TEXT "".add(SB), NOSPLIT|ABIInternal, $0-16
34 FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
35 FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
36 FUNCDATA $5, "".add.arginfo1(SB)
37 FUNCDATA $6, "".add.argliveinfo(SB)
38 PCDATA $3, $1
39 .loc 1 22 0
40 ADDQ BX, AX
41 RET
在1.18的汇编代码中就没有通过栈空间来传递参数了,而是直接通过寄存器完成操作,
ADDQ BX, AX
,并且返回值直接放到寄存器中。
寄存器数量是有上限的,如果传递的参数个数超过了寄存器的上限,又会怎样处理呢
1package main
2
3func main(){
4 add(1,2,3,4,5,6,7,8,9,10,11,12)
5}
6
7//go:noinline
8func add(a,b,c,d,e,f,g,h,i,j,k,l int) int{
9 return a+b+c+d+e+f+g+h+i+j+k+l
10}
对应的汇编
1main_pc0:
2 .file 1 "<source>"
3 .loc 1 5 0
4 TEXT "".main(SB), ABIInternal, $104-0
5 CMPQ SP, 16(R14)
6 PCDATA $0, $-2
7 JLS main_pc111
8 PCDATA $0, $-1
9 SUBQ $104, SP
10 MOVQ BP, 96(SP)
11 LEAQ 96(SP), BP
12 FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
13 FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
14 .loc 1 11 0
15 MOVQ $10, (SP)
16 MOVQ $11, 8(SP)
17 MOVQ $12, 16(SP)
18 MOVL $1, AX
19 MOVL $2, BX
20 MOVL $3, CX
21 MOVL $4, DI
22 MOVL $5, SI
23 MOVL $6, R8
24 MOVL $7, R9
25 MOVL $8, R10
26 MOVL $9, R11
27 PCDATA $1, $0
28 NOP
29 CALL "".add(SB)
30 .loc 1 12 0
31 MOVQ 96(SP), BP
32 ADDQ $104, SP
33 RET
34main_pc111:
35 NOP
36 .loc 1 5 0
37 PCDATA $1, $-1
38 PCDATA $0, $-2
39 CALL runtime.morestack_noctxt(SB)
40 PCDATA $0, $-1
41 JMP main_pc0
42 .loc 1 21 0
43 TEXT "".add(SB), NOSPLIT|ABIInternal, $0-96
44 FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
45 FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
46 FUNCDATA $5, "".add.arginfo1(SB)
47 FUNCDATA $6, "".add.argliveinfo(SB)
48 PCDATA $3, $1
49 .loc 1 22 0
50 LEAQ (BX)(AX*1), DX
51 ADDQ DX, CX
52 ADDQ DI, CX
53 ADDQ SI, CX
54 ADDQ R8, CX
55 ADDQ R9, CX
56 ADDQ R10, CX
57 ADDQ R11, CX
58 MOVQ "".j+8(SP), DX
59 ADDQ DX, CX
60 MOVQ "".k+16(SP), DX
61 ADDQ DX, CX
62 MOVQ "".l+24(SP), DX
63 LEAQ (DX)(CX*1), AX
64 RET
通过汇编可以看到,会先使用寄存器,当寄存器不够时,会使用栈空间。
手写汇编
通过手写一段汇编代码,验证一下各个寄存器的位置,我用的go版本是 1.14.13,所以传参数用的是栈空间。 在 main.go 文件中
1package main
2
3func add(int, int) int
4
5func main() {
6 print(add(10, 20))
7}
定义一个 main 函数作为整个程序的入口,声明一个 add(int, int) int
函数,add 函数的具体实现是用汇编写的,
在 main.go 同级目录下创建一个 add_amd64.s 的文件。
使用硬BP寄存器
1TEXT ·add(SB), $0-24
2 MOVQ 8(SP), AX
3 MOVQ 16(SP), BX
4 ADDQ BX, AX
5 MOVQ AX, 24(SP)
6 RET
使用 go run . 命令,看一下程序执行的结果。
这时候的栈帧如图所示
因为add函数栈空间是0,所以伪SP寄存器没有被压入栈中,伪SP寄存器和硬件SP寄存器指向的是同一个位置。使用伪SP寄存器
1TEXT ·add(SB), $16-24
2 MOVQ a+16(SP), AX
3 MOVQ b+24(SP), BX
4 ADDQ BX, AX
5 MOVQ AX, ret+32(SP)
6 RET
为了区分对比,这时候把add函数的栈空间设置为16,然后使用伪SP寄存器来获取值。 执行结果如下:
这时候的栈空间如图
使用FP寄存器
代码如下:
1TEXT ·add(SB), $16-24
2 MOVQ a+0(FP), AX
3 MOVQ b+8(FP), BX
4 ADDQ BX, AX
5 MOVQ AX, ret+16(FP)
6 RET
执行结果:
![](https://cdn.jsdelivr.net/gh/lqxhub/images@master/blog/3622259-66c52609a214fb8c (1).webp) 说明还是正确的,此时的栈空间没有变化,和上面是一样的。
到这,应该能理解各个寄存器的相对位置了吧: 在callee栈空间不为0的时候,
1FP = 硬件SP + framsize + 16
2SP = 硬件SP + framsize
在callee栈空间为0的时候,
1FP = 硬件SP + 8
2SP = 硬件SP
汇编简单分析
通过上面的代码会发现,手写汇编的代码和反汇编的代码有些不同。反汇编得到的代码,在函数前面会有一段
CALL runtime.morestack_noctxt(SB)
其实这个是编译器自动插入的一段函数,这段指令会调用一次 runtime.morestack_noctxt
这个函数具体的作用是挺复杂的,主要有 检查是否需要扩张栈,go的栈空间是可以动态扩充的,所以在调用函数前会检查当前的栈空间是否需要扩充。还有一个功能就是检查当前协程需要抢占。go在1.14之前goroutine的抢占是协作式抢占模式,怎么判断一个协程是否需要抢占呢?后台协程会定时扫描当前运行中的协程,如果发现一个协程运行比较久,会将其标记为抢占状态。这个扫描的时间点就是函数调用期间完成的。
不同类型参数传递
传结构体
1package main
2
3type One struct {
4 a int
5 b int
6}
7
8func main(){
9 o := One {
10 a:10,
11 b.20,
12 }
13 f1(o)
14}
15
16//go:noinline
17 func f1(o One) int {
18 return o.a + o.b
19 }
只贴 关键的汇编代码吧
1 MOVQ $10, (SP)
2 MOVQ $0, 8(SP)
3 PCDATA $1, $0
4 CALL "".f1(SB)
5
6..........................
7
8 TEXT "".f1(SB), NOSPLIT|ABIInternal, $0-24
9 MOVQ "".o+16(SP), AX
10 MOVQ "".o+8(SP), CX
11 ADDQ CX, AX
12 MOVQ AX, "".~r1+24(SP)
13 RET
在传结构体的时候,只把结构体的内容传进去了。
传结构体指针
1package main
2
3type One struct {
4 a int
5 b int
6}
7
8func main(){
9 o := &One{
10 a:10,
11 b:20,
12 }
13 f1(o)
14}
15
16//go:noinline
17 func f1(o *One) int {
18 return o.a + o.b
19 }
汇编
1 LEAQ ""..autotmp_2+16(SP), AX
2 MOVQ AX, (SP)
3 PCDATA $1, $0
4 CALL "".f1(SB)
5
6.......................
7
8 TEXT "".f1(SB), NOSPLIT|ABIInternal, $0-16
9 MOVQ "".o+8(SP), AX
10 MOVQ (AX), CX
11 ADDQ 8(AX), CX
12 MOVQ CX, "".~r1+16(SP)
13 RET
可以看到,传递指针的时候,只是把结构体第一个元素的地址传递进去了。
如果是传一个空的结构体
1package main
2
3type One struct {
4}
5
6func main(){
7 o := One{}
8 f1(o,10,20)
9}
10
11//go:noinline
12 func f1(o One, a,b int) int {
13 return a + b
14 }
汇编
1 TEXT "".f1(SB), NOSPLIT|ABIInternal, $0-24
2 .loc 1 15 0
3 MOVQ "".b+16(SP), AX
4 MOVQ "".a+8(SP), CX
5 ADDQ CX, AX
6 MOVQ AX, "".~r3+24(SP)
7 RET
可以看到,当传递一个空结构体时,相当于没有传递,因为空结构体的空间大小是0,所以编译器就给忽略了。
最后
暂时就想到这么多,先写这些吧,后面想起别的再补充吧