深入了解Go語言編譯鏈接的過程
1 前言
interface、channel的文章中經(jīng)常會提到,Go在編譯時會將interface和channel關(guān)鍵字轉(zhuǎn)換成runtime中的結(jié)構(gòu)和函數(shù)調(diào)用。所以我覺得很有必要就Go的編譯過程理一理做個進(jìn)行總結(jié),然后結(jié)合之前對底層原理總結(jié)的文章,那么對整個邏輯會更加清晰。我也是查了各種資料,盡量把整個過程能總起出一些東西來,學(xué)習(xí)嘛,總是需要不斷總結(jié),分享!
1.1 什么是ASCII字符
ASCII 代表美國信息交換標(biāo)準(zhǔn)代碼,用于電子通信。在計(jì)算機(jī)內(nèi)部,所有信息最終都是一個二進(jìn)制值。每一個二進(jìn)制位(bit)有0和1兩種狀態(tài),因此八個二進(jìn)制位就可以組合出256種狀態(tài),這被稱為一個字節(jié)(byte)。也就是說,一個字節(jié)一共可以用來表示256種不同的狀態(tài),每一個狀態(tài)對應(yīng)一個符號,就是256個符號,從00000000到11111111。 上個世紀(jì)60年代,美國制定了一套字符編碼,對英語字符與二進(jìn)制位之間的關(guān)系,做了統(tǒng)一規(guī)定,這被稱為 ASCII 碼,一直沿用至今。
它使用整數(shù)對數(shù)字(0-9)、大寫字母(AZ)、小寫字母(az)和分號(;)、感嘆號(?。┑确栠M(jìn)行編碼。整數(shù)比字母或字母更容易存儲在電子設(shè)備中符號。例如,97用于表示“a”,33用于表示“!” 并且可以很容易地存儲在內(nèi)存中。
我們知道Go是采用UTF-8是編碼規(guī)則, 和ASCII碼之間的聯(lián)系呢?了解UTF-8之前我們先了解Unicode,因?yàn)锳SCII碼只能表示英語,不能表示其他語言。Unicode 為世界上所有字符都分配了一個唯一的數(shù)字編號,但是Unicode 只是一個符號集,它只規(guī)定了符號的二進(jìn)制代碼,卻沒有規(guī)定這個二進(jìn)制代碼應(yīng)該如何存儲。
因?yàn)榛ヂ?lián)網(wǎng)的普及,UTF-8 就是在互聯(lián)網(wǎng)上使用最廣的一種 Unicode 的實(shí)現(xiàn)方式。Unicode的編碼規(guī)則,對于單字節(jié)的符號,字節(jié)的第一位設(shè)為0,后面7位為這個符號的 Unicode 碼,因此對于英語字母,UTF-8 編碼和 ASCII 碼是相同的。
對于ASCII碼、Unicode、UTF-8之間的聯(lián)系就不展開更多更細(xì)的總結(jié),用個栗子來說明下我們編寫的Go程序文件和編碼之間關(guān)系。
1.2 圖說ASCII碼和Go程序文件
這里參考下一個網(wǎng)絡(luò)上的圖片說明,首先我們Go代碼Hello.go如下
package main import "fmt" func main() { fmt.Println("hello world") }
我們知道我們用心敲下的每一行代碼都是字節(jié)序列,然后每個字節(jié)代表一個字符,代碼和ASCII碼之間的對應(yīng)關(guān)系就是中間一列代表文本對應(yīng)的 ASCII 字符,最右邊的列就是我們的代碼,跟下面的對照表示一一對應(yīng)的,比如hello.go文件的首字母p的值是對應(yīng)的就是70。
16進(jìn)制查看文件內(nèi)容
ASCII碼對照表
hello.go 文件都是由 ASCII 字符表示的,它被稱為文本文件,8個bit看成一個單位,假定源程序都是ASCII碼,轉(zhuǎn)換為我們?nèi)祟惗寄芨美斫獾膅o程序,那么到這里編碼和程序文件之間的關(guān)系已經(jīng)清楚了,接下來就從編譯和鏈接過程來看有哪些步驟,然后每一個步驟做了什么!
2 編譯過程
我們知道Go 程序并不能直接運(yùn)行,每條 Go 語句必須轉(zhuǎn)化為一系列的低級機(jī)器語言指令,將這些指令打包到一起,并以二進(jìn)制磁盤文件的形式存儲起來,也就是可執(zhí)行目標(biāo)文件。這個過程就涉及到對源文件進(jìn)行詞法分析、語法分析、語義分析、優(yōu)化,最后生成匯編代碼文件(以.s作為文件后綴),再經(jīng)過匯編器將匯編文件生成.o二進(jìn)制程序,最后經(jīng)過鏈接器轉(zhuǎn)換成可執(zhí)行的目標(biāo)程序(比如windows下的.exe程序)。
源文件編譯為執(zhí)行程序的過程
編譯過程
2.1 詞法分析
詞法分析(lexical analysis)維基百科上給出的定義:是計(jì)算機(jī)科學(xué)中將字符序列轉(zhuǎn)換為標(biāo)記(token)序列的過程。進(jìn)行詞法分析的程序或者函數(shù)叫作詞法分析器(lexical analyzer,簡稱lexer),也叫掃描器(scanner)。詞法分析器一般以函數(shù)的形式存在,供語法分析器調(diào)。
Go在編譯源碼時首先,由詞法分析器(lexer)對源代碼文件進(jìn)行解析,將文件中的字符串序列轉(zhuǎn)為Token序列(在src/cmd/compile/internal/syntax/tokens.go),token包含標(biāo)識符、關(guān)鍵字、特殊符號等都是以常量的形式存在。
const ( _ token = iota _EOF // EOF // names and literals _Name // name _Literal // literal // operators and operations // _Operator is excluding '*' (_Star) _Operator // op _AssignOp // op= _IncOp // opop _Assign // = _Define // := _Arrow // <- _Star // *
而掃描代碼在(src/cmd/compile/internal/syntax/scanner.go),通過核心的next()函數(shù),不斷讀取下一個函數(shù),然后通過一個大的switch-case來匹配比如換行、字符串、括號等標(biāo)識符將其轉(zhuǎn)換為token,從而完成一次解析。
func (s *scanner) next() { for s.ch == ' ' || s.ch == '\t' || s.ch == '\n' && !nlsemi || s.ch == '\r' { s.nextch() } ... switch s.ch { case -1: if nlsemi { s.lit = "EOF" s.tok = _Semi break } s.tok = _EOF case '\n': s.nextch() s.lit = "newline" s.tok = _Semi case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': s.number(false) case '"': s.stdString() case '`': s.rawString() ... }
2.2 語法分析
語法分析的輸入是詞法分析器輸出的 Token 序列,語法分析器會按照順序解析 Token 序列,該過程會將詞法分析生成的 Token 按照編程語言定義好的文法(Grammar)自下而上或者自上而下的規(guī)約,
轉(zhuǎn)換成有意義的結(jié)構(gòu)體,即抽象語法樹(AST【Abstract syntax tree】)。每一個 Go 的源代碼文件最終會被解析成一個獨(dú)立的抽象語法樹歸納成一個source file結(jié)構(gòu)(語法樹最頂層的結(jié)構(gòu)或者開始符號都是 SourceFile)
SourceFile = PackageClause ";" { ImportDecl ";" } { TopLevelDecl ";" } .
詞法分析會返回一個不包含空格、換行等字符的 Token 序列,例如:package, json, import, (, io, ), …,而語法分析會把 Token 序列轉(zhuǎn)換成有意義的結(jié)構(gòu)體,即語法樹
"json.go": SourceFile { PackageName: "json", ImportDecl: []Import{ "io", }, TopLevelDecl: ... }
2.3 類型檢查
經(jīng)過詞法分析構(gòu)建成抽象語法樹之后,下一步就是類型檢查,類型檢查會對抽象語法樹中定義和使用的類型進(jìn)行檢查,會按照以下步驟處理和驗(yàn)證不同語法樹的節(jié)點(diǎn)。Go語言的編譯器同時使用靜態(tài)類型檢查和動態(tài)類型檢查,這里只討論靜態(tài)類型檢查。
- 常量、類型和函數(shù)名及類型;
- 變量的賦值和初始化;
- 函數(shù)和閉包的主體;
- 哈希鍵值對的類型;
- 導(dǎo)入函數(shù)體
- 外部的聲明
通過對類型的驗(yàn)證,保證節(jié)點(diǎn)不存在類型錯誤,包括:結(jié)構(gòu)體對接口的實(shí)現(xiàn)。類型檢查階段不止會對節(jié)點(diǎn)的類型進(jìn)行驗(yàn)證,還會展開和改寫一些內(nèi)建的函數(shù),例如 make 關(guān)鍵字在這個階段會根據(jù)子樹的結(jié)構(gòu)被替換成 runtime.makeslice 或者 runtime.makechan 等函數(shù)
類型檢查-修改關(guān)鍵字節(jié)點(diǎn)操作類型
注:此過程中也可能改寫AST,包括去除一些不會被執(zhí)行的代碼,優(yōu)化代碼以提高執(zhí)行效率,而且會修改make、new等關(guān)鍵字對應(yīng)節(jié)點(diǎn)的操作類型
2.4 中間代碼生成
經(jīng)過對抽象語法樹的類型檢查后,可以認(rèn)為當(dāng)前代碼不存在類型和語法上的錯誤了,接下來Go編譯器會將抽象語法樹轉(zhuǎn)為中間代碼。中間代碼是編譯器或者虛擬機(jī)使用的語言,它可以來幫助我們分析計(jì)算機(jī)程序。在編譯過程中,編譯器會在將源代碼轉(zhuǎn)換到機(jī)器碼的過程中,先把源代碼轉(zhuǎn)換成一種中間的表示形式。
源代碼-》中間代碼-》機(jī)器碼
中間代碼生成分為三步:配置初始化、遍歷和替換、SSA生成
2.4.1 配置初始化
- 緩存類型信息
- 根據(jù)當(dāng)前的 CPU 架構(gòu)初始化 SSA 配置
- 初始化一些編譯器可能用到的 Go 語言運(yùn)行時的函數(shù)
2.4.2 遍歷和替換
在生成中間代碼之前,編譯器還需要替換抽象語法樹中節(jié)點(diǎn)的一些元素,這個替換的過程是通過cmd/compile/internal/gc.wal和以相關(guān)函數(shù)實(shí)現(xiàn)的。
這些用于遍歷抽象語法樹的函數(shù)會將一些關(guān)鍵字和內(nèi)建函數(shù)轉(zhuǎn)換成函數(shù)調(diào)用,例如: 上述函數(shù)會將panic、recover兩個內(nèi)建函數(shù)轉(zhuǎn)換成runtime.gopanic和runtime.gorecover兩個真正運(yùn)行時函數(shù),而關(guān)鍵字new也會被轉(zhuǎn)換成調(diào)用runtime.newobject函數(shù)。
關(guān)鍵字或內(nèi)建函數(shù)到運(yùn)行時函數(shù)的映射
這些映射關(guān)系都在src/cmd/compile/internal/gc/builtin/runtime.go,包括channel、make、new、select等關(guān)鍵字或內(nèi)建函數(shù),但是這里只有聲明。
func makemap64(mapType *byte, hint int64, mapbuf *any) (hmap map[any]any) func makemap(mapType *byte, hint int, mapbuf *any) (hmap map[any]any)
函數(shù)的實(shí)現(xiàn)是在src/runtime運(yùn)行時包下面,比如對應(yīng)channel的轉(zhuǎn)換后的實(shí)際實(shí)現(xiàn)在src/runtime/chan.go文件
2.4.3 SSA(靜態(tài)單賦值)生成
經(jīng)過 walk 系列函數(shù)的處理之后,抽象語法樹就不會改變了,Go 語言的編譯器會使用 cmd/compile/internal/gc.compileSSA 函數(shù)將抽象語法樹轉(zhuǎn)換成中間代碼
func compileSSA(fn *Node, worker int) { f := buildssa(fn, worker) pp := newProgs(fn, worker) genssa(f, pp) pp.Flush() }
cmd/compile/internal/gc.buildssa負(fù)責(zé)生成具有 SSA 特性的中間代碼,我們可以使用命令行工具來觀察中間代碼的生成過程,假設(shè)我們有以下的 Go 語言源代碼,其中只包含一個簡單的hello函數(shù):
package hello func hello(a int) int { c := a + 2 return c }
上述文件中包含源代碼對應(yīng)的抽象語法樹、幾十個版本的中間代碼以及最終生成的 SSA
2.5 機(jī)器碼生成
Go 語言編譯的最后一個階段是根據(jù) SSA 中間代碼生成機(jī)器碼,這里談的機(jī)器碼是在目標(biāo) CPU 架構(gòu)上能夠運(yùn)行的二進(jìn)制代碼。機(jī)器碼的生成實(shí)際上是對SSA的降級過程,在 SSA 中間代碼降級的過程中,編譯器將一些值重寫成了目標(biāo) CPU 架構(gòu)的特定值,降級的過程處理了所有機(jī)器特定的重寫規(guī)則并對代碼進(jìn)行了一定程度的優(yōu)化。執(zhí)行架構(gòu)特定的優(yōu)化和重寫并生成指令,經(jīng)由匯編器將這些指令轉(zhuǎn)換為機(jī)器碼。具體的底層原理就很復(fù)雜了,我也不清楚,了解個過程就行了。
就源碼編譯為匯編指令舉個栗子:
$ cat hello.go package hello func hello(a int) int { c := a + 2 return c } $ GOOS=linux GOARCH=amd64 go tool compile -S hello.go "".hello STEXT nosplit size=15 args=0x10 locals=0x0 0x0000 00000 (hello.go:30) TEXT "".hello(SB), NOSPLIT|ABIInternal, $0-16 0x0000 00000 (hello.go:30) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x0000 00000 (hello.go:30) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x0000 00000 (hello.go:31) MOVQ "".a+8(SP), AX 0x0005 00005 (hello.go:31) ADDQ $2, AX 0x0009 00009 (hello.go:32) MOVQ AX, "".~r1+16(SP) 0x000e 00014 (hello.go:32) RET 0x0000 48 8b 44 24 08 48 83 c0 02 48 89 44 24 10 c3 H.D$.H...H.D$.. ...
3 鏈接過程
編譯過程其實(shí)是對單個文件進(jìn)行的,而鏈接過程將編譯過程生成的一個個目標(biāo)文件鏈接成最終的可執(zhí)行程序,最終得到的文件是分成各種段的,比如數(shù)據(jù)段、代碼段、BSS段等等,運(yùn)行時會被裝載到內(nèi)存中。各個段具有不同的讀寫、執(zhí)行屬性,保護(hù)了程序的安全運(yùn)行。比如Hello.go編譯后會生成一個hello.a二進(jìn)制代碼文件,然后結(jié)合其他庫和基礎(chǔ)庫,在windows下生成一個exe程序。
以上就是深入了解Go語言編譯鏈接的過程的詳細(xì)內(nèi)容,更多關(guān)于Go語言編譯鏈接的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
go語言轉(zhuǎn)換json字符串為json數(shù)據(jù)的實(shí)現(xiàn)
本文主要介紹了go語言轉(zhuǎn)換json字符串為json數(shù)據(jù)的實(shí)現(xiàn),文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2025-03-03Go語言標(biāo)準(zhǔn)輸入輸出庫的基本使用教程
輸入輸出在任何一門語言中都必須提供的一個功能,下面這篇文章主要給大家介紹了關(guān)于Go語言標(biāo)準(zhǔn)輸入輸出庫的基本使用,文中通過實(shí)例代碼介紹的非常詳細(xì),需要的朋友可以參考下2022-02-02Golang實(shí)現(xiàn)帶優(yōu)先級的select
這篇文章主要為大家詳細(xì)介紹了如何在Golang中實(shí)現(xiàn)帶優(yōu)先級的select,文中的示例代碼講解詳細(xì),對我們學(xué)習(xí)Golang有一定的幫助,需要的可以參考一下2023-04-04