一文聊透 Linux 缺頁異常的處理 —— 圖解 Page Faults

2023-12-21 15:01:38

本文基於核心 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 存取的這段虛擬記憶體背後沒有實體記憶體與之對映,表現的具體形式主要有三種:

  1. 虛擬記憶體對應在程序頁表體系中的相關各級頁目錄或者頁表是空的,也就是說這段虛擬記憶體完全沒有被對映過。

  2. 虛擬記憶體之前被對映過,其在程序頁表的各級頁目錄以及頁表中均有對應的頁目錄項和頁表項,但是其對應的實體記憶體被核心 swap out 到磁碟上了。

  3. 虛擬記憶體雖然背後對映著實體記憶體,但是由於對實體記憶體的存取許可權不夠而導致的保護型別的缺頁中斷。比如,嘗試去寫一個唯讀的實體記憶體頁。

雖然缺頁中斷產生的原因多種多樣,核心也會根據不同的缺頁原因進行不同的處理,但不管怎麼說,一切的起點都是從 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(&current->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 宏中。

  1. 首先核心通過 swp_type 提取交換區在 swapper_spaces 陣列中的索引(一維索引)。

  2. 通過 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 過程就很清晰了

  1. 首先核心會通過 pte_to_swp_entry 將程序頁表中的 pte 轉換為 swp_entry_t

  2. 通過 lookup_swap_cache 根據 swp_entry_t 到 swap cache 中查詢是否已經有其他程序將記憶體頁 swap 進來了。

  3. 如果 swap cache 沒有對應的記憶體頁,則呼叫 swapin_readahead 啟動預讀,在這個過程中,核心會重新分配實體記憶體頁,並將這個實體記憶體頁加入到 swap cache 中,隨後通過 swap_readpage 將交換區的內容讀取到這個記憶體頁中。

  4. 現在我們需要的記憶體頁已經 swap in 到記憶體中了,後面的流程就和普通的缺頁處理一樣了,根據 swap in 進來的記憶體頁地址重新建立初始化一個新的 pte,然後用這個新的 pte,將程序頁表中原來的 swp_entry_t 替換掉。

  5. 為新的記憶體頁建立反向對映關係,加入 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 一步一步構建完善程序頁表體系。

總體上來講引起缺頁中斷的原因分為兩大類:

  • 第一類是缺頁虛擬記憶體地址背後對映的實體記憶體頁不在記憶體中

  • 第二類是缺頁虛擬記憶體地址背後對映的實體記憶體頁在記憶體中。

第一類缺頁中斷的原因涉及到三種場景:

  1. 缺頁虛擬記憶體地址 address 在程序頁表中間頁目錄對應的頁目錄項 pmd_t 是空的。

  2. 缺頁地址 address 對應的 pmd_t 雖然不是空的,頁表也存在,但是 address 對應在頁表中的 pte 是空的。

  3. 虛擬記憶體地址 address 在程序頁表中的頁表項 pte 不是空的,但是其背後對映的實體記憶體頁被核心 swap out 到磁碟上了。

第二類缺頁中斷的原因涉及到兩種場景:

  1. NUMA Balancing。

  2. 寫時複製了(Copy On Write, COW)。

最後我們介紹了核心整個 swap in 的完整過程,其中涉及到的重要內容包括交換區的佈局以及在核心中的組織結構,swap cache 與 page cache 之間的區別,swap 預讀機制。

好了,今天的內容到這裡就結束了,感謝大家的收看,我們下篇文章見~~~~