走进Golang之运行与Plan9汇编

本文目录速览:程序员

经过上一篇走进Golang之汇编原理,咱们知道了目标代码的生成经历了那些过程。今天咱们一块儿来学习一下生成的目标代码如何在计算机上执行。以及经过查阅 Golang 的 Plan9 汇编来了解Golang的一些内部秘密。golang

Golang的运行环境

当咱们把编译后的Go代码运行起来,它会以进程的方式出如今系统中。而后开始处理请求、数据,咱们会看到这个进程占用了内存消耗、cpu占比等等信息。本文就是要来解释在程序的运行过程当中,内存、CPU、操做系统(固然还有其它的硬件,文中关系不大,就不说了)是如何进行配合,完成了咱们代码所指定的事情。面试

内存

首先,咱们先来讲说内存。先来看一个咱们运行的go进程。shell

代码以下:编程

package main

import (
	"fmt"
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/", sayHello)

	err := http.ListenAndServe(":9999", nil)
	if err != nil {
		log.Fatal("ListenAndServe: ", err)
	}
}

func sayHello(w http.ResponseWriter, r *http.Request) {
	fmt.Printf("fibonacci: %d\n", fibonacci(1000))

	_, _ = fmt.Fprint(w, "Hello World!")
}

func fibonacci(num int) int {
	if num < 2 {
		return 1
	}
	return fibonacci(num-1) + fibonacci(num-2)
}
复制代码

来看一下执行状况segmentfault

dayu.com >ps aux

USER               PID   %CPU  %MEM      VSZ     RSS    TT  STAT   STARTED    TIME     COMMAND
xxxxx              3584  99.2  0.1     4380456  4376   s003  R+    8:33下午   0:05.81  ./myhttp
复制代码

这里咱们先来不关注其它指标,先来看 VSZRSS缓存

  • VSZ: 是指虚拟地址,他是程序实际操做的内存。包含了分配尚未使用的内存。
  • RSS: 是实际的物理内存,包含了栈内存与堆内存。

每个进程都是运行在本身的内存沙盒里,程序被分配的地址都是 “虚拟内存”,物理内存对程序开发者来讲实际是不可见的,并且虚拟地址比进程实际的物理地址要大的多。咱们常常编程中取指针对应的地址实际就是虚拟地址。这里必定要注意区分虚拟内存与物理内存。来一张图感觉一下。网络

虚拟内存与物理内存

这张图主要是为了说明两个问题:ide

  1. 程序使用的是虚拟内存,可是操做系统会把虚拟内存映射到物理内存;你会发现本身机器上全部进程的VSZ要大得多;
  2. 物理内存能够被多个进程共享,甚至一个进程内的不一样地址可能映射的都是同一个物理内存地址。

上面搞明白了程序中的内存具体是指什么,接下来讲明程序是如何使用内存的(虚拟内存),内存说白了就是比硬盘存取速度更快的一个硬件,为了方便内存的管理,操做系统把分配给进程的内存划分红了不一样的功能块。像咱们常常说的:代码区,静态数据区,堆区,栈区等。函数

这里借用一张网络上的图来看一下。

go语言内存的划分

这里就是咱们程序(进程)在虚拟内存中的分布。

代码区:存放的就是咱们编译后的机器码,通常来讲这个区域只能是只读。

静态数据区:存放的是全局变量与常量。这些变量的地址编译的时候就肯定了(这也是使用虚拟地址的好处,若是是物理地址,这些地址编译的时候是不可能肯定的)。Data与BSS都属于这一部分。这部分只有程序停止(kill掉、crasg掉等)才会被销毁。

栈区:主要是 Golang 里边的函数、方法以及其本地变量存储的地方。这部分伴随函数、方法开始执行而分配,运行完后就被释放,特别注意这里的释放并不会清空内存。后面文章讲内存分配的时候再详细说;还有一个点须要记住栈通常是从高地址向低地址方向分配,换句话说:高地址属于栈低,低地址属于栈底,它分配方向与堆是相反的。

堆区:像 C/C++ 语言,堆彻底是程序员本身控制的。可是 Golang 里边因为有GC机制,咱们写代码的时候并不须要关心内存是在栈仍是堆上分配。Golang 会本身判断若是变量的生命周期在函数退出后还不能销毁或者栈上资源不够分配等等状况,就会被放到堆上。堆的性能会比栈要差一些。缘由也留到内存分配相关的文章再给你们介绍。

内存的结构搞明白了,咱们的程序被加载到内存还须要操做系统来指挥才能正确运行。

补充一个比较重要的概念:

寻址空间:通常指的是CPU对于内存寻址的能力,通俗地说,就是能最多用到多少内存的一个问题。好比:32条地址线(32位机器),那么总的地址空间就有 2^32 个,若是是64位机器,就是 2^64 个寻址空间。可使用 uname -a 来查看本身系统支持的位数字。

操做系统、CPU、内存互相配合

为了讲清楚程序运行与调用,咱们得先理清楚操做系统、内存、CPU、寄存器这几者之间的关系。

  • CPU: 计算机的大脑,它才能理解并执行指令;
  • 寄存器:严格讲寄存器是CPU的组成部分,它主要负责CPU在计算时临时存储数据;固然CPU还有多级的高速缓存,与咱们这里相关度不大,就略过,你们知道其目的是为了弥补内存与CPU速度的差距便可;
  • 内存:像上面内存被划分红不一样区,每一部分存了不一样的数据;固然这些区的划分、以及虚拟内存与物理内存的映射都是操做系统来作的;
  • 操做系统:控制各类硬件资源,为其它运行的程序提供操做接口(系统调用)及管理。

这里操做系统是一个软件,CPU、寄存器、内存(物理内存)都是实打实的硬件。操做系统虽然也是一堆代码写出来的。可是她是硬件对其它应用程序的接口。总的来说操做系统经过系统调用控制全部的硬件资源,他把其它的程序调度到CPU上让其它程序执行,可是为了让每一个程序都有机会使用CPU,CPU又经过时间中断把控制权交给操做系统。

让操做系统能够控制咱们的程序,咱们编写的程序须要遵循操做系统的规定。这样操做系统才能控制程序执行、切换进程等操做。

最后咱们的代码被编译成机器码以后,本质就是一条条的指令。咱们指望的就是CPU去执行完这些指令进而完成任务。而操做系统又可以帮助咱们让CPU来执行代码以及提供所需资源的调用接口(系统调用)。是否是很是简单?

Go程序的调用规约

在上面咱们知道整个虚拟内存被咱们划分为:代码区、静态数据区、栈区、堆区。接下来要讲的Go程序的调用规约(其实就是函数、方法运行的规则),主要是涉及上面所说的栈部分(堆部分会在内存分配的文章里边去讲)。以及计算机软硬各个部分如何配合。接下来咱们就来看一下程序的基本单位函数跟方法是怎么执行与相互调用的。

函数在栈上的分布

这一部分,咱们先来了解一些理论,而后接着用一个实际的例子来分析一下。先经过一张图来看一下在 Golang 中函数是如何在栈上分布的。

几个涉及到的专业用语:

  • 栈:这里说的栈跟上面的解释含义一致。不管是进程、线程、goroutine都有本身的调用栈;
  • 栈帧:能够理解是函数调用时在栈上为函数所分配的区域;
  • 调用者:caller,好比:a函数调用了b函数,那么a就是调用者
  • 被调者:callee,仍是上面的例子,b就是被调者

栈帧

这幅图所展现的就是一个 栈帧 的结构。也能够说栈桢是栈给一个函数分配的栈空间,它包括了函数调用者地址、本地变量、返回值地址、调用者参数等信息。

这里有几个注意点,图中的 BPSP都表示对应的寄存器。

  • BP:基址指针寄存器(extended base pointer),也叫帧指针,存放着一个指针,表示函数栈开始的地方。
  • SP:栈指针寄存器(extended stack pointer),存放着一个指针,存储的是函数栈空间的栈顶,也就是函数栈空间分配结束的地方,注意这里是硬件寄存器,不是Plan9中的伪寄存器。

BPSP 放在一块儿,一个表示开始(栈顶)、一个表示结束(栈低)。

有了上面的基础知识,接着下面用实际的例子来验证一下。

Go的调用实例

才开始,咱们就从一个简单的函数开始来分析一下整个函数的调用过程(下面涉及到 Plan9 汇编,请别慌,大部分都可以看懂,而且我也会写注释)。

package main

func main() {
	a := 3
	b := 2
	returnTwo(a, b)
}

func returnTwo(a, b int) (c, d int) {
	tmp := 1 // 这一行的主要目的是保证栈桢不为0,方便分析
	c = a + b
	d = b - tmp
	return
}
复制代码

上面有两个函数,main 定义了两个本地变量,而后调用 returnTwo 函数。returnTwo 函数有两个参数与两个返回值。设计两个返回值主要是一块儿来看一下 golang 的多返回值是如何实现的。接下来咱们把上面的代码对应的汇编代码展现出来。

main函数

有几行代码须要特别解释下,

0x0000 00000 (test1.go:3)       TEXT    "".main(SB), ABIInternal, $56-0
复制代码

这一行中的重点信息:$56-056 表示的该函数栈桢大小(两个本地变量,两个参数是int类型,两个返回值是int类型,1个保存base pointer,合计7 * 8 = 56);0表示 mian 函数的参数与返回值大小。待会能够在 returnTwo 中去看一下它的返回值又是多少。

接下来在看一下计算机是怎么在栈上分配大小的。

0x000f 00015 (test1.go:3)       SUBQ    $56, SP // 分配,56的大小在上面第一行定义了
... ...
0x004b 00075 (test1.go:7)       ADDQ    $56, SP // 释放掉,可是并未清空
复制代码

这两行,一个是分配,一个是释放。为何用了 SUBQ 指令就能进行分配呢?而 ADDQ 是释放?记得咱们前面说过吗? SP 是一个指针寄存器,而且指向栈顶,栈又是从高地址向低地址分配。那么对它作一次减法,是否是表示从高地址向低地址方向移动指针了呢?释放也是一样的道理,一次加法操做又把 SP 恢复到初始状态。

再来看一下对 BP 寄存器的操做。

0x0013 00019 (test1.go:3)       MOVQ    BP, 48(SP) // 保存BP
0x0018 00024 (test1.go:3)       LEAQ    48(SP), BP // BP存放了新的地址
... ...
0x0046 00070 (test1.go:7)       MOVQ    48(SP), BP // 恢复BP的地址
复制代码

这三行代码是否是感受很变扭?写来写去让人云里雾里的。我先用文字描述一下,后面再用图来解释。

咱们先作以下假设:此时 BP 指向的 是:0x00ff,48(SP) 的 地址 是:0x0008。

  • 第一条指令 MOVQ BP, 48(SP) 是把 0x00ff 写入到 48(SP)的位置;
  • 第二条指令 LEAQ 48(SP), BP 是更新寄存器指针,让 BP 保存 48(SP) 这个位置的地址,也就是 0x00ff 这个值。
  • 第三条指令 MOVQ 48(SP), BP ,由于一开始 48(SP) 保存了最开始 BP 的所存的值 0x00ff,因此这里是又把 BP 恢复回去了。

这几行代码的做用相当重要,正由于如此在执行的时候,咱们才能找到函数开始的地方以及回到调用函数的位置,它才能够继续往下执行(若是以为饶,先放过,后面有图,看完后再回来理解)。接着来看一下 returnTwo 函数。

returnTwo函数汇编

这里 NOSPLIT|ABIInternal, $0-32 说明,该函数的栈桢大小是0,因为有两个int参数,以及2个int返回值,合计为 4*8 = 32 字节大小,是否是跟上面的 main 函数对上了?。

这里有没有对 returnTwo 函数的栈桢大小是0表示迷惑呢?难道这个函数不须要栈空间吗?其实主要缘由是:golang的参数传递与返回值都是要求使用栈来进行的(这也是为何go可以支持多参数返回的缘由)。因此参数与返回值所需空间都由 caller 来提供。

接下来,咱们用完整的图来演示一下这个调用过程。

函数调用

这个图就画了将近1个小时,但愿对你们理解有帮助。

整个的流程是:初始化 ----> call main function ----> call returnTwo function ----> returnTwo return ----> main return。

经过这张图,在结合我上面的文字解释,相信你们可以理解了。不过这里还有几个注意点:

  • BPSP 是寄存器,它保存的是栈上的地址,因此执行中能够对 SP 作运算找到下一个指令的位置;
  • 栈被回收 ADDQ $56, SP ,只是改变了 SP 指向的位置,内存中的数据并不会清空,只有下次被分配使用的时候才会清空;
  • callee的参数、返回值内存都是caller分配的;
  • returnTwo ret的时候,call returnTwo的next指令 所在栈位置会被弹出,也就是图中 0x0d00 地址所保存的指令,因此 returnTwo 函数返回后,SP 又指向了 0x0d08 地址。

因为上面涉及到一些 Plan9 的知识,就顺带一块儿介绍一些它的语法,若是直接讲语法会很枯燥,下面会结合一些实际中会用到的状况来介绍。既有收获又能学会语法。

Go的汇编plan9

咱们整个程序的编译最终会被翻译成机器码,而汇编能够算是机器码的文本形式,他们之间能够一一对应。因此若是咱们可以看懂汇编一点点就可以分析出不少实际问题。

开发go语言的都是当前世界最TOP的那群程序员,他们选择了持续装逼,不用标准的 AT&T 也不用 Intel 汇编器,偏要本身搞一套,没办法,谁让人家牛呢!Golang的汇编是基于 Plan9 汇编的,我的以为要彻底学懂太复杂了,由于这涉及到不少底层知识。不过若是只是要求看懂仍是可以作到的。下面咱们就举一些例子来试试看。

PS: 这东西彻底学懂也没有必要,投入产出比过低了,对于一个应用工程师可以看懂就行。

在正式开始前,咱们仍是补充一些必要信息,上文已经涉及过一些,为了完整这里在总体介绍一下。

几个重要的伪寄存器:

  • SB:是一个虚拟寄存器,保存了静态基地址(static-base) 指针,即咱们程序地址空间的开始地址;
  • NOSPLIT:向编译器代表不该该插入 stack-split 的用来检查栈须要扩张的前导指令;
  • FP:使用形如 symbol+offset(FP) 的方式,引用函数的输入参数;
  • SP:plan9 的这个 SP 寄存器指向当前栈帧的局部变量的开始位置,使用形如 symbol+offset(SP) 的方式,引用函数的局部变量,注意:这个寄存器与上文的寄存器是不同的,这里是伪寄存器,而咱们展现出来的都是硬件寄存器。

其它还有一些操做指令,根据名字多半都可以看出来,就再也不介绍,直接开始干。

查看go应用代码对应的翻译函数

package main

func main() {
}

func test() []string {
    a := make([]string, 10)
    return a
}

--------

"".test STEXT size=151 args=0x18 locals=0x40
        0x0000 00000 (test1.go:6)       TEXT    "".test(SB), ABIInternal, $64-24 // 栈帧大小,与参数、返回值大小
        0x0000 00000 (test1.go:6)       MOVQ    (TLS), CX
        0x0009 00009 (test1.go:6)       CMPQ    SP, 16(CX)
        0x000d 00013 (test1.go:6)       JLS     141
        0x000f 00015 (test1.go:6)       SUBQ    $64, SP
        0x0013 00019 (test1.go:6)       MOVQ    BP, 56(SP)
        0x0018 00024 (test1.go:6)       LEAQ    56(SP), BP
        ... ...
        0x001d 00029 (test1.go:6)       MOVQ    $0, "".~r0+72(SP)
        0x0026 00038 (test1.go:6)       XORPS   X0, X0
        0x0029 00041 (test1.go:6)       MOVUPS  X0, "".~r0+80(SP)
        0x002e 00046 (test1.go:7)       PCDATA  $2, $1
        0x002e 00046 (test1.go:7)       LEAQ    type.string(SB), AX
        0x0035 00053 (test1.go:7)       PCDATA  $2, $0
        0x0035 00053 (test1.go:7)       MOVQ    AX, (SP)
        0x0039 00057 (test1.go:7)       MOVQ    $10, 8(SP)
        0x0042 00066 (test1.go:7)       MOVQ    $10, 16(SP)
        0x004b 00075 (test1.go:7)       CALL    runtime.makeslice(SB) // 对应的底层runtime function
        ... ...
        0x008c 00140 (test1.go:8)       RET
        0x008d 00141 (test1.go:8)       NOP
        0x008d 00141 (test1.go:6)       PCDATA  $0, $-1
        0x008d 00141 (test1.go:6)       PCDATA  $2, $-1
        0x008d 00141 (test1.go:6)       CALL    runtime.morestack_noctxt(SB)
        0x0092 00146 (test1.go:6)       JMP     0
复制代码

根据对应的代码行数与名字,很明显的能够看到应用层写的 make 对应底层是 makeslice

逃逸分析

这里先说一下逃逸分析的概念。这里牵扯到栈、堆分配的问题。若是变量被分配到栈上,会伴随函数调用结束自动回收,而且分配效率很高;其次分配到堆上,则须要GC进行标记回收。所谓逃逸就是指变量从栈上逃到了堆上(不少人对这个概念都不清楚就在谈逃逸分析,面试遇到了好几回😓)。

package main

func main() {
}

func test() *int {
	t := 3
	return &t
}

------

"".test STEXT size=98 args=0x8 locals=0x20
        0x0000 00000 (test1.go:6)       TEXT    "".test(SB), ABIInternal, $32-8
        0x0000 00000 (test1.go:6)       MOVQ    (TLS), CX
        0x0009 00009 (test1.go:6)       CMPQ    SP, 16(CX)
        0x000d 00013 (test1.go:6)       JLS     91
        0x000f 00015 (test1.go:6)       SUBQ    $32, SP
        0x0013 00019 (test1.go:6)       MOVQ    BP, 24(SP)
        0x0018 00024 (test1.go:6)       LEAQ    24(SP), BP
        ... ...
        0x001d 00029 (test1.go:6)       MOVQ    $0, "".~r0+40(SP)
        0x0026 00038 (test1.go:7)       PCDATA  $2, $1
        0x0026 00038 (test1.go:7)       LEAQ    type.int(SB), AX
        0x002d 00045 (test1.go:7)       PCDATA  $2, $0
        0x002d 00045 (test1.go:7)       MOVQ    AX, (SP)
        0x0031 00049 (test1.go:7)       CALL    runtime.newobject(SB) // 堆上分配空间,表示逃逸了
        ... ...
复制代码

这里若是是对 slice 使用汇编进行逃逸分析,并不会很直观。由于只会看到调用了 runtime.makeslice 函数,该函数内部其实又调用了 runtime.mallocgc 函数,这个函数会分配的内存其实就是堆上的内存(若是栈上足够保存,是不会看到对 runtime.makslice 函数的调用)。

实际go也提供了更方便的命令来进行逃逸分析:go build -gcflags="-m",若是真的是作逃逸分析,建议使用该命令,别折腾用汇编。

传值仍是传指针

对于golang中的基本类型:字符串、整型、布尔类型就很少说了,确定是值传递,那么对于结构体、指针究竟是值传递仍是指针传递呢?

package main

type Student struct {
    name string
    age  int
}

func main() {
    jack := &Student{"jack", 30}
    test(jack)
}

func test(s *Student) *Student {
    return s
}

-------

"".test STEXT nosplit size=20 args=0x10 locals=0x0
        0x0000 00000 (test1.go:14)      TEXT    "".test(SB), NOSPLIT|ABIInternal, $0-16
        ... ...
        0x0000 00000 (test1.go:14)      MOVQ    $0, "".~r1+16(SP) // 初始返回值为0
        0x0009 00009 (test1.go:15)      PCDATA  $2, $1
        0x0009 00009 (test1.go:15)      PCDATA  $0, $1
        0x0009 00009 (test1.go:15)      MOVQ    "".s+8(SP), AX // 将引用地址复制到 AX 寄存器
        0x000e 00014 (test1.go:15)      PCDATA  $2, $0
        0x000e 00014 (test1.go:15)      PCDATA  $0, $2
        0x000e 00014 (test1.go:15)      MOVQ    AX, "".~r1+16(SP) // 将 AX 的引用地址又复制到返回地址
        0x0013 00019 (test1.go:15)      RET

复制代码

经过这里能够看到在go里边,只有值传递,由于它底层仍是经过拷贝对应的值。

总结

今天的文章到此结束,本次主要讲了下面几个点:

  1. 计算机软硬资源之间的相互配合;
  2. Golang 编写的代码,函数与方法是怎么执行的,主要讲了栈上分配与相关调用;
  3. 使用 Plan9 分析了一些常见的问题。

但愿本文对你们在理解、学习Go的路上有一些帮助。

参考资料

我的公众号:dayuTalk

相关文章
相关标签/搜索