應(yīng)用編譯,計算機中一定要掌握的知識細節(jié)
本文轉(zhuǎn)載自微信公眾號「腦子進煎魚了」,作者陳煎魚。轉(zhuǎn)載本文請聯(lián)系腦子進煎魚了公眾號。
“Hello World” 程序幾乎是每個程序員入門和開發(fā)環(huán)境測試的基本標準。代碼如下:
- #inclue <stdio.h>
- int main()
- {
- printf("Hello Wolrd\n");
- return 0;
- }
編譯該程序,再運行,就基本完成了所有新手的第一個程序。表面看起來輕輕松松,毫無懸念。但是實際上單純這幾下操作,就已經(jīng)包含了不少暗操作。本著追根溯源的目的,我們將進一步對其流程進行分析。
涉及的流程
其內(nèi)部主要包含 4 個步驟,分別是:預(yù)處理、編譯、匯編以及鏈接。由于篇幅問題本文主要涉及前三部分,鏈接部分將會放到下一篇文章來講解。
預(yù)編譯
程序編譯的第一步是 “預(yù)編譯” 環(huán)境。主要作用是處理源代碼文件中以 ”#“ 開始的預(yù)編譯指令,例如:#include、#define 等。
常見的處理規(guī)則是:
- 將所有 #define 刪除,并且展開所有的宏定義。
- 處理所有條件預(yù)編譯指令,比如 if、ifdef、elif、else、endif。
- 處理 #include 預(yù)編譯指令,將所包含的文件插入到該預(yù)編譯指令的位置(可遞歸處理子級引入)。
- 刪除所有的注釋。
- 添加行號和文件名標識,以便于編譯時編譯器產(chǎn)生調(diào)試用的行號信息及用于編譯時產(chǎn)生編譯錯誤或警告時顯示行號。
- 保留所有的 #pragma 編譯器指令,后續(xù)編譯器將會使用。
在預(yù)編譯后,文件中將不包含宏定義或引入。因為在預(yù)編譯后將會全部展開,相應(yīng)的代碼段均已被插入文件中。
像 Go 語言中的話,主要是 go generate 命令會涉及到相關(guān)的預(yù)編譯處理。
編譯
第二步正式進入到 "編譯" 環(huán)境。主要作用是把預(yù)處理完的文件進行一系列詞法分析、語法分析、語義分析及優(yōu)化后生成相應(yīng)的匯編代碼文件。該部分通常是整個程序構(gòu)建的核心部分,也是最復(fù)雜的部分之一。
執(zhí)行編譯操作的工具,一般稱其為 “編譯器”。編譯器是將高級語言翻譯成機器語言的一個工具。例如我們平時用 Go 語言寫的程序,編譯器就可以將其編譯成機器可以執(zhí)行的指令及數(shù)據(jù)。那么我們就不需要再去關(guān)心相關(guān)的底層細節(jié),因為使用機器指令或匯編語言編寫程序是一件十分費時及乏味的事情。
且高級語言能夠使得程序員更關(guān)注程序邏輯的本身,不再需要過多的關(guān)注計算機本身的限制,具有更高的平臺可移植性,能夠在多種計算機架構(gòu)下運行。
編譯過程
編譯過程一般分為 6 步:掃描、語法分析、語義分析、源代碼優(yōu)化、代碼生成和目標代碼優(yōu)化。整個過程如下:
編譯過程
我們結(jié)合上圖的源代碼(Source Code)到最終目標代碼(Final Target Code)的過程,以一段最簡單的 Go 語言程序的代理例子來復(fù)現(xiàn)和講述整個過程,如下:
- package main
- import (
- "fmt"
- )
- func main() {
- fmt.Println("Hello World.")
- }
詞法分析
首先 Go 程序會被輸入到掃描器中,可以理解為所有解析程序的第一步,都是讀取源代碼。而掃描器的任務(wù)很簡單,就是利用有限狀態(tài)機對源代碼的字符序列進行分割,最終變成一系列的記號(Token)。
如下 Hello World 利用 go/scanner 進行處理:
- 1:1 package "package"
- 1:9 IDENT "main"
- 1:13 ; "\n"
- 3:1 import "import"
- 3:8 ( ""
- 4:2 STRING "\"fmt\""
- 4:7 ; "\n"
- 5:1 ) ""
- 5:2 ; "\n"
- 7:1 func "func"
- 7:6 IDENT "main"
- 7:10 ( ""
- 7:11 ) ""
- 7:13 { ""
- 8:2 IDENT "fmt"
- 8:5 . ""
- 8:6 IDENT "Println"
- 8:13 ( ""
- 8:14 STRING "\"Hello World.\""
- 8:28 ) ""
- 8:29 ; "\n"
- 9:1 } ""
- 9:2 ; "\n"
在經(jīng)過掃描器的掃描后,可以看到輸出了一大堆的 Token。如果沒有前置知識的情況下,第一眼可能會非常懵逼。在此可以初步了解一下 Go 所主要包含的標識符和基本類型,如下:
- // Special tokens
- ILLEGAL Token = iota
- EOF
- COMMENT
- // Identifiers and basic type literals
- // (these tokens stand for classes of literals)
- IDENT // main
- INT // 12345
- FLOAT // 123.45
- IMAG // 123.45i
- CHAR // 'a'
- STRING // "abc"
- literal_end
再根據(jù)所輸出的 Token 稍加思考,做對比,就可得知其僅是單純的利用掃描器翻譯和輸出。而實質(zhì)上在識別記號時,掃描器也會完成其他工作,例如把標識符放到符號表,將數(shù)字、字符串常量存放到文字表等。
詞法分析產(chǎn)生的記號一般可以分為如下幾類:
- 關(guān)鍵字。
- 標識符。
- 字面量(包含數(shù)字、字符串等)。
- 特殊符合(如加號、等號)
語法分析/語義分析
語法分析器
語法分析器(Grammar Parser)將對掃描器所產(chǎn)生的記號進行語法分析,從而產(chǎn)生語法樹(Syntax Tree),也稱抽象語法樹(Abstract Syntax Tree,AST)。
常見的分析方式是自頂向下或者自底向上,以及采取上下文無關(guān)語法(Context-free Grammer)作為分析手段。這塊可參考一些計算機理論的資料,涉及的比較廣。
但語法分析僅完成了對表達式的語法層面的分析,但并不清楚這個語句是否真正有意義,還需要一步語義分析。
語義分析器
語義分析器(Semantic Analyzer)將會對對語法分析器所生成的語法樹上的表達式標識具體的類型。主要分為兩類:
- 靜態(tài)語義:在編譯器就可以確定的語義。
- 動態(tài)語義:在運行期才可以確定的語義。
在經(jīng)過語義分析階段后,整個語法樹的表達式都會被標識上類型,如果有些類型需要進行隱式轉(zhuǎn)換,語義分析程序?qū)谡Z法書中插入相應(yīng)的轉(zhuǎn)換點,成為有更具體含義的語義。
實戰(zhàn)演練
語法分析器生成的語法樹,本質(zhì)上就是以表達式(Expression)為節(jié)點的樹。在 Go 語言中可通過 go/token、go/parser、go/ast 等相關(guān)方法生成語法樹,代碼如下:
- func main() {
- src := []byte("package main\n\nimport (\n\t\"fmt\"\n)\n\nfunc main() {\n\tfmt.Println(\"Hello World.\")\n}")
- fset := token.NewFileSet() // positions are relative to fset
- f, err := parser.ParseFile(fset, "", src, 0)
- if err != nil {
- panic(err)
- }
- ast.Print(fset, f)
- }
其經(jīng)過語法分析器(自頂下向)分析后會所輸出的結(jié)果如下:
- 0 *ast.File {
- 1 . Package: 1:1
- 2 . Name: *ast.Ident {
- 3 . . NamePos: 1:9
- 4 . . Name: "main"
- 5 . }
- 6 . Decls: []ast.Decl (len = 2) {
- 7 . . 0: *ast.GenDecl {
- 8 . . . TokPos: 3:1
- 9 . . . Tok: import
- 10 . . . Lparen: 3:8
- 11 . . . Specs: []ast.Spec (len = 1) {
- 12 . . . . 0: *ast.ImportSpec {
- 13 . . . . . Path: *ast.BasicLit {
- 14 . . . . . . ValuePos: 4:2
- 15 . . . . . . Kind: STRING
- 16 . . . . . . Value: "\"fmt\""
- 17 . . . . . }
- 18 . . . . . EndPos: -
- 19 . . . . }
- 20 . . . }
- 21 . . . Rparen: 5:1
- 22 . . }
- 23 . . ...
- 71 . }
- 72 . Scope: *ast.Scope {
- 73 . . Objects: map[string]*ast.Object (len = 1) {
- 74 . . . "main": *(obj @ 27)
- 75 . . }
- 76 . }
- 77 . Imports: []*ast.ImportSpec (len = 1) {
- 78 . . 0: *(obj @ 12)
- 79 . }
- 80 . Unresolved: []*ast.Ident (len = 1) {
- 81 . . 0: *(obj @ 46)
- 82 . }
- 83 }
- Package:解析出 package 關(guān)鍵字的位置,1:1 指的是位置在第一行的第一個。
- Name:解析出 package name 的名稱,類型是 *ast.Ident,1:9 指的是位置在第一行的第九個。
- Decls:節(jié)點的頂層聲明,其對應(yīng) BadDecl(Bad Declaration)、GenDecl(Generic Declaration)、FuncDecl(Function Declaration)。
- Scope:在此文件中的函數(shù)作用域,以及作用域?qū)?yīng)的對象。
- Imports:在此文件中所導(dǎo)入的模塊。
- Unresolved:在此文件中未解析的標識符。
- Comments:在此文件中的所有注釋內(nèi)容。
可視化后的語法樹如下:
可視化后的語法樹
在上文中,主要涉及語法分析和語義分析部分,其歸屬于編譯器前端,最終結(jié)果是得到了語法樹,也就是常說是抽象語法樹(AST)。
有興趣可以親自試試 yuroyoro/goast-viewer,會對語法樹的理解更加的清晰。
中間語言生成
現(xiàn)代的編譯器有這多個層次的優(yōu)化,通常源代碼級別會有一個優(yōu)化過程。例如單純的 1+2 的表達式就可以被優(yōu)化。
而在 Go 語言中,中間語言則會涉及靜態(tài)單賦值(Static Single Assignment,SSA)的特性。例如有一個很簡單的 SayHelloWorld 方法,如下:
- package helloworld
- func SayHelloWorld(a int) int {
- c := a + 2
- return c
- }
想看到源代碼到中間語言,再到 SSA 的話,可通過 GOSSAFUNC 編譯源代碼并查看:
- $ GOSSAFUNC=SayHelloWorld go build helloworld.go
- # command-line-arguments
- dumped SSA to ./ssa.html
打開 ssa.html,可看到這個文件源代碼所對應(yīng)的語法樹,好幾個版本的中間代碼以及最終所生成的 SSA。
SSA
從左往右依次為:Sources(源代碼)、AST(抽象語法樹),其次最右邊第一欄起就是第一輪中間語言(代碼),后面還有十幾輪。
目標代碼生成與優(yōu)化
在中間語言生成完畢后,還不能直接使用。因為機器真正能執(zhí)行的是機器碼。這時候就到了編譯器后端的工作了。
從階段上來講,在源代碼級優(yōu)化器產(chǎn)生中間代碼時,則標志著接下來的過程都屬于編譯器后端。
編譯器后端主要包括如下兩類,作用如下::
- 代碼生成器(Code Generator):代碼生成器將中間代碼轉(zhuǎn)換成目標機器代碼。
- 目標代碼優(yōu)化器(Target Code Optimizer):針對代碼生成器所轉(zhuǎn)換出的目標機器代碼進行優(yōu)化。
在 Go 語言中,以上行為包含在前面所提到的十幾輪 SSA 優(yōu)化降級中,有興趣可自行研究 SSA,最后在 genssa 中可看見最終的中間代碼:
最終降級完成的 SSA
此時的代碼已經(jīng)降級的與最終的匯編代碼比較接近,但還沒經(jīng)過正式的轉(zhuǎn)換。
匯編
完成程序編譯后,第三步將是 ”匯編“,匯編器會將匯編代碼轉(zhuǎn)變成機器可執(zhí)行的指令,每一個匯編語句幾乎都對應(yīng)著一條機器指令?;具壿嬀褪歉鶕?jù)匯編指令和機器指令的對照表一一翻譯。
在 Go 語言中,genssa 所生成的目標代碼已經(jīng)完成了優(yōu)化降級,接下來會調(diào)用 src/cmd/internal/obj 包中的匯編器將 SSA 中間代碼生成為機器碼。
我們可通過 go tool compile -S 的方式進行查看:
- $ go tool compile -S helloworld.go
- "".SayHelloWorld STEXT nosplit size=15 args=0x10 locals=0x0
- 0x0000 00000 (helloworld.go:3) TEXT "".SayHelloWorld(SB), NOSPLIT|ABIInternal, $0-16
- 0x0000 00000 (helloworld.go:3) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
- 0x0000 00000 (helloworld.go:3) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
- 0x0000 00000 (helloworld.go:4) MOVQ "".a+8(SP), AX
- 0x0005 00005 (helloworld.go:4) ADDQ $2, AX
- 0x0009 00009 (helloworld.go:5) MOVQ AX, "".~r1+16(SP)
- 0x000e 00014 (helloworld.go:5) RET
- 0x0000 48 8b 44 24 08 48 83 c0 02 48 89 44 24 10 c3 H.D$.H...H.D$..
- go.cuinfo.packagename. SDWARFINFO dupok size=0
- 0x0000 68 65 6c 6c 6f 77 6f 72 6c 64 helloworld
- gclocals·33cdeccccebe80329f1fdbee7f5874cb SRODATA dupok size=8
- 0x0000 01 00 00 00 00 00 00 00 ........
至此就完成了一個高級語言再到計算機所能理解的機器碼轉(zhuǎn)換的完整流程了。
總結(jié)
在本文中,我們基本了解了一個應(yīng)用程序是怎么從源代碼編譯到最終的機器碼,其中每一步展開都是一塊非常大的計算機基礎(chǔ)知識。若有讀者對其感興趣,可根據(jù)文中的實操步驟進行深入的剖析和了解。
在下一篇文章中,將會進一步針對最后的一個步驟鏈接來進行分析和了解其最后一公里。