Go Assembly 汇编编程 | 青训营

412 阅读6分钟

Golang的汇编和我们所熟知的汇编语言不一样,是一套专门用于Go语言程序的汇编语言,其大体形式与Plan9汇编类似,脱离了Go的编译器和包体系无法独立运行。

但是Golang的汇编却可以和正常go代码一样被直接编译运行甚至相互调用,不需要额外的配置,通过编写Go Assembly一定程度上可以从底层采用各种指令对程序进行优化,有时候也需要分析Go Assembly分析程序性能问题。

下面我们以标准库中出现的一些汇编代码为例,来学习一下如果在go中进行汇编编程。

示例

下面是一个完整的例子:

#include "textflag.h"
// bool Cas(int32 *val, int32 old, int32 new)
// Atomically:
//  if(*val == old){
//      *val = new;
//      return 1;
//  }else
//      return 0;
TEXT ·Cas(SB), NOSPLIT, $0-13
    MOVL   ptr+0(FP), BX
    MOVL   old+4(FP), AX
    MOVL   new+8(FP), CX
    LOCK
    CMPXCHGL   CX, 0(BX)
    SETEQ  ret+12(FP)
    RET

以上是一个Cas函数的汇编代码,第一行TEXT ·Cas(SB), NOSPLIT, $0-13中TEXT是一个函数的声明,后面接一个<包名>·<函数名>(SB)格式的文本,类似的如main·Main(SB),需要注意的是中间的点位于中间,不是小数点!另外如果省略包名则表示该汇编函数是在其目录所在包下。

然后是逗号后接一个标志NOSPLIT表示该函数不进行栈溢出检查,不进行栈溢出检查可以提高程序性能,如果有多个标志,可以用|进行分隔。通过合理使用这些标志,可以优化程序性能。根据官方汇编文档,这些标志都定义在"textflag.h"中,所以使用这些标志的话必须引入textflag.h库,以下是一些其他的标志:

  • NOPROF = 1 (For items.) Don't profile the marked function. This flag is deprecated. TEXT
  • DUPOK = 2 It is legal to have multiple instances of this symbol in a single binary. The linker will choose one of the duplicates to use.
  • NOSPLIT = 4 (For items.) Don't insert the preamble to check if the stack must be split. The frame for the routine, plus anything it calls, must fit in the spare space remaining in the current stack segment. Used to protect routines such as the stack splitting code itself. TEXT
  • RODATA = 8 (For and items.) Put this data in a read-only section. DATA``GLOBL
  • NOPTR = 16 (For and items.) This data contains no pointers and therefore does not need to be scanned by the garbage collector. DATA``GLOBL
  • WRAPPER = 32 (For items.) This is a wrapper function and should not count as disabling . TEXT``recover
  • NEEDCTXT = 64 (For items.) This function is a closure so it uses its incoming context register. TEXT
  • LOCAL = 128 This symbol is local to the dynamic shared object.
  • TLSBSS = 256 (For and items.) Put this data in thread local storage. DATA``GLOBL
  • NOFRAME = 512 (For items.) Do not insert instructions to allocate a stack frame and save/restore the return address, even if this is not a leaf function. Only valid on functions that declare a frame size of 0. TEXT
  • TOPFRAME = 2048 (For items.) Function is the outermost frame of the call stack. Traceback should stop at this function. TEXT

接着,后面又用逗号分隔,并且接一个$0-13,这个语句的意思是声明这个函数的帧大小frame size是0字节,中间的'-'是分隔符,后面的参数13表示函数参数的大小是13字节(包括传入的参数和返回值)。

关于帧大小实际上就是函数自己所需要的栈大小,这里是0是因为该函数没有声明额外的局部变量,都是使用寄存器,自然不需要额外的空间。

参数大小是包括返回值和传入的参数的,这里的13包括old和new两个int32的参数一共8字节,然后val是个指针,指针所占空间大小和具体类型无关,和平台架构有关,64位架构一律占8字节,32位架构占4字节,因为这段代码引用自atomic_386,是32位架构,所以指针占4字节,最后在加上返回值bool,占1字节,一共是13字节。

关于$0-13的声明,测试的时候发现简单的程序哪怕和实际不相符也不会导致编译不通过,但是会出现一些预料之外的情况,比如访问到了其他的变量的值,因为前面的0会影响你当前函数分配的帧大小,后面的13主要是交给调用者去处理的。

下面便是函数体的处理了,MOVL ptr+0(FP), BX,其中,MOVL指令表示对32位数执行mov操作,L后缀基本上表示的是32位操作,同样地,如果是对64位数进行操作,则是Q后缀,相应的有MOVQ,ADDQ等命令,ptr-0(FP)是对函数参数的引用,ptr只是个标识,为了增加可读性,没有实际作用,实际上可以是任何值,但是不能省略也不能为空,后面+0(FP)+表示向FP虚拟寄存器所在地址的高地址取数据,0表示偏移,此处ptr+0(FP)相当于获取第一个参数,并将它通过MOVQ指令存入BX寄存器。

在Go Assembly中如MOVQ这样的指令大多数 第一个参数都是src,第二个参数是dst,也就是目的操作数都是第二个。

需要注意的是,Go Assembly中寄存器是不分32位还是64位,然后对寄存器有EBX和BX之分的,统一都是BX,下面是Go Assembly中的一些其他的寄存器:

AXBXCXDXSIDIBPSPR8~R15PC

一共是16个寄存器,其中R10R11在ARM中是保留寄存器,是交给链接器和编译器使用的,程序中不应该使用。

接着是LOCK指令,这条指令是实现原子操作的关键指令,LOCK指令会使其后面紧跟着的指令变成原子指令,也就是CMPXCHGL会变成原子指令,而该指令会比较两个值是否相等,相等的时候会交换两个32位数。后面紧跟的SETEQ指令会设置ret+12(FP)处的值为布尔类型的true,不执行这条命令就是默认的false(0值)

一个函数体结尾应该是一个跳转指令,比如JMPRET

Go调用汇编

从以上这个例子中,我们已经学会了如何声明一个汇编函数,下面我们将自己试试用汇编写一个Add函数并在main函数中调用。

go程序想调用汇编代码,需要一定的条件,那就是我们需要再go文件中声明一个和汇编同名的函数s声明(无函数体)。

我们在asm目录下创建一个main.go,添加一下内容:

package main
​
import "fmt"func main() {
    res := Add(1, 2)
    fmt.Println(res)
​
}
​
func Add(x, y int64) int64

然后再asm目录下创建一个asm.s文件,添加一下内容:

​
TEXT    ·Add(SB),$0-24
    MOVQ x+0(FP),AX
    MOVQ y+8(FP),CX
    ADDQ CX,AX
    MOVQ AX,ret+16(FP)
    RET

接着直接编译运行,我们可以看到程序成功输出了3.

汇编调用Go

上面我们已经学会了在Go程序中调用汇编了,接下来我们学习一下如何在汇编中调用Go函数

首先,修改下main.go中的内容:

package main
​
import "fmt"func main() {
    res := Add(3, 2)
    fmt.Println(res)
​
}
func Hello(a int64) int64 {
    fmt.Println("Hello")
    return a + 1
}
func Add(x, y int64) int64

然后我们在asm.s中的Add函数中调用Hello

TEXT    ·Add(SB),$16-24
    MOVQ x+0(FP),AX
    MOVQ y+8(FP),CX
    ADDQ CX,AX
    MOVQ AX,+0(SP)
    CALL ·Hello(SB)
    MOVQ +8(SP),AX
    MOVQ AX,ret+16(FP)
    RET

注意,因为Hello需要一个int64的参数并且返回一个int64的参数,所以我们在传参调用该函数的时候需要16的帧大小以存储参数和返回值的值,不然就会报错,记得修改$0-24$16-24

接着,我们编译运行,发现运行输出了:

Hello 6

另外,如果你需要在函数中声明一个int64大小的局部变量的话,修改$16$24然后通过a-0(SP)的方式引用该变量,a是局部变量的名字。局部变量都是用'-'表示引用的是本函数的栈空间,'+'的话就是获取调用者的占空间,因为调用栈帧是从高地址向低地址增长的。