<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          頁(yè)表是什么?被字節(jié)問(wèn)懵了!

          共 48376字,需瀏覽 97分鐘

           ·

          2023-07-28 07:51

          圖解學(xué)習(xí)網(wǎng)站:xiaolincoding.com


          虛擬內(nèi)存和物理內(nèi)存的關(guān)系,在騰訊、字節(jié)面試中很喜歡問(wèn),很多同學(xué)都很難理解頁(yè)表是什么?今天就跟大家專門嘮一嘮。


          《一步一圖帶你構(gòu)建 Linux 頁(yè)表體系 —— 詳解虛擬內(nèi)存如何與物理內(nèi)存進(jìn)行映射》

          虛擬內(nèi)存其實(shí)是 CPU 和操作系統(tǒng)使用的一個(gè)障眼法,聯(lián)手給進(jìn)程編織了一個(gè)假象,讓進(jìn)程誤以為自己獨(dú)占了全部的內(nèi)存空間:

          • 在 32 位系統(tǒng)中,進(jìn)程以為自己獨(dú)占了 3G 的內(nèi)存空間。
          32位系統(tǒng)中虛擬內(nèi)存空間整體布局.png
          • 在 64 位系統(tǒng)中,進(jìn)程以為自己獨(dú)占了 128T 的內(nèi)存空間。
          64位系統(tǒng)中虛擬內(nèi)存空間整體布局.png

          這么做的好處是,操作系統(tǒng)為每個(gè)進(jìn)程營(yíng)造出一片獨(dú)立的虛擬地址空間,使得進(jìn)程與進(jìn)程之間相互隔離,互不干擾的,解決了多進(jìn)程同時(shí)運(yùn)行時(shí)產(chǎn)生的內(nèi)存地址沖突問(wèn)題。

          image.png

          同時(shí)虛擬內(nèi)存還提供了系統(tǒng)安全方面的保障,會(huì)對(duì)進(jìn)程訪問(wèn)內(nèi)存的行為進(jìn)行相關(guān)的安全權(quán)限檢查,保障了系統(tǒng)的穩(wěn)定性和安全性。比如:

          • 有些物理內(nèi)存頁(yè)只允許內(nèi)核來(lái)訪問(wèn),進(jìn)程在用戶態(tài)的時(shí)候是無(wú)法訪問(wèn)的。

          • 虛擬內(nèi)存中保存了訪問(wèn)其映射的物理內(nèi)存相關(guān)的權(quán)限,進(jìn)程只能執(zhí)行規(guī)定權(quán)限范圍內(nèi)的訪存操作。比如,上面虛擬內(nèi)存空間里代碼段的權(quán)限是可讀,可執(zhí)行,但是不可寫。數(shù)據(jù)段具有可讀可寫的權(quán)限但是不可執(zhí)行。堆則具有可讀可寫,可執(zhí)行的權(quán)限(Java 中的字節(jié)碼存儲(chǔ)在堆中,所以需要可執(zhí)行權(quán)限),棧一般是可讀可寫的權(quán)限,一般很少有可執(zhí)行權(quán)限。而文件映射與匿名映射區(qū)存放了共享鏈接庫(kù),所以也需要可執(zhí)行的權(quán)限。

          image.png

          但是當(dāng)程序運(yùn)行起來(lái)之后,程序中所需要的數(shù)據(jù)本質(zhì)上還是保存在物理內(nèi)存中的,無(wú)論操作系統(tǒng)對(duì)虛擬內(nèi)存設(shè)計(jì)的多么精彩,最終虛擬內(nèi)存空間中每一個(gè)虛擬內(nèi)存地址都是要映射到物理內(nèi)存空間的中某一個(gè)特定物理內(nèi)存地址上的。

          進(jìn)程虛擬內(nèi)存空間中的每一個(gè)字節(jié)都有與其對(duì)應(yīng)的虛擬內(nèi)存地址,同樣物理內(nèi)存空間中每一個(gè)字節(jié)都有與其對(duì)應(yīng)的物理內(nèi)存地址。

          下面我們就來(lái)把舞臺(tái)上的桌布拿走,一起到內(nèi)核中探秘一下 CPU 和操作系統(tǒng)聯(lián)手編織的這個(gè)障眼戲法是如何玩轉(zhuǎn)起來(lái)的~~~~

          本文概覽.png

          1. 虛擬內(nèi)存如何與物理內(nèi)存映射起來(lái)

          《深入理解 Linux 物理內(nèi)存管理》一文中,筆者在介紹物理內(nèi)存管理的時(shí)候曾提到,內(nèi)核會(huì)將整個(gè)物理內(nèi)存空間劃分為一頁(yè)一頁(yè)大小相同的的內(nèi)存塊,每個(gè)內(nèi)存塊大小為 4K,稱為一個(gè)物理內(nèi)存頁(yè)。

          一頁(yè)大小的內(nèi)存塊在內(nèi)核中用 struct page 結(jié)構(gòu)體來(lái)進(jìn)行管理,struct page 中封裝了每頁(yè)內(nèi)存塊的狀態(tài)信息,比如:組織結(jié)構(gòu),使用信息,統(tǒng)計(jì)信息,以及與其他內(nèi)核結(jié)構(gòu)的關(guān)聯(lián)映射信息等。

          內(nèi)核會(huì)為每個(gè)物理內(nèi)存頁(yè) page 進(jìn)行統(tǒng)一編號(hào)。這個(gè)編號(hào)稱之為 PFN(Page Frame Number),PFN 與 struct page 是一一對(duì)應(yīng)的關(guān)系并且全局唯一

          然后內(nèi)核會(huì)將劃分出來(lái)的這些一頁(yè)一頁(yè)的內(nèi)存塊統(tǒng)一組織在一個(gè)全局?jǐn)?shù)組 mem_map 中管理。后續(xù)虛擬內(nèi)存與物理內(nèi)存的映射以及調(diào)度均是以頁(yè)為單位進(jìn)行的。

          image.png
          typedef struct pglist_data {
              // NUMA 節(jié)點(diǎn)id
              int node_id;
              // 指向 NUMA 節(jié)點(diǎn)內(nèi)管理所有物理頁(yè) page 的數(shù)組
              struct page *node_mem_map;
          }

          既然物理內(nèi)存是以頁(yè)為單位進(jìn)行管理,而虛擬內(nèi)存最終是要映射到物理內(nèi)存上的,所以在虛擬內(nèi)存空間中也有與之相對(duì)應(yīng)的虛擬頁(yè)這個(gè)概念,內(nèi)存的映射是以頁(yè)為單位進(jìn)行的。

          image.png

          如上圖所示,在內(nèi)存映射的場(chǎng)景中,虛擬內(nèi)存頁(yè)的類型總共分為以下三種:

          1. 第一種就是圖中灰色方框里標(biāo)注的未分配頁(yè)面,進(jìn)程的虛擬內(nèi)存空間是非常龐大的,遠(yuǎn)遠(yuǎn)的超過(guò)物理內(nèi)存空間,但這并不意味著進(jìn)程可以直接隨意使用虛擬內(nèi)存,事實(shí)上進(jìn)程對(duì)虛擬內(nèi)存的使用也是需要向內(nèi)核申請(qǐng)的。進(jìn)程虛擬內(nèi)存空間中的虛擬內(nèi)存頁(yè)在未被進(jìn)程申請(qǐng)之前的狀態(tài)就是未分配頁(yè)面。

          2. 第二種就是圖中紫色方框里標(biāo)注的已分配未映射頁(yè)面,我們?cè)谶M(jìn)程中可以通過(guò)動(dòng)態(tài)鏈接庫(kù) glic 中的 malloc 接口或者直接通過(guò)系統(tǒng)調(diào)用 mmap 向內(nèi)核申請(qǐng)?zhí)摂M內(nèi)存,申請(qǐng)到的虛擬內(nèi)存頁(yè)此時(shí)就變?yōu)榱艘逊峙涞捻?yè)面。但此時(shí)的虛擬內(nèi)存頁(yè)只是虛擬內(nèi)存,其背后并沒(méi)有與物理內(nèi)存映射起來(lái),所以稱為已分配未映射頁(yè)面。

          3. 第三種是圖中綠色方框里標(biāo)注的正常頁(yè)面,當(dāng)進(jìn)程開(kāi)始讀寫這些已分配未映射的虛擬內(nèi)存頁(yè)時(shí),在 CPU 中用于地址翻譯的硬件 MMU 會(huì)產(chǎn)生一個(gè)缺頁(yè)中斷,隨后內(nèi)核會(huì)為其分配相應(yīng)的物理內(nèi)存頁(yè)面,并將虛擬內(nèi)存頁(yè)與物理內(nèi)存頁(yè)映射起來(lái)。此時(shí)這些已分配未映射的虛擬內(nèi)存頁(yè)就變?yōu)榱?strong>正常頁(yè)面。從此以后,進(jìn)程就可以正常讀寫這些虛擬內(nèi)存頁(yè)了。

          MMU  負(fù)責(zé)將虛擬內(nèi)存地址翻譯為物理內(nèi)存地址,筆者后面會(huì)詳細(xì)介紹這個(gè)地址翻譯過(guò)程。

          明白了這些之后,我們?cè)賮?lái)看上面這副內(nèi)存映射圖,從圖中我們可以讀出以下幾種信息:

          1. 每個(gè)進(jìn)程獨(dú)占全部的虛擬內(nèi)存空間,比如上圖中,進(jìn)程 1 的虛擬內(nèi)存空間(藍(lán)色部分)和進(jìn)程 2 的虛擬內(nèi)存空間(黃色部分)它們都擁有屬于各自的虛擬內(nèi)存頁(yè)1 到虛擬內(nèi)存頁(yè) 7 這段范圍的虛擬內(nèi)存。也就是說(shuō)進(jìn)程1 和進(jìn)程 2 看到的虛擬內(nèi)存空間地址范圍都是一樣的。

          2. 每個(gè)進(jìn)程的虛擬內(nèi)存空間都是相互隔離,互不干擾的,進(jìn)程可以在屬于自己的虛擬內(nèi)存空間里隨意折騰。比如上圖中,進(jìn)程 1 里的虛擬內(nèi)存頁(yè) 1 是一個(gè)未分配頁(yè)面,而進(jìn)程 2 里的虛擬內(nèi)存頁(yè) 1 卻是一個(gè)正常頁(yè)面,被內(nèi)核映射到物理內(nèi)存頁(yè) 2 中。也就是說(shuō)雖然每個(gè)進(jìn)程擁有的虛擬內(nèi)存地址空間范圍是一樣的,但是各自虛擬內(nèi)存空間中的虛擬頁(yè)可能映射的物理頁(yè)不一樣,使用的方式和用途也不一樣。

          3. 進(jìn)程所看到的連續(xù)虛擬內(nèi)存,在物理內(nèi)存中有可能是不連續(xù)的,比如上圖中,進(jìn)程 1 里的虛擬頁(yè) 4 和 虛擬頁(yè) 5,它們?cè)谶M(jìn)程 1 的虛擬內(nèi)存空間中是連續(xù)的,但是它們背后映射的物理內(nèi)存頁(yè)卻是不連續(xù)的。虛擬內(nèi)存頁(yè) 4 被映射到了物理內(nèi)存頁(yè) 1 中,虛擬內(nèi)存頁(yè) 5 被映射到了物理內(nèi)存頁(yè) 4 中。

          4. 物理內(nèi)存空間中藍(lán)色部分是進(jìn)程 1 正在使用的內(nèi)存(物理頁(yè) 1,物理頁(yè) 4,物理頁(yè) 7),黃色部分是進(jìn)程 2 正在使用的內(nèi)存(物理頁(yè) 2,物理頁(yè) 3,物理頁(yè) 6)。這些復(fù)雜且瑣碎的內(nèi)存映射細(xì)節(jié)統(tǒng)統(tǒng)由內(nèi)存管理子系統(tǒng)進(jìn)行管理,從而極大的解放了程序員的心智負(fù)擔(dān)。

          現(xiàn)在讓我們把視角從進(jìn)程的虛擬內(nèi)存空間切換到內(nèi)核中的內(nèi)存管理系統(tǒng)中,來(lái)看一下內(nèi)核是如何管理這些內(nèi)存映射關(guān)系的。

          談到映射,我們自然會(huì)想到 Map 這個(gè)數(shù)據(jù)結(jié)構(gòu),那么虛擬內(nèi)存與物理內(nèi)存之間的映射關(guān)系如果用 Map 來(lái)表達(dá)的話,就是如下形式:

           Map<虛擬內(nèi)存,物理內(nèi)存>

          如果我們給上面那副圖加上 Map 映射關(guān)系的話,就演變成了這樣:

          image.png

          Map<虛擬內(nèi)存,物理內(nèi)存> 的映射關(guān)系在內(nèi)核中是被一個(gè)叫做頁(yè)表的東西來(lái)管理的,頁(yè)表除了管理虛擬內(nèi)存與物理內(nèi)存之間的映射關(guān)系之外,還會(huì)有一些訪問(wèn)權(quán)限的管理,來(lái)控制進(jìn)程對(duì)物理內(nèi)存的訪問(wèn)權(quán)限。

          由于進(jìn)程是獨(dú)占虛擬內(nèi)存空間的,而且不同進(jìn)程之間的虛擬內(nèi)存空間是相互隔離的,所以每個(gè)進(jìn)程也都會(huì)有屬于自己的頁(yè)表,來(lái)專門管理各自虛擬內(nèi)存空間中的映射關(guān)系以及各自訪問(wèn)物理內(nèi)存的權(quán)限。

          好了,現(xiàn)在我們已經(jīng)大概清楚了虛擬內(nèi)存與物理內(nèi)存映射的一個(gè)總體框架了,當(dāng)我們有了一個(gè)全局視角之后,下面我們就來(lái)深入到細(xì)節(jié)中,來(lái)看看內(nèi)核究竟如何通過(guò)一張頁(yè)表來(lái)管理這些內(nèi)存映射關(guān)系以及訪問(wèn)權(quán)限的。

          2. 內(nèi)核如何通過(guò)頁(yè)表來(lái)管理內(nèi)存映射關(guān)系

          我們都知道內(nèi)核對(duì)物理內(nèi)存的管理是按照頁(yè)為基本單位進(jìn)行的,進(jìn)程運(yùn)行起來(lái)所需要的數(shù)據(jù)也是存儲(chǔ)在一個(gè)一個(gè)的物理頁(yè)中,既然物理內(nèi)存頁(yè)可以存儲(chǔ)進(jìn)程的普通數(shù)據(jù),那么它也一定可以存儲(chǔ)進(jìn)程虛擬內(nèi)存與物理內(nèi)存之間的映射關(guān)系。

          事實(shí)上,內(nèi)核也是這么干的,內(nèi)核會(huì)從物理內(nèi)存空間中拿出一個(gè)物理內(nèi)存頁(yè)來(lái)專門存儲(chǔ)進(jìn)程里的這些內(nèi)存映射關(guān)系,而這種物理內(nèi)存頁(yè)我們將其稱之為頁(yè)表,從這里可以看出頁(yè)表的本質(zhì)其實(shí)就是一個(gè)物理內(nèi)存頁(yè)。

          而內(nèi)核會(huì)在頁(yè)表中劃分出來(lái)一個(gè)個(gè)大小相等的小內(nèi)存塊,這些小內(nèi)存塊我們稱之為頁(yè)表項(xiàng) PTE(Page Table Entry),正是這個(gè) PTE 保存了進(jìn)程虛擬內(nèi)存空間中的虛擬頁(yè)與物理內(nèi)存頁(yè)的映射關(guān)系,以及控制物理內(nèi)存訪問(wèn)的相關(guān)權(quán)限位。

          在 32 位系統(tǒng)中頁(yè)表中的 PTE 占用 4 個(gè)字節(jié),64 位系統(tǒng)中頁(yè)表的 PTE 占用 8 個(gè)字節(jié)。

          因?yàn)閮?nèi)存映射的粒度是按照頁(yè)為單位進(jìn)行的,所以進(jìn)程虛擬內(nèi)存空間中的每個(gè)虛擬頁(yè)在頁(yè)表中都會(huì)有一個(gè) PTE 與之對(duì)應(yīng),而虛擬頁(yè)背后映射的物理內(nèi)存頁(yè)的起始地址就保存在 PTE 中。

          image.png

          而進(jìn)程虛擬內(nèi)存空間中的每一個(gè)字節(jié)都有一個(gè)虛擬內(nèi)存地址來(lái)表示,格式為:頁(yè)表內(nèi)偏移 + 物理內(nèi)存頁(yè)內(nèi)偏移

          image.png

          因?yàn)樯衔囊呀?jīng)說(shuō)了,進(jìn)程虛擬內(nèi)存空間中的每一個(gè)虛擬頁(yè)在頁(yè)表中都會(huì)有一個(gè) PTE 與之對(duì)應(yīng),專門用來(lái)存儲(chǔ)該虛擬頁(yè)背后映射的物理內(nèi)存頁(yè)的起始地址。

          上述虛擬內(nèi)存地址格式中的 頁(yè)表內(nèi)偏移 就是專門用來(lái)定位虛擬內(nèi)存頁(yè)在頁(yè)表中的 PTE 的,因?yàn)轫?yè)表本質(zhì)其實(shí)還是一個(gè)物理內(nèi)存頁(yè),而一個(gè)物理內(nèi)存頁(yè)里邊的內(nèi)存肯定都是連續(xù)的,每個(gè) PTE 的尺寸又是相同的,所以我們可以把頁(yè)表看做一個(gè)數(shù)組,PTE 看做數(shù)組里的元素,在一個(gè)數(shù)組里定位元素,我們直接通過(guò)元素的索引 index 就可以定位了。這個(gè)索引 index 就是 頁(yè)表內(nèi)偏移

          這樣一來(lái),給定一個(gè)虛擬內(nèi)存地址,內(nèi)核會(huì)先從這個(gè)虛擬內(nèi)存地址中提取出 頁(yè)表內(nèi)偏移 ,然后根據(jù) 頁(yè)表起始地址 + 頁(yè)表內(nèi)偏移 * sizeof(PTE) 就能獲取到該虛擬內(nèi)存地址所在虛擬頁(yè)在頁(yè)表中對(duì)應(yīng)的 PTE 了。

          這里大家可能會(huì)有一個(gè)疑問(wèn),頁(yè)表內(nèi)偏移我們可以從虛擬內(nèi)存地址中獲取,那這個(gè)頁(yè)表起始地址我們?cè)搹哪睦铽@取呢 ?

          進(jìn)程的虛擬內(nèi)存空間在內(nèi)核中是用 struct mm_struct 結(jié)構(gòu)來(lái)描述的,每個(gè)進(jìn)程都有自己獨(dú)立的虛擬內(nèi)存空間,而進(jìn)程的虛擬內(nèi)存到物理內(nèi)存的映射也是獨(dú)立的,為了保證每個(gè)進(jìn)程里內(nèi)存映射的獨(dú)立進(jìn)行,所以每個(gè)進(jìn)程都會(huì)有獨(dú)立的頁(yè)表,而頁(yè)表的起始地址就存放在 struct mm_struct 結(jié)構(gòu)中的 pgd 屬性中。

          事實(shí)上,mm_struct->pgd 存放的是進(jìn)程的頂級(jí)頁(yè)表的起始地址,而為了讓大家清晰的理解整個(gè)內(nèi)存映射的過(guò)程,所以筆者在本小節(jié)中只討論單級(jí)頁(yè)表的情形,在這里單級(jí)頁(yè)表的語(yǔ)義就是頂級(jí)頁(yè)表。

          struct mm_struct {
            // 當(dāng)前進(jìn)程頂級(jí)頁(yè)表的起始地址
            pgd_t * pgd;
          }

          而進(jìn)程的頂級(jí)頁(yè)表起始地址 pgd 又是在什么時(shí)候被內(nèi)核設(shè)置進(jìn)去的呢?

          很顯然這個(gè)設(shè)置的時(shí)機(jī)是在進(jìn)程被創(chuàng)建出來(lái)的時(shí)候,當(dāng)我們使用 fork 系統(tǒng)調(diào)用創(chuàng)建進(jìn)程的時(shí)候,內(nèi)核在 _do_fork 函數(shù)中會(huì)通過(guò) copy_process 將父進(jìn)程的所有資源拷貝到子進(jìn)程中,這其中也包括父進(jìn)程的虛擬內(nèi)存空間。

          long _do_fork(unsigned long clone_flags,
                 unsigned long stack_start,
                 unsigned long stack_size,
                 int __user *parent_tidptr,
                 int __user *child_tidptr,
                 unsigned long tls)
          {
                        ......... 省略 ..........
               struct pid *pid;
               struct task_struct *p;

                        ......... 省略 ..........
              // 拷貝父進(jìn)程的所有資源
               p = copy_process(clone_flags, stack_start, stack_size,
                   child_tidptr, NULL, trace, tls, NUMA_NO_NODE);

                       ......... 省略 ..........
          }

          copy_process 函數(shù)開(kāi)始拷貝父進(jìn)程中的所有資源到子進(jìn)程中:

          static __latent_entropy struct task_struct *copy_process(
               unsigned long clone_flags,
               unsigned long stack_start,
               unsigned long stack_size,
               int __user *child_tidptr,
               struct pid *pid,
               int trace,
               unsigned long tls,
               int node)

          {

              struct task_struct *p;
              // 為進(jìn)程創(chuàng)建 task_struct 結(jié)構(gòu)
              p = dup_task_struct(current, node);

                  ....... 初始化子進(jìn)程 ...........

                  ....... 開(kāi)始拷貝父進(jìn)程資源  .......      

              // 拷貝父進(jìn)程的虛擬內(nèi)存空間以及頁(yè)表
              retval = copy_mm(clone_flags, p);

                  ......... 省略拷貝父進(jìn)程的其他資源 .........

              // 分配 CPU
              retval = sched_fork(clone_flags, p);
              // 分配 pid
              pid = alloc_pid(p->nsproxy->pid_ns_for_children);

                  ........... 省略 .........
          }

          copy_mm 函數(shù)負(fù)責(zé)處理子進(jìn)程虛擬內(nèi)存空間的初始化工作,它會(huì)調(diào)用 dup_mm 函數(shù),最終在 dup_mm 函數(shù)中將父進(jìn)程虛擬內(nèi)存空間的所有內(nèi)容包括父進(jìn)程的相關(guān)頁(yè)表全部拷貝到子進(jìn)程中,其中就包括了為子進(jìn)程分配頂級(jí)頁(yè)表起始地址 pgd。

          static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
          {
              ...... 省略 ........
              
              mm = dup_mm(tsk, current->mm);
              
              ...... 省略 ........
          }

          /**
           * Allocates a new mm structure and duplicates the provided @oldmm structure
           * content into it.
           */

          static struct mm_struct *dup_mm(struct task_struct *tsk,
              struct mm_struct *oldmm)

          {
               // 子進(jìn)程虛擬內(nèi)存空間,此時(shí)還是空的
               struct mm_struct *mm;
               int err;
               // 為子進(jìn)程申請(qǐng) mm_struct 結(jié)構(gòu)
               mm = allocate_mm();
               if (!mm)
                  goto fail_nomem;
               // 將父進(jìn)程 mm_struct 結(jié)構(gòu)里的內(nèi)容全部拷貝到子進(jìn)程 mm_struct 結(jié)構(gòu)中
               memcpy(mm, oldmm, sizeof(*mm));
               // 為子進(jìn)程分配頂級(jí)頁(yè)表起始地址并賦值給 mm_struct->pgd
               if (!mm_init(mm, tsk, mm->user_ns))
                  goto fail_nomem;
               // 拷貝父進(jìn)程的虛擬內(nèi)存空間中的內(nèi)容以及頁(yè)表到子進(jìn)程中
               err = dup_mmap(mm, oldmm);
               if (err)
                  goto free_pt;

               return mm;
          }

          最后內(nèi)核會(huì)在 mm_init 函數(shù)中調(diào)用 mm_alloc_pgd,并在 mm_alloc_pgd 函數(shù)中通過(guò)調(diào)用 pgd_alloc 為子進(jìn)程分配其獨(dú)立的頂級(jí)頁(yè)表起始地址,賦值給子進(jìn)程 struct mm_struct 結(jié)構(gòu)中的 pgd 屬性。

          static struct mm_struct *mm_init(struct mm_struct *mm, struct task_struct *p,
              struct user_namespace *user_ns)

          {
              .... 初始化子進(jìn)程的 mm_struct 結(jié)構(gòu) ......
              
              // 為子進(jìn)程分配頂級(jí)頁(yè)表起始地址 pgd
              if (mm_alloc_pgd(mm))
                  goto fail_nopgd;

          }

          static inline int mm_alloc_pgd(struct mm_struct *mm)
          {
              // 內(nèi)核為子進(jìn)程分配好其頂級(jí)頁(yè)表起始地址之后
              // 賦值給子進(jìn)程 mm_struct 結(jié)構(gòu)中的 pgd 屬性
              mm->pgd = pgd_alloc(mm);
              if (unlikely(!mm->pgd))
                  return -ENOMEM;
              return 0;
          }

          到現(xiàn)在為止,一個(gè)進(jìn)程就算是被完整的創(chuàng)建出來(lái)了,它擁有了自己獨(dú)立的頁(yè)表(頁(yè)表內(nèi)容和父進(jìn)程一模一樣),同時(shí)也擁有了屬于自己的頂級(jí)頁(yè)表起始地址 pgd,但是這里大家需要特別注意一點(diǎn)的就是進(jìn)程的 struct mm_struct 結(jié)構(gòu)中的這個(gè) pgd 現(xiàn)在還只是頂級(jí)頁(yè)表的虛擬內(nèi)存地址,還無(wú)法被 CPU 直接使用。

          當(dāng)這個(gè)進(jìn)程被調(diào)度到某個(gè) CPU 之上時(shí),內(nèi)核就會(huì)調(diào)用 context_switch 來(lái)對(duì)進(jìn)程上下文進(jìn)行切換,切換的內(nèi)容主要包括:

          1. 進(jìn)程虛擬內(nèi)存空間的切換。
          2. 寄存器以及進(jìn)程棧的切換。
          /*
           * context_switch - switch to the new MM and the new thread's register state.
           */

          static __always_inline struct rq *
          context_switch(struct rq *rq, struct task_struct *prev,
                     struct task_struct *next, struct rq_flags *rf)

          {
              ........ 省略 ,,,,,,,,,,

              if (!next->mm) {                                // to kernel

                  ........ 內(nèi)核線程的切換 ,,,,,,,,,,

              } else {                                        // to user
                  ........ 用戶進(jìn)程的切換 ,,,,,,,,,,

                  membarrier_switch_mm(rq, prev->active_mm, next->mm);
                  // 切換進(jìn)程虛擬內(nèi)存空間
                  switch_mm_irqs_off(prev->active_mm, next->mm, next);
              }

              // 切換 CPU 上下文和進(jìn)程棧
              switch_to(prev, next, prev);
              barrier();
              return finish_task_switch(prev);
          }

          和本小節(jié)主題相關(guān)的是  switch_mm_irqs_off 函數(shù),它主要負(fù)責(zé)對(duì)進(jìn)程虛擬內(nèi)存空間進(jìn)行切換,其中就包括了調(diào)用 load_new_mm_cr3 函數(shù)將進(jìn)程頂級(jí)頁(yè)表起始地址 mm_struct-> pgd 中的虛擬內(nèi)存地址通過(guò) __sme_pa 宏 轉(zhuǎn)換為物理內(nèi)存地址,并將 pgd 的物理內(nèi)存地址加載到 cr3 寄存器中。

          void switch_mm_irqs_off(struct mm_struct *prev, struct mm_struct *next,
             struct task_struct *tsk)

          {
                // 通過(guò) __sme_pa 將 pgd 的虛擬內(nèi)存地址轉(zhuǎn)換為物理內(nèi)存地址
                // 并加載到 cr3 寄存器中
                load_new_mm_cr3(next->pgd, new_asid, true);
          }

          cr3 寄存器中存放的是當(dāng)前進(jìn)程頂級(jí)頁(yè)表 pgd 的物理內(nèi)存地址,不能是虛擬內(nèi)存地址。

          進(jìn)程的上下文在內(nèi)核中完成切換之后,現(xiàn)在 cr3 寄存器中保存的就是當(dāng)前進(jìn)程頂級(jí)頁(yè)表的起始物理內(nèi)存地址了,當(dāng) CPU 通過(guò)下圖所示的虛擬內(nèi)存地址訪問(wèn)進(jìn)程的虛擬內(nèi)存時(shí),CPU 首先會(huì)從 cr3 寄存器中獲取到當(dāng)前進(jìn)程的頂級(jí)頁(yè)表起始地址,然后從虛擬內(nèi)存地址中提取出虛擬內(nèi)存頁(yè)對(duì)應(yīng) PTE 在頁(yè)表內(nèi)的偏移,通過(guò) 頁(yè)表起始地址 + 頁(yè)表內(nèi)偏移 * sizeof(PTE) 這個(gè)公式定位到虛擬內(nèi)存頁(yè)在頁(yè)表中所對(duì)應(yīng)的 PTE。

          image.png

          而虛擬內(nèi)存頁(yè)背后所映射的物理內(nèi)存頁(yè)的起始地址就保存在該 PTE  中,隨后 CPU 繼續(xù)從上圖所示的虛擬內(nèi)存地址中提取后半部分——物理內(nèi)存頁(yè)內(nèi)偏移,并通過(guò) 物理內(nèi)存頁(yè)起始地址 + 物理內(nèi)存頁(yè)內(nèi)偏移 就定位到了該物理內(nèi)存頁(yè)中一個(gè)具體的物理字節(jié)上。

          好了,現(xiàn)在我們已經(jīng)梳理清楚了內(nèi)核如何通過(guò)頁(yè)表來(lái)完成進(jìn)程的虛擬內(nèi)存與物理內(nèi)存之間的映射關(guān)系了,并在這個(gè)基礎(chǔ)上,我們又近一步了解了 CPU 如何通過(guò)虛擬內(nèi)存訪問(wèn)其背后映射的物理內(nèi)存的整個(gè)過(guò)程。

          但是這里筆者還要和大家特別強(qiáng)調(diào)的一點(diǎn)的是:當(dāng)用戶進(jìn)程被 CPU 調(diào)度起來(lái),訪問(wèn)進(jìn)程虛擬內(nèi)存的時(shí)候,上述的虛擬內(nèi)存地址與物理內(nèi)存地址轉(zhuǎn)換的過(guò)程都是在用戶態(tài)進(jìn)行的,正常的內(nèi)存訪問(wèn)無(wú)需進(jìn)入內(nèi)核態(tài)。

          除非 CPU 訪問(wèn)的虛擬內(nèi)存頁(yè)面類型是:

          1. 未分配頁(yè)面。
          2. 已分配未映射頁(yè)面。
          3. 以映射,但是由于內(nèi)存緊張的原因,該虛擬內(nèi)存頁(yè)映射的物理內(nèi)存頁(yè)被置換到磁盤上了。

          以上三種虛擬內(nèi)存頁(yè)有一個(gè)共同的特征就是它們背后的物理內(nèi)存頁(yè)均不在內(nèi)存中,要么是沒(méi)有映射,要么是被置換到磁盤上。當(dāng) CPU 訪問(wèn)這些虛擬內(nèi)存頁(yè)面的時(shí)候,就會(huì)產(chǎn)生缺頁(yè)中斷,隨后進(jìn)入內(nèi)核態(tài)為其分配物理內(nèi)存頁(yè)面,填充物理內(nèi)存頁(yè)面中的內(nèi)容,最后在頁(yè)表中建立映射關(guān)系。之后的內(nèi)存訪問(wèn)均是在用戶態(tài)中進(jìn)行。

          通過(guò)前邊文章 《深入理解 Linux 虛擬內(nèi)存管理》 的介紹,我們知道,進(jìn)程的整個(gè)虛擬內(nèi)存空間分為兩個(gè)部分,一個(gè)是用戶態(tài)虛擬內(nèi)存空間,一個(gè)是內(nèi)核態(tài)虛擬內(nèi)存空間。

          64位系統(tǒng)中虛擬內(nèi)存空間整體布局.png

          而 CPU 無(wú)論是在用戶態(tài)還是在內(nèi)核態(tài),訪問(wèn)的均是虛擬內(nèi)存地址,不管是用戶空間的虛擬內(nèi)存地址還是內(nèi)核空間的虛擬內(nèi)存地址最終都是要與物理內(nèi)存進(jìn)行映射的,而通過(guò)前邊的介紹我們也知道了,虛擬內(nèi)存與物理內(nèi)存的映射關(guān)系是通過(guò)頁(yè)表來(lái)管理的。

          所以頁(yè)表也就分為了兩個(gè)部分:

          1. 進(jìn)程用戶態(tài)頁(yè)表:主要負(fù)責(zé)管理進(jìn)程用戶態(tài)虛擬內(nèi)存空間到物理內(nèi)存的映射關(guān)系。

          2. 內(nèi)核態(tài)頁(yè)表:主要負(fù)責(zé)管理內(nèi)核態(tài)虛擬內(nèi)存空間到物理內(nèi)存的映射關(guān)系,這一部分主要供內(nèi)核使用。

          和進(jìn)程用戶態(tài)虛擬內(nèi)存空間一樣,內(nèi)核態(tài)虛擬內(nèi)存空間也有一個(gè) struct mm_struct 結(jié)構(gòu)來(lái)描述:

          struct mm_struct init_mm = {
            .mm_rb    = RB_ROOT,
            .pgd    = swapper_pg_dir,
            .mm_users  = ATOMIC_INIT(2),
            .mm_count  = ATOMIC_INIT(1),
            .mmap_sem  = __RWSEM_INITIALIZER(init_mm.mmap_sem),
            .page_table_lock =  __SPIN_LOCK_UNLOCKED(init_mm.page_table_lock),
            .mmlist    = LIST_HEAD_INIT(init_mm.mmlist),
            .user_ns  = &init_user_ns,
            INIT_MM_CONTEXT(init_mm)
          };

          從這里我們可以看到內(nèi)核空間的頂級(jí)頁(yè)表起始地址 pgd 叫做 swapper_pg_dir,定義在文件 arch/x86/include/asm/pgtable_64.h 中:

          #define swapper_pg_dir init_top_pgt

          內(nèi)核的頁(yè)表在系統(tǒng)初始化的時(shí)候被一段匯編代碼 arch\x86\kernel\head_64.S所創(chuàng)建。后續(xù)內(nèi)核虛擬內(nèi)存空間的創(chuàng)建以及內(nèi)核頁(yè)表的初始化工作是在系統(tǒng)啟動(dòng)函數(shù) start_kernel 中調(diào)用 setup_arch 完成。

          asmlinkage __visible void __init start_kernel(void)
          {
              ........ 省略 ........
              // 創(chuàng)建內(nèi)核虛擬內(nèi)存空間,初始化內(nèi)核頁(yè)表
              setup_arch(&command_line);

              ........ 省略 ........
          }
          void __init setup_arch(char **cmdline_p)
          {
              // 初始化內(nèi)核頁(yè)表
              clone_pgd_range(swapper_pg_dir     + KERNEL_PGD_BOUNDARY,
                      initial_page_table + KERNEL_PGD_BOUNDARY,
                      KERNEL_PGD_PTRS);
              // 將內(nèi)核頂級(jí)頁(yè)表起始地址轉(zhuǎn)換為物理地址,并加載到 cr3 寄存器中
              load_cr3(swapper_pg_dir);
              // 刷新 TLB 頁(yè)表緩存
              __flush_tlb_all();
          }

          這里我們又看到了熟悉的 cr3 寄存器,無(wú)論是進(jìn)程頁(yè)表也好還是內(nèi)核頁(yè)表也好,再被 CPU 訪問(wèn)之前都必須先加載到 cr3 寄存器中。

          現(xiàn)在內(nèi)核頁(yè)表已經(jīng)被創(chuàng)建和初始化好了,但是對(duì)于處于內(nèi)核態(tài)的進(jìn)程以及內(nèi)核線程來(lái)說(shuō)并不能直接訪問(wèn)這個(gè)內(nèi)核頁(yè)表,它們只能訪問(wèn)內(nèi)核頁(yè)表的 copy 副本,進(jìn)程的頁(yè)表分為兩個(gè)部分,一個(gè)是進(jìn)程用戶態(tài)頁(yè)表,另一個(gè)就是內(nèi)核頁(yè)表的 copy 部分。

          前邊我們介紹 fork 系統(tǒng)調(diào)用在創(chuàng)建子進(jìn)程的時(shí)候,會(huì)拷貝父進(jìn)程的所有資源,當(dāng)拷貝父進(jìn)程的虛擬內(nèi)存空間的時(shí)候,內(nèi)核會(huì)通過(guò) pgd_alloc 函數(shù)為子進(jìn)程創(chuàng)建頂級(jí)頁(yè)表 pgd,其實(shí)這里還有一項(xiàng)重要的工作,筆者在前邊沒(méi)有講,那就是在 pgd_alloc 函數(shù)中還會(huì)調(diào)用 pgd_ctor,這個(gè) pgd_ctor 函數(shù)的主要工作就是將內(nèi)核頁(yè)表拷貝到進(jìn)程頁(yè)表中。

          static inline int mm_alloc_pgd(struct mm_struct *mm)
          {
              // 內(nèi)核為子進(jìn)程分配好其頂級(jí)頁(yè)表起始地址之后
              // 賦值給子進(jìn)程 mm_struct 結(jié)構(gòu)中的 pgd 屬性
              mm->pgd = pgd_alloc(mm);
              if (unlikely(!mm->pgd))
                  return -ENOMEM;
              return 0;
          }

          pgd_t *pgd_alloc(struct mm_struct *mm)
          {
              pgd_t *pgd;
              // 為子進(jìn)程分配頂級(jí)頁(yè)表
              pgd = _pgd_alloc();
              if (pgd == NULL)
                  goto out;

              mm->pgd = pgd;

              ...... 根據(jù)配置,與初始化子進(jìn)程頁(yè)表 .....
              // 拷貝內(nèi)核頁(yè)表到子進(jìn)程中
              pgd_ctor(mm, pgd);

              ....... 省略 ........
          }

          當(dāng)進(jìn)程通過(guò)系統(tǒng)調(diào)用切入到內(nèi)核態(tài)之后,就會(huì)使用內(nèi)核頁(yè)表的這部分 copy 副本,來(lái)訪問(wèn)內(nèi)核空間虛擬內(nèi)存映射的物理內(nèi)存。當(dāng)進(jìn)程頁(yè)表中內(nèi)核部分的拷貝副本與主內(nèi)核頁(yè)表不同步時(shí),進(jìn)程在內(nèi)核態(tài)就會(huì)發(fā)生缺頁(yè)中斷,隨后會(huì)同步主內(nèi)核頁(yè)表到進(jìn)程頁(yè)表中,這里又是延時(shí)拷貝在內(nèi)核中的一處應(yīng)用。

          內(nèi)核線程有一點(diǎn)和普通的進(jìn)程不同,內(nèi)核線程只能運(yùn)行在內(nèi)核態(tài),而在內(nèi)核態(tài)中,所有進(jìn)程看到的虛擬內(nèi)存空間全部都是一樣的,所以對(duì)于內(nèi)核線程來(lái)說(shuō)并不需要為其單獨(dú)的定義 mm_struct 結(jié)構(gòu)來(lái)描述內(nèi)核虛擬內(nèi)存空間,內(nèi)核線程的 struct task_struct 結(jié)構(gòu)中的 mm 屬性指向 null,內(nèi)核線程之間調(diào)度是不涉及地址空間切換的,從而避免了無(wú)用的 TLB 緩存以及 CPU 高速緩存的刷新。

           struct task_struct {
              // 對(duì)于內(nèi)核線程來(lái)說(shuō),它并沒(méi)有自己的地址空間
              // 因?yàn)樗冀K工作在內(nèi)核空間中,所有進(jìn)程看到的都是一樣的
              struct mm_struct  *mm;
          }

          但是內(nèi)核線程依然需要訪問(wèn)內(nèi)核空間中的虛擬內(nèi)存,也就是說(shuō)內(nèi)核線程仍然需要內(nèi)核頁(yè)表,但是它又沒(méi)有自己的地址空間,那該怎么辦呢?

          內(nèi)核這里做了一個(gè)非常巧妙的處理,當(dāng)一個(gè)內(nèi)核線程被調(diào)度時(shí),它會(huì)發(fā)現(xiàn)自己的虛擬地址空間為 null,雖然它不會(huì)訪問(wèn)用戶態(tài)的內(nèi)存,但是它會(huì)訪問(wèn)內(nèi)核內(nèi)存,聰明的內(nèi)核會(huì)將調(diào)度之前的上一個(gè)用戶態(tài)進(jìn)程的虛擬內(nèi)存空間 mm_struct 直接賦值給內(nèi)核線程 task_struct->active_mm 中 。

           struct task_struct {
              // 內(nèi)核線程的 active_mm 指向前一個(gè)進(jìn)程的地址空間
              // 普通進(jìn)程的 active_mm 指向 null
              struct mm_struct *active_mm;
          }

          因?yàn)閮?nèi)核線程不會(huì)訪問(wèn)用戶空間的內(nèi)存,它僅僅只會(huì)訪問(wèn)內(nèi)核空間的內(nèi)存,所以直接復(fù)用上一個(gè)用戶態(tài)進(jìn)程頁(yè)表的內(nèi)核部分就可以避免為內(nèi)核線程分配 mm_struct 和相關(guān)頁(yè)表的開(kāi)銷,以及避免內(nèi)核線程之間調(diào)度時(shí)地址空間的切換開(kāi)銷。

          好了,在本小節(jié)中,筆者通過(guò)一張單級(jí)頁(yè)表的例子,帶著大家分別從進(jìn)程用戶態(tài)和內(nèi)核態(tài)的角度闡述了頁(yè)表是如何表達(dá)虛擬內(nèi)存與物理內(nèi)存之間的映射關(guān)系的。在我們清楚了頁(yè)表這個(gè)概念之后,下面筆者準(zhǔn)備繼續(xù)帶大家去看一下頁(yè)表的演化過(guò)程,那么在這這前,我們先來(lái)分析下單級(jí)頁(yè)表有哪些不足,近而導(dǎo)致進(jìn)程的頁(yè)表體系需要向前演進(jìn)。

          3. 單級(jí)頁(yè)表的不足

          image.png

          經(jīng)過(guò)上小節(jié)內(nèi)容的介紹我們知道,頁(yè)表的本質(zhì)其實(shí)就是一個(gè)物理內(nèi)存頁(yè),一張頁(yè)表 4K 大小,下面我們以 32 位系統(tǒng)來(lái)舉例說(shuō)明,在 32 位系統(tǒng)中,頁(yè)表中的一個(gè) PTE 占用 4B 大小,所以一張頁(yè)表可以容納 1024 個(gè) PTE。

          在進(jìn)程中虛擬內(nèi)存與物理內(nèi)存的映射是以頁(yè)為單位的,進(jìn)程虛擬內(nèi)存空間中的一個(gè)虛擬內(nèi)存頁(yè)映射物理內(nèi)存空間的一個(gè)物理內(nèi)存頁(yè),這種映射關(guān)系以及訪存權(quán)限都保存在 PTE 中,所以進(jìn)程中的一個(gè)虛擬內(nèi)存頁(yè)對(duì)應(yīng)頁(yè)表中的一個(gè) PTE,一個(gè) PTE 能夠映射 4K 的物理內(nèi)存(一個(gè)物理內(nèi)存頁(yè))。

          一張頁(yè)表里邊可以容納 1024 個(gè) PTE,一個(gè) PTE 可以映射 4K 的物理內(nèi)存,那么一張頁(yè)表就可以映射 1024 * 4K = 4M  大小的物理內(nèi)存 ,而頁(yè)表本質(zhì)上是一個(gè)物理內(nèi)存頁(yè)(4K大小),所以內(nèi)核需要用額外的 4K 大小的物理內(nèi)存去映射 4M 的物理內(nèi)存。

          假設(shè)我們現(xiàn)在系統(tǒng)中有 4G 的物理內(nèi)存,一張頁(yè)表能夠映射 4M 大小的物理內(nèi)存,而為了映射這 4G 的物理內(nèi)存,我們需要 1024 張頁(yè)表,一張頁(yè)表占用 4K 物理內(nèi)存,所以為了映射 4G 的物理內(nèi)存,我們額外需要 4M 的物理內(nèi)存(1024張頁(yè)表)來(lái)映射。

          image.png

          更要命的是這 4M 物理內(nèi)存(1024張頁(yè)表)還必須是連續(xù)的,因?yàn)轫?yè)表是單級(jí)的,而頁(yè)表相當(dāng)于是 PTE 的數(shù)組,進(jìn)程虛擬內(nèi)存空間中的一個(gè)虛擬內(nèi)存頁(yè)對(duì)應(yīng)一個(gè) PTE,而 PTE 在頁(yè)表這個(gè)數(shù)組中的索引 index 就保存在虛擬內(nèi)存地址中,內(nèi)核通過(guò)頁(yè)表的起始地址加上這個(gè)索引 index 才能定位到虛擬內(nèi)存頁(yè)對(duì)應(yīng)的 PTE,近而通過(guò) PTE 定位到映射的物理內(nèi)存頁(yè)。

          image.png

          如果這 4M 物理內(nèi)存(1024張頁(yè)表)不是連續(xù)的,那么我們就無(wú)法通過(guò)訪問(wèn)數(shù)組的方式定位 PTE 了。而系統(tǒng)經(jīng)過(guò)長(zhǎng)時(shí)間運(yùn)行之后,由于內(nèi)存碎片的原因,是很難找到這么大一片連續(xù)的物理內(nèi)存的。

          大家需要注意的是,這 4M 的連續(xù)物理內(nèi)存還只是一個(gè)進(jìn)程所需要的,因?yàn)檫M(jìn)程的虛擬內(nèi)存空間都是獨(dú)立的,頁(yè)表也是獨(dú)立的,一個(gè)進(jìn)程就需要額外的 4M 連續(xù)物理內(nèi)存(1024張頁(yè)表)來(lái)支持進(jìn)程內(nèi)獨(dú)立的內(nèi)存映射關(guān)系。假如在系統(tǒng)中跑上 100 個(gè)進(jìn)程,那總共就需要額外的 400M 連續(xù)的物理內(nèi)存。這對(duì)于一個(gè)只有 4G 物理內(nèi)存,單級(jí)頁(yè)表的系統(tǒng)來(lái)說(shuō),無(wú)疑是巨大的開(kāi)銷和浪費(fèi)。

          在進(jìn)程啟動(dòng)的時(shí)候就為它分配 4M 的頁(yè)表這確實(shí)是比較大的開(kāi)銷,這一點(diǎn)是沒(méi)錯(cuò)的,但是為什么說(shuō)是一種浪費(fèi)呢?

          如果進(jìn)程一啟動(dòng)就立馬會(huì)訪問(wèn)全部的 4G 物理內(nèi)存,那么的確需要在一開(kāi)始就為進(jìn)程分配 4M 的連續(xù)物理內(nèi)存來(lái)存放頁(yè)表,那這一點(diǎn)開(kāi)銷無(wú)論多么大都是必須的,不能省的,否則進(jìn)程將無(wú)法運(yùn)行。

          但程序的局部性原理告訴我們,進(jìn)程在運(yùn)行之后,對(duì)于內(nèi)存的訪問(wèn)不會(huì)一下子就要訪問(wèn)全部的內(nèi)存,相反進(jìn)程對(duì)于內(nèi)存的訪問(wèn)會(huì)表現(xiàn)出明顯的傾向性,更加傾向于訪問(wèn)最近訪問(wèn)過(guò)的數(shù)據(jù)以及熱點(diǎn)數(shù)據(jù)附近的數(shù)據(jù)。

          程序局部性原理表現(xiàn)為:時(shí)間局部性和空間局部性。時(shí)間局部性是指如果程序中的某條指令一旦執(zhí)行,則不久之后該指令可能再次被執(zhí)行;如果某塊數(shù)據(jù)被訪問(wèn),則不久之后該數(shù)據(jù)可能再次被訪問(wèn)。空間局部性是指一旦程序訪問(wèn)了某個(gè)存儲(chǔ)單元,則不久之后,其附近的存儲(chǔ)單元也將被訪問(wèn)。

          所以無(wú)論一個(gè)進(jìn)程在實(shí)際運(yùn)行過(guò)程中總共需要占用的內(nèi)存資源有多大,根據(jù)程序局部性原理,在某一段時(shí)間內(nèi),進(jìn)程真正需要的物理內(nèi)存其實(shí)是很少的一部分,我們只需要為每個(gè)進(jìn)程分配很少的物理內(nèi)存就可以保證進(jìn)程的正常執(zhí)行運(yùn)轉(zhuǎn)。

          既然在某一個(gè)特定的時(shí)刻,進(jìn)程只需要很少的物理內(nèi)存就可以正常運(yùn)轉(zhuǎn),那么進(jìn)程虛擬內(nèi)存與物理內(nèi)存之間的映射關(guān)系相應(yīng)也會(huì)很少,根本就不需要 4M 的物理內(nèi)存來(lái)保存映射關(guān)系。

          我們完全可以在進(jìn)程初始狀態(tài)下,創(chuàng)建一個(gè)最小集的頁(yè)表,當(dāng)進(jìn)程實(shí)際確實(shí)需要的時(shí)候,我們?cè)賮?lái)創(chuàng)建相應(yīng)具體的頁(yè)表,這又是延時(shí)分配思想在內(nèi)核中的另一處應(yīng)用。

          那么內(nèi)核是如何做到的呢?接下來(lái)我們就需要向多級(jí)頁(yè)表演進(jìn)了~~~~

          4. 多級(jí)頁(yè)表的演進(jìn)

          在開(kāi)始為大家介紹多級(jí)頁(yè)表之前,筆者這里還是要和大家不斷強(qiáng)化幾個(gè)核心概念,這些概念非常重要,這關(guān)系到大家是否能從本質(zhì)上理解多級(jí)頁(yè)表的設(shè)計(jì)。

          1. 頁(yè)表本質(zhì)上還是一個(gè)物理內(nèi)存頁(yè),只不過(guò)這個(gè)物理內(nèi)存頁(yè)比較特殊,里面存放的是 PTE,保存虛擬內(nèi)存與物理內(nèi)存的映射關(guān)系。既然它是一個(gè)普通的物理內(nèi)存頁(yè),那么也會(huì)參與內(nèi)核的調(diào)度,既會(huì)被內(nèi)核 swap in 以及 swap out,也會(huì)被緩存在 CPU 高速緩存中加速訪問(wèn)。

          2. 在 32 位系統(tǒng)中,一個(gè) PTE 占用 4 個(gè)字節(jié),可以映射 4K 的物理內(nèi)存,一張頁(yè)表本身占用 4K 的物理內(nèi)存,可以映射 4M 的物理內(nèi)存。

          image.png
          1. 定位虛擬內(nèi)存頁(yè)在進(jìn)程頁(yè)表中對(duì)應(yīng)的 PTE 是通過(guò)數(shù)組的訪問(wèn)方式進(jìn)行的,虛擬內(nèi)存地址中包含了其對(duì)應(yīng)的 PTE 在頁(yè)表中的偏移(頁(yè)表數(shù)組中的 index),所以這就要求每一級(jí)頁(yè)表都必須是連續(xù)的,比如上小節(jié)中介紹的單級(jí)頁(yè)表,這 1024 張頁(yè)表必須是連續(xù)的物理內(nèi)存(4M 大小)。

          2. 進(jìn)程對(duì)系統(tǒng)內(nèi)存的訪問(wèn)具有明顯的局部性,在任意時(shí)刻,我們只需要為進(jìn)程分配很少的內(nèi)存就能保證進(jìn)程的正常運(yùn)行。

          在強(qiáng)化了這些核心概念之后,我們繼續(xù)沿著上小節(jié)中介紹的單級(jí)頁(yè)表的思路往下捋,接下來(lái)我們還是以 4G 的物理內(nèi)存為例,根據(jù)局部性原理我們知道,進(jìn)程在啟動(dòng)之后的任意時(shí)刻都不可能一下子就要訪問(wèn)全部的 4G 物理內(nèi)存,但是我們需要給進(jìn)程提供尋址 4G 物理內(nèi)存的能力,也就是說(shuō)你先別管我訪問(wèn)不訪問(wèn),反正 4G 物理內(nèi)存的尋址能力我是需要的。

          “讓社會(huì)的不良風(fēng)氣吹進(jìn)來(lái),我可以不收,但你們不能不送”——范德彪

          image.png

          所以在單級(jí)頁(yè)表的情況下,我們必須要為進(jìn)程額外分配 4M 的連續(xù)物理內(nèi)存來(lái)存放 1024 張頁(yè)表,不管進(jìn)程訪問(wèn)不訪問(wèn),這 4M 的開(kāi)銷是不能省的。

          那么現(xiàn)在我們?cè)谀贸鲆粋€(gè) 4K 的物理內(nèi)存頁(yè)作為頁(yè)表,然后將這個(gè)頁(yè)表放在單級(jí)頁(yè)表的前面,組成一個(gè)二級(jí)頁(yè)表的體系,情況會(huì)變成什么樣呢?

          image.png

          之前筆者不斷地和大家強(qiáng)調(diào)過(guò),頁(yè)表的本質(zhì)是一個(gè)物理內(nèi)存頁(yè),頁(yè)表是 PTE  的數(shù)組,而 PTE 的本質(zhì)是指向其映射的一個(gè)物理內(nèi)存頁(yè),既然 PTE 可以指向一個(gè)普通的物理內(nèi)存頁(yè),那么它也可以指向一個(gè)頁(yè)表。

          根據(jù)這個(gè)思路,二級(jí)頁(yè)表中的一個(gè) PTE 本質(zhì)上指向的還是一個(gè)物理內(nèi)存頁(yè),只不過(guò)這個(gè)物理內(nèi)存頁(yè)比較特殊,它是一張頁(yè)表(一級(jí)頁(yè)表),一級(jí)頁(yè)表是用來(lái)映射真正的物理內(nèi)存的,一張一級(jí)頁(yè)表可以映射 4M 物理內(nèi)存。

          這也就是說(shuō)二級(jí)頁(yè)表中的一個(gè) PTE 就可以映射 4M 物理內(nèi)存,同樣的道理,二級(jí)頁(yè)表中也包含了 1024 個(gè) PTE,所以一張二級(jí)頁(yè)表就可以映射 4G 的物理內(nèi)存。

          雖說(shuō)二級(jí)頁(yè)表和一級(jí)頁(yè)表本質(zhì)上都是一樣的,它們都是一個(gè)物理內(nèi)存頁(yè),但是我們習(xí)慣上將二級(jí)頁(yè)表叫做頁(yè)目錄表,用來(lái)做一級(jí)頁(yè)表的索引,就好像書(shū)中的目錄一樣,二級(jí)頁(yè)表中的 PTE  我們習(xí)慣上稱為做頁(yè)目錄項(xiàng) (Page Directory Entry, PDE)。

          因?yàn)橐粡堩?yè)目錄表就可以映射 4G 的物理內(nèi)存了,所以在二級(jí)頁(yè)表的情況下,我們只需要在進(jìn)程啟動(dòng)的時(shí)候額外為它分配 4K 的連續(xù)物理內(nèi)存就可以了,這相比單級(jí)頁(yè)表下,需要為每個(gè)進(jìn)程額外分配 4M 的連續(xù)物理內(nèi)存節(jié)省了非常多寶貴的內(nèi)存資源。

          image.png

          但進(jìn)程運(yùn)行起來(lái)肯定會(huì)訪問(wèn)內(nèi)存對(duì)吧,要訪問(wèn)內(nèi)存就需要有映射,在運(yùn)行過(guò)程中光有一張頁(yè)目錄表肯定是不夠的,根據(jù)程序局部性原理,進(jìn)程在運(yùn)行中的任意時(shí)刻,只會(huì)訪問(wèn)很小一塊的內(nèi)存,比如這時(shí)進(jìn)程需要訪問(wèn) 4K 的物理內(nèi)存(一個(gè)物理內(nèi)存頁(yè)),在二級(jí)頁(yè)表情況下,內(nèi)核會(huì)本著你訪問(wèn)多少,我映射多少的原則來(lái)進(jìn)行內(nèi)存映射,下面我們來(lái)一起看看二級(jí)頁(yè)表下的映射過(guò)程并與一級(jí)頁(yè)表對(duì)比下內(nèi)存消耗。

          當(dāng)前系統(tǒng)中,進(jìn)程只有一張頁(yè)目錄表,頁(yè)目錄表里的 PDE 沒(méi)有映射任何東西,這時(shí)進(jìn)程需要訪問(wèn)一個(gè)物理內(nèi)存頁(yè),而對(duì)物理內(nèi)存頁(yè)的映射任務(wù)主要是在一級(jí)頁(yè)表的 PTE 中,所以現(xiàn)在首要的任務(wù)就是建立一張一級(jí)頁(yè)表出來(lái),并用頁(yè)目錄表索引起來(lái)。

          image.png

          在二級(jí)頁(yè)表的情況下,內(nèi)核只需要一張 4K 的頁(yè)目錄表和一張 4K 的一級(jí)頁(yè)表總共 8K 的內(nèi)存就可以支持進(jìn)程訪問(wèn)一個(gè) 4K 物理頁(yè)面了,而根據(jù)程序的空間局部性原理,在不久的將來(lái),進(jìn)程只會(huì)訪問(wèn)與該物理內(nèi)存頁(yè)臨近的頁(yè)面,所以事實(shí)上,即使進(jìn)程訪問(wèn) 4M 的內(nèi)存,依然只需要一張 4K 的頁(yè)目錄表和一張 4K 的一級(jí)頁(yè)表就可以滿足了。

          image.png

          當(dāng)進(jìn)程需要訪問(wèn)下一個(gè) 4M 的物理內(nèi)存時(shí),這時(shí)候第一個(gè)一級(jí)頁(yè)表已經(jīng)映射滿了,那就需要再創(chuàng)建第二張頁(yè)表用來(lái)映射下一個(gè) 4M 的物理內(nèi)存,當(dāng)然了,第二張頁(yè)表依然需要索引在頁(yè)目錄表的 PDE 中。

          image.png

          這時(shí)候內(nèi)核就需要一張頁(yè)目錄表和兩張一級(jí)頁(yè)表共 12K 額外的物理內(nèi)存來(lái)映射,這依然比單級(jí)頁(yè)表的 4M 連續(xù)物理內(nèi)存開(kāi)銷小很多。

          同理,隨著進(jìn)程一個(gè) 4M 接著一個(gè) 4M 物理內(nèi)存的訪問(wèn),在極端的情況下整個(gè)頁(yè)目錄表都被映射滿了,這時(shí)候內(nèi)核就需要 4K(頁(yè)目錄表)+ 4M(1024張一級(jí)頁(yè)表)的額外內(nèi)存來(lái)保存映射關(guān)系了,這種情況下看起來(lái)會(huì)比單級(jí)頁(yè)表下的 4M 內(nèi)存開(kāi)銷大了那么一點(diǎn)點(diǎn),但這種屬于極端情況,非常少見(jiàn),極大部分情況下還是比單級(jí)頁(yè)表開(kāi)銷少很多很多的。

          而且在二級(jí)頁(yè)表體系下,上面極端情況中的這 1024 張一級(jí)頁(yè)表不需要是連續(xù)的,因?yàn)槲覀冎恍枰WC頂級(jí)頁(yè)表(這里指頁(yè)目錄表)是連續(xù)的就可以了,通過(guò)頁(yè)目錄表中的 PDE 可以唯一索引到一張一級(jí)頁(yè)表的起始物理內(nèi)存地址,而頁(yè)表內(nèi)肯定是連續(xù)的 4K 物理內(nèi)存,所以依然可以通過(guò)數(shù)組的方式索引到一級(jí)頁(yè)表中的 PTE,近而找到其映射的物理內(nèi)存頁(yè)面。

          除此之外二級(jí)頁(yè)表體系還有一個(gè)優(yōu)勢(shì),就是當(dāng)內(nèi)存緊張的時(shí)候,那些不經(jīng)常使用的一級(jí)頁(yè)表可以被 swap out 到磁盤中,當(dāng)進(jìn)程再次訪問(wèn)到該頁(yè)表映射的物理內(nèi)存時(shí),內(nèi)核在將頁(yè)表從磁盤中 swap in 到內(nèi)存中。當(dāng)然了,頂級(jí)頁(yè)表(這里指頁(yè)目錄表)必須是常駐內(nèi)存的,不允許 swap 。

          image.png

          既然頁(yè)表的本質(zhì)是一個(gè)物理內(nèi)存頁(yè),那么同理,進(jìn)程經(jīng)常訪問(wèn)的那些頁(yè)表也會(huì)被緩存到 CPU 高速緩存中加速下一次的訪問(wèn)速度。

          在本小節(jié)中我們主要揭露多級(jí)頁(yè)表的本質(zhì),除了二級(jí)頁(yè)表之外,根據(jù)同樣的道理,也會(huì)有三級(jí)頁(yè)表,四級(jí)頁(yè)表,Linux 內(nèi)核甚至還支持五級(jí)頁(yè)表,無(wú)論頁(yè)表有多少級(jí),但是都逃脫不了本小節(jié)中介紹的本質(zhì)。本質(zhì)的原理我們清楚了之后,下面我們就來(lái)看下多級(jí)頁(yè)表具體的工作過(guò)程吧~~~

          4.1 二級(jí)頁(yè)表

          現(xiàn)在我們對(duì)單級(jí)頁(yè)表體系下的虛擬內(nèi)存的尋址過(guò)程已經(jīng)非常熟悉了,那么多級(jí)頁(yè)表體系下的虛擬內(nèi)存尋址過(guò)程也是一樣的,都逃脫不了前邊為大家介紹的頁(yè)表本質(zhì)。在引入二級(jí)頁(yè)表尋址過(guò)程之前,我們?cè)趤?lái)回顧下單級(jí)頁(yè)表尋址的本質(zhì)核心邏輯:

          image.png
          1. 首先無(wú)論是幾級(jí)頁(yè)表,它們通過(guò)虛擬內(nèi)存尋址的本質(zhì)就是定位虛擬內(nèi)存頁(yè)對(duì)應(yīng)在頁(yè)表中的 PTE,然后通過(guò) PTE 找到其映射的具體物理內(nèi)存頁(yè)。

          2. 頁(yè)表的本質(zhì)是一個(gè)物理內(nèi)存頁(yè),其中包含了 1024 個(gè) PTE,每個(gè) PTE 可以映射一個(gè)具體的物理內(nèi)存頁(yè),PTE 中保存了物理內(nèi)存頁(yè)的起始地址,進(jìn)程地址空間中的一個(gè)虛擬內(nèi)存頁(yè)對(duì)應(yīng)頁(yè)表中的一個(gè) PTE。因?yàn)樵趦?nèi)核中是按照頁(yè)為單位進(jìn)行內(nèi)存映射的。

          3. 在單級(jí)頁(yè)表體系下,前面提到的 1024 張一級(jí)頁(yè)表背后是通過(guò)連續(xù)的 4M 物理內(nèi)存保存的,既然是連續(xù)的,那么我們可以把單級(jí)頁(yè)表看做一個(gè)大的 PTE 數(shù)組,只要我們知道了單級(jí)頁(yè)表的起始物理內(nèi)存地址以及虛擬內(nèi)存頁(yè)對(duì)應(yīng) PTE 在單級(jí)頁(yè)表(1024 張一級(jí)頁(yè)表)中的 index,那么就可以定位到 PTE 了。

          這里的單級(jí)頁(yè)表起始物理內(nèi)存地址指的就是 1024 張一級(jí)頁(yè)表中,第一張頁(yè)表的起始物理內(nèi)存地址。

          image.png
          1. cr3 寄存器保存了頂級(jí)頁(yè)表的起始物理內(nèi)存地址,頂級(jí)頁(yè)表隨著進(jìn)程的創(chuàng)建而創(chuàng)建,保存在進(jìn)程 mm_struct->pgd,當(dāng)進(jìn)程被 CPU 調(diào)度的時(shí)候,會(huì)伴隨著進(jìn)程上下文切換,其中就會(huì)將 mm_struct->pgd 轉(zhuǎn)換為物理內(nèi)存地址并加載到 cr3 寄存器中。

          這里的頂級(jí)頁(yè)表指的就是單級(jí)頁(yè)表(1024 張一級(jí)頁(yè)表)

          1. 無(wú)論在幾級(jí)頁(yè)表體系下,進(jìn)程虛擬內(nèi)存空間中的虛擬內(nèi)存地址格式在設(shè)計(jì)上總共分為兩大部分,一部分是用來(lái)定位虛擬內(nèi)存頁(yè)在頁(yè)表中對(duì)應(yīng)的 PTE 偏移,另一部分是用來(lái)定位物理內(nèi)存頁(yè)中要訪問(wèn)的具體字節(jié)。
          image.png
          1. 單級(jí)頁(yè)表的起始物理內(nèi)存地址(保存在 cr3 寄存器中)有了,虛擬內(nèi)存頁(yè)在單級(jí)頁(yè)表中對(duì)應(yīng) PTE 的偏移(保存在虛擬內(nèi)存地址中)有了,通過(guò)公式 頁(yè)表起始地址 + 頁(yè)表內(nèi)偏移 * sizeof(PTE) 就可以定位到虛擬內(nèi)存頁(yè)對(duì)應(yīng)的 PTE 了,而 PTE 中保存了映射物理內(nèi)存頁(yè)的起始地址,在加上虛擬內(nèi)存地址中保存的物理內(nèi)存頁(yè)內(nèi)偏移,這樣就可以定位到虛擬內(nèi)存地址對(duì)應(yīng)的物理字節(jié)了。

          從單級(jí)頁(yè)表演進(jìn)到二級(jí)頁(yè)表之后,虛擬內(nèi)存尋址的底層邏輯還是一樣的,只不過(guò)現(xiàn)在的頂級(jí)頁(yè)表變成了頁(yè)目錄表(Page Directory), cr3 寄存器現(xiàn)在存放的是頁(yè)目錄表的起始物理內(nèi)存地址。

          image.png

          通過(guò)虛擬內(nèi)存地址定位 PTE 的步驟由原來(lái)的一步變成了現(xiàn)在的兩步,因?yàn)槲覀兌嗉恿艘患?jí)頁(yè)目錄表,所以現(xiàn)在需要首先定位頁(yè)目錄表中的 PDE,然后通過(guò) PDE 定位到具體的頁(yè)表,近而找到頁(yè)表中的 PTE。

          所以在二級(jí)頁(yè)表體系下的虛擬內(nèi)存地址的格式也就發(fā)生了變化,單級(jí)頁(yè)表下虛擬內(nèi)存地址中只需要保存頁(yè)表中的 PTE 偏移即可,二級(jí)頁(yè)表下虛擬內(nèi)存地址還需要多保存一份頁(yè)目錄表中 PDE 的偏移。

          image.png

          二級(jí)頁(yè)表應(yīng)用在 32 位系統(tǒng)中,相應(yīng)的虛擬內(nèi)存地址由 32 位 bit 組成,在 32 位系統(tǒng)中頁(yè)目錄表中的 PDE 和頁(yè)表中的 PTE 均占用 4 個(gè)字節(jié)。而前邊我們也介紹過(guò)了,頁(yè)目錄表和頁(yè)表的本質(zhì)其實(shí)就是一個(gè)物理內(nèi)存頁(yè),它們分別占用 4K 大小。

          因此一張頁(yè)目錄表中有 1024 個(gè) PDE,要尋址這 1024 個(gè) PDE 用 10 個(gè) bit 就可以了,所以在上圖中的虛擬內(nèi)存地址中的 頁(yè)目錄表中 PDE 偏移 部分占用 10 個(gè) bit 位。

          同樣的道理,一張頁(yè)表中有 1024 個(gè) PTE, 要尋址這個(gè) 1024 個(gè) PTE 也是需要 10 個(gè) bit,上圖中虛擬內(nèi)存地址中的 一級(jí)頁(yè)表中 PTE 偏移 部分也需要占用 10 個(gè) bit 位。

          這樣一來(lái)我們就可以通過(guò)虛擬內(nèi)存地址中的前 10 個(gè) bit 定位到頁(yè)目錄表中的 PDE ,而 PDE 指向的是一級(jí)頁(yè)表的起始物理內(nèi)存地址,然后我們通過(guò)接下來(lái)的 10 個(gè) bit 就可以定位到頁(yè)表中的 PTE,而 PTE 指向的是虛擬內(nèi)存頁(yè)最終映射的物理內(nèi)存頁(yè)的起始地址。

          現(xiàn)在我們找到物理內(nèi)存頁(yè)了,那么如何在物理內(nèi)存頁(yè)中找到我們要訪問(wèn)的字節(jié)呢 ?這就需要上圖虛擬內(nèi)存地址中的最后一部分 物理內(nèi)存頁(yè)內(nèi)偏移了,因?yàn)橐粋€(gè)物理內(nèi)存頁(yè)占用 4K 大小,我們用 12 位 bit 就可以尋址內(nèi)存頁(yè)中的任意字節(jié)了。

          這樣加起來(lái),剛好可以組成一個(gè) 32 位的虛擬內(nèi)存地址,在我們清楚了二級(jí)頁(yè)表下的虛擬內(nèi)存地址格式之后,接下來(lái)我們就來(lái)看下二級(jí)頁(yè)表體系下的尋址過(guò)程:

          image.png
          1. 當(dāng) CPU 訪問(wèn)進(jìn)程虛擬內(nèi)存空間中的一個(gè)地址時(shí),會(huì)先從 cr3 寄存器中拿出頁(yè)目錄表的起始物理內(nèi)存地址,然后從虛擬內(nèi)存地址中解析出前 10 bit 的內(nèi)容作為頁(yè)目錄表中 PDE 的偏移,通過(guò)公式 頁(yè)目錄表起始地址 + 頁(yè)目錄表內(nèi)偏移 * sizeof(PDE) 就可以定位到該虛擬內(nèi)存頁(yè)在頁(yè)目錄表中的 PDE 了。

          2. PDE 中保存了其指向的一級(jí)頁(yè)表的起始物理內(nèi)存地址,我們?cè)趶奶摂M內(nèi)存地址中解析出下一個(gè) 10 bit 作為頁(yè)表中 PTE 的偏移,然后通過(guò)公式 頁(yè)表起始地址 + 頁(yè)表內(nèi)偏移 * sizeof(PTE) 就能定位到虛擬內(nèi)存頁(yè)在一級(jí)頁(yè)表中的 PTE 了。

          3. PTE 中保存了最終映射的物理內(nèi)存頁(yè)的起始地址,最后我們從虛擬內(nèi)存地址中解析出最后 12 個(gè) bit,最終定位到虛擬內(nèi)存地址對(duì)應(yīng)的物理字節(jié)上。

          現(xiàn)在二級(jí)頁(yè)表下虛擬內(nèi)存尋址的完整過(guò)程筆者就為大家介紹完了,那么我們提到了這么次的 PDE,PTE,它們內(nèi)部到底長(zhǎng)什么樣子呢?我們接著往下看~~~

          4.1.1.  32 位頁(yè)表項(xiàng) PTE

          image.png

          在進(jìn)程的虛擬內(nèi)存空間中,每一個(gè)虛擬內(nèi)存頁(yè)在頁(yè)表中都有一個(gè) PTE 與之對(duì)應(yīng),在 32 位系統(tǒng)中,每個(gè) PTE 占用 4 個(gè)字節(jié)大小,其中保存了虛擬內(nèi)存頁(yè)背后映射的物理內(nèi)存頁(yè)的起始地址,以及進(jìn)程訪問(wèn)物理內(nèi)存的一些權(quán)限標(biāo)識(shí)位。

          PTE  在內(nèi)核中是用 unsigned long 類型描述的,在 32 位系統(tǒng)中占用 4 個(gè)字節(jié):

          typedef unsigned long pteval_t;

          typedef struct { pteval_t pte; } pte_t;

          下面是 PTE 中 32 bit (4 字節(jié)) 的布局格式:

          image.png

          由于內(nèi)核將整個(gè)物理內(nèi)存劃分為一頁(yè)一頁(yè)的單位,每個(gè)物理內(nèi)存頁(yè)大小為 4K,所以物理內(nèi)存頁(yè)的起始地址都是按照 4K 對(duì)齊的,也就導(dǎo)致物理內(nèi)存頁(yè)的起始地址的后 12 位全部是 0,我們只需要在 PTE 中存儲(chǔ)物理內(nèi)存地址的高 20 位就可以了,剩下的低 12 位可以用來(lái)標(biāo)記一些權(quán)限位。下面是 PTE 權(quán)限位的含義:

          P(0) 表示該 PTE 映射的物理內(nèi)存頁(yè)是否在內(nèi)存中,值為 1 表示物理內(nèi)存頁(yè)在內(nèi)存中駐留,值為 0 表示物理內(nèi)存頁(yè)不在內(nèi)存中,可能被 swap 到磁盤上了。當(dāng) PTE 中的 P 位為 0 時(shí),上圖中的其他權(quán)限位將變得沒(méi)有意義,這種情況下其他 bit 位存放物理內(nèi)存頁(yè)在磁盤中的地址。當(dāng)物理內(nèi)存頁(yè)需要被 swap in 的時(shí)候,內(nèi)核會(huì)在這里找到物理內(nèi)存頁(yè)在磁盤中的位置。

          image.png

          當(dāng)我們通過(guò)上述虛擬內(nèi)存尋址過(guò)程找到其對(duì)應(yīng)的 PTE 之后,首先會(huì)檢查它的 P 位,如果為 0 直接觸發(fā)缺頁(yè)中斷(page fault),隨后進(jìn)入內(nèi)核態(tài),由內(nèi)核的缺頁(yè)異常處理程序負(fù)責(zé)將映射的物理頁(yè)面換入到內(nèi)存中。

          R/W(1) 表示進(jìn)程對(duì)該物理內(nèi)存頁(yè)擁有的讀,寫權(quán)限,值為 1 表示進(jìn)程對(duì)該物理頁(yè)擁有讀寫權(quán)限,值為 0 表示進(jìn)程對(duì)該物理頁(yè)擁有只讀權(quán)限,進(jìn)程對(duì)只讀頁(yè)面進(jìn)行寫操作將觸發(fā) page fault (寫保護(hù)中斷異常),用于寫時(shí)復(fù)制(Copy On Write, COW)的場(chǎng)景。

          比如,父進(jìn)程通過(guò) fork 系統(tǒng)調(diào)用創(chuàng)建子進(jìn)程之后,父子進(jìn)程的虛擬內(nèi)存空間完全是一模一樣的,包括父子進(jìn)程的頁(yè)表內(nèi)容都是一樣的,父子進(jìn)程頁(yè)表中的 PTE 均指向同一物理內(nèi)存頁(yè)面,此時(shí)內(nèi)核會(huì)將父子進(jìn)程頁(yè)表中的 PTE 均改為只讀的,并將父子進(jìn)程共同映射的這個(gè)物理頁(yè)面引用計(jì)數(shù) + 1。

          當(dāng)父進(jìn)程或者子進(jìn)程對(duì)該頁(yè)面發(fā)生寫操作的時(shí)候,我們現(xiàn)在假設(shè)子進(jìn)程先對(duì)頁(yè)面發(fā)生寫操作,隨后子進(jìn)程發(fā)現(xiàn)自己頁(yè)表中的 PTE 是只讀的,于是產(chǎn)生寫保護(hù)中斷,子進(jìn)程進(jìn)入內(nèi)核態(tài),在內(nèi)核的缺頁(yè)中斷處理程序中發(fā)現(xiàn),訪問(wèn)的這個(gè)物理頁(yè)面引用計(jì)數(shù)大于 1,說(shuō)明此時(shí)該物理內(nèi)存頁(yè)面存在多進(jìn)程共享的情況,于是發(fā)生寫時(shí)復(fù)制(Copy On Write, COW),內(nèi)核為子進(jìn)程重新分配一個(gè)新的物理頁(yè)面,然后將原來(lái)物理頁(yè)中的內(nèi)容拷貝到新的頁(yè)面中,最后子進(jìn)程頁(yè)表中的 PTE 指向新的物理頁(yè)面并將 PTE 的 R/W 位設(shè)置為 1,原來(lái)物理頁(yè)面的引用計(jì)數(shù) - 1。

          后面父進(jìn)程在對(duì)頁(yè)面進(jìn)行寫操作的時(shí)候,同樣也會(huì)發(fā)現(xiàn)父進(jìn)程的頁(yè)表中 PTE 是只讀的,也會(huì)產(chǎn)生寫保護(hù)中斷,但是在內(nèi)核的缺頁(yè)中斷處理程序中,發(fā)現(xiàn)訪問(wèn)的這個(gè)物理頁(yè)面引用計(jì)數(shù)為 1 了,那么就只需要將父進(jìn)程頁(yè)表中的 PTE 的 R/W 位設(shè)置為 1 就可以了。

          U/S(2) 值為 0 表示該物理內(nèi)存頁(yè)面只有內(nèi)核才可以訪問(wèn),值為 1 表示用戶空間的進(jìn)程也可以訪問(wèn)。

          PCD(4) 是 Page Cache Disabled 的縮寫,表示 PTE 指向的這個(gè)物理內(nèi)存頁(yè)中的內(nèi)容是否可以被緩存再 CPU CACHE  中,值為 1 表示 Disabled,值為 0 表示 Enabled。

          PWT(3)  同樣也是和 CPU CACHE  相關(guān)的控制位,Page Write Through 的縮寫,值為 1 表示 CPU CACHE 中的數(shù)據(jù)發(fā)生修改之后,采用 Write Through 的方式同步回物理內(nèi)存頁(yè)中。值為 0 表示采用 Write Back 的方式同步回物理內(nèi)存頁(yè)。

          當(dāng) CPU 修改了高速緩存中的數(shù)據(jù)之后,這些修改后的緩存內(nèi)容同步回內(nèi)存的方式有兩種:

          1. Write Back:CPU 修改后的緩存數(shù)據(jù)不會(huì)立馬同步回內(nèi)存,只有當(dāng) cache line 被替換時(shí),這些修改后的緩存數(shù)據(jù)才會(huì)被同步回內(nèi)存中,并覆蓋掉對(duì)應(yīng)物理內(nèi)存頁(yè)中舊的數(shù)據(jù)。

          2. Write Through:CPU  修改高速緩存中的數(shù)據(jù)之后,會(huì)立刻被同步回物理內(nèi)存頁(yè)中。

          A(5) 表示 PTE 指向的這個(gè)物理內(nèi)存頁(yè)最近是否被訪問(wèn)過(guò),1 表示最近被訪問(wèn)過(guò)(讀或者寫訪問(wèn)都會(huì)設(shè)置為 1),0 表示沒(méi)有。該 bit 位被硬件 MMU 設(shè)置,由操作系統(tǒng)重置。內(nèi)核會(huì)經(jīng)常檢查該比特位,以確定該物理內(nèi)存頁(yè)的活躍程度,不經(jīng)常使用的內(nèi)存頁(yè),很可能就會(huì)被內(nèi)核 swap out 出去。

          D(6) 主要針對(duì)文件頁(yè)使用,當(dāng) PTE 指向的物理內(nèi)存頁(yè)是一個(gè)文件頁(yè)時(shí),進(jìn)程對(duì)這個(gè)文件頁(yè)寫入了新的數(shù)據(jù),這時(shí)文件頁(yè)就變成了臟頁(yè),對(duì)應(yīng)的 PTE 中 D 比特位會(huì)被設(shè)置為 1,表示文件頁(yè)中的內(nèi)容與其背后對(duì)應(yīng)磁盤中的文件內(nèi)容不同步了。關(guān)于臟頁(yè)的詳細(xì)描述,可以回看下筆者之前的這篇文章 《從 Linux 內(nèi)核角度探秘 JDK NIO 文件讀寫本質(zhì)》

          PAT(7) 表示是否支持 PAT(Page Attribute Table) , PAT 的相關(guān)內(nèi)容和本文主題無(wú)關(guān),這里就不做過(guò)多的介紹了。

          G(8) 設(shè)置為 1 表示該 PTE 是全局的,該標(biāo)志位表示 PTE 中保存的映射關(guān)系是否是全局的,什么意思呢,一般來(lái)說(shuō)進(jìn)程都有各自獨(dú)立的虛擬內(nèi)存空間,進(jìn)程的頁(yè)表也是獨(dú)立的 ,CPU 每次訪問(wèn)進(jìn)程虛擬內(nèi)存地址的時(shí)候都需要進(jìn)行地址翻譯(上一小節(jié)介紹的尋址過(guò)程),為了加速地址翻譯的速度,避免每次遍歷頁(yè)表,CPU 會(huì)把經(jīng)常被訪問(wèn)到的 PTE 緩存在一個(gè) TLB 的硬件緩存中,由于 TLB 中緩存的是當(dāng)前進(jìn)程相關(guān)的 PTE,所以操作系統(tǒng)每次在切換進(jìn)程的時(shí)候,都會(huì)重新刷新 TLB 緩存。

          而有一些 PTE 是所有進(jìn)程共享的,比如說(shuō)內(nèi)核虛擬內(nèi)存空間中的映射關(guān)系,所有進(jìn)程進(jìn)入內(nèi)核態(tài)看到的都是一樣的。所以會(huì)將這些全局共享的 PTE 中的 G 比特位置為 1 ,這樣在每次進(jìn)程切換的時(shí)候,就不會(huì) flush 掉 TLB 緩存的那些共享的全局 PTE(比如內(nèi)核地址的空間中使用的 PTE),從而在很大程度上提升了性能。

          以上介紹的這些 PTE 相關(guān)的權(quán)限比特位定義在內(nèi)核文件/arch/x86/include/asm/pgtable_types.h 中:

          #define _PAGE_BIT_PRESENT 0 /* is present */
          #define _PAGE_BIT_RW  1 /* writeable */
          #define _PAGE_BIT_USER  2 /* userspace addressable */
          #define _PAGE_BIT_PWT  3 /* page write through */
          #define _PAGE_BIT_PCD  4 /* page cache disabled */
          #define _PAGE_BIT_ACCESSED 5 /* was accessed (raised by CPU) */
          #define _PAGE_BIT_DIRTY  6 /* was written to (raised by CPU) */
          #define _PAGE_BIT_PAT  7 /* on 4KB pages */
          #define _PAGE_BIT_GLOBAL 8 /* Global TLB entry PPro+ */

          // 從 PTE 中提取相應(yīng)比特位的掩碼
          #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_PWT (_AT(pteval_t, 1) << _PAGE_BIT_PWT)
          #define _PAGE_PCD (_AT(pteval_t, 1) << _PAGE_BIT_PCD)
          #define _PAGE_ACCESSED (_AT(pteval_t, 1) << _PAGE_BIT_ACCESSED)
          #define _PAGE_DIRTY (_AT(pteval_t, 1) << _PAGE_BIT_DIRTY)
          #define _PAGE_PSE (_AT(pteval_t, 1) << _PAGE_BIT_PSE)
          #define _PAGE_GLOBAL (_AT(pteval_t, 1) << _PAGE_BIT_GLOBAL)
          image.png

          除此之外,內(nèi)核還定義了一系列的方法,用于操作 PTE 的相關(guān)權(quán)限比特位,這些方法三個(gè)一組,分別用于相應(yīng)權(quán)限比特位的查詢,設(shè)置,清除操作,定義在內(nèi)核文件 /arch/x86/include/asm/pgtable.h 中:

          // 查詢 PTE 指向的物理內(nèi)存頁(yè)是否在內(nèi)存中
          static inline int pte_present(pte_t a)

          // 查詢內(nèi)存頁(yè)是否可寫
          static inline int pte_write(pte_t pte)
          // 設(shè)置內(nèi)存頁(yè)可寫
          static inline pte_t pte_mkwrite(pte_t pte)
          // 禁止讀寫
          static inline pte_t pte_wrprotect(pte_t pte)

          // 查詢 PTE 是否是 global 
          static inline int pte_global(pte_t pte)
          static inline pte_t pte_mkglobal(pte_t pte)
          static inline pte_t pte_clrglobal(pte_t pte)

          // 查詢 PTE 指向的內(nèi)存頁(yè)是否是臟頁(yè)
          static inline int pte_dirty(pte_t pte)
          static inline pte_t pte_mkclean(pte_t pte)
          static inline pte_t pte_mkdirty(pte_t pte)

          // 查詢 PTE 指向的內(nèi)存頁(yè)是否最近被訪問(wèn)過(guò)
          static inline int pte_young(pte_t pte)
          static inline pte_t pte_mkold(pte_t pte)
          static inline pte_t pte_mkyoung(pte_t pte)

          4.1.2. 32 位頁(yè)目錄項(xiàng) PDE

          image.png

          同 PTE 一樣,PDE 在 32 位系統(tǒng)中也是用 unsigned long 類型來(lái)描述的,同樣也是占用 4 個(gè)字節(jié)大小。

          typedef unsigned long pgdval_t;

          PDE  是用來(lái)指向一級(jí)頁(yè)表的起始物理內(nèi)存地址的,而頁(yè)表的本質(zhì)是一個(gè)物理內(nèi)存頁(yè)(4K 大小),因此頁(yè)表的起始內(nèi)存地址也是按照 4K 對(duì)齊的,后 12 位全部為 0 ,我們可以繼續(xù)用 PDE 的低 12 位來(lái)標(biāo)記頁(yè)目錄項(xiàng)的權(quán)限位:

          image.png

          這里和頁(yè)表中 PTE 的權(quán)限位不同的是,PDE 中的第 6 個(gè)比特位臟頁(yè)標(biāo)記位沒(méi)有了,因?yàn)?PDE 指向的是一級(jí)頁(yè)表,頁(yè)表并不是一個(gè)文件頁(yè),所以臟頁(yè)標(biāo)記在這里就沒(méi)有意義了。

          還有就是 PDE 中的第 8 比特位,Global 全局標(biāo)記位也沒(méi)有了,因?yàn)?TLB 緩存的 PTE 而不是 PDE,所以不需要設(shè)置 Global 標(biāo)記來(lái)防止進(jìn)程切換導(dǎo)致 TLB flush。

          最后一個(gè)不同的是 PDE 中的第 7 比特位由 PTE 中的 PAT 標(biāo)記變?yōu)榱?PS 標(biāo)記位。那么這個(gè) PS 比特位在這里是干什么用的呢?

          當(dāng) PS 標(biāo)記為 0 的時(shí)候,PDE 的映射關(guān)系確實(shí)如本小節(jié)第一張圖中所示,PDE 指向一級(jí)頁(yè)表的起始內(nèi)存地址,這種情況下,PDE 的作用確實(shí)是我們前邊介紹的頁(yè)目錄項(xiàng)的作用。

          但是當(dāng) PS 標(biāo)記為 1 的時(shí)候,PDE 就會(huì)被內(nèi)核拿來(lái)當(dāng)做 PTE 使用,不過(guò)這個(gè) ”PTE“ 比較特殊,其特殊之處在于該 PDE 會(huì)指向一個(gè)大頁(yè)內(nèi)存,這個(gè)物理內(nèi)存頁(yè)不是普通的 4K 大小,而是 4M 大小。

          image.png

          筆者在前面的小節(jié)中曾介紹過(guò),在二級(jí)頁(yè)表體系下,頁(yè)目錄表中的一個(gè) PDE 可以映射的物理內(nèi)存空間是 4M ,既然這樣,PDE 也可以直接指向一張 4M 的內(nèi)存大頁(yè)。為什么內(nèi)核還需要支持大頁(yè)內(nèi)存呢?

          我們都知道 Linux 管理內(nèi)存的最小單位是 page,每個(gè) page 描述 4K 大小的物理內(nèi)存,但在一些內(nèi)存敏感的使用場(chǎng)景中,用戶往往期望使用一些巨型大頁(yè)。

          因?yàn)檫@些巨型頁(yè)要比普通的 4K 內(nèi)存頁(yè)要大很多,所以遇到缺頁(yè)中斷的情況就會(huì)相對(duì)減少,由于減少了缺頁(yè)中斷所以性能會(huì)更高。

          另外,由于巨型頁(yè)比普通頁(yè)要大,所以巨型頁(yè)需要的頁(yè)表項(xiàng)要比普通頁(yè)要少,頁(yè)表項(xiàng)里保存了虛擬內(nèi)存地址與物理內(nèi)存地址的映射關(guān)系,當(dāng) CPU 訪問(wèn)內(nèi)存的時(shí)候需要頻繁通過(guò) MMU 訪問(wèn)頁(yè)表項(xiàng)獲取物理內(nèi)存地址,由于要頻繁訪問(wèn),所以頁(yè)表項(xiàng)一般會(huì)緩存在 TLB 中,因?yàn)榫扌晚?yè)需要的頁(yè)表項(xiàng)較少,所以節(jié)約了 TLB 的空間同時(shí)降低了 TLB 緩存 MISS 的概率,從而加速了內(nèi)存訪問(wèn)。

          還有一個(gè)使用巨型頁(yè)受益場(chǎng)景就是,當(dāng)一個(gè)內(nèi)存占用很大的進(jìn)程(比如 Redis)通過(guò) fork 系統(tǒng)調(diào)用創(chuàng)建子進(jìn)程的時(shí)候,會(huì)拷貝父進(jìn)程的相關(guān)資源,其中就包括父進(jìn)程的頁(yè)表,由于巨型頁(yè)使用的頁(yè)表項(xiàng)少,所以拷貝的時(shí)候性能會(huì)提升不少。

          既然 PS 標(biāo)記為 1 的情況下,PDE 指向的是一個(gè) 4M 的物理大頁(yè)內(nèi)存,這種情況下內(nèi)核就把 PDE 當(dāng)做一個(gè)特殊的 ”PTE“ 使用了,所以 PDE 中的比特位布局又發(fā)生了變化,不過(guò)大部分還是和 PTE  一樣的。

          image.png

          不過(guò)這里筆者還是要向大家特殊說(shuō)明一下,第 13 到 31 比特位的作用,粗略的從總體來(lái)講這個(gè)范圍的比特位確實(shí)是用來(lái)保存 4M 大頁(yè)的起始內(nèi)存地址的,所以筆者直接在 31:13 范圍內(nèi)的比特位直接標(biāo)記成 4M 大頁(yè)的起始內(nèi)存地址。

          但是進(jìn)一步細(xì)分來(lái)說(shuō),其實(shí) 4M 內(nèi)存大頁(yè)的起始地址都是按照 4M 對(duì)齊的,也就是說(shuō) 4M 大頁(yè)的起始內(nèi)存地址的后 22 位全部為 0 ,我們只需要用 10 個(gè)比特位就可以標(biāo)記了,事實(shí)上,4M 大頁(yè)的起始內(nèi)存地址在內(nèi)核中就是使用 31:22 范圍內(nèi)的比特標(biāo)記的,剩下的比特用來(lái)做內(nèi)存地址的擴(kuò)展使用,不過(guò)這個(gè)和本文主旨無(wú)關(guān),筆者就直接忽略了。

          和 PTE 一樣,PDE 的相關(guān)權(quán)限比特位也定義在內(nèi)核文件:/arch/x86/include/asm/pgtable_types.h 中:

          #define _PAGE_BIT_PSE  7 /* 4 MB page */
          // 從 PDE 中提取 PS 比特位掩碼
          #define _PAGE_PSE (_AT(pdeval_t, 1) << _PAGE_BIT_PSE)

          我們可以通過(guò)如下位運(yùn)算,從 PDE 中提取 PS 比特位來(lái)確定該 PDE 指向的是一級(jí)頁(yè)表還是 4M 大頁(yè)。

          native_pde_val(pde) & _PAGE_PSE

          4.2 四級(jí)頁(yè)表

          在 32 位系統(tǒng)中,內(nèi)核主要采用二級(jí)頁(yè)表體系來(lái)進(jìn)行虛擬內(nèi)存尋址,但是到了 64 位系統(tǒng)中,二級(jí)頁(yè)表明顯就不夠用了,因?yàn)槎?jí)頁(yè)表最多只能映射 4G 的物理內(nèi)存空間,而 64 位系統(tǒng)中,進(jìn)程的虛擬尋址空間是巨大的,進(jìn)程的用戶態(tài)需要尋址 128T 的虛擬內(nèi)存空間,內(nèi)核態(tài)也有 128T 的虛擬內(nèi)存空間。

          64位系統(tǒng)中虛擬內(nèi)存空間整體布局.png

          為了能夠?qū)ぶ愤@么大的虛擬內(nèi)存空間,內(nèi)核在 64 位系統(tǒng)中引入了四級(jí)頁(yè)表體系,當(dāng)我們清楚了二級(jí)頁(yè)表的虛擬尋址過(guò)程,四級(jí)頁(yè)表就很簡(jiǎn)單了,不就是多引入了兩級(jí)頁(yè)目錄么,前面小節(jié)介紹的多級(jí)頁(yè)表的本質(zhì)還是不變的。

          image.png

          64 位系統(tǒng)中的四級(jí)頁(yè)表相比 32 位系統(tǒng)中的二級(jí)頁(yè)表來(lái)說(shuō),多引入了兩個(gè)層級(jí)的頁(yè)目錄,分別是四級(jí)頁(yè)表和三級(jí)頁(yè)表,四級(jí)頁(yè)表體系完整的映射關(guān)系如下圖所示:

          image.png

          但是在內(nèi)核中一般不這么叫,內(nèi)核中稱上圖中的四級(jí)頁(yè)表為全局頁(yè)目錄 PGD(Page Global Directory),PGD 中的頁(yè)目錄項(xiàng)叫做 pgd_t,PGD 是四級(jí)頁(yè)表體系下的頂級(jí)頁(yè)表,保存在進(jìn)程 struct mm_struct 結(jié)構(gòu)中的 pgd 屬性中,在進(jìn)程調(diào)度上下文切換的時(shí)候,由內(nèi)核通過(guò) load_new_mm_cr3 方法將 pgd 中保存的頂級(jí)頁(yè)表虛擬內(nèi)存地址轉(zhuǎn)換物理內(nèi)存地址,隨后加載到 cr3 寄存器中,從而完成進(jìn)程虛擬內(nèi)存空間的切換。

          上圖中的三級(jí)頁(yè)表在內(nèi)核中稱之為上層頁(yè)目錄 PUD(Page Upper Directory),PUD 中的頁(yè)目錄項(xiàng)叫做 pud_t 。

          二級(jí)頁(yè)表在這里也改了一個(gè)名字叫做中間頁(yè)目錄 PMD(Page Middle Directory),PMD 中的頁(yè)目錄項(xiàng)叫做 pmd_t,最底層的用來(lái)直接映射物理內(nèi)存頁(yè)面的一級(jí)頁(yè)表,名字不變還叫做頁(yè)表(Page Table)

          由于在四級(jí)頁(yè)表體系下,又多引入了兩層頁(yè)目錄(PGD,PUD),所以導(dǎo)致其通過(guò)虛擬內(nèi)存地址定位 PTE 的步驟又增加了兩步,首先需要定位頂級(jí)頁(yè)表 PGD 中的頁(yè)目錄項(xiàng) pgd_t,pgd_t 指向的 PUD 的起始內(nèi)存地址,然后在定位 PUD 中的頁(yè)目錄項(xiàng) pud_t,后面的流程就和二級(jí)頁(yè)表一樣了。

          因此 64 位的虛擬內(nèi)存地址格式也就隨著發(fā)生了變化:

          image.png

          32 位系統(tǒng)中的頁(yè)目錄表,頁(yè)表和 64 位系統(tǒng)中的頁(yè)目錄表,頁(yè)表在內(nèi)核中都是使用一個(gè)普通 4K 的物理內(nèi)存頁(yè)存儲(chǔ)映射關(guān)系的,不同的是 64 位系統(tǒng)中的頁(yè)表中的 PTE 以及頁(yè)目錄表(PGD,PUD,PMD)中的 PDE 都是占用 8 個(gè)字節(jié),在內(nèi)核中都是使用 unsigned long 類型描述:

          // 定義在內(nèi)核文件:/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;

          // 定義在內(nèi)核文件:/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;

          內(nèi)核這里使用 struct 結(jié)構(gòu)來(lái)包裹 unsigned long 類型的目的是要確保這些頁(yè)目錄項(xiàng)以及頁(yè)表項(xiàng)只能被專門的輔助函數(shù)訪問(wèn),不能直接訪問(wèn)。

          一張頁(yè)表 4K 大小,頁(yè)表中的一個(gè) PTE 占用 8 個(gè)字節(jié),所以在 64 位系統(tǒng)中一張頁(yè)表只能包含 512 個(gè) PTE,在內(nèi)核中使用 PTRS_PER_PTE 常量來(lái)表示一張頁(yè)表中可以容納的 PTE 個(gè)數(shù),用 PAGE_SHIFT 常量表示一個(gè)物理內(nèi)存頁(yè)的大小:2^PAGE_SHIFT。

          /*
           * entries per page directory level
           */

          #define PTRS_PER_PTE 512

          /* PAGE_SHIFT determines the page size */
          #define PAGE_SHIFT  12

          要尋址頁(yè)表中這 512 個(gè)  PTE,我們用 9 個(gè) bit 就可以了,因此上圖虛擬內(nèi)存地址中的 一級(jí)頁(yè)表中的 PTE 偏移 占用 9 個(gè) bit 位。而一個(gè) PTE 可以映射 4K 大小的物理內(nèi)存(一個(gè)物理內(nèi)存頁(yè)),所以在 64 位的四級(jí)頁(yè)表體系下,一張一級(jí)頁(yè)表可以映射的物理內(nèi)存空間大小為 2M 大小。

          image.png

          一張中間頁(yè)目錄 PMD 也是 4K 大小,PMD  中的頁(yè)目錄項(xiàng) pmd_t 也是占用 8 個(gè)字節(jié),所以一張 PMD 中只能容納 512 個(gè) pmd_t,內(nèi)核中使用 PTRS_PER_PMD 常量來(lái)表示 PMD 中可以容納多少個(gè)頁(yè)目錄項(xiàng) pmd_t。因次 64 位虛擬內(nèi)存地址中的 PMD中的頁(yè)目錄偏移 使用 9 個(gè) bit 就可以表示了。

          /*
           * PMD_SHIFT determines the size of the area a middle-level
           * page table can map
           */

          #define PMD_SHIFT 21
          #define PTRS_PER_PMD 512

          而一個(gè) pmd_t 指向一張一級(jí)頁(yè)表,所以一個(gè) pmd_t 可以映射的物理內(nèi)存為 2M,內(nèi)核中使用 PMD_SHIFT 常量來(lái)表示一個(gè) pmd_t 可以映射的物理內(nèi)存范圍:2^PMD_SHIFT。一張 PMD 可以映射 1G 的物理內(nèi)存。

          image.png

          同理我們知道,一張上層頁(yè)目錄 PUD 中可以容納 512 個(gè)頁(yè)目錄項(xiàng) pud_t,內(nèi)核中使用 PTRS_PER_PUD 常量來(lái)表示 PUD 中可以容納多少個(gè)頁(yè)目錄項(xiàng) pud_t。 64 位虛擬內(nèi)存地址中的 PUD中的頁(yè)目錄偏移 也是使用 9 個(gè) bit 就可以表示了。

          /*
           * 3rd level page
           */

          #define PUD_SHIFT 30
          #define PTRS_PER_PUD 512

          內(nèi)核中使用 PUD_SHIFT 常量來(lái)表示一個(gè) pud_t 可以映射的物理內(nèi)存范圍:2^PUD_SHIFT,一個(gè) pud_t 指向一張 PMD,因此可以映射 1G 的物理內(nèi)存。一張 PUD 可以映射 512G 的物理內(nèi)存。

          image.png

          一樣的道理,頂級(jí)頁(yè)目錄 PGD 中可以容納的頁(yè)目錄 pgd_t 個(gè)數(shù) PTRS_PER_PGD = 512, 64 位虛擬內(nèi)存地址中的 PGD中的頁(yè)目錄偏移 也是使用 9 個(gè) bit 就可以表示了,一個(gè) pgd_t 可以映射的物理內(nèi)存為 2^PGDIR_SHIFT = 512 G。一張 PGD 可以映射的物理內(nèi)存為 256 T,可以說(shuō)是非常非常巨大了。

          /*
           * 4th level page in 5-level paging case
           */

          #define PGDIR_SHIFT  39
          #define PTRS_PER_PGD  512

          通過(guò)以上內(nèi)容介紹,我們就得到了 64 位虛擬內(nèi)存地址中的比特位布局情況:

          image.png
          • PAGE_SHIFT  用來(lái)表示頁(yè)表中的一個(gè) PTE 可以映射的物理內(nèi)存大小(4K)。

          • PMD_SHIFT 用來(lái)表示 PMD 中的一個(gè)頁(yè)目錄項(xiàng) pmd_t 可以映射的物理內(nèi)存大小(2M)。

          • PUD_SHIFT 用來(lái)表示 PUD 中的一個(gè)頁(yè)目錄項(xiàng) pud_t 可以映射的物理內(nèi)存大小(1G)。

          • PGD_SHIFT 用來(lái)表示 PGD 中的一個(gè)頁(yè)目錄項(xiàng) pgd_t 可以映射的物理內(nèi)存大小(512G)。

          這些 XXX_SHIFT  常量在內(nèi)核中除了可以表示對(duì)應(yīng)頁(yè)目錄項(xiàng)映射的物理內(nèi)存大小之外,還可以從一個(gè) 64 位虛擬內(nèi)存地址中獲取其在對(duì)應(yīng)頁(yè)目錄中的偏移。

          比如我們現(xiàn)在需要從一個(gè) 64 位虛擬內(nèi)存地址中獲取它在 PGD 中的偏移,可以講虛擬內(nèi)存地址右移 PGD_SHIFT 位來(lái)得到:

          #define pgd_index(address) ( address >> PGDIR_SHIFT) 

          然后我們可以通過(guò) PGD 的起始內(nèi)存地址加上 pgd_index 就可以得到虛擬內(nèi)存地址在 PGD 中的頁(yè)目錄項(xiàng) pgd_t 了。

          #define pgd_offset_pgd(pgd, address) (pgd + pgd_index((address)))

          同樣的道理,我們可以將虛擬內(nèi)存地址右移 PUD_SHIFT 位,并用掩碼 PTRS_PER_PUD - 1 掩掉高 9 位 , 只保留低 9 位,就可以得到虛擬內(nèi)存地址在 PUD 中的偏移了:

          PTRS_PER_PUD - 1 轉(zhuǎn)換為二進(jìn)制是 9 個(gè) 1,用來(lái)截取最低的 9 個(gè)比特位。

          static inline unsigned long pud_index(unsigned long address)
          {
           return (address >> PUD_SHIFT) & (PTRS_PER_PUD - 1);
          }

          我們通過(guò) pgd_t 獲取 PUD 的起始內(nèi)存地址 + pud_index 得到虛擬內(nèi)存地址對(duì)應(yīng)的 pud_t:

          /* Find an entry in the third-level page table.. */
          static inline pud_t *pud_offset(pgd_t *pgd, unsigned long address)
          {
           return (pud_t *)pgd_page_vaddr(*pgd) + pud_index(address);
          }

          根據(jù)相同的計(jì)算邏輯,我們可以通過(guò) pmd_offset 函數(shù)獲取虛擬內(nèi)存地址在 PMD 中的頁(yè)目錄項(xiàng) pmd_t:

          /* Find an entry in the second-level page table.. */
          static inline pmd_t *pmd_offset(pud_t *pud, unsigned long address)
          {
           return (pmd_t *)pud_page_vaddr(*pud) + pmd_index(address);
          }

          static inline unsigned long pmd_index(unsigned long address)
          {
           return (address >> PMD_SHIFT) & (PTRS_PER_PMD - 1);
          }

          通過(guò) pte_offset_kernel 函數(shù)可以獲取虛擬內(nèi)存地址在一級(jí)頁(yè)表中的 PTE:

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

          現(xiàn)在我們已經(jīng)清楚了內(nèi)核如何通過(guò)一系列的 XXX_offset 方法從虛擬內(nèi)存地址中提取對(duì)應(yīng)頁(yè)目錄以及頁(yè)表中的偏移了,有了這些基礎(chǔ)之后,接下來(lái)我們就來(lái)看一下四級(jí)頁(yè)表體系的尋址過(guò)程:

          image.png
          1. 首先 MMU 會(huì)從 cr3 寄存器中獲取頂級(jí)頁(yè)目錄 PGD 的起始內(nèi)存地址,然后通過(guò) pgd_index 從虛擬內(nèi)存地址中截取 PGD 中的頁(yè)目錄項(xiàng)偏移,這樣就定位到了具體的一個(gè) pgd_t。

          2. pgd_t 中保存的是 PMD 的起始內(nèi)存地址,通過(guò) pud_index 可以從虛擬內(nèi)存地址中截取 PUD 中的頁(yè)目錄項(xiàng)偏移,從而確定虛擬內(nèi)存地址在 PUD 中的頁(yè)目錄項(xiàng) pud_t。

          3. 同樣的道理,根據(jù) pud_t 中保存的 PMD 其實(shí)內(nèi)存地址,在加上通過(guò) pmd_index 獲取到的 PMD 中的頁(yè)目錄項(xiàng)偏移,定位到虛擬內(nèi)存地址在 PMD 中的頁(yè)目錄項(xiàng) pmd_t。

          4. 后面的尋址流程就和二級(jí)頁(yè)表一樣了,pmd_t 指向具體頁(yè)表的起始內(nèi)存地址,通過(guò) pte_index 截取虛擬內(nèi)存地址在 一級(jí)頁(yè)表中的 PTE 偏移,最終定位到一個(gè)具體的 PTE 中,PTE  指向的正是虛擬內(nèi)存地址映射的物理內(nèi)存頁(yè)面,然后通過(guò)虛擬內(nèi)存地址中的低 12 位(物理內(nèi)存頁(yè)內(nèi)偏移),最終確定到一個(gè)具體的物理字節(jié)上。

          4.2.1.  64 位頁(yè)表項(xiàng)

          在 64 位系統(tǒng)中,頁(yè)表中 PTE 在內(nèi)核中使用 unsigned long 類型描述,占用 8 個(gè)字節(jié):

          typedef unsigned long   pteval_t;
          typedef struct { pteval_t pte; } pte_t;

          這 64 位的 PTE 布局如下:

          image.png

          這里我們以 36 位物理內(nèi)存地址(最多 52 位)為例進(jìn)行說(shuō)明,首先物理內(nèi)存頁(yè)的起始內(nèi)存地址都是按照 4K 對(duì)齊的,所以 36 位物理內(nèi)存地址的低 12 位全部為 0 ,和 32 位的 PTE 一樣,內(nèi)核可以用這低 12 位來(lái)描述 PTE 的權(quán)限位,其中 0 到 8 之間的比特位,在 32 位 PTE 和 64 位 PTE 中含義都是一樣的,這里筆者不在贅述。

          R(11) 這里的 R 表示 restart,該比特位主要用于 HLAT paging,當(dāng)遍歷到 R 位是 1 的 PTE 時(shí),MMU 會(huì)重新從 CR3 寄存器開(kāi)始遍歷頁(yè)表,這里我們只做簡(jiǎn)單了解。

          本例中的物理內(nèi)存地址是 36 位的,由于物理內(nèi)存頁(yè)都是 4K 對(duì)齊的,低 12 位全都是 0 ,因此我們只需要在 PTE 中存儲(chǔ)物理內(nèi)存地址的高 24 位即可,這部分存儲(chǔ)在 PTE 中的第 12 到 35 比特位。

          Reserved(51:36) 這些是預(yù)留位,全部設(shè)置為 0 。Protection(62:59) 這 4 個(gè)比特位用于對(duì)物理內(nèi)存頁(yè)的訪問(wèn)進(jìn)行控制。

          XD(63) 該比特位是 64 位 PTE 中新增的,32 位 PTE 中是沒(méi)有的,值為 1 表示該 PTE 所映射的物理內(nèi)存頁(yè)面中的數(shù)據(jù)是可以被執(zhí)行的。

          4.2.2. 64 位頁(yè)目錄項(xiàng)

          在 64 位系統(tǒng)中使用的四級(jí)頁(yè)表體系中一共包含了三個(gè)層級(jí)的頁(yè)目錄,它們分別為:全局頁(yè)目錄 PGD(Page Global Directory),上層頁(yè)目錄 PUD(Page Upper Directory),PMD(Page Middle Directory)。

          這三種類型的頁(yè)目錄中的頁(yè)目錄項(xiàng) PDE 在內(nèi)核中也是使用 unsigned long 類型來(lái)描述的,在 64 位系統(tǒng)中占用 8 個(gè)字節(jié):

          typedef unsigned long   pmdval_t;
          typedef unsigned long   pudval_t;
          typedef unsigned long   pgdval_t;

          typedef struct { pmdval_t pmd; } pmd_t;
          typedef struct { pudval_t pud; } pud_t;
          typedef struct { pgdval_t pgd; } pgd_t;

          64 位 PDE 中的比特位布局如下圖所示:

          image.png

          當(dāng) 64 位 PDE 的 PS(7)  比特位為 0 時(shí),該 PDE 指向的是其下一級(jí)頁(yè)目錄或者頁(yè)表的起始內(nèi)存地址。

          image.png

          當(dāng) 64 位 PDE 的 PS(7) 比特位為 1 時(shí),該 PDE 指向的就是一個(gè)內(nèi)存大頁(yè),對(duì)于 PMD 中的頁(yè)目錄項(xiàng) pmd_t 而言,它指向的是一張 2M 大小的物理內(nèi)存大頁(yè)。

          image.png

          對(duì)于 PUD 中的頁(yè)目錄項(xiàng) pud_t 而言,它指向的是一張 1G 大小的物理內(nèi)存大頁(yè)。

          image.png

          當(dāng) 64 位 PDE 的 PS(7) 比特位為 1 時(shí),這些頁(yè)目錄項(xiàng) PDE 就被當(dāng)做了一個(gè)特殊的 ”PTE“ 對(duì)待了,因此 PDE 中的比特位布局又就變成了 64 位 PTE 中的樣子了。

          image.png

          為了表述嚴(yán)謹(jǐn),這里筆者需要特殊說(shuō)明的一點(diǎn)是,方便讓大家容易理解,筆者將第 12 到 35 比特位直接標(biāo)注為了存儲(chǔ)大頁(yè)內(nèi)存的地址,但事實(shí)上,大頁(yè)內(nèi)存的地址并不需要這么多位來(lái)存儲(chǔ),因?yàn)榇箜?yè)中的內(nèi)存容量比較大,所以大頁(yè)個(gè)數(shù)相對(duì)較少,它們的起始內(nèi)存地址不會(huì)特別高,使用小于 24 位的比特就可以存放了,多出來(lái)的比特位被用作其他目的,但是這些都和本文主旨無(wú)關(guān),筆者就直接忽略掉了。

          內(nèi)核當(dāng)然也會(huì)提供一系列的輔助函數(shù)來(lái)對(duì)頁(yè)目錄進(jìn)行操作:

          • pgd_alloc,pud_alloc,pmd_alloc 負(fù)責(zé)創(chuàng)建初始化對(duì)應(yīng)的頁(yè)目錄。

          • mk_pgd,mk_pud,mk_pmd,mk_pte 用于創(chuàng)建相應(yīng)頁(yè)目錄項(xiàng)和頁(yè)表項(xiàng),并初始化上述比特位。

          • 以及提供相關(guān) pgd_xxx,pud_xxx,pmd_xxx 等形式的輔助函數(shù),用于對(duì)相關(guān)比特位的增刪改查操作。

          5. CPU 的整個(gè)尋址過(guò)程

          本文的重點(diǎn)是要為大家完整清晰地構(gòu)建出內(nèi)核中的頁(yè)表體系,給大家解釋清楚虛擬內(nèi)存是如何與物理內(nèi)存映射起來(lái)的,當(dāng)我們理解了這些之后,在本小節(jié)中,筆者準(zhǔn)備帶大家一起探秘下,當(dāng) CPU 訪問(wèn)一個(gè)進(jìn)程虛擬內(nèi)存空間中的某個(gè)虛擬內(nèi)存地址之后,操作系統(tǒng)背后到底發(fā)生了什么。

          image.png

          經(jīng)過(guò)本文前邊內(nèi)容的介紹,上圖中的這個(gè)四級(jí)頁(yè)表的遍歷過(guò)程,我們已經(jīng)非常的清楚了,我們可以明顯的體會(huì)到整個(gè)地址翻譯的過(guò)程需要的步驟還是比較多的,而 CPU 訪問(wèn)內(nèi)存的操作是非常非常頻繁的,如果我們采用內(nèi)核這種軟件的方式對(duì)頁(yè)表進(jìn)行遍歷,效率會(huì)非常的差。

          而采用一種專門的硬件來(lái)對(duì)軟件進(jìn)行加速,無(wú)疑是一種最簡(jiǎn)單,最直接有效的優(yōu)化手段,于是在 CPU 中引入了一個(gè)專門對(duì)頁(yè)表進(jìn)行遍歷的地址翻譯硬件 MMU(Memory Management Unit),有了 MMU 硬件的加持整個(gè)地址翻譯的過(guò)程就非常的快了。

          事實(shí)上,上圖中展示的四級(jí)頁(yè)表的整個(gè)遍歷操作均是在 MMU 中進(jìn)行的:

          image.png

          經(jīng)過(guò)前邊的內(nèi)容我們知道,這些頁(yè)目錄,頁(yè)表的本質(zhì)其實(shí)在內(nèi)核看來(lái)都是一張普通的 4K 大小的物理內(nèi)存頁(yè),而物理內(nèi)存頁(yè)中經(jīng)常被訪問(wèn)到的內(nèi)存數(shù)據(jù)都是緩存在 CPU 的高速緩存 L1 ,L2,L3 CACHE 中的,這樣可以利用局部性原理加速 CPU 對(duì)內(nèi)存的訪問(wèn)。

          CPU緩存結(jié)構(gòu).png

          所以頁(yè)目錄表和頁(yè)表中那些經(jīng)常被 MMU 遍歷到的頁(yè)目錄項(xiàng) PDE,頁(yè)表項(xiàng) PTE 均會(huì)緩存在 CPU 的 CACHE 中,這樣 MMU 就可以直接從 CPU 高速緩存中獲取 PDE , PTE 了,近一步加速了整個(gè)地址翻譯的過(guò)程。

          當(dāng) MMU 拿到一個(gè) CPU 正在訪問(wèn)的虛擬內(nèi)存地址之后, MMU 首先會(huì)從 CR3 寄存器中獲取頂級(jí)頁(yè)目錄表 PGD 的起始內(nèi)存地址,然后從虛擬內(nèi)存地址中提取出 pgd_index,從而定位到 PGD 中的一個(gè)頁(yè)目錄項(xiàng) pdg_t,MMU 首先會(huì)從 CPU 的高速緩存中去獲取這個(gè) pgd_t,如果 pgd_t 經(jīng)常被訪問(wèn)到,那么此時(shí)它已經(jīng)存在于高速緩存中了,MMU 直接可以進(jìn)行下一級(jí)頁(yè)目錄的地址翻譯操作,避免了慢速的內(nèi)存訪問(wèn)。

          同樣的道理,在 MMU 經(jīng)過(guò)層層的頁(yè)目錄遍歷之后,終于定位到了一級(jí)頁(yè)表中的 PTE,MMU 也是先會(huì)從 CPU 高速緩存中去獲取 PTE,如果 PTE 不在高速緩存中,MMU 才會(huì)去內(nèi)存中去獲取。獲取到 PTE 之后,MMU 就得到了虛擬內(nèi)存地址背后映射的物理內(nèi)存地址了。

          image.png

          在我們引入 MMU 之后,雖然加快了整個(gè)頁(yè)表遍歷的過(guò)程,但是 CPU  每訪問(wèn)一個(gè)虛擬內(nèi)存地址,MMU 還是需要查找一次 PTE,即便是最好的情況,MMU 也還是需要到 CPU 高速緩存中去找一下的,即便這樣開(kāi)銷已經(jīng)很小了,但是我們還是想近一步降低這個(gè)訪問(wèn) CPU CACHE 的開(kāi)銷,讓 CPU 訪存性能達(dá)到極致,那么該怎么辦呢?

          既然 MMU 每次都需要查找一次 PTE,那么我們能不能在 MMU 中引入一層硬件緩存,這樣 MMU 可以把查找到的 PTE 緩存在硬件中,下次再需要的時(shí)候直接到硬件緩存中拿現(xiàn)成的 PTE 就可以了,這樣一來(lái),CPU 的訪存效率又被近一步加快了。

          這個(gè) MMU 中的硬件緩存就叫做 TLB(Translation Lookaside Buffer),TLB 是一個(gè)非常小的,虛擬尋址的硬件緩存,專門用來(lái)緩存被 MMU 翻譯之后的熱點(diǎn) PTE。當(dāng)我們引入 TLB 之后,整個(gè)尋址過(guò)程就又有了一些新的變化:

          image.png

          當(dāng) CPU 將要訪問(wèn)的虛擬內(nèi)存地址送到 MMU 中翻譯時(shí),MMU 首先會(huì)在 TLB 中通過(guò)虛擬內(nèi)存尋址查找其對(duì)應(yīng)的 PTE 是否緩存在 TLB 中,如果 cache hit ,那么 MMU 就可以直接獲得現(xiàn)成的 PTE,避免了漫長(zhǎng)的地址翻譯過(guò)程。

          如果 cache miss,那么 MMU 就需要重新遍歷頁(yè)表,然后獲取 PTE 的內(nèi)存地址,從 CPU 高速緩存或者內(nèi)存中去查找 PTE,慢速路徑下獲取到 PTE 之后,MMU 會(huì)將 PTE 緩存到 TLB 中,加快下一次獲取 PTE 的速度。

          當(dāng) MMU 獲取到 PTE 之后,就可以從 PTE 中拿到物理內(nèi)存頁(yè)的起始地址了,在加上虛擬內(nèi)存地址的低 12 位(物理內(nèi)存頁(yè)內(nèi)偏移)這樣就獲取到了虛擬內(nèi)存地址映射的物理內(nèi)存地址了。

          image.png

          那么當(dāng) MMU 拿到我們最終要訪問(wèn)的物理內(nèi)存地址之后,又該怎么辦呢?

          image.png
          • 當(dāng) MMU 獲取到最終的物理內(nèi)存地址,首先會(huì)根據(jù)物理內(nèi)存地址到 CPU 高速緩存中去查找數(shù)據(jù),如果 cache hit,整個(gè)訪存操作快速結(jié)束。

          • 如果 cache miss,那么 MMU 會(huì)將物理內(nèi)存地址放到系統(tǒng)總線上傳輸,隨后 IO bridge 會(huì)將系統(tǒng)總線上傳輸?shù)牡刂沸盘?hào)傳遞到存儲(chǔ)總線上。

          • 內(nèi)存中的存儲(chǔ)控制器感受到存儲(chǔ)總線上的地址信號(hào)之后,會(huì)將物理內(nèi)存地址從存儲(chǔ)總線上讀取出來(lái)。并根據(jù)物理內(nèi)存地址定位到具體的存儲(chǔ)器模塊,隨后解析物理內(nèi)存地址從  DRAM 芯片中取出對(duì)應(yīng)物理內(nèi)存地址里的數(shù)據(jù)。

          存儲(chǔ)器模塊.png

          關(guān)于 DRAM 芯片的具體訪問(wèn)細(xì)節(jié),感興趣的讀者朋友可以回看下筆者之前文章 《一步一圖帶你深入理解 Linux 虛擬內(nèi)存管理》 的 ”8. 到底什么是物理內(nèi)存地址“ 小節(jié)。

          • 存儲(chǔ)控制器將讀取到的數(shù)據(jù)放到存儲(chǔ)總線傳輸上,隨后 IO bridge 將存儲(chǔ)總線上的數(shù)據(jù)信號(hào)轉(zhuǎn)換為系統(tǒng)總線上的數(shù)據(jù)信號(hào),然后繼續(xù)沿著系統(tǒng)總線傳遞。

          • CPU 芯片感受到系統(tǒng)總線上的數(shù)據(jù)信號(hào)之后,將數(shù)據(jù)從系統(tǒng)總線上讀取出來(lái)并拷貝到寄存器中,隨后通過(guò) ALU  完成計(jì)算。

          總結(jié)

          本文筆者通過(guò)頁(yè)表體系這條主線脈絡(luò),為大家串講了一下之前介紹的虛擬內(nèi)存管理以及物理內(nèi)存管理的相關(guān)內(nèi)容,在我們回顧完虛擬內(nèi)存管理和物理內(nèi)存管理之后,隨后我們引出了虛擬內(nèi)存如何與物理內(nèi)存進(jìn)行映射這個(gè)問(wèn)題,并在這個(gè)過(guò)程中為大家揭露了頁(yè)表的本質(zhì)。

          在我們清楚了頁(yè)表的本質(zhì)之后,筆者又沿著頁(yè)表體系的演進(jìn)這條主線,對(duì)單級(jí)頁(yè)表,二級(jí)頁(yè)表,四級(jí)頁(yè)表展開(kāi)了介紹,其中花了一定的篇幅為大家詳細(xì)的介紹了 32 位和 64 位頁(yè)表項(xiàng)以及頁(yè)目錄想的比特位布局,讓大家真真實(shí)實(shí)的看到了頁(yè)表項(xiàng)和頁(yè)目錄項(xiàng)到底長(zhǎng)什么樣子。

          在這個(gè)基礎(chǔ)之上,筆者又對(duì)虛擬內(nèi)存地址格式的組成進(jìn)行了詳細(xì)的剖析,并深入到內(nèi)核中,帶著大家梳理了內(nèi)核是如何從虛擬內(nèi)存地址中提取對(duì)應(yīng)頁(yè)目錄以及頁(yè)表中的偏移的,在這些基礎(chǔ)之上詳細(xì)介紹了頁(yè)表的整個(gè)遍歷過(guò)程。

          在本文的最后,筆者帶大家又梳理了一遍 CPU 尋址的完整過(guò)程,對(duì)前邊的知識(shí)內(nèi)容做一個(gè)串聯(lián)回顧。

          到這里頁(yè)表相關(guān)的知識(shí)內(nèi)容,筆者就為大家介紹完了,感謝大家的收看,我們下篇文章見(jiàn)~~~~~~

          歷史好文:
          我們又出成績(jī)了!!
          拿了 7 個(gè)大廠 offer,我有話說(shuō)!
          就按這個(gè)方向沖!
          偷偷匯總 23 屆互聯(lián)網(wǎng)大廠薪資
          米哈游穩(wěn)住了!問(wèn)的很基礎(chǔ)!

          瀏覽 4686
          點(diǎn)贊
          評(píng)論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          評(píng)論
          圖片
          表情
          推薦
          點(diǎn)贊
          評(píng)論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  五月丁香涩婷婷基地 | 日韩v亚洲 | 日本一级婬片A片免费看 | 天天干天天色免费 | 午夜特黄 |