進程與fork()、wait()、exec函數組


進程與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;
}

運行結果如下:

返回目錄

參考資料


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM