前言
最近在看《Unix 高級環境編程》的第十章,內容主要是與信號相關的概述和API。在看到章末的時候,有兩個函數system()和sleep()的實現讓我感覺比較困惑,並且在函數的內部實現中也使用了很多前面與信號相關的API,所以我覺得有必要好好實現一下這兩個函數。於是我就照着書上的代碼重新實現了一遍,並在代碼中加入了相關的註釋,算是記錄一下我的個人理解吧。
system()函數
函數功能
system函數接受一個待執行的命令cmd_string
作爲參數,在函數中執行這條命令,並返回shell的終止狀態(子進程的終止狀態)。在函數中使用fork()創建一個子進程,並且在子進程中使用execl()
執行shell腳本解釋器程序,並將待執行命令cmd_string
傳入。接着在shell腳本解釋器中fork()出一個子進程,在子進程中使用execl()
執行這條命令。以下是執行system("/bin/ed")的流程圖:
疑惑點及解釋
其實整個函數的執行流程還是挺清晰的,就是其中的一些信號操作有點讓人迷惑:
1. 父進程需要屏蔽(阻塞)SIGCHLD
信號。
2. 父進程需要忽略SIGINT
信號和SIGQUIT
信號。
接下來我就以我目前學到的知識,來解釋以上兩個比較迷惑的地方。
1. 爲什麼父進程需要屏蔽(阻塞)SIGCHLD
信號?
首先講到這個,得先了解一下SIGCHLD
信號的產生條件是什麼。SIGCHLD
的產生條件爲:
子進程終止時,或者子進程接收到SIGSTOP信號停止時,或者子進程處在停止態,接受到SIGCONT後喚醒時。總的來說就是子進程的狀態發生變化時會向父進程發送SIGCHLD
信號。所以子進程終止後,會向父進程發送SIGCHLD
信號。
而父進程接收到SIGCHLD
信號後,會發生什麼呢?默認情況下父進程會執行相應的信號處理函數。在SIGCHLD
信號對應的信號處理函數中,它會執行wait()
收集這個終止子進程的信息,並把它徹底銷燬後返回。在收集完這個子進程的信息後,內核中關於這個子進程的信息就被抹去了,也就無法再找到有關這個已經被回收子進程的信息。收集的信息保存在status中。
Ok,簡單介紹了一下與SIGCHLD
信號相關的內容之後,現在來分析system()函數。假設在父進程中不對SIGCHLD
進行阻塞,看看會發生什麼。假設不對SIGCHLD
進行阻塞,那麼有可能在父進程還沒有執行到waitpid()
這個函數時,子進程就已經執行完畢,並且發送SIGCHLD
信號給父進程。這時父進程會執行相應的信號處理函數,也就是執行wait()
對子進程進行回收。回收完後在內核中就找不到有關子進程的信息了。接着父進程繼續往下執行,執行waitpid()
函數。因爲內核中已經沒有子進程的相關信息了,所以waitpid()
無法阻塞等待子進程,出錯,會返回一個小於0的值,將出錯信息保存到status中。最後system()會將保存錯誤信息的status作爲返回值返回。這就明顯有問題了。本來子進程是正常執行,shell解釋器以及命令都是正常執行,但是在system()中卻無法獲得子進程中shell解釋器正常執行返回的正確信息(被SIGCHLD
的信號處理函數的wait()
截獲了),只能返回錯誤信息。這肯定是不合理的。所以阻塞SIGCHLD
信號就是爲了確保在waitpid()
處能夠真正獲得子進程的終止狀態以及相應的信息。
那如果不在函數中執行waitpid()
,而是依靠信號處理程序中的wait()
獲取子進程的信息,這樣看起來好像也可以。但是,缺少了waitpid()
的阻塞等待,父進程有可能在子進程執行完畢前就已經結束了,那麼也無法獲取子進程的終止狀態和信息。
waitpid()
就是一個主動獲取子進程結束狀態的函數。
2. 爲什麼父進程需要忽略SIGINT
信號和SIGQUIT
信號?
這裏需要重新回看一下上面那張流程圖:
鍵入中斷字符可將中斷信號SIGINT
發送給前臺進程組中的所有進程。當有中斷信號SIGINT
發送進來時,a.out(父進程)和ed進程(子進程的子進程)會捕捉到這個信號(shell進程忽略此信號)。在執行命令的過程中,由system執行的命令可能是交互命令,而父進程(也就是system()的調用者)在執行時放棄了控制,等待該執行程序的結束。那麼按正常邏輯來說,中斷信號和退出信號應該只傳送到當前正在交互(或者說正在控制)的子進程,而不應該傳送到所有前臺進程中。這也就是爲什麼父進程需要忽略中斷信號SIGINT
和退出信號SIGQUIT
。
system()函數的返回值
這一部分我就直接引用某位大佬的博客了:
system函數對返回值的處理,涉及3個階段:
階段1:創建子進程等準備工作。如果失敗,返回-1。
階段2:調用/bin/sh拉起shell腳本,如果拉起失敗或者shell未正常執行結束(參見備註1),原因值被寫入到status的低8~15比特位中。system的man中只說明瞭會寫了127這個值,但實測發現還會寫126等值。
階段3:如果shell腳本正常執行結束,將shell返回值填到status的低8~15比特位中。
備註1:只要能夠調用到/bin/sh,並且執行shell過程中沒有被其他信號異常中斷,都算正常結束。比如:不管shell腳本中返回什麼原因值,是0還是非0,都算正常執行結束。即使shell腳本不存在或沒有執行權限,也都算正常執行結束。如果shell腳本執行過程中被強制kill掉等情況則算異常結束。
如何判斷階段2中,shell腳本是否正常執行結束呢?系統提供了宏:WIFEXITED(status)。如果WIFEXITED(status)爲真,則說明正常結束。
如何取得階段3中的shell返回值?你可以直接通過右移8bit來實現,但安全的做法是使用系統提供的宏:WEXITSTATUS(status)。
由於我們一般在shell腳本中會通過返回值判斷本腳本是否正常執行,如果成功返回0,失敗返回正數。
所以綜上,判斷一個system函數調用shell腳本是否正常結束的方法應該是如下3個條件同時成立:
(1)-1 != status
(2)WIFEXITED(status)爲真
(3)0 == WEXITSTATUS(status)
注意:根據以上分析,當shell腳本不存在、沒有執行權限等場景下時,以上前2個條件仍會成立,此時WEXITSTATUS(status)爲127,126等數值。所以,我們在shell腳本中不能將127,126等數值定義爲返回值,否則無法區分中是shell的返回值,還是調用shell腳本異常的原因值。shell腳本中的返回值最好多1開始遞增。
————————————————
版權聲明:本文爲CSDN博主「cheyo車油」的原創文章,遵循 CC 4.0 BY-SA 版權協議,轉載請附上原文出處鏈接及本聲明。
原文鏈接:https://blog.csdn.net/cheyo/article/details/6595955
實現代碼及過程解析
#include <errno.h>
#include <signal.h>
#include <unistd.h>
// 參數爲傳入的、需要執行的命令
int system(const char* cmd_string) {
pid_t pid;
int status;
struct sigaction ignore, saveintr, savequit; // 信號處理動作
sigset_t child_mask, save_mask; // 信號集
// 如果命令爲空,直接返回
if (cmd_string == nullptr)
return(1);
// 將ignore信號動作中的信號處理函數改成忽略
ignore.sa_handler = SIG_IGN;
// 這個函數用於清除函數參數所指向的信號集中的所有信號
// sa_mask是ignore中的屏蔽字
sigemptyset(&ignore.sa_mask);
// 不設置對信號處理的各個選項
ignore.sa_flags = 0;
// 將SIGINT信號的處理動作更改忽略,並且保存原來對應的信號處理動作到saveintr中
if (sigaction(SIGINT, &ignore, &saveintr) < 0)
return(-1);
// 與上一個函數的功能類似,將原來的信號處理動作保存到savequit中
if (sigaction(SIGQUIT, &ignore, &savequit) < 0)
return(-1);
// 清空child_mask信號集
sigemptyset(child_mask);
// 向child_mask信號集中添加SIGCHLD信號
siaddset(&child_mask, SIGCHLD);
// 爲當前進程設置信號屏蔽字,內容爲當前信號屏蔽字和child_mask中信號屏蔽字的並集,其實就是多加了一個SIGCHLD信號。
// 這個信號會在子線程執行完畢後返回
// 原來的信號屏蔽字保存到save_mask中
if (sigprocmask(SIG_BLOCK, &child_mask, &save_mask) < 0)
return(-1);
if ((pid = fork()) < 0) {
status = -1;
} else if (pid == 0) { // 子進程
// 因爲子進程會繼承父進程中的信號屏蔽字以及信號處理動作等
// 所以針對父進程做了特殊處理後,在子進程中需要恢復爲初始狀態
// 將SIGINT信號和SIGQUIT信號的處理動作恢復爲初始動作
sigaction(SIGINT, &saveintr, NULL);
sigaction(SIGQUIT, &save_mask, NULL);
// 將子進程的信號屏蔽字重新設爲原來的初始狀態,也就是不屏蔽SIGCHLD
sigprocmask(SIG_SETMASK, &save_mask, NULL);
// 在子線程中執行新的shell解釋器sh,在這個新的shell中會fork()之後使用exec()執行命令cmd_string
execl("/bin/sh", "sh", "-c", cmd_string, (char*)0);
// _exit()與exit()的區別爲,_exit()不會執行各種終止處理程序,所以也就不會對標準I/O流進行flush
// 如果execl()執行成功,則不會返回到這裏,因爲子進程的代碼段已經被覆蓋爲execl()中指定的程序
// 如果execl()執行失敗,纔會返回到這裏,接着往下執行_exit(127)
_exit(127);
} else {
// 等待子線程執行完畢,對子線程進行回收,並將子進程的終止狀態保存到status中
// waitpid()正常返回的是收集到的子線程的pid
// 返回-1則說明調用出錯,錯誤信息保存到errno中
// EINTR表示因爲被中斷而出錯
// ECHILD 調用者沒有等待的子進程(wait),也就是沒有可以回收的子進程(原來的子進程已經被其他wait()給回收了)
// 或是pid指定的進程或進程組不存在(waitpid)或者pid指定的進程組中沒有那個成員是調用者的子進程
while (waitpid(pid, &status, 0) < 0) {
if (errno != EINTR) {
status = -1;
break;
}
}
}
// 經過上面的wait()之後,子進程必然執行完畢,將父進程的信號處理動作和信號屏蔽字都恢復爲初始狀態
if (sigaction(SIGINT, &saveintr, NULL) < 0)
return(-1);
if (sigaction(SIGQUIT, &savequit, NULL) < 0)
return(-1);
if (sigprocmask(SIG_SETMASK, &save_mask, NULL) < 0)
return(-1);
// system()返回子進程終止狀態,也就是shell程序的終止狀態
return(status);
}
sleep()函數
函數功能及實現方式
使調用進程休眠,函數參數爲休眠的時間。函數內部使用alarm
設定鬧鐘,接着調用sigsuspend()
使線程阻塞。當鬧鐘到時間後,會發送SIGALRM
信號喚醒進程。當然如果有其他信號發送進來,也會喚醒進程,此時函數會返回剩餘的休眠時間。
疑惑點及解釋
1. sigsuspend()
的作用,以及爲什麼在執行alarm
之前需要阻塞SIGALRM
信號?
sigsuspend()
在這個sleep()中主要起到阻塞的作用。在調用sigsuspend()
時,可以傳入一個信號集參數,這個信號集將會被作爲暫時的信號屏蔽字。當sigsuspend()
停止阻塞並返回後,信號屏蔽字會自動重置爲調用sigsuspend()
的狀態。至於爲什麼在執行alarm
之前需要阻塞SIGALRM
,我的理解是:在使用alarm
設定鬧鐘之後到sigsuspend()
阻塞還有一定的距離,如果用戶設置的時間過短,導致在還未執行到sigsuspend()
阻塞時就已經收到SIGALRM
信號,那麼真正執行sigsuspend()
阻塞時就沒有SIGALRM
信號來中斷它,進程一直休眠。在調用sigsuspend()
時,通過傳入設置好的信號屏蔽字,停止SIGALRM
信號的阻塞。通過這樣操作來提供一個原子操作,確保在alarm
設定鬧鐘之後、調用sigsuspend()
阻塞之前進程會阻塞SIGALRM
信號。
2. alarm(0)的作用?
首先根據alarm
的定義,如果參數爲0,則取消之前設定的鬧鐘,之前鬧鐘剩餘的時間會作爲alarm
的返回值。根據sleep()的定義,如果在休眠的過程中接收到其他的信號,那麼休眠會提前結束,並且返回剩餘的休眠時間。所以很明顯,這個alarm(0)就是爲了應對其他信號導致休眠提前結束的情況。當接收到其他的信號時,sigsuspend()
會返回,接着往下執行。那麼這個時候肯定是要取消之前已經設置的鬧鐘,並且要獲取鬧鐘的剩餘時間,alarm(0)就派上用場了。
實現代碼及過程解析
#include <errno.h>
#include <signal.h>
#include <unistd.h>
// `SIGALRM`信號對應的處理程序
static void sig_alrm(int signo) {
// 什麼事情都不做,就是準備喚醒sigsuspend()
}
// seconds爲線程休眠的時間長度
// 如果sleep()被其他信號中斷,則會停止休眠,返回剩餘的休眠時間;否則休眠結束後返回0
// 藉助alarm()函數實現
unsigned int sleep(unsigned int seconds) {
struct sigaction new_act, old_act; // 信號處理動作
sigset_t new_mask, old_mask, susp_mask; // 信號集,用於保存需要屏蔽的信號
unsigned int unslept; // 被中斷後保存剩餘的休眠時間
// 給SIGALRM信號設置對應的信號處理動作,並保存舊的信號處理動作
new_act.sa_handler = sig_alrm; // 指定信號處理程序
sigemptyset(&new_act.sa_mask); // 清空信號處理動作中的屏蔽字。在調用信號處理動作中的信號處理程序時,sa_mask指定的信號會被阻塞直到處理程序執行結束
new_act.sa_flags = 0; // 不設置對信號處理的各個選項
sigaction(SIGALRM, &new_act, &old_act); // 將新的信號處理動作與SIGALRM信號綁定
// 在調用alarm()之前,阻塞SIGALRM信號
// 阻塞之後可以防止alarm()設置的休眠時間過短,在還未執行到sigsuspend()阻塞時就已經收到SIGALRM信號
// 從而導致真正執行sigsuspend()阻塞時沒有SIGALRM信號來中斷它
sigempty(&new_mask);
sigaddset(&new_mask, SIGALRM);
sigprocmask(SIG_BLOCK, &new_mask, &old_mask);
alarm(seconds);
sigdelset(&susp_mask, SIGALRM); // 在阻塞信號集中刪除SIGALRM。因爲sigsuspend()需要SIGALRM信號來中斷
sigsuspend(&susp_mask); // 阻塞,等待信號。捕獲信號並且執行完信號處理程序後會返回,繼續往下執行。信號屏蔽字在執行完後會被重設爲調用前的狀態
// 同時將susp_mask信號集作爲信號屏蔽字,也就是susp_mask信號集中的信號會被阻塞。暫時解除對SIGALRM信號的阻塞
unslept = alarm(0); // 如果sigsuspend()被外部信號所打斷(也就是上面設置的alarm()還沒有到時間)
// 清除上面alarm()定時,並且返回alarm()剩下的時間
sigaction(SIGALRM, &old_act, NULL); // 將SIGALRM的信號處理動作設置爲初始值
sigprocmask(SIG_SETMASK, &old_mask, NULL); // 恢復原來的信號屏蔽字,解除對SIGALRM信號的阻塞
return unslept; // 返回休眠被打斷後剩餘的休眠時間。如果休眠沒有被打斷,則返回0
}
總結
關於system()
和sleep()
的解析就到這裏,如果大家對這兩個函數還有什麼疑問,可以直接在下面評論,我會在第一時間給出自己的見解。謝謝~~~