【譯】分頁技術簡介

譯註:這篇文章節選自《用Rust編寫一個操做系統》系列。它由淺入深的介紹了分頁技術(Paging)的歷史由來,以及在現代操做系統中的實現。這是我目前讀過的把Paging講的最清楚的一篇文章,所以將它翻譯出來,但願更多的讀者可以受益。
翻譯:喵叔
譯文blog.betacat.io/post/introd…
原文os.phil-opp.com/paging-intr…
做者:Philipp Oppermannhtml

分頁技術是現代操做系統中經常使用的一種內存管理方案。這篇文章介紹了咱們爲何須要內存隔離(memory isolation),內存分段(segmentation)是怎麼實現的,虛擬內存(virtual memory)是什麼,以及分頁技術是怎樣解決內存碎片化(fragmentation)的問題。同時,這裏還探討了在x86_64架構上多級頁表的層次結構。git

該系列博客使用GitHub進行管理和開發,若是你有任何問題或疑問,請給我開一個issue,你也能夠在底部留言。這篇文章的完整代碼能夠在這裏找到。github

內存保護

操做系統的一個主要職責就是隔離應用程序,好比說,web瀏覽器不該該可以干擾到文本編輯器。爲了實現此目的,操做系統會利用硬件的某些功能來確保一個進程的內存空間不會被另外一個進程訪問到。固然,不一樣的硬件和操做系統也會有不一樣的實現。web

舉個栗子,某些ARM Cortex-M處理器(多用於嵌入式系統)具備內存保護單元(MPU),它容許你定義少許具備不一樣訪問權限(例如無訪問、只讀、讀寫)的內存區域。在每次發生內存訪問的時候,MPU都會確保該地址所在的區域具備合法的訪問權限,不然將觸發異常。同時在發生進程切換的時候,操做系統也會及時的更新相應區域及其訪問權限,這樣就確保了每一個進程只能訪問本身的地址空間,從而達到隔離進程的做用。數組

另外一方面,在x86平臺上,硬件支持兩種不一樣的內存保護方式:分段(segmentation)和分頁(paging)。瀏覽器

分段

分段技術早在1978年就已經出現,起初是爲了增長CPU的尋址空間。當時的狀況是CPU只使用16位地址,這將可尋址空間限制爲64KiB(216)。爲了訪問到更高的內存,一些段寄存器被引入進來,每一個段寄存器都包含一個偏移地址。CPU將這些偏移地址做爲基地址,加到應用程序要訪問的內存地址上,這樣它就可使用到高達1MiB的內存空間了。緩存

段寄存器有好幾種,CPU會根據不一樣的內存訪問請求而選擇不一樣的段寄存器:對於取指令請求,代碼段寄存器CS會被使用;堆棧操做(push/pop)會用到堆棧段寄存器SS;其餘操做則使用數據段DS或額外的段ES,後來又添加了兩個能夠自由使用的段寄存器FSGS安全

在分段技術的初版實現中,段寄存器直接存放了段偏移量,而且沒有訪問控制的檢查。後來,隨着保護模式的引入,這種狀況發生了改變。當CPU以這種模式運行時,段寄存器中包含了一個局部或全局描述符表中的索引,該表除了包含偏移地址外,還包含段大小和訪問權限。經過爲每一個進程加載單獨的描述符表,操做系統就能將進程的內存訪問限制到它本身的地址空間內,從而達到隔離進程的做用。bash

經過在實際訪問發生以前修改要訪問的目標地址,分段技術實際上已經在使用一種如今廣爲使用的技術:虛擬內存。架構

虛擬內存

虛擬內存背後的思想是將內存地址從底層存儲設備中抽象出來。即在訪問存儲設備前,增長一個地址轉換的過程。對於上面的分段來講,這裏的轉換過程就是爲內存地址加上一個段偏移量。例如,當一個進程在一個偏移量爲0x1111000的段中訪問內存地址0x1234000時,它實際訪問到的地址是0x2345000

爲了區分這兩種地址類型,咱們將轉換前的地址稱爲虛擬地址(virtual),轉換後的地址稱爲物理地址(physical)。這兩種地址之間的一個重要區別是物理地址是惟一的,而且老是指向同一個內存位置。而虛擬地址則依賴於轉換函數,因此兩個不一樣的虛擬地址徹底有可能指向相同的物理地址。一樣,相同的虛擬地址在不一樣的轉換函數做用下也有可能指向不一樣的物理地址。

下面咱們將同一個程序並行的運行兩次,來看看它的內存映射狀況:

segmentation-same-program-twice

在這裏,相同的程序運行了兩次,可是使用了不一樣的轉換函數。第一個進程實例的段偏移量爲100,所以它的虛擬地址0-150被轉換爲物理地址100-250。第二個進程實例的偏移量爲300,因此它的虛擬地址0-150被轉換爲物理地址300-450。這樣就容許了兩個進程互不干擾地運行相同的代碼而且使用相同的虛擬地址。

這個技術的另外一個優勢是,無論程序內部使用什麼樣的虛擬地址,它均可以被加載到任意的物理內存點。這樣,操做系統就能夠在不從新編譯程序的前提下,充分利用全部的內存。

內存碎片

在將內存地址區分爲虛擬地址和物理地址以後,分段技術做爲鏈接這二者的橋樑就顯得尤其重要。但分段技術的一個問題在於它可能致使內存碎片化。好比,若是咱們想在上面兩個進程的基礎上再運行第三個程序:

segmentation-fragmentation

能夠看到,即使有足夠多的空閒內存,咱們也沒法將第三個進程映射到物理內存中。這裏的主要問題是,咱們須要連續的大塊內存,而不是大量不連續的小塊內存。

解決這種碎片化問題的一個辦法就是,先暫停程序的執行,移動已使用的內存使他們更緊湊一些,而後更新轉換函數,最後再恢復執行:

segmentation-fragmentation-compacted

如今咱們有了足夠的連續空間來運行第三個進程了。

但在碎片整理的過程當中,須要移動大量的內存,這會下降性能。並且這種整理須要按期完成,以避免內存變得過於分散。這使得程序會被隨機的暫停而且失去響應,因此這種方法會使得程序的性能變得不可預測。

綜上,內存碎片化是使得大多數系統再也不使用分段技術的緣由之一。事實上,64位的x86甚至再也不支持分段,而是使用另外一種分頁技術,由於它能夠徹底避免這些碎片問題。

分頁

分頁技術的核心思想是將虛擬內存空間和物理內存空間劃分紅固定大小的小塊,虛擬內存空間的塊稱爲(pages),物理地址空間的塊稱爲(frames)。每個頁均可以單獨映射到一個幀上,這使得咱們能夠將一個大塊的虛擬內存分散的映射到一些不連續的物理幀上。

若是回顧剛纔內存碎片化的示例,咱們能夠看到使用分頁顯然更有優點:

paging-fragmentation

在本例中,咱們的頁大小爲50字節,這意味着咱們的每一個進程空間都被劃分紅了三頁,每頁都單獨映射到一個幀,所以連續的虛擬內存空間能夠被映射到非連續的物理幀上。這就容許咱們在沒有執行碎片整理的狀況下直接運行第三個程序。

隱藏的碎片

與分段相比,分頁使用大量容量較小、但大小固定的內存區域,而不是少許容量較大、但大小可變的區域。由於每一個幀都有相同的大小,因此不會出現因幀過小而沒法使用的狀況,於是也「不會」產生內存碎片。

固然這僅僅是「看起來 」沒有碎片產生,實際上仍是會有一些隱藏的碎片,咱們稱之爲「內部碎片」。發生內部碎片是由於不是每一個內存區域都正巧是頁面大小的整數倍。仍拿上面的程序舉例,假如該程序的大小爲101字節,咱們仍然須要3個大小爲50字節的頁來裝載它,這比實際須要的多佔了49個字節。爲了區分這兩種狀況,咱們把使用分段而引起的碎片稱爲「外部碎片」。

雖然內部碎片也不是咱們想要的,但比起外部碎片來講要好不少。它仍然會浪費一些內存,但好在不須要碎片整理,而且碎片的總量是可預測的(平均每一個內存區域有半頁碎片)。

頁表

能夠看到,可能有數以百萬的的內存頁被映射到了幀,這裏的映射信息是須要額外存放的。在分段技術的實現中,每一個活躍的內存單元都有一個單獨的段寄存器來存放段信息,但這對於分頁來講是不可能的,由於分頁的數量遠遠多於寄存器。分頁使用一個名爲頁表(page table) 的結構來存儲它的映射信息。

對於上面的示例,它的頁表大概長這個樣子:

paging-page-tables

每一個進程都有它本身的頁表,咱們用一個特殊的寄存器來存放指向當前活動頁表的指針。在x86上,該寄存器爲CR3。在每次運行一個進程以前,操做系統將負責把正確的指針放到該寄存器中。

在每次發生內存訪問時,CPU會從寄存器中讀取頁表指針,並從頁表中查找該頁面被映射哪一個幀上。這個過程由硬件完成,對程序徹底透明。爲了加快這裏的轉換過程,許多CPU都有一個特殊的緩存,用來記住上次轉換的結果。

根據架構的不一樣,頁表中的每一項還能夠在flags字段中存儲訪問權限等屬性。在上面的例子中,r/w表示該頁既可讀又可寫。

多級頁表

咱們剛纔看到的那個簡單的頁表存在一個問題:在較大的地址空間中它會浪費內存。例如,假設一個進程使用4個虛擬頁面01_000_0001_000_0501_000_100(這裏的_表示千位分隔符):

single-level-page-table

它只須要4個物理幀,可是頁表中有超過100萬個項目。並且咱們還不能省略空項目,由於這樣的話在地址轉換的過程當中,CPU就不能直接跳轉到正確的頁表項(例如,不能保證第四頁就在頁表的第四位)。

爲了減小內存的浪費,咱們可使用一個二級頁表。這裏的第二級頁表包含的是內存地址區間跟第一級頁表的映射信息。

或許用一個例子能解釋的更清楚一點。咱們假設每一個1級頁表負責一個大小爲10_000的內存區域,那上面的映射關係能夠擴展爲:

multilevel-page-table

這裏,第0頁屬於第一個10_000字節區域,所以它的映射關係落入到第二級頁表的頭一項。這一項指向了一個一級頁表T1,而T1又代表,第0頁被映射到第0個幀。

另外,第1_000_0001_000_0501_000_100頁都屬於第100個10_000字節區域,因此它們使用第二級頁表的第100項。這一項指向了另外一個一級頁表T2,而T2又將這三個頁面分別映射到幀100、150和200。值得注意的是,這裏一級頁表的頁地址不包括區域的偏移量,因此這裏映射1_000_050頁的那一項寫的僅僅是50

咱們在第2級表中仍然有100個空位置,但比以前的百萬個空位置要少得多。這是由於咱們不須要爲10_0001_000_000之間未映射的內存區域建立1級頁表。

兩級頁表的原理能夠擴展到3、四或更多級別。而後,頁表寄存器CR3就指向最高級別的頁表,該表則指向下一個較低級別的表,這個低級別的表再指向另外一個更低一級的表,以此類推。最後,1級頁面表指向映射的幀。該原理一般被稱爲多級分層 頁表。

如今咱們已經瞭解了分頁和多級頁表的工做方式,接下來咱們來看看x86_64架構是如何實現分頁的(下面假設CPU工做在64位模式下)。

x86_64下的分頁

x86_64架構使用4級頁表,頁大小爲4KiB。這裏每級頁表都有固定的512項,每項都爲8個字節,所以每一個頁表的大小爲512 * 8B = 4KiB —— 正巧放滿一頁。

一個虛擬地址的結構以下,它包含每級頁表的索引:

x86_64-table-indices-from-address

能夠看到,每級頁表的索引都是9比特,這是由於每級頁表都有2^9 = 512項。這裏最低的12位表示的是該地址在一個4KiB大小的頁中的偏移量(2^12 bytes = 4KiB)。第48到64位被丟棄,這意味着x86_64實際上只支持48位地址,因此它並非真正的64位。雖然有計劃經過一個5級頁表來將地址擴展到第57位,可是目前尚未生產出支持此功能的處理器。

雖然說第48到64位被丟棄,但也不能將它們設爲任意值。相反,此範圍內的全部bit都必須跟第47位的值同樣,這一方面是爲了保證地址的惟一性,另外一方面也是爲了之後的擴展,好比5級頁表。這被稱爲符號擴展 ,由於它與二進制補碼中的符號擴展很是類似。 若是地址未進行正確的符號擴展,則CPU會拋出異常。

一個地址轉換的實例

讓咱們經過一個例子來了解這整個轉換的過程:

x86_64-page-table-translation

CR3寄存器存放的是當前活動的第4級頁表的物理地址,即整個4級頁表的根表地址。表中的每一項都指向下一個級別表的物理幀,最終,第1級表中的內容則指向被映射的頁幀。值得注意的是,頁表中的全部地址都是物理的而不是虛擬的,不然CPU也須要轉換這些地址(這會致使無休止的遞歸)。

上面頁表的層次結構映射了兩個頁面(藍色)。從頁表索引咱們能夠推斷出這兩個頁面的虛擬地址是0x803FE7F0000x803FE00000。咱們來看看當進程讀取地址0x803FE7F5CE時會發生什麼。首先,咱們將該地址轉換爲二進制,看看它的頁表索引和頁面偏移量:

x86_64-page-table-translation-addresses

使用這些索引,咱們就能夠遍歷頁表的層次結構以肯定該地址對應的物理幀:

  • 首先,咱們從CR3寄存器中讀取第4級頁表的物理地址。
  • 從上面的二進制表示中,咱們能夠看到該地址的第4級索引是1,因此咱們查看第4級頁表的第一項,它告訴咱們第3級表存儲在地址16KiB。
  • 咱們從該地址加載第3級表,並查看索引爲0的內容,它將咱們帶向地址爲24KiB的第2級頁表。
  • 第2級索引是511,所以咱們查看該頁表的最後一個項以找出第1級頁表的地址。
  • 一樣的,從第1級頁表的第127項咱們終於找到該頁被映射到了物理幀12KiB,即十六進制的0xc000。
  • 最後一步就是將頁面偏移量和幀地址加起來,這樣咱們就獲得了該虛擬地址對應的物理地址0xc000 + 0x5ce = 0xc5ce。

x86_64-page-table-translation-steps

第1級頁表的權限標誌位爲r,表示只讀。硬件會強制檢查這些權限,若是咱們嘗試對該頁面進行寫操做,那將會觸發異常。高級別頁表中的權限會限制低級別頁表的權限,所以若是咱們將第3級頁表中的第511項設爲只讀,則由它所指向的頁面都不可寫,即便在第4級頁表中有的頁面權限標誌位爲讀寫

須要注意的是,儘管本示例中,每級頁表僅有一個實例,但實際上,在一個地址空間中,每級頁表一般會有多個實例,包含:

  • 一個第4級頁表
  • 512個第3級頁表(第4級頁表有512項)
  • 512 * 512個第2級頁表(每一個第3級頁表都有512項)
  • 512 * 512 * 512個第1級頁表(每一個第2級頁表都有512項)

頁表結構

x86_64中的頁表其實是一個長度爲512的數組。 用Rust的話說:

#[repr(align(4096))]
pub struct PageTable {
    entries: [PageTableEntry; 512],
}
複製代碼

repr屬性所示,頁表須要跟頁面對齊,即在4KiB的邊界上對齊。這能夠確保一個頁表始終填充滿整個頁面,並容許內部結構的空間優化。

數組中的每一項大小都爲8 bytes(64bits),格式爲:

Bit(s) Name Meaning
0 present 該頁已經被加載到內存中
1 writable 該頁是可寫的
2 user accessible 若是未被設置,只有運行在內核模式下的代碼能夠訪問這個頁面
3 write through caching 寫操做直接反應到內存中
4 disable cache 該頁不使用緩存
5 accessed 在使用該頁後,CPU會設置此位
6 dirty 在寫操做發生後,CPU會設置此位
7 huge page/null 在P1和P4中必須是0,在P3中則建立1GiB頁面,在P2中則建立2MiB頁面
8 global 在地址空間切換的時候,該頁未從緩存中刷新(必須設置CR4寄存器的PGE位)
9-11 available 能夠由操做系統自由使用
12-51 physical address 該頁跟頁幀的52位對齊,或者是下一個頁表
52-62 available 能夠由操做系統自由使用
63 no execute 禁止執行此頁上的代碼(必須設置EFER寄存器中的NXE位)

能夠看到只有位12-51被用來存儲物理幀的地址,其他的位被用做標誌或者能夠被操做系統自由使用。這是創建在咱們老是指向4096 byes整數倍地址的基礎上,這個地址多是另外一個頁表,也有多是一個物理幀。這也意味着第0-11位始終爲零,並且硬件會在使用該地址以前將他們置爲0,所以咱們沒有必要存儲這些位。第52-63位也是如此,由於x86_64架構僅支持52位物理地址(道理跟它僅支持48位虛擬地址相似)。

下面來詳細的解釋下這些標誌位:

  • present用來區分已映射頁面和未映射頁面。當內存用滿以後,操做系統會臨時的將一些頁面交換到磁盤上。隨後,若是該頁面被訪問到了,一個叫缺頁中斷 的異常會被拋出,那麼操做系統就知道須要從磁盤中從新加載這個丟失的頁面,而後再繼續執行應用程序。
  • writableno execute分別表示,該頁的內容是可寫的仍是包含可執行的指令。
  • 當對頁面進行讀或寫操做時,CPU會自動設置accesseddirty標記。操做系統能夠利用這些信息,來決定好比自上次存盤以後,哪些頁能夠被換出或者哪些頁的內容已經被修改過。
  • write through cachingdisable cache用來控制每一頁的緩存。
  • user accessible用來標誌該頁是否能夠被用戶空間的代碼訪問,不然只有內核空間的代碼才能夠訪問。這個特性使得在用戶空間的程序運行時,內核代碼仍然保持住它的映射,從而加快系統調用。然而,Spectre漏洞仍然容許處於用戶空間的代碼讀取這些頁面。
  • global標誌用來告訴硬件,該頁在全部的地址空間中均可用,所以在發生地址空間切換的時候不須要從緩存中刪除(請參閱下面有關TLB的部分)。該標誌一般與一個未被設置的user accessible一塊兒使用,用來將內核代碼映射到全部地址空間。
  • huge page用在第2級或者第3級頁表中表示該頁中的每一項都直接指向一個物理幀。有了這個標誌後,頁面大小將增長512倍,對於第2級的頁表項,頁面大小變爲2MiB = 512 * 4KiB,對於第3級的頁表項,頁面大小變爲1GiB = 512 * 2MiB。使用較大頁面的優勢是,咱們只須要更少的轉換緩存行和更少的頁表。

Rust裏的x86_64包已經有了頁表頁表項這兩種類型,所以咱們不須要本身建立它們。

頁表緩存

上面所說的4級頁表使得虛擬地址的轉換變得很昂貴,由於每次轉換都須要4次內存訪問。爲了提升性能,x86_64架構將最近的幾回轉換緩存在TLB(translation lookaside buffer)中,這樣地址轉換就有可能直接從緩存中讀取結果。

與其餘CPU緩存不一樣,TLB不是徹底透明的,當頁表的內容發生變化時,它不會更新或刪除緩存,這意味着內核必須在修改頁表時手動更新TLB。爲此,有一個名爲invlpg(invalidate page)的特殊CPU指令,用於從TLB中刪除指定頁面的轉換緩存,以便在下一次訪問時從頁表中再次加載該頁面。另外,還能夠經過從新加載CR3寄存器(即模擬一次地址空間切換)來刷新TLB。x86_64包在tlb模塊中將這兩種方法封裝成了不一樣的Rust函數。

切記,在每次頁表修改時都要刷新TLB,不然CPU可能會繼續使用舊的轉換緩存,這會致使很是難以調試且又不肯定的錯誤。

實現

有一件事尚未提到:咱們的內核已經運行在分頁上了。咱們在「A minimal Rust Kernel」那篇文章中添加的引導程序已經設置了一個4級分頁的層次結構,它將內核的每一個頁面都映射到一個物理幀。這樣作主要是由於在x86_64的64位模式下分頁是必需的。

這意味着咱們在內核中使用的每一個內存地址都是一個虛擬地址。訪問地址爲0xb8000的VGA緩衝區會起做用,是由於引導程序對該頁進行了恆等映射 ,即虛擬頁0xb8000被映射到物理幀0xb8000

分頁使咱們的內核已經相對安全,由於每一個超出邊界的內存訪問都會致使頁錯誤,所以不會有隨機的物理內存寫入發生。引導程序甚至爲每一個頁都設置了正確的訪問權限,這意味着只有包含代碼的頁是可執行的,以及只有數據頁是可寫入的。

缺頁中斷

讓咱們試着經過訪問內核以外的一段內存來引發缺頁中斷。首先,咱們建立一個缺頁中斷處理程序並將其註冊到IDT中,這樣咱們就能夠將出錯信息打印出來,而不是看到一個籠統的double fault

// in src/interrupts.rs

lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();

        […]

        idt.page_fault.set_handler_fn(page_fault_handler); // new

        idt
    };
}

use x86_64::structures::idt::PageFaultErrorCode;

extern "x86-interrupt" fn page_fault_handler(
    stack_frame: &mut ExceptionStackFrame,
    _error_code: PageFaultErrorCode,
) {
    use crate::hlt_loop;
    use x86_64::registers::control::Cr2;

    println!("EXCEPTION: PAGE FAULT");
    println!("Accessed Address: {:?}", Cr2::read());
    println!("{:#?}", stack_frame);
    hlt_loop();
}
複製代碼

在發生缺頁中斷時,CPU會將引發該錯誤的虛擬地址放置到CR2寄存器中。咱們使用x86_64包的Cr2::read函數來讀取並打印它。一般,PageFaultErrorCode會提供該錯誤的更多信息,但目前LLVM存在一個傳遞無效錯誤代碼的bug,所以咱們暫時忽略它。若是缺頁中斷不被解決,咱們的代碼就沒法繼續執行,所以最後咱們進入了一個hlt_loop的循環。

如今,讓咱們來訪問內核以外的一段內存:

// in src/main.rs

#[cfg(not(test))]
#[no_mangle]
pub extern "C" fn _start() -> ! {
    use blog_os::interrupts::PICS;

    println!("Hello World{}", "!");

    // set up the IDT first, otherwise we would enter a boot loop instead of
    // invoking our page fault handler
    blog_os::gdt::init();
    blog_os::interrupts::init_idt();
    unsafe { PICS.lock().initialize() };
    x86_64::instructions::interrupts::enable();

    // new
    let ptr = 0xdeadbeaf as *mut u32;
    unsafe { *ptr = 42; }

    println!("It did not crash!");
    blog_os::hlt_loop();
}
複製代碼

運行以後,能夠看到咱們的缺頁中斷處理程序被調用了:

qemu-page-fault

並且CR2寄存器確實包含咱們試圖訪問的地址:0xdeadbeaf

咱們也能夠看到當前的指令指針是0x20430a,因此咱們知道這個地址指向一個代碼頁。代碼頁由引導加載程序以只讀的方式映射,所以該地址容許讀操做,而不容許寫操做。讓咱們把0xdeadbeaf指針改成0x20430a來測試一下:

// Note: The actual address might be different for you. Use the address that
// your page fault handler reports.
let ptr = 0x20430a as *mut u32;
// read from a code page -> works
unsafe { let x = *ptr; }
// write to a code page -> page fault
unsafe { *ptr = 42; }
複製代碼

若是註釋掉最後一行,咱們看到讀操做有效,但寫操做會致使頁錯誤。

訪問頁表

讓咱們看看內核運行時的頁表:

// in src/main.rs

#[cfg(not(test))]
#[no_mangle]
pub extern "C" fn _start() -> ! {
    […] // initialize GDT, IDT, PICS

    use x86_64::registers::control::Cr3;

    let (level_4_page_table, _) = Cr3::read();
    println!("Level 4 page table at: {:?}", level_4_page_table.start_address());

    println!("It did not crash!");
    blog_os::hlt_loop();
}
複製代碼

咱們使用x86_64包的Cr3::read函數從CR3寄存器中讀取當前活動的第4級頁表,這個函數的返回值是一個二元組:PhysFrameCr3Flags,咱們只關心頁幀(frame),因此暫時忽略第二個返回值。

運行以後,能夠看到這樣的輸出:

Level 4 page table at: PhysAddr(0x1000)
複製代碼

能夠看到,輸出的是一個PhysAddr類型,它包裝的是當前活動的第4級頁表的物理地址0x1000,如今的問題就變成:咱們如何從內核中訪問該頁表?

當分頁處於激活狀態時,咱們不可能直接訪問物理內存,不然一個程序就能夠很輕易地繞過內存保護來訪問另外一個程序的內存。所以,訪問該表的惟一方法就是經過一個被映射到物理地址0x1000的虛擬頁。而爲頁表的物理幀建立映射是一個常見的問題,好比當內核在爲新線程分配堆棧時,它就須要訪問頁表。

下一篇文章將詳細介紹此問題的解決方案。如今,咱們只須要知道引導程序使用一種叫遞歸頁表 的技術將虛擬地址空間的最後一頁映射到第4級頁表的物理幀。虛擬地址空間的最後一頁是0xffff_ffff_ffff_f000,因此咱們能夠經過它來讀取該表的內容:

// in src/main.rs

#[cfg(not(test))]
#[no_mangle]
pub extern "C" fn _start() -> ! {
    […] // initialize GDT, IDT, PICS

    let level_4_table_pointer = 0xffff_ffff_ffff_f000 as *const u64;
    for i in 0..10 {
        let entry = unsafe { *level_4_table_pointer.offset(i) };
        println!("Entry {}: {:#x}", i, entry);
    }

    println!("It did not crash!");
    blog_os::hlt_loop();
}
複製代碼

首先,咱們將這個地址強制轉換爲一個u64類型的指針,由於正如上一節所提到的,每一個頁表項都是8個字節(64位),所以u64正巧能夠裝下一項。而後再使用for循環打印頁表的前10項,在循環內部,咱們使用unsafe塊來讀取原始指針,使用offset方法來執行指針的偏移運算。

運行以後,是這樣的結果:

qemu-print-p4-entries

一樣,由頁表結構那一節可知,第0項的0x2023表示該項存在、可寫、已被CPU訪問過,而且映射到幀0x2000。第1項也有相同的標誌位,而且被映射到0x6e2000,除此以外,它還有一個表示該頁已被寫入的dirty標誌。第2-9項全爲0,表示它們還未被加載到內存中,所以這些範圍內的虛擬地址尚未被映射到任何物理地址上。

固然,若是不想使用不安全的原始指針,咱們也可使用x86_64包所提供的PageTable類型:

// in src/main.rs

#[cfg(not(test))]
#[no_mangle]
pub extern "C" fn _start() -> ! {
    […] // initialize GDT, IDT, PICS

    use x86_64::structures::paging::PageTable;

    let level_4_table_ptr = 0xffff_ffff_ffff_f000 as *const PageTable;
    let level_4_table = unsafe {&*level_4_table_ptr};
    for i in 0..10 {
        println!("Entry {}: {:?}", i, level_4_table[i]);
    }

    println!("It did not crash!");
    blog_os::hlt_loop();
}
複製代碼

咱們先將0xffff_ffff_ffff_f000轉換爲一個原始指針,而後再把它變爲一個Rust的引用,這個操做仍然須要在unsafe塊中進行,由於編譯器並不知道能不能訪問該地址。在轉換完成以後,咱們就有了一個安全的&PageTable類型,它能讓咱們使用數組索引的方式來訪問各個頁表項。

該類型還爲每一個頁表項的屬性提供了描述信息,所以在打印的時候咱們就能夠直觀的看到,哪些標誌位被設置了:

qemu-print-p4-entries-abstraction

下一步就是順着第0或者1個頁表項的指針追蹤到第3級頁表。但一樣的問題,這裏的0x20000x6e5000都是物理地址,咱們不能直接訪問它們。這個問題將在下一篇文章中解決。

總結

本文介紹了兩種內存保護技術:分段和分頁。前者使用的是可變大小的內存區域,但會引起外部碎片;後者使用的是固定大小的頁面,並支持更細粒度的訪問權限控制。

分頁技術將頁的映射信息存在頁表當中,頁表之間能夠組織出支持多個級別的層次結構。x86_64架構使用的是4級頁表和4KiB頁大小。硬件會自動遍歷頁表,並在TLB中緩存轉換結果。該緩存區不會自動更新,所以須要在每次頁表有變化的時候手動刷新。

咱們瞭解到內核已經運行在分頁技術上,而且非法的內存訪問會致使頁錯誤。咱們還嘗試訪問當前活動的頁表,但只能訪問到第4級頁表,由於頁表中存儲的是物理地址,而咱們不能從內核中直接訪問物理地址。

下一步?

下一篇文章將在這篇文章的基礎上更深刻一步,介紹一種叫遞歸頁表 的技術,用來解決咱們剛纔遇到的內核代碼不能直接訪問頁表的問題。這個技術容許咱們遍歷整個頁表的層次結構,而且用軟件實現地址轉換功能。同時,咱們還將介紹怎樣在已有的頁表中添加一個新的映射關係。

相關文章
相關標籤/搜索