Go 汇编器快速指南

Go 汇编器快速指南

本文档简要概述了 gc Go 编译器使用的汇编语言的特殊形式。本文档不全面。

汇编器基于 Plan 9 汇编器的输入样式,该样式已在其他地方详细记录。如果您打算编写汇编语言,您应该阅读该文档,尽管其中大部分内容是 Plan 9 特定的。本文档总结了该文档中解释的语法和差异,并描述了在编写与 Go 交互的汇编代码时适用的特殊性。

关于 Go 汇编器最重要的一点是,它不是底层机器的直接表示。某些细节与机器精确对应,但有些则不对应。这是因为编译器套件(参见此描述)在通常的流水线中不需要汇编器阶段。相反,编译器操作的是一种半抽象指令集,指令选择部分发生在代码生成之后。汇编器在半抽象形式上工作,因此当您看到像 MOV 这样的指令时,工具链实际为该操作生成的可能根本不是移动指令,也许是清除或加载。或者它可能与具有该名称的机器指令完全对应。通常,机器特定的操作倾向于以它们自己的形式出现,而更一般的概念,如内存移动、子例程调用和返回,则更抽象。细节因架构而异,我们对这种不精确性表示歉意;情况并未得到很好的定义。

汇编器程序是一种解析半抽象指令集描述并将其转换为要输入给链接器的指令的方式。如果您想了解给定架构(例如 amd64)的汇编指令是什么样子,标准库的源代码中有很多示例,例如在 runtimemath/big 包中。您还可以检查编译器作为汇编代码发出的内容(实际输出可能与您在此处看到的有所不同)

$ 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), $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	$2, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
	0x001d 00029 (x.go:4)	PCDATA	$0, $0
	0x001d 00029 (x.go:4)	PCDATA	$1, $0
	0x001d 00029 (x.go:4)	CALL	runtime.printlock(SB)
	0x0022 00034 (x.go:4)	MOVQ	$3, (SP)
	0x002a 00042 (x.go:4)	CALL	runtime.printint(SB)
	0x002f 00047 (x.go:4)	CALL	runtime.printnl(SB)
	0x0034 00052 (x.go:4)	CALL	runtime.printunlock(SB)
	0x0039 00057 (x.go:5)	MOVQ	8(SP), BP
	0x003e 00062 (x.go:5)	ADDQ	$16, SP
	0x0042 00066 (x.go:5)	RET
	0x0043 00067 (x.go:5)	NOP
	0x0043 00067 (x.go:3)	PCDATA	$1, $-1
	0x0043 00067 (x.go:3)	PCDATA	$0, $-1
	0x0043 00067 (x.go:3)	CALL	runtime.morestack_noctxt(SB)
	0x0048 00072 (x.go:3)	JMP	0
...

FUNCDATAPCDATA 指令包含供垃圾收集器使用的信息;它们由编译器引入。

要查看链接后二进制文件中包含的内容,请使用 go tool objdump

$ go build -o x.exe x.go
$ go tool objdump -s main.main x.exe
TEXT main.main(SB) /tmp/x.go
  x.go:3		0x10501c0		65488b0c2530000000	MOVQ GS:0x30, CX
  x.go:3		0x10501c9		483b6110		CMPQ 0x10(CX), SP
  x.go:3		0x10501cd		7634			JBE 0x1050203
  x.go:3		0x10501cf		4883ec10		SUBQ $0x10, SP
  x.go:3		0x10501d3		48896c2408		MOVQ BP, 0x8(SP)
  x.go:3		0x10501d8		488d6c2408		LEAQ 0x8(SP), BP
  x.go:4		0x10501dd		e86e45fdff		CALL runtime.printlock(SB)
  x.go:4		0x10501e2		48c7042403000000	MOVQ $0x3, 0(SP)
  x.go:4		0x10501ea		e8e14cfdff		CALL runtime.printint(SB)
  x.go:4		0x10501ef		e8ec47fdff		CALL runtime.printnl(SB)
  x.go:4		0x10501f4		e8d745fdff		CALL runtime.printunlock(SB)
  x.go:5		0x10501f9		488b6c2408		MOVQ 0x8(SP), BP
  x.go:5		0x10501fe		4883c410		ADDQ $0x10, SP
  x.go:5		0x1050202		c3			RET
  x.go:3		0x1050203		e83882ffff		CALL runtime.morestack_noctxt(SB)
  x.go:3		0x1050208		ebb6			JMP main.main(SB)

常量

尽管汇编器借鉴了 Plan 9 汇编器,但它是一个独立的程序,因此存在一些差异。其中之一是常量求值。汇编器中的常量表达式使用 Go 的运算符优先级进行解析,而不是原始的类 C 优先级。因此 3&1<<2 的结果是 4,而不是 0——它解析为 (3&1)<<2 而不是 3&(1<<2)。此外,常量始终被求值为 64 位无符号整数。因此 -2 不是整数值减二,而是具有相同位模式的无符号 64 位整数。这种区别很少重要,但为了避免歧义,当右操作数的高位被设置时,除法或右移操作将被拒绝。

符号

一些符号,例如 R1LR,是预定义的并引用寄存器。确切的集合取决于架构。

有四个预定义的符号引用伪寄存器。这些不是真实的寄存器,而是工具链维护的虚拟寄存器,例如帧指针。伪寄存器的集合对于所有架构都是相同的

所有用户定义的符号都写成伪寄存器 FP(参数和局部变量)和 SB(全局变量)的偏移量。

可以将 SB 伪寄存器看作是内存的起点,因此符号 foo(SB) 是内存中地址为 foo 的名称。这种形式用于命名全局函数和数据。在名称后添加 <>,如 foo<>(SB),使该名称仅在当前源文件中可见,类似于 C 文件中的顶级 static 声明。在名称后添加偏移量指的是从符号地址开始的该偏移量,因此 foo+4(SB)foo 开头之后的四个字节。

FP 伪寄存器是一个虚拟帧指针,用于引用函数参数。编译器维护一个虚拟帧指针,并引用栈上作为该伪寄存器偏移量的参数。因此,0(FP) 是函数的第一个参数,8(FP) 是第二个参数(在 64 位机器上),依此类推。但是,当以这种方式引用函数参数时,需要在开头放置一个名称,例如 first_arg+0(FP)second_arg+8(FP)。(偏移量的含义——从帧指针的偏移量——与其在 SB 中的使用不同,在 SB 中它是从符号的偏移量。)汇编器强制执行此约定,拒绝简单的 0(FP)8(FP)。实际名称在语义上无关紧要,但应用于记录参数的名称。值得强调的是,FP 始终是一个伪寄存器,而不是硬件寄存器,即使在具有硬件帧指针的架构上也是如此。

对于带有 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) 等。

在具有名为 SP 的硬件寄存器的架构上,名称前缀将对虚拟堆栈指针的引用与对架构 SP 寄存器的引用区分开来。也就是说,x-8(SP)-8(SP) 是不同的内存位置:前者引用虚拟堆栈指针伪寄存器,而后者引用硬件的 SP 寄存器。

在 SP 和 PC 传统上是物理编号寄存器别名的机器上,在 Go 汇编器中,SP 和 PC 的名称仍然被特殊对待;例如,对 SP 的引用需要一个符号,就像 FP 一样。要访问实际的硬件寄存器,请使用真正的 R 名称。例如,在 ARM 架构上,硬件 SP 和 PC 可以作为 R13 和 R15 访问。

分支和直接跳转总是写成 PC 的偏移量,或者写成跳转到标签

label:
	MOVW $0, R1
	JMP label

每个标签仅在其定义的函数内可见。因此,文件中允许多个函数定义和使用相同的标签名称。直接跳转和调用指令可以以文本符号为目标,例如 name(SB),但不能以符号偏移量为目标,例如 name+4(SB)

指令、寄存器和汇编器指令始终使用大写字母,以提醒您汇编编程是一项充满挑战的工作。(例外:ARM 上的 g 寄存器重命名。)

在 Go 对象文件和二进制文件中,符号的完整名称是包路径后跟一个点和符号名称:fmt.Printfmath/rand.Int。因为汇编器的解析器将点和斜杠视为标点符号,所以这些字符串不能直接用作标识符名称。相反,汇编器允许在标识符中使用中点字符 U+00B7 和除号斜杠 U+2215,并将其重写为普通点和斜杠。在汇编器源文件中,上面的符号写为 fmt·Printfmath∕rand·Int。编译器在使用 -S 标志时生成的汇编列表直接显示点和斜杠,而不是汇编器所需的 Unicode 替换。

大多数手写的汇编文件不包含符号名称中的完整包路径,因为链接器会将当前对象文件的包路径插入到任何以点开头的名称的开头:在 math/rand 包实现中的汇编源文件中,该包的 Int 函数可以被称为 ·Int。这个约定避免了在自己的源代码中硬编码包的导入路径的需要,使得代码更容易从一个位置移动到另一个位置。

指令

汇编器使用各种指令将文本和数据绑定到符号名称。例如,这是一个简单的完整函数定义。TEXT 指令声明符号 runtime·profileloop,并且后面的指令构成函数体。TEXT 块中的最后一条指令必须是某种跳转,通常是 RET(伪)指令。(如果不是,链接器将附加一个跳转到自身的指令;TEXT 中没有直通。)在符号之后,参数是标志(见下文)和帧大小,一个常量(但见下文)

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

在一般情况下,帧大小后面是参数大小,由减号分隔。(这不是减法,只是特殊的语法。)帧大小 $24-8 表示该函数有一个 24 字节的帧,并用 8 字节的参数调用,这些参数位于调用者的帧上。如果 TEXT 没有指定 NOSPLIT,则必须提供参数大小。对于带有 Go 原型的汇编函数,go vet 将检查参数大小是否正确。

请注意,符号名称使用一个中间点来分隔组件,并指定为从静态基伪寄存器 SB 的偏移量。该函数将从包 runtime 的 Go 源代码中调用,使用简单名称 profileloop

全局数据符号由一系列初始化 DATA 指令后跟一个 GLOBL 指令定义。每个 DATA 指令初始化相应内存的一部分。未明确初始化的内存将清零。DATA 指令的一般形式是

DATA	symbol+offset(SB)/width, value

它使用给定值初始化给定偏移量和宽度的符号内存。给定符号的 DATA 指令必须以递增的偏移量写入。

GLOBL 指令声明一个符号为全局。参数是可选标志和声明为全局数据的大小,除非 DATA 指令已初始化,否则其初始值将全部为零。GLOBL 指令必须遵循任何相应的 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 字节表,包含 4 字节整数值,并声明 runtime·tlsoffset,一个 4 字节的隐式清零变量,不包含指针。

指令可以有一个或两个参数。如果有两个,第一个是标志的位掩码,可以写成数字表达式,相加或相或,或者可以符号化设置以便于人类理解。它们的值在标准 #include 文件 textflag.h 中定义,如下所示

特殊指令

PCALIGN 伪指令用于指示下一个指令应通过使用空操作指令填充来对齐到指定边界。

它目前支持 arm64、amd64、ppc64、loong64 和 riscv64。例如,下面的 MOVD 指令的开始对齐到 32 字节

PCALIGN $32
MOVD $2, R0

与 Go 类型和常量的交互

如果一个包有任何 .s 文件,那么 go build 将指示编译器发出一个名为 go_asm.h 的特殊头文件,.s 文件随后可以 #include。该文件包含 Go 结构字段偏移量、Go 结构类型大小以及当前包中定义的大多数 Go const 声明的符号 #define 常量。Go 汇编应避免对 Go 类型的布局做出假设,而应使用这些常量。这提高了汇编代码的可读性,并使其对 Go 类型定义或 Go 编译器使用的布局规则中的数据布局更改保持健壮。

常量形式为 const_name。例如,给定 Go 声明 const bufSize = 1024,汇编代码可以引用此常量的值为 const_bufSize

字段偏移量采用 type_field 形式。结构体大小采用 type__size 形式。例如,考虑以下 Go 定义

type reader struct {
	buf [bufSize]byte
	r   int
}

汇编可以引用此结构体的大小为 reader__size,并且两个字段的偏移量为 reader_bufreader_r。因此,如果寄存器 R1 包含指向 reader 的指针,汇编可以引用 r 字段为 reader_r(R1)

如果这些 #define 名称中有任何一个含糊不清(例如,一个带有 _size 字段的结构体),#include "go_asm.h" 将因“宏重定义”错误而失败。

运行时协调

为了垃圾收集器正确运行,运行时必须知道所有全局数据和大多数栈帧中指针的位置。Go 编译器在编译 Go 源文件时会发出此信息,但汇编程序必须显式定义它。

标记有 NOPTR 标志(见上文)的数据符号被视为不包含指向运行时分配数据的指针。带有 RODATA 标志的数据符号被分配在只读内存中,因此被视为隐式标记为 NOPTR。总大小小于指针的数据符号也被视为隐式标记为 NOPTR。无法在汇编源文件中定义包含指针的符号;此类符号必须在 Go 源文件中定义。即使没有 DATAGLOBL 指令,汇编源仍然可以通过名称引用该符号。一个好的经验法则是,在 Go 中而不是在汇编中定义所有非 RODATA 符号。

每个函数还需要注释,以提供其参数、结果和局部堆栈帧中活动指针的位置。对于没有指针结果且没有局部堆栈帧或没有函数调用的汇编函数,唯一的要求是在同一包的 Go 源文件中为该函数定义一个 Go 原型。汇编函数的名称不能包含包名称组件(例如,syscall 包中的函数 Syscall 在其 TEXT 指令中应使用名称 ·Syscall,而不是等效的名称 syscall·Syscall)。对于更复杂的情况,需要显式注释。这些注释使用标准 #include 文件 funcdata.h 中定义的伪指令。

如果函数没有参数且没有结果,则可以省略指针信息。这由 TEXT 指令上的参数大小注释 $n-0 表示。否则,即使对于未直接从 Go 调用的汇编函数,也必须通过 Go 源文件中的函数原型提供指针信息。(原型还将允许 go vet 检查参数引用。)在函数开始时,假设参数已初始化,但结果未初始化。如果结果将在调用指令期间持有活动指针,则函数应首先将结果清零,然后执行伪指令 GO_RESULTS_INITIALIZED。此指令记录结果现在已初始化,并应在堆栈移动和垃圾收集期间进行扫描。通常,最好安排汇编函数不返回指针或不包含调用指令;标准库中没有汇编函数使用 GO_RESULTS_INITIALIZED

如果函数没有局部堆栈帧,则可以省略指针信息。这由 TEXT 指令上局部帧大小注释 $0-n 表示。如果函数不包含调用指令,也可以省略指针信息。否则,局部堆栈帧不得包含指针,并且汇编必须通过执行伪指令 NO_LOCAL_POINTERS 来确认这一事实。由于堆栈大小调整是通过移动堆栈来实现的,因此堆栈指针可能在任何函数调用期间更改:即使指向堆栈数据的指针也不得保留在局部变量中。

汇编函数应始终提供 Go 原型,既要为参数和结果提供指针信息,也要让 go vet 检查用于访问它们的偏移量是否正确。

特定于架构的细节

列出每台机器的所有指令和其他详细信息是不切实际的。要查看给定机器(例如 ARM)定义了哪些指令,请查看该架构的 obj 支持库的源代码,该库位于目录 src/cmd/internal/obj/arm 中。在该目录中有一个文件 a.out.go;它包含一个以 A 开头的长常量列表,如下所示

const (
	AAND = obj.ABaseARM + obj.A_ARCHSPECIFIC + iota
	AEOR
	ASUB
	ARSB
	AADD
	...

这是汇编器和链接器已知该架构的指令及其拼写列表。此列表中的每条指令都以大写字母 A 开头,因此 AAND 表示按位与指令 AND(没有前导 A),并以汇编源形式写为 AND。枚举主要按字母顺序排列。(在 cmd/internal/obj 包中定义的与架构无关的 AXXX 表示无效指令)。A 名称的序列与机器指令的实际编码无关。cmd/internal/obj 包负责处理该细节。

386 和 AMD64 架构的指令都列在 cmd/internal/obj/x86/a.out.go 中。

这些架构共享通用寻址模式的语法,例如 (R1)(寄存器间接)、4(R1)(带偏移量的寄存器间接)和 $foo(SB)(绝对地址)。汇编器还支持每个架构特有的一些(不一定是所有)寻址模式。以下各节列出了这些。

前几节示例中显而易见的一个细节是指令中的数据流向从左到右:MOVQ $0, CX 清除 CX。此规则甚至适用于传统表示法使用相反方向的架构。

以下是有关支持架构的关键 Go 特定细节的一些描述。

32 位 Intel 386

运行时指向 g 结构的指针通过 MMU 中一个(就 Go 而言)未使用的寄存器的值来维护。在运行时包中,汇编代码可以包含 go_tls.h,它定义了一个操作系统和架构相关的宏 get_tls 用于访问此寄存器。get_tls 宏接受一个参数,该参数是将 g 指针加载到的寄存器。

例如,使用 CX 加载 gm 的序列如下所示

#include "go_tls.h"
#include "go_asm.h"
...
get_tls(CX)
MOVL	g(CX), AX     // Move g into AX.
MOVL	g_m(AX), BX   // Move g.m into BX.

get_tls 宏也在 amd64 上定义。

寻址模式

当使用编译器和汇编器的 -dynlink-shared 模式时,对固定内存位置(例如全局变量)的任何加载或存储都必须假定会覆盖 CX。因此,为了安全地与这些模式一起使用,汇编源通常应避免使用 CX,除非在内存引用之间。

64 位 Intel 386 (又名 amd64)

在汇编器级别,这两种架构的行为大致相同。64 位版本上访问 mg 指针的汇编代码与 32 位 386 上相同,只是它使用 MOVQ 而不是 MOVL

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

寄存器 BP 是被调用者保存的。当帧大小大于零时,汇编器会自动插入 BP 保存/恢复。允许将 BP 用作通用寄存器,但这可能会干扰基于采样的性能分析。

ARM

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

R10 指向 g(goroutine)结构体。在汇编源代码中,此指针必须称为 g;名称 R10 不被识别。

为了方便人们和编译器编写汇编,ARM 链接器允许使用通用寻址形式和伪操作,例如 DIVMOD,这些可能无法用单个硬件指令表示。它将这些形式实现为多条指令,通常使用 R11 寄存器来保存临时值。手写的汇编可以使用 R11,但这样做需要确保链接器也没有使用它来实现函数中的任何其他指令。

定义 TEXT 时,指定帧大小 $-4 告诉链接器这是一个叶子函数,不需要在入口处保存 LR

名称 SP 始终指代前面描述的虚拟栈指针。对于硬件寄存器,请使用 R13

条件码语法是在指令后附加一个点和一个或两个字母的代码,例如 MOVW.EQ。可以附加多个代码:MOVM.IA.W。代码修饰符的顺序无关紧要。

寻址模式

ARM64

R18 是“平台寄存器”,在 Apple 平台上保留。为了防止意外滥用,该寄存器命名为 R18_PLATFORMR27R28 由编译器和链接器保留。R29 是帧指针。R30 是链接寄存器。

指令修饰符在指令后以句点附加。唯一修饰符是 P(后增量)和 W(预增量):MOVW.PMOVW.W

寻址模式

参考:Go ARM64 汇编指令参考手册

PPC64

此汇编器用于 GOARCH 值 ppc64 和 ppc64le。

参考:Go PPC64 汇编指令参考手册

IBM z/Architecture,又名 s390x

寄存器 R10R11 是保留的。汇编器在汇编某些指令时使用它们来保存临时值。

R13 指向 g (goroutine) 结构。该寄存器必须称为 g;名称 R13 不被识别。

R15 指向堆栈帧,通常应仅使用虚拟寄存器 SPFP 访问。

加载和存储多指令对一系列寄存器进行操作。寄存器范围由起始寄存器和结束寄存器指定。例如,LMG (R9), R5, R7 将分别用 0(R9)8(R9)16(R9) 处的 64 位值加载 R5R6R7

存储-存储指令,如 MVCXC,将长度作为第一个参数写入。例如,XC $8, (R9), (R9) 将清除 R9 中指定地址的八个字节。

如果向量指令将长度或索引作为参数,则它将是第一个参数。例如,VLEIF $1, $16, V2 将值 16 加载到 V2 的索引 1 中。使用向量指令时应注意确保它们在运行时可用。要使用向量指令,机器必须同时具有向量功能(功能列表中的第 129 位)和内核支持。如果没有内核支持,向量指令将无效(它将等同于 NOP 指令)。

寻址模式

MIPS, MIPS64

通用寄存器名为 R0R31,浮点寄存器为 F0F31

R30 保留用于指向 gR23 用作临时寄存器。

TEXT 指令中,MIPS 的帧大小 $-4 或 MIPS64 的帧大小 $-8 指示链接器不要保存 LR

SP 指的是虚拟堆栈指针。对于硬件寄存器,请使用 R29

寻址模式

GOMIPS 环境变量的值(hardfloatsoftfloat)通过预定义 GOMIPS_hardfloatGOMIPS_softfloat 提供给汇编代码。

GOMIPS64 环境变量的值(hardfloatsoftfloat)通过预定义 GOMIPS64_hardfloatGOMIPS64_softfloat 提供给汇编代码。

不支持的操作码

汇编器旨在支持编译器,因此并非所有硬件指令都为所有架构定义:如果编译器不生成它,它可能就不存在。如果您需要使用缺失的指令,有两种方法。一种是更新汇编器以支持该指令,这很简单,但只有当该指令可能再次使用时才值得。对于简单的一次性情况,可以使用 BYTEWORD 指令将显式数据放入 TEXT 中的指令流。以下是 386 运行时定义 64 位原子加载函数的方式。

// uint64 atomicload64(uint64 volatile* addr);
// so actually
// void atomicload64(uint64 *res, uint64 volatile *addr);
TEXT runtime·atomicload64(SB), NOSPLIT, $0-12
	MOVL	ptr+0(FP), AX
	TESTL	$7, AX
	JZ	2(PC)
	MOVL	0, AX // crash with nil ptr deref
	LEAL	ret_lo+4(FP), BX
	// MOVQ (%EAX), %MM0
	BYTE $0x0f; BYTE $0x6f; BYTE $0x00
	// MOVQ %MM0, 0(%EBX)
	BYTE $0x0f; BYTE $0x7f; BYTE $0x03
	// EMMS
	BYTE $0x0F; BYTE $0x77
	RET