...

Go 汇编器快速指南

A Quick Guide to Go's Assembler

Go 汇编快速指南

This document is a quick outline of the unusual form of assembly language used by the gc suite of Go compilers (6g, 8g, etc.). The document is not comprehensive.

本文档简述了 Go 编译器套件(6g8g 等)中 gc 使用的非一般形式的汇编语言。本文档并不全面。

The assembler is based on the input to the Plan 9 assemblers, which is documented in detail on the Plan 9 site. If you plan to write assembly language, you should read that document although much of it is Plan 9-specific. This document provides a summary of the syntax and describes the peculiarities that apply when writing assembly code to interact with Go.

该汇编器基于 Plan 9 汇编的输入,详情见 Plan 9 网站 上的文档。 尽管它是 Plan 9 的规范,但你若想编写汇编语言,同样需要阅读该文档。本文档提供了语法的概要, 并描述了编写汇编代码与 Go 交互时所要用到的特性。

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 (see this description) needs no assembler pass in the usual pipeline. Instead, the compiler emits a kind of incompletely defined instruction set, in binary form, which the linker then completes. In particular, the linker does instruction selection, so when you see an instruction like MOV what the linker 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.

关于 Go 的汇编,最重要的一点就是它并非底层机器码的直接表示。其中有些细节与机器码精确对应, 有些则不是。这是因为该编译器套件 (见此描述) 并不需要在普通管道中传递汇编,而是由编译器按二进制形式产生一种未完整定义的指令集, 随后它将由链接器补充完整。具体来说,就是指令由链接器选择。因此当你看到像 MOV 这样的指令时,链接器实际为该操作生成的可能完全不是移动指令,也许是清理或加载指令, 也可能刚好对应于同名的机器指令。一般来说,机器特定的操作往往以它们自身出现, 而更一般的概念如内存移动或子程序调用和返回则更抽象。细节随架构而变, 我们为这种笼统的定义致歉,这种情况仍未明确定义。

The assembler program is a way to generate that intermediate, incompletely defined instruction sequence as input for the linker. If you want to see what the instructions look like in assembly for a given architecture, say amd64, there are many examples in the sources of the standard library, in packages such as runtime and math/big. You can also examine what the compiler emits as assembly code:

汇编器程序用于生成中间的、未完整定义的指令序列,它们将作为链接器的输入。 若你想查看给定架构(比如 amd64)指令的汇编形式,可参考标准库中 runtimemath/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     ,
...

The FUNCDATA and PCDATA directives contain information for use by the garbage collector; they are introduced by the compiler.

FUNCDATAPCDATA 命令包含了垃圾回收器所要用到的信息, 它们由编译器引入。

Symbols

符号

Some symbols, such as PC, R0 and SP, are predeclared and refer to registers. There are two other predeclared symbols, SB (static base) and FP (frame pointer). All user-defined symbols other than jump labels are written as offsets to these pseudo-registers.

有些符号,例如 PCR0SP 都是预声明且 引用寄存器。还有两个预声明的符号,即 SB(静态基)和 FP(帧指针)。除跳转标签外,所有用户定义的符号都会写作这些伪寄存器的偏移量。

The SB pseudo-register can be thought of as the origin of memory, so the symbol foo(SB) is the name foo as an address in memory. This form is used to name global functions and data. Adding <> to the name, as in foo<>(SB), makes the name visible only in the current source file, like a top-level static declaration in a C file.

SB 伪寄存器可视作内存的来源,因此符号 foo(SB) 是将名字 foo 作为内存中的地址。

The FP pseudo-register is a virtual frame pointer used to refer to function arguments. The compilers maintain a virtual frame pointer and refer to the arguments on the stack as offsets from that pseudo-register. Thus 0(FP) is the first argument to the function, 8(FP) is the second (on a 64-bit machine), and so on. When referring to a function argument this way, it is conventional to place the name at the beginning, as in first_arg+0(FP) and second_arg+8(FP). Some of the assemblers enforce this convention, rejecting plain 0(FP) and 8(FP). For assembly functions with Go prototypes, go vet will check that the argument names and offsets match. On 32-bit systems, the low and high 32 bits of a 64-bit value are distinguished by adding a _lo or _hi suffix to the name, as in arg_lo+0(FP) or arg_hi+4(FP). If a Go prototype does not name its result, the expected assembly name is ret.

FP 伪寄存器是一个用于引用函数实参的虚拟帧指针,因此 0(FP) 就是函数的第一个实参,而 8(FP) 就是第二个(在64位机器上),依次类推。 当通过这种方式引用一个函数实参时,约定将名字放在前面,就像 first_arg+0(FP)second_arg+8(FP) 这样。有些汇编器强制遵循此约定,拒绝单纯的 0(FP)8(FP)。对于带 Go 原型的汇编函数,go vet 会检查该实参的名称和偏移匹配。

The SP pseudo-register is a virtual stack pointer used to refer to frame-local variables and the arguments being prepared for function calls. It points to the top of the local stack frame, so references should use negative offsets in the range [−framesize, 0): x-8(SP), y-4(SP), and so on. On architectures with a real 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.

SP 伪寄存器是一个用于引用局部栈帧变量的虚拟栈指针,其实参用于函数调用。 它指向局部栈帧的顶端,因此引用需使用区间 [−framesize, 0) 的负值作为偏移量: 如 x-8(SP)y-4(SP)、等等。在真实存在名为 SP 寄存器的架构上,可通过名称前缀来区分引用虚拟栈指针和该架构上的 SP 寄存器。换言之,x-8(SP)-8(SP) 是不同的内存位置。 第一个时引用了虚拟栈指针的伪寄存器,而第二个则引用了硬件上的 SP 寄存器。

Instructions, registers, and assembler directives are always in UPPER CASE to remind you that assembly programming is a fraught endeavor. (Exception: the g register renaming on ARM.)

指令、寄存器和汇编命令总是大写的,以此来提醒你汇编语言需要非常慎重地对待。 (例外:mg 寄存器在ARM上重命名了。)

In Go object files and binaries, the full name of a symbol is the package path followed by a period and the symbol name: fmt.Printf or math/rand.Int. Because the assembler's parser treats period and slash as punctuation, those strings cannot be used directly as identifier names. Instead, the assembler allows the middle dot character U+00B7 and the division slash U+2215 in identifiers and rewrites them to plain period and slash. Within an assembler source file, the symbols above are written as fmt·Printf and math∕rand·Int. The assembly listings generated by the compilers when using the -S flag show the period and slash directly instead of the Unicode replacements required by the assemblers.

在 Go 目标文件和二进制文件中,符号的全名为包路径后跟一个点号和符号名: 如 fmt.Printfmath/rand.Int。由于汇编的解析器将点号 和斜杠视作标点,因此那些字符串不能直接用作标识符明。取而代之,汇编器允许分隔符 U+00B7 和除法斜杠 U+2215 出现在标识符中,并将它们重写为纯粹点号和斜杠。 在汇编源文件中,上面的符号应写作 fmt·Printfmath∕rand·Int. 当使用 -S 标志时,由编译器生成的汇编列表会直接显示点号和斜杠, 而非汇编器需要的 Unicode 替代符。

Most hand-written assembly files do not include the full package path in symbol names, because the linker inserts the package path of the current object file at the beginning of any name starting with a period: in an assembly source file within the math/rand package implementation, the package's Int function can be referred to as ·Int. This convention avoids the need to hard-code a package's import path in its own source code, making it easier to move the code from one location to another.

Most hand-written assembly files do not include the full package path in symbol names, because the linker inserts the package path of the current object file at the beginning of any name starting with a period: in an assembly source file within the math/rand package implementation, the package's Int function can be referred to as ·Int. This convention avoids the need to hard-code a package's import path in its own source code, making it easier to move the code from one location to another.

Directives

The assembler uses various directives to bind text and data to symbol names. For example, here is a simple complete function definition. The TEXT directive declares the symbol runtime·profileloop and the instructions that follow form the body of the function. The last instruction in a TEXT block must be some sort of jump, usually a RET (pseudo-)instruction. (If it's not, the linker will append a jump-to-itself instruction; there is no fallthrough in TEXTs.) After the symbol, the arguments are flags (see below) and the frame size, a constant (but see below):

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

In the general case, the frame size is followed by an argument size, separated by a minus sign. (It's not a subtraction, just idiosyncratic syntax.) The frame size $24-8 states that the function has a 24-byte frame and is called with 8 bytes of argument, which live on the caller's frame. If NOSPLIT is not specified for the TEXT, the argument size must be provided. For assembly functions with Go prototypes, go vet will check that the argument size is correct.

Note that the symbol name uses a middle dot to separate the components and is specified as an offset from the static base pseudo-register SB. This function would be called from Go source for package runtime using the simple name profileloop.

Global data symbols are defined by a sequence of initializing DATA directives followed by a GLOBL directive. Each DATA directive initializes a section of the corresponding memory. The memory not explicitly initialized is zeroed. The general form of the DATA directive is

DATA	symbol+offset(SB)/width, value

which initializes the symbol memory at the given offset and width with the given value. The DATA directives for a given symbol must be written with increasing offsets.

The GLOBL directive declares a symbol to be global. The arguments are optional flags and the size of the data being declared as a global, which will have initial value all zeros unless a DATA directive has initialized it. The GLOBL directive must follow any corresponding DATA directives.

For example,

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

declares and initializes divtab<>, a read-only 64-byte table of 4-byte integer values, and declares runtime·tlsoffset, a 4-byte, implicitly zeroed variable that contains no pointers.

There may be one or two arguments to the directives. If there are two, the first is a bit mask of flags, which can be written as numeric expressions, added or or-ed together, or can be set symbolically for easier absorption by a human. Their values, defined in the standard #include file textflag.h, are:

Runtime Coordination

For garbage collection to run correctly, the runtime must know the location of pointers in all global data and in most stack frames. The Go compiler emits this information when compiling Go source files, but assembly programs must define it explicitly.

A data symbol marked with the NOPTR flag (see above) is treated as containing no pointers to runtime-allocated data. A data symbol with the RODATA flag is allocated in read-only memory and is therefore treated as implicitly marked NOPTR. A data symbol with a total size smaller than a pointer is also treated as implicitly marked NOPTR. It is not possible to define a symbol containing pointers in an assembly source file; such a symbol must be defined in a Go source file instead. Assembly source can still refer to the symbol by name even without DATA and GLOBL directives. A good general rule of thumb is to define all non-RODATA symbols in Go instead of in assembly.

Each function also needs annotations giving the location of live pointers in its arguments, results, and local stack frame. For an assembly function with no pointer results and either no local stack frame or no function calls, the only requirement is to define a Go prototype for the function in a Go source file in the same package. The name of the assembly function must not contain the package name component (for example, function Syscall in package syscall should use the name ·Syscall instead of the equivalent name syscall·Syscall in its TEXT directive). For more complex situations, explicit annotation is needed. These annotations use pseudo-instructions defined in the standard #include file funcdata.h.

If a function has no arguments and no results, the pointer information can be omitted. This is indicated by an argument size annotation of $n-0 on the TEXT instruction. Otherwise, pointer information must be provided by a Go prototype for the function in a Go source file, even for assembly functions not called directly from Go. (The prototype will also let go vet check the argument references.) At the start of the function, the arguments are assumed to be initialized but the results are assumed uninitialized. If the results will hold live pointers during a call instruction, the function should start by zeroing the results and then executing the pseudo-instruction GO_RESULTS_INITIALIZED. This instruction records that the results are now initialized and should be scanned during stack movement and garbage collection. It is typically easier to arrange that assembly functions do not return pointers or do not contain call instructions; no assembly functions in the standard library use GO_RESULTS_INITIALIZED.

If a function has no local stack frame, the pointer information can be omitted. This is indicated by a local frame size annotation of $0-n on the TEXT instruction. The pointer information can also be omitted if the function contains no call instructions. Otherwise, the local stack frame must not contain pointers, and the assembly must confirm this fact by executing the pseudo-instruction NO_LOCAL_POINTERS. Because stack resizing is implemented by moving the stack, the stack pointer may change during any function call: even pointers to stack data must not be kept in local variables.

Architecture-specific details

It is impractical to list all the instructions and other details for each machine. To see what instructions are defined for a given machine, say 32-bit Intel x86, look in the top-level header file for the corresponding linker, in this case 8l. That is, the file $GOROOT/src/cmd/8l/8.out.h contains a C enumeration, called as, of the instructions and their spellings as known to the assembler and linker for that architecture. In that file you'll find a declaration that begins

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

Each instruction begins with a initial capital A in this list, so AADCB represents the ADCB (add carry byte) instruction. The enumeration is in alphabetical order, plus some late additions (AXXX occupies the zero slot as an invalid instruction). The sequence has nothing to do with the actual encoding of the machine instructions. Again, the linker takes care of that detail.

One detail evident in the examples from the previous sections is that data in the instructions flows from left to right: MOVQ $0, CX clears CX. This convention applies even on architectures where the usual mode is the opposite direction.

Here follows some descriptions of key Go-specific details for the supported architectures.

32-bit Intel 386

The runtime pointer to the g structure is maintained through the value of an otherwise unused (as far as Go is concerned) register in the MMU. A OS-dependent macro get_tls is defined for the assembler if the source includes an architecture-dependent header file, like this:

#include "zasm_GOOS_GOARCH.h"

Within the runtime, the get_tls macro loads its argument register with a pointer to the g pointer, and the g struct contains the m pointer. The sequence to load g and m using CX looks like this:

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

64-bit Intel 386 (a.k.a. amd64)

The assembly code to access the m and g pointers is the same as on the 386, except it uses MOVQ rather than MOVL:

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

ARM

The registers R10 and R11 are reserved by the compiler and linker.

R10 points to the g (goroutine) structure. Within assembler source code, this pointer must be referred to as g; the name R10 is not recognized.

To make it easier for people and compilers to write assembly, the ARM linker allows general addressing forms and pseudo-operations like DIV or MOD that may not be expressible using a single hardware instruction. It implements these forms as multiple instructions, often using the R11 register to hold temporary values. Hand-written assembly can use R11, but doing so requires being sure that the linker is not also using it to implement any of the other instructions in the function.

When defining a TEXT, specifying frame size $-4 tells the linker that this is a leaf function that does not need to save LR on entry.

The name SP always refers to the virtual stack pointer described earlier. For the hardware register, use R13.

Unsupported opcodes

The assemblers are designed to support the compiler so not all hardware instructions are defined for all architectures: if the compiler doesn't generate it, it might not be there. If you need to use a missing instruction, there are two ways to proceed. One is to update the assembler to support that instruction, which is straightforward but only worthwhile if it's likely the instruction will be used again. Instead, for simple one-off cases, it's possible to use the BYTE and WORD directives to lay down explicit data into the instruction stream within a TEXT. Here's how the 386 runtime defines the 64-bit atomic load function.

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