從開機加電到執行main函數之前的過程
1.啟動BIOS,準備實模式下中斷向量表和中斷服務程序
- 在按下電源按鈕的瞬間,CPU硬件邏輯強制將CS:IP設置為0xFFFF:0x0000,指向內存地址的0xFFFF0位置,此位置屬於BIOS的地址範圍。關於硬件如何指向BIOS區,這是一個純硬件動作,在RAM實地址空間中,屬於BIOS地址空間部分為空,硬件只要見到CPU發出的地址屬於BIOS地址範圍,直接從硬件層次將訪問重定向到BIOS的ROM區中。這也就是為什麽RAM中存在空洞的原因。
- BIOS程序在內存最開始的位置(0x00000)用1KB的內存空間(0x00000
0x003FF)構建中斷向量表,並在緊挨著它的位置用256個字節的內存空間構建BIOS數據區(0x004000x004FF),大約在56KB以後的位置(0x0E2CE)加載了8KB左右的與中斷向量表相對應的若幹中斷服務程序。
2.加載操作系統內核程序,並為保護模式做準備
-
加載操作系統的過程分為三步
- 由BIOS中斷int 0x19把第一扇區bootsect的內容加載到內存
- 在bootsect的指揮下,把其後的四個扇區的內容加載至內存
- 在bootsect的指揮下,把隨後的240個扇區內容加載至內存
-
加載第一部分代碼---引導程序bootsect
- int 0x19對應的中斷服務程序的入口地址為0x0E6F2,這個中斷服務程序的作用是將軟盤的第一個扇區的程序(512B)加載到內存的指定位置,該服務程序是BIOS事先設計好的,與Linux操作系統無關。該服務程序將軟驅0號磁頭對應盤面的0磁道1扇區的內容拷貝至內存0x07C00處。該扇區的作用就是Linux操作系統的引導程序bootsect,其作用就是擺脫BIOS的限制,陸續將軟盤中的操作系統程序載入到內存中。
-
加載第二部分代碼---setup
- bootsect的作用就是把第二批和第三批程序陸續加載到內存的適當位置。為了完成之一目標,bootsect首先要做的工作就是規劃內存。
SETUPLEN = 4 ! nr of setup-sectors BOOTSEG = 0x07c0 ! original address of boot-sector INITSEG = DEF_INITSEG ! we move boot here - out of the way (0x9000) SETUPSEG = DEF_SETUPSEG ! setup starts here (0x9020) SYSSEG = DEF_SYSSEG ! system loaded at 0x10000 (65536). ENDSEG = SYSSEG + SYSSIZE ! where to stop loading
-
該代碼的作用就是對後續操作所涉及的內存位置進行設置,包括將要加載的扇區數(SETUPLEN)和被加載到的位置(SETUPSEG)、啟動扇區被BIOS加載的位置(BOOTSEG)和將要移動到的新位置(INITSEG),內核被加載的位置(SYSSEG)、內核的末尾位置(ENDSEG)和根文件系統的系統設備號(ROOT_DEV)
-
接著,bootsect啟動程序將它自身(512B內容)從內存0x07C00復制到內存0x90000(INITSEG)處
mov ax,#BOOTSEG
mov ds,ax
mov ax,#INITSEG
mov es,ax
mov cx,#256
sub si,si
sub di,di
rep
movw
-
由於“兩頭約定”和“定位識別”的作用,所以bootsect在開始時“被迫”加載到0x07C00處。現在將其自身移至0x90000處,說明操作系統開始根據自己需要安排內存了
-
bootsect復制完成之後,內存位置0x7C000和0x9000位置有相同的代碼。這段代碼復制完成之後便需要將CS:IP的值設置到0x9000的位置,這一功能使用
jmpi go, INITSEG
代碼來實現,執行這段代碼之後,程序就轉到執行0x90000處來執行新位置的代碼了。Linus的設計思路是:跳轉到新位置之後在新位置接著執行後面的mov ax, cs,而不是死循環。jmpi go, INITSEG
與go: mov ax, cs
配合,巧妙地實現了“到新位置後接著原來的執行序繼續執行下去”的目的。 -
由於bootsect復制到了新的地方,並且要在新的地方繼續執行。因為代碼的整體位置發生了變化,那麽代碼的的各個段也會發生變化,現在需要對DS、ES、SS和SP進行調整。
go: mov ax,cs
mov dx,#0xfef4 ! arbitrary value >>512 - disk parm size
mov ds,ax
mov es,ax
push ax
mov ss,ax ! put stack at 0x9ff00 - 12.
mov sp,dx
- 至此,bootsect的第一步操作:規劃內存並把自身從0x07C00的位置復制到0x90000的位置的動作已經完成了。接下來需要將Setup程序加載到內存中。
加載setup程序需要借助BIOS的int 0x13中斷。int 0x13中斷與int 0x19中斷的不同點:
- int 0x19中斷向量所指向的啟動加載服務程序時BIOS執行的,int 0x13的中斷服務程序時linux系統自身的啟動代碼bootsect執行的
- int 0x19的中斷服務程序只負責將軟盤的第一扇區的代碼加載到0x07C00位置,而int 0x13中斷服務程序可以根據設計者的意圖,把指定的扇區的代碼加載到內存的指定位置。執行的代碼如下。這段代碼首先設置各寄存器參數,再調用中斷服務程序進行數據傳輸,將軟盤從第2扇區開始的4個扇區加載至內存的SETUPSEG
load_setup:
xor dx, dx ! drive 0, head 0
mov cx,#0x0002 ! sector 2, track 0
mov bx,#0x0200 ! address = 512, in INITSEG
mov ax,#0x0200+SETUPLEN ! service 2, nr of sectors
int 0x13 ! read it
jnc ok_load_setup ! ok - continue
push ax ! dump error code
call print_nl
mov bp, sp
call print_hex
pop ax
xor dl, dl ! reset FDC
xor ah, ah
int 0x13
j load_setup
-
加載第三部分代碼---system模塊
- 代碼加載方式與第二部分代碼加載方式類似,同樣調用 int 0x13中斷。bootsect借助BIOS中斷int 0x13,將240個扇區的system模塊加載進內存。加載工作主要由read_it子程序完成的,這個子程序將軟盤的第6扇區的約240個扇區的system模塊加載至內存的SYSSEG(0x10000)處往後的120KB空間中。
-
三部分代碼加載完之後,bootsect還需要確定下根設備號,經過一系列檢測,得知軟盤為根設備,所有就把根設備好保存在root_dev中,這個根設備號作為機器系統數據之一,它將在根文件系統加載中發揮關鍵作用。這一步完成之後,bootsect的所有工作都做完了,接著執行
jmpi 0, SETUPSEG
跳轉至0x90200處,這地方存放的是setup程序,這意味著由bootsect程序繼續執行。
- setup程序做的第一件事就是利用BIOS提供的中斷服務程序從設備上提取內核運行所需要的系統參數,如:硬盤大小、內存大小、光標位置等參數。BIOS提取的機器系統數據占用的內存空間為0x90000~0x901FD,共510個字節,即原來的bootsect只有2字節未被覆蓋。當bootsect使用完之後,其所在的內存區域馬上被覆蓋掉,可見操作系統對內存是嚴格按需使用的。
3.由16位模式切換到32位模式,為main函數的調用做準備
-
操作系統要將計算機由16位的實模式轉換到32位的保護模式,在這期間需要做大量的重建工作,並且繼續工作到操作系統的main函數執行過程中。操作系統需要做的工作包括:
- 打開32位尋址空間
- 打開保護模式
- 建立保護模式下的中斷響應機制等與保護模式配套的相關工作
- 建立內存的分頁機制
- 做好調用main函數的相關準備
-
關中斷,並將system移動到內存地址的起始位置0x00000
- 下面要執行的代碼將為操作系統進入保護模式做準備,此處即將進行實模式下中斷向量表和保護模式下中斷描述符表(IDT)的交接工作。在這段代碼執行之前首先要關中斷,試想,如果沒有cli,又恰好發生了中斷,如用戶不小心碰了下鍵盤,中斷就要切換進來,就不得不面對實模式的中斷機制已經廢除,但保護模式下的中斷機制尚未建立完成的尷尬局面,結果必然是系統崩潰。cli和sti保證了這個過程中中斷描述符表能夠完整的創建,以避免不可預料的中斷進入,從而造成中斷描述符表創建不完整或新老中斷機制 混用的情況。
-
setup程序做了一個影響深遠的工作:將位於0x10000的內核程序拷貝至內存起始地址為0x00000處。代碼如下:
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
這樣做能取得一箭三雕的效果:
廢除BIOS的中斷向量表,等價於廢除了BIOS提供的實模式下的中斷服務程序
- 收回使用壽命剛剛結束的程序所占用的內存空間
- 讓內核代碼占據內存物理地址最開始的、最天然的、最有利的位置
我們廢除了16位中斷機制,但操作系統是不能沒有中斷的,對外設的使用、系統調用、進程調度都離不開中斷,因此,接下來操作系統需要建立新的32位的中斷機制
- 設置中斷描述符表和全局描述符表
- 幾個基本概念
- GDT(全局描述符表):它是系統中唯一存放段寄存器內容(段描述符)的數組,配合程序進行保護模式下的段尋址。它在操作系統的進程切換中具有重要意義,可理解為所有進程的總目錄表,其中存放著每一個任務(task)局部描述符表(LDT)地址和任務狀態段(TSS)地址,用於完成進程中各段的尋址、現場保護與現場恢復
- GDTR(GDT基地址寄存器):GDT可以存放在內存的任意位置,當程序通過段寄存器引用一個段描述符時,需要取得GDT的入口,GDTR所標識的即為此入口。在操作系統對GDT的初始化完成後,可以用LGDT指令將GDT基地址加載至GDTR中
- IDT(中斷描述符表):保存保護模式下所有中斷服務程序的入口地址,類似於實模式下的中斷向量表
- IDTR(IDT基地址寄存器):保存IDT的起始地址
- 劃分一塊內存區域,並向這塊內存區域中寫入數據,即填寫GDT和IDT的表項。由於此時內核尚未真正運行起來,還沒有進程,所以現在常見的GDT表的第一項為空,第二項為內核代碼段描述符,第三項為內核數據段描述符,其余項皆為空。IDT表雖然已經設置,實為一張空表,原因是目前已經關中斷,無需調用中斷服務程序。實現代碼如下。這段代碼運行完成之後,所得到的結果如下圖所示:
- 幾個基本概念
gdt:
.word 0,0,0,0 ! dummy
.word 0x07FF ! 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 ! base address=0
.word 0x9A00 ! code read/exec
.word 0x00C0 ! granularity=4096, 386
.word 0x07FF ! 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 ! base address=0
.word 0x9200 ! data read/write
.word 0x00C0 ! granularity=4096, 386
idt_48:
.word 0 ! idt limit=0
.word 0,0 ! idt base=0L
gdt_48:
.word 0x800 ! gdt limit=2048, 256 GDT entries
.word 512+gdt,0x9 ! gdt base = 0X9xxxx
-
打開A20,實現32位尋址。打開A20,意味著CPU可以進行32位尋址,最大尋址空間為4GB,如下圖所示。Linux 0.11最大只能支持16MB的物理內存,但是其線性地址空間已經是4GB(為什麽只能支持16MB?)
-
為了建立保護模式下的中斷機制,setup程序需要對8259A中斷控制器進行重新編程。CPU工作方式由實模式轉變為保護模式,一個重要的特征就是要根據GDT表來決定後續將執行哪裏的程序。
-
註意這段代碼
jmpi 0,8 ! jmp offset 0 of segment 8 (cs)
這句中的“0”是段內偏移,“8”是保護模式下的段選擇符,用於選擇描述符表和描述符表項以及所要求的特權級。這裏的8的解讀方式很有意思,如果把8當做十進制的8來看待,這行程序的意思就很難理解了。必須把8看成二進制的1000,再把前後代碼聯合起來當做一個整體來看,便可形成下圖,才能明白這行代碼的真實意圖。註意,這個是以位為單位的數據使用方式,4bit的每一位都有明確的意義,這是底層代碼的一個特色。這裏的1000的最後兩位00表示內核特權級,與之相對應的用戶特權級是11,第三位的0表示GDT表,如果是1,則表示LDT。1000的1表示所選的表(此時就是GDT表)的1項(GDT表項號排序為0項、1項、2項,也就是第2項)來確定代碼段的段基址和段限長等信息,從上圖可以看到,代碼是從段基址0x00000000、偏移為0處開始執行的,也就是head程序的開始位置,這意味著將執行head程序。到此為止,setup就執行完畢了,它為系統能夠在保護模式下運行做了一系列的準備工作,但這些還不夠,後續準備工作將由head程序來完成 -
head.s開始執行
- 在執行main函數之前,先要執行三個有匯編代碼生成的程序,即bootsect、setup和head之後,才執行由main函數開始的由C語言編寫的操作系統內核程序。前面講述過,第一步:加載bootsect到0x07C00,然後復制到0x90000;第二步:加載setup到0x90200。需要註意的是,這兩段程序是分別加載和分別執行的,head程序與它們的加載方式有所不同。
- head程序的加載過程如下:先將head.s匯編成目標代碼,將用C語言編寫的內核程序編譯成 目標代碼,然後兩者一起鏈接成system模塊。也就是說,在system模塊裏面,既有內核程序,又有head程序,兩者是緊挨著的。要點是:head程序在前面,內核程序在後面,所以head程序名字叫head,head程序在內存中占有
25KB+184B
的空間,這個數字很重要,望留心 - head程序所做的工作:用程序自身的代碼和程序自身所在的內存空間創建了內核分頁機制,即在0x00000的位置創建了頁目錄表、頁表、緩沖區、GDT、IDT,並將head程序已經執行過的代碼所占用的內存空間覆蓋,這意味著head程序自己將自己廢棄,main函數即將執行。
- 將各寄存器(CS、DS、ES、FS、GS)的用法從實模式轉變到保護模式。在實模式下,CS本身就是代碼段基址,而在保護模式下,CS本身並不是代碼段基址,而是代碼段選擇符。以下代碼完成各寄存器模式的轉換工作。代碼中
movl $0x10, %eax
的解析與前面語句jmpi 0,8
的解析方式相同。將0x10也看成二進制00010000.
_pg_dir:
startup_32:
movl $0x10,%eax
mov %ax,%ds
mov %ax,%es
mov %ax,%fs
mov %ax,%gs
-
接下來需要對中斷描述符表進行設置,中斷描述符的結構如下:
call setup_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
這是重建保護模式下中斷服務體系的開始,程序先讓所有中斷描述符默認指向ignor_int這個位置(將來main函數裏面還要讓中斷描述符對應具體的中斷服務程序),之後還需要對中斷描述符表寄存器的值進行設置,具體操作狀態如下:
- 接下來head程序要廢除已有的GDT,並在內核中的新位置重建全局描述表,其中第二項和第三項分別為內核代碼段描述符和內核數據段描述符,其段限長均被設置為16MB,並設置全局描述符表寄存器的值
call setup_gdt
setup_gdt:
lgdt gdt_descr
ret
_gdt: .quad 0x0000000000000000 /* NULL descriptor */
.quad 0x00c09a0000000fff /* 16Mb */
.quad 0x00c0920000000fff /* 16Mb */
.quad 0x0000000000000000 /* TEMPORARY - don‘t use */
.fill 252,8,0 /* space for LDT‘s and TSS‘s etc */
- 為什麽要廢除原來的GDT而重新設計一套新的GDT呢?
原來GDT所在的位置是設計代碼時在setup.s裏面設置的,將來這個setup模塊所在的內存位置會在設計緩沖區時被覆蓋。如果不改變位置,GDT內容將來肯定會被緩沖區覆蓋掉,從而影響系統的運行。這樣一來,將來整個內存中唯一安全的地方就是現在head.s所在的位置了。 那麽有沒有可能在執行setup程序時直接把GDT的內容拷貝到head.s所在的位置呢?肯定不能,如果先復制GDT的內容,後移動system模塊,它就會被後者覆蓋掉;如果先移動system模塊,後復制GDT內容,它又會把head.s對應的程序覆蓋掉,而這時head.s還沒有執行呢,所以,無論如何,都要重新建立GDT。
- 全局描述符表GDT的位置和內容發生了變化,段限長由原來的8MB擴展到現在的16MB,同時還需要再一次調整各寄存器,使其適應新的段限
- 接下來head程序需要重新檢查A20地址線是否打開,因為A20地址線是否打開時保護模式和實模式的根本區別之一。另外還要檢測是否有數學協處理器的存在,如果有,則將其設置為保護模式的工作狀態
- head程序將為調用main函數做最後的準備。將L6標號和main函數的入口地址壓棧,棧頂為main函數地址,目的是使head程序執行完之後通過
ret
指令就可以執行main函數
pushl $L6 # return address for main, if it decides to.
pushl $_main
jmp setup_paging
- 壓棧完成之後,head程序將跳轉至setup_paging處去執行,開始創建分頁機制
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 */
首先會將頁目錄表和4個頁表放在物理內存的起始位置。從內存起始位置開始的5頁空間內容全部清零(每頁4KB),為初始化頁目錄和頁表做準備。註意,這個動作啟用了一個頁目錄和4個頁表覆蓋了head程序自身所在內存空間的作用。head程序將也目錄表和4個頁表所占物理內存空間清零後,設置頁目錄表的前4項,使之分別指向4個頁表。設置完頁目錄表後,linux 0.11在保護模式下支持的最大尋址地址為0xFFFFFF(16MB),此處將第4張頁表(由pg3指向的位置)的最後一個頁表項(pg3+4092指向的位置)指向尋址範圍的最後一個頁面,即0xFFF000開始的4KB字節大小的內存空間。然後開始從高地址向低地址方向填寫全部4個頁表,依次指向內存從高地址向低地址方向的各個頁面。填寫過程如下列各圖所示。 註意這4個頁表都是內核專屬頁表,將來每個用戶進程都有他們專屬的頁表,兩者在尋址範圍方面的區別將在後文介紹。
將頁目錄表和4個頁表放在物理內存的起始位置,這個動作意義重大,是操作系統能夠掌控全局、掌控進程在內存中安全運行的基石之一
-
head程序已將頁表設置完畢了,但分頁機制的建立還沒有完成。需要設置頁目錄基址寄存器CR3,使之指向頁目錄表,再將CR0寄存器設置的最高位(31位)置位1,如下圖所示
-
所有設置完成之後的內存布局為如下,可以看出,只有184字節的剩余代碼,由此可見在設計head程序和system模塊時,其計算是非常精確的,對head.s的代碼量的控制非常到位
-
head程序執行的最後一步:ret 跳入main函數程序中執行。這個函數調用方法與普通函數調用方法有很大差別。
先看看普通函數的調用和返回方法。普通函數都是使用CALL指令來實現。 CALL指令會將EIP的值自動壓棧,保護返回現場,然後執行被調函數的程序。等到執行被調用函數的
ret
指令時,自動出棧給EIP並恢復現場,繼續執行CALL的下一條指令。這是通常的函數調用方法。但對操作系統的main函數來說,這個方法就有些怪異了。main函數式操作系統的,如果用CALL調用操作系統的main函數,那麽ret時返回給誰呢?難道還有一個更底層的系統程序接收操作系統的返回麽?操作系統已經是最底層的系統了,所以邏輯上不成立。那麽如何調用了操作系統的main函數,又不需要返回呢?操作系統的設計者采用了下圖的下半部分所示的方法。 這個方法的妙處在於用ret是實現的調用操作系統的main函數,既然是ret調用,當然就不需要再用ret了。不過CALL做的壓棧和跳轉動作誰來完成呢?操作系統的設計者做了一個仿CALL的動作,手工編寫壓棧和跳轉代碼,模仿了CALL的全部動作,實現了調用setup_paging函數。註意,壓棧的EIP值並不是調用setup_paging函數的下一條指令的地址,而是操作系統的main函數的執行入口地址_main。這樣,當setup_paging函數執行到ret時,從棧中將操作系統的main函數的執行入口地址_main自動出棧給EIP,EIP指向main函數的入口地址,實現了用返回指令“調用”main函數。
- 至此,Linux操作系統內核啟動的一個重要階段已經完成了,接下來就要進入main函數對應的代碼了。
從開機加電到執行main函數之前的過程