保護模式

保護模式

保護模式, (Protected Mode,或有時簡寫為 pmode) 是一種80286系列和之後的 x86兼容 CPU 操作模式。保護模式有一些新的特色,設計用來增強多工和系統穩定度,像是內存保護,分頁系統,以及硬體支援的虛擬內存。大部分的現今 x86操作系統都在保護模式下運行,包含 Linux、FreeBSD、以及微軟 Windows 2.0和之後版本。

另外一種 286 和其之後 CPU 的操作模式是真實模式,一種向前兼容且關閉這些特色的模式。設計用來讓新的晶元可以執行舊的軟體。依照設計的規格,所有的 x86 CPU 都是在真實模式下開機來確保傳統操作系統的向前兼容性。在任何保護模式的特色可用前,他們必須要由某些程序手動地切換到保護模式。在現今的電腦,這種切換通常是由 操作系統 在開機時候必須完成的第一件工作的一個。它也可能當 CPU 在保護模式下運行時,使用 虛擬86模式 來執行設計給真實模式的程序碼。

簡介


儘管用軟體的方式也有某些可能在真實模式的系統下使用多工,但保護模式下內存保護的特色,可以避免有問題的程序破壞其他工作或是 操作系統 核心所擁有的內存。保護模式也有中斷正在執行程序的硬體支援,可以把 execution content 交給其他工作,得以實現 先佔式多工。
大部分可以使用保護模式的 CPU 也擁有 32 位元暫存器 的特色 (例如 80386 系列和其後任何的晶元),導入了融合保護模式而成為 32 位元處理的概念。80286 晶元雖有支援保護模式,但是仍然只有 16 位元暫存器。Windows 2.0 和之後版本中的保護模式增強稱為 "386 增強模式",是因為他們除了保護模式外,還需要 32 位元的暫存器,並且無法在 286 上面執行 (即使 286 支援保護模式)。
即使在 32 位元晶元上已經打開了保護模式,但是 1 MB 以上的內存並無法存取,是由於一種仿照 IBM XT 系統設計特性的 memory wrap-around(內存連續) 的因素。這種限制可以由打開 A20 line 來迴避。
在保護模式下,前面 32 個中斷都是保留給 CPU 例外處理用。舉個例子,中斷 0D (十進位 13) 是 一般保護模式錯物 和 中斷 00 是 除以零。
8086/8088時代,處理器只存在一種操作模式(Operation Mode),當時由於不存在其它操作模式,因此這種模式也沒有被命名。自從80286到80386開始,處理器增加了另外兩種操作模式——保護模式PM (Protected Mode)和系統管理模式SMM(System Management Mode),因此,8086/8088的模式被命名為實地址模式RM(Real-address Mode)。
PM是處理器的native模式,在這種模式下,處理器支持所有的指令和所有的體系結構特性,提供最高的性能和兼容性。對於所有的新型應用程序和操作系統來說,建議都使用這種模式。為了保證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》中有非常詳盡和準確的介紹。
在Protected Mode下,一個重要的必不可少的數據結構就是GDT(Global Descriptor Table)。
為什麼要有GDT?首先考慮一下在Real Mode下的編程模型:
在Real Mode下,對一個內存地址的訪問是通過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。
到了Protected Mode,內存的管理模式分為兩種,段模式和頁模式,其中頁模式也是基於段模式的。也就是說,Protected Mode的內存管理模式事實上是:純段模式和段頁式。進一步說,段模式是必不可少的,而頁模式則是可選的——如果使用頁模式,則是段頁式;否則這是純段模式。
既然是這樣,就先不去考慮頁模式。對於段模式來講,訪問一個內存地址仍然使用Segment:Offset的方式,這是很自然的。由於 Protected Mode運行在32-bit系統上,那麼Segment的兩個因素:Base Address和Limit也都是32位的。IA-32允許將一個段的Base Address設為32-bit所能表示的任何值(Limit則可以被設為32-bit所能表示的,以2^12為倍數的任何指),而不象Real Mode下,一個段的Base Address只能是16的倍數(因為其低4-bit是通過左移運算得來的,只能為0,從而達到使用16-bit段寄存器表示20-bit Base Address的目的),而一個段的Limit只能為固定值64 KB。另外,Protected Mode,顧名思義,又為段模式提供了保護機制,也就說一個段的描述符需要規定對自身的訪問許可權(Access)。所以,在Protected Mode下,對一個段的描述則包括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是Protected Mode所必須的數據結構,也是唯一的——不應該,也不可能有多個。另外,正象它的名字(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只是一個可選的數據結構,你完全可以不用它。使用它或許可以帶來一些方便性,但同時也帶來複雜性,如果你想讓你的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是Protected Mode所必須的數據結構,那麼在進入Protected Mode之前,必須設定好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,即使實際的物理內存遠沒有那麼多,但這個空間定義是為了將來由頁式管理來實現虛擬內存。
在這裡,只是處於Booting階段,所以只需要初步設置一下GDT,等真正進入Protected Mode,啟動了OS Kernel之後,具體OS打算如何設置GDT,使用何種內存管理模式,由Kernel自身來設置,Booting只需要給Kernel的數據段和代碼段設置全部線性空間就可以了。

段描述符的格式


具體到代碼段和數據段,它們的格式如下圖所示:
下面就是在Booting階段為進入Protected Mode而設置的臨時的gdt。這裡定義了3個段描述符:第一個是系統規定的空描述符,第2個是引用4 GB線性空間的代碼段,第3個是引用4 GB線性空間的數據段。這是"Basic Flat Model"所要求的最下GDT設置,但就booting階段,只是為了進入Protected Mode,並為內核提供一個連續的,最大的線性空間這個目的而言,已經足夠了。
# 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絕對物理地址,而不是相對於某個段的偏移量。而在Booting階段,在進入Protected Mode之前,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 , %eax
addl $gdt, %eax
movl %eax, (gdt_48+2)
lgdt gdt_48 # load gdt with whatever is appropriate

其他東西


在進入Protected Mode之前,除了需要設置和裝入GDT之外,還需要做如下一些事情:
屏蔽所有可屏蔽中斷;
裝入IDTR;
所有協處理器被正確的Reset。
由於在Real Mode和Protected Mode下的中斷處理機制有一些不同,所以在進入Protected Mode之前,務必禁止所有可屏蔽中斷,這可以通過下面兩種方法之一:
使用CLI指令;
對8259A可編程中斷控制器編程以屏蔽所有中斷。
即使當進入Protected Mode之後,也不能馬上將中斷打開,這時因為必須在OS Kernel中對相關的Protected Mode中斷處理所需的數據結構正確的初始化之後,才能打開中斷,否則會產生處理器異常。
在Real Mode下,中斷處理使用IVT(Interrupt Vector Table),在Protected Mode下,中斷處理使用IDT(Interrupt Descriptor Table),所以,必須在進入Protected Mode之前設置IDTR。
IDTR的格式和GDTR相同,IDTR的裝入方式和GDTR也相同。由於IDT中相關的中斷處理程序需要讓OS Kernel來設定,所以在Booting階段,只需要將IDTR中IDT的基地址和Size都設為0就可以了,隨後,等進入Protected Mode之後,由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
好了,一切準備就緒,Fire!:)
進入Protected Mode,還是進入Real Mode,完全靠CR0寄存器的PE標誌位來控制:如果PE=1,則CPU切換到PM,否則,則進入RM。
設置CR0-PE位的方法有兩種:
第一種是80286所使用的LMSW指令,後來的80386及更高型號的CPU為了保持向後兼容,都保留了這個指令。這個指令只能影響最低的4 bit,即PE,MP,EM和TS,對其它的沒有影響。
#
#通過LMSW指令進入Protected Mode
#
movw , %ax # protected mode (PE) bit
lmsw %ax # This is it!
第二種是Intel所建議的在80386以後的CPU上使用的進入PM的方式,即通過MOV指令。MOV指令可以設置CR0寄存器的所有域的值。
#
#通過MOV指令進入Protected Mode
#
movl %cr0, %eax
xorb , %al # set PE = 1
movl %eax, %cr0 # go!!
OK,現在已經進入Protected Mode了。
很簡單,right?But It's not over yet!

啟動內核


已經從Real Mode進入Protected Mode,現在馬上就要啟動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寄存器為例,對於其它寄存器也是一樣的:
在Real Mode下,當執行一個裝載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的內容進行地址運算的時候,則僅僅引用不可見部分。
在Protected Mode下,當執行一個裝載CS寄存器的指令的時候,段選擇子(Segment Selector)被裝入CS寄存器的可見部分,同時CPU根據此選擇子到相應的描述符表中(GDT或LDT)找到相應的段描述符並將其內容裝載入CS寄存器的不可見部分。隨後CPU當需要通過CS的內容進行地址運算的時候,也僅僅引用不可見部分。
從上面的描述可以看出,事實上CPU在引用段寄存器的內容進行地址運算時,Real Mode和Protected Mode是一致的。另外,也明白了為什麼在Real Mode下設置的段寄存器的內容到了Protected Mode下仍然引用的是16-bit段。
那麼如何將CS設置為引用32-bit段?方法就像前面所討論的,使用jmp或call指令,引用一個段選擇子,到GDT中裝載一個引用32-bit段的段描述符。
需要注意的是,如果CS寄存器的內容指出當前是一個16-bit段,那麼當前的地址模式也就是16-bit地址模式,這與你當前是出於Real Mode還是Protected Mode無關。而裝載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的入口處,並開始執行它。
此時,Booting階段結束,OS正式開始運行!