全面剖析《自己動手寫作業系統》的pmtest1.asm

FreeeLinux發表於2017-03-18
段機制輕鬆體驗 
記憶體定址: 
真實模式下的記憶體定址: 
讓我們首先來回顧真實模式下的定址方式 
段首地址×16+偏移量 = 實體地址 
為什麼要×16?因為在8086CPU中,地址線是20位,但暫存器是16位的,最高定址64KB,它無法定址到1M記憶體。於是,Intel設計了這種定址方式,先縮小4位成16位放入到段暫存器,用到時候,再將其擴大到20位,這也造成了段的首地址必須是16的倍數的限制。 
公式:xxxx:yyyy 
保護模式下分段機制的記憶體定址: 
分段機制是利用一個稱作段選擇符的偏移量,從而到描述符表找到需要的段描述符,而這個段描述符中就存放著真正的段的物理首地址,再加上偏移量 
一段話,出現了三個新名詞: 
段選擇子 
描述符表 
段描述符 
================================ 
我們現在可以這樣來理解這段話: 
有一個結構體型別,它有三個成員變數: 
段物理首地址 
段界限 
段屬性 
記憶體中,維護一個該結構體型別的陣列。 
而分段機制就是利用一個索引,找到該陣列對應的結構體,從而得到段的物理首地址,然後加上偏移量,得到真正的實體地址。 
公式:xxxx:yyyyyyyy 
其中,xxxx也就是索引,yyyyyyyy是偏移量(因為32位暫存器,所以8個16進位制)xxxx存放在段暫存器中。 
================================ 
現在,我們來到過來分析一下那三個新名詞: 
段描述符:一個結構體,它有三個成員變數: 
段物理首地址 
段界限 
段屬性 
描述符表:也就是一個陣列,什麼樣的陣列呢?是一個段描述符組成的陣列。 
段選擇子:   也就是陣列的索引,但這時候的索引不在是高階語言中陣列的下標,而是我們將要找的那個段描述符相對於陣列首地址(也就是全域性描述表的首地址)偏移位置。 
就這麼簡單,如圖: 

圖中,通過Selector(段選擇子)找到儲存在Descriptor Table(描述符表)中某個Descriptor(段描述符),該段描述符中存放有該段的物理首地址,所以就可以找到記憶體中真正的物理段首地址Segment 
Offset(偏移量):就是相對該段的偏移量 
物理首地址 + 偏移量 就得到了實體地址 本圖就是DATA 
但這時,心細的朋友就發現了一個GDTR這個傢伙還沒有提到! 
我們來看一下什麼是GDTR 
Global Descriptor Table Register(全域性描述符表暫存器) 
但是這個暫存器有什麼用呢 ? 
大家想一下,段描述符表現在是存放在記憶體中,那CPU是如何知道它在哪裡呢?所以,Iterl公司設計了一個全域性描述符表暫存器,專門用來存放段描述符表的首地址,以便找到記憶體中段描述符表。 
這時,段描述符表地址被存到GDTR暫存器中了。 
================================= 
好了,分析就到這,我們來看一下正式的定義: 
當x86 CPU 工作在保護模式時,可以使用全部32根地址線訪問4GB的記憶體,因為80386的所有通用暫存器都是32位的,所以用任何一個通用暫存器來間接定址,不比分段就可以訪問4G空間中任意的記憶體地址。 
但這並不意味著,此時段暫存器就不再有用了。實際上,段暫存器更加有用了,雖然再定址上沒有分段的限制了,但在保護模式下,一個地址空間是否可以被寫入,可以被多少優先順序的程式碼寫入,是不是允許執行等等涉及保護的問題就出來了。要解決這些問題,必須對一個地址空間定義一些安全上的屬性。段暫存器這時就派上了用場。但是設計屬性和保護模式下段的引數,要表示的資訊太多了,要用64位長的資料才能表示。我們把著64位的屬性資料叫做段描述符,上面說過,它包含3個變數: 
段物理首地址、段界限、段屬性 
80386的段暫存器是16位(注意:通用暫存器在保護模式下都是32位,但段暫存器沒有被改變)的,無法放下保護模式下64位的段描述符。如何解決這個問題呢?方法是把所有段的段描述符順序存放在記憶體中的指定位置,組成一個段描述符表(Descriptor Table);而段暫存器中的16位用來做索引資訊,這時,段暫存器中的資訊不再是段地址了,而是段選擇子(Selector)。可以通過它在段描述符表中“選擇”一個專案已得到段的全部資訊。 
那麼段描述符表存放在哪裡呢?80386引入了兩個新的暫存器來管理段描述符,就是GDTR和LDTR,(LDTR大家先忘記它,隨著學習的深入,我們會在以後學習)。 
這樣,用以下幾步來總體體驗下保護模式下定址的機制 
1、段暫存器中存放段選擇子Selector 
2、GDTR中存放著段描述符表的首地址 
3、通過選擇子根據GDTR中的首地址,就能找到對應的段描述符 
4、段描述符中有段的物理首地址,就得到段在記憶體中的首地址 
5、加上偏移量,就找到在這個段中存放的資料的真正實體地址。 
好的,那我們開始編碼,看看如何實現先前描述的內容 
================================= 
首先,既然我們需要一個陣列,全域性描述符表,那我們就定義一塊連續的結構體: 
[SECTION .gdt] ;為了程式碼可讀性,我們將這個陣列放到一個節中 
;由一塊連續的地址組成的,不就是一個陣列嗎?看下面程式碼,^_^ 
段基地址 段界限 段屬性 
GDT_BEGIN: Descriptor 0,   0, 0 
GDT_CODE32: Descriptor 0, 0, DA_C 
;上面,我定義了二個連續地址的結構體,大家先認為Descriptor就是一個結構體型別,我們會在以後詳細講述 
;第一個結構體,全部是0,是為了遵循Interl規範,先記得就OK 
;第二個定義了一個程式碼段,段基地址和段界限我們暫且還不知道,先初始化為0,但是因為是個程式碼段,程式碼段具備執行的屬性,那麼DA_C就代表是一個可執行程式碼段,DA_C是一個預先定義好的常量,我們會在詳細講解段描述符中講解。 
================================= 
我們繼續來實現,那麼下面,我們就需要設計段選擇子了,因為上面程式碼已經包含了段描述符和全域性描述符表 
還記得選擇子是個什麼東西嗎 ? 
段選擇子:   也就是陣列的索引,但這時候的索引不在是高階語言中陣列的下標,而是我們將要找的那個段描述符相對於陣列首地址(也就是全域性描述表的首地址)偏移位置。 
看我程式碼怎麼實現,包含以上程式碼不再說明: 
[SECTION .gdt] 
GDT_BEGIN: Descriptor 0, 0, 0 
GDT_CODE32: Descriptor 0, 0, DA_C 
;下面是定義程式碼段選擇子,它就是相對陣列首地址的偏移量 
SelectorCode32 equ GDT_CODE32 - GDT_BEGIN 
;因為第一個段描述符,不被使用,所以就不比設定段選擇子了。 
================================= 
偏移地址: 
注意一點,我們在程式中使用的都是偏移地址,相對於段的偏移地址,用上面的例子來說,象 GDT_CODE32 GDT_BEGIN 這些結構體的首地址都是相對於資料段的偏移量。什麼意思呢 ? 
因為我們的程式到底載入到記憶體的哪個地方是不固定,不知道的,只需使用偏移地址操作就行了,如: 
SelectorCode32 ,它本身就是一個偏移地址 
但是SelectorCode32 equ GDT_CODE32 - GDT_BEGIN 
怎麼解釋呢 ? 
GDT_CODE32是相對於資料段的偏移量, 
GDT_BEGIN也是相對於資料段的偏移量,雖然它是陣列的首地址,說的羅索一些,GDT_BEGIN是陣列的首地址,但是它是相對於資料段的偏移量 
那麼兩個偏移量相減就是GDT_CODE32 相對於GDT_BEGIN的偏移量 
所以,我們要時時刻刻記得,在程式中,我們永遠使用的是偏移量,因為我們不知道程式將要被載入記憶體那塊地方。 
好了,基礎也學的差不多了,下面我們要自己動手寫一段程式,實現真實模式到保護模式之間的跳轉 
===================================================================== 
;實現從真實模式到保護模式之間的跳轉 
;參考:《自己動手寫作業系統》 
---------------------------------------------------------------------- 
%include "pm.inc" 

org 0100h 
jmp LABEL_BEGIN 
[SECTION .gdt] 
GDT_BEGIN: Descriptor 0, 0,   0 
GDT_CODE32: Descriptor 0, LenOfCode32 - 1, DA_C + DA_32 
GDT_VIDEO: Descriptor 0B8000H, 0FFFFH,   DA_DRW 
GdtLen equ $ - GDT_BEGIN 
GdtPtr dw GdtLen - 1 
dd 0 
;定義段選擇子 
SelectorCode32 equ GDT_CODE32 - GDT_BEGIN 
SelectorVideo equ GDT_VIDEO - GDT_BEGIN 
[SECTION .main] 
[BITS 16] 
LABEL_BEGIN: 
mov ax, cs 
mov ds, ax 
mov es, ax 
mov ss, ax 

;初始化32位程式碼段選擇子 
;我們可以在真實模式下通過段暫存器×16 + 偏移兩 得到實體地址, 
;那麼,我們就可以將這個實體地址放到段描述符中,以供保護模式下使用, 
;因為保護模式下只能通過段選擇子 + 偏移量 
xor eax, eax 
mov ax, cs 
shl eax, 4 
add eax, LABEL_CODE32 
mov word [GDT_CODE32 + 2],ax 
shr eax, 16 
mov byte [GDT_CODE32 + 4],al 
mov byte [GDT_CODE32 + 7],ah 
;得到段描述符表的實體地址,並將其放到GdtPtr中 
xor eax, eax 
mov ax, ds 
shl eax, 4 
add eax, GDT_BEGIN 
mov dword [GdtPtr + 2],eax 

;載入到gdtr,因為現在段描述符表在記憶體中,我們必須要讓CPU知道段描述符 表在哪個位置 
;通過使用lgdtr就可以將源載入到gdtr暫存器中 
lgdt [GdtPtr] 
;關中斷 
cli 
;開啟A20線 
in al, 92h 
or al, 00000010b 
out 92h, al 
;準備切換到保護模式,設定PE為1 
mov eax, cr0 
or eax, 1 
mov cr0, eax 
;現在已經處在保護模式分段機制下,所以定址必須使用段選擇子:偏移量來 定址 
;跳轉到32位程式碼段中 
;因為此時偏移量位32位,所以必須dword告訴編譯器,不然,編譯器將階段 成16位 
jmp dword SelectorCode32:0;跳轉到32位程式碼段第一條指令開始執行 

[SECTION .code32] 
[BITS 32] 
LABEL_CODE32: 
mov ax, SelectorVideo 
mov es, ax 
xor edi, edi 
mov edi, (80 * 10 + 10) 
mov ah, 0ch 
mov al, 'G' 
mov [es:edi],ax 
jmp $ 
LenOfCode32 equ $ - LABEL_CODE32 
=================================== 
這段程式碼的大概意思是: 
先在16位程式碼段,真實模式下執行,在真實模式下,通過段暫存器×16+偏移量得到32位程式碼的真正物理首地址,並將放入到段描述符表中,以供在保護模式下使用,上面說過了,保護模式下定址,是通過段選擇子,段描述符表,段描述符一起工作定址的。所以在真實模式下所做的工作就是初始化段描述符表裡的所有段描述符。 
我們來看一下段描述符表,它有3個段: 
GDT_BEGIN 
GDT_CODE32 
GDT_VIDEO 
GDT_BEGIN,遵循Intel公司規定,全部置0 
GDT_CODE32,32位程式碼段描述符,供保護模式下使用 
GDT_VIDEO,視訊記憶體段首地址,我們知道,視訊記憶體首地址是0B8000H. 
回想一下,我們在真實模式下往顯示器上輸出文字時,我們設定段暫存器為 
0B800h,(注意後面比真正實體地址少一個0)。 
而我們現在在保護模式下訪問視訊記憶體,那麼0B8000h就可以直接放到段描述符中即可。因為段描述符中存放的是段的真正的實體地址。 
下面我們來逐行分析該程式碼 
org 0100h 
這句話告訴載入器,將這段程式載入到偏移段首地址0100h處,即:偏移256位元組處,為什麼要載入到偏移256個位元組處呢 ?這是因為,在DOS中,需要留下256個位元組和DOS系統進行通訊。 
jmp LABEL_BEGIN 
執行這句話就跳轉到LABEL_BEGIN處開始執行。 
好,我們看一下LABEL_BEGIN在那塊,也就是16位程式碼段 
[SECTION .main] 
[BITS 16] 
LABEL_BEGIN: 
這樣程式就從.main節的第一段程式碼開始執行。 
我們看一下上面的程式碼,[BITS 16]告訴編譯器,這是一個16位程式碼段,所使用的暫存器都是16位暫存器。 
該程式碼段初始化所有段描述符表中的段物理首地址 
首先在真實模式下計算出32位程式碼段的物理首地址 
對照 段值 × 16 + 偏移量 = 實體地址 
1 mov ax, cs 
2 shl eax, 4 ;向左移動4位,不就是×16嗎?呵呵 
;到現在為止,eax就是程式碼段的物理首地址了,那麼。。。看 
3 add eax, LABEL_CODE32 
;為eax (程式碼段首地址)加上 LABEL_CODE32偏移量,得到的不就是LABEL_CODE32的真正實體地址了嗎 ?LABEL_CODE32在程式中,不就是32位程式碼段的首地址嗎 ? 
上面說過,程式碼中,使用的變數,或者標籤 都是相對程式物理首地址的偏移量。 
OK,現在我們已經知道了32位程式碼段的物理首地址,那麼將eax放入到段描述符中就行了 
我們先假設Descriptor就是一個結構體型別,(實際它是一個巨集定義的資料結構,為了不影響整體思路,我們放到以後講) 
看一下這個Descriptor段描述符的記憶體模型: 
; 高地址………………………………………………………………………低地址 
; |   7   |   6   |   5   |   4   |   3   |   2   |   1   |   0   | 
共 8 位元組 
; |--------========--------========--------========--------========| 
; ┏━━━┳━━━━━━━┳━━━━━━━━━━━┳━━━━━━━┓ 
; ┃31..24┃   段屬性   ┃   段基址(23..0)   ┃ 段界限(15..0)┃ 
; ┃   ┃       ┃   |       ┃       ┃ 
; ┃ 基址2┃       ┃基址1b│   基址1a   ┃   段界限1 ┃ 
; ┣━━━╋━━━┳━━━╋━━━━━━━━━━━╋━━━━━━━┫ 
; ┃   %6 ┃ %5 ┃ %4 ┃ %3 ┃   %2   ┃   %1   ┃ 
; ┗━━━┻━━━┻━━━┻━━━┻━━━━━━━┻━━━━━━━┛ 
由於歷史原因,段描述符的記憶體排列不是按照 段基地址 段界限 段屬性 這樣的來排列的,所以我們現在要想一種辦法,把eax裡所存放的物理首地址拆開,分別放到2,3,4,7位元組處 
那麼很顯然,我們可以將eax暫存器中的ax先放到2,3位元組處 
mov word [GDT_CODE32 + 2],ax 
因為在偏移2個位元組處,所以,首地址 + 2,才能定位到下標為2的位元組開頭處 
而,word 告訴編譯器,我要一次訪問2個位元組的記憶體 
好,簡單的搞定了,那麼再看,我們現在要將eax高16位元組分別放到下標為4,7位元組處。 
雖然eax的ax代表低16位,但是Intel並沒有給高位一個名字定義,(不會是high ax,呵呵),所以,我們沒有辦法去訪問高位。但是我們可以將高16位放到低16位中,因為這時,低16位我們已經不關心它的值了。 
好,看程式碼 
shr eax, 16 
這句程式碼就將eax向右移動16位,低位被拋棄,高位變成了低位。呵呵。。。 
現在好辦了,低16位又可以分為al,和 ah,那麼現在我們就將al放到4位置,ah放到7位置吧 
mov byte [GDT_CODE32 + 4], AL 
mov byte [GDT_CODE32 + 7], AH 
不用我再解釋這段程式碼了,自己去分析為什麼吧。。。。 

好了,32位程式碼段描述符設定好了,其界限設定看程式碼吧,為什麼要那樣設定,很簡單的,界限 = 長度 - 1,段屬性: 
DA_C: 98h   可執行 
DA_32: 4000h 32位程式碼段 
是個常量,換算成二進位制位,對照段描述符屬性位置去看吧,參考任意一本保護模式書。 
段描述符設定好了,但是,先段描述符表,還在記憶體中,我們必須想辦法放到暫存器中,這時,就用到了gdtr(Golbal Descriptor Table Register),使用一條指令 
lgdtr [GdtPtr] 
就可以將GdtPtr載入到gdtr中 
而gdtr的記憶體模型是: 
高位元組               低位元組 

但GdtPtr是什麼呢 ? 
就是我們定義的和這個暫存器記憶體模型一摸一樣的結構體: 
GdtLen equ $ - LABEL_BEGIN 
GdtPtr dw GdtLen - 1   ;界限 
dd 0   ;真正實體地址 
那現在我們就要計算GdtPtr第二個位元組 也就是真正實體地址了 
xor eax, eax 
mov ax, ds 
shl eax, 4 
add eax, GDT_BEGIN 
mov dword [GdtPtr + 2],eax 
自己分析吧,和計算32位段首地址基本一樣的, 
搞定後,使用lgdt [GdtPtr]就將此載入到暫存器GDTR中了 
然後關中斷 
cli 真實模式下的中斷和保護模式下的中斷處理不一樣,那就關吧,規矩 
開啟A20線 
in al, 92h 
or al, 00000010b 
out 92h, al 
如果不開啟A20線,就無辦法訪問1M之上的記憶體,沒辦法,開啟吧,規矩,想知道歷史了,去查吧 
然後設定CR0的PE位 
mov eax, cr0 
or eax, 1 
mov cr0, eax 
這個簡單說一下,以後再詳細 
CR0也是一個暫存器,其中有個PE位,如果為0,就說明為真實模式, 
如果置1,說明為保護模式。現在我們要進入保護模式下工作,那麼就要設定PE為1。 
好了,看一下這個main節中的最後一個程式碼 
jmp dword SelectorCode32 : 0 
哈哈,現在已經再保護模式下了,當然要使用段選擇子 + 偏移量來定址啊,這樣不就是定址到了32位程式碼段中去了嗎,偏移量為0不就說明從第一個程式碼開始執行。 
不是嗎 ?呵呵,那dword了? 
因為現在的程式碼段是16位,編譯器只能將它編譯位16位,但處於保護模式下,它的偏移量應該是32位,所以,要顯示告訴編譯器,我這裡使用的是32位,把我這塊給編譯成32位的!!! 
如果不加dword, 
jmp SelectorCode32:0 
這句話不會出什麼問題,16位的0是0,32位的0還是0,但如果這樣呢?: 
jmp SelectorCode32:0x12345678 
跳轉到偏移0x12345678中,這時就錯了 
如果不將dword,編譯器就將該地址截斷成16位,取低位,變成了0x5678 
你說對嗎 ?哈哈 
所以我們必須這樣做: 
jmp dword SelectorCodde32:0x12345678 
OKEY,我們繼續追擊,執行完上面那個跳轉後, 
程式碼就跳到了32位程式碼段的中,開始執行第一條指令 
mov ax, SelectorVideo 
再看 
mov es,ax 
呵呵,真實模式下,放的是16位的段值,而現在呢,不就是要將段選擇子放到段暫存器裡嗎 ?然後通過段選擇子(偏移量)找到描述符表中對應的段描述符的嗎 !!!! 
繼續看下面程式碼 
xor edi, edi 
mov edi, (80 * 10 + 10) 
mov ah, 0ch 
mov al, 'G' 
跟真實模式下差不多,設定目標10行10列 
設定現實字元:G 
mov [es:edi],ax 
也和真實模式下一樣, 
只不過真實模式是這樣來定址 : 
es×16 + edi 
而保護模式下呢 
es是一個偏移,根據這個偏移找到段描述符表中的對應視訊記憶體段,然後這個視訊記憶體段裡存放的就是0B8000h,然後在加上偏移 不就的了嗎!!! 
哈哈 。。。。程式分析完畢,細節之處,自己體會去 
總結: 
1. 注意程式中使用的全部是偏移地址。注意兩種偏移地址 
A 對於程式的起始地址來說,所有變數和標籤都是相對於整個程式的偏移量 
B 對於段中定義的程式碼,有兩種偏移: 
相對於程式起始地址的偏移 
相對於段標籤的偏移。 
2.不管是真實模式下的實體地址,還是保護模式下的實體地址,反正他們都是實體地址,呵呵,真實模式下求的實體地址,也能在保護模式下使用,只是他們不同的是,如何定址的方式不一樣。 
3.一個程式中可以包含多個不同位的段,32位或者16位,他們之間也可以互相跳轉,只是32位段用的是32位暫存器,16位程式碼段用的是16位暫存器,如果要在16位段下使用32位暫存器,必須象高階語言中強制型別轉換一樣,顯示的定義 dword 
參考: 《自動動手寫作業系統》 
《Undocument Windows 2000 Secrets》 
《Linux 核心完全剖析》

相關文章