剩料木板的問題
在記憶體那一階,你看過作業系統像個房東,發給每個行程一塊連續的 RAM。那幅圖像是誠實的,卻帶著一道傷口:外部碎裂。隨著行程來來去去,空閒空間碎成一地零零落落、形狀彆扭的縫隙。你也許總共還有 100 MB 空著,卻仍然載不進一個 40 MB 的行程,因為沒有任何單一縫隙寬達 40 MB。這就像想在一間還有八張空椅的餐廳裡安排一桌六人——可那八張椅子是一張在這、兩張在那,散落在整個店裡。空間是存在的,只是不成一片。
病根在於我們讓區塊可以是行程要求的任意大小。把木板按客製長度裁切,剩下的邊角料就很少正好合下一張訂單。於是有一個看似簡單、卻原來能修好一切的問題:如果記憶體根本不是客製木板,而是一塊塊一模一樣的磚呢?如果每一塊都是同樣的固定大小,那麼某個行程剩下的邊角,對任何別的行程來說都恰好是對的尺寸。這個單一的決定,就是分頁的全部核心,而這一階剩下的內容,不過是把它的後果一一推演出來。
兩個詞:分頁與框
分頁把兩邊的世界都切成磚,並給兩邊不同的名字,好讓我們永不混淆。實體 RAM 被切成等大的格子,叫做框(frame)。一個行程的邏輯位址空間,則被切成同樣大小的等大區塊,叫做分頁(page)。一個分頁和一個框大小完全相同——一頁 4 KB 正好塞進一個 4 KB 的框,像手套套上手。分頁要做的事很單純:決定哪一頁進哪一個框,並把這個對應記住。
殺死碎裂的魔法就在這裡:一個行程的各頁「不必」落在相鄰的框裡。第 0 頁可以住在第 12 框、第 1 頁住在第 5 框、第 2 頁住在第 27 框——散布在實體 RAM 各處。對執行中的程式而言這是隱形的;它的位址空間看起來仍是一段平滑、連續的範圍。散落發生在底下的實體記憶體裡,而一張表會把它追蹤下來。既然任何一個空閒框都跟另一個一樣好,作業系統只要任何地方有足夠的空閒框,就能隨時、以任意順序載入一個行程。剩料木板的問題根本不可能發生:每塊磚都合每個洞。
一個位址其實是兩個數字
如果各頁是散落的,一個位址怎麼還說得通?訣竅是把每一個邏輯位址讀成兩個黏在一起的數字:一個分頁編號與位移。分頁編號說的是「這個位址落在哪一頁」;位移說的是「它坐落在那一頁裡多深的地方」。把位址想成一個門牌:分頁編號是那棟樓,位移是樓裡的那一戶。轉譯只需要弄清楚那棟樓對應到哪一個實體框——而戶號(位移)永遠不變。
這個拆分並不是程式得去做的算術;它直接從位元上掉出來,這正是分頁之所以快的原因。一頁 4 KB 時,位移要能從 0 數到 4095,而既然 2^12 = 4096,那正好是位址最低的 12 個位元。那 12 個位元之上的一切,就是分頁編號。所以對一個 32 位元位址,硬體只是切一刀:最高的 20 個位元是分頁編號、最低的 12 個是位移——不必除法,純粹是線路。我們用一個小小的位址把它講具體。
Logical address = 9221, page size = 4 KB (4096 bytes) page number = 9221 / 4096 = 2 (which page) offset = 9221 % 4096 = 1029 (how far in) so 9221 == (page 2, offset 1029) If page 2 maps to frame 5: physical = 5 * 4096 + 1029 = 21509 Note: the offset 1029 is carried across UNCHANGED.
記住每頁住處的那張表
總得有個東西把「分頁編號到框編號」的對應存起來,那個東西就是分頁表——每個行程一張。它正是一本書的索引:查那個分頁編號,讀出它真正住在哪個框。分頁表是一個用分頁編號當索引的陣列;第 2 筆存著第 2 頁的框、第 3 筆存著第 3 頁的框,依此類推。要轉譯時,硬體拿著分頁編號到表裡查出一個框,再把那個框黏到不變的位移上,組成真正的實體位址。下一篇導覽會把這整趟轉譯一步步建起來;在這裡我們只需要知道:負責記憶的,就是這張表。
絕不能允許程式自己去設這張表——那會讓它把自己的頁映射到別的行程的框上、讀走人家的秘密。所以這張表是核心的,建在受保護的記憶體裡,而真正做查表的硬體,是坐在 CPU 與 RAM 之間的記憶體管理單元(MMU)。當作業系統切換行程時,它會把硬體指向新行程的那張表。有一個特別的暫存器,叫做分頁表基底暫存器,存著當前那張表的位址;在情境切換時改動它,正是「同一個邏輯位址 9221 對每個行程能指向不同實體位元組」的原理。順帶一提,每一筆表項攜帶的不只是框編號——有效位元、保護位元、髒位元也跟著走,這些後面的導覽會派上用場。
陷阱所在——以及為什麼一顆小快取即將登場
分頁很美,但要誠實面對它隱藏的帳單。分頁表太大,住不進暫存器,所以它待在 RAM 裡。這意味著程式做的「每一次」記憶體存取,如今都要花兩次記憶體存取:先讀分頁表去找框,再讀真正的資料。我們悄悄地把碰記憶體的成本加倍了——而記憶體本來就是機器裡慢的那一塊。一個天真的分頁設計,會讓每個程式以一半的速度跑,這沒人能接受。
解法靠的是真實程式的一個令人開心的事實:它們會把那少數幾頁一用再用——裝著正在跑的迴圈的那一頁、裝著正在掃描的陣列的那一頁。所以與其每次都去翻 RAM 裡那張大表,硬體在 CPU 旁邊保留了一顆又小又非常快的快取,存著最近用過的(分頁編號到框)配對。那顆快取就是轉譯後備緩衝區(TLB)——把它想成你剛碰過的那幾頁的便利貼。當你要的那一頁就在便利貼上時,你完全跳過讀表、以近乎零的成本拿到框;只有當它不在時,你才付出整趟查表的代價。