本文將會討論關於 Go 連結器、目標檔案(object file)以及重定位(relocation)相關的內容。
為什麼要關注這些東西呢?如果你想學習任何一個大專案的內部機制,那麼你首先要做的一件事就是學會將其分割成不同的部件或者模組。接下來,你需要搞懂這些模組向外提供的介面。在 Go 中,編譯器、連結器與執行時就是這樣的高層次模組。編譯器與連結器之間的介面就是目標檔案,所以我們今天就從目標檔案開始。
生成 Go 目標檔案
讓我們來做一個實驗,寫一個非常簡單的程式並編譯它,再看一下生成的目標檔案是什麼樣的。在這個例子中,我寫了這樣一段程式:
1 2 3 4 5 |
package main func main() { print(1) } |
非常簡單明瞭,不是嗎? 現在我們來編譯:
1 |
go tool 6g test.go |
這個命令會生成一個名為 test.6 的目標檔案。為了搞清楚這個檔案的內部結構,我們會用到 goobj 庫。這個庫在 Go 的原始碼中有用到,它主要被用來實現一些單元測試,以確定是否在各種不同的情況下生成的目標檔案都是正確的。為了這篇部落格,我寫了一個簡單的程式將 goobj 庫生成的內容輸出到終端介面。你可以在這裡找到程式的原始碼。
首先,你需要下載並安裝我的程式:
1 |
go get github.com/s-matyukevich/goobj_explorer |
接下來執行如下命令:
1 |
goobj_explorer -o test.6 |
現在你就可以在你的終端看到輸出的 goob.Package 的結構體了。
探索目標檔案
目標檔案中最有意思的一部分就是 Syms 陣列了。實際上,這是一個符號表。你在程式中定義的所有東西,包括函式、全域性變數、型別、常量等等,都寫在這個表中。讓我們來看一下這個表中儲存 main 函式對應的項。(注意:我已經刪掉了輸出中 Reloc 與 Func 的內容。我們稍後會討論這兩個部分。)
1 2 3 4 5 6 7 8 9 10 |
&goobj.Sym{ SymID: goobj.SymID{Name:"main.main", Version:0}, Kind: 1, DupOK: false, Size: 48, Type: goobj.SymID{}, Data: goobj.Data{Offset:137, Size:44}, Reloc: ..., Func: ..., } |
goobj.Sym 結構體中各域的命字就已經很好的解釋了其本身的含義:
Field | Description/描述 |
---|---|
SymID | 唯一的符號 ID。這個 ID 值包含了符號的名稱與版本號。版本資訊可以幫助區分同名稱的符號。 |
Kind | 識別符號號的所屬的型別(稍後會有更加詳細的介紹) |
DupOK | 標識是否允許符號冗餘(同名符號)。 |
Size | 符號資料的大小。 |
Type | 引用另外一個表示符號型別的符號(如果存在)。 |
Data | 包含二進位制資料。不同型別的符號該域的含義不同。例如,對於函式該域表示彙編程式碼,對於字串符號該域表示原始字串,等等。 |
Reloc | 重定位列表(稍後會有詳細介紹)。 |
Func | 包含函式符號的後設資料(稍會有詳細介紹)。 |
現在,讓我們來看一下各種符號。所有的符號型別都是定義在 goobj 包中的常量(請參考這裡)。下面,我列出了其中一部分的常量值:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
const ( _ SymKind = iota // readonly, executable STEXT SELFRXSECT // readonly, non-executable STYPE SSTRING SGOSTRING SGOFUNC SRODATA SFUNCTAB STYPELINK SSYMTAB // TODO: move to unmapped section SPCLNTAB SELFROSECT ... |
正如我們看到的那樣,main.main 符號屬於型別 1,對應於 STEXT 常量。STEXT 是一個包含可執行程式碼的符號。接下來,我們來看一下 Reloc 陣列。這個陣列包括如下的結構:
1 2 3 4 5 6 7 |
type Reloc struct { Offset int Size int Sym SymID Add int Type int } |
每個可重定位項意味著 [offset, offset+size] 這個區間的位元組需要被一個合適的地址所替代。這個合適的地址可以能過通過將 Sym 符號的地址加上 Add 個位元組得到。
深入理解重定位
接下來讓我們用一個例子來解釋一下重定位是如何工作的。為了演示,我們需要使用 -S 引數編譯我們的程式,這樣編譯器會輸出生成的彙編程式碼:
1 |
go tool 6g -S test.go |
讓我們看一下彙編程式碼並找到 main 函式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
"".main t=1 size=48 value=0 args=0x0 locals=0x8 0x0000 00000 (test.go:3) TEXT "".main+0(SB),$8-0 0x0000 00000 (test.go:3) MOVQ (TLS),CX 0x0009 00009 (test.go:3) CMPQ SP,16(CX) 0x000d 00013 (test.go:3) JHI ,22 0x000f 00015 (test.go:3) CALL ,runtime.morestack_noctxt(SB) 0x0014 00020 (test.go:3) JMP ,0 0x0016 00022 (test.go:3) SUBQ $8,SP 0x001a 00026 (test.go:3) FUNCDATA $0,gclocals·3280bececceccd33cb74587feedb1f9f+0(SB) 0x001a 00026 (test.go:3) FUNCDATA $1,gclocals·3280bececceccd33cb74587feedb1f9f+0(SB) 0x001a 00026 (test.go:4) MOVQ $1,(SP) 0x0022 00034 (test.go:4) PCDATA $0,$0 0x0022 00034 (test.go:4) CALL ,runtime.printint(SB) 0x0027 00039 (test.go:5) ADDQ $8,SP 0x002b 00043 (test.go:5) RET , |
在後續文章中,我們會更仔細地分析這段程式碼以弄明白 Go 執行時的工作方式。在這兒,我們只對下面這一行感興趣:
1 |
0x0022 00034 (test.go:4) CALL ,runtime.printint(SB) |
這一行指令在函式資料中的 0x0022(十六進位制)或者 00034(十進位制) 偏移處。這一行實際表示呼叫 runtime.printint 函式。可是問題在於編譯器在編譯階段並不知道 runtime.printint 函式在什麼位置。這個符號在另外一個編譯器完全不知道的目標檔案中。因此,編譯器就使用了重定位。下面是對應於這一個函式呼叫的重定位項(我從 goobj_explorer 工具的輸出中拷貝過來的):
1 2 3 4 5 6 7 |
{ Offset: 35, Size: 4, Sym: goobj.SymID{Name:"runtime.printint", Version:0}, Add: 0, Type: 3, }, |
這個重定位項告訴連結器從偏移 35 位元組開始的 4 個位元組需要替換為 runtime.printint 符號的開始地址。不過,從 main 函式開始的 35 位元組偏移的位置上實際上是我們之前看到的呼叫指令的引數(這個指定令從偏移量 34 位元組處開始,其中第一個位元組對應 call 指令,隨後四個位元組是這個指令所需的地址)。
連結器是如何工作的?
弄清楚了重定位是如何工作的之後,我們就能搞懂連結器的工作原理了。下面的概要非常的簡單,但是卻說明了連結器的工作原理:
- 連結器收集 main 包引用的所有其它包中的符號資訊,並將它們裝載到一個大的位元組陣列(或者二進位制映象)中。
- 對於每個符號,連結器計算它在映象中的地址。
- 然後它為每一個符號應用重定位。這就非常簡單了,因為連結器已經知道所有重定位項引用的符號的精確地址。
- 連結器準備所有 ELF 格式(Linux 系統中)檔案或者 PE 格式檔案(windows 系統中)所需的檔案頭。然後它再生成一個可執行的檔案。
深入理解 TLS
細心的讀者可能會從 goobj_explorer 的輸出中注意到編譯器為 main 方法生成了一個奇怪的重定位條目。它不能對應到任何方法呼叫甚至是指向了一個空符號:
1 2 3 4 5 6 7 |
{ Offset: 5, Size: 4, Sym: goobj.SymID{}, Add: 0, Type: 9, }, |
那麼這個重定位條目是做什麼的呢?我們可以看到這個條目的偏移量為 5 位元組並且其大小為 4 位元組。在這個偏移處,對應的彙編指令為:
1 |
0x0000 00000 (test.go:3) MOVQ (TLS),CX |
這條指令從 0 偏移處開始並且佔 9 位元組的空間(因為下一條命令是從 9 位元組偏移處開始的)。我們以猜測這個重定位條目會用某個地址替換掉這個奇怪的 TLS ,但是 TLS 到底是什麼東西呢?它的地址又是什麼呢?
TLS 其實是執行緒本地儲存 (Thread Local Storage )的縮寫。這個技術在很多程式語言中都有用到(請參考這裡)。簡單地說,它為每個執行緒提供了一個這樣的變數,不同變數用於指向不同的記憶體區域。
在 Go 語言中,TLS 儲存了一個 G 結構體的指標。這個指標所指向的結構體包括 Go 例程的內部細節(後面會詳細談到這些內容)。因此,當在不同的例程中訪問該變數時,實際訪問的是該例程相應的變數所指向的結構體。連結器知道這個變數所在的位置,前面的指令中移動到 CX 暫存器的就是這個變數。對於 AMD64,TLS 是用 FS 暫存器來實現的, 所在我們前面看到的命令實際上可以翻譯為 MOVQ FS, CX。
在重定位的最後,我列出了包含所有重定位型別的列舉型別:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// Reloc.type enum { R_ADDR = 1, R_SIZE, R_CALL, // relocation for direct PC-relative call R_CALLARM, // relocation for ARM direct call R_CALLIND, // marker for indirect call (no actual relocating necessary) R_CONST, R_PCREL, R_TLS, R_TLS_LE, // TLS local exec offset from TLS segment register R_TLS_IE, // TLS initial exec offset from TLS base pointer R_GOTOFF, R_PLT0, R_PLT1, R_PLT2, R_USEFIELD, }; |
正如你從這個列舉型別中可以看到的那樣, 重定位型別 3 是 R_CALL,重定位型別 9 是 R_TLS。這些列舉型別的名稱很好地解釋了我們前面討論的它們的行為。
更多關於 Go 目標檔案的內容
在後續文章中,我們會繼續目標檔案的討論。我也會為你提供更多的資訊以幫助你來理解 Go 執行時是如何工作的。如果你有任何問題,歡迎你在評論中提出來。