进程的信号

一、什么是信号

首先,要把信号和信号量分别开,信号是用来通知进程发生了一些事件,而信号量是用来解决同步与互斥问题的。可以通过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的使用这里不做更多介绍,可以在有需要的时候再了解。

相关推荐
HLC++13 小时前
Linux的进程间通信
android·linux·服务器
华清远见IT开放实验室14 小时前
实验室建设案例 | 石家庄科技信息职业学院嵌入式实验室——从底层硬件到系统应用,一所应用型高校的嵌入式人才培养这样落地
linux·arm开发·stm32·嵌入式硬件·高校·实验室建设
groundhappy16 小时前
idalib安装和codex ida-mcp配置
linux·开发语言·python
通信小小昕17 小时前
Ubuntu 26.04 中文输入法安装
linux·运维·ubuntu
张小姐的猫18 小时前
【Linux】网络编程 —— HTTP协议(上)
linux·运维·服务器·网络·http·单例模式·策略模式
栩栩云生19 小时前
AI 写代码犯的错,早被写进了错题集
linux·安全·ai编程
imc.1120 小时前
linux基础IO
linux·运维·服务器
BelongPanda1 天前
Linux Nginx 纯手动 Let‘s Encrypt 泛域名证书配置教程
linux·nginx
酷可达拉斯1 天前
Linux操作系统-shell编程(0)
linux·运维·服务器·python·云计算
2301_777998341 天前
Linux中断机制:操作系统如何高效运行
linux·运维·服务器