转载请注明出处:www.huamo.online
字节杭州 求贤若渴:
Go汇编语言快速指导
Go汇编语言不是常见的汇编格式,它是Go编译器专用的。本文对其进行了简要的介绍,并不是全面文档。
Go汇编语言基于Plan9汇编语言的输入风格设计,后者的详细描述在这里。本文讲述了Go汇编语法的摘要,以及一些和Plan9汇编的不同,还描述了Go汇编与Go语言交互时所适用的特性。
关于Go汇编程序最重要的一点是,它并不是底层机器码的直接表达。有一些细节精确映射了机器层面,而有些并没有。这是因为编译器套件(在这里有介绍)在普通管道中并不需要传递汇编程序。取而代之的,编译器是在一种半抽象指令集上工作,并且在代码生成之后还会发生部分的指令选择。所以当你看到一个像MOV
这样的指令时,背后的工具链实际为这个操作生成的指令也许根本不是移动指令,有可能是clear
或者load
,也有可能它就直接对应机器指令相同的名字。一般来说,特定于机器的操作往往表里如一,而更通用的概念,例如内存移动和子程序调用以及返回则偏向于抽象指令。细节因机器架构而异,没有明确的定义。
汇编程序会将半抽象的指令集描述解析为待输入到链接器的指令。如果你想看看给定架构上的汇编指令是什么样子,例如amd64
,在标准库源码里就有很多这样的例子。例如runtime
和math/big
。除了这里,你还可以自己实验编译器发射出的汇编代码(实际输出可能和这里展示的不一样):
1 | $ cat x.go |
FUNCDATA
和PCDATA
指令包含了垃圾回收器需要用到的信息,编译器会专门介绍它们。
常量
在常量计算上,Go汇编和Plan9汇编有所不同。Go汇编的常量表达式依据Go语言的运算符优先级计算,而不是原始的那种类C的优先级标准。 因此3&1<<2
结果是4而不是0,因为它被解析为(3&1)<<2
而不是3&(1<<2)
。而且,常量总是被当做64位无符号整数计算。因此-2
不是整型值减2,而是相同位编排的无符号64位整型。这些不同无关紧要,但为了避免模糊不清,如果右操作数的高位已经被设置,那么除法或右移操作是会被拒绝的。
符号
有些符号,例如R1
或LR
,都是提前定义好并代表了寄存器。其确定的集合依赖机器架构。
有4个预定义好的符号指向了伪寄存器。它们并不是真正的寄存器,而是由工具链维护的虚拟寄存器,例如帧指针。伪寄存器集合对于所有的架构都是一样的:
FP
:帧指针:指示参数和本地变量PC
:程序计数器:跳转和分支SB
:静态基指针:指示全局符号SP
:栈指针:总是标识栈顶
所有用户定义的符号都是作为伪寄存器FP
和SB
的偏移量进行写操作。
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
在机器层面,SP
和PC
通常都是一个物理带编号名称的寄存器别名,在Go汇编程序中,SP
和PC
依然是被特殊对待的伪寄存器。例如,想要指向Go汇编的SP
,就需要一个名字,很像FP
。而要访问真实的物理寄存器,则使用真实的R
名称。例如,在ARM
架构上,硬件SP
使用R13
访问,硬件PC
使用R15
访问。
分支和直接跳转通常以PC
的偏移量书写,或者直接跳到指定标签。
1 | label: |
每个label
只在它被定义的函数内部可见。因此允许在一个文件当中多个函数定义使用同一个名字的label
。直接跳转和call
命令可以以文本符号作为目标,例如name(SB)
,但不能是符号的偏移,例如name+4(SB)
。
指令,寄存器,以及汇编命令总是以大写形式出现,以提醒你汇编编程是一项极其艰巨的任务(…哈哈哈…)。
在Go目标文件(object file
)和二进制文件中,一个符号的全名是包路径后跟句点再加符号名称:fmt.Printf
或者math/rand.Int
。因为汇编程序的解析器会将句点和斜杠视为标点符号,所以他们并不能直接用作标识符名称。代替方案是,汇编程序允许在标识符中使用U+00B7
代替.
,使用U+2215
代替/
,并把它们翻译为普通句点和斜杠。在一个汇编源码文件中,上面的符号写为fmt.Printf
和math/rand.Int
。编译器在使用-S
标志时生成的程序及列表直接显示句点和斜杠,而不是汇编程序所需的Unicode
替代。
大多数手写的汇编文件都不会在符号名字中包含完整的包路径,因为链接器会对所有以.
开头的任何名称前面,插入当前目标文件的包路径:例如在math
包下的汇编源文件,包中的Asin()
函数都可以被汇编源码引用为.Asin()
。这个约定避免了在汇编源码中硬编码包的导入路径,从而可以更方便的将代码移动位置。
命令
汇编程序使用多种命令将文本和数据绑定到符号名称上。例如这里有一个简单的完整函数定义。
1 | TEXT runtime·profileloop(SB),NOSPLIT,$8 |
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 | DATA divtab<>+0x00(SB)/4, $0xf4f8fcff |
含义为:声明并初始化了divtab<>
,一个只读的,64字节列表,存储4字节的整型元素;又声明了runtime.tlsoffset
,一个4字节的,隐含零值的,不包含指针的变量。
命令有可能有一到两个参数,如果有2个,那么第一个就是标志(flags)的位掩码,可以写为数字表达式,所有标志的细节在runtime/textflag.h
文件中有详细说明
#define NOSPLIT 4
:不插入前置代码以检查栈是否需要被分裂。用来保护那些自己在代码中做栈分裂的协程。
运行时协调(Runtime Coordination)
为了垃圾回收器能正确运行,运行时必须知道所有全局数据中的,和大多数栈帧中的指针地址。Go编译器在编译Go源程序时会发射这些信息,但是汇编程序必须要显式定义它们。
如果一个数据符号被标记为NOPTR
,就会被认为不包含指向运行时分配的数据的指针。如果一个符号被标记为RODATA
,它就会被分配在只读内存区域因此也等于隐式对待为NOPTR
。如果一个符号总共内存大小还小于一个指针的大小,那么它也被隐式对待为NOPTR
。在一个汇编源码中不可能定义一个包含指针的符号,这样的符号必须在Go代码中定义。汇编代码即使没有DATA
和GLOBL
命令也能根据名称引用符号。一个好的经验法则是在Go代码中定义所有非只读的符号,而不是在汇编代码中定义。
特定于机器架构的细节
参考链接
转载请注明出处:www.huamo.online