我是如何學習寫一個作業系統(三):作業系統的啟動之保護模式
- 2019 年 10 月 3 日
- 筆記
前言
上一篇其實已經說完了boot的大致工作,但是Linux在最後進入作業系統之前還有一些操作,比如進入保護模式。在我自己的FragileOS里進入保護模式是在引導程式結束後完成的。
實模式到保護模式屬於作業系統的一個大坎,所以需要先提一下
從實模式到保護模式
實模式和保護模式都是CPU的工作模式,它們的主要區別就是定址方式
實模式出現於早期8088CPU時期。當時由於CPU的性能有限,一共只有20位地址線(所以地址空間只有1MB),以及8個16位的通用暫存器,以及4個16位的段暫存器。所以為了能夠通過這些16位的暫存器去構成20位的主存地址,必須採取一種特殊的方式。訪問記憶體的就變成了:
物理地址 = 段基址 << 4 + 段內偏移
隨著CPU的發展,可以訪問的記憶體空間也從1MB變為現在4GB,暫存器的位數也變為32位。並且在實模式下,用戶程式對記憶體的訪問非常自由,沒有任何限制,隨隨便便就可以修改任何一個記憶體單元。所以實模式已經不能滿足時代的要求了,保護模式就應運而生了
保護模式的偏移值變成了32位,定址方式仍然需要段暫存器,但是這些段暫存器存放的不再是段基址了,而是類似一個數組的索引
而這個數組就是一個就做全局描述符表 (GDT)的東西,GDT中含有一個個表項,每一個表項稱為段描述符。
而我們通過段暫存器里的的這個索引,可以找到對應的表項。段描述符存放了段基址、段界限、記憶體段類型屬性
處理器內部有一個 48 位的暫存器,稱為全局描述符表暫存器(GDTR)。也就是為了來記錄GDT的

段描述符
FragileOS里進入保護模式
- 根據上面的描述,在進入保護模式時就先需要構造一個GDT
- 當然中間還需要一些其它的初始化,在後面詳細提
- 然後再根據特定操作來讓CPU識別該進入保護模式了
一部分程式碼
[SECTION .gdt] ; 利用宏定義定義gdt ; 段基址 段界限 屬性 LABEL_GDT: Descriptor 0, 0, 0 LABEL_DESC_CODE32: Descriptor 0, 0fffffh, DA_C | DA_32 | DA_LIMIT_4K LABEL_DESC_VIDEO: Descriptor 0B8000h, 0fffffh, DA_DRW LABEL_DESC_VRAM: Descriptor 0, 0fffffh, DA_DRW | DA_LIMIT_4K in al, 92h ; 切換到保護模式 or al, 00000010b out 92h, al mov eax, cr0 or eax , 1 mov cr0, eax Linux啟動前的最後準備
現在來看看Linux在啟動前最後還做了什麼
獲得系統數據和進入保護模式
setup.s主要的任務就是從BIOS拿到系統數據然後存放到一個記憶體位置
獲取當前游標的位置
mov ax,#INITSEG ! this is done in bootsect already, but... mov ds,ax mov ah,#0x03 ! read cursor pos xor bh,bh int 0x10 ! save it in known place, con_init fetches mov [0],dx ! it from 0x90000. 獲取記憶體大小
mov ah,#0x88 int 0x15 mov [2],ax 檢查現在的顯示方式
mov ah,#0x0f int 0x10 mov [4],bx ! bh = display page mov [6],ax ! al = video mode, ah = window width 進入保護模式
進入保護模式的程式碼也在setup中
首先先把內核SYSTEM部分移動到0位置,在之前它是被讀入在0x10000位置
mov ax,#0x0000 cld ! 'direction'=0, movs moves forward do_move: mov es,ax ! destination segment add ax,#0x1000 cmp ax,#0x9000 jz end_move mov ds,ax ! source segment sub di,di sub si,si mov cx,#0x8000 rep movsw jmp do_move 然後就是載入上面說的全局描述符表和中斷向量表
中斷向量表前面沒有提過,但是比較簡單,有點類似GDT,就是 作業系統必須維護一份中斷向量表,每一個表項紀錄一個中斷處理程式(ISR,Interrupt Service Routine)的地址
end_move: mov ax,#SETUPSEG ! right, forgot this at first. didn't work :-) mov ds,ax lidt idt_48 ! load idt with 0,0 lgdt gdt_48 ! load gdt with whatever appropriate 再接著就是打開A20地址線,如果不打開A20地址線,即使在保護模式下最大定址還是1M
call empty_8042 mov al,#0xD1 ! command write out #0x64,al call empty_8042 mov al,#0xDF ! A20 on out #0x60,al call empty_8042 初始化8259A晶片,8259A是專門為了對8085A和8086/8088進行中斷控制而設計的晶片,它是可以用程式控制的中斷控制器。單個的8259A能管理8級向量優先順序中斷。 對於對硬體的初始化其實就是依照CPU的固定套路
部分程式碼
mov al,#0x11 ! initialization sequence out #0x20,al ! send it to 8259A-1 .word 0x00eb,0x00eb ! jmp $+2, jmp $+2 out #0xA0,al ! and to 8259A-2 最後的最後,終於可以正式進入保護模式,可以看到這裡進入保護模式的方法和我上面的move cr0 ax不太一樣,Linux之所以使用這種方法是為了兼容286之前的CPU,另外需要注意的是在進入保護模式之後需要立馬執行一條段間跳轉來讓CPU刷新指令隊列,這裡跳轉的描述就已經是用段值來描述了,段指的第三位到第十五位用來指向GDT里的索引(1000),也就是跳到第2個段描述符里記錄的地址
mov ax,#0x0001 ! protected mode (PE) bit lmsw ax ! This is it! jmpi 0,8 ! jmp offset 0 of segment 8 (cs) 第二個GTD段描述符,所以上面也就是跳轉到記憶體0處
.word 0x07FF ! 8Mb - limit=2047 (2048*4096=8Mb) .word 0x0000 ! base address=0 .word 0x9A00 ! code read/exec .word 0x00C0 ! granularity=4096, 386 IDT和分頁管理機制
再往下就是正式進入到了內核部分,在此之前需要再提一下IDT和分頁管理機制
IDT
中斷描述符表把每個中斷或異常編號和一個指向中斷處理事件服務程式的描述符聯繫起來。同GDT和LDT一樣,IDT是一個8-位元組的描述符數組。和GDT、LDT不同的是,IDT的第一項可以包含一個描述符。為了形成一個在IDT內的索引,處理器把中斷、異常標識號乘以8以後來做為IDT的索引。因為只有256個編號,IDT不必包含超過256個描述符。它可以包含比256更少的項,只是那些需要使用的中斷、異常的項。
IDT可以在記憶體的任意位置。處理器通過IDT暫存器(IDTR)來定位IDT。指令LIDT和SIDT用來操作IDTR。

分頁機制
將用戶程式(進程)的邏輯地址空間分成若干個頁(4KB)並編號,同時將記憶體的物理地址也分成若干個塊或頁框 4KB)並編號,這樣也就是為了讓所有的應用程式看都像是獨佔一片記憶體,起始地址都是為0,最後再建立一個頁表存儲著頁到頁框也就是真實記憶體地址的映射

在記憶體里有一個暫存器(PTR)來存儲頁表
映射的完成
- 進程訪問某個邏輯地址
- 由線性地址的頁號,以及頁表暫存器中的始址,找到頁表並找到對應的頁表項
- 由頁表項上的塊號,找到物理記憶體中的塊號
- 根據塊號,和線性地址的頁內地址,找到物理地址
我們通過設置CR0暫存器的PG位來開啟分頁功能,而其它操作就都由CPU來完成,當然前提是我們有一張頁表
兩級頁表結構
為了減少記憶體的佔用量,80X86採用了分級頁表
頁目錄有2的十次方個4位元組的表項,這些表項指向對應的二級表,線性地址的最高10位作為頁目錄用來尋找二級表的索引
二級頁表裡的表項含有相關頁面的20位物理基地址,二級頁表使用線性地址中間10位來作為尋找表項的索引
- 進程訪問某個邏輯地址
- 由線性地址中的頁號,以及外層頁表暫存器(CR3)中的外層頁表始址,找到二級頁表的始址
- 由二級頁表的始址,加上線性地址中的外層頁內地址,找到對應的二級頁表中的頁表項
- 由頁表項中的物理塊號,加上線性地址中的頁內地址,找到對物理地址

所以說CPU定址一共需要進行兩步:
- 首先將給定一個邏輯地址 (其實是段內偏移量)
- CPU利用段式記憶體管理單元,先將為個邏輯地址轉換成一個執行緒地址 (也就是前面說的GDT)
- 再利用其頁式記憶體管理單元,轉換為最終物理地址。(二級頁表)
進入到了內核部分
head.s這部分其實已經是進入了內核部分了,但是在Linux0.12里還是把它歸為Boot部分。這一部分的主要工作是重新設置GDT和IDT,然後在設置管理記憶體的分頁處理機制 (在進入保護模式後,Linux用的就是AT&T的彙編語法了,最顯著的差別就是源操作數和目的數的位置對調了)
- 設置IDT
setup_idt: lea ignore_int,%edx movl $0x00080000,%eax movw %dx,%ax /* selector = 0x0008 = cs */ movw $0x8E00,%dx /* interrupt gate - dpl=0, present */ lea idt,%edi mov $256,%ecx rp_sidt: movl %eax,(%edi) movl %edx,4(%edi) addl $8,%edi dec %ecx jne rp_sidt lidt idt_descr ret - 設置GDT
setup_gdt: lgdt gdt_descr ret gdt_descr: .word 256*8-1 # so does gdt (not that that's any .long gdt # magic number, but it works for me :^) .align 8 - 這裡就是已經準備跳入C語言的main部分了,也就是彙編里的函數調用,先把main的地址壓入棧中,當下一個函數執行完ret的時候,就會去執行main了
after_page_tables: pushl $0 # These are the parameters to main :-) pushl $0 pushl $0 pushl $L6 # return address for main, if it decides to. pushl $main jmp setup_paging L6: jmp L6 # main should never return here, but # just in case, we know what happens. - 最後就是設置分頁機制了
STOS指令:將AL/AX/EAX的值存儲到[EDI]指定的記憶體單元
CLD清除方向標誌和STD設置方向標誌,當方向標誌是0,該指令通過遞增的指針數據每一次迭代之後(直到ECX是零或一些其它條件,這取決於REP前綴的香味)工作,而如果該標誌是1,指針遞減。
setup_paging: movl $1024*5,%ecx /* 5 pages - pg_dir+4 page tables */ xorl %eax,%eax xorl %edi,%edi /* pg_dir is at 0x000 */ cld;rep;stosl movl $pg0+7,pg_dir /* set present bit/user r/w */ movl $pg1+7,pg_dir+4 /* --------- " " --------- */ movl $pg2+7,pg_dir+8 /* --------- " " --------- */ movl $pg3+7,pg_dir+12 /* --------- " " --------- */ movl $pg3+4092,%edi movl $0xfff007,%eax /* 16Mb - 4096 + 7 (r/w user,p) */ std 1: stosl /* fill pages backwards - more efficient :-) */ subl $0x1000,%eax jge 1b xorl %eax,%eax /* pg_dir is at 0x0000 */ movl %eax,%cr3 /* cr3 - page directory start */ movl %cr0,%eax orl $0x80000000,%eax movl %eax,%cr0 /* set paging (PG) bit */ ret /* this also flushes prefetch-queue */ 小結
這一節主要是描述了保護模式和一些CPU需要的數據結構。這幾篇文章相當於講述了一台電腦啟動的時候都發生了什麼。
- 通過引導程式boot來載入真正的內核程式碼
- 獲得一些硬體上的系統參數保存在一些記憶體里供後面使用
- 最後是初始化像GDT、IDT等,然後設置分頁等等