1. 程式人生 > 實用技巧 >Linux0.11小結

Linux0.11小結

第一部分 基礎內容

1.作業系統基礎

作業系統是計算機硬體系統與使用者程式間重要環節,理解作業系統的原理是編寫優秀程式碼的基礎。教課書中闡述的作業系統一般由5部分組成。

一個最簡單的作業系統,可以不需要檔案,不需要網路,只要實現多程序,且程序間也不需要通訊,相互獨立。那麼這樣一個簡單的OS僅需要兩塊內容:程序管理、記憶體管理。這兩方面內容是相輔相成,不可分割的,因為現在計算機系統的基本架構仍是指令儲存-執行。記憶體管理很大程度上依賴處理器的硬體支援,而程序管理則是在這個基礎上,用軟體的方式虛擬化出的一套機制,使得多個程式能同時使用計算機。

下面先介紹一些與作業系統相關的基礎知識。

1.1作業系統的啟動簡介

計算機中最初始的是硬體,理解作業系統的啟動雖不難,但卻可以讓人去除對作業系統的敬畏之情,給我印象最深的就是《自己動手寫作業系統》開篇講的,10分鐘寫出一個OS,也正是這個才鼓勵我學習了下面的內容。

一般PC機的啟動過程如下:

  1. 上電自檢,當電壓穩定後,釋放reset,控制權交給CPU(一般由一些特定的控制晶片完成);

  2. CPU的地址指標首先指向BIOS,由BIOS執行一些必要的檢查和初始化,BIOS最後把啟動盤中的第一個扇區載入到0x7C00處(即boot.bin),並跳轉到此處執行;

  3. Boot.bin主要是把loader.bin載入到0x9000:0x200處,並跳轉到此處執行;

  4. Loader.bin讀出記憶體資訊,並把kernel.bin載入到0x8000:0處,然後進入保護模式,在保護模式下初始化頁目錄頁表,並啟動分頁機制,然後把kernel.bin按照elf頭資訊以及ld –Text xx的引數移到正確的位置,並跳入核心起始點;

  5. 作業系統核心開始工作。

b)是計算機轉由軟體控制的關鍵,可以直接寫一個顯示的小例子,然後dd到硬碟的引導扇區(即第一個扇區),重啟計算機就會發現螢幕上顯示你所要的字元。

至於boot.bin為什麼要載入一個loader.bin進來,主要是boot.bin只有512byte,太小了,做不了太多事。Loader.bin則沒有限制,可以做很多工作,把kernel.bin載入到記憶體中,利用BOIS中斷服務獲得系統的一些硬體資訊,如記憶體大小、硬碟資訊等,並存放在記憶體相應位置,供OS以後使用。然後最重要的就是使計算機進入保護模式,做一些初始化工作:gdt,idt,A20線,分頁等, 並把控制權交給kernel.bin。

注意進保護模式前,記憶體中主要是BOIS(固化在ROM中)及BOIS初始化出來的一些資料結構,如真實模式下的中斷向量表等。因此在進保護模式前,BOIS服務(即軟中斷int xx)仍是可用的,事實上啟動過程中載入、顯示兩大操作正是利用的BOIS的int 13和int 10中斷服務例程,另外初始化硬體,獲得諸如記憶體資訊、硬碟資訊等都是通過BOIS服務完成的。

進入保護模式後,中斷向量的方式和真實模式完全不一樣了,此時BOIS不再有用,而是真正要靠OS來接管一切,從頭開始。

1.2保護模式下的儲存機制

在真實模式下,採用分段的儲存機制,定址以seg:offset方式定址。其中seg是段描述符16bit,offset為16bit,總地址為seg<<4+offset,最大地址為1M。

在保護模式下,定址也採用seg:offset方式,seg仍為16bit,但offset為32bit,且實現的機制也大不相同。其中最關鍵的是GDT表,如下圖所示

GDT表放在記憶體中,其地址有GDTR暫存器標識。Seg中存放的為一個索引值,指向GDT表中的某一項。GDT表中的每一項稱為描述符DESCRIPTOR,它裡面包含了該段的base基址即該段的limit。其中base為32bit的,加上offset即為所要定址的線性地址。

GDT表只有一個,光靠它來實現多程序的地址空間分割還不夠,處理器還提供LDT機制,如下圖所示:

暫存器GDTR是一個32bit的,標識了GDT表的地址,LDTR暫存器是16bit的,其中存放的卻是一個選擇子SELECTOR,索引GDT表中的某一項。Seg中的TI=1,則表明這是一個LDT定址,根據LDTR中的索引值找到GDT表中的相應項,得到LDT表的基址base。Seg中的索引值在LDT表中索引到相應項,得到實際base,加上offset就是線性地址了。

GDT表只有一個,LDT表卻可以有很多,事實上是每個程序都有自己的LDT表,且它們都在GDT表中有相應項對應。在切換程序時,只要改變LDTR暫存器的值,就可以輕易實現各個程序使用自己的LDT表。這樣就可以實現多程序的地址空間分割了,因為各個程序可以使用相同的seg段,但所指向的實際地址卻可不同,不相沖突,就好像每個程序都獨享了所有地址段。

上面說了分段機制得到的是線性地址,要變為實際的實體地址,還需要分頁機制。如果說GDT+LDT方式的分段,使得多程序在表面上實現了地址空間分割,那麼分頁機制則在表面上是地址空間分割的情況下,實現了實體地址並不需要分割,不需要連續,甚至可以重疊。

如上圖所示,記憶體中有一個頁目錄表,其地址有CR3暫存器標識,頁目錄表中有1024項PDE,由線性地址的高10bit索引;每個PDE為32bit,指向一個頁表,每個頁表中有1024項PTE,有線性地址的中間10bit索引;每個PTE為32bit,指向該頁框的基址,每個頁框有4KB大小,由線性地址的低12bit定址。

分段機制使得各個程序使用相同的段,但由於各個程序的LDT表項所指的基址base不同,從而實現了線性空間的分割。但分頁機制使得分割的線性空間可以隨意對映到任意實體地址。這在後面詳講。

1.3特權級與堆疊問題

X86架構的CPU分了4個特權級,linux只用了0級作核心級,3級作使用者級。一般情況下,jmp和call的轉移,程式碼段資料段的訪問遵循以下規則:

低---高

高---低

相同

適用情況

一致程式碼段

Y

N

Y

供使用者使用的程式碼資源,及某些異常處理的系統程式碼

非一致程式碼段

N

N

Y

避免被使用者破壞而保護起來的系統程式碼

資料段(非一致)

N

Y

Y

系統核心可以檢視使用者資料

特權級的表現形式有3種,所有光說低、高還不夠明確,而且上述的只是一般的轉移,必要時還可以通過呼叫門來實現不同特權級間的切換。更為確切的比較方法將在下面講述,首先看一下特權級的3種表現形式。

CPL:正在執行的程式或任務的特權級,有CS、SS的1~0bit體現;

DPL:段或門的特權級,被儲存在段描述符或門描述符的DPL欄位中;

RPL:由段選擇子的1~0bit體現。

下面主要關心在不同特權級間切換時,堆疊的情況。程式碼在相同特權級間跳轉時,堆疊不變,在不同特權級間跳轉時,則會用到兩個不同的堆疊。

如上圖所示,無特權級變化的情況主要發生在核心態的程序被中斷時,而使用者態下的程序被中斷則會發生特權級變化。

從低優先順序切換到高優先順序,會使用另一個堆疊,並把之前的ss、esp壓入新的堆疊,以便返回時可以直接找到以前的堆疊。但是怎麼找到高優先順序的堆疊的呢?這就需要用到TSS段,每個程序都有自己的TSS段,和LDT一樣,TSS段在GDT表中也有相應的描述項,該項由TR暫存器索引,所以程序切換時,和LDTR暫存器一樣,TR暫存器也要相應地改。

1.4中斷

程序間的切換當然要各種各樣的中斷來支援。中斷一般指程式執行過程中因硬體而隨機發生,通常用來處理外部事件,當然軟體通過執行int n指令也可以產生中斷;異常一般指處理器執行過程中檢測到錯誤,如除零等。總之,它們都是程式執行過程中的強制性轉移,轉移到相應的處理程式。

保護模式下,x86處理器支援共256箇中斷異常處理。

0~19

是各種異常的向量號,特例,其中2號為非遮蔽中斷,9、15號為intel保留的。

20~31

Intel保留的。

32~255

使用者定義的中斷,包括外部中斷和int n指令

首先看看機器的中斷異常的實現機制。異常及軟體中斷int n,都是程式執行時,遇到相應的指令就跳轉,與硬體無關。另外,機器提供兩個引腳,實現外部中斷,一個是NMI,不可遮蔽中斷,一般用作災難性的處理,如斷電等,另一個是INTR引腳,一般連線中斷處理晶片8259A來實現外部中斷。

然後看中斷向量表IDT,裡面有256項,每一項定義了該號中斷髮生時,應執行的內容。在真實模式下,中斷向量表中沒一項可能就是一條跳轉指令,跳到中斷處理程式處。而在保護模式下,中斷向量表IDT裡有256個門描述符,即中斷門。每一箇中斷對應一箇中斷門描述符,從而找到相應的處理程式,中斷門的結構功能與前面講的呼叫門幾乎是一樣的。

所有的中斷處理程式都是核心態下的函式,所以我們這裡所有的selector都指向唯一的程式碼段描述符SELECTOR_KERNEL_CS = 0x8,只要設定好每項的offset,指向各個中斷處理程式的函式入口。

2.linux0.11核心執行框架

這裡致力於弄清楚核心是如何運轉起來的,先不關心以什麼策略使它運轉得更高效。核心運轉的關鍵就是多程序,理清楚程序的幾方面是關鍵。

  1. 由哪些部分構成

  1. 何時切換(哪些機制使它切換)

  2. 怎麼切換並保證空間地址、資料不發生錯誤

程序執行離不開記憶體,或者說是建立在記憶體管理之上的,那麼記憶體的情況是怎麼樣的?針對這兩方面,總結出下面這張圖。

針對這張圖需要說明的幾點是:

  1. 核心kernel本身不會執行,它包含一組核心函式庫,為所有程序所共享。系統中時刻都有一個程序在執行,或運行於使用者態,或運行於核心態,在核心態下時可用核心函式庫。大部分核心函式會設計成可重入的(只有區域性變數、堆疊實現),所以允許多個程序同時執行在核心態;但有些函式不可避免的用到全域性變數,這時有兩種方法,一是執行這些函式的程序不允許被搶佔,二是精心設計同步方法。

  2. GDT表中有K_CS/K_DS,供程序在核心態下時使用。因為程序進入核心態只有一種可能,就是發生中斷,1.4節已經說明了IDG表中所有門描述符的SELECTOR都是SELECTOR_KERNEL_CS = 0x8,因此K_CS/K_DS是被所有程序共用的核心態描述符(基址),即整個核心函式空間是所有程序共享的。

  3. 核心為每個程序維護了一個task_struct和k_stack,它們通常放在一個頁框內,有union結構生成。k_stack則供程序在核心態時用(1.3節已說明不同特權級間的切換會造成堆疊的切換)。Task_struct為程序管理用,主要包含了程序所管理的資源如檔案、IO、訊號等;還包含了各種執行時的變數屬性如優先順序、時間片、程序號組等;與執行框架最緊密相關的是LDT表段、TSS段,GDT表中還為每個程序準備了一個LDTn描述符,和一個TSSn描述符,與這兩個私有段對應。

  4. 程序建立時,就在GDT表中為它初始化好LDTn和TSSn,且基址分別指向task_struct中的LDT表段和TSS段。

  5. TSS段是支援程序執行、切換的關鍵,它主要包含3部分關鍵的內容:1)程序的執行狀態(各種暫存器),當要切換到一個程序時,通過ljmp tss_sel指令,tss_sel是TSSn在GDT表中的索引,這可以由程序組號直接得到,處理器首先將該tss_sel裝入TR暫存器,然後由GDT表中TSSn找到程序task_struct中的TSS段,彈出TSS段中儲存的狀態;2)ldt_sel選擇子,它是LDTn在GDT表中的索引,ljmp tss_sel指令也會將它裝載到LDTR暫存器中,從而使程序正確定址;3)ss0、esp0指向程序的核心棧頂,若程序在核心態時被中斷,堆疊不會切換,用不到esp0,當程序要從核心態切回用戶態時,一定保證核心棧中為空了,下次再切到核心態時,仍有esp0指向核心棧頂,所有esp0不需要變化。

  6. LDT表段為程序提供正確的線性基址。如一共64個程序,所有程序共享一個頁目錄表,每個可有16項PDE,16個頁表,16*1024項PTE,指向16*1024個頁框,共64M的線性地址空間。這樣就將線性空間有效分割開了,互不相關,使用者程式編譯時只需關心offset地址。

  7. 上述方案共用一個頁目錄表,從而限制每個程序只有64M線性空間,目前的linux系統通常是每個程序有自己的頁目錄表,即每個程序完全有一套自己的線性空間,這才是真正的分割、互補相關。此時每個程序可有4G空間,但每次切換程序時應改變CR3暫存器的值(它應該會儲存在task_struct中),LDT將不再需要。事實上,目前的linux系統程序狀態採用軟體儲存,TSS段也是不需要的,只要僅一個全域性TSS段,用於使用者態核心態切換時堆疊的切換,可想而知,每次切換到一個新程序,該全域性TSS段的esp0應修改為該程序的k_stack。

  8. 各個程序有自己的線性空間,但他們對應的物理空間卻可以重合。事實上fork出新程序時,它的資料段和程式碼段是完全父程序重合的(是完全複製了所有頁表項)。對於共用的程式碼,重合當然沒有問題,但對於要寫的資料段(如堆疊),共享當然不行了,linux採取了寫時複製(copy on write)技術,即fork時複製的所有頁表項的屬性都設為了只讀RD,若一個程序試圖寫時,會發生頁故障中斷,中斷處理程式(參加page.s)會尋找一個新的物理頁,修改頁表項使其對映到新的物理頁,同時把原頁中的資料複製到新頁上來(這比較耗時的)。

  9. 如何管理物理頁。有K_CS/K_DS可見(基址為0,且limit為整個記憶體空間),核心態下線性空間和物理空間是一樣的,可以用一個數組,記錄每個線性頁框(實際也對應物理頁框)被映射了幾次,每次分配頁及撤銷頁時都要維護這個陣列。若一個頁框被映射了0次,那麼它就是空閒的,可用。

上述是我讀了一遍程式碼後,總結而成的。在這探尋過程,實際上是按照一個路徑來的,重點看懂幾個關鍵函式後,就能對核心執行框架有一個很好的理解。下面主要來闡述這幾個函式。

2.1建立程序fork()

建立程序這樣的工作應該是在使用者控制元件呼叫的,所以這裡就不得不提到sys_call系統呼叫。下面就以fork為例,說明系統呼叫的工作機制。

如上圖所示,首先將呼叫號寫入eax,比如fork的呼叫號為NR_fork=2,然後int 80h進入軟中斷,IGDT表的對應項會指示程式執行到system_call函式;

system_call函式首先判斷呼叫是否在範圍內,如linux 0.11版系統呼叫總數為72;

把ds,es的值設為0x10,即KERNEL_DS。要注意的是,此時的cs已經是0x08(即KERNEL_CS)了,因為IDT表中的所有SELECTOR都是0x08;

根據呼叫號,從sys_call_table中選擇相應的函式執行;

因為系統呼叫很可能是該程序變為中斷狀態(如資源的問題),因此必須判斷是否要schedule一下。若真schedule了,則會切到另一個程序去執行。當該程序再次被執行時,是從schedule的switch_to函式末尾開始執行的(參見switch_to),它要返回的是ret_from_sys_call,所以事先把該返回地址壓棧;

最後還要看一下該程序是否收到訊號(檢視task_struct中的signal項),若有訊號,就去執行do_signal,這是程序間通訊的基礎,以後再講。

好了,瞭解了系統呼叫,現在來看_sys_fork,它首先找一個空任務,linux0.11最多允許64個程序,核心中維護一個task[64]陣列,標示某個task是否存在了。

然後把任務號壓棧(注意任務號和程序號的區別),然後呼叫最關鍵的函式copy_process。由名字就可知,建立程序實際上是複製了父程序。

首先獲得一個新物理頁作為新程序的task_struct,然後其內容完全複製current的;

修改屬性值,包括pid,utime等;

修改執行狀態值(tss段),主要是esp0指向新程序的核心棧,而且以後都不用變,前面講過,另外就是ldt_sel,應索引到該程序在GDT中的ldt_sel,其它的如暫存器之類的基本不用改,不過要注意的是eax需改為0,即子程序fork返回的是0;

複製程序空間,這裡不是複製內容,而只是複製PDE和頁表,使新程序的地址空間與父程序對映到相同的物理頁,還有一個很重要的工作就是修改p中LDT段的內容,使其指向新程序空間的基址。

設定gdt表中對應該程序的TSS,LDT描述符,使其指向task_struct中的TSS段和LDT段;

最後設定state為RUNNING,子程序就可以被排程執行了。

關鍵來看一下copy_mem(nr,p)的工作:

首先獲得源基址和段長,新基址為nr*64M,然後修改task_struct中ldt段為新基址。此時新基址有了,但新程序定址所需的PDE和頁表還沒有,下面就建立;

獲得源PDE和新PDE的地址,注意了這裡是核心空間,頁目錄表的基址為0,base/4M即為第幾項,每項大小為4Byte。

獲得PDE的總項數,一般就為16項(每項4M,共64M),對每一項,若為空則跳過(可能為空的),若不為空,則:

獲得該PDE項對應的頁表from_table,新頁表需重新獲取物理頁(每個頁表的大小也為一個頁框4K),並使新PDE項指向該新頁表,但屬性設定為只讀,為的是寫時複製;

新頁表中的1024項全部複製源頁表的內容,即對映到相同地址空間;

最後,對每個PTE項對映的物理頁,都要維護其mem_page[*to_table],即使該物理頁的對映次數++。

2.2執行使用者程式execve()

execve()函式也要進行一次系統呼叫,來打造一個全新的程序空間,執行新的程式,它執行完之後,新程序就和原父程序完全不相干了,就連父程序原先為子程序安排在execve()呼叫之後的那些內碼表不存在了。那麼首先看看一個新打造出來的程序空間是什麼樣的呢?主要包括

  1. code程式碼;

  2. data全域性資料;

  3. bss未初始化的全域性資料;

  4. stack堆疊供程序內函式呼叫引數、區域性變數用;

  5. 引數即該程序執行的引數,包括程式名,附加引數argv,引數個數argc等。

相比較這個模型而言,其實execve()所做的事情非常少。

如上圖所示,call sys_call_table[]選擇了sys_execve執行,首先把核心棧中存放返回eip值的地址賦給eax,並壓棧作為引數呼叫do_execve();

do_execve()首先根據檔名,找到該檔案,並獲得它的執行頭ex;

釋放該程序的原地址空間,包括使相應的PDE項清零,相應的頁表釋放;

獲取32個物理頁,把程式的執行引數賦值到這些頁中,一般而言肯定是綽綽有餘的,需注意的是,這裡是在核心態,把資料複製到使用者態的頁表中,需一定的技巧。然後把這32頁安排在該程序空間的末尾,這裡當然就會填寫相應的PDE項,並重新分配頁表來指向這些頁框;

末尾32頁,最末存放的是執行引數,多餘的部分作為stack,且此時p指標指向該stack的頭。create_table(p)把各執行引數argv,argc的地址寫入該stack中,並使p指標相應前移;

最後是神奇的一步,是核心棧中返回eip的值為ex.entry程式入口,esp值為p,即使用者態堆疊中。然後該系統呼叫返回後,就會去執行新的程式了。

可見,execve執行完之後,只是提供了程式的執行入口,並讓該程序eip指向該入口處開始執行,但實際的程式卻並未載入到程序空間內。執行時,當然會發生缺頁錯誤,這是再到磁碟中的檔案中去找所缺的部分載入。這裡就要提高linux下可執行檔案的格式了,0.11版時用的是a.out格式,現在已經不用了,現在普遍用的是elf格式,它把整個檔案分為多個段program,並有一個elf頭標示所有這些段頭,每個段頭又會標識該段應被載入到程序空間中的偏移地址,這些都是編譯器自動完成的。所以缺頁中斷程式只要根據所缺頁的偏移地址去檔案中找相應的program來載入即可,對於有些沒有執行的分支,就不會載入,這樣也提高了效率。堆疊也一樣,當末128K用完後,會分配新的物理頁作為堆疊。

2.3程序退出exit()及等待wait()

程序的退出也是一個系統呼叫,最終呼叫的函式實體為do_exit()。在我們寫應用程式的時候,有時候中間判斷出現異常時,會呼叫exit(-1)這樣的函式來終止程序,但往往我們的main程式不會呼叫exit,而只是在最後return 1。但實際上,編譯器編譯時運自動為每個應用程式的末尾加上glibc執行時庫中的exit函式來執行exit的系統呼叫。另外一般父程序會等待子程序的結束,利用wait系統呼叫。

do_exit()函式比較簡單,首先使該程序的PDE清零,釋放其佔用的頁表;

第二步比較關鍵,遍歷所有程序,找到它的所有子程序,將其父重設為task[1],即init程序。若該子程序還在執行,則不用管,它exit時會自動發訊號,若該子程序已經處於ZOMBIE狀態(但還沒有銷燬,可能是該父親並未呼叫wait),則應重新向新父親init程序傳送SIGCHLD訊號(它之前可能已經發過了,但是發給原父親的),可見init程序會處理所有沒被處理的ZOMBIE程序;

釋放該程序佔用的檔案、tty等系統資源,並將其狀態設為ZOMBIE;

最後通知父程序,即向父程序傳送SIGCHLD訊號,由上面可知,父程序至少為init程序,然後執行排程。

有do_exit()的實現來看,有兩點需注意:

  1. 作為子程序,它呼叫exit後,其實並未真正銷燬,而是變為了ZOMBIE狀態,不會再次被呼叫,等待其父程序來銷燬;

  2. 作為父程序,它呼叫exit時,要麼其所有子程序都被他呼叫wait時銷燬了,而若還沒全銷燬,或壓根它就沒呼叫wait去處理它的子程序,那麼它在exit是也至少會將這些子程序的父親指向init程序。

父程序一般呼叫waitpid來等待子程序結束,並銷燬其task_struct。其工作情況如上圖所示,這段程式碼有點彆扭。

首先置flag=0,根據pid值找相應的程序,或者是一個特定的子程序、或是一組、或pid=-1時就找所有的程序;

若該程序的狀態為ZOMBIE了,則releas它的task_struct,並返回其pid。這裡可見它只要銷燬一個程序就會返回,所以一般父程序有多個子程序時,會迴圈呼叫wait直到銷燬了它想要銷燬的那個;

若該程序還沒終止,則置flag=1,然後執行下面的if()框架:置自己的state為中斷狀態,即掛起自己,然後呼叫執行其它程序;

直到它再次被喚醒時(是被訊號喚醒的),它繼續從schedule()下面開始執行,若僅是被SIGCHLD喚醒,則繼續回去尋找子程序來銷燬,否則就返回-1。這裡也說明,父程序中需迴圈呼叫wait。

2.4初始函式main()

前面講了程序的建立、打造、終止,任何事物都要有個最原始的,那麼最原始的程序哪來的呢?上面多次提到的init程序又是怎麼回事呢?那就要去看main函式,它是核心執行完head.s程式碼後就開始執行的,事實上我是先看它,再尋這看完上面的那些函式的,看完之後再回頭來看它,會發現結構更加清晰了。

main()之前是head.s程式碼,整個系統還是序列控制,還不存在多程序。進行一些初始化initial()後,執行關鍵的一步mov_to_user_model(),它假裝push了esp、eip等值到堆疊中,然後iret,彈出堆疊中的資料到相應暫存器中,注意這裡的ss、cs都是LDT選擇子,LDT段應該在task_struct中,task[0]的task_struct在initial中就準備好了,其中的LDT段的base都為0。這裡就有幾點需注意的地方了:

  1. 先看esp、eip的值就不難發現,0號task的程序體和核心是重合的,使用者態堆疊就是head.s用的堆疊;

  2. 而0號task的核心態堆疊是和其task_struct聯合union在一起的,而這個union體是認為地初始化在核心空間的(低1M地址內),這是一個特殊,以後所有的程序的task_struct都會在主存(高於1M)中分配新頁框;

  3. 這以後就不存在序列控制路勁了,即head.s用的那個堆疊卻是不會再被核心用了,而只是作為task0的使用者堆疊,以後任意時刻,系統中都是一個程序在執行,或在使用者態,或在核心態,整個核心空間的函式是被所有程序共享的;

然後task0會fork出task1,即為init程序,這之後task0就進入休眠,迴圈執行pause(),事實上,一個程序執行pause()系統呼叫後,會變為掛起狀態INTERRUPTIBLE,然後呼叫schedule(),直到被訊號喚醒,而實際上不會有程序發訊號給task0,那麼task0到底會不會被執行呢?會!這就需看schedule()函式中的一個程式設計小技巧了,它在排程時,是從task1開始遍歷的,但最後若發現沒有可執行的程序,則會啟動task0,與task0一直是INTERRUPTIBLE無關。Task0也不幹事,反正就是一直再掛起,再schedule(),直到有可執行的其它程序。

再來看init程序,它首先fork出一個子程序去執行/bin/sh程式,然後等待該子程序退出,該子程序會退出嗎?會的!應該執行引數argv1不對,這只是為了初始化一下環境,詳細參加sh程式;

然後它重新fork出一個子程序,以正確的引數執行/bin/sh程式,然後等待子程序的退出,注意了,它用的是wait,實際上是waitpid的一個封轉,即引數pid=-1,找所有的子程序。所有失去父親未被銷燬的程序都會指向init程序,所以它迴圈呼叫wait來銷燬所有ZOMBIE程序,直到它本身的那個子程序,即sh程式終止,才退出這個迴圈。

Sh程式會終止嗎?會的,輸入命令exit它就終止啦!終止後init程序又會進while(1)迴圈,即再次fork來執行sh程式,那時候還沒有使用者介面,linux反正就是一直執行sh,sh可以建立程序來執行。

第二部分 核心的血肉

上面講了核心的執行框架,有了這個框架,核心就可以運轉了,上述內容闡述了,使用者可以方便地通過核心(系統呼叫)建立程序、打造程序、銷燬程序。但一個OS核心要能被使用者使用,還必須包含一個具體的系統呼叫介面,這些介面主要分為幾個大部分,也就是教科書上講的如檔案系統、裝置驅動、網路等,另外要使核心運轉得高效,滿足使用者的需求,還必須為它設計各種執行策略,如排程方法、程序間通訊方法等。

總的來講,這些內容一般都是教課書上津津樂道的內容,在前面學習內容的基礎上,再來看這裡的內容,會覺得更清晰一點。

看的也不深入,講的不多。

3.程序排程

Linux0.11版本的程序排程比較簡單,效率比較低,它實際上就是遍歷所有可執行程序,是一個O(n)複雜度的演算法,現代linux的排程演算法已相當成熟,引入了等待佇列的思想,利用紅黑樹實現了一種O(1)時間複雜度的排程演算法。但通過對0.11版的排程程式的學習,可以很好的理解排程程式時幹嘛的,什麼時候、怎麼樣來完成這樣的事情。

首先看程序排程發生在那些情況下。總結一下,主要有3個地方會發生schedule:1)時鐘中斷,這是最重要的一項,把保證一個程序不會永遠佔用CPU;2)在sys_call中,執行完相應的sys_call_table[]的函式後,sys_call主體函式會判斷current->state是否還是RUNNING,因為這過程中可能因為資源、訊號等是該程序阻塞,若不是了,就schedule;3)一個sys_call_table[]函式本身就是專門為了排程的,如pause()、exit()、sleep_on()、waitpid()等,它們往往使該程序state變為非RUNNING,然後直接呼叫scheduel,注意與第二種稍有區別。

程序排程總體上分為兩部分內容,一個各程序狀態的轉換,二是排程。

3.1程序狀態轉換

程序排程是指,在所有RUNNING狀態的程序中選擇一個最合適的程序來執行。那其它狀態的呢?ZOMBIE就不看了,已經是將死,不會再被排程,STOP在0.11版還沒用,另外就是INTERRUPTIBLE和UNINTERRUPTIBLE。

UNTERRUPTIBLE狀態是不能被訊號喚醒的,它一般是程序執行時需要用到某個資源如檔案IO等,而此時此資源被其它程序佔用,那它就呼叫sleep_on()進入UNINTERRUPTIBLE,只有當該資源釋放時,才會呼叫wake_up()喚醒該程序。它不能被訊號喚醒。

INTERRUPTION則是和資源無關,它更多是為了兼顧多程序執行的順序安排而設定的,最簡單的就是前面講的wait()呼叫,使父程序處於INTERRUPTION狀態,直到子程序終止傳送SIGCHLD訊號給它,它才被喚醒。它當然可以被wake_up()喚醒。

3.2程序排程

下面看schedule函式的總體框架:

首先遍歷所有程序,找出過期程序,置SIGALARM訊號。Jiffies是核心維護的全域性變數,是系統啟動開始所經過的滴答數,10ms/滴答。若一個程序預期在一個時間之前完成,過期的則會進行相應處理,那就是SIGALARM訊號的處理,這裡就不多講了。並且還找所有state為INTERRUPTIBLE的程序,若它受到訊號,則置RUNNING。

然後遍歷所有程序,找出具有最大counter值的程序作為下一個執行程序。若所有task的counter都為0了,就將所有task的counter重置為f(priority)(關於priority的函式)。這裡的counter就是常常說的動態優先順序,程序每執行一次,counter--,priority就是常說的靜態優先順序。

上面的過程還清楚地顯示出INTERRUPTIBLE程序如何被訊號喚醒的過程,但對UNINTERRUPTIBLE卻不涉及,那它的狀態轉換又是在哪呢?

前面也提到UNINTERRUPTIBLE是和資源息息相關的,原來每個資源如檔案(0.11版核心所有程序的開啟檔案和<64,由file_table[64]維護,每個程序最多有20個檔案,有每個task_struct中的filp[20]維護),核心中都為它維護一個等待佇列,該佇列對所有程序可見(通過file_table[64])。

每當程序去使用一個正被其它程序使用的資源時(一定在核心態中),該程序就會執行到sleep_on()分支上去,變為UNINTERRUPTIBLE狀態。當另一個程序(在核心態中)釋放了該資源,那麼它也會執行到一個分支上去,檢視該資源的等待佇列,對其中一個呼叫wake_up()喚醒。

4程序間通訊

前面講了訊號的喚醒機制,而實際上,訊號並不是專門為喚醒設計的,它最主要的設計意圖是為了實現一套程序間通訊機制。比如兩個都是RUNNING狀態的程序,其中一個向另一個傳送一個訊號,該程序收到訊號時,就可以執行一段相應的功能程式碼。

4.1使用者態執行模型分析

首先看使用者怎麼使用這套機制的,一般在linux下多程序程式設計,會使用訊號通訊。首先要知道的是,訊號中最重要的一個數據結構時sigaction,它包括一個sa_handler處理函式和sa_restorer返回函式;每個程序的task_struct中有三項訊號相關的,signal為一個32bit的訊號點陣圖,blocked是阻塞點陣圖,sigaction[32]對應每個訊號。

一般一個程序task-receve中需註冊某訊號的處理函式,int sigaction(int sig,struct sigaction)是一個使用者態函式,定義在glibc中,它實際上是執行一個系統呼叫sys_signal,把該sigaction寫到task_struct中。要注意的是這裡的sa_handler和sa_restorer是處理函式的指標,即一個函式入口地址,且是使用者態下的地址。在系統呼叫中(核心態下),僅是用的這個使用者態地址(實際上只是把這個地址壓入棧中eip位置,後面會看到),而並不會去執行這個使用者態函式,所以這裡是沒問題的。

另一個程序task-send向該程序傳送訊號,一般用glibc中的函式kill(pid,sig),raise(sig)等,它們最終也是系統呼叫sys_kill(),sys_raise()等,並最終會呼叫核心函式send_sig(pid,sig),將pid所指程序的task_struct中的signal欄位的相應bit置位。

這樣,當task-receve發現收到訊號後(一般是在sys_call中發現的),就會根據它註冊過的sigaction來對程序進行一番打造,使得它執行一個sa_handler,然後繼續沿原路徑執行。

4.2核心態打造過程分析

關鍵就是看程序如何發現訊號,並如何讓程序插入一段執行sa_handler,且不影響原程序的控制路徑(即只是在原路徑中插入一段sa_handler)。

前面講了訊號是核心控制的task_struct中的組成部分,使用者是不可見的,所以發現訊號一定要程序在核心態下,也就是說,使用者程序一定要在執行到核心態後才會執行訊號處理工作,各個能使程序進入核心態的點,一般稱為陷入點(這個名詞好像不對,記不清了)。實際上在講sys_call的時候,略去了ret_from_sys_call部分的關於訊號處理的部分,現在來看。

系統呼叫sys_call的最後部分首先判斷是否是在核心態下呼叫該sys_call的(好像這種情況不存在),是的話就不處理訊號,因為訊號是要處理使用者態堆疊的。

然後判斷有無收到訊號,即看task_struct中的點陣圖有無置位的,有的話則取最低一位的訊號,轉化成數型,壓入堆疊,作為引數呼叫do_signal。為什麼只取最低一位呢?其它訊號就無效了嗎?0.11版貌似這裡做的不完善。

和之前execve函式一樣,從sys_call到這裡的call do_signal,核心態堆疊中的資料是固定的,最開頭是返回到使用者態的(一定是使用者態,前面提到了核心態下系統呼叫是不處理訊號的)eip、cs、eflag、esp、ss,它們都作為do_signal的引數。其中eip是設為了long型,esp是設為了long *型,其實都一樣,都是32bit的數(一個地址),這樣做只是方便c語言的程式設計。

Do_signal()函式首先根據signr值在task_struct中找到相應的sigaction,然後進行最重要的兩步:

  1. 一是把核心棧中eip的值該為sa_handler(函式入口地址);

  2. 二是把核心棧中esp值減去7或8,即在返回後的使用者態堆疊開闢一小段,並在其中填入一系列資料,注意這裡是在核心態向用戶態空間寫資料,需要一定的技巧,這裡封裝了put_fs_long()。

這樣一打造後,情況如下圖所示:

可以看到打造之後,從sys_call返回後,eip指向使用者空間的sa_handler函式體,且esp指向新使用者堆疊的新處,程序就開始執行sa_handler;

執行完之後,返回ret,因為這是在一個空間內,是short jmp,所以只需要堆疊中彈出eip值即可,而這個值正是do_signal寫入的sa_restorer的入口地址,那麼程式就開始執行sa_restorer;

Sa_restorer也是一個使用者態的函式,不過一般不需要使用者定義,其功能單一固定,就是讓程序回到原來位置處,glibc中已經為我們定義好了,如上圖右側所示。

它彈出先前do_signal中寫入使用者堆疊的一些列資料,直到old_eip(那些寫入的資料好像沒用到,可能只是linus想測試一下),然後ret,同樣是short jmp,堆疊中的old_eip正好就是指向原程序斷點的位置,則程序又會回到原地方繼續執行了。

5.檔案系統

Linux0.11版完全借用了minix的檔案系統,現代linux的檔案系統已有了很大發展,尤其是加入了虛擬檔案系統後,功能更加完善,可以識別多種檔案系統。這部分內容以後再慢慢學。不過從minix檔案系統還是可以學到一些最基礎的檔案系統方面的內容。

在磁碟中,檔案系統包括超級快,節點對映,區對映,節點,資料等,前幾部分的內容固定,且存放在磁碟固定位置,一般是磁碟開始位置,作業系統掛載一個磁碟檔案系統時,就是通過讀取這些磁碟塊的內容,然後再去索引各個檔案的。因此若磁碟的這些關鍵部分壞了,則檔案很可能讀不到,就如引導扇區壞了則無法引導OS一樣。

在記憶體中,核心長期維護著每個檔案系統的超級塊,另外記憶體中有一塊區域成為快取,它有一塊一塊組成,用來存放讀到記憶體中的磁碟塊,且一一對應,滿了之後會交換出去。在頻繁對某些檔案操作時,這樣做可以提高效率。

在程序級別,每個程序的task_struct中有filp[20],即每個程序最多可以開啟20個檔案,另外整個核心維護一個file_table[64],即整個系統中最多同時存在64個開啟檔案,它們之間有對映關係。對程序而言,它自認為是獨享20個檔案的,每次程序要使用檔案時,它必是系統呼叫進入核心態,然後核心會在file_table中找到與它對應的檔案,若發現該檔案正在被其它程序使用,則會掛起這個程序。前面也提到了,每個檔案資源(這裡指file_table中的)都有一個和它對應的等待佇列。

另外一點,linux中裝置也被當成是特殊檔案的,裝置驅動的編寫。

6.網路