通过上一篇关于Golang汇编原理的文章,我们知道了目标代码的生成经历了哪些过程。今天我们就来了解一下生成的目标代码是如何在计算机上执行的。并通过查看Golang的Plan9编译了解一些Golang的内部秘密。Golang的运行环境当我们运行编译好的Go代码时,它会以进程的形式出现在系统中。然后开始处理请求和数据,我们会看到这个进程占用的内存,cpu比例等信息。这篇文章就是讲解内存、CPU、操作系统(当然还有其他硬件,与本文无关,就不说了)在运行过程中如何协同完成我们代码指定的事情的程序。内存首先,让我们谈谈内存。我们先来看一个我们运行的go进程。代码如下:packagemainimport("fmt""log""net/http")funcmain(){http.HandleFunc("/",sayHello)err:=http.ListenAndServe(":9999",nil)iferr!=nil{log.Fatal("ListenAndServe:",err)}}funcsayHello(whttp.ResponseWriter,r*http.Request){fmt.Printf("fibonacci:%d\n",fibonacci(1000))_,_=fmt。Fprint(w,"HelloWorld!")}funcfibonacci(numint)int{ifnum<2{return1}returnfibonacci(num-1)+fibonacci(num-2)}看执行:dayu.com>psauxUSERPID%CPU%MEMVSZRSSTTSTATSTARTEDTIMECOMMANDxxxxx358499.20.143804564376s003R+8:33pm0:05.81./myhttp这里先不关注其他指标,先看VSZ和RSS。VSZ:指的是虚拟地址,也就是程序实际操作的内存。包含尚未使用的已分配内存。RSS:是实际的物理内存,包括栈内存和堆内存。每个进程都运行在自己的内存沙箱中,分配给程序的地址就是“虚拟内存”。物理内存实际上对程序开发者是不可见的,虚拟地址高于进程的实际物理地址。大得多。我们在编程中经常取的指针对应的地址其实就是一个虚拟地址。这里要注意虚拟内存和物理内存的区别。来一张图感受下吧。这张图主要是为了说明两个问题:程序使用了虚拟内存,但是操作系统会将虚拟内存映射到物理内存;你会发现你机器上所有进程的VSZ都大了很多;物理内存可以是多进程的,甚至一个进程内的不同地址也可能映射到同一个物理内存地址。上面说清楚了程序中的内存指的是什么。接下来解释程??序是如何使用内存(虚拟内存)的。说白了,内存就是一种比硬盘访问速度更快的硬件。为了便于内存管理,操作系统将分配给进程的内存划分为不同的功能块。就是我们常说的:代码区、静态数据区、堆区、栈区等。我们来看一张来自网上的图。下面是我们的程序(进程)在虚拟内存中的分布情况。代码区:存放我们编译好的机器码。一般来说,这个区域只能是只读的。静态数据区:存放全局变量和常量。这些变量的地址在编译的时候就确定了(这也是使用虚拟地址的好处,如果是物理地址,编译的时候就不能确定这些地址)。Data和BSS都属于这部分。这部分只有在程序终止时(kill、crasg等)才会被销毁。栈区:主要存放Golang中的函数、方法和局部变量。这部分在函数和方法开始执行时分配,运行完毕后释放。特别注意这里的释放并不会清除内存。内存分配我会在后面的文章中讲到;还有一点要记住,栈一般是从高地址向低地址分配的。也就是说:高地址属于栈底,低地址属于栈底。它的分配方向与A堆相同,相反。堆区:和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中是如何分布在栈上的。涉及到的几个技术名词:Stack:这里说的stack,和上面解释的意思是一样的。进程、线程和协程都有自己的调用栈;栈帧:可以理解为调用函数时在栈上为函数分配的区域;caller:调用者,例如:a函数调用b函数,则a为Caller;Callee:被调用者,还是上面的例子,b是被调用者。栈帧此图显示了栈帧的结构。也可以说,栈帧是栈为一个函数分配的栈空间,其中包括函数调用者地址、局部变量、返回值地址、调用者参数等信息。这里有几点需要注意。图中BP和SP都代表对应的寄存器。BP:扩展基指针寄存器(extendedbasepointer),也叫帧指针,存放的是一个指示函数栈从哪里开始的指针。SP:扩展栈指针寄存器(extendedstackpointer),存放指针,存放函数栈空间的栈顶,也就是函数栈空间分配结束的地方。注意这里是硬件寄存器,不是Plan9中的伪寄存器。BP和SP放在一起,一个用于开始(堆栈的顶部),一个用于结束(堆栈的底部)。有了上面的基础知识,下面我们用一个实际的例子来验证一下。刚刚开始的Go调用实例,先从一个简单的函数开始分析整个函数调用过程(以下涉及Plan9汇编,大家不要慌,大部分都能看懂,我也会写注释)。packagemainfuncmain(){a:=3b:=2returnTwo(a,b)}funcreturnTwo(a,bint)(c,dint){tmp:=1//这一行的主要目的是保证栈帧不0,方便分析c=a+bd=b-tmreturn}上面有两个函数,main定义了两个局部变量,然后调用了returnTwo函数。returnTwo函数有两个参数和两个返回值。两个返回值的设计主要是看golang的多个返回值是如何实现的。接下来,我们展示与上述代码对应的汇编代码。有几行代码需要特别说明,关键信息在0x000000000(test1.go:3)TEXT"".main(SB),ABIInternal,$56-0:$56-0这一行。56代表函数的栈帧大小(两个局部变量,两个参数是int类型,两个返回值是int类型,一个保存基指针,一共是7*8=56);0表示mian函数的参数大小和返回值。后面在returnTwo中可以看到它的返回值是多少。接下来,让我们看看计算机是如何在栈上分配大小的。0x000f00015(test1.go:3)SUBQ$56,SP//分配,上面第一行定义了56的大小……0x004b00075(test1.go:7)ADDQ$56,SP//释放了,但是这两行不清除,一条分配,一条释放。为什么用SUBQ指令就可以分配呢?ADDQ发布了吗?还记得我们之前说过的话吗?SP是一个指针寄存器,指向栈顶,栈是从高地址向低地址分配的。那么对它做一个减法,是不是意味着指针从高地址移动到了低地址呢?释放也是如此,一个加法操作将SP恢复到初始状态。我们来看看BP寄存器的操作。0x001300019(test1.go:3)MOVQBP,48(SP)//保存BP0x001800024(test1.go:3)LEAQ48(SP),BP//BP保存新地址……0x004600070(test1.go:7)MOVQ48(SP),BP//恢复BP的地址。这三行代码是不是感觉很别扭?写写使人迷惑。我先用文字描述一下,后面再用图片来解释。我们先做如下假设:此时BP指向的值是:0x00ff,48(SP)的地址是:0x0008。第一条指令MOVQBP,48(SP)是向48(SP)的位置写入0x00ff;第二条指令LEAQ48(SP),BP是更新寄存器指针,让BP保存48(SP)地址的位置,即值0x00ff。第三条指令MOVQ48(SP),BP,因为48(SP)一开始保存了原来BP中存储的值0x00ff,所以这里又恢复了BP。这几行代码的作用非常重要,正因如此,我们在执行的时候,可以找到函数开始的地方,回到调用函数的地方,这样就可以继续执行了(如果觉得不好意思,先放着吧,后面有图片,看完再回来理解)。接下来,让我们看一下returnTwo函数。这里NOSPLIT|ABIInternal,$0-32表示这个函数的栈帧大小为0,由于有两个int型参数和两个int型返回值,所以总大小为4*8=32字节。是不是和上面的main函数一样对?.这里有没有混淆returnTwo函数的堆栈帧大小为0?这个函数不需要堆栈空间吗?其实主要是:golang的参数传递和返回值都需要使用栈(这也是go可以支持多参数返回的原因)。因此,参数和返回值所需的空间由调用者提供。接下来,我们用一张完整的图来演示调用过程。这张图画了将近一个小时,希望对大家理解有所帮助。整个过程是:初始化---->调用main函数---->调用returnTwo函数---->returnTwo返回---->main返回。通过这张图,再结合我上面的文字解释,相信大家都能看懂了。不过这里还有几点需要注意:BP和SP是寄存器,存放的是栈上的地址,所以在执行的时候可以对SP进行运算,找到下一条指令所在的位置;堆栈被回收ADDQ$56,SP,只是改变了SP指向的位置,内存中的数据不会被清除,只有在下次分配和使用时才会被清除;callee的参数和返回值内存都是caller分配的;当returnTworet时,调用returnTwo的下一条指令所在的栈位置会被弹出,也就是图中存放在地址0x0d00的指令,所以returnTwo函数返回后,SP再次指向地址0x0d08。由于上面涉及到Plan9的一些知识,所以顺便介绍一下它的一些语法。如果直接讲语法,会很无聊。下面将介绍一些在实践中会用到的情况。既收获又学习语法。Go的汇编方案9我们整个程序的编译最终都会被翻译成机器码,而汇编可以看作是机器码的文本形式,它们之间是一一对应的。所以如果我们能稍微了解一下编译,我们就可以分析出很多实际问题。Go语言的开发者是目前世界上最TOP的程序员。他们选择继续假装好斗。他们不使用标准的AT&T或Intel汇编器。Golang的编译是基于Plan9的编译。个人觉得太复杂了,完全看不懂,因为涉及到很多底层知识。但如果你只是要求理解它,你仍然可以做到。让我们举一些例子来尝试。PS:这个东西没必要完全理解。投入产出比太低。一个应用工程师看懂就够了。在正式开始之前,我们还需要补充一些必要的信息,上面已经介绍了一些,为了完整起见,这里做一个整体的介绍。几个重要的伪寄存器SB:是一个虚拟寄存器,里面存放着静态基地址(static-base)指针,也就是我们程序地址空间的起始地址;NOSPLIT:向编译器表明不应该插入stack-split以检查堆栈是否需要扩展前导指令;FP:使用symbol+offset(FP)的形式来引用函数的输入参数;SP:plan9的SP寄存器指向当前栈帧的局部变量的开头,使用符号+offset(SP)的形式引用函数的局部变量。注意:该寄存器与上述寄存器不同。这里是一个伪寄存器,我们展示的都是硬件寄存器。还有一些其他的操作说明,大部分从名字就可以看出来,就不多介绍了,直接动手吧。查看翻译函数packagemainfuncmain(){}functest()[]string{a:=make([]string,10)returna}------"".testSTEXTsize=151args=0x18locals=0x400x000000000(test1.go:6)TEXT"".test(SB),ABIInternal,$64-24//栈帧大小,参数和返回值大小0x000000000(test1.go:6)MOVQ(TLS),CX0x000900009(test1.go:6)CMPQSP,16(CX)0x000d00013(test1.go:6)JLS1410x000f00015(test1.go:6)SUBQ$64,SP0x001300019(test1.go:6)MOVQBP,56(SP)0x001800024(test1.go:6)LEAQ56(SP),BP......0x001d00029(test1.go:6)MOVQ$0,"".~r0+72(SP)0x002600038(test1.go:6)XORPSX0,X00x002900041(test1.go:6)MOVUPSX0,"“.~r0+80(SP)0x002e00046(test1.go:7)PCDATA$2,$10x002e00046(test1.go:7)LEAQtype.string(SB),AX0x003500053(test1.go:7)PCDATA$2,$00x003500053(test1.go:7)MOVQAX,(SP)0x003900057(test1.go:7)MOVQ$10,8(SP)0x004200066(test1.go:7)MOVQ$10,16(SP)0x004b00075(test1.go:7)CALLruntime.makeslice(SB)//对应的底层runtime函数......0x008c00140(test1.go:8)RET0x008d00141(test1.go:8)NOP0x008d00141(test1.go:6)PCDATA$0,$-10x008d00141(test1.go:6)PCDATA$2,$-10x008d00141(test1.go:6)CALLruntime.morestack_noctxt(SB)0x009200146(test1.go:6)JMP0根据对应的代码行号和名称,很明显看到应用层写的make对应底层是makeslice逃逸分析。先说一下逃逸分析的概念。这就涉及到栈和堆的分配问题。如果变量是在栈上分配的,会随着函数调用结束自动回收,分配效率非常高;其次,如果分配在堆上,GC需要标记为回收。所谓逃逸是指变量从栈逃逸到堆(很多人不清楚这个概念,所以说的都是逃逸分析,我也遇到过好几次面试了😓)。packagemainfuncmain(){}functest()*int{t:=3return&t}------"".testSTEXTsize=98args=0x8locals=0x200x000000000(test1.go:6)TEXT"".test(SB),ABIInternal,$32-80x000000000(test1.go:6)MOVQ(TLS),CX0x000900009(test1.go:6)CMPQSP,16(CX)0x000d00013(test1.go:6)JLS910x000f00015(test1.go:6)SUBQ$32,SP0x001300019(test1.go:6)MOVQBP,24(SP)0x001800024(test1.go:6)LEAQ24(SP),BP......0x001d00029(test1.go:6)MOVQ$0,"".~r0+40(SP)0x002600038(test1.go:7)PCDATA$2,$10x002600038(test1.go:7)LEAQtype.int(SB),AX0x002d00045(test1.go:7)PCDATA$2,$00x002d00045(test1.go:7)MOVQAX,(SP)0x003100049(test1.go:7)CALLruntime.newobject(SB)//在heap上分配空间,意思是escape...如果用assembly对slice进行escape分析,会不会很直观.因为只调用了runtime.makeslice函数,所以该函数实际上调用的是runtime.mallocgc函数。这个函数分配的内存其实就是堆上的内存(如果栈上有足够的存储空间,就不会看到runtime..makslice函数调用)。其实go也提供了一个更方便的逃逸分析命令:gobuild-gcflags="-m",如果你真的要做逃逸分析,建议使用这个命令而不是使用assembly。传值还是传指针对于golang中的基本类型:字符串、整数、布尔类型,我就不多说了。它们必须按值传递。那么对于结构体和指针来说,到底应该传值还是指针传递呢?packagemaintypeStudentstruct{namestringageint}funcmain(){jack:=&Student{"jack",30}test(jack)}functest(s*Student)*Student{returns}------"".testSTEXTnosplitsize=20args=0x10locals=0x00x000000000(test1.go:14)TEXT"".test(SB),NOSPLIT|ABIInternal,$0-16......0x000000000(test1.go:14)MOVQ$0,"".~r1+16(SP)//初始返回值为00x000900009(test1.go:15)PCDATA$2,$10x000900009(test1.go:15)PCDATA$0,$10x000900009(test1.go:15)MOVQ"".s+8(SP),AX//将引用地址复制到AX寄存器0x000e00014(test1.go:15)PCDATA$2,$00x000e00014(test1.go:15)PCDATA$0,$20x000e00014(test1.go:15)MOVQAX,""。~r1+16(SP)//将AX的引用地址复制到返回地址0x001300019(test1.go:15)RET通过这个可以看到在go中,只传值,因为底层还是复制相应的值。总结今天的文章到此结束。这次主要讲了以下几点:计算机软硬件资源的配合;Golang写的代码,函数和方法如何执行,主要讲栈分配和相关调用;使用Plan9分析一些常见问题。希望这篇文章能帮助你理解和学习Go。
