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

来源:https://www.cnblogs.com/binlovetech/archive/2023/12/21/17918733.html
-Advertisement-
Play Games

本文基於內核 5.4 版本源碼討論 在前面兩篇介紹 mmap 的文章中,筆者分別從原理角度以及源碼實現角度帶著大家深入到內核世界深度揭秘了 mmap 記憶體映射的本質。從整個 mmap 映射的過程可以看出,內核只是在進程的虛擬地址空間中尋找出一段空閑的虛擬記憶體區域 vma 然後分配給本次映射而已。 v ...


本文基於內核 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;

image

如果是文件映射的話,內核還會額外做一項工作,就是將分配出來的這段虛擬記憶體區域 vma 與映射文件關聯映射起來。

vma->vm_file = get_file(file);
error = call_mmap(file, vma);

映射的核心就是將虛擬記憶體區域 vm_area_struct 相關的記憶體操作 vma->vm_ops 設置為文件系統的相關操作 ext4_file_vm_ops。這樣一來,進程後續對這段虛擬記憶體的讀寫就相當於是讀寫映射文件了。

image

無論是匿名映射還是文件映射,內核在處理 mmap 映射過程中貌似都是在進程的虛擬地址空間中和虛擬記憶體打交道,僅僅只是為 mmap 映射分配出一段虛擬記憶體而已,整個映射過程我們並沒有看到物理記憶體的身影。

那麼大家所關心的物理記憶體到底是什麼時候映射進來的呢 ?這就是今天本文要討論的主題 —— 缺頁中斷。

image

1. 缺頁中斷產生的原因

如下圖所示,當 mmap 系統調用成功返回之後,內核只是為進程分配了一段 [vm_start , vm_end] 範圍內的虛擬記憶體區域 vma ,由於還未與物理記憶體發生關聯,所以此時進程頁表中與 mmap 映射的虛擬記憶體相關的各級頁目錄和頁表項還都是空的。

image

當 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 的前六個比特位來描述引起缺頁異常的具體原因,後面比特位的含義我們先暫時忽略。

image

P(0) : 如果 error_code 第 0 個比特位置為 0 ,表示該缺頁異常是由於 CPU 訪問的這個虛擬記憶體地址 address 背後並沒有一個物理記憶體頁與之映射而引起的,站在進程頁表的角度來說,就是 CPU 訪問的這個虛擬記憶體地址 address 在進程四級頁表體系中對應的各級頁目錄項或者頁表項是空的(頁目錄項或者頁表項中的 P 位為 0 )。

image

如果 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 訪問虛擬記憶體開始的,既然提到了虛擬記憶體,我們就不得不回顧一下進程虛擬記憶體空間的佈局:

image

在 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 中將虛擬記憶體地址與物理記憶體地址映射起來。

image

而內核通過 vmalloc 記憶體分配介面在 vmalloc 映射區申請記憶體的時候,首先也會在 32T 大小的 vmalloc 映射區中劃分出一段未被使用的虛擬記憶體區域出來,我們暫且叫這段虛擬記憶體區域為 vmalloc 區,這一點和前面文章介紹的 mmap 非常相似,只不過 mmap 工作在用戶空間的文件與匿名映射區,vmalloc 工作在內核空間的 vmalloc 映射區。

內核空間中的 vmalloc 映射區就是由這樣一段一段的 vmalloc 區組成的,每調用一次 vmalloc 記憶體分配介面,就會在 vmalloc 映射區中映射出一段 vmalloc 虛擬記憶體區域,而且每個 vmalloc 區之間隔著一個 4K 大小的 guard page(虛擬記憶體),用於防止記憶體越界,將這些非連續的物理記憶體區域隔離起來。

image

和 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 則是數組的大小,也表示該虛擬記憶體區域包含的物理記憶體頁個數。

image

在內核中所有的這些 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;          
};

image

看起來和用戶空間中虛擬記憶體區域的組織形式越來越像了,不同的是由於用戶空間是進程間隔離的,所以組織用戶空間虛擬記憶體區域的紅黑樹以及雙向鏈表是進程獨占的。

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 相關頁目錄項以及頁表項的創建。

image

大家需要註意的是,這裡的內核頁表指的是內核主頁表,內核主頁表的頂級頁目錄起始地址存放在 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 記憶體分配介面修改完內核主頁表之後,主頁表中的相關頁目錄項以及頁表項的內容就發生了改變,而這背後的一切,進程現在還被蒙在鼓裡,一無所知,此時,進程頁表中的內核部分相關的頁目錄項以及頁表項還都是空的。

image

當進程陷入內核態訪問這部分頁表的的時候,會發現相關頁目錄或者頁表項是空的,就會進入缺頁中斷的內核處理部分,也就是前面提到的 vmalloc_fault 函數中,如果發現缺頁的虛擬地址在內核主頁表頂級全局頁目錄表中對應的頁目錄項 pgd 存在,而缺頁地址在進程頁表內核部分對應的 pgd 不存在,那麼內核就會把內核主頁表中 pgd 頁目錄項里的內容複製給進程頁表內核部分中對應的 pgd。

image

事實上,同步內核主頁表的工作只需要將缺頁地址對應在內核主頁表中的頂級全局頁目錄項 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 是一個還未被分配的虛擬記憶體地址,進程對該地址的訪問是非法的,自然也就不用處理缺頁了。

image

所以內核就需要根據缺頁地址 address 通過 find_vma 函數在進程地址空間中找出符合 address < vma->vm_end 條件的第一個 vma 出來,也就是挨著 address 最近的一個 vma。

image

而缺頁地址 address 可以出現在進程地址空間中的任意位置,根據 address 的分佈會有下麵三種情況:

第一種情況就是 address 的後面沒有一個 vma 出現,也就是說進程地址空間中沒有一個 vma 符合條件:address < vma->vm_end。進程訪問的是一個還未分配的虛擬記憶體地址,屬於非法地址訪問,不需要處理缺頁。

image

第二種情況就是 address 恰巧包含在一個 vma 中,這個自然是正常情況,內核開始處理該 vma 區域的缺頁異常。

image

第三種情況是 address 不巧落在了 find_vma 的前面,也就是 address < find_vma->vm_start。這種情況自然也是非法地址訪問,不需要處理缺頁。

image

但是這裡有一種特殊情況就是萬一這個 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 中緩存的文件頁之間的關聯。

image

由於進程 1 的缺頁處理髮生了物理記憶體的分配以及磁碟 IO ,所以本次缺頁處理屬於 VM_FAULT_MAJOR。

當進程 2 訪問其地址空間中映射的這段虛擬記憶體時,由於頁表是空的,也會發生缺頁,但是當進程 2 進入內核中發現所映射的文件頁已經被進程 1 載入進 page cache 中了,進程 2 的缺頁處理只需要將這個文件頁映射進自己的頁表就可以了,不需要重新分配記憶體以及發生磁碟 IO 。這種情況就屬於 VM_FAULT_MINOR。

image

最後需要將進程總共發生的 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 次數。

image

5. handle_mm_fault 完善進程頁表體系

饒了一大圈,現在我們終於來到了缺頁處理的核心邏輯,之前筆者提到,引起缺頁中斷的原因大概有三種:

  • 第一種是 CPU 訪問的虛擬記憶體地址 address 之前完全沒有被映射過,其在頁表中對應的各級頁目錄項以及頁表項都還是空的。

  • 第二種是 address 之前被映射過,但是映射的這塊物理記憶體被內核 swap out 到磁碟上了。

  • 第三種是 address 背後映射的物理記憶體還在,只是由於訪問許可權不夠引起的缺頁中斷,比如,後面要為大家介紹的寫時複製(COW)機制就屬於這一種。

下麵筆者一種接一種的帶大家一起梳理,我們先來看第一種情況:

image

由於現在正在被訪問的虛擬記憶體地址 address 之前從來沒有被映射過,所以該虛擬記憶體地址在進程頁表中的各級頁目錄表中的目錄項以及頁表中的頁表項都是空的。內核的首要任務就是先要將這些缺失的頁目錄項和頁表項一一補齊。

筆者在之前的文章《一步一圖帶你構建 Linux 頁表體系》 中曾為大家介紹過,在當前 64 位體系架構下,其實只使用了 48 位來描述進程的虛擬記憶體空間,其中用戶態地址空間 128T,內核態地址空間 128T,所以我們只需要使用 48 位的虛擬記憶體地址就可以表示進程虛擬記憶體空間中的任意地址了。

而這 48 位的虛擬記憶體地址內又分為五個部分,它們分別是虛擬記憶體地址在全局頁目錄表 PGD 中對應的頁目錄項 pgd_t 的偏移,在上層頁目錄表 PUD 中對應的頁目錄項 pud_t 的偏移,在中間頁目錄表 PMD 中對應的頁目錄項 pmd_t 的偏移,在頁表中對應的頁表項 pte_t 的偏移,以及在其背後映射的物理記憶體頁中的偏移。

image

內核中使用 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)了。

image

接下來的任務就是根據這個 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 頁表體系》 一文中介紹的頁目錄項比特位佈局嗎 ?

image

在頁目錄項剛剛被創建出來的時候,內核會將他們全部初始化為 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。

image

/*
 * 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 值,所謂填充頁目錄項就是按照下圖所示的頁目錄項比特位佈局,根據每個比特位的具體含義進行相應的填充。

image

由於頁目錄項所承擔的一項最重要的工作就是定位其下一級頁目錄表的起始物理記憶體地址,這裡的下一級頁目錄表就是剛剛我們新創建出來的 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,並且擁有了初始的許可權位。

image

目前為止,我們只是完善了缺頁虛擬記憶體地址對應在進程頁表頂級頁目錄中的目錄項 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。

image

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;
}

然後再根據頁目錄項中的比特位佈局,將其下一級頁目錄表的物理記憶體地址截取出來。

image

那麼如何截取呢 ? 上圖中展示的頁目錄項比特位佈局筆者是按照 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 位。

image

這低 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 的物理記憶體地址截取出來了。

image

雖然我們是按照 52 位的物理記憶體地址截取的,但是對於 36 位的物理記憶體地址來說,頁目錄項中的低 36 位到 51 位之間的比特位都是 0 值,所以也不影響。

static inline unsigned long p4d_page_vaddr(p4d_t p4d)
{
    return (unsigned long)__va(p4d_val(p4d) & p4d_pfn_mas

您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 優化內容 這篇不聊技術點,說一下優化後的Python機器人代碼怎麼使用,優化內容如下: 將hook庫獨立成一個庫,發佈到pypi,可使用pip安裝 將微信相關的代碼發佈成另一個庫,也可以pip安裝 git倉庫統一,以後都在這個倉庫更新,不再一篇文章一個倉庫 開始建群,根據群里反饋增加功能和修複bug ...
  • 數據的預處理是數據分析,或者機器學習訓練前的重要步驟。通過數據預處理,可以 提高數據質量,處理數據的缺失值、異常值和重覆值等問題,增加數據的準確性和可靠性 整合不同數據,數據的來源和結構可能多種多樣,分析和訓練前要整合成一個數據集 提高數據性能,對數據的值進行變換,規約等(比如無量綱化),讓演算法更加 ...
  • Qt 是一個跨平臺C++圖形界面開發庫,利用Qt可以快速開發跨平臺窗體應用程式,在Qt中我們可以通過拖拽的方式將不同組件放到指定的位置,實現圖形化開發極大的方便了開發效率,本章將重點介紹`QStyledItemDelegate`自定義代理組件的常用方法及靈活運用。在Qt中,`QStyledItemD... ...
  • 一:背景 1. 講故事 前段時間有位朋友找到我,說他們的程式出現了CPU爆高,讓我幫忙看下怎麼回事?這種問題好的辦法就是抓個dump丟給我,推薦的工具就是用 procdump 自動化抓捕。 二:Windbg 分析 1. CPU 真的爆高嗎 還是老規矩,要想找到這個答案,可以使用 !tp 命令。 0: ...
  • Lock、Monitor線程鎖 官網使用 https://learn.microsoft.com/zh-cn/dotnet/api/system.threading.monitor?view=net-8.0 一. Lock 1.1介紹 Lock關鍵字實際上是一個語法糖,它將Monitor對象進行封裝 ...
  • 前言 國產資料庫作為國產化替代的重要環節,在我國信創產業政策的指引下實現加速發展,我們國產資料庫已進入百花齊放的快速發展期,相信接觸到政府類等項目的童鞋尤為瞭解,與此同時我們有一部分也在使用各種開源的ORM都早已支持主流國產資料庫,我們也有一部分在使用官方EF Core但沒有對國產資料庫的統一的管理 ...
  • Quartz.NET 是一個強大的開源作業調度庫,提供了許多高級功能。以下是 Quartz.NET 的常用高級功能: Cron 表達式觸發器: 使用 Cron 表達式定義靈活的調度規則,實現複雜的時間調度策略。 作業依賴性: 允許定義作業之間的依賴關係,確保它們按照特定的順序執行。 作業執行中的數據 ...
  • git教程 代碼托管平臺:git.acwing.com 1 git基本概念 工作區:倉庫的目錄。工作區是獨立於各個分支的。 暫存區:數據暫時存放的區域,類似於工作區寫入版本庫前的緩存區。暫存區是獨立於各個分支的。切換分支不會新創建暫存區。 版本庫:存放所有已經提交到本地倉庫的代碼版本 版本結構:樹結 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...