從上一篇的幻覺,到這一篇的機件
在這個階段的第一篇裡,你看見了虛擬記憶體的核心把戲:每個行程都相信自己擁有一片廣大、私密、簡單、從接近 0x0 開始的位址空間,儘管真實的 RAM 是共享的、散落各處、而且更小。你印出來的那個指標,像 0x7fffffffe4a0,是一個虛擬位址——一個由硬體維持的虛構。在它底下某處,另一個數字,也就是實體位址(physical address),命名著真實記憶體晶片裡某個真實的位元組。那一篇告訴你幻覺存在。這一篇講的是「把戲究竟怎麼變的」——那些具體的資料結構,以及那一塊把虛構變成可運作機器的硬體。
把每個虛擬位址都對應到一個實體位址,最天真的辦法是一張巨大的查表、每個位元組一個項目——但在 48 位元的位址空間下,那是每個行程數百兆個項目,荒謬至極。整套設計都建立在一個省成本的點子上:不要逐位元組翻譯。改為把記憶體切成固定大小的塊,一次翻譯一整塊。那些塊就是分頁(page),而它也是整個系統用來思考、保護、搬移記憶體的單位。
分頁:把位址切成兩半
一個分頁,不過就是位址空間中一個固定大小、對齊的區塊——在今天幾乎所有常見系統上,它是 4 KiB,也就是 4096 個位元組。實體記憶體被切成同樣大小的區塊,叫做頁框(frame,或頁框 page frame)。於是翻譯根本不是針對個別位元組:它把一個虛擬分頁對應到一個實體頁框,而那一頁裡的每個位元組都以相同的偏移量一起被帶過去。如果虛擬分頁 P 住在實體頁框 F 裡,那麼虛擬位元組(P, 偏移)就是實體位元組(F, 偏移)——偏移量在兩邊是一模一樣的。
這正是為什麼 4 KiB 這個大小會直接顯現在位址的位元裡。因為 4096 是 2^12,任何位址最低的 12 個位元都是「頁內」的偏移量,它們永遠不會被翻譯——它們原封不動地穿過去。那 12 個位元之上的一切,是頁碼(page number),而那才是硬體真正會去查的部分。所以拆解一個位址純粹是位元切割:把低 12 位元遮掉得到偏移、把其餘部分右移得到頁碼。沒有算術,只是一刀切下。
a 4 KiB page => offset is the low 12 bits (2^12 = 4096)
virtual address 0x00405abc, split:
bits: ... 0000 0100 0000 0101 | 1010 1011 1100
\_________________________/ \___________/
page number 0x405 offset 0xabc
page_number = vaddr >> 12 = 0x405
offset = vaddr & 0xFFF = 0xabc
if page 0x405 maps to frame 0x131, the physical address is:
paddr = (0x131 << 12) | 0xabc = 0x131abc分頁表:一張隨行程而生的、記錄每頁住在哪的地圖
現在我們需要真正的查找:給定一個頁碼,它在哪一個頁框裡?那個答案住在分頁表裡——一個由作業系統建立、由硬體讀取的資料結構。關鍵在於,每個行程都有自己的分頁表,這正是為什麼兩個行程可以都使用虛擬位址 0x405000、卻落在完全不同的實體記憶體上:它們的表把那一頁指向不同的頁框。這張表,就是上一篇承諾「私密位址空間」背後那個具體的物件。
表裡的每一列,是一個分頁表項目(PTE)。一個 PTE 存放著該頁的頁框碼,外加少數幾個單一位元的旗標,而這個階段接下來的內容都仰賴它們:一個 present(存在)位元(這一頁此刻是不是真的在 RAM 裡,還是碰它就得驚動作業系統——這是下一篇隨選分頁的種子)、像 writable(可寫)與 user-accessible(使用者可存取)這樣的權限位元(這是每頁保護的基礎)、一個 accessed(已存取)位元與一個 dirty(已弄髒)位元(這一頁有沒有被讀過、有沒有被寫過——用來決定該逐出哪一頁)。頁框碼回答「在哪裡」;旗標位元回答「依什麼規則」。
MMU 與 TLB:在每一次存取上,又快又默默地做這件事
接下來這部分,要相信了才會懂得欣賞:這個翻譯,發生在你程式所做的每一次記憶體存取上——每一次指令擷取、每一次載入、每一次儲存,每秒上億次。沒有任何軟體跟得上,所以它是由記憶體管理單元(MMU)在硬體裡完成的——一個坐落在 CPU 核心與記憶體之間的單元。當 CPU 發出一個虛擬位址,MMU 走過分頁表、找到頁框、組出實體位址,然後存取才終於抵達 RAM。你的程式從來看不見這件事發生;在它那一側,它只是把一個指標解參考了一下。
但走過一棵四層的樹,意味著「光是為了翻譯一個位址」就要多達四次額外的記憶體讀取——那會讓每次存取慢上五倍,無法接受。解法是一個快取:轉譯後備緩衝區(TLB),一張在 MMU 內部、極小、極快的表,記著最近的「頁碼到頁框」翻譯。一次存取時,MMU 先查 TLB;一次命中(hit)只要一個週期就給出頁框,那個緩慢的分頁表走查就被完全跳過。只有未命中(miss)才會觸發完整的走查,而它的結果接著會被快取進 TLB 以備下次。因為程式一再地碰同樣那些頁(這就是區域性),TLB 的命中率通常遠高於 99%,翻譯也就變得幾乎不花成本。
- 你的程式碼把一個指標解參考,於是 CPU 發出一個虛擬位址——它被硬體切成一個頁碼(高位元)與一個偏移(低 12 位元)。
- MMU 在 TLB 裡查那個頁碼。命中時它立刻拿到頁框碼、直接跳到最後一步;未命中時它得做緩慢的走查。
- 未命中時,MMU 走過多層分頁表、一路到那個分頁表項目,讀出頁框碼與旗標位元,並把結果快取進 TLB。
- 它檢查旗標——如果那一頁不存在、或這次存取違反了它的權限,MMU 就不翻譯,而是向作業系統拋出一個錯誤(這是接下來兩篇的主題);否則它組出實體位址 頁框*4096 + 偏移,讓存取抵達 RAM。
那些旗標替你買到了什麼,以及這裡老實說是個簡化
注意一下,光是一張頁框碼的表、加上每個項目幾個旗標位元,能掉出多少東西來。把一頁的 present 位元清掉,作業系統就能假裝它有比實際更多的 RAM、只在被碰到時才把資料分頁進來——那就是隨選分頁(demand paging),下一篇。把一頁的 writable 位元清掉、卻讓兩個行程共用它的頁框,於是一次寫入觸發一個錯誤、作業系統把它變成一份私有的副本——那就是寫入時複製(copy-on-write),再下一篇。把核心那些頁的 user-accessible 位元清掉,使用者程式就在物理上無法讀取它們;這「就是」記憶體保護在每一頁上的執行。硬體在每次存取上免費地檢查這些位元,這正是為什麼現代系統裡的保護既便宜又絕對,而不是軟體事後的補丁。
對這幅乾淨的圖所掩蓋的兩件事,要誠實。第一,分頁大小不是自然法則:4 KiB 是常見的預設值,但大多數 CPU 也支援大型分頁(huge pages,在 x86-64 上是 2 MiB 與 1 GiB),它讓每個 TLB 項目涵蓋更多記憶體,因而替那些有大片、熱門區域的工作負載減少 TLB 未命中——代價是較粗的粒度。第二,TLB 是每核心一份、並不會神奇地與分頁表保持同步:當作業系統改變一個對應時,它必須明確地清掉(flush)那個過時的 TLB 項目,而在一次行程切換時,它通常會讓項目失效,好讓下一個行程看不到前一個行程的翻譯。漏掉一次清除,是作業系統裡一類真實又難纏的 bug——硬體會樂呵呵地繼續用一個軟體以為自己已經刪掉的翻譯。
這把你帶到了哪裡
退一步,那個幻覺就不再神祕了。記憶體被切成 4 KiB 的頁;每個行程帶著一張分頁表、把它的頁對應到實體頁框,而每個項目裡的幾個旗標位元決定著存在與權限;MMU 在每一次存取上執行翻譯;而 TLB 快取最近的翻譯,讓常見的情形幾乎不花成本。每支程式所享有的那片私密、簡單的位址空間,不過就是這張地圖,被硬體一秒讀上數十億次。
我們刻意讓兩個旗標位元帶著張力懸在那裡,而接下來幾篇就來拉它們。當 MMU 找到一個 present 位元被清掉的分頁表項目時,它無法翻譯——於是它把控制權交給作業系統,成為一次分頁錯誤(page fault)。下一篇,講分頁錯誤與隨選分頁,會展示核心在那個處理常式裡做了什麼:找到或取來那一頁、裝上一個有效的對應、然後恢復你的指令,彷彿什麼都沒發生過——這正是讓一支程式得以使用比機器實際擁有更多記憶體的機制。地圖你現在握有了;接下來,你會看著作業系統按需把它填滿。