應(yīng)用編譯,計(jì)算機(jī)中一定要掌握的知識(shí)細(xì)節(jié)
本文轉(zhuǎn)載自微信公眾號(hào)「腦子進(jìn)煎魚了」,作者陳煎魚。轉(zhuǎn)載本文請(qǐng)聯(lián)系腦子進(jìn)煎魚了公眾號(hào)。
“Hello World” 程序幾乎是每個(gè)程序員入門和開發(fā)環(huán)境測試的基本標(biāo)準(zhǔn)。代碼如下:
- #inclue <stdio.h>
 - int main()
 - {
 - printf("Hello Wolrd\n");
 - return 0;
 - }
 
編譯該程序,再運(yùn)行,就基本完成了所有新手的第一個(gè)程序。表面看起來輕輕松松,毫無懸念。但是實(shí)際上單純這幾下操作,就已經(jīng)包含了不少暗操作。本著追根溯源的目的,我們將進(jìn)一步對(duì)其流程進(jìn)行分析。
涉及的流程
其內(nèi)部主要包含 4 個(gè)步驟,分別是:預(yù)處理、編譯、匯編以及鏈接。由于篇幅問題本文主要涉及前三部分,鏈接部分將會(huì)放到下一篇文章來講解。
預(yù)編譯
程序編譯的第一步是 “預(yù)編譯” 環(huán)境。主要作用是處理源代碼文件中以 ”#“ 開始的預(yù)編譯指令,例如:#include、#define 等。
常見的處理規(guī)則是:
- 將所有 #define 刪除,并且展開所有的宏定義。
 - 處理所有條件預(yù)編譯指令,比如 if、ifdef、elif、else、endif。
 - 處理 #include 預(yù)編譯指令,將所包含的文件插入到該預(yù)編譯指令的位置(可遞歸處理子級(jí)引入)。
 - 刪除所有的注釋。
 - 添加行號(hào)和文件名標(biāo)識(shí),以便于編譯時(shí)編譯器產(chǎn)生調(diào)試用的行號(hào)信息及用于編譯時(shí)產(chǎn)生編譯錯(cuò)誤或警告時(shí)顯示行號(hào)。
 - 保留所有的 #pragma 編譯器指令,后續(xù)編譯器將會(huì)使用。
 
在預(yù)編譯后,文件中將不包含宏定義或引入。因?yàn)樵陬A(yù)編譯后將會(huì)全部展開,相應(yīng)的代碼段均已被插入文件中。
像 Go 語言中的話,主要是 go generate 命令會(huì)涉及到相關(guān)的預(yù)編譯處理。
編譯
第二步正式進(jìn)入到 "編譯" 環(huán)境。主要作用是把預(yù)處理完的文件進(jìn)行一系列詞法分析、語法分析、語義分析及優(yōu)化后生成相應(yīng)的匯編代碼文件。該部分通常是整個(gè)程序構(gòu)建的核心部分,也是最復(fù)雜的部分之一。
執(zhí)行編譯操作的工具,一般稱其為 “編譯器”。編譯器是將高級(jí)語言翻譯成機(jī)器語言的一個(gè)工具。例如我們平時(shí)用 Go 語言寫的程序,編譯器就可以將其編譯成機(jī)器可以執(zhí)行的指令及數(shù)據(jù)。那么我們就不需要再去關(guān)心相關(guān)的底層細(xì)節(jié),因?yàn)槭褂脵C(jī)器指令或匯編語言編寫程序是一件十分費(fèi)時(shí)及乏味的事情。
且高級(jí)語言能夠使得程序員更關(guān)注程序邏輯的本身,不再需要過多的關(guān)注計(jì)算機(jī)本身的限制,具有更高的平臺(tái)可移植性,能夠在多種計(jì)算機(jī)架構(gòu)下運(yùn)行。
編譯過程
編譯過程一般分為 6 步:掃描、語法分析、語義分析、源代碼優(yōu)化、代碼生成和目標(biāo)代碼優(yōu)化。整個(gè)過程如下:
編譯過程
我們結(jié)合上圖的源代碼(Source Code)到最終目標(biāo)代碼(Final Target Code)的過程,以一段最簡單的 Go 語言程序的代理例子來復(fù)現(xiàn)和講述整個(gè)過程,如下:
- package main
 - import (
 - "fmt"
 - )
 - func main() {
 - fmt.Println("Hello World.")
 - }
 
詞法分析
首先 Go 程序會(huì)被輸入到掃描器中,可以理解為所有解析程序的第一步,都是讀取源代碼。而掃描器的任務(wù)很簡單,就是利用有限狀態(tài)機(jī)對(duì)源代碼的字符序列進(jìn)行分割,最終變成一系列的記號(hào)(Token)。
如下 Hello World 利用 go/scanner 進(jìn)行處理:
- 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。如果沒有前置知識(shí)的情況下,第一眼可能會(huì)非常懵逼。在此可以初步了解一下 Go 所主要包含的標(biāo)識(shí)符和基本類型,如下:
- // 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 稍加思考,做對(duì)比,就可得知其僅是單純的利用掃描器翻譯和輸出。而實(shí)質(zhì)上在識(shí)別記號(hào)時(shí),掃描器也會(huì)完成其他工作,例如把標(biāo)識(shí)符放到符號(hào)表,將數(shù)字、字符串常量存放到文字表等。
詞法分析產(chǎn)生的記號(hào)一般可以分為如下幾類:
- 關(guān)鍵字。
 - 標(biāo)識(shí)符。
 - 字面量(包含數(shù)字、字符串等)。
 - 特殊符合(如加號(hào)、等號(hào))
 
語法分析/語義分析
語法分析器
語法分析器(Grammar Parser)將對(duì)掃描器所產(chǎn)生的記號(hào)進(jìn)行語法分析,從而產(chǎn)生語法樹(Syntax Tree),也稱抽象語法樹(Abstract Syntax Tree,AST)。
常見的分析方式是自頂向下或者自底向上,以及采取上下文無關(guān)語法(Context-free Grammer)作為分析手段。這塊可參考一些計(jì)算機(jī)理論的資料,涉及的比較廣。
但語法分析僅完成了對(duì)表達(dá)式的語法層面的分析,但并不清楚這個(gè)語句是否真正有意義,還需要一步語義分析。
語義分析器
語義分析器(Semantic Analyzer)將會(huì)對(duì)對(duì)語法分析器所生成的語法樹上的表達(dá)式標(biāo)識(shí)具體的類型。主要分為兩類:
- 靜態(tài)語義:在編譯器就可以確定的語義。
 - 動(dòng)態(tài)語義:在運(yùn)行期才可以確定的語義。
 
在經(jīng)過語義分析階段后,整個(gè)語法樹的表達(dá)式都會(huì)被標(biāo)識(shí)上類型,如果有些類型需要進(jìn)行隱式轉(zhuǎn)換,語義分析程序?qū)?huì)在語法書中插入相應(yīng)的轉(zhuǎn)換點(diǎn),成為有更具體含義的語義。
實(shí)戰(zhàn)演練
語法分析器生成的語法樹,本質(zhì)上就是以表達(dá)式(Expression)為節(jié)點(diǎn)的樹。在 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)過語法分析器(自頂下向)分析后會(huì)所輸出的結(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 指的是位置在第一行的第一個(gè)。
 - Name:解析出 package name 的名稱,類型是 *ast.Ident,1:9 指的是位置在第一行的第九個(gè)。
 - Decls:節(jié)點(diǎn)的頂層聲明,其對(duì)應(yīng) BadDecl(Bad Declaration)、GenDecl(Generic Declaration)、FuncDecl(Function Declaration)。
 - Scope:在此文件中的函數(shù)作用域,以及作用域?qū)?yīng)的對(duì)象。
 - Imports:在此文件中所導(dǎo)入的模塊。
 - Unresolved:在此文件中未解析的標(biāo)識(shí)符。
 - Comments:在此文件中的所有注釋內(nèi)容。
 
可視化后的語法樹如下:
可視化后的語法樹
在上文中,主要涉及語法分析和語義分析部分,其歸屬于編譯器前端,最終結(jié)果是得到了語法樹,也就是常說是抽象語法樹(AST)。
有興趣可以親自試試 yuroyoro/goast-viewer,會(huì)對(duì)語法樹的理解更加的清晰。
中間語言生成
現(xiàn)代的編譯器有這多個(gè)層次的優(yōu)化,通常源代碼級(jí)別會(huì)有一個(gè)優(yōu)化過程。例如單純的 1+2 的表達(dá)式就可以被優(yōu)化。
而在 Go 語言中,中間語言則會(huì)涉及靜態(tài)單賦值(Static Single Assignment,SSA)的特性。例如有一個(gè)很簡單的 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,可看到這個(gè)文件源代碼所對(duì)應(yīng)的語法樹,好幾個(gè)版本的中間代碼以及最終所生成的 SSA。
SSA
從左往右依次為:Sources(源代碼)、AST(抽象語法樹),其次最右邊第一欄起就是第一輪中間語言(代碼),后面還有十幾輪。
目標(biāo)代碼生成與優(yōu)化
在中間語言生成完畢后,還不能直接使用。因?yàn)闄C(jī)器真正能執(zhí)行的是機(jī)器碼。這時(shí)候就到了編譯器后端的工作了。
從階段上來講,在源代碼級(jí)優(yōu)化器產(chǎn)生中間代碼時(shí),則標(biāo)志著接下來的過程都屬于編譯器后端。
編譯器后端主要包括如下兩類,作用如下::
- 代碼生成器(Code Generator):代碼生成器將中間代碼轉(zhuǎn)換成目標(biāo)機(jī)器代碼。
 - 目標(biāo)代碼優(yōu)化器(Target Code Optimizer):針對(duì)代碼生成器所轉(zhuǎn)換出的目標(biāo)機(jī)器代碼進(jìn)行優(yōu)化。
 
在 Go 語言中,以上行為包含在前面所提到的十幾輪 SSA 優(yōu)化降級(jí)中,有興趣可自行研究 SSA,最后在 genssa 中可看見最終的中間代碼:
最終降級(jí)完成的 SSA
此時(shí)的代碼已經(jīng)降級(jí)的與最終的匯編代碼比較接近,但還沒經(jīng)過正式的轉(zhuǎn)換。
匯編
完成程序編譯后,第三步將是 ”匯編“,匯編器會(huì)將匯編代碼轉(zhuǎn)變成機(jī)器可執(zhí)行的指令,每一個(gè)匯編語句幾乎都對(duì)應(yīng)著一條機(jī)器指令?;具壿嬀褪歉鶕?jù)匯編指令和機(jī)器指令的對(duì)照表一一翻譯。
在 Go 語言中,genssa 所生成的目標(biāo)代碼已經(jīng)完成了優(yōu)化降級(jí),接下來會(huì)調(diào)用 src/cmd/internal/obj 包中的匯編器將 SSA 中間代碼生成為機(jī)器碼。
我們可通過 go tool compile -S 的方式進(jìn)行查看:
- $ 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 ........
 
至此就完成了一個(gè)高級(jí)語言再到計(jì)算機(jī)所能理解的機(jī)器碼轉(zhuǎn)換的完整流程了。
總結(jié)
在本文中,我們基本了解了一個(gè)應(yīng)用程序是怎么從源代碼編譯到最終的機(jī)器碼,其中每一步展開都是一塊非常大的計(jì)算機(jī)基礎(chǔ)知識(shí)。若有讀者對(duì)其感興趣,可根據(jù)文中的實(shí)操步驟進(jìn)行深入的剖析和了解。
在下一篇文章中,將會(huì)進(jìn)一步針對(duì)最后的一個(gè)步驟鏈接來進(jìn)行分析和了解其最后一公里。




















 
 
 





 
 
 
 