過程詳解 | 操作系統16位實模式切換到32位保護模式
這篇文章的內容是我在研究自制操作系統時遇到的一個問題,即如何從16位實模式切換到32位保護模式,網上的資料比較少,講得也不是很詳細,在這跟大家分享一下我的解決方案。
前置知識:
計算機是如何啟動的?
(http://www.ruanyifeng.com/blog/2013/02/booting.html)
自制操作系統:引導扇區的實現
(https://www.cnblogs.com/jiqingwu/p/os_boot_sector.html)
1、實模式和保護模式
請參考:實模式和保護模式
(https://blog.csdn.net/qq_37653144/article/details/82818191)
現代CPU有三種工作模式:實模式、保護模式和虛擬8086模式。CPU在加電后自動進入16位實模式,在主引導記錄MBR加載特定的操作系統后,由操作系通經過一些設置切換到32位保護模式。
以WIN7為例,首先由MBR載入啟動管理器bootmgr,在bootmgr中進行實模式到保護模式的切換,隨后裝載winload.exe,然后通過winload.exe加載Windows7內核,從而啟動整個Windows7系統。
2、實模式到保護模式的切換
從實模式切換到保護模式大致可以分為以下幾個步驟:
1、屏蔽中斷
2、初始化全局描述符表(GDT)
3、將CR0寄存器最低位置1
4、執行遠跳轉
5、初始化段寄存器和棧指針
1、屏蔽中斷
在16位實模式下的中斷由BIOS處理,進入保護模式后,中斷將交給中斷描述符表IDT里規定的函數處理,在剛進入保護模式時IDTR寄存器的初始值為0,一旦發生中斷(例如BIOS的時鐘中斷)就將導致CPU發生異常,所以需要首先屏蔽中斷。
屏蔽中斷可以使用cli指令:
cli
2、初始化GDT
屏蔽中斷后我們就可以正式開始切換到保護模式的過程了。首先我們要完成GDT的初始化,這也是整個切換過程中最復雜的一步。
32位保護模式下的地址計算方式和16位實模式不一樣,16位保護模式支持20位的尋址,物理地址等于段地址 * 16 + 偏移地址,其中段地址可以通過段寄存器獲得,段地址和偏移地址都是16位。
在32位保護模式中,段與段之間是互相隔離的,當訪問的地址超出段的界限時處理器就會阻止這種訪問,因此每個段都需要有起始地址、范圍、訪問權限以及其他屬性四個部分,這四個部分合在一起叫做段描述符(Segment Descriptor),總共需要8個字節來描述。但Intel為了保持向后兼容,將段寄存器仍然規定為16-bit,顯然我們無法用16-bit的段寄存器來直接存儲64-bit的段描述符。
解決的辦法是將所有64-bit的段描述符放到一個數組中,將16-bit段寄存器的值作為下標來訪問這個數組(以字節為單位),獲取64-bit的段描述符,這個數組就叫做全局描述符表(Global Descriptor Table, GDT)。

上面這個就是64位段描述符的具體結構,包括段起始地址Base,限長Limit,Access Byte和Flags都是訪問權限,結構如下:

Access Byte和Flags各項的解釋,我直接復制OS Dev)了,更詳細的解釋可以參考《x86匯編語言:從實模式到保護模式》這本書的11.3節:
Pr: Present bit. This must be 1 for all valid selectors.
Privl: Privilege, 2 bits. Contains the ring level, 0 = highest (kernel), 3 = lowest (user applications).
S: Descriptor type. This bit should be set for code or data segments and should be cleared for system segments (eg. a Task State Segment)
Ex: Executable bit. If 1 code in this segment can be executed, ie. a code selector. If 0 it is a data selector.
DC: Direction bit/Conforming bit.
Direction bit for data selectors: Tells the direction. 0 the segment grows up. 1 the segment grows down, ie. the offset has to be greater than the limit.
Conforming bit for code selectors:
- If 1 code in this segment can be executed from an equal or lower privilege level. For example, code in ring 3 can far-jump to conforming code in a ring 2 segment. The privl-bits represent the highest privilege level that is allowed to execute the segment. For example, code in ring 0 cannot far-jump to a conforming code segment with privl==0x2, while code in ring 2 and 3 can. Note that the privilege level remains the same, ie. a far-jump form ring 3 to a privl==2-segment remains in ring 3 after the jump.
- If 0 code in this segment can only be executed from the ring set in privl.
RW: Readable bit/Writable bit.
Readable bit for code selectors: Whether read access for this segment is allowed. Write access is never allowed for code segments.
Writable bit for data selectors: Whether write access for this segment is allowed. Read access is always allowed for data segments.
Ac: Accessed bit. Just set to 0. The CPU sets this to 1 when the segment is accessed.
Gr: Granularity bit,粒度位,如果Gr置0則段界限limit是以字節為單位的,即段的大小為1B到1MB,如果Gr置0則以4KB為單位,段的大小從4KB到4GB。
Sz: Size bit,置0表示該段的偏移地址或操作數是16位的,比如說代碼段該位置0,則取指令時會使用16位的指令指針寄存器IP來取指令,而棧段在進行棧操作時,會使用SP寄存器;置1表示使用的偏移地址或操作數是32位的,使用EIP和ESP寄存器。
Flags區域中空余的2位其中一位是給64位CPU使用的,這里我們置為0即可,另一位是留給用戶軟件使用的,CPU并不會使用。
GDT可以通過LGDT指令加載,加載后GDT的地址和大小被保存到了GDTR寄存器:

LGDT指令的操作數為6字節的數據,2個低位字節為GDT的大小減一,減一的原因是Size的最大值是65535,而GDT最多可以有65536字節(最多8192項)。4個高位字節為GDT的32位地址。
我們可以在匯編代碼中定義GDT并用LGDT指令加載GDT,然后讓MBR將我們這一段匯編代碼加載到內存中的某個地址,然后跳轉到這個地址執行,這樣就完成了GDT的初始化。
接下來我們就來看一下在匯編代碼中定義GDT的方法。Intel處理器規定GDT中的第一個描述符必須是空描述符,這是因為寄存器和內存單元的初始值一般都是0,如果程序設計有問題,就會無意中用全0的索引來選擇描述符,gdt_null的作用就有點像C語言中的空指針。用匯編代碼定義如下:
gdt_start:; 第一個描述符必須是空描述符gdt_null: dd 0 dd 0gdt_end:
dd 0定義了一個4字節的數據,兩個dd 0正好是8字節,也就是一個描述符的大小。
接下來我們要定義代碼段和數據段,為了方便,我們可以將代碼段和數據段的起始地址都設為0,大小都設為4GB,這種設置方式也叫作平坦模式。現在的定義如下:
gdt_start:; 第一個描述符必須是空描述符gdt_null: dd 0 dd 0; 代碼段描述符gdt_code: dw 0xffff ; Limit (bits 0-15) dw 0x0 ; Base (bits 0-15) db 0x0 ; Base (bits 16-23) db 10011010b ; Access Byte db 11001111b ; Flags , Limit (bits 16-19) db 0x0 ; Base (bits 24-31); 數據段描述符gdt_data: dw 0xffff ; Limit (bits 0-15) dw 0x0 ; Base (bits 0-15) db 0x0 ; Base (bits 16-23) db 10010010b ; Access Byte db 11001111b ; Flags , Limit (bits 16-19) db 0x0 ; Base (bits 24-31)gdt_end:
此時代碼段和數據段的訪問權限和其他屬性如下:
代碼段:Access byte 10011010bPr = 1 ; 標記為有效的段Privl = 0 ; ring3權限S = 1 ; 代碼段或數據段Ex = 1 ; 可執行,即代碼段DC = 0 ; 標記該段只能在Privl權限(即ring3權限)下執行RW = 1 ; 可讀Ac = 0 ; 該段的內容是否被修改,默認為0,由CPU置位Flags 1100bGr = 1 ; 粒度為4KBSz = 1 ; 32位模式 數據段:Access byte 10010010bPr = 1 ; 標記為有效的段Privl = 0 ; ring3權限S = 1 ; 代碼段或數據段Ex = 0 ; 不可執行,即數據段DC = 0 ; 標記該段只能在Privl權限(即ring3權限)下執行RW = 1 ; 可寫Ac = 0 ; 該段的內容是否被修改,默認為0,由CPU置位Flags 1100bGr = 1 ; 粒度為4KBSz = 1 ; 32位模式
接著我們要定義棧段,由于我們的引導程序是加載到07c00h這個地址的,因此我們可以將07c00h以下的空間都作為棧空間,加入棧段后的定義如下:
gdt_start:; 第一個描述符必須是空描述符gdt_null: dd 0 dd 0; 代碼段描述符gdt_code: dw 0xffff ; Limit (bits 0-15) dw 0x0 ; Base (bits 0-15) db 0x0 ; Base (bits 16-23) db 10011010b ; Access Byte db 11001111b ; Flags , Limit (bits 16-19) db 0x0 ; Base (bits 24-31); 數據段描述符gdt_data: dw 0xffff ; Limit (bits 0-15) dw 0x0 ; Base (bits 0-15) db 0x0 ; Base (bits 16-23) db 10010010b ; Access Byte db 11001111b ; Flags , Limit (bits 16-19) db 0x0 ; Base (bits 24-31); 棧段描述符gdt_stack: dw 0x7c00 ; Limit (bits 0-15) dw 0x0 ; Base (bits 0-15) db 0x0 ; Base (bits 16-23) db 10010010b ; Access Byte db 01000000b ; Flags , Limit (bits 16-19) db 0x0 ; Base (bits 24-31)gdt_end: 棧段:Access byte 10010010bPr = 1 ; 標記為有效的段Privl = 0 ; ring3權限S = 1 ; 代碼段或數據段Ex = 0 ; 不可執行,即數據段DC = 0 ; 標記該段只能在Privl權限(即ring3權限)下執行RW = 1 ; 可寫Ac = 0 ; 該段的內容是否被修改,默認為0,由CPU置位Flags 1100bGr = 0 ; 粒度為1BSz = 1 ; 32位模式
接著我們定義6字節的GDT描述符:
; GDT descriptiorgdt_descriptor:dw gdt_end - gdt_start - 1 ; Size of our GDT, always less one of the true sizedd gdt_start ; Start address of our GDT
接著調用lgdt指令將GDT描述符加載到gdtr寄存器,整個GDT的初始化就算是完成了:
lgdt [gdt_descriptor]
3、將CR0最低位置1
CR0是系統內的32位控制寄存器之一,可以控制CPU的一些重要特性。其中最低位是保護允許位(Protected Mode Enable, PE),PE位置1后CPU進入保護模式(注意此時還是16位保護模式,不是32位保護模式),置0時則為實模式。現在我們要進入保護模式,即將CR0的最低位置1,匯編代碼如下:
; 把 cr0 的最低位置為 1,開啟保護模式mov eax, cr0or eax, 0x1mov cr0, eax
有關CR0其他位的功能以及其他控制寄存器可以查看Control register,這里不再贅述。
4、執行遠跳轉
將cr0最低位置1后,CPU就進入了保護模式,此時需要馬上執行一條遠跳轉指令:
jmp 08h:PModeMain
這條指令有兩個作用,第一個作用是將cs段寄存器的值修改為08h,切換到保護模式后,CPU尋址的方式就從實模式中的段地址 * 16 + 偏移地址改為了通過gdt尋址,所以這里的08h是段選擇子而不是段地址,并且遠跳轉指令會自動將cs的值修改為對應的段選擇子,這里是08h。
之前提到過,段描述符中的Sz位確定操作數的默認大小,將代碼段寄存器切換到08h后,代碼段描述符的Sz位為1,即32位模式,此時才真正進入了32位保護模式。
遠跳轉的另一個作用是清空CPU的流水線,流水線的作用在計組中有提到過,為了加速指令的執行,CPU在執行當前指令時會同時加載并解析接下來的一些指令,在進入保護模式之前,已經有許多指令進入了流水線,這些指令都是按16位模式處理的,而進入保護模式后的指令都是32位,所以這里通過一個遠跳轉來讓CPU清空流水線。
切換到32位模式后,就應該執行32位的指令了,所以從PModeMain開始的指令都采用32位模式編譯,通過[bits 32]這個標記實現:
[bits 32]PModeMain:
5、初始化段寄存器和棧指針
上一步中我們將代碼段寄存器cs初始化成了0x08,現在我們還需要初始化其他的段寄存器如數據段寄存器ds,拓展段寄存器es,棧段ss以及fs,gs兩個由操作系統使用的段。
另外我們還需要初始化棧指針ebp和esp,代碼如下:
[bits 32]PModeMain: mov ax, 0x10 ; 將數據段寄存器ds和附加段寄存器es置為0x10 mov ds, ax mov es, ax mov fs, ax ; fs和gs寄存器由操作系統使用,這里統一設成0x10 mov gs, ax mov ax, 0x18 ; 將棧段寄存器ss置為0x18 mov ss, ax mov ebp, 0x7c00 ; 現在棧頂指向 0x7c00 mov esp, ebp
此時我們就可以開始執行32位保護模式下的代碼了。
3、測試:32位保護模式下的打印
接下來我們要實現的是32位保護模式下的打印功能,測試我們是否已經成功進入32位保護模式了。
在32位保護模式中我們無法使用實模式下的BIOS中斷指令進行打印,但是天無絕人之路,我們仍然可以通過直接修改顯存來進行打印。
顯卡包括顯存本身是外圍設備,但CPU很友好的把顯存映射到了0x8B000這個物理地址,因此我們可以直接通過mov指令來訪問顯存。在文本模式下,被打印的字符在顯存中總共占2個字節,第一個字節是字符的ASCII碼,第二個字節是字符的顏色屬性,這里與BIOS中int 10調用的顏色屬性是一樣的:

打印字符串的代碼我們可以寫成一個函數,具體的內容大家看下面的代碼和注釋:
; 打印字符串; @param 被打印的字符串print: push ebp mov ebp, esp mov ebx, [ebp+8] ; [ebp+8]即傳入的字符串參數 xor ecx, ecx mov ah, 0x0f ; ah為打印的顏色屬性,0x0f為白字黑底 mov edx, 0xb8000 ; 顯存的地址loop1_begin: mov al, [ebx] ; al為被打印的字符 cmp al, 0 ; 若al為0,結束打印 je loop1_end mov [edx], ax ; 向顯存中寫入字符及其顏色屬性(2字節) inc ebx add edx, 2 jmp loop1_beginloop1_end: mov esp, ebp pop ebp ret
函數調用:
PModeTest: push TEST_STRING ; 被打印字符的地址 call printPModePause: hlt jmp PModePause TEST_STRING db 'We are in protected mode!', 0
效果如下:

4、完整代碼
完整的代碼,需要在引導程序中加載這段代碼到正確的位置,這里是09000h,或者直接把org 09000h改成org 07c00h,把這段代碼寫到主引導扇區:
org 09000h cli ; 屏蔽中斷 lgdt [gdt_descriptor] ; 初始化GDT ; 把 cr0 的最低位置為 1,開啟保護模式mov eax, cr0or eax, 0x1mov cr0, eax jmp 08h:PModeMain [bits 32]PModeMain: mov ax, 0x10 ; 將數據段寄存器ds和附加段寄存器es置為0x10 mov ds, ax mov es, ax mov fs, ax ; fs和gs寄存器由操作系統使用,這里統一設成0x10 mov gs, ax mov ax, 0x18 ; 將棧段寄存器ss置為0x18 mov ss, ax mov ebp, 0x7c00 ; 現在棧頂指向 0x7c00 mov esp, ebp jmp PModeTest PModeTest: push TEST_STRING ; 被打印字符的地址 call printPModePause: hlt jmp PModePause ; 打印字符串; @param 被打印的字符串print: push ebp mov ebp, esp mov ebx, [ebp+8] ; [ebp+8]即傳入的字符串參數 xor ecx, ecx mov ah, 0x0f ; ah為打印的顏色屬性,0x0f為白字黑底 mov edx, 0xb8000 ; 顯存的地址loop1_begin: mov al, [ebx] ; al為被打印的字符 cmp al, 0 ; 若al為0,結束打印 je loop1_end mov [edx], ax ; 向顯存中寫入字符及其顏色屬性(2字節) inc ebx add edx, 2 jmp loop1_beginloop1_end: mov esp, ebp pop ebp ret TEST_STRING db 'We are in protected mode!', 0 gdt_start:; 第一個描述符必須是空描述符gdt_null: dd 0 dd 0; 代碼段描述符gdt_code: dw 0xffff ; Limit (bits 0-15) dw 0x0 ; Base (bits 0-15) db 0x0 ; Base (bits 16-23) db 10011010b ; Access Byte db 11001111b ; Flags , Limit (bits 16-19) db 0x0 ; Base (bits 24-31); 數據段描述符gdt_data: dw 0xffff ; Limit (bits 0-15) dw 0x0 ; Base (bits 0-15) db 0x0 ; Base (bits 16-23) db 10010010b ; Access Byte db 11001111b ; Flags , Limit (bits 16-19) db 0x0 ; Base (bits 24-31); 棧段描述符gdt_stack: dw 0x7c00 ; Limit (bits 0-15) dw 0x0 ; Base (bits 0-15) db 0x0 ; Base (bits 16-23) db 10010010b ; Access Byte db 01000000b ; Flags , Limit (bits 16-19) db 0x0 ; Base (bits 24-31)gdt_end: ; GDT descriptiorgdt_descriptor:dw gdt_end - gdt_start - 1 ; Size of our GDT, always less one of the true sizedd gdt_start ; Start address of our GDT
5、A20地址線
A20的“A”是“Address”的首字符,A20也就是第21根地址線的意思。這里涉及到一個歷史遺留問題,但如果我們是在Bochs這樣的模擬器上進行調試的話沒有影響,所以我把這個問題放到了最后來講。
在8086處理器上只有20根地址線,最多支持20位的地址,因此每當物理地址達到最高端0xFFFFF時,再加上1又會繞回最低端0x00000。為了兼容地址回繞這個特性,后來生產的CPU都會默認關閉第21根地址線,需要操作系統在進入保護模式時手動開啟。
不過某些BIOS,包括模擬器(qemu, Bochs等)會默認幫我們打開A20地址線,所以我們不需要手動開啟了,我們可以用一段程序來測試A20地址線是否打開:
PModeTest: mov edi, 0x112345 mov esi, 0x012345 mov [esi], esi mov [edi], edi mov eax, [esi] cmp eax, edi je A20_DISABLE push aA20_ENABLE call print jmp PModePauseA20_DISABLE: push aA20_DISABLE call printPModePause: hlt jmp PModePause aA20_ENABLE db 'A20 Enable', 0aA20_DISABLE db 'A20 Disable', 0
測試結果,可以看到在Bochs中A20地址線確實是默認開啟的:

從80486處理器開始,0x92端口的第2位用來控制A20地址線,將0x92端口的第2位置1即可開啟A20地址線,如果要手動開啟A20地址線的話,可以使用以下代碼:
in al, 92hor al, 00000010bout 92h, al