Go汇编(一)

想要搞明白Go汇编相关知识,需要明确很多概念。由于Go通过编译之后生成的汇编语言依然是一种中间层,并不完全对应到CPU硬件的汇编,因此有一些冲突的概念需要仔细区分。在详细分析Go汇编之前,需要先理解这些前提条件,只有明白这些前提条件,才能更好的理解Go汇编的使用方法,因此这一节我们先简单介绍Go汇编的一些基础概念。

Go汇编

Go的汇编的原型是plan9,基于plan9但是和plan9不完全相同,引用官方的一段话:

The most important thing to know about Go’s assembler is that it is not a direct representation of the underlying machine. Some of the details map precisely to the machine, but some do not. This is because the compiler suite needs no assembler pass in the usual pipeline. Instead, the compiler operates on a kind of semi-abstract instruction set, and instruction selection occurs partly after code generation. The assembler works on the semi-abstract form, so when you see an instruction like MOV what the toolchain actually generates for that operation might not be a move instruction at all, perhaps a clear or load. Or it might correspond exactly to the machine instruction with that name. In general, machine-specific operations tend to appear as themselves, while more general concepts like memory move and subroutine call and return are more abstract. The details vary with architecture, and we apologize for the imprecision; the situation is not well-defined.

The assembler program is a way to parse a description of that semi-abstract instruction set and turn it into instructions to be input to the linker.

要了解Go的汇编器最重要的是要知道Go的汇编器不是对底层机器的直接表示,即Go的汇编器没有直接使用目标机器的汇编指令。Go汇编器所用的指令,一部分与目标机器的指令一一对应,而另外一部分则不是。这是因为编译器套件不需要汇编器直接参与常规的编译过程。相反,编译器使用了一种半抽象的指令集,并且部分指令是在代码生成后才被选择的。汇编器基于这种半抽象的形式工作,所以虽然你看到的是一条MOV指令,但是工具链针对对这条指令实际生成可能完全不是一个移动指令,也许会是清除或者加载。也有可能精确的对应目标平台上同名的指令。概括来说,特定于机器的指令会以他们的本尊出现, 然而对于一些通用的操作,如内存的移动以及子程序的调用以及返回通常都做了抽象。细节因架构不同而不一样,我们对这样的不精确性表示歉意,情况并不明确。

汇编器程序的工作是对这样半抽象指令集进行解析并将其转变为可以输入到链接器的指令。

一般来说,对于C/C++,大家通用的惯例都是通过.h实现变量、方法的声明。其他文件想要使用这个变量、方法则需要引入这个.h文件。对应的,对于Go来说,.go文件,也是用来实现变量、方法的声明。而汇编文件则才是真正的实现。

类似:

声明Age变量:

var Age int32

定义Age变量:

GLOBL ·Age(SB),NOPTR,4

DATA ·MyInt+0(SB)/1,0x37
DATA ·MyInt+1(SB)/1,0x25
DATA ·MyInt+2(SB)/1,0x00
DATA ·MyInt+3(SB)/1,$0x00

不同的是,对于C/C++来说,引入的是文件.h级别,而对于go来说,引入的是包(文件夹)级别。

Linux操作系统为例,进程的内存结构如下图所示:

image-20220705214337548

对于汇编来说,程序都是分段的,例如数据段、代码段、栈段。每个段的职责都不一样,数据段只存放定义的静态数据,代码段只存放代码指令,栈段专门用来有序存放数据。每种段在逻辑上都是内存连续的,同时不会重叠,也互不影响。在分析汇编的时候,我们最需要关注的便是栈段,因为函数的执行都会伴随着出栈和入栈。

对于栈,一般来说指的都是栈段。然后平时文档只还有一个词语:栈帧。栈和栈帧的区别在于:

  • 栈是基于线程的,每个线程分配一个栈段。例如Go,每个goroutine初始栈大小就为2k,Go汇编对于栈的操作都是在这2k的内存上进行操作。
  • 栈帧是基于函数的,每个函数一个栈帧。栈帧是对栈的进一步细化,当调用一个函数的时候,需要首先为这个函数分配栈帧,例如Go汇编,通过减小SP指针的地址实现扩大栈帧。当函数调用完毕,则需要回收这个函数的栈帧。Go汇编通过增加SP指针的地址实现。

寄存器

寄存器顾名思义,是用来临时寄存数据的器件。一般和CPU打交道最多的便是寄存器。寄存器的存取速度非常快,对于CPU来说,每个寄存器都有自己的名字,不同的CPU也有不同的约定意义。比如:

  • 在执行Loop指令的时候,怎么知道什么时候应该停止循环呢?x86通用约定:每次执行Loop指令时,都会先将CX寄存器中的值减一,再对比值是否大于0,如果大于0,则继续循环。
  • CPU怎么能知道应该去哪里读取下一条指令然后执行呢?毕竟CPU还需要提供跳转功能,怎么告知CPU下一条指令应该跳转到那个地址呢?x86通用约定:CPU每次都会从IP寄存器中读取应该执行的指令,读取完毕之后会将IP指令的地址加一。

因此,对于寄存器来说,每个寄存器都有自己约定的意义。当遇见一个寄存器的时候,应该去了解他代表的意义是什么。对于x86常见的大分类为:通用寄存器、段寄存器、控制寄存器、指针寄存器等。

对于Go汇编来说,Go汇编定义了4个通用伪寄存器,用来屏蔽不同平台的差异,这4个伪寄存器在编译的时候,会转换成对应目标平台的真实指令。

  • PC(Program counter):程序计数器寄存器:作用和x86IP寄存器相同,用来指向CPU下一条需要执行的指令,程序跳转功能便是通过修改此寄存器的值实现。

  • SB(Static base pointer): 全局静态基址指针寄存器,主要用在全局变量中,通过symbol(SB)表示symbol的全局的真实地址。同时可以通过symbol+offset(SB)可以用来表示其符号偏移的内存地址。 比如foo(SB),表示foo的内存地址,foo(SB)+4表示基于foo的内存地址偏移4字节,一般在全局变量、函数定义的时候会使用。

    GLOBL ·count(SB),4      //count表示一个全局地址
    
    DATA ·count+0(SB)/1,1   //通过全局地址+偏移量定义对应地址的值
    DATA ·count+1(SB)/1,2
    DATA ·count+2(SB)/1,3
    DATA ·count+3(SB)/1,$4
    
  • FP(Frame Pointer): 栈帧指针寄存器,一般用在手写汇编语言的时候通过symbol+offset(FP)定位参数和返回值。

  • SP(Stack Pointer):栈指针寄存,一般symbol+offset(SP)的方式来引用函数的局部变量。

由于Go汇编定义的伪寄存器和硬件的寄存器有重名,同时对于Go汇编来说,大多时候都是伪寄存器和硬件寄存器混用,因此重名的寄存器很容易被错误理解。在Go汇编的官方文档中给出了如何区分伪寄存器和硬件寄存器:

On architectures with a hardware register named SP, the name prefix distinguishes references to the virtual stack pointer from references to the architectural SP register. That is, x-8(SP) and -8(SP) are different memory locations: the first refers to the virtual stack pointer pseudo-register, while the second refers to the hardware’s SP register.

也就是包含符号前缀:x-8(SP)的,是伪寄存器。

不包含符号前缀的:-8(SP),是硬件寄存器。

然而需要注意的是,对于通过go tool compile -S命令编译获取的汇编,都是硬件SP。详情参考讨论:about SP

指令

指令用来指示CPU应该如何操作,对于Go汇编来说,指令的格式如下:

instruction src dst

其中:

  • instruction: 指令
  • src:源操作数
  • dst:目标操作数

注意区分源操作数和目标操作数。例如MOV AX,symbol-SP(8)表示将AX寄存器中的内容移动到symbol-SP(8)所指向的地址,而不是将symbol-SP(8)移动到AX寄存器中。

对于Go汇编,需要注意理解的有以下指令:

  • CALL : call主要用来调用子程序,需要注意的是当使用call调用子函数时,会先将call指令的下一条指令的地址存放在栈上,然后再修改程序计数器(x86为IP寄存器,Go汇编为PC寄存器)中的地址,使得CPU去跳转执行子程序。
  • RET: ret指令一般都和call指令配套使用,当子程序执行完毕后,可以调用ret指令进行返回,ret指令主要为取出栈上存储的地址,并将其复制给程序计数器,从而实现了返回的效果。
  • SUBQ: 本来SUBQ没什么好说的,但是Go汇编没有PUSH,POP指令,而是通过SUBQ SP / ADDQ SP来增加/减小栈帧的大小。这里要注意:SP始终指向栈顶的位置,SP增加,意味着栈顶增加,那么栈的可用空间便随之变大。
  • LEA: 获取目标当前的地址,例如:LEAQ 8(SP), BP8(SP)的地址保存在BP寄存器中。一般来说,一般的指令都是对对应地址的内容进行操作,例如:MOV 8(SP),AX,是将8(SP)对应的内容保存在AX寄存器中,但是LEA指令是针对地址进行操作。

前面说过,golang通过中间层汇编,来屏蔽各个平台的差异,但是Go汇编可能没有全部支持所有的CPU指令,对于没有支持的CPU指令,可以通过Go汇编提供的BYTE命令将真实的机器码填充到对应的位置。

数据类型

查看所有的汇编指令可以发现,对于汇编来说,并没有和高级语言对应的数据类型的概念。汇编指令总的来说所做的功能都是:移动数据和获取地址。对于高级语言所提供的“数据类型(struct)”,仅仅是一种数据排列方式的约定!比如下图:

image-20220701005648123

GLOBL text<>(SB),NOPTR,8
DATA text<>+0(SB)/8,"Hello"

GLOBL ·Data(SB),NOPTR,16

DATA  ·Data+0(SB)/8,text<>(SB)
DATA  ·Data+8(SB)/8,$5

简单了解过string结构的人应该能看出来这是定义了一个string类型。因此对应go的类型声明为:

var Data string

但是,我依然可以将这16字节内容 (64位操作系统)当做其他struct来处理,例如:

type Point struct{
   X int64
   Y int64
}

然后重新声明Data的数据类型:

var Data Point

也是可以正常运行的。

数据类型,仅仅是高级语言对对内存排列的一种约定。

数据单元

在汇编指令中经常会看见”长得比较像”的指令,例如:MOV、 MOVB、 MOVQ 。常见后缀如下:

  • B:Byte 1字节
  • W: Word字,2个字节
  • D: Double 2个字,4个字节
  • Q:Quadword 4个字,8个字节

想要定位一段数据,则需要两个元素:数据的起始地址和数据的结束地址。 或者数据的起始地址以及数据的长度。因此在指令中需要不仅需要指出数据的地址,还要告诉CPU数据的长度。这包含两种情况:

  • 当指令中包含寄存器的时候,则数据的长度可以以寄存器的长度为准,例如:
    • MOV AX [0],那么对于16位的CPU,地址[0]和地址[1]都会被赋值。
    • MOV AL [0],那么对于16位的CPU,只有地址[0]会被赋值。
  • 当指令中不包含寄存器的时候,则需要通过指令+后缀指定,例如:
    • MOVB $11,[0],11占用一个字节,这个指令只会影响第一个字节。
    • MOVW $11,[0],11占用两个字节,这个指令会使得[0][1] 两个字节的值都被覆盖。
    • MOVD $11,[0],11占用四个字节,这个指令会使得[0],[1],[2],[3]4个字节的值都被覆盖

函数调用栈

对于Go汇编来说,函数参数、返回值、局部变量等都是按顺序放在栈上。同时对于Go汇编来说,函数调用栈是caller-save模式。什么意思呢?先明确以下两个概念:

  • caller: 函数调用方,例如函数A调用了函数B,则A被称为caller
  • callee: 函数被调用方,例如函数A调用了函数,则B被称为callee

那么对于caller-save模式来说,在caller调用callee的时候,caller需要将callee的参数、返回值(不包括局部变量)所需要的栈准备好,并且将参数传递到栈上,然后才能执行CALL callee

对于一个函数,其函数调用栈的结构如图所示:

golang栈结构

  • 从图中左边可以看出来,栈的增长是从高位地址向低位地址增长。这也是为什么增加栈帧的指令是SUBQ SP

  • 同时可以看出来,对于调用一个函数来说,返回值、参数、返回地址都是属于caller,其中返回值和参数所占用的空间需要caller手动分配,return address则是在执行CALL指令的时候,由CALL将下一条指令的地址存放在栈上。

  • 栈存放内容严格按照返回值、参数、返回地址且从右到左的顺序存放。

  • 对于callee的栈来说,主要包含caller BP和局部变量,如果caller还调用了其他函数,则它的栈帧也包含其他函数的返回值、参数、返回地址。

  • 对于caller BP,这是由编译器在编译的时候自动插入的。具体作用官方并没有解释,同时caller BP并不是一个必选项,其需要同时满足以下两个条件才会自动插入caller BP

    • 当前函数的栈帧大小大于0,注意是当前函数,不包括caller frame
    • 下面的方法返回值为true
    func Framepointer_enabled(goos, goarch string) bool {
      return framepointer_enabled != 0 && goarch == "amd64" && goos != "nacl"
    }
    
  • 从右边的寄存器的位置,可以验证前面说的Go汇编给出的伪寄存器的相对位置,可以看到FP起始位置为第一个参数,伪SP则指向caller BP,如果没有caller BP,则指向return address,硬件SP则永远在栈顶。