本文內容和圖片均整理自互聯網。
原文地址:https://xiaomi-info.github.io/2019/11/27/golang-compiler-plan9/
版權歸原作者所有。
通過上一篇走進 Golang 之彙編原理,我們知道了目標代碼的生成經歷了那些過程。今天我們一起來學習一下生成的目標代碼如何在計算機上執行。以及通過查閱 Golang
的 Plan9 彙編來了解 Golang 的一些內部祕密。
Golang 的運行環境
當我們把編譯後的 Go 代碼運行起來,它會以進程的方式出現在系統中。然後開始處理請求、數據,我們會看到這個進程佔用了內存消耗、cpu 佔比等等信息。本文就是要來解釋在程序的運行過程中,內存、CPU、操作系統(當然還有其它的硬件,文中關係不大,就不說了)是如何進行配合,完成了我們代碼所指定的事情。
內存
首先,我們先來說說內存。先來看一個我們運行的 go 進程。
代碼如下:
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)
}
來看一下執行情況
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
這裏我們先來不關注其它指標,先來看 VSZ
與 RSS
。
- VSZ: 是指虛擬地址,他是程序實際操作的內存。包含了分配還沒有使用的內存。
- RSS: 是實際的物理內存,包含了棧內存與堆內存。
每一個進程都是運行在自己的內存沙盒裏,程序被分配的地址都是 “虛擬內存”,物理內存對程序開發者來說實際是不可見的,而且虛擬地址比進程實際的物理地址要大的多。我們經常編程中取指針對應的地址實際就是虛擬地址。這裏一定要注意區分虛擬內存與物理內存。來一張圖感受一下。
這張圖主要是爲了說明兩個問題:
- 程序使用的是虛擬內存,但是操作系統會把虛擬內存映射到物理內存;你會發現自己機器上所有進程的 VSZ 總和要比實際物理內存大得多;
- 物理內存可以被多個進程共享,甚至一個進程內的不同地址可能映射的都是同一個物理內存地址。
上面搞明白了程序中的內存具體是指什麼,接下來說明程序是如何使用內存的(虛擬內存),內存說白了就是比硬盤存取速度更快的一個硬件,爲了方便內存的管理,操作系統把分配給進程的內存劃分成了不同的功能塊。像我們經常說的:代碼區,靜態數據區,堆區,棧區等。
這裏借用一張網絡上的圖來看一下。
這裏就是我們程序(進程)在虛擬內存中的分佈。
代碼區:存放的就是我們編譯後的機器碼,一般來說這個區域只能是隻讀。
靜態數據區:存放的是全局變量與常量。這些變量的地址編譯的時候就確定了(這也是使用虛擬地址的好處,如果是物理地址,這些地址編譯的時候是不可能確定的)。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 就是被調者
這幅圖所展示的就是一個 棧幀
的結構。也可以說棧楨是棧給一個函數分配的棧空間,它包括了函數調用者地址、本地變量、返回值地址、調用者參數等信息。
這裏有幾個注意點,圖中的 BP
、SP
都表示對應的寄存器。
- BP:基址指針寄存器(extended base pointer),也叫幀指針,存放着一個指針,表示函數棧開始的地方。
- SP:棧指針寄存器(extended stack pointer),存放着一個指針,存儲的是函數棧空間的棧頂,也就是函數棧空間分配結束的地方,注意這裏是硬件寄存器,不是 Plan9 中的僞寄存器。
BP
與 SP
放在一起,一個表示開始(棧頂)、一個表示結束(棧底)。
有了上面的基礎知識,接着下面用實際的例子來驗證一下。
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
的多返回值是如何實現的。接下來我們把上面的代碼對應的彙編代碼展示出來。
有幾行代碼需要特別解釋下,
0x0000 00000 (test1.go:3) TEXT "".main(SB), ABIInternal, $56-0
這一行中的重點信息:$56-0
。56 表示的該函數棧楨大小(兩個本地變量,兩個參數是 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
函數。
這裏 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。
通過這張圖,在結合我上面的文字解釋,相信大家能夠理解了。不過這裏還有幾個注意點:
- BP 與 SP 是寄存器,它保存的是棧上的地址,所以執行中可以對
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 裏邊,只有值傳遞,因爲它底層還是通過拷貝對應的值。
總結
今天的文章到此結束,本次主要講了下面幾個點:
- 計算機軟硬資源之間的相互配合;
Golang
編寫的代碼,函數與方法是怎麼執行的,主要講了棧上分配與相關調用;- 使用
Plan9
分析了一些常見的問題。
希望本文對大家在理解、學習 Go 的路上有一些幫助。
參考資料
- [1]a visual guide to go memory allocator from scratch
- [2]a quick guide to go’s assembler
- [3]a quick guide to go’s assembler 中文版
- [4]go 和 plan9 彙編
- [5]plan9 assembly 完全解析
- [6]寄存器 wiki
- [7]go 函數調用 ━ 棧和寄存器視角
下回預告
到目前爲止,我們已經瞭解到 Go
代碼是怎麼生成機器碼的;機器碼又是如何在計算機中執行的,特別是函數對內存上棧區間的使用。接下來將會介紹 Golang
的內存分配策略是如何的。Gqi