本文最初是在公司内部小组技术分享

为什么想分享这个知识点

  • 经常性在阅读GO源码时,会追溯到底层的汇编实现,如果不理解,就会懵逼了
  • 网上大量的大神在分析某个特性或者实现时,常常都会祭出底层汇编,这也说明,要明白GO底层的很多设计,都必须要了解GO汇编
  • 性能优化,代码分析所需,虽然这种情况很少
  • 了解底层实现,充实自己的武器库
  • 具备部分辩伪能力,学习了GO汇编,多少会对语言特性的底层实现有一些嗅觉
  • 装逼,装逼是第一生产力

前置知识点

进程内存布局

进程在内存中的布局主要分为4个区域:代码区,数据区,堆和栈

GO ASM

  • GO汇编并不对应某种真实的硬件架构,可以理解为是一种伪汇编语言,初衷是想做到架构无关、可移植,但现状是骨感的,源码中随处可见一个函数不同架构的汇编源码实现。
  • GO ASM 基于PLAN 9,自定义了几个平台通用的伪寄存器。
X64 rax rbx rcx rdx rdi rsi rbp rsp r8 r9 r10 r11 r12 r13 r14 rip
Plan9 AX BX CX DX DI SI BP SP R8 R9 R10 R11 R12 R13 R14 PC

GO 汇编自定义的伪寄存器,具体定义见go官网文档asm

  • FP: Frame pointer: arguments and locals.
  • PC: Program counter: jumps and branches.
  • SB: Static base pointer: global symbols.
  • SP: Stack pointer: top of stack.
  • BP: Base Pointer: base of frame Pointer

几个相关的概念

  • 栈:进程、线程、goroutine 都有自己的调用栈,先进后出(FILO)
  • 栈帧:可以理解是函数调用时,在栈上为函数所分配的内存区域
  • 调用者:caller,比如:A 函数调用了 B 函数,那么 A 就是调用者
  • 被调者:callee,比如:A 函数调用了 B 函数,那么 B 就是被调者

GO 调用约定

  • 由调用者(caller)负责函数参数的准备工作
  • call指令负责保存PC地址
  • 被调用者(callee)负责BP的保存,SP扩栈,当完成函数操作后,需要恢复BP,SP
  • 被调用函数,需要声明自己所需栈空间大小和参数大小

GO routine栈帧布局

Go 汇编使用的是caller-save模式,被调用函数的入参参数、返回值都由调用者维护、准备。因此,当需要调用一个函数时,需要先将这些工作准备好,才调用下一个函数,另外这些都需要进行内存对齐,对齐的大小是 sizeof(uintptr)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
                                                                             
                       -----------------                                           
                       current func arg0                                           
                       ----------------- <----------- FP(pseudo FP)                
                        caller ret addr                                            
                       +---------------+                                           
                       | caller BP(*)  |                                           
                       ----------------- <----------- SP(pseudo SP,实际上是当前栈帧的 BP 位置)
                       |   Local Var0  |                                           
                       -----------------                                           
                       |   Local Var1  |                                           
                       -----------------                                           
                       |   Local Var2  |                                           
                       -----------------                -                          
                       |   ........    |                                           
                       -----------------                                           
                       |   Local VarN  |                                           
                       -----------------                                           
                       |               |                                           
                       |               |                                           
                       |  temporarily  |                                           
                       |  unused space |                                           
                       |               |                                           
                       |               |                                           
                       -----------------                                           
                       |  call retn    |                                           
                       -----------------                                           
                       |  call ret(n-1)|                                           
                       -----------------                                           
                       |  ..........   |                                           
                       -----------------                                           
                       |  call ret1    |                                           
                       -----------------                                           
                       |  call argn    |                                           
                       -----------------                                           
                       |   .....       |                                           
                       -----------------                                           
                       |  call arg3    |                                           
                       -----------------                                           
                       |  call arg2    |                                           
                       |---------------|                                           
                       |  call arg1    |                                           
                       -----------------   <------------  hardware SP 位置           
                         return addr                                               
                       +---------------+                                           
                                                                                  

常见指令

助记符 指令种类 用途 示例
MOVQ 传送 数据传送 MOVQ 48, AX // 把 48 传送到 AX
LEAQ 传送 地址传送 LEAQ AX, BX // 把 AX 有效地址传送到 BX
PUSHQ 传送 栈压入 PUSHQ AX // 将 AX 内容送入栈顶位置
POPQ 传送 栈弹出 POPQ AX // 弹出栈顶数据后修改栈顶指针
ADDQ 运算 相加并赋值 ADDQ BX, AX // 等价于 AX+=BX
SUBQ 运算 相减并赋值 SUBQ BX, AX // 等价于 AX-=BX
CMPQ 运算 比较大小 CMPQ SI CX // 比较 SI 和 CX 的大小
CALL 转移 调用函数 CALL runtime.println(SB) // 发起调用
JMP 转移 无条件转移指令 JMP 0x0185 //无条件转至 0x0185 地址处
JLS 转移 条件转移指令 JLS 0x0185 //左边小于右边,则跳到 0x0185

指令的后缀来对应不同长度的操作数。B(8位),W(16位),D(32位),Q(64位)。

函数声明

go汇编函数命名

TEXT 指代码段,是存储在 .text 段中的,除了 TEXT 之外还有 DATA/GLOBL。中点 · 比较特殊,是一个 unicode 的中点,该点在 mac 下的输入方法是 option+shift+9。在程序被链接之后,所有的中点· 都会被替换为句号.,比如你的方法是 runtime·main,在编译之后的程序里的符号则是 runtime.main

NOSPLIT: 向编译器表明,不应该插入 stack-split 的用来检查栈需要扩张的前导指令

Talk is cheap, Show me code

输出汇编方法

  • 方法 1 先使用 go build -gcflags "-N -l" main.go 生成对应的可执行二进制文件 再使用 go tool objdump -s "main\." main 反编译获取对应的汇编
  • 方法 2 使用 go tool compile -S -N -l main.go 这种方式直接输出汇编
  • 方法 3 使用go build -gcflags="-N -l -S" main.go 直接输出汇编

-l 禁止内联 -N 编译时,禁止优化 -S 输出汇编代码

实例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package main

func addInt(a, b int) int{
	sum := 0 // 不设置该局部变量sum,add栈空间大小会是0
	sum = a+b
	return sum
}

func main() {
	println(addInt(1, 2))
}
1
go build -gcflags="-N -l -S" main.go

-l 禁止内联 -N 编译时,禁止优化 -S 输出汇编代码

截取主要输出

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# command-line-arguments
"".addInt STEXT nosplit size=60 args=0x18 locals=0x10
        0x0000 00000 (main.go:3)   TEXT    "".addInt(SB), NOSPLIT|ABIInternal, $16-24 ;;; addInt函数定义,函数栈帧16字节,输入参数24字节(3个int参数),此函数有NOSPLIT,因此没有栈分裂的检查指令
        0x0000 00000 (main.go:3)   SUBQ    $16, SP ;;; 当前SP向下扩栈16字节
        0x0004 00004 (main.go:3)   MOVQ    BP, 8(SP) ;;; 将当前BP(即main函数的BP)保存在SP+8处
        0x0009 00009 (main.go:3)   LEAQ    8(SP), BP ;;; 将SP+8的地址值保存在BP寄存器中
        0x000e 00014 (main.go:3)   FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x000e 00014 (main.go:3)   FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x000e 00014 (main.go:3)   MOVQ    $0, "".~r2+40(SP) ;;; 初始化结果值为0
        0x0017 00023 (main.go:4)   MOVQ    $0, "".sum(SP) ;;; 初始化sum变量为0
        0x001f 00031 (main.go:5)   MOVQ    "".a+24(SP), AX ;;; AX = AX + 24(SP)
        0x0024 00036 (main.go:5)   ADDQ    "".b+32(SP), AX ;;; AX = AX + 32(SP)
        0x0029 00041 (main.go:5)   MOVQ    AX, "".sum(SP)  ;;; SUM = AX
        0x002d 00045 (main.go:6)   MOVQ    AX, "".~r2+40(SP) ;;; 40(SP) = AX
        0x0032 00050 (main.go:6)   MOVQ    8(SP), BP ;;; 处理收尾工作,将main函数的BP取出到BP寄存器
        0x0037 00055 (main.go:6)   ADDQ    $16, SP ;;; SP=SP+16,释放函数栈空间
        0x003b 00059 (main.go:6)   RET  ;;; 返回函数调用
    ...... 省略
"".main STEXT size=110 args=0x0 locals=0x28
        0x0000 00000 (main.go:9)   TEXT    "".main(SB), ABIInternal, $40-0 ;;; main函数定义入口,栈帧大小为40个字节,参数大小为0个字节
        0x0000 00000 (main.go:9)   MOVQ    (TLS), CX ;;; 这些是检查栈分裂的代码,由编译器编译时插入,预防栈溢出
        0x0009 00009 (main.go:9)   CMPQ    SP, 16(CX)
        0x000d 00013 (main.go:9)   PCDATA  $0, $-2
        0x000d 00013 (main.go:9)   JLS     103     ;;; 如果检测到栈空间不够,调到103行执行栈空间扩张
        0x000f 00015 (main.go:9)   PCDATA  $0, $-1
        0x000f 00015 (main.go:9)   SUBQ    $40, SP ;;; 将SP地址往下移动40个字节,SP=SP-40,初始化main函数占空间
        0x0013 00019 (main.go:9)   MOVQ    BP, 32(SP) ;;; 将当前BP的值保存在地址SP+32地址处
        0x0018 00024 (main.go:9)   LEAQ    32(SP), BP ;;; 将SP+32对应的内存地址保存在BP中,此时完成main函数的栈帧空间布局初始化
        0x001d 00029 (main.go:9)   FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) ;;;垃圾收集器需要的信息。它们由编译器产生
        0x001d 00029 (main.go:9)   FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) ;;;垃圾收集器需要的信息。它们由编译器产生
        0x001d 00029 (main.go:10)  MOVQ    $1, (SP) ;;; 将立即数1存入SP指向的内存地址处
        0x0025 00037 (main.go:10)  MOVQ    $2, 8(SP) ;;; 将立即数2存入SP+8指向的内存地址处
        0x002e 00046 (main.go:10)  PCDATA  $1, $0
        0x002e 00046 (main.go:10)  CALL    "".addInt(SB) ;;; 调用addInt,call指令将下一条指令压栈
        0x0033 00051 (main.go:10)  MOVQ    16(SP), AX ;;;将函数返回值move到AX寄存器
        0x0038 00056 (main.go:10)  MOVQ    AX, ""..autotmp_0+24(SP) ;;; 将AX move到SP+24
        0x003d 00061 (main.go:10)  NOP
        0x0040 00064 (main.go:10)  CALL    runtime.printlock(SB) ;;;执行printlock调用
        0x0045 00069 (main.go:10)  MOVQ    ""..autotmp_0+24(SP), AX
        0x004a 00074 (main.go:10)  MOVQ    AX, (SP)
        0x004e 00078 (main.go:10)  CALL    runtime.printint(SB)
        0x0053 00083 (main.go:10)  CALL    runtime.printnl(SB)
        0x0058 00088 (main.go:10)  CALL    runtime.printunlock(SB)
        0x005d 00093 (main.go:11)  MOVQ    32(SP), BP
        0x0062 00098 (main.go:11)  ADDQ    $40, SP
        0x0066 00102 (main.go:11)  RET
        0x0067 00103 (main.go:11)  NOP
        0x0067 00103 (main.go:9)   PCDATA  $1, $-1
        0x0067 00103 (main.go:9)   PCDATA  $0, $-2
        0x0067 00103 (main.go:9)   CALL    runtime.morestack_noctxt(SB) ;;; 栈分裂过程
        0x006c 00108 (main.go:9)   PCDATA  $0, $-1
        0x006c 00108 (main.go:9)   JMP     0
  • 指令15-24所做的事情: 进入main函数,完成main函数栈空间初始化,主要是依据函数的栈空间大小进行向下扩栈操作,保存caller的BP数据

这里多说一句,这个main父函数 对应的源码在runtime/proc.go:114。GO 程序真正的入口是在runtime/asm_arm64.s:11 (以Mac 系统为例),这里简单摘录几种一部分代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
TEXT runtime·rt0_go(SB),NOSPLIT,$0
	// SP = stack; R0 = argc; R1 = argv

	SUB	$32, RSP
	MOVW	R0, 8(RSP) // argc
	MOVD	R1, 16(RSP) // argv
...
  MOVW	8(RSP), R0	// copy argc
	MOVW	R0, -8(RSP)
	MOVD	16(RSP), R0		// copy argv
	MOVD	R0, 0(RSP)
	BL	runtime·args(SB)
	BL	runtime·osinit(SB)
	BL	runtime·schedinit(SB) ;;; 此处又会执行go函数的初始化

	// create a new goroutine to start program
	MOVD	$runtime·mainPC(SB), R0		// entry
	MOVD	RSP, R7
	MOVD.W	$0, -8(R7)
	MOVD.W	R0, -8(R7)
	MOVD.W	$0, -8(R7)
	MOVD.W	$0, -8(R7)
	MOVD	R7, RSP
	BL	runtime·newproc(SB)
	ADD	$32, RSP

	// start this M
	BL	runtime·mstart(SB)
...

runtime·schedinit 的实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
func schedinit() {
  ...
  tracebackinit()
	moduledataverify()
	stackinit()  // goroutine栈空间初始化
	mallocinit()  // mspan, mcache, mcentral, mheap等数据结构初始化的地方
	fastrandinit() // must run before mcommoninit
	mcommoninit(_g_.m, -1)
	cpuinit()       // must run before alginit
	alginit()       // maps must not be used before this call
	modulesinit()   // provides activeModules
	typelinksinit() // uses maps, activeModules
	itabsinit()     // uses activeModules

	msigsave(_g_.m)
	initSigmask = _g_.m.sigmask

	goargs()
	goenvs()
	parsedebugvars()
	gcinit() // gc初始化

	sched.lastpoll = uint64(nanotime())
	procs := ncpu
	if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
		procs = n
	}
	if procresize(procs) != nil {
		throw("unknown runnable goroutine during bootstrap")
	}
  ...
}
  • 指令29-46:main函数负责将参数准备好

  • call指令调用&&addInt函数中0到45行指令

  • addInt函数中的50-59指令:

手动写汇编

大致了解了GO汇编的一些语法,我们自己来动手写一个汇编程序 在项目中,随意新建一个文件,后缀以.s结尾,定义一个函数addInt

1
2
3
4
5
6
7
8
#include "textflag.h"

TEXT ·addInt(SB),NOSPLIT,$0-24
    MOVQ $0, AX;
    ADDQ a+0(FP), AX;
    ADDQ b+8(FP), AX;
    MOVQ AX, i+16(FP);
    RET

在main函数中调用该函数

1
2
3
4
5
6
7
8
package main

//此处仅申明函数,函数实现在.s文件中,go编译器会自动查找相同目录下.s文件
func addInt(a, b int) int

func main() {
	println(addInt(1, 2))
}

执行正常编译,可以看到我们自己动手写汇编有很大不用,首先不使用SP寄存器,而是使用FP寄存器,FP寄存器指向的参数起始地址处,参考上面的布局图。

执行反编译

1
go tool objdump -s "main\." main

-s "main\."表示只输出与main有关的汇编,不然输出会淹没有效信息,截取输出如下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
TEXT main.addInt(SB) main.go
  main.go:3             0x105c8a0               4883ec10                SUBQ $0x10, SP   ;;; 扩栈  
  main.go:3             0x105c8a4               48896c2408              MOVQ BP, 0x8(SP) ;;; 保存父函数BP  
  main.go:3             0x105c8a9               488d6c2408              LEAQ 0x8(SP), BP ;;; 设置当前函数BP  
  main.go:3             0x105c8ae               48c744242800000000      MOVQ $0x0, 0x28(SP)     
  main.go:4             0x105c8b7               48c7042400000000        MOVQ $0x0, 0(SP)        
  main.go:5             0x105c8bf               488b442418              MOVQ 0x18(SP), AX       
  main.go:5             0x105c8c4               4803442420              ADDQ 0x20(SP), AX       
  main.go:5             0x105c8c9               48890424                MOVQ AX, 0(SP)          
  main.go:6             0x105c8cd               4889442428              MOVQ AX, 0x28(SP)       
  main.go:6             0x105c8d2               488b6c2408              MOVQ 0x8(SP), BP        
  main.go:6             0x105c8d7               4883c410                ADDQ $0x10, SP          
  main.go:6             0x105c8db               c3                      RET                     
  :-1                   0x105c8dc               cc                      INT $0x3                
  :-1                   0x105c8dd               cc                      INT $0x3                
  :-1                   0x105c8de               cc                      INT $0x3                
  :-1                   0x105c8df               cc                      INT $0x3                

TEXT main.main(SB) main.go
  main.go:9             0x105c8e0               65488b0c2530000000      MOVQ GS:0x30, CX                        
  main.go:9             0x105c8e9               483b6110                CMPQ 0x10(CX), SP                       
  main.go:9             0x105c8ed               7658                    JBE 0x105c947                           
  main.go:9             0x105c8ef               4883ec28                SUBQ $0x28, SP  ;;;main函数扩栈                        
  main.go:9             0x105c8f3               48896c2420              MOVQ BP, 0x20(SP) ;;;保存父函数BP                      
  main.go:9             0x105c8f8               488d6c2420              LEAQ 0x20(SP), BP ;;;设置当前函数BP                     
  main.go:10            0x105c8fd               48c7042401000000        MOVQ $0x1, 0(SP)                        
  main.go:10            0x105c905               48c744240802000000      MOVQ $0x2, 0x8(SP)                      
  main.go:10            0x105c90e               e88dffffff              CALL main.addInt(SB) ;;;发起函数调用                   
  main.go:10            0x105c913               488b442410              MOVQ 0x10(SP), AX                       
  main.go:10            0x105c918               4889442418              MOVQ AX, 0x18(SP)                       
  main.go:10            0x105c91d               0f1f00                  NOPL 0(AX)                              
  main.go:10            0x105c920               e85b15fdff              CALL runtime.printlock(SB)              
  main.go:10            0x105c925               488b442418              MOVQ 0x18(SP), AX                       
  main.go:10            0x105c92a               48890424                MOVQ AX, 0(SP)                          
  main.go:10            0x105c92e               e86d1dfdff              CALL runtime.printint(SB)               
  main.go:10            0x105c933               e80818fdff              CALL runtime.printnl(SB)                
  main.go:10            0x105c938               e8c315fdff              CALL runtime.printunlock(SB)            
  main.go:11            0x105c93d               488b6c2420              MOVQ 0x20(SP), BP                       
  main.go:11            0x105c942               4883c428                ADDQ $0x28, SP                          
  main.go:11            0x105c946               c3                      RET                                     
  main.go:9             0x105c947               e8f4b1ffff              CALL runtime.morestack_noctxt(SB)       
  main.go:9             0x105c94c               eb92                    JMP main.main(SB) 

可以得知:

  • (FP)伪寄存器,只有在编写 Go 汇编代码时使用。FP 伪寄存器指向 caller 传递给 callee 的第一个参数
  • 使用 go tool compile / go tool objdump 得到的汇编中看不到(FP)寄存器的踪影

回过头来看那几个重要寄存器

  • FP: 使用如 symbol+offset(FP)的方式,引用 callee 函数的入参参数。例如 arg0+0(FP),arg1+8(FP),使用 FP 必须加 symbol ,否则无法通过编译(从汇编层面来看,symbol 没有什么用,加 symbol 主要是为了提升代码可读性)。另外,需要注意的是:往往在编写 go 汇编代码时,要站在 callee 的角度来看(FP),在 callee 看来,(FP)指向的是 caller 调用 callee 时传递的第一个参数的位置。假如当前的 callee 函数是 add,在 add 的代码中引用 FP,该 FP 指向的位置不在 callee 的 stack frame 之内。而是在 caller 的 stack frame 上,指向调用 add 函数时传递的第一个参数的位置,经常在 callee 中用symbol+offset(FP)来获取入参的参数值。
  • SB: 全局静态基指针,一般用在声明函数、全局变量中。
  • SP: 该寄存器也是最具有迷惑性的寄存器,因为会有伪 SP 寄存器和硬件 SP 寄存器之分。plan9 的这个伪 SP 寄存器指向当前栈帧第一个局部变量的结束位置(为什么说是结束位置,可以看下面寄存器内存布局图),使用形如 symbol+offset(SP) 的方式,引用函数的局部变量。offset 的合法取值是 [-framesize, 0),注意是个左闭右开的区间。假如局部变量都是 8 字节,那么第一个局部变量就可以用 localvar0-8(SP) 来表示。与硬件寄存器 SP 是两个不同的东西,在栈帧 size 为 0 的情况下,伪寄存器 SP 和硬件寄存器 SP 指向同一位置。手写汇编代码时,如果是 symbol+offset(SP)形式,则表示伪寄存器 SP。如果是 offset(SP)则表示硬件寄存器 SP。务必注意:对于编译输出(go tool compile -S / go tool objdump)的代码来讲,所有的 SP 都是硬件 SP 寄存器,无论是否带 symbol(这一点非常具有迷惑性,需要慢慢理解。往往在分析编译输出的汇编时,看到的就是硬件 SP 寄存器)。
  • PC: 实际上就是在体系结构的知识中常见的 pc 寄存器,在 x86 平台下对应 ip 寄存器,amd64 上则是 rip。除了个别跳转之外,手写 plan9 汇编代码时,很少用到 PC 寄存器

分享总结

想达到的目的

  • 对于GO ASM有初步的了解,如果能引起大家的兴趣,那就更好了
  • 能看懂源码中一些简单的汇编,帮助大家学习源码
  • 有兴趣的小伙伴,一起研究GO语言分享吧

适度

  • GO ASM不宜深究,总体程度达到能看懂大概意思即可,很多东西花了太多时间搞清楚了,反而觉得没啥用
  • 总体达到能通过汇编分析一些特性底层实现能力即可

参考资料