天天看點

【Linux】Linux程序的建立與管理

在Linux系統中,除了系統啟動之後的第一個程序由系統來建立,其餘的程序都必須由已存在的程序來建立,新建立的程序叫做子程序,而建立子程序的程序叫做父程序。那個在系統啟動及完成初始化之後,Linux自動建立的程序叫做根程序。根程序是Linux中所有程序的祖宗,其餘程序都是根程序的子孫。具有同一個父程序的程序叫做兄弟程序。

Linux程序建立的過程示意圖如下所示:

【Linux】Linux程式的建立與管理

子程序的建立

在Linux中,父程序以分裂的方式來建立子程序,建立一個子程序的系統調用叫做fork()。

系統調用fork()

為了在一個程序中分裂出子程序,Linux提供了一個系統調用fork()。這裡所說的分裂,實際上是一種複制。因為在系統中表示一個程序的實體是程序控制塊,建立新程序的主要工作就是要建立一個新控制塊,而建立一個新控制塊最簡單的方法就是複制。

當然,這裡的複制并不是完全複制,因為父程序控制塊中某些項的内容必須按照子程序的特性來修改,例如程序的辨別、狀态等。另外,子程序控制塊還必須要有表示自己父程序的域和私有空間,例如資料空間、使用者堆棧等。下面的兩張圖就表示父程序和相對應的子程序的記憶體映射:

【Linux】Linux程式的建立與管理

稍微介紹一下程序位址空間:

程序的資料區也就是未初始化的資料(.bss)、已初始化的資料(.data);程序的棧區也就是程序的使用者棧和堆;程序程式代碼就是程序的程式檔案(.text);除此之外還有程序的系統堆棧區。可見下面的這張更詳細的介紹圖:

【Linux】Linux程式的建立與管理

例子:

#include <stdio.h>

int count1=0;
int main(void)
{
    int pid;
    int count2=0;
    count1++;
    count2++;
    printf("count1=%d,count2=%d\n",count1,count2);

    pid=fork();
    count1++;
    count2++;
    printf("count1=%d,count2=%d\n",count1,count2);
    printf("pid=%d\n"pid);

    return 0;
}
           

程式運作結果為:

【Linux】Linux程式的建立與管理

由此可知:

  • 函數fork()卻是分裂出了兩個程序:因為自函數fork()之後執行了兩遍之後的代碼(先子程序一次,後父程序一次)。同時,這也證明了父程序和子程序運作的是同一個程式,也正是這個理由,系統并未在記憶體中給子程序配置獨立的程式運作空間,而隻是簡單地将程式指針指向父程序的代碼;
  • 兩個程序具有各自的資料區和使用者堆棧,在函數fork()生成子程序時,将父程序資料區和使用者堆棧的内容分别複制給了子程序。同時,接下來的内容,父程序和子程序都是對自己的資料區和堆棧中的内容進行修改運算了。

其實,在父程序中調用fork()之後會産生兩種結果:一種為分裂子程序失敗,另一種就是分裂子程序成功。如果fork()失敗,則傳回-1,;否則會出現父程序和子程序兩個程序,在子程序中fork()傳回0,在父程序中fork()傳回子程序的ID。系統調用fork()工作流程示意圖如下:

【Linux】Linux程式的建立與管理

也就是說,在fork()函數之前需要确認核心中有足夠的資源來完成。如果資源滿足要求,則核心slab配置設定器在相應的緩沖區中構造子程序的程序控制塊,并将父程序控制塊中的全部成員都複制到子程序的控制塊,然後再把子程序控制塊必須的私有資料改成子程序的資料。當fork()傳回到使用者空間之前,向子程序的核心棧中壓入傳回值0,而向父程序核心堆棧壓入子程序的pid。最後進行一次程序排程,決定是運作子程序還是父程序。

顯然,為了能夠使子程式和父程式執行不同的代碼,在fork()之後應該根據fork()的傳回值使用分支結構來組成程式代碼。例如:

#include <stdio.h>
#include <sys/types.h>

int main(void)
{
    pid_t pid;

    pid = fork();
    if(pid < 0){
        ...                //列印fork()失敗資訊
    }else if(pid == 0){
        ...                //子程序代碼
    }else{
        ...                //父程序代碼
    }

    return 0;
}
           

在代碼中獲得目前程序pid的函數為:getpid();

在代碼中獲得目前程序父程序pid的函數為:getppid()。

這裡需要注明一點:父子程序的排程的順序是由排程器決定的,與程序的建立順序無關。

關于子程序的時間片

與UCOSIII不同,Linux在大多數情況下是按時間片來進行程序排程的。當某一個程序所擁有的時間片用完之後,核心會立即剝奪它的運作權,停止它的執行。那麼當子程序被建立出來之後,這個子程序的初始時間片應該是多大呢?

Linux規定,如果父程序隻是如之前那樣簡單地建立了一個子程序,那麼系統會将父程序剩餘的時間片分成兩份,一份留給父程序,另一份作為子程序的時間片。因為之前的這種簡單方式下,父子程序使用的是同一個代碼,還沒有成為兩個真正的各自獨立的程序,是以沒有資格享用兩份時間片。

與程序相關的系統調用

函數execv()

為了在程式運作中能夠加載并運作一個可執行檔案,Linux提供了系統調用execv()。其原型為:

int execv(const char* path, char* const argv[]);
           

其中,參數path為可執行檔案路徑,argv[]為指令行參數。

如果一個程序調用了execv(),那麼該函數便會把函數參數path所指定的可執行檔案加載到程序的使用者記憶體空間,并覆寫掉原檔案,然後便運作這個新加載的可執行檔案。

在實際應用中,通常調用execv()的都是子程序。人們之是以建立一個子程序,其目的就是執行一個與父程序代碼不同的程式,而系統調用execv()就是子程序執行一個新程式的手段之一。子程序調用execv()之後,系統會立即為子程序加載可執行檔案配置設定私有程式記憶體空間,從此子程序也成為一個真正的程序。

如果說子程序是父程序的“兒子”,那麼子程序在調用execv()之前,它所具有的單獨使用者堆棧和資料區也僅相當于它的私有“房間”;但因它還沒有自己的“住房”,是以也隻能寄住在“父親”家,而不能“自立門戶”,盡管它有自己的“戶口”(程序控制塊)。

調用execv()後,父程序與子程序存儲結構的示意圖如下:

【Linux】Linux程式的建立與管理

與上文剛fork()的記憶體映像圖相比,剛調用fork()之後,父子共同使用同一塊程式代碼;而調用execv()之後,子程式擁有了自己的程式代碼區。

例子:

#include <stdio.h>
#include <sys/types.h>

int main(void)
{
    pid_t pid;

    if(!(pid=fork())){
        execv("./hello.o",NULL);
    }else {
        printf("my pif is %d\n", getpid());
    }

    return 0;
}
           

而可執行檔案./hello.o可以編寫一個.c程式再進行編譯獲得。

函數execv()其實是Linux的exec函數族成員之一,該函數族一共有5個函數和1個系統調用,分别是:

int execl(const char* path, const char* arg, ...);
int execlp(const char* file, const char* arg, ...);
int execle(const char* path, const char* arg, ..., char* const envp[]);
int execv(const char* path, const char* argv[]);
int execvp(const char* file, const char* argv[]);
int execve(const char* path, const char* argv[], char* const envp[]);
           

其中,隻有execv()是真正意義上的系統調用,其他的都是在此基礎上經過包裝的庫函數。

execv函數族的作用是,根據指定的檔案名找到可執行檔案,并将其關聯到調用exec族函數的程序,進而使程序執行該可執行檔案。簡單地說,就是用execv族函數加載的程式檔案代替該程序原來的程式檔案。

與一般的函數不同,exec族函數執行成功後一般不會傳回調用點,因為它運作了一個新的程式,程序的代碼段、資料段和堆棧等都已經被新的資料所取代,隻留下程序ID等一些表面資訊仍保持原樣,雖然還是舊的軀殼,但是其實質内容已經全部變化了。隻有調用失敗了,它們才會傳回一個-1,從原程式的調用點接着往下執行。

系統調用wait()

雖然子程序調用函數execv()之後擁有自己的記憶體空間,稱為一個真正的程序,但由于子程序畢竟由父程序所建立,是以按照計算機技術中誰建立誰負責銷毀的慣例,父程序需要在子程序結束之後釋放子程序所占用的系統資源。

為實作上述目标,當子程序運作結束後,系統會向該子程序的父程序發出一個資訊,請求父程序釋放子程序所占用的系統資源。但是,父程序并沒有準确的把握一定結束于子程序結束之後,那麼為了保證完成為子程序釋放資源的任務,父程序應該調用系統調用wait()。

如果一個程序調用了系統調用wait(),那麼程序就立即進入等待狀态(也叫阻塞狀态),一直等到系統為本程序發送一個消息。在處理父程序與子程序的關系上,那就是在等待某個子程序已經退出的資訊;如果父程序得到了這個資訊,父程序就會在處理子程序的“後事”之後才會繼續運作。

也就是說,wait()函數功能是:父程序一旦調用了wait就立即阻塞自己,由wait自動分析是否目前程序的某個子程序已經退出,如果讓它找到了這樣一個已經變成僵屍的子程序,wait就會收集這個子程序的資訊,并把它徹底銷毀後傳回;如果沒有找到這樣一個子程序,wait就會一直阻塞在這裡,直到有一個出現為止。

如果父程序先于子程序結束程序,則子程序會因為失去父程序而成為“孤兒程序”。在Linux中,如果一個程序變成了“孤兒程序”,那麼這個程序将以系統在初始化時建立的init程序為父程序。也就是說,Linux中的所有“孤兒程序”以init程序為“養父”,init程序負責将“孤兒程序”結束後的資源釋放任務。

這裡區分一下僵屍程序和孤兒程序:

  • 孤兒程序:一個父程序退出,而它的一個或多個子程序還在運作,那麼那些子程序将成為孤兒程序。孤兒程序将被init程序(程序号為1)所收養,并由init程序對它們完成狀态收集工作;
  • 僵屍程序:一個程序使用fork建立子程序,如果子程序退出,而父程序并沒有調用wait或waitpid擷取子程序的狀态資訊,那麼子程序的程序描述符仍然儲存在系統中。這種程序稱之為僵死程序(也就是程序為中止狀态,僵死狀态)。

Linix提供了一種機制可以保證隻要父程序想知道子程序結束時的狀态資訊, 就可以得到。這種機制就是: 在每個程序退出的時候,核心釋放該程序所有的資源,包括打開的檔案,占用的記憶體等。 但是仍然為其保留一定的資訊(包括程序号the process ID,退出狀态the termination status of the process,運作時間the amount of CPU time taken by the process等)。直到父程序通過wait / waitpid來取時才釋放。 但這樣就導緻了問題,如果程序不調用wait / waitpid的話, 那麼保留的那段資訊就不會釋放,其程序号就會一直被占用,但是系統所能使用的程序号是有限的,如果大量的産生僵死程序,将因為沒有可用的程序号而導緻系統不能産生新的程序. 此即為僵屍程序的危害,應當避免。

參考文章:孤兒程序與僵屍程序[總結]。

系統調用exit()

系統調用exit()用來終結一個程序,通常這個系統調用會在一些與程序控制有關的系統調用中被調用。

如果一個程序調用exit(),那麼這個程序會立即退出運作,并負責釋放被中止程序除了程序控制塊之外的各種核心資料結構。這種隻剩下“身份證”的程序叫做“僵屍程序”,其程序控制塊域state的值為TASK_ZOMBLE。

一個因調用exec函數族函數而執行新的可執行檔案的子程序,當程序執行結束時,便會執行系統調用exit()而使自己成為一個“僵屍程序”,這也正是父程序要負責銷毀子程序的程序控制塊的原因。

另外,exit()執行到最後,将調用程序排程函數schedule()進行一次程序排程。

這裡區分一下exit()和_exit()函數:

exit()定義在stdlib.h檔案,_exit()定義在unistd.h檔案中。同時兩者的步驟也是不一樣的:

【Linux】Linux程式的建立與管理

具體展現在哪裡呢?

在Linux标準庫中,有一套稱為進階I/O函數,例如我們所熟知的printf、fopen、fread、fwrite都在此列,它們也被稱為緩沖I/O。其特征是對應每一個打開的檔案,都存在一個緩沖區,在每次讀檔案時會多讀若幹條記錄,這樣下次讀檔案時就可以直接從記憶體的緩沖區去讀。在每次寫檔案時也會先寫入緩沖區,當緩沖區寫滿,或者我們fflush()手動的重新整理緩沖區,或者遇到\n、EOF這樣的結束符,才會把對應的資料從緩沖區寫入到檔案中。這樣的好處是大大增加的檔案的讀寫的速度,因為我們都知道磁盤上的讀寫速度是很慢的,但這也為我們程式設計帶來了一點麻煩,例如有些資料,我們認為已經寫入了檔案,但實際上它們很可能存在在緩沖區中。

也就是說,_exit()函數的作用是:直接使程序停止運作,清除其使用的記憶體空間,并清除其在核心的各種資料結構;exit()函數則在這些基礎上做了一些小動作,在執行退出之前還加了若幹道工序。exit()函數與_exit()函數的最大差別在于exit()函數在調用exit  系統調用前要檢查檔案的打開情況,把檔案緩沖區中的内容寫回檔案。也就是圖中的“清理I/O緩沖”。

參考文章:exit函數和_exit函數的差別。

系統調用vfork()

vfork()是Linux提供的另一個用來生成一個子程序的系統調用。

與fork()不同,vfork()并不把父程序全部複制到子程序中,而隻是用用指派指針的方法使子程序與父程序的資源實作共享。由于函數vfork()生成的子程序與父程序共享同一塊記憶體空間,是以實質上vfork()建立的是一個線程,但習慣上人們還是叫它子程序。

用vfork()建立子程序且調用execve()之後,子程序的記憶體映像如下所示:

【Linux】Linux程式的建立與管理

這裡區分一下fork()與vfork():

  • fork():子程序拷貝父程序的資料段,代碼段 。vfork():子程序與父程序共享資料段 ;
  • fork():父子程序的執行次序不确定 。vfork():保證子程序先運作,在調用execve()或exit()之前,與父程序資料是共享的,在它調用execve()或exit()之後,父程序才可能被排程運作。

注意:由于vfork()保證子程序先運作,在它調用execve()或exit()之後,父程序才可能被排程運作。如果在調用這兩個函數之前,子程序依賴于父程序的進一步動作,則會導緻死鎖。 

核心中的程序和線程

作業系統是一個先于其他程式運作的程式,那麼當系統中除了作業系統之外沒有其他的程序時怎麼辦?同時,系統中的所有使用者程序必須由父程序來建立,那麼“祖宗程序”是什麼呢?

前一個問題由Linux的程序0來解決,後一個問題由Linux的程序1來解決。其實,前者是一個核心程序,而後者先是一個核心程序,後來又變為使用者程序。

核心線程及其建立

Linux實作線程的機制非常獨特,它沒有另外定義線程的資料結構和排程算法,隻不過在建立時不為其配置設定單獨的記憶體空間。如果線程運作在使用者空間,那麼它就是使用者線程;如果運作在核心空間,那麼它就是核心線程。并且,無論是線程還是程序,Linux都用同一個排程器對它們進行排程。

但是Linux卻單獨提供了一個用于建立核心線程的函數kernel_thread()。該函數的原型如下:

int kernel_thread(int (* fn)(void *), void* arg, unsigned long flags);
           

其中,fn指向線程代碼;arg為線程代碼所需的入口參數。

核心線程周期性執行,通常用來完成一些需要對核心幕後完成的任務,例如磁盤高速緩存的重新整理、網絡連接配接的維護和頁面的交換等,是以它們也叫做核心任務。

程序0

計算機啟動之後,首先進行Linux的引導和加載,在Linux核心加載之後,初始化函數start_kernel()會立即建立一個核心線程。因為Linux對線程和程序沒有太嚴格的區分,是以就把這個由初始化函數建立的線程叫做程序0。

程序0的執行代碼時核心函數cpu_idel(),該函數中隻有一條hlt(暫停)指令;也就是說,這個程序什麼工作也沒做,是以也叫做空閑程序。該空閑程序的PCB叫做init_task(),當系統沒有可運作的其它程序時,排程器才會選擇程序0來運作。

程序1

程序1也叫做init程序,它是核心初始化時建立的第2個核心線程,其運作代碼為核心函數init()。

該函數首先建立kswapd()等4個與記憶體管理有關的核心線程。接下來,在核心的init()中調用execve()系統調用裝入另一個需要在使用者空間運作的init函數代碼,于是程序1就變成一個普通的程序。它是使用者空間的第一個程序,是以它就成了其他使用者程序的根程序。

隻要系統,init程序就永不中止,它負責建立和監控作業系統外層所有程序的活動。

守護程序

守護程序是不受終端控制并在背景運作的程序。Linux使用了很多守護程序,在背景做一些經常性的注入定期進行頁交換之類的例行工作。

守護程序一般可以通過以下方式啟動:

  • 在系統啟動時由啟動腳本啟動,這些啟動腳本通常放在/etc/rc.d目錄下;
  • 利用inetd超級伺服器啟動,大部分網絡服務都是這樣啟動的,如ftp、telnet等;
  • 由cron定時啟動以及在終端用nohup啟動的程序也是守護程序。
【Linux】Linux程式的建立與管理

繼續閱讀