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

為什麼要虛擬記憶體?大小、安全、自由

每個程式執行時都以為自己獨佔整台機器,從位址零開始,沒有鄰居可以撞上。那個讓人安心的謊言就是虛擬記憶體——在程式所指名的位址與位元組真正棲身的實體記憶體之間,悄悄當著一名翻譯。本篇要說的是這個幻覺為何存在,以及它一口氣解決的三個問題:大小、安全與自由。

同一個位址卻指兩件不同的事

我們已經爬了很長一段路。我們建好了資料路徑,把它管線化以追求吞吐量,接著用一整級的篇幅、靠快取去馴服記憶體牆——那是一張小而快的書桌,擺著你最常用的書,好讓你很少需要走去圖書館。快取假設了一件我們從未質疑的事:一條像 `load x5, 0(x6)` 的指令,指的是 RAM 裡一個真實的位置。虛擬記憶體就是我們不再把那件事視為理所當然的時刻。它在程式所說的位址與硬體所用的位址之間,悄悄塞進一名翻譯。

於是現在有兩種位址。一個虛擬位址(virtual address)是你的程式所指名的——出現在你的指標裡、出現在編譯器產生的偏移裡的那個數字。一個實體位址則是 RAM 晶片上真正的位置。它們通常不一樣,而兩者之間的鴻溝由位址轉換架橋:在每一次記憶體存取時做一次查找,把一個虛擬位址映射到一個實體位址。關鍵在於,兩個不同的程式可以都用虛擬位址 0x4000,卻把它轉換到兩個不同的實體位置——所以它們永遠不會相撞。

三個問題,一個優雅的把戲

為什麼要費事弄一個翻譯?想像一下那個糟糕的舊世界,每個程式都直接指名實體 RAM。大小:你的程式只有在塞得進那一刻空閒的實體 RAM 時才跑得動,而一個比 RAM 還大的程式根本無法執行。安全:沒有任何東西能阻止程式 A 寫到位址 0x4000、踩爛程式 B 的資料——更糟的是踩爛作業系統的。自由:編譯器得在建置時就確切知道程式會被載入到 RAM 的哪裡,因為兩個程式不可能都坐在實體位址 0。虛擬記憶體用同一個動作把這三者全部化解。

大小被解決,是因為一個虛擬位址空間可以比實體 RAM 還大。一個程式把它的程式碼、堆疊與堆積佈在一段廣闊的虛擬範圍上,而只有它真正碰到的部分才需要佔用真實的 RAM 框;其餘的可以住在磁碟上,按需取回。安全被解決,是因為轉換同時也是一道關卡:每一個映射都帶著權限位元,所以一個寫向程式並不擁有的記憶體的脫韁寫入,會被攔下並拒絕。這就是行程隔離記憶體保護——那道牆讓一個有臭蟲的程式無法毀掉另一個。自由被解決,是因為程式整個是用虛擬位址寫的;作業系統可以把它載入到實體 RAM 的任何地方,只要調整映射就好。編譯器永遠不必知道實體的真相。

映射怎麼運作:分頁與框

把每一個位元組個別映射,需要的地圖會跟記憶體本身一樣大——沒用。訣竅是以區塊為單位來映射。虛擬記憶體被切成固定大小的區塊,叫做分頁(page,常見是 4 KB),實體 RAM 則被切成同樣大小的區塊,叫做(frame)。於是轉換只需把一個分頁映射到一個;一個分頁之內的一切都保持它的位置,所以位址的低位元——分頁裡的偏移(offset)——原封不動地直接通過、不被轉換。只有高位元,也就是分頁編號,要被查找。這正是我們替快取用過的tag/index/offset切分的精神:底下的位元在一個區塊之內定址,頂上的位元辨識是哪一個區塊。

  A 32-bit virtual address, 4 KB pages (offset = 12 bits):

   31                                12 11                 0
  +-------------------------------------+--------------------+
  |        virtual page number (20)     |   offset  (12)     |
  +-------------------------------------+--------------------+
                     |                            |
           look up in the page table              | (unchanged)
                     v                            v
  +-------------------------------------+--------------------+
  |       physical frame number         |   offset  (12)     |
  +-------------------------------------+--------------------+
   ... physical address ...

  4 KB page  = 2^12 bytes -> 12 offset bits
  20 vpn bits -> 2^20 = ~1 million pages in a 4 GB space
用 4 KB 分頁轉換一個 32 位元虛擬位址。最上面的 20 位元(虛擬分頁編號)被查找以找出一個實體框編號;最下面的 12 位元(偏移)原封不動通過。

地圖本身就是分頁表:每一個虛擬分頁一列,每一列指名它住的框,外加那些至關重要的權限位元(可讀?可寫?可執行?到底在不在 RAM 裡?)。在每一次存取時執行這個查找的硬體,是記憶體管理單元,也就是 MMU。我們會用下一篇導覽把分頁表細細展開;現在,只要握住它的形狀:一張每個程式各一份的表,把分頁編號變成框編號,並帶著每個分頁的規矩。

現在一個載入到底做了什麼

讓我們追蹤一個 `load`,好讓各個零件咬合起來。假設你的程式執行 `load x5, 0(x6)`,而 x6 握著虛擬位址 0x00004ABC。在 4 KB 分頁下,最下面的 12 位元(0xABC)是偏移;最上面的 20 位元(0x00004)是虛擬分頁編號。以下是完整的旅程——並請注意,在你的程式碼裡看起來是一次記憶體存取的事,底下其實是硬體在每一次存取都替你做的一個轉換步驟。

  1. 拆開位址。MMU 把虛擬位址分成虛擬分頁編號 0x00004 與偏移 0xABC。偏移被擱在一邊,原封不動。
  2. 查找分頁。MMU 為虛擬分頁 0x00004 查閱分頁表,找到(比方說)實體框 0x0009,連同它的權限位元。
  3. 檢查權限。這是一次讀取,而該分頁被標為可讀且在場,所以存取獲准。(若這是對一個唯讀分頁的寫入,或對一個這程式並不擁有的分頁,MMU 就會改而升起一個保護錯誤。)
  4. 組成實體位址。MMU 把框 0x0009 與原本的偏移 0xABC 接起來,得到實體位址 0x00009ABC。直到這一刻,它才帶著一個真實的位置去找快取與 RAM。
  5. 取得資料。實體位址送往快取(未命中時送往 RAM),位於 0x00009ABC 的位元組回來,被寫進暫存器 x5——正如先前那幾級所假設的,只是現在前面悄悄多了一個轉換。

兩個誠實的擔憂應該正纏著你。第一,那個分頁表查找本身就是一次記憶體存取——所以現在每個載入豈不是要付兩趟記憶體?是的,在天真的設計裡是這樣,而這正是 TLB 存在的理由:一個裝著最近幾次轉換的小快取,命中時就完全跳過走訪分頁表這件事。第二,萬一那個分頁根本不在 RAM 裡呢?那麼轉換會觸發一個分頁錯誤,作業系統從磁碟取回那個分頁,而指令重新開始——這條慢路徑,正是讓一個比 RAM 還大的程式跑得起來的動力。我們會各用整篇導覽來談;現在,只要記住這兩件事都可能主宰效能。

誠實的邊角:它是協同設計,而且可能很痛

虛擬記憶體不是純硬體的功能,也不是純作業系統的功能——它是一個硬體/作業系統協同設計,而這正是這裡最重要的一個誠實註記。MMU 與 TLB 是矽;它們轉換得快,並在每一次存取都檢查權限,不需要軟體幫忙。但分頁表是由作業系統建立並維護的,而當有例外的事發生時——一個需要走訪分頁表的 TLB 未命中,或一個需要磁碟的分頁錯誤——控制權就跨過使用者/核心邊界、進入作業系統。進入核心這件事本身就是一種陷阱,是系統呼叫的近親。哪一層都無法獨力交付這個幻覺。

而效能可能真的很痛。在快樂路徑上——對一個在場的分頁的 TLB 命中——轉換幾乎免費,藏在快取存取的陰影裡。但一個 TLB 未命中要付一次分頁表走訪(更多記憶體存取),而一個分頁錯誤要付一趟磁碟,那是數百萬個時脈週期——比一次快取命中慢上好幾個數量級。這個教訓呼應了我們早已信任的效能鐵律區域性原則:轉換的平均代價之所以低,只因為命中很常見。一段以散亂、對分頁不友善的方式碰記憶體的程式碼,會把 TLB 拖垮、觸發錯誤,讓同一個運算以一模一樣的結果跑得慢上許多倍。

最後一個誠實的限定,因為這個領域很愛說鬆散的話。虛擬記憶體不會給你更多 RAM,也不會讓記憶體變快——在每一次存取上,它做的工作其實比一台赤裸的實體機器還多一點。它買來的不是速度,而是結構:給每個程式一個私有、受保護、可重定位的位址空間。這就是為什麼即使在有好幾 GB 餘裕的手機與筆電上,它仍然無所不在。你在常見情況上付一點點、藏得很好的稅,去換取讓整個現代世界、許多彼此隔離的程式得以存在的可能。