Unix 环境高级编程(APUE) system函数和sleep函数简单解析

3 篇文章 0 订阅

前言

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值