再探 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寄存器指向的是同一个位置。

在汇编中,函数的定义

TEXT    "".add(SB), NOSPLIT|ABIInternal, $0-24

TEXT 是一个特殊的指令,定义一个函数

.add 是函数的名

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

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

生成汇编

了解了这些基本概念后,上一段代码,通过汇编看一下不同版本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下的汇编代码

main_pc0:
        .file 1 ""
        .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看一下

main_pc0:
        .file 1 ""
        .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的汇编代码中就没有通过栈空间来传递参数了,而是直接通过寄存器完成操作,
ADDQ BX, AX,并且返回值直接放到寄存器中。

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

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
}

对应的汇编

main_pc0:
        .file 1 ""
        .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 文件中

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寄存器
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寄存器
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寄存器

代码如下:

TEXT ·add(SB), $16-24
    MOVQ a+0(FP), AX
    MOVQ b+8(FP), BX
    ADDQ BX, AX
    MOVQ AX, ret+16(FP)
    RET

执行结果:


说明还是正确的,此时的栈空间没有变化,和上面是一样的。

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

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

在callee栈空间为0的时候,

FP = 硬件SP + 8
SP = 硬件SP

汇编简单分析

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

不同类型参数传递

传结构体

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
 }

只贴 关键的汇编代码吧

    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

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

传结构体指针
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
 }

汇编

        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

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

如果是传一个空的结构体

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
 }

汇编

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

最后

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

你可能感兴趣的:(再探 go 汇编)