Linux进程信号(预备工作、信号产生、信号保存、信号处理)

1、关于信号的预备工作

        通过kill -l操作,我们可以看到所有的信号。信号的名称就是对前面数字编号的宏定义,调用时效果是一样的,如kill -9 pid效果等同于kill -SIGKILL pid。可以通过man 7 signal指令查看信号的默认处理动作。

        总共62个信号,其中1~31为普通信号,34~64为实时信号。

        信号运行的逻辑是什么?认识+动作(1)进程是如何识别信号的?进程信号本身是被程序员编写的属性和逻辑的集合,识别工作由底层编码完成。(2)当进程收到信号的时候,进程可能正在执行更重要的代码,所以信号不一定会被立即处理。(3)进程本身必须由对信号的保存能力。(4)进程在处理信号的时候,一般有三种动作(默认、自定义、忽略【信号被捕捉】)。

        信号是发送给进程的,而进程要保存信号,那么保存在哪里?task_struct/PCB里。如何保存?注意到普通信号有32个,可以用一个32bit位位图来保存,在我们的PCB结构体里有相对应的unsigned int字段专门保存是否收到了指定的信号,比特位的位置,代表信号编号,比特位的内容代表是否收到该信号,0没有,1表示有。如何理解信号的发送呢?发送信号的本质:修改PCB中的信号位图。PCB是内核维护的数据结构对象,PCB的管理者是OS,谁有权利修改PCB中的内容呢?只有OS。所以说无论有多少种发送信号的方式,本质都是通过OS向目标进程发送的信号。所以说kill命令底层一定调用了对应的系统调用,OS必须要提供发送信号、处理信号的系统调用。

2、产生信号的方式

        (1)产生信号的第一种方式:通过键盘(组合键)向目标进程发送信号。一种很常见的信号产生方式是我们的ctrl + c按键操作,直接终止进程。本质上是因为ctrl + c是一个组合键,发送给OS,然后OS将ctrl + c解释为二号信号,即2)SIGINT。

        通过man 7 signal指令我们查看到二号信号的描述是通过键盘终止进程。如何证明ctrl + c就是二号信号?我们来了解一个叫做signal的接口,通过man 2 signal查看。signal接口,参数列表,第一个参数为信号编号,第二个参数为函数指针,signal接口的作用是,对指定编号的信号设置自定义动作,完成信号的捕捉,当进程接受该信号时,不执行默认动作而执行自定义动作。函数指针所指向函数的参数int为被捕捉信号的编号。

        来编写一个代码测试一下。

#include<iostream>
#include<csignal>
#include<unistd.h>
#include<cstdio>
using namespace std;
void ca(int sig)
{
    printf("catch a sig! num:%d  pid:%d\n", sig, getpid());
}

int main()
{
    //这里是对signal的调用,并不是对ca的调用
    //这里设置了对二号信号的捕捉方法,并不代表该函数被调用了
    //一般ca不会被调用,除非收到SIGINT信号!
    signal(SIGINT, ca);                                                                                                                                                                                       
    while(true)
    {
        printf("正在运行中\n");
        sleep(1);
    }

    return 0;
}

                运行起来就算我们使用ctrl + c或者kill -2 pid也不能终止进程,而是调用我们自己写的捕捉函数。注意,为了保证系统的安全性,9号信号是一个管理员信号,9号信号是不能被捕捉的。

        使用kill -9 pid可以终止该进程。也可以通过ctrl + \指令来终止进程,本质上是操作系统接受热键再将其解释为kill -3 pid指令,终止进程。以上是我们的第一种产生信号的方式。

        (2)产生信号的第二种方式:系统调用向目标进程发送信号

        来认识三个系统调用接口kill,

        通过man 2 kill操作来查看kill接口。其作用是向目标进程发送信号,参数列表第一个是目标进程的pid,第二个是要发送的信号编号。发送成功返回0,失败返回1。

        来操作一下,写两个程序。一个是死循环程序,一直运行。另一个是用于终止死循环进程的程序。

        用于终止进程

        死循环

        当死循环运行起来,打开另一个会话窗口,运行kill程序 + pid + sig命令即可向目标进程发送信号。我们在会话窗口执行的kill命令本质上也是调用kill系统接口。

        另一个接口raise函数,通过man raise指令来查看raise信息。作用是给自身进程发送信号。只有一个参数sig,代表发送的信号编号。

        另一个接口abort,是一个c库提供的通过向自己发送6号信号终止自身进程的一种方式。

        关于信号处理的行为的理解:有很多的情况,进程收到的大部分信号,默认处理动作都是终止进程。信号的不同,代表不同的事件,但是对事件发生之后的处理动作可以一样。

        (3)产生信号的第三种方式:硬件异常产生信号

        信号产生,不一定非得用户显式地发送(通过键盘,系统调用等)。

        观察下面这段代码。有一个除0操作,运行起来之后,程序发出报错并直接终止。本质上是因为除0发生之后,操作系统会向进程发送八号信号SIGFPE。

        如果我们利用signal接口,用自己写的函数对8号信号进行捕捉。会发生如下现象。可以看到,我们只发生了一次除0,为什么一直在发送8号信号?

        首先,发生除0错误的时候,操作系统是怎么收到8号信号的?这跟硬件有关系。CPU内是有很多寄存器的,比如说通用寄存器eax、ebx、ecx、edx等等,用于保存我们代码中的数据以及计算结果。可是,我们的CPU内部在进行对应的运算时,除了要能够把结果算出来之外,它还得保证你这次的运算有没有出问题,所以CPU内部有一个东西叫做状态寄存器,状态寄存器中也有很多很多的数据,只不过这段数据不属于你代码当中的数据,而是用于衡量你这一次运算结果的数据,它里面也是由二进制序列构成的,不同的比特位有不同的含义。比方说,在运算的时候除0,那么结果就会无穷大,就会引起状态寄存器中的溢出标志位由零被设为一。溢出标志位默认为0,为0就代表这次运算是没有溢出的,设为1就代表本次计算是溢出状态,CPU认为这个运算结果没有意义,不需要被采纳。因为操作系统是软硬件资源的管理者,所以当CPU发生运算异常,操作系统也是知晓的,操作系统会检测CPU状态寄存器,溢出标志位被置一就向对应进程发送八号信号,进而引起进程终止。

        第二个问题,为什么,我们用自定义函数对八号信号进行捕捉之后,只进行一次除零操作,为什么会一直收到八号信号?收到八号信号,调用我们自定义函数,并没有引起进程退出。进程没有退出,就有可能会被再次调度。CPU内部只有一份寄存器,寄存器的内容属于当前进程的上下文,状态寄存器是由CPU自己维护的,一旦出异常,用户是没有能力维护或修复这个问题的。所以每当进程被切换的时候,就有无数次状态寄存器被保存和恢复的过程,每一次恢复的时候,就让OS识别到了CPU内部的状态寄存器中的溢出标志位,所以一直会发送8号信号。

        众所周知,如果我们在代码中对nullptr进行解引用,程序也会崩溃,我们将其称为段错误或者非法内存引用,但这其实也是一种由硬件引起的异常向进程发送信号导致的进程终止。我们在对nullptr解引用时,指针是指向虚拟地址的,而虚拟地址会通过页表向物理地址进行转化,这里的页表是和MMU协同工作的,MMU是一块由CPU维护的内存管理单元,是一个硬件。当发生越界访问或者野指针解引用时,MMU会发生异常。操作系统会识别MMU的异常并向进程发送11号信号SIGSEGV,终止掉该进程。以上就是产生信号的第三个方式,硬件异常产生。

        (4)产生信号的第四种方式:由软件条件产生信号。

        比如当我们使用管道进行进程间通信时,如果读取管道的进程停止读取,那么写入管道的进程会收到13号信号SIGPIPE。还有一个接口叫做alarm,只有一个参数int seconds,作用是设置一个闹钟,当指定时间到了之后向进程发送14号信号SIGALRM,终止进程。

        理解一下为什么通过闹钟产生信号是由软件条件产生信号。任意一个进程,都可以通过alarm系统调用在内核中设置闹钟,因此OS中可能会存在着很多的闹钟,那么操作系统是需要对闹钟进行管理的(先描述,再组织),先要为其设计对应的struct,再通过特定的数据结构将其组织起来,比如说对链表的增删查改。

        什么是核心转储?先讲一个问题,数组越界一定会导致程序崩溃吗?当我们定义一个int a[10],可能对a[11]或者a[12]进行访问时,程序没有发生崩溃,当a[10000]时会发生崩溃,这是为什么?当我们定义一个int a[10],确实会申请十个元素,但这并不代表系统给这个代码块或者函数分配的栈帧结构是十个元素,栈帧结构的字节数可能很大,所以,即便少量越界了,但依然是在有效栈区里面,所以就没报错,如果大量越界,可能就会访问一个并不是有效栈区的空间,这时就会被系统识别出来,并向进程发送11号信号SIGSEGV。

        通过man 7 signal查看不同信号的执行动作,我们可以看到大多数的信号的默认执行动作都是终止进程,但是Action那一栏却有不同,Term指的是单纯终止一个进程而不发生其他动作,Core代表核心转储,在操作系统终止我们进程时还会发生其他动作。在云服务器上,默认情况下,如果进程是core退出的,我们是看不到明显现象的。

        想要查看core发生的现象,我们先使用ulimit -a指令,这个指令用于查看操作系统为用户设置的各种资源上限,包括管道大小,最多能有多少文件描述符等。这里可以看到第一行的core file size为0,这代表云服务器默认关闭了核心转储,通过ulimit -c 1024可以将其设置为1024,这一操作叫做打开云服务器的core file选项,默认可以形成1024个block数据块。

        这是我们写一个会发生段错误的代码,编译并运行。可以看到这次程序崩溃后面还有一个core dumped的提示,这代表已经核心转储,查看当前目录下还会有一个core.10226文件,在我们的程序因为core信号终止进程时,系统会将进程的上下文保存到新生成的文件中去,文件的后缀就是进程的pid,方便我们后续用gdb进行调试,这就是核心转储,也是term和core信号的区别。

3、信号的保存

        先讲一些信号的常见概念。(1)实际执行信号的处理动作称为信号递达(Delivery)。(2)信号从产生到递达之间的状态,称为信号未决(Pending)。(3)进程可以选择阻塞(Block)某个信号。(4)被阻塞的信号产生时将保持在未决状态,知道进程解除对此信号的阻塞,才执行递达的动作。(5)注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

        进程可能在任何时刻收到来自操作系统给它发送的任意的信号,该信号可能并不会被立即处理。所以它需要暂时被保存,那么怎么保存?上面讲过,实际上在内核(PCB)中采用位图结构来保存收到的信号。在PCB内部有一个pending位图,实际上就是一个unsigned int,共32个比特位,比特位的位置代表信号的编号,比特位的内容代表是否收到该信号,比特位为0代表没有收到该信号,比特位为1代表收到了该信号,且该信号未决。操作系统向进程发信号,本质上就是对进程PCB的pending位图设置比特位。PCB内部还有一个block位图,也是一个unsigned interesting,32个比特位,比特位的位置代表信号的编号,比特位的内容代表是否阻塞该信号。比方说,对于二号信号,将其的block位图第二个比特位设置为1,那么代表该进程阻塞了二号信号,如果该进程收到了二号信号,其pending位图的第二个比特位也会置1,并且二号信号在进程解除对其的阻塞之前,永远不会递达,一直处于未决状态,pending位图也一直为1除此之外,在内核中还有一个函数指针数组handle,大小为31,元素的类型为 void(*)(int),代表信号递达时,不同信号处理的动作。因此系统在执行某个信号递达时,会先检查进程的block位图中该信号有没有置1,如果阻塞则不进行动作,如果没有则检查进程的pending位图中该信号有没有收到,如果收到则在handle中寻找该信号的处理动作,完成信号递达

        之前所讲的signal()信号捕捉接口,即使根据编号修改内核中handle中对应信号的函数指针。

        一些结论。(1)一个信号没有产生,并不妨碍它可以先被阻塞。(2)由于信号的保存是由位图来支持的,位图的一个位只能表示两种情况,因此系统在同一时刻向进程发送多个相同信号时,进程只能收到一个信号。

4、信号的处理

        信号产生的时候不会被立即处理,而是在合适的时候。这个合适的时候叫做从内核态返回用户态的时候。现在讲讲用户态和内核态。

        什么是用户态?我们自己所写的所有代码,在编译运行之后,全部都是以用户态运行的,我们自己所写的程序,难免会访问两种资源,第一种是操作系统自身的资源,比如说getpid,waitpid等等,pid等数据是操作系统自己维护的,这些是需要访问进程的内核数据结构来达成的。第二种是硬件资源,比如printf,write,read。用户为了访问内核或者硬件资源,必须通过系统调用完成访问,这些系统接口时操作系统提供的。当发生系统调用时,当前进程会由用户态切换为内核态,执行系统调用的是进程,但是身份是内核。系统调用的话进程必须将身份切换为内核,因此调用的成本会更高(修改CPU的状态寄存器,陷入内核,进行函数跳转),频繁发生系统调用会影响程序的运行效率,这也是为什么stl中的序列式容器string或者vector在开辟空间时会开辟1.5或者2倍,因为开辟空间本质上是系统开辟虚拟内存,为了避免发生频繁的系统调用。

        CPU内有许多寄存器,包括可见寄存器和不可见寄存器,寄存器中保存的相当大的一部分数据都是和当前进程强相关的数据,被称为进程的上下文,进程在切换时,除了要切换PCD、页表,还要切换进程的上下文,被切走的进程会将自己的上下文带走。CPU里也有专门保存当前进程PCB和页表起始地址的寄存器。有一个CR3寄存器里面有对应的比特位,用于表征当前进程的运行级别,例如为0代表内核态,为1代表用户态。

        进程是怎么进入到OS中执行系统调用的呢?PCB中有专门mm_struct)保存进程地址空间,进程地址空间一共4G,0~3G通过用户级页表映射到物理内存,为了保持不同进程之间的独立性,每个进程都有自己独立的用户级页表,映射到不同的物理内存。除此之外,操作系统内部还维护了一个内核级页表,是为了维护从虚拟到物理之间的操作系统级别的代码所构建的一张内核级页表。在我们开机时,将操作系统加载到内存,通过内核级页表映射到当前进程的虚拟地址空间的3~4G,由于在内存中操作系统只有一份,因此在整个系统中内核级页表也只有一份。因此每个进程都可以通过内核级页表访问操作系统的代码和数据。 进程要访问OS的接口,其实只需要在自己的地址空间上进行跳转就可以了。每一个进程都有3~4G,都会共享一个内核级页表,无论进程如何切换,都不会更改任何的[3,4]地址空间。在进程进行系统调用之前,系统一定会在CPU中的CR3寄存器中检查进程的运行级别,为内核态才执行相关调用,为用户态会终止进程。

        一开始所讲的信号处理的时候是从内核态返回用户态的时候,这说明,进程曾经一定是进入了内核态,比较常见的进入内核态的方式是系统调用,进程切换。关于进程切换,一个进程没有被执行完就被切换,一定是被放到运行队列或者等待队列,进程一定要进入内核态,才能以操作系统的身份放过去,唤醒时也是内核态被放入运行队列里,再次调度时才返回用户态。

5、信号的捕捉流程

        根据上面所说的信号的执行发生在进程由内核态返回用户态时,与信号的捕捉结合起来,具体讲讲信号的捕捉流程。

        一个信号通过系统调用或者进程切换进入内核态,相关操作是陷入内核,再将CPU寄存器中的进程运行级别修改,再执行相关操作。完后在进程不会直接由内核态返回用户态,此时进程还处于一个拥有较高权限的状态,操作系统会通过CPU中的寄存器找到进程的PCB,进而对PCB中的三张表(pending位图,block位图,handle函数指针表)进行检测,先遍历block位图,如果为1,说明该信号被忽略,就不到pending中检测了,如果为0,就到pending中查看,pending中如果为1,说明收到该信号了,这时要去handler表中查看匹配的方法。而我们信号递达的动作有三种:默认、忽略、自定义。对于默认,大部分的动作都是终止进程,这时进程处于内核态,显然时有权限和身份来执行该动作的,直接将进程终止,完成递达。对于忽略,操作系统将pending位图中的该信号由1置0,完成递达。如果是自定义,那么进程需要运行我们自定义的用户态的代码,这就引出一个问题,进程能不能以内核态的身份运行用户态的代码呢?技术上是可以的,在waitpid函数中有一个输出型参数status,通过操作系统向status中写数据,用户可以获取等待进程的系统数据。但操作系统为了保持系统的安全性,并不会去以内核态执行一份用户代码,而是先将进程的运行级别改为用户态,在用户态下进程的权限较低,用户态下运行代码需要受到操作系统的一系列管控,因此更加安全。所以处理信号时,如果是执行用户自定义的函数,进程需要先回到用户态,再处理信号,让信号递达,这时会先将pending位图中对应信号由1置0,再执行相关函数。信号递达后,进程也不会直接继续执行用户态的代码,而是先跳转回内核,再返回用户态执行用户代码。

        有一种场景,假设要处理的信号比较耗时,这个信号递达可能会持续一段时间。左这个信号递达期间,系统会自动将当前信号加入信号屏蔽字,换句话说就是将该信号的block位图由0置1,因此在递达期间,如果系统继续向进程发送同类型的信号,那么后续信号无法被递达,但是会在pending位图中保存,对该信号由0置1。当上一个信号处理完毕,系统回解除对该信号的阻塞,执行后续信号,但是在上一个信号处理期间,若发送了多次信号,那么后续只会递达一次,因为pending位图只会保存一个。

其他话题:可重入函数。

        我们可以将main函数和处理信号时执行的函数认为是两个执行流,如果有一个函数在main中被调用,在调用期间进程去处理信号,处理信号的动作也是调用这个函数,这种情况称为“重入”, 函数的重入可能会发生问题(问题多种多样),导致程序的执行结果与我们预期不相符。如果一个函数被重入后发生了问题,那么称这个函数为不可重入函数,相反,如果没有发生问题,则称为 可重入函数。我们c/c++库中的大部分函数都是不可重入函数,涉及数据结构的扩容或者io的大部分都是。

  • 21
    点赞
  • 36
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值