存儲器 - 內存:程序的虛擬內存是如何映射到物理內存
計算機組成原理目錄:https://www.cnblogs.com/binarylei/p/12585607.html
程序運行時,指令和數據都需要先加載到內存里面,才會被 CPU 拿去執行。那程序中的虛擬地址最終是如何映射到內存中的物理地址呢?從簡單頁表,到多級頁表,再到 TLB,都解決了那些問題?
- 簡單頁表:類似數組,時間復雜度為 O(1)。但空間復雜度為數組的長度,即頁的個數。32 位的內存地址為 4MB(= 2^20 * 4byte)。
- 多級頁表:類似 B+ 樹,時間復雜度為 O(n),如 4 級頁表就需要查詢 4 次。但程序只需要存儲正在使用的虛擬頁的映射關系,空間復雜度大大降低。
- TLB:使用緩存保存之前虛擬頁的映射關系。因為指令和數據往往都是連續的,存在空間局部性和時間局部性。也就是說,連續執行的多個指令和數據往往在同一個虛擬頁中,沒必要每次都從內存中讀取頁表來解析虛擬地址。
1. 虛擬地址和物理地址
另外,學習本節時可以將下面兩個知識點對比學習。
- 內存地址映射:虛擬地址是如何映射到物理地址?
- 高速緩存映射:內存地址是如何映射到 CPU Cache?
程序在編譯時不可能知道裝載后的物理內存地址,實際上,程序編譯生成的地址都是虛擬地址。在我們日常使用的 Linux 或者 Windows 操作系統下,程序並不能直接訪問物理內存。為了解決這個問題,當程序裝載后,會通過虛擬地址映射到真實的物理地址。

內存被分成固定大小的頁(Page),然后再通過虛擬內存地址(Virtual Address) 到物理內存地址(Physical Address) 的地址轉換(Address Translation),才能訪問實際存放數據的物理內存位置。而我們的程序看到的內存地址,都是虛擬內存地址。
這些虛擬內存地址究竟是怎么轉換成物理內存地址的呢?這一講里,我們就來看一看。
2. 簡單頁表
頁表(Page Table):想要把虛擬內存地址,映射到物理內存地址,最直觀的辦法,就是來建一張映射表。這個映射表,能夠實現虛擬內存里面的頁,到物理內存里面的頁的映射。這個映射表,在計算機里面,就叫作頁表。
頁表地址轉換,把一個內存地址分成頁號(Directory) 和偏移量(Offset) 兩個部分。以一個 32 位的內存地址,頁的大小 4KB 為例,內存地址的 20 位的高位表示頁號,12 位(212 = 4KB)的低位表示偏移量。
總結: 對於一個內存地址轉換,其實就是這樣三個步驟:
- 把虛擬內存地址,切分成頁號和偏移量的組合;
- 從頁表里面,查詢出虛擬頁號,對應的物理頁號;
- 直接拿物理頁號,加上前面的偏移量,就得到了物理內存地址。
下面我們先計算一下,這樣一個頁表需要多大的空間嗎?我們還是以 32 位的內存地址空間為例,需要一個數組大小為 220 的數組,同時存儲一個內存地址需要 4byte 大小,共需要內存大小為 4MB(= 220 * 4 byte)。根據虛擬頁號查找物理頁號公式如下:
物理頁號 = arr[虛擬頁號]
很顯然,一個程序就需要 4MB,我們的計算機上運行上千個進程是很正常的,這樣一算下來,光存儲頁表的開銷就有 4GB 啊?你有沒有更好的數據結構來存儲頁面呢?
3. 多級頁表
很明顯,大部分進程所占用的內存是有限的,我們只需要保存那些用到的頁之間的映射關系就好了。如果你對數據結構比較熟悉,你可能要說了,那我們是不是應該用哈希表(HashMap)這樣的數據結構呢?
很可惜你猜錯了。在實踐中,我們其實采用的是一種叫作多級頁表(Multi-Level Page Table) 的解決方案。為什么我們不用哈希表而用多級頁表呢?別着急,聽我慢慢跟你講。
3.1 進程的內存地址分配 - "兩頭實、中間空"
要知道為什么使用多級頁表而不是哈希表,首先就要知道,一個進程的內存地址空間是怎么分配的。在整個進程的內存地址空間,通常是 "兩頭實、中間空"。在程序運行的時候,內存地址從頂部往下,不斷分配占用的棧的空間。而堆的空間,內存地址則是從底部往上,是不斷分配占用的。
所以,在一個實際的程序進程里面,虛擬內存占用的地址空間,通常是兩段連續的空間。而不是完全散落的隨機的內存地址。而多級頁表,就特別適合這樣的內存地址分布。
3.2 頁表樹
事實上,多級頁表就像一個多叉樹的數據結構,所以我們常常稱它為頁表樹(Page Table Tree)。這種數據結構其實和 B+ 樹類似,允許一個結點存儲多條記錄,並且非葉子結點只存儲索引,只有葉子結點存儲數據。
使用多級頁表后,同樣的虛擬內存地址,偏移量的部分和上面簡單頁表一樣不變,而原先的頁號部分需要拆分成多段。我們還是以一個 32 位的內存地址,頁的大小 4KB 為例,其 20 個高位表示頁號,12 個低位表示偏移量。如果拆分成一個 4 級的多級頁表,需要將前 20 個高位從高到低,分成 4 級到 1 級這樣 4 個頁表索引。

說明: 一個進程會有一個 4 級頁表,通過虛擬地址查找物理地址時:
- 先通過 4 級頁表索引,找到 4 級頁表里面對應的條目(Entry)。這個條目里存放的是一張 3 級頁表所在的地址。4 級頁面里面的每一個條目,都對應着一張 3 級頁表,所以我們可能有多張 3 級頁表。
- 再根據 3 級頁表索引,在 3 級頁表中查找對應的 2 級頁表地址。
- 依次類推,直到 1 級頁表。在最后一級頁表中,保存虛擬地址對應的物理地址頁號。
多級頁表的結構和 B+ 樹非常類似,允許一個結點存儲多條記錄,並且非葉子結點只存儲索引,只有葉子結點存儲數據。

3.3 復雜度分析
3.3.1 空間復雜度
如果 32 位的內存地址(20 位頁號 + 12 位偏移量)平均拆分成 4 級,每級都用 5 bit 表示,那么每張頁表能存儲 2^5 = 32 條記錄。
- 滿 1 級頁表:一個滿頁大小 128 byte,可以映射 128 KB 內存地址。一個頁表共 32 條記錄,每條記錄中存儲物理內存對應的頁號,需要 4 byte,而對應的一個頁的大小為 4KB。
- 滿 2 級頁表:對應的就是 32 個 1 級頁表,也就是 4MB(= 32 * 128 KB)。
那么,我們現在可以推算一下,一個進程占用 8MB 的內存空間需要多大的頁表空間?8MB 分成了 2 個 4MB 的連續空間,一共需要 2 個獨立的、填滿的 2 級索引表,也就意味着 64 個 1 級索引表,2 個獨立的 3 級索引表,1 個 4 級索引表。一共需要 69 個索引表,大概就是 9KB(= 69 * 128byte)。比起 4MB 來說,只有差不多 1/500。
3.3.2 時間復雜度
多級頁表雖然節約了我們的存儲空間,卻帶來了時間上的開銷,所以多級頁表其實是一個 "以時間換空間" 的策略。原本我們進行一次地址轉換,只需要訪問一次內存就能找到物理頁號,算出物理內存地址。但是,用了 4 級頁表,我們就需要訪問 4 次內存,才能找到物理頁號了。
4. 加速地址轉換:TLB
多級頁表以時間換空間的策略,大節省了內存開銷。但使用 4 級頁表后,就需要訪問 4 次內存才能找到物理頁號。而虛擬地址和物理內存地址之間的地址轉換,是一個非常高頻的動作,對它的性能要求非常高,你有什么好的解決方案呢?我們最先想到的可能就是加緩存,事實上,CPU 也是這么做的。
4.1 為什么可以使用緩存
程序所需要使用的指令,都順序存放在虛擬內存里面。我們執行的指令,也是一條條順序執行下去的。也就是說,對於指令地址和需要訪問的數據,都存在空間局部性和時間局部性。
我們連續執行了 5 條指令,因為內存地址都是連續的,所以這 5 條指令通常都在同一個“虛擬頁”里。我們可以把之前的內存轉換地址緩存下來,使得不需要反復去訪問內存來進行內存地址轉換。

4.2 TLB
地址變換高速緩沖(Translation-Lookaside Buffer,TLB):是 CPU 中的一塊緩存芯片,這塊緩存存放了之前已經進行過地址轉換的查詢結果。有了 TLB ,當同樣的虛擬地址需要進行地址轉換的時候,我們可以直接在 TLB 查詢結果,而不需要多次訪問內存來完成一次轉換。
TLB 和我們前面講的 CPU 的高速緩存類似,可以分成指令的 TLB 和數據的 TLB,也就是 ITLB 和 DTLB。同樣的,我們也可以根據大小對它進行分級,變成 L1、L2 這樣多層的 TLB。另外,和高速緩存一樣,同樣需要用臟標記這樣的標記位,來實現 "寫回" 這樣緩存管理策略。

為了性能,我們整個內存轉換過程也要由硬件來執行。在CPU芯片里面,我們封裝了內存管理單元(MMU,Memory Management Unit)芯片,用來完成地址轉換。和TLB的訪問和交互,都是由這個 MMU 控制的。
參考:
- 《計算機組成與設計:硬件 / 軟件接口》的第 5.7 章節:虛擬內存。
- 《What Every Programmer Should Know About Memory》的第 4 部分:Virtual Memory。
每天用心記錄一點點。內容也許不重要,但習慣很重要!