走進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

相關文章
相關標籤/搜索