linux0.11啟動與初始化
簡單描述Linux0.11的啟動與初始化過程。
www.2cto.com
啟動過程中需要關注:IDT, GDT, LDT, TSS, 頁表, 堆棧這些數據。
一:啟動過程
啟動的代碼文件為bootsect.s、setup.s、head.s
bootsect.s也就是啟動扇區的代碼。這段代碼主要是將setup.s和head.s中的內容讀入內存的相應區域。然後開始執行setup.s
www.2cto.com
setup.s
1:使用BIOS中斷來獲得相關系統信息:內存大小,硬盤分區信息,顯示卡信息
2:將head.s代碼拷貝到內存地址為0X0000的地方。
3:加載idt表和gdt表地址
4:開啟A20地址線,只有開啟它了才能訪問高於1M地址的內存
5:重新設定中斷控制器。這之後以前的BIOS中斷號就沒用了。
6:置位CR0寄存器的最後一位進入保護模式, 然後用jmpi 0, 8指令跳轉到地址0x08:0x0000處開始執行,也就是head.s的起始代碼處。
這裡設定的idt表全部為空,也即這時並不處理任何中斷。
gdt表中有三個描述符:0--NULL, 1--內核代碼段, 2--內核數據段描述符。
此時內核代碼段與內核數據段:基地址為0x00000000, 限長為:8MB
head.s
1:將堆棧設定在static_stack處,堆棧大小為1KB
2:重新設定設定IDT和GDT,此時全部IDT的都設置為ignore_int,即仍然忽略中斷。
GDT中包含256個描述符。
0--NULL, 1--內核代碼段, 2--內核數據段, 3--保留, 4--進程0的TSS段, 5--進程0的LDT段, 6--進程1的TSS段, 7--進程1的LDT段......
可見系統的GDT中為每個進程都設定了一個TSS和LDT段。
內核代碼段和內核數據段:基地址(0x00000000),限長(16MB)
3:設定分頁(由於內存管理部分目前沒看到,因此關於進程的頁表如何設定暫不明白,這裡是內核的頁表)
在0x00000000處的第一頁存放“頁目錄”,隨後存放4頁“頁表”,每個頁表對應於頁目錄中的一項。
設定的頁表,要求線性地址等於物理地址。
4個頁表能映射16MB的物理空間,因此這16MB的物理內存地址與線性地址是相同的。(0.11的內核沒有PAGE_OFFSET)
4:開始執行init/main.c中的main函數。
方式如下:
pushl $_main # 將main函數地址入棧
jmp setup_paging # 開始分頁
......
setup_paging:
.......
ret # 分頁完成後,用ret指令彈出堆棧中的main函數入口地址,並開始執行main函數。
好像內核代碼中常用這種彈出堆棧的方式來執行其他的函數
二:main函數
在啟動過程中設定好GDT和頁表後,開始在main函數中設定其他的內容。
主要是:設定IDT,設備初始化,創建進程0,fork出進程1,用進程1來執行init
main.c中主要的初始化函數是:trap_init,sched_init
trap_init中調用set_trap_gate來設定相應的中斷描述符表。
下面以0號中斷為例,描述其實現過程
1:調用set_trap_gate(0, ÷_error)
這個宏定義用來設定IDT表中的第0項的陷阱門描述符。
#define set_trap_gate(n, addr) _set_gate(&idt[n], 15, 0, addr)
在_set_gate中:&idt[n]為第n個描述符的地址, 15為描述符的類型(陷阱門),0為描述符的權限(最高權限),addr為要調用的代碼的地址。
_set_gate宏會調用相應的匯編指令,在&idt[n]處寫入8字節的描述符。
2:divide_error的實現
該函數是以匯編碼的形式實現。與該函數對應有一個處理函數do_divide_error(用C語言實現)
對其他的多數陷阱門處理方式也是如此,有一個匯編方式實現的,還有一個C語言實現的處理函數。
當中斷0發生時,先調用divide_error,該函數再調用do_divide_error。
void do_divide_error(long esp, long error_code)
上面為函數原型。第一個參數為出錯時的代碼地址的指針,第二個參數是錯誤碼。(有些中斷不產生錯誤碼,則錯誤碼設成0)
因此在divide_error的匯編代碼中,主要的功能就是將出錯地址的指針和錯誤碼這兩個參數傳遞給do_divide_error函數,
同時將目前的數據段設定為內核數據段。
sched_init函數
該函數設定了進程0的TSS和LDT描述符,並將它們的選擇子加載進了TR和LDTR寄存器。
另外該函數設定了時鐘中斷和系統調用
這裡主要說下系統調用的執行,以fork函數為例。
1:在sched_init中初始化系統調用
set_system_gate(0x80, &system_call)
#define set_system_gate(0x80, &system_call) _set_gate(&idt[n], 15, 3, addr)
可見系統調用也是一個陷阱門。區別是權限值為3, 因此用戶進程能通過int 0x80的中斷進入內核,執行系統調用。
2:每個系統調用都有一個對應的編號,fork為第二個系統調用,因此fork的調用號為2。
當執行fork函數的時候,它會用int 0x80來調用system_call函數。
此時fork的調用號被放入eax寄存器中。
3:全部的系統調用函數指針都保存在數組sys_call_table中。
在system_call函數中會執行指令
call _sys_call_table(,%eax, 4)來跳轉到eax指定的系統函數代碼上,對fork來說就是sys_fork函數。
4:system_call
i) system_call首先將相應的寄存器入棧。
pushl %edx
pushl %ecx
pushl %ebx 這三個寄存器對應了相應的系統調用函數的3個參數
因此0.11中,系統函數最多只能有3個參數。
ii)將ds和es設定為內核數據段,將fs設定為用戶進程的數據段,需要用戶進程的數據時,可使用fs來訪問
iii)用call _sys_call_table(,%eax, 4)來執行系統調用
iii)檢查當前進程是否處於可執行狀態,檢查當前進程的時間片是否用完,相應的執行schedule
iv)最後是對進程信號的處理。(信號機制沒看完)
三:進入用戶態
在main函數中相關初始化後,main以進程0的身份進入用戶態。
然後調用fork函數,創建進程1,進程1調用init函數
init函數加載根文件系統,運行初始化配置命令,然後執行shell程序,這樣便進入了命令行窗口。
0.11內核中,每個進程都有一個TSS段和一個LDT段,它們保存在進程描述符strut task_struct結構中。
相應段的描述符保存在GDT表中。
在LDT段中,有3個LDT描述符,0--NULL, 1--進程代碼段, 2--進程數據段。
進程n的代碼段和數據段:基地址=n*64MB,限長=64MB。(進程0和1的限長為640KB)
因此系統中最多有64個進程。
進程0的task_struct為INIT_TASK,進程0的TSS和LDT描述符在sched_init中設定。
main函數調用move_to_user_mode函數來執行進程0,進入用戶態。
0.11內核中所有進程都是屬於用戶態,不像之後的Linux內核裡有內核線程。
move_to_user_mode函數
此函數使用iret返回的方式,從內核態進入用戶態。
+------------+
+ ss + pushl $0x17
+------------+
+ esp + pushl %%eax #eax中保存了esp
+------------+
+ eflags + pushfl
+------------+
+ cs + pushl $0x0f
+------------+
+ eip + pushl $1f #目的代碼的偏移地址
+------------+
首先采用上面的push指令,將相關的數據壓入堆棧,然後執行iret將它們彈出堆棧。
於是進程0從堆棧中的cs:eip指向的代碼開始執行。
四:fork函數
進程0執行fork函數創建出進程1.
1:調用get_free_page為進程描述符分配內存。
p = (struct task_struct *) get_free_page();
這一頁內存,前面保存task_struct內存, 頁尾為進程的內核棧,
當一個用戶程序調用系統函數進入內核態後,系統函數執行時使用的棧就是這個。
2:設定進程的task_struct結構體
3:內存拷貝,將父親進程的內存拷貝給新進程。
4:設定新進程在GDT中的TSS和LDT描述符
有關fork最主要的是弄明白了,為什麼它可以“返回”兩次。
1:調用fork時,CPU自動將父進程的返回地址入棧(即eip寄存器入棧)
2:創建子進程的task_struct後,將TSS段中的eax字段設成0,eip字段設成父進程的返回地址。
3:將子進程的狀態設成TASK_RUNNING(就緒狀態)
4:fork函數以子進程的pid返回。
5:等到執行schedule,調度到子進程時。會自動將子進程的TSS內容加載進寄存器。
因此這時CPU中eax寄存器值為0, eip為父進程的返回地址。所以子進程從fork函數的下一條指令開始執行,返回值在eax中,為0。