# 2.3 Go 程序啟動引導
Go 程序啟動后需要對自身運行時進行初始化,其真正的程序入口由 runtime 包控制。 以 AMD64 架構上的 Linux 和 macOS 為例,分別位于:`src/runtime/rt0_linux_amd64.s`和`src/runtime/rt0_darwin_amd64.s`。
```
TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8
JMP _rt0_amd64(SB)
TEXT _rt0_amd64_darwin(SB),NOSPLIT,$-8
JMP _rt0_amd64(SB)
```
可見,兩者均跳轉到了`_rt0_amd64`函數。這種做法符合直覺,在程序編譯為機器碼之后, 依賴特定 CPU 架構的指令集,而操作系統的差異則是直接反應在運行時進行不同的系統級操作上, 例如:系統調用。
> `rt0`其實是`runtime0`的縮寫,意為運行時的創生,隨后所有創建的都是`1`為后綴。
## 2.3.1 入口參數
操作系統通過入口參數的約定與應用程序進行溝通,為了支持從系統給運行時傳遞參數,Go 程序 在進行引導時將對這部分參數進行處理。程序剛剛啟動時,棧指針 SP 的前兩個值分別對應`argc`和`argv`,分別存儲參數的數量和具體的參數的值:
```
TEXT _rt0_amd64(SB),NOSPLIT,$-8
MOVQ 0(SP), DI // argc
LEAQ 8(SP), SI // argv
JMP runtime·rt0_go(SB)
TEXT runtime·rt0_go(SB),NOSPLIT,$0
// 將參數向前復制到一個偶數棧上
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)
// 初始化 g0 執行棧
MOVQ $runtime·g0(SB), DI // DI = g0
LEAQ (-64*1024+104)(SP), BX
MOVQ BX, g_stackguard0(DI) // g0.stackguard0 = SP + (-64*1024+104)
MOVQ BX, g_stackguard1(DI) // g0.stackguard1 = SP + (-64*1024+104)
MOVQ BX, (g_stack+stack_lo)(DI) // g0.stack.lo = SP + (-64*1024+104)
MOVQ SP, (g_stack+stack_hi)(DI) // g0.stack.hi = SP
// 確定 CPU 處理器的信息
MOVL $0, AX
CPUID // CPUID 會設置 AX 的值
MOVL AX, SI
(...)
```
## 2.3.2 線程本地存儲 TLS
確定完程序入口參數和 CPU 處理器信息之后,一個影響運行時非常重要的操作便是本地線程存儲 (Thread Local Storage, TLS)。
TEXT runtime·rt0_go(SB),NOSPLIT,$0
(...)
#ifdef GOOS_darwin
JMP ok // 在 Darwin 系統上跳過 TLS 設置
#endif
LEAQ runtime·m0+m_tls(SB), DI // DI = m0.tls
CALL runtime·settls(SB) // 將 TLS 地址設置到 DI
// 使用它進行存儲,確保能正常運行
MOVQ TLS, BX
MOVQ $0x123, g(BX)
MOVQ runtime·m0+m_tls(SB), AX
CMPQ AX, $0x123 // 判斷 TLS 是否設置成功
JEQ 2(PC) // 如果相等則向后跳轉兩條指令
CALL runtime·abort(SB) // 使用 INT 指令執行中斷
ok:
// 程序剛剛啟動,此時位于主線程
// 當前棧與資源保存在 g0
// 該線程保存在 m0
MOVQ TLS, BX
LEAQ runtime·g0(SB), CX
MOVQ CX, g(BX)
LEAQ runtime·m0(SB), AX
MOVQ CX, m_g0(AX) // m->g0 = g0
MOVQ AX, g_m(CX) // g0->m = m0
(...)
而`g0`和`m0`是一組全局變量,在程序運行之初就已經存在。 除了程序參數外,會首先將 m0 與 g0 通過指針互相關聯。
```
TEXT runtime·settls(SB),NOSPLIT,$32
ADDQ $8, DI // DI = DI + 8, ELF 格式使用 -8(FS)
MOVQ DI, SI // SI = DI
MOVQ $0x1002, DI // 0x1002 == ARCH_SET_FS
MOVQ $SYS_arch_prctl, AX
SYSCALL
CMPQ AX, $0xfffffffffffff001 // 驗證是否成功
JLS 2(PC)
MOVL $0xf1, 0xf1 // 崩潰
RET
```
可以看到到此函數進行`arch_prctl`系統調用并`ARCH_SET_FS`作為參數傳遞, 為 FS 段寄存器設置了基礎。
## 2.3.3 早期校驗與系統級初始化
在正式初始化運行時組件之前,還需要做一些校驗和系統級的初始化工作,這包括:運行時類型檢查, 系統參數的獲取以及影響內存管理和程序調度的相關常量的初始化。
```
TEXT runtime·rt0_go(SB),NOSPLIT,$0
(...)
CALL runtime·check(SB)
MOVL 16(SP), AX // 復制 argc
MOVL AX, 0(SP)
MOVQ 24(SP), AX // 復制 argv
MOVQ AX, 8(SP)
CALL runtime·args(SB)
CALL runtime·osinit(SB)
(...)
```
### 運行時類型檢查
首先要進行的便是運行時類型檢查,由`check`來完成。 其本質上基本上屬于對編譯器翻譯工作的一個校驗,顯然如果編譯器的編譯工作 不正確,運行時的運行過程便不是一個有效的過程。這里粗略展示整個函數的內容:
```
// runtime/runtime1.go
func check() {
var (
a int8
b uint8
(...)
)
(...)
// 校驗 int8 類型 sizeof 是否為 1,下同
if unsafe.Sizeof(a) != 1 { throw("bad a") }
if unsafe.Sizeof(b) != 1 { throw("bad b") }
(...)
}
```
### 系統參數、處理器與內存常量
`argc, argv`作為來自操作系統的參數傳遞給`args`處理程序參數的相關事宜。
```
// runtime/runtime1.go
func args(c int32, v **byte) {
argc = c
argv = v
sysargs(c, v)
}
```
**圖 5.1:ELF 格式的進程棧結構**
`args`函數將參數指針保存到了`argc`和`argv`這兩個全局變量中, 供其他初始化函數使用,而后調用了平臺特定的`sysargs`。 對于 Darwin 系統而言,只負責獲取程序的`executable_path`:
```
// runtime/os_darwin.go
//go:linkname executablePath os.executablePath
var executablePath string
func sysargs(argc int32, argv **byte) {
// 跳過 argv, envv 與第一個字符串為路徑
n := argc + 1
for argv_index(argv, n) != nil { n++ }
executablePath = gostringnocopy(argv_index(argv, n+1))
(...)
}
```
這個參數用于設置`os`包中的`executablePath`變量。
而在 Linux 平臺中,這個過程就變得復雜起來了。 與 Darwin 使用`mach-o`不同,Linux 使用 ELF 格式 \[Matz et al. 2014\]。 ELF 除了 argc, argv, envp 之外,會攜帶輔助向量(auxiliary vector) 將某些內核級的信息傳遞給用戶進程,例如**內存物理頁大小**。具體結構如圖 5.1 所示。
對照圖 5.1 的詞表,我們能夠很容易的看明白`sysargs`在 Linux amd64 下作的事情:
```
// runtime/os_linux.go
// physPageSize 是操作系統的內存物理頁字節大小。
// 內存頁的映射和反映射操作必須以 physPageSize 的整數倍完成
var physPageSize uintptr
func sysargs(argc int32, argv **byte) {
// 跳過 argv, envp 來獲取 auxv
n := argc + 1
for argv_index(argv, n) != nil { n++ }
n++ // 跳過 NULL 分隔符
// 嘗試讀取 auxv
auxv := (*[1 << 28]uintptr)(add(unsafe.Pointer(argv), uintptr(n)*sys.PtrSize))
if sysauxv(auxv[:]) != 0 {
return
}
// 處理無法讀取 auxv 的情況:
// 一種方法是嘗試讀取 /proc/self/auxv。
// 如果這個文件不存在,還可以嘗試調用 mmap 等內存分配的系統調用直接測試物理頁的大小。
(...)
}
func sysauxv(auxv []uintptr) int {
var i int
// 依次讀取 auxv 鍵值對
for ; auxv[i] != _AT_NULL; i += 2 {
tag, val := auxv[i], auxv[i+1]
switch tag {
case _AT_PAGESZ:
// 讀取內存頁的大小
physPageSize = val
// 這里其實也可能出現無法讀取到物理頁大小的情況,但后續再內存分配器初始化的時候還會對
// physPageSize 的大小進行檢查,如果讀取失敗則無法運行程序,從而拋出運行時錯誤
(...)
}
(...)
}
return i / 2
}
```
因此對于 Linux 而言,物理頁大小在`sysargs`中便能直接完成初始化。
最后是,`osinit`完成對 CPU 核心數的獲取,因為這與調度器有關。 而 Darwin 上由于使用的是`mach-o`格式,在此前的`sysargs`上 還沒有確定內存頁的大小,因而在這個函數中,還會額外使用`sysctl`完成物理頁大小的查詢。
```
var ncpu int32
// Linux
func osinit() {
ncpu = getproccount()
}
// Darwin
func osinit() {
ncpu = getncpu()
physPageSize = getPageSize() // 內部使用 sysctl 來獲取物理頁大小.
}
```
> `Darwin`從操作系統發展來看,是從 NeXTSTEP 和 FreeBSD 2.x 發展而來的后代, macOS 系統調用的特殊之處在于它提供了兩套調用接口,一個是 Mach 調用,另一個則是 POSIX 調用。 Mach 是 NeXTSTEP 遺留下來的產物,其 BSD 層本質上是對 Mach 內核的一層封裝。 盡管用戶態進程可以直接訪問 Mach 調用,但出于通用性的考慮, 物理頁大小獲取的方式是通過 POSIX`sysctl`這個系統調用進行獲取 \[Bacon, 2007\]。
>
> 事實上`Linux`與`Darwin`下的系統調用如何參與到 Go 程序中去稍有不同,我們暫時不做深入討論,留到以后再統一分析。
可以看出,對運行時最為重要的兩個系統級參數:CPU 核心數與內存物理頁大小。
## 2.3.4 運行時組件核心
萬事俱備只欠東風,對于 Go 運行時而言,最后的這三個函數及其后續 調用關系完整實現了整個程序的全部運行時機制的準備工作:
```
TEXT runtime·rt0_go(SB),NOSPLIT,$0
(...)
// 調度器初始化
CALL runtime·schedinit(SB)
// 創建一個新的 goroutine 來啟動程序
MOVQ $runtime·mainPC(SB), AX
PUSHQ AX
PUSHQ $0 // 參數大小
CALL runtime·newproc(SB)
POPQ AX
POPQ AX
// 啟動這個 M,mstart 應該永不返回
CALL runtime·mstart(SB)
(...)
RET
```
其中:
1. `schedinit`:進行各種運行時組件初始化工作,這包括我們的調度器與內存分配器、回收器的初始化
2. `newproc`:負責根據主 goroutine (即`main`)入口地址創建可被運行時調度的執行單元
3. `mstart`:開始啟動調度器的調度循環
編譯器負責生成了`main`函數的入口地址,`runtime.mainPC`在數據段中被定義為`runtime.main`保存主 goroutine 入口地址:
~~~
DATA runtime·mainPC+0(SB)/8,$runtime·main(SB)
GLOBL runtime·mainPC(SB),RODATA,$8
~~~
最后我們來大致瀏覽一下`schedinit`的全貌。`schedinit`函數名表面上是調度器的初始化,但實際上它包含了所有核心組件的初始化工作。
```
// src/runtime/proc.go
func schedinit() {
_g_ := getg()
(...)
// 棧、內存分配器、調度器相關初始化
sched.maxmcount = 10000 // 限制最大系統線程數量
stackinit() // 初始化執行棧
mallocinit() // 初始化內存分配器
mcommoninit(_g_.m) // 初始化當前系統線程
(...)
gcinit() // 垃圾回收器初始化
(...)
// 創建 P
// 通過 CPU 核心數和 GOMAXPROCS 環境變量確定 P 的數量
procs := ncpu
if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
procs = n
}
procresize(procs)
(...)
}
```
我們最感興趣的三大運行時組件在如下函數簽名中進行大量初始化工作:
* `stackinit()`goroutine 執行棧初始化
* `mallocinit()`內存分配器初始化
* `mcommoninit()`系統線程的部分初始化工作
* `gcinit()`垃圾回收器初始化
* `procresize()`根據 CPU 核心數,初始化系統線程的本地緩存
## 2.3.5 小結
我們通過一個簡化的調用關系圖來對本節中我們觀察到的程序啟動流程,如圖 5.2 所示。
**圖 5.2:Go 程序引導過程調用關系**
根據分析我們可以看到,Go 程序既不是從`main.main`直接啟動,也不是從`runtime.main`直接啟動。 相反,其實際的入口位于`runtime._rt0_amd64_*`。隨后會轉到`runtime.rt0_go`調用。在這個調用中,除了進行運行時類型檢查外,還確定了兩個很重要的運行時常量,即處理器核心數以及內存物理頁大小。
程序引導和初始化工作是整個運行時最關鍵的基礎步驟之一。在`schedinit`這個函數的調用過程中, 還會完成整個程序運行時的初始化,包括調度器、執行棧、內存分配器、調度器、垃圾回收器等組件的初始化。 最后通過`newproc`和`mstart`調用進而開始由調度器轉為執行主 goroutine。
運行時組件的內容我們留到組件各自的章節中進行討論,我們在下一節中著先著重討論當一切都初始化好后, 程序的正式啟動過程,即`runtime.main`。
## 進一步閱讀的參考文獻
* \[Matz et al. 2014\] Michael Matz, Jan Hubicka, Andreas Jaeger, Mark Mitchell. System V Application Binary Interface: AMD64 Architecture Processor Supplement. Nov, 2017.[https://www.uclibc.org/docs/psABI-x86\_64.pdf](https://www.uclibc.org/docs/psABI-x86_64.pdf)
* \[Bacon, 2007\] Jean Bacon. UNIX family tree. Operating System Foundations Lecture Notes, part 4. Last access: Jan, 2020.[https://www.cl.cam.ac.uk/teaching/0708/OSFounds/P04-4.pdf](https://www.cl.cam.ac.uk/teaching/0708/OSFounds/P04-4.pdf)
- 第一部分 :基礎篇
- 第1章 Go語言的前世今生
- 1.2 Go語言綜述
- 1.3 順序進程通訊
- 1.4 Plan9匯編語言
- 第2章 程序生命周期
- 2.1 從go命令談起
- 2.2 Go程序編譯流程
- 2.3 Go 程序啟動引導
- 2.4 主Goroutine的生與死
- 第3 章 語言核心
- 3.1 數組.切片與字符串
- 3.2 散列表
- 3.3 函數調用
- 3.4 延遲語句
- 3.5 恐慌與恢復內建函數
- 3.6 通信原語
- 3.7 接口
- 3.8 運行時類型系統
- 3.9 類型別名
- 3.10 進一步閱讀的參考文獻
- 第4章 錯誤
- 4.1 問題的演化
- 4.2 錯誤值檢查
- 4.3 錯誤格式與上下文
- 4.4 錯誤語義
- 4.5 錯誤處理的未來
- 4.6 進一步閱讀的參考文獻
- 第5章 同步模式
- 5.1 共享內存式同步模式
- 5.2 互斥鎖
- 5.3 原子操作
- 5.4 條件變量
- 5.5 同步組
- 5.6 緩存池
- 5.7 并發安全散列表
- 5.8 上下文
- 5.9 內存一致模型
- 5.10 進一步閱讀的文獻參考
- 第二部分 運行時篇
- 第6章 并發調度
- 6.1 隨機調度的基本概念
- 6.2 工作竊取式調度
- 6.3 MPG模型與并發調度單
- 6.4 調度循環
- 6.5 線程管理
- 6.6 信號處理機制
- 6.7 執行棧管理
- 6.8 協作與搶占
- 6.9 系統監控
- 6.10 網絡輪詢器
- 6.11 計時器
- 6.12 非均勻訪存下的調度模型
- 6.13 進一步閱讀的參考文獻
- 第7章 內存分配
- 7.1 設計原則
- 7.2 組件
- 7.3 初始化
- 7.4 大對象分配
- 7.5 小對象分配
- 7.6 微對象分配
- 7.7 頁分配器
- 7.8 內存統計
- 第8章 垃圾回收
- 8.1 垃圾回收的基本想法
- 8.2 寫屏幕技術
- 8.3 調步模型與強弱觸發邊界
- 8.4 掃描標記與標記輔助
- 8.5 免清掃式位圖技術
- 8.6 前進保障與終止檢測
- 8.7 安全點分析
- 8.8 分代假設與代際回收
- 8.9 請求假設與實務制導回收
- 8.10 終結器
- 8.11 過去,現在與未來
- 8.12 垃圾回收統一理論
- 8.13 進一步閱讀的參考文獻
- 第三部分 工具鏈篇
- 第9章 代碼分析
- 9.1 死鎖檢測
- 9.2 競爭檢測
- 9.3 性能追蹤
- 9.4 代碼測試
- 9.5 基準測試
- 9.6 運行時統計量
- 9.7 語言服務協議
- 第10章 依賴管理
- 10.1 依賴管理的難點
- 10.2 語義化版本管理
- 10.3 最小版本選擇算法
- 10.4 Vgo 與dep之爭
- 第12章 泛型
- 12.1 泛型設計的演進
- 12.2 基于合約的泛型
- 12.3 類型檢查技術
- 12.4 泛型的未來
- 12.5 進一步閱讀的的參考文獻
- 第13章 編譯技術
- 13.1 詞法與文法
- 13.2 中間表示
- 13.3 優化器
- 13.4 指針檢查器
- 13.5 逃逸分析
- 13.6 自舉
- 13.7 鏈接器
- 13.8 匯編器
- 13.9 調用規約
- 13.10 cgo與系統調用
- 結束語: Go去向何方?