真實模式到保護模式(七)
今天我們來看看從真實模式是如何進入到保護模式的以及為何要進入保護模式。在這之前,我們先來看看什麼叫真實模式,什麼是保護模式。
真實模式具有以下幾個特點:a> 它是遠古時期的程式開發方式,也就是直接操作實體記憶體; b> CPU 指令的運算元直接使用實地址(也就是實際記憶體地址); c> 程式設計師擁有絕對的權利(也就是利用 CPU 指哪打哪)。那麼這樣好嗎?對程式設計師來說是很好的,因為絕對權是掌握在自己手裡的,換句話說,自己掌握程式的生殺大權。but 凡事有利必有弊,它的弊端慢慢就呈現出來了,最主要的體現在兩方面:a> 難以重定位,因為程式每次都需要同樣的記憶體地址來執行; b> 給多道程式設計帶來障礙,不管記憶體多大,但凡一個位元組被其他程式佔用就會出現無法執行的情況。
下來我們就來說說在出現這樣的局面以後,便會有改革出現。那便是堪稱 CPU 歷史的里程碑 -- 8086 處理器的出現。為何這樣說呢?因為它是現代計算處理器的鼻祖,開創了段地址 + 偏移地址的源頭。下來我們來看看它的一些特點:a> 地址線寬度為 20 位,可訪問 1M 的記憶體空間; b> 引入 [ 段地址:偏移地址 ] 的記憶體訪問方式。8086 的段暫存器和通用暫存器為 16 位,單個暫存器定址最多訪問 64K 的記憶體空間。它是需要兩個暫存器配合使用,以此來完成記憶體空間的訪問。那麼我們來深入解析下 [ 段地址:偏移地址 ] 的這種方式。其中硬體所做的工作是將段地址左移 4 位,構成 20 位的基地址(起始地址),基地址 + 偏移地址 = 實地址
mov ax, [0x1234] ; 實地址 = (ds << 4) + 0x1234 mov ax, [es:0x1234] ; 實地址 = (es << 4) + 0x1234
我們看到在直接操作某個偏移地址時,其實硬體已經做了相應的工作,將其基地址預設設為 ds 暫存器。別的正常操作就執行相應的段地址 + 偏移地址來完成的。那麼我們在之前所說的 8086 的段暫存器和通用暫存器為 16 位,就意味著它所能訪問的最大地址為 0xFFFF : 0xFFFF,即 10FFEF;這個地址早都已經超過了 1MB 的空間,那麼 CPU 是如何處理的呢?很遺憾的是在 8086 CPU 中,它的處理方式便是不處理。說到這我們就有必要來說一個概念:HMA,8086 中的高階地址區(High Memory Area),我們通過下圖來說什麼是 HMA
我們看到多出來的 0xFFF0 的空間便是 HMA 了。因為 8086 只有 20 位地址線,因此最高位會被丟棄(也就是我們現在說的溢位),這種方式也叫作回捲。如下
不得不說 8086 在當時是非常成功的一個產品,但是它也有不足。主要體現在以下幾個方面:1、1MB 的記憶體完全是不夠用的;2、開發者在程式中大量使用記憶體回捲技術(也就是 HMA 地址被使用);3、應用程式之間沒有界限,相互之間隨意干擾,也就是 A 程式可以隨意訪問 B 程式中的資料,C 程式可以修改吸引排程程式的指令。由於存在以上的種種不足,那麼 8086 的二代 80286 也就由此粉墨登場了。它既然是 8086 的二代,那麼就必須相容 8086 的開發方式,在原來基礎上增大記憶體容量,增加地址線數量(24 位)。同時 [ 段地址:偏移地址 ] 的方式也被強化,為每個段提供更多屬性(如範圍、特權級等),為每個段的定義提供固定方式。
80286 在預設情況下完全相容 8086 的執行方式(真實模式),也就是預設可直接訪問 1MB 的記憶體空間,通過特殊的方式訪問 1MB+ 的記憶體空間。那麼既然支援原來的真實模式,也就是說它現在是另一種特殊的模式,這種特殊的方式指的是什麼呢?那便是我們所要講的保護模式了,具體區別如下所示
我們來看看保護模式具有哪些特點呢?1、每一段記憶體擁有一個屬性定義(描述符 Descriptor);2、每個段的屬性定義構成一張表(描述符表 Descriptor Table);3、段暫存器儲存的是屬性定義在表中的索引(選擇子 Selector)。我們來看看描述符(Descriptor)的記憶體結構,如下圖所示
描述附表(Descriptor Table)如下圖所示
選擇子(Selector)的結構如下圖所示
那麼我們是如何進入保護模式的呢?通過以下幾個步驟便可進入到保護模式:1、定義描述附表;2、開啟 A20 地址線;3、載入描述表;4、通知 CPU 進入保護模式。雖然 80286 在 8086 的基礎上增加了一些新功能,但是它也有缺陷。80286 的歷史意義是引入了保護模式,為現代作業系統和應用程式奠定了基礎。它的缺陷表現在段暫存器為 24 位,但是通用暫存器只有 16 位(顯得有點不倫不類)。理論上,段存器中的數值可以直接作為段基址,16 位通用暫存器最多可以訪問 64K 的記憶體,為了訪問 16M 記憶體,必須不停切換段基址。基於以上的缺陷,第三代 80386 便出現了,它是計算機新時期的標誌。它具有以下幾個特點:1、32 位地址匯流排(可支援 4G 的記憶體空間,這便是我們現代記憶體的鼻祖了);2、段暫存器和通用暫存器都為 32 位,任何一個暫存器都能訪問到記憶體的任意角落。可以說它開啟了平坦記憶體模式的新時代,段基址為 0,使用通用暫存器訪問 4G 記憶體空間。那麼新時期的記憶體使用方式也就有了 3 個模式:1、真實模式,為了相容 8086 的記憶體使用方式(指哪打哪);2、分段模式,通過 [ 段地址:偏移地址 ] 的方式將記憶體從功能上分段(資料段、程式碼段等);3、平坦模式,所有記憶體就是一個段 [ 0 : 32位偏移地址 ]。
我們下來來看看關於段屬性的定義,如下圖所示
選擇子屬性定義,如下圖所示
保護模式中的段定義,如下圖所示
彙編中的 section 關鍵字用於“邏輯的”定義一段程式碼集合,section 定義的程式碼段不同於 [ 段地址:偏移地址 ] 的程式碼段。section 定義的程式碼段僅限於原始碼中的程式碼段(程式碼節),[ 段地址:偏移地址 ] 中的程式碼段指記憶體中的程式碼段。如下
比如我們在前面定義了 .s1 為 0x1,在後面繼續定義它為 0x2,那麼它的最終值為 0x01020000,剩下的位全部補 0。在彙編中,編譯器進行編譯的時候會分為 16 位方式和 32 位方式進行編譯。具體是 [bits 16] 指示編譯器將程式碼按照 16 位方式進行編譯,[bits 32] 指示編譯器將程式碼按照 32 位方式進行編譯。其中要注意的事項:a> 段描述附表中的第 0 個描述符不使用(僅用於佔位);b> 程式碼中必須顯示的指明 16 位程式碼段和 32 位程式碼段;c> 必須使用 jmp 指令從 16 位程式碼段跳轉到 32 位程式碼段。我們接下來來看看保護模式的程式碼是怎麼編寫的,具體原始碼如下
%include "inc.asm" org 0x9000 jmp CODE16_SEGMENT [section .gdt] GDT_ENTRY : Descriptor 0, 0, 0 CODE32_DESC : Descriptor 0, Code32SegLen - 1, DA_C + DA_32 GdtLen equ $ - GDT_ENTRY GdtPtr: dw GdtLen - 1 dd 0 Code32Selector equ (0x0001 << 3) + SA_TIG +SA_RPL0 ; end of gdt [section .s16] [bits 16] CODE16_SEGMENT: mov ax, cs mov ds, ax mov es, ax mov ss, ax mov sp, 0x7c00 mov eax, 0 mov ax, cs shl eax, 4 add eax, CODE32_SEGMENT mov word [CODE32_DESC + 2], ax shr eax, 16 mov byte [CODE32_DESC + 4], al mov byte [CODE32_DESC + 7], ah mov eax, 0 mov ax, ds shl eax, 4 add eax, GDT_ENTRY mov dword [GdtPtr + 2], eax ; 1. load GDT lgdt [GdtPtr] ; 2. close interrupt cli ; 3. open A20 in al, 0x92 or al, 00000010b out 0x92, al ; 4. enter protect mode mov eax, cr0 or eax, 0x01 mov cr0, eax ; 5. jump to 32 bits code jmp dword Code32Selector : 0 [section .s32] [bits 32] CODE32_SEGMENT: mov eax, 0 jmp CODE32_SEGMENT Code32SegLen equ $ - CODE32_SEGMEN
我們在進入保護模式後,進入一個死迴圈用以驗證程式碼的正確性。執行效果如下
我們在進行反彙編之後,在第 5 步打上斷點,單步執行用以驗證程式碼的正確性,我們看到程式碼是正確的。我們在上面的程式碼中為什麼不直接使用標籤定義描述符中的段基地址?為什麼 16 位程式碼段到 32 位程式碼段必須無條件跳轉呢?那麼在彙編中,NASM 將彙編檔案當成一個獨立的程式碼段進行編譯,彙編程式碼中的標籤(Label)代表的是段內偏移地址,真實模式下需要配合段暫存器中的值計算標籤的實體地址,這便是我們不直接使用標籤定義描述符中的段基地址的原因了。程式碼跳轉則是由於在彙編中存在一個流水線技術的概念。什麼是流水線技術呢?處理器為了提高效率將當前指令和後續指令預取到流水線,因此,可能同時預期的指令中既有 16 位程式碼又有 32 位程式碼。為了避免將 32 位程式碼用 16 位程式碼的方式執行,需要重新整理流水線,此時便需要使用無條件跳轉 jmp 技術才能強制重新整理流水線。
我們在上面程式碼的編寫中,在第 5 步進行跳轉的時候是用了 dword 關鍵字,那麼它的作用是什麼呢?去掉它有何影響呢?我們從 s16 ==> s32 的時候,在 16 位程式碼中,所有的立即數預設為 16 位,所以從 16 位程式碼段跳轉到 32 位程式碼段時,必須做強制轉換。否則,段內的偏移地址可能會被截斷,如果執行的是 jmp dword Code32Selector : 0x12345678 這句指令時,它就會被截斷。到時傳過去的就只剩 0x5678 了。那麼我們在之前雖然從真實模式進入到了保護模式,可是連最基本的 hello world 都打印不出來,下來我們就繼續深入保護模式之定義視訊記憶體段。我們知道,為了顯示資料的話,必須得存在兩大硬體:顯示卡 + 顯示器。顯示卡是為顯示器提供需要顯示的資料,控制顯示器的模式和狀態;而顯示器則是將目標資料以可見的方式呈現在螢幕上。
視訊記憶體是什麼呢?顯示卡擁有自己內部的資料儲存器,即為視訊記憶體。視訊記憶體在本質上和普通記憶體並無差別,用於儲存目標資料,操作視訊記憶體中的資料將導致顯示器上內容的改變。顯示卡的工作模式分為兩種:文字模式和圖形模式。在不同模式下,顯示卡對視訊記憶體內容的解釋是不同的,可以使用專屬指令或 int 0x10 中斷改變顯示卡的工作模式。在文字模式下:視訊記憶體的地址範圍對映為:[ 0xB8000, 0XBFFF ],一完整螢幕可以顯示 25 行,每行 80 個字元。下來我們來看看顯示卡的文字模式原理,如下圖所示
文字模式下顯示字元示例程式碼如下
具體原始碼如下
%include "inc.asm" org 0x9000 jmp CODE16_SEGMENT [section .gdt] GDT_ENTRY : Descriptor 0, 0, 0 CODE32_DESC : Descriptor 0, Code32SegLen - 1, DA_C + DA_32 VIEDO_DESC : Descriptor 0xB8000, 0X07FFF, DA_DRWA + DA_32 GdtLen equ $ - GDT_ENTRY GdtPtr: dw GdtLen - 1 dd 0 Code32Selector equ (0x0001 << 3) + SA_TIG + SA_RPL0 VideoSelector equ (0x0002 << 3) + SA_TIG + SA_RPL0 ; end of gdt [section .s16] [bits 16] CODE16_SEGMENT: mov ax, cs mov ds, ax mov es, ax mov ss, ax mov sp, 0x7c00 ; initialize GDT for 32 bits code segment mov eax, 0 mov ax, cs shl eax, 4 add eax, CODE32_SEGMENT mov word [CODE32_DESC + 2], ax shr eax, 16 mov byte [CODE32_DESC + 4], al mov byte [CODE32_DESC + 7], ah mov eax, 0 mov ax, ds shl eax, 4 add eax, GDT_ENTRY mov dword [GdtPtr + 2], eax ; 1. load GDT lgdt [GdtPtr] ; 2. close interrupt cli ; 3. open A20 in al, 0x92 or al, 00000010b out 0x92, al ; 4. enter protect mode mov eax, cr0 or eax, 0x01 mov cr0, eax ; 5. jump to 32 bits code jmp dword Code32Selector : 0 [section .s32] [bits 32] CODE32_SEGMENT: mov ax, VideoSelector mov gs, ax mov edi, (80 * 12 + 37) * 2 mov ah, 0x0c mov al, 'P' mov [gs:edi], ax jmp $ Code32SegLen equ $ - CODE32_SEGMENT
我們先來定義一個視訊記憶體的段,再來定義一個對應的選擇子,最後在 32 程式碼段中進行相應的資料操作。我們來看看螢幕上是否會打印出紅色的字元 P。執行結果如下
我們看到已經打印出一個紅色的字元 P 了。那麼我們現在已經可以打印出一個字元了,列印 hello,world 就不是什麼難事了。我們繼續來完成這個在保護模式下的入門級程式設計實驗,具體該如何完成呢?1、定義全域性堆疊段(.gs),用於保護模式下的函式呼叫;2、定義全域性資料段(.dat),用於定義只讀資料(Hello,World!);3、利用對視訊記憶體段的操作定義字串列印函式(PrintString)。那麼這個列印函式應如何設計呢?流程如下圖所示
在這塊會涉及到 32 位保護模式下的乘法操作,我們就順便來看看這個操作是如何完成的。首先是將被乘數放到 AX 暫存器中,接著將乘數放到通用暫存器或 記憶體單元(16 位),最後是將相乘的結果放到EAX 暫存器中。那麼在彙編中,$ 和 $$ 有何區別呢? $ 表示當前行相對於程式碼起始位置處的偏移量,而 $$ 則表示的是當前程式碼節(section)的起始位置。示例如下
我們來看看實現字串的具體原始碼是怎麼編寫的,如下
%include "inc.asm" org 0x9000 jmp CODE16_SEGMENT [section .gdt] GDT_ENTRY : Descriptor 0, 0, 0 CODE32_DESC : Descriptor 0, Code32SegLen - 1, DA_C + DA_32 VIEDO_DESC : Descriptor 0xB8000, 0X07FFF, DA_DRWA + DA_32 DATA32_DESC : Descriptor 0, Data32SegLen - 1, DA_DR + DA_32 STACK_DESC : Descriptor 0, TopOfStackInit, DA_DRW + DA_32 GdtLen equ $ - GDT_ENTRY GdtPtr: dw GdtLen - 1 dd 0 Code32Selector equ (0x0001 << 3) + SA_TIG + SA_RPL0 VideoSelector equ (0x0002 << 3) + SA_TIG + SA_RPL0 Data32Selector equ (0x0003 << 3) + SA_TIG + SA_RPL0 StackSelector equ (0x0004 << 3) + SA_TIG + SA_RPL0 ; end of gdt TopOfStackInit equ 0x7c00 [section .dat] [bits 32] DATA32_SEGMENT: DTOS db "D.T.OS!", 0 DTOS_OFFSET equ DTOS - $$ HELLO_WORLD db "Hello World!", 0 HELLO_WORLD_OFFSET equ HELLO_WORLD - $$ Data32SegLen equ $ - DATA32_SEGMENT [section .s16] [bits 16] CODE16_SEGMENT: mov ax, cs mov ds, ax mov es, ax mov ss, ax mov sp, TopOfStackInit ; initialize GDT for 32 bits code segment mov esi, CODE32_SEGMENT mov edi, CODE32_DESC call InitDescItem mov esi, DATA32_SEGMENT mov edi, DATA32_DESC call InitDescItem mov eax, 0 mov ax, ds shl eax, 4 add eax, GDT_ENTRY mov dword [GdtPtr + 2], eax ; 1. load GDT lgdt [GdtPtr] ; 2. close interrupt cli ; 3. open A20 in al, 0x92 or al, 00000010b out 0x92, al ; 4. enter protect mode mov eax, cr0 or eax, 0x01 mov cr0, eax ; 5. jump to 32 bits code jmp dword Code32Selector : 0 ; esi --> code segment label ; edi --> descriptor label InitDescItem: push eax mov eax, 0 mov ax, cs shl eax, 4 add eax, esi mov word [edi + 2], ax shr eax, 16 mov byte [edi + 4], al mov byte [edi + 7], ah pop eax ret [section .s32] [bits 32] CODE32_SEGMENT: mov ax, VideoSelector mov gs, ax mov ax, StackSelector mov ss, ax mov ax, Data32Selector mov ds, ax mov ebp, DTOS_OFFSET mov bx, 0x0c mov dh, 12 mov dl, 33 call PrintString mov ebp, HELLO_WORLD_OFFSET mov bx, 0x0c mov dh, 13 mov dl, 31 call PrintString jmp $ ; ds:ebp --> string address ; bx --> attribute ; dx --> dh : row, dl : col PrintString: push ebp push eax push edi push cx push dx print: mov cl, [ds:ebp] cmp cl, 0 je end mov eax, 80 mul dh add al, dl shl eax, 1 mov edi, eax mov ah, bl mov al, cl mov [gs:edi], ax inc ebp inc dl jmp print end: pop dx pop cx pop edi pop eax pop ebp ret Code32SegLen equ $ - CODE32_SEGMENT
執行結果如下
我們看到已經成功打印出 hello world 了,我們將生成的 data.img 拷貝至 window 中,在建立的虛擬機器中執行,看看結果是否一致
我們看到還是正確打印出了字串,這雖然和我們之前所列印的效果是一樣的,但是在本質上已經發生了翻天覆地的變化。現在是在保護模式下的 32 位程式碼段中打印出來的,截止目前為止,我們已經成功地從真實模式切換到了保護模式。通過對保護模式的學習,總結如下:1、[ 段地址:偏移地址 ] 的定址方式解決了早期程式重定位難的問題,8086 真實模式下的程式無法保證安全性;2、80286 中剔除了保護模式,加強了記憶體段的安全性;3、出於相容性的考慮,80286 之後的處理器都有 2 種工作模式,處理器需要設定特定步驟才能進入到保護模式,預設為真實模式哦;4、80386 處理器開啟了新處理器的篇章,32 位的暫存器和地址匯流排能夠直接訪問 4G 記憶體的任意角落;5、需要在 16 位真實模式中對 GDT 中的資料進行初始化,程式碼中需要為 GDT 定義一個標識資料結構(GdtPtr),需要使用 jmp 指令從 16 位程式碼跳轉到 32 位程式碼;6、真實模式下可以使用 32 位暫存器和 32 位地址,視訊記憶體是顯示卡內部的儲存單元,其本質上與普通記憶體無任何差別;7、顯示卡有兩種工作模式:文字模式 & 圖形模式,文字模式下操作視訊記憶體單元中的資料能夠立即反應到顯示器。