許多房客,一棟公寓
在前面的階梯裡,你已經知道一支正在執行的程式是一個行程——是正在烹煮的食譜,而不是架上的那本食譜書——而且每個行程都相信自己擁有一套從位址 0 往上延伸的位址空間。當時我們把這件事視為理所當然。現在我們要把它賺回來。打開你筆電現在的工作清單:一個瀏覽器、一個編輯器、一個音樂播放器、十幾個背景小幫手,全都在同一刻活著。每一個當初編譯時都當作自己獨佔了整台機器,每一個都會提到「位址 1000」那個位元組。然而實體 RAM 只有一條,而位址 1000 只能對應一個地方。它們怎麼可能全都說的是真話?
想像一棟公寓。每個房客自家大門上都標著「1」「2」「3」這樣的房號——對某一家而言的「3 號房」,跟另一家的「3 號房」是完全不同的兩件事。訣竅在於:那些門牌號是每一戶私有的;大樓真正的線路用的是另一套全域編號,沒有任何房客看得到。大樓管理員——也就是我們的核心——默默維護著一張對照表,把「李房客的 3 號房」對映到「四樓東翼那一間實際的單位」。這就是記憶體管理的全部主意:讓每個行程保有自己一套簡單、私有的編號,同時由系統把那些私有編號對映到那唯一一塊共用的真實記憶體上。本階接下來,我們都會靠這幅畫面來說明。
兩種位址
所以我們一直含糊地統稱為「位址」的,其實是兩件截然不同的東西。程式產生的那個數字——也就是房客門上的房號——叫做邏輯位址(也叫虛擬位址)。它是 CPU 指令真正在操作的東西:「載入 1000 處的值」「跳到 4096」。而最終落到記憶體匯流排上、選中一個真實 RAM 格子的那個數字——大樓真正的線路編號——叫做實體位址。一個行程能指名的所有邏輯位址,組成它的邏輯(或虛擬)位址空間;所有真實格子,組成實體位址空間。這兩個集合並不相同,而且幾乎從不一對一地對齊。
這裡是整篇導覽最重要的一句話:CPU 從來看不到實體位址,而 RAM 晶片也從來看不到邏輯位址。處理器只說邏輯位址;記憶體硬體只懂實體位址。兩者之間坐著一個翻譯員,在程式執行期間、對每一次記憶體存取,都把每個邏輯位址換算成對應的實體位址。我們會在下一篇導覽裡正式認識這個翻譯員——記憶體管理單元,也就是 MMU。現在,先記住這幅畫面:程式說「1000」,翻譯員把它變成比如「15384」,而碰到真實 RAM 的,永遠只有 15384。
「1000」是什麼時候被釘到真實格子上的?
如果程式只是說「1000」,系統是在什麼時候決定那指的是哪個真實格子?這個「名字何時被綁到實體位置」的問題,叫做位址綁定(address binding),而它可以發生在三個不同的時刻,一個比一個有彈性。最早的是編譯期:如果你不知怎地早就確定這支程式永遠會載入到實體位址 8000,編譯器大可把真實的數字直接烤進去。這正是小型嵌入式韌體的做法,但它很僵硬——程式一搬家,每個位址就全錯了。
中間的選項是載入期。編譯器讓位址維持「可重定位」的狀態——以起點 0 為基準寫成相對值——而當載入器把程式擺進記憶體時,它會把選定的起始位址加到每個位址上,這個步驟叫做重定位。這比較有彈性,但程式一旦載入,就被釘死在那個位置了;之後你若不重做一遍那些加法,就沒法搬動它。最後、也最強大的選項是執行期:綁定一直延後到每次存取真正執行時才做,而且由硬體完成。唯有這最後一個選擇,能讓作業系統在程式執行當中,把它拎起來、丟到 RAM 裡別的地方去——而這,正是 MMU 替我們換來的能力。
Three moments a name can become a real cell: compile time | real addresses baked in | rigid, never moves load time | loader adds a start offset | fixed once placed execution time | hardware translates each ref| can move while running <-- MMU Program says: logical 1000 At run time: 1000 --[ translate ]--> physical 15384
何必這麼麻煩?為了隔離、搬移與共享
這層「間接」並不是無聊的小聰明;它替我們買到三樣我們活不下去的東西。第一,隔離與保護。因為瀏覽器的邏輯位址 1000 和編輯器的邏輯位址 1000 會翻譯成不同的實體格子,兩者誰都不可能不小心——或刻意——讀到、塗改到對方的記憶體。當某個行程伸手伸到它配額之外時,翻譯員還能乾脆拒絕翻譯,這就是一根亂跑的指標為什麼會換來一次乾淨的當掉,而不是悄悄地把鄰居弄壞。一支出錯的程式,再也不會把整台機器一起拖垮。
第二,自由擺放。既然程式是以 0 為基準命名位址、由硬體加上真正的偏移量,作業系統就能把它載入到任何剛好有空位的地方,甚至日後再把它挪到別處——這份彈性,正是接下來幾篇導覽裡,讓我們把許多行程塞進同一條 RAM、甚至把睡著的行程暫放到磁碟上的同一份本事。第三,共享。可以給兩個行程一組指向「完全相同」實體格子的翻譯——例如某個共用函式庫的一份唯讀副本——於是單一一塊實體記憶體同時服務多位房客。同一套機制,既能把行程彼此隔牆分開,也能在作業系統選擇時,讓它們安全地重疊。
這一階要蓋的東西
你現在握住了後面一切的承重區別:邏輯位址(程式所說的)對上實體位址(位元組真正住的地方),由一個發生在每次存取上的翻譯把兩者接起來。本階接下來會把那個翻譯變得具體,然後看著它開始吃力。下一篇,我們會以最簡單、最誠實的形式認識 MMU——一個存著行程從哪裡開始的基底暫存器,和一個存著它有多大的界限暫存器:加上基底來翻譯,檢查界限來保護。
從那裡開始,問題接連湧出,而本階後面每一篇導覽各回答一個。如果我們給每個行程一整塊連續的記憶體,要怎麼挑用哪一個空洞——最先適配、最佳適配,還是最差適配——而記憶體又為什麼最後會佈滿用不上的縫隙:既有一塊內部浪費掉的小碎屑,也有塊與塊之間零散的洞(碎裂,或許靠緊縮來修補,或許靠把某個行程置換到磁碟上)?連結器又是怎麼把你的程式碼跟函式庫縫在一起——是靜態地烤進去,還是執行期動態共享——再由載入器把完成的映像帶活起來的?最後,如果我們不再假裝記憶體是一整塊扁平的東西,而是透過分段與它的分段表,給程式一個由幾塊有意義的部分——程式碼、資料、堆疊——組成的邏輯視角,會怎麼樣?這每一個,都只是今天這個問題的進一步答案:許多程式,要怎麼老老實實地共用同一塊記憶體?