Linux系統程式設計之程式控制(程式建立、終止、等待及替換)

烏有先生ii發表於2021-11-06

程式建立

在上一節講解程式概念時,我們提到fork函式是從已經存在的程式中建立一個新程式。那麼,系統是如何建立一個新程式的呢?這就需要我們更深入的剖析fork函式。

1.1 fork函式的返回值

呼叫fork建立程式時,原程式為父程式,新程式為子程式。執行man fork後,我們可以看到如下資訊:

#include <unistd.h>
pid_t fork(void);

fork函式有兩個返回值,子程式中返回0,父程式返回子程式pid,如果建立失敗則返回-1。

實際上,當我們呼叫fork後,系統核心將會做:

  • 分配新的記憶體塊和核心資料結構(如task_struct)給子程式
  • 將父程式的部分資料結構內容拷貝至子程式
  • 新增子程式到系統程式列表中
  • fork返回,開始排程

image-20210815112339172

1.2 寫時拷貝

在建立程式的過程中,預設情況下,父子程式共享程式碼,但是資料是各自私有一份的。如果父子只需要對資料進行讀取,那麼大多數的資料是不需要私有的。這裡有三點需要注意:

第一,為什麼子程式也會從fork之後開始執行?

因為父子程式是共享程式碼的,在給子程式建立PCB時,子程式PCB中的大多數資料是父程式的拷貝,這裡面就包括了程式計數器(PC)。由於PC中的資料是即將執行的下一條指令的地址,所以當fork返回之後,子程式會和父程式一樣,都執行fork之後的程式碼。

第二,建立程式時,子程式需要拷貝父程式所有的資料嗎?

父程式的資料有很多,但並不是所有的資料都要立馬使用,因此並不是所有的資料都進行拷貝。一般情況下,只有當父程式或者子程式對某些資料進行寫操作時,作業系統才會從記憶體中申請記憶體塊,將新的資料拷寫入申請的記憶體塊中,並且更改頁表對應的頁表項,這就是寫時拷貝。原理如下圖所示:

image-20210815120742835

第三,為什麼資料要各自私有?

這是因為程式具有獨立性,每個程式的執行不能干擾彼此。

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);
}

image-20210815172538765

程式碼2

 #include<stdio.h>  
 #include<unistd.h>  
 int main()  
 {  
   printf("Hello world");  
    _exit(0);                                                  }  

image-20210815172816988

相比於_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位元位)。

image-20210815211524186

我們以下一段程式碼為例,來展示一下非阻塞等待方式

#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將變成子程式的程式號,退出迴圈等待。最終的執行結果如下:

image-20210815213529132

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

image-20210815233743515

SHELL

當前 Shell,它的值通常是/bin/bash。

image-20210815233908961

TERM

當前終端型別

image-20210815234151056

HOME

當前使用者主目錄的路徑,很多程式需要在主目錄下儲存配置檔案,使得每個使用者在執行該程式時都有自己的一套配置。

image-20210815234257823

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 環境變數的組織形式

image-20210815235403900

environ 變數是一個char * 型別,儲存著系統的環境變數。*每個程式都會收到一張環境表,環境表是一個字元指標陣列,每個指標指向一個以’\0’結尾的環境字串。

4.3 exec函式族

4.3.1 exec函式族的使用

知道了環境變數的概念後,再簡要介紹一下命令列引數。當我們在某個目錄下輸入ls -als -l時,會有如下顯示:

image-20210816003940801

我們發現,同樣的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讀取命令和分析命令就是一個很典型的例子,如下圖所示:

image-20210816011210421

我們平時輸入的如ls -a等命令實際上是一個個可執行程式。當shell讀取一行命令時,shell會對命令進行解析,並且shell建立一個子程式,再通過呼叫execve,用可執行程式替換掉子程式,當程式執行完畢並且退出後,shell讀取子程式的退出資訊。這樣,即便會出現程式崩潰的情況,也不會影響到shell本身。

以上就是關於程式控制的內容,主要分為四個方面——程式建立,程式終止,程式等待以及程式替換。有了以上的知識,我們已經可以實現一個很簡易的shell,如何實現,請讀者自行思考!

相關文章