Go語言內幕(3):連結器、連結器、重定位

yhx發表於2015-10-05

本文將會討論關於 Go 連結器、目標檔案(object file)以及重定位(relocation)相關的內容。

為什麼要關注這些東西呢?如果你想學習任何一個大專案的內部機制,那麼你首先要做的一件事就是學會將其分割成不同的部件或者模組。接下來,你需要搞懂這些模組向外提供的介面。在 Go 中,編譯器、連結器與執行時就是這樣的高層次模組。編譯器與連結器之間的介面就是目標檔案,所以我們今天就從目標檔案開始。

生成 Go 目標檔案

讓我們來做一個實驗,寫一個非常簡單的程式並編譯它,再看一下生成的目標檔案是什麼樣的。在這個例子中,我寫了這樣一段程式:

非常簡單明瞭,不是嗎? 現在我們來編譯:

這個命令會生成一個名為 test.6 的目標檔案。為了搞清楚這個檔案的內部結構,我們會用到 goobj 庫。這個庫在 Go 的原始碼中有用到,它主要被用來實現一些單元測試,以確定是否在各種不同的情況下生成的目標檔案都是正確的。為了這篇部落格,我寫了一個簡單的程式將 goobj 庫生成的內容輸出到終端介面。你可以在這裡找到程式的原始碼。

首先,你需要下載並安裝我的程式:

接下來執行如下命令:

現在你就可以在你的終端看到輸出的 goob.Package 的結構體了。

探索目標檔案

目標檔案中最有意思的一部分就是 Syms 陣列了。實際上,這是一個符號表。你在程式中定義的所有東西,包括函式、全域性變數、型別、常量等等,都寫在這個表中。讓我們來看一下這個表中儲存 main 函式對應的項。(注意:我已經刪掉了輸出中 Reloc 與 Func 的內容。我們稍後會討論這兩個部分。)

goobj.Sym 結構體中各域的命字就已經很好的解釋了其本身的含義:

Field Description/描述
SymID 唯一的符號 ID。這個 ID 值包含了符號的名稱與版本號。版本資訊可以幫助區分同名稱的符號。
Kind 識別符號號的所屬的型別(稍後會有更加詳細的介紹)
DupOK 標識是否允許符號冗餘(同名符號)。
Size 符號資料的大小。
Type 引用另外一個表示符號型別的符號(如果存在)。
Data 包含二進位制資料。不同型別的符號該域的含義不同。例如,對於函式該域表示彙編程式碼,對於字串符號該域表示原始字串,等等。
Reloc 重定位列表(稍後會有詳細介紹)。
Func 包含函式符號的後設資料(稍會有詳細介紹)。

現在,讓我們來看一下各種符號。所有的符號型別都是定義在 goobj 包中的常量(請參考這裡)。下面,我列出了其中一部分的常量值:

正如我們看到的那樣,main.main 符號屬於型別 1,對應於 STEXT 常量。STEXT 是一個包含可執行程式碼的符號。接下來,我們來看一下 Reloc 陣列。這個陣列包括如下的結構:

每個可重定位項意味著 [offset, offset+size] 這個區間的位元組需要被一個合適的地址所替代。這個合適的地址可以能過通過將 Sym 符號的地址加上 Add 個位元組得到。

深入理解重定位

接下來讓我們用一個例子來解釋一下重定位是如何工作的。為了演示,我們需要使用 -S 引數編譯我們的程式,這樣編譯器會輸出生成的彙編程式碼:

讓我們看一下彙編程式碼並找到 main 函式。

在後續文章中,我們會更仔細地分析這段程式碼以弄明白 Go 執行時的工作方式。在這兒,我們只對下面這一行感興趣:

這一行指令在函式資料中的 0x0022(十六進位制)或者 00034(十進位制) 偏移處。這一行實際表示呼叫 runtime.printint 函式。可是問題在於編譯器在編譯階段並不知道 runtime.printint 函式在什麼位置。這個符號在另外一個編譯器完全不知道的目標檔案中。因此,編譯器就使用了重定位。下面是對應於這一個函式呼叫的重定位項(我從 goobj_explorer 工具的輸出中拷貝過來的):

這個重定位項告訴連結器從偏移 35 位元組開始的 4 個位元組需要替換為 runtime.printint 符號的開始地址。不過,從 main 函式開始的 35 位元組偏移的位置上實際上是我們之前看到的呼叫指令的引數(這個指定令從偏移量 34 位元組處開始,其中第一個位元組對應 call 指令,隨後四個位元組是這個指令所需的地址)。

連結器是如何工作的?

弄清楚了重定位是如何工作的之後,我們就能搞懂連結器的工作原理了。下面的概要非常的簡單,但是卻說明了連結器的工作原理:

  • 連結器收集 main 包引用的所有其它包中的符號資訊,並將它們裝載到一個大的位元組陣列(或者二進位制映象)中。
  • 對於每個符號,連結器計算它在映象中的地址。
  • 然後它為每一個符號應用重定位。這就非常簡單了,因為連結器已經知道所有重定位項引用的符號的精確地址。
  • 連結器準備所有 ELF 格式(Linux 系統中)檔案或者 PE 格式檔案(windows 系統中)所需的檔案頭。然後它再生成一個可執行的檔案。

深入理解 TLS

細心的讀者可能會從 goobj_explorer 的輸出中注意到編譯器為 main 方法生成了一個奇怪的重定位條目。它不能對應到任何方法呼叫甚至是指向了一個空符號:

那麼這個重定位條目是做什麼的呢?我們可以看到這個條目的偏移量為 5 位元組並且其大小為 4 位元組。在這個偏移處,對應的彙編指令為:

這條指令從 0 偏移處開始並且佔 9 位元組的空間(因為下一條命令是從 9 位元組偏移處開始的)。我們以猜測這個重定位條目會用某個地址替換掉這個奇怪的 TLS ,但是 TLS 到底是什麼東西呢?它的地址又是什麼呢?

TLS 其實是執行緒本地儲存 (Thread Local Storage )的縮寫。這個技術在很多程式語言中都有用到(請參考這裡)。簡單地說,它為每個執行緒提供了一個這樣的變數,不同變數用於指向不同的記憶體區域。

在 Go 語言中,TLS 儲存了一個 G 結構體的指標。這個指標所指向的結構體包括 Go 例程的內部細節(後面會詳細談到這些內容)。因此,當在不同的例程中訪問該變數時,實際訪問的是該例程相應的變數所指向的結構體。連結器知道這個變數所在的位置,前面的指令中移動到 CX 暫存器的就是這個變數。對於 AMD64,TLS 是用 FS 暫存器來實現的, 所在我們前面看到的命令實際上可以翻譯為 MOVQ FS, CX。

在重定位的最後,我列出了包含所有重定位型別的列舉型別:

正如你從這個列舉型別中可以看到的那樣, 重定位型別 3 是 R_CALL,重定位型別 9 是 R_TLS。這些列舉型別的名稱很好地解釋了我們前面討論的它們的行為。

更多關於 Go 目標檔案的內容

在後續文章中,我們會繼續目標檔案的討論。我也會為你提供更多的資訊以幫助你來理解 Go 執行時是如何工作的。如果你有任何問題,歡迎你在評論中提出來。

相關文章