进程的信号

一、什么是信号

首先,要把信号和信号量分别开,信号是用来通知进程发生了一些事件,而信号量是用来解决同步与互斥问题的。可以通过kill -l查询信号和对应的编号:

比较常见的是前31个,其中,2号SIGINT就是Ctrl+C发出的信号,9号SIGKILL用来强制杀死进程(不可被屏蔽),15号SIGTERM是kill的默认信号,17号SIGCHILD是子进程退出以后向父进程发出的信号。这四个相对更常见,而9号和19号SIGSTOP都是不可被屏蔽的信号,什么是信号屏蔽后面再解释。

通过kill -编号 <pid> 就可以在命令行向指定的进程发送对应编号的信号,而进程在接收到信号后,如果处于接收信号的时机且信号没有被屏蔽,就会去找对应信号的处理办法,从而处理对应的事件。

二、信号的产生和处理

2.1 信号的产生方式

  • 通过命令行kill的方式产生信号,就是命令产生
  • 通过组合键例如Ctrl+C的方式产生,就是终端产生
  • 通过程序内部调用函数产生信号,就是程序内产生
  • 当程序内发生除0或野指针等问题时产生的信号,就是硬件产生信号

首先是第一种方式,使用命令行,是相对比较直观的方式,但是需要获取进程的PID,可以直接让进程打印自己的PID或者ps然后grep找对对应的进程然后获得进程的PID。

然后是组合键,最常用的就是Ctrl+C,可以产生2号信号退出当前前台正在运行的进程。为什么可以通过组合键向前台进程发送信号呢?这是由于,键盘输入会向CPU申请硬件中断,CPU会将键盘输入读入内核,然后识别出这是组合键,根据组合键向前台进程发送信号。此外,除了Ctrl+C还有Ctrl+\(SIGQUIT)、Ctrl+Z(SIGTSTP)。

再然后就是调用函数产生信号:

cpp 复制代码
#include <signal.h>
#include <stdlib.h>

// 常见信号产生函数
kill(pid, sig);      // 向指定进程发送信号
raise(sig);          // 向自身发送信号
abort();             // 发送 SIGABRT
alarm(seconds);      // 设置定时器,超时发送SIGALRM

其中,kill函数可以用来模拟命令行的kill指令,也就是通过pid和sig指定要发送的信号和要发送的进程。raise则是向自己发送信号,类似于kill(getpid(),sig),而abort就是向自己发送SIGABRT信号,alarm则是设置一个倒计时,时间一到就给自己发送SIGALRM信号。

最后是除0或野指针错误等,在内核中会记录进程当前的状态,例如,如果发生了除0错误,就会在状态寄存器上记录当前的错误,CPU发现出错后,就会向进程发送信号,如果我们不做处理并且不退出进程,CPU就会一直发出异常信号。

2.2.1 信号的保存

进程并不是有信号就会马上处理的,例如在CPU保存上下文的时候,产生信号并不会让CPU马上处理这个信号。因此,信号需要保存下来,也就是未决信号集。未决信号集通常用一个bit表示一个信号,有点类似于位图,当接收到信号后,未决信号集就会将对应信号的比特位改为1,表示收到了该信号。如果,信号没有被屏蔽,等CPU可以处理进程信号的时候,就会找到对应的处理办法。

2.2.2 信号的阻塞

已经知道,收到的信号是通过位图的形式保存,那么很容易就能想到,信号的阻塞也可以使用同样的办法实现,只需要在要阻塞的信号的比特位改为1,就可以知道这里的信号会被阻塞。

信号为什么要阻塞呢?因为,在某些情况,例如进程进入临界区,这个时候就希望一些信号不要打断进程的运行,那么将这个信号阻塞,直到进程退出临界区再取消阻塞信号,然后再处理信号。

cpp 复制代码
#include <signal.h>

int sigemptyset(sigset_t *set);               // 清空信号集
int sigfillset(sigset_t *set);                // 将所有信号加入信号集
int sigaddset(sigset_t *set, int signum);     // 添加指定信号
int sigdelset(sigset_t *set, int signum);     // 删除指定信号
int sigismember(const sigset_t *set, int signum); // 测试信号是否在信号集中

sigset_t这个类型的数据,就是上面提到的类似于位图的数据结构,通过上面这些函数,就可以修改sigset_t特定位置的值,从而方便后面调用函数阻塞特定的信号。

cpp 复制代码
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

在我们完成对set的修改后,就可以调用sigprocmask函数,实现对阻塞信号集的修改。其中,how有三个参数:

cpp 复制代码
SIG_BLOCK    // 添加阻塞:将set中的信号添加到当前阻塞集合
SIG_UNBLOCK  // 移除阻塞:从当前阻塞集合中移除set中的信号
SIG_SETMASK  // 完全替换:用set替换当前阻塞集合

然后第一个set,就是我们修改好的set,而第二个oldset用来保存原来的阻塞信号集。

另外,我们还可以调用sigpending函数获取当前未决信号集,结合sigismember我们就可以在屏幕上直观的打出当前未决信号集,从而测试阻塞信号集的作用:

cpp 复制代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>

int main(void){
    sigset_t set;
    sigemptyset(&set);
    sigaddset(&set, SIGINT);
    sigaddset(&set, SIGTSTP);
    sigset_t oldset;
    sigprocmask(SIG_BLOCK, &set, &oldset);
    while(true){
        sigset_t pending;
        sigpending(&pending);
        for(int i = 31; i > 0; i--){
            if(sigismember(&pending, i)){
                printf("1");
            }
            else {
                printf("0");
            }
        }
        printf("\n");
        sleep(1);
    }

    return 0;
}

我们可以看到,当使用^C以后,进程并没有终止,pending中的第二位变成了1,再输入^Z以后,进程也没有停止,第20位变为了1,最后使用^\才使进程退出。这就是因为,我们在前面把SIGINT和SIGTSTP加入了阻塞集。所以9号和19号信号不能被阻塞的意思就是,即使我们将这两个信号加入阻塞集,进程依然会接收到这两个信号。

为什么需要这两个信号?试想,如果一个进程将所有信号全部阻塞,那么如果这个进程是一个死循环,是否还有停下来的办法?如果没有这两个后门,就没有停止这个进程的方法了。

2.3 信号的处理

当进程接收到信号以后,通过有三种处理方式:SIG_DLF、SIG_IGN、用户自定义,也就是默认,忽略以及自定义。

我们可以借助signal函数来实现对信号处理方法的修改,例如:

cpp 复制代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>

void handler(int signo){
    printf("接收到一个信号:%d\n", signo);
}

int main(){
    signal(SIGINT, handler);
    signal(SIGTSTP, SIG_IGN);
    signal(SIGQUIT, SIG_DFL);
    while(true){
        pause();
    }
    return 0;
}

我们可以看到使用^C会打印信息,而^Z则没有反应,使用^\就退出了程序。此外还可以使用sigaction函数达成同样的效果。对于9号和19号信号出于同样的原因,同样不能修改这两个信号的处理方式。

三、处理信号的时机

我们已经知道,进程并不是随时都会处理信号,那么到底什么时候会处理信号呢?

首先,我们需要知道什么是内核态和用户态。由于一些操作可能会伤害到操作系统,因此,这些操作的权限不能直接给到用户,因此就有了内核态和用户态的区分,但是又为了让用户可以安全使用这部分操作,就有了系统提供的接口,也就是系统调用。因此,当用户使用系统调用时,CPU就会从用户态进入到内核态,实现只有在内核态才能完成的操作。

信号的处理时机,就是在CPU从内核态转到用户态的时候,这个时候CPU会检查是否有信号需要处理。而什么时候是CPU从内核态转到用户态呢?

  • 系统调用返回时,必然是从内核态回到用户态
  • 中断返回时,产生中断必然会进入内核态处理,返回时就是内核态到用户态
  • 进程被重新调度时,调度进程一定是在内核态,命令转回到进程后需要回到用户态

总结起来,就是内核态到用户态时会检查是否有信号需要处理。

信号的使用非常灵活,相比于signal函数,还是更推荐使用sigaction函数修改信号的处理方式,而sigaction的使用这里不做更多介绍,可以在有需要的时候再了解。

相关推荐
XH-hui8 小时前
【打靶日记】群内靶机Secure
linux·网络安全
Shingmc38 小时前
【Linux】进程控制
linux·服务器·算法
视觉装置在笑7139 小时前
Shell 变量基础与进阶知识
linux·运维
Web极客码9 小时前
如何通过命令行工具检查 Linux 版本信息
linux·运维·服务器
欢鸽儿9 小时前
Vitis】Linux 下彻底清除启动界面 Recent Workspaces 历史路径
linux·嵌入式硬件·fpga
繁华似锦respect10 小时前
C++ 智能指针底层实现深度解析
linux·开发语言·c++·设计模式·代理模式
hweiyu0011 小时前
Linux 命令:dd
linux
---学无止境---11 小时前
i386 架构中断管理函数详解
linux·架构
kkkkkkkkl2411 小时前
Prometheus指标入门详解
linux·服务器