LINUX0.11核心閱讀筆記 (1)
我是通過閱讀趙炯老師編的厚厚的linux核心完全剖析看完LINUX0.11的程式碼,不得不發自內心的說Linus真的是個天才。雖然我覺得很多OS設計的思想他是從UNIX學來的,但是他自己很周全很漂亮很巧妙地實現瞭如此龐大一個系統的絕大多數程式碼。這裡面有太多環節需要注意,很難得。。。
讀完之後覺得很有收穫,雖然版本很低,但是已經對OS有一個很具體的認識了,比理論上的要來得深刻、真實。下面是我自己學習過程的思考和總結,在看完細節之後主要從LINUX各個功能模組其及相互之間和內部的層次關係去考慮的,本文圖片均取自該書。我覺得這篇總結性質的文章對還沒有接觸linux0.11核心的人來說肯定沒有什麼意義。應該只有讀過的程式碼的人才會有同感吧。另外我看程式碼的時候使用了
一.原始碼目錄
<!--[if !vml]--><!--[endif]-->
圖1
二.系統總體流程:
系統從boot開始動作,把核心從啟動盤裝到正確的位置,進行一些基本的初始化,如檢測記憶體,保護模式相關,建立頁目錄和記憶體頁表,GDT表,IDT表。然後進入main進行初始化設定,main完成系統各個模組要用到的所有資料結構和外部裝置的初始化。使得系統可以正常的工作。然後才進入使用者模式。執行第一個
這裡整個系統建立起來了,OS就處於被動狀態,靠中斷和系統呼叫來完成每一項服務。
三.各個目錄的閱讀總結:
(一) boot
1.bootsect.s :
bootsect.s編譯結果生成一個512BYTE(一個扇區)映象。這個扇區的最後一個字是0xAA55,倒數第二個字是root_dev變數,值為ROOT_DEV(306),即根檔案系統所在的裝置號。這段程式碼必須寫入到啟動盤的啟動扇區,也就第一個物理扇區上。這樣機器啟動後,BIOS自動把它載入到7C00H處並跳到那裡開始執行。bootsect將自己移動到90000H(576K)處,並跳至那裡相應位置執行。然後利用
2.setup.s:
利用BIOS中斷把系統引數如顯示卡,硬碟引數儲存到記憶體90000開始的位置,即覆蓋原bootsect所在的記憶體位置。再把整個system 模組移動到00000 位置。載入GDTR和IDTR,這裡的GDT表是臨時的,儲存了兩個描述符,即核心程式碼、核心資料段描述符,其段基地址為0。而載入IDT除了進入保護模式需要載入IDTR之外,沒有任何意義。開啟A20 地址線開啟擴充套件記憶體。重新設定8259中斷碼0x20~0x2f。進入保護模式(PE置1)跳轉到system模組中的head.s中(0處)執行。Bootsect.s和setup.s執行時記憶體變化情況。
<!--[if !vml]--><!--[endif]-->
圖2
3.head.s:
前4KB的程式碼將被頁目錄覆蓋掉。這些程式碼執行的操作包括:設定系統堆疊為_stack_start。重新設定GDTR和IDTR。gdt,idt表都定義在head.s的末端,長度均為256項(2KBYTE)。第2個頁面到第5個頁面是系統的4張頁表。最後一個頁表後面的程式碼執行分頁操作,即填充的4個頁目錄和4張頁表的內容,實現對等對映,即實體地址=線性地址。每個頁表項屬性為存在並且使用者可讀寫。設定好後置CR3頁目錄地址即0。啟動分頁標誌,CR0的PG標誌置1。跳到main函式中執行。
跳到main之前,記憶體佈局如下:從0到16M
頁目錄4K(0x0開始)
頁表1 4K
頁表2 4K
頁表3 4K
頁表4 4K
軟盤緩衝區1K
head.s後半部分程式碼
IDT表2K
GDT表2K
main.o程式碼部分
核心其餘部分(大約到512K,end值為結束地址)
setup儲存的系統引數(90000H~900200)這個區間還儲存著root_dev.
BIOS(640K-1M)
主記憶體區(1M-16M)
現在初始化好了核心工作依賴的主要的資料結構是GDT和IDT表,還有頁表。
(二)核心初始化init
main.c將進行進一步初始化工作。主要方面:分配主記憶體功能,IDT表各中斷描述符重新設定,對核心其它模組如mm,fs進行初始化,然後移到使用者模式下生成程序1執行init,常駐程序0死迴圈執行pause。程序init載入根檔案系統,設定終端標準IO,建立程序2以/etc/rc為標準輸入檔案執行shell.完成rc檔案中的命令。
init等程序2退出,進入死迴圈:建立子程序,建立新會話,設定標準IO終端,以登入方式執行shell.
至此係統動作起來了。
所以整個系統的建立起來後除了兩個死迴圈的程序idle和init,其它的動作都是由使用者在shell下執行命令,產生系統呼叫來工作的。
通過執行move_to_usermdoe(),idle和init程序都屬於使用者態下的程序。而核心則完全是中斷驅動的。也就是說只有通過中斷才能進入系統,如時鐘和系統呼叫等。
所以問題的重點就在於核心各部分資料結構的建立、初始化、操作是怎樣進行的。這些初始化流程涉及到核心各個模組全部重要的資料結構。
現在從main執行的一系列初始化程式碼來淺窺一下:
1.根據記憶體的大小,設定高速緩衝的末端。16M記憶體把高速緩衝末端設為4M。緩衝末端到主存末端為主記憶體區。
2.mem_init(main_memory_start,memory_end);主記憶體區初始化
設定高階記憶體HIGH_MEMORY=memory_end,
設定記憶體對映位元組圖mem_map [ PAGING_PAGES ],將不可用的全部置為USED,可用的置為0。mem_map陣列是系統mm模組核心資料結構,記載了每個記憶體頁使用計數。
3.trap_init().硬體中斷向量表設定。
向IDT中填充各個中斷描述符,使其指向對應的中斷處理程式。對於錯誤,基本是結束當前程序。其他如外設中斷都是各個模組初始化的時候向IDT表中相應項進行設定。
4.blk_dev_init();// 塊裝置初始化。
初始化請求陣列request[],將所有請求項置為空閒項(dev = -1)。
5.chr_dev_init();// 字元裝置初始化。尚為空操作。
6.tty_init();// tty 初始化。
/// tty 終端初始化函式。
// 初始化串列埠終端和控制檯終端。
void tty_init (void)
{
rs_init ();// 初始化序列中斷程式和序列介面1 和2。(serial.c, 37)
con_init ();// 初始化控制檯終端。(console.c, 617)
}
rs_init 初始化兩個串列埠,安裝串列埠中斷處理IDT項。
con_init 初始化顯示器和鍵盤。安裝鍵盤中斷處理IDT項。
7.time_init().取CMOS 時鐘,並設定開機時間 startup_time(為從1970-1-1-0 時起到開機時的秒數)
8.sched_init();// 排程程式初始化(載入了任務0 的tr, ldtr)
這裡初始化與程序排程有關的資料結構。
手工設定了任務0的TSS和LDT到GDT表中。
清GDT表和task[NR_TASKS]陣列其餘部分。
ltr (0);// 將任務0 的TSS 載入到任務暫存器tr。
lldt (0);// 將區域性描述符表載入到區域性描述符表暫存器。
設定核心的工作心跳--8253定時器,安裝定時器中斷。
設定系統呼叫中斷門:set_system_gate (0x80, &system_call);
9.buffer_init(buffer_memory_end);// 緩衝管理初始化,建記憶體連結串列等。
在核心的結束地址end(由連線程式生成)到buffer_memory_end之間(除掉640kb-1M的BIOS範圍)區域中,建立緩衝區連結串列(表頭start_buffer)並分配緩衝塊(1KB)。
初始化空閒表free_list,HASH表hash_table。
10.hd_init();// 硬碟初始化。
blk_dev[MAJOR_NR].request_fn = DEVICE_REQUEST; //設定硬碟的裝置請求函式為do_hd_request.
設定硬碟中斷處理IDT項。
11.floppy_init();//軟盤初始化。
blk_dev[MAJOR_NR].request_fn = DEVICE_REQUEST;
設定軟盤中斷處理IDT項。
12.sti()開中斷。
13.move_to_user_mode();移動到使用者態執行。
這是個巨集。由嵌入彙編程式碼組成。設定核心堆疊中的CS為任務0程式碼段(使用者態),通過中斷返回iret, 自動載入了LDT0的程式碼段到CS,資料段到SS,DS等,完成了從特權級從0跳到3。其實執行的程式碼在記憶體中的位置完全相同。只是完成執行權跳到使用者態而已。這樣,核心執行變成了任務0的執行。
14.fork();生成程序1,執行init();
這裡的fork()是行內函數。為了不使用使用者棧。
程序0從此死迴圈執行pause();
15 init();
程序1執行init()函式。
呼叫setup取硬碟分割槽資訊hd.載入虛擬盤,程序init載入根檔案系統,設定終端標準IO,建立程序2以/etc/rc為標準輸入檔案執行shell.完成rc檔案中的命令。載入完根檔案系統之後,整個OS就已經完整地執行起來了。
init等程序2退出,進入死迴圈:建立子程序,建立新會話,設定標準IO終端,以登入方式執行shell.,剩下的動作由使用者來決定了。
(三)kernel:
<!--[if !vml]-->
<!--[endif]-->
圖3
個人認為最主要的是中斷程式碼,然後是中斷程式碼會呼叫的通用程式碼。為什麼這麼說呢,無論是排程schedule,還是fork,都只有在使用者程序執行int 0x80 中斷進行系統呼叫或者是硬體中斷才能進入核心程式碼,執行核心函式。當核心初始化結束後所有程序都是使用者態程序,只有通過IDT表中定義的那些中斷函式去執行核心程式碼。所以中斷是OS的主線,只是在功能上分成了多個模組。
在traps.c中,設定了絕大多數中斷向量,通過set_trap_gate()或者set_intr_gate設定對應IDT描述符。set_trap_gate()不會遮蔽中斷,而set_intr_gate遮蔽外部中斷,中斷處理程式都是用匯編定義的,大部分在asm.s中定義,其餘在system_call.s,keyboard.s,rs_io.s中定義。 彙編程式中再呼叫C語言程式做具體的處理。
比較重要的中斷時鐘中斷int 0x20,系統呼叫中斷int 0x80,頁故障中斷int14,還有一些外部裝置如鍵盤,硬碟等也很重要,不過屬於fs模組的內容。大多數異常只是簡單呼叫sys_exit()結束當前程序,並重新排程其他程序schedule()。
從幾個重要中斷去中斷執行流程去弄清OS怎麼工作的:
1)int 0x20 時鐘中斷。
時鐘是整個OS工作的心跳。8253每10ms產生一箇中斷。中斷服務執行do_timer(),然後do_signal();
do_timer主要判斷當前程序時間片是否用完,如果用完且處於使用者態則執行schedule()重新排程。如果中斷時當前程序正在核心態執行,則不能進行切換。也是說linux在核心態不支援任務搶佔。這樣使得核心的設計大大的簡化了,因為除了程序自己放棄執行(如sleep,wait類)不用擔心臨界區資源競爭的問題。
如果當前程序是使用者程序,判斷當前訊號點陣圖中是否有未處理的訊號,取最小訊號然後呼叫do_signal()。這個函式想要執行使用者定義的訊號處理函式。
do_signal()把訊號對應的處理函式插入到核心堆疊eip處,並修改使用者堆疊使中斷返回後用戶程序執行訊號處理函式,
訊號處理函式返回後執行一個sa_restorer,恢復使用者堆疊為正常中斷退出之後的狀態。(這是一個技巧,它實現了核心空間呼叫使用者空間的函式!!!)
另外核心空間與使用者空間資料交換預設通過fs來完成。
使用者程序總是通過中斷進入核心態,訊號判斷總是發生在時鐘中斷和系統呼叫中斷處理之後。所以實時性也很強,因此稱為軟中斷。
關於訊號,在schedule()中,對當前系統中的所有程序alarm訊號定時判斷,可睡眠打斷程序如果有未遮蔽訊號置位喚醒(狀態改為就緒)。
因此時鐘,系統呼叫,以及最頻繁呼叫的schedule()裡面都會處理訊號。因此訊號總是可以及時地得到"觸發"。
2)int 0x80 系統呼叫
系統呼叫的架構就是一個統一的中斷入口和出口_system_call,保護現場,準備引數(最多三個),取呼叫號,呼叫系統呼叫函式列表中對應處理函式,多是名為sys_XXX()的C函式。C處理函式返回後的後期流程:
如果程序系統呼叫後狀態不為就緒態或者時間片用完,執行排程schedule();判斷中斷前是使用者程序且有未處理的訊號?執行do_signal(),中斷返回。
最重要的系統呼叫莫過於fork()和execve;
首先程序的重要組成部分:
任務陣列task[],每個任務佔一項(假定序號為nr),每個虛擬地址空間都是64M, 範圍從nr*64M到(nr+1)*64M-1 ,在頁目錄表中最多佔16項,每項對應一個頁表即4M空間。程序任務資料結構task和核心棧共用一頁空間,核心棧頂在頁空間末端,task資料在頁起始端。
程序的頁表佔用的頁需要通過記憶體管理提供的介面get_free_page()來申請。 每個程序在GDT表中佔用兩個描述符項,LDT(nr)和TSS(nr)。
fork()流程:
呼叫find_empty_process()找一個空閒的程序任務task[]下標即任務號nr,並取得一個不重複的pid.
呼叫copy_process(...),裡面一堆引數都是系統棧中儲存的全部內容。(彙編和C混合程式設計技巧!)。copy_process
向mm申請一頁記憶體儲存task資料和設定核心堆疊。把父程序也就是當前程序的task資料全部拷貝,然後修改。設定tss內容,(需要修改的主要是ss0=核心資料段,esp0=申請的頁底部,eax=0)。
copy_mem拷貝程序空間。注意任務號nr意義在於程序的虛擬地址空間在nr*64M~(nr+1)*64M範圍內。copy_mem先計運算元程序虛擬地址空間基址和父程序空間大小,設定子程序LDT中的程式碼段和資料段描述符基址和段限。呼叫copy_page_tables複製程序空間。copy_page_tables就是把父程序佔用的頁目錄項和全部頁表中指定的有效的物理頁面全部拷貝到子程序的頁目錄項和頁表中去。同時把父子程序的頁表設為共享的也就是隻讀的,一旦任意一個程序執行寫記憶體操作,將發生頁錯誤中斷。這個中斷將導致系統為程序重新分配可寫的記憶體頁。copy_page_tables先計算父子程序虛擬地址空間佔用的目錄項(16個最多)開始地址,對每個有效的目錄項先為子程序分配一個頁面作為頁表,然後對該目錄項下所有有效的頁表項進行復制。同時把r/w位都置0。把對應物理頁的mem_map[]加1。這樣做非常高效而且非常巧妙。最大限度地共享了本身就只讀或者不需要再寫的頁面。每當程序和核心之間要交換資料時尤其是核心向程序空間寫資料時總是要先驗證程序給的線性地址是否有效。如verify_area,write_verify..這兩個函式最終會呼叫un_wp_page,取消頁面的防寫。對mem_map[]=1的直接置r/w為1,mem_map[]>1表明頁面共享了,記憶體頁對映表mem_map[]-1然後申請空閒物理頁,設定到頁表項中,並複製頁面copy_page。可見,父子程序先寫程序者將申請空閒頁並拷貝頁面內容,另一個則可以直接使用原來的頁面,因為這時mem_map[]=1了。
程序空間拷貝完畢之後,再設定一些task結構資料。給GDT表填加兩項LDT(nr),TSS(nr).程序狀態設為就緒,等待被排程就OK了。。
這就是所謂的寫時複製,太神奇了。。
execve提供了需求載入的機制。
它載入一個程式檔案到程序中執行,因此把原來程序擁有的頁表項和頁表全部釋放掉。同時分配一頁記憶體存放參數。
根據可執行檔案頭把程序任務資料結構task[nr]所有資料都設定好,但是並不載入一頁程式碼資料。所以整個程序就是一副空架子。
從程序空間的第一條語句開始執行就會產生中斷,然後根據PC的值從外設中載入所在頁到記憶體中。這個中斷將執行do_no_page.
這個函式在fs模組中定義,在fs模組中再仔細分析。
3)缺頁中斷int14
這個中斷是十分有用的,它實現寫時複製。fork和execve沒做完的事情都會由這個中斷提供的功能來了結。
中斷錯誤號為出錯頁表項的最後3位。根據P位0或1判斷是缺頁中斷或防寫中斷。
缺頁中斷呼叫do_no_page,防寫呼叫do_wp_page.
do_wp_page提供寫時複製機制,
取消頁面保護(對於主記憶體區而言),頁面不是共享狀態,即mem_map[]=1,則置r/w=1返回。
如果頁面是共享狀態(mem_map[]>1),mem_map[]--,申請一頁記憶體,並拷貝,對映到程序空間。
do_no_page提供的需求載入機制。
CR2提供發生錯誤時的線性地址。如果當前程序沒有可執行檔案且該地址比資料段末地址大,這可能是因為堆疊伸長引起的,直接申請一頁記憶體對映到該線性地址所在頁。
否則嘗試頁面共享,即如果有執行檔案而且其inode使用計數>1,表明系統中可能有程序也在執行這個程式,這樣可以查詢到這個程序並把其對應地址處的頁面共享到自己空間,也就是修改這兩個程序對應的頁表項。而且是共享方式,所以只能讀不能寫,如果有一個程序要執行寫,則會引起防寫中斷,系統再給寫的程序另外再分配記憶體頁並拷貝一頁內容。如果嘗試頁面共享失敗,沒辦法只得從外設中載入,找到可執行檔案的inode.計算要讀的邏輯塊號(注意第一塊是檔案頭),讀一頁(4塊)到記憶體。分配頁面,複製緩衝中的4塊資料,把頁面對映到程序空間引起中斷的線性地址處。
(四)mm記憶體管理
linux的mm雖然只有兩個檔案memory.c和page.s,但是內容卻很不簡單。必須對分頁機制有很好的理解才能讀明白。這個版本的核心每個程序虛擬空間64M,共支援4G/64M=64的任務數。所有程序共用一個頁目錄,但是卻有自己的頁表。對虛擬地址的劃分使得在頁目錄中也存在劃分。每個程序虛擬空間最大佔用16個目錄項,每個目錄項指向一個頁表(1024個記憶體頁),對應4M空間。線性地址分三段,每段都是一個索引index或者叫偏移(offset),第一段索引是在頁目錄(基址在CR3)中找到頁目錄項,頁目錄項裡儲存