1. 程式人生 > 實用技巧 >作業系統——全域性描述符(六)

作業系統——全域性描述符(六)

作業系統——全域性描述符表(六)

2020-09-1710:43:27 hawk


概述

  前面簡單介紹了一下保護模式下相關的一些基礎知識,下面會進一步對於保護模式做更為詳細的分析和講解。這篇部落格主要講述保護模式的重要組成部分——全域性描述符表。


全域性描述符表基礎

  實際上,顧名思義,保護模式要比真實模式相對更安全一些,其中一個重要的方面就是地址訪問方面的。對於保護模式來說,記憶體段不在類似於真實模式下簡單地說用段暫存器載入一下段基址,即可直接使用。在保護模式下,需要載入很多額外的資訊,需要提前就把定義好,才可以正常使用。書上有一個非常形象的例子——類似於家庭成員需要上戶口一樣,只有在戶口簿上登記過的人才算合法。而只有在相關資料結構上登記過的記憶體段才是可以合法訪問的記憶體段,而這個資料結構就是全域性描述符表(Global Descriptor Table,GDT)

段描述符

  正如我們之前一直提到的,相容性是電腦科學學科的優良傳統。自然的,在真實模式下訪問記憶體的方式是“段基址:段內偏移地址”,則在保護模式下最好仍然也是這種形式。當然為了安全性,還需要額外新增一些屬性位,然而暫存器位數並不足以支撐這些功能,導致直接使用暫存器實現“段基址:段內偏移地址”模式不是很現實。因此我們通過在記憶體中儲存段基址、段內偏移地址和相關的屬性位,從而在仍然採用“段基址:段內偏移地址”模式的基礎上,新增額外的屬性位來提高安全性。實際上這個資料結構就是段描述符,如下所示

  可以看到,實際上段描述符是連續的8位元組,64位的資料結構。這裡我們簡單的對段描述符的每一個屬性進行描述。

  1.  段基址,類似於真實模式中的段基址,但是又有所不同——獲取最終的地址的時候,不需要在進行移位了,直接與段內偏移進行加減即可。因為這裡段基址一共佔用了32位(分佈在三個部分),而地址匯流排也就32位,自然不需要向8086CPU一樣進行移位了。

  2.  段界限,其通過20bit來進行表示。實際上這個表示的是段邊界的擴充套件最值。在保護模式下,段的拓展方向只有上下兩種。對於資料段和程式碼段來說,其段的拓展方向是向上,即地址越來越高,此時的段界限用來表示段內偏移的最大值;對於棧段,段的拓展方向是向下,即地址越來越低,此時的段界限用來表示段內偏移的最小值。這裡需要說明的是,段界限只是一個單位量,不過其單位要麼是1位元組,要麼是4KB,這是由段描述符中的G位來標明的。即最終段的邊界是此段界限值 * 單位,即最終段的大小要麼是2 ^ 20 * 1B = 1MB,要麼是2 ^ 20 * 4KB = 2 ^ 32 B = 4GB。即實際的段界限邊界值=(描述符中段界限+1) * (段界限的粒度大小:4KB或1B)-1。

  3.  對於G來說,其表示段界限粒度大小。如果為0,則段界限粒度大小為1位元組;如果為1,則粒度大小為4KB。

  4.  對於D/B來說,其用來指示有效地址(段內偏移地址)及運算元的大小,是為了相容286CPU保護模式的16位。對於程式碼段來說,如果為0,表示指令中的有效地址和運算元都是16位,指令有效地址用IP暫存器;如果為1,表示指令中的有效地址和運算元都是32位,指令有效地址用EIP暫存器。對於棧段來說,如果為0,使用sp暫存器,棧的最大範圍為16位;如果是1,使用esp暫存器,棧的最大範圍為32位。這裡說明一下,是否為程式碼段或者棧段(資料段,只不過ss指向該段)由後面S和TYPE共同決定。

  5.  對於L來說,用來設定是否為64位程式碼段。如果L為1,表示是64位程式碼段,否則表示32位程式碼段。

  6.  對於AVL來說,作業系統可以隨意使用該位,沒有專門的用途。

  7.  對於P來說,即Present,表明該段是否存在於記憶體中,由CPU進行檢查該欄位。如果P為1,則表明該段是存在於記憶體中;如果P為0,CPU將丟擲異常,會轉到相應的異常處理程式中,處理完後再將P置為1。

  8.  對於DPL來說,即Descriptor Privilege Level,描述符特權級。其將計算機按照權力劃分為不同等級,每一種等級稱為一個特權級。由於其由2bit進行標識,自然劃分為0、1、2、3級特權。數字越小,則特權級越大。CPU由真實模式進入保護模式後,特權級自動為0;作業系統處於最高的0特權級;使用者程式通常處於3特權級,許可權最小。

  9.  對於S來說,用來描述段是否為系統段。如果S為0表示為系統段;S為1表示為非系統段。對於系統段,在介紹TYPE時還會提及。

  10.  對於TYPE來說,共4bit,用來指定本段的型別。對於CPU來說,段大體可以分為兩大類——系統段(凡是硬體執行所需要的東可以稱之為系統)和資料段(凡是軟體所需要的東西都稱之為資料,包括程式碼),這是由上面的S決定的。而對於系統段來說,各種稱之為門的便是系統段,也就是硬體系統所需要的種種結構。而Type則用來表示記憶體段或門的子型別,如下表所示

系統段 系統段型別 第3~0位 說明
第3位 第2位 第1位 第0位
未定義 0 0 0 0 保留
可用的80286 TSS 0 0 0 1 僅限286的任務狀態段
LDT 0 0 1 0 區域性描述符表
忙碌的80286 TSS 0 0 1 1 僅限286,表示當前任務忙碌
80286呼叫門 0 1 0 0 僅限286
任務門 0 1 0 1 在現代作業系統中很少用到
80286中斷門 0 1 1 0 僅限286
80286陷阱門 0 1 1 1 僅限286
未定義 1 0 0 0 保留
可用的80386 TSS 1 0 0 1 386以上CPU的任務狀態段
未定義 1 0 1 0 保留
忙碌的80386 TSS 1 0 1 1 386以上CPU,表明當前任務忙碌
80386呼叫門 1 1 0 0 386以上CPU的呼叫門
未定義 1 1 0 1 保留
80386中斷門 1 1 1 0 386以上CPU的中斷門
80386陷阱門 1 1 1 1 386以上CPU的陷阱門
對於非系統段,按程式碼段和資料段劃分,這4位有不同的意義
非系統段 記憶體段型別 X(可執行) R(可讀) C(一致性) A 說明
程式碼段 1 0 0 * 只執行程式碼段
1 1 0 * 可執行、可讀程式碼段
1 0 1 * 可執行、一致性程式碼段
1 1 1 * 可讀、可執行、一致性程式碼段
記憶體段型別 X(可執行) E(可寫) W(向下拓展) A 說明
資料段 0 0 0 * 只讀資料段
0 1 0 * 可讀可寫資料段
0 0 1 * 只讀,向下擴充套件資料段
0 1 1 * 可讀,可寫,向下擴充套件資料段

  這裡書上貌似有一些問題——E和W的位置顛倒了,至少在我實驗的時候是相反的。這裡稍微說一下門,其相當於入口,通往一段程式,可以先有一個大概影響即可,後面還會在進行分析。下面我們說明一下非系統段的相關資訊,即A、C、X、R、W和E。對於A來說,即Accessed位,表示最近是否被訪問,每當該段被PCU訪問過後,CPU會自動將該位置為1,而新建段描述符時,會將該位置為0;對於C,即Conforming,表明是一致性程式碼段,即如果自己是轉移的目標段,並且自己是一致性程式碼段,則自己的特權級一定要高於當前特權級,轉移後的特權級不與自己的DPL為主,而是與轉移前的低特權級一致;對於R,用來限制程式碼段的訪問,也就是CPU仍然可以通過cs:ip來繼續訪問和執行這些指令,但是不能讀取位於該程式碼段上的資料(如果程式碼和資料寫在一起的話);對於W,表明該段是否可以被寫入,該位為0表示可以不可以寫入,否則可以寫入;最後則是E,用來表示拓展方向,如果該位為1表示向下擴充套件,即地址越來越低,通常用於棧段。

  這裡就簡單的介紹完了段描述符,如果還想更深入的瞭解的話可以查詢一下相關的資料,比如Intel手冊。

全域性描述符表GDT、區域性描述符表LDT和選擇子selector

  正如前面介紹的,一個段描述符只能用來定義一個記憶體段。而作業系統中至少包含注入程式碼段、資料段等多個記憶體段,因此需要一個可以容納多個段描述符的資料結構進行管理,即全域性描述符表(Global Descriptor Table)。實際上,全域性描述符表GDT相當於是描述符的數值,陣列中的每個元素都是前面介紹的8位元組的段描述符,而我們可以通過使用選擇子中提供的下標進行索引段描述符。

  之所以稱之為全域性描述符表,因為多個程式都可以在裡面定義自己的段描述符,是公用的。而正如前面介紹過的,單純通過“段基址:段偏移”無法安全的訪問記憶體,因此通過增加額外屬性解決問題,而由於增加了額外的屬性,不方便直接放入32位的暫存器中,因此將其放置在記憶體中。但是我們需要知道GDT在記憶體中的具體位置,因此通過專門的暫存器進行指定,即GDTR(GDT Register)。GDTR是一個48位的暫存器,結構如下所示

  其可以指定GDT在記憶體中的起始地址,同樣儲存這GDT的大小,即最大為2 ^ 16 = 64KB。而考慮到每一個段描述符大小為8位元組,因此最多可以容納64KB / 8B = 8K = 8192個段或門。當然,對於GDTR暫存器,其賦值不能使用簡單的mov指令,CPU中專門為其設定了lgdt指令(Load GDT),其指令格式如下

lgdt 48位記憶體資料

  這條指令既可以在真實模式下會自行,同樣也可以在保護模式下執行。對於在真實模式下執行時理所當然的,因為在真實模式下切換到保護模式中,需要構建GDT,自然使用到這個命令;而對於保護模式下可以執行的原因其實也很簡單——真實模式下只能訪問低端1MB的記憶體空間,自然初始化的GDT處於低端1MB的記憶體空間中;而進入保護模式後,可以訪問更多的記憶體空間,自然可以將GDT放置在其他更合適的位置上,所以可以通過指令lgdt指令重新更換gdt。

  下面則是如何使用GDT——即段的選擇子。在保護模式下,段暫存器CS、DS、ES、FS、GS和SS存入的是選擇子(selector),基本可以理解為GDT的索引值(還包括一些其他的資訊),然後通過GDTR和選擇子,基本就可以獲取對應的段描述符,從而獲取記憶體段的起始地址和段界限值等相關資訊。由於段暫存器是16位,則選擇子也是16位,其結構如下所示

  其低兩位用來儲存RPL,即請求特權級(0、1、2、3特權級),這裡可以簡單理解為請求者的當前特權級;第2位TI,即Table Indicator,用來表示是在GDT中還是LDT中進行索引描述符,當TI為0表示在GDT中進行索引,TI為1表示在LDT中進行索引(後面會講到LDT)。而高3~15位則是描述符的索引值,用來進行索引,由於其是13位,所以其可以索引的個數即為2 ^ 13 = 8192,和前面GDT中的段描述符的最大個數是一致的。這裡需要額外說明一下,實際上GDT中的第0個段描述符是不可用的,原因也比較簡單,用來避免出現忘記初始化選擇子(如果忘記初始化選擇子,則一般選擇子為0,則索引值自然就為0),如果選擇子選到了第0個描述符,則處理器將發出異常。

  這裡在稍微說明一下保護模式下如何進行地址的訪問——首先通過段暫存器獲取對應的選擇子的值,從中獲取TI和描述符索引值,然後根據TI和對應的暫存器找到相關的表項,然後根據描述符索引值獲取對應的描述符,然後從描述符中即可獲取段基址。然後將段基址和段偏移暫存器直接相加,即可獲取最後的地址。

  當然,根據TI,實際上還存在有LDT(Local Descriptor Table),按照CPU的設想,一個任務對應一個LDT。而LDT也位於記憶體當中,也是一塊記憶體,因此同樣需要首先在GDT中進行註冊,然後通過選擇子進行獲取相關的地址,這裡LDT實際上是系統段(可以看一下前面GDT中TYPE欄位的表)。類似於GDT,同樣有LDTR,即LDT Register指向對應的LDT;同樣使用指令lldt(Load LDT)初始化LDTR,但是不同於lgdt,其指令如下所示

lldt 16位記憶體資料/16位暫存器

  可以看到,其資料大小不同於lgdt,因為其儲存的是GDT的選擇子。除此之外,LDT中第0個段描述符是可用的——因為如果要索引LDT的話,其TI位會被設定為1,這也就表明選擇子是被初始化過的,因此無需通過第0個段描述符不可用來避免選擇子的未初始化。

A20地址線

  前面的真實模式下,暫存器都是16位的,地址線都是20位的。對於8086等CPU,對於訪問地址是很普通的——就是通過“段基址:段偏移”進行訪問即可,由於其分段訪問基址,實際上可以訪問的地址會超過記憶體空間,但是由於地址線只有20位,因此相當於自動去掉最高位,表現形式也就是迴繞。但是對於80286等後續發展的CPU來說,其地址線不僅僅是20位,會出現24位甚至32位,但是還需要使用CPU的真實模式,如果不加處理的進行工作的話,記憶體地址可能會出現問題——訪問0x100000時,對於8086CPU來說,會迴繞到0;但是對於目前的CPU來說,其至少有24位地址線,則不會進行迴繞,會訪問到對應的實體記憶體上去。

  因此實際上目前是通過A20Gate進行相容8086的。如果A20Gate被開啟,則不會進行地址迴繞;如果A20Gate被禁止,則會採用8086CPU類似的地址迴繞。因此如果我們需要從真實模式下轉換為保護模式,需要開啟A20Gate,其方法也很簡單,通過與相關的IO介面進行互動即可,程式碼如下所示

in    al, 0x92
or    al, 0000_0010b  ;(or  al, 0x1)
out    0x92, al

CR0暫存器

  實際上從真實模式轉換到保護模式的話,除了構建GDT,開啟A20地址線以外,我們還需要通過設定一些暫存器來設定CPU的工作模式。

  實際上,除了段暫存器、通用暫存器以外,還有一些額外的暫存器支援計算機的執行,比如之前提到的GDTR、LDRT等,還有控制暫存器CRx。這裡從真實模式轉換到保護模式,需要用到控制暫存器CR0。實際上,控制暫存器是CPU的視窗,既可以用來展示CPU的內部狀態,也可用於控制CPU的執行基址。而對於CR0控制暫存器,其第0位,即PE位,Protection Enable,此位用於開啟CPU的保護模式,是保護模式的開關。因此我們在進行相關的設定後,只需要將CR0控制器的PE位置為1,即可最終開啟保護模式,相關程式碼如下所示

mov     eax, cr0
or     eax, 0x00000001
mov    cr0, eax

實驗

  雖然前面一直在完善MBR程式,但是實際上在上一節MBR成功將loader載入入記憶體後,MBR就已經完成了其歷史使命。因此這裡我們從真實模式進入到保護模式的任務就交給了loader程式。因此,結合前面所講的,下面我們將通過完成GDT的構建與載入、開啟A20地址線、最後設定CR0暫存器,從而最終完成進入保護模式。

  雖然上面說MBR已經完成了歷史使命,但是這裡還是需要修改一下其內容——其讀取磁碟的大小,因為上一次實驗最後的loader程式功能比較簡單,所以程式大小很小,只需要mbr讀取磁碟上一個扇區的內容即可完成loader程式的讀入,從而完成loader的載入。之後的loader需要完成從真實模式進入保護模式,自然需要較多的程式碼進行實現,因此這裡需要修改一下其讀入的扇區數,這裡直接修改為5扇區,其餘沒有任何的改變,就不再放原始碼了。

  下面我們提前進行一些巨集定義——這樣子有利於我們在loader程式中更注重程式碼邏輯而非實現細節,巨集定義我們按照前面的規則,仍然定義在include/boot.inc中,原始碼如下所示

;-------------------------- loader 和 kernel---------------------
LOADER_BASE_ADDR equ 0x700
LOADER_START_SECTOR equ 0x1



;-----------------------------GDT描述符屬性----------------------
;    下面的所有相關的性質主要描述GDT的高32位,格式如下所示
;    00000000_0_0_0_0_0000_0_00_0_0000_00000000b
;    段基址8_G1_D/B1_L1_AVL1_段界限4_P1_DPL2_S1_TYPE4_段基址8


GDT_DES equ 00000000_0_0_0_0_0000_0_00_0_0000_00000000b                            ;段描述符


GDT_DES_G_4K equ 00000000_1_0_0_0_0000_0_00_0_0000_00000000b                        ;段界限粒度為4K
GDT_DES_G_1B equ GDT_DES                                        ;段界限粒度為1B


GDT_DES_D_16 equ GDT_DES                                        ;程式碼段,有效地址和運算元16位,ip暫存器
GDT_DES_D_32 equ 00000000_0_1_0_0_0000_0_00_0_0000_00000000b                        ;程式碼段,有效地址和運算元32位,eip暫存器


GDT_DES_B_16 equ GDT_DES_D_16                                        ;棧段,sp暫存器
GDT_DES_B_32 equ GDT_DES_D_32                                        ;棧段,esp暫存器


GDT_DES_L_32 equ GDT_DES                                        ;32位程式碼段
GDT_DES_L_64 equ 00000000_0_0_1_0_0000_0_00_0_0000_00000000b                        ;64位程式碼段


GDT_DES_AVL equ GDT_DES                                            ;這個屬性沒有實際意義,但作為組成部分必須賦值



GDT_DES_UNPRESENT equ GDT_DES                                        ;段未存在於記憶體中
GDT_DES_PRESENT equ 00000000_0_0_0_0_0000_1_00_0_0000_00000000b                        ;段存在於記憶體中


GDT_DES_DPL_0 equ GDT_DES                                        ;0特權級
GDT_DES_DPL_1 equ 00000000_0_0_0_0_0000_0_01_0_0000_00000000b                        ;1特權級
GDT_DES_DPL_2 equ 00000000_0_0_0_0_0000_0_10_0_0000_00000000b                        ;2特權級
GDT_DES_DPL_3 equ 00000000_0_0_0_0_0000_0_11_0_0000_00000000b                        ;3特權級


GDT_DES_UNSYSTEM equ 00000000_0_0_0_0_0000_0_00_1_0000_00000000b                    ;非系統段
GDT_DES_SYSTEM equ GDT_DES                                        ;系統段


GDT_DES_TYPE_CODE_X equ 00000000_0_0_0_0_0000_0_00_0_1000_00000000b                    ;程式碼段具有可執行性
GDT_DES_TYPE_CODE_R equ 00000000_0_0_0_0_0000_0_00_0_0100_00000000b                    ;程式碼段具有可讀性
GDT_DES_TYPE_CODE_C equ 00000000_0_0_0_0_0000_0_00_0_0010_00000000b                    ;程式碼段具有一致性


GDT_DES_TYPE_DATA_W equ 00000000_0_0_0_0_0000_0_00_0_0010_00000000b                    ;資料段具有可寫性
GDT_DES_TYPE_DATA_E equ 00000000_0_0_0_0_0000_0_00_0_0100_00000000b                    ;程式碼段具有向下擴充套件性


;程式碼段的段描述符的高32位:其4k對齊;指令中有效地址和運算元為32位
;32位程式碼段;存在於記憶體中;位於0特權級;非系統段;僅可執行程式碼段
;採用平坦模式,段基址為0,段界限為0xfffff

GDT_DES_CODE_HIGH_4B equ ((0x00 << 24) | GDT_DES_G_4K | GDT_DES_D_32 | GDT_DES_L_32 | GDT_DES_AVL | (0xf << 16) | \
GDT_DES_PRESENT | GDT_DES_DPL_0 | GDT_DES_UNSYSTEM | GDT_DES_TYPE_CODE_X | 0x00)                        



;資料段的段描述符的高32位:其4k對齊;如果為棧段,採用esp暫存器;
;設定32位程式碼段(以防萬一,不用的話設定也沒有影響);存在於記憶體;段界限為0xfffff;位於0特權級
;非系統段;可讀、可寫資料段,會向上拓展
;採用平坦模式,段基址為0,段界限為0xfffff
GDT_DES_DATA_HIGH_4B equ ((0x00 << 24) | GDT_DES_G_4K | GDT_DES_B_32 | GDT_DES_L_32 | GDT_DES_AVL | (0xf << 16) | \
GDT_DES_PRESENT | GDT_DES_DPL_0 | GDT_DES_UNSYSTEM | GDT_DES_TYPE_DATA_W | 0x00)                        
                                                    


;視訊記憶體段的段描述符的高32位:其4k對齊;如果為棧段,採用esp暫存器;
;設定32位程式碼段(以防萬一,不用的話設定也沒有影響);存在於記憶體;段界限為7;位於0特權級
;非系統段;可讀、可寫資料段,會向上擴充套件
;這裡沒有采用平坦模式,根據真實模式1MB記憶體佈局,段基址為0xb8000,段界限為0x7 = (32KB - 1) / 4KB
GDT_DES_VIDEO_HIGH_4B equ ((0x00 << 24) | GDT_DES_G_4K | GDT_DES_B_32 | GDT_DES_L_32 | GDT_DES_AVL | (0x0 << 16) | \
GDT_DES_PRESENT | GDT_DES_DPL_0 | GDT_DES_UNSYSTEM | GDT_DES_TYPE_DATA_W | 0x0b)                        
                                                    


;----------------------選擇子屬性--------------------------------------------
;    下面的所有相關的性質主要描述選擇子,格式如下所示
;    0000000000000_0_00b
;    索引13_TI1_RPL2

GDT_SECT equ 0000000000000_0_00b                                    ;選擇子格式

GDT_SECT_RPL_0 equ GDT_SECT                                        ;RPL為0特權級
GDT_SECT_RPL_1 equ 0000000000000_0_01b                                    ;RPL為1特權級
GDT_SECT_RPL_2 equ 0000000000000_0_01b                                    ;RPL為2特權級
GDT_SECT_RPL_3 equ 0000000000000_0_01b                                    ;RPL為3特權級

GDT_SECT_TI_GDT equ GDT_SECT                                        ;在GDT中進行索引
GDT_SECT_TI_LDT equ 0000000000000_1_00b                                    ;在LDT中進行索引

  這裡說明一下,別看程式碼比較多,實際上大部分就是將GDT的段描述符的每一個部分對應的數值使用巨集進行表示,然後最後通過這些巨集的組合完成對於程式碼段、資料段或者視訊記憶體段的段描述符的高32位的表示。所以你也可以不使用這些巨集,直接用32bit的二進位制數來表示,但是那樣的話可讀性就非常差了。下面則是最後的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:
        mov    di, cx

        mov byte    dl, [di + loaderMsg];這裡通過變址定址訪問記憶體,由於前面設定了ds段暫存器為0,這裡直接獲取字串中的對應字元
        sub    dl, 0
        jz    LOOPEND            ;判斷字串是否結束。有條件跳轉,因此僅僅修改段偏移地址,由於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            ;無條件相對近跳轉,會重新跳轉到LOOP處執行迴圈

    LOOPEND:
;------------------------------------------------------------------------
;    我們將上面的指令分析一下
;    可以看到,對於記憶體定址來說,這裡通過直接定址進行定址
;    我們每一次輸入兩個位元組資訊,其中低位元組是上面分析的字元的屬性
;    高位元組是字元對應的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暫存器中

    LOOP2:
        mov    di, cx

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

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

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

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

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




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

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

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




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

  這個原始碼稍稍複雜了一些。實際上大體上可以分為兩部分——真實模式下的保護模式環境的配置和保護模式下的驗證這兩部分。對於真實模式下的保護模式環境的配置,這部分也是程式碼和資料混合放置在一起的,第一個jmp LOADER_START指令就是為了跳過前面的資料部分,從而直接到相關的程式碼部分。而前面的資料部分也很簡單,就是單純的排列出GDT,裡面包含3個設定好的段描述符(程式碼段,資料-棧段,以及視訊記憶體段),以及預留的60個段描述符的位置;除此之外,還有GDTR的值,因為GDT Register同樣是從記憶體中載入資料,因此這裡準備好帶載入GDTR中的值。而程式碼部分就是前面分析過的三大塊——載入GDT,開啟A20Gate以及設定CR0暫存器。這樣子就算基本完成了從真實模式進入保護模式的操作。注意的是,這後面緊跟了一個無條件跳轉,並且就跳轉到自己的下一條指令,看似沒有作用,實際上缺了這條無條件跳轉,程式就不會正常執行,這個在後面分析。

  上面跳轉之後就進入到了保護模式下的驗證部分,這部分程式碼實際上就是簡單的向視訊記憶體中輸入資料並顯示。這裡實際上就是簡單的驗證了能否通過選擇子正確選擇對應的段並進行正常的讀寫,這裡我們在後面給出截圖。這裡需要說明一下幾點

  1.  書上好像有一些問題——對於GDT中的段描述符的TYPE的資料段的W和E,也就是可寫性質和向下擴充套件,這兩個位放反了(或者可能我理解錯了)。大家可以注意一下這一點,因為我在實驗的時候這兩點困擾了我很久,然後突然嘗試了一下更換了這兩位,才最終出現正常現象。

  2.  在完成真實模式向保護模式的轉換後,即完成CR0暫存器的置位後,需要立馬進行一個無條件跳轉,用來重新整理流水線,否則可能會出錯。這個原因後面再提及,下面我們放出相關的實驗步驟,這裡還需要說明一下,這裡之後的環境我更改為了bochs(qemu不知道為什麼,讀取磁碟的時候好像會有一些問題,可能是設定的不太正確),其配置教程前面已經給過連結了,這裡在給一下。相關的配置檔案如下所示

#Bochs執行中使用的記憶體,設定為32M
megs: 32
 
#設定真實機器的BIOS和VGA BIOS
#修改成你們對應的地址
romimage: file=/home/hawk/Desktop/bochs/share/bochs/BIOS-bochs-latest
vgaromimage: file=/home/hawk/Desktop/bochs/share/bochs/VGABIOS-lgpl-latest
#設定Bochs所使用的磁碟
#設定啟動碟符
boot: disk

#設定日誌檔案的輸出
#log: bochs.out

#開啟或關閉某些功能,修改成你們對應的地址
mouse: enabled=0
keyboard_mapping: enabled=1, map=/home/hawk/Desktop/bochs/share/bochs/keymaps/x11-pc-us.map

#硬碟設定
ata0: enabled=1, ioaddr1=0x1f0, ioaddr2=0x3f0, irq=14
ata0-slave: type=disk, path="hawk.img", mode=flat, cylinders=121, heads=16, spt=63

  我們首先正常編譯MBR程式和loader程式,指令如下所示

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
d if=loader.bin of=hawk.img bs=512 seek=1 count=5 conv=notrunc

  結果如圖所示

  下面我們啟動bochs虛擬機器,命令如下所示

  結果如圖所示

  然後我們在觀察一下GDTR和相關的暫存器,結果如圖所示

  可以看到,es對應的base確實為0xb8000,也就是文字模式視訊記憶體的起始地址處,則進入保護模式成功。另外說明一下,這裡面的段界限(limit)的單位是位元組,而我們之前程式碼中設定的段界限粒度都是4KB,所以需要相應的進行轉換,這個需要明確一下,我做實驗的時候一直以為bochs顯示錯誤。。。

  最後,我們來分析一下前面提到的無條件遠端呼叫問題。這裡就稍微說明一下問題即可。

  我們知道,一方面,段描述符位於記憶體中,訪問記憶體對於CPU來說是非常慢的動作,因此CPU中使用段描述符緩衝暫存器緩衝段描述符,而且其相容真實模式,其僅僅在更改段暫存器值的時候才會進行更新。而我們在從真實模式轉換進入保護模式的過程中,段描述符緩衝暫存器還是真實模式下的內容(20位段基址),因此這對保護模式來說會造成錯誤。另一方面,CPU為了提高效率,採用了流水線技術、分支預測技術等,也就是同一時刻會同時執行不同指令的不同階段,這會導致什麼問題呢——我們簡單的將CPU執行指令分為取指,譯碼、執行等步驟,則如果剛剛執行完CR暫存器的置位,從真實模式進入了保護模式的話,此時同一時刻下幾條指令已經被送上流水線了,而之後進行譯碼等步驟的時候,是按照保護模式進行的,此時段描述符暫存器還沒有進行更新,則其除了低20位外,其餘都被置為0,包括D/B位中的D,從而CPU進行處理的時候運算元都是16位,然而我們已經進行入了32位的保護模式,後面執行的很可能都是32位下的程式碼了,這樣子很可能會導致譯碼失敗。

  綜上可以知道,現在問題的關鍵是在剛剛結束模式轉換後,必須立即要更新程式碼段描述符緩衝暫存器的值(否則尋找下一條指令會用到程式碼段描述符緩衝暫存器的值,則會出現錯誤),又要清空流水線(否則可能因為程式碼段描述符緩衝暫存器錯誤值而進行錯誤譯碼,從而導致錯誤)。這裡就是用遠端轉移進行完成。我們注意到,第一點,我們將這個程式碼仍然處於[bits 16]下進行編譯的。一方面,其運算元等仍然是16位,則不會出現上面的譯碼失敗的問題;另一方面,如果進行無條件遠端轉移,則CPU會清空流水線(分支預測大概率錯的),這樣子確保了後面的指令一定是在保護模式下進行的。另外,如果是遠端轉移的話,其會修改程式碼段暫存器的值,從而完成對於程式碼段描述符緩衝暫存器的更新,從而避免了後面因為程式碼段描述符緩衝暫存器仍處於真實模式而導致的錯誤。