前言
上一篇其實已經說完了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等,然后設置分頁等等