概況
保護模式與實模式相對應。在80286以前,CPU只有實時模式,地址匯流排有20位,而記憶體地址是16位,也就是最多能夠訪問2^20=1M的記憶體空間。在80286及以後,記憶體地址改為16位或32位,至少可以訪問到2^32=4G的記憶體空間。但為了保證後續的CPU能夠運行舊的CPU,只能保持向下兼容。因此,80286及以後的CPU首先進入實模式,然後通過切換機制再進入到保護模式。
實模式
那么什麼是實模式呢?CPU復位(reset)或加電(power on)的時候以實模式啟動,處理器以實模式工作。在實模式下,記憶體定址方式和8086相同,由16位段暫存器的內容乘以16(10H)當做段基地址,加上16位偏移地址形成20位的物理地址,最大定址空間1MB,最大分段64KB。可以使用32位指令。32位的x86 CPU用做高速的8086。在實模式下,所有的段都是可以讀、寫和可執行的。
保護模式與實模式相比,主要是兩個差別:一是提供了段間的保護機制,防止程式間胡亂訪問地址帶來的問題,二是訪問的記憶體空間變大,見前面的描述。
在8086/8088時代,處理器只存在一種操作模式(Operation Mode),當時由於不存在其它操作模式,因此這種模式也沒有被命名。自從80286到80386開始,處理器增加了另外兩種操作模式——保護模式和系統管理模式SMM(System Management Mode),因此,8086/8088的模式被命名為實地址模式RM(Real-address Mode)。
保護模式是處理器的本機模式,在這種模式下,處理器支持所有的指令和所有的體系結構特性,提供最高的性能和兼容性。對於所有的新型應用程式和作業系統來說,建議都使用這種模式。為了保證PM的兼容性,處理器允許在受保護的,多任務的環境下執行RM程式。這個特性被稱做虛擬8086模式(Virtual -8086 Mode),儘管它並不是一個真正的處理器模式。Virtual-8086模式實際上是一個PM的屬性,任何任務都可以使用它。
RM提供了Intel 8086處理器的編程環境,另外有一些擴展(比如切換到PM或SMM的能力)。當主機被Power-up或Reset後,處理器處於RM下。
SMM是一個對所有Intel處理器都統一的標準體系結構特性。出現於Intel386 SL晶片。這個模式為OS實現平台指定的功能(比如電源管理或系統安全)提供了一種透明的機制。當外部的SMM interrupt pin(SMI#)被激活或者從APIC(Advanced Programming Interrupt Controller)收到一個SMI,處理器將進入SMM。在SMM下,當保存當前正在運行程式的整個上下文(Context)時,處理器切換到一個分離的地址空間。然後SMM指定的代碼或許被透明的執行。當從SMM返回時,處理器將回到被系統管理中斷之前的狀態。
由於機器在Power-up或Reset之後,處理器處於RM狀態,而對於Intel 80386以及其後的晶片,只有使用PM才能發揮出最大的作用。所以我們就面臨著一個從RM切換到PM的問題。
本文不討論SMM,本節的重點集中於在Booting階段如何從RM切換到PM,這裡不會過多的討論PM的細節,因為《Intel Architecture Software Developer’s Manual Volume 3: System Programming》中有非常詳盡和準確的介紹。
GDT
全局描述表(GDT Global Descriptor Table):在保護模式下,一個重要的必不可少的數據結構就是它。
為什麼要有GDT?我們首先考慮一下在實時模式下的編程模型:
在實時模式下,我們對一個記憶體地址的訪問是通過Segment:Offset的方式來進行的,其中Segment是一個段的Base Address,一個Segment的最大長度是64 KB,這是16-bit系統所能表示的最大長度。而Offset則是相對於此Segment Base Address的偏移量。Base Address+Offset就是一個記憶體絕對地址。由此,我們可以看出,一個段具備兩個因素:Base Address和Limit(段的最大長度),而對一個記憶體地址的訪問,則是需要指出:使用哪個段?以及相對於這個段Base Address的Offset,這個Offset應該小於此段的Limit。當然對於16-bit系統,Limit不要指定,默認為最大長度64KB,而 16-bit的Offset也永遠不可能大於此Limit。我們在實際編程的時候,使用16-bit段暫存器CS(Code Segment),DS(Data Segment),SS(Stack Segment)來指定Segment,CPU將段暫存器中的數值向左偏移4-bit,放到20-bit的地址線上就成為20-bit的Base Address。
到了保護模式,記憶體的管理模式分為兩種,段模式和頁模式,其中頁模式也是基於段模式的。也就是說,保護模式的記憶體管理模式事實上是:純段模式和段頁式。進一步說,段模式是必不可少的,而頁模式則是可選的——如果使用頁模式,則是段頁式;否則這是純段模式。
既然是這樣,我們就先不去考慮頁模式。對於段模式來講,訪問一個記憶體地址仍然使用Segment:Offset的方式,這是很自然的。由於保護模式運行在32位系統上,那么Segment的兩個因素:Base Address和Limit也都是32位的。IA-32允許將一個段的Base Address設為32-bit所能表示的任何值(Limit則可以被設為32-bit所能表示的,以2^12為倍數的任何值),而不象實時模式下,一個段的Base Address只能是16的倍數(因為其低4-bit是通過左移運算得來的,只能為0,從而達到使用16-bit段暫存器表示20-bit Base Address的目的),而一個段的Limit只能為固定值64 KB。另外,保護模式,顧名思義,又為段模式提供了保護機制,也就說一個段的描述符需要規定對自身的訪問許可權(Access)。所以,在保護模式下,對一個段的描述則包括3方面因素:[Base Address, Limit, Access],它們加在一起被放在一個64-bit長的數據結構中,被稱為段描述符。這種情況下,如果我們直接通過一個64-bit段描述符來引用一個段的時候,就必須使用一個64-bit長的段暫存器裝入這個段描述符。但Intel為了保持向後兼容,將段暫存器仍然規定為16-bit(儘管每個段暫存器事實上有一個64-bit長的不可見部分,但對於程式設計師來說,段暫存器就是16-bit的),那么很明顯,我們無法通過16-bit長度的段暫存器來直接引用64-bit的段描述符。怎么辦?
解決的方法就是把這些長度為64-bit的段描述符放入一個數組中,而將段暫存器中的值作為下標索引來間接引用(事實上,是將段暫存器中的高13-bit的內容作為索引)。這個全局的數組就是GDT。事實上,在GDT中存放的不僅僅是段描述符,還有其它描述符,它們都是64-bit長,我們隨後再討論。
GDT可以被放在記憶體的任何位置,那么當程式設計師通過段暫存器來引用一個段描述符時,CPU必須知道GDT的入口,也就是基地址放在哪裡,所以Intel的設計者門提供了一個暫存器GDTR用來存放GDT的入口地址,程式設計師將GDT設定在記憶體中某個位置之後,可以通過LGDT指令將GDT的入口地址裝入此暫存器,從此以後,CPU就根據此暫存器中的內容作為GDT的入口來訪問GDT了。
GDT是保護模式所必須的數據結構,也是唯一的——不應該,也不可能有多個。另外,正象它的名字(Global Descriptor Table)所揭示的,它是全局可見的,對任何一個任務而言都是這樣。
除了GDT之外,IA-32還允許程式設計師構建與GDT類似的數據結構,它們被稱作LDT(Local Descriptor Table),但與GDT不同的是,LDT在系統中可以存在多個,並且從LDT的名字可以得知,LDT不是全局可見的,它們只對引用它們的任務可見,每個任務最多可以擁有一個LDT。另外,每一個LDT自身作為一個段存在,它們的段描述符被放在GDT中。
IA-32為LDT的入口地址也提供了一個暫存器LDTR,因為在任何時刻只能有一個任務在運行,所以LDT暫存器全局也只需要有一個。如果一個任務擁有自身的LDT,那么當它需要引用自身的LDT時,它需要通過LLDT將其LDT的段描述符裝入此暫存器。LLDT指令與LGDT指令不同的是,LGDT指令的運算元是一個32-bit的記憶體地址,這個記憶體地址處存放的是一個32-bit GDT的入口地址,以及16-bit的GDT Limit。而LLDT指令的運算元是一個16-bit的選擇子,這個選擇子主要內容是:被裝入的LDT的段描述符在GDT中的索引值——這一點和剛才所討論的通過段暫存器引用段的模式是一樣的。
LDT
LDT只是一個可選的數據結構,你完全可以不用它。使用它或許可以帶來一些方便性,但同時也帶來複雜性,如果你想讓你的OS核心保持簡潔性,以及可移植性,則最好不要使用它。
引用GDT和LDT中的段描述符所描述的段,是通過一個16-bit的數據結構來實現的,這個數據結構叫做Segment Selector——段選擇子。它的高13位作為被引用的段描述符在GDT/LDT中的下標索引,bit 2用來指定被引用段描述符被放在GDT中還是到LDT中,bit 0和bit 1是RPL——請求特權等級,被用來做保護目的,我們這裡不詳細討論它。
前面所討論的裝入段暫存器中作為GDT/LDT索引的就是Segment Selector,當需要引用一個記憶體地址時,使用的仍然是Segment:Offset模式,具體操作是:在相應的段暫存器裝入Segment Selector,按照這個Segment Selector可以到GDT或LDT中找到相應的Segment Descriptor,這個Segment Descriptor中記錄了此段的Base Address,然後加上Offset,就得到了最後的記憶體地址。
安裝描述
由上一節的討論得知,GDT是保護模式所必須的數據結構,那么我們在進入保護模式之前,必須設定好GDT,並通過LGDT將其裝入相應的暫存器。
儘管GDT允許被放在記憶體的任何位置,但由於GDT中的元素——描述符——都是64-bit長,也就是說都是8個位元組,所以為了讓CPU對GDT的訪問速度達到最快,我們應該將GDT的入口地址放在以8個位元組對齊,也就是說是8的倍數的地址位置。
GDT中第一個描述符必須是一個空描述符,也就是它的內容應該全部為0。如果引用這個描述符進行記憶體訪問,則是產生General Protection異常。
如果一個OS不使用虛擬記憶體,段模式會是一個不錯的選擇。但現代OS沒有不使用虛擬記憶體的,而實現虛擬記憶體的比較方便和有效的記憶體管理方式是頁式管理。但是在IA-32上如果我們想使用頁式管理,我們只能使用段頁式——沒有方法可以完全禁止段模式。但我們可以盡力讓段的效果降低的最小。
IA-32提供了一種被稱作“Basic Flat Model”的分段模式可以達到這種效果。這種模式要求在GDT中至少要定義兩個段描述符,一個用來引用Data Segment,另一個用來引用Code Segment。這2個Segment都包含整個線性空間,即Segment Limit = 4 GB,即使實際的物理記憶體遠沒有那么多,但這個空間定義是為了將來由頁式管理來實現虛擬記憶體。
在這裡,我們只是處於啟動階段,所以我們只需要初步設定一下GDT,等真正進入保護模式,啟動了OS Kernel之後,具體OS打算如何設定GDT,使用何種記憶體管理模式,由Kernel自身來設定,啟動只需要給Kernel的數據段和代碼段設定全部線性空間就可以了。
段描述符的格式如下圖所示:
具體到代碼段和數據段,它們的格式如下圖所示:
下面就是在啟動階段為進入保護模式而設定的臨時的gdt。這裡定義了3個段描述符:第一個是系統規定的空描述符,第2個是引用4 GB線性空間的代碼段,第3個是引用4 GB線性空間的數據段。這是"Basic Flat Model"所要求的最下GDT設定,但就啟動階段,只是為了進入保護模式,並為核心提供一個連續的,最大的線性空間這個目的而言,已經足夠了。
# Descriptor tables
gdt:
.word 0, 0, 0, 0 # dummy
.word 0xFFFF # 4Gb - (0x100000*0x1000 = 4Gb)
.word 0 # base address = 0
.word 0x9A00 # code read/exec
.word 0x00CF # granularity = 4096, 386
# (+5th nibble of limit)
.word 0xFFFF # 4Gb - (0x100000*0x1000 = 4Gb)
.word 0 # base address = 0
.word 0x9200 # data read/write
.word 0x00CF # granularity = 4096, 386
# (+5th nibble of limit)
載入描述
設定好GDT之後,我們需要通過LGDT指令將設定的gdt的入口地址和gdt表的大小裝入GDTR暫存器。
GDTR暫存器包括兩部分:32-bit的線性基地址,以及16-bit的GDT大小(以位元組為單位)。需要注意的是,對於32-bit線性基地址,必須是32-bit絕對物理地址,而不是相對於某個段的偏移量。而我們在啟動階段,在進入保護模式之前,我們CS和DS設定很可能不是0,所以我們必須計算出gdt的絕對物理地址。
為了執行LGDT指令,你需要把這兩部分內容放在記憶體的某個位置,然後將這個位置的記憶體地址作為運算元傳遞給LGDT指令。然後LGDT指令會自動將保存在這個位置的這兩部分值裝入GDTR暫存器。
# 這是存放GDTR所需的兩部分內容的位置
gdt_48:
.word 0x8000 # gdt limit=2048,
# 256 GDT entries
.word 0, 0 # gdt base (filled in later)
# 下面這段代碼用來計算GDT的32-bit線性地址,並將其裝入GDTR暫存器。
xorl %eax, %eax # Compute gdt_base
movw %ds, %ax # (Convert %ds:gdt to a linear ptr)
shll 4, %eax
addl $gdt, %eax
movl %eax, (gdt_48+2)
lgdt gdt_48 # load gdt with whatever is appropriate
其他東西
在進入保護模式之前,除了需要設定和裝入GDT之外,還需要做如下一些事情:
禁止所有可禁止中斷;
裝入IDTR;中文全稱:中斷描述表暫存器
所有協處理器被正確的復位。
由於在實時模式和保護模式下的中斷處理機制有一些不同,所以在進入保護模式之前,務必禁止所有可禁止中斷,這可以通過下面兩種方法之一:
使用CLI指令;
對8259A可程式中斷控制器編程以禁止所有中斷。
即使當我們進入保護模式之後,也不能馬上將中斷打開,這時因為我們必須在OS Kernel中對相關的保護模式中斷處理所需的數據結構正確的初始化之後,才能打開中斷,否則會產生處理器異常。
在實時模式下,中斷處理使用IVT(Interrupt Vector Table),在保護模式下,中斷處理使用IDT(Interrupt Descriptor Table),所以,我們必須在進保護模式之前設定IDTR。
IDTR的格式和GDTR相同,IDTR的裝入方式和GDTR也相同。由於IDT中相關的中斷處理程式需要讓OS Kernel來設定,所以在啟動階段,我們只需要將IDTR中IDT的基地址和Size都設為0就可以了,隨後,等進入保護模式之後,由OS Kernel來真正設定它。
關於中斷機制和中斷處理,請參考 Interrupt & Exception ,這裡不再贅述。
#
# 這是存放IDTR所需的兩部分內容的位置
#
idt_48:
.word 0 # idt limit = 0
.word 0, 0 # idt base = 0L
# 對於IDTR的處理,只需要這一條指令即可
lidt idt_48 # load idt with 0,0
#
# 通過設定8259A PIC,禁止所有可禁止中斷
#
movb xFF, %al # mask all interrupts for now
outb %al, xA1
call delay
movb xFB, %al # mask all irq's but irq2 which
outb %al, x21 # is cascaded
# 保證所有的協處理都被正確的Reset
xorw %ax, %ax
outb %al, xf0
call delay
outb %al, xf1
call delay
# Delay is needed after doing I/O
delay:
outb %al,x80
ret
5. Let's Go
好,一切準備就緒
進入保護模式,還是進入實時模式,完全靠CR0暫存器的PE標誌位來控制:如果PE=1,則CPU切換到PM,否則,則進入RM。
設定CR0-PE位的方法有兩種:
第一種
第一種是80286所使用的LMSW指令,後來的80386及更高型號的CPU為了保持向後兼容,都保留了這個指令。這個指令只能影響最低的4 bit,即PE,MP,EM和TS,對其它的沒有影響。
#
#通過LMSW指令進入保護模式
#
movw $0x0001, %ax # protected mode (PE) bit
lmsw %ax # This is it!
第二種
第二種是Intel所建議的在80386以後的CPU上使用的進入PM的方式,即通過移動MOV指令。MOV指令可以設定CR0暫存器的所有域的值。
#
#通過MOV指令進入保護模式
#
movl %cr0, %eax
xorb $0x01, %al # set PE = 1
movl %eax, %cr0 # go!!
現在已經進入保護模式了。
啟動核心
我們已經從實時模式進入保護模式,現在我們馬上就要啟動OS Kernel了。
OS Kernel運行在32-bit段模式,而當前我們卻仍然處於16-bit段模式。這是怎么回事?為了了解這個問題,我們需要仔細探討一下IA-32的段模式的實現方法。
IA-32共提供了6個16-bit段暫存器:CS,DS,SS,ES,FS,GS。但事實上,這16-bit只是對程式設計師可見的部分,但每個暫存器仍然包括64-bit的不可見部分。
可見部分是為了供程式設計師裝載段暫存器,但一旦裝載完成,CPU真正使用的就只是不可見部分,可見部分就完全沒有用了。
不可見部分存放的內容是什麼?具體格式我沒有看到相關資料,但可以確定的是隱藏部分的內容和段描述符的內容是一致的(請參考段描述的格式),只不過格式可能不完全相同。但格式對我們理解這一點並不重要,因為程式設計師不可能能夠直接操作它。
我們以CS暫存器為例,對於其它暫存器也是一樣的:
在實時模式下,當我們執行一個裝載CS暫存器的指令的時候(jmp,call,ret等),相關的值會被裝入CS暫存器的可見部分,但同時CPU也會根據可見部分的內容來設定不可見部分。比如我們執行"ljmp x1234, $go "之後,CS暫存器的可見部分的內容就是1234h,同時,不可見部分的32-bit Base Address域被設定為00001234h,20-bit的Limit域被設定為固定值10000h,也就是64 KB,Access Information部分的其它值我們不去考慮,只考慮其D/B位,由於執行此指令時處於Real Mode模式,所以D/B被設定為0,表示此段是一個16-bit段。當對CS暫存器的可見部分和不可見部分的內容都被設定之後,CS暫存器的裝載工作完成。隨後當CPU需要通過CS的內容進行地址運算的時候,則僅僅引用不可見部分。
在保護模式下,當我們執行一個裝載CS暫存器的指令的時候,段選擇子(Segment Selector)被裝入CS暫存器的可見部分,同時CPU根據此選擇子到相應的描述符表中(GDT或LDT)找到相應的段描述符並將其內容裝載入CS暫存器的不可見部分。隨後CPU當需要通過CS的內容進行地址運算的時候,也僅僅引用不可見部分。
從上面的描述可以看出,事實上CPU在引用段暫存器的內容進行地址運算時,實時模式和保護模式是一致的。另外,也明白了為什麼我們在實時模式下設定的段暫存器的內容到了保護模式下仍然引用的是16-bit段。
那么我們如何將CS設定為引用32-bit段?方法就像我們前面所討論的,使用jmp或call指令,引用一個段選擇子,到GDT中裝載一個引用32-bit段的段描述符。
需要注意的是,如果CS暫存器的內容指出當前是一個16-bit段,那么當前的地址模式也就是16-bit地址模式,這與你當前是出於實時模式還是保護模式無關。而我們裝載32-bit段的jmp指令或call指令必須使用的是32-bit地址模式。而我們當前的boot部分代碼是16-bit代碼,所以我們必須在此jmp/call指令前加上地址轉換前綴代碼66h。
下面的例子就是使用jmp指令裝入32-bit段。Jmpi指令的含義是段間跳轉,其Opcode為Eah,其格式為:jmpi Offset, Segment Selector。
# 由於當前的代碼是16-bit代碼,而我們要執行32-bit地址模式的指令,指令前
# 需要有地址模式切換前綴66h,如果我們直接寫jmp指令,由編譯器來生成代碼
# 的話,是無法作到這一點的,所以我們直接寫相關數據。
.byte 0x66, 0xea # prefix + jmpi-opcode
.long 0x1000 # Offset
.word __KERNEL_CS # CS segment selector
上面的代碼相當於32-bit指令:
jmpi 0x1000,__KERNEL_CS
如果__KERNEL_CS段選擇子所引用的段描述符設定的段空間為線形地址[0,4 GB],而我們將OS Kernel放在物理地址1000h,那么此jmpi指令就跳轉到OS Kernel的入口處,並開始執行它。
此時,啟動階段結束,OS正式開始運行!