把錯覺切成大小相等的方磚
上一篇我們見識了虛擬記憶體這個宏大的承諾:每個程式都相信自己擁有一長條私有、連續的位址,而一位翻譯員悄悄把它想像中的房間,對應到真實的建築上。明顯的問題是「怎麼做」——如果程式要第 4,000,000 號位元組,那個位元組到底住在實體記憶體的哪裡?逐一對應每一個位元組,需要一張和記憶體本身一樣大的地圖,這荒謬至極。所以我們不對應位元組,我們對應「區塊」。
區塊是這整個階段的核心物件,所以我們好好認識它。我們把虛擬位址空間切成大小相等的小塊,叫做分頁(page);又把實體記憶體切成大小完全相同的小塊,叫做頁框(frame)。典型大小是 4 KB——也就是 4096 個位元組,即 2^12。分頁只是一個概念,是程式想像中那棟房子裡的一塊方磚;頁框則是真實建築裡一個實際的格子。分頁的工作,就是決定此刻哪一個分頁住在哪一個頁框裡——而兩者的順序完全不必一致。
一個位址其實偷偷是兩個數字
讓分頁變得便宜的,是這個漂亮的環節。因為分頁大小是 2 的次方,一個虛擬位址會乾淨俐落地切成兩個欄位——正如記憶體階層那一階裡,快取位址切成標籤、索引、偏移那樣。低位是偏移(offset)——指分頁「裡面」的第幾個位元組;高位是分頁號(page number)——指第幾頁。用 4 KB 的分頁,最低的 12 位(因為 2^12 = 4096)是偏移,其上的一切則是虛擬分頁號。
32-bit virtual address, 4 KB pages (offset = 12 bits)
31 12 11 0
+-----------------------------+-------------------+
| virtual page number (20) | offset (12) |
+-----------------------------+-------------------+
| translate | copied
v (page -> frame) v unchanged
+-----------------------------+-------------------+
| physical frame number | offset (12) |
+-----------------------------+-------------------+
example: virtual 0x00402F00
page# = 0x00402 -> table says frame 0x019
offset = 0xF00 -> copied straight through
physical = 0x019F00看看這替我們省下了什麼。偏移從不需要翻譯——某位元組在它那塊方磚裡的位置,不管你把那塊磚叫分頁還是頁框,都是同一個。於是整個位址翻譯的動作,化約成一次查表:拿虛擬分頁號,查出它此刻對應到哪個實體頁框號,再把那個頁框號黏到原封不動的偏移前面。這一次替換——分頁號換頁框號——就是全部的把戲。本篇其餘的一切,只不過是「替換表放在哪」以及「怎麼把查表變快」。
分頁表:一本大查詢筆記本
翻譯員把「分頁對頁框」的對應放在哪?放在一個叫分頁表的結構裡——概念上就是一個很長的陣列,用虛擬分頁號當索引,每個格子告訴你那一頁住在哪個頁框。要翻譯,就拿分頁號當索引去查這張表,讀出頁框號。關鍵在於,每個執行中的程式都有「自己」的分頁表,所以兩個不同程式裡相同的虛擬位址,會落到兩個不同的頁框。這正是那個「房子錯覺」運作的方式:房間號碼相同,真實建築卻不同。
這本筆記本裡的每一筆,叫做分頁表項(PTE),它存的不只是頁框號——還帶著一小撮做真正安全工作的狀態位。有效(valid)位表示這一頁此刻到底有沒有對應到真實頁框。保護位(protection bits)規定允許做什麼:可讀、可寫、可執行。而在場(present/resident)位表示這一頁此刻是真的坐在記憶體裡,還是已被換出到磁碟。頁框號加上這些位,「就是」那份對應——小歸小,但機器做的每一次載入與儲存,都得穿過其中一筆。
這些位,正是虛擬記憶體兌現上一篇第二個承諾——保護——的方式。若一次載入指向某頁、而它的有效位是關的,或一次儲存指向某頁、而它的可寫位是關的,硬體就會拒絕並陷入作業系統——這就是在野指標或脫韁的寫入毀壞任何東西之前,攔下它的機制。同一套機制也強制使用者/核心的界線,因為核心的分頁只要標記成使用者程式碼不得進入即可。翻譯與保護不是兩套系統硬湊在一起;它們是「同一次查表」,這正是我們一再堅持「虛擬記憶體是位址翻譯加保護,絕不只是『多出來的 RAM』」的原因。
一步步走完一次翻譯
我們把一次載入完整地走過分頁表——走長路,先不抄任何捷徑。假設一個程式從虛擬位址 0x00402F00 執行一次載入,分頁大小為 4 KB。硬體得先把這個虛擬位址變成實體位址,才能取那個位元組。它走的每一步如下。
- 拆開位址。低 12 位 0xF00 是分頁內的偏移。高 20 位 0x00402 是虛擬分頁號。只有分頁號會被翻譯。
- 找到這個程式的分頁表。一個特殊的硬體暫存器(常稱分頁表基底暫存器)指向目前程式的分頁表在實體記憶體中的起點。
- 用它來索引。拿虛擬分頁號 0x00402 當索引,讀出那一筆分頁表項——這次讀取本身就是一次記憶體存取。
- 檢查那些位。有效位有設嗎?保護位允許這次載入嗎?這一頁在記憶體裡嗎?任一項檢查失敗,就陷入作業系統,而非繼續。
- 組出實體位址。從那一筆取出頁框號——比方 0x019——把原封不動的偏移 0xF00 接在它後面,得到實體位址 0x019F00。現在終於可以去取那個位元組了。
留意藏在第 3 步裡那筆痛苦的代價。讀分頁表項「本身就是跑一趟記憶體」。所以一台天真的分頁機器,會把每一次載入或儲存變成「兩次」記憶體存取——一次讀對應、再一次讀真正的資料。這大致會把效能砍半,無法接受。解法是一個小而快、專門快取最近翻譯結果的東西,叫做 TLB,那是第 4 篇完整的主題;眼下你只要記住:分頁表能用,但靠它自己,會慢到毀滅性的地步。
表太大了——於是我們把表自己也分頁
還有第二個問題,這次關乎大小,而非速度。一張扁平的分頁表,需要替「每一個可能的」虛擬分頁都備一筆,無論程式用不用得到。在 32 位、4 KB 分頁的機器上有 2^20 頁,於是約一百萬筆、每筆假設 4 位元組——每個程式 4 MB 的表。糟,但還活得下去。在 64 位機器上,同樣的扁平方案會要求一張比人類製造過的所有 RAM 加起來還大的分頁表。一塊連續的陣列根本不可能。
解藥妙在自我指涉:把分頁表自己也分頁。多層分頁表把那一塊巨大的陣列拆成一棵小表構成的樹。虛擬分頁號本身又被切成好幾個欄位,每個欄位索引樹的一層:最上面的欄位在根表裡挑一筆,那一筆指向第二層的表,它的某一筆又指向下一層,直到最後一層終於給出頁框。精妙之處在於,涵蓋未用位址範圍的子樹,乾脆「根本不存在」——只碰幾 MB 的程式,只需付出幾張小表的代價,而非那整個不可能的一百萬筆。
這一切查找——拆位址、走各層、檢查那些位、組出結果——都由一個專責的硬體完成,叫做記憶體管理單元(MMU),它坐在處理器與記憶體之間,每一次存取都翻譯一遍。MMU 與這趟多層走訪,是第 5 篇深入的主題。要記住的誠實代價是:多層表以空間換時間,因為現在單單一次翻譯,可能得做「好幾次」記憶體讀取才能走完那棵樹——這正是為什麼第 4 篇的 TLB 不是奢侈品,而是必需品。虛擬記憶體是硬體與作業系統的協同設計,而一次 TLB 未命中或一次分頁錯誤,能悄悄主宰效能。