這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。本文由 伯樂線上 - yhx 翻譯,黃利民 校稿。未經許可,禁止轉載!
英文出處:Sergey Matyukevich。歡迎加入翻譯組。
啟動過程是理解 Go 語言運行時工作原理的關鍵。如果你想繼續深入瞭解 Go,那麼分析啟動過程就非常重要。因此第五部分就著重講解 Go 運行時,特別是 Go 程式的啟動過程。這一次你會學到如下的內容:
- Go 語言啟動過程
- 大小可變的棧是如何?的
- TLS 的實現機制
請注意這篇部落格中會有很多彙編代碼,你需要提前瞭解一下這方面的知識(Go 彙編器快速入門請參考這裡)。讓我們開始吧!
尋找進入點
首先需要找到啟動 Go 程式後執行的第一個函數。為了找到這個函數,我們寫了一個極其簡單的 Go 應用程式:
package mainfunc main() {print(123)}
然後,編譯並連結:
go tool 6g test.gogo tool 6l test.6
這樣會在目前的目錄下產生一個可執行檔 6.out。下一步需要用到 objdump,這是一個 Linux 系統上的工具。在 windows 或者 Mac 上,你需要找類似的工具或者直接跳過這一步。運行下面的命令:
objdump -f 6.out
你可以看到包含開始地址的輸出資訊:
6.out: file format elf64-x86-64architecture: i386:x86-64, flags 0x00000112:EXEC_P, HAS_SYMS, D_PAGEDstart address 0x000000000042f160
接下來,我們要反組譯碼可執行程式,再找到在開始位置處到底是什麼函數:
objdump -d 6.out > disassemble.txt
現在,我們可以開啟 disassemble.txt 檔案並搜尋 “42f160”,可以得到如下結果:
000000000042f160 <_rt0_amd64_linux>: 42f160:48 8d 74 24 08 lea 0x8(%rsp),%rsi 42f165:48 8b 3c 24 mov (%rsp),%rdi 42f169:48 8d 05 10 00 00 00 lea 0x10(%rip),%rax # 42f180 42f170:ff e0 jmpq *%rax
很好,我們找到它了。在我的這台電腦上(與 OS 以及機器的架構有關)進入點的函數為 _rt0_amd64_linux。
啟動順序
現在我們需要在 Go 運行時源碼中找到這個函數對應的原始碼。它位於 rto_linux_arm64.s 這個檔案中。如果你去看一下 Go 語言運行時包,你會發現有很多檔案名稱首碼都和 OS 或者機器架構相關。當產生運行時包時,只有與當前系統和架構相關的檔案會被選用。而其餘的則會被略過。讓我們來看一下 rt0_linux_arm64.s:
TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8LEAQ8(SP), SI // argvMOVQ0(SP), DI // argcMOVQ$main(SB), AXJMPAXTEXT main(SB),NOSPLIT,$-8MOVQ$runtime·rt0_go(SB), AXJMPAX
_rt0_amd64_linux 函數非常的簡單。它只是將參數(argc 與 argv )儲存到寄存器(DI 與 SI)中然後調用 main 函數。儲存在棧中的參數可以通過 SP(棧指標)訪問。main 函數也非常簡單。它只是調用了 runtime.rt0_go。runtime.rt0_go 函數就複雜一些了,所以我將其切分成幾個部分,再依次討論各部分。
第一部分是這樣的:
MOVQDI, AX// argcMOVQSI, BX// argvSUBQ$(4*8+7), SP// 2args 2autoANDQ$~15, SPMOVQAX, 16(SP)MOVQBX, 24(SP)
這裡,我們將之前儲存的命令列參數值分別放到 AX 與 BX 寄存器中。同時減小棧指標以增加兩個額外的四位元組變數並且將棧指標其調為 16 位元對齊。最後,將參數放回到棧中。
// create istack out of the given (operating system) stack.// _cgo_init may update stackguard.MOVQ$runtime·g0(SB), DILEAQ(-64*1024+104)(SP), BXMOVQBX, g_stackguard0(DI)MOVQBX, g_stackguard1(DI)MOVQBX, (g_stack+stack_lo)(DI)MOVQSP, (g_stack+stack_hi)(DI)
第二部分更加巧妙。首先,我們將全域變數 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 函數的開始處會是這樣的:
"".main t=1 size=48 value=0 args=0x0 locals=0x80x0000 00000 (test.go:3)TEXT"".main+0(SB),$8-00x0000 00000 (test.go:3)MOVQ(TLS),CX0x0009 00009 (test.go:3)CMPQSP,16(CX)0x000d 00013 (test.go:3)JHI,220x000f 00015 (test.go:3)CALL,runtime.morestack_noctxt(SB)0x0014 00020 (test.go:3)JMP,00x0016 00022 (test.go:3)SUBQ$8,SP
首先,我們從 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 函數中的代碼:
// find out information about the processor we're onMOVQ$0, AXCPUIDCMPQAX, $0JEnocpuinfo// Figure out how to serialize RDTSC.// On Intel processors LFENCE is enough. AMD requires MFENCE.// Don't know about the rest, so let's do MFENCE.CMPLBX, $0x756E6547 // "Genu"JNEnotintelCMPLDX, $0x49656E69 // "ineI"JNEnotintelCMPLCX, $0x6C65746E // "ntel"JNEnotintelMOVB$1, runtime·lfenceBeforeRdtsc(SB)notintel:MOVQ$1, AXCPUIDMOVLCX, runtime·cpuid_ecx(SB)MOVLDX, runtime·cpuid_edx(SB)nocpuinfo:
這一部分對於理解主要的 Go 語言概念不是非常的重要,所以我們只是簡單的看一下。這段代碼旨在發現系統的 CPU 類型。如果是 Intel 類型,就設定 runtime·lfenceBeforeRdtsc 變數,此變數只是在 runtime.cputicks 中使用到。這個函數根據 runtime·lfenceBeforeRdtsc 使用不同的彙編指令獲得 cpu ticks 的值。最後,我們執行 CPUID 彙編指令並將結果儲存到 runtime.cpuid_ecx 與 runtime.cpuid_edx 中。這些變數都會被 alg.go 用來根據電腦的架構選擇合適的雜湊演算法。
OK,讓我們繼續分析另外一部分代碼:
// if there is an _cgo_init, call it.MOVQ_cgo_init(SB), AXTESTQAX, AXJZneedtls// g0 already in DIMOVQDI, CX// Win64 uses CX for first parameterMOVQ$setg_gcc<>(SB), SICALLAX// update stackguard after _cgo_initMOVQ$runtime·g0(SB), CXMOVQ(g_stack+stack_lo)(CX), AXADDQ$const__StackGuard, AXMOVQAX, g_stackguard0(CX)MOVQAX, g_stackguard1(CX)CMPLruntime·iswindows(SB), $0JEQ ok
這段代碼只有在 cgo 被允許的情況下才會執行。cgo 相關的內容我會另外討論,我可能在後面的部落格中討論到這個主題。這兒,我們只是想明白基本的啟動工作流程,所以我們先跳過這一部分。
下一段代碼負責設定 TLS :
needtls:// skip TLS setup on Plan 9CMPLruntime·isplan9(SB), $1JEQ ok// skip TLS setup on SolarisCMPLruntime·issolaris(SB), $1JEQ okLEAQruntime·tls0(SB), DICALLruntime·settls(SB)// store through it, to make sure it worksget_tls(BX)MOVQ$0x123, g(BX)MOVQruntime·tls0(SB), AXCMPQAX, $0x123JEQ 2(PC)MOVLAX, 0// abort
我前面就一直提到 TLS 。現在是時候搞明白它到底是如何?的了。
TLS 內部實現
如果你仔細閱讀過前面的代碼,很容易就會發現只有幾行是真正起作用的代碼:
LEAQruntime·tls0(SB), DICALLruntime·settls(SB)
所有其它的代碼都是在你的系統不支援 TLS 時跳過 TLS 設定或者檢測 TLS 是否正常工作的代碼。這兩行代碼將 runtime.tlso 變數的地址儲存到 DI 寄存器中,然後調用 runtime.settls 函數。這個函數的代碼如下:
// set tls base to DITEXT runtime·settls(SB),NOSPLIT,$32ADDQ$8, DI// ELF wants to use -8(FS)MOVQDI, SIMOVQ$0x1002, DI// ARCH_SET_FSMOVQ$158, AX// arch_prctlSYSCALLCMPQAX, $0xfffffffffffff001JLS2(PC)MOVL$0xf1, 0xf1 // crashRET
從注釋可以看出,這個函數執行了 arch_prctl 系統調用,並將 ARCH_SET_FS 作為參數傳入。我們也可以看到,系統調用使用 FS 寄存器儲存基址。在這個例子中,我們將 TLS 指向 runtime.tls0 變數。
還記得 main 開始時的彙編指令嗎?
0x0000 00000 (test.go:3)MOVQ(TLS),CX
在前面我已經解釋了這條指令將 runtime.g 結構體執行個體的地址載入到 CX 寄存器中。這個結構體描述了當前 goroutine,且儲存到 TLS 中。現在我們明白了這條指令是如何被彙編成機器指令的了。開啟之前是建立的 disasembly.txt 檔案,搜尋 main.main 函數,你會看到其中第一條指令為:
400c00: 64 48 8b 0c 25 f0 ff mov %fs:0xfffffffffffffff0,%rcx
這條指令中的冒號(%fs:0xfffffffffffffff0)表示段定址(更多內容請參考這裡)。
回到啟動過程
最後,讓我們看一下 runtime.rto_go 函數的最後兩部分:
ok:// set the per-goroutine and per-mach "registers"get_tls(BX)LEAQruntime·g0(SB), CXMOVQCX, g(BX)LEAQruntime·m0(SB), AX// save m->g0 = g0MOVQCX, m_g0(AX)// save m0 to g0->mMOVQAX, g_m(CX)
這裡,我們將 TLS 地址載入到 BX 寄存器中,並將 runtime.g0 變數的地址儲存到 TLS 中。同時初始化 runtime.m0 變數。如果 runtime.g0 表示根 goroutine,那麼 runtime.m0 對應於運行這個 goroutine 的系統級線程。在後面的部落格中我們也許會更進一步介紹 runtime.g0 和 runtime.m0。
啟動過程的最後一部分就是初始化參數並調用不同的函數,不過這又是另外的主題了。
更多關於 Golang 的內容
我們已經學習了 Go 的啟動過程以及其棧實現的內部機制了。後面,我們需要分析啟動過程的最後一部分。這將是下一篇部落格的主題。如果你想及時看到部落格更新,請關注 @altoros。