走進Golang之編譯器原理

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

本文目錄速覽:

認識 go build

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

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

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

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

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

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

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

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-RzRQoOtc-1573696152676)(https://dayutalk.cn/img/go-byq-0.png)]

如果對上面的代碼執行 go build -n 我們看一下輸出信息:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-E4jG2DkH-1573696152677)(https://dayutalk.cn/img/go-byq-1.png)]

來分析下整個執行過程

編譯器編譯過程

這一部分是編譯的核心,通過 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 構造的這顆樹是什麼樣子。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-L13PMI7l-1573696152681)(https://dayutalk.cn/img/go-byq-5.svg)]

這其中有意思的地方我全部用文字標註出來了。你會發現其實每一個 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:https://github.com/helei112g

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章