go汇编入门

110 阅读5分钟

2020.01学习笔记

go汇编学习笔记。

go汇编

参考资料:

  1. go语言调度器源代码情景分析之七:函数调用过程
  2. go语言高级编程-第三章 汇编语言

go汇编最关键的是理解四个伪寄存器 PC, SB, FP, SP。四个伪寄存器和AMD64的寄存器的关系如下图:

编译过程

go语言代码 => go汇编代码 => 平台代码

PC

PC寄存器与IP寄存器是等价的,也就是说在 go汇编代码 => 平台代码 过程中,PC寄存器直接转换成IP寄存器。

SB

我们都知道,在linux下,虚拟内存被划分成 文本区,数据区(大概可以分为全局数据区,只读数据区,bss区),栈区,堆区。SB寄存器在go汇编中就是用来定义和读取 文本区,数据区的。

定义和读取数据区

定义数据的格式:

GlOBL symbol(SB), width
DATA symbol+offset(SB)/width, value
//pkg.s
//DATA ·Id+0(SB)/8, $18         填充Id的数据,offset=>第0个字节开始,width=>填充宽度8
//GLOBL ·Id(SB), $8             导出8个字节的变量Id,前面的·是格式,加上就好

//pkg.go
//var Id int                    声明Id为整形

offset 和 width也可以是别的值,操作的内存不要超出声明的8个字节就行了。

还可以给数据加上 NOPTR(非指针),RODATA(只读) 等标志,但是这很麻烦,我们一般不用go汇编声明数据,直接用go语言(var Id = 18)就好了。

但是用汇编读取数据区的数据是经常要做的,读取数据格式:

symbol+offset(SB)
//MOVQ    ·Id+0(SB), AX    从Id变量的第0个字节开始读取,MOVQ指令决定了读取宽度为8个字节

定义和读取文本区

文本区也叫做代码区,代码是可运行的数据。

定义文本区基本就是定义函数。

定义函数的格式,先记住格式,$framesize-argsize跟函数调用栈有关,搞清楚之后也很容易确定:

TEXT symbol(SB), $framesize-argsize
//pkg.s
//TEXT ·ReadData(SB), $0-8          $0-8表示函数的帧为0字节,参数和返回值总大小为8字节
//    MOVQ    ·Id+0(SB), AX
//    MOVQ    AX, r0+0(FP)
//    RET
//
//pkg.go
//func ReadData() int               声明函数

读取文本区就是调用函数,跳转到函数定义的地址。

symbol(SB)
//  CALL  ·ReadData(SB)

上面只是说了定义函数和调用函数的格式,那原理是什么?这跟 FP,SP 寄存器相关。

FP 和 SP 和 真SP 和 BP

这是三个密切相关的寄存器,我们硬件也有一个栈顶寄存器SP,接下来把它叫做 真SP。

在C中,CALL指令会把当前IP压栈,RET指令相当于 POP IP,然后在被调用函数内一般还会将BP寄存器压栈,用BP寄存器访问局部变量。如果不记得了,可以先复习一下C函数调用栈。

在go中,CALL指令会把当前PC压栈,RET指令相当于 POP PC,BP寄存器会根据被调用函数的framsize是否大于0来确定是否压栈。

通过寄存器来引用一个值的格式,symbol只是一个助记符,没有其他意义,也不需要和传入的参数名字一样:

symbol+offset(FP)
//arg1+8(FP)      表示FP的地址向前偏移8个字节的地址中的数据

要注意的是,在go汇编中 offset(SP) 的SP表示真SP,symbol+offset(SP)的SP表示伪寄存器。

我们定义一个函数:

//  想实现一个函数 func(a int, b int) int { return a*a + b*b }
//
//pkg.s
//TEXT ·Sum(SB), $0-24
//    MOVQ  a+8(SP), AX
//    IMULQ AX, AX
//    MOVQ  b+16(SP), CX
//    IMULQ CX, CX
//    ADDQ  CX, AX
//    MOVQ  AX, r1+24(SP)
//    RET
//
//pkg.go
//func Sum(a int, b int) int

这个函数的 framsize 为 0, argsize 为 24。

假设调用 Sum(1, 2),它的函数帧栈为:

因为 framsize 等于 0,所以 BP 寄存器不压栈,真SP和伪SP都指向返回地址,FP指向函数的第一个参数。

所以,我们可以通过三种方式 arg1+0(FP), arg1+8(SP), 8(SP)来获取第一个参数的值。

go的返回值也是通过栈传递的,在这里 r1+16(FP), r1+24(SP), 24(SP)都可以表示返回值。

再定义一个函数:

//  想实现一个函数 func(a int, b int) int { return a*a + b*b }
//
//pkg.s
//TEXT ·Sum(SB), $16-24
//    MOVQ  a+0(FP), AX
//    IMULQ AX, AX
//    MOVQ  b+8(FP), CX
//    IMULQ CX, CX
//    ADDQ  CX, AX
//    MOVQ  AX, r1+16(FP)
//    RET
//
//pkg.go
//func Sum(a int, b int) int

不同之处在于 framsize 为 16,通过 FP 来使用参数和设置返回值,函数帧栈为:

因为 framsize 大于 0 ,BP寄存器会压栈。同时 真SP 向下偏移framsize字节,预留空间。

go汇编 => 平台汇编 的过程中编译器会根据framsize会通过插入额外的压栈指令处理,手写汇编的时候不用自己判断,只需要知道寄存器指向哪里就好了。

为什么要预留空间呢?我们手写汇编时不应该使用PUSHQ,POPQ指令改变真SP的值,只能使用framsize规划好的内存,因为在运行这些指令时,我们没法知道协程栈会不会爆掉。如果提前规划好,调用前就可以比较栈够不够大。

而且,FP,SP只是真SP的快捷键,当真SP改变时,FP,SP也会改变。

在BP寄存器压栈时,它们的关系为:

FP = 真SP + framsize + 16
SP = 真SP + framsize

那framsize怎么确定,其实framsize不需要十分精确,但是要保证足够用来调用函数,比如如果想调用一个声明为 func (int, int)int 的函数,那自己的framsize至少得为24吧,否则调用函数的空间都没有。如果要保存局部变量,那还得更大一点。总之,framsize不用十分精确,至少要够用,大一点也无所谓。

再用一个函数验证FP,SP,真SP之间的关系:

//  func(int) (int, int, int)
TEXT ·Output(SB), $8-48
    //  通过FP获取
    MOVQ    arg1+0(FP), AX
    MOVQ    AX, ret1+8(FP)
    //  通过伪SP获取
    MOVQ    arg1+16(SP), BX // 当前framsize大小为8 > 0,所以 FP 在 SP 的上方 16 字节处
    MOVQ    BX, ret2+16(FP)
    //  通过真SP获取
    MOVQ    24(SP), DX
    MOVQ    DX, ret3+24(FP)
    RET

调用:

a, b, c := Output(100)
println(a, b, c)            //  100 100 100

混合类型

func (a bool, b int16, c []int)

这种类型只要考虑清楚内存对齐,并不比全是 int 复杂多少,这个图很好地说明了问题: