啟動過程是理解 Go 語言執行時工作原理的關鍵。如果你想繼續深入瞭解 Go,那麼分析啟動過程就非常重要。因此第五部分就著重講解 Go 執行時,特別是 Go 程式的啟動過程。這一次你會學到如下的內容:
- Go 語言啟動過程
- 大小可變的棧是如何實現的
- TLS 的實現機制
請注意這篇部落格中會有很多彙編程式碼,你需要提前瞭解一下這方面的知識(Go 彙編器快速入門請參考這裡)。讓我們開始吧!
尋找入口點
首先需要找到啟動 Go 程式後執行的第一個函式。為了找到這個函式,我們寫了一個極其簡單的 Go 應用程式:
1 2 3 4 5 |
package main func main() { print(123) } |
然後,編譯並連結:
1 2 |
go tool 6g test.go go tool 6l test.6 |
這樣會在當前目錄下生成一個可執行檔案 6.out。下一步需要用到 objdump,這是一個 Linux 系統上的工具。在 windows 或者 Mac 上,你需要找類似的工具或者直接跳過這一步。執行下面的命令:
1 |
objdump -f 6.out |
你可以看到包含開始地址的輸出資訊:
1 2 3 4 |
6.out: file format elf64-x86-64 architecture: i386:x86-64, flags 0x00000112: EXEC_P, HAS_SYMS, D_PAGED start address 0x000000000042f160 |
接下來,我們要反彙編可執行程式,再找到在開始位置處到底是什麼函式:
1 |
objdump -d 6.out > disassemble.txt |
現在,我們可以開啟 disassemble.txt 檔案並搜尋 “42f160”,可以得到如下結果:
1 2 3 4 5 |
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 <main> 42f170: ff e0 jmpq *%rax |
很好,我們找到它了。在我的這臺電腦上(與 OS 以及機器的架構有關)入口點的函式為 _rt0_amd64_linux。
啟動順序
現在我們需要在 Go 執行時原始碼中找到這個函式對應的原始碼。它位於 rto_linux_arm64.s 這個檔案中。如果你去看一下 Go 語言執行時包,你會發現有很多檔名字首都和 OS 或者機器架構相關。當生成執行時包時,只有與當前系統和架構相關的檔案會被選用。而其餘的則會被略過。讓我們來看一下 rt0_linux_arm64.s:
1 2 3 4 5 6 7 8 9 |
TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8 LEAQ 8(SP), SI // argv MOVQ 0(SP), DI // argc MOVQ $main(SB), AX JMP AX TEXT main(SB),NOSPLIT,$-8 MOVQ $runtime·rt0_go(SB), AX JMP AX |
_rt0_amd64_linux 函式非常的簡單。它只是將引數(argc 與 argv )儲存到暫存器(DI 與 SI)中然後呼叫 main 函式。儲存在棧中的引數可以通過 SP(棧指標)訪問。main 函式也非常簡單。它只是呼叫了 runtime.rt0_go。runtime.rt0_go 函式就複雜一些了,所以我將其切分成幾個部分,再依次討論各部分。
第一部分是這樣的:
1 2 3 4 5 6 |
MOVQ DI, AX // argc MOVQ SI, BX // argv SUBQ $(4*8+7), SP // 2args 2auto ANDQ $~15, SP MOVQ AX, 16(SP) MOVQ BX, 24(SP) |
這裡,我們將之前儲存的命令列引數值分別放到 AX 與 BX 暫存器中。同時減小棧指標以增加兩個額外的四位元組變數並且將棧指標其調為 16 位元對齊。最後,將引數放回到棧中。
1 2 3 4 5 6 7 8 |
// create istack out of the given (operating system) stack. // _cgo_init may update stackguard. MOVQ $runtime·g0(SB), DI LEAQ (-64*1024+104)(SP), BX MOVQ BX, g_stackguard0(DI) MOVQ BX, g_stackguard1(DI) MOVQ BX, (g_stack+stack_lo)(DI) MOVQ SP, (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 函式的開始處會是這樣的:
1 2 3 4 5 6 7 8 |
"".main t=1 size=48 value=0 args=0x0 locals=0x8 0x0000 00000 (test.go:3) TEXT "".main+0(SB),$8-0 0x0000 00000 (test.go:3) MOVQ (TLS),CX 0x0009 00009 (test.go:3) CMPQ SP,16(CX) 0x000d 00013 (test.go:3) JHI ,22 0x000f 00015 (test.go:3) CALL ,runtime.morestack_noctxt(SB) 0x0014 00020 (test.go:3) JMP ,0 0x0016 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 函式中的程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
// find out information about the processor we're on MOVQ $0, AX CPUID CMPQ AX, $0 JE nocpuinfo // 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. CMPL BX, $0x756E6547 // "Genu" JNE notintel CMPL DX, $0x49656E69 // "ineI" JNE notintel CMPL CX, $0x6C65746E // "ntel" JNE notintel MOVB $1, runtime·lfenceBeforeRdtsc(SB) notintel: MOVQ $1, AX CPUID MOVL CX, runtime·cpuid_ecx(SB) MOVL DX, 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,讓我們繼續分析另外一部分程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// if there is an _cgo_init, call it. MOVQ _cgo_init(SB), AX TESTQ AX, AX JZ needtls // g0 already in DI MOVQ DI, CX // Win64 uses CX for first parameter MOVQ $setg_gcc<>(SB), SI CALL AX // update stackguard after _cgo_init MOVQ $runtime·g0(SB), CX MOVQ (g_stack+stack_lo)(CX), AX ADDQ $const__StackGuard, AX MOVQ AX, g_stackguard0(CX) MOVQ AX, g_stackguard1(CX) CMPL runtime·iswindows(SB), $0 JEQ ok |
這段程式碼只有在 cgo 被允許的情況下才會執行。cgo 相關的內容我會另外討論,我可能在後面的部落格中討論到這個主題。這兒,我們只是想明白基本的啟動工作流,所以我們先跳過這一部分。
下一段程式碼負責設定 TLS :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
needtls: // skip TLS setup on Plan 9 CMPL runtime·isplan9(SB), $1 JEQ ok // skip TLS setup on Solaris CMPL runtime·issolaris(SB), $1 JEQ ok LEAQ runtime·tls0(SB), DI CALL runtime·settls(SB) // store through it, to make sure it works get_tls(BX) MOVQ $0x123, g(BX) MOVQ runtime·tls0(SB), AX CMPQ AX, $0x123 JEQ 2(PC) MOVL AX, 0 // abort |
我前面就一直提到 TLS 。現在是時候搞明白它到底是如何實現的了。
TLS 內部實現
如果你仔細閱讀過前面的程式碼,很容易就會發現只有幾行是真正起作用的程式碼:
1 2 |
LEAQ runtime·tls0(SB), DI CALL runtime·settls(SB) |
所有其它的程式碼都是在你的系統不支援 TLS 時跳過 TLS 設定或者檢測 TLS 是否正常工作的程式碼。這兩行程式碼將 runtime.tlso 變數的地址儲存到 DI 暫存器中,然後呼叫 runtime.settls 函式。這個函式的程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 |
// set tls base to DI TEXT runtime·settls(SB),NOSPLIT,$32 ADDQ $8, DI // ELF wants to use -8(FS) MOVQ DI, SI MOVQ $0x1002, DI // ARCH_SET_FS MOVQ $158, AX // arch_prctl SYSCALL CMPQ AX, $0xfffffffffffff001 JLS 2(PC) MOVL $0xf1, 0xf1 // crash RET |
從註釋可以看出,這個函式執行了 arch_prctl 系統呼叫,並將 ARCH_SET_FS 作為引數傳入。我們也可以看到,系統呼叫使用 FS 暫存器儲存基址。在這個例子中,我們將 TLS 指向 runtime.tls0 變數。
還記得 main 開始時的彙編指令嗎?
1 |
0x0000 00000 (test.go:3) MOVQ (TLS),CX |
在前面我已經解釋了這條指令將 runtime.g 結構體例項的地址載入到 CX 暫存器中。這個結構體描述了當前 goroutine,且儲存到 TLS 中。現在我們明白了這條指令是如何被彙編成機器指令的了。開啟之前是建立的 disasembly.txt 檔案,搜尋 main.main 函式,你會看到其中第一條指令為:
1 |
400c00: 64 48 8b 0c 25 f0 ff mov %fs:0xfffffffffffffff0,%rcx |
這條指令中的冒號(%fs:0xfffffffffffffff0)表示段定址(更多內容請參考這裡)。
回到啟動過程
最後,讓我們看一下 runtime.rto_go 函式的最後兩部分:
1 2 3 4 5 6 7 8 9 10 11 |
ok: // set the per-goroutine and per-mach "registers" get_tls(BX) LEAQ runtime·g0(SB), CX MOVQ CX, g(BX) LEAQ runtime·m0(SB), AX // save m->g0 = g0 MOVQ CX, m_g0(AX) // save m0 to g0->m MOVQ AX, g_m(CX) |
這裡,我們將 TLS 地址載入到 BX 暫存器中,並將 runtime.g0 變數的地址儲存到 TLS 中。同時初始化 runtime.m0 變數。如果 runtime.g0 表示根 goroutine,那麼 runtime.m0 對應於執行這個 goroutine 的系統級執行緒。在後面的部落格中我們也許會更進一步介紹 runtime.g0 和 runtime.m0。
啟動過程的最後一部分就是初始化引數並呼叫不同的函式,不過這又是另外的主題了。
更多關於 Golang 的內容
我們已經學習了 Go 的啟動過程以及其棧實現的內部機制了。後面,我們需要分析啟動過程的最後一部分。這將是下一篇部落格的主題。如果你想及時看到部落格更新,請關注 @altoros。