这篇文档是对于Go编译器套件(6g, 8g, etc.)中不常用的汇编语言的快速预览,涵盖面不是很广泛。

Go的汇编语言基于Plan 9的汇编,Plan 9网站的页面上有详细描述。如果你想编写汇编语言,你应该读这篇文档,虽然它是Plan 9相关的。这边文档总结了汇编的语法,并且描述了使用汇编语言和Go程序交互时的特殊之处。

有一点是很重要的是,Go的汇编中没有直接体现出底层的机器。有些汇编细节能直接对应到机器,但有些不是。这是因为编译器套件在常规过程中不需要汇编语言。取而代之的是,编译器产生二进制的不完整的汇编指令集,链接器会完成它。实际上,链接器做了汇编指令的选择,所以当你看到类似于MOV这样的指令,链接器的实际操作可能不是一个移动指令,也许是清除或者载入。或者可能会根据指令的名字对应到真实的机器指令。总体上,机器相关的指令操作趋向于体现出真实的机器指令,但是一些通用的概念类似于移动内存数据、调用子例程、返回等操作就更抽象了。具体的细节和架构相关,我们为这种不精确性道歉。

汇编程序是生成中间码的一种方法,未完整定义的指令集作为链接器的输入。 如果你想看到特定CPU架构下的汇编指令集,如amd64,在Go标准库的源文件中就有许多例子,在runtime和math/big包中。 或者你还可以参照下面的程序,来检查编译器的汇编输出:

$ cat x.go
package main

func main() {
    println(3)
}
$ go tool 6g -S x.go        # or: go build -gcflags -S x.go

--- prog list "main" ---
0000 (x.go:3) TEXT    main+0(SB),$8-0
0001 (x.go:3) FUNCDATA $0,gcargs·0+0(SB)
0002 (x.go:3) FUNCDATA $1,gclocals·0+0(SB)
0003 (x.go:4) MOVQ    $3,(SP)
0004 (x.go:4) PCDATA  $0,$8
0005 (x.go:4) CALL    ,runtime.printint+0(SB)
0006 (x.go:4) PCDATA  $0,$-1
0007 (x.go:4) PCDATA  $0,$0
0008 (x.go:4) CALL    ,runtime.printnl+0(SB)
0009 (x.go:4) PCDATA  $0,$-1
0010 (x.go:5) RET     ,
...

FUNCDATA和PCDATA指令用来包含一些垃圾收集器需要的信息。它们由编译器产生。

符号

有些符号,例如PC,R0和SP,是预定义的并且是对一个寄存器的引用。 另外还有两种预定义的符号,SB(static base)和FP(frame pointer)。 所有用户定义的符号,除了标签跳转之外,都是对伪寄存器的offsets操作。

SB伪寄存器可以想象成内存的地址,所以符号foo(SB)是一个由foo这个名字代表的内存地址。这种形式一般用来命名全局函数和数据。给名字增加一个<>符号,就像foo<>(SB),会让这个名字只有在当前文件可见,就像在C文件中预定义的static。

FP伪寄存器是一个虚拟的帧指针,用来指向函数的参数。编译器维护了一个虚拟的栈指针,使用对伪寄存器的offsets操作的形式,指向栈上的函数参数。 于是,0(FP)就是第一个参数,8(FP)就是第二个(64位机器),以此类推。 当用这种方式引用函数参数时,可以很方便的在符号前面加上一个名称,就像first_arg+0(FP)和second_arg+8(FP)。有些汇编程序强制使用这种约定,禁止单一的0(FP)和8(FP)。在使用Go标准定义的汇编函数中,go vet会检查参数的名字和它们的匹配范围。 在32位系统上,一个64位值的高32和低32位表示为增加_lo和_hi这个两个后缀到一个名称,就像arg_lo+0(FP)或者arg_hi+4(FP)。如果一个Go原型函数没有命名它的结果,期待的名字将会被返回。

SP伪寄存器是一个虚拟的栈指针,用来指向栈帧本地的变量和为函数调用准备参数。它指向本地栈帧的顶部,所以一个对栈帧的引用必须是一个负值且范围在[-framesize:0]之间,例如: x-8(SP),y-4(SP),以此类推。在CPU架构中,存在一个真实的寄存器SP,虚拟的栈寄存器和真实的SP寄存器的区别在于名字的前缀上。就是说,x-8(SP)和-8(SP)是不同的内存地址:前者是引用伪栈指针寄存器,但后者是硬件中真实存在的SP寄存器。

指令、寄存器和汇编指令始终使用大写字母表示,提醒你汇编语言编程是非常令人担忧的。(例外:在ARM平台下,代表当前goroutine的g寄存器被重新命名。)

在Go对象文件和二进制文件中,符号的完整名字是包的路径加上一个句点:fmt.Printf或math/rand.Int。但是汇编器会把句点和斜杠当做标点符号来对待,这些字符不能当做符号的标识符。取而代之的是,允许在汇编程序中使用中点字符(Unicode字符00B7)和除法斜杠(原文中是division slash,Unicode字符2215,区别于forward slash)当做标识符并且把它们重写成纯句点和斜杠。 在汇编语言的源文件中,上面的符号写成fmt·Printf和math∕rand.Int。 通过在编译时使用-S标志看到的汇编代码列表中直接显示了句点和斜杠,而不是在汇编程序中需要的Unicode替代字符(指上面的两个特殊Unicode字符)。

大部分手写的汇编文件中,不要在符号名中包含完整的包路径,因为链接器会在任何以句点开头的名字前面插入当前对象文件的路径:在math/rand包的汇编源文件中,rand包的Int函数被当做了·Int来引用。这种便捷性避免了需要在自身的源代码中硬编码导入路径,可以让代码从一个地方移动到另一个地方时变得更容易。

指令

汇编程序中使用多种指令绑定文本和数据到符号名。举个例子,下面有一个简单但是完整的函数定义。TEXT指令声明了符号runtime·profileloop,指令紧接在类似于函数的主体中。TEXT块的最后必须是某种形式的跳转,通常是一个RET(伪)指令。(如果没有,链接器会追加一个跳转到块自身的指令,TEXT块中没有fallthrough) 符号的后面,参数是标志和栈帧的大小,是一个常量(但是看下面的代码):

TEXT runtime·profileloop(SB),NOSPLIT,$8
    MOVQ    $runtime·profileloop1(SB), CX
    MOVQ    CX, 0(SP)
    CALL    runtime·externalthreadhandler(SB)
    RET

这个函数的栈帧大小为8字节(MOVQ CX, 0(SP)操作栈指针),没有参数

一般情况下,栈帧的大小跟在参数的大小之后,由一个减法符号分隔。(它不是减号,只是特殊的语法) 栈帧大小是$24-8描述了函数有24字节的栈帧并且需要一个8字节的参数,存在于调用者的栈帧中。如果没有为TEXT指定NOSPLIT标志,必须提供参数大小。在使用Go标准定义的汇编函数中,go vet会检查参数大小是否正确。

注意符号名是使用中点来分割组件的,并且被定义为从伪寄存器SB开始的一个offsets。在Go源码的runtime包中,使用简称profileloop来调用。

全局数据符号使用初始化的一系列DATA指令来定义,并且跟在一个GLOBAL指令之后。每个DATA指令初始化一块指定的内存区域。没有明确初始化的内存区域会被置为零。标准的DATA指令形式为:

DATA    symbol+offset(SB)/width, value

这样就初始化了symbol,内存在指定的offset处,带有指定的width和给定的value。一个symbol中的DATA指令必须是逐渐增长的offsets。

GLOBAL指令将一个symbol声明为全局的。参数是可选的标志和需要声明为全局的数据的大小,并会初始化为零值,除非DATA指令中已经初始化它。GLOBAL指令必须跟在对应的DATA指令之后。

举例:

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位table含有4字节的整数值。 并且声明了runtime·tlsoffset,一个4字节并且明确被零值初始化的值,其中不含有指针。

指令可以含有一个或者两个参数。如果有两个参数,第一个是比特掩码的标志,可以写成数字的表达式,多个掩码之间可以相加或者做逻辑或运算,或者可以写成友好可读的形式。这些值定义在头文件textflag.h中:

  • NOPROF = 1 (TEXT项使用.) 不优化NOPROF标记的函数。这个标志已废弃。

  • DUPOK = 2 在二进制文件中允许一个符号的多个实例。链接器会选择其中之一。

  • NOSPLIT = 4 (TEXT项使用.) 不插入预先检测是否将栈空间分裂的代码。程序的栈帧中,如果调用任何其他代码都会增加栈帧的大小,必须在栈顶留出可用空间。用来保护处理栈空间分裂的代码本身。

  • RODATA = 8 (DATA和GLOBAL项使用.) 将这个数据放在只读的块中。

  • NOPTR = 16 这个数据不包含指针所以就不需要垃圾收集器来扫描。

  • WRAPPER = 32 (For TEXT items.) This is a wrapper function and should not count as disabling recover.

协调Runtime

为了使垃圾收集正确运行,runtime必须知道在全局数据和大多数栈帧中指针的位置。Go的编译器在编译Go源文件的时候生成这些信息,但是在汇编程序中必须明确定义这些信息。

带有NOPTR标志的数据符号,不包含指向runtime分配的数据的指针。 带有RODATA标志的数据符号,是在只读内存中分配的,并且被看做是明确定义的NOPTR类型的数据。总的大小小于一个指针大小的数据符号,也被看做是明确定义的NOPTR类型。不能在汇编语言中定义包含指针的符号;取而代之的是,符号必须定义在Go源文件中。汇编源文件中依然可以使用名字引用一个符号,即使这个符号没有使用DATA和GLOBAL指令定义。一个很好的通用规则是,在Go代码中定义非只读的数据,而不是在汇编程序中。

每个函数同样需要给出注解,标明在其参数、返回结果和本地栈帧上生存的指针的位置。如果汇编函数没有指针类型的结果并且没有本地栈帧,或者没有调用函数,唯一需要做的是为函数在同名的包中定义一个Go函数原型。在更复杂的情况下,需要明确的注释出。这些注释使用在头文件funcdata.h中定义的伪指令。

如果一个函数没有参数并且没有返回结果,就可以忽略指针信息。这可以通过在TEXT指令中使用参数大小$n-0指出。否则,Go原文件中的Go原型函数必须提供指针的信息,即使汇编函数不是直接被Go代码调用的。(这个原型会让go vet检查参数引用。) 在函数的开头,参数都假设是已经被初始化的,但是函数的返回结果会假设是未初始化的。如果在执行CALL指令时,结果中HOLD住一个指针,函数应该在开头就将返回结果初始化为零值,并且接着执行伪指令GO_RESULTS_INITIALIZED。这个指令记录了当前返回结果已经被初始化,并且在当栈帧转移和垃圾收集的时候扫描返回结果。非常具有代表性的是会安排汇编函数不返回指针或者不包含任何CALL指令;在Go标准库中的汇编函数都没有使用GO_RESULTS_INITIALIZED。

如果一个函数没有本地栈帧,就可以忽略指针信息。这可以通过在TEXT指令中使用栈帧大小$0-n指出。如果函数没有包含CALL指令,同样可以忽略指针信息。否则,本地栈帧必须不包含指针(函数没有本地栈帧且含有CALL指令的情况下),汇编中必须通过NO_LOCAL_POINTERS来确认这种情况。因为栈的缩放使用过移动栈来实现的,栈指针可能在函数调用的时候发生改变:甚至栈数据的指针必须不得保持在本地变量。

架构相关的细节

列出某种机器的全部指令和细节是不切实际的。如果想看到某种特定机器的指令,如32位Intel X86,查看对应编辑器的顶层的头文件,这里是8l。就是说,在文件$GOROOT/src/cmd/8l/8.out.h中包含了C枚举量,叫做as,是指定架构的汇编器和链接器的机器指令的指令的写法。

enum    as
{
    AXXX,
    AAAA,
    AAAD,
    AAAM,
    AAAS,
    AADCB,
    ...

在上面的代码中每个指令以大写字母A开头,所以AADCB表示ADCB指令(和进位字节)。枚举量是按照字母顺序排序的,加上后面的附加内容(AXXX占据了第0个位置,被当做一个独立的指令)。对于在实际机器中的编码,这些指令序列什么都不需要改变。再说一遍,这是因为链接器会负责具体的细节。

在前一小节的例子中需要注意的是,数据在指令中的顺序是从左到右: MOVQ $0, CX清除CX。即使在某些架构上顺序是相反的,这种规则也是适用的。

这里有一些对于Go所指的架构的相关的细节的描述。

32位Intel 386

runtime中指向g结构体(goroutine)的指针通过MMU中其他未使用的寄存器来维护(这也是Golang中担心的)。 如果源码中包含了架构相关的头文件,那么汇编器会定义一个OS相关的宏,就像下面这样:

#include "zasm_GOOS_GOARCH.h"

在runtime内部,get_tls宏将g指针载入到它的参数寄存器中,并且g结构体中包含了m指针。使用CX寄存器来载入g和m的指令序列如下:

get_tls(CX)
MOVL    g(CX), AX     // Move g into AX.
MOVL    g_m(AX), BX   // Move g->m into BX.

64位Intel 386(amd64)

访问g和m指针的汇编和386相似,只不过指令中使用MOVQ,而不是MOVL:

get_tls(CX)
MOVQ    g(CX), AX     // Move g into AX.
MOVQ    g_m(AX), BX   // Move g->m into BX.

ARM

寄存器R10和R11由编译器和链接器保留。

R10指向g(goroutine)结构体。在汇编源码中,这个指针必须以g来引用,R10这个名称是不被认可的。

为了让人类和编译器更容易的写汇编代码,ARM的链接器允许通用的寻址形式和像DIV、MOD这样的伪操作,这可能不是使用一个单条的指令可以表现出来的。链接器使用多条指令来实现这些操作,经常使用R11来保存临时的值。在手写的汇编程序中可以使用R11寄存器,但是这样做就需要确认链接器还没有使用R11来实现函数中的其他指令。

当定义一个TEXT段,声明栈帧大小$-4会告诉链接器这个函数是一个leaf function,不需要在入口保存LR寄存器。

leaf function的解释:

Leaf function,A function that does not require a stack frame. A leaf function does not require a function table entry. It cannot call any functions, allocate space, or save any nonvolatile registers. It can leave the stack unaligned while it executes.

名称SP总是会引用在之前提到过的虚拟栈帧。而硬件中的SP寄存器使用R13。