Linux--进程信号

Posted includeevey

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux--进程信号相关的知识,希望对你有一定的参考价值。

前言

        无人问津也好,技不如人也罢,你都要试着安静下来,去做自己该做的事情,而不是让烦恼和焦虑毁掉你不就不多的热情和定力。心可以碎,手不能停,该干什么干什么,在崩溃中继续努力前行,这才是一个成年人的素养。        

                                                                                                        --余华        

与大家分享余华老师的名言,希望大家能在学习疲惫时调整好心态,继续砥砺前行!那么今日主题进程信号,以信号的产生-信号的保存-信号的处理为时间线进行讲解,后面也从信号中衍生出来的话题,比如可重入函数,volatile关键字等。

信号入门

信号

信号概念

信号是进程之间事件异步通知的一种方式,属于软中断。

在Linux终端中,通过kill -l查看信号,我们发现信号总数并不是64,它的范围是[1-31]和[34-64]。一般把[1-31]的信号称之为普通信号,[34-64]称之为实时信号。

[hongxin@VM-8-2-centos ~]$ kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8  43)

SIGRTMIN+9  44)     SIGRTMIN+10     45) SIGRTMIN+11    46) SIGRTMIN+12 47)

SIGRTMIN+13 48)

SIGRTMIN+14   49)  SIGRTMIN+15   50) SIGRTMAX-14   51) SIGRTMAX-13 52)

SIGRTMAX-12  53)   SIGRTMAX-11   54) SIGRTMAX-10    55) SIGRTMAX-9  56) SIGRTMAX-8    57)

SIGRTMAX-7   58)    SIGRTMAX-6     59) SIGRTMAX-5       60) SIGRTMAX-4  61) SIGRTMAX-3  62) 

SIGRTMAX-2  63)     SIGRTMAX-1     64) SIGRTMAX 

生活角度的信号

在生活中,我们理解的信号:当一个信号产生时,首先我们知道其意思,并且能产生对应的行为。

        举例:红绿灯

                1.首先我们能够识别红绿灯,认识它。 

我们为什么能够认识信号,这原因是,从小老师的教导,我们记住对红绿灯星号发出后做出相对应的行为。

                2.当红绿灯亮起时,红灯停,绿灯行。这是产生行为-走/停。

当绿灯亮起时,我们必须走吗,可以不走,你可以选择下一个的红绿灯,也可以选择跳个舞再走。所以得出一个结论,当信号(随时)产生时,但可以不(立即)执行。

                3.当信号产生,时间窗口将它保存后,信号被处理。

如何处理:①可以默认处理(红灯停,绿灯行)②初略处理(当灯亮时,不做出任何行为)③自定义处理(当灯亮时,选择跳舞)。    

技术应用角度的信号 

将上面的例子和概念迁移到进程中

        1.进程的识别:需要先认识(先组织后描述)再产生行为 (处理信号)。

        2.进程本身是被程序员编写的属性和逻辑的集合。

        3.当进程收到信号时,进程可能正在执行更重要的代码,所以信号不一定会被立即处理。      

        4.进程本身必须要有对信号的保存能力

        5.进程处理信号有三种方式:默认,自定义,忽略【信号被捕捉】

我们知道信号不是被立即处理的,所以信号是需要被保存起来的。那么它是保存在哪里?又是如何保存的呢?

关于信号保存在哪里是不难理解的,因为我们发现信号时发送给进程的,例如我们熟知的kill -9 pid。当进程进入僵尸状态了,我们就可以使用它将其“杀死”。而进程需要识别信号,那么信号是不是应该被保存在PBC(tack_strcut)中的。

对于如何保存,在tack_strcut中建立32位的位图,比特位的位置代表:信号的编号。比特位的内容代表:是否收到信号,0未收到,1收到信号。如图:

发生信号的本质,其实不是发送,而是修改。将位图0置1,进程接受到信号。

谁来维护位图呢?很显然不可能是用户,pbc的数据是不可能让用户随意修改的。只能OS(操作系统),修改位图也只能是OS。

无论未来我们学习多少种信号的发送,本质都是OS向目标进程发送的信号(修改位图)!

回过来,当我们不能直接对PCB进行修改数据,那么当我们发送信号时,OS肯定会提供发送信号处理信号的相关系统调用。

当我们知道信号需要发生,保存,处理。我们可以画出它的生命周期,如图 :

为了更好的观察信号,当用户输入命令,在Shell下启动一个前台进程。用户按下Ctrl-C ,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程 。

前台进程因为收到信号,进而引起进程退出 。代码如下:

#include <iostream>
#include <unistd.h>

int main()


    while(true)
    
        std::cout<< "I am process!" << getpid() << std::endl;
        sleep(1);
    

    return 0;

终端指令如下:

[hongxin@VM-8-2-centos 2023-4-3]$ make
g++ -o mysignal mysignal.cc -std=c++11
[hongxin@VM-8-2-centos 2023-4-3]$ ll
total 20
-rw-rw-r-- 1 hongxin hongxin   82 Apr  3 21:26 makefile
-rwxrwxr-x 1 hongxin hongxin 9184 Apr  3 21:30 mysignal
-rw-rw-r-- 1 hongxin hongxin  179 Apr  3 21:29 mysignal.cc
[hongxin@VM-8-2-centos 2023-4-3]$ ./mysignal 
I am process!25658
I am process!25658
I am process!25658
I am process!25658
I am process!25658
^C

[hongxin@VM-8-2-centos 2023-4-3]$ 

当按下Ctrl + c时进程终端,其本质是Ctrl + c是一个组合键,是被操作系统识别,Ctrl + c被操作系统解释为2号信号,2) SIGINT 。

如果想了解SIGINT,就可以通过手册查询:man 7 signal

        SIGINT        2       Term    Interrupt from keyboard        

Ctrl + c这里是被默认处理,这里默认处理就是Term->terminal  终止进程键盘上获取Ctrl + c然后终止进程。我们也讲过自定义处理。提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。信号捕捉函数signal。

通过man手册进行了解signal函数,man 2 signal 进入手册:

       #include <signal.h>

       typedef void (*sighandler_t)(int);

       sighandler_t signal(int signum, sighandler_t handler);

signal参数:

         signum     //信号数 例如SIGINT 为 2 

         handler    //自定义方法,写一个 handler函数,调用 handler里的方法

返回值:        

        成功返回信号处理程序的前一个值,或错误时返回SIG_ERR。发生错误时,errno设置为指示原因。     

测试现象:当我们没有按下Ctrl + c时,代码一直运行;按下Ctrl + c后信号被捕捉,进程退出。

#include <iostream>
#include <unistd.h>
#include <signal.h>

void myhandler(int signal)

    std::cout<< "进程捕捉到了一个信号,信号编号是:"<< signal<< std::endl;
    exit(0);


int main()

    signal(2,myhandler);

    while(true)
    
        std::cout<< "I am process!" << getpid() << std::endl;
        sleep(1);
    

    return 0;

[hongxin@VM-8-2-centos 2023-4-3]$ ./mysignal 
I am process!8099
I am process!8099
I am process!8099
I am process!8099
I am process!8099
I am process!8099
I am process!8099
I am process!8099
I am process!8099
^C进程捕捉到了一个信号,信号编号是:2

所以signal(2,myhandler);这里是signal函数的调用,并不是myhandler的调用,仅仅是设置了对2号信号的捕捉方法,并不代表该方法被调用了,一般这个方法不会执行,除非收到对应的信号!当捕捉到2号信号后才执行myhandler方法。

产生信号

通过终端按键产生信号

在上述介绍的Ctrl + c就是从键盘产生信号,不光Ctrl + c,我们也通过Ctrl + \\也能对进程发生信号。

[hongxin@VM-8-2-centos 2023-4-3]$ ./mysignal 
I am process!12795
I am process!12795
I am process!12795
I am process!12795
I am process!12795
I am process!12795
I am process!12795
I am process!12795
^\\Quit

通过kill -l,我们可以发现, Ctrl + \\其实就是3号信号( SIGQUIT)。所以我们通过kill -3 pid将该进程终止。 

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

下面我们通过代码测试来理解调用系统函数向进程发信号,这里可以用到调用系统函数中其中一个kill函数,通过man 2 kill查看

       #include <sys/types.h>
       #include <signal.h>

       int kill(pid_t pid, int sig);

参数:

        pid:进程的pid,sig:第几个信号

返回值:

        zero is returned.  On error, -1  is returned, and errno is set appropriately.

该测试代码,用自己的mian调用kill函数,实现用 mykill向进程发送信号,然后处理。

mysignal.cc

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <string>
#include <stdlib.h>

//调用main参数错误后,打印
static void Usage(const std::string &proc)

    std::cout<< "/n Usage: " << proc << "pid signo"<< std::endl;


//argc表示程序运行时发送给main函数的命令行参数的个数(包括可执行程序以及传参)
//argv[]是字符指针数组,它的每个元素都是字符指针,指向命令行中每个参数的第一个字符。
//argv[0]指向可执行程序
//argv[1]指向可执行程序后的第一个字符串。
//argv[2]指向可执行程序后的第二个字符串。
//argv[argc]为NULL
int main(int argc ,char *argv[])

    //系统调用向目标进程发送信号
    if(argc != 3)
    
        Usage(argv[0]);
        exit(1);
    

    //将mian第二参数字符串转换成pid_t,得到的pid 
    pid_t pid =atoi(argv[1]);
    //字符串转成pid_t,signo=几号信号
    pid_t signo =atoi(argv[2]);

    //调用kill函数
    int n = kill(pid,signo);
    if(n != 0)
    
        perror("kill fail");
    


    //  while(true)
    // 
    //     std::cout<< "I am process !" << getpid() << std::endl;
    //     sleep(1);
    //  

    return 0;


mytest.cc

#include <iostream>
#include <sys/types.h>
#include <unistd.h>


//一直运行的程序,用于测试
int main()

    while (true)
    
        std::cout<< "我是一个正在运行的进程,pid: " << getpid() <<std::endl;
        sleep(1);
    
    

 makefile

.PHONY:all
all:mysignal mytest

mytest:mytest.cc
	g++ -o $@ $^ -std=c++11

mysignal:mysignal.cc
	g++ -o $@ $^ -std=c++11 -g
.PHONY:clean
clean:
	rm -f mysignal mytest

代码显示过程:打开两个进程,其中一个运行mytest,让进程一直运行;一个用mykill来“杀死”一直运行的进程。结果是正确运用mykill,将mytest进程“杀死”。

那么同样的,我们也可以用./mykill pid 信号,来调用其他信号。kill()可以向任意进程发送任意信号。

除了kill函数,这里也再介绍一个函数raise。

功能:给自己发任意信号
       #include <signal.h>

       int raise(int sig);

    //如果cnt==5调用信号3,终止程序
    int cnt=0;
    while(cnt <= 10)
    
        printf("cnt :%d\\n",cnt++);
        if(cnt==5) raise(3);
    

结果:当打印到5时,调用信号3,退出进程。

[hongxin@VM-8-2-centos 2023-4-4]$ ./mysignal 
cnt :0
cnt :1
cnt :2
cnt :3
cnt :4
Quit

raise(3)的实现,其实也可以用kill替换。kill(getpid(),sig)。

第三个函数abort,通man手册来查看它的用法:

功能:给自己发送指定的信号:6) SIGABRT

      #include <stdlib.h>

       void abort(void);

代码的实现和结果

    int cnt=0;
    while(cnt <= 10)
    
        printf("cnt :%d\\n",cnt++);
        if(cnt==6) abort();
    

[hongxin@VM-8-2-centos 2023-4-4]$ ./mysignal 
cnt :0
cnt :1
cnt :2
cnt :3
cnt :4
cnt :5
Aborted 

为了证实abort给自己发送指定的信号,是不是6) SIGABRT,那么我们可以通过上述写的mytest,将进程运行,然后用kill调用6号信号,看是否一样(Aborted )。结果很是这样的,abort我们也可以直接做封装,kill(getpid,Aborted )。

硬件异常产生信号

看了上述,调用系统函数向进程发信号。我们发现一个问题:信号处理的行为,很多的情况,进程收到大部分的信号,默认处理动作都是终止进程。

那么信号的意义是什么?

        举个例子:在刚开始编写程序时,经常会出现各种错误,很多时候的处理方式都是进程终止,但是我们可以通过错误码对应的错误信息,找到错误。

所以说信号的意义信号的不同,代表不同的事件,但是对事件发生之后的处理动作可以一样!

下面通过代码来理解,我们知道操作系统是不能除0的,其原因不是说操作系统不能算,它是可以进行除0计算的,但是算出是非常大的值(一个错误值),所以系统直接将它设置浮点数错误。这里我们写一段关于除0的代码,观察会出现什么情况。

    while(true)
    
        std::cout<< " 我正在运行....."<< std::endl;
        sleep(1);
        int a = 10;
        a /= 0;
    

[hongxin@VM-8-2-centos 2023-4-4]$ make
g++ -o mysignal mysignal.cc -std=c++11 -g
mysignal.cc: In function ‘int main(int, char**)’:
mysignal.cc:31:11: warning: division by zero [-Wdiv-by-zero]
         a /= 0;
           ^
g++ -o mytest mytest.cc -std=c++11
[hongxin@VM-8-2-centos 2023-4-4]$ ./mysignal 
 我正在运行.....
Floating point exception

不出意外,报了浮点数错误。并且它还将进程终止了,除0后会将进程终止呢?

        因为当前进程会受到来自OS系统的信号(告知),8)SIGFPE

为了能够证明,Floating point exception 实质就是向系统发送了SIGFPE,下面通过代码进行证明,代码逻辑:当除0时,操作系统会向进程发送SIGFPE信号,此时通过signal()函数捕捉到SIGFPE时,通过自定义函数catchSig打印出捕捉到的信号。

void catchSig(int signo)

    std::cout<< " 获取一个信号吗,信号编号是:" << signo <<std::endl;
    exit(1);


int main(int argc ,char *argv[])


    //3. 产生信号的方式:硬件异常产生信号
    // 信号产生,不一定非得用户显示的发送!

    signal(SIGFPE,catchSig);

    while(true)
    
        std::cout<< " 我正在运行....."<< std::endl;
        sleep(1);
        int a = 10;
        a /= 0;
    

     return 0;

运行结果,也正如上述猜想一样,获取到的信号是8号信号,我再通过kill -l查看8号信号,再次确认8号就是SIGFPE信号。

[hongxin@VM-8-2-centos 2023-4-4]$ ./mysignal 
 我正在运行.....
 获取一个信号吗,信号编号是:8

当catchSig中不调用exit函数时,会出现什么情况的呢?

        现象是:一直打印输出

一直打印是因为在循环中一直出错吗?

        我们将 int a = 10;  a /= 0; 剥离出while,然后发现不是这个原因造成的结果。所以说,收到信号后不一定会引起进程的退出。

问题又更新了,操作系统如何得知应该给当前进程发送8号信号的?

        这个问题就跟硬件相关了,下图是对除0的详细理解。当CPU运行异常后,CPU会通过状态寄存器获取错误,这里是溢出标志位置1,那CPU就清楚错误原因,这个时候就可以向进程发生相对应的信号,这里是除0,寄存器存储不下,溢出错误,发生8号信号。

上述的一直打印输出的问题还未解决。通过对硬件有一定了解后,再来解决该问题。

        我们知道CPU内部只有一套,但寄存器中的内容是属于当前进程的上下文中(之前涉猎到的知识)。CPU检查出问题后,是没有能力去修正这个问题的(有时仅仅是编码时的错误)。当进程被切换的时候,就有无数次状态寄存器被保存和回复的过程。每一次恢复时,操作系统就能识别到CPU内部的状态寄存器中的溢出标志位是1。

这里的问题,简单来说:就是CPU识别到问题了,但未解决,状态寄存器中的溢出标志位一直是1,捕捉信号到一直都SIGFPE,操作系统就一直发出该信号。

除了除0问题,还有一个我们也经常遇见对空指针解引用的问题。代码和结果如下:

   while(true)
   
       std::cout<< " 我正在运行....." << std::endl;
       sleep(1);
       int* p= nullptr;
       *p = 100;
   

[hongxin@VM-8-2-centos 2023-4-4]$ ./mysignal 
 我正在运行.....
Segmentation faul

那么野指针报错,操作系统又会向进程发生那一个信号呢?

        答:11) SIGSEGV

同样的方式,也能证明它是它是11信号。

hongxin@VM-8-2-centos 2023-4-4]$ ./mysignal 
 我正在运行.....
 获取一个信号吗,信号编号是:11

操作系统怎么知道野指针了呢?

        操作系统认为对nullptr地址访问无意义,认为报错。然后在MMU中记录起原因,然后进程就知道错误原因,知道原因后就能做出相应的行为,发送11号信号。

由软件条件产生信号 

SIGPIPE是一种由软件条件产生的信号,在“管道”中已经介绍过了。本节主要介绍alarm函数和SIGALRM信号。

 #include <unistd.h>

unsigned int alarm(unsigned int seconds);

调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动 作是终止当前进程。

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

int main(int argc ,char *argv[])

    
    int cnt=0;
    alarm(1);
    while(true)
    
        printf("cnt:%d\\n",cnt);
        cnt++;
    

    return 0;

在一分钟后闹钟响起,操作系统向进程发送信号 14) SIGALRM 

这份代码的意义是什么呢?

        统计1s左右,我们计算机能够将数据累加多少次!

//多次运行,统计结果

cnt:111965Alarm clock (1)

cnt:124696Alarm clock (2)

cnt:131017Alarm clock (3)

cnt:128791Alarm clock (4)

将代码调整后,观察:首先将cnt调整成全局变量,再设置signal捕捉,循环中只++,signal调用的函数进行打印。 

//多次打印后的结果

[hongxin@VM-8-2-centos 2023-4-4]$ ./mysignal 
 获取一个信号吗,信号编号是:561830771

[hongxin@VM-8-2-centos 2023-4-4]$ ./mysignal 
 获取一个信号吗,信号编号是:563495923
[hongxin@VM-8-2-centos 2023-4-4]$ ./mysignal 
 获取一个信号吗,信号编号是:562496315
[hongxin@VM-8-2-centos 2023-4-4]$ ./mysignal 
 获取一个信号吗,信号编号是:562106150

为什么第二次++的次数与第一次++的次数相差几乎500倍呢?

        因为第一次打印比较多,就会进行多次I/O操作,I/O会花费大量的时间。得出结论:IO非常慢

还有一个很有小细节,当调用的catchSig时,没有写exit函数时,运行后也打印一次。相当于这个闹钟只响一次,一次过后不再响。

void catchSig(int signo)

    std::cout<< " 获取一个信号吗,信号编号是:" << cnt <<std::endl;
    //exit(1);

如果我们想闹钟一直响,我们可以catchSig中再设置alarm。

void catchSig(int signo)

    std::cout<< " 获取一个信号吗,信号编号是:" << cnt <<std::endl;
    //exit(1);
    alarm(1);

如何理解闹钟是由软件条件产生信号?

        --"闹钟"其实就是用软件实现的

任意一个进程都可以通过alarm系统调用在内核中设置闹钟,操作系统中可能会存在很多闹钟,此时,操作系统就需要管理这些闹钟。管理闹钟就是需要先描述,再组织。

总结

1.上面所说的所有信号产生,最终都要有OS来进行执行,为什么?

        OS是进程的管理者 ,只有OS有权力向目标进程写入信号

2.信号的处理是否是立即处理的?

        不是,在合适的时候

3.信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适呢?

        是需要被保存下来的,被记录在PCB中

4.一个进程在没有收到信号的时候,能否能知道,自己应该对合法信号作何处理呢?

        能知道,当未收到信号时,对信号如何做处理已经被默认程序员写入在代码中的

5.如何理解OS向进程发送信号?能否描述一下完整的发送处理过程?

        操作系统发送信号,本质是在进程(结构体)的位图(signal)进行修改,将对应位图的信号编号进行置1处理,置1表示操作系统向进程发送信号,如果是0表示未发送信号。

核心转储

关于产生信号的退出问题,大部分信号的执行结构都是终止,但是有两种终止方式:Term,Core。那么他们有什么区别呢?

为了便于操作和理解,这里我们采用11号信号进行测试和观察,观察Core是如终止进程的。代码就是简单数组越界问题。

    while (true)
    
        int a[10];
        a[10000] = 106;
    

[hongxin@VM-8-2-centos 2023-4-4]$ ./mysignal 
Segmentation fault

结果只出现一个错误描述,感觉上与Term正常结束是几乎一样的,因为我们没有看见其他现象,但事实就是如此吗?

        首先我这里是使用的云服务器,在云服务器上,默认如果进程是core退出,暂时看不到明显的现象,如果想看到可以输入:ulimit -a 进行观察

通过观察发现,core file size设置为 0,则代表了云服务器默认关闭了core file选项。如果我们想打开此选项:ulimit -c 1024;我们再输入ulimit -a查看:

core file size          (blocks, -c) 1024

当我们打开云服务器的core file选项后,再运行当前代码。我们发现不仅多了core dumped,而且还多生成了一个core文件。

[hongxin@VM-8-2-centos 2023-4-4]$ ./mysignal 
Segmentation fault (core dumped)

[hongxin@VM-8-2-centos 2023-4-4]$ ll
total 304
-rw------- 1 hongxin hongxin 557056 Apr  5 10:36 core.598
-rw-rw-r-- 1 hongxin hongxin    167 Apr  4 00:29 makefile
-rwxrwxr-x 1 hongxin hongxin  47864 Apr  5 10:20 mysignal
-rw-rw-r-- 1 hongxin hongxin   2734 Apr  5 10:19 mysignal.cc
-rwxrwxr-x 1 hongxin hongxin   9176 Apr  5 10:20 mytest
-rw-rw-r-- 1 hongxin hongxin    259 Apr  4 00:00 mytest.cc

core dumped--核心转储 ;core.598->这个598--引起core问题进程的pid;core.589一般是以core+引起core问题进程pid命名文件,该文件存在磁盘中。

为什么需要有核心转储呢?

        目的是为了支持调试,如何支持呢?直接在gdb的上下文中core-file core.xxx

作为对面,还是用上述代码,不报段错误,直接死循环用kill -2 pid终止进程(Term)。然后观察终止后会不会产生core文件,发生核心转储。

 SIGINT        2       Term    Interrupt from keyboard

结论

        以core退出的可以被核心转储,Term退出是没有被核心转储即为正常退出。核心转储其目的是为了更便于调试。 

最后关系信号产生,最后一个问题:

        如果将全部信号捕捉,然后自定义处理后,是不是该进程一直被执行,就不能被终止。

为了探究这个问题,下面进行代码测试,实验见证真理。

然后我们发现即使其他进程都被自定义处理后,但是kill -9 还是能将进程终止的。9号信号是管理员信号,在操作系统内是静止对9号信号做捕捉的。

信号的保存--阻塞信号

信号其他相关常见概念

        ●实际执行信号的处理动作称为信号递达(Delivery)

        信号从产生到递达之间的状态,称为信号未决(Pending)。

        进程可以选择阻塞 (Block )某个信号。

        被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.

        注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

对于抵达,未决,阻塞这三个名词是非常有必要了解其意思。相信大家很好理解抵达和未决。对于阻塞是不易理解的。

        例:整个这个信号的过程,我们可以用生活中的例子理解,上课时老师发出信号,说:大家把书上例题1,7,13勾画上,有时间去把做了。这个时候由于老师需要继续上课,我们有更要的事情需要处理,就在书上记录(勾画)上题,但是一直没有做。这段时间老师发出的信号就叫做未决。

当回家后我们觉得这个老师讲的知识点很难,我们不想做,该信号阻塞(记录但不做)。但是过一段时间你认为老师很严格,不做后果很严重,就选择把例题做了,该信号抵达。

还有一种情况,老师上课说:把这个例题算出来(信号的产生),大家不需要记录,直接就做。在这个过程不需要保存信号,发出信号直接执行(抵达)。

注意,阻塞和未决是不一样的,阻塞是需要保存这个信号,未决是在发出信号,不管你保不保存信号都是未决状态。

注意,阻塞和忽略也是不同。比如当老师布置了例题(发出信号),我们认为做不做都不影响时,我们直接不记录这些例题(忽略)。在未保存信号下,我们执行的策略是(忽略)。在发出信号到未做这段时间处于未决状态,阻塞是在未决状态之间的。忽略是在未决之后,更是在抵达(如何执行老师的信号)之后。所以说忽略是在递达之后可选的一种处理动作。

在内核中的表示     

信号在内核中的表示示意图

        ●每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针(handler)表示处理动作。

        ●在代码运行过程中,在用户层调用signal函数,对信号进行捕捉,若有信号被捕捉到,操作系统向进程发送信号,进程中pending位图置1,如果操作系统判断该信号可以立即执行,则不需要保存信号,例如  SIGINT 信号--正常处理(Term )。

        ●如果是SIGQUIT信号需要保存处理--( Core),一旦产生SIGQUIT信号将被阻塞,当它的处理动作是用户自定义函数sighandler。此时需要接触对该信号的阻塞,然后用内核态转入到用户态,对用户态的handler进行执行。

        ●如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。

地址空间第二讲

关于上述介绍,我们发现程序员写的代码是在用户态那一层,PCB则是在内核态一层。什么时候需要访问内核态呢?内核资源是通过什么访问的呢?

在这个进程中,有两块空间,其中一块是让用户使用的,另外的一块是让操作系统使用的。

        例:这好比在学校,作为学生我们只能在自己的班级上课,而不能去其他班级。作为校长就可以随便去哪个班级。我们则是用户,校长就好比操作系统。

用户为了访问内核或者硬件资源都必须通过系统调用。但系统调用往往都比较耗时,因为系统调用会进行大量操作,所以我们应该尽量避免频繁的调用系统操作。

                                                                                                                      

这里介绍了进程中有用户态和内核态,也知道了如果用户态如果想访问操作系统本身的资源或者硬件资源时,需要通过系统调用访问,而且系统调用比较耗时。

系统调用是用户用相关系统调用接口实现访问内核资源,这个调用过程是CPU完成的,所以我们还是不理解,操作系统是如何跟进程联系起来的呢?

我们知道在CPU中有大量的寄存器

        1.可见寄存器,如exa,exd等通用寄存器。

        2.不可见寄存器,如状态寄存器,CR3等寄存器。

凡是这些寄存器与当前进程相关,进程就会存储寄存器的上下文数据--(保存了程序运行时寄存器的当中的内容:如一个进程在运行过程中被切换出去,上下文信息就保存了寄存器的信息,直到这个进程重新拥有cpu资源)。

在CPU中的诸多寄存器中,有指定寄存器保存task_struct的起始地址实现直接跳转到进程中,也有指定寄存器保存页表起始地址,还有CR3表示当前进程的运行级别的指定寄存器。上下文数据也有专门的寄存器保存。

知道了他们之间的联系,那么他们又是如何执行的呢?比如我是一个进程,怎么就跑到操作系统中去执行方法呢?

如上图,则是进程是如何调用到系统资源的原理图。

        ●每个进程的数据都被保存到相应的寄存器中,当在用户空间执行程序时,相关上下文的寄存器运行。当系统识别到用户通过系统调用访问内核数据时,在CPU中这个系统调用接口,在起始的位置就会帮你调整进程的运行级别,系统调用接口会通过Int 80-陷入内核(在设计系统接口时就已经编写好的),Int80就会用到CR3寄存器。改变运行级别:将级别0变成1。

        ●而且每个进程都有3-4G的内核空间,都会共享内核级页表,无论进程如何切换都不会改变3-4的内核数据资源。所以在CPU中的指定寄存器中改变运行级别后,直接在mm_struct直接实现跳转获取相关的内核数据。

sigset_t

从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。 因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。下一节将详细介绍信号集的各种操作。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。

sigset_t实质就是结构体封装的一个数组,在c++中bitset也讲过--位图。

# define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))

typedef struct

 

    unsigned long int __val[_SIGSET_NWORDS];

  __sigset_t;

#endif

信号集操作函数 

sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统 实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作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所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有效信号。

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

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

        ●这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。  

sigprocmask 

调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)--block。

#include <signal.h>

int sigprocmask(int how, const sigset_t *set, sigset_t *oset);

返回值:若成功则为0,若出错则为-1

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

如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。

sigpending

#include <signal.h>

int sigpending(sigset_t *set);

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

 面用刚学的几个函数做个实验。程序如下:

#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <vector>

//#define BLOCK_SIGNAL 2
#define MAX_SIGNUM 31
static std::vector<int> sigarr = 2;


static void show_pending(const sigset_t &pending)

    for(size_t signal=MAX_SIGNUM; signal > 0; --signal)
    
        if(sigismember(&pending,signal))
        
            std::cout<< "1";
        
        else
        
            std::cout<< "0";
           
    
    std::cout<<std::endl;
 

static void myhandler(int signo)

    std::cout << signo << " 号信号已经被递达!!" << std::endl;


int main()

    for(const auto &sig : sigarr) signal(sig, myhandler);

    //1.尝试屏蔽指定的信号
    sigset_t block,oblock,pending;
    //1.1初始化
    sigemptyset(&block);
    sigemptyset(&oblock);
    sigemptyset(&pending);
    //1.2添加要屏蔽的信号
    //批量化屏蔽
    for(const auto &sig : sigarr) sigaddset(&block, sig);
    //1.3开始屏蔽,设置进内核(进程)
    sigprocmask(SIG_SETMASK,&block,&oblock);
    //2.遍历打印pending信号集
    int cnt = 10;
    while(true)
    
        //2.1初始化
        sigemptyset(&pending);
        //2.2获取pending
        sigpending(&pending);
        //2.3打印
        show_pending(pending);
        //慢一点
        sleep(1);
         if(cnt-- == 0)
        
            sigprocmask(SIG_SETMASK, &oblock, &block); //一旦对特定信号进行解除屏蔽,一般OS要至少立马递达一个信号!
            std::cout << "恢复对信号的屏蔽,不屏蔽任何信号\\n";
        
    

这里就不将代码的运行结果打印出来了,自己去运行一下对于尝试对结果进行分析,这样学习效果可能会更好。

该代码证明了:信号如果是被block,它是无法被抵达的,只能被pending

信号的抵达处理--捕捉信号 

捕捉的流程

如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。

为了便于记忆,我们将图简化倒过来的8:

捕捉信号的方法--sigaction(新增)

sigaction函数可以读取和修改与指定信号相关联的处理动作。

#include <signal.h>

       int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

signum

        指定信号的编号,并且可以是除SIGKILL和SIGSTOP之外的任何有效信号。

act,oldact,sigaction :

        若act指针非空,则根据act修改该信号的处理动作。若oact指针非 空,则通过oact传 出该信号原来的处理动作。act和oact指向sigaction结构体 :

struct sigaction
               void     (*sa_handler)(int);
               void     (*sa_sigaction)(int, siginfo_t *, void *);
               sigset_t   sa_mask;
               int        sa_flags;
               void     (*sa_restorer)(void);
           ;

                ●将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。                 ●sa_sigaction和sa_restorer一般设置为nullptr,sa_flags设置为0,都不管它。

                ●sa_mask其定义类型sigset_t   ,在上诉中已经讲过,本质是数组,用结构体封装的数组。其中可包括定义block,pending位图的信号集。

return val:

        returns 0 on success; on error, -1 is returned, and errno is set to indicate the error.

下面通过代码测试来熟悉sigaction,我们代码大致实现的功能:调用sigaction对SIGINT信号进行捕捉,捕捉到SIGINT信号后调用handler方法,在SIGINT信号发生打印确认捕捉,细节睡眠10秒。程序如下:

#include <iostream>
#include <signal.h>
#include <unistd.h>

using namespace std;

void Count(int cnt)

    while (cnt--)
    
        cout<< cnt<<" ";
        fflush(stdout);
        sleep(1);
    
    cout<<endl;


void handler(int signal)

    cout<< "get a signal"<<signal <<endl; 
    Count(10);


int main()

    struct sigaction act,oldact;
    act.sa_flags=0;
    act.sa_restorer=nullptr;
    act.sa_handler = handler;
    sigemptyset(&act.sa_mask);

    sigaction(SIGINT,&act,&oldact);

    while(true) sleep(1);

    return 0;

在下图运行结果中,发现当我们一直用kill调用SIGINT信号,但sigaction并不是每次都捉,        

        ●现象一:当只用kill一次调用SIGINT信号,只打印一次,睡眠结束后不打印。

        ●现象二:多次kill调用SIGINT信号,每次最开始打印1次,当睡眠10秒结束后,再打印一次。

有上面现象,我们可以得出:

        ●当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止(现象二)。

        ●一般一个信号被解除屏蔽的时候,会自动进行抵达当期屏蔽的信号,如果该信号已经被pending的话,没有就不做任何动作(现象一)。

        ●如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。sa_flags字段包含一些选项,本章的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函数。

结论:进程处理信号的原则是串行处理同类型的信号,不允许递归处理。 

再度理解sigaction函数的参数sa_mask:

        当我们正在处理某一种信号的时候,我们也想顺便屏蔽其他信号,就可以添加到这个sa_mask中

        例:在当前代码上加入,其效果是,不仅能屏蔽信号2,还能屏蔽3;

    sigaddset(&act.sa_mask,3);

可重入函数

下列在主函数中,调用insert时开始插入一半的时候,如果调用了信号捕捉,然后信号捕捉的自定义函数handler中又调用insert。

因为我们知道单链表的插入是头插,第一步:node1->next=head。但此时调用了handler方法,第二步:就从head = node1变成了node2->next=head,最后head会先:head=node2,然后head=node1,在同一步进行了,就会出现head只链接head1的问题。

具体解释如下: 

       ●main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。

        ●像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称 为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。想一下,为什么两个不同的 控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?        

一般而言,main执行流与信号捕捉是两个执行流

        1.如果在main中,和在handler中,该函数被重复进入,出现问题--该函数(insert)不可重入函数

           1.如果在main中,和在handler中,该函数被重复进入,未出现问题--该函数(insert)可重入函数

首先我们应该明白不可重入函数不是一个问题,而是在单执行流下的特性。因为在很多场景下我们是在单执行流下调用,该函数的起始目的也不是为了在多执行流下调用的。所以不是不可重入函数出现了问题,而是用户调用是没有想明白而已。

volatile 

相信大家在c语言就已经对volatile关键字涉猎了,一段代码中加volatile与不加volatile查看汇编代码后,得出结论是:volatile忽略编译器的优化,保持内存可见性。

在gcc中也有编译器的优化级别,我们通过man gcc查看

name=value -O1,-O2,-O3,-Os,-Ofast

这里先看不被优化的程序:

#include <stdio.h>
#include <signal.h>

int quit =0;

void handler(int signo)

    printf("%d 信号已经被捕捉!\\n",signal);
    printf("quit -> %d\\n",quit); 
    quit=1;
    printf("-> %d\\n",quit);


int main()

    signal(2,handler);
    while(!quit);
    printf("注意,我是正常退出!\\n");
    return 0;

该代码:如果未发送SIGINT,程序一直循环,当发送SIGINT信号,信号被捕捉,进入handler将quit置1,然后正常退出。

[hongxin@VM-8-2-centos 2023-4-7]$ ./mysignal 
^C4195520 信号已经被捕捉!
quit -> 0
-> 1
注意,我是正常退出!

当我们将gcc的运行级别改成-O不退出3时,我们再观察发现,改代码

[hongxin@VM-8-2-centos 2023-4-7]$ ./mysignal 
^C4195520 信号已经被捕捉!
quit -> 0
-> 1

|

操作系统中与CPU的关系,CPU相当于毛坯房,操作系统是装修。那么对于CPU会进行以下几个步骤:

        1.取指令

        2.分析指令

         3.执行命令

        4.将结果写会对应的内存

其原理图如下:

如何解决这个问题呢?我们直接在quit前volatile,程序正常。

volatile int quit =0;

[hongxin@VM-8-2-centos 2023-4-7]$ ./mysignal 
^C4195520 信号已经被捕捉!
quit -> 0
-> 1
注意,我是正常退出!

volatile:保持内存可见性。

        由于gcc被优化,在代码中如果需要访问内存数据,就需要加volatile,其目的是为了保持内存的可见性,让寄存器能够访问内存数据相反,不能不保持内存可见性,那么在用户态中quit的临时数据就不会被改变,也不会向内存中访问被修改的数据。

SIGCHLD信号 - 选学了解

进程一章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。

其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。

请编写一个程序完成以下功能:父进程fork出子进程,子进程调用exit(2)终止,父进程自定义SIGCHLD信号的处理函数,在其中调用wait获得子进程的退出状态并打印。

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>

int quit = 0;

void handler(int signo)

    printf("pid : %d,%d信号,正在被捕捉\\n",getpid(),getppid());


void Count(int cnt)

    while (cnt)
    
        printf("cnt: %2d\\r", cnt);
        fflush(stdout);
        cnt--;
        sleep(1);
    
    printf("\\n");


int main()

    // 显示的设置对SIGCHLD进行忽略
    signal(SIGCHLD, handler);
    //signal(SIGCHLD, SIG_DFL);

    printf("我是父进程, %d, ppid: %d\\n", getpid(), getppid());

    pid_t id = fork();
    if (id == 0)
    
        printf("我是子进程, %d, ppid: %d,我要退出啦\\n", getpid(), getppid());
        Count(5);
        exit(1);
    

    while (1)
        sleep(1);

    return 0;

测试结果:

[hongxin@VM-8-2-centos 2023-4-8]$ ./mysignal 
我是父进程, 2945, ppid: 21647
我是子进程, 2946, ppid: 2945,我要退出啦
cnt:  1
pid : 2945,21647信号,正在被捕捉

该上面是证明了子进程退出会向父进程发送 SIGCHLD信号,但未对父进程在信号处理函数中调用wait清理子进程,下面就是在handler中wait清理子进程的代码和解释。

void handler(int signo)

    //1.有很多子进程,在同一个时刻退出
        //--在同一时刻退出也必须依次退出,必须while退完
    //2.有很多子进程,在同一时刻只有一步部分退出
        //--尽管只有一部分退出,对于系统而言,它是不知道到底有多少个进程需要退出,那么只有退完之后才知道
        //--在waitpid中默认的是阻塞是等待,如果没有退出完,就会发生僵尸
        //--所以我们将等待改成非阻塞,
    while (1)
    
        //如果指定了WNOHANG,并且存在一个或多个由pid指定的子(ren),但尚未更改状态,则返回0。出现错误时,返回-1。
        pid_t ret = waitpid(-1,NULL,WNOHANG);
        if (ret == 0)
        
            //ret==0 则就是 waitpid调用成功 && 子进程没退出
            //子进程没有退出,我的waitpid没有等待失败,仅仅是监测到了子进程没退出.那么继续等待退出即可
            break;
        
        else if(ret > 0)
        
            //waitpid调用成功 && 子进程退出成功
            printf("wait child success %d\\n ",ret);
        

        printf("child is quit! %d\\n",getpid());
        
    
    
    printf("pid : %d,%d信号,正在被捕捉\\n",getpid(),getppid());

事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证 在其它UNIX系统上都可 用。请编写程序验证这样做不

进程信号(Linux)

Linux进程信号

信号入门

1、生活角度的信号

  • 你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递”
  • 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取”。
  • 在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“记住了有一个快递要去取
  • 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:
     1. 执行默认动作(幸福的打开快递,使用商品)
     2. 执行自定义动作(快递是零食,你要送给你你的女朋友)
     3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)
  • 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话

2、技术应用角度的信号

先看下面的代码,一眼就看出来是死循环。

1 #include <stdio.h>
2 int main()
3 
4   while(1)                                                                                                                                                                     
5   printf("I am a process, I am waiting signal!\\n");
6   sleep(1);
7   
8 

我们直接Ctrl+C终止这个进程

我们知道可以终止这个进程,但是为什么能终止?

我们输入Ctrl+C,在Shell下启动一个前台进程 ,这个键盘输入产生一个硬件中断,被OS获取,解释成信号(Ctrl+C是2号信号),2号信号发送给目标前台进程。前台进程因为收到2号信号,进而引起进程退出。

上面是系统默认的处理方式,输入Ctrl+C就会终止掉进程,就跟你默默的打开快递一样,没有做任何思考(进程就是你,操作系统就是快递员,信号就是快递)。
那么我们是否可以自定义进行处理?当然是可以的,这里先介绍一个函数:signal函数

#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
signal()将信号符号设置为处理程序,处理程序可以是SIGIGN、SIG-DFL,也可以是程序员定义的函数(“信号处理程序”)的地址。
signum:信号编号
如果配置设置为SIG_IGN,则忽略该信号。
如果配置设置为SIG_DFL,则发生与信号关联的默认动作。
如果配置设置为一个功能,然后首先要么配置重置到SIG_DFL,或sianal被阻塞,然后处理程序是用signum参数调用。如果对处理程序的调用导致信号被阻塞,那么信号在从处理程序返回时被解除阻塞。
handler:对应自处理方法

我们写来用signal函数自定义捕抓这个信号,signal函数对2号信号进行捕捉,如果我们捕抓到了2号信号,证明Ctrl+C确实是收到了2号信号

    1 #include <stdio.h>
    2 #include <signal.h>
    3 void handler(int signo)                                                                                                                                                         
    4 
    5   printf("this signo is %d\\n",signo);
    6 
    7 int main()
    8 
    9   signal(2, handler);
   10 	while(1)
   11 		printf("I am a process, I am waiting signal!\\n");
   12 		sleep(1);
   13 
   14 

当该进程收到2号信号时,我们在输入Ctrl+C就不会终止进程,而是执行我们对应的自定义方法,捕抓到了该信号,并打印出来。由此证明,Ctrl+C确实是收到了2号信号。

这里要退出这个进程可以使用Ctrl+\\(Ctrl+\\对应3号信号SIGQUIT,代表退出信号) 。

3、注意

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

4、信号概念

信号是进程之间事件异步通知的一种方式,属于软中断。

5、用kill -l命令可以察看系统定义的信号列表

下面是每个信号编号对应的信号。

其中1-31号信号是普通信号,34-64号是实时信号。每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定义#define SIGINT 2(Ctrl+C对应的信号)

我们可以通过命令,找出对应信号编号宏定义的路径,在查看其宏定义。

[dy@VM-12-10-centos rumen]$ sudo find / | grep 'signum.h'
/usr/include/bits/signum.h

下面是信号对应的宏定义

6、信号是如何发送的以及如何记录?

  1. 信号如何发送:进程收到信号,本质是进程内对应结构体里面的信号位图被修改了,只有OS有这个资格去修改,所以是OS直接去修改了目标进程task_struct中的信号位图。例外信号发送的方式有多种,但是只有OS有资格发送(掌管生杀大权)。
  2. 信号如何记录:当一个进程接受到信号后,该信号记录在进程对应的task_struct(PCB)中,PCB本质就是一个结构体变量,我们可以用32位的位图来记录一个信号是否产生。

    其中比特位的位置代表信号编号,比特位的内容就代表是否收到信号,比如第6个比特位是1就表明收到了6号信号。

7、信号处理常见方式概览

可选的处理动作有以下三种:

  1. 忽略此信号。(忽略并不是不处理,比如你拿了快递丢到一边,继续玩手机)
  2. 执行该信号的默认处理动作。
  3. 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号

可以通过命令man 7 signal查看各个信号默认的处理动作

注意:SIGKILL和SIGSTOP命令是不能捕捉的,捕捉了,系统就不能杀掉一个进程和停止一个进程了。

产生信号

1、通过终端按键产生信号

刚开始,我们就已经展示了Ctrl+C能终止一个进程。其实Ctrl+\\也可以终止掉一个进程。我们继续拿第一次的代码来展示。

1 #include <stdio.h>
2 int main()
3 
4   while(1)                                                                                                                                                                     
5   printf("I am a process, I am waiting signal!\\n");
6   sleep(1);
7   
8 

终止掉了该进程。

那么Ctrl+C和Ctrl+\\ 有什么区别?

1、Ctrl+C对应2号信号(SIGINT),SIGINT的默认处理动作是终止进程。
2、Ctrl+\\ 对应3号信号(SIGQUIT),SIGQUIT的默认处理动作是终止进程并且Core Dump。

这个两个命令都是终止进程,但是SIGQUIT多出了一个操作为Core Dump(核心转储)。

Core Dump

那么什么是核心转储(Core Dump)?我们先来看一下这个两个命令对应的信号的默认处理方式。


从上面这张图,我们看到两个都是终止进程,但是对应的行为Action是不一样的,一个对应Term,一个对应Core,我们在来看看这两个的区别。

看完这两个的区别之后,我们在来看核心转储的概念。

  1. 首先解释什么是Core Dump。当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部 保存到磁盘上,文件名通常是core,这叫做CoreDump。
  2. 进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortemDebug(事后调试)。
  3. 一个进程允许产生多大的core文件取决于进程的Resource Limit(资源限制)(这个信息保存在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。首先用ulimit命令改变Shell进程的Resource Limit,允许core文件最大为1024K: $ ulimit -c 1024

了解概念之后我们继续往下走,刚刚我们知道一个进程默认是不允许产生core文件的,即核心转储被关闭了,我们可以通过使用ulimit -a命令查看当前资源限制的设定。
可以看到,core file size的大小为0,因为核心转储被关闭了。我们可以通过命令ulimit -c size来修改其大小,这就代表着核心转储被打开了。

注意:ulimit命令改变了Shell进程的Resource Limit,test进程的PCB由Shell进程复制而来,所以也具有和Shell进程相同的Resource Limit值,这样就可以产生Core Dump了


我们在运行刚开始的文件,输入Ctrl+\\ 就会显示Core Dump,并且会产生一个Core文件(保存进程的用户空间内存数据),core文件后面还带有一个进程编号pid


我们在对应的程序里面获取对应的pid,来验证一下是否是对应的进程编号。

  1 #include <stdio.h>
  2 #include <unistd.h>
  3 int main()
  4 
  5   while(1)
  6     printf("pid:%d\\n",getpid());                                                                                                                                                  
  7   printf("I am a process, I am waiting signal!\\n");
  8   sleep(1);
  9   
 10 

我们这里可以看到是一样的,说明就是对应的进程编号

上面我们的程序异常终止了,我们肯定会去想为什么终止,这时候就需要去调试代码。
核心转储作用:核心转储的目的就是为了在调试时,方便问题的定位。

我们写一段代码来展示:

  #include <stdio.h>    
  #include <unistd.h>    
  int main()    
      
    printf("hello world\\n");    
    sleep(3);                                                                                                                                                                         
    int a = 1/0;    
    return 0;    
    

除0错误,生成了一个core文件。

我们通过命令gdb对当前的可执行程序进行调试,然后直接使用core-file core文件命令加载core文件,即可判断出该程序在终止时收到了8号信号,并且定位到了错误的代码处,显示行数。

这样的操作就叫事后调试,以后我们的程序崩溃了,就可以打开核心转储,gdb调试即可找到对应报错的行数。

我们之前学进程等待的时候,其中的waitpid函数和wait函数:

pid_t waitpid(pid_t pid, int *status, int options);
pid_t wait(int*status);

status是一个输出型参数,用于获取子进程的退出状态。status的不同比特位所代表的信息不同(可以当位图理解,0表示没有,1表示有),具体如下图。

如果进程是正常退出,高八位表示其退出状态,如果进程是异常终止,则低七位表示该进程收到对应的终止信号,第八位是core dump标志,表示该进程是否进行了核心转储。

我们编写一个进程等待的程序,在子进程中进行一个非法访问,比如野指针异常、除0、异常终止等。

  #include <stdio.h>    
  #include <stdlib.h>    
  #include <unistd.h>    
  #include <sys/wait.h>    
  #include <sys/types.h>    
  int main()    
      
    pid_t pid = fork();    
    if(pid < 0)    
        
      perror("fork fail");    
      return 1;    
        
    else if(pid == 0)    
        
      printf("i am child\\n");    
      int a = 1/0;    
        
    else    
        
      int status = 0;    
      int ret = wait(&status);    
      printf("exitCode:%d, core dump:%d, exitSignal:%d\\n",(status>>8)&0xFF,(status>>7)&1, status & 0x7F);                                                                             
        
    return 0;    
     

通过下面的运行结果,我们可以看到core dump标志位为1,即第八位为1,说明对应子进程发生了核心转储(为0则表示没有发生核心转储)。

子进程没有发生核心转储,core dump标志位为0。

所以我们现在知道了core dump标志位的作用:表示一个进程崩溃的时候,是否进行了core dump。

组合键

通过终端按键产生信号,并不只有Ctrl+C、Ctrl+\\,还有其他的组合键。我们这里写一个程序,将1-31号信号进行捕获,并将收到信号后的默认处理动作改为我们自定义的动作。

  
#include <stdio.h>
#include <unistd.h>
#include <signal.h>

void handler(int signal)

	printf("catch signal:%d\\n", signal);

int main()

	int signo;
	for (signo = 1; signo <= 31; signo++)
		signal(signo, handler);
	
	while (1)
		sleep(1);
	
	return 0;



通过运行结果,我们可以看到Ctrl+C、Ctrl+\\、Ctrl+Z都被捕获了,然后执行我们的自定义动作,需要注意的是,我们去杀死这个程序时,执行kill pid,我们可以捕抓到对应的信号,但是kill -9 pid我们是捕获不到的,刚开始就说过,如果9号信号捕获了,那么一个进程就永远都杀不死了,即便是操作系统本身。

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

我们先在后台执行一个死循环程序,然后执行kill -SIGSEGV pid命令发送SIGSEGV信号给该进程。

指定发送某种信号的kill命令可以有多种写法,上面的命令还可以写成 kill -SIGSEGV 19707或 kill -11 19707,11是信号SIGSEGV的编号。以往遇 到的段错误都是由非法内存访问产生的,而这个程序本身没错,给它发SIGSEGV也能产生段错误。

kill命令:调用kill函数实现的。kill函数可以给一个指定的进程发送指定的信号。

#include <signal.h>
int kill(pid_t pid, int signo);
成功返回0,错误返回-1

模拟kill命令

#include <stdio.h>    
#include <stdlib.h>    
#include <sys/types.h>    
#include <signal.h>    
void Usage(char* proc)    
    
  printf("Usage: %s pid signo\\n", proc);                                                
    
int main(int argc, char* argv[])    
    
  if(argc != 3)    
    Usage(argv[0]);    
    return 1;    
      
  argv是命令行参数指针数组
  pid_t pid = atoi(argv[1]);    
  int signo = atoi(argv[2]);    
  kill(pid, signo);    
  return 0;    

我们开起一个后台进程

[dy@VM-12-10-centos SignalGeneration]$ sleep 1000 &
[1] 30379

然后我们通过自己编写的代码,通过程序名 进程pid 信号编号杀死该后台进程。

raise函数:raise函数可以给当前进程发送指定的信号(自己给自己发信号)

int raise(int signo);
成功返回0,错误返回-1

通过程序,我们来看一下raise的使用:

  1 #include <stdio.h>
  2 #include <signal.h>
  3 #include <unistd.h>
  4 void handler(int signo)
  5 
  6   printf("this signo is %d\\n",signo);
  7 
  8 int main()
  9 
 10   signal(2, handler);
 11   while(1)
 12   
 13     sleep(1);
 14     raise(2);                                                                                                               
 15   
 16 

每隔一秒给自己发送一个2号信号

当然,9号信号不能自己给自己发,会直接终止掉进程的,至于原因,这里就不在冗余。

abort函数:使当前进程接收到信号而异常终止。

#include <stdlib.h>
void abort(void);
就像exit函数一样,abort函数总是会成功的,所以没有返回值

通过程序,我们来看一下abort的使用:

  1 #include <stdio.h>
  2 #include <signal.h>
  3 #include <stdlib.h>
  4 #include <unistd.h>
  5 void handler(int signo)
  6 
  7   printf("this signo is %d\\n",signo);
  8 
  9 int main()
 10 
 11   signal(6, handler);                                                                                                       
 12   while(1)
 13   
 14     sleep(1);
 15     abort();
 16   
 17 

发送一个指定信号(SIGABRT:6)就异常终止。

abort函数的作用是异常终止进程,而abort本质是通过向当前进程发送SIGABRT信号而终止进程的,但使用abort函数终止进程总是成功的。
exit函数的作用是正常终止进程,使用exit函数终止进程可能会失败,

3、由软件条件产生信号

进行通信中的SIGPIPE信号:

  • SIGPIPE信号是一种由软件条件产生的信号,当进程在使用管道进行通信时,读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么此时写端进程就会收到SIGPIPE信号进而被操作系统终止。

我们来看例外的一种软件条件产生的信号,SIGALRM信号:

#include <unistd.h>
unsigned int alarm(unsigned int seconds);
调用alarm 函数可以设定一个闹钟
也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号
该信号的默认处理动作是终止当前进程

alarm函数的返回值:
若调用alarm函数前,进程已经设置了闹钟,则返回上一个闹钟时间的剩余时间,并且本次闹钟的设置会覆盖上一次闹钟的设置。
如果调用alarm函数前,进程没有设置闹钟,则返回值为0

比如,我们可以看看在自己的终端上面,一秒钟可以将一个变量累加到多少。

  1 #include <stdio.h>
  2 #include <unistd.h>
  3 int main()
  4 
  5   int count = 0;
  6   alarm(1);
  7   while(1)
  8   
  9     count++;
 10     printf("count = %d\\n",count);                                                   
 11   
 12 

在我自己的服务器上,可以加到24000左右。

其实,count累加的值远远大于上面的运行结果。由于我们每进行一次累加就进行了一次打印操作,而与外设之间的IO操作所需的时间要比累加操作的时间更长。再加上自己网络传输等耗时,所以累加的比较少。

我们可以让count一直累加,然后捕获SIGALRM信号,最后在输出count看看是多少。

  1 #include <stdio.h>
  2 #include <unistd.h>
  3 #include <signal.h>
  4 #include <stdlib.h>
  5 int count = 0;
  6 void handler(int sig)
  7 
  8   printf("catch a signal:%d\\n", sig);                                               
  9   printf("count = %d\\n",count);
 10   exit(-1);
 11 
 12 int main()
 13 
 14   signal(SIGALRM, handler);
 15   alarm(1);
 16   while(1)
 17   
 18     count++;
 19   
 20   return 0;
 21 


由此证明:与计算机单纯的计算相比较,计算机与外设进行IO时的速度是非常慢的。

4、硬件异常产生信号

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

模拟一下野指针异常

    1 #include <stdio.h>
    2 #include <unistd.h>
    3 #include <signal.h>
    4 #include <stdlib.h>
    5 int main()                                                                        
    6                                     
    7   int *p;                            
    8   *p = 100;
    9   return 0;
   10 


通过运行结果我们可以得知,程序异常了,操作系统是如何识别到的?

首先,学进程的时候我们就知道,访问一个变量,需要经过页表的映射,虚拟地址转换成物理地址,最终找到对应的数据位置。


总结:

1、其中页表属于一种软件映射关系,而实际上在从虚拟地址到物理地址映射的时候还有一个硬件叫做MMU,它是一种负责处理CPU的内存访问请求的计算机硬件,因此映射工作不是由CPU做的,而是由MMU做的,但现在MMU已经集成到CPU当中了。

2、当需要进行虚拟地址到物理地址的映射时,我们先将页表的左侧的虚拟地址导给MMU,然后MMU会计算出对应的物理地址,我们再通过这个物理地址进行相应的访问。

3、而MMU既然是硬件单元,那么它当然也有相应的状态信息,当我们要访问不属于我们的虚拟地址时,MMU在进行虚拟地址到物理地址的转换时就会出现错误,然后将对应的错误写入到自己的状态信息当中,这时硬件上面的信息也会立马被操作系统识别到,进而将对应进程发送SIGSEGV信号。

所以C/C++程序崩

以上是关于Linux--进程信号的主要内容,如果未能解决你的问题,请参考以下文章

《Linux高性能服务器编程》学习总结——信号

linux进程间通信异步信号处理机制

linux进程间通信异步信号处理机制

linux系统上信号发送和信号接收讲解

linux 多线程信号处理总结

linux云自动化运维基础知识8(进程)