走進Golang之編譯器原理

爲了學好Golang底層知識裝逼,折騰了一下編譯器相關知識。下面的內容並不會提高你的生產技能點,但能夠提升你的裝逼指數。請按需閱讀!html

本文目錄速覽:前端

認識 go build

當咱們敲下 go build 的時候,咱們的寫的源碼文件究竟經歷了哪些事情?最終變成了可執行文件。node

這個命令會編譯go代碼,今天就來一塊兒看看go的編譯過程吧!git

首先先來認識如下go的代碼源文件分類github

  • 命令源碼文件:簡單說就是含有 main 函數的那個文件,一般一個項目一個該文件,我也沒想過須要兩個命令源文件的項目
  • 測試源碼文件:就是咱們寫的單元測試的代碼,都是以 _test.go 結尾
  • 庫源碼文件:沒有上面特徵的就是庫源碼文件,像咱們使用的不少第三方包都屬於這部分

go build 命令就是用來編譯這其中的 命令源碼文件 以及它依賴的 庫源碼文件。下面表格是一些經常使用的選項在這裏集中說明如下。golang

可選項 說明
-a 將命令源碼文件與庫源碼文件所有從新構建,即便是最新的
-n 把編譯期間涉及的命令所有打印出來,但不會真的執行,很是方便咱們學習
-race 開啓競態條件的檢測,支持的平臺有限制
-x 打印編譯期間用到的命名,它與 -n 的區別是,它不只打印還會執行

接下來就用一個 hello world 程序來演示如下上面的命令選項。express

go的演示代碼

若是對上面的代碼執行 go build -n 咱們看一下輸出信息:segmentfault

編譯代碼

來分析下整個執行過程後端

編譯器編譯過程

這一部分是編譯的核心,經過 compilebuildidlink 三個命令會編譯出可執行文件 a.out緩存

而後經過 mv 命令把 a.out 移動到當前文件夾下面,並改爲跟項目文件同樣的名字(這裏也能夠本身指定名字)。

文章的後面部分,咱們主要講的就是 compilebuildid、 link 這三個命令涉及的編譯過程。

編譯器原理

這是go編譯器的源碼路徑

編譯器流程

如上圖所見,整個編譯器能夠分爲:編譯前端與編譯後端;如今咱們看看每一個階段編譯器都作了些什麼事情。先來從前端部分開始。

詞法分析

詞法分析簡單來講就是將咱們寫的源代碼翻譯成 Token,這是個什麼意思呢?

爲了理解 Golang 從源代碼翻譯到 Token 的過程,咱們用一段代碼來看一下翻譯的一一對應狀況。

源碼到token

圖中重要的地方我都進行了註釋,不過這裏仍是有幾句話多說一下,咱們看着上面的代碼想象如下,若是要咱們本身來實現這個「翻譯工做」,程序要如何識別 Token 呢?

首先先來給Go的token類型分個類:變量名、字面量、操做符、分隔符以及關鍵字。咱們須要把一堆源代碼按照規則進行拆分,其實就是分詞,看着上面的例子代碼咱們能夠大概制定一個規則以下:

  1. 識別空格,若是是空格能夠分一個詞;
  2. 遇到 ()、'<'、'>' 等這些特殊運算符的時候算一個分詞;
  3. 遇到 " 或者 數字字面量算分詞。

經過上面的簡單分析,其實能夠看出源代碼轉 Token 其實沒有很是複雜,徹底能夠本身寫代碼實現出來。固然也有不少經過正則的方式實現的比較通用的詞法分析器,像 Golang 早期就用的是 lex,在後面的版本中才改用了用go來本身實現。

語法分析

通過詞法分析後,咱們拿到的就是 Token 序列,它將做爲語法分析器的輸入。而後通過處理後生成 AST 結構做爲輸出。

所謂的語法分析就是將 Token 轉化爲可識別的程序語法結構,而 AST 就是這個語法的抽象表示。構造這顆樹有兩種方法。

  1. 自上而下 這種方式會首先構造根節點,而後就開始掃描 Token,遇到 STRING 或者其它類型就知道這是在進行類型申明,func 就表示是函數申明。就這樣一直掃描直到程序結束。

  2. 自下而上 這種是與上一種方式相反的,它先構造子樹,而後再組裝成一顆完整的樹。

go語言進行語法分析使用的是自下而上的方式來構造 AST,下面咱們就來看一下go語言經過 Token 構造的這顆樹是什麼樣子。

go的AST樹

這其中有意思的地方我所有用文字標註出來了。你會發現其實每個 AST 樹的節點都與一個 Token 實際位置相對應。

這顆樹構造後,咱們能夠看到不一樣的類型是由對應的結構體來進行表示的。這裏若是有語法、詞法錯誤是不會被解析出來的。由於到目前爲止說白了都是進行的字符串處理。

語義分析

編譯器裏邊都把語法分析後的階段叫作 語義分析,而go的這個階段叫 類型檢查;可是我看了如下go本身的文檔,其實作的事情沒有太大差異,咱們仍是按照主流規範來寫這個過程。

那麼語義分析(類型檢查)究竟要作些什麼呢?

AST 生成後,語義分析將使用它做爲輸入,而且的有一些相關的操做也會直接在這顆樹上進行改寫。

首先就是 Golang 文檔中提到的會進行類型檢查,還有類型推斷,查看類型是否匹配,是否進行隱式轉化(go沒有隱式轉化)。以下面的文字所說:

The AST is then type-checked. The first steps are name resolution and type inference, which determine which object belongs to which identifier, and what type each expression has. Type-checking includes certain extra checks, such as "declared and not used" as well as determining whether or not a function terminates.

大意是:生成AST以後是類型檢查(也就是咱們這裏說的語義分析),第一步是進行名稱檢查和類型推斷,簽訂每一個對象所屬的標識符,以及每一個表達式具備什麼類型。類型檢查也還有一些其它的檢查要作,像「聲明未使用」以及肯定函數是否停止。

Certain transformations are also done on the AST. Some nodes are refined based on type information, such as string additions being split from the arithmetic addition node type. Some other examples are dead code elimination, function call inlining, and escape analysis.

這一段是說:AST也會進行轉換,有些節點根據類型信息進行精簡,好比從算術加法節點類型中拆分出字符串加法。其它一些例子像dead code的消除,函數調用內聯和逃逸分析。

上面兩段文字來自 golang compile

這裏多說一句,咱們經常在debug代碼的時候,須要禁止內聯,其實就是操做的這個階段。

# 編譯的時候禁止內聯
go build -gcflags '-N -l'

-N 禁止編譯優化
-l 禁止內聯,禁止內聯也能夠必定程度上減少可執行程序大小
複製代碼

通過語義分析以後,就能夠說明咱們的代碼結構、語法都是沒有問題的。因此編譯器前端主要就是解析出編譯器後端能夠處理的正確的AST結構。

接下來咱們看看編譯器後端又有哪些事情要作。

機器只可以理解二進制並運行,因此編譯器後端的任務簡單來講就是怎麼把AST翻譯成機器碼。

中間碼生成

既然已經拿到AST,機器運行須要的又是二進制。爲何不直接翻譯成二進制呢?其實到目前爲止從技術上來講已經徹底沒有問題了。

可是, 咱們有各類各樣的操做系統,有不一樣的CPU類型,每一種的位數可能不一樣;寄存器可以使用的指令也不一樣,像是複雜指令集與精簡指令集等;在進行各個平臺的兼容以前,咱們還須要替換一些底層函數,好比咱們使用make來初始化slice,此時會根據傳入的類型替換爲:makeslice64 或者 makeslice。固然還有像painc、channel等等函數的替換也會在中間碼生成過程當中進行替換。這一部分的替換操做能夠在這裏查看

中間碼存在的另一個價值是提高後端編譯的重用,好比咱們定義好了一套中間碼應該是長什麼樣子,那麼後端機器碼生成就是相對固定的。每一種語言只須要完成本身的編譯器前端工做便可。這也是你們能夠看到如今開發一門新語言速度比較快的緣由。編譯是絕大部分均可以重複使用的。

並且爲了接下來的優化工做,中間代碼存在具備非凡的意義。由於有那麼多的平臺,若是有中間碼咱們能夠把一些共性的優化都放到這裏。

中間碼也是有多種格式的,像 Golang 使用的就是SSA特性的中間碼(IR),這種形式的中間碼,最重要的一個特性就是最在使用變量以前老是定義變量,而且每一個變量只分配一次。

代碼優化

在go的編譯文檔中,我並沒找到獨立的一步進行代碼的優化。不過根據咱們上面的分析,能夠看到其實代碼優化過程遍及編譯器的每個階段。你們都會力所能及的作些事情。

一般咱們除了用高效代碼替換低效的以外,還有以下的一些處理:

  • 並行性,充分利用如今多核計算機的特性
  • 流水線,cpu有時候在處理a指令的時候,還能同時處理b指令
  • 指令的選擇,爲了讓cpu完成某些操做,須要使用指令,可是不一樣的指令效率有很是大的差異,這裏會進行指令優化
  • 利用寄存器與高速緩存,咱們都知道cpu從寄存器取是最快的,從高速緩存取次之。這裏會進行充分的利用

機器碼生成

通過優化後的中間代碼,首先會在這個階段被轉化爲彙編代碼(Plan9),而彙編語言僅僅是機器碼的文本表示,機器還不能真的去執行它。因此這個階段會調用匯編器,彙編器會根據咱們在執行編譯時設置的架構,調用對應代碼來生成目標機器碼。

這裏比有意思的是,Golang 總說本身的彙編器是跨平臺的。其實他也是寫了多分代碼來翻譯最終的機器碼。由於在入口的時候他會根據咱們所設置的 GOARCH=xxx 參數來進行初始化處理,而後最終調用對應架構編寫的特定方法來生成機器碼。這種上層邏輯一致,底層邏輯不一致的處理方式很是通用,很是值得咱們學習。咱們簡單來一下這個處理。

首先看入口函數 cmd/compile/main.go:main()

var archInits = map[string]func(*gc.Arch){
    "386":      x86.Init,
    "amd64":    amd64.Init,
    "amd64p32": amd64.Init,
    "arm":      arm.Init,
    "arm64":    arm64.Init,
    "mips":     mips.Init,
    "mipsle":   mips.Init,
    "mips64":   mips64.Init,
    "mips64le": mips64.Init,
    "ppc64":    ppc64.Init,
    "ppc64le":  ppc64.Init,
    "s390x":    s390x.Init,
    "wasm":     wasm.Init,
}

func main() {
    // 從上面的map根據參數選擇對應架構的處理
    archInit, ok := archInits[objabi.GOARCH]
    if !ok {
        ......
    }
    // 把對應cpu架構的對應傳到內部去
    gc.Main(archInit)
}
複製代碼

而後在 cmd/internal/obj/plist.go 中調用對應架構的方法進行處理

func Flushplist(ctxt *Link, plist *Plist, newprog ProgAlloc, myimportpath string) {
    ... ...
    for _, s := range text {
        mkfwd(s)
        linkpatch(ctxt, s, newprog)
        // 對應架構的方法進行本身的機器碼翻譯
        ctxt.Arch.Preprocess(ctxt, s, newprog)
        ctxt.Arch.Assemble(ctxt, s, newprog)

        linkpcln(ctxt, s)
        ctxt.populateDWARF(plist.Curfn, s, myimportpath)
    }
}
複製代碼

整個過程下來,能夠看到編譯器後端有不少工做須要作的,你須要對某一個指令集、cpu的架構瞭解,才能正確的進行翻譯機器碼。同時不能僅僅是正確,一個語言的效率是高仍是低,也在很大程度上取決於編譯器後端的優化。特別是即將進入AI時代,愈來愈多的芯片廠商誕生,我估計之後對這方面人才的需求會變得愈來愈旺盛。

總結

總結一下學習編譯器這部分古老知識帶給個人幾個收穫:

  1. 知道整個編譯由幾個階段構成,每一個階段作什麼事情;可是更深刻的每一個階段實現的一些細節還不知道,也不打算知道;
  2. 就算是編譯器這種複雜,很底層的東西也是能夠經過分解,讓每個階段獨立變得簡單、可複用,這對我在作應用開發有一些意義;
  3. 分層是爲了劃分指責,可是某些事情還須要全局的去作,好比優化,其實每個階段都會去作;對於咱們設計系統也是有必定參考意義的;
  4. 瞭解到 Golang 對外暴露的不少方法實際上是語法糖(如:make、painc etc.),編譯器會幫我忙進行翻譯,最開始我覺得是go代碼層面在運行時去作的,相似工廠模式,如今回頭來看本身真是太天真了;
  5. 對接下來準備學習Go的運行機制、以及Plan9彙編進行了一些基礎準備。

本文的不少信息都來自下面的資料。

我的公衆號:dayuTalk

GitHub:github.com/helei112g

相關文章
相關標籤/搜索