Linux之进程信号

一、什么是进程信号

进程信号是一种事件通知机制,属于软件中断

信号的作用:发生某事件时,打断进程当前操作,转而去处理这个事件

例子:

假设你正在学习,没有突发事件你不会停止学习。(此时你就是一个进程)此时你的母上大人做好了午饭,跑来叫你吃饭(吃饭信号)。然后你就去吃饭了,不学习了,吃完饭又回来学习(处理完信号回来继续作业)

注意:

信号和信号量不是同一个概念。

信号量是进程间通信(IPC)的方式之一,而信号是一种事件通知机制。

二、常见的信号

kill -l   #该命令可以查看常见的信号
man 7 signal   #查看信号的相关描述

1-31号信号属于非可靠信号 34-64号信号属于可靠信号

我们主要学习1到31号非可靠信号。

输入man 7 signal可以看到

三、信号的产生

1、硬件产生信号

通过按键的终端软件产生信号:

之前已经知道按下组合键Ctrl+C可以结束一个进程。

Ctrl + C 的本质就是给进程发送了 2 信号,进程接收到 2 号信号后的默认处理动作是结束进程。

那如何理解组合键变成信号呢?其实键盘的工作方式是通过中断方式进行的。键盘是槽位的,每个槽位都会对应一个编号。因为有键盘驱动,操作系统是能够识别这些编号的。只要按下了一些键,操作系统立马就能够识别到。那么当你按下组合键,操作系统也是可以识别到的。操作系统既然都识别到了你按下了组合键,那么操作系统给特定的进程发送信号,也就是轻而易举的事情了。

注意:

Ctrl + C 产生的信号只能发给前台进程。一个命令后面加个 & 可以放到后台运行,这样 shell 不必等待进程结束就可以接受新的命令,启动新的进程。 shell 可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl + C 这种组合键产生的信号。 前台进程在运行过程中用户随时可能按下 Ctrl + C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous) 的。

使用 signal 函数后,当进程接收到 signum 信号时,进程会调用 handler 函数(handler 是回调函数,handler 是 函数指针类型,该函数的返回值是 void,参数是 int)并将 signum 传递给 handler 函数,其实 signal 函数相当于可以自定义捕捉某个信号。signal 函数的返回值是对于 signum 信号的旧的处理方法。

代码块:

#include <iostream>
#include <signal.h>
#include <unistd.h>
​
using namespace std;
​
void catchSignal(int signal)
{
    cout << "捕捉到了一个信号: " << signal << endl;
}
​
int main()
{
    // signal(2, catchSignal);  // 这种写法也可以
    // catchSignal是自定义捕捉
    signal(SIGINT, catchSignal);  // 特定信号的处理动作一般只有一个
    while(true)
    {
        cout << "我是一个进程,我的pid是: " << getpid() << endl;
        sleep(2);
    }
​
    return 0;
}

注:signal 函数仅仅是修改进程对特定信号的后续处理动作,并不是直接调用对应的处理动作。而是当进程接收到特定信号时,才会去调用对应的处理动作。如果后续没有产生 SIGINT 信号,catchSignal 函数就不会被调用,signal 函数往往放在最前面,先注册特定信号的处理方法。 现在就无法通过 Ctrl + C(2 号信号)终止该进程了,那么我们可以通过 Ctrl + \ (3 号信号)终止该进程。如果你也将 3 号信号也自定义捕捉了,那么可以发生 8 号信号(浮点数异常)来终止进程。

2、调用系统函数向进程发信号

通过系统调用实现 mykill 命令

2.1 kill函数

系统调用 kill 函数可以想指定的进程发送指定的信号。

#include <sys/types.h>
#include <signal.h>
​
int kill(pid_t pid, int sig);
  • pid参数是要发送信号的目标进程ID。可以使用进程ID(PID)或进程组ID(PGID)。

  • sig参数是要发送的信号编号。可以使用预定义的信号宏(如SIGKILLSIGTERM)或用户自定义的信号编号kill()函数返回0表示成功,-1表示失败。

代码展示:

#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <cstring>
#include <stdlib.h>
​
using namespace std;
​
static void Usage(string proc)
{
    cout << "\tUsage: \n\t";
    cout << proc << " 信号编号 目标进程\n"
             <<endl;
}
​
// 通过系统调用向进程发送信号(设计mykill命令)
// ./mykill -2 pid
int main(int argc, char* argv[])
{
    if(argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }
​
    int signal = atoi(argv[1] + 1);
    int id = atoi(argv[2]);
​
    kill(id, signal);
​
    return 0;
}

2.2 raise函数

raise()函数:可以用来向当前进程自身发送信号。

raise(sig)等价于kill(getpid(), sig)

  • sig参数是要发送的信号编号,同样可以使用预定义的信号宏(如SIGKILLSIGTERM)或用户自定义的信号编号。

#include <signal.h>
int raise(int sig);

代码:

using namespace std;
​
int main()
{
    while (true)
    {
        std::cout << "我是一个进程,我正在运行 ..., pid: " << getpid() << std::endl;
        sleep(1);
        raise(8);
    }
}
3、通过软件条件产生信号
3.1 终止写端进程SIGPIPE(13号信号)

学习管道的时候,我们说过:当管道读端关闭,写端一直在写,操作系统会自动终止对应的写端进程。操作系统是通过发送 13 号信号(SIGPIPE)来终止写端进程的!

    1. 创建匿名管道

    1. 让父进程进行读取,子进程进行写入

    1. 父子进程通行一段时间(该步骤可以省略)

    1. 让父进程先关闭读端,子进程只有一直写入就行

    1. 父进程通过 waitpid 等待子进程拿到子进程的退出信息

父进程的读端已经关闭,子进程的写端再进行写入也没有任何的意义,那么操作系统就向子进程发送 13 号信号(SIGPIPE)。像管道的读端关闭写端还在写的这样情况,其实就是不符合软件条件(管道通信的条件,管道也是一种软件),那么操作系统就会向不符合软件条件的进程发送特定的信号终止进程。

3.2 alarm函数与SIGALRM信号

alarm 函数可以设定一个闹钟,也就是告诉操作系统在 seconds 秒后给当前进程发送 14 号信号(SIGALRM),该信号的默认处理动作是终止当前进程。

alarm函数:

  • alarm函数是一个POSIX标准的函数,用于设置一个定时器,当定时器到达指定时间后,会发送一个SIGALRM信号。

  • 函数原型:unsigned int alarm(unsigned int seconds)

  • 参数seconds表示定时器的时间间隔,单位为秒。

  • 返回值:若之前已设置了定时器,则返回之前的剩余时间;若之前没有设置定时器,则返回0。

这个函数的返回值是 0 或者是以前设定的闹钟时间还余下的秒数。打个比方,某人要小睡一觉,设定闹钟为 30 分钟之后响,20 分钟后被人吵醒了,还想多睡一会儿。于是重新设定闹钟为 15 分钟之后响,以前设定的闹钟时间还余下的时间就是 10 分钟。如果 seconds 值为 0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。

代码:

#include <iostream>
#include <unistd.h>
​
using namespace std;
​
int main()
{
    alarm(1);
    int count = 0;
    // 验证1s内,count++会进行多少次
    // cout + 网络 = IO
    while(true)
    {
        cout << "count: " << count++ << endl;
    }
​
    return 0;
}

通过上图可以看到,count 一定被加加了 7w+ 次,这次数是比较少的,其实是由 cout 和网络传输数据慢导致的。如果想单纯看看计算的算力,可以通过下面的程序。

4、硬件异常产生信号

硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。

4.1 SIGFPE(8号信号)

代码:

#include <iostream>
#include <unistd.h>
#include <signal.h>
​
using namespace std;
​
void handler(int signum)
{
    sleep(1);
    cout << "收到了一个信号: " << signum << endl;
}
​
int main()
{
    signal(SIGFPE, handler);
    int a = 100;
    a /= 0;
    while(1)  sleep(1);
    
    return 0;
}
​

将程序运行起来,就会发现程序在死循环打印语句。那为什么会这样呢?如何理解除零呢?进行计算的是 CPU 这个硬件,CPU 内部是有寄存器的,其中有一个寄存器是状态寄存器。该寄存器不进行数值保存,它只用来保存 CPU 本次计算的状态,其结构也是位图,有着对应的状态标记位(溢出标记位)。当状态寄存器的溢出标记位为 0,操作系统就将计算结果写回到内存中;而当溢出标记位为 1时,操作系统就会意识到有除零错误(溢出问题),操作系统会找到当前哪个进程在运行,向该进程发送 SIGFPE 信号,进程会在合适的时候处理该信号。

4.2 SIGSEGV(11号信号)
#include <iostream>
#include <unistd.h>
#include <signal.h>
​
using namespace std;
​
void handler(int signum)
{
    sleep(1);
    cout << "收到了一个信号: " << signum << endl;
}
​
int main()
{
    // SIGSEGV 段错误(11号信号)
    signal(SIGSEGV, handler);
    sleep(2);
     cout << "野指针问题 ... here" << endl;
    int *p = nullptr;
    *p = 100; // 2, 野指针问题
    while(1)  sleep(1);
    
    return 0;
}

当野指针或越界访问时,使用的地址都是非法地址,那么 MMU 进行转化的时候,就一定会报错。只有 MMU 报错,操作系统就能识别当前进程出现了硬件异常,将该硬件异常转化成对应的信号发送给进程。

四、核心转储

是否有一个疑问,31个信号的默认处理方式都是结束进程,并且还可以自定义处理方式,那么为什么要这么多信号呢?一个信号不就行了吗?

  • 重要的不是产生信号的结果而是产生信号的原因

  • 所有出现异常的进程,必然是收到了某一个信号。

首先解释什么是核心转储(Core Dump)。当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是 core,这种行为就叫做核心转储(Core Dump)。

以信号2和3为例,他两的默认处理方式一个是Term,一个是Core。

  • Term和Core的结果都是结束进程。

那么这两个方式的区别在哪里呢?

  • Term方式仅仅是结束进程,结束了以后就什么都不干了。

  • 但是Core不仅结束进程,而且还会保存一些信息。

在数据越界非常严重的时候,该进程会接收到SIGSEGV信号,来结束进程。

11号信号的默认处理方式是Core。

在云服务器上,默认情况下是看不到Core退出的现象的,这是因为云服务器关闭了core file选项:

core file size(红色框)的大小是0,意味着这个选项是关闭的。

  • 从这里还可以看到别的关于这个云服务器的信息,比如能够打开的最多文件个数,管道个数,以及栈的大小等等信息。

为了能够看到Core方式的明显现象,我们需要将core file选项打开:

此时该选项就打开了,表示的意思就是核心转储文件的大小是1024个数据块。

  • 再运行数据越界的程序时,同样会收到SIGSEGV信号停止。

  • 但是在当前目录下会多出一个文件,如上图中的绿色框。

core.1739:被叫做核心转储文件,其中后缀1739是接收到该信号进程的pid值。

对于一个奔溃的程序,我们最关心的是它为什么崩溃,在哪里崩溃?

  • 当进程出现异常的时候,将进程在对应的时刻,在内存中的有效数据转储到磁盘中-------核心转储

  • 核心转储的文件我们可以拿着它进行调试,快速定位到出现异常而崩溃的位置。

  • 使用gdb调试我们的可执行程序。

  • 调试开始后,输入core-file core.pid值,表明调试核心转储文件。

  • 此时gdb就会直接定位到产生异常的位置。

这就是核心转储的重要意义,它相比Term方式,能够让我们快速定位出现异常的位置。

五、阻塞信号与信号处理

1、信号其他的常见的相关概念

实际执行信号的处理动作称为信号递达(Delivery),信号处理动作有默认、忽略、自定义捕捉。 信号从产生到递达之间的状态,称为信号未决(Pending),也就是进程收到了一个信号但该信号还未被处理,信号被保存在位图(Pending 位图)中。 进程可以选择阻塞 (Block )某个信号。 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。 注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

2、信号在内核中的表示

为了表示信号递达、未决和阻塞三个概念,那么操作系统就要用一定的结构去表示它们。操作系统就使用了三张表来表示这三个概念,如下图所示:

block是位图结构,1表示该信号被阻塞,0表示该信号未被阻塞,如果某个信号对应的比特位为1,就block了,则该信号无法被递达,只有解除block,才能递达该信号。其中 pending 表就是保存信号的位图结构(unsigned int),1 表示收到了信号,0 表示没有收到信号;handler 表是函数指针数组,数组的下标就是信号编号,数组中存的是信号的处理动作;

信号处理的过程:操作系统给目标进程就是修改 pending 位图,这样信号就完成发送了。进程在合适的时候处理信号,遍历 pending 位图看哪些比特位为 1。当发现比特位为 1 时,就去看对应的 block 位图上的比特位是否为 1。如果是 1,则说明该信号被阻塞着,进程不会去处理该信号,也不会将 pending 位图的比特位从 1 改成 0;而如果是 0,则说明该信号没有被阻塞,进程可以处理该信号,处理完成后还需要将 pending 位图上的比特位从 1 改成 0,表示该信号已经处理完成。

3、sigset_t类型

编程语言都会给我们提高 .h 或者 .hpp 和语言本身的定义类型;操作系统也会给我们提供 .h 和操作系统自定义的类型,像 pid_t 和 key_t 等。如果要访问硬件,那么语言类的头文件也会包含对应的系统调用接口,将系统调用封装起来给我们使用。

sigset_t 也是操作系统自定义的类型,该类型是位图结构,用以表示上图的 pending 表和 block 表。用户不能直接通过位操作来修改位图(unsigned int),需要使用操作系统提供的方法来修改位图。

未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。

4、信号集操作函数

sigset_t 类型对于每种信号用一个比特位表示有效或无效状态,至于这个类型内部如何存储这些比特位则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作 sigset_ t 变量,而不应该对它的内部数据做任何解释,比如用 printf 直接打印 sigset_t 变量是没有意义的。

#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
  • 函数 sigemptyset 初始化 set 所指向的信号集,使其中所有信号的对应比特位清零,表示该信号集不包含任何有效信号。

  • 函数 sigfillset 初始化 set 所指向的信号集,使其中所有信号的对应比特位置 1,表示该信号集的有效信号包括系统支持的所有信号。

  • sigaddset 函数将 signo 信号对应的比特位置为 1sigdelset 函数将 signo 信号对应的比特位置为 0。

  • sigismember 函数可以判断 signo 信号是否在信号集中,如果 signo 信号在信号集中,返回 1;如果不在,返回 0;出错则返回 -1。

  • 注意:在使用 sigset_ t 类型的变量之前,一定要调用sigemptyset 或 sigfillset 函数做初始化,使信号集处于确定的状态。初始化 sigset_t 变量之后就可以在调用 sigaddset 和 sigdelset 在该信号集中添加或删除某种有效信号。

5、sigpending与sigprocmask
5.1 sigpending

sigpending 函数通过输出型参数 set 获取当前进程的未决信号集,调用成功返回 0,出错则返回 -1。

5.2 sigprocmask

sigprocmask 函数可以帮助我们读取或更改进程的信号屏蔽字(阻塞信号集),调用成功返回 0,出错则返回 -1。

如果 oldset 是非空指针,则读取进程的当前信号屏蔽字通过 oldset 参数传出。如果 set 是非空指针,则更改进程的信号屏蔽字,参数 how 指示如何更改。如果 oldset 和 set 都是非空指针,则先将原来的信号屏蔽字备份到 oldset 里,然后根据 set 和 how 参数更改信号屏蔽字。假设当前的信号屏蔽字为 mask,下表说明了 how 参数的可选值。

#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <cassert>
​
using namespace std;
​
static void showPending(sigset_t& pending)
{
    for(int signal = 31; signal >= 1; --signal)
    {
        if(sigismember(&pending, signal))
            cout << '1';
        else
            cout << '0';
    }
    cout << endl << "----------------" << endl;
}
​
int main()
{
    // 定义并初始化信号集
    sigset_t set, oldset;
    sigemptyset(&set);
    sigemptyset(&oldset);
​
    // 将2号信号添加到信号屏蔽集中
    sigaddset(&set, 2);
    // 将信号屏蔽集设置到当前进程的PCB中
    // 默认情况下,进程不会对任何信号进行block
    int n = sigprocmask(SIG_BLOCK, &set, &oldset);
    assert(n == 0); // assert本质是一个宏
    (void)n;
    cout << "block 2 号信号成功......" << endl;
​
    // 重复打印当前进程的pending信号集
    sigset_t pending;
    sigemptyset(&pending);
    while(true)
    {
        // 获取当前进程的pending信号集
        sigpending(&pending);
        // 打印pending信号集
        showPending(pending);
        sleep(2);
    }
​
    return 0;
}
​
6、信号处理

处理信号,就是进程收到信号,当进程对该信号不阻塞时,会在handle函数指针数组中找到对应的递达方法,来处理当前信号。

注意:当进程收到某信号,并不是立马进行处理的,而是等到合适的时机才进行处理。

处理信号有三种方法:

1.使用默认方法

2.忽略此信号

3.自定义捕捉

由于是由默认方法和忽略信号,就是在handle数组对应信号数组中填入SIG_DEL和SIG_IGN。很好理解,下面来说明一下自定义捕捉信号。

六、捕捉信号

1、内核如何实现信号的捕捉

在上面提及到,信号产生之后,进程可能无法立即处理,进程需要在合适的时候去处理信号。那这个合适的时候是什么呢?带着这个问题,我们来探究一下信号处理的整个流程!

信号相关的数据字段是在进程的 PCB 内部,PCB 内部属于内核范畴,普通用户无法对信号进行检测和处理。那么要对信号进行处理,就需要在内核状态。当执行系统调用或被系统调度时,进程所处的状态就是内核态;不执行操作系统的代码时,进程所处的状态就是用户态。现在我们已经知道需要在内核态下进行信号处理,那究竟具体是什么时候呢?结论:在内核态中,从内核态返回用户态的时候,进行信号的检测和处理!如何进入内核态呢?进行系统调用或产生异常等。汇编指令int 80(80 是中断编号)可以进程进入内核态,也就是将代码的执行权限从普通用户转交给操作系统,让操作系统去执行!注:汇编指令int 80内置在系统调用函数中。

为什么要从用户态到内核态?操作系统是软硬件资源的管理者。

用户需要要访问某些软硬件资源,只要访问硬件,就必须通过操作系统来进行访问,那么就必须从用户态转变到内核态! 。

什么行为会引起从用户态到内核态的转变?执行系统调用、进程调度、处理异常等!

为什么要从内核态到用户态呢?用户的代码还没有执行完、用户的进程还没有调度完等!

Linux信号知识看这文章:(389条消息) 【Linux】进程信号_阿亮joy.的博客-CSDN博客

2、信号的捕获函数sigaction

  • sa_mask,之前有说到过当在处理一个信号的自定义函数时,这个信号会被系统阻塞,直到处理完。如果还想阻塞其它的信号,可以设置sa_mask。

  • sigaction多用于实时信号。

#include <iostream>
#include <signal.h>
#include <unistd.h>
​
​
using namespace std;
​
​
void Count(int cnt)//延时函数
{
    while(cnt)
    {
        printf("cnt:%2d\r",cnt);
        fflush(stdout);
        cnt--;
        sleep(1);
    }
}
​
​
void sigcb(int signo){
    cout<<"信号已递达,编号是:"<<signo<<endl;
    Count(10);//延时10秒
}
​
​
int main(){
    struct sigaction act,oldact;
    act.sa_handler =sigcb;//自定义处理方式
    act.sa_flags=0;
    sigaction(SIGINT,&act,&oldact);
    while(1) sleep(1);
}

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

代码小陈的编程之旅

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值