程式建立
在上一節講解程式概念時,我們提到fork函式是從已經存在的程式中建立一個新程式。那麼,系統是如何建立一個新程式的呢?這就需要我們更深入的剖析fork函式。
1.1 fork函式的返回值
呼叫fork建立程式時,原程式為父程式,新程式為子程式。執行man fork
後,我們可以看到如下資訊:
#include <unistd.h>
pid_t fork(void);
fork函式有兩個返回值,子程式中返回0,父程式返回子程式pid,如果建立失敗則返回-1。
實際上,當我們呼叫fork後,系統核心將會做:
- 分配新的記憶體塊和核心資料結構(如task_struct)給子程式
- 將父程式的部分資料結構內容拷貝至子程式
- 新增子程式到系統程式列表中
- fork返回,開始排程
1.2 寫時拷貝
在建立程式的過程中,預設情況下,父子程式共享程式碼,但是資料是各自私有一份的。如果父子只需要對資料進行讀取,那麼大多數的資料是不需要私有的。這裡有三點需要注意:
第一,為什麼子程式也會從fork之後開始執行?
因為父子程式是共享程式碼的,在給子程式建立PCB時,子程式PCB中的大多數資料是父程式的拷貝,這裡面就包括了程式計數器(PC)。由於PC中的資料是即將執行的下一條指令的地址,所以當fork返回之後,子程式會和父程式一樣,都執行fork之後的程式碼。
第二,建立程式時,子程式需要拷貝父程式所有的資料嗎?
父程式的資料有很多,但並不是所有的資料都要立馬使用,因此並不是所有的資料都進行拷貝。一般情況下,只有當父程式或者子程式對某些資料進行寫操作時,作業系統才會從記憶體中申請記憶體塊,將新的資料拷寫入申請的記憶體塊中,並且更改頁表對應的頁表項,這就是寫時拷貝。原理如下圖所示:
第三,為什麼資料要各自私有?
這是因為程式具有獨立性,每個程式的執行不能干擾彼此。
1.3 fork函式的用法及其呼叫失敗的原因
fork函式的用法:
- 一個父程式希望複製自己,通過條件判斷,使父子程式分流同時執行不同的程式碼段。例如,父程式等待客戶端請求,生成子 程式來處理請求。
- 如子程式從fork返回後,呼叫程式替換的函式,如exec等(將會在本節4.程式替換中講解)。
fork函式呼叫失敗的原因:
- 系統中程式太多
- 實際使用者的程式數超過了限制
2.程式終止
2.1 程式終止的原因
程式終止的原因有三種
- 程式碼執行完畢,結果正確
- 程式碼執行完畢,結果不正確
- 程式碼異常終止
2.2 常見的程式退出方法
程式正常終止
1.從main函式return,這是最常見的程式退出方法。在函式設計中,0代表正確,非0代表錯誤。其中不同的非0的退出碼對應了退出原因。
2.呼叫exit或者_exit
_exit函式是系統呼叫,執行man _exit
可以看到
#include <unistd.h>
void _exit(int status);
status 定義了程式的終止狀態。父程式可以通過wait來獲得子程式的status(會在3.程式等待中講解)。
需要注意的是,
exit函式是庫函式,雖然status是int,但是僅有低8位可以被父程式所用。所以_exit(-1)時,在終端執行echo $?發現返回值 是255。
#include <stdlib.h>
void exit(int status);
從作用上來看,_exit和exit是相似的,exit是對_exit的封裝,exit的執行實際上是通過呼叫_exit來實現的。
但是二者也有一些細微的差別,請看如下程式碼段:
程式碼1
int main()
{
printf("Hello world");
exit(0);
}
程式碼2
#include<stdio.h>
#include<unistd.h>
int main()
{
printf("Hello world");
_exit(0); }
相比於_exit函式,exit函式先要執行使用者定義的清理函式,在沖刷緩衝區,關閉所有開啟的流,將所有的快取資料寫入檔案後,再呼叫_exit。因此我們可以看到,執行exit輸出了“hello World",而執行_exit並沒有輸出。
那麼,return和exit有什麼區別呢?
在普通函式中,return是用來終止函式的,只有在main函式中才是終止程式,而exit無論在哪裡,一旦呼叫,整個程式就會終止。
3.程式等待
3.1 為什麼要有程式等待?
在講程式概念時我們提到,當子程式退出,父程式如果不管不顧,子程式殘留資源(PCB)存放於核心中,就可能會造成殭屍程式。如果該資源不能得到釋放,就會導致記憶體洩漏。殭屍程式是不能使用 kill -9 命令清除掉的。因為 kill 命令只是用來終止程式的, 而殭屍程式已經終止。
同時,父程式派給子程式的任務完成的如何,我們是需要知道的。例如,子程式執行完成,結果對還是不對, 或者是否正常退出。
因此,就需要父程式通過程式等待的方式,回收子程式的資源。
3.2 程式等待的方法
一個程式在終止時會關閉所有檔案,釋放在使用者空間分配的記憶體,但它的 PCB 還保留著,核心在其中儲存了一些資訊:如果是正常終止則儲存著退出狀態,如果是異常終止則儲存著導致該程式終止的訊號是哪個。當這個程式的父程式呼叫 wait 或 waitpid 獲取這些資訊後,才會將這個程式徹底清除掉。
一個程式的退出狀態可以在 Shell 中通過執行echo $?
檢視,因為 Shell 是它的父程式,當它終止時 Shell 呼叫 wait 或 waitpid 得到它的退出 狀態同時徹底清除掉這個程式。
3.2.1 wait函式
#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int*status);
- 返回值:成功返回被等待程式pid,失敗返回-1。
- status:是一個輸出型引數,將wait函式內部計算的結果通過status返回給呼叫者,父程式從而獲取子程式退出狀態,如果不關心子程式的退出狀態則可以將引數設定成為NULL。
這裡提一下輸入型引數和輸出型引數的區別,輸入型引數是呼叫者給函式傳的引數,而輸出型引數是是函式將內部計算結果返回給呼叫者,因此輸出型引數往往用指標。
父程式呼叫 wait 函式可以回收子程式終止資訊。該函式有三個功能:
- 阻塞等待子程式退出
- 回收子程式殘留資源
- 獲取子程式結束狀態(退出原因)。
當父程式呼叫wait得到傳出引數status後,可以藉助巨集函式來進一步判斷程式終止的具體原因:
WIFEXITED(status): 若為正常終止子程式返回的狀態,則為真。(檢視程式是否是正常退出)
WEXITSTATUS(status): 若WIFEXITED非零,說明子程式正常終止,提取子程式退出碼。(檢視程式的退出碼(exit 的引數))
3.2.2 waitpid函式
作用同 wait,但waitpid可指定 pid 程式清理,可以通過非阻塞方式等待子程式退出。
pid_ t waitpid(pid_t pid, int *status, int options);
pid:
- pid = -1,等待任一子程式退出,此時與wait等效
- pid > 0, 回收指定 ID 的子程式,pid為指定程式的程式號。如果不存在該子程式,則立即出錯返回
status:
- 同wait
option:
- 0:阻塞模式,即父程式會阻塞在waitpid處,等到子程式退出後繼續。
- WNOHANG: 非阻塞模式,若pid指定的子程式沒有結束,則waitpid函式返回0,不予以等待。若正常結束,則返回該子程式的ID。一般情況下,非阻塞模式需要搭配迴圈使用。
注意:一次 wait 或 waitpid 呼叫只能清理一個子程式,清理多個子程式應使用迴圈。
返回值:
- 當正常返回的時候waitpid返回收集到的子程式的程式ID;
- 如果設定了選項WNOHANG,而呼叫中waitpid發現沒有已退出的子程式可收集,則返回0;
- 如果呼叫中出錯,則返回-1,這時errno會被設定成相應的值以指示錯誤所在
3.3.3 子程式的status
關於status的用法,我已經在wait函式處講解,此處不再贅述。這裡將從底層的角度剖析status的含義。
status不能簡單的當作整形來看待,可以當作點陣圖來看待,具體細節如下圖(只研究status低16位元位)。
我們以下一段程式碼為例,來展示一下非阻塞等待方式
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{
pid_t pid;
pid = fork();
if(pid < 0){
printf("%s fork error\n",__FUNCTION__);
return 1;
}else if( pid == 0 ){ //child
printf("child is run, pid is : %d\n",getpid());
sleep(5);
exit(1);
} else{
int status = 0;
pid_t ret = 0;
do
{
ret = waitpid(-1, &status, WNOHANG);//非阻塞式等待
if( ret == 0 ){
printf("child is running\n");
}
sleep(1);
}while(ret == 0);
if( WIFEXITED(status) && ret == pid ){
printf("wait child 5s success, child return codeis:%d.\n",WEXITSTATUS(status));
}else{
printf("wait child failed, return.\n");
return 1;
}
}
return 0;
}
這段程式碼先建立子程式,讓子程式等待5s再退出,父程式每1s檢查一下,5s後子程式退出,ret將變成子程式的程式號,退出迴圈等待。最終的執行結果如下:
4.程式替換
4.1程式替換的原理
在講程式替換原理前,我們需要先知道什麼是程式替換。在講fork函式時我們提到,fork 建立子程式後執行的是和父程式相同的程式(但有可能執行不同的程式碼分支),如果此時我們用一個新的程式替換掉子程式的地址空間、程式碼段和資料,子程式將會從新程式的啟動例程開始執行,這就是程式替換。
程式替換並不是建立新的程式,因為替換前後該程式的PID並未改變。
4.2 環境變數
程式替換需要用到一種exec函式,在講exec函式族之前,我們先介紹一下環境變數的概念。
4.2.1常見的環境變數
按照慣例,環境變數字串都是name=value 這樣的形式,大多數 name 由大寫字母加下劃線組成,一般把name 的部分叫做環境變數,value 的部分則是環境變數的值。
環境變數定義了程式的執行環境,具有全域性屬性,因此設定環境變數時要加export,一些比較重要的環境變數的含義如下:
PATH
可執行檔案的搜尋路徑。ls 命令也是一個程式,執行它不需要提供完整的路徑名/bin/ls, 然而通常我們執行當前目錄下的程式 a.out 卻需要提供完整的路徑名./a.out,這是因為 PATH 環境變數的值裡面包含了 ls 命令所在的目錄/bin,卻不包含 a.out 所在的目錄。
PATH 環境變數的值可以包含多個目錄,用:號隔開。在 Shell 中用 echo 命令可以檢視這個環境變數的值: echo $PATH
SHELL
當前 Shell,它的值通常是/bin/bash。
TERM
當前終端型別
HOME
當前使用者主目錄的路徑,很多程式需要在主目錄下儲存配置檔案,使得每個使用者在執行該程式時都有自己的一套配置。
4.2.2與環境變數相關的函式
getenv函式
獲取環境變數值: char *getenv(const char *name)
;
成功:返回環境變數的值;失敗:NULL (name 不存在)
setenv 函式
設定環境變數的值 :int setenv(const char *name, const char *value, int overwrite)
;
成功:返回0;失敗: 返回-1
引數 overwrite 取值:
1:覆蓋原環境變數
0:不覆蓋。(該引數常用於設定新環境變數,如:HELLO = “hello”)
unsetenv 函式
刪除環境變數 name 的定義: int unsetenv(const char *name)
;
成功:0;失敗:-1
注意事項:name 不存在仍返回 0(成功)。
4.2.3 環境變數的組織形式
environ 變數是一個char * 型別,儲存著系統的環境變數。*每個程式都會收到一張環境表,環境表是一個字元指標陣列,每個指標指向一個以’\0’結尾的環境字串。
4.3 exec函式族
4.3.1 exec函式族的使用
知道了環境變數的概念後,再簡要介紹一下命令列引數。當我們在某個目錄下輸入ls -a
和ls -l
時,會有如下顯示:
我們發現,同樣的ls命令,由於後面所跟的字串不同,顯示了不同的結果。這裡的“-a”,“-l”被稱為引數。實際上,一個程式內可以通過加入引數,讓相同的程式執行不同的功能。
接下來我們來介紹程式替換必不可少的函式族——exec函式族。
其實有六種以 exec 開頭的函式,統稱 exec 函式:
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, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);
注意:這些函式如果呼叫成功則載入新的程式從啟動程式碼開始執行,不再返回。 如果呼叫出錯則返回-1 所以exec函式只有出錯的返回值而沒有成功的返回值!
這些函式如何使用,我們來看下面這段程式碼:
#include <unistd.h>
int main()
{
char *const argv[] = {"ps", "-ef", NULL};//argv[0]始終是程式名
char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};
//execl("/bin/ps", "ps", "-ef", NULL);
// 帶p的,可以使用環境變數PATH,無需寫全路徑
//execlp("ps", "ps", "-ef", NULL);
// 帶e的,需要自己組裝環境變數
//execle("ps", "ps", "-ef", NULL, envp);
//execv("/bin/ps", argv);
// 帶p的,可以使用環境變數PATH,無需寫全路徑
//execvp("ps", argv);
// 帶e的,需要自己組裝環境變數
execve("/bin/ps", argv, envp);
exit(0);
}
事實上,只有execve是真正的系統呼叫,其它五個函式最終都呼叫 execve。
這些函式原型看起來很容易混,但只要掌握了規律就很好記。
- l(list) : 表示引數採用列表,如果採用列表形式,const char *arg中的第一個引數必須是可執行程式本身,如上例中的 “ps”。
- v(vector) : 引數用陣列 ,v和l只能二選一
- e(env) : 表示自己維護環境變數,有e引數中就需要有char *const envp[]
- p(path) : 有p自動搜尋環境變數PATH,第一個引數直接輸入程式名即可,且有p一定沒有e,因為有表示已經自動新增了環境變數,如果沒有p則需要輸入對應程式的路徑
4.3.2 程式替換的應用
我們平時使用的shell讀取命令和分析命令就是一個很典型的例子,如下圖所示:
我們平時輸入的如ls -a
等命令實際上是一個個可執行程式。當shell讀取一行命令時,shell會對命令進行解析,並且shell建立一個子程式,再通過呼叫execve,用可執行程式替換掉子程式,當程式執行完畢並且退出後,shell讀取子程式的退出資訊。這樣,即便會出現程式崩潰的情況,也不會影響到shell本身。
以上就是關於程式控制的內容,主要分為四個方面——程式建立,程式終止,程式等待以及程式替換。有了以上的知識,我們已經可以實現一個很簡易的shell,如何實現,請讀者自行思考!