0%

再探 go 汇编

五一假期在家没事逛论坛的时候,发现了一个宝藏网站,传送门 这个网站可以在线生成多种语言的汇编代码,有这个好东西,那必须拿go实验一番。

很久之前我写过一篇go通过go汇编看多返回值实现的文章传送门。当时写的时候比较早,后来 go 1.17 对函数调用时,传递参数做了修改,简单说就是go1.17之前,函数参数是通过栈空间来传递的,在go1.17时做出了改变,在一些平台上(AMD64)可以像C,C++那样使用寄存器传递参数和函数返回值了。为什么做出这个改变呢,原因就是寄存器更快。虽然内存已经很快了,但是还是没法和寄存器相比。之前为啥不用寄存器,用栈空间,原因是实现简单,不用考虑不同平台,不用架构的区别。

简单总结一下两种方式

  1. 栈空间:
  • 优点:实现简单,不用区分不同的平台,通用性强
  • 缺点:效率低
  1. 寄存器:
  • 优点:速度快
  • 缺点:通用性差,不同的平台需要单独处理
    当然,这里说的通用性差是对于编译器来说的

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。这个很少有用到。

通过一个栈帧的图来理解一下这几个寄存器

栈帧.jpg

大体的栈帧就是图中的这样,图中标注的寄存器都是以 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寄存器指向的是同一个位置。

在汇编中,函数的定义

1
TEXT    "".add(SB), NOSPLIT|ABIInternal, $0-24
是一个特殊的指令,定义一个函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

```.add``` 是函数的名

```NOSPLIT``` 向编译器表明不应该插入 stack-split 的用来检查栈需要扩张的前导指令

``` $0-24``` 这两个参数,0是声明这个函数需要的栈空间的大小,一般来说就是局部变量需要的空间,单位是位。
24是声明函数传入参数和返回值需要的栈空间的大小,单位也是位。

## 生成汇编
了解了这些基本概念后,上一段代码,通过汇编看一下不同版本go是如何处理函数传递参数的

先通过一段简单的代码看一下区别

```go
package main

func main(){
add(10,20)
}

//go:noinline
func add(a,b int) int{
return a+b
}

//go:noinline
这个是告诉编译器不要对这个函数内联,这个东西叫go的编译指令,go还有你很多别的指令,这里就不展开了。
想想go也挺有意思的,C++是通过 inline 显式的指定要内联,go是告诉编译器不要内联。

先看一下在1.16下的汇编代码

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
main_pc0:
.file 1 "<source>"
.loc 1 5 0
TEXT "".main(SB), ABIInternal, $32-0
MOVQ (TLS), CX
CMPQ SP, 16(CX)
PCDATA $0, $-2
JLS main_pc64
PCDATA $0, $-1
SUBQ $32, SP
MOVQ BP, 24(SP)
LEAQ 24(SP), BP
FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
.loc 1 11 0
MOVQ $10, (SP)
MOVQ $20, 8(SP)
PCDATA $1, $0
CALL "".add(SB)
.loc 1 12 0
MOVQ 24(SP), BP
ADDQ $32, SP
RET
NOP
.loc 1 5 0
PCDATA $1, $-1
PCDATA $0, $-2
NOP
main_pc64:
CALL runtime.morestack_noctxt(SB)
PCDATA $0, $-1
JMP main_pc0
.loc 1 21 0
TEXT "".add(SB), NOSPLIT|ABIInternal, $0-24
FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
.loc 1 22 0
MOVQ "".b+16(SP), AX
MOVQ "".a+8(SP), CX
ADDQ CX, AX
MOVQ AX, "".~r2+24(SP)
RET

代码有很多,只关注下面图中标注的

MOVQ "".b+16(SP), AXMOVQ "".a+8(SP), CX 可以得知,函数是通过SP寄存器偏移完成传递参数的。
这里要注意,.b+16(SP) 这种写法看着像是使用的是伪SP寄存器,实际上用的是硬件SP寄存器

同样的,在函数调用之前,也会把数值放到栈的指定位置

MOVQ $10, (SP) , MOVQ $20, 8(SP)

把编译器换成最新的 1.18看一下

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
main_pc0:
.file 1 "<source>"
.loc 1 5 0
TEXT "".main(SB), ABIInternal, $24-0
CMPQ SP, 16(R14)
PCDATA $0, $-2
JLS main_pc47
PCDATA $0, $-1
SUBQ $24, SP
MOVQ BP, 16(SP)
LEAQ 16(SP), BP
FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
.loc 1 11 0
MOVL $10, AX
MOVL $20, BX
PCDATA $1, $0
NOP
CALL "".add(SB)
.loc 1 12 0
MOVQ 16(SP), BP
ADDQ $24, SP
RET
main_pc47:
NOP
.loc 1 5 0
PCDATA $1, $-1
PCDATA $0, $-2
CALL runtime.morestack_noctxt(SB)
PCDATA $0, $-1
JMP main_pc0
.loc 1 21 0
TEXT "".add(SB), NOSPLIT|ABIInternal, $0-16
FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
FUNCDATA $5, "".add.arginfo1(SB)
FUNCDATA $6, "".add.argliveinfo(SB)
PCDATA $3, $1
.loc 1 22 0
ADDQ BX, AX
RET

在1.18的汇编代码中就没有通过栈空间来传递参数了,而是直接通过寄存器完成操作,

BX, AX```,并且返回值直接放到寄存器中。
1
2
3
4
5
6
7
8
9
10
11
12
13

寄存器数量是有上限的,如果传递的参数个数超过了寄存器的上限,又会怎样处理呢
```go
package main

func main(){
add(1,2,3,4,5,6,7,8,9,10,11,12)
}

//go:noinline
func add(a,b,c,d,e,f,g,h,i,j,k,l int) int{
return a+b+c+d+e+f+g+h+i+j+k+l
}

对应的汇编

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
main_pc0:
.file 1 "<source>"
.loc 1 5 0
TEXT "".main(SB), ABIInternal, $104-0
CMPQ SP, 16(R14)
PCDATA $0, $-2
JLS main_pc111
PCDATA $0, $-1
SUBQ $104, SP
MOVQ BP, 96(SP)
LEAQ 96(SP), BP
FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
.loc 1 11 0
MOVQ $10, (SP)
MOVQ $11, 8(SP)
MOVQ $12, 16(SP)
MOVL $1, AX
MOVL $2, BX
MOVL $3, CX
MOVL $4, DI
MOVL $5, SI
MOVL $6, R8
MOVL $7, R9
MOVL $8, R10
MOVL $9, R11
PCDATA $1, $0
NOP
CALL "".add(SB)
.loc 1 12 0
MOVQ 96(SP), BP
ADDQ $104, SP
RET
main_pc111:
NOP
.loc 1 5 0
PCDATA $1, $-1
PCDATA $0, $-2
CALL runtime.morestack_noctxt(SB)
PCDATA $0, $-1
JMP main_pc0
.loc 1 21 0
TEXT "".add(SB), NOSPLIT|ABIInternal, $0-96
FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
FUNCDATA $5, "".add.arginfo1(SB)
FUNCDATA $6, "".add.argliveinfo(SB)
PCDATA $3, $1
.loc 1 22 0
LEAQ (BX)(AX*1), DX
ADDQ DX, CX
ADDQ DI, CX
ADDQ SI, CX
ADDQ R8, CX
ADDQ R9, CX
ADDQ R10, CX
ADDQ R11, CX
MOVQ "".j+8(SP), DX
ADDQ DX, CX
MOVQ "".k+16(SP), DX
ADDQ DX, CX
MOVQ "".l+24(SP), DX
LEAQ (DX)(CX*1), AX
RET

通过汇编可以看到,会先使用寄存器,当寄存器不够时,会使用栈空间。

手写汇编

通过手写一段汇编代码,验证一下各个寄存器的位置,我用的go版本是 1.14.13,所以传参数用的是栈空间。
main.go 文件中

1
2
3
4
5
6
7
package main

func add(int, int) int

func main() {
print(add(10, 20))
}

定义一个 main 函数作为整个程序的入口,声明一个 add(int, int) int 函数,add 函数的具体实现是用汇编写的,
main.go 同级目录下创建一个 add_amd64.s 的文件。

使用硬BP寄存器

1
2
3
4
5
6
TEXT ·add(SB), $0-24
MOVQ 8(SP), AX
MOVQ 16(SP), BX
ADDQ BX, AX
MOVQ AX, 24(SP)
RET

使用 go run . 命令,看一下程序执行的结果。

这时候的栈帧如图所示

因为add函数栈空间是0,所以伪SP寄存器没有被压入栈中,伪SP寄存器和硬件SP寄存器指向的是同一个位置。

使用伪SP寄存器

1
2
3
4
5
6
TEXT ·add(SB), $16-24
MOVQ a+16(SP), AX
MOVQ b+24(SP), BX
ADDQ BX, AX
MOVQ AX, ret+32(SP)
RET

为了区分对比,这时候把add函数的栈空间设置为16,然后使用伪SP寄存器来获取值。
执行结果如下:

这时候的栈空间如图

使用FP寄存器

代码如下:

1
2
3
4
5
6
TEXT ·add(SB), $16-24
MOVQ a+0(FP), AX
MOVQ b+8(FP), BX
ADDQ BX, AX
MOVQ AX, ret+16(FP)
RET

执行结果:

![](3622259-66c52609a214fb8c (1).webp)
说明还是正确的,此时的栈空间没有变化,和上面是一样的。

到这,应该能理解各个寄存器的相对位置了吧:
在callee栈空间不为0的时候,

1
2
FP = 硬件SP + framsize + 16
SP = 硬件SP + framsize

在callee栈空间为0的时候,

1
2
FP = 硬件SP + 8
SP = 硬件SP

汇编简单分析

通过上面的代码会发现,手写汇编的代码和反汇编的代码有些不同。反汇编得到的代码,在函数前面会有一段
CALL runtime.morestack_noctxt(SB)
其实这个是编译器自动插入的一段函数,这段指令会调用一次 runtime.morestack_noctxt 这个函数具体的作用是挺复杂的,主要有 检查是否需要扩张栈,go的栈空间是可以动态扩充的,所以在调用函数前会检查当前的栈空间是否需要扩充。还有一个功能就是检查当前协程需要抢占。go在1.14之前goroutine的抢占是协作式抢占模式,怎么判断一个协程是否需要抢占呢?后台协程会定时扫描当前运行中的协程,如果发现一个协程运行比较久,会将其标记为抢占状态。这个扫描的时间点就是函数调用期间完成的。

不同类型参数传递

传结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

type One struct {
a int
b int
}

func main(){
o := One {
a:10,
b.20,
}
f1(o)
}

//go:noinline
func f1(o One) int {
return o.a + o.b
}

只贴 关键的汇编代码吧

1
2
3
4
5
6
7
8
9
10
11
12
13
    MOVQ    $10, (SP)
MOVQ $0, 8(SP)
PCDATA $1, $0
CALL "".f1(SB)

..........................

TEXT "".f1(SB), NOSPLIT|ABIInternal, $0-24
MOVQ "".o+16(SP), AX
MOVQ "".o+8(SP), CX
ADDQ CX, AX
MOVQ AX, "".~r1+24(SP)
RET

在传结构体的时候,只把结构体的内容传进去了。

传结构体指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

type One struct {
a int
b int
}

func main(){
o := &One{
a:10,
b:20,
}
f1(o)
}

//go:noinline
func f1(o *One) int {
return o.a + o.b
}

汇编

1
2
3
4
5
6
7
8
9
10
11
12
13
        LEAQ    ""..autotmp_2+16(SP), AX
MOVQ AX, (SP)
PCDATA $1, $0
CALL "".f1(SB)

.......................

TEXT "".f1(SB), NOSPLIT|ABIInternal, $0-16
MOVQ "".o+8(SP), AX
MOVQ (AX), CX
ADDQ 8(AX), CX
MOVQ CX, "".~r1+16(SP)
RET

可以看到,传递指针的时候,只是把结构体第一个元素的地址传递进去了。

如果是传一个空的结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

type One struct {
}

func main(){
o := One{}
f1(o,10,20)
}

//go:noinline
func f1(o One, a,b int) int {
return a + b
}

汇编

1
2
3
4
5
6
7
TEXT    "".f1(SB), NOSPLIT|ABIInternal, $0-24
.loc 1 15 0
MOVQ "".b+16(SP), AX
MOVQ "".a+8(SP), CX
ADDQ CX, AX
MOVQ AX, "".~r3+24(SP)
RET

可以看到,当传递一个空结构体时,相当于没有传递,因为空结构体的空间大小是0,所以编译器就给忽略了。

最后

暂时就想到这么多,先写这些吧,后面想起别的再补充吧