Featured image of post 再探 go 汇编

再探 go 汇编

这篇文章深入探讨了 Go 语言中的汇编实现及参数传递的演变,尤其是 Go 1.17 引入的寄存器传递方式的优化。通过对比栈和寄存器的优缺点,作者清晰解释了效率和通用性之间的权衡。文章详细总结了 Go 汇编的基本概念、寄存器功能和栈帧结构,结合具体代码实例展示了如何手动编写汇编函数,同时比较了不同版本的汇编输出。此外,针对结构体和指针的传递方式进行了详细分析,为读者理解 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寄存器指向的是同一个位置。

在汇编中,函数的定义

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), AXMOVQ "".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,所以编译器就给忽略了。

最后

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

发表了58篇文章 · 总计133.24k字
本博客已稳定运行
© QX
使用 Hugo 构建
主题 StackJimmy 设计