進程與fork()、wait()、exec函數組
內容簡介:本文將引入進程的基本概念;着重學習exec函數組、fork()、wait()的用法;最后,我們將基於以上知識編寫Linux shell作為練習。
————————CONTENTS————————
進程與程序
Unix是如何運行程序的呢?這看起來很容易:首先登錄,然后shell打印提示符,輸入命令並按回車鍵,程序就開始運行了。當程序結束后,shell會打印一個新的提示符。但是,這些是如何實現的呢?shell在這段時間里做了什么呢?
首先,我們來引入“進程”的概念。
一、進程
進程(Process)是計算機中的程序關於某數據集合上的一次運行活動,是系統進行資源分配和調度的基本單位,是操作系統結構的基礎。
即使在系統中通常有許多其他的程序在運行,但進程也可以向每個程序提供一種假象,仿佛它在獨占地使用處理器。但事實上進程是輪流使用處理器的。我們假設一個運行着三個進程的系統,如下圖所示:
三個進程的執行是交錯的。進程A運行一段時間后,B開始運行直到完成。然后進程C運行了一會兒,進程A接着運行直到完成。最后,進程C也運行結束了。
通過ps
命令與一些參數的組合,可以查看當前狀態下的所有進程:
二、上下文切換
內核為每個進程維持一個上下文(context)。上下文就是內核重新啟動一個被強占的進程所需的狀態。
當內核代表用戶執行系統調用時,可能會發生上下文切換。如果系統調用因為等待某個事件而發生阻塞,那么內核可以讓當前進程休眠,切換到另一個進程。
下圖展示了一對進程A和B之間上下文切換的實例:
在這個例子中,進程A初始運行在用戶模式中,直到它通過執行系統調用陷入到內核,在內核模式下執行指令。然后在某一時刻,它開始代表進程B(仍然是內核模式下)執行指令。在切換之后,內核代表進程B在用戶模式下執行指令。隨后,進程B在用戶模式下執行了一會兒,內核判定進程B已經運行了足夠長的時間,就執行一個從進程B到進程A的上下文切換,將控制返回給進程A中緊隨在剛剛系統調用之后的那條指令。進程A繼續運行,直到下一次異常發生。
exec函數組
那么問題來了:一個程序如何運行另一個程序呢?
首先我們得搞清楚需要調用什么函數來完成這個過程。如果想使用man -k xxx
這個命令進行搜索,必須知道相應的關鍵字。思考一下,我們想到了process(進程)、execute(執行)、program(程序)等等
我們可以嘗試man -k program | grep execute | grep process
命令,但發現沒有搜到任何相關的內容。擴大搜索范圍,我們再試試man -k program | grep execute
,這下找到了不少內容:
“execve(2) -execute program”這個解釋似乎是我們想要的,再進一步使用man -k execute
搜索,通過觀察說明,我們找到了一系列相關的函數:
這些函數均以“exec”開頭,exec是一組函數的總稱,我們可以通過man -k exec
來尋找相關信息:
通過描述,我們大概找到了符合要求的幾個函數。
查閱資料了解到,exec系列函數共有7個函數可供使用,這些函數的區別在於:指示新程序的位置是使用路徑還是文件名,如果是使用文件名,則在系統的PATH環境變量所描述的路徑中搜索該程序;在使用參數時使用參數列表的方式還是使用argv[]數組的方式。
如果想了解關於exec函數組的詳細信息,可以通過man 3 exec
查看:
函數組可簡要表示為:
#include <unistd.h>
int execl(const char *pathname, const char *arg0, ... /* (char *)0 */ );
int execv(const char *pathname, char *const argv[]);
int execle(const char *pathname, const char *arg0, .../* (char *)0, char *const envp[] */ );
int execve(const char *pathename, char *const argv[], char *const envp[]);
int execlp(const char *filename, const char *arg0, ... /* (char *)0 */ );
int execvp(const char *filename, char *const argv[]);
int fexecve(int fd, char *const argv[], char *const envp[]);
//返回:如果執行成功將不返回,否則返回-1,失敗代碼存儲在errno中。
//前4個函數取路徑名作為參數,后兩個是取文件名作為參數,最后一個是以一個文件描述符作為參數。
可以見到這些函數名字不同, 而且他們用於接受的參數也不同。
實際上他們的功能都是差不多的, 因為要用於接受不同的參數所以要用不同的名字區分它們(類似於Java中的函數重載)。
但是實際上它們的命名是有規律的:
exec[l or v][p][e]
exec函數里的參數可以分成3個部分:執行文件部分,命令參數部分,和環境變量部分。
假如要執行:ls -l /etc
- 執行文件部分就是:"/usr/bin/ls"
- 命令參數部分就是:"ls","-l","/etc",NULL
- 環境變量部分:這是1個數組,最后的元素必須是NULL 例如:char * env[] = {"PATH=/etc", "USER=vivian", "STATUS=testing", NULL};
命名規則如下:
-
e:參數必須帶環境變量部分,環境變量部分參數會成為執行exec函數期間的環境變量;
-
l:命令參數部分必須以"," 相隔, 最后1個命令參數必須是NULL;
-
v:命令參數部分必須是1個以NULL結尾的字符串指針數組的頭部指針。例如char * pstr就是1個字符串的指針, char * pstr[] 就是數組了, 分別指向各個字符串;
-
p:執行文件部分可以不帶路徑, exec函數會在$PATH中找。
下面我們將以ls -l
為例,詳細介紹這幾個函數:
1、execl()
int execl(const char *pathname, const char *arg0, ... /* (char *)0 *\);
- execl()函數用來執行參數path字符串所指向的程序,第二個及以后的參數代表執行文件時傳遞的參數列表,最后一個參數必須是空指針以標志參數列表為空.
程序如下:
#include <unistd.h>
int main()
{
execl("/bin/ls","ls","-l","/etc",(char *)0);
return 0;
}
運行結果如下:
2、execv()
int execv(const char *path, char *const argv[]);
- execv()函數函數用來執行參數path字符串所指向的程序,第二個為數組指針維護的程序參數列表,該數組的最后一個成員必須是空指針。
程序如下:
#include <unistd.h>
int main()
{
char *argv[] = {"ls", "-l", "/etc"/*,(char *)0*/};
execv("/bin/ls", argv);
return 0;
}
運行結果如下:
3、execle()
int execle(const char *pathname, const char *arg0, .../* (char *)0, char *const envp[] */ );
- execle()函數用來執行參數path字符串所指向的程序,第二個及以后的參數代表執行文件時傳遞的參數列表,最后一個參數必須指向一個新的環境變量數組,即新執行程序的環境變量。
程序如下:
#include <unistd.h>
int main(int argc, char *argv[], char *env[])
{
execle("/bin/ls","ls","-l","/etc",(char *)0,env);
return 0;
}
運行結果如下:
4、execlp()
int execlp(const char *filename, const char *arg0, ... /* (char *)0 */ );
- execlp()函數會從PATH環境變量所指的目錄中查找文件名為第一個參數指示的字符串,找到后執行該文件,第二個及以后的參數代表執行文件時傳遞的參數列表,最后一個參數必須是空指針.
程序如下:
#include <unistd.h>
int main()
{
execlp("ls", "ls", "-l", "/etc", (char *)0);
return 0;
}
運行結果:
5、execvp()
int execvp(const char *file, char *const argv[]);
- execvp()函數會從PATH環境變量所指的目錄中查找文件名為第一個參數指示的字符串,找到后執行該文件,第二個及以后的參數代表執行文件時傳遞的參數列表,最后一個成員必須是空指針。
程序如下:
#include <unistd.h>
int main()
{
char *argv[] = {"ls", "-l", "/etc", /*(char *)0*/};
execvp("ls", argv);
return 0;
}
運行結果如下:
6、argv[0]的值對程序運行的影響
以上我們以ls -l
示范了exec函數組的使用。如何實現對其他命令的調用呢?很簡單,我們只需要修改argv[0]的值。比如:
#include <unistd.h>
int main()
{
char *argv[] = {"who",(char *)0};
execvp("who", argv);
return 0;
}
運行結果為:
7、總結
我們再來看這樣一個使用到“execvp()”函數的程序:
#include <unistd.h>
int main()
{
char *argv[] = {"ls", "-l", ".", (char *)0};
printf("*** Begin to Show ls -l\n");
execvp("ls", argv);
printf("ls -l is done! ***");
return 0;
}
運行程序:
竟然只有第一行printf的輸出!!execvp后面的那一條printf打印的消息哪里去了???
原因在於:一個程序在一個程序中運行時,內核將新程序載入到當前進程,替代當前進程的代碼和數據。如果執行成功,execvp沒有返回值。當前程序從進程中清除,新的程序在當前進程中運行。
這使我們聯想到“庄周夢蝶”的故事。庄子在夢中化作了蝴蝶,雖然身體是蝴蝶的身體,但思想已換做庄子的思想,蝴蝶的思想已被完全覆蓋了。類比execv函數組,系統調用從當前進程中把當前程序的機器指令清除,然后在空的進程中載入調用時指定的程序代碼,最后運行這個新的程序。exec調整進程的內存分配使之適應新的程序對內存的要求。相同的進程,不同的內容。
fork()
那么問題來了:如果execvp用命令指定的程序代碼覆蓋了shell的程序代碼,然后在命令指定的程序結束之后退出。這樣shell就不能再次接受新的命令。那shell如何能做到運行程序的同時還能等待下一個命令呢?
我們設想,如果能創建一個完全相同的新進程就好了,這樣就可以在新進程里執行命令程序,且不影響原進程了。
尋找關鍵詞:process(進程)、create(創建)、new(新的)......
使用man -k xxx | grep xxx
命令,我們最終找到了這樣一個函數:
(注:Unix標准的復制進程的系統調用時fork(即分叉),但是Linux,BSD等操作系統並不止實現這一個,確切的說linux實現了三個:fork,vfork,clone。在這里我們重點講解fork的使用。)
如何知道更多關於fork函數的細節?參考婁老師的別出心裁的Linux系統調用學習法這篇博客,我們可以通過man -k fork
命令進行搜索,可以看到,fork函數位於manpages的第二節,與系統調用有關。
使用man 2 fork
命令查看fork函數,可以看到關於fork函數的所有信息:
大致將fork()可以總結為:
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);
//返回:子進程返回0,父進程返回子進程的PID,如果出錯,則返回-1。
一般來說,運行一個C程序直到該程序全部結束,系統只會分配一個PID給這個程序,也就是說,系統里只有一條關於這個程序的進程。但執行了fork函數就不同了。fork()的作用是復制當前進程(包括進程在內存的堆棧數據),然后這個新的進程和舊的進程一起執行下去。而且這兩個進程是互不影響的。
例如:調用一次fork()之后的進程如下:
以下面這個程序為例:
int main(){
printf("it's the main process step 1!!\n\n");
fork();//創建一個新的進程
printf("step2 after fork() !!\n\n");
int i; scanf("%d",&i);//防止程序退出
return 0;
}
運行結果為:
根據上面調用fork()的示意圖不難理解,程序在fork()函數之前只有一條主進程,所以只打印一次step 1;而執行fork()函數之后,程序分為了兩個進程,一個是原來的主進程,另一個是fork()的新進程,他們都會執行fork()函數之后的代碼,所以step 2打印了兩次。
此時使用ps -ef | grep fork4
命令查看系統的進程,可以發現兩條名字相同的進程:
可以看到,4732那個為父進程,4733為子進程(因為由圖可知4733的父進程為4732)。
wait()
考慮下面這個程序:
void fork2()
{
printf("L0 ");
fork();
printf("L1 ");
fork();
printf("Bye ");
}
程序執行情況的示意圖為:
進程圖可以幫助我們看清這個程序運行了四個進程,每個都調用了一次printf("Bye ")
,這些printf可以以任意順序執行。“L0 L1 Bye Bye L1 Bye Bye ”為一種可能的輸出,而“L0 Bye L1 Bye L1 Bye Bye ”這種情況就不可能出現。
通過分析上面的進程圖,我們可以發現:一旦子進程建立,父進程與子進程的執行順序並不固定。這種不確定性有時並不是我們想要的。那么,如何調用一個函數,使得父進程等待子進程結束后,再繼續執行呢?
關鍵詞:wait(等待)、process(進程)......
使用man -k xxx | grep xxx
命令,按照關鍵詞進行搜索:
我們了解到,一個進程可以通過調用wait函數來等待它的子進程終止或者停止。
同樣地,我們使用man -k wait
查看與“wait”相關的信息,從它們的功能說明可以看到,最后幾個函數似乎是我們想要的。
再使用man 2 wait
命令查看詳細信息:
wait()的使用方法為:
#include <sys/types.h>
#include <unistd.h>
pid_t wait(int *status);
//返回:如果成功,則返回子進程的PID,如果出錯,則返回-1。
函數功能是:父進程一旦調用了wait就立即阻塞自己,由wait自動分析是否當前進程的某個子進程已經退出,如果讓它找到了這樣一個已經變成僵屍的子進程,wait就會收集這個子進程的信息,並把它徹底銷毀后返回;如果沒有找到這樣一個子進程,wait就會一直阻塞在這里,直到有一個出現為止。
需要注意的幾點是:
-
當父進程忘了用wait()函數等待已終止的子進程時,子進程就會進入一種無父進程的狀態,此時子進程就是僵屍進程。
-
wait()要與fork()配套出現,如果在使用fork()之前調用wait(),wait()的返回值則為-1,正常情況下wait()的返回值為子進程的PID。
-
如果先終止父進程,子進程將繼續正常進行,只是它將由init進程(PID 1)繼承,當子進程終止時,init進程捕獲這個狀態。
那么,傳給函數wait()的參數status是什么呢?
參數status用來保存被收集進程退出時的一些狀態,它是一個指向int類型的指針。但如果我們對這個子進程是如何死掉毫不在意,只想把這個僵屍進程消滅掉,(事實上絕大多數情況下,我們都會這樣想),我們就可以設定這個參數為NULL,就像下面這樣:
pid = wait(NULL);
如果成功,wait會返回被收集的子進程的進程ID,如果調用進程沒有子進程,調用就會失敗,此時wait返回-1,同時errno被置為ECHILD。
如果參數status的值不是NULL,wait就會把子進程退出時的狀態取出並存入其中, 這是一個整數值(int),指出了子進程是正常退出還是被非正常結束的,以及正常結束時的返回值,或被哪一個信號結束的等信息。由於這些信息 被存放在一個整數的不同二進制位中,所以用常規的方法讀取會非常麻煩,人們就設計了一套專門的宏(macro)來完成這項工作,以下是其中最常用的兩個:
-
1.WIFEXITED(status) 這個宏用來指出子進程是否為正常退出的,如果是,它會返回一個非零值。
-
2.WEXITSTATUS(status) 當WIFEXITED返回非零值時,我們可以用這個宏來提取子進程的返回值,如果子進程調用exit(5)退出,WEXITSTATUS(status) 就會返回5;如果子進程調用exit(7),WEXITSTATUS(status)就會返回7。請注意,如果進程不是正常退出的,也就是說, WIFEXITED返回0,這個值就毫無意義。
如果想知道status參數的所有宏,可以先通過grep -nr "wait" /usr/include
命令查看與wait相關的頭文件的位置:
從結果我們可以得出結論,wait.h的所在位置為:/usr/include/x86_64-linux-gnu/sys/wait.h
。接下來只需要執行cat /usr/include/x86_64-linux-gnu/sys/wait.h
命令,即可查看到其中包含的所有信息:
下面通過一個實例進一步學習wait()的用法:
void fork9() {
int child_status;
if (fork() == 0) {
printf("HC: hello from child\n");
exit(0);
} else {
printf("HP: hello from parent\n");
wait(&child_status);
printf("CT: child has terminated\n");
}
printf("Bye\n");
}
此進程的示意圖可表示為:
由於父進程必須等待子進程執行完畢后,才能打印“CT”,所以“HC\nHP\nCT\nBye”為一種可能的輸出,而“HP\nCT\nBye\nHC”這種情況就不可能出現。
返回目錄
編程練習:myshell
一、思路分析
在上面的學習中,我們知道了如何在應用程序中創建和操作進程,以及如何通過Linux系統調用來使用多個進程。事實上,像Unix shell和Web服務器這樣的程序大量使用了fork()和execve()函數,現在我們通過調用以上學習的函數,自己寫一個類似於shell的程序。
一個shell的主循環執行下面的4步:
- 用戶鍵入a.out;
- shell建立一個新的進程來運行這個程序;
- shell將程序從磁盤載入;
- 程序在它的進程中運行直到結束。
二、偽代碼
shell由下面的循環組成:
while(!end_of_input)
get command
execute command
wait for command to finish
以時間為參考,shell的主循環可以由下圖來表示:
shell讀入一個新的一行輸入,建立一個新進程,在這個程序中運行程序並等待這個進程結束。當shell檢測到輸入結束時,它就退出。
因此,要寫一個shell,需要學會:
- 運行一個程序——exec函數組;
- 建立一個進程——fork()函數;
- 等待進程結束——wait()函數。
學習了以上內容,我們就可以實現自己的shell了。
三、產品代碼
有了以上的分析之后,我們可以根據偽代碼寫出詳細的代碼,以下程序可作為參考:
#include <stdio.h>
#include <unistd.h>
#include <wait.h>
#include <stdlib.h>
#include <string.h>
#define MAX 128
void eval (char *cmdline); //對用戶輸入的命令進行解析
int parseline (char *buf, char **argv);
int builtin_command(char **argv);
int main()
{
char cmdline[MAX];
while(1){
printf("vivian@vivian-VirtualBox:~/20155303/week5/myshell$ ");
fgets(cmdline,MAX,stdin);
if(feof(stdin))
{
printf("error");
exit(0);
}
eval(cmdline);
}
}
void eval(char *cmdline)
{
char *argv[MAX];
char buf[MAX];
int bg;
pid_t pid;
strcpy(buf,cmdline);
bg = parseline(buf,argv);
if(argv[0]==NULL)
return;
if(!builtin_command(argv))
{
if((pid=fork()) == 0)
{
if(execvp(argv[0],argv) < 0) {
printf("%s : Command not found.\n",argv[0]);
exit(0);
}
}
if(!bg){
int status;
if(waitpid(-1,&status,0) < 0)
printf("waitfg: waitpid error!");
}
else
printf("%d %s",pid, cmdline);
return;
}
}
int builtin_command(char **argv)
{
if(!strcmp(argv[0], "quit"))
exit(0);
if(!strcmp(argv[0],"&"))
return 1;
return 0;
}
int parseline(char *buf,char **argv)
{
char *delim;
int argc;
int bg;
buf[strlen(buf)-1]=' ';
while(*buf && (*buf == ' '))
buf++;
argc=0;
while( (delim = strchr(buf,' '))){
argv[argc++] = buf;
*delim= '\0';
buf = delim + 1;
while(*buf && (*buf == ' '))
buf++;
}
argv[argc] = NULL;
if(argc == 0)
return 1;
if((bg=(*argv[argc-1] == '&')) != 0)
argv[--argc] = NULL;
return bg;
}
運行結果如下: