本文基於內核 5.4 版本源碼討論
在前面兩篇介紹 mmap 的文章中,筆者分別從原理角度以及源碼實現角度帶着大家深入到內核世界深度揭秘了 mmap 內存映射的本質。從整個 mmap 映射的過程可以看出,內核只是在進程的虛擬地址空間中尋找出一段空閒的虛擬內存區域 vma 然後分配給本次映射而已。
vma = vm_area_alloc(mm);
vma->vm_start = addr;
vma->vm_end = addr + len;
vma->vm_flags = vm_flags;
vma->vm_page_prot = vm_get_page_prot(vm_flags);
vma->vm_pgoff = pgoff;
如果是文件映射的話,內核還會額外做一項工作,就是將分配出來的這段虛擬內存區域 vma 與映射文件關聯映射起來。
vma->vm_file = get_file(file);
error = call_mmap(file, vma);
映射的核心就是將虛擬內存區域 vm_area_struct 相關的內存操作 vma->vm_ops 設置為文件系統的相關操作 ext4_file_vm_ops。這樣一來,進程後續對這段虛擬內存的讀寫就相當於是讀寫映射文件了。
無論是匿名映射還是文件映射,內核在處理 mmap 映射過程中貌似都是在進程的虛擬地址空間中和虛擬內存打交道,僅僅只是為 mmap 映射分配出一段虛擬內存而已,整個映射過程我們並沒有看到物理內存的身影。
那麼大家所關心的物理內存到底是什麼時候映射進來的呢 ?這就是今天本文要討論的主題 —— 缺頁中斷。
1. 缺頁中斷產生的原因
如下圖所示,當 mmap 系統調用成功返回之後,內核只是為進程分配了一段 [vm_start , vm_end] 範圍內的虛擬內存區域 vma ,由於還未與物理內存發生關聯,所以此時進程頁表中與 mmap 映射的虛擬內存相關的各級頁目錄和頁表項還都是空的。
當 CPU 訪問這段由 mmap 映射出來的虛擬內存區域 vma 中的任意虛擬地址時,MMU 在遍歷進程頁表的時候就會發現,該虛擬內存地址在進程頂級頁目錄 PGD(Page Global Directory)中對應的頁目錄項 pgd_t 是空的,該 pgd_t 並沒有指向其下一級頁目錄 PUD(Page Upper Directory)。
也就是説,此時進程頁表中只有一張頂級頁目錄表 PGD,而上層頁目錄 PUD(Page Upper Directory),中間頁目錄 PMD(Page Middle Directory),一級頁表(Page Table)內核都還沒有創建。
由於現在被訪問到的虛擬內存地址對應的 pgd_t 是空的,進程的四級頁表體系還未建立,所以 MMU 會產生一個缺頁中斷,進程從用户態轉入內核態來處理這個缺頁異常。
此時 CPU 會將發生缺頁異常時,進程正在使用的相關寄存器中的值壓入內核棧中。比如,引起進程缺頁異常的虛擬內存地址會被存放在 CR2 寄存器中。同時 CPU 還會將缺頁異常的錯誤碼 error_code 壓入內核棧中。
隨後內核會在 do_page_fault 函數中來處理缺頁異常,該函數的參數都是內核在處理缺頁異常的時候需要用到的基本信息:
dotraplinkage void
do_page_fault(struct pt_regs *regs, unsigned long error_code, unsigned long address)
struct pt_regs 結構中存放的是缺頁異常發生時,正在使用中的寄存器值的集合。address 表示觸發缺頁異常的虛擬內存地址。
error_code 是對缺頁異常的一個描述,目前內核只使用了 error_code 的前六個比特位來描述引起缺頁異常的具體原因,後面比特位的含義我們先暫時忽略。
P(0) : 如果 error_code 第 0 個比特位置為 0 ,表示該缺頁異常是由於 CPU 訪問的這個虛擬內存地址 address 背後並沒有一個物理內存頁與之映射而引起的,站在進程頁表的角度來説,就是 CPU 訪問的這個虛擬內存地址 address 在進程四級頁表體系中對應的各級頁目錄項或者頁表項是空的(頁目錄項或者頁表項中的 P 位為 0 )。
如果 error_code 第 0 個比特位置為 1,表示 CPU 訪問的這個虛擬內存地址背後雖然有物理內存頁與之映射,但是由於訪問權限不夠而引起的缺頁異常(保護異常),比如,進程嘗試對一個只讀的物理內存頁進行寫操作,那麼就會引起寫保護類型的缺頁異常。
R/W(1) : 表示引起缺頁異常的訪問類型是什麼 ? 如果 error_code 第 1 個比特位置為 0,表示是由於讀訪問引起的。置為 1 表示是由於寫訪問引起的。
注意:該標誌位只是為了描述是哪種訪問類型造成了本次缺頁異常,這個和前面提到的訪問權限沒有關係。比如,進程嘗試對一個可寫的虛擬內存頁進行寫入,訪問權限沒有問題,但是該虛擬內存頁背後並未有物理內存與之關聯,所以也會導致缺頁異常。這種情況下,error_code 的 P 位就會設置為 0,R/W 位就會設置為 1 。
U/S(2):表示缺頁異常發生在用户態還是內核態,error_code 第 2 個比特位設置為 0 表示 CPU 訪問內核空間的地址引起的缺頁異常,設置為 1 表示 CPU 訪問用户空間的地址引起的缺頁異常。
RSVD(3):這裏用於檢測頁表項中的保留位(Reserved 相關的比特位)是否設置,這些頁表項中的保留位都是預留給內核以後的相關功能使用的,所以在缺頁的時候需要檢查這些保留位是否設置,從而決定近一步的擴展處理。設置為 1 表示頁表項中預留的這些比特位被使用了。設置為 0 表示頁表項中預留的這些比特位還沒有被使用。
I/D(4):設置為 1 ,表示本次缺頁異常是在 CPU 獲取指令的時候引起的。
PK(5):設置為 1,表示引起缺頁異常的虛擬內存地址對應頁表項中的 Protection 相關的比特位被設置了。
error_code 比特位的含義定義在文件 /arch/x86/include/asm/traps.h 中:
/*
* Page fault error code bits:
*
* bit 0 == 0: no page found 1: protection fault
* bit 1 == 0: read access 1: write access
* bit 2 == 0: kernel-mode access 1: user-mode access
* bit 3 == 1: use of reserved bit detected
* bit 4 == 1: fault was an instruction fetch
* bit 5 == 1: protection keys block access
*/
enum x86_pf_error_code {
X86_PF_PROT = 1 << 0,
X86_PF_WRITE = 1 << 1,
X86_PF_USER = 1 << 2,
X86_PF_RSVD = 1 << 3,
X86_PF_INSTR = 1 << 4,
X86_PF_PK = 1 << 5,
};
2. 內核處理缺頁中斷的入口 —— do_page_fault
經過上一小節的介紹我們知道,缺頁中斷產生的根本原因是由於 CPU 訪問的這段虛擬內存背後沒有物理內存與之映射,表現的具體形式主要有三種:
- 虛擬內存對應在進程頁表體系中的相關各級頁目錄或者頁表是空的,也就是説這段虛擬內存完全沒有被映射過。
- 虛擬內存之前被映射過,其在進程頁表的各級頁目錄以及頁表中均有對應的頁目錄項和頁表項,但是其對應的物理內存被內核 swap out 到磁盤上了。
- 虛擬內存雖然背後映射着物理內存,但是由於對物理內存的訪問權限不夠而導致的保護類型的缺頁中斷。比如,嘗試去寫一個只讀的物理內存頁。
雖然缺頁中斷產生的原因多種多樣,內核也會根據不同的缺頁原因進行不同的處理,但不管怎麼説,一切的起點都是從 CPU 訪問虛擬內存開始的,既然提到了虛擬內存,我們就不得不回顧一下進程虛擬內存空間的佈局:
在 64 位體系結構下,進程虛擬內存空間總體上分為兩個部分,一部分是 128T 的用户空間,地址範圍為:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF FFFF 。但實際上,Linux 內核是用 TASK_SIZE_MAX 來定義用户空間的末尾的,也就是説 Linux 內核是使用 TASK_SIZE_MAX 來分割用户虛擬地址空間與內核虛擬地址空間的。
#define TASK_SIZE_MAX task_size_max()
#define task_size_max() ((_AC(1,UL) << __VIRTUAL_MASK_SHIFT) - PAGE_SIZE)
#define __VIRTUAL_MASK_SHIFT 47
#define PAGE_SHIFT 12
#define PAGE_SIZE (_AC(1,UL) << PAGE_SHIFT)
TASK_SIZE_MAX 的計算邏輯首先是將 1 左移 47 位得到的地址是 0x0000800000000000,然後減去一個 PAGE_SIZE (4K),就是 0x00007FFFFFFFF000,所以實際上,64 位體系結構的 Linux 內核中,進程用户空間實際可用的虛擬地址範圍是:0x0000 0000 0000 0000 - 0x0000 7FFF FFFF F000。
進程虛擬內存空間的另一部分則是 128T 的內核空間,虛擬地址範圍為:0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF。由於在內核空間的一開始包含了 8T 的地址空洞,所以內核空間實際可用的虛擬地址範圍是:0xFFFF 8800 0000 0000 - 0xFFFF FFFF FFFF FFFF。
既然進程虛擬內存地址範圍有用户空間與內核空間之分,那麼當 CPU 訪問虛擬內存地址時產生的缺頁中斷也要區分下是用户空間產生的缺頁還是內核空間產生的缺頁。
static int fault_in_kernel_space(unsigned long address)
{
/*
* On 64-bit systems, the vsyscall page is at an address above
* TASK_SIZE_MAX, but is not considered part of the kernel
* address space.
*/
if (IS_ENABLED(CONFIG_X86_64) && is_vsyscall_vaddr(address))
return false;
// 在進程虛擬內存空間中,TASK_SIZE_MAX 以上的虛擬地址均屬於內核空間
return address >= TASK_SIZE_MAX;
}
當引起缺頁中斷的虛擬內存地址 address 是在 TASK_SIZE_MAX 之上時,表示該缺頁地址是屬於內核空間的,內核的缺頁處理程序 __do_page_fault 就要進入 do_kern_addr_fault 分支去處理內核空間的缺頁中斷。
當引起缺頁中斷的虛擬內存地址 address 是在 TASK_SIZE_MAX 之下時,表示該缺頁地址是屬於用户空間的,內核則進入 do_user_addr_fault 分支處理用户空間的缺頁中斷。
static noinline void
__do_page_fault(struct pt_regs *regs, unsigned long hw_error_code,
unsigned long address)
{
// mmap_sem 是進程虛擬內存空間 mm_struct 的讀寫鎖
// 內核這裏將 mmap_sem 預取到 cacheline 中,並標記為獨佔狀態( MESI 協議中的 X 狀態)
prefetchw(¤t->mm->mmap_sem);
// 這裏判斷引起缺頁異常的虛擬內存地址 address 是屬於內核空間的還是用户空間的
if (unlikely(fault_in_kernel_space(address)))
// 如果缺頁異常發生在內核空間,則由 vmalloc_fault 進行處理
// 這裏使用 unlikely 的原因是,內核對內存的使用通常是高優先級的而且使用比較頻繁,所以內核空間一般很少發生缺頁異常。
do_kern_addr_fault(regs, hw_error_code, address);
else
// 缺頁異常發生在用户態
do_user_addr_fault(regs, hw_error_code, address);
}
NOKPROBE_SYMBOL(__do_page_fault);
進程工作在內核空間,就相當於你工作在你們公司的核心部門,負責的是公司的核心業務,公司所有的資源都會向核心部門傾斜,可以説是要什麼給什麼。
進程在內核空間工作也是一樣的道理,由於內核負責的是整個系統最為核心的任務,基本上系統中所有的資源都會向內核傾斜,物理內存資源也是一樣。內核對內存的申請優先級是最高的,使用頻率也是最頻繁的。
所以在為內核分配完虛擬內存之後,都會立即分配物理內存,而且是申請多少給多少,最大程度上優先保證內核的工作穩定進行。因此通常在內核中,缺頁中斷一般很少發生,這也是在上面那段內核代碼中,用 unlikely 修飾 fault_in_kernel_space 函數的原因。
而進程工作在用户空間,就相當於你工作在你們公司的非核心部門,負責的是公司的邊緣業務,公司沒有那麼多的資源提供給你,你在工作中需要申請的資源,公司不會馬上提供給你,而是需要延遲到沒有這些資源你的工作就無法進行的時候(你真正必須使用的時候),公司迫不得已才會把資源分配給你。也就是説,你用到什麼的時候才會給你什麼,而不是像你在核心部門那樣,要什麼就給你什麼。
比如,筆者在前面兩篇文章中為大家介紹的 mmap 內存映射,就是工作在進程用户地址空間中的文件映射與匿名映射區,進程在使用 mmap 申請內存的時候,內核僅僅只是為進程在文件映射與匿名映射區分配一段虛擬內存,重要的物理內存資源不會馬上分配,而是延遲到進程真正使用的時候,才會通過缺頁中斷 __do_page_fault 進入到 do_user_addr_fault 分支進行物理內存資源的分配。
內核空間中的缺頁異常主要發生在進程內核虛擬地址空間中 32T 的 vmalloc 映射區,這段區域的虛擬內存地址範圍為:0xFFFF C900 0000 0000 - 0xFFFF E900 0000 0000。內核中的 vmalloc 內存分配接口就工作在這個區域,它用於將那些不連續的物理內存映射到連續的虛擬內存上。
3. 內核態缺頁異常處理 —— do_kern_addr_fault
do_kern_addr_fault 函數的工作主要就是處理內核虛擬內存空間中 vmalloc 映射區裏的缺頁異常,這一部分內容,筆者會在 vmalloc_fault 函數中進行介紹。
static void
do_kern_addr_fault(struct pt_regs *regs, unsigned long hw_error_code,
unsigned long address)
{
// 該缺頁的內核地址 address 在內核頁表中對應的 pte 不能使用保留位(X86_PF_RSVD = 0)
// 不能是用户態的缺頁中斷(X86_PF_USER = 0)
// 且不能是保護類型的缺頁中斷 (X86_PF_PROT = 0)
if (!(hw_error_code & (X86_PF_RSVD | X86_PF_USER | X86_PF_PROT))) {
// 處理 vmalloc 映射區裏的缺頁異常
if (vmalloc_fault(address) >= 0)
return;
}
}
讀到這裏,大家可能會有一個疑惑,作者你剛剛不是才説了嗎,工作在內核就相當於工作在公司的核心部門,要什麼資源公司就會給什麼資源,在內核空間申請虛擬內存的時候,都會馬上分配物理內存資源,而且申請多少給多少。
既然物理內存會馬上被分配,那為什麼內核空間中的 vmalloc 映射區還會發生缺頁中斷呢 ?
事實上,內核空間裏 vmalloc 映射區中發生的缺頁中斷與用户空間裏文件映射與匿名映射區以及堆中發生的缺頁中斷是不一樣的。
進程在用户空間中無論是通過 brk 系統調用在堆中申請內存還是通過 mmap 系統調用在文件與匿名映射區中申請內存,內核都只是在相應的虛擬內存空間中劃分出一段虛擬內存來給進程使用。
當進程真正訪問到這段虛擬內存地址的時候,才會產生缺頁中斷,近而才會分配物理內存,最後將引起本次缺頁的虛擬地址在進程頁表中對應的全局頁目錄項 pgd,上層頁目錄項 pud,中間頁目錄 pmd,頁表項 pte 都創建好,然後在 pte 中將虛擬內存地址與物理內存地址映射起來。
而內核通過 vmalloc 內存分配接口在 vmalloc 映射區申請內存的時候,首先也會在 32T 大小的 vmalloc 映射區中劃分出一段未被使用的虛擬內存區域出來,我們暫且叫這段虛擬內存區域為 vmalloc 區,這一點和前面文章介紹的 mmap 非常相似,只不過 mmap 工作在用户空間的文件與匿名映射區,vmalloc 工作在內核空間的 vmalloc 映射區。
內核空間中的 vmalloc 映射區就是由這樣一段一段的 vmalloc 區組成的,每調用一次 vmalloc 內存分配接口,就會在 vmalloc 映射區中映射出一段 vmalloc 虛擬內存區域,而且每個 vmalloc 區之間隔着一個 4K 大小的 guard page(虛擬內存),用於防止內存越界,將這些非連續的物理內存區域隔離起來。
和 mmap 不同的是,vmalloc 在分配完虛擬內存之後,會馬上為這段虛擬內存分配物理內存,內核會首先計算出由 vmalloc 內存分配接口映射出的這一段虛擬內存區域 vmalloc 區中包含的虛擬內存頁數,然後調用夥伴系統依次為這些虛擬內存頁分配物理內存頁。
3.1 vmalloc
下面是 vmalloc 內存分配的核心邏輯,封裝在 __vmalloc_node_range 函數中:
/**
* __vmalloc_node_range - allocate virtually contiguous memory
* Allocate enough pages to cover @size from the page level
* allocator with @gfp_mask flags. Map them into contiguous
* kernel virtual space, using a pagetable protection of @prot.
*
* Return: the address of the area or %NULL on failure
*/
void *__vmalloc_node_range(unsigned long size, unsigned long align,
unsigned long start, unsigned long end, gfp_t gfp_mask,
pgprot_t prot, unsigned long vm_flags, int node,
const void *caller)
{
// 用於描述 vmalloc 虛擬內存區域的數據結構,同 mmap 中的 vma 結構很相似
struct vm_struct *area;
// vmalloc 虛擬內存區域的起始地址
void *addr;
unsigned long real_size = size;
// size 為要申請的 vmalloc 虛擬內存區域大小,這裏需要按頁對齊
size = PAGE_ALIGN(size);
// 因為在分配完 vmalloc 區之後,馬上就會為其分配物理內存
// 所以這裏需要檢查 size 大小不能超過當前系統中的空閒物理內存
if (!size || (size >> PAGE_SHIFT) > totalram_pages())
goto fail;
// 在內核空間的 vmalloc 動態映射區中,劃分出一段空閒的虛擬內存區域 vmalloc 區出來
// 這裏虛擬內存的分配過程和 mmap 在用户態文件與匿名映射區分配虛擬內存的過程非常相似,這裏就不做過多的介紹了。
area = __get_vm_area_node(size, align, VM_ALLOC | VM_UNINITIALIZED |
vm_flags, start, end, node, gfp_mask, caller);
if (!area)
goto fail;
// 為 vmalloc 虛擬內存區域中的每一個虛擬內存頁分配物理內存頁
// 並在內核頁表中將 vmalloc 區與物理內存映射起來
addr = __vmalloc_area_node(area, gfp_mask, prot, node);
if (!addr)
return NULL;
return addr;
}
同 mmap 用 vm_area_struct 結構來描述其在用户空間的文件與匿名映射區分配出來的虛擬內存區域一樣,內核空間的 vmalloc 動態映射區也有一種數據結構來專門描述該區域中的虛擬內存區,這個結構就是下面的 vm_struct。
// 用來描述 vmalloc 區
struct vm_struct {
// vmalloc 動態映射區中的所有虛擬內存區域也都是被一個單向鏈表所串聯
struct vm_struct *next;
// vmalloc 區的起始內存地址
void *addr;
// vmalloc 區的大小
unsigned long size;
// vmalloc 區的相關標記
// VM_ALLOC 表示該區域是由 vmalloc 函數映射出來的
// VM_MAP 表示該區域是由 vmap 函數映射出來的
// VM_IOREMAP 表示該區域是由 ioremap 函數將硬件設備的內存映射過來的
unsigned long flags;
// struct page 結構的數組指針,數組中的每一項指向該虛擬內存區域背後映射的物理內存頁。
struct page **pages;
// 該虛擬內存區域包含的物理內存頁個數
unsigned int nr_pages;
// ioremap 映射硬件設備物理內存的時候填充
phys_addr_t phys_addr;
// 調用者的返回地址(這裏可忽略)
const void *caller;
};
由於內核在分配完 vmalloc 虛擬內存區之後,會馬上為其分配物理內存,所以在 vm_struct 結構中有一個 struct page 結構的數組指針 pages,用於指向該虛擬內存區域背後映射的物理內存頁。nr_pages 則是數組的大小,也表示該虛擬內存區域包含的物理內存頁個數。
在內核中所有的這些 vm_struct 均是被一個單鏈表串聯組織的,在早期的內核版本中就是通過遍歷這個單向鏈表來在 vmalloc 動態映射區中尋找空閒的虛擬內存區域的,後來為了提高查找效率引入了紅黑樹以及雙向鏈表來重新組織這些 vmalloc 區域,於是專門引入了一個 vmap_area 結構來描述 vmalloc 區域的組織形式。
struct vmap_area {
// vmalloc 區的起始內存地址
unsigned long va_start;
// vmalloc 區的結束內存地址
unsigned long va_end;
// vmalloc 區所在紅黑樹中的節點
struct rb_node rb_node; /* address sorted rbtree */
// vmalloc 區所在雙向鏈表中的節點
struct list_head list; /* address sorted list */
// 用於關聯 vm_struct 結構
struct vm_struct *vm;
};
看起來和用户空間中虛擬內存區域的組織形式越來越像了,不同的是由於用户空間是進程間隔離的,所以組織用户空間虛擬內存區域的紅黑樹以及雙向鏈表是進程獨佔的。
struct mm_struct {
struct vm_area_struct *mmap; /* list of VMAs */
struct rb_root mm_rb;
}
而內核空間是所有進程共享的,所以組織內核空間虛擬內存區域的紅黑樹以及雙向鏈表是全局的。
static struct rb_root vmap_area_root = RB_ROOT;
extern struct list_head vmap_area_list;
在我們瞭解了 vmalloc 動態映射區中的相關數據結構與組織形式之後,接下來我們看一看為 vmalloc 區分配物理內存的過程:
static void *__vmalloc_area_node(struct vm_struct *area, gfp_t gfp_mask,
pgprot_t prot, int node)
{
// 指向即將為 vmalloc 區分配的物理內存頁
struct page **pages;
unsigned int nr_pages, array_size, i;
// 計算 vmalloc 區所需要的虛擬內存頁個數
nr_pages = get_vm_area_size(area) >> PAGE_SHIFT;
// vm_struct 結構中的 pages 數組大小,用於存放指向每個物理內存頁的指針
array_size = (nr_pages * sizeof(struct page *));
// 首先要為 pages 數組分配內存
if (array_size > PAGE_SIZE) {
// array_size 超過 PAGE_SIZE 大小則遞歸調用 vmalloc 分配數組所需內存
pages = __vmalloc_node(array_size, 1, nested_gfp|highmem_mask,
PAGE_KERNEL, node, area->caller);
} else {
// 直接調用 kmalloc 分配數組所需內存
pages = kmalloc_node(array_size, nested_gfp, node);
}
// 初始化 vm_struct
area->pages = pages;
area->nr_pages = nr_pages;
// 依次為 vmalloc 區中包含的所有虛擬內存頁分配物理內存
for (i = 0; i < area->nr_pages; i++) {
struct page *page;
if (node == NUMA_NO_NODE)
// 如果沒有特殊指定 numa node,則從當前 numa node 中分配物理內存頁
page = alloc_page(alloc_mask|highmem_mask);
else
// 否則就從指定的 numa node 中分配物理內存頁
page = alloc_pages_node(node, alloc_mask|highmem_mask, 0);
// 將分配的物理內存頁依次存放到 vm_struct 結構中的 pages 數組中
area->pages[i] = page;
}
atomic_long_add(area->nr_pages, &nr_vmalloc_pages);
// 修改內核主頁表,將剛剛分配出來的所有物理內存頁與 vmalloc 虛擬內存區域進行映射
if (map_vm_area(area, prot, pages))
goto fail;
// 返回 vmalloc 虛擬內存區域起始地址
return area->addr;
}
在內核中,凡是有物理內存出現的地方,就一定伴隨着頁表的映射,vmalloc 也不例外,當分配完物理內存之後,就需要修改內核頁表,然後將物理內存映射到 vmalloc 虛擬內存區域中,當然了,這個過程也伴隨着 vmalloc 區域中的這些虛擬內存地址在內核頁表中對應的 pgd,pud,pmd,pte 相關頁目錄項以及頁表項的創建。
大家需要注意的是,這裏的內核頁表指的是內核主頁表,內核主頁表的頂級頁目錄起始地址存放在 init_mm 結構中的 pgd 屬性中,其值為 swapper_pg_dir。
struct mm_struct init_mm = {
// 內核主頁表
.pgd = swapper_pg_dir,
}
#define swapper_pg_dir init_top_pgt
內核主頁表在系統初始化的時候被一段彙編代碼 arch\x86\kernel\head_64.S 所創建。後續在系統啓動函數 start_kernel 中調用 setup_arch 進行初始化。
正如之前文章《一步一圖帶你構建 Linux 頁表體系》 中介紹的那樣,普通進程在內核態亦或是內核線程都是無法直接訪問內核主頁表的,它們只能訪問內核主頁表的 copy 副本,於是進程頁表體系就分為了兩個部分,一個是進程用户態頁表(用户態缺頁處理的就是這部分),另一個就是內核頁表的 copy 部分(內核態缺頁處理的是這部分)。
在 fork 系統調用創建進程的時候,進程的用户態頁表拷貝自他的父進程,而進程的內核態頁表則從內核主頁表中拷貝,後續進程陷入內核態之後,訪問的就是內核主頁表中拷貝的這部分。
這也引出了一個新的問題,就是內核主頁表與其在進程中的拷貝副本如何同步呢 ? 這就是本小節,筆者想要和大家交代的主題 —— 內核態缺頁異常的處理。
3.2 vmalloc_fault
當內核通過 vmalloc 內存分配接口修改完內核主頁表之後,主頁表中的相關頁目錄項以及頁表項的內容就發生了改變,而這背後的一切,進程現在還被矇在鼓裏,一無所知,此時,進程頁表中的內核部分相關的頁目錄項以及頁表項還都是空的。
當進程陷入內核態訪問這部分頁表的的時候,會發現相關頁目錄或者頁表項是空的,就會進入缺頁中斷的內核處理部分,也就是前面提到的 vmalloc_fault 函數中,如果發現缺頁的虛擬地址在內核主頁表頂級全局頁目錄表中對應的頁目錄項 pgd 存在,而缺頁地址在進程頁表內核部分對應的 pgd 不存在,那麼內核就會把內核主頁表中 pgd 頁目錄項裏的內容複製給進程頁表內核部分中對應的 pgd。
事實上,同步內核主頁表的工作只需要將缺頁地址對應在內核主頁表中的頂級全局頁目錄項 pgd 同步到進程頁表內核部分對應的 pgd 地址處就可以了,正如上圖中所示,每一級的頁目錄項中存放的均是其下一級頁目錄表的物理內存地址。
例如內核主頁表這裏的 pgd 存放的是其下一級 —— 上層頁目錄 PUD 的起始物理內存地址 ,PUD 中的頁目錄項 pud 又存放的是其下一級 —— 中間頁目錄 PMD 的起始物理內存地址,依次類推,中間頁目錄項 pmd 存放的又是頁表的起始物理內存地址。
既然每一級頁目錄表中的頁目錄項存放的都是其下一級頁目錄表的起始物理內存地址,那麼頁目錄項中存放的就相當於是下一級頁目錄表的引用,這樣一來我們就只需要同步最頂級的頁目錄項 pgd 就可以了,後面只要與該 pgd 相關的頁目錄表以及頁表發生任何變化,由於是引用的關係,這些改變都會立刻自動反應到進程頁表的內核部分中,後面就不需要同步了。
/*
* 64-bit:
*
* Handle a fault on the vmalloc area
*/
static noinline int vmalloc_fault(unsigned long address)
{
// 分別是缺頁虛擬地址 address 對應在內核主頁表的全局頁目錄項 pgd_k ,以及進程頁表中對應的全局頁目錄項 pgd
pgd_t *pgd, *pgd_k;
// p4d_t 用於五級頁表體系,當前 cpu 架構體系下一般採用的是四級頁表
// 在四級頁表下 p4d 是空的,pgd 的值會賦值給 p4d
p4d_t *p4d, *p4d_k;
// 缺頁虛擬地址 address 對應在進程頁表中的上層目錄項 pud
pud_t *pud;
// 缺頁虛擬地址 address 對應在進程頁表中的中間目錄項 pmd
pmd_t *pmd;
// 缺頁虛擬地址 address 對應在進程頁表中的頁表項 pte
pte_t *pte;
// 確保缺頁發生在內核 vmalloc 動態映射區
if (!(address >= VMALLOC_START && address < VMALLOC_END))
return -1;
// 獲取缺頁虛擬地址 address 對應在進程頁表的全局頁目錄項 pgd
pgd = (pgd_t *)__va(read_cr3_pa()) + pgd_index(address);
// 獲取缺頁虛擬地址 address 對應在內核主頁表的全局頁目錄項 pgd_k
pgd_k = pgd_offset_k(address);
// 如果內核主頁表中的 pgd_k 本來就是空的,説明 address 是一個非法訪問的地址,返回 -1
if (pgd_none(*pgd_k))
return -1;
// 如果開啓了五級頁表,那麼頂級頁表就是 pgd,這裏只需要同步頂級頁表項就可以了
if (pgtable_l5_enabled()) {
// 內核主頁表中的 pgd_k 不為空,進程頁表中的 pgd 為空,那麼就同步頁表
if (pgd_none(* )) {
// 將主內核頁表中的 pgd_k 內容複製給進程頁表對應的 pgd
set_pgd(pgd, *pgd_k);
// 刷新 mmu
arch_flush_lazy_mmu_mode();
} else {
BUG_ON(pgd_page_vaddr(*pgd) != pgd_page_vaddr(*pgd_k));
}
}
// 四級頁表體系下,p4d 是頂級頁表項,同樣也是隻需要同步頂級頁表項即可,同步邏輯和五級頁表一模一樣
// 因為是四級頁表,所以這裏會將 pgd 賦值給 p4d,p4d_k ,後面就直接把 p4d 看做是頂級頁表了。
p4d = p4d_offset(pgd, address);
p4d_k = p4d_offset(pgd_k, address);
// 內核主頁表為空,則停止同步,返回 -1 ,表示正在訪問一個非法地址
if (p4d_none(*p4d_k))
return -1;
// 內核主頁表不為空,進程頁表為空,則同步內核頂級頁表項 p4d_k 到進程頁表對應的 p4d 中,然後刷新 mmu
if (p4d_none(*p4d) && !pgtable_l5_enabled()) {
set_p4d(p4d, *p4d_k);
arch_flush_lazy_mmu_mode();
} else {
BUG_ON(p4d_pfn(*p4d) != p4d_pfn(*p4d_k));
}
// 到這裏,頁表的同步工作就完成了,下面代碼用於檢查內核地址 address 在進程頁表內核部分中是否有物理內存進行映射
// 如果沒有,則返回 -1 ,説明進程在訪問一個非法的內核地址,進程隨後會被 kill 掉
// 返回 0 表示表示地址 address 背後是有物理內存映射的, vmalloc 動態映射區的缺頁處理到此結束。
// 根據頂級頁目錄項 p4d 獲取 address 在進程頁表中對應的上層頁目錄項 pud
pud = pud_offset(p4d, address);
if (pud_none(*pud))
return -1;
// 該 pud 指向的是 1G 大頁內存
if (pud_large(*pud))
return 0;
// 根據 pud 獲取 address 在進程頁表中對應的中間頁目錄項 pmd
pmd = pmd_offset(pud, address);
if (pmd_none(*pmd))
return -1;
// 該 pmd 指向的是 2M 大頁內存
if (pmd_large(*pmd))
return 0;
// 根據 pmd 獲取 address 對應的頁表項 pte
pte = pte_offset_kernel(pmd, address);
// 頁表項 pte 並沒有映射物理內存
if (!pte_present(*pte))
return -1;
return 0;
}
NOKPROBE_SYMBOL(vmalloc_fault);
在我們聊完內核主頁表的同步過程之後,可能很多讀者朋友不禁要問,既然已經有了內核主頁表,而且內核地址空間包括內核頁表又是所有進程共享的,那進程為什麼不能直接訪問內核主頁表而是要訪問主頁表的拷貝部分呢 ? 這樣還能省去拷貝內核主頁表(fork 時候)以及同步內核主頁表(缺頁時候)這些個開銷。
之所以這樣設計一方面有硬件限制的原因,畢竟每個 CPU 核心只會有一個 CR3 寄存器來存放進程頁表的頂級頁目錄起始物理內存地址,沒辦法同時存放進程頁表和內核主頁表。
另一方面的原因則是操作頁表都是需要對其進行加鎖的,無論是操作進程頁表還是內核主頁表。而且在操作頁表的過程中可能會涉及到物理內存的分配,這也會引起進程的阻塞。
而進程本身可能處於中斷上下文以及競態區中,不能加鎖,也不能被阻塞,如果直接對內核主頁表加鎖的話,那麼系統中的其他進程就只能阻塞等待了。所以只能而且必須是操作主內核頁表的拷貝,不能直接操作內核主頁表。
好了,該向大家交代的現在都已經交代完了,我們閒話不多説,繼續本文的主題內容~~~
4. 用户態缺頁異常處理 —— do_user_addr_fault
進程用户態虛擬地址空間的佈局我們現在已經非常熟悉了,在處理用户態缺頁異常之前,內核需要在進程用户空間眾多的虛擬內存區域 vma 之中找到引起缺頁的內存地址 address 究竟是屬於哪一個 vma 。如果沒有一個 vma 能夠包含 address , 那麼就説明該 address 是一個還未被分配的虛擬內存地址,進程對該地址的訪問是非法的,自然也就不用處理缺頁了。
所以內核就需要根據缺頁地址 address 通過 find_vma 函數在進程地址空間中找出符合 address < vma->vm_end 條件的第一個 vma 出來,也就是挨着 address 最近的一個 vma。
而缺頁地址 address 可以出現在進程地址空間中的任意位置,根據 address 的分佈會有下面三種情況:
第一種情況就是 address 的後面沒有一個 vma 出現,也就是説進程地址空間中沒有一個 vma 符合條件:address < vma->vm_end。進程訪問的是一個還未分配的虛擬內存地址,屬於非法地址訪問,不需要處理缺頁。
第二種情況就是 address 恰巧包含在一個 vma 中,這個自然是正常情況,內核開始處理該 vma 區域的缺頁異常。
第三種情況是 address 不巧落在了 find_vma 的前面,也就是 address < find_vma->vm_start。這種情況自然也是非法地址訪問,不需要處理缺頁。
但是這裏有一種特殊情況就是萬一這個 find_vma 是棧區怎麼辦呢 ? 棧是允許擴展的但不允許收縮,如果壓棧指令 push 引用了一個棧區之外的地址 address,這種異常不是由程序錯誤所引起的,因此缺頁處理程序需要單獨處理棧區的擴展。
如果 find_vma 中的 vm_flags 標記了 VM_GROWSDOWN,表示該 vma 中的地址增長方向是由高到底了,説明這個 vma 可能是棧區域,近而需要到 expand_stack 函數中判斷是否允許擴展棧,如果允許的話,就將棧所屬的 vma 起始地址 vm_start 擴展至 address 處。
現在我們已經校驗完了 vma,並確定了缺頁地址 address 是一個合法的地址,下面就可以放心地調用 handle_mm_fault 函數對這塊 vma 進行缺頁處理了。
/* Handle faults in the user portion of the address space */
static inline
void do_user_addr_fault(struct pt_regs *regs,
unsigned long hw_error_code,
unsigned long address)
{
struct vm_area_struct *vma;
struct task_struct *tsk;
struct mm_struct *mm;
tsk = current;
mm = tsk->mm;
.............. 省略 ..............
// 在進程虛擬地址空間查找第一個符合條件:address < vma->vm_end 的虛擬內存區域 vma
vma = find_vma(mm, address);
// 如果該缺頁地址 address 後面沒有 vma 跳轉到 bad_area 處理異常
if (unlikely(!vma)) {
bad_area(regs, hw_error_code, address);
return;
}
// 缺頁地址 address 恰好落在一個 vma 中,跳轉到 good_area 處理 vma 中的缺頁
if (likely(vma->vm_start <= address))
goto good_area;
// 上面第三種情況,vma 不是棧區,跳轉到 bad_area
if (unlikely(!(vma->vm_flags & VM_GROWSDOWN))) {
bad_area(regs, hw_error_code, address);
return;
}
// vma 是棧區,嘗試擴展棧區到 address 地址處
if (unlikely(expand_stack(vma, address))) {
bad_area(regs, hw_error_code, address);
return;
}
/*
* Ok, we have a good vm_area for this memory access, so
* we can handle it..
*/
good_area:
// 處理 vma 區域的缺頁異常,返回值 fault 是一個位圖,用於描述缺頁處理過程中發生的狀況信息。
fault = handle_mm_fault(vma, address, flags);
// 本次缺頁是否屬於 VM_FAULT_MAJOR,缺頁處理過程中是否發生了物理內存的分配以及磁盤 IO
// 與其對應的是 VM_FAULT_MINOR 表示缺頁處理過程中所需內存頁已經存在於內存中了,只是修改頁表即可。
major |= fault & VM_FAULT_MAJOR;
/*
* Major/minor page fault accounting. If any of the events
* returned VM_FAULT_MAJOR, we account it as a major fault.
*/
if (major) {
// 統計進程總共發生的 VM_FAULT_MAJOR 次數
tsk->maj_flt++;
perf_sw_event(PERF_COUNT_SW_PAGE_FAULTS_MAJ, 1, regs, address);
} else {
// 統計進程總共發生的 VM_FAULT_MINOR 次數
tsk->min_flt++;
perf_sw_event(PERF_COUNT_SW_PAGE_FAULTS_MIN, 1, regs, address);
}
}
NOKPROBE_SYMBOL(do_user_addr_fault);
handle_mm_fault 函數會返回一個 unsigned int 類型的位圖 vm_fault_t,通過這個位圖可以簡要描述一下在整個缺頁異常處理的過程中究竟發生了哪些狀況,方便內核對各種狀況進行針對性處理。
/**
* Page fault handlers return a bitmask of %VM_FAULT values.
*/
typedef __bitwise unsigned int vm_fault_t;
比如,位圖 vm_fault_t 的第三個比特位置為 1 表示 VM_FAULT_MAJOR,置為 0 表示 VM_FAULT_MINOR。
enum vm_fault_reason {
VM_FAULT_MAJOR = (__force vm_fault_t)0x000004,
};
VM_FAULT_MAJOR 的意思是本次缺頁所需要的物理內存頁還不在內存中,需要重新分配以及需要啓動磁盤 IO,從磁盤中 swap in 進來。
VM_FAULT_MINOR 的意思是本次缺頁所需要的物理內存頁已經加載進內存中了,缺頁處理只需要修改頁表重新映射一下就可以了。
我們來看一個具體的例子,筆者在之前的文章 《從內核世界透視 mmap 內存映射的本質(原理篇)》中為大家介紹多個進程調用 mmap 對磁盤上的同一個文件進行共享文件映射的時候,此時在各個進程的地址空間中都只是各自分配了一段虛擬內存用於共享文件映射而已,還沒有分配物理內存頁。
當第一個進程開始訪問這段虛擬內存映射區時,由於沒有物理內存頁,頁表還是空的,於是產生缺頁中斷,內核則會在夥伴系統中分配一個物理內存頁,然後將新分配的內存頁加入到 page cache 中。
然後調用 readpage 激活塊設備驅動從磁盤中讀取映射的文件內容,用讀取到的內容填充新分配的內存頁,最後在進程 1 頁表中建立共享映射的這段虛擬內存與 page cache 中緩存的文件頁之間的關聯。
由於進程 1 的缺頁處理髮生了物理內存的分配以及磁盤 IO ,所以本次缺頁處理屬於 VM_FAULT_MAJOR。
當進程 2 訪問其地址空間中映射的這段虛擬內存時,由於頁表是空的,也會發生缺頁,但是當進程 2 進入內核中發現所映射的文件頁已經被進程 1 加載進 page cache 中了,進程 2 的缺頁處理只需要將這個文件頁映射進自己的頁表就可以了,不需要重新分配內存以及發生磁盤 IO 。這種情況就屬於 VM_FAULT_MINOR。
最後需要將進程總共發生的 VM_FAULT_MAJOR 次數以及 VM_FAULT_MINOR 次數統計到進程 task_struct 結構中的相應字段中:
struct task_struct {
// 進程總共發生的 VM_FAULT_MINOR 次數
unsigned long min_flt;
// 進程總共發生的 VM_FAULT_MAJOR 次數
unsigned long maj_flt;
}
我們可以在 ps 命令上增加 -o 選項,添加 maj_flt ,min_flt 數據列來查看各個進程的 VM_FAULT_MAJOR 次數和 VM_FAULT_MINOR 次數。
5. handle_mm_fault 完善進程頁表體系
饒了一大圈,現在我們終於來到了缺頁處理的核心邏輯,之前筆者提到,引起缺頁中斷的原因大概有三種:
- 第一種是 CPU 訪問的虛擬內存地址 address 之前完全沒有被映射過,其在頁表中對應的各級頁目錄項以及頁表項都還是空的。
- 第二種是 address 之前被映射過,但是映射的這塊物理內存被內核 swap out 到磁盤上了。
- 第三種是 address 背後映射的物理內存還在,只是由於訪問權限不夠引起的缺頁中斷,比如,後面要為大家介紹的寫時複製(COW)機制就屬於這一種。
下面筆者一種接一種的帶大家一起梳理,我們先來看第一種情況:
由於現在正在被訪問的虛擬內存地址 address 之前從來沒有被映射過,所以該虛擬內存地址在進程頁表中的各級頁目錄表中的目錄項以及頁表中的頁表項都是空的。內核的首要任務就是先要將這些缺失的頁目錄項和頁表項一一補齊。
筆者在之前的文章《一步一圖帶你構建 Linux 頁表體系》 中曾為大家介紹過,在當前 64 位體系架構下,其實只使用了 48 位來描述進程的虛擬內存空間,其中用户態地址空間 128T,內核態地址空間 128T,所以我們只需要使用 48 位的虛擬內存地址就可以表示進程虛擬內存空間中的任意地址了。
而這 48 位的虛擬內存地址內又分為五個部分,它們分別是虛擬內存地址在全局頁目錄表 PGD 中對應的頁目錄項 pgd_t 的偏移,在上層頁目錄表 PUD 中對應的頁目錄項 pud_t 的偏移,在中間頁目錄表 PMD 中對應的頁目錄項 pmd_t 的偏移,在頁表中對應的頁表項 pte_t 的偏移,以及在其背後映射的物理內存頁中的偏移。
內核中使用 unsigned long 類型來表示各級頁目錄中的目錄項以及頁表中的頁表項,在 64 位系統中它們都是佔用 8 字節。
// 定義在內核文件:/arch/x86/include/asm/pgtable_64_types.h
typedef unsigned long pteval_t;
typedef unsigned long pmdval_t;
typedef unsigned long pudval_t;
typedef unsigned long pgdval_t;
typedef struct { pteval_t pte; } pte_t;
// 定義在內核文件:/arch/x86/include/asm/pgtable_types.h
typedef struct { pmdval_t pmd; } pmd_t;
typedef struct { pudval_t pud; } pud_t;
typedef struct { pgdval_t pgd; } pgd_t;
而各級頁目錄表以及頁表在內核中其實本質上都是一個 4K 物理內存頁,只不過這些物理內存頁存放的內容比較特殊,它們存放的是頁目錄項和頁表項。一張頁目錄表可以存放 512 個頁目錄項,一張頁表可以存放 512 個頁表項
// 全局頁目錄表 PGD 可以容納的頁目錄項 pgd_t 的個數
#define PTRS_PER_PGD 512
// 上層頁目錄表 PUD 可以容納的頁目錄項 pud_t 的個數
#define PTRS_PER_PUD 512
// 中間頁目錄表 PMD 可以容納的頁目錄項 pmd_t 的個數
#define PTRS_PER_PMD 512
// 頁表可以容納的頁表項 pte_t 的個數
#define PTRS_PER_PTE 512
因此我們可以把全局頁目錄表 PGD 看做是一個能夠存放 512 個 pgd_t 的數組 —— pgd_t[PTRS_PER_PGD],虛擬內存地址對應在 pgd_t[PTRS_PER_PGD] 數組中的索引使用 9 個比特位就可以表示了。
在內核中使用 pgd_offset 函數來定位虛擬內存地址在全局頁目錄表 PGD 中對應的頁目錄項 pgd_t,這個過程和訪問數組一模一樣,事實上整個 PGD 就是一個 pgd_t[PTRS_PER_PGD] 數組。
首先我們通過 mm_struct-> pgd 獲取 pgd_t[PTRS_PER_PGD] 數組的首地址(全局頁目錄表 PGD 的起始內存地址),然後將虛擬內存地址右移 PGDIR_SHIFT(39)位再用掩碼 PTRS_PER_PGD - 1 將高位全部掩去,只保留低 9 位得到虛擬內存地址在 pgd_t[PTRS_PER_PGD] 數組中的索引偏移 pgd_index。
然後將 mm_struct-> pgd 與 pgd_index 相加就可以定位到虛擬內存地址在全局頁目錄表 PGD 中的頁目錄項 pgd_t 了。
/*
* a shortcut to get a pgd_t in a given mm
*/
#define pgd_offset(mm, address) pgd_offset_pgd((mm)->pgd, (address))
#define pgd_offset_pgd(pgd, address) (pgd + pgd_index((address)))
#define pgd_index(address) (((address) >> PGDIR_SHIFT) & (PTRS_PER_PGD - 1))
#define PGDIR_SHIFT 39
#define PTRS_PER_PGD 512
在後續即將要介紹的源碼實現中,大家還會看到一個 p4d 的頁目錄,該頁目錄用於在五級頁表體系下表示四級頁目錄。
typedef unsigned long p4dval_t;
typedef struct { p4dval_t p4d; } p4d_t;
而在四級頁表體系下,這個 p4d 就不起作用了,但為了代碼上的統一處理,在四級頁表下,前面定位到的頂級頁目錄項 pgd_t 會賦值給四級頁目錄項 p4d_t,後續處理都會將 p4d_t 看做是頂級頁目錄項,這一點需要和大家在這裏先提前交代清楚。
static inline p4d_t *p4d_offset(pgd_t *pgd, unsigned long address)
{
if (!pgtable_l5_enabled())
// 四級頁表體系下,p4d_t 其實就是頂級頁目錄項
return (p4d_t *)pgd;
return (p4d_t *)pgd_page_vaddr(*pgd) + p4d_index(address);
}
現在我們已經通過 pgd_offset 定位到虛擬內存地址 address 對應在全局頁目錄 PGD 的頁目錄項 pgd_t(p4d_t)了。
接下來的任務就是根據這個 p4d_t 定位虛擬內存對應在上層頁目錄 PUD 中的頁目錄項 pud_t。但在定位之前,我們需要首先判斷這個 p4d_t 是否是空的,如果是空的,説明在目前的進程頁表中還不存在對應的 PUD,需要馬上創建一個新的出來。
而 PUD 的相關信息全部都保存在 p4d_t 裏,我們可以通過 native_p4d_val 函數將頂級頁目錄項 p4d_t 中的值獲取出來。
static inline p4dval_t native_p4d_val(p4d_t p4d)
{
return p4d.p4d;
}
在 64 位系統中,各級頁目錄項都是用 unsigned long 類型來表示的,共 8 個字節,64 個 bit,還記得我們之前在《一步一圖帶你構建 Linux 頁表體系》 一文中介紹的頁目錄項比特位佈局嗎 ?
在頁目錄項剛剛被創建出來的時候,內核會將他們全部初始化為 0 值,如果一個頁目錄項中除了第 5 , 6 比特位之外剩下的比特位全都為 0 的話,則表示這個頁目錄項是空的。
static inline int p4d_none(p4d_t p4d)
{
// p4d_t 中除了第 5,6 比特位之外,剩餘比特位如果全是 0 則表示 p4d_t 是空的
return (native_p4d_val(p4d) & ~(_PAGE_KNL_ERRATUM_MASK)) == 0;
}
// 頁目錄項中第 5, 6 比特位置為 1
#define _PAGE_KNL_ERRATUM_MASK (_PAGE_DIRTY | _PAGE_ACCESSED)
如果我們通過 p4d_none 函數判斷出頂級頁目錄項 p4d 是空的,那麼就需要調用 __pud_alloc 函數分配一個新的上層頁目錄表 PUD 出來,然後用 PUD 的起始物理內存地址以及頁目錄項的初始權限位 _PAGE_TABLE 填充 p4d。
/*
* Allocate page upper directory.
* We've already handled the fast-path in-line.
*/
int __pud_alloc(struct mm_struct *mm, p4d_t *p4d, unsigned long address)
{
// 調用 get_zeroed_page 申請一個 4k 物理內存頁並初始化為 0 值作為新的 PUD
// new 指向新分配的 PUD 起始內存地址
pud_t *new = pud_alloc_one(mm, address);
if (!new)
return -ENOMEM;
// 操作進程頁表需要加鎖
spin_lock(&mm->page_table_lock);
// 如果頂級頁目錄項 p4d 中的 P 比特位置為 0 表示 p4d 目前還沒有指向其下一級頁目錄 PUD
// 下面需要填充 p4d
if (!p4d_present(*p4d)) {
// 更新 mm->pgtables_bytes 計數,該字段用於統計進程頁表所佔用的字節數
// 由於這裏新增了一張 PUD 目錄表,所以計數需要增加 PTRS_PER_PUD * sizeof(pud_t)
mm_inc_nr_puds(mm);
// 將 new 指向的新分配出來的 PUD 物理內存地址以及相關屬性填充到頂級頁目錄項 p4d 中
p4d_populate(mm, p4d, new);
} else /* Another has populated it */
// 釋放新創建的 PMD
pud_free(mm, new);
// 釋放頁表鎖
spin_unlock(&mm->page_table_lock);
return 0;
}
下面我們來看一下填充頂級頁目錄項 p4d 的一些細節,填充的邏輯封裝在下面的 p4d_populate 函數中。
static inline void p4d_populate(struct mm_struct *mm, p4d_t *p4d, pud_t *pud)
{
set_p4d(p4d, __p4d(_PAGE_TABLE | __pa(pud)));
}
#define _KERNPG_TABLE (_PAGE_PRESENT | _PAGE_RW | _PAGE_ACCESSED | \
_PAGE_DIRTY | _PAGE_ENC)
#define _PAGE_TABLE (_KERNPG_TABLE | _PAGE_USER)
各級頁目錄項以及頁表項,它們的本質其實就是一塊 8 字節大小,64 bits 的小內存塊,內核中使用 unsigned long 類型來修飾,各級頁目錄項以及頁表項在初始的時候,它們的這 64 個比特位全部為 0 值,所謂填充頁目錄項就是按照下圖所示的頁目錄項比特位佈局,根據每個比特位的具體含義進行相應的填充。
由於頁目錄項所承擔的一項最重要的工作就是定位其下一級頁目錄表的起始物理內存地址,這裏的下一級頁目錄表就是剛剛我們新創建出來的 PUD。所以第一件重要的事情就是通過 __pa(pud) 來獲取 PUD 的起始物理內存地址,然後將 PUD 的物理內存地址填充到頂級頁目錄項 p4d 中的對應比特位上。
由於物理內存地址在內核中都是按照 4K 對齊的,所以 PUD 物理內存地址的低 12 位全部都是 0 ,我們可以利用這 12 個比特位存放一些權限標記位,頁目錄項在初始化時需要置為 1 的權限標記位定義在 _PAGE_TABLE 中。也就是説 _PAGE_TABLE 定義了頁目錄項初始權限標記位集合。
#define _PAGE_BIT_PRESENT 0 /* is present */
#define _PAGE_BIT_RW 1 /* writeable */
#define _PAGE_BIT_USER 2 /* userspace addressable */
#define _PAGE_BIT_ACCESSED 5 /* was accessed (raised by CPU) */
#define _PAGE_BIT_DIRTY 6 /* was written to (raised by CPU) */
#define _PAGE_PRESENT (_AT(pteval_t, 1) << _PAGE_BIT_PRESENT)
#define _PAGE_RW (_AT(pteval_t, 1) << _PAGE_BIT_RW)
#define _PAGE_USER (_AT(pteval_t, 1) << _PAGE_BIT_USER)
#define _PAGE_ACCESSED (_AT(pteval_t, 1) << _PAGE_BIT_ACCESSED)
#define _PAGE_DIRTY (_AT(pteval_t, 1) << _PAGE_BIT_DIRTY)
我們通過 _PAGE_TABLE 和 __pa(pud) 進行或運算 —— _PAGE_TABLE | __pa(pud),這樣就可以按照上圖中的比特位佈局構造出一個 8 字節的 unsigned long 類型的整數了,這個整數的第 12 到 35 比特位通過 __pa(pud) 填充進來,低 12 位比特通過 _PAGE_TABLE 填充進來。
隨後我們通過 __p4d 將這個剛剛構造出來的 unsigned long 整數轉換成 p4d_t 類型。
#define __p4d(x) native_make_p4d(x)
static inline p4d_t native_make_p4d(pudval_t val)
{
return (p4d_t) { val };
}
最後我們通過 set_p4d 將我們剛剛構造出來的 p4d_t 賦值給原始的 p4d_t。
# define set_p4d(p4dp, p4d) native_set_p4d(p4dp, p4d)
這樣一來,缺頁的虛擬內存地址對應在頂級頁目錄表中的頁目錄項 p4d_t 就被填充好了,現在它已經指向了剛剛新創建出來的 PUD,並且擁有了初始的權限位。
目前為止,我們只是完善了缺頁虛擬內存地址對應在進程頁表頂級頁目錄中的目錄項 p4d_t,在四級頁表體系下,我們還需要繼續向下逐級的去補齊虛擬內存地址對應在其他頁目錄中的目錄項,處理邏輯上都是一模一樣的。
頂級頁目錄項 p4d 中包含了其下一級頁目錄 PUD 的相關信息,在內核中使用 pud_offset 函數來定位虛擬內存地址 address 對應在 PUD 中的頁目錄項 pud_t。
/* Find an entry in the third-level page table.. */
static inline pud_t *pud_offset(p4d_t *p4d, unsigned long address)
{
return (pud_t *)p4d_page_vaddr(*p4d) + pud_index(address);
}
和頂級頁目錄 PGD 一樣,上層頁目錄 PUD 也可以看做是一個能夠存放 512 個 pud_t 的數組 —— pud_t[PTRS_PER_PUD] 。
// 上層頁目錄表 PUD 可以容納的頁目錄項 pud_t 的個數
#define PTRS_PER_PUD 512
內核通過 pud_index 函數將虛擬內存地址右移 PUD_SHIFT(30)位然後用掩碼 PTRS_PER_PUD - 1 將高位全部掩掉,只保留低 9 位得到虛擬內存地址在上層頁目錄 PUD 中對應的頁目錄項 pud_t 的偏移 —— pud_index。
static inline unsigned long pud_index(unsigned long address)
{
return (address >> PUD_SHIFT) & (PTRS_PER_PUD - 1);
}
#define PUD_SHIFT 30
現在我們有了 pud_index,如果我們還能夠知道上層頁目錄表 PUD 的虛擬內存地址,兩者一相加就能得到頁目錄項 pud_t 了。而 PUD 的物理內存地址恰好保存在剛剛填充好的頂級頁目錄項 p4d 中,我們可以從 p4d 中將 PUD 的物理內存地址提取出來,然後通過 __va 轉換成虛擬內存地址不就行了麼。
static inline unsigned long p4d_page_vaddr(p4d_t p4d)
{
return (unsigned long)__va(p4d_val(p4d) & p4d_pfn_mask(p4d));
}
首先我們通過 p4d_val 將頂級頁目錄項 p4d 的值(8 字節,64 比特)提取出來。
#define p4d_val(x) native_p4d_val(x)
static inline p4dval_t native_p4d_val(p4d_t p4d)
{
return p4d.p4d;
}
然後再根據頁目錄項中的比特位佈局,將其下一級頁目錄表的物理內存地址截取出來。
那麼如何截取呢 ? 上圖中展示的頁目錄項比特位佈局筆者是按照 36 位物理內存地址所畫,事實上 Linux 內核最大可支持 52 位的物理內存地址。
#define __PHYSICAL_MASK_SHIFT 52
我們將 1 左移 __PHYSICAL_MASK_SHIFT 位然後再減 1 得到 __PHYSICAL_MASK(低 52 位全部為 1)。
#define __PHYSICAL_MASK ((phys_addr_t)((1ULL << __PHYSICAL_MASK_SHIFT) - 1))
然後拿 p4d_val & __PHYSICAL_MASK 就可以將 p4d_val 的高位截取掉,只保留低 52 位。
這低 52 位中包含了兩個部分,一個是我們想要提取的下一級頁目錄表的物理內存地址,另一個則是低 12 位的權限標記位。
如果我們再能夠把這低 12 位的權限標記位用掩碼掩掉,就可以得到下一級頁目錄表的物理內存地址了。
#define PAGE_SHIFT 12
#define PAGE_SIZE (_AC(1,UL) << PAGE_SHIFT)
#define PAGE_MASK (~(PAGE_SIZE-1)) // 0xFFFFFFFFFFFFF000
上面的 PAGE_MASK 掩碼就是用於將頁目錄項 p4d 的低 12 位掩掉的,我們接着在 p4d_val & __PHYSICAL_MASK 的基礎上再與上 PAGE_MASK,就可以將 p4d 中保存的下一級頁目錄表 PUD 的物理內存地址截取出來了。
雖然我們是按照 52 位的物理內存地址截取的,但是對於 36 位的物理內存地址來説,頁目錄項中的低 36 位到 51 位之間的比特位都是 0 值,所以也不影響。
static inline unsigned long p4d_page_vaddr(p4d_t p4d)
{
return (unsigned long)__va(p4d_val(p4d) & p4d_pfn_mask(p4d));
}
static inline p4dval_t p4d_pfn_mask(p4d_t p4d)
{
/* No 512 GiB huge pages yet */
return PTE_PFN_MASK;
}
/* Extracts the PFN from a (pte|pmd|pud|pgd)val_t of a 4KB page */
#define PTE_PFN_MASK ((pteval_t)PHYSICAL_PAGE_MASK)
#define PHYSICAL_PAGE_MASK (((signed long)PAGE_MASK) & __PHYSICAL_MASK)
現在我們已經得到 PUD 的物理內存地址了,隨後通過 __va 轉換成虛擬內存地址,然後在加上 pud_index 就得到缺頁虛擬內存地址在進程頁表上層頁目錄 PUD 中對應的頁目錄項 pud_t 了。
在得到 pud_t 之後,內核還是需要通過 pud_none 來判斷下該上層頁目錄項 pud_t 是否是空的,如果是空的話,就需要通過 __pmd_alloc 函數重新分配一張中間頁目錄表 PMD 出來,然後填充這個空的 pud_t,這裏的邏輯和前面處理 p4d_t 的邏輯一模一樣。
// 同 p4d_none 的邏輯一樣
static inline int pud_none(pud_t pud)
{
return (native_pud_val(pud) & ~(_PAGE_KNL_ERRATUM_MASK)) == 0;
}
由於這個 PUD 是之前為了填充頂級頁目錄項 p4d_t 而新創建出來的,所以 PUD 這張頁目錄表裏還全是 0 值,缺頁虛擬內存地址在 PUD 中對應的目錄項 pud_t 自然也是 0 值,通過 pud_none 判斷自然是返回 true 。
隨後內核會調用 __pmd_alloc 函數新分配一張 4K 大小的物理內存頁作為 PMD , 然後用 PMD 的物理內存地址去填充這個空的 pud_t。這裏的邏輯和 __pud_alloc 還是一模一樣。
/*
* Allocate page middle directory.
* We've already handled the fast-path in-line.
*/
int __pmd_alloc(struct mm_struct *mm, pud_t *pud, unsigned long address)
{
// 調用 alloc_pages 從夥伴系統申請一個 4K 大小的物理內存頁,作為新的 PMD
pmd_t *new = pmd_alloc_one(mm, address);
if (!new)
return -ENOMEM;
// 如果 pud 還未指向其下一級頁目錄 PMD,則需要初始化填充 pud
if (!pud_present(*pud)) {
mm_inc_nr_pmds(mm);
// 將 new 指向的新分配出來的 PMD 物理內存地址以及相關屬性填充到上層頁目錄項 pud 中
pud_populate(mm, pud, new);
} else /* Another has populated it */
pmd_free(mm, new);
return 0;
}
填充上層頁目錄項 pud_t 的邏輯和之前填充頂級頁目錄項 p4d_t 的邏輯也是一樣的。
static inline void pud_populate(struct mm_struct *mm, pud_t *pud, pmd_t *pmd)
{
set_pud(pud, __pud(_PAGE_TABLE | __pa(pmd)));
}
都是通過 PMD 的物理內存地址 __pa(pmd) 以及頁目錄的初始權限標記位集合 _PAGE_TABLE 來構造一個 unsigned long 類型的整數。
通過 __pud 將這個剛剛構造出來的 unsigned long 整數轉換成 pud_t 類型:
#define __pud(x) native_make_pud(x)
static inline pud_t native_make_pud(pmdval_t val)
{
return (pud_t) { val };
}
最後將 __pud 的返回值通過 set_pud 賦值給原始的上層頁目錄項 pud 。這樣就算完成了 pud 的填充。
# define set_pud(pudp, pud) native_set_pud(pudp, pud)
static inline void native_set_pud(pud_t *pudp, pud_t pud)
{
WRITE_ONCE(*pudp, pud);
}
中間頁目錄表 PMD 有了,接下來的任務就該定位缺頁虛擬內存地址在進程頁表 PMD 中對應的頁目錄項 pmd_t 了。
和前面的 PGD ,PUD 一樣, PMD 也可以看做是一個能夠存放 512 個 pmd_t 的數組 —— pmd_t[PTRS_PER_PMD] 。
// 中間頁目錄表 PMD 可以容納的頁目錄項 pmd_t 的個數
#define PTRS_PER_PMD 512
內核通過 pmd_offset 函數來定位虛擬內存地址 address 對應在 PMD 中的頁目錄項 pmd_t。
static inline pmd_t *pmd_offset(pud_t *pud, unsigned long address)
{
return (pmd_t *)pud_page_vaddr(*pud) + pmd_index(address);
}
還是之前的套路,首先需要通過 pud_page_vaddr 從上層頁目錄 PUD 中的頁目錄項 pud_t 中提取出其下一級頁目錄表 PMD 的起始虛擬內存地址。
static inline unsigned long pud_page_vaddr(pud_t pud)
{
return (unsigned long)__va(pud_val(pud) & pud_pfn_mask(pud));
}
然後通過 pmd_index 獲取缺頁虛擬內存地址在 PMD 中的偏移,和之前的處理方式一樣,首先將缺頁虛擬內存地址 address 右移 PMD_SHIFT(21)位,然後和掩碼 PTRS_PER_PMD - 1 相與,只保留低 9 位。
static inline unsigned long pmd_index(unsigned long address)
{
return (address >> PMD_SHIFT) & (PTRS_PER_PMD - 1);
}
#define PMD_SHIFT 21
#define PTRS_PER_PMD 512
最後用剛剛提取出的 PMD 起始虛擬內存地址 pud_page_vaddr 與 pmd_index 相加就得到我們尋找的中間頁目錄項 pmd_t 了。
在我們獲取到 pmd_t 之後,接下來就該處理頁表了,而頁表是直接與物理內存頁進行映射的,後續我們需要到頁表項中,根據權限位的設置來解析出具體的缺頁原因,然後進行針對性的缺頁處理,這一部分的內容封裝在 handle_pte_fault 函數中,這是我們下一小節中要介紹的內容。
而本小節中介紹的 __handle_mm_fault 的主要工作是將進程頁表中的三級頁目錄表 PGD,PUD,PMD 補齊,然後獲取到 pmd_t 就完成了,隨後會把 pmd_t 送到 handle_pte_fault 函數中進行頁表的處理。
在我們理解了以上內容之後,再回頭來看 __handle_mm_fault 源碼實現就很清晰了:
static vm_fault_t __handle_mm_fault(struct vm_area_struct *vma,
unsigned long address, unsigned int flags)
{
// vm_fault 結構用於封裝後續缺頁處理用到的相關參數
struct vm_fault vmf = {
// 發生缺頁的 vma
.vma = vma,
// 引起缺頁的虛擬內存地址
.address = address & PAGE_MASK,
// 處理缺頁的相關標記 FAULT_FLAG_xxx
.flags = flags,
// address 在 vma 中的偏移,單位也頁
.pgoff = linear_page_index(vma, address),
// 後續用於分配物理內存使用的相關掩碼 gfp_mask
.gfp_mask = __get_fault_gfp_mask(vma),
};
// 獲取進程虛擬內存空間
struct mm_struct *mm = vma->vm_mm;
// 進程頁表的頂級頁表地址
pgd_t *pgd;
// 五級頁表下會使用,在四級頁表下 p4d 與 pgd 的值一樣
p4d_t *p4d;
vm_fault_t ret;
// 獲取 address 在全局頁目錄表 PGD 中對應的目錄項 pgd
pgd = pgd_offset(mm, address);
// 在四級頁表下,這裏只是將 pgd 賦值給 p4d,後續均已 p4d 作為全局頁目錄項
p4d = p4d_alloc(mm, pgd, address);
if (!p4d)
return VM_FAULT_OOM;
// 首先 p4d_none 判斷全局頁目錄項 p4d 是否是空的
// 如果 p4d 是空的,則調用 __pud_alloc 分配一個新的上層頁目錄表 PUD,然後填充 p4d
// 如果 p4d 不是空的,則調用 pud_offset 獲取 address 在上層頁目錄 PUD 中的目錄項 pud
vmf.pud = pud_alloc(mm, p4d, address);
if (!vmf.pud)
return VM_FAULT_OOM;
........ 省略 1G 大頁缺頁處理 ..........
// 首先 pud_none 判斷上層頁目錄項 pud 是不是空的
// 如果 pud 是空的,則調用 __pmd_alloc 分配一個新的中間頁目錄表 PMD,然後填充 pud
// 如果 pud 不是空的,則調用 pmd_offset 獲取 address 在中間頁目錄 PMD 中的目錄項 pmd
vmf.pmd = pmd_alloc(mm, vmf.pud, address);
if (!vmf.pmd)
return VM_FAULT_OOM;
........ 省略 2M 大頁缺頁處理 ..........
// 進行頁表的相關處理以及解析具體的缺頁原因,後續針對性的進行缺頁處理
return handle_pte_fault(&vmf);
}
6. handle_pte_fault
在上一小節的開頭,筆者列舉了引起缺頁異常主要的三種原因,要麼缺頁的虛擬內存地址從來還沒有被映射過,要麼是雖然之前映射過,但是物理內存頁被 swap 到磁盤上了,要麼是因為訪問權限不夠的原因引起的缺頁。
從總體上來講引起缺頁中斷的原因分為兩大類,一類是缺頁虛擬內存地址背後映射的物理內存頁不在內存中,另一類是缺頁虛擬內存地址背後映射的物理內存頁在內存中。
而每一類下邊又包含若干種缺頁的場景,在本小節中筆者會帶着大家一一把這些場景梳理清楚,下面我們來看第一類,其中分為了三種缺頁場景。
第一種場景是,缺頁虛擬內存地址 address 在進程頁表中間頁目錄對應的頁目錄項 pmd_t 是空的,我們可以通過 pmd_none 方法來判斷。
static inline int pmd_none(pmd_t pmd)
{
unsigned long val = native_pmd_val(pmd);
return (val & ~_PAGE_KNL_ERRATUM_MASK) == 0;
}
這種情況表示缺頁地址 address 對應的 pmd 目前還沒有對應的頁表,連頁表都還沒有,那麼自然 pte 也是空的,物理內存頁就更不用説了,肯定還沒有。
第二種場景是,缺頁地址 address 對應的 pmd_t 雖然不是空的,頁表也存在,但是 address 對應在頁表中的 pte 是空的。內核中通過 pte_offset_map 定位 address 在頁表中的 pte 。這個過程和前面介紹的定位頁目錄項的過程一模一樣。
#define pte_offset_map(dir, address) pte_offset_kernel((dir), (address))
static inline pte_t *pte_offset_kernel(pmd_t *pmd, unsigned long address)
{
return (pte_t *)pmd_page_vaddr(*pmd) + pte_index(address);
}
static inline unsigned long pte_index(unsigned long address)
{
return (address >> PAGE_SHIFT) & (PTRS_PER_PTE - 1);
}
#define PAGE_SHIFT 12
// 頁表可以容納的頁表項 pte_t 的個數
#define PTRS_PER_PTE 512
這種情況下,雖然頁表是存在的,但是奈何 address 在頁表中的 pte 是空的,和第一種場景一樣,都説明了該 address 之前從來還沒有被映射過。
既然之前都沒有被映射,那麼現在就該把這塊內容補齊,筆者在之前的文章 《從內核世界透視 mmap 內存映射的本質(原理篇)》 中曾為大家介紹了四種內存映射方式,它們分別為:私有匿名映射,私有文件映射,共享文件映射,共享匿名映射。這四種內存映射方式從總體上來説分為兩類:一類是匿名映射,另一類是文件映射。
所以在處理虛擬內存映射區 vma 中的缺頁時,也需要分為匿名映射區的缺頁處理以及文件映射區的缺頁處理。那麼在這裏,我們該如何區分這個缺頁的 vma 到底是屬於匿名映射區還是文件映射區呢 ?
還記得筆者之前在 《從內核世界透視 mmap 內存映射的本質(源碼實現篇)》 一文中介紹的內存映射核心函數 mmap_region 嗎?關於文件映射和匿名映射,有這樣的兩段代碼:
unsigned long mmap_region(struct file *file, unsigned long addr,
unsigned long len, vm_flags_t vm_flags, unsigned long pgoff,
struct list_head *uf)
{
........ 省略 ........
// 文件映射
if (file) {
// 將文件與虛擬內存映射起來
vma->vm_file = get_file(file);
// 這一步中將虛擬內存區域 vma 的操作函數 vm_ops 映射成文件的操作函數(和具體文件系統有關)
// ext4 文件系統中的操作函數為 ext4_file_vm_ops
// 從這一刻開始,讀寫內存就和讀寫文件是一樣的了
error = call_mmap(file, vma);
if (error)
goto unmap_and_free_vma;
addr = vma->vm_start;
vm_flags = vma->vm_flags;
} else {
// 這裏處理私有匿名映射
// 將 vma->vm_ops 設置為 null,只有文件映射才需要 vm_ops 這樣才能將內存與文件映射起來
vma_set_anonymous(vma);
}
}
在處理文件映射的代碼中,內核調用了一個叫 call_mmap 的函數,內核在該函數中將虛擬內存的相關操作函數 vma->vm_ops 映射成了文件相關的操作函數 ext4_file_vm_ops。正因為如此,後續進程讀寫這塊虛擬內存就相當於讀寫文件了。
static int ext4_file_mmap(struct file *file, struct vm_area_struct *vma)
{
........ 省略 ........
vma->vm_ops = &ext4_file_vm_ops;
........ 省略 ........
}
而在處理匿名映射的代碼中,內核調用了一個叫做 vma_set_anonymous 的函數,在這裏會將 vma->vm_ops 設置為 null ,因為這裏映射的匿名內存頁,背後並沒有文件來支撐。
static inline void vma_set_anonymous(struct vm_area_struct *vma)
{
vma->vm_ops = NULL;
}
所以判斷一個虛擬內存區域 vma 到底是文件映射區還是匿名映射區就是要看這個 vma 的 vm_ops 是否為 null。
static inline bool vma_is_anonymous(struct vm_area_struct *vma)
{
return !vma->vm_ops;
}
如果 vma_is_anonymous 返回 true,那麼內核就會在 handle_pte_fault 函數中調用 do_anonymous_page 進行匿名映射區的缺頁處理。
如果 vma_is_anonymous 返回 false,那麼內核就調用 do_fault 進行文件映射區的缺頁處理。
// pte 是空的,表示缺頁地址 address 還從來沒有被映射過,接下來就要處理物理內存的映射
if (!vmf->pte) {
// 判斷缺頁的虛擬內存地址 address 所在的虛擬內存區域 vma 是否是匿名映射區
if (vma_is_anonymous(vmf->vma))
// 處理匿名映射區發生的缺頁
return do_anonymous_page(vmf);
else
// 處理文件映射區發生的缺頁
return do_fault(vmf);
}
第三種缺頁場景是,虛擬內存地址 address 在進程頁表中的頁表項 pte 不是空的,但是其背後映射的物理內存頁被內核 swap out 到磁盤上了,CPU 訪問的時候依然會產生缺頁。
那麼我們如何知道 pte 背後映射的物理內存頁在不在內存中呢 ?
筆者在之前的文章《一步一圖帶你構建 Linux 頁表體系》 中介紹了頁表項 pte 的比特位佈局如下圖所示:
其中 pte 的第 0 個比特位表示該 pte 映射的物理內存頁是否在內存中,值為 1 表示物理內存頁在內存中駐留,值為 0 表示物理內存頁不在內存中,可能被 swap 到磁盤上了。
#define _PAGE_BIT_PRESENT 0 /* is present */
#define _PAGE_PRESENT (_AT(pteval_t, 1) << _PAGE_BIT_PRESENT)
如果我們可以把 pte 中的相關權限位提取出來,然後判斷權限位第 0 個比特位是否為 1 ,是不是就能知道 pte 映射的物理內存頁到底在不在內存中了,這個邏輯封裝在 pte_present 方法中:
static inline int pte_present(pte_t a)
{
return pte_flags(a) & (_PAGE_PRESENT | _PAGE_PROTNONE);
}
pte_flags 函數用於從 pte 中提取相關的權限位,如何提取呢 ?可還記得我們在上小節中介紹的從頁目錄項中提取其下一級頁目錄表的物理內存地址時使用到的掩碼 PTE_PFN_MASK 嗎 ?
static inline unsigned long p4d_page_vaddr(p4d_t p4d)
{
return (unsigned long)__va(p4d_val(p4d) & PTE_PFN_MASK;
}
/* Extracts the PFN from a (pte|pmd|pud|pgd)val_t of a 4KB page */
#define PTE_PFN_MASK ((pteval_t)PHYSICAL_PAGE_MASK)
#define PHYSICAL_PAGE_MASK (((signed long)PAGE_MASK) & __PHYSICAL_MASK)
如果我們把掩碼 PTE_PFN_MASK 取反,然後在和 pte 做與運算,這樣 pte 中的相關權限標記位不就提取出來麼。
#define PTE_FLAGS_MASK (~PTE_PFN_MASK)
static inline pteval_t pte_flags(pte_t pte)
{
return native_pte_val(pte) & PTE_FLAGS_MASK;
}
static inline pteval_t native_pte_val(pte_t pte)
{
return pte.pte;
}
然後用權限標記位 pte_flags 和 _PAGE_PRESENT 做 & 運算就可以知道 pte 背後映射的物理內存頁是否在內存中了。
如果我們通過 pte_present 判斷映射的物理內存頁不在內存中了,説明它已經被內核 swap out 到磁盤上了,這種情況下的缺頁處理就需要調用 do_swap_page 函數,將磁盤上的物理內存頁重新 swap in 到內存中來。
if (!pte_present(vmf->orig_pte))
// 將之前映射的物理內存頁從磁盤中重新 swap in 到內存中
return do_swap_page(vmf);
以上介紹的這三種缺頁場景都是屬於缺頁內存地址 address 背後映射的物理內存頁不在內存中的類別。
下面我們來看下另一類別,也就是缺頁虛擬內存地址背後映射的物理內存頁在內存中的情況 ,這裏又會近一步分為兩種缺頁場景。
筆者曾在 《深入理解 Linux 物理內存管理》一文中為大家介紹了 Linux 內核在 NUMA 架構下物理內存管理的相關內容。
在 NUMA 架構下,CPU 訪問自己的本地內存節點是最快的,但訪問其他內存節點就會慢很多,這就導致了 CPU 訪問內存的速度不一致。
回到我們缺頁處理的場景中就是缺頁虛擬內存地址背後映射的物理內存頁雖然在內存中,但是它可能是進程所在 CPU 中的本地 NUMA 節點上的內存,也可能是其他 NUMA 節點上的內存。
因為 CPU 對不同 NUMA 節點上的內存有訪問速度上的差異,所以內核通常傾向於讓 CPU 儘量訪問本地 NUMA 節點上的內存。NUMA Balancing 機制就是用來解決這個問題的。
通俗來講,NUMA Balancing 主要幹兩件事情,一件事是讓內存跟着 CPU 走,另一件事是讓 CPU 跟着內存走。
進程申請到的物理內存頁可能在當前 CPU 的本地 NUMA 節點上,也可能在其他 NUMA 節點上。
所謂讓內存跟着 CPU 走的意思就是,當進程訪問的物理內存頁不在當前 CPU 的本地 NUMA 節點上時,NUMA Balancing 就會嘗試將遠程 NUMA 節點上的物理內存頁遷移到本地 NUMA 節點上,加快進程訪問內存的速度。
所謂讓 CPU 跟着內存走的意思就是,當進程經常訪問的大部分物理內存頁均不在當前 CPU 的本地 NUMA 節點上時,NUMA Balancing 乾脆就把進程重新調度到這些物理內存頁所在的 NUMA 節點上。當然整個 NUMA Balancing 的過程會根據我們設置的 NUMA policy 以及各個 NUMA 節點上缺頁的次數來綜合考慮是否遷移內存頁。這裏涉及到的細節很多,筆者就不一一展開了。
NUMA Balancing 會週期性掃描進程虛擬內存地址空間,如果發現虛擬內存背後映射的物理內存頁不在當前 CPU 本地 NUMA 節點的時候,就會把對應的頁表項 pte 標記為 _PAGE_PROTNONE,也就是將 pte 的第 8 個 比特位置為 1,隨後會將 pte 的 Present 位置為 0 。
#define _PAGE_PROTNONE (_AT(pteval_t, 1) << _PAGE_BIT_PROTNONE)
#define _PAGE_BIT_PROTNONE _PAGE_BIT_GLOBAL
#define _PAGE_BIT_GLOBAL 8
這種情況下調用 pte_present 依然很返回 true ,因為當前的物理內存頁畢竟是在內存中的,只不過不在當前 CPU 的本地 NUMA 節點上而已。
當 pte 被標記為 _PAGE_PROTNONE 之後,這意味着該 pte 背後映射的物理內存頁進程對其沒有讀寫權限,也沒有可執行的權限。進程在訪問這段虛擬內存地址的時候就會發生缺頁。
當進入缺頁異常的處理程序之後,內核會在 handle_pte_fault 函數中通過 pte_protnone 函數判斷,缺頁的 pte 是否被標記了 _PAGE_PROTNONE 標識。
static inline int pte_protnone(pte_t pte)
{
return (pte_flags(pte) & (_PAGE_PROTNONE | _PAGE_PRESENT))
== _PAGE_PROTNONE;
}
如果 pte 被標記了 _PAGE_PROTNONE,並且對應的虛擬內存區域是一個具有讀寫,可執行權限的 vma。這就説明該 vma 背後映射的物理內存頁不在當前 CPU 的本地 NUMA 節點上。
static inline bool vma_is_accessible(struct vm_area_struct *vma)
{
return vma->vm_flags & (VM_READ | VM_EXEC | VM_WRITE);
}
這裏需要調用 do_numa_page,將這個遠程 NUMA 節點上的物理內存頁遷移到當前 CPU 的本地 NUMA 節點上,從而加快進程訪問內存的速度。
if (pte_protnone(vmf->orig_pte) && vma_is_accessible(vmf->vma))
return do_numa_page(vmf);
NUMA Balancing 機制看起來非常好,但是同時也會為系統引入很多開銷,比如,掃描進程地址空間的開銷,缺頁的開銷,更主要的是頁面遷移的開銷會很大,這也會引起 CPU 有時候莫名其妙的飆到 100 %。因此筆者建議在一般情況下還是將 NUMA Balancing 關閉為好,除非你有明確的理由開啓。
我們可以將內核參數 /proc/sys/kernel/numa_balancing 設置為 0 或者通過 sysctl 命令來關閉 NUMA Balancing。
echo 0 > /proc/sys/kernel/numa_balancing
sysctl -w kernel.numa_balancing=0
第二種場景就是寫時複製了(Copy On Write, COW),這種場景和 NUMA Balancing 一樣,都屬於缺頁虛擬內存地址背後映射的物理內存頁在內存中而引起的缺頁中斷。
COW 在內核的內存管理子系統中很常見了,比如,父進程通過 fork 系統調用創建子進程之後,父子進程的虛擬內存空間完全是一模一樣的,包括父子進程的頁表內容都是一樣的,父子進程頁表中的 PTE 均指向同一物理內存頁面,此時內核會將父子進程頁表中的 PTE 均改為只讀的,並將父子進程共同映射的這個物理頁面引用計數 + 1。
static inline unsigned long
copy_one_pte(struct mm_struct *dst_mm, struct mm_struct *src_mm,
pte_t *dst_pte, pte_t *src_pte, struct vm_area_struct *vma,
unsigned long addr, int *rss)
{
/*
* If it's a COW mapping, write protect it both
* in the parent and the child
*/
if (is_cow_mapping(vm_flags) && pte_write(pte)) {
// 設置父進程的 pte 為只讀
ptep_set_wrprotect(src_mm, addr, src_pte);
// 設置子進程的 pte 為只讀
pte = pte_wrprotect(pte);
}
// 獲取 pte 中映射的物理內存頁(此時父子進程共享該頁)
page = vm_normal_page(vma, addr, pte);
// 物理內存頁的引用計數 + 1
get_page(page);
}
當父進程或者子進程對該頁面發生寫操作的時候,我們現在假設子進程先對頁面發生寫操作,隨後子進程發現自己頁表中的 PTE 是隻讀的,於是產生缺頁中斷,子進程進入內核態,內核會在本小節介紹的缺頁中斷處理程序中發現,訪問的這個物理頁面引用計數大於 1,説明此時該物理內存頁面存在多進程共享的情況,於是發生寫時複製(Copy On Write, COW),內核為子進程重新分配一個新的物理頁面,然後將原來物理頁中的內容拷貝到新的頁面中,最後子進程頁表中的 PTE 指向新的物理頁面並將 PTE 的 R/W 位設置為 1,原來物理頁面的引用計數 - 1。
後面父進程在對頁面進行寫操作的時候,同樣也會發現父進程的頁表中 PTE 是隻讀的,也會產生缺頁中斷,但是在內核的缺頁中斷處理程序中,發現訪問的這個物理頁面引用計數為 1 了,那麼就只需要將父進程頁表中的 PTE 的 R/W 位設置為 1 就可以了。
還有筆者在之前的文章 《從內核世界透視 mmap 內存映射的本質(原理篇)》中介紹的私有文件映射,也用到了 COW,當多個進程採用私有文件映射的方式對同一文件的同一部分進行映射的時候,後續產生的 pte 也都是隻讀的。
當任意進程開始對它的私有文件映射區進行寫操作時,就會發生寫時複製,隨後內核會在這裏介紹的缺頁中斷程序中重新申請一個內存頁,然後將 page cache 中的內容拷貝到這個新的內存頁中,進程頁表中對應的 pte 會重新關聯到這個新的內存頁上,此時 pte 的權限變為可寫。
在以上介紹的兩種寫時複製應用場景中,他們都有一個共同的特點,就是進程的虛擬內存區域 vma 的權限是可寫的,但是其對應在頁表中的 pte 卻是隻讀的,而 pte 映射的物理內存頁也在內存中。
內核正是利用這個特點來判斷本次缺頁中斷是否是由寫時複製引起的。如果是,則調用 do_wp_page 進行寫時複製的缺頁處理。
// 判斷本次缺頁是否為寫時複製引起的
if (vmf->flags & FAULT_FLAG_WRITE) {
// 這裏説明 vma 是可寫的,但是 pte 被標記為不可寫,説明是寫保護類型的中斷
if (!pte_write(entry))
// 進行寫時複製處理,cow 就發生在這裏
return do_wp_page(vmf);
}
在我們清楚了以上背景知識之後,再來看 handle_pte_fault 的缺頁處理邏輯就很清晰了:
static vm_fault_t handle_pte_fault(struct vm_fault *vmf)
{
pte_t entry;
if (unlikely(pmd_none(*vmf->pmd))) {
// 如果 pmd 是空的,説明現在連頁表都沒有,頁表項 pte 自然是空的
vmf->pte = NULL;
} else {
// vmf->pte 表示缺頁虛擬內存地址在頁表中對應的頁表項 pte
// 通過 pte_offset_map 定位到虛擬內存地址 address 對應在頁表中的 pte
// 這裏根據 address 獲取 pte_index,然後從 pmd 中提取頁表起始虛擬內存地址相加獲取 pte
vmf->pte = pte_offset_map(vmf->pmd, vmf->address);
// vmf->orig_pte 表示發生缺頁時,address 對應的 pte 值
vmf->orig_pte = *vmf->pte;
// 這裏 pmd 不是空的,表示現在是有頁表存在的,但缺頁虛擬內存地址在頁表中的 pte 是空值
if (pte_none(vmf->orig_pte)) {
pte_unmap(vmf->pte);
vmf->pte = NULL;
}
}
// pte 是空的,表示缺頁地址 address 還從來沒有被映射過,接下來就要處理物理內存的映射
if (!vmf->pte) {
// 判斷缺頁的虛擬內存地址 address 所在的虛擬內存區域 vma 是否是匿名映射區
if (vma_is_anonymous(vmf->vma))
// 處理匿名映射區發生的缺頁
return do_anonymous_page(vmf);
else
// 處理文件映射區發生的缺頁
return do_fault(vmf);
}
// 走到這裏表示 pte 不是空的,但是 pte 中的 p 比特位是 0 值,表示之前映射的物理內存頁已不在內存中(swap out)
if (!pte_present(vmf->orig_pte))
// 將之前映射的物理內存頁從磁盤中重新 swap in 到內存中
return do_swap_page(vmf);
// 這裏表示 pte 背後映射的物理內存頁在內存中,但是 NUMA Balancing 發現該內存頁不在當前進程運行的 numa 節點上
// 所以將該 pte 標記為 _PAGE_PROTNONE(無讀寫,可執行權限)
// 進程訪問該內存頁時發生缺頁中斷,在這裏的 do_numa_page 中,內核將該 page 遷移到進程運行的 numa 節點上。
if (pte_protnone(vmf->orig_pte) && vma_is_accessible(vmf->vma))
return do_numa_page(vmf);
entry = vmf->orig_pte;
// 如果本次缺頁中斷是由寫操作引起的
if (vmf->flags & FAULT_FLAG_WRITE) {
// 這裏説明 vma 是可寫的,但是 pte 被標記為不可寫,説明是寫保護類型的中斷
if (!pte_write(entry))
// 進行寫時複製處理,cow 就發生在這裏
return do_wp_page(vmf);
// 如果 pte 是可寫的,就將 pte 標記為髒頁
entry = pte_mkdirty(entry);
}
// 將 pte 的 access 比特位置 1 ,表示該 page 是活躍的。避免被 swap 出去
entry = pte_mkyoung(entry);
// 經過上面的缺頁處理,這裏會判斷原來的頁表項 entry(orig_pte) 值是否發生了變化
// 如果發生了變化,就把 entry 更新到 vmf->pte 中。
if (ptep_set_access_flags(vmf->vma, vmf->address, vmf->pte, entry,
vmf->flags & FAULT_FLAG_WRITE)) {
// pte 既然變化了,則刷新 mmu (體系結構相關)
update_mmu_cache(vmf->vma, vmf->address, vmf->pte);
} else {
// 如果 pte 內容本身沒有變化,則不需要刷新任何東西
// 但是有個特殊情況就是寫保護類型中斷,產生的寫時複製,產生了新的映射關係,需要刷新一下 tlb
/*
* This is needed only for protection faults but the arch code
* is not yet telling us if this is a protection fault or not.
* This still avoids useless tlb flushes for .text page faults
* with threads.
*/
if (vmf->flags & FAULT_FLAG_WRITE)
flush_tlb_fix_spurious_fault(vmf->vma, vmf->address);
}
return 0;
}
7. do_anonymous_page 處理匿名頁缺頁
在本文的第五小節中,我們完成了各級頁目錄的補齊填充工作,但是現在最後一級頁表還沒有着落,所以在處理缺頁之前,我們需要調用 pte_alloc 繼續把頁表補齊了。
#define pte_alloc(mm, pmd) (unlikely(pmd_none(*(pmd))) && __pte_alloc(mm, pmd))
首先我們通過 pmd_none 判斷缺頁地址 address 在進程頁表中間頁目錄 PMD 中對應的頁目錄項 pmd 是否是空的,如果 pmd 是空的,説明此時還不存在一級頁表,這樣一來,就需要調用 __pte_alloc 來分配一張頁表,然後用頁表的 pfn 以及初始權限位 _PAGE_TABLE 來填充 pmd。
static inline void pmd_populate(struct mm_struct *mm, pmd_t *pmd,
struct page *pte)
{
// 通過頁表 page 獲取對應的 pfn
unsigned long pfn = page_to_pfn(pte);
// 將頁表 page 的 pfn 以及初始權限位 _PAGE_TABLE 填充到 pmd 中
set_pmd(pmd, __pmd(((pteval_t)pfn << PAGE_SHIFT) | _PAGE_TABLE));
}
這裏 __pte_alloc 的流程邏輯和前面我們介紹的__pud_alloc,__pmd_alloc 可以説是一模一樣,都是創建其下一級頁目錄或者頁表,然後填充對應的頁目錄項,這裏就不做過多的介紹了。
int __pte_alloc(struct mm_struct *mm, pmd_t *pmd)
{
spinlock_t *ptl;
// 調用 get_zeroed_page 申請一個 4k 物理內存頁並初始化為 0 值作為新的 頁表
// new 指向新分配的 頁表 起始內存地址
pgtable_t new = pte_alloc_one(mm);
if (!new)
return -ENOMEM;
// 鎖定中間頁目錄項 pmd
ptl = pmd_lock(mm, pmd);
// 如果 pmd 是空的,説明此時 pmd 並未指向頁表,下面就需要用新頁表 new 來填充 pmd
if (likely(pmd_none(*pmd))) {
// 更新 mm->pgtables_bytes 計數,該字段用於統計進程頁表所佔用的字節數
// 由於這裏新增了一張頁表,所以計數需要增加 PTRS_PER_PTE * sizeof(pte_t)
mm_inc_nr_ptes(mm);
// 將 new 指向的新分配出來的頁表 page 的 pfn 以及相關初始權限位填充到 pmd 中
pmd_populate(mm, pmd, new);
new = NULL;
}
spin_unlock(ptl);
return 0;
}
// 頁表可以容納的頁表項 pte_t 的個數
#define PTRS_PER_PTE 512
現在我們已經有了一級頁表,但是頁表中的 pte 還都是空的,接下來就該用這個空的 pte 來映射物理內存頁了。
首先我們通過 alloc_zeroed_user_highpage_movable 來分配一個物理內存頁出來,關於物理內存詳細的分配過程,感興趣的讀者可以看下筆者的這篇文章——《深入理解 Linux 物理內存分配全鏈路實現》。
這個物理內存頁就是為缺頁地址 address 映射的物理內存了,隨後我們通過 mk_pte 利用物理內存頁 page 的 pfn 以及缺頁內存區域 vma 中記錄的頁屬性 vma->vm_page_prot 填充一個新的頁表項 entry 出來。
entry 這裏只是一個臨時的值,後續會將 entry 的值設置到真正的 pte 中。
#define mk_pte(page, pgprot) pfn_pte(page_to_pfn(page), (pgprot))
如果缺頁內存地址 address 所在的虛擬內存區域 vma 是可寫的,那麼我們就通過 pte_mkwrite 和 pte_mkdirty 將臨時頁表項 entry 的 R/W(1) 比特位和D(6) 比特位置為 1 。表示該頁表項背後映射的物理內存頁 page 是可寫的,並且標記為髒頁。
if (vma->vm_flags & VM_WRITE)
entry = pte_mkwrite(pte_mkdirty(entry));
注意,此時缺頁內存地址 address 在頁表中的 pte 還是空的,我們還沒有設置呢,目前只是先將值初始化到臨時的頁表項 entry 中,下面才到設置真正的 pte 的時候。
調用 pte_offset_map_lock,首先獲取 address 在一級頁表中的真正 pte,然後將一級頁表鎖定。
#define pte_offset_map_lock(mm, pmd, address, ptlp) \
({ \
// 獲取 pmd 映射的一級頁表鎖
spinlock_t *__ptl = pte_lockptr(mm, pmd); \
// 獲取 pte
pte_t *__pte = pte_offset_map(pmd, address); \
*(ptlp) = __ptl; \
// 鎖定一級頁表
spin_lock(__ptl); \
__pte; \
})
按理説此時獲取到的 pte 應該是空的,如果 pte 不為空,説明已經有其他線程把缺頁處理好了,pte 已經被填充了,那麼本次缺頁處理就該停止,不能在往下走了,直接跳轉到 release 處,釋放頁表鎖,釋放新分配的物理內存頁 page。
if (!pte_none(*vmf->pte))
goto release;
如果 pte 為空,説明此時沒有其他線程對缺頁進行併發處理,我們可以接着處理缺頁。
進程使用到的常駐內存等相關統計信息保存在 task->rss_stat 字段中:
struct task_struct {
// 統計進程常駐內存信息
struct task_rss_stat rss_stat;
}
由於這裏我們新分配一個匿名內存頁用於缺頁處理,所以相關 rss_stat 統計信息 —— task->rss_stat.count[MM_ANONPAGES] 要加 1 。
// MM_ANONPAGES —— Resident anonymous pages
inc_mm_counter_fast(vma->vm_mm, MM_ANONPAGES);
#define inc_mm_counter_fast(mm, member) add_mm_counter_fast(mm, member, 1)
static void add_mm_counter_fast(struct mm_struct *mm, int member, int val)
{
struct task_struct *task = current;
if (likely(task->mm == mm))
task->rss_stat.count[member] += val;
else
add_mm_counter(mm, member, val);
}
隨後調用 page_add_new_anon_rmap 建立匿名頁的反向映射關係,關於匿名頁的反向映射筆者已經在之前的文章 —— 《深入理解 Linux 物理內存管理》 中詳細介紹過了,感興趣的朋友可以回看下。
反向映射建立好之後,調用 lru_cache_add_active_or_unevictable 將匿名內存頁加入到 LRU 活躍鏈表中。
最後調用 set_pte_at 將之間我們臨時填充的頁表項 entry 賦值給缺頁 address 真正對應的 pte。
set_pte_at(vma->vm_mm, vmf->address, vmf->pte, entry);
#define set_pte_at(mm, addr, ptep, pte) native_set_pte_at(mm, addr, ptep, pte)
static inline void native_set_pte_at(struct mm_struct *mm, unsigned long addr,
pte_t *ptep , pte_t pte)
{
native_set_pte(ptep, pte);
}
static inline void native_set_pte(pte_t *ptep, pte_t pte)
{
WRITE_ONCE(*ptep, pte);
}
到這裏我們才算是真正把進程的頁表體系給補齊了。
在明白以上內容之後,我們回過頭來看在 do_anonymous_page 匿名頁缺頁處理的邏輯就很清晰了:
static vm_fault_t do_anonymous_page(struct vm_fault *vmf)
{
// 缺頁地址 address 所在的虛擬內存區域 vma
struct vm_area_struct *vma = vmf->vma;
// 指向分配的物理內存頁,後面與虛擬內存進行映射
struct page *page;
vm_fault_t ret = 0;
// 臨時的 pte 用於構建 pte 中的值,後續會賦值給 address 在頁表中對應的真正 pte
pte_t entry;
// 如果 pmd 是空的,表示現在還沒有一級頁表
// pte_alloc 這裏會創建一級頁表,並填充 pmd 中的內容
if (pte_alloc(vma->vm_mm, vmf->pmd))
return VM_FAULT_OOM;
// 頁表創建好之後,這裏從夥伴系統中分配一個 4K 物理內存頁出來
page = alloc_zeroed_user_highpage_movable(vma, vmf->address);
if (!page)
goto oom;
// 將 page 的 pfn 以及相關權限標記位 vm_page_prot 初始化一個臨時 pte 出來
entry = mk_pte(page, vma->vm_page_prot);
// 如果 vma 是可寫的,則將 pte 標記為可寫,髒頁。
if (vma->vm_flags & VM_WRITE)
entry = pte_mkwrite(pte_mkdirty(entry));
// 鎖定一級頁表,並獲取 address 在頁表中對應的真實 pte
vmf->pte = pte_offset_map_lock(vma->vm_mm, vmf->pmd, vmf->address,
&vmf->ptl);
// 是否有其他線程在併發處理缺頁
if (!pte_none(*vmf->pte))
goto release;
// 增加 進程 rss 相關計數,匿名內存頁計數 + 1
inc_mm_counter_fast(vma->vm_mm, MM_ANONPAGES);
// 建立匿名頁反向映射關係
page_add_new_anon_rmap(page, vma, vmf->address, false);
// 將匿名頁添加到 LRU 鏈表中
lru_cache_add_active_or_unevictable(page, vma);
setpte:
// 將 entry 賦值給真正的 pte,這裏 pte 就算被填充好了,進程頁表體系也就補齊了
set_pte_at(vma->vm_mm, vmf->address, vmf->pte, entry);
// 刷新 mmu
update_mmu_cache(vma, vmf->address, vmf->pte);
unlock:
// 解除 pte 的映射
pte_unmap_unlock(vmf->pte, vmf->ptl);
return ret;
release:
// 釋放 page
put_page(page);
goto unlock;
oom:
return VM_FAULT_OOM;
}
8. do_fault 處理文件頁缺頁
筆者在之前的文章《從內核世界透視 mmap 內存映射的本質(源碼實現篇)》 中,在為大家介紹到 mmap 文件映射的源碼實現時,特別強調了一下,mmap 內存文件映射的本質其實就是將虛擬映射區 vma 的相關操作 vma->vm_ops 映射成文件的相關操作 ext4_file_vm_ops。
unsigned long mmap_region(struct file *file, unsigned long addr,
unsigned long len, vm_flags_t vm_flags, unsigned long pgoff,
struct list_head *uf)
{
........ 省略 ........
// 文件映射
if (file) {
// 將文件與虛擬內存映射起來
vma->vm_file = get_file(file);
// 這一步中將虛擬內存區域 vma 的操作函數 vm_ops 映射成文件的操作函數(和具體文件系統有關)
// ext4 文件系統中的操作函數為 ext4_file_vm_ops
// 從這一刻開始,讀寫內存就和讀寫文件是一樣的了
error = call_mmap(file, vma);
}
}
static int ext4_file_mmap(struct file *file, struct vm_area_struct *vma)
{
vma->vm_ops = &ext4_file_vm_ops;
}
在 vma->vm_ops 中有個重要的函數 fault,在 ext4 文件系統中的實現是:ext4_filemap_fault 函數。
static const struct vm_operations_struct ext4_file_vm_ops = {
.fault = ext4_filemap_fault,
.map_pages = filemap_map_pages,
.page_mkwrite = ext4_page_mkwrite,
};
vma->vm_ops->fault 函數就是專門用於處理文件映射區缺頁的,本小節要介紹的文件頁的缺頁處理的核心就是依賴這個函數完成的。
我們知道 mmap 進行文件映射的時候只是單純地建立了虛擬內存與文件之間的映射關係,此時並沒有物理內存分配。當進程對這段文件映射區進行讀取操作的時候,會觸發缺頁,然後分配物理內存(文件頁),這一部分邏輯在下面的 do_read_fault 函數中完成,它主要處理的是由於對文件映射區的讀取操作而引起的缺頁情況。
而 mmap 文件映射又分為私有文件映射與共享文件映射兩種映射方式,而私有文件映射的核心特點是讀共享的,當任意進程對私有文件映射區發生寫入操作時候,就會發生寫時複製 COW,這一部分邏輯在下面的 do_cow_fault 函數中完成。
對共享文件映射區進行的寫入操作而引起的缺頁,內核放在 do_shared_fault 函數中進行處理。
static vm_fault_t do_fault(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma;
struct mm_struct *vm_mm = vma->vm_mm;
vm_fault_t ret;
// 處理 vm_ops->fault 為 null 的異常情況
if (!vma->vm_ops->fault) {
// 如果中間頁目錄 pmd 指向的一級頁表不在內存中,則返回 SIGBUS 錯誤
if (unlikely(!pmd_present(*vmf->pmd)))
ret = VM_FAULT_SIGBUS;
else {
// 獲取缺頁的頁表項 pte
vmf->pte = pte_offset_map_lock(vmf->vma->vm_mm,
vmf->pmd,
vmf->address,
&vmf->ptl);
// pte 為空,則返回 SIGBUS 錯誤
if (unlikely(pte_none(*vmf->pte)))
ret = VM_FAULT_SIGBUS;
else
// pte 不為空,返回 NOPAGE,即本次缺頁處理不會分配物理內存頁
ret = VM_FAULT_NOPAGE;
pte_unmap_unlock(vmf->pte, vmf->ptl);
}
} else if (!(vmf->flags & FAULT_FLAG_WRITE))
// 缺頁如果是讀操作引起的,進入 do_read_fault 處理
ret = do_read_fault(vmf);
else if (!(vma->vm_flags & VM_SHARED))
// 缺頁是由私有映射區的寫入操作引起的,則進入 do_cow_fault 處理寫時複製
ret = do_cow_fault(vmf);
else
// 處理共享映射區的寫入缺頁
ret = do_shared_fault(vmf);
return ret;
}
8.1 do_read_fault 處理讀操作引起的缺頁
當我們調用 mmap 對文件進行映射的時候,無論是採用私有文件映射的方式還是共享文件映射的方式,內核都只是會在進程的地址空間中為本次映射創建出一段虛擬映射區 vma 出來,然後將這段虛擬映射區 vma 與映射文件關聯起來就結束了,整個映射過程並未涉及到物理內存的分配。
下面是多進程對同一文件中的同一段文件區域進行私有映射後,內核中的結構圖:
當任意進程開始訪問其地址空間中的這段虛擬內存區域 vma 時,由於背後沒有對應文件頁進行映射,所以會發生缺頁中斷,在缺頁中斷中內核會首先分配一個物理內存頁並加入到 page cache 中,隨後將映射的文件內容讀取到剛剛創建出來的物理內存頁中,然後將這個物理內存頁映射到缺頁虛擬內存地址 address 對應在進程頁表中的 pte 中。
除此之外,內核還會考慮到進程訪問內存的空間局部性,所以內核除了會映射本次缺頁需要的文件頁之外,還會將其相鄰的文件頁讀取到 page cache 中,然後將這些相鄰的文件頁映射到對應的 pte 中。這一部分預先提前映射的邏輯在 map_pages 函數中實現。
static const struct vm_operations_struct ext4_file_vm_ops = {
.fault = ext4_filemap_fault,
.map_pages = filemap_map_pages,
.page_mkwrite = ext4_page_mkwrite,
};
如果不滿足預先提前映射的條件,那麼內核就只會專注處理映射本次缺頁所需要的文件頁。
首先通過上面的 fault 函數,當映射文件所在文件系統是 ext4 時,該函數的實現為 ext4_filemap_fault,該函數只負責獲取本次缺頁所需要的文件頁。
當獲取到文件頁之後,內核會調用 finish_fault 函數,將文件頁映射到缺頁地址 address 在進程頁表中對應的 pte 中,do_read_fault 函數處理就完成了,不過需要注意的是,對於私有文件映射的話,此時的這個 pte 還是隻讀的,多進程之間讀共享,當任意進程嘗試寫入的時候,會發生寫時複製。
static unsigned long fault_around_bytes __read_mostly =
rounddown_pow_of_two(65536);
static vm_fault_t do_read_fault(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma;
vm_fault_t ret = 0;
// map_pages 用於提前預先映射文件頁相鄰的若干文件頁到相關 pte 中,從而減少缺頁次數
// fault_around_bytes 控制預先映射的的字節數默認初始值為 65536(16個物理內存頁)
if (vma->vm_ops->map_pages && fault_around_bytes >> PAGE_SHIFT > 1) {
// 這裏會嘗試使用 map_pages 將缺頁地址 address 附近的文件頁預讀進 page cache
// 然後填充相關的 pte,目的是減少缺頁次數
ret = do_fault_around(vmf);
if (ret)
return ret;
}
// 如果不滿足預先映射的條件,則只映射本次需要的文件頁
// 首先會從 page cache 中讀取文件頁,如果 page cache 中不存在則從磁盤中讀取,並預讀若干文件頁到 page cache 中
ret = __do_fault(vmf); // 這裏需要負責獲取文件頁,並不映射
// 將本次缺頁所需要的文件頁映射到 pte 中。
ret |= finish_fault(vmf);
unlock_page(vmf->page);
return ret;
}
__do_fault 函數底層會調用到 vma->vm_ops->fault,在 ext4 文件系統中對應的實現是 ext4_filemap_fault。
static vm_fault_t __do_fault(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma;
vm_fault_t ret;
...... 省略 ......
ret = vma->vm_ops->fault(vmf);
...... 省略 ......
return ret;
}
vm_fault_t ext4_filemap_fault(struct vm_fault *vmf)
{
ret = filemap_fault(vmf);
return ret;
}
filemap_fault 主要的任務就是先把缺頁所需要的文件頁獲取出來,為後面的映射做準備。
以下內容涉及到文件以及 page cache 的相關操作,對細節感興趣的讀者可以回看下筆者之前的文章 —— 《從 Linux 內核角度探秘 JDK NIO 文件讀寫本質》
內核在這裏首先會調用 find_get_page 從 page cache 中嘗試獲取文件頁,如果文件頁存在,則繼續調用 do_async_mmap_readahead 啓動異步預讀機制,將相鄰的若干文件頁一起預讀進 page cache 中。
如果文件頁不在 page cache 中,內核則會調用 do_sync_mmap_readahead 來同步預讀,這裏首先會分配一個物理內存頁出來,然後將新分配的內存頁加入到 page cache 中,並增加頁引用計數。
隨後會通過 address_space_operations 中定義的 readpage 激活塊設備驅動從磁盤中讀取映射的文件內容,然後將讀取到的內容填充新分配的內存頁中。並同步預讀若干相鄰的文件頁到 page cache 中。
static const struct address_space_operations ext4_aops = {
.readpage = ext4_readpage
}
vm_fault_t filemap_fault(struct vm_fault *vmf)
{
int error;
// 獲取映射文件
struct file *file = vmf->vma->vm_file;
// 獲取 page cache
struct address_space *mapping = file->f_mapping;
// 獲取映射文件的 inode
struct inode *inode = mapping->host;
// 獲取映射文件內容在文件中的偏移
pgoff_t offset = vmf->pgoff;
// 從 page cache 讀取到的文件頁,存放在 vmf->page 中返回
struct page *page;
vm_fault_t ret = 0;
// 根據文件偏移 offset,到 page cache 中查找對應的文件頁
page = find_get_page(mapping, offset);
if (likely(page) && !(vmf->flags & FAULT_FLAG_TRIED)) {
// 如果文件頁在 page cache 中,則啓動異步預讀,預讀後面的若干文件頁到 page cache 中
fpin = do_async_mmap_readahead(vmf, page);
} else if (!page) {
// 如果文件頁不在 page cache,那麼就需要啓動 io 從文件中讀取內容到 page cahe
// 由於涉及到了磁盤 io ,所以本次缺頁類型為 VM_FAULT_MAJOR
count_vm_event(PGMAJFAULT);
count_memcg_event_mm(vmf->vma->vm_mm, PGMAJFAULT);
ret = VM_FAULT_MAJOR;
// 啓動同步預讀,將所需的文件數據讀取進 page cache 中並同步預讀若干相鄰的文件數據到 page cache
fpin = do_sync_mmap_readahead(vmf);
retry_find:
// 嘗試到 page cache 中重新讀取文件頁,這一次就可以讀到了
page = pagecache_get_page(mapping, offset,
FGP_CREAT|FGP_FOR_MMAP,
vmf->gfp_mask);
}
}
..... 省略 ......
}
EXPORT_SYMBOL(filemap_fault);
文件頁現在有了,接下來內核就會調用 finish_fault 將文件頁映射到 pte 中。
vm_fault_t finish_fault(struct vm_fault *vmf)
{
// 為本次缺頁準備好的物理內存頁,即後續需要用 pte 映射的內存頁
struct page *page;
vm_fault_t ret = 0;
if ((vmf->flags & FAULT_FLAG_WRITE) &&
!(vmf->vma->vm_flags & VM_SHARED))
// 如果是寫時複製場景,那麼 pte 要映射的是這個 cow 複製過來的內存頁
page = vmf->cow_page;
else
// 在 filemap_fault 函數中讀取到的文件頁,後面需要將文件頁映射到 pte 中
page = vmf->page;
// 對於私有映射來説,這裏需要檢查進程地址空間是否被標記了 MMF_UNSTABLE
// 如果是,那麼 oom 後續會回收這塊地址空間,這會導致私有映射的文件頁丟失
// 所以在為私有映射建立 pte 映射之前,需要檢查一下
if (!(vmf->vma->vm_flags & VM_SHARED))
// 地址空間沒有被標記 MMF_UNSTABLE 則會返回 o
ret = check_stable_address_space(vmf->vma->vm_mm);
if (!ret)
// 將創建出來的物理內存頁映射到 address 對應在頁表中的 pte 中
ret = alloc_set_pte(vmf, vmf->memcg, page);
if (vmf->pte)
// 釋放頁表鎖
pte_unmap_unlock(vmf->pte, vmf->ptl);
return ret;
}
alloc_set_pte 將之前我們準備好的文件頁,映射到缺頁地址 address 在進程頁表對應的 pte 中。
vm_fault_t alloc_set_pte(struct vm_fault *vmf, struct mem_cgroup *memcg,
struct page *page)
{
struct vm_area_struct *vma = vmf->vma;
// 判斷本次缺頁是否是 寫時複製
bool write = vmf->flags & FAULT_FLAG_WRITE;
pte_t entry;
vm_fault_t ret;
// 如果頁表還不存在,需要先創建一個頁表出來
if (!vmf->pte) {
// 如果 pmd 為空,則創建一個頁表出來,並填充 pmd
// 如果頁表存在,則獲取 address 在頁表中對應的 pte 保存在 vmf->pte 中
ret = pte_alloc_one_map(vmf);
if (ret)
return ret;
}
// 根據之前分配出來的內存頁 pfn 以及相關頁屬性 vma->vm_page_prot 構造一個 pte 出來
// 對於私有文件映射來説,這裏的 pte 是隻讀的
entry = mk_pte(page, vma->vm_page_prot);
// 如果是寫時複製,這裏才會將 pte 改為可寫的
if (write)
entry = maybe_mkwrite(pte_mkdirty(entry), vma);
// 將構造出來的 pte (entry)賦值給 address 在頁表中真正對應的 vmf->pte
// 現在進程頁表體系就全部被構建出來了,文件頁缺頁處理到此結束
set_pte_at(vma->vm_mm, vmf->address, vmf->pte, entry);
// 刷新 mmu
update_mmu_cache(vma, vmf->address, vmf->pte);
return 0;
}
8.2 do_cow_fault 處理私有文件映射的寫時複製
上小節 do_read_fault 函數處理的場景是,進程在調用 mmap 對文件進行私有映射或者共享映射之後,立馬進行讀取的缺頁場景。
但是如果當我們採用的是 mmap 進行私有文件映射時,在映射之後,立馬進行寫入操作時,就會發生寫時複製,寫時複製的缺頁處理流程內核封裝在 do_cow_fault 函數中。
由於我們這裏要進行寫時複製,所以首先要調用 alloc_page_vma 從夥伴系統中重新申請一個物理內存頁出來,我們先把這個剛剛新申請出來用於寫時複製的內存頁稱為 cow_page
然後調用上小節中介紹的 __do_fault 函數,將原來的文件頁從 page cache 中讀取出來,我們把原來的文件頁稱為 page 。
最後調用 copy_user_highpage 將原來文件頁 page 中的內容拷貝到剛剛新申請的內存頁 cow_page 中,完成寫時複製之後,接着調用 finish_fault 將 cow_page 映射到缺頁地址 address 在進程頁表中的 pte 上。
這樣一來,進程的這段虛擬文件映射區就映射到了專屬的物理內存頁 cow_page 上,而且內容和原來文件頁 page 中的內容一模一樣,進程對各自虛擬內存區的修改只能反應到各自對應的 cow_page上,而且各自的修改在進程之間是互不可見的。
由於 cow_page 已經脱離了 page cache,所以這些修改也都不會回寫到磁盤文件中,這就是私有文件映射的核心特點。
static vm_fault_t do_cow_fault(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma;
vm_fault_t ret;
// 從夥伴系統重新申請一個用於寫時複製的物理內存頁 cow_page
vmf->cow_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma, vmf->address);
// 從 page cache 讀取原來的文件頁
ret = __do_fault(vmf);
// 將原來文件頁中的內容拷貝到 cow_page 中完成寫時複製
copy_user_highpage(vmf->cow_page, vmf->page, vmf->address, vma);
// 將 cow_page 重新映射到缺頁地址 address 對應在頁表中的 pte 上。
ret |= finish_fault(vmf);
unlock_page(vmf->page);
// 原來的文件頁引用計數 - 1
put_page(vmf->page);
return ret;
}
8.3 do_shared_fault 處理對共享文件映射區寫入引起的缺頁
上小節我們介紹的 do_cow_fault 函數處理的場景是,當我們採用 mmap 進行私有文件映射之後,立即對虛擬映射區進行寫入操作之後的缺頁處理邏輯。
如果我們調用 mmap 對文件進行共享文件映射之後,然後立即對虛擬映射區進行寫入操作,這背後的缺頁處理邏輯又是怎樣的呢 ?
其實和之前的文件缺頁處理邏輯的核心流程都差不多,不同的是由於這裏我們進行的共享文件映射,所以多個進程中的虛擬文件映射區都會映射到 page cache 中的文件頁上,由於沒有寫時複製,所以進程對文件頁的修改都會直接反映到 page cache 中,近而後續會回寫到磁盤文件上。
由於共享文件映射涉及到髒頁回寫,所以在共享文件映射的缺頁處理場景中,為了防止數據的丟失會額外有一些文件系統日誌的記錄工作。
static vm_fault_t do_shared_fault(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma;
vm_fault_t ret, tmp;
// 從 page cache 中讀取文件頁
ret = __do_fault(vmf);
if (vma->vm_ops->page_mkwrite) {
unlock_page(vmf->page);
// 將文件頁變為可寫狀態,併為後續記錄文件日誌做一些準備工作
tmp = do_page_mkwrite(vmf);
}
// 將文件頁映射到缺頁 address 在頁表中對應的 pte 上
ret |= finish_fault(vmf);
// 將 page 標記為髒頁,記錄相關文件系統的日誌,防止數據丟失
// 判斷是否將髒頁回寫
fault_dirty_shared_page(vma, vmf->page);
return ret;
}
9. do_wp_page 進行寫時複製
本小節即將要介紹的 do_wp_page 函數和之前介紹的 do_cow_fault 函數都是用於處理寫時複製的,其最為核心的邏輯都是差不多的,只是在觸發場景上會略有不同。
do_cow_fault 函數主要處理的寫時複製場景是,當我們使用 mmap 進行私有文件映射時,在剛映射完之後,此時進程的頁表或者相關頁表項 pte 還是空的,就立即進行寫入操作。
do_wp_page 函數主要處理的寫時複製場景是,訪問的這塊虛擬內存背後是有物理內存頁映射的,對應的 pte 不為空,只不過相關 pte 的權限是隻讀的,而虛擬內存區域 vma 是有寫權限的,在這種類型的虛擬內存進行寫入操作的時候,觸發的寫時複製就在 do_wp_page 函數中處理。
比如,我們使用 mmap 進行私有文件映射之後,此時只是分配了虛擬內存,進程頁表或者相關 pte 還是空的,這時對這塊映射的虛擬內存進行訪問的時候就會觸發缺頁中斷,最後在之前介紹的 do_read_fault 函數中將映射的文件內容加載到 page cache 中,pte 指向 page cache 中的文件頁。
但此時的 pte 是隻讀的,如果我們對這塊映射的虛擬內存進行寫入操作,就會發生寫時複製,由於現在 pte 不為空,背後也映射着文件頁,所以會在 do_wp_page 函數中進行處理。
除了私有映射的文件頁之外,do_wp_page 還會對匿名頁相關的寫時複製進行處理。
比如,我們通過 fork 系統調用創建子進程的時候,內核會拷貝父進程佔用的所有資源到子進程中,其中也包括了父進程的地址空間以及父進程的頁表。
一個進程中申請的物理內存頁既會有文件頁也會有匿名頁,而這些文件頁和匿名頁既可以是私有的也可以是共享的,當內核在拷貝父進程的頁表時,如果遇到私有的匿名頁或者文件頁,就會將其對應在父子進程頁表中的 pte 設置為只讀,進行寫保護。並將父子進程共同引用的匿名頁或者文件頁的引用計數加 1。
static inline unsigned long
copy_one_pte(struct mm_struct *dst_mm, struct mm_struct *src_mm,
pte_t *dst_pte, pte_t *src_pte, struct vm_area_struct *vma,
unsigned long addr, int *rss)
{
/*
* If it's a COW mapping, write protect it both
* in the parent and the child
*/
if (is_cow_mapping(vm_flags) && pte_write(pte)) {
// 設置父進程的 pte 為只讀
ptep_set_wrprotect(src_mm, addr, src_pte);
// 設置子進程的 pte 為只讀
pte = pte_wrprotect(pte);
}
// 獲取 pte 中映射的物理內存頁(此時父子進程共享該頁)
page = vm_normal_page(vma, addr, pte);
// 物理內存頁的引用技術 + 1
get_page(page);
}
static inline bool is_cow_mapping(vm_flags_t flags)
{
// vma 是私有可寫的
return (flags & (VM_SHARED | VM_MAYWRITE)) == VM_MAYWRITE;
}
現在父子進程擁有了一模一樣的地址空間,頁表是一樣的,頁表中的 pte 均指向同一個物理內存頁面,對於私有的物理內存頁來説,父子進程的相關 pte 此時均變為了只讀的,私有物理內存頁的引用計數為 2 。而對於共享的物理內存頁來説,內核就只是簡單的將父進程的 pte 拷貝到子進程頁表中即可,然後將子進程 pte 中的髒頁標記清除,其他的不做改變。
當父進程或者子進程對該頁面發生寫操作的時候,我們現在假設子進程先對頁面發生寫操作,隨後子進程發現自己頁表中的 pte 是隻讀的,於是就會產生寫保護類型的缺頁中斷,由於子進程頁表中的 pte 不為空,所以會進入到 do_wp_page 函數中處理。
由於現在子進程和父子進程頁表中的相關 pte 指向的均是同一個物理內存頁,內核在 do_wp_page 函數中會發現這個物理內存頁的引用計數大於 1,存在多進程共享的情況,所以就會觸發寫時複製,這一過程在 wp_page_copy 函數中處理。
在 wp_page_copy 函數中,內核會首先為子進程分配一個新的物理內存頁 new_page,然後調用 cow_user_page 將原有內存頁 old_page 中的內容全部拷貝到新內存頁中。
創建一個臨時的頁表項 entry,然後讓 entry 指向新的內存頁,將 entry 重新設置為可寫,通過 set_pte_at_notify 將 entry 值設置到子進程頁表中的 pte 上。最後將原有內存頁 old_page 的引用計數減 1 。
static vm_fault_t wp_page_copy(struct vm_fault *vmf)
{
// 缺頁地址 address 所在 vma
struct vm_area_struct *vma = vmf->vma;
// 當前進程地址空間
struct mm_struct *mm = vma->vm_mm;
// 原來映射的物理內存頁,pte 為只讀
struct page *old_page = vmf->page;
// 用於寫時複製的新內存頁
struct page *new_page = NULL;
// 寫時複製之後,需要修改原來的 pte,這裏是臨時構造的一個 pte 值
pte_t entry;
// 是否發生寫時複製
int page_copied = 0;
// 如果 pte 原來映射的是一個零頁
if (is_zero_pfn(pte_pfn(vmf->orig_pte))) {
// 新申請一個零頁出來,內存頁中的內容被零初始化
new_page = alloc_zeroed_user_highpage_movable(vma,
vmf->address);
if (!new_page)
goto oom;
} else {
// 新申請一個物理內存頁
new_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma,
vmf->address);
if (!new_page)
goto oom;
// 將原來內存頁 old page 中的內容拷貝到新內存頁 new page 中
cow_user_page(new_page, old_page, vmf->address, vma);
}
// 給頁表加鎖,並重新獲取 address 在頁表中對應的 pte
vmf->pte = pte_offset_map_lock(mm, vmf->pmd, vmf->address, &vmf->ptl);
// 判斷加鎖前的 pte (orig_pte)與加鎖後的 pte (vmf->pte)是否相同
// 目的是判斷此時是否有其他線程正在併發修改 pte
if (likely(pte_same(*vmf->pte, vmf->orig_pte))) {
if (old_page) {
// 更新進程常駐內存信息 rss_state
if (!PageAnon(old_page)) {
// 減少 MM_FILEPAGES 計數
dec_mm_counter_fast(mm,
mm_counter_file(old_page));
// 由於發生寫時複製,這裏匿名頁個數加 1
inc_mm_counter_fast(mm, MM_ANONPAGES);
}
} else {
inc_mm_counter_fast(mm, MM_ANONPAGES);
}
// 將舊的 tlb 緩存刷出
flush_cache_page(vma, vmf->address, pte_pfn(vmf->orig_pte));
// 創建一個臨時的 pte 映射到新內存頁 new page 上
entry = mk_pte(new_page, vma->vm_page_prot);
// 設置 entry 為可寫的,正是這裏, pte 的權限由只讀變為了可寫
entry = maybe_mkwrite(pte_mkdirty(entry), vma);
// 為新的內存頁建立反向映射關係
page_add_new_anon_rmap(new_page, vma, vmf->address, false);
// 將新的內存頁加入到 LRU active 鏈表中
lru_cache_add_active_or_unevictable(new_page, vma);
// 將 entry 值重新設置到子進程頁表 pte 中
set_pte_at_notify(mm, vmf->address, vmf->pte, entry);
// 更新 mmu
update_mmu_cache(vma, vmf->address, vmf->pte);
if (old_page) {
// 將原來的內存頁從當前進程的反向映射關係中解除
page_remove_rmap(old_page, false);
}
/* Free the old page.. */
new_page = old_page;
page_copied = 1;
} else {
mem_cgroup_cancel_charge(new_page, memcg, false);
}
// 釋放頁表鎖
pte_unmap_unlock(vmf->pte, vmf->ptl);
if (old_page) {
// 舊內存頁的引用計數減 1
put_page(old_page);
}
return page_copied ? VM_FAULT_WRITE : 0;
}
現在子進程處理完了,下面我們再來看當父進程發生寫入操作的時候會發生什麼 ?
首先和子進程一樣,現在父進程頁表中的相關 pte 仍然是隻讀的,訪問這段虛擬內存地址依然會產生寫保護類型的缺頁中斷,和子進程不同的是,此時父進程 pte 中指向的原有物理內存頁 old_page 的引用計數已經變為 1 了,説明父進程是獨佔的,複用原來的 old_page 即可,不必進行寫時複製,只是簡單的將父進程頁表中的相關 pte 改為可寫就行了。
static inline void wp_page_reuse(struct vm_fault *vmf)
__releases(vmf->ptl)
{
struct vm_area_struct *vma = vmf->vma;
struct page *page = vmf->page;
pte_t entry;
// 先將 tlb cache 中緩存的 address 對應的 pte 刷出緩存
flush_cache_page(vma, vmf->address, pte_pfn(vmf->orig_pte));
// 將原來 pte 的 access 位置 1 ,表示該 pte 映射的物理內存頁是活躍的
entry = pte_mkyoung(vmf->orig_pte);
// 將原來只讀的 pte 改為可寫的,並標記為髒頁
entry = maybe_mkwrite(pte_mkdirty(entry), vma);
// 將更新後的 entry 值設置到頁表 pte 中
if (ptep_set_access_flags(vma, vmf->address, vmf->pte, entry, 1))
// 更新 mmu
update_mmu_cache(vma, vmf->address, vmf->pte);
pte_unmap_unlock(vmf->pte, vmf->ptl);
}
理解了上面的核心內容,我們再來看 do_wp_page 的處理邏輯就很清晰了:
static vm_fault_t do_wp_page(struct vm_fault *vmf)
__releases(vmf->ptl)
{
struct vm_area_struct *vma = vmf->vma;
// 獲取 pte 映射的物理內存頁
vmf->page = vm_normal_page(vma, vmf->address, vmf->orig_pte);
...... 省略處理特殊映射相關邏輯 ....
// 物理內存頁為匿名頁的情況
if (PageAnon(vmf->page)) {
...... 省略處理 ksm page 相關邏輯 ....
// reuse_swap_page 判斷匿名頁的引用計數是否為 1
if (reuse_swap_page(vmf->page, &total_map_swapcount)) {
// 如果當前物理內存頁的引用計數為 1 ,並且只有當前進程在引用該物理內存頁
// 則不做寫時複製處理,而是複用當前物理內存頁,只是將 pte 改為可寫即可
wp_page_reuse(vmf);
return VM_FAULT_WRITE;
}
unlock_page(vmf->page);
} else if (unlikely((vma->vm_flags & (VM_WRITE|VM_SHARED)) ==
(VM_WRITE|VM_SHARED))) {
// 處理共享可寫的內存頁
// 由於大家都可寫,所以這裏也只是調用 wp_page_reuse 複用當前內存頁即可,不做寫時複製處理
// 由於是共享的,對於文件頁來説是可以回寫到磁盤上的,所以會額外調用一次 fault_dirty_shared_page 判斷是否進行髒頁的回寫
return wp_page_shared(vmf);
}
copy:
// 走到這裏表示當前物理內存頁的引用計數大於 1 被多個進程引用
// 對於私有可寫的虛擬內存區域來説,就要發生寫時複製
// 而對於私有文件頁的情況來説,不必判斷內存頁的引用計數
// 因為是私有文件頁,不管文件頁的引用計數是不是 1 ,都要進行寫時複製
return wp_page_copy(vmf);
}
10. do_swap_page 處理 swap 缺頁異常
如果在遍歷進程頁表的時候發現,虛擬內存地址 address 對應的頁表項 pte 不為空,但是 pte 中第 0 個比特位置為 0 ,則表示該 pte 之前是被物理內存映射過的,只不過後來被內核 swap out 出去了。
我們需要的物理內存頁不在內存中反而在磁盤中,現在我們就需要將物理內存頁從磁盤中 swap in 進來。但在 swap in 之前內核需要知道該物理內存頁的內容被保存在磁盤的什麼位置上。
筆者在之前文章《一步一圖帶你構建 Linux 頁表體系》 中的第 4.2.1 小節中詳細介紹了 64 位頁表項 pte 的比特位佈局,以及各個比特位的含義。
typedef unsigned long pteval_t;
typedef struct { pteval_t pte; } pte_t;
64 位的 pte 主要用來表示物理內存頁的地址以及相關的權限標識位,但是當物理內存頁不在內存中的時候,這些比特位就沒有了任何意義。我們何不將這些已經沒有任何意義的比特位利用起來,在物理內存頁被 swap out 到磁盤上的時候,將物理內存頁在磁盤上的位置保存在這些比特位中。本質上還利用的是之前 pte 中的那 64 個比特,為了區別 swap 的場景,內核使用了一個新的結構體 swp_entry_t 來包裝。
typedef struct {
unsigned long val;
} swp_entry_t;
swap in 的首要任務就是先要從進程頁表中將這個 swp_entry_t 讀取出來,然後從 swp_entry_t 中解析出內存頁在 swap 交換區中的位置,根據磁盤位置信息將內存頁的內容讀取到內存中。由於產生了新的物理內存頁,所以就要創建新的 pte 來映射這個物理內存頁,然後將新的 pte 設置到頁表中,替換原來的 swp_entry_t。
這裏筆者需要為大家解釋的第一個問題就是 —— 這個 swp_entry_t 究竟是長什麼樣子 的,它是如何保存 swap 交換區相關位置信息的 ?
10.1 交換區的佈局及其組織結構
要明白這個,我們就需要先了解一下 swap 交換區(swap area)的佈局,swap 交換區共有兩種類型,一種是 swap 分區(swap partition),另一種是 swap 文件(swap file)。
swap partition 可以認為是一個沒有文件系統的裸磁盤分區,分區中的磁盤塊在磁盤中是連續分佈的。
swap file 可以認為是在某個現有的文件系統上,創建的一個定長的普通文件,專門用於保存匿名頁被 swap 出來的內容。背後的磁盤塊是不連續的。
Linux 系統中可以允許多個這樣的 swap 交換區存在,我們可以同時使用多個交換區,也可以為這些交換區指定優先級,優先級高的會被內核優先使用。這些交換區都可以被靈活地添加,刪除,而不需要重啓系統。多個交換區可以分散在不同的磁盤設備上,這樣可以實現硬件的並行訪問。
在使用交換區之前,我們可以通過 mkswap 首先創建一個交換區出來,如果我們創建的是 swap partition,則在 mkswap 命令後面直接指定分區的設備文件名稱即可。
mkswap /dev/sdb7
如果我們創建的是 swap file,則需要額外先使用 dd 命令在現有文件系統中創建出一個定長的文件出來。比如下面通過 dd 命令從 /dev/zero 中拷貝創建一個 /swapfile 文件,大小為 4G。
dd if=/dev/zero of=/swapfile bs=1M count=4096
然後使用 mkswap 命令創建 swap file :
mkswap /swapfile
當 swap partition 或者 swap file 創建好之後,我們通過 swapon 命令來初始化並激活這個交換區。
swapon /swapfile
當前系統中各個交換區的情況,我們可以通過 cat /proc/swaps 或者 swapon -s 命令產看:
交換區在內核中使用 struct swap_info_struct 結構體來表示,系統中眾多的交換區被組織在一個叫做 swap_info 的數組中,數組中的最大長度為 MAX_SWAPFILES,MAX_SWAPFILES 在內核中是一個常量,一般指定為 32,也就是説,系統中最大允許 32 個交換區存在。
struct swap_info_struct *swap_info[MAX_SWAPFILES];
由於交換區是有優先級的,所以內核又會按照優先級高低,將交換區組織在一個叫做 swap_avail_heads 的雙向鏈表中。
static struct plist_head *swap_avail_heads;
swap_info_struct 結構用於描述單個交換區中的各種信息:
/*
* The in-memory structure used to track swap areas.
*/
struct swap_info_struct {
// 用於表示該交換區的狀態,比如 SWP_USED 表示正在使用狀態,SWP_WRITEOK 表示交換區是可寫的狀態
unsigned long flags; /* SWP_USED etc: see above */
// 交換區的優先級
signed short prio; /* swap priority of this type */
// 指向該交換區在 swap_avail_heads 鏈表中的位置
struct plist_node list; /* entry in swap_active_head */
// 該交換區在 swap_info 數組中的索引
signed char type; /* strange name for an index */
// 該交換區可以容納 swap 的匿名頁總數
unsigned int pages; /* total of usable pages of swap */
// 已經 swap 到該交換區的匿名頁總數
unsigned int inuse_pages; /* number of those currently in use */
// 如果該交換區是 swap partition 則指向該磁盤分區的塊設備結構 block_device
// 如果該交換區是 swap file 則指向文件底層依賴的塊設備結構 block_device
struct block_device *bdev; /* swap device or bdev of swap file */
// 指向 swap file 的 file 結構
struct file *swap_file; /* seldom referenced */
};
而在每個交換區 swap area 內部又會分為很多連續的 slot (槽),每個 slot 的大小剛好和一個物理內存頁的大小相同都是 4K,物理內存頁在被 swap out 到交換區時,就會存放在 slot 中。
交換區中的這些 slot 會被組織在一個叫做 swap_map 的數組中,數組中的索引就是 slot 在交換區中的 offset (這個位置信息很重要),數組中的值表示該 slot 總共被多少個進程同時引用。
什麼意思呢 ? 比如現在系統中一共有三個進程同時共享一個物理內存頁(內存中的概念),當這個物理內存頁被 swap out 到交換區上時,就變成了 slot (內存頁在交換區中的概念),現在物理內存頁沒了,這三個共享進程就只能在各自的頁表中指向這個 slot,因此該 slot 的引用計數就是 3,對應在數組 swap_map 中的值也是 3 。
交換區中的第一個 slot 用於存儲交換區的元信息,比如交換區對應底層各個磁盤塊的壞塊列表。因此筆者將其標註了紅色,表示不能使用。
swap_map 數組中的值表示的就是對應 slot 被多少個進程同時引用,值為 0 表示該 slot 是空閒的,下次 swap out 的時候首先查找的就是空閒 slot 。 查找範圍就是 lowest_bit 到 highest_bit 之間的 slot。當查找到空閒 slot 之後,就會將整個物理內存頁回寫到這個 slot 中。
struct swap_info_struct {
unsigned char *swap_map; /* vmalloc'ed array of usage counts */
unsigned int lowest_bit; /* index of first free in swap_map */
unsigned int highest_bit; /* index of last free in swap_map */
但是這裏會有一個問題就是交換區面向的是整個系統,而系統中會有很多進程,如果多個進程併發進行 swap 的時候,swap_map 數組就會面臨併發操作的問題,這樣一來就不得不需要一個全局鎖來保護,但是這也導致了多個 CPU 只能串行訪問,大大降低了併發度。
那怎麼辦呢 ? 想想 JDK 中的 ConcurrentHashMap,將鎖分段唄,這樣可以將鎖競爭分散開來,大大提升併發度。
內核會將 swap_map 數組中的這些 slot,按照常量 SWAPFILE_CLUSTER 指定的個數,256 個 slot 分為一個 cluster。
#define SWAPFILE_CLUSTER 256
每個 cluster 中包含一把 spinlock_t 鎖,如果 cluster 是空閒的,那麼 swap_cluster_info 結構中的 data 指向下一個空閒的 cluster,如果 cluster 不是空閒的,那麼 data 保存的是該 cluster 中已經分配的 slot 個數。
struct swap_cluster_info {
spinlock_t lock; /*
* Protect swap_cluster_info fields
* and swap_info_struct->swap_map
* elements correspond to the swap
* cluster
*/
unsigned int data:24;
unsigned int flags:8;
};
#define CLUSTER_FLAG_FREE 1 /* This cluster is free */
#define CLUSTER_FLAG_NEXT_NULL 2 /* This cluster has no next cluster */
#define CLUSTER_FLAG_HUGE 4 /* This cluster is backing a transparent huge page */
這樣一來 swap_map 數組中的這些獨立的 slot,就被按照以 cluster 為單位重新組織了起來,這些 cluster 被串聯在 cluster_info 鏈表中。
為了進一步利用 cpu cache,以及實現無鎖化查找 slot,內核會給每個 cpu 分配一個 cluster —— percpu_cluster,cpu 直接從自己的 cluster 中查找空閒 slot,近一步提高了 swap out 的吞吐。
當 cpu 自己的 percpu_cluster 用盡之後,內核則會調用 swap_alloc_cluster 函數從 free_clusters 中獲取一個新的 cluster。
struct swap_info_struct {
struct swap_cluster_info *cluster_info; /* cluster info. Only for SSD */
struct swap_cluster_list free_clusters; /* free clusters list */
struct percpu_cluster __percpu *percpu_cluster; /* per cpu's swap location */
}
現在交換區的整體佈局筆者就為大家介紹完了,可能大家這裏有一點還是會比較困惑 —— 你説來説去,這個 slot 到底是個啥 ?
哈哈,大家先別急,我們現在已經對進程的虛擬內存空間非常熟悉了,這裏我們把交換區 swap_info_struct 與進程的內存空間 mm_struct 放到一起一對比就很清楚了。
首先進程虛擬內存空間中的虛擬內存別管説的如何天花亂墜,説到底還是要保存在真實的物理內存中的,虛擬內存與物理內存通過頁表來關聯起來。
同樣的道理,別管交換區佈局的如何天花亂墜,swap out 出來的數據説到底還是要保存在真實的磁盤中的,而交換區中是按照 slot 為單位進行組織管理的,磁盤中是按照磁盤塊來組織管理的,大小都是 4K 。
交換區中的 slot 就好比於虛擬內存空間中的虛擬內存,都是虛擬的概念,物理內存頁與磁盤塊才是真實本質的東西。
虛擬內存是連續的,但其背後映射的物理內存可能是不連續,交換區中的 slot 也都是連續的,但磁盤中磁盤塊的扇區地址卻不一定是連續的。頁表可以將不連續的物理內存映射到連續的虛擬內存上,內核也需要一種機制,將不連續的磁盤塊映射到連續的 slot 中。
當我們使用 swapon 命令來初始化激活交換區時,內核會掃描交換區中各個磁盤塊的扇區地址,以確定磁盤塊與扇區的對應關係,然後蒐集扇區地址連續的磁盤塊,將這些連續的磁盤塊組成一個塊組,slot 就會一個一個的映射到這些塊組上,塊組之間的扇區地址是不連續的,但是 slot 是連續的。
slot 與連續的磁盤塊組的映射關係保存在 swap_extent 結構中:
/*
* A swap extent maps a range of a swapfile's PAGE_SIZE pages onto a range of
* disk blocks. A list of swap extents maps the entire swapfile. (Where the
* term `swapfile' refers to either a blockdevice or an IS_REG file. Apart
* from setup, they're handled identically.
*
* We always assume that blocks are of size PAGE_SIZE.
*/
struct swap_extent {
// 紅黑樹節點
struct rb_node rb_node;
// 塊組內,第一個映射的 slot 編號
pgoff_t start_page;
// 映射的 slot 個數
pgoff_t nr_pages;
// 塊組內第一個磁盤塊
sector_t start_block;
};
由於一個塊組內的磁盤塊都是連續的,slot 本來又是連續的,所以 swap_extent 結構中只需要保存映射到該塊組內第一個 slot 的編號 (start_page),塊組內第一個磁盤塊在磁盤上的塊號,以及磁盤塊個數就可以了。
虛擬內存頁類比 slot,物理內存頁類比磁盤塊,這裏的 swap_extent 可以看做是虛擬內存區域 vma,進程的虛擬內存空間正是由一段一段的 vma 組成,這些 vma 被組織在一顆紅黑樹上。
交換區也是一樣,它是由一段一段的 swap_extent 組成,同樣也會被組織在一顆紅黑樹上。我們可以通過 slot 在交換區中的 offset,在這顆紅黑樹中快速查找出 slot 背後對應的磁盤塊。
struct swap_info_struct {
struct rb_root swap_extent_root;/* root of the swap extent rbtree */
現在交換區內部的樣子,我們已經非常清楚了,有了這些背景知識之後,我們在回過頭來看本小節最開始提出的問題 —— swp_entry_t 到底長什麼樣子。
10.2 一睹 swp_entry_t 真容
匿名內存頁在被內核 swap out 到磁盤上之後,內存頁中的內容保存在交換區的 slot 中,在 swap in 的場景中,內核需要根據 swp_entry_t 裏的信息找到這個 slot,進而找到其對應的磁盤塊,然後從磁盤塊中讀取出被 swap out 出去的內容。
這個就和交換區的佈局有很大的關係,首先系統中存在多個交換區,這些交換區被內核組織在 swap_info 數組中。
struct swap_info_struct *swap_info[MAX_SWAPFILES];
我們首先需要知道匿名內存頁到底被 swap out 到哪個交換區裏了,所以 swp_entry_t 裏必須包含交換區在 swap_info 數組中的索引,而這個索引正是 swap_info_struct 結構中的 type 字段。
struct swap_info_struct {
// 該交換區在 swap_info 數組中的索引
signed char type;
}
在確定了交換區的位置後,我們需要知道匿名頁被 swap out 到交換區中的哪個 slot 中,所以 swp_entry_t 中也必須包含 slot 在交換區中的 offset,這個 offset 就是 swap_info_struct 結構裏 slot 所在 swap_map 數組中的下標。
struct swap_info_struct {
unsigned char *swap_map;
}
所以總結下來 swp_entry_t 中需要包含以下三種信息:
第一, swp_entry_t 需要標識該頁表項是一個 pte 還是 swp_entry_t,因為它倆本質上是一樣的,都是 unsigned long 類型的無符號整數,是可以相互轉換的。
#define __pte_to_swp_entry(pte) ((swp_entry_t) { pte_val(pte) })
#define __swp_entry_to_pte(swp) ((pte_t) { (swp).val })
第 0 個比特位置 1 表示是一個 pte,背後映射的物理內存頁存在於內存中。如果第 0 個比特位置 0 則表示該 pte 背後映射的物理內存頁已經被 swap out 出去了,那麼它就是一個 swp_entry_t,指向內存頁在交換區中的位置。
第二,swp_entry_t 需要包含被 swap 出去的匿名頁所在交換區的索引 type,第 2 個比特位到第 7 個比特位,總共使用 6 個比特來表示匿名頁所在交換區的索引。
第三,swp_entry_t 需要包含匿名頁所在 slot 的位置 offset,第 8 個比特位到第 57 個比特位,總共 50 個比特來表示匿名頁對應的 slot 在交換區的 offset 。
/*
* Encode and decode a swap entry:
* bits 0-1: present (must be zero)
* bits 2-7: swap type
* bits 8-57: swap offset
* bit 58: PTE_PROT_NONE (must be zero)
*/
#define __SWP_TYPE_SHIFT 2
#define __SWP_TYPE_BITS 6
#define __SWP_OFFSET_BITS 50
#define __SWP_OFFSET_SHIFT (__SWP_TYPE_BITS + __SWP_TYPE_SHIFT)
內核提供了宏 __swp_type 用於從 swp_entry_t 中將匿名頁所在交換區編號提取出來,還提供了宏 __swp_offset 用於從 swp_entry_t 中將匿名頁所在 slot 的 offset 提取出來。
#define __swp_type(x) (((x).val >> __SWP_TYPE_SHIFT) & __SWP_TYPE_MASK)
#define __swp_offset(x) (((x).val >> __SWP_OFFSET_SHIFT) & __SWP_OFFSET_MASK)
#define __SWP_TYPE_MASK ((1 << __SWP_TYPE_BITS) - 1)
#define __SWP_OFFSET_MASK ((1UL << __SWP_OFFSET_BITS) - 1)
有了這兩個宏之後,我們就可以根據 swp_entry_t 輕鬆地定位到匿名頁在交換區中的位置了。
內核首先會通過 swp_type 從 swp_entry_t 提取出匿名頁所在的交換區索引 type,根據 type 就可以從 swap_info 數組中定位到交換區數據結構 swap_info_struct 。
內核將定位交換區 swap_info_struct 結構的邏輯封裝在 swp_swap_info 函數中:
struct swap_info_struct *swp_swap_info(swp_entry_t entry)
{
return swap_type_to_swap_info(swp_type(entry));
}
static struct swap_info_struct *swap_type_to_swap_info(int type)
{
return READ_ONCE(swap_info[type]);
}
得到了交換區的 swap_info_struct 結構,我們就可以獲取交換區所在磁盤分區底層的塊設備 —— swap_info_struct->bdev。
struct swap_info_struct {
// 如果該交換區是 swap partition 則指向該磁盤分區的塊設備結構 block_device
// 如果該交換區是 swap file 則指向文件底層依賴的塊設備結構 block_device
struct block_device *bdev; /* swap device or bdev of swap file */
}
最後通過 swp_offset 定位匿名頁所在 slot 在交換區中的 offset, 然後利用 offset 在紅黑樹 swap_extent_root 中查找其對應的 swap_extent。
struct swap_info_struct {
struct rb_root swap_extent_root;/* root of the swap extent rbtree */
}
前面我們提到過 swap file 背後所在的磁盤塊不一定是連續的,而 swap file 中的 slot 卻是連續的,內核需要用 swap_extent 結構來描述 slot 與磁盤塊的映射關係。
所以對於 swap file 來説,我們找到了 swap_extent 也就確定了 slot 對應的磁盤塊了。
static sector_t map_swap_entry(swp_entry_t entry, struct block_device **bdev)
{
struct swap_info_struct *sis;
struct swap_extent *se;
pgoff_t offset;
// 通過 swap_info[swp_type(entry)] 獲取交換區 swap_info_struct 結構
sis = swp_swap_info(entry);
// 獲取交換區所在磁盤分區塊設備
*bdev = sis->bdev;
// 獲取匿名頁在交換區的偏移
offset = swp_offset(entry);
// 通過 offset 到紅黑樹 swap_extent_root 中查找對應的 swap_extent
se = offset_to_swap_extent(sis, offset);
// 獲取 slot 對應的磁盤塊
return se->start_block + (offset - se->start_page);
}
而 swap partition 是一個沒有文件系統的裸磁盤分區,其背後的磁盤塊都是連續分佈的,所以對於 swap partition 來説,slot 與磁盤塊是直接映射的,我們獲取到 slot 的 offset 之後,在乘以一個固定的偏移 2 ^ PAGE_SHIFT - 9 跳過用於存儲交換區元信息的 swap header ,就可以直接獲得磁盤塊了。
這裏有點像 《深入理解 Linux 虛擬內存管理》 一文中提到的內核虛擬內存空間中的直接映射區,虛擬內存與物理內存都是直接映射的,通過虛擬內存地址減去一個固定的偏移直接就可以獲得物理內存地址了。
static sector_t swap_page_sector(struct page *page)
{
return (sector_t)__page_file_index(page) << (PAGE_SHIFT - 9);
}
pgoff_t __page_file_index(struct page *page)
{
// 在 swap 場景中,swp_entry_t 的值會設置到 page 結構中的 private 字段中
// 具體什麼時候設置的,我們這裏先不管,後面會説
swp_entry_t swap = { .val = page_private(page) };
return swp_offset(swap);
}
以上介紹的就是內核在 swap file 和 swap partition 場景下,如何獲取 slot 對應的磁盤塊 sector_t 的邏輯與實現。
有了 sector_t,內核接着就會利用 bdev_read_page 函數將 slot 對應在 sector 中的內容讀取到物理內存頁 page 中,這就是整個 swap in 的過程。
/**
* bdev_read_page() - Start reading a page from a block device
* @bdev: The device to read the page from
* @sector: The offset on the device to read the page to (need not be aligned)
* @page: The page to read
*/
int bdev_read_page(struct block_device *bdev, sector_t sector,
struct page *page)
swap_readpage 函數負責將匿名頁中的內容從交換區中讀取到物理內存頁中來,這裏也是 swap in 的核心實現:
int swap_readpage(struct page *page, bool synchronous)
{
struct bio *bio;
int ret = 0;
struct swap_info_struct *sis = page_swap_info(page);
blk_qc_t qc;
struct gendisk *disk;
// 處理交換區是 swap file 的情況
if (sis->flags & SWP_FS) {
// 從交換區中獲取交換文件 swap_file
struct file *swap_file = sis->swap_file;
// swap_file 本質上還是文件系統中的一個文件,所以它也會有 page cache
struct address_space *mapping = swap_file->f_mapping;
// 利用 page cache 中的 readpage 方法,從 swap_file 所在的文件系統中讀取匿名頁內容到 page 中。
// 注意這裏只是利用 page cache 的 readpage 方法從文件系統中讀取數據,內核並不會把 page 加入到 page cache 中
// 這裏 swap_file 和普通文件的讀取過程是不一樣的,page cache 不緩存內存頁。
// 對於 swap out 的場景來説,內核也只是利用 page cache 的 writepage 方法將匿名頁的內容寫入到 swap_file 中。
ret = mapping->a_ops->readpage(swap_file, page);
if (!ret)
count_vm_event(PSWPIN);
return ret;
}
// 如果交換區是 swap partition,則直接從磁盤塊中讀取
// 對於 swap out 的場景,內核調用 bdev_write_page,直接將匿名頁的內容寫入到磁盤塊中
ret = bdev_read_page(sis->bdev, swap_page_sector(page), page);
out:
return ret;
}
swap_readpage 是內核 swap 機制的最底層實現,直接和磁盤打交道,負責搭建磁盤與內存之間的橋樑。雖然直接調用 swap_readpage 可以基本完成 swap in 的目的,但在某些特殊情況下會導致 swap 的性能非常糟糕。
比如下圖所示,假設當前系統中存在三個進程,它們共享引用了同一個物理內存頁 page。
當這個被共享的 page 被內核 swap out 到交換區之後,三個共享進程的頁表會發生如下變化:
當 進程1 開始讀取這個共享 page 的時候,由於 page 已經 swap out 到交換區了,所以會發生 swap 缺頁異常,進入內核通過 swap_readpage 將共享 page 的內容從磁盤中讀取進內存,此時三個進程的頁表結構變為下圖所示:
現在共享 page 已經被 進程1 swap in 進來了,但是 進程2 和 進程 3 是不知道的,它們的頁表中還儲存的是 swp_entry_t,依然指向 page 所在交換區的位置。
按照之前的邏輯,當 進程2 以及 進程3 開始讀取這個共享 page 的時候,其實 page 已經在內存了,但是它們此刻感知不到,因為 進程2 和 進程3 的頁表中存儲的依然是 swp_entry_t,還是會產生 swap 缺頁中斷,重新通過 swap_readpage 讀取交換區中的內容,這樣一來就產生了額外重複的磁盤 IO。
除此之外,更加嚴重的是,由於 進程2 和 進程3 的 swap 缺頁,又會產生兩個新的內存頁用來存放從 swap_readpage 中讀取進來的交換區數據。
產生了重複的磁盤 IO 不説,還產生了額外的內存消耗,並且這樣一來,三個進程對內存頁就不是共享的了。
還有一種極端場景是一個進程試圖讀取一個正在被 swap out 的 page ,由於 page 正在被內核 swap out,此時進程頁表指向該 page 的 pte 已經變成了 swp_entry_t。
進程在這個時候訪問 page 的時候,還是會產生 swap 缺頁異常,進程試圖 swap in 這個正在被內核 swap out 的 page,但是此時 page 仍然還在內存中,只不過是正在被內核刷盤。
而按照之前的 swap in 邏輯,進程這裏會調用 swap_readpage 從磁盤中讀取,產生額外的磁盤 IO 以及內存消耗不説,關鍵是此刻 swap_readpage 出來的數據都不是完整的,這肯定是個大問題。
內核為了解決上面提到的這些問題,因此引入了一個新的結構 —— swap cache 。
10.3 swap cache
有了 swap cache 之後,情況就會變得大不相同,我們在回過頭來看第一個問題 —— 多進程共享內存頁。
進程1 在 swap in 的時候首先會到 swap cache 中去查找,看看是否有其他進程已經把內存頁 swap in 進來了,如果 swap cache 中沒有才會調用 swap_readpage 從磁盤中去讀取。
當內核通過 swap_readpage 將內存頁中的內容從磁盤中讀取進內存之後,內核會把這個匿名頁先放入 swap cache 中。進程 1 的頁表將原來的 swp_entry_t 填充為 pte 並指向 swap cache 中的這個內存頁。
由於進程1 頁表中對應的頁表項現在已經從 swp_entry_t 變為 pte 了,指向的是 swap cache 中的內存頁而不是 swap 交換區,所以對應 slot 的引用計數就要減 1 。
還記得我們之前介紹的 swap_map 數組嗎 ?slot 被進程引用的計數就保存在這裏,現在這個 slot 在 swap_map 數組中保存的引用計數從 3 變成了 2 。表示還有兩個進程也就是 進程2 和 進程3 仍在繼續引用這個 slot 。
當進程2 發生 swap 缺頁中斷的時候進入內核之後,也是首先會到 swap cache 中查找是否現在已經有其他進程把共享的內存頁 swap in 進來了,內存頁 page 在 swap cache 的索引就是頁表中的 swp_entry_t。由於這三個進程共享的同一個內存頁,所以三個進程頁表中的 swp_entry_t 都是相同的,都是指向交換區的同一位置。
由於共享內存頁現在已經被 進程1 swap in 進來了,並存放在 swap cache 中,所以 進程2 通過 swp_entry_t 一下就在 swap cache 中找到了,同理,進程 2 的頁表也會將原來的 swp_entry_t 填充為 pte 並指向 swap cache 中的這個內存頁。slot 的引用計數減 1。
現在這個 slot 在 swap_map 數組中保存的引用計數從 2 變成了 1 。表示只有 進程3 在引用這個 slot 了。
當 進程3 發生 swap 缺頁中斷的之後,內核還是先通過 swp_entry_t 到 swap cache 中去查找,找到之後,將 進程 3 頁表原來的 swp_entry_t 填充為 pte 並指向 swap cache 中的這個內存頁,slot 的引用計數減 1。
現在 slot 的引用計數已經變為 0 了,這意味着所有共享該內存頁的進程已經全部知道了新內存頁的地址,它們的 pte 已經全部指向了新內存頁,不在指向 slot 了,此時內核便將這個內存頁從 swap cache 中移除。
針對第二個問題 —— 進程試圖 swap in 這個正在被內核 swap out 的 page,內核的處理方法也是一樣,內核在 swap out 的時候首先會在交換區中為這個 page 分配 slot 確定其在交換區的位置,然後通過之前文章 《深入理解 Linux 物理內存管理》 中
介紹的匿名頁反向映射機制找到所有引用該內存頁的進程,將它們頁表中的 pte 修改為指向 slot 的 swp_entry_t。
然後將匿名頁 page 先是放入到 swap cache 中,慢慢地通過 swap_writepage 回寫。當匿名頁被完全回寫到交換區中時,內核才會將 page 從 swap cache 中移除。
如果當內核正在回寫的過程中,不巧有一個進程又要訪問該內存頁,同樣也會發生 swap 缺頁中斷,但是由於此時沒有回寫完成,內存頁還保存在 swap cache 中,內核通過進程頁表中的 swp_entry_t 一下就在 swap cache 中找到了,避免了再次發生磁盤 IO,後面的過程就和第一個問題一樣了。
上述查找 swap cache 的過程。內核封裝在 __read_swap_cache_async 函數裏,在 swap in 的過程中,內核會首先調用這裏查看 swap cache 是否已經緩存了內存頁,如果沒有,則新分配一個內存頁並加入到 swap cache 中,最後才會調用 swap_readpage 從磁盤中將所需內容讀取到新內存頁中。
struct page *__read_swap_cache_async(swp_entry_t entry, gfp_t gfp_mask,
struct vm_area_struct *vma, unsigned long addr,
bool *new_page_allocated)
{
struct page *found_page = NULL, *new_page = NULL;
struct swap_info_struct *si;
int err;
// 是否分配新的內存頁,如果內存頁已經在 swap cache 中則無需分配
*new_page_allocated = false;
do {
// 獲取交換區結構 swap_info_struct
si = get_swap_device(entry);
// 首先根據 swp_entry_t 到 swap cache 中查找,內存頁是否已經被其他進程 swap in 進來了
found_page = find_get_page(swap_address_space(entry),
swp_offset(entry));
// swap cache 已經緩存了,就直接返回,不必啓動磁盤 IO
if (found_page)
break;
// 如果 swap cache 中沒有,則需要新分配一個內存頁
// 用來存儲從交換區中 swap in 進來的內容
if (!new_page) {
new_page = alloc_page_vma(gfp_mask, vma, addr);
if (!new_page)
break; /* Out of memory */
}
// swap 沒有完成時,內存頁需要加鎖,禁止訪問
__SetPageLocked(new_page);
__SetPageSwapBacked(new_page);
// 將新的內存頁先放入 swap cache 中
// 在這裏會將 swp_entry_t 設置到 page 結構的 private 屬性中
err = add_to_swap_cache(new_page, entry, gfp_mask & GFP_KERNEL);
} while (err != -ENOMEM);
return found_page;
}
前面我們提到,Linux 系統中同時允許多個交換區存在,內核將這些交換區組織在 swap_info 數組中。
struct swap_info_struct *swap_info[MAX_SWAPFILES];
內核會為系統中每一個交換區分配一個 swap cache,被內核組織在一個叫做 swapper_spaces 的數組中。交換區的 swap cache 在 swapper_spaces 數組中的索引也是 swp_entry_t 中存儲的 type 信息,通過 swp_type 來提取。
// 一個交換區對應一個 swap cache
struct address_space *swapper_spaces[MAX_SWAPFILES] __read_mostly;
這裏我們可以看到,交換區的 swap cache 和文件的 page cache 一樣,都是 address_space 結構來描述的,而對於 swap file 來説,因為它本質上是文件系統裏的一個文件,所以 swap file 既有 swap cache 也有 page cache 。
這裏大家需要區分 swap file 的 swap cache 和 page cache,前面在介紹 swap_readpage 函數的時候,筆者也提過,swap file 的 page cache 在 swap 的場景中是不會緩存內存頁的,內核只是利用 page cache 相關的操作函數 —— address_space->a_ops ,從 swap file 所在的文件系統中讀取或者寫入匿名頁,匿名頁是不會加入到 page cache 中的。
而交換區是針對整個系統來説的,系統中會存在很多進程,當發生 swap 的時候,系統中的這些進程會對同一個 swap cache 進行爭搶,所以為了近一步提高 swap 的並行度,內核會將一個交換區中的 swap cache 分裂多個出來,將競爭的壓力分散開來。
這樣一來,一個交換就演變出多個 swap cache 出來,swapper_spaces 數組其實是一個 address_space 結構的二維數組。每個 swap cache 能夠管理的匿名頁個數為 2^SWAP_ADDRESS_SPACE_SHIFT 個,涉及到的內存大小為 4K * SWAP_ADDRESS_SPACE_PAGES —— 64M。
/* One swap address space for each 64M swap space */
#define SWAP_ADDRESS_SPACE_SHIFT 14
#define SWAP_ADDRESS_SPACE_PAGES (1 << SWAP_ADDRESS_SPACE_SHIFT)
通過一個給定的 swp_entry_t 查找對應的 swap cache 的邏輯,內核定義在 swap_address_space 宏中。
- 首先內核通過 swp_type 提取交換區在 swapper_spaces 數組中的索引(一維索引)。
- 通過 swp_offset >> SWAP_ADDRESS_SPACE_SHIFT(二維索引),定位 slot 具體歸哪一個 swap cache 管理。
#define swap_address_space(entry) \
(&swapper_spaces[swp_type(entry)][swp_offset(entry) \
>> SWAP_ADDRESS_SPACE_SHIFT])
struct page * lookup_swap_cache(swp_entry_t entry)
{
struct swap_info_struct *si = get_swap_device(entry);
// 通過 swp_entry_t 定位 swap cache
// 根據 swp_offset 在 swap cache 中查找內存頁
page = find_get_page(swap_address_space(entry), swp_offset(entry));
return page;
}
當我們通過 swapon 命令來初始化並激活一個交換區的時候,內核會在 init_swap_address_space 函數中為交換區初始化 swap cache。
int init_swap_address_space(unsigned int type, unsigned long nr_pages)
{
struct address_space *spaces, *space;
unsigned int i, nr;
// 計算交換區包含的 swap cache 個數
nr = DIV_ROUND_UP(nr_pages, SWAP_ADDRESS_SPACE_PAGES);
// 為交換區分配 address_space 數組,用於存放多個 swap cache
spaces = kvcalloc(nr, sizeof(struct address_space), GFP_KERNEL);
// 挨個初始化交換區中的 swap cache
for (i = 0; i < nr; i++) {
space = spaces + i;
// 將 a_ops 指定為 swap_aops
space->a_ops = &swap_aops;
/* swap cache doesn't use writeback related tags */
// swap cache 不會回寫
mapping_set_no_writeback_tags(space);
}
// 保存交換區中的 swap cache 個數
nr_swapper_spaces[type] = nr;
// 將初始化好的 address_space 數組放入 swapper_spaces 數組中(二維數組)
swapper_spaces[type] = spaces;
return 0;
}
// 交換區中的 swap cache 個數
static unsigned int nr_swapper_spaces[MAX_SWAPFILES] __read_mostly;
struct address_space *swapper_spaces[MAX_SWAPFILES] __read_mostly;
這裏我們可以看到,對於 swap cache 來説,內核會將 address_space-> a_ops 初始化為 swap_aops。
static const struct address_space_operations swap_aops = {
.writepage = swap_writepage,
.set_page_dirty = swap_set_page_dirty,
#ifdef CONFIG_MIGRATION
.migratepage = migrate_page,
#endif
};
10.4 swap 預讀
現在我們已經清楚了當進程虛擬內存空間中的某一段 vma 發生 swap 缺頁異常之後,內核的 swap in 核心處理流程。但是整個完整的 swap 流程還沒有結束,內核還需要考慮內存訪問的空間局部性原理。
當進程訪問某一段內存的時候,在不久之後,其附近的內存地址也將被訪問。對應於本小節的 swap 場景來説,當進程地址空間中的某一個虛擬內存地址 address 被訪問之後,那麼其周圍的虛擬內存地址在不久之後,也會被進程訪問。
而那些相鄰的虛擬內存地址,在進程頁表中對應的頁表項也都是相鄰的,當我們處理完了缺頁地址 address 的 swap 缺頁異常之後,如果其相鄰的頁表項均是 swp_entry_t,那麼這些相鄰的 swp_entry_t 所指向交換區的內容也需要被內核預讀進內存中。
這樣一來,當 address 附近的虛擬內存地址發生 swap 缺頁的時候,內核就可以直接從 swap cache 中讀到了,避免了磁盤 IO,使得 swap in 可以快速完成,這裏和文件的預讀機制有點類似。
swap 預讀在 Linux 內核中由 swapin_readahead 函數負責,它有兩種實現方式:
第一種是根據缺頁地址 address 周圍的虛擬內存地址進行預讀,但前提是它們必須屬於同一個 vma,這個邏輯在 swap_vma_readahead 函數中完成。
第二種是根據內存頁在交換區中周圍的磁盤地址進行預讀,但前提是它們必須屬於同一個交換區,這個邏輯在 swap_cluster_readahead 函數中完成。
struct page *swapin_readahead(swp_entry_t entry, gfp_t gfp_mask,
struct vm_fault *vmf)
{
return swap_use_vma_readahead() ?
swap_vma_readahead(entry, gfp_mask, vmf) :
swap_cluster_readahead(entry, gfp_mask, vmf);
}
在本小節介紹的 swap 缺頁場景中,內核是按照缺頁地址周圍的虛擬內存地址進行預讀的。在函數 swap_vma_readahead 的開始,內核首先調用 swap_ra_info 方法來計算本次需要預讀的頁表項集合。
預讀的最大頁表項個數由 page_cluster 決定,但最大不能超過 2 ^ SWAP_RA_ORDER_CEILING。
#ifdef CONFIG_64BIT
#define SWAP_RA_ORDER_CEILING 5
// 最大預讀窗口
max_win = 1 << min_t(unsigned int, READ_ONCE(page_cluster),
SWAP_RA_ORDER_CEILING);
page_cluster 的值可以通過內核參數 /proc/sys/vm/page-cluster 來調整,默認值為 3,我們可以通過設置 page_cluster = 0來禁止 swap 預讀。
當要 swap in 的內存頁在交換區的位置已經接近末尾了,則需要減少預讀頁的個數,防止預讀超出交換區的邊界。
如果預讀的頁表項不是 swp_entry_t,則説明該頁表項是一個空的還沒有進行過映射或者頁表項指向的內存頁還在內存中,這種情況下則跳過,繼續預讀後面的 swp_entry_t。
/**
* swap_vma_readahead - swap in pages in hope we need them soon
* @entry: swap entry of this memory
* @gfp_mask: memory allocation flags
* @vmf: fault information
*
* Returns the struct page for entry and addr, after queueing swapin.
*
* Primitive swap readahead code. We simply read in a few pages whoes
* virtual addresses are around the fault address in the same vma.
*
* Caller must hold read mmap_sem if vmf->vma is not NULL.
*
*/
static struct page *swap_vma_readahead(swp_entry_t fentry, gfp_t gfp_mask,
struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf->vma;
struct vma_swap_readahead ra_info = {0,};
// 獲取本次要進行預讀的頁表項
swap_ra_info(vmf, &ra_info);
// 遍歷預讀窗口 ra_info 中的頁表項,挨個進行預讀
for (i = 0, pte = ra_info.ptes; i < ra_info.nr_pte;
i++, pte++) {
// 獲取要進行預讀的頁表項
pentry = *pte;
// 頁表項為空,表示還未進行內存映射,直接跳過
if (pte_none(pentry))
continue;
// 頁表項指向的內存頁仍然在內存中,跳過
if (pte_present(pentry))
continue;
// 將 pte 轉換為 swp_entry_t
entry = pte_to_swp_entry(pentry);
if (unlikely(non_swap_entry(entry)))
continue;
// 利用 swp_entry_t 先到 swap cache 中去查找
// 如果沒有,則新分配一個內存頁並添加到 swap cache 中,這種情況下 page_allocated = true
// 如果有,則直接從swap cache 中獲取內存頁,也就不需要預讀了,page_allocated = false
page = __read_swap_cache_async(entry, gfp_mask, vma,
vmf->address, &page_allocated);
if (page_allocated) {
// 發生磁盤 IO,從交換區中讀取內存頁的內容到新分配的 page 中
swap_readpage(page, false);
}
}
}
這樣一來,經過 swap_vma_readahead 預讀之後,缺頁內存地址 address 周圍的頁表項所指向的內存頁就全部被加載到 swap cache 中了。
當進程下次訪問 address 周圍的內存地址時,雖然也會發生 swap 缺頁異常,但是內核直接從 swap cache 中就可以讀取到了,避免了磁盤 IO。
10.5 還原 do_swap_page 完整面貌
當我們明白了前面介紹的這些背景知識之後,再回過頭來看內核完整的 swap in 過程就很清晰了
- 首先內核會通過 pte_to_swp_entry 將進程頁表中的 pte 轉換為 swp_entry_t
- 通過 lookup_swap_cache 根據 swp_entry_t 到 swap cache 中查找是否已經有其他進程將內存頁 swap 進來了。
- 如果 swap cache 沒有對應的內存頁,則調用 swapin_readahead 啓動預讀,在這個過程中,內核會重新分配物理內存頁,並將這個物理內存頁加入到 swap cache 中,隨後通過 swap_readpage 將交換區的內容讀取到這個內存頁中。
- 現在我們需要的內存頁已經 swap in 到內存中了,後面的流程就和普通的缺頁處理一樣了,根據 swap in 進來的內存頁地址重新創建初始化一個新的 pte,然後用這個新的 pte,將進程頁表中原來的 swp_entry_t 替換掉。
- 為新的內存頁建立反向映射關係,加入 lru active list 中,最後 swap_free 釋放交換區中的資源。
vm_fault_t do_swap_page(struct vm_fault *vmf)
{
// 將缺頁內存地址 address 對應的 pte 轉換為 swp_entry_t
entry = pte_to_swp_entry(vmf->orig_pte);
// 首先利用 swp_entry_t 到 swap cache 查找,看內存頁已經其他進程被 swap in 進來
page = lookup_swap_cache(entry, vma, vmf->address);
swapcache = page;
// 處理匿名頁不在 swap cache 的情況
if (!page) {
// 通過 swp_entry_t 獲取對應的交換區結構
struct swap_info_struct *si = swp_swap_info(entry);
// 針對 fast swap storage 比如 zram 等 swap 的性能優化,跳過 swap cache
if (si->flags & SWP_SYNCHRONOUS_IO &&
__swap_count(entry) == 1) {
/* skip swapcache */
// 當只有單進程引用這個匿名頁的時候,直接跳過 swap cache
// 從夥伴系統中申請內存頁 page,注意這裏的 page 並不會加入到 swap cache 中
page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma,
vmf->address);
if (page) {
__SetPageLocked(page);
__SetPageSwapBacked(page);
set_page_private(page, entry.val);
// 加入 lru 鏈表
lru_cache_add_anon(page);
// 直接從 fast storage device 中讀取被換出的內容到 page 中
swap_readpage(page, true);
}
} else {
// 啓動 swap 預讀
page = swapin_readahead(entry, GFP_HIGHUSER_MOVABLE,
vmf);
swapcache = page;
}
// 因為涉及到了磁盤 IO,所以本次缺頁異常屬於 FAULT_MAJOR 類型
ret = VM_FAULT_MAJOR;
count_vm_event(PGMAJFAULT);
count_memcg_event_mm(vma->vm_mm, PGMAJFAULT);
}
// 現在之前被換出的內存頁已經被內核重新 swap in 到內存中了。
// 下面就是重新設置 pte,將原來頁表中的 swp_entry_t 替換掉
vmf->pte = pte_offset_map_lock(vma->vm_mm, vmf->pmd, vmf->address,
&vmf->ptl);
// 增加匿名頁的統計計數
inc_mm_counter_fast(vma->vm_mm, MM_ANONPAGES);
// 減少 swap entries 計數
dec_mm_counter_fast(vma->vm_mm, MM_SWAPENTS);
// 根據被 swap in 進來的新內存頁重新創建 pte
pte = mk_pte(page, vma->vm_page_prot);
// 用新的 pte 替換掉頁表中的 swp_entry_t
set_pte_at(vma->vm_mm, vmf->address, vmf->pte, pte);
vmf->orig_pte = pte;
// 建立新內存頁的反向映射關係
do_page_add_anon_rmap(page, vma, vmf->address, exclusive);
// 將內存頁添加到 lru 的 active list 中
activate_page(page);
// 釋放交換區中的資源
swap_free(entry);
// 刷新 mmu cache
update_mmu_cache(vma, vmf->address, vmf->pte);
return ret;
}
總結
本文我們介紹了 Linux 內核如何通過缺頁中斷將進程頁表從 0 到 1 一步一步的完整構建出來。從進程虛擬內存空間佈局的角度來講,缺頁中斷主要分為兩個方面:
- 內核態缺頁異常處理 —— do_kern_addr_fault,這裏主要是處理 vmalloc 虛擬內存區域的缺頁異常,其中涉及到主內核頁表與進程頁表內核部分的同步問題。
- 用户態缺頁異常處理 —— do_user_addr_fault,其中涉及到的主內容是如何從 0 到 1 一步一步構建完善進程頁表體系。
總體上來講引起缺頁中斷的原因分為兩大類:
- 第一類是缺頁虛擬內存地址背後映射的物理內存頁不在內存中
- 第二類是缺頁虛擬內存地址背後映射的物理內存頁在內存中。
第一類缺頁中斷的原因涉及到三種場景:
- 缺頁虛擬內存地址 address 在進程頁表中間頁目錄對應的頁目錄項 pmd_t 是空的。
- 缺頁地址 address 對應的 pmd_t 雖然不是空的,頁表也存在,但是 address 對應在頁表中的 pte 是空的。
- 虛擬內存地址 address 在進程頁表中的頁表項 pte 不是空的,但是其背後映射的物理內存頁被內核 swap out 到磁盤上了。
第二類缺頁中斷的原因涉及到兩種場景:
- NUMA Balancing。
- 寫時複製了(Copy On Write, COW)。
最後我們介紹了內核整個 swap in 的完整過程,其中涉及到的重要內容包括交換區的佈局以及在內核中的組織結構,swap cache 與 page cache 之間的區別,swap 預讀機制。
好了,今天的內容到這裏就結束了,感謝大家的收看,我們下篇文章見~~~~