前言
最近在看《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()
的解析就到这里,如果大家对这两个函数还有什么疑问,可以直接在下面评论,我会在第一时间给出自己的见解。谢谢~~~