【Linux】进程信号

目录

信号入门

1. 生活角度的信号

2. 技术应用角度的信号

3. 注意

4. 信号概念 

5. 查看信号列表

6. 信号处理常见方式概览

产生信号

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

2. 通过系统调用向进程发信号

3. 硬件异常产生信号

4. 由软件条件产生信号

5. CormDump

阻塞信号

1. 信号其他相关常见概念

2. 信号的保存和发送

3. 在内核中的表示

4. sigset_t

5. 信号集操作函数

sigprocmask

sigpending

捕捉信号

1. 内核态与用户态

2. 内核如何实现信号的捕捉

3. sigaction函数

4. 可重入函数 

5. volatile

6. SIGCHLD信号


信号入门

1. 生活角度的信号

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

生活中还有各种各样的信号:上下课铃声,红绿灯,发令枪,闹钟等等。这些其实都是信号。 

下面我们来从生活角度的信号输出几个信号的结论,支撑我们对信号的理解 

  1. 那我们是怎么算认识这些信号呢——1.能识别信号 2.知道信号的处理方法
  2. 即便是我们现在没有信号产生,我也知道信号产生之后,我该干什么?
  3. 信号产生了,我们可能并不立即处理这个信号,在合适的时候,因为我们可能正在做更重要的事情 -- 所以,信号产生后 --时间窗口.--信号处理时在这个时间窗口内,你必须记住信号到来!

2. 技术应用角度的信号

我们前面学习进程控制知道,我们在Shell下启动了一个前台进程,我们可以通过在键盘上使用Ctrl+c 组合键终止该进程

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

int main()
{
  while(1)
  {
    printf("I am a process,pid:%d\n",getpid());
    sleep(1);
  }
                                                
  return 0;
}

运行结果:

可以看到我们这里的进程本来是在正常运行的,但是当我们使用Ctrl+c 组合键之后,该进程就被终止了。 

那么为什么使用这个组合键就可以终止我们的进程呢?

这是因为用键盘使用Ctrl+c组合键之后会产生一个硬件中断,被OS获取,解释成信号,发送2号信号给前台进程,前台进程收到信号后便终止了。

下面我们来证明这里的前台进程收到了2号信号才被终止的。

我们先来来为大家介绍一个函数——signal函数

//功能:对一个信号进行捕捉,执行自定义处理方式

//函数原型:
#include<signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum,sighandler_t handler);

//参数:
//signum:表示需要捕捉的信号编号
//handler:表示对捕捉信号的处理方法,该方法的参数是int,返回值是void

下面我们就来使用一下signal函数,看一下我们使用组合键之后进程是否收到了二号信号 

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

void myhandler(int signo)
{
  printf("process get a signal:%d\n",signo);   
}

int main()
{
  signal(2,myhandler);
  while(1)
  {
    printf("I am a process,pid:%d\n",getpid());
    sleep(1);
  }

  return 0;
}

 运行结果:

 

我们看到这一次当我们启动一个前台进程之后再使用Ctrl+c 组合键,该进程并没有被终止。我们还看到我们通过signal函数捕捉到了2号信号,并且通过myhandler函数改变了信号的默认处理方式。这个现象就证明了上面说的:使用Ctrl+c 组合键其实是OS给前台进程发送了一个2号信号,从而导致了该进程终止。

3. 注意

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

4. 信号概念 

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

下面我们来从技术应用角度的信号输出几个信号的结论,支撑我们对信号的理解 

  1. 进程必须识别+能够处理信号 --- 信号没有产生,也要具备处理信号的能力 --- 信号的处理能力,属于进程内置功能的一部分
  2. 进程即便是没有收到信号,也能知道哪些信号该怎么处理
  3. 当进程真的收到了一个具体的信号的时候,进程可能并不会立即处理这个信号,合适的时候再进程处理。
  4. 一个进程必须当信号产生,到信号开始被处理,就一定会有时间窗口,进程具有临时保存哪些信号已经发生了的能力

通过生活角度和技术应用角度了解了信号之后,我们就可以将生活例子与信号处理的过程相结合,来解释一下信号处理的过程:

进程就相当于是你,然后操作系统就是快递员,信号就是快递,操作系统给进程发信号就好比快递员给你送快递,而我们收快递的方式也就相当于进程处理信号的方式。我们不一定会立即去取快递,而是等我们下班等更重要的事情做完再去处理。

总结: 信号是进程之间时间异步通知的一种方式。

5. 查看信号列表

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

  • 每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定义 #defineSIGINT 2
  • 1-31号属于普通信号,编号34以上的是实时信号,本章只讨论编号34以下的信号,不讨论实时信号。
  • 这些信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说明: man 7 signal 

6. 信号处理常见方式概览

(sigaction函数稍后详细介绍),可选的处理动作有以下三种:

  1. 忽略此信号。
  2. 执行该信号的默认处理动作。
  3. 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。

产生信号

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

在上面我们说过,我们可以通过在键盘上按Ctrl+c可以终止一个进程,这里终止进程的本质是按Ctrl+c产生了一个硬件中断,被OS获取,解释成信号,然后发送2号信号给前台进程,从而导致前台进程被终止。除了Ctrl+c之外,我们还可以按Ctrl+\或者Ctrl+z来终止一个前台进程。

我们可以看到通过按Ctrl+\或者Ctrl+z可以产生一个信号,操作系统给该前台进程发信号从而导致前台进程被终止。

那这里的Ctrl+\与Ctrl+z的按键分别会产生几号信号呢?

同样的,我们可以通过signal函数来捕捉然后查看一下结果:

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

void myhandler(int signo)
{
  printf("process get a signal:%d\n",signo);
}

int main()
{
  signal(3,myhandler);
  signal(20,myhandler);                        
  while(1)
  {
    printf("I am a process,pid:%d\n",getpid());
    sleep(1);
  }

  return 0;
}

运行结果:

现在我有一个问题,是不是所有的普通信号都能被捕捉呢?

答案是否定的,9号信号和19号信号就不能够被捕捉!

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

void myhandler(int signo)
{
  printf("process get a signal:%d\n",signo);
}

int main()
{
  signal(9,myhandler);
  signal(19,myhandler);                        
  while(1)
  {
    printf("I am a process,pid:%d\n",getpid());
    sleep(1);
  }

  return 0;
}

运行结果:

可以看到我们即使代码通过signal函数对9号信号和19号信号进行了捕捉,但是我们当向一个进程发9号信号和19号信号时,该进程并不会打印捕捉到9号信号和19号信号,而是执行收到9号信号和19号信号的默认处理动作,终止该进程。

那为什么9号进程和19号信号不能被捕捉呢?

9号信号和19号信号不能被捕捉的原因是它们具有特殊的含义和作用。

SIGKILL和SIGSTOP是两个特殊的信号,它们不能被捕获或忽略。这是因为它们的作用是强制结束进程或暂停进程,这是一种非常强大的操作,不应该被轻易地控制或修改。如果允许进程捕获这些信号并将其处理方式改为其他操作,那么就可能导致进程的行为变得不可预测或不稳定。(比如我们的电脑中植入一个病毒,病毒只需要将所有的信号捕捉然后让其处理方式变成忽略,然后该进程无法被杀死。这样后果是非常可怕的。)

因此,为了保证系统的稳定性和可靠性,操作系统规定SIGKILL和SIGSTOP信号不能被捕获或忽略。当操作系统发送这些信号时,进程必须按照它们的意思终止或暂停。

2. 通过系统调用向进程发信号

我们还可以通过系统调用接口来向一个进程发送信号。下面我们就来介绍一下这些系统调用:

(1)kill函数

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

kill函数的函数原型如下:

#include<signal.h>
int kill(pid_t pid,int signo);

//返回值:成功返回0,失败返回-1

kill函数用于向进程ID为pid的进程发送一个signo信号,如果信号发送成功,则返回0,失败则返回-1。

下面我们就来使用一下kill函数吧:

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

using namespace std;

int main()
{
  int cnt = 0;
  while(true)
  {
    cout << "I am a process, pid: " << getpid() << endl;
    sleep(1);
    cnt++;
    if(cnt % 2 == 0)
    {
      cout << "kill myself" << endl;
      kill(getpid(), 2);                                
    }
  }
  return 0;
}

运行结果: 

可以看到我们成功用kill函数将进程退出了。

上面我们说了kill命令是调用kill函数实现的,下面我们来用kill函数来模拟一下kill命令:

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

using namespace std;

//输入错误,提示kill指令使用方法
void Usage(string proc)
{
  cout << "Usage:\n\t" << proc << " signum pid\n\n";
}

int main(int argc,char* argv[])
{
    if(argc!=3)
    {
      Usage(argv[0]);
      exit(1);
    }
    int signum = stoi(argv[1]);
    pid_t pid = stoi(argv[2]);

    int n = kill(pid,signum);
    if(n == -1)
    {
      perror("kill");
      exit(2);
    }

	return 0;
}

运行结果: 

我们看到,当我们使用自定义kill指令不规范时,提示使用方式。当我们输入./mykill 9 2215成功将pid为2215的./myproc进程成功杀死。 

如此一来我们便用kill函数模拟了kill命令。

除了可以使用kill函数向进程发信号外,我们还可以使用另外两个系统调用向进程发信号:

rasie函数与abort函数,下面我就来为大家介绍一下这两个函数。

(2)raise函数

raise函数可以给当前进程发送指定的信号,即自己给自己发送信号。

raise函数的函数原型如下:

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

//返回值:成功返回0,失败返回-1

下面我们来使用一下raise函数,每隔2秒给自己发送一个2号信号,同时我们捕捉2号信号。

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

using namespace std;

void myhandler(int signo)
{
    cout << "process get a signal: " << signo <<endl;
    // exit(1);
}

int main(int argc,char* argv[])
{
  signal(2, myhandler);
  int cnt = 0;
	while(true)
  {
    cout << "I am a process, pid: " << getpid() << endl;
    sleep(1);
    cnt++;
    if(cnt % 2 == 0) 
    {
      cout << "kill myself" << endl;
      raise(2);
    }
  }

	return 0;
}

运行结果:

可以看到当前进程每隔1秒就会收到一个2号信号。

(3)abort函数

abort函数使当前进程接收到信号而异常终止,本质就是给当前进程发送6号信号(SIGABRT),从而使得当前进程异常终止。

abort函数的函数原型如下:

#include<stdlib.h>
void abort(void);

//无返回值,就像exit函数一样,abort函数总是会成功的,所以没有返回值。

 下面我们来使用一下abort函数,每隔2秒给自己发送一个6号信号,同时我们捕捉6号信号。

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

using namespace std;

void myhandler(int signo)
{
    cout << "process get a signal: " << signo <<endl;
}

int main(int argc,char* argv[])
{
  signal(SIGABRT, myhandler);
  int cnt = 0;
	while(true)
  {
    cout << "I am a process, pid: " << getpid() << endl;
    sleep(1);
    cnt++;
    if(cnt % 2 == 0) 
    {
      cout << "kill myself" << endl;
      abort();
    }
  }

	return 0;
}

运行结果:

我们发现这次的运行结果中,我们捕捉了6号信号,但是它并没有像上面raise函数那样每隔2秒打印一次捕捉到了信号,而是该进程直接就被终止了,这是为什么?

abort() 函数会向当前进程发送一个SIGABRT信号,这个信号默认的行为是终止进程。即使在信号处理函数中捕获了这个信号,也仅仅改变了进程对信号的处理方式,但并没有阻止SIGABRT信号的发送。因此,进程仍然会因为接收到这个信号而终止。exit函数是正常终止一个进程。就像exit函数一样,使用abort函数终止进程总是会成功的,因此即使捕捉了SIGABRT信号,但还是会终止该进程。

3. 硬件异常产生信号

硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。

下面我主要为大家介绍两种硬件异常:

  • CPU的运算单元产生异常
  • MMU(内存管理单元)产生异常

CPU的运算单元产生异常

在冯诺依曼体系结构中,中央处理器(CPU)是计算机的核心部件,负责执行各种算术、逻辑和指令操作。CPU内部有许多寄存器,这些寄存器用于临时存储数据和执行计算。

当我们需要进行算术运算时,通常需要将操作数从内存加载到寄存器中进行计算。这是因为内存中的数据需要先加载到寄存器中,才能被CPU执行算术运算。运算完成后,结果通常会被写入一个寄存器中,然后可能需要将原始操作数写回内存。

除了用于存储操作数和结果的寄存器外,CPU还有一组称为状态寄存器的特殊寄存器。状态寄存器用于记录指令执行过程中的各种状态信息,例如有无进位、有无溢出、结果正负等。这些状态信息对于后续的指令执行和结果判断非常重要。

因此,状态寄存器可以看作是CPU内部的一个标志位集合,用于指示指令执行的各种状态和结果。通过检查状态寄存器中的标志位,我们可以确定指令执行的结果和状态,从而进行相应的后续操作或处理。

而操作系统是软硬件的管理者,在程序运行的过程中,当进程执行除以0的指令,即浮点数运算出现错误时,在CPU当中就会出异常并且会有一个溢出标志位它就会被设置。因为OS是软硬件的管理者,那么它就都得对软硬件的健康进行负责,因此操作系统马上就会识别到是哪个进程导致的错误,并将识别到的硬件错误信息解释成信号发送给进程,从而终止该进程。

比如说当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为8号信号(SIGFPE信号)发送给进程。

下面我们通过代码来看一下这个现象:

#include<iostream>

using namespace std;

int main()
{
    int a = 10;
    a /= 0;

    return 0;
}

 运行结果:

MMU(内存管理单元)产生异常

我们前面学过进程虚拟地址空间就知道,在语言层面上面看到的地址其实都是虚拟地址,我们如果想访问一个变量,我们需要先经过页表的映射,将该变量的虚拟地址转化成物理地址,然后才能进行相应的访问操作。

在页表进行虚拟地址的映射时,还需要一个硬件的帮忙——MMU,它可以将虚拟地址映射到物理地址以及物理地址访问权限的管理。当我们需要进行虚拟地址到物理地址的映射时,页表将左侧的虚拟地址导给MMU,MMU会根据这个虚拟地址计算出对应的物理地址,然后我们可以通过这个物理地址进行访问。

然而,仅仅知道物理地址还不够,因为计算机还需要确保对该物理地址的访问是合法的。这就是MMU发挥作用的地方。MMU会根据虚拟地址计算出对应的物理地址,并检查该物理地址的访问权限。如果访问权限允许,MMU会允许访问并返回物理地址;否则,它会拒绝访问并产生一个异常或错误,然后操作系统识别到错误就会找到对应的进程给它发信号终止该进程。

因此,MMU在虚拟地址到物理地址的映射过程中起着关键作用。它不仅负责地址转换,还负责管理内存的访问权限,确保程序只能访问其有权访问的内存区域。

比如当前进程访问了非法内存地址(野指针),MMU会产生异常,内核将这个异常解释为11号信号(SIGSEGV信号)发送给进程。

下面我们通过代码来看一下一个现象:

#include<iostream>

using namespace std;

int main()
{
    int* ptr = nullptr;
    *ptr = 6;
    
    return 0;
}

运行结果:

小结

  • 软件上面的错误,通常会体现在硬件或者其他软件上。操作系统是硬件的管理者,既然是硬件的管理者就得对他们的健康进行负责,当某个硬件出现错误时,操作系统马上就会识别到是哪个进程导致的错误,并将识别到的硬件错误信息解释成信号发送给进程,从而终止该进程。
  • 在windows或者Linux下进程崩溃的本质是进程收到了对应的信号,然后进程执行信号的默认处理动作(终止进程)
  • 信号的处理并不是立即处理的,而是在合适的时候。

4. 由软件条件产生信号

接下来我们再来介绍一下由软件条件产生信号。

之前我们在学习进程间通信匿名管道读写规则的时候,我们知道当我们的读端不读并且关闭读端文件描述符,这个时候,由于操作系统不会做无用功,所以写端会受到操作系统发来的信号从而终止写端进程。

我们来回顾一下这个现象:

#include <iostream>
#include <cstdio>
#include <string>
#include <cstring>
#include <cstdlib> //stdlib.h
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
 
#define N 2
#define NUM 1024
 
using namespace std;
 
// child
void Writer(int wfd)
{
    string s = "hello, I am child";
    pid_t self = getpid();
    int number = 0;
 
    char buffer[NUM];
    while (true)
    {
        sleep(1);
        // 构建发送字符串
        buffer[0] = 0; // 字符串清空, 只是为了提醒阅读代码的人,我把这个数组当做字符串了
        snprintf(buffer, sizeof(buffer), "%s-%d-%d", s.c_str(), self, number++);
        // 发送/写入给父进程, system call
        write(wfd, buffer, strlen(buffer)); // strlen(buffer) + 1???
    }
}
 
// father
void Reader(int rfd)
{
    char buffer[NUM]; 
    int cnt = 0;
    while(true)
    {
        buffer[0] = 0; 
        // system call
        ssize_t n = read(rfd, buffer, sizeof(buffer)); //sizeof != strlen,sizeof(buffer)表示缓冲区大小
        if(n > 0)
        {
            buffer[n] = 0; // 0 == '\0'
            cout << "father get a message[" << getpid() << "]# " << buffer << endl;
        }
        else if(n == 0) 
        {
            printf("father read file done!\n");
            break;
        }
        else break;
 
        cnt++;
        if(cnt>5) break;// 读取5s后关闭读端
        // cout << "n: " << n << endl;
    }
}
 
int main()
{
    int pipefd[N] = {0};
    int n = pipe(pipefd);
    if (n < 0)
        return 1;
 
    // child -> w, father->r
    pid_t id = fork();
    if (id < 0)
        return 2;
    if (id == 0)
    {
        // child
        close(pipefd[0]);//子进程关闭读端
 
        // IPC code
        Writer(pipefd[1]);
 
        close(pipefd[1]);
        exit(0);
    }
    // father
    close(pipefd[1]);//父进程关闭写端
 
    // IPC code
    Reader(pipefd[0]);// 读取5s
    close(pipefd[0]);//父进程关闭写端
    cout << "father close read fd: " << pipefd[0] << endl;
    sleep(5); //为了观察僵尸
 
    int status = 0;
    pid_t rid = waitpid(id, &status, 0);
    if(rid < 0) return 3;
    cout << "wait child success: " << rid << " exit code: " << ((status>>8)&0xFF) << " exit signal: " << (status&0x7F) << endl;
 
    sleep(5);
    cout << "father quit" << endl;
 
    return 0;
}

运行结果:

可以看到我们的程序运行后,我们的子进程在退出时收到的是13号信号,即SIGPIPE信号。

除了上面这种情况是由软件条件的产生信号之外,我们还有一种情况也是由软件条件产生的信号。

下面我们来介绍一下 alarm函数

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

alarm函数的函数原型如下:

#include<unistd.h>
unsigned int alarm(unsigned int seconds);

//返回值:
//1.如果在调用alarm函数之前,进程没有设置闹钟,则返回值为0.
//2.如果在调用alarm函数之前,进程已经设置过闹钟了,那么函数的返回值就是以前设定的闹钟时间还剩余的秒数.

下面我们先来使用一下alarm函数:

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

using namespace std;

int main()
{
    int n = alarm(5);

    while(1)
    {
        cout << "process is running..." << endl;
        sleep(1);
    }

    return 0;
}

 运行结果:

我们看到闹钟5秒后运行,然后终止进程,通过man 7 signal看到SIGALRM默认动作就是终止掉进程。 

下面我们捕捉 SIGALRM,利用闹钟来做一些事情:

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

using namespace std;

void work()
{
    cout << "printf log ..." <<endl;
}

void handler(int signo)
{
    work();
    alarm(5);
}

int main()
{
    signal(SIGALRM,handler);
    int n = alarm(5);

    while(1)
    {
        cout << "process is running..." << endl;
        sleep(1);
    }

    return 0;
}

运行结果:

我们通过捕捉闹钟信号,然后在函数中再设置闹钟,这样我们就能通过闹钟每隔一段时间做一些其他的事情。

我们再来看一下alarm的返回值:

alarm返回值:
1.如果在调用alarm函数之前,进程没有设置闹钟,则返回值为0.
2.如果在调用alarm函数之前,进程已经设置过闹钟了,那么函数的返回值就是以前设定的闹钟时间还剩余的秒数.

下面我们来验证一下:

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

using namespace std;

void handler(int signo)
{
    // work();
    cout << "...get a sig,number: " << signo << endl;
    int n = alarm(5);
    cout << "剩余时间" << n << endl;
}

int main()
{
    signal(SIGALRM,handler);
    int n = alarm(50);

    while(1)
    {
        cout << "process is running... , pid:" << getpid() << endl;
        sleep(1);
    }

    return 0;
}

 运行结果:

可以看到我们第一次用kill -14指令 向进程8984设置闹钟的时候,由于我们代码中已经有一次闹钟的设置,所以返回的是代码当中的剩余描述。

在我们第一次用kill -14指令向进程8984设置闹钟之后,SIGALRM信号就被捕捉,然后执行handler函数,就会开始调用每五秒一次的闹钟。所以我们后面在用kill -14指令,显示的都是5秒之内的剩余时间。

5. CormDump

SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core Dump。

首先解释什么是Core Dump。当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core,这叫做Core Dump。进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存在PCB中)。

当进程崩溃的时候,我们都可以通过waitpid()和status输出型参数来获取,崩溃的原因,崩溃时收到了哪个信号。但是我们还想知道我们的进程是在哪一行代码进行崩溃的。

我们再用wait和waitpid函数的进程等待时,说过status输出型参数不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status低16比特位):

当时我们就见过core dump,但是那个时候我们暂时没有用到它。

当进程正常退出时,status的次低8位表示的是进程的退出状态,即退出码。如果进程是被信号所杀,那么status的次低7位表示的是终止该进程的信号,而第8位就是core dump,但并不是所有的信号都会core dump,但只要你的进程是因为信号而终止的,该信号的就会被设置,但是有没有core dump由satuts低8位中的第8位所决定。

在Linux当中,当一个进程退出的时候,它的退出码和退出信号都会被设置(正常情况)
当一个进程异常的时候,进程的退出信号会被设置,表明当前进程退出的原因。如果必要,OS会设置退出信息中的core dump标志位,并将进程在内存中的数据转储到磁盘当中文件名通常是core,方便我们后期调试。

core dump标志位实际上是用于表示进程被异常终止时是否会形成core dump。

下面这段代码来看一下进程被SIGQUIT终止,core dump标志位有没有被置1

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

using namespace std;

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        //child
        int cnt = 500;
        while(cnt)
        {
            cout << "I am a child process,pid: " << getpid() << " cnt:" << cnt << endl;
            sleep(1);
            cnt--;
        }
        exit(0);
    }
    //father
    int status;
    pid_t rid = waitpid(id,&status,0);
    if(rid == id)
    {
        cout << "child quit info, rid: " << rid << " exit code: " << 
        ((status>>8)&0xFF) << " exit signal: " << (status&0x7F) <<
        " core dump: " << ((status>>7)&1) <<endl;
    }
}

运行结果:


SIGQUIT的默认处理动作是终止进程并且Core Dump,可是在上面执行进程异常的代码时我看到core dump都是0,这是为什么呢?

这是因为我们使用的是云服务器,默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用 ulimit命令改变这个限制,允许产生core文件。 

我们先通过 ulimit-a 指令来查看一下:

我们可以通过ulimit -c size 命令来打开这个功能并且设置core 文件的大小,允许core文件最大为1024K: $ ulimit -c 10240

可以看到我们成功打开这个功能并且设置core 文件的大小。如果我们要关闭的话,只需要用ulimit -c 0指令就可以了。

现在打开这个功能后我们再来运行测试一下:

可以看到这次我们的core dump就成功置为1

core.pid文件

我们通过ll查看当前目录下的core文件:

我们发现,只有core dump被置1才会产生core文件,这也说明了标志位实际上是用于表示进程被异常终止时是否会形成core dump。 

我们发现:打开系统的core dump功能,一旦进程出异常,OS会将进程在内存中的运行信息,给我dump(转储)到进程的当前目录(磁盘形成core.pid文件 : 核心转储 (core dump) 

事后调试——core-file core文件调试 

前面我们也说过,通过这个文件我们可以看到我们异常具体出现在哪一行。

下面我们来使用一下:

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

using namespace std;

int main()
{
    int a = 10;
    int b =0;

    a/=b;
    
    cout << " a= " << b << endl;

    return 0;
}

调试结果:

注意:编译的时候要带上-g选项才能进行调试。 

可以看到我们先将程序运行起来,然后再用core-file调试,就可以看到是哪一行出错了。

这种方法叫做事后调试!

core dump的作用:当我们的程序在运行过程中崩溃了,我们一般会通过调试去逐步查找程序崩溃的原因。但是在某些特殊情况下我们就不能够去逐步查找,而是需要用到我们的core dump,比如说你是某个服务器的后台开发人员,此时你们启动服务器不到一个小时该服务器就挂掉了,再启动又隔了不到一个小时又挂掉,这个时候你不可能说去逐步查找错误,因为你的服务器是要给别人提供服务的,如果你逐步查找错误,每启动一次每隔半小时服务器挂一次,那么对于用户来说体验感是极差的,以后别人就不想再使用你们公司的服务器了。这个时候我们就需要使用core dump了,当第一次服务器挂了之后,我们可以对程序进程调试,然后使用core-file core文件 命令加载core文件,然后我们就可以快速的知道该程序是因为什么原因退出以及在哪一行崩溃的,从而解决该错误。

阻塞信号

1. 信号其他相关常见概念

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

信号的流程可以用下面的这张图来表示:

2. 信号的保存和发送

为什么信号保存?

进程收到信号之后,可能不会立即处理这个信号。信号不会被处理,就要有一个时间窗口。

学习完第二节我们知道信号产生有四种方式分别是键盘产生、硬件异常产生、通过系统调用产生以及软件条件产生信号。但是无论产生信号的方式是什么,但是最终一定是通过OS向目标进程发送的信号。因为OS是进程的管理者!!

那么什么是信号的发送呢?如何理解OS给进程发送信号呢?

我们知道信号的编号是有规律的,普通信号是从1-31号的, 就像是数组的下标。

在进程的task_struct中,它里面有进程的各种属性,而我们的普通信号是从1-31号的,它里面一定要有对应的数据变量,来保存记录是否收到对应的信号,那采用什么数据变量,来标识是否收到信号呢?

其实在task_struct中,有一个signal整数,我们通过该整数采用位图结构来标识该进程是否收到信号。

signal比特位的位置(第几个),代表的就是哪一个信号,比特位置1代表的就是当前进程是否收到了该信号。

OS是进程的管理者,只有它有资格才能修改task struct 内部的属性!

所以OS给进程发送信号本质是OS向指定进程的task_struct中的信号位图中写入比特位设置为1,即完成了信号的发送。

3. 在内核中的表示

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

  • block位图:也叫做信号屏蔽字图。比特位的位置,代表信号的编号,比特位的内容为1,代表该信号被阻塞。
  • pending位图:比特位的位置,代表信号的编号,比特位的内容为1,表示该进程收到过该信号。
  • handler表:存储函数指针的数组,表示某个信号处理时的默认动作,SIG_DFL表示默认处理,SIG_IGN表示忽略该信号,其它表示自定义处理。

  • 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
  • SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
  • SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函sighandler。
  • 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号。

4. sigset_t

从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态。

  • 在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞。
  • 而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。

5. 信号集操作函数

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);

注意: 对于sigset类型的变量,我们不能够直接使用位运算来进行操作(因为不同的系统sigset_t的实现方式不同),因此我们必须使用上面的这些函数来进行操作。 

  • 函数sigemptyset:初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
  • 函数sigfillset:初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
  • 函数sigaddset在set所指向的信号集中,将signo信号对应的比特位由0置1
  • 函数sigdelset:在set所指向的信号集中,将signo信号对应的比特位由1置0

上面这四个函数都是成功返回0,出错返回-1。

  • 函数sigismember:判断signo信号是否在set所指向的信号集中(若在该信号集中返回1,不包含则返回0,出错返回-1。)

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

sigprocmask

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

#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。

下面我们通过上面所学的几个函数来做一个实验:

  1. 调用sigprocmask函数对2号信号/SIGINT(Ctrl+c)进行屏蔽,并捕捉2号信号,每隔一秒打印一次pending信号集。
  2. 使用Ctrl+c组合键向进程发送2号信号。
  3. 此时2号信号被阻塞,无法被递达,因此会处于未决状态。
  4. 过10秒后我们解除对2号信号的屏蔽,此时2号信号就会被递达,我们打印一下pending信号集看一下会发生什么变化。
#include <iostream>
#include <unistd.h>
#include <signal.h>

using namespace std;

void PrintPending(sigset_t& pending)
{
    for (int signo = 31; signo >= 1; signo--)
    {
        if(sigismember(&pending,signo))//判断指定信号是否在目标集合中
        {
            cout << "1";
        }
        else
        {
            cout << "0";
        }
    }
    cout << endl;
}

void handler(int signo)
{
    cout << "catch a signo" << endl;
}

int main()
{
    // 0. 对2号信号进行自定义捕捉
    signal(2,handler);

    //1. 先对2号信号进行屏蔽 --- 数据预备
    //定义信号集对象,并清空初始化
    sigset_t bset, oset; // 在哪里开辟的空间???用户栈上的,属于用户区
    sigemptyset(&bset);
    sigemptyset(&oset);
    sigaddset(&bset, 2);// 我们已经把2好信号屏蔽了吗?没有,只是数据预备,因为还没有设置进入到你的进程的task_struct

    // 1.2 调用系统调用,将数据设置进内核
    sigprocmask(SIG_SETMASK, &bset, &oset);// 我们已经把2号信号屏蔽了吗?是的

    // 2. 重复打印当前进程的pending 0000000000000000000000000
    sigset_t pending;
    int cnt = 0;
    while (true)
    {
        //2.1 获取未决信号集
        int n = sigpending(&pending);
        if(n < 0) continue;
        //2.2 打印
        PrintPending(pending);  

        sleep(1);
        cnt++;
        if(cnt == 10)
        {
            cout << "unblock 2 signo" << endl;
            sigprocmask(SIG_SETMASK,&oset,nullptr);// 我们已经把2号信号解开屏蔽了吗?是的
        }
    }
    // // 3 发送2号 0000000000000000000000010

    return 0;
}

运行结果:

我们可以看到pending表中的右边第二个数字由0变1,再由1变0。

这是因为刚开始我们对2号信号进行了屏蔽,即使我们收到了2号信号它也不会递达,因此我们会看到pending表由0变1,当过了10秒之后,我们解除了对2号信号的阻塞之后,2号信号就会立即递达执行用户自定义动作,因此此时pending表又会由1变0。

我们还有一个问题,可以屏蔽所有的信号吗?

不可以!9号和19号信号就不能屏蔽。

下面我们来测试一下9号和19号信号:

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

using namespace std;

void PrintPending(sigset_t& pending)
{
    for (int signo = 31; signo >= 1; signo--)
    {
        if(sigismember(&pending,signo))//判断指定信号是否在目标集合中
        {
            cout << "1";
        }
        else
        {
            cout << "0";
        }
    }
    cout << endl;
}

void handler(int signo)
{
    cout << "catch a signo" << endl;
}

int main()
{
    // 4. 我可以将所有的信号都进行屏蔽,信号不就不会被处理了吗? 肯定的!9
    sigset_t bset, oset;
    sigemptyset(&bset);
    sigemptyset(&oset);
    for (int i = 1; i <= 31; i++)
    {
        sigaddset(&bset, i); // 屏蔽了所有信号吗???
    }
    sigprocmask(SIG_SETMASK, &bset, &oset);

    sigset_t pending;
    while (true)
    {
        // 2.1 获取
        int n = sigpending(&pending);
        if (n < 0)
            continue;
        // 2.2 打印
        PrintPending(pending);
        sleep(1);
    }

    return 0;
}

运行结果: 

可以看到并不能够将这两个信号屏蔽。我们依然可以通过9号19号信号终止进程。

这跟前面使用signal函数进行信号捕捉一样,9号和19号信号不能被捕捉!!

SIGKILL和SIGSTOP是两个特殊的信号,它们不能被捕获或忽略。这是因为它们的作用是强制结束进程或暂停进程,这是一种非常强大的操作,不应该被轻易地控制或修改。如果允许进程捕获这些信号并将其处理方式改为其他操作,那么就可能导致进程的行为变得不可预测或不稳定。 (假如电脑出现了病毒,9号信号可以被屏蔽或者捕捉,我们就不能杀死病毒了!)

捕捉信号

1. 内核态与用户态

因为信号的产生是异步的,当前进程有可能正在做着更重要的事情,因此我们需要在合适的时候去处理它。

(1) 那么什么是合适的时候呢?也就是信号是什么时候进行处理的呢?

这个合适的时候就是进程从内核态切换回用户态的时候,进行信号检测与信号的处理

(2) 那什么是内核态?什么又是用户态呢?他们之间的区别又是什么呢?

  • 内核态:允许你访问操作系统的代吗和数据!执行OS的代码和数据时,计算机所处的状态就叫做内核态。处于内核态的 CPU 可以访问任意的数据,包括外围设备,比如网卡、硬盘等,处于内核态的 CPU 可以从一个程序切换到另外一个程序,并且占用 CPU 不会发生抢占情况,一般处于特权级 0 的状态我们称之为内核态。
  • 用户态:只能访问用户自己的代码和数据!就是用户代码和数据被访问或者执行的时候,所处的状态。我们自己写的代码全部都是在用户态执行的。处于用户态的 CPU 只能访问受限资源,不能直接访问内存等硬件设备,不能直接访问内存等硬件设备,必须通过「系统调用」陷入到内核中,才能访问这些特权资源。

(3) 为什么要区分用户态和内核态?
在 CPU 的所有指令中,有一些指令是非常危险的,如果错用,将导致整个系统崩溃,比如:清空内存,修改时钟等。如果所有的程序代码都能够直接使用这些指令,那么很有可能我们的系统一天将会死 n 次。

所以,CPU将指令分为 特权指令 和 非特权指令 ,对于较为危险的指令,只允许操作系统本身及其相关模块进行调用,普通的、用户自行编写的应用程序只能使用那些不会造成危险的指令。

(4) 什么时候会从用户态陷入内核态?

三种方式程序会从用户态陷入内核态:

  • 系统调用:这是用户态进程主动要求切换到内核态的一种方式,用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作。
  • 异常:当CPU在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。
  • 外围设备的中断:当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。

这3种方式是系统在运行时由用户态转到内核态的最主要方式,其中系统调用可以认为是用户进程「主动」发起的,异常和外围设备中断则是「被动」的。

(5) 知道了用户态、内核态以及他们之前的区别之后。有一个问题:执行用户的代码时,用户的代码一定要被加载进内存,那么OS的数据和代码需要加载内存嘛?

其实是一定要被加载进内存的,但是我们的电脑一般都只有一个CPU,OS的代码是怎么被执行到的呢?

我们都知道每个进程都有它自己的虚拟地址空间,用户所写的代码和数据位于用户空间,通过用户级页表(每个进程都有一份)与物理内存之间建立映射关系。可是我们的OS的代码和数据呢?我们来看下面这张图:

可以看到上面这幅图,每个虚拟地址空间是由2部分组成的:3G的用户空间以及1G的内核空间,这3G的用户空间里面存放的是每个进程的代码与数据,每个进程通过各自的用户级页表就可以将自己代码和数据映射到不同的物理内存中。而我们的OS只有一个,它的代码和数据是存放在内核空间的,这个1G的内核空间是被所有进程所共享的,OS通过内核页表就可以将它的代码和数据映射到物理内存中,其中内核页表是被所有进程共享的。

因此这也就说明了进程具有了地址空间是能够看到用户和内核的所有内容的,但是并不一定能够访问操作系统的内容。

(6) 那CPU进行调度的时候,它又怎么知道当前进程是处于用户态还是内核态的呢?

我们之前说过在CPU内部有一个CR3寄存器

  • 保存页目录的基地址:CR3寄存器保存了页目录的基地址,这个基地址是操作系统在启动时计算得到的。页目录是操作系统用来管理虚拟内存和物理内存之间映射关系的数据结构。
  • 页转换机制:CR3寄存器中的值用于页转换机制,通过页目录和页表中的信息,将虚拟地址转换为物理地址。这是x86架构下处理器的分页机制的核心部分。

因此,CR3寄存器是CPU中用于管理虚拟内存的重要寄存器之一,通过它,处理器可以将虚拟地址转换为物理地址,实现虚拟内存的管理。

在CPU还有一个ecs寄存器!!它的低两位就是用来切换用户态和内核态的。当ecs寄存器的低两位为0,表示当前是内核态。当ecs寄存器的低两位为11,表示当前是用户态。那我们如何进行切换呢?通过汇编语言int 0x80就可以进行切换。

在汇编语言中,int 0x80是一个指令,用于在x86架构的计算机上发起系统调用。它的作用是将CPU从用户态切换到内核态,以便进程可以请求操作系统提供的服务。

在Linux系统编程中,int 0x80是一个重要的汇编指令,常用于系统调用和内核交互。当程序执行到int 0x80指令时,它会触发一个中断,操作系统会捕获该中断并执行相应的系统调用。

小结:

  • 进程直接无论如何切换,我们能够保证我们一定能够找到同一个OS,因为我们每个进程都有3-4G的地址空间,使用同一张内核页表。
  • 所谓的系统调用:就是进程的身份转化成内核态,然后根据内核页表找到系统函数,执行就行了。
  • 在大部分情况下,实际上我们OS都是可以在进程的上下文中直接运行的。

 2. 内核如何实现信号的捕捉

上面了解了内核态与用户态之后,接下来我来为大家讲解一下内核是如何实现信号的捕捉的。

1、当我们在执行主控制流程的某条指令时,可能因为中断、异常或者系统调用等进入内核。

2、当内核处理完异常准备返回用户态时,就需要先检查一下pending位图,如果在pending位图中发现有未决信号,并且该信号没有被阻塞,那么我们就需要对该信号进行处理。

3、如果待处理信号的处理动作是默认或者忽略,则执行该信号的处理动作之后,将该信号在pending表中的标志位由1置0;如果pending位图中没有未决信号,或者该未决信号被阻塞了,我们直接返回到用户态,从主控制流中上次被中断的位置继续向下执行。

如果待处理信号的处理动作是自定义,即该信号的处理动作是用户所提供的,那么处理该信号时就要由内核态返回用户态去执行信号处理函数。

4、信号处理函数返回时再执行特殊的系统调用sigreturn再次进入内核

5、最终调用sys_sigreturn函数返回用户态,从主控制流中上次被中断的位置继续向下执行。

下面通过一张图片来展示信号捕捉的流程:

理解了上面这幅图。下面我们用一副简图来帮大家更好的记忆: 

这个图形有点像我们数学里面的无穷大∞,我们可以利用无穷大∞来帮助我们记忆信号捕捉的过程,其中∞与直线的交点就代表着一次状态切换,箭头的方向就表示状态切换的方向,图中无穷大∞的交点就代表着信号的检测

我们说过内核态的权限是很高的,但是为什么一定要切换到用户态,才能够执行信号的捕捉方法呢,内核态直接执行不可以吗?

理论上OS是可以执行用户的代码的,但是OS不相信任何人!如果有一些恶意用户在信号处理函数的代码中封装了删除数据库的操作。那么此时OS如果去执行了这些代码就会导致数据库被删除(删库跑路hhhh)。因此不能够让OS去直接执行用户的代码,因为OS不能保证用户的代码都是合法或者没有恶意的。

 3. sigaction函数

除了signal函数可以捕捉信号外,sigaction函数也可以捕捉信号。

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

sigaction函数的原型如下:

#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
//返回值
//调用成功则返回0,出错则返回- 1。

说明:

  • signo表示的是信号编号。
  • 若act指针非空,则根据act修改该信号的处理动作。
  • 若oact指针非空,则通过oact传出该信号原来的处理动作。
  • 调用成功则返回0,出错则返回- 1。

act和oact指向sigaction结构体,下面我们来看一下sigaction这个结构体的定义。

结构体的第一个成员sa_handler

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

结构体的第三个成员:sa_mask

  • 当处理某个信号时,屏蔽的信号字,默认用函数设置为0。
  • 当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。
  • 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。

结构体的第二个成员sa_sigaction:sa_sigactionshi是实时信号的处理函数我们这里不关心。

结构体的第四个成员:sa_flags,该成员我们不关心,默认设置为0

结构体的第五个成员:sa_restorer,该成员我们这里也不关心。

下面我们就来使用以下sigaction函数来解决两个问题:

问题1:验证信号被处理的时候,对应的信号也会被添加到block表中,防止信号捕捉被嵌套调用

上面我们说sigaction第三个参数是处理某个信号时,屏蔽的信号字,且当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字。下面我们来进行验证。

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

using namespace std;

void printfPending()
{
    sigset_t set;
    sigpending(&set);

    for (int signo = 1; signo <= 31; signo++)
    {
        if(sigismember(&set,signo))
            cout << "1";
        else
            cout << "0";
    }
    cout << endl;
}

void handler(int signo)
{
    cout << "catch a signal" << signal << endl;
    //一直在handler函数
    while (true)
    {
        printfPending();
        sleep(1);
    }
}

int main()
{
    struct sigaction act, oact;
    memset(&act, 0, sizeof(act));
    memset(&oact, 0, sizeof(oact));

    sigemptyset(&act.sa_mask);
    act.sa_handler = handler;
    sigaction(2, &act, &oact);

    while (true)
    {
        cout << "I am a process:" << getpid() << endl;
        sleep(1);
    }
    
    return 0;
}

运行结果:

我们看到当我们第一次用Ctrl+c发送2号信号时,信号被捕捉且进行处理,我们用一个while循环一直指向处理函数。我们发现当我们第二次用Ctrl+c发送2号信号时,我们发现这次并没有执行handle中的函数。但是被pending位图标记收到该信号了。说明函数运行完当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字。

下面我们取消while循环来看一下:

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

using namespace std;

void printfPending()
{
    sigset_t set;
    sigpending(&set);

    for (int signo = 1; signo <= 31; signo++)
    {
        if(sigismember(&set,signo))
            cout << "1";
        else
            cout << "0";
    }
    cout << endl;
}

void handler(int signo)
{
    cout << "catch a signal" << signal << endl;
}

int main()
{
    struct sigaction act, oact;
    memset(&act, 0, sizeof(act));
    memset(&oact, 0, sizeof(oact));

    sigemptyset(&act.sa_mask);
    act.sa_handler = handler;
    sigaction(2, &act, &oact);

    while (true)
    {
        cout << "I am a process:" << getpid() << endl;
        sleep(1);
    }
    
    return 0;
}

运行结果:

我们看到这次每次Ctrl+c的信号都能被捕捉。说明当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。防止信号捕捉被嵌套调用

问题2: pending位图,什么时候从1->0.?

我们来看下面这段代码:

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

using namespace std;

void printfPending()
{
    sigset_t set;
    sigpending(&set);

    for (int signo = 1; signo <= 31; signo++)
    {
        if(sigismember(&set,signo))
            cout << "1";
        else
            cout << "0";
    }
    cout << endl;
}

void handler(int signo)
{
    printfPending();
    cout << "catch a signal" << signal << endl;
}

int main()
{
    struct sigaction act, oact;
    memset(&act, 0, sizeof(act));
    memset(&oact, 0, sizeof(oact));

    sigemptyset(&act.sa_mask);
    act.sa_handler = handler;
    sigaction(2, &act, &oact);

    while (true)
    {
        cout << "I am a process:" << getpid() << endl;
        sleep(1);
    }
    
    return 0;
}

 运行结果:

上面这段代码,我们在执行处理函数handler之前打印pending位图。可以看到执行信号捕捉方法之前,pending位图就进行了清零操作。

所以我们得出结论:pending位图,什么时候从1->0.?执行信号捕捉方法之前,先清0,再调用

4. 可重入函数 

我们来看下面这张图片: 

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

像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数。

反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?

  • 两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数不会造成错乱的原因主要是因为每个控制流程都有自己的堆栈空间,它们彼此之间是独立的。
  • 当函数被调用时,它的参数、局部变量等都会被压入到调用者的堆栈中。因此,即使两个不同的控制流程调用同一个函数,它们各自都有自己的参数和局部变量,这些变量在各自的控制流程中都是独立的。
  • 此外,函数中的参数和局部变量通常只在函数被调用期间存在。当函数执行完毕后,这些变量就会被从堆栈中弹出,并被销毁。因此,即使两个控制流程同时访问同一个函数,它们也不会互相干扰,因为这些变量只存在于各自的堆栈中。
  • 需要注意的是,如果两个控制流程同时访问同一个全局变量或静态变量,那么就可能会造成数据错乱。因为全局变量和静态变量在程序运行期间一直存在,它们可以被多个控制流程共享。因此,在设计程序时需要特别注意避免这种情况的发生。

如果一个函数符合以下条件之一则是不可重入的:

  • 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
  • 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

注意:我们大部分函数都是不可重入函数,因为我们的stl容器和库函数等都很多都使用链表来管理,扩容的时候需要malloc和free。

5. volatile

volatile是我们C语言中的一个关键字,它的作用 是保持内存的可见性。告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作

下面我们来看一段代码:

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

using namespace std;

int flag = 0;

void handler(int signo)
{
    cout << "catch a signal: " << signo << endl;
    flag = 1;
}

int main()
{
    signal(2,handler);
    // 在优化条件下, flag变量可能被直接优化到CPU内的寄存器中
    while(!flag); // flag 0, !falg 真
    cout << "process quit normal" << endl;

    return 0;
}

运行结果:

可以看到运行结果符合我们的预期。当我们使用Crtl+c组合键,2号信号被捕捉,执行自定义函数handlerflag由0变成1,此时循环条件不满足进程退出。 

下面我们在给Makefile中给gcc带上优化-O3选项:

运行结果: 

 

优化情况下,使用Crtl+c组合键向进程发送2号信号,2号信号被捕捉,执行自定义动作,修改 flag =1,但是while条件依旧满足,进程继续运行!但是很明显flag肯定已经被修改了,但是为何循环依旧执行?

但是我们优化之后,CPU每次检测flag只会在内存器检测,读取内存器flag的值(一直是0)。而我们捕获信号修改的flag是内存的flag,对寄存器没有任何修改。所以我们这次才会看到,即使我们捕获到2号信号,但是进程并没有退出。就相当于我们CPU和内存器当中形成了一条屏障,CPU每次读数据只从寄存器当中读,不会从内存当中读了。(内存不可见了)

那如何解决这个问题呢?我们可以使用volatile关键字来解决这个问题

 运行结果:

可以看到当我们的flag变量被volatile关键字修饰时,尽管在我们的Makefile中给gcc带上优化-O3选项,当进程收到2号信号,执行信号处理函数将内存中的flag变量从0置1时,main函数的执行流也能够检测到内存中的flag变量的变化,从而跳出死循环进程退出。 

6. SIGCHLD信号

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

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

(1)下面我们来验证一下子进程退出会向父进程发送:

我们来看下面这段代码:

首先父进程fork出子进程,5秒后子进程调用exit(0)终止,父进程自定义SIGCHLD信号的处理函数,在其中调用wait获得子进程的退出状态并打印。

#include <iostream>
#include <signal.h>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

using namespace std;

void handler(int signo)
{
    sleep(5);
    pid_t rid = waitpid(-1, nullptr, 0);//-1表示等待任意子进程

    cout << "I am proccess: " << getpid() << " catch a signo: " << signo << "child process quit: " << rid << endl;

}

int main()
{
    signal(17, handler);

    pid_t id = fork();
    if (id == 0)
    {
        while (true)
        {
            cout << "I am child process: " << getpid() << ", ppid: " << getppid() << endl;
            sleep(5);
            break;
        }
        cout << "child quit!!!" << endl;
        exit(0);
    }
    sleep(1);

    // father
    while (true)
    {
        cout << "I am father process: " << getpid() << endl;
        sleep(1);
    }

    return 0;
}

运行结果:

可以看到子进程退出,并不是静悄悄的退出,确实是向父进程发送了17号信号。

(2)那如果我们有十个进程呢?

采用非阻塞轮询的方式进行回收!

#include <iostream>
#include <signal.h>
#include <cstring>
#include <ctime>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

using namespace std;

void handler(int signo)
{
    sleep(5);
    pid_t rid;
    while ((rid = waitpid(-1, nullptr, WNOHANG)) > 0)//WNOHANG非阻塞轮询+循环的方式就可以实现回收所有子进程
    {
        cout << "I am proccess: " << getpid() << " catch a signo: " << signo << "child process quit: " << rid << endl;
    }
}

int main()
{
    srand(time(nullptr));
    signal(17, handler);

    // 如果我们有10个子进程呢??如果同时退出呢?
    // 如果退出一半呢?
    for (int i = 0; i < 10; i++)
    {
        pid_t id = fork();
        if (id == 0)
        {
            while (true)
            {
                cout << "I am child process: " << getpid() << ", ppid: " << getppid() << endl;
                sleep(5);
                break;
            }
            cout << "child quit!!!" << endl;
            exit(0);
        }
        sleep(rand()%3+3);
        // sleep(1);
    }
    // father
    while (true)
    {
        cout << "I am father process: " << getpid() << endl;
        sleep(1);
    }

    return 0;
}

运行结果: 

上面代码我们用rand()打乱进程生成的时间。然后我们通过一个while循环+非阻塞轮询的方式进行回收。可以看到我们成功实现边创建子进程边回收。

(3)我们现在还有一个问题。如果我们不关系子进程退出码,必须得等待吗???必须得调用 wait吗??

事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。

我们来看下面这段代码进行验证:

#include <iostream>
#include <signal.h>
#include <cstring>
#include <ctime>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

using namespace std;

int main()
{
    signal(17, SIG_IGN); // SIG_DFL -> action -> IGN

    // 如果我们有10个子进程呢??如果同时退出呢?
    // 如果退出一半呢?
    for (int i = 0; i < 10; i++)
    {
        pid_t id = fork();
        if (id == 0)
        {
            while (true)
            {
                cout << "I am child process: " << getpid() << ", ppid: " << getppid() << endl;
                sleep(5);
                break;
            }
            cout << "child quit!!!" << endl;
            exit(0);
        }
        sleep(1);
    }
    // father
    while (true)
    {
        cout << "I am father process: " << getpid() << endl;
        sleep(1);
    }

    return 0;
}

运行结果:

可以看到fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。

我们通过 man 7 signal指令看到17号进程本来就是Ign,和我们上面通过signal设置的Ign有什么区别呢?

因为手册上写的这个Ign默认是SIG_DFL,他的默认动作是Ign,也就是上面都不做。 而我们显性的吧动作设置成Ign,handler方法就能通过0和1来选择处理方法。如果是显性设置操作系统就直接将子进程进行回收了。

注意:系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证。

  • 22
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值