頁表

分頁虛擬記憶體與虛擬記憶體的概念一起於 1962 年在 Ferranti Atlas 計算機上發明,這是第一臺具有分頁虛擬記憶體的計算機。隨著時間的推移,該功能遷移到更新的計算機,併成為所有類 Unix 系統的實際功能。 1985 年,該功能包含在 Intel 80386 中,這是 Linux 1.0 開發所用的 CPU。

頁表將 CPU 看到的虛擬地址對映到外部記憶體總線上看到的物理地址。

Linux 將頁表定義為一個層次結構,目前高度為五層。 然後,每個受支援架構的架構程式碼都會將其對映到硬體的限制。

對應於虛擬地址的物理地址通常由底層物理頁幀引用。 **頁幀號**或**pfn**是頁的物理地址(在外部記憶體總線上看到的)除以 PAGE_SIZE

物理記憶體地址 0 將為 *pfn 0*,最高的 pfn 將是 CPU 的外部地址匯流排可以定址的物理記憶體的最後一頁。

當頁面粒度為 4KB 且地址範圍為 32 位時,pfn 0 位於地址 0x00000000,pfn 1 位於地址 0x00001000,pfn 2 位於 0x00002000,依此類推,直到我們到達 0xfffff 處的 pfn 0xfffff000。 對於 16KB 頁面,pfs 位於 0x00004000、0x00008000 ... 0xffffc000,並且 pfn 從 0 變為 0x3ffff。

正如您所看到的,對於 4KB 頁面,頁面基本地址使用地址的位 12-31,這就是為什麼在這種情況下 PAGE_SHIFT 定義為 12,並且 PAGE_SIZE 通常根據頁面移位定義為 (1 << PAGE_SHIFT)

隨著時間的推移,為了應對不斷增長的記憶體大小,開發了更深的層次結構。 當 Linux 建立時,使用了 4KB 頁面和一個名為 swapper_pg_dir 的單頁表,其中包含 1024 個條目,覆蓋 4MB,這與 Torvald 的第一臺計算機擁有 4MB 物理記憶體的事實相吻合。 這個單表中的條目被稱為 *PTE*:s - 頁表條目。

軟體頁表層次結構反映了頁表硬體已變為分層的事實,這樣做是為了節省頁表記憶體並加快對映速度。

當然,人們可以想象一個單一的線性頁表,其中包含大量的條目,將整個記憶體分解為單個頁面。 這樣的頁表將非常稀疏,因為虛擬記憶體的很大一部分通常保持未使用狀態。 透過使用分層頁表,虛擬地址空間中的大孔不會浪費寶貴的頁表記憶體,因為它足以在頁表層次結構中的更高級別將大區域標記為未對映。

此外,在現代 CPU 上,更高級別的頁表條目可以直接指向物理記憶體範圍,這允許在單個高級別頁表條目中對映幾個兆位元組甚至千兆位元組的連續範圍,從而在將虛擬記憶體對映到物理記憶體時採取捷徑:當您找到像這樣的大型對映範圍時,無需在層次結構中進一步遍歷。

頁表層次結構現在已發展成這樣

+-----+
| PGD |
+-----+
   |
   |   +-----+
   +-->| P4D |
       +-----+
          |
          |   +-----+
          +-->| PUD |
              +-----+
                 |
                 |   +-----+
                 +-->| PMD |
                     +-----+
                        |
                        |   +-----+
                        +-->| PTE |
                            +-----+

頁表層次結構的不同級別上的符號具有以下含義,從底部開始

  • **pte**,pte_tpteval_t = **頁表條目** - 前面提到過。 *pte* 是 pteval_t 型別的 PTRS_PER_PTE 元素的陣列,每個元素都將單個虛擬記憶體頁面對映到單個物理記憶體頁面。 架構定義 pteval_t 的大小和內容。

    一個典型的例子是 pteval_t 是一個 32 位或 64 位的值,其高位是 **pfn**(頁幀號),低位是一些特定於架構的位,例如記憶體保護。

    名稱的**entry**部分有點令人困惑,因為雖然在 Linux 1.0 中,這確實指的是單個頂級頁表中的單個頁表條目,但當首次引入兩級頁表時,它被追溯修改為對映元素的陣列,因此 *pte* 是最底層的頁 *表*,而不是頁表 *條目*。

  • **pmd**,pmd_tpmdval_t = **頁面中間目錄**,*pte* 之上的層次結構,具有對 *pte*:s 的 PTRS_PER_PMD 引用。

  • **pud**,pud_tpudval_t = **頁面上層目錄**是在其他級別之後引入的,用於處理 4 級頁表。 它可能未使用,或者如我們稍後將討論的那樣 *摺疊*。

  • **p4d**,p4d_tp4dval_t = **頁面第 4 級目錄**是在 *pud* 之後引入的,用於處理 5 級頁表。 現在很明顯,我們需要用一個指示目錄級別的數字來替換 *pgd*、*pmd*、*pud* 等,並且我們不能再使用臨時名稱了。 這僅在實際具有 5 級頁表的系統上使用,否則會被摺疊。

  • **pgd**,pgd_tpgdval_t = **頁面全域性目錄** - Linux 核心處理核心記憶體的 PGD 的主頁表仍然可以在 swapper_pg_dir 中找到,但系統中的每個使用者空間程序也都有自己的記憶體上下文,因此也有自己的 *pgd*,可以在 struct mm_struct 中找到,而 struct mm_struct 又在每個 struct task_struct 中引用。 因此,任務具有以 struct mm_struct 形式的記憶體上下文,而這又具有指向相應頁面全域性目錄的 struct pgt_t *pgd 指標。

重複一遍:頁表層次結構中的每個級別都是 *指標陣列*,因此 **pgd** 包含指向下一級別下方的 PTRS_PER_PGD 指標,**p4d** 包含指向 **pud** 專案的 PTRS_PER_P4D 指標,依此類推。 每個級別上的指標數量由架構定義。

      PMD
--> +-----+           PTE
    | ptr |-------> +-----+
    | ptr |-        | ptr |-------> PAGE
    | ptr | \       | ptr |
    | ptr |  \        ...
    | ... |   \
    | ptr |    \         PTE
    +-----+     +----> +-----+
                       | ptr |-------> PAGE
                       | ptr |
                         ...

頁表摺疊

如果架構未使用所有頁表級別,則可以 *摺疊* 它們,這意味著跳過它們,並且對頁表執行的所有操作都將在編譯時進行擴充,以便在訪問下一個較低級別時僅跳過一個級別。

希望保持架構中立的頁表處理程式碼(例如虛擬記憶體管理器)需要編寫為遍歷當前所有五個級別。 這種風格也應該優先用於特定於架構的程式碼,以便對未來的更改具有魯棒性。

MMU、TLB 和頁面錯誤

記憶體管理單元 (MMU) 是一個硬體元件,用於處理虛擬地址到物理地址的轉換。 它可能會使用硬體中相對較小的快取,稱為 轉換後備緩衝區 (TLB)頁面遍歷快取 來加速這些轉換。

當 CPU 訪問記憶體位置時,它會向 MMU 提供一個虛擬地址,MMU 會檢查 TLB 或頁面遍歷快取中是否存在現有轉換(在支援它們的架構上)。 如果未找到轉換,MMU 會使用頁面遍歷來確定物理地址並建立對映。

當頁面被寫入時,頁面的髒位會被設定(即開啟)。 記憶體的每個頁面都有相關的許可權和髒位。 後者表示自頁面載入到記憶體中以來,頁面已被修改。

如果沒有任何阻止它的情況,最終可以訪問物理記憶體,並且對物理幀執行所請求的操作。

MMU 找不到某些轉換有幾個原因。 可能是因為 CPU 試圖訪問當前任務不允許訪問的記憶體,或者是因為資料不存在於物理記憶體中。

當這些情況發生時,MMU 會觸發頁面錯誤,這是一種異常,它會向 CPU 發出訊號,暫停當前執行並執行一個特殊函式來處理上述異常。

頁面錯誤有常見和預期的原因。 這些是由程序管理最佳化技術(稱為“惰性分配”和“寫時複製”)觸發的。 當幀已交換到持久儲存(交換分割槽或檔案)並從其物理位置逐出時,也可能發生頁面錯誤。

這些技術提高了記憶體效率,減少了延遲,並最大限度地減少了空間佔用。 本文件不會深入探討“惰性分配”和“寫時複製”的細節,因為這些主題超出了範圍,因為它們屬於程序地址管理。

交換與其他提到的技術不同,因為它是不受歡迎的,因為它是在記憶體壓力很大的情況下執行的。

交換不能用於核心邏輯地址對映的記憶體。 這些是核心虛擬空間的一個子集,它直接對映一個連續的物理記憶體範圍。 給定任何邏輯地址,其物理地址都是透過對偏移量進行簡單的算術運算來確定的。 訪問邏輯地址的速度很快,因為它們避免了對複雜頁表查詢的需求,但代價是幀不可逐出和分頁。

如果核心無法為必須存在於物理幀中的資料騰出空間,核心會呼叫記憶體不足 (OOM) 殺手來騰出空間,方法是終止優先順序較低的程序,直到壓力降至安全閾值以下。

此外,頁面錯誤也可能由程式碼錯誤或 CPU 指示訪問的惡意製作的地址引起。 程序的執行緒可以使用指令來定址不屬於其自身地址空間的(非共享)記憶體,或者可以嘗試執行想要寫入只讀位置的指令。

如果上述條件發生在使用者空間中,核心會向當前執行緒傳送 段錯誤 (SIGSEGV) 訊號。 該訊號通常會導致執行緒及其所屬程序的終止。

本文件將簡化並展示 Linux 核心如何處理這些頁面錯誤、建立表和表條目、檢查記憶體是否存在,如果不存在,則請求從持久儲存或其他裝置載入資料,並更新 MMU 及其快取的高層檢視。

第一步取決於架構。 大多數架構跳轉到 do_page_fault(),而 x86 中斷處理程式由呼叫 handle_page_fault()DEFINE_IDTENTRY_RAW_ERRORCODE() 宏定義。

無論採用何種路線,所有架構最終都會呼叫 handle_mm_fault(),而 handle_mm_fault() 又(可能)最終呼叫 __handle_mm_fault() 來執行分配頁表的實際工作。

無法呼叫 __handle_mm_fault() 的不幸情況意味著虛擬地址指向不允許訪問的物理記憶體區域(至少從當前上下文中)。 這種情況會使核心向程序傳送上述 SIGSEGV 訊號,並導致已經解釋過的後果。

__handle_mm_fault() 透過呼叫多個函式來查詢頁表上層的條目偏移量並分配可能需要的表來執行其工作。

查詢偏移量的函式具有像 *_offset() 這樣的名稱,其中“*”代表 pgd、p4d、pud、pmd、pte;相反,用於逐層分配相應表的函式被稱為 *_alloc,使用上述約定在層次結構中以相應的表型別命名它們。

頁表遍歷可能在中間層或上層(PMD、PUD)之一結束。

Linux 支援比通常的 4KB 更大的頁面大小(即所謂的 巨頁)。 當使用這些型別的較大頁面時,更高級別的頁面可以直接對映它們,而無需使用較低級別的頁面條目 (PTE)。 巨頁包含通常從 2MB 到 1GB 的大型連續物理區域。 它們分別由 PMD 和 PUD 頁面條目對映。

巨頁帶來了幾個好處,例如減少 TLB 壓力、減少頁表開銷、提高記憶體分配效率以及提高某些工作負載的效能。 但是,這些好處也伴隨著權衡,例如浪費記憶體和分配挑戰。

在分配的遍歷的最後,如果它沒有返回錯誤,__handle_mm_fault() 最終會呼叫 handle_pte_fault(),而 handle_pte_fault() 又透過 do_fault() 執行 do_read_fault()do_cow_fault()do_shared_fault() 中的一個。“read”、“cow”、“shared”給出了關於它正在處理的錯誤的原因和型別的提示。

工作流的實際實現非常複雜。 它的設計允許 Linux 以一種根據每個架構的特定特徵量身定製的方式處理頁面錯誤,同時仍然共享一個共同的總體結構。

為了結束對 Linux 如何處理頁面錯誤的高層檢視,讓我們補充一點,可以使用 pagefault_disable()pagefault_enable() 分別停用和啟用頁面錯誤處理程式。

幾個程式碼路徑使用了後兩個函式,因為它們需要停用陷入頁面錯誤處理程式的陷阱,主要是為了防止死鎖。