Linux进程信号

💘作者:泠沫
💘博客主页:泠沫的博客
💘专栏:Linux系统编程,文件认识与理解,Linux进程学习…
💘觉得博主写的不错的话,希望大家三连(✌关注,✌点赞,✌评论),多多支持一下!!
在这里插入图片描述

🏠Ⅰ.信号预备知识

在学习信号的过程中,我们主要从信号的整个生命周期展开叙述:
在这里插入图片描述

🚀 1.1信号概念

  • 信号: 信号是进程之间事件异步通知的一种方式,属于软中断。
  • 信号递达:实际执行信号的处理动作。
  • 信号未决:信号从产生到递达之间的状态。
  • 信号阻塞:当某个信号被阻塞时,就不会发送给该进程,这个信号将被保存在内核中等待下次处理。

注意:

  1. Linux操作系统处理某个信号是先看这个信号是否被阻塞,如果被阻塞,直接跳过该信号,继续判断下一个信号是否阻塞。直到某个信号不是阻塞,然后在查看这个信号是否未决,然后再决定是否执行该信号。
  2. 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作
  3. 阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

🚀 1.2信号列表

在Linux环境下,可以使用 kill -l 命令查看所有信号列表。
在这里插入图片描述
需要注意的是,Linux中只有62个信号,我们重点学习1-31号普通信号,后面的34-64是实时信号,这里不做介绍。

🚀 1.3信号处理

信号处理方式一般有三种:

  1. 默认处理。
  2. 忽略处理。
  3. 自定义处理。

不管是哪一种处理方式,我们都应该能达成共识的是,信号到来之时,我们可能有更重要的事情要做,不一定立马处理这个信号。但是,我们得保存这个信号,以便于之后的处理。

🏠Ⅱ.信号产生发送

在学习信号发送之前,我们要谨记一点,所有的信号都是由操作系统“发送”的,也只有操作系统才能“发送”信号。

🚀 2.1快捷键发送信号

在进程运行过程中,我们可以选择输入 ctrl + c 给进程发送2号信号SIGINT,来让进程退出。同样的快捷键还有 ctrl + \ 发送3号信号SIGQUIT。
我们可以使用下面代码,对2号信号进行自定义处理动作,从而验证这两个快捷键与对应的信号是否一致。

#include<iostream>
#include<sys/types.h>
#include<unistd.h>
#include<signal.h>
using namespace std;

void handler(int signo)
{
    cout << "捕捉到了一个" << signo << "号信号....." << endl;
}

int main()
{
    signal(2, handler);
    int cnt = 0;
    while(true)
    {
        cout << "我是一个进程, pid: " << getpid() << " cnt:" << cnt << endl;
        cnt++;
        sleep(1);
    }
    return 0;
}

在这里插入图片描述
通过实验我们发现, ctrl + c确实是2号信号,ctrl + \就不予验证了。

🚀 2.2命令行发送信号

在命令行中,我们可以输入 kill 命令查看所有信号,同样我们也可以使用 kill 命令来对特定进程发送特定的信号。 格式为:kill - 信号编号 进程pid
在这里插入图片描述

🚀 2.3系统调用发送信号

在此之前,我们知道所谓的命令本质上就是写好的可执行程序。那么在kill程序之中,也必定是调用特定的系统调用接口才能实现给特定进程发送信号,这个系统接口就是 kill
在这里插入图片描述
kill 的作用就是给任意一个进程发送任意一个信号。第一个参数就是进程pid,第二个参数就是信号编号。

在系统调用之上,C语言给我们封装了两个函数,一个是raise,另一个是abort。
在这里插入图片描述
在这里插入图片描述
raise的作用是给任意一个进程发送6号信号SIGABRT,而abort的作用是给自己发送6号信号SIGABRT。这里就不做演示了。

🚀 2.4软件条件产生信号

由软件条件产生的信号在之前笔者介绍匿名管道的时候有提到过。如果一个进程关闭匿名管道的读端,那么另一个负责管理匿名管道写端的进程会收到来自操作系统发送到13号信号SIGPIPE。
在这里,笔者再介绍另一种由软件条件产生信号的情况。alarm,一个“闹钟”系统接口。

在这里插入图片描述
该系统调用接口的作用是设置一个时间,时间到了,操作系统就会给当前进程发送14号信号SIGALRM。

在内存当中,有许多被加载的进程,其中可能许多进程都设置了闹钟,那操作系统是不是要把这些闹钟管理起来?管理的本质是先描述,再组织。那么操作系统就会给每一个闹钟创建一个内核数据结构对象。对脑中的管理就变成了对这些数据结构对象的管理,然后操作系统再用一个数据结构将这些对象组织起来,这个管理闹钟的数据结构就像是一个软件,它根据闹钟结束时间排序,在闹钟时间达到的时候准时唤醒特定的进程,给它发送14号信号。所以,14号信号本质上也是由软件条件产生的信号。

🚀 2.5硬件异常产生信号

在我们学习语言的时候,通常在代码中如果出现了除0错误,或者野指针问题,整个进程就会崩溃。这是因为操作系统给这两种情况的进程发送了对应的信号,进程接收到信号之后对信号做出默认处理动作就是进程退出。
接下来我们简单介绍除0错误:
除0错误是CPU在进行计算的时候发现0作为除数时,计算时发现结果溢出,然后CPU中有个寄存器是用来标志溢出位。操作系统发现CPU溢出位有效,立马给对应的进程发送8号信号SIGFPE。如果我们将13号信号做自定义处理动作,不让进程退出的话。由于进程切换的原因,每一个进程都是有特定时间片的,当一个进程从CPU上拿下来的时候,CPU内保存到数据就是该进程的上下文,当进程拿下来,CPU中用来标记溢出位的寄存器恢复。等下一次该进程重新被执行的时候,CPU中标志溢出位的寄存器有效,操作系统又会给该进程发送8号信号SIGFPE,所以我们看到的现象就是操作系统不断地给该进程发送8号信号。
同样的,野指针问题就是MMU会出现异常,操作系统发现该异常后会给对应的进程发送11号信号SIGSEGV。

#include<iostream>
#include<cstdio>
#include<cstdlib>
#include<sys/types.h>
#include<unistd.h>
#include<signal.h>
using namespace std;

void handler(int signo)
{
    cout << "捕捉到了一个" << signo << "号信号....." << endl;
    sleep(1);
}

int main()
{
    signal(8, handler);
    int cnt = 0;
    while(true)
    {
        cout << "我是一个进程, pid: " << getpid() << " cnt:" << cnt << endl;
        cnt++;
        sleep(1);
        if(cnt == 3)
        {
            int a = 10;
            a /= 0;
        }
    }
    return 0;
}

在这里插入图片描述

🏠Ⅲ.信号保存

通过前面的学习,我们知道了所有的信号其实本质上都是操作系统发送给进程的。那么操作系统究竟是如何给特定的进程发送信号的呢?接下来要介绍的就是操作系统如何发送信号以及进程是如何保存信号的。

🚀 3.1信号阻塞block位图

在每一个进程控制块struct task_struct{}当中,简单来说都有两个特定的整型变量用来保存信号(其实是结构体,这里简单化)。操作系统采用的是位图的结构,用一个整形变量的32个比特位,每一个比特位用来标识对应的信号。在进程控制块中,就有两个位图block和pending。这两个位图一个用来保存阻塞信号,一个用来保存未决信号。

🚀 3.2信号未决pending位图

在这里插入图片描述

通过block和pending位图,我们可以知道进程在处理一个信号的过程可以分为以下几个步骤:

  1. 先在struct task_struct中找到block,然后判断block位图对应位置的比特位是否为1,如果是1,表示该信号被阻塞,那么进程将不会再去pending位图里面查询该信号对应的比特位的内容。
  2. 如果是在block位图对应位置比特位内容是0,则表示该信号没有被阻塞。接下来进程去pending位图中查询对应位置比特位的内容。如果是0,表示没有接收到该信号,那就没办法处理。
  3. 如果在pending位图对应位置比特位内容为1,则表示该信号既没有被阻塞,又接收到了该信号,那么进程会在合适的时候处理该信号(由用户态转变为内核态的时候处理)。

通过上述学习,其实我们发现,操作系统所谓的发送信号,其实本质上就是修改对应进程PCB中的pending位图。如果该信号被阻塞,也会把pending位图中对应位置的比特位内容改成1。也正是因为发送信号是修改对应的位图,发送信号的工作只能由操作系统来完成,因为进程控制块是操作系统创建的内核数据结构,由操作系统统一维护,用户没有权力去修改。

虽然我们作为用户没办法直接去修改pending位图和block位图,但是我们可以使用C语言封装的系统调用接口的函数去间接修改这两个位图内容。接下来我们学习C语言提供信号操作集函数。

🚀 3.3信号操作集函数

在进程控制块中,为了更好的保存block位图和pending位图,操作系统内核代码定义了一种新的类信叫做sigset_t,这个类型表示的是一个信号集。我们不需要了解这个信号集的底层实现,只需要知道这个信号集是用来表示所有信号的就行。

下面介绍的一批函数就是对信号集进行操作:

  • sigemptyset
    在这里插入图片描述
    sigemptyset函数是传入一个信号集,然后把这个信号集初始化,全部比特位置为0。相当于把所有信号都删除。

  • sigfillset
    在这里插入图片描述sigfillset函数是传入一个信号集,然后把这个信号集所有比特位置为1。相当于添加所有信号。

  • sigaddset
    在这里插入图片描述sigaddset函数是传入一个信号集和一个信号编号,然后把传入的信号编号在信号集对应的比特位置为1。相当于添加指定信号。

  • sigdelset
    在这里插入图片描述sigdelset函数是传入一个信号集和一个信号编号,然后把传入的信号编号在信号集对应的比特位置为0。相当于删除指定信号。

  • sigismember
    在这里插入图片描述
    sigismember函数是传入一个信号集和一个信号编号,然后判断该信号编号在信号集对应的比特位位置的内容是否为1,如果为1返回1,为0就返回0。相当于判断某一个信号是否存在。

上面所介绍的函数都是对一个信号集进行操作,那是因为之前所说的block和pending本质上都是信号集,我们想对block和pending进行修改,其实是将我们设置好的信号集set去替换原来的block和pending,从而达到修改它们的效果。接下来我们要学习到的就是替换block的函数和获取pending的函数:

  • sigprocmask
    在这里插入图片描述
    sigprocmask函数就是用来修改内核里面的block信号集。

    第一个参数how就是表示如何修改,有三个选项:SIG_BLOCK,SIG_SETMASK,SIG_UNBLOCK
    SIG_BLOCK表示把我们设置好的set信号集里面包含的信号添加到内核block信号集当中。
    SIG_UNBLOCK表示我们把设置好的set信号集里面包含的信号在内核block信号集中移除。
    SIG_SETMASK表示我们把设置好的set信号集直接替换内核中的block信号集。

    第二个参数set就是我们通过上面的信号集操作函数自己设置好的信号集。

    第三个参数是一个输出型参数,用来保存老的block信号集。

  • sigpending
    在这里插入图片描述
    sigpending函数是传入一个输出型参数,用来获取当前的pending信号集。

在介绍完这些信号操作集函数,我们来做一个实验:不断打印出当前的pending信号集,先阻塞2号信号,然后我们给进程传递一个2号信号,原本进程是要退出的。但是由于2号信号被阻塞,所以只会处于未决状态。这时候继续打印pending信号集观察到从原来的31个比特位全0变成第二个比特位为1其余还是0。过一段时间,解除对2号信号的阻塞,这时候2号信号即将被递达,我们可以将2号信号的处理动作改成自定义动作,方便观察。

默认情况下,所有的信号都是不被阻塞的。
默认情况下,如果一个信号被阻塞了,该信号不会被递达。

#include<iostream>
#include<signal.h>
#include<stdlib.h>
#include<unistd.h>
using namespace std;

void handler(int signo)
{
    cout << "捕捉到一个" << signo << "号信号...." << endl;
    cout << "我即将执行2号信号,我要退出啦...." << endl;
    sleep(1);
    exit(0);
}
int main()
{
    sigset_t set, oset, pending;
    sigemptyset(&set);
    sigemptyset(&oset);
    sigemptyset(&pending);
    signal(2, handler);
    sigaddset(&set,2);
    sigprocmask(SIG_SETMASK, &set, &oset);
    int cnt = 0;
    while(true)
    {
        sigpending(&pending);
        for(int i = 1; i < 32; i++)
        {
            cout << sigismember(&pending, i);
        }
        cout << endl;
        sleep(1);
        if(cnt++ == 10)
        {
            sigprocmask(SIG_SETMASK, &oset, &set);
        }

    }
    return 0;
}

在这里插入图片描述

🏠Ⅳ.信号递达处理

🚀 4.1信号捕捉

通过前面的学习,我们知道了信号是由操作系统发送给对应进程。因为发送信号的本质是修改对应进程的呢和数据结构对象中包含的一个信号集,该信号集是采用位图的形式来保存每一个信号。同样的,每一个进程还维护了两个信号集,一个是用来保存阻塞信号,一个是用来保存未决信号。我们也通过代码来演示如何发送信号以及修改和观察block,pending信号集。可是,我们刚开始的时候谈到过,进程在接收到操作系统发送到信号时,一般不会立即处理,而是会在进程由用户态转变成内核态的时候才会处理对应的信号。

接下来我们就来详细谈谈信号的整个捕捉和处理过程(这里的处理主要是指自定义处理,默认处理和忽略处理过程并不需要接下来的全过程)

  1. CPU在执行进程主执行流所对应的代码时,因为中断,异常或者系统调用会把自身用来标定当前进程运行级别的寄存器更改为内核态,那么该进程就可以访问进程地址空间中最上面3G-4G的空间(这部分属于内核所对应的地址空间,只有CPU中特定寄存器标定为内核态才能访问)。
  2. 进程处于内核态时,当内核处理完异常准备回到用户态的时候,操作系统会去处理当前进程可以递达的信号。
  3. 操作系统处理信号前肯定要对block和pending进行信号查询,然后再处理可递达的信号。如果该信号有自定义动作,那么会切换回用户态去执行信号处理函数(而不是主执行流),这里之所以切换模式,是因为操作系统怕你写的代码有问题,它担心你越权操作,所以它必须要切换回用户态去执行代码。
  4. 当Linux内核为信号处理程序创建堆栈帧时,将向堆栈中插入对sigreturn的调用帧,以便在从信号处理程序返回时调用sigreturn。所以在处理完信号之后,CPU会执行特殊的系统调用sigreturn,再次让进程切换到内核态。
  5. 最后进程再从内核态返回用户态,然后CPU继续执行主执行流处的代码。

整个过程我们可以用下面这幅图形象的说明整个信号捕捉过程中,到底进程发生了几次状态切换:
在这里插入图片描述
上面图中,红线上方表示用户态,下方表示内核态。蓝色箭头代表执行方向,绿色交点表示进程状态切换,可根据箭头指向看出从什么状态切换成另一个状态。交点旁边还简要说明了切换过程。

🚀 4.2二谈进程地址空间

在介绍完信号的捕捉,我们再第二次深入谈一谈进程地址空间:
在这里插入图片描述

在此之前,对于32位操作系统,我们所谈到的进程地址空间一般都指的是0-3G这块空间,我们把这一块地址空间叫做用户态地址空间,而3G-4G我们称之为内核态地址空间。内核态地址空间仅允许操作系统内核访问,用户程序无法直接读取或写入该地址空间。接下来我们二谈进程地址空间–内核态地址空间。

内核态空间中存储着操作系统内核的代码数据结构和状态信息等,包括了中断向量表、系统调用接口、驱动程序、内存管理机制、进程调度器、文件系统、设备管理和网络协议栈等。也就是说,所有操作系统内核所需的任务和功能都位于内核态空间中。

通过前面的学习,我们知道用户级页表是每一个进程各自拥有一份,但是内核级页表是一个全局页表,所有进程共享这个内核级页表。所有进程共享全局页表映射内核空间的原因是为了减少内存开销和提高系统性能。在这种实现方式下,操作系统内核的代码和数据只需要被加载到内存一次,并在全局页表中进行一次映射,就能够被所有进程共享和访问。使用全局页表的好处是避免了每个进程都需要单独维护一份映射内核空间的页表的开销,节省了内存占用和地址转换的时间成本。

在进程执行系统调用等需要内核协助的操作时,用户程序需要通过特殊的方式(如软件中断、陷阱门等机制)将控制权交给内核态进行操作。操作系统在内核态运行时可以使用更多的权限和资源,例如访问硬件级别的 I/O 操作、修改内存页表等,而这些操作在用户态是不被允许的。同时,用户态与内核态之间的切换会导致 CPU 上下文的转换,因此频繁地进行上下文切换会对系统性能产生影响,因此应该尽量减少进程在用户态与内核态之间的切换。

进程在内核态执行过程中,操作系统需要保证数据的完整性、一致性和安全性,并防止恶意代码通过操作系统获得控制权而对计算机系统进行破坏或盗取信息等行为。

🚀 4.3信号处理

至于这里的信号处理,在前面其实已经提到了信号的三种处理方式,对于忽略处理,这里不做解释。
这里主要介绍默认处理和自定义处理。

先来介绍自定义处理:

  • signal
    在这里插入图片描述
    signal函数是用来对指定的信号进行自定义处理函数。

    第一个参数就是要自定义处理函数的信号编号。

    第二个参数是一个函数指针,这个函数就是我们要实现自定义操作的函数。

至于这个函数的使用就不做过多介绍了,可以参考本篇文章2.1部分。

接下来我们重点介绍默认处理方法。

🚀 4.4核心转储

首先我们可以使用 man 7 signal 查看命令的默认处理:
在这里插入图片描述

从图中我们可以看到,大部分信号的默认处理动作是core和term,这两种处理方式都是退出当前进程。
接下来我们主要来学习这两种方式的区别:

对于term退出,那就是正常退出。而core退出一般都是程序出异常导致退出。但是为什么我们看不到明显现象呢?那是因为当前我们用的云服务器默认关闭了core file size。

我们可以使用 ulimit -a 查看我们的core file size,我们发现,后面是0:
在这里插入图片描述
我们可以使用后面 -c选项来重新设置它的大小 ulimit -c 1024 ,然后再使用 ulimit -a 查看:
在这里插入图片描述
这个时候我们在创建一个进程,里面使用除0错误,到时候进程就会以core的方式退出,且当前路径下就会多出一个文件:

#include<iostream>
#include<cstdio>
#include<cstdlib>
#include<sys/types.h>
#include<unistd.h>
#include<signal.h>
using namespace std;

int main()
{
 //   signal(8, handler);
    int cnt = 0;
    while(true)
    {
        cout << "我是一个进程, pid: " << getpid() << " cnt:" << cnt << endl;
        cnt++;
        sleep(1);
        if(cnt == 3)
        {
            int a = 10;
            a /= 0;
        }
    }
    return 0;
}

在这里插入图片描述

这个文件是以core开头,. + 进程pid结尾。 如果直接去看是看不懂的,它的作用是通过调试代码才能体现出来的,接下来我们使用gdb对之前的代码进行调试:
调试命令是: gdb 可执行程序名 (如果可执行程序要支持调试的话,在使用gcc或g++ 生成可执行程序时需要带上 -g 选项)

调试过后,输入 core-file 新生成的文件的文件名

在这里插入图片描述
从图中我们可以看到,这个文件里面保存的就是整个代码出问题的地方,通过这种方式我们就能快速定位我们代码出异常的地方。

针对上面这种情况,当程序出现异常的时候,我们将进程在对应的时刻,在内存中的数据和代码转储到磁盘上的过程叫做核心转储(core dumped)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值