Golang 程序啟動(dòng)流程分析

本文使用 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ī)器代碼,并寫出最終的目標(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、從磁盤上把可執(zhí)行程序讀入內(nèi)存;

  • 2、創(chuàng)建進(jìn)程和主線程;

  • 3、為主線程分配棧空間;

  • 4、把由用戶在命令行輸入的參數(shù)拷貝到主線程的棧;

  • 5、把主線程放入操作系統(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 為指針類型
    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ù)拷貝到主線程棧上;
  • 2、初始化全局變量 g0,為 g0 在主線程棧上分配大約 64K ??臻g,并設(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)主線程,主線程會(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 為線程本地存儲(chǔ),在 golang 程序運(yùn)行過(guò)程中,每個(gè) m 都需要和一個(gè)工作線程關(guān)聯(lián),那么工作線程如何知道其關(guān)聯(lián)的 m,此時(shí)就會(huì)用到線程本地存儲(chǔ),線程本地存儲(chǔ)就是線程私有的全局變量,通過(guò)線程本地存儲(chǔ)可以為每個(gè)線程初始化一個(gè)私有的全局變量 m,然后就可以在每個(gè)工作線程中都使用相同的全局變量名來(lái)訪問(wèn)不同的 m 結(jié)構(gòu)體對(duì)象。后面會(huì)分析到其實(shí)每個(gè)工作線程 m 在剛剛被創(chuàng)建出來(lái)進(jìn)入調(diào)度循環(huán)之前就利用線程本地存儲(chǔ)機(jī)制為該工作線程實(shí)現(xiàn)了一個(gè)指向 m 結(jié)構(gòu)體實(shí)例對(duì)象的私有全局變量。

在后面代碼分析中,會(huì)經(jīng)常看到調(diào)用 getg 函數(shù),getg 函數(shù)會(huì)從線程本地存儲(chǔ)中獲取當(dāng)前正在運(yùn)行的 g,這里獲取出來(lái)的 m 關(guān)聯(lián)的 g0。

tls 地址會(huì)寫到 m0 中,而 m0 會(huì)和 g0 綁定,所以可以直接從 tls 中獲取到 g0。

// 下面開(kāi)始初始化tls(thread local storage,線程本地存儲(chǔ)),設(shè)置 m0 為線程私有變量,將 m0 綁定到主線程
needtls:
    LEAQ    runtime·m0+m_tls(SB), DI  //DI = &m0.tls,取m0的tls成員的地址到DI寄存器

    // 調(diào)用 runtime·settls 函數(shù)設(shè)置線程本地存儲(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ǔ)是否可以正常工作,確保值正確寫入了 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)主線程;
  • 調(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)線程出來(lái)工作;
  • 調(diào)用 runtime·newproc 為main 函數(shù)創(chuàng)建 goroutine;
  • 調(diào)用 runtime·mstart 啟動(dòng)主線程,執(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ù)檢查了各種類型以及類型轉(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)主線程進(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 匯編編寫的,如果沒(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ù)寫出相關(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

https://blog.csdn.net/yockie/article/details/79166713

https://blog.csdn.net/ocean_1996/article/details/107088530

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容