golang汇编语言

转载请注明出处:www.huamo.online

Go汇编语言快速指导

Go汇编语言不是常见的汇编格式,它是Go编译器专用的。本文对其进行了简要的介绍,并不是全面文档。

Go汇编语言基于Plan9汇编语言的输入风格设计,后者的详细描述在这里。本文讲述了Go汇编语法的摘要,以及一些和Plan9汇编的不同,还描述了Go汇编与Go语言交互时所适用的特性。

关于Go汇编程序最重要的一点是,它并不是底层机器码的直接表达。有一些细节精确映射了机器层面,而有些并没有。这是因为编译器套件(在这里有介绍)在普通管道中并不需要传递汇编程序。取而代之的,编译器是在一种半抽象指令集上工作,并且在代码生成之后还会发生部分的指令选择。所以当你看到一个像MOV这样的指令时,背后的工具链实际为这个操作生成的指令也许根本不是移动指令,有可能是clear或者load,也有可能它就直接对应机器指令相同的名字。一般来说,特定于机器的操作往往表里如一,而更通用的概念,例如内存移动和子程序调用以及返回则偏向于抽象指令。细节因机器架构而异,没有明确的定义。

汇编程序会将半抽象的指令集描述解析为待输入到链接器的指令。如果你想看看给定架构上的汇编指令是什么样子,例如amd64,在标准库源码里就有很多这样的例子。例如runtimemath/big。除了这里,你还可以自己实验编译器发射出的汇编代码(实际输出可能和这里展示的不一样):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ cat x.go
package main

func main() {
println(3)
}

$ GOOS=linux GOARCH=amd64 go tool compile -S x.go # or: go build -gcflags -S x.go
"".main STEXT size=74 args=0x0 locals=0x10
0x0000 00000 (x.go:3) TEXT "".main(SB), ABIInternal, $16-0
0x0000 00000 (x.go:3) MOVQ (TLS), CX
0x0009 00009 (x.go:3) CMPQ SP, 16(CX)
0x000d 00013 (x.go:3) JLS 67
0x000f 00015 (x.go:3) SUBQ $16, SP
0x0013 00019 (x.go:3) MOVQ BP, 8(SP)
0x0018 00024 (x.go:3) LEAQ 8(SP), BP
0x001d 00029 (x.go:3) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d 00029 (x.go:3) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d 00029 (x.go:3) FUNCDATA $3, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d 00029 (x.go:4) PCDATA $2, $0
0x001d 00029 (x.go:4) PCDATA $0, $0
...

FUNCDATAPCDATA指令包含了垃圾回收器需要用到的信息,编译器会专门介绍它们。

常量

在常量计算上,Go汇编和Plan9汇编有所不同。Go汇编的常量表达式依据Go语言的运算符优先级计算,而不是原始的那种类C的优先级标准。 因此3&1<<2结果是4而不是0,因为它被解析为(3&1)<<2而不是3&(1<<2)。而且,常量总是被当做64位无符号整数计算。因此-2不是整型值减2,而是相同位编排的无符号64位整型。这些不同无关紧要,但为了避免模糊不清,如果右操作数的高位已经被设置,那么除法或右移操作是会被拒绝的。

符号

有些符号,例如R1LR,都是提前定义好并代表了寄存器。其确定的集合依赖机器架构。

有4个预定义好的符号指向了伪寄存器。它们并不是真正的寄存器,而是由工具链维护的虚拟寄存器,例如帧指针。伪寄存器集合对于所有的架构都是一样的:

  • FP:帧指针:指示参数和本地变量

  • PC:程序计数器:跳转和分支

  • SB:静态基指针:指示全局符号

  • SP:栈指针:总是标识栈顶

所有用户定义的符号都是作为伪寄存器FPSB的偏移量进行写操作。

SB伪寄存器可以想象成内存的原点,所以类似于foo(SB)这种符号的名字foo就被当做在内存中的地址。这种形式用于命名全局函数和数据。在名字后面添加<>,例如foo<>(SB)会使得这个名字只对当前源文件可见,有点像在C文件中一个顶级static声明。如果在名字后面添加了一个偏移量,指的是符号地址偏移几个字节后的地址,例如foo+4(SB)指示的是从foo位置开始加4个字节的地址。

FP伪寄存器是一个虚拟帧指针用来指示方法参数。编译器维护一个虚拟帧指针,并用伪寄存器偏移量来指示栈中的变量。因此0(FP)就是方法中第1个参数,8(FP)就是第2个参数(在64位机器上讨论),以此类推。然而,当用这种方式来指示方法参数时,必须要在刚开始放一个名字,例如first_arg+0(FP)second_arg+8(FP)。汇编程序强制这种规则,它会拒绝0(FP)8(FP)这种简单写法。实际名字是语义无关的,但应该用来记录参数的名字。需要强调的是,FP总是指一个伪寄存器,并不是硬件寄存器,即使有些机器架构上有一个硬件帧指针寄存器。这一点和SP完全不一样

这种方式和上一段的SB偏移有所不同,foo+4(SB)指的是相对于foo地址的偏移4个字节,而arg+4(FP)指的却是相对于帧指针FP地址的偏移4个字节。切记!**

对于Go原始类型参数的汇编方法,go vet会检查参数名字和偏移量是否匹配。在32位系统上,一个64位的值的低32位和高32位会分别被加上_lo_hi后缀,就像arg_lo+0(FP)arg_hi+4(FP)这样。如果一个Go原始类型在返回时没有被命名,那么预期的汇编名字是ret

SP伪寄存器是一个虚拟栈指针,用来指示本地帧变量以及为了函数调用准备的变量。它指向本地栈帧的顶部,所以所有的引用都应该使用负值偏移量,范围为[-framesize, 0):例如x-8(SP), y-4(SP)等等。这里的偏移规则和FP一样。

在具有名为SP的硬件寄存器的架构上,名称前缀被用来区分是虚拟栈指针的应用还是架构SP寄存器的应用。也就是说,x-8(SP)-8(SP)是不同的内存地址:第一个是指虚拟栈指针伪寄存器SP,第2个是指硬件寄存器SP

在机器层面,SPPC通常都是一个物理带编号名称的寄存器别名,在Go汇编程序中,SPPC依然是被特殊对待的伪寄存器。例如,想要指向Go汇编的SP,就需要一个名字,很像FP。而要访问真实的物理寄存器,则使用真实的R名称。例如,在ARM架构上,硬件SP使用R13访问,硬件PC使用R15访问。

分支和直接跳转通常以PC的偏移量书写,或者直接跳到指定标签。

1
2
3
label:
MOVW $0, R1
JMP label

每个label只在它被定义的函数内部可见。因此允许在一个文件当中多个函数定义使用同一个名字的label。直接跳转和call命令可以以文本符号作为目标,例如name(SB),但不能是符号的偏移,例如name+4(SB)

指令,寄存器,以及汇编命令总是以大写形式出现,以提醒你汇编编程是一项极其艰巨的任务(…哈哈哈…)。

在Go目标文件(object file)和二进制文件中,一个符号的全名是包路径后跟句点再加符号名称fmt.Printf或者math/rand.Int。因为汇编程序的解析器会将句点和斜杠视为标点符号,所以他们并不能直接用作标识符名称。代替方案是,汇编程序允许在标识符中使用U+00B7代替.,使用U+2215代替/,并把它们翻译为普通句点和斜杠。在一个汇编源码文件中,上面的符号写为fmt.Printfmath/rand.Int。编译器在使用-S标志时生成的程序及列表直接显示句点和斜杠,而不是汇编程序所需的Unicode替代。

大多数手写的汇编文件都不会在符号名字中包含完整的包路径,因为链接器会对所有以.开头的任何名称前面,插入当前目标文件的包路径:例如在math包下的汇编源文件,包中的Asin()函数都可以被汇编源码引用为.Asin()。这个约定避免了在汇编源码中硬编码包的导入路径,从而可以更方便的将代码移动位置。

命令

汇编程序使用多种命令将文本和数据绑定到符号名称上。例如这里有一个简单的完整函数定义。

1
2
3
4
5
TEXT runtime·profileloop(SB),NOSPLIT,$8
MOVQ $runtime·profileloop1(SB), CX
MOVQ CX, 0(SP)
CALL runtime·externalthreadhandler(SB)
RET

TEXT命令声明了符号runtime.profileloop,以及后续的指令构成了整个函数体。在一个TEXT块中的最后一个指令必须是跳转类的,通常是一个RET(伪)指令。(如果没有,链接器会尾加一个跳转到自身的指令,在TEXT块中不存在fallthrough自动贯穿)。在runtime.profileloop符号之后,参数分别是标志NOSPLIT,和帧大小$8,是一个常数。

一般情况下,帧大小后面还跟了一个函数参数大小,以减号-分隔。(这并不是减法,只是一种独特的写法)。例如帧大小$24-8表示该函数有一个24字节的帧,并且被8字节大小的参数调用(包括传入参数和返回值,总共加起来8个字节),这些参数都存在于调用者帧上面。如果TEXT块没有说明NOSPLIT,参数大小必须提供。对于Go原型的汇编函数,go vet会检查参数大小是否正确。

符号名字使用.来分隔,并且名字代表了相对于SB的偏移量。这个函数在runtime包的汇编源码中,所以同一包中的Go程序可以使用简单名称profileloop进行调用。

全局数据符号由一系列的后接GLOBL命令的初始化DATA命令定义,每一个DATA命令初始化一片对应的内存区域。那些没有被明确初始化的内存则为零值。DATA命令的一般形式为

1
DATA	symbol+offset(SB)/width, value

含义是:使用给定的value,对相对于symbol偏移offset的地址,宽度为width字节的内存区域进行初始化。对于一个给定symbol上的一系列DATA指令,初始化时,偏移量必须是递增的。不一定需要单步递增。

GLOBL命令将一个符号声明为全局数据。后面的参数是可选的标志,和符号的数据大小,这个符号的初始值全为0,除非一个DATA指令对它进行了初始化。GLOBL命令必须在对应的DATA命令后面。GLOBL的格式如下

1
GLOBL symbol(SB), [flgs], width

实际例子

1
2
3
4
5
6
7
DATA divtab<>+0x00(SB)/4, $0xf4f8fcff
DATA divtab<>+0x04(SB)/4, $0xe6eaedf0
...
DATA divtab<>+0x3c(SB)/4, $0x81828384
GLOBL divtab<>(SB), RODATA, $64

GLOBL runtime·tlsoffset(SB), NOPTR, $4

含义为:声明并初始化了divtab<>,一个只读的,64字节列表,存储4字节的整型元素;又声明了runtime.tlsoffset,一个4字节的,隐含零值的,不包含指针的变量。

命令有可能有一到两个参数,如果有2个,那么第一个就是标志(flags)的位掩码,可以写为数字表达式,所有标志的细节在runtime/textflag.h文件中有详细说明

  • #define NOSPLIT 4:不插入前置代码以检查栈是否需要被分裂。用来保护那些自己在代码中做栈分裂的协程。

运行时协调(Runtime Coordination)

为了垃圾回收器能正确运行,运行时必须知道所有全局数据中的,和大多数栈帧中的指针地址。Go编译器在编译Go源程序时会发射这些信息,但是汇编程序必须要显式定义它们。

如果一个数据符号被标记为NOPTR,就会被认为不包含指向运行时分配的数据的指针。如果一个符号被标记为RODATA,它就会被分配在只读内存区域因此也等于隐式对待为NOPTR。如果一个符号总共内存大小还小于一个指针的大小,那么它也被隐式对待为NOPTR。在一个汇编源码中不可能定义一个包含指针的符号,这样的符号必须在Go代码中定义。汇编代码即使没有DATAGLOBL命令也能根据名称引用符号。一个好的经验法则是在Go代码中定义所有非只读的符号,而不是在汇编代码中定义。

特定于机器架构的细节

参考链接

转载请注明出处:www.huamo.online