Go語言內幕(5):執行時啟動過程

yhx發表於2015-10-20

啟動過程是理解 Go 語言執行時工作原理的關鍵。如果你想繼續深入瞭解 Go,那麼分析啟動過程就非常重要。因此第五部分就著重講解 Go 執行時,特別是 Go 程式的啟動過程。這一次你會學到如下的內容:

  • Go 語言啟動過程
  • 大小可變的棧是如何實現的
  • TLS  的實現機制

請注意這篇部落格中會有很多彙編程式碼,你需要提前瞭解一下這方面的知識(Go 彙編器快速入門請參考這裡)。讓我們開始吧!

尋找入口點

首先需要找到啟動 Go 程式後執行的第一個函式。為了找到這個函式,我們寫了一個極其簡單的 Go 應用程式:

然後,編譯並連結:

這樣會在當前目錄下生成一個可執行檔案 6.out。下一步需要用到 objdump,這是一個 Linux 系統上的工具。在 windows 或者 Mac 上,你需要找類似的工具或者直接跳過這一步。執行下面的命令:

你可以看到包含開始地址的輸出資訊:

接下來,我們要反彙編可執行程式,再找到在開始位置處到底是什麼函式:

現在,我們可以開啟 disassemble.txt 檔案並搜尋 “42f160”,可以得到如下結果:

很好,我們找到它了。在我的這臺電腦上(與 OS 以及機器的架構有關)入口點的函式為 _rt0_amd64_linux

啟動順序

現在我們需要在 Go 執行時原始碼中找到這個函式對應的原始碼。它位於 rto_linux_arm64.s 這個檔案中。如果你去看一下 Go 語言執行時包,你會發現有很多檔名字首都和 OS 或者機器架構相關。當生成執行時包時,只有與當前系統和架構相關的檔案會被選用。而其餘的則會被略過。讓我們來看一下 rt0_linux_arm64.s

_rt0_amd64_linux 函式非常的簡單。它只是將引數(argc 與 argv )儲存到暫存器(DI 與 SI)中然後呼叫 main 函式。儲存在棧中的引數可以通過 SP(棧指標)訪問。main 函式也非常簡單。它只是呼叫了 runtime.rt0_goruntime.rt0_go 函式就複雜一些了,所以我將其切分成幾個部分,再依次討論各部分。

第一部分是這樣的:

這裡,我們將之前儲存的命令列引數值分別放到 AX 與 BX 暫存器中。同時減小棧指標以增加兩個額外的四位元組變數並且將棧指標其調為 16 位元對齊。最後,將引數放回到棧中。

第二部分更加巧妙。首先,我們將全域性變數 runtime.g0 的地址載入到 DI 暫存器中。這變數定義在 proc1.go 檔案中,屬於 runtime.g 型別。Go 為系統中每個 goroutine 建立一個此型別變數。正如你猜測的那樣,runtime.g0 屬於根 goroutine。然後,我們初始化描述根 goroutine 棧的各個域。stack.lo 與 stack.hi 的含義應該很清楚。它們分別是當前 goroutine 棧的開始與結束指標,但是 stackguard0 與stackguard1 是什麼呢?為了搞明白兩個變數,我們要先將 runtime.rto_go 函式的分析放置一邊去看一下 Go 中棧增長的方式。

Go 中可變大小棧的實現

Go 語言使用可變大小的棧。每個 goroutine 開始都只有一個較小的棧,不過當已使用棧的大小達到某個閾值後棧的大小就會發生改變。顯然,這裡必然存在某種機制檢測棧的大小是否達到閾值。事實上,在每個函式開始的時候都會執行這樣的檢測。為了看一下到底是怎麼樣工作的,讓我們使用 -S 標誌再編譯一次我們的示例程式(這個標誌會顯示生成的彙編程式碼)。main 函式的開始處會是這樣的:

首先,我們從 TLS ( thread local storage) 變數中載入一個值至 CX 暫存器(我已經在前面的部落格中介紹了 TLS)。這個值是一個指標,該指標指向當前 goroutine 對應的 runteim.g 結構體。然後,我們將棧指標與 runtime.g 結構體中偏移 16 位元組處的值進行比較。因此我們可以知道該位置即是 stackguard0 域。

所以,這就是我們檢測是否到達棧閾值的方式。如果還沒有達到閾值,我們就一直呼叫 runtime.morestack_noctx 函式直到為棧分配足夠的空間為止。stackguard1 與 stackguard0 非常相似,但是它是用在 C 的棧增長中,而不是 Go 中。runtime.morestack_noctx 內部工作的機制也是非常有意思的內容,我們稍後會討論到這一部分。現在,我們回到啟動過程。

繼續 Go 啟動過程

在開始啟動過程前,我們先來看下面一段程式碼,這段程式碼是 runtime.rt0_go 函式中的程式碼:

這一部分對於理解主要的 Go 語言概念不是非常的重要,所以我們只是簡單的看一下。這段程式碼旨在發現系統的 CPU 型別。如果是 Intel 型別,就設定 runtime·lfenceBeforeRdtsc 變數,此變數只是在 runtime.cputicks 中使用到。這個函式根據 runtime·lfenceBeforeRdtsc 使用不同的彙編指令獲得 cpu ticks 的值。最後,我們執行 CPUID 彙編指令並將結果儲存到 runtime.cpuid_ecx 與 runtime.cpuid_edx 中。這些變數都會被 alg.go 用來根據計算機的架構選擇合適的雜湊演算法。

OK,讓我們繼續分析另外一部分程式碼:

這段程式碼只有在 cgo 被允許的情況下才會執行。cgo 相關的內容我會另外討論,我可能在後面的部落格中討論到這個主題。這兒,我們只是想明白基本的啟動工作流,所以我們先跳過這一部分。

下一段程式碼負責設定 TLS :

我前面就一直提到 TLS 。現在是時候搞明白它到底是如何實現的了。

TLS 內部實現

如果你仔細閱讀過前面的程式碼,很容易就會發現只有幾行是真正起作用的程式碼:

所有其它的程式碼都是在你的系統不支援 TLS 時跳過 TLS 設定或者檢測 TLS 是否正常工作的程式碼。這兩行程式碼將 runtime.tlso 變數的地址儲存到 DI 暫存器中,然後呼叫 runtime.settls 函式。這個函式的程式碼如下:

從註釋可以看出,這個函式執行了 arch_prctl 系統呼叫,並將 ARCH_SET_FS 作為引數傳入。我們也可以看到,系統呼叫使用 FS 暫存器儲存基址。在這個例子中,我們將 TLS 指向 runtime.tls0 變數。

還記得 main 開始時的彙編指令嗎?

在前面我已經解釋了這條指令將 runtime.g 結構體例項的地址載入到 CX 暫存器中。這個結構體描述了當前 goroutine,且儲存到 TLS 中。現在我們明白了這條指令是如何被彙編成機器指令的了。開啟之前是建立的 disasembly.txt 檔案,搜尋 main.main 函式,你會看到其中第一條指令為:

這條指令中的冒號(%fs:0xfffffffffffffff0)表示段定址(更多內容請參考這裡)。

回到啟動過程

最後,讓我們看一下 runtime.rto_go 函式的最後兩部分:

這裡,我們將 TLS 地址載入到 BX 暫存器中,並將 runtime.g0 變數的地址儲存到 TLS 中。同時初始化 runtime.m0 變數。如果 runtime.g0 表示根 goroutine,那麼 runtime.m0 對應於執行這個 goroutine 的系統級執行緒。在後面的部落格中我們也許會更進一步介紹 runtime.g0 和 runtime.m0。

啟動過程的最後一部分就是初始化引數並呼叫不同的函式,不過這又是另外的主題了。

更多關於 Golang 的內容

我們已經學習了 Go 的啟動過程以及其棧實現的內部機制了。後面,我們需要分析啟動過程的最後一部分。這將是下一篇部落格的主題。如果你想及時看到部落格更新,請關注 @altoros

相關文章