1. 程式人生 > 實用技巧 >作業系統——記憶體分頁(八)

作業系統——記憶體分頁(八)

作業系統——記憶體分頁(八)

2020-09-1923:48:03 hawk


概述

  這一篇部落格和後面的幾篇部落格,將會在保護模式的基礎上,接觸到更多的硬體,並對其進行相關的操作。這一篇部落格主要完成對於虛擬記憶體的相關介紹和實驗。


虛擬地址

  實際上,雖然通過loader程式,我們已經從真實模式成功進入了保護模式,地址空間也達到了4GB。但是需要明確的是,這個記憶體空間仍然是所有程序(包括作業系統)共享這4GB的記憶體空間的。如果我們把段基址:段內偏移地址成為線性地址的話,則線性地址是唯一的,也就是同一時間線性地址應該只能屬於某一個程序。

  然而這就會產生一個問題,實際上實體地址可能只有32MB(bochs虛擬機器設定),但是實際上地址匯流排為32位,也就是每個程序對應的記憶體空間理論上也該是4GB。那麼自然的,我們可以想到,每個程序對應的記憶體空間就是我們標題,虛擬空間。下面我們將具體分析一下虛擬空間的產生原因以及實現。

記憶體分頁

  從最開始的真實模式,到我們前面實現的保護模式,我們一直都是在記憶體分段機制下進行工作的。但是如果實體記憶體不足,或者記憶體碎片過多而無法容納新的程序的話,這種分段機制反而成了累贅。我們考慮下圖的情況

  下面這張圖是一張記憶體管理中比較經典的佈局。對於目前的記憶體分段機制來說,段基址:段內偏移產生的線性地址就是實體地址,而程式引用的線性地址是連續的,所以程式所分配到的實體地址也應該是連續的。所以雖然空閒空間是35M,大於程序D所需要的記憶體空間大小,但是由於空閒空間沒有足夠大的連續空間,因此仍然無法直接分配給程序D。因此合理的方案就是將一些老的程序中不常用的段換出到硬碟上,騰出空間給新程序使用,等老程序再次需要該段的時候,再從硬碟上載入到記憶體上即可,如下圖所示

  

  對於如何將記憶體進行換入換出等操作,我之前是比較迷惑的,這裡這本書比較好的解開了我的迷惑,下面說明一下。實際上對於作業系統來說,我們所寫的程式碼僅僅是完成了某件事的一部分而已,還有很大一部分是CPU上的硬體負責的,由CPU自動完成——比如呼叫一個函式時,CPU自動將返回地址壓入棧;進入中斷時,壓入返回地址、標誌暫存器,根據當前特權級決定是否壓入當前棧段暫存器及指標等。

  而對於記憶體段的換入,實際上CPU在引用一個段的時候,都要先檢視段描述符,也就是我們前面實現的GDT/LDT的每一條值。如果描述符中的P位為0,則說明記憶體中不存在該段,此時CPU會丟擲NP異常,轉而去執行中斷描述符表中的NP異常應對的中斷處理程式,此中斷處理程式是作業系統負責提供的,主要工作是將相應的段從外存(比如硬碟)中載入到記憶體,並且將該段描述符的P重新置為1即可。這樣子就完成了記憶體段的換入。

  而對於記憶體段的移出,實際上是類似的。對於段描述符的A位,每次CPU在完成訪問後,會自動將段描述符的A位置為1。而作業系統每發現該位為1後就重新將該位清0。這樣在一個週期內統計該位為1的次數就知道該段的使用頻率了,也就是當實體記憶體不足的時候,我們只需要將使用頻率最低的段換出到硬碟上,並且將該段的描述符的P位置為0即可完成換出操作。

  這裡鋪墊完前面的基礎,現在轉入到我們的正題——為什麼要採取分頁機制。實際上也很好理解,一方面,如果實體記憶體特別的小,以至於無法容納程序的任何一個段,那麼實際上也就無法執行程序了,自然更無法完成段的換入換出;另一方面,如果程序的段都比較大,每一個進行換入換出操作都需要大量的IO操作,會導致系統響應奇慢無比,也是不現實的。而這些問題的本質,實際上都是由目前的記憶體機制上線性地址就是實體地址,而編譯器產生的線性地址是連續的,因此這也就導致了每一個段的實體地址也必須是連續的。因此,如果我們可以解除線性地址與實體地址的一一對應的關係,然後通過某種對映關係,將線性地址重新對映到任意實體地址上,則我們可以解決程式使用的線性地址連續,而實際的實體地址不連續的問題。而這種對映關係實際上CPU已經給我們了最大的硬體支援,即頁表。


頁表

  首先需要明確的是,分頁機制仍然是建立在分段機制的基礎之上的,這也是電腦科學的優良傳統——相容性。對於記憶體訪址來說,前面一直是段基址:段內偏移,分頁機制也是在這個基礎上的,只不過加了一步對映操作而已,如下圖所示

  實際上分頁機制的主體思想就是:通過對映,將連續的線性地址與任意實體記憶體地址相關聯,邏輯連續的線性地址與其對應的實體地址可以不連續,也就是一方面,分頁機制將線性地址轉換為了實體地址;另一方面,分頁機制用大小相等的頁替代了大小不等的段。

  下面我們概述一下作業系統在分頁機制下載入程序的過程。

  如上圖所示,其表示一個程序的地址轉換過程。每一個程序都有屬於自己的4GB虛擬地址空間。而圖上最右側的4GB實體地址空間(實際的記憶體空間)是屬於所有程序,包括作業系統在內的共享資源。其中標記已分配頁的記憶體塊,表示已經分配給了其他程序;而當前程序只能使用未分配的頁。而前面也講過,分頁機制是建立在分段基址的基礎上的,每載入一個程序,作業系統都會按照程序中各段的起始範圍,在程序自己的4GB虛擬地址中找可用地址分配記憶體段,此時僅僅是邏輯上的記錄,並沒有分配真實的實體地址空間。隨後作業系統才會開始為這些虛擬記憶體頁分配真實的實體記憶體頁——會在實體記憶體中查詢可用的頁,然後再頁表中進行等級,從而完成虛擬頁到物理頁的分配。

一級頁表

  實際上我們可以將地址分為高部分和低部分。如果我們人為的將低部分理解為一種記憶體單位的大小,將高部分理解為上述記憶體單位的數量,那麼對於32位地址空間,也就是記憶體單位大小 * 記憶體單位數目 = 4GB。而頁表就是將對應的頁索引和上述記憶體塊的一一對映即可。如果記憶體單元大小增大,則記憶體單元數目就減少,則頁表中表示索引所消耗的儲存就少,並且索引數目頁減少,從而節省了頁表空間。實際上,對於上面一直分析的記憶體單位,更為官方的名稱是頁,cpu中採用的頁大小是4KB。這裡需要理解一下,頁僅僅是地址空間的計量單位,只要是4KB的地址空間都可以稱為一頁。所以4GB地址空間可以被劃分為4GB / 4KB = 1M = 2 ^ 20個頁,則自然頁表中需要1M個頁表項,而每個頁表項的值即使相關的索引值(頁表項大小是4位元組,因此索引還需要乘以4),即最多也就是地址位數即可表示。實際上一級頁表如下所示

  那麼實際上通過線性地址對映到實體地址的方法就很明顯了——將線性地址的高20位作為索引,再頁表中進行索引,將索引的結果作為對映的物理頁的高20位;將線性地址的低12位直接當作對映的物理頁的低12位即可。

  實際上可能有人還有疑惑——如果程序都是用上面的線性地址。如果頁表也是在記憶體中,那麼作業系統如果要訪問頁表的話,也需要對線性地址進行轉換的話,不是也需要將頁表所在的記憶體進行對映,但是對映過程本身有需要頁表,這不就是雞生蛋,蛋生雞的問題了?實際上不是這樣的。一方面,分頁機制開啟前是會將頁表地址載入到控制暫存器CR3中,這是開啟分頁機制的先決條件,而載入的頁表地址自然是實體地址(沒有開啟分頁機制,則沒有地址對映);另一方面,如果開啟了分頁機制,實際上CPU就會通過頁部件自動進行地址對映轉換的,這裡面所有操作都是相當於在實體地址下進行的,這裡可以簡單理解為我們每次地址轉換的時候,CPU關閉了分頁機制,當地址轉換完後又恢復到之前的分頁機制。這樣子就不會出現雞生蛋、蛋生雞問題了,因為最後相當於在實體地址下查找了頁表,然後在訪問頁表對應的值,僅僅相當於以前真實模式下的地址訪存,只不過連續訪問了兩次而已。這也就是最普通的一級頁表。

二級頁表

  實際上,雖然一級頁表可以完成地址間的轉換,但是仍然有較大的問題。一級頁表最多可容納1M個頁表項,而每一個頁表項是4位元組(地址位數),全部填滿也就是4MB,而當程序數多起來後,光是頁表就會佔用很多的空間。因此我們通過二級頁表來解決這個問題。

  首先從理論上分析一下,4GB線性地址最多需要1M個頁。一級頁表是將這1M個標準頁放置到一張頁表中;而二級頁表是將這1M個標準頁平均放置到1K個頁表中,每個頁表包含有1K個頁表項,即每個頁表大小恰為4K,即又是一個頁的大小。那麼如何快速的訪問這些頁表呢。也很簡單,專門有頁目錄表來儲存這些頁表,也就是每個頁表的實體地址在頁目錄中都以頁目錄項(Page Directory Entry,PDE)的形式進行儲存,每一個大小同樣仍然是4K,為一頁的大小。而這些頁目錄表和頁表,仍然都是儲存在實體記憶體中的。下面分析一下二級頁表中的地址轉換問題。

  由於頁目錄表僅僅包含1024個PDE,即2 ^ 10個PDE,因此我們只需要地址的高10位作為頁目錄表的索引,即可找到對應的頁表。而這些頁表根據前面的分析,仍然僅包含1024個頁表項,因此使用緊鄰著的10位作為頁表的索引,即可最終找到對應的物理頁,而最後的第12位,則作為頁內偏移即可。那麼其步驟實際上就是

1.    用虛擬地址的高10位乘以4,作為頁目錄表的偏移地址,加上頁目錄表的實體地址,所得到的和作為頁表的實體地址
2.    用虛擬地址的中間10位乘以4,作為頁表的偏移地址,加上頁表的實體地址,所得到的和作為物理頁地址
3.    用虛擬地址的最後12位,作為物理頁的頁內偏移,進行訪問即可

  

  而一般的,每一個程序都有自己獨立的頁表,這樣子,多程序並行執行時,也就是在各自的虛擬空間中執行,如下圖所示

  

  而前面我們實際上一直沒有具體講解頁表項和頁目錄項,既然給他們起了正式的名稱,自然代表其包含有重要含義。這裡我們想一下,既然頁目錄項或頁表項記錄的都是最小單位是頁的地址,也就是代表著其值都是4K對齊的,也就是其低12位全部為0。既然如此,我們自然不可能浪費了這些空間,而會在上面進行記錄,也確實如此,相關的格式如下所示

  下面簡單介紹一下這些屬性為所代表的資訊

  1.  P,即Present,即為存在位。若為1表示該頁存在於實體記憶體中;若為0表示該頁不在實體記憶體中。實際上作業系統的頁式虛擬記憶體管理就是通過P位和相應的pagefault異常來實現的;

  2.  RW,Read/Write,即讀寫位。若為1表示可讀可寫;否則表示可讀不可寫;

  3.  US,User/Supervisor,即普通使用者/超級使用者位。若為1表示處於User級,任意特權級(0、1、2、3特權級)的程式都可以訪問該頁;否則處於Supervisor,只允許0、1、2特權級的程式進行訪問;

  4.  PWT,Page-level Write-Through,即頁級通寫位。若為1表示該頁不僅是普通記憶體,還是快取記憶體;

  5.  PCD,Page-level Cache Disable,即頁級告訴快取禁止位;若為1表示將該也進行快取記憶體;

  6.  A,Accessed,意為訪問為。類似於GDT中的A,若為1表示該頁已經被CPU訪問過。是由CPU置0,而作業系統會定期清0,然後統計次數用來記憶體移出;

  7.  D,Dirty,意為髒頁位。當CPU對於一個頁面執行寫操作時,就會設定對應的頁表項,但不會上升到對應的頁目錄項的D位;

  8.  PAT,Page Attribute Table,意為頁屬性表位,能夠在頁面一級的粒度上設定記憶體屬性,機制比較複雜;

  9.  G,Global,即全域性位。實際上由於多次訪問記憶體,速度比較慢。因此往往會將實體地址轉換結果儲存在TLB(Translation Lookaside Buffer)中。而如果G為1,則該頁將在快取記憶體TLB中一直儲存;否則會被替代掉;

  10.  AVL,仍然類似於GDT的段描述符中的AVL,表示可用。

啟用分頁機制步驟

  下面就簡單介紹一下如何啟用分頁機制。實際上這也相當的簡單,主要分為三步

  1.  準備好頁目錄和頁表

  2.  將頁表地址寫入控制暫存器cr3

  3.  暫存器cr0的PG位置1即可

  對於cr0暫存器,我們已經比較熟悉了,前面從真實模式進入保護模式的過程中,已經給出了相關的解釋。下面我們主要簡單的介紹一下cr3控制暫存器,畢竟之後我們需要寫入資料。cr3控制暫存器格式如下所示

  實際上對於cr3控制暫存器的低12位來說,除了第3、第4位,其餘為全部沒有用。而對於PCD、PWT,用來表示快取相關的資訊,我們就預設設定為0即可,因此這裡低12位全部為0即可。最後說一句,PG位就是cr0暫存器的第31位,也就是最高位,將其置1即可。

作業系統和使用者程序

  這裡說明一下,為了計算機的安全,使用者程序必須執行在低特權級。當用戶程序需要訪問硬體相關的資源時,需要向作業系統申請,由作業系統去完成,然後將結果返回給使用者程序。這也就導致了,使用者程序可以有無限多個,但是作業系統只能有一個,所以,作業系統必須“共享”給所有使用者程序。那麼如何通過頁表實現這個共享呢?很簡單,只要作業系統屬於使用者程序的虛擬地址空間即可。也就是在所有使用者程序的4GB虛擬地址空間中,高3GB以上的部分劃分給作業系統,也就是對映到同一個物理頁地址,其上是對應的作業系統程式碼即可;0-3GB是使用者程序自己的虛擬空間即可。


實驗

  這次程式碼稍微有一些複雜,程式碼倉庫點選此連結。這一次的實驗仍然是改進前面已經實現的loader程式,這次主要是用來開啟分頁機制,從而使我們的程式在虛擬地址空間中進行執行。這次需要稍微說明一下。首先頁目錄表和頁表都存在於實體記憶體之中,我們需要將其放置在對應的位置中,由於我們已經開啟了32位保護模式,因此這次我們不妨將其放置在實體地址的0x100000處,為了便於管理和實現,我們將頁表和頁目錄表緊挨在一起,如下所示

  下一個問題就是前面提到的作業系統,或者說核心。實際上按照書上的預計,最後完全完成之後,核心體積大約也就是70KB以內,因此我們仍然將作業系統的核心放置在低1MB記憶體空間中,也就是最開始的真實模式下的1MB記憶體佈局中,當然,也可以放置在任何其他地方,但是通過這種方式,我希望跟深刻的理解一下記憶體的分頁機制,因此將其仍然放置在了低1MB的記憶體空間中。但是前面我們已經分析過了,我們是需要把作業系統放置在所有使用者程序的虛擬地址空間的高3GB上的,那麼也就是相當於我們需要把虛擬地址的0xc0000000上的1MB地址對映到實體記憶體的1MB之內。我們給出修改後的boot.inc巨集定義,實際上僅僅添加了一部分,這裡給出來修改的部分,如下所示

PAGE_DIR_TABLE_POS equ 0x100000
;--------------------------頁表相關屬性------------------------------------------
;    下面的所有相關的性質主要描述PTE和PDE,格式如下所示
;    00000000000000000000_000_0_0_0_0_0_0_0_0_0b
;    頁表物理頁地址/頁目錄表物理頁地址12-31位20_AVL3_G1_PAT1_D1_A1_PCD1_PWT1_US1_RW1_P1

PTDE equ 00000000000000000000_000_0_0_0_0_0_0_0_0_0b                            ;頁表/頁目錄項

PTDE_G_0 equ PTDE                                                ;非全域性頁
PTDE_G_1 equ 00000000000000000000_000_1_0_0_0_0_0_0_0_0b                            ;全域性頁        

PTDE_D_1 equ 00000000000000000000_000_0_0_1_0_0_0_0_0_0b                            ;髒頁面


PTDE_US_1 equ 00000000000000000000_000_0_0_0_0_0_0_1_0_0b                            ;User級別
PTDE_US_0 equ PTDE                                                ;超級使用者

PTDE_RW_1 equ 00000000000000000000_000_0_0_0_0_0_0_0_1_0b                            ;可讀可寫
PTDE_RW_0 equ PTDE                                                ;可讀不可寫

PTDE_P_1 equ 00000000000000000000_000_0_0_0_0_0_0_0_0_1b                            ;頁存在於物理頁中
PTDE_P_0 equ PTDE                                                ;也不存在於物理頁中

  仍然是簡單的將頁表項/頁目錄項的屬性進行表示,增加可讀性。下面是修改後的loader程式的原始碼,如下所示

;    這裡實現簡單的loader,其講系統由真實模式進入保護模式
;    最後loader仍然會輸出相關的字串,然後進行懸停,方便進行觀察
;------------------------------------------------------------------------

%include "boot.inc"
;    類似於C語言的巨集定義
;--------------------------------------------------------------------------------
;    這個檔案的主要定義如下所示
;    LOADER_BASE_ADDR equ 0x700
;    LOADER_START_SECTOR equ 0x1
;    GDT和GDT的選擇子的巨集定義


SECTION    LOADER    vstart=LOADER_BASE_ADDR         ;這個地址表示將起始地址設定為LOADER_BASE_ADDR——因為MBR會將loader程式載入到LOADER_BASE_ADDR處

LOADER_STACK_TOP equ LOADER_BASE_ADDR        ;這裡提前說明一下,實際上loader的起始棧頂是LOADER_BASE_ADDR

    jmp    LOADER_START            ;16位真實模式相對近轉移

;-----------------這部分內容用來構建GDT結構,由於棧向下生長,因此不會破壞GDT--------------------------------
;    db: data byte,         1位元組
;    dw: data word,         2位元組
;    dd: data double-word,    4位元組
;    dq: data quarter-word,    8位元組


GDT_BASE:
    dd 0x00000000, 0x00000000        ;前面分析過了,GDT的第0個段描述符無法使用,因此直接置為0即可

GDT_CODE:
    dd 0x0000ffff, GDT_DES_CODE_HIGH_4B    ;這是GDT的第1個段描述符,程式碼段。由於採用了平坦模式,因此段基址設定為0, 段界限設定為0xfffff

GDT_DATA_STACK:
    dd 0x0000ffff, GDT_DES_DATA_HIGH_4B    ;這是GDT的第2個段描述符,資料(棧)段。由於採用了平坦模式,因此斷機制設定為0,段界限設定為0xfffff
                        ;這裡需要說明一下,這裡純資料和棧公用一個段,且該段向上擴充套件。但是棧的方向和段的拓展方向並沒有關係。
                        ;段的拓展方向僅僅是用來約束段偏移的,即向上拓展的話,則[base_add, base_add + offset]是對應的段
                        ;向下拓展的話,則[base_add, base_add + offset]非該段,其餘都是段描述符對應的段

GDT_VIDEO:
    dd 0x8000_0007, GDT_DES_VIDEO_HIGH_4B    ;這是GDT的第3個段描述符,視訊記憶體資料段。未採用平坦模式,段基址為0xb8000,段大小為32KB,即段界限為0x7
                        

;-----------------下面定義一下GDT的選擇子的--------------------------------
GDT_SECT_CODE equ ((0x0001 << 3) | GDT_SECT_TI_GDT | GDT_SECT_RPL_0)        ;描述符索引值為0x1;在GDT中索引;請求許可權為0特權級
GDT_SECT_DATASTACK equ ((0x0002 << 3) | GDT_SECT_TI_GDT | GDT_SECT_RPL_0)    ;描述符索引值為0x2;在GDT中索引;請求許可權為0特權級
GDT_SECT_VIDEO equ ((0x0003 << 3) | GDT_SECT_TI_GDT | GDT_SECT_RPL_0)        ;描述符索引值為0x3;在GDT中索引;請求許可權為0特權級



;-----------------這裡則是GDTR相關的資訊,用來構造GDTR的值--------------------------------
GDT_SIZE equ ($ - GDT_BASE)                            ;當前記憶體中GDT的大小
GDT_LIMIT equ (GDT_SIZE - 1)                            ;GDTR中的值,由於其從0開始,需要減一,類似於段界限

times 60 dq 0                                    ;在預留60個段描述符的空間


GDT_PTR:
    dw GDT_LIMIT
    dd GDT_BASE                                ;低2個位元組是GDT的界限,高4個位元組是GDT記憶體起始地址




LOADER_START:
;    向1MB記憶體中的文字模式的顯示介面卡區域寫入資料
;------------------------------------------------------------------------
;    每個字元2位元組,其低位元組為字元對應的ASCII碼,高位元組為字元的屬性
;    由於其為背景藍色,前景色淺品紅色,不閃爍,其高位元組值為 00011101b
;------------------------------------------------------------------------

    mov    cx, 0x0
    mov byte    al, [format]        ;初始化計數器cx,。由於前面已經設定了ds段暫存器為0,該指令相當於將字元屬性位元組讀入ax暫存器中

LOOP_LOADER:
        mov    di, cx

        mov byte    dl, [di + loaderMsg];這裡通過變址定址訪問記憶體,由於前面設定了ds段暫存器為0,這裡直接獲取字串中的對應字元
        sub    dl, 0
        jz    LOOP_LOADER_END            ;判斷字串是否結束。有條件跳轉,因此僅僅修改段偏移地址,由於cs始終為0,自然跳轉到LOOPEND對應的位置

        add    di, di
    add    di, 160            ;由於VGA模式為80 * 25,即一行80個字元,每一個字元2位元組,如果輸出在終端的第2行,則需要從80 * 2 = 160的偏移開始

        mov byte    [es:di], dl        ;這裡通過變址定址訪問記憶體

        add    di, 1
        mov byte    [es:di], al        ;這裡通過變址定址訪問記憶體
        
    add    cx, 1
           jmp near    LOOP_LOADER            ;無條件相對近跳轉,會重新跳轉到LOOP處執行迴圈

LOOP_LOADER_END:
;------------------------------------------------------------------------
;    我們將上面的指令分析一下
;    可以看到,對於記憶體定址來說,這裡通過直接定址進行定址
;    我們每一次輸入兩個位元組資訊,其中低位元組是上面分析的字元的屬性
;    高位元組是字元對應的ascii碼,從而完成了記憶體的寫入。




;------------------------準備進入保護模式------------------------------------------------------------------------------
;    1.    將GDT裝載入GDTR中
    lgdt    [GDT_PTR]


;    2.    開啟A20Gate
    mov    dx, 0x92
    in    al, dx
    or    al, 0000_0010b
    out    dx, al


;    3.    修改CR0暫存器
    mov    eax, cr0
    or    eax, 0x00000001
    mov    cr0, eax


;----------------------------------這裡需要通過無條件跳轉來重新整理流水線,否則會出錯,需要特別注意一下----------------
    jmp GDT_SECT_CODE:PROTECTION_MODE_START                        ;絕對地址遠呼叫





;-------------------------------------下面是觀察用的保護模式下的程式碼,用來確認成功進入保護模式---------------
[bits 32]
PROTECTION_MODE_START:

    mov    ax, GDT_SECT_DATASTACK
    mov    ds, ax
    mov    gs, ax
    mov    ss, ax                ;初始化各個段暫存器,將其都指向GDT_SECT_DATASTACK段描述符對應的段

    mov    esp, LOADER_STACK_TOP
    mov    ax, GDT_SECT_VIDEO
    mov    es, ax                ;這裡將es設定為GDT_VIDEO段描述符對應的段,即視訊記憶體段,那裡沒有使用平坦模式,訪問視訊記憶體仍然類似於真實模式



;    在開啟保護模式的基礎上向文字模式的顯示介面卡區域寫入資料
;------------------------------------------------------------------------
;    每個字元2位元組,其低位元組為字元對應的ASCII碼,高位元組為字元的屬性
;    由於其為背景藍色,前景色淺品紅色,不閃爍,其高位元組值為 00011101b
;------------------------------------------------------------------------

    mov    cx, 0x0
    mov byte    al, [format]        ;初始化計數器cx,。由於前面已經設定了ds段暫存器為0,該指令相當於將字元屬性位元組讀入ax暫存器中

LOOP_PROTECT:
        mov    di, cx

        mov byte    dl, [di + protectionMsg];這裡通過變址定址訪問記憶體,由於前面設定了ds段暫存器為0,這裡直接獲取字串中的對應字元
        sub    dl, 0
        jz    LOOP_PROTECT_END            ;判斷字串是否結束。有條件跳轉,因此僅僅修改段偏移地址,由於cs始終為0,自然跳轉到LOOPEND對應的位置

        add    di, di
    add    di, 320            ;由於VGA模式為80 * 25,即一行80個字元,每一個字元2位元組,如果輸出在終端的第3行,則需要從80 * 2 * 2 = 320的偏移開始

        mov byte    [es:di], dl        ;這裡通過變址定址訪問記憶體

        add    di, 1
        mov byte    [es:di], al        ;這裡通過變址定址訪問記憶體
        
    add    cx, 1
           jmp near    LOOP_PROTECT        ;無條件相對近跳轉,會重新跳轉到LOOP處執行迴圈

LOOP_PROTECT_END:


;---------------------------------------準備開啟分頁機制---------------------------------------------------------------------------
;    1.    構建頁目錄表和頁表
    call    SETUP_PAGE

;--------------------------------------------------移動GDT--------------------------------------------------------------------------
;    由於我們都將核心執行在3GB以上,因此我們同樣需要重啟載入GDT,從而確保GDTR是從核心空間進行訪問的
;    其中需要注意的是,其餘程式碼段、資料-棧段都是平坦模式,但是視訊記憶體段並沒有使用平攤模式,因此我們需要修改其基址到核心空間中
;    還有esp,也要修改其值到核心空間中
;    這些步驟很簡單,就是加上核心空間的基址即可
    sgdt    [GDT_PTR]            ;將GDTR內容進行儲存
    
    mov dword    ebx, [GDT_PTR + 2]        ;獲取GDT的基址
    or dword    [ebx + 0x18 + 4], 0xc0000000    ;將視訊記憶體段的段基址移動到0xc0000000, 也就是段描述符最高8位修改即可
    add    esp, 0xc0000000                ;同樣將棧空間進行對映


;    2.    將頁目錄表地址寫入控制暫存器cr3
    mov    eax, PAGE_DIR_TABLE_POS
    mov    cr3, eax

;    3.    修改CR0暫存器
    mov    eax, cr0
    or    eax, 0x80000000
    mov    cr0, eax

;--------------------------------------------------過載GDT-------------------------------------------------------------------------
    lgdt    [GDT_PTR]



;    在開啟分頁機制的基礎上向文字模式的顯示介面卡區域寫入資料
;------------------------------------------------------------------------
;    每個字元2位元組,其低位元組為字元對應的ASCII碼,高位元組為字元的屬性
;    由於其為背景藍色,前景色淺品紅色,不閃爍,其高位元組值為 00011101b
;------------------------------------------------------------------------

    mov    cx, 0x0
    mov byte    al, [format]        ;初始化計數器cx,。由於前面已經設定了ds段暫存器為0,該指令相當於將字元屬性位元組讀入ax暫存器中

LOOP_PAGE:
        mov    di, cx

        mov byte    dl, [di + pageMsg];這裡通過變址定址訪問記憶體,由於前面設定了ds段暫存器為0,這裡直接獲取字串中的對應字元
        sub    dl, 0
        jz    LOOP_PAGE_END            ;判斷字串是否結束。有條件跳轉,因此僅僅修改段偏移地址,由於cs始終為0,自然跳轉到LOOPEND對應的位置

        add    di, di
    add    di, 480            ;由於VGA模式為80 * 25,即一行80個字元,每一個字元2位元組,如果輸出在終端的第4行,則需要從80 * 2 * 3 = 480的偏移開始

        mov byte    [es:di], dl        ;這裡通過變址定址訪問記憶體

        add    di, 1
        mov byte    [es:di], al        ;這裡通過變址定址訪問記憶體
        
    add    cx, 1
           jmp near    LOOP_PAGE        ;無條件相對近跳轉,會重新跳轉到LOOP處執行迴圈

LOOP_PAGE_END:
;------------------------------------------------------------------------
;    我們將上面的指令分析一下
;    可以看到,對於記憶體定址來說,這裡通過直接定址進行定址
;    我們每一次輸入兩個位元組資訊,其中低位元組是上面分析的字元的屬性
;    高位元組是字元對應的ascii碼,從而完成了記憶體的寫入。




;    下面進行迴圈,確保程式懸停在該處,從而觀察輸出
;------------------------------------------------------------------------

    jmp    $            ;無條件相對近跳轉,會重新跳轉到LOOP處執行迴圈

;------------------------------------------------------------------------
;    我們將上面的指令分析一下
;    $表示當前行的地址,這樣子相當於始終執行這一行指令,從而使程式懸停






;-----------------------------------下面開始建立頁目錄以及頁表----------------------------------------------
SETUP_PAGE:
    
    mov    ecx, 4096                    ;先把頁目錄佔用的空間逐位元組清零
    mov    esi, 0

.CLEAR_PAGE_DIRECTORY:
    
    mov byte [ds:PAGE_DIR_TABLE_POS + esi], 0x0        ;平坦模式,且保護模式下記憶體訪址很靈活
    inc    esi
    loop .CLEAR_PAGE_DIRECTORY                ;保護模式下,ecx作為迴圈計數器




;    建立頁目錄項PDE
;---------------------------------------------設計說明------------------------------------------------------
;    對於頁目錄表和頁表,實際上上有一些額外的注意點
;    1.    在載入核心前,程式中執行的一直都是loader程式,其本身的程式碼都在低端1MB記憶體中,所以需要確保其在記憶體分頁機制下和虛擬地址和段機制下的線性地址這兩者對應的實體地址是一致的。而段機制下線性地址和實體地址一一對應。也就是虛擬地址0-0xfffff必須一一對映到實體地址的0-0xfffff,也就是頁目錄表第0項對應的頁表0-0xff頁表項值已經確定
;    2.    前面已經分析過了,作業系統會被載入到低1MB記憶體空間中,而同時我們需要將作業系統實體地址對映到高0xc0000000處,其對應的頁目錄表索引是0x300。那麼也就是頁目錄表第0x300項對應的頁表0-0xff頁表項值已經確定
;    這裡不妨直接將頁目錄表第0項頁目錄項和第0x300頁目錄項指向同一個頁表
;    3.    我們有可能還想在虛擬地址中動態操作頁表,因此將第1023頁表項指向自己(開啟分頁機制後,雖然cr3暫存器也可以直接訪問到頁表的實體地址,但是即使我們獲取了cr3暫存器值,也僅僅是實體地址,我們進行訪存的時候,仍然會當做虛擬地址,在進行轉換),這裡要說明的就是,開啟分頁機制後,只有頁部件可以以實體地址訪問,其餘所有的地址都會首先經過頁部件進行轉換。
;---------------------------------------------------------------------------------------------------------------------
;    首先構造頁目錄表的第0頁目錄項和第0x300的頁目錄項,分別指向對應的頁表(頁表和頁表目錄表緊挨著)
    mov    eax, PAGE_DIR_TABLE_POS
    add    eax, 0x1000                                ;即這是第1個頁表地址
    mov    ebx, eax                                ;儲存第1個頁表的地址
    or    eax, PTDE_US_1 | PTDE_RW_1 | PTDE_P_1                    ;User使用者級(任何特權級都可訪問);可讀可寫;記憶體存在硬碟
    
;    將構造好的頁表地址分別放入第0個頁目錄項和第0x300(偏移為0xc00)
    mov    [ds:PAGE_DIR_TABLE_POS], eax
    mov    [ds:PAGE_DIR_TABLE_POS + 0xc00], eax

;    將頁目錄項地址放入第1023(0x3ff)項(偏移為0xffc, 4092)
    sub    eax, 0x1000
    mov    [ds:PAGE_DIR_TABLE_POS + 4092], eax




;--------------------------------下面構建第一個頁表的頁表項(PTE)------------------------------------------------------
    mov    ecx, 0x100                                ;前面分析需要對映到低端1MB記憶體中,所以頁表需要完成1MB / 4KB = 256個頁表項
    mov    esi, 0
    mov    eax, PTDE_US_1 | PTDE_RW_1 | PTDE_P_1                ;User使用者級(任何特權級都可訪問);可讀可寫;記憶體存在硬碟

.LOOP_CREATE_PTE:
    mov    [ds:ebx + 4 * esi], eax                            ;ebx在前面儲存了第1個頁表的地址
    add    eax, 0x1000
    inc    esi
    loop    .LOOP_CREATE_PTE


;------------------------------------完善核心空間的其他頁表---------------------------------------------------------------
;    前面將第0x300頁目錄項,也就是對應虛擬地址 0xc0000000~(0xc0000000 + 4M)的空間完成了對映(實際上完成了1M的部分對映)
;    第1023頁目錄項,完成了對映(對映到了自己,即頁目錄表)
;    但是為了完全共享核心,需要將0x300~1023所有的頁目錄項全部初始化好
;    否則如果僅僅初始化一部分,新建使用者程序,為了共享會將核心頁目錄表複製到使用者程序頁目錄表,但如果此時系核心申請記憶體,新使用了
;    頁目錄項,而對應的使用者程序則無法同步
;------------------------------------------------------------------------------------------------------------------------
    mov    eax, PAGE_DIR_TABLE_POS
    add    eax, 0x2000                                ;此時指向第二個頁表地址
    or    eax, PTDE_US_1 | PTDE_RW_1 | PTDE_P_1                ;User使用者級(任何特權級都可訪問);可讀可寫;記憶體存在硬碟
    mov    esi, 0x301
    mov    ecx, 254                                ;即1022 - 0x301 + 1
    mov    ebx, PAGE_DIR_TABLE_POS
.LOOP_CREATE_KERNEL_PDE:
    
    mov    [ds:ebx + 4 * esi], eax
    add    eax, 0x1000
    inc    esi
    loop    .LOOP_CREATE_KERNEL_PDE

    ret

;    下面進行常量設定
;------------------------------------------------------------------------
    loaderMsg db "Hawk's LOADER", 0                ;即偽操作指令,表示每一個元素大小為1位元組, 並且在結尾為\x00表明字串結束
    protectionMsg db "Now in PROTECTION mode", 0        ;即偽操作指令,表示每一個元素大小為1位元組, 並且在結尾為\x00表明字串結束
    pageMsg db "Now enable Paging", 0                ;即偽操作指令,表示每一個元素大小為1位元組, 並且在結尾為\x00表明字串結束
    format db 00011101b                        ;這裡是視訊記憶體中的字元屬性,表明其為背景藍色,前景色淺品紅色,不閃爍   
    times (2560 - ($ - $$)) db 0                ;使用0填充至5個扇區

  這裡相關的程式碼已經非常長了。但是實際上相當一部分程式碼是前面用來進入保護模式的程式碼。而新增的程式碼邏輯相當清晰——

  1.  建立頁目錄表和頁目錄;

  2.  將0-0xfffff和0xc00000000-0xc000fffff對映到相同的頁表中,並且該頁表對映到的物理頁面是低端1MB記憶體空間(前面對映方便後面loader繼續操作;後面對映為高階1GB記憶體為核心空間作鋪墊)

  3.  然後調整GDTR;通過修改cr0和cr3控制暫存器,完成記憶體分頁機制

  4.  過載GDT

  實際上這裡需要說明的有幾點

  1.  核心實際上會被載入到低端1MB空間中(目前還沒載入),但是為了後面使用者程序可以共享作業系統,所有程序的虛擬地址的高1GB是核心空間,低3GB是程序空間。後面的實現就是將頁目錄表中代表高1GB的頁目錄項實際上指向的就是核心程式碼所在物理頁(即低端1MB空間 +其他物理空間),因此我們需要將頁目錄表中對應的(第0x300索引開始)頁目錄項進行設定。

  2.  當我們開啟了分頁機制後,cpu中所有的地址都是虛擬地址,但是當cpu進行訪存時,所有地址都會經過頁部件,使用二級頁表進行地址轉換,轉換成實體地址。也就是如果cpu中使用了正確的實體地址,但是經過一次地址轉換再去訪問,反而無法訪問到正確的地址。因此,我們無法通過獲取cr3暫存器的值獲取頁目錄表的實體地址來修改頁目錄表。這裡通過在開啟頁目錄表之前,就在當前目錄表的最後一個頁目錄項中,將值設定為頁目錄表的起始地址。這樣子實際上就可以直接通過虛擬地址訪問到對應的頁目錄表和頁表,從而直接對其進行修改。可能還是比較難以理解,這裡我再詳細說明一下。我們這裡使用的都是二級頁表。如果我們的虛擬地址是0x1111111111_______,也就是最高10位全部為1,則實際上會進入頁目錄表的最後一個頁目錄項,並將其對應的值當做頁表;而我們前面將最後一個頁目錄項的地址設定為頁目錄表的起始地址,也就是其將頁目錄表當做了頁表,說起來可能很繞,但是仔細想一下,確實這個方法很厲害。如果我們要修改對應的頁表的值,我們只需要將虛擬地址的中間10位設定為對應的頁目錄項的索引值,則就會訪問一級頁表中對應的索引項的值,在這裡就是頁目錄項對應的索引項的值,也就是頁表的地址。這裡有一個特殊的,如果我們中間10位仍然全為1,則訪問的就是一級頁表對應的最後一項對應的地址,這裡就是頁目錄表的最後一項,仍然是頁目錄表。這種設定即可以完成修改頁目錄表,同時可以任意修改頁目錄表所指向的頁表。

  下面我們在虛擬機器中進行測試,首先進行編譯和構建,命令還是老樣子,如下所示

nasm -I include/ -o mbr.bin mbr.S
nasm -I include/ -o loader.bin loader.S

dd if=mbr.bin of=../hawk.img bs=512 count=1 conv=notrunc
dd if=loader.bin of=../hawk.img bs=512 seek=1 count=5 conv=notrunc

  結果如下所示

  然後我們執行虛擬機器,命令如下所示

~/bochs/bin/bochs -f bochsrc.disk

  結果如圖所示

  此時我們觀察一下頁表的對映情況(注意,這裡預設使用的就是二級頁表),如圖所示

  這裡需要稍微解釋一下——info tab應該是根據cr3暫存器的值,使用二級頁表機制翻譯頁目錄表。

  首先頁目錄表填充了第0項,第0x300-1022和第1023項,但是大部分頁表項都沒有填充。上面tab資訊中,前兩項十分好理解,就是第0項和第0x300指向第1個頁表,而該頁表指向低端1MB實體地址;

  第三、四和五項這裡需要說明。首先其虛擬地址最高位都為1,也就是選擇了頁目錄表的最後一項,然後將其中對應的地址當做頁表。而最後一個頁目錄項的值指向的是頁目錄表的地址,從而其最後的實體地址會根據頁目錄表地址作為頁表,和中間10位作為索引,這就非常有趣了。

  因為前面說了,一共填充了2個部分,如果中間10位位0,也就是訪問高10位指定的頁表的第0項對應的地址,這裡也就是頁目錄表的第0項,其指向了第一個頁表,也就是0x101000 + 偏移,和第三條資訊一模一樣。

  如果中間10位表示0x300-1022,也就是訪問頁表的第0x300-1022項對應的地址,這裡也就是頁目錄表中第0x300-1022項對應的地址,(核心地址空間,前面將核心地址空間提前初始化,對映到了第2-254個頁表),和第四條資訊一模一樣。

  如果中間10位全為1,也就是訪問頁表的最後一項對應的地址,這裡也就是頁目錄表中最後一項,其指向了頁目錄表,也就是0x100000 + 偏移,和第五條資訊一模一樣。