我是如何學習寫一個作業系統(三):作業系統的啟動之保護模式
前言
上一篇其實已經說完了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等,然後設定分頁等等