本文使用 golang 1.17 代碼棺棵,如有任何問(wèn)題良蛮,還望指出哪怔。
Golang 代碼被操作系統(tǒng)運(yùn)行起來(lái)的流程
一灶伊、編譯
go 源代碼首先要通過(guò) go build 編譯為可執(zhí)行文件,在 linux 平臺(tái)上為 ELF 格式的可執(zhí)行文件肚逸,編譯階段會(huì)經(jīng)過(guò)編譯器爷辙、匯編器彬坏、鏈接器三個(gè)過(guò)程最終生成可執(zhí)行文件。
- 1膝晾、編譯器:*.go 源碼通過(guò) go 編譯器生成為 *.s 的 plan9 匯編代碼栓始,Go 編譯器入口是 compile/internal/gc/main.go 文件的 main 函數(shù);
- 2血当、匯編器:通過(guò) go 匯編器將編譯器生成的 *.s 匯編語(yǔ)言轉(zhuǎn)換為機(jī)器代碼幻赚,并寫(xiě)出最終的目標(biāo)程序 *.o 文件,src/cmd/internal/obj 包實(shí)現(xiàn)了go匯編器歹颓;
- 3坯屿、鏈接器:匯編器生成的一個(gè)個(gè) *.o 目標(biāo)文件通過(guò)鏈接處理得到最終的可執(zhí)行程序,src/cmd/link/internal/ld 包實(shí)現(xiàn)了鏈接器巍扛;
二、運(yùn)行
go 源碼通過(guò)上述幾個(gè)步驟生成可執(zhí)行文件后乏德,二進(jìn)制文件在被操作系統(tǒng)加載起來(lái)運(yùn)行時(shí)會(huì)經(jīng)過(guò)如下幾個(gè)階段:
1撤奸、從磁盤(pán)上把可執(zhí)行程序讀入內(nèi)存;
2喊括、創(chuàng)建進(jìn)程和主線(xiàn)程胧瓜;
3、為主線(xiàn)程分配椫J玻空間府喳;
4、把由用戶(hù)在命令行輸入的參數(shù)拷貝到主線(xiàn)程的棧蘑拯;
5钝满、把主線(xiàn)程放入操作系統(tǒng)的運(yùn)行隊(duì)列等待被調(diào)度執(zhí)起來(lái)運(yùn)行;
Golang 程序啟動(dòng)流程分析
1申窘、通過(guò) gdb 調(diào)試分析程序啟動(dòng)流程
此處以一個(gè)簡(jiǎn)單的 go 程序通過(guò)單步調(diào)試來(lái)分析其啟動(dòng)過(guò)程的流程:
main.go
package main
import "fmt"
func main() {
fmt.Println("hello world")
}
編譯該程序并使用 gdb 進(jìn)行調(diào)試弯蚜。使用 gdb 調(diào)試時(shí)首先在程序入口處設(shè)置一個(gè)斷點(diǎn),然后進(jìn)行單步調(diào)試即可看到該程序啟動(dòng)過(guò)程中的代碼執(zhí)行流程剃法。
$ go build -gcflags "-N -l" -o main main.go
$ gdb ./main
(gdb) info files
Symbols from "/home/gosoon/main".
Local exec file:
`/home/gosoon/main', file type elf64-x86-64.
Entry point: 0x465860
0x0000000000401000 - 0x0000000000497893 is .text
0x0000000000498000 - 0x00000000004dbb65 is .rodata
0x00000000004dbd00 - 0x00000000004dc42c is .typelink
0x00000000004dc440 - 0x00000000004dc490 is .itablink
0x00000000004dc490 - 0x00000000004dc490 is .gosymtab
0x00000000004dc4a0 - 0x0000000000534b90 is .gopclntab
0x0000000000535000 - 0x0000000000535020 is .go.buildinfo
0x0000000000535020 - 0x00000000005432e4 is .noptrdata
0x0000000000543300 - 0x000000000054aa70 is .data
0x000000000054aa80 - 0x00000000005781f0 is .bss
0x0000000000578200 - 0x000000000057d510 is .noptrbss
0x0000000000400f9c - 0x0000000000401000 is .note.go.buildid
(gdb) b *0x465860
Breakpoint 1 at 0x465860: file /home/gosoon/golang/go/src/runtime/rt0_linux_amd64.s, line 8.
(gdb) r
Starting program: /home/gaofeilei/./main
Breakpoint 1, _rt0_amd64_linux () at /home/gaofeilei/golang/go/src/runtime/rt0_linux_amd64.s:8
8 JMP _rt0_amd64(SB)
(gdb) n
_rt0_amd64 () at /home/gaofeilei/golang/go/src/runtime/asm_amd64.s:15
15 MOVQ 0(SP), DI // argc
(gdb) n
16 LEAQ 8(SP), SI // argv
(gdb) n
17 JMP runtime·rt0_go(SB)
(gdb) n
runtime.rt0_go () at /home/gaofeilei/golang/go/src/runtime/asm_amd64.s:91
91 MOVQ DI, AX // argc
......
231 CALL runtime·mstart(SB)
(gdb) n
hello world
[Inferior 1 (process 39563) exited normally]
通過(guò)單步調(diào)試可以看到程序入口函數(shù)在 runtime/rt0_linux_amd64.s
文件中的第 8 行碎捺,最終會(huì)執(zhí)行 CALL runtime·mstart(SB)
指令后輸出 “hello world” 然后程序就退出了。
啟動(dòng)流程流程中的函數(shù)調(diào)用如下所示:
rt0_linux_amd64.s -->_rt0_amd64 --> rt0_go-->runtime·settls -->runtime·check-->runtime·args-->runtime·osinit-->runtime·schedinit-->runtime·newproc-->runtime·mstart
2贷洲、golang 啟動(dòng)流程分析
上節(jié)通過(guò)gdb調(diào)試已經(jīng)看到了 golang 程序在啟動(dòng)過(guò)程中會(huì)執(zhí)行一系列的匯編指令收厨,本節(jié)會(huì)具體分析啟動(dòng)程序過(guò)程中每條指令的含義,了解了這些才能明白 golang 程序在啟動(dòng)過(guò)程中所執(zhí)行的操作优构。
src/runtime/rt0_linux_amd64.s
#include "textflag.h"
TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8
JMP _rt0_amd64(SB)
TEXT _rt0_amd64_linux_lib(SB),NOSPLIT,$0
JMP _rt0_amd64_lib(SB)
首先執(zhí)行的第8行即 JMP _rt0_amd64
诵叁,此處在 amd64 平臺(tái)下運(yùn)行,_rt0_amd64
函數(shù)所在的文件為 src/runtime/asm_amd64.s
俩块。
TEXT _rt0_amd64(SB),NOSPLIT,$-8
// 處理 argc 和 argv 參數(shù)黎休,argc 是指命令行輸入?yún)?shù)的個(gè)數(shù)浓领,argv 存儲(chǔ)了所有的命令行參數(shù)
MOVQ 0(SP), DI // argc
// argv 為指針類(lèi)型
LEAQ 8(SP), SI // argv
JMP runtime·rt0_go(SB)
_rt0_amd64
函數(shù)中將 argc 和 argv 兩個(gè)參數(shù)保存到 DI 和 SI 寄存器后跳轉(zhuǎn)到了 rt0_go
函數(shù),rt0_go
函數(shù)的主要作用:
- 1势腮、將 argc联贩、argv 參數(shù)拷貝到主線(xiàn)程棧上;
- 2捎拯、初始化全局變量 g0泪幌,為 g0 在主線(xiàn)程棧上分配大約 64K 棧空間署照,并設(shè)置 g0 的stackguard0祸泪,stackguard1,stack 三個(gè)字段建芙;
- 3没隘、執(zhí)行 CPUID 指令,探測(cè) CPU 信息禁荸;
- 4右蒲、執(zhí)行 nocpuinfo 代碼塊判斷是否需要初始化 cgo;
- 5赶熟、執(zhí)行 needtls 代碼塊瑰妄,初始化 tls 和 m0;
- 6映砖、執(zhí)行 ok 代碼塊间坐,首先將 m0 和 g0 綁定,然后調(diào)用
runtime·args
函數(shù)處理進(jìn)程參數(shù)和環(huán)境變量邑退,調(diào)用runtime·osinit
函數(shù)初始化 cpu 數(shù)量竹宋,調(diào)用runtime·schedinit
初始化調(diào)度器,調(diào)用runtime·newproc
創(chuàng)建第一個(gè) goroutine 執(zhí)行 main 函數(shù)瓜饥,調(diào)用runtime·mstart
啟動(dòng)主線(xiàn)程逝撬,主線(xiàn)程會(huì)執(zhí)行第一個(gè) goroutine 來(lái)運(yùn)行 main 函數(shù),此處會(huì)阻塞住直到進(jìn)程退出乓土;
TEXT runtime·rt0_go(SB),NOSPLIT|TOPFRAME,$0
// 處理命令行參數(shù)的代碼
MOVQ DI, AX // AX = argc
MOVQ SI, BX // BX = argv
// 將棧擴(kuò)大39字節(jié)宪潮,此處為什么擴(kuò)大39字節(jié)暫時(shí)還沒(méi)有搞清楚
SUBQ $(4*8+7), SP
ANDQ $~15, SP // 調(diào)整為 16 字節(jié)對(duì)齊
MOVQ AX, 16(SP) //argc放在SP + 16字節(jié)處
MOVQ BX, 24(SP) //argv放在SP + 24字節(jié)處
// 開(kāi)始初始化 g0,runtime·g0 是一個(gè)全局變量趣苏,變量在 src/runtime/proc.go 中定義狡相,全局變量會(huì)保存在進(jìn)程內(nèi)存空間的數(shù)據(jù)區(qū),下文會(huì)介紹查看 elf 二進(jìn)制文件中的代碼數(shù)據(jù)和全局變量的方法
// g0 的棧是從進(jìn)程棧內(nèi)存區(qū)進(jìn)行分配的食磕,g0 占用了大約 64k 大小尽棕。
MOVQ $runtime·g0(SB), DI // g0 的地址放入 DI 寄存器
LEAQ (-64*1024+104)(SP), BX // BX = SP - 64*1024 + 104
// 開(kāi)始初始化 g0 對(duì)象的 stackguard0,stackguard1,stack 這三個(gè)字段
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
執(zhí)行完以上指令后,進(jìn)程內(nèi)存空間布局如下所示:
然后開(kāi)始執(zhí)行獲取 cpu 信息的指令以及與 cgo 初始化相關(guān)的彬伦,此段代碼暫時(shí)可以不用關(guān)注滔悉。
// 執(zhí)行CPUID指令伊诵,嘗試獲取CPU信息,探測(cè) CPU 和 指令集的代碼
MOVL $0, AX
CPUID
MOVL AX, SI
CMPL 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·isIntel(SB)
MOVB $1, runtime·lfenceBeforeRdtsc(SB)
notintel:
// Load EAX=1 cpuid flags
MOVL $1, AX
CPUID
MOVL AX, runtime·processorVersionInfo(SB)
nocpuinfo:
// cgo 初始化相關(guān)回官,_cgo_init 為全局變量
MOVQ _cgo_init(SB), AX
// 檢查 AX 是否為 0
TESTQ AX, AX
// 跳轉(zhuǎn)到 needtls
JZ needtls
// arg 1: g0, already in DI
MOVQ $setg_gcc<>(SB), SI // arg 2: setg_gcc
CALL AX
// 如果開(kāi)啟了 CGO 特性曹宴,則會(huì)修改 g0 的部分字段
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)
下面開(kāi)始執(zhí)行 needtls
代碼塊,初始化 tls 和 m0歉提,tls 為線(xiàn)程本地存儲(chǔ)笛坦,在 golang 程序運(yùn)行過(guò)程中,每個(gè) m 都需要和一個(gè)工作線(xiàn)程關(guān)聯(lián)苔巨,那么工作線(xiàn)程如何知道其關(guān)聯(lián)的 m版扩,此時(shí)就會(huì)用到線(xiàn)程本地存儲(chǔ),線(xiàn)程本地存儲(chǔ)就是線(xiàn)程私有的全局變量侄泽,通過(guò)線(xiàn)程本地存儲(chǔ)可以為每個(gè)線(xiàn)程初始化一個(gè)私有的全局變量 m礁芦,然后就可以在每個(gè)工作線(xiàn)程中都使用相同的全局變量名來(lái)訪(fǎng)問(wèn)不同的 m 結(jié)構(gòu)體對(duì)象。后面會(huì)分析到其實(shí)每個(gè)工作線(xiàn)程 m 在剛剛被創(chuàng)建出來(lái)進(jìn)入調(diào)度循環(huán)之前就利用線(xiàn)程本地存儲(chǔ)機(jī)制為該工作線(xiàn)程實(shí)現(xiàn)了一個(gè)指向 m 結(jié)構(gòu)體實(shí)例對(duì)象的私有全局變量悼尾。
在后面代碼分析中宴偿,會(huì)經(jīng)常看到調(diào)用 getg
函數(shù)诀豁,getg
函數(shù)會(huì)從線(xiàn)程本地存儲(chǔ)中獲取當(dāng)前正在運(yùn)行的 g,這里獲取出來(lái)的 m 關(guān)聯(lián)的 g0窥妇。
tls 地址會(huì)寫(xiě)到 m0 中舷胜,而 m0 會(huì)和 g0 綁定,所以可以直接從 tls 中獲取到 g0活翩。
// 下面開(kāi)始初始化tls(thread local storage烹骨,線(xiàn)程本地存儲(chǔ)),設(shè)置 m0 為線(xiàn)程私有變量材泄,將 m0 綁定到主線(xiàn)程
needtls:
LEAQ runtime·m0+m_tls(SB), DI //DI = &m0.tls沮焕,取m0的tls成員的地址到DI寄存器
// 調(diào)用 runtime·settls 函數(shù)設(shè)置線(xiàn)程本地存儲(chǔ),runtime·settls 函數(shù)的參數(shù)在 DI 寄存器中
// 在 runtime·settls 函數(shù)中將 m0.tls[1] 的地址設(shè)置為 tls 的地址
// runtime·settls 函數(shù)在 runtime/sys_linux_amd64.s#599
CALL runtime·settls(SB)
// 此處是在驗(yàn)證本地存儲(chǔ)是否可以正常工作拉宗,確保值正確寫(xiě)入了 m0.tls峦树,
// 如果有問(wèn)題則 abort 退出程序
// get_tls 是宏,位于 runtime/go_tls.h
get_tls(BX) // 將 tls 的地址放入 BX 中,即 BX = &m0.tls[1]
MOVQ $0x123, g(BX) // BX = 0x123旦事,即 m0.tls[0] = 0x123
MOVQ runtime·m0+m_tls(SB), AX // AX = m0.tls[0]
CMPQ AX, $0x123
JEQ 2(PC) // 如果相等則向后跳轉(zhuǎn)兩條指令即到 ok 代碼塊
CALL runtime·abort(SB) // 使用 INT 指令執(zhí)行中斷
繼續(xù)執(zhí)行 ok 代碼塊魁巩,主要邏輯為:
- 將 m0 和 g0 進(jìn)行綁定,啟動(dòng)主線(xiàn)程姐浮;
- 調(diào)用
runtime·osinit
函數(shù)用來(lái)初始化 cpu 數(shù)量谷遂,調(diào)度器初始化時(shí)需要知道當(dāng)前系統(tǒng)有多少個(gè)CPU核; - 調(diào)用
runtime·schedinit
函數(shù)會(huì)初始化m0和p對(duì)象卖鲤,還設(shè)置了全局變量 sched 的 maxmcount 成員為10000肾扰,限制最多可以創(chuàng)建10000個(gè)操作系統(tǒng)線(xiàn)程出來(lái)工作畴嘶; - 調(diào)用
runtime·newproc
為main 函數(shù)創(chuàng)建 goroutine; - 調(diào)用
runtime·mstart
啟動(dòng)主線(xiàn)程集晚,執(zhí)行 main 函數(shù)窗悯;
// 首先將 g0 地址保存在 tls 中,即 m0.tls[0] = &g0甩恼,然后將 m0 和 g0 綁定
// 即 m0.g0 = g0, g0.m = m0
ok:
get_tls(BX) // 獲取tls地址到BX寄存器蟀瞧,即 BX = m0.tls[0]
LEAQ runtime·g0(SB), CX // CX = &g0
MOVQ CX, g(BX) // m0.tls[0]=&g0
LEAQ runtime·m0(SB), AX // AX = &m0
MOVQ CX, m_g0(AX) // m0.g0 = g0
MOVQ AX, g_m(CX) // g0.m = m0
CLD // convention is D is always left cleared
// check 函數(shù)檢查了各種類(lèi)型以及類(lèi)型轉(zhuǎn)換是否有問(wèn)題,位于 runtime/runtime1.go#137 中
CALL runtime·check(SB)
// 將 argc 和 argv 移動(dòng)到 SP+0 和 SP+8 的位置
// 此處是為了將 argc 和 argv 作為 runtime·args 函數(shù)的參數(shù)
MOVL 16(SP), AX
MOVL AX, 0(SP)
MOVQ 24(SP), AX
MOVQ AX, 8(SP)
// args 函數(shù)會(huì)從棧中讀取參數(shù)和環(huán)境變量等進(jìn)行處理
// args 函數(shù)位于 runtime/runtime1.go#61
CALL runtime·args(SB)
// osinit 函數(shù)用來(lái)初始化 cpu 數(shù)量条摸,函數(shù)位于 runtime/os_linux.go#301
CALL runtime·osinit(SB)
// schedinit 函數(shù)用來(lái)初始化調(diào)度器悦污,函數(shù)位于 runtime/proc.go#654
CALL runtime·schedinit(SB)
// 創(chuàng)建第一個(gè) goroutine 執(zhí)行 runtime.main 函數(shù)。獲取 runtime.main 的地址钉蒲,調(diào)用 newproc 創(chuàng)建 g
MOVQ $runtime·mainPC(SB), AX
PUSHQ AX // runtime.main 作為 newproc 的第二個(gè)參數(shù)入棧
PUSHQ $0 // newproc 的第一個(gè)參數(shù)入棧切端,該參數(shù)表示runtime.main函數(shù)需要的參數(shù)大小,runtime.main沒(méi)有參數(shù)顷啼,所以這里是0
// newproc 創(chuàng)建一個(gè)新的 goroutine 并放置到等待隊(duì)列里踏枣,該 goroutine 會(huì)執(zhí)行runtime.main 函數(shù), 函數(shù)位于 runtime/proc.go#4250
CALL runtime·newproc(SB)
// 彈出棧頂?shù)臄?shù)據(jù)
POPQ AX
POPQ AX
// mstart 函數(shù)會(huì)啟動(dòng)主線(xiàn)程進(jìn)入調(diào)度循環(huán)钙蒙,然后運(yùn)行剛剛創(chuàng)建的 goroutine茵瀑,mstart 會(huì)阻塞住,除非函數(shù)退出躬厌,mstart 函數(shù)位于 runtime/proc.go#1328
CALL runtime·mstart(SB)
CALL runtime·abort(SB) // mstart should never return
RET
// Prevent dead-code elimination of debugCallV2, which is
// intended to be called by debuggers.
MOVQ $runtime·debugCallV2<ABIInternal>(SB), AX
RET
此時(shí)進(jìn)程內(nèi)存空間布局如下所示:
查看 ELF 二進(jìn)制文件結(jié)構(gòu)
可以通過(guò) readelf 命令查看 ELF 二進(jìn)制文件的結(jié)構(gòu)马昨,可以看到二進(jìn)制文件中代碼區(qū)和數(shù)據(jù)區(qū)的內(nèi)容,全局變量保存在數(shù)據(jù)區(qū)扛施,函數(shù)保存在代碼區(qū)鸿捧。
$ readelf -s main | grep runtime.g0
1765: 000000000054b3a0 376 OBJECT GLOBAL DEFAULT 11 runtime.g0
// _cgo_init 為全局變量
$ readelf -s main | grep -i _cgo_init
2159: 000000000054aa88 8 OBJECT GLOBAL DEFAULT 11 _cgo_init
總結(jié)
本文主要介紹 Golang 程序啟動(dòng)流程中的關(guān)鍵代碼,啟動(dòng)過(guò)程的主要代碼是通過(guò) Plan9 匯編編寫(xiě)的疙渣,如果沒(méi)有做過(guò)底層相關(guān)的東西看起來(lái)還是非常吃力的匙奴,筆者對(duì)其中的一些細(xì)節(jié)也未完全搞懂,如果有興趣可以私下討論一些詳細(xì)的實(shí)現(xiàn)細(xì)節(jié)妄荔,其中有一些硬編碼的數(shù)字以及操作系統(tǒng)和硬件相關(guān)的規(guī)范理解起來(lái)相對(duì)比較困難泼菌。針對(duì) Golang runtime 中的幾大組件也會(huì)陸續(xù)寫(xiě)出相關(guān)的分析文章。
參考:
https://loulan.me/post/golang-boot/
https://mp.weixin.qq.com/s/W9D4Sl-6jYfcpczzdPfByQ
https://programmerall.com/article/6411655977/
https://ld246.com/article/1547651846124
https://zboya.github.io/post/go_scheduler/#mstartfn