Chapter 1. go 汇编入门

$ go version
go version go1.10 linux/amd644

第一章: Go 编译入门

在开始深入研究运行时类库和标准类库之前,学习一些go的抽象汇编语言是有必要的。这份快速指引希望能助你提速。

目录

  • 伪汇编
  • 拆解一个简单程序
  • 分析goroutines, stacks 和 splits
  • 总结
  • 参考文献

  • 这篇文章需要基础的汇编知识
  • 如果涉及到机器架构,假定都是 linux/amd64
  • 我们将一直使用编译器优化项
  • 除了特别说明,引用文字或者注释基本来自于官方文档或者codebase

伪汇编

Go编译器输出一种抽象、简便格式的汇编,这种汇编并不适合任何真实的硬件。Go汇编使用这种伪汇编输出 适用于不同的机器架构。
这种设计有很多好处,其中最主要的是go可以很容易地适应一种新的架构。要看更多信息,Rob Pike 的 《The Design of the Go Assembler》这本书里面有讲到。文末参考文献里面也有罗列。

关于Go汇编,最需要知道的一点是它并不依赖于一个具体的机器。很多东西跟机器有映射,但是有些不是。这是因为编译器组件在执行过程中并不需要汇编校验通过。而是编译器操作是基于一堆半抽象指令集,指令选择有一部分发生在代码生成之后,所以当你看到一个指令MOV 可能不是move 这个指令,有可能是clear 或者load . 或者在某些机器架构中就跟他的名字一样的含义。一般来说 机器相关的操作倾向于跟他们展示的含义一样, 而像跟内存移动、子程序调用与返回等相关的指令则更抽象。这些细节随机器架构调整,我们为这种不精确道歉,到目前位置,并没有一个很好的解决方案。

汇编语言是一种解析半抽象指令描述指令集 的途径,他可以将半抽象指令集转换成输出到链接器的指令。

拆解一个简单程序

看下面这段代码(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
  ;; ...省略stack-split 相关的开始部分...
  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
  ;; ...省略stack-split 相关的结尾部分...

为了更好地了解汇编器做了什么,我们将逐行分析这两个方法。

分析 add

0x0000 TEXT "".add(SB), NOSPLIT, $0-16
  • 0x0000: 当前指令的偏移,程序的开始
  • TEXT "".add: TEXT 表明 字符 "".add 符号作为.text端(程序段) 的一部分,指示 之后的指令是function的一部分。
  • (SB): SB 是一个存储“静态基地址”指针的虚拟寄存器,即存储我们程序空间的开始地址
  • "".add(SB) 标识我们的字符位于距离程序空间开始处固定偏移量的位置。换言之,它有一个绝对地址:这是个全局函数变量。objdump将会验证这一切。
$ objdump -j .text -t direct_topfunc_call | grep 'main.add'
000000000044d980 g     F .text  000000000000000f main.add

所有用户定义变量都是以为寄存器FP(参数和本地变量)和SB(全局变量)为基准,找到对应的偏移量而被写入。SB伪寄存器被认为是内存的起始地址,所以foo(SB) 就是foo在内存中的地址。

  • NOSPLIT: 告知编译器不要将其插入stack-split的前导指令, 这用来检查当前的栈是否需要扩大。
    在我们这个例子中, 编译器自己设置了一些flag:很容易就能看出来,因为add方法没有本地变量也没有自己的栈帧,它不会超过当前的栈;因此在每次函数调用时践行检查是纯粹浪费CPU指令周期。

"NOSPLIT": 在栈必须分割时去检查不要插入到前导指令。 协程的栈帧,包括协程所调用的那些,必须位于堆栈段中一段空闲空间的顶部。用来保护类似用来栈分配代码的协程,

本章文末,我们会有一个关于go 协程跟栈分配的简单介绍。

  • $0-16$0 标识将要在占空间分配的字节数;而$16 表示传参所占的空间

通常,帧大小后面紧跟着参数大小,被“-”分割(这并不是一个减号,而是一个语法约定)。帧大小 $24-8 标识这个方法有一个24字节的帧空间,如果要调用它需要8字节的参数大小,这参数大小分配在调用方的帧空间中。

0x0000 FUNCDATA $0, gclocals·f207267fbf96a0178e8758c6e3e0ce28(SB)
0x0000 FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)

FUNCDATA 和PCDATA 表示了垃圾回收所需要的信息;编译器用这些信息。
关于这部分现在不要关注;当讨论垃圾回收的时候,我们将回过头来了解这部分。

0x0000 MOVL "".b+12(SP), AX
0x0004 MOVL "".a+8(SP), CX

Go调用过程 规定,所有的参数必须加载到栈中,使用调用者预分配的栈空间。
调用方需要适当地扩大或者缩小栈大小,以保证参数可以传递到被调用方法,以及接受来自被调用者的返回。
Go编译器并没有产生PUSH/POP家族的指令,而是通过增大或者缩小硬件栈指针SP来进行栈的缩小或者扩大的。
【更新: 在issue #21: about SP register中我们建一个讨论了这个问题。】

SP伪寄存器是一个用于指示本地变量和传参的伪寄存器。它指向本地栈的顶部,所以引用应该使用范围在 [−framesize, 0) 的负偏移量,比如x-8(SP), y-4(SP) 等等。

虽然官方文档生成“所有的用户定义的变量都是以伪寄存器FP为基础进行偏移(参数和本地变量)”,对手写代码适合。
像很多现代编译器,Go 工具集经常采用以代码生成地址为基准的偏移量,进行引用参数和本地变量。这使得平台可以用很少的指令 将帧指针(frame-pointer) 用作额外的一般用途寄存器(比如像x86)。
如果感兴趣,你可以看下在文末文章中介绍的x86-64 栈帧布局。
【更新:在issue #2: Frame pointer中我们讨论了这个问题】
"".b+12(SP)"".a+8(SP)分别指向栈的低12字节和低8字节的地址。
.a.b 就是一个随意指定的别名;虽然他们没有具体的文法含义,但是当使用虚拟存储器的相对地址时,他们是必须的。虚拟frame-pointer 的文中提到了这一点:

FP 伪寄存器是一个虚拟指针,用来指示方法的参数。编译器维护一份虚拟指针,指向栈中的参数,这些参数的地址是从伪指针之后的偏移量。因此0(FP)是方法的第一个参数, 8(FP)在第二个等等(在64位机器上)。如果以这种方法指示方法的参数,需要在开始处放置一个名字,比如 first_arg+0(FP) 和 second_arg+8(FP).(偏移量的意思是从frame pointer开始的偏移量,到SB的距离,这就是一个变量的偏移量)。编译器遵循这种约定,拒绝类似0(FP)和 8(FP). 具体的名字是预发无关的,但是需要被用来只是变量的名字。

最后,有两点需要注意:

  1. 第一个参数a并不是位于0(SP), 而是位于8(SP);这是因为调用方通过CALL这个伪指令使用了0(SP)这个地址。
  2. 参数是以一个相反的顺序传递,比如第一个参数里栈顶更近。
0x0008 ADDL CX, AX
0x000a MOVL AX, "".~r2+16(SP)
0x000e MOVB $1, "".~r3+20(SP)

ADDL 执行了两个长变量(占了4字节的值)的相加操作,并且将结果存储在AX中。
这个结果被移到 "".~r2+16(SP), 这个地址是调用者在栈空间中预占的空间,并且期待在这儿找到它的值。 再强调一下, "".~r2 并没有具体的文法含义。

为了更好地延时go语言是如何处理多返回值的情况,我们也返回了一个固定的布尔类型的true. 机制跟返回第一个值是一样的,只是距离SP的偏移量变了。

0x0013 RET

最后的伪指令 RET 告诉Go编译器插入一些指令,这些指令依托于特定的平台,以便从子协程调用中返回。
像这样的操作会将存储在0(SP)中的返回地址弹出,并且跳回到这个地址。

一个TEXT块的最后一条指令必须是jump指令的一种,通常是一个RET(伪)指令。(如果不是,链接器会自己加入一个”跳回自己”的指令;TEXTs中没有fallthrough)

这儿需要一次性学习好多句法和语言,这儿有一个快速的总结:

声明一个全局方法  "".add (实际链接是 main.add )
;;  不要插入stack-split 序列
;; 0 字节 栈空间,16字节传入参数 
;; func add(a, b int32) (int32, bool)
0x0000 TEXT "".add(SB), NOSPLIT, $0-16
  ;; ...省略 FUNCDATA 相关知识...
  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          ;; 计算 AX=CX+AX
  0x000a MOVL   AX, "".~r2+16(SP)   ;; 把结果移动到调用者的栈空间 
  0x000e MOVB   $1, "".~r3+20(SP)   ;; 将布尔类型的true移动到调用者的栈空间
  0x0013 RET                ;; 跳转到 0(SP)中存储地址对应内存中去 

最后,这儿有一个当main.add 执行完之后栈的示意图

   |    +-------------------------+ <-- 32(SP)              
   |    |                         |                         
 G |    |                         |                         
 R |    |                         |                         
 O |    | main.main's saved       |                         
 W |    |     frame-pointer (BP)  |                         
 S |    |-------------------------| <-- 24(SP)              
   |    |      [alignment]        |                         
 D |    | "".~r3 (bool) = 1/true  | <-- 21(SP)              
 O |    |-------------------------| <-- 20(SP)              
 W |    |                         |                         
 N |    | "".~r2 (int32) = 42     |                         
 W |    |-------------------------| <-- 16(SP)              
 A |    |                         |                         
 R |    | "".b (int32) = 32       |                         
 D |    |-------------------------| <-- 12(SP)              
 S |    |                         |                         
   |    | "".a (int32) = 10       |                         
   |    |-------------------------| <-- 8(SP)               
   |    |                         |                         
   |    |                         |                         
   |    |                         |                         
 \ | /  | return address to       |                         
  \|/   |     main.main + 0x30    |                         
   -    +-------------------------+ <-- 0(SP) (TOP OF STACK)

(diagram made with https://textik.com)

分析 main

省略了一些代码,帮你节省滚鼠标的时间,如下是main方法汇编之后的样子:

0x0000 TEXT     "".main(SB), $24-0
  ;; ...omitted stack-split prologue...
  0x000f SUBQ       $24, SP
  0x0013 MOVQ       BP, 16(SP)
  0x0018 LEAQ       16(SP), BP
  ;; ...omitted FUNCDATA stuff...
  0x001d MOVQ       $137438953482, AX
  0x0027 MOVQ       AX, (SP)
  ;; ...omitted PCDATA stuff...
  0x002b CALL       "".add(SB)
  0x0030 MOVQ       16(SP), BP
  0x0035 ADDQ       $24, SP
  0x0039 RET
  ;; ...omitted stack-split epilogue...
0x0000 TEXT "".main(SB), $24-0

其实也没啥:

  • "".main (链接之后是main.main)在.text 代码段中中是一个全局方法,它有一个到我们地址空间开始处有固定偏移量的地址。
  • 分配了24字节的栈空间 并且没有接收任何参数,也没有任何返回。
0x000f SUBQ     $24, SP
0x0013 MOVQ     BP, 16(SP)
0x0018 LEAQ     16(SP), BP

如上文所述,Go 调用必须将所有的参数放在栈中。
main这个调用者,通过递减虚拟栈指针,增加了24字节的栈空间(记住:栈是向下增长的,所以SUBQ 指令就是扩大栈空间)。这24字节包含如下部分:

  • 8字节(16(SP) - 24(SP))用来存储当前栈指针BP的值,为了 栈展开(stack-unwinding) 和 方便调试(facilitate debugging)。
  • 1+3 字节 (12(SP) - 16(SP)) 为了存储第二个返回值(布尔类型)加 3字节的偏移对齐量(amd64 机器架构)
  • 4字节(8(SP) - 12(SP))存储第一个返回值(int32)
  • 4字节(4(SP) - 8(SP))存储参数b(int32)
  • 4字节(0(SP) - 4(SP))存储参数a(int32)

最后, 伴随着栈的增长,LEAQ 计算栈指针的新地址,并将其存储在BP中。

0x001d MOVQ     $137438953482, AX
0x0027 MOVQ     AX, (SP)

调用者把给 被调用者的参数作为一个Quad word 推送到刚刚扩容栈的栈顶。
137438953482 虽然开始看起来像个垃圾值,实际上这个值对应的就是 1032 这两个 4 字节值,它们两被连接成了一个 8 字节值。

$ echo 'obase=2;137438953482' | bc
10000000000000000000000000000000001010
\____/\______________________________/
   32                              10
0x002b CALL     "".add(SB)

我们使用相对于基地址指针的偏移量来调用add方法, 这相当于直接跳到一个指定的地址。
注意:CALL 将返回地址(一个8字节的值)推到栈顶;所以每次在add函数中引用SP寄存器的时候还需要额外偏移8字节。
比如 "".a 不再位于0(SP), 而是位于 8(SP).

0x0030 MOVQ     16(SP), BP
0x0035 ADDQ     $24, SP
0x0039 RET

最后:
1、将帧指针(frame-pointer)下降一个栈帧(stack-frame)的大小(就是“向下”一级)
2、释放我们先前占用的24字节栈空间
3、请求Go汇编器插入一个子协程返回相关的操作

分析goroutines, stacks 和 splits

现在并不是一个深入研究go协程实现的好时机(后面可能会讲),但是随着我们越来越多地研究汇编输出, 跟栈管理相关的指令将会迅速熟悉起来。我们应该快速熟悉这些模式,当我们熟悉了,我们会理解指令在做什么以及为什么这么做。

栈(Stacks)

由于Go程序中的协程数量是不确定的,在实践中会有几百万个,所以在分配栈空间时要保守一些,避免耗光所有可用的内存。
每个协程启动时会分配2kb的栈内存(虽然说是栈空间,其实分配在堆上).
当协程执行其job时,可能因为超出其初始分配的空间大小。为了避免这种情况发生,runtime 保证当一个协程超出其栈空间时,会分配一个2倍大小的空间给它,并将初始空间的内容分配到新空间中。
这个过程被称为栈分裂(stack-split) ,这是协程栈动态大小的一个有效方法。

分裂(Splits)

为了保证stack-splitting 正常工作,编译器会在可能发生栈超出的每个方法开始跟结束插入几条指令。
正如在本文开始我们看到的,为了避免没必要的超出,被标注NOSPLIT的方法不会扩大他的栈空间。有了这个标注,编译器不会插入这些指令。
看一下main方法,这次没有省略 stack-split 前导指令:

0x0000 TEXT "".main(SB), $24-0
  ;; stack-split prologue
  0x0000 MOVQ   (TLS), CX
  0x0009 CMPQ   SP, 16(CX)
  0x000d JLS    58

  0x000f SUBQ   $24, SP
  0x0013 MOVQ   BP, 16(SP)
  0x0018 LEAQ   16(SP), BP
  ;; ...omitted FUNCDATA stuff...
  0x001d MOVQ   $137438953482, AX
  0x0027 MOVQ   AX, (SP)
  ;; ...omitted PCDATA stuff...
  0x002b CALL   "".add(SB)
  0x0030 MOVQ   16(SP), BP
  0x0035 ADDQ   $24, SP
  0x0039 RET

  ;; stack-split epilogue
  0x003a NOP
  ;; ...omitted PCDATA stuff...
  0x003a CALL   runtime.morestack_noctxt(SB)
  0x003f JMP    0

如我们所见,stack-split 序列被分成一个prologue (序幕) 和一个epilogue(结束):

  • prologue 检查协程是否超出空间,如果是,直接跳到epilogue
  • epilogue ,会触发栈增长机制,然后跳转到prologue

这创造了一个反馈循环,这个循环保证了只要有足够的栈空间未被分配,“饥饿"协程就能正常运转。

Prologue
0x0000 MOVQ (TLS), CX   ;; store current *g in CX
0x0009 CMPQ SP, 16(CX)  ;; compare SP and g.stackguard0
0x000d JLS  58      ;; jumps to 0x3a if SP <= g.stackguard0

TLS 是一个runtime线程持有的虚拟寄存器,保存了指向当前go协程的指针。会跟踪当前协程的运行时状态.
看一下runtime中g协程的定义

type g struct {
    stack       stack   // 16 bytes
    // stackguard0 is the stack pointer compared in the Go stack growth prologue.
    // It is stack.lo+StackGuard normally, but can be StackPreempt to trigger a preemption.
    stackguard0 uintptr
    stackguard1 uintptr

    // ...omitted dozens of fields...
}

16(CX) 关联了 g.stackguard0,一个阈值。其用途是当跟栈指针比较,判断一个协程是否马上要用完当前的栈空间。
prologue 检查当前SP的值是否小于或者等于stackguard0 的值,如果是,则跳转到Epilogue。

Epilogue
0x003a NOP
0x003a CALL runtime.morestack_noctxt(SB)
0x003f JMP  0

Epilogue 的结构比较直接: 它直接调用runtime的函数,然后跳转到方法的第一个指令prologue去。

在 CALL 之前出现的 NOP 这个指令使 prologue 部分不会直接跳到 CALL 指令位置。在一些平台上,直接跳到 CALL 可能会有一些麻烦的问题;所以在调用位置插一个 noop 的指令并在跳转时跳到这个 NOP 位置是一种最佳实践。
[更新:我们在issue #4: Clarify "nop before call" paragraph.讨论了这个问题]

Minus some subtleties (缺失了一些细节)

我们仅仅覆盖了冰山一角。
像栈扩容有很多细节我们在这儿并不能一一描述。扩容细节非常复杂,将会有一个单独的章节介绍。

到时再回头看这儿。

总结

快速介绍GO汇编 已经给你足够的内容去玩味。
随着我们深入研究本书其他部分的go内核知识,Go汇编将会是我们理解场景背后知识最依赖的工具之一。

如果有任何问题或者建议,不要迟疑,创建一个有关chapter1的issue, prefix!!!

参考文献

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,042评论 6 490
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 89,996评论 2 384
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,674评论 0 345
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,340评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,404评论 5 384
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,749评论 1 289
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,902评论 3 405
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,662评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,110评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,451评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,577评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,258评论 4 328
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,848评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,726评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,952评论 1 264
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,271评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,452评论 2 348

推荐阅读更多精彩内容

  • 原文地址:C语言函数调用栈(一)C语言函数调用栈(二) 0 引言 程序的执行过程可看作连续的函数调用。当一个函数执...
    小猪啊呜阅读 4,591评论 1 19
  • 王爽汇编全书知识点大纲 第一章 基础知识 机器语言 汇编语言的产生 汇编语言的组成 存储器 cpu对存储器的读写 ...
    2c3ba901516f阅读 2,408评论 0 1
  • 编程语言的发展 机器语言由0和1组成 汇编语言(Assembly Language)用符号代替了0和1,比机器语言...
    阿凡提说AI阅读 3,989评论 0 15
  • 1.地址总线,数据总线,控制总线在哪里,它们有什么作用?答:它们都是cpu连接外部组件的线路。地址总线:地址总线A...
    MagicalGuy阅读 1,437评论 0 1