在深入学习 runtime 和标准库的实现之前,我们需要先对 Go 的汇编有一定的熟练度。这份快速指南希望能够加速你的学习进程。
- 本章假设你已经对某一种汇编器的基础知识有所了解
-
涉及到架构相关的情况时,请假设我们是运行在
linux/amd64
平台上 - 学习过程中编译器优化会 打开 。
目录
本章中的引用段落/注释都引用自官方文档或者 Go 的代码库,除非另外注明
"伪汇编"
Go 编译器会输出一种抽象可移植的汇编代码,这种汇编并不对应某种真实的硬件架构。Go 的汇编器会使用这种伪汇编,再为目标硬件生成具体的机器指令。
伪汇编这一个额外层可以带来很多好处,最主要的一点是方便将 Go 移植到新的架构上。相关的信息可以参考文后列出的 Rob Pike 的 The Design of the Go Assembler 。
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 needs no assembler pass in the usual pipeline. Instead, the compiler operates on a kind of semi-abstract instruction set, and instruction selection occurs partly after code generation. The assembler works on the semi-abstract form, so when you see an instruction like MOV what the toolchain 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.
The assembler program is a way to parse a description of that semi-abstract instruction set and turn it into instructions to be input to the linker.
拆解一个简单程序
思考一下下面这段 Go 代码 ( direct_topfunc_call.go ):
//go:noinline
func add(a, b int32) (int32, bool) { return a + b, true }
func main() { add(10, 32) }
(注意这里的
//go:noinline
编译器指令。。不要省略掉这部分)
将这段代码编译到汇编:
$ GOOS=linux GOARCH=amd64 go tool compile -S direct_topfunc_call.go
0x0000 TEXT "".add(SB), NOSPLIT, $0-16
0x0000 FUNCDATA $0, gclocals·f207267fbf96a0178e8758c6e3e0ce28(SB)
0x0000 FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 MOVL "".b+12(SP), AX
0x0004 MOVL "".a+8(SP), CX
0x0008 ADDL CX, AX
0x000a MOVL AX, "".~r2+16(SP)
0x000e MOVB $1, "".~r3+20(SP)
0x0013 RET
0x0000 TEXT "".main(SB), $24-0
;; ...omitted stack-split prologue...
0x000f SUBQ $24, SP
0x0013 MOVQ BP, 16(SP)
0x0018 LEAQ 16(SP), BP
0x001d FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x001d MOVQ $137438953482, AX
0x0027 MOVQ AX, (SP)
0x002b PCDATA $0, $0
0x002b CALL "".add(SB)
0x0030 MOVQ 16(SP), BP
0x0035 ADDQ $24, SP
0x0039 RET
;; ...omitted stack-split epilogue...
接下来一行一行地对这两个函数进行解析来帮助我们理解编译器在编译期间都做了什么事情。
解剖
add
0x0000 TEXT "".add(SB), NOSPLIT, $0-16
-
0x0000
: 当前指令相对于当前函数的偏移量。 -
TEXT "".add
:TEXT
指令声明了"".add
是.text
段(程序代码在运行期会放在内存的 .text 段中)的一部分,并表明跟在这个声明后的是函数的函数体。 在链接期,""
这个空字符会被替换为当前的包名: 也就是说,"".add
在链接到二进制文件后会变成main.add
。 -
(SB)
:SB
是一个虚拟寄存器,保存了静态基地址(static-base) 指针,即我们程序地址空间的开始地址。"".add(SB)
表明我们的符号位于某个固定的相对地址空间起始处的偏移位置 (最终是由链接器计算得到的)。换句话来讲,它有一个直接的绝对地址: 是一个全局的函数符号。objdump
这个工具能帮我们确认上面这些结论:
$ objdump -j .text -t direct_topfunc_call | grep 'main.add'
000000000044d980 g F .text 000000000000000f main.add
All user-defined symbols are written as offsets to the pseudo-registers FP (arguments and locals) and SB (globals). 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.
-
NOSPLIT
: 向编译器表明 不应该 插入 stack-split 的用来检查栈需要扩张的前导指令。 在我们add
函数的这种情况下,编译器自己帮我们插入了这个标记: 它足够聪明地意识到,由于add
没有任何局部变量且没有它自己的栈帧,所以一定不会超出当前的栈;因此每次调用函数时在这里执行栈检查就是完全浪费 CPU 循环了。
"NOSPLIT": Don't insert the preamble to check if the stack must be split. The frame for the routine, plus anything it calls, must fit in the spare space at the top of the stack segment. Used to protect routines such as the stack splitting code itself.
本章结束时会对 goroutines 和 stack-splits 进行简单介绍。
-
$0-16
:$0
代表即将分配的栈帧大小;而$16
指定了调用方传入的参数大小。
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.
0x0000 FUNCDATA $0, gclocals·f207267fbf96a0178e8758c6e3e0ce28(SB)
0x0000 FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
The FUNCDATA and PCDATA directives contain information for use by the garbage collector; they are introduced by the compiler.
现在还不要对这个太上心;在本书深入探讨垃圾收集时,会再回来了解这些知识。
0x0000 MOVL "".b+12(SP), AX
0x0004 MOVL "".a+8(SP), CX
Go 的调用规约要求每一个参数都通过栈来传递,这部分空间由 caller 在其栈帧(stack frame)上提供。
调用其它过程之前,caller 就需要按照参数和返回变量的大小来对应地增长(返回后收缩)栈。
Go 编译器不会生成任何 PUSH/POP 族的指令: 栈的增长和收缩是通过在栈指针寄存器
SP
上分别执行减法和加法指令来实现的。
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.
尽管官方文档说 " All user-defined symbols are written as offsets to the pseudo-register FP(arguments and locals) ",实际这个原则只是在手写的代码场景下才是有效的。 与大多数最近的编译器做法一样,Go 工具链总是在其生成的代码中,使用相对栈指针(stack-pointer)的偏移量来引用参数和局部变量。这样使得我们可以在那些寄存器数量较少的平台上(例如 x86),也可以将帧指针(frame-pointer)作为一个额外的通用寄存器。 如果你喜欢了解这些细节问题,可以参考本章后提供的 Stack frame layout on x86-64 一文。
"".b+12(SP)
和
"".a+8(SP)
分别指向栈的低 12 字节和低 8 字节位置(记住: 栈是向低位地址方向增长的!)。
.a
和
.b
是分配给引用地址的任意别名;尽管
它们没有任何语义上的含义
,但在使用虚拟寄存器和相对地址时,这种别名是需要强制使用的。 虚拟寄存器帧指针(frame-pointer)的文档对此有所提及:
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. However, when referring to a function argument this way, it is necessary to place a name at the beginning, as in first_arg+0(FP) and second_arg+8(FP). (The meaning of the offset —offset from the frame pointer— distinct from its use with SB, where it is an offset from the symbol.) The assembler enforces this convention, rejecting plain 0(FP) and 8(FP). The actual name is semantically irrelevant but should be used to document the argument's name.
最后,有两个重点需要指出:
-
第一个变量
a
的地址并不是0(SP)
,而是在8(SP)
;这是因为调用方通过使用CALL
伪指令,把其返回地址保存在了0(SP)
位置。 - 参数是反序传入的;也就是说,第一个参数和栈顶距离最近。
0x0008 ADDL CX, AX
0x000a MOVL AX, "".~r2+16(SP)
0x000e MOVB $1, "".~r3+20(SP)
ADDL
进行实际的加法操作,L 这里代表
L
ong,4 字节的值,其将保存在
AX
和
CX
寄存器中的值进行相加,然后再保存进
AX
寄存器中。 这个结果之后被移动到
"".~r2+16(SP)
地址处,这是之前调用方专门为返回值预留的栈空间。这一次
"".~r2
同样没什么语义上的含义。
为了演示 Go 如何处理多返回值,我们同时返回了一个 bool 常量
true
。 返回这个 bool 值的方法和之前返回数值的方法是一样的;只是相对于
SP
寄存器的偏移量发生了变化。
0x0013 RET
最后的
RET
伪指令告诉 Go 汇编器插入一些指令,这些指令是对应的目标平台中的调用规约所要求的,从子过程中返回时所需要的指令。 一般情况下这样的指令会使在
0(SP)
寄存器中保存的函数返回地址被 pop 出栈,并跳回到该地址。
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.)
我们一次性需要消化的语法和语义细节有点多。下面将我们已经覆盖到的知识点作为注释加进了汇编代码中:
;; Declare global function symbol "".add (actually main.add once linked)
;; Do not insert stack-split preamble
;; 0 bytes of stack-frame, 16 bytes of arguments passed in
;; func add(a, b int32) (int32, bool)
0x0000 TEXT "".add(SB), NOSPLIT, $0-16
;; ...omitted FUNCDATA stuff...
0x0000 MOVL "".b+12(SP), AX ;; move second Long-word (4B) argument from caller's stack-frame into AX
0x0004 MOVL "".a+8(SP), CX ;; move first Long-word (4B) argument from caller's stack-frame into CX
0x0008 ADDL CX, AX ;; compute AX=CX+AX
0x000a MOVL AX, "".~r2+16(SP) ;; move addition result (AX) into caller's stack-frame
0x000e MOVB $1, "".~r3+20(SP) ;; move `true` boolean (constant) into caller's stack-frame
0x0013 RET ;; jump to return address stored at 0(SP)
总之,下面是
main.add
即将执行
RET
指令时的栈的情况。