JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

連結器、載入器與系統呼叫

組譯器把每個原始檔變成了一個半成品目的檔。現在來看連結器如何把這些片段縫成一支程式、載入器如何把它擺進記憶體並啟動它,以及系統呼叫如何成為那唯一一道有人看守的門——你的程式碼凡是靠自己辦不到的事,都得透過它去向作業系統開口。

許多目的檔,一支程式

在第一篇的尾聲,組譯器交給你一個目的檔:一塊機器指令,外加一張記著「鬆脫線頭」的表。那些線頭,是這個檔案用到、卻沒有定義的東西的名字——它呼叫的某個 `printf`、一個住在別處的全域計數器。組譯器之所以沒辦法填上它們的位址,是因為它一次只看得到一個檔案。然而真正的程式,通常是許多目的檔加上預先建好的函式庫,全部分別編譯。總得有人把它們湊在一起、把每一個線頭都解析成一個真實的位址。那個人就是連結器

把每個目的檔想成由不同作者寫的一章,裡頭滿是「見另一章所定義的某函式」這類註腳。連結器就是那位編輯:他把所有章節首尾相接拼成一本書,再走過每一條註腳、把它改寫成指向真正的頁碼。具體來說它做兩件事:符號解析——把每一個未解析的名字(一個參照)對應到那唯一一個定義它的檔案;以及重定位——一旦每個區段都在程式的位址空間裡分到了最終的位置,就修補每一條指令與指標,讓它們的數字指向正確的地方。產出的是一個單一的可執行檔,每一個線頭都打好了結。

靜態連結對動態連結

當連結器需要某個函式庫裡的程式碼——比方說把東西印到螢幕的那個函式——它有兩種供應方式。靜態連結把函式庫的程式碼直接複製進你的可執行檔,於是成品完全自給自足:它要跑的一切都住在它裡面。動態連結則改成留下一個樁與一張紙條,寫著「真正的 `printf` 住在共享 C 函式庫裡;執行時再去找它」。真正的接合,於是不再由連結器完成,而是在程式啟動時由載入器的一部分收尾。整個靜態對動態連結的選擇,是一樁關於「黏合在何處、何時發生」的取捨。

兩種風格各有所得、也各有所失。靜態連結給你一個單一的胖檔案、毫無執行時相依——好出貨,也不怕函式庫在你腳下被換掉——但每支程式都各帶一份相同函式庫的副本,浪費磁碟與記憶體,而且那個函式庫一個安全修補,就意味著得把每支程式重新建置。動態連結讓幾十支程式共用一份只在實體記憶體裡放一次的函式庫副本,而且打過補丁的函式庫能立刻把它們全部修好,但它多了一點啟動成本,以及那個惡名昭彰的風險:執行時實際出現的版本,不是你當初測試所用的那個。沒有哪個單純比較好;選擇取決於你看重什麼。

載入器:從檔案到執行中的行程

磁碟上的可執行檔,不過是一片沉睡的位元組圖案;在它被喚醒之前什麼都不會跑。那場喚醒,是載入器的工作——它是作業系統的一部分。當你啟動一支程式,載入器讀取可執行檔的標頭,搞清楚每個區段有多大,向作業系統要一塊嶄新的位址空間,把程式碼與初始資料複製(或記憶體映射)進去,架好一個空的堆疊,把任何動態連結收尾,最後藉由把程式計數器指向進入點,把控制權交給程式的第一條指令。從那一刻起,磁碟上的檔案就成了一個活生生的行程,擁有自己對記憶體的私有視野。

  1. 讀標頭。載入器剖析可執行檔,搞清楚它的各個區段——程式碼、已初始化的資料、要保留多少歸零空間——以及各自該擺在哪。
  2. 鑿出一塊位址空間。作業系統交給新行程一塊自己私有的虛擬位址空間,讓它看起來彷彿獨自擁有整台機器。
  3. 擺好程式碼與資料。載入器把程式的位元組映射進那塊空間;多虧虛擬記憶體,它不必一開始就全部複製,而能在需要時才把分頁缺頁載入。
  4. 解析動態函式庫。如果程式是動態連結的,載入器會找到每個共享函式庫、把它映射進來,並把那些樁接到真正的函式上。
  5. 跳到進入點。載入器設好堆疊與程式計數器,接著移交控制權;你的程式碼現在跑起來了。

這裡也正是作業系統悄悄落實行程隔離之處。每個行程都拿到自己的位址空間,於是一支程式根本沒辦法稱呼、更別說碰到另一支的記憶體——它能寫的位址裡,沒有任何一個能伸進鄰居那邊。行程隔離,就是讓單一一台機器能把瀏覽器、音樂播放器與一個有臭蟲的遊戲並排跑起來的東西:其中一個當機,頂多只結束那一個,而不會把整個系統一起拖下水。

系統呼叫:通往作業系統的有人看守之門

你執行中的行程,被封在自己的位址空間裡,這帶出一個問題:它沒辦法讀檔、在螢幕上畫東西,或送出一個網路封包,因為這些全都會碰到大家共用的硬體。CPU 跑在兩種特權層級裡——給作業系統用的、不受限的核心模式,以及給一般程式用的、被圍起來的使用者模式。在使用者模式裡,那些跟裝置對話的危險指令根本動不了;試著執行一條就會陷入。那麼你的程式碼到底是怎麼把事情辦成的?它透過一個系統呼叫,客客氣氣地向作業系統開口。

一個系統呼叫,是一道從使用者模式進入核心模式、受控而刻意的門。程式把一個替它想要的服務命名的編號(open、read、write、exit)連同引數,放進約定好的暫存器——它自己的一套小小呼叫慣例——接著執行一條特殊的陷阱指令,例如 RISC-V 的 `ecall`。那條指令做了任何普通跳躍都做不到的事:它把 CPU 切進核心模式,並且跳到作業系統裡一個單一、固定的進入點,兩者在一個不可分割的步驟裡完成。核心檢查這個請求是否被允許,代替程式執行那件有特權的工作,把結果放回一個暫存器,再把控制權還給陷阱指令的下一條,並退回使用者模式。

# RISC-V (Linux): write "Hi\n" to the screen, then exit cleanly.
# A system call = put a number + args in registers, then 'ecall'.

  li   a7, 64           # a7 = syscall number 64  -> 'write'
  li   a0, 1            # a0 = file descriptor 1  -> standard output
  la   a1, msg          # a1 = address of the bytes to write
  li   a2, 3            # a2 = how many bytes
  ecall                 # trap into the kernel; it does the write, returns

  li   a7, 93           # a7 = syscall number 93  -> 'exit'
  li   a0, 0            # a0 = exit status 0      -> success
  ecall                 # trap again; the kernel ends this process
兩個系統呼叫。注意這裡沒有 'call printf'——只有設定暫存器與一條 ecall 陷阱。那些編號與暫存器的選擇,是核心自己事先約定好的呼叫慣例。

退一步:去讀它,而不是去寫它

把這一級收攏起來看。在這五篇裡,你看著一支高階程式一路融化到貼著鐵皮:運算式被攤平成暫存器算術、if 與迴圈變成條件分支、堆疊在程序呼叫與遞迴下漲落、一份呼叫慣例讓陌生人寫的程式碼能合作,而現在,連結器、載入器與系統呼叫把你的程式碼,接到了它旁邊的函式庫與它上方的作業系統。每一步都剝掉一層令人安心的虛構,直到只剩下指令、暫存器與記憶體。這趟下降正是全部的重點:編譯器與作業系統合力藏起了多得驚人的機械,好讓你能用 C 來思考。

最後誠實補一句,和這一級開場那句一樣。在 2026 年,幾乎沒有人會為了真正的工作而手寫組合語言——編譯器在分配暫存器與排程指令上做得比人好,而且它們從不疲倦。始終無價的,是讀懂它的能力。讀懂編譯器產出的組合語言,正是你用來搞懂「為什麼某一版迴圈,在結果一模一樣的情況下卻慢上好幾倍」的方法,也是你在連原始碼都看不到時除錯一份當機傾印、檢查一段攸關安全的程式是否真的名副其實的方法。你不需要變成一個組合語言程式設計師。你需要的,是再也不會對「機器在你的程式碼底下到底在做什麼」感到一頭霧水。