一支程式是由許多片湊成的
到目前為止,這個階段都把行程當成它就這麼出現在記憶體裡、完整成形、等著MMU去翻譯它的位址。但那個完成的映像是從哪來的?真實的程式並不是寫成一個巨大的單一檔案。你把程式碼寫在好幾個原始檔裡;別人寫的某個函式庫負責處理列印和數學運算;系統還提供更多用來跟磁碟對話的程式碼。這每一塊都被分別編譯成一個目的檔——一段幾乎可以執行、卻又還差一點的機器碼,因為它一直在提到那些活在其他片裡的東西。
把它想成用一套組件拼一件家具。每一袋零件本身都是完整的,但說明書會寫像「把面板 A 接到第 3 袋的螺栓上」這樣的話。第 1 袋沒辦法獨力完成;它有個只有第 3 袋能填的洞。目的檔正是如此:當你的程式碼呼叫一個叫 `printf` 的函式時,編譯器還不知道 `printf` 將會坐落在記憶體的哪裡,於是它留下一個貼了標籤的空白——一個未解析參考——上頭寫著「跳到 printf 最後落腳的任何地方」。每個目的檔都是已完工的機器碼,與這些指向別處所定義之符號、貼了標籤的空白的混合體。
連結器:縫合各片、填上空白
把所有那些目的檔(加上它們所需的函式庫片段)接過來、焊成一支連貫程式的工作,屬於連結器。它做兩件事。第一,它把各片一個接一個地排進單一的合併映像,決定每段程式碼與資料的相對位置。第二——也是關鍵的部分——它走過每一個貼了標籤的空白並把它填上:既然它已經知道 `printf` 被放在哪了,就能把那條「跳到 printf」的指令補上 printf 真正的偏移量。解析這些符號、修補這些參考,正是連結的核心。
注意連結器填進去的是哪一種位址。它根本不可能知道這支程式之後會落在哪一塊實體 RAM——那要看使用者按兩下執行它的當下還有什麼別的在跑。所以連結器是在邏輯位址裡作業:它產出的映像,排版時就當作自己將從位址 0 開始往上跑,正是第一篇導覽裡那個私有世界。在我們的故事裡,這就是那套私有編號真正被創造出來的時刻。連結器烤進去的是相對於映像起點而言正確的偏移量,把「真正的起始位址是多少」這個問題留到後面。那個「決定真正位址」的問題有個名字:位址繫結,而它可以在三個不同的時間點發生。
- 編譯時期:如果你事先就確切知道程式會在 RAM 的哪裡執行,就能把真正的實體位址直接烤進去。某些極小的嵌入式系統就這麼做,但這很僵硬——把程式挪個地方,每一個位址就全錯了。
- 載入時期:連結器把位址留成從零起算的偏移量,當程式被搬進記憶體時,載入器再把選定的起始位址加上去,逐一改寫每一個。這行得通,但每次程式被重新載入到新地方,這套改寫就得重做一遍。
- 執行時期:即使程式正在跑,位址都還是維持為從零起算的偏移量,由MMU在每一次存取時加上基底暫存器。這正是整個這個階段所假設的方案,因為唯有如此,作業系統才能自由地重定位一個執行中的行程。
這三個繫結時間點,正是第二篇導覽的重定位為什麼如此重要的原因。在執行時期繫結下,程式從不把自己綁死在一個真實位址上,所以作業系統只要改一個暫存器,就能搬動它、把它換出、或在它周圍做壓縮——而這些若連結器在編譯時期就把實體位址硬接進去,全都不可能辦到。這個階段整套有彈性的記憶體機制,悄悄地全都建立在連結器選擇把位址留成偏移量、而非把它們烤死進去這件事上。
載入器:從磁碟映像到活的行程
連結器交給我們一個躺在磁碟上、完成的可執行檔——仍然是被動的,仍然只是一份食譜。把它變成一個活的行程,是載入器的工作,也就是當你啟動一支程式時運作的那部分作業系統。回想行程那個階段:exec系統呼叫正是用一支全新程式取代某行程內容的東西;載入器就是 exec 所驅動的那套機制。它讀取可執行檔,為它撥出 RAM,把程式碼與資料複製進那塊 RAM,並把位址空間所需的一切安排妥當,好讓 CPU 能開始執行最開頭那條指令。
on disk (executable) in RAM (process image)
+------------------+ base ->+------------------+
| text (code) | loader | text (code) |
| data (globals) | ========> | data (globals) |
| bss = zeros | copy + | bss (zeroed) |
| symbol/reloc tbl | set up | heap v |
+------------------+ | ... (grows) |
| stack ^ |
+------------------+
loader also sets the base register so logical 0 -> base注意載入器並不是逐字把每樣東西都複製進去。文字區段(你的機器碼)和已初始化的資料區段是直接從檔案複製過來的,但bss——那些起始值為零的全域變數——根本不存在檔案裡;載入器只是保留那麼多 RAM 並把它清零,這讓可執行檔更小。它也為堆積和堆疊留出空房間,好讓它們在執行期成長。最後,如果這個作業系統用的是載入時期繫結,載入器會走過重定位表、把選定的基底加上去逐一改寫每個位址;如果用的是執行時期繫結,它則只設定基底暫存器,往後永遠交給 MMU 去做那個加法。
靜態與動態連結,以及共享函式庫
在「函式庫各片何時被併進來」這件事上,路有個岔口,這就是靜態對動態連結的抉擇。採用靜態連結時,連結器在建置當下,就把你用到的每個函式庫函式的實際機器碼,原原本本複製進你的可執行檔。成品完全自給自足:一個不需任何別的東西就能跑的檔案。代價是體積與陳舊。如果有一千支程式全都靜態連結同一個列印函式庫,那段程式碼就有一千份躺在磁碟上,而當它們全跑起來時又佔掉一千份 RAM——而且一旦在函式庫裡發現了臭蟲,這一千支程式每一支都得重新建置才能拿到修正。
採用動態連結時,函式庫程式碼被排除在你的可執行檔之外。連結器改為替每個函式庫函式插入一段小小的樁碼:一個寫著「真正的程式碼住在另一個函式庫檔案裡,去把它找出來」的佔位符。那個解析動作被延到程式真正執行時才做——在載入時,甚至懶到第一次呼叫時才做。它找到的函式庫就是共享函式庫(在 Linux 上是 `.so`,在 Windows 上是 DLL),而魔法就在「共享」這個詞裡:那個函式庫程式碼在記憶體裡只要一份副本,就能同時被映射進每一個使用它的行程的位址空間,於是它們全都讀同樣的實體分頁,而不是各自扛著一份自己的副本。
把整條流水線拼起來
退一步看,從你敲出的文字到能跑的程式碼,這整段旅程排成了一條流水線。編譯器把每個原始檔變成一個塞滿機器碼與貼標籤空白的目的檔。連結器把這些目的檔與所需的函式庫片段焊成一個從邏輯位址零開始排版的映像,並把它能解的空白都解掉。載入器則在你啟動程式時,把那個映像複製進真正的 RAM、設好堆積與堆疊,再不是改寫位址、就是設定基底暫存器讓 MMU 去做。唯有到這一步,一支被動的程式才變成一個活的行程。
有一個誠實的但書,把這一切繫回這個階段的其餘部分。這裡的一切,仍然假設載入器能找到一整段連續的 RAM 來把整個映像丟進去——也就是第三篇導覽裡那同一個連續配置假設,以及那同一份外部碎裂之苦。在現代機器上這個假設悄悄是假的:載入器並不需要單一一塊,因為位址空間被切成可以散落在實體記憶體各處的分頁。但那套機制——分頁——是下一個階段的故事了。無論記憶體方案是什麼,連結器與載入器都一樣不可或缺:總得有人把各片組裝好、放置妥當,第一條指令才跑得起來。