【Linux】信号

本文从生活中的快递收发类比介绍了信号的概念,详细讲解了信号的产生、处理,包括signal函数、键盘中断、系统调用等方式,并讨论了核心转储和信号保存。文章还探讨了用户态和内核态下的信号处理,以及如何通过sigprocmask等函数管理信号屏蔽字。
摘要由CSDN通过智能技术生成

📕 信号入门

生活角度的信号

下面一个网购的过程,有利于我们理解信号。

  • 我在网上买了很多件商品,在等待不同商品快递的到来。但即便快递没有到来,我也知道快递来临时,我该怎么处理快递。也就是我能“识别快递”。

  • 当快递员到了你楼下,我也收到快递到来的通知,但是我正在打游戏,需5min之后才能去取快递。那么在在这5min之内,我并没有去取快递,但是我是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取”。

  • 在收到通知,再到我拿到快递期间,是有一个时间窗口的,在这段时间,我并没有拿到快递,但是我知道有一个快递已经来了。本质上是我“记住了有一个快递要去取”。

  • 当我时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,我要送给我的女朋友)3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)。

  • 快递到来的整个过程,对我来讲是异步的,我不能准确断定快递员什么时候给我打电话。

技术角度的信号

当我们按下Ctrl-C ,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程。前台进程因为收到信号,进而引起进程退出

将生活例子和 Ctrl-C 信号处理过程相结合,实际上就可以理解技术角度的信号。( 进程就是 “我”,操作系统就是快递员,信号就是快递。

一个进程正在运行,突然操作系统给进程发送了一个信号(体现异步,进程并不关心信号何时产生),进程在接收这个信号之前,就已经认识这个信号,并且知道该如何处理这个信号了(执行默认动作、执行自定义动作、忽略),所以,收到信号之后,就按照规定好的处理方式来处理这个信号(不一定立即处理,因为可能进程在执行优先级更高的事情)。
不难理解,在信号产生到信号处理之间,有一段时间窗口。可是,进程不一定只收到一个信号,这就需要将信号保存起来,同样地,信号保存也是——先描述、再组织

如下,使用 kill -l 可以查看各种信号, 31 号后面的是实时信号。实时信号要求产生之后立马处理,所以不需要保存下来!1-31 号信号是普通信号,要保存对应信号是否产生

请添加图片描述

根据上述特点:1. 只需要考虑 1-31 号信号。2. 只需要保存信号有无。 3. 先描述,再组织。不难想到,可以用位图的数据结构来表示信号!!毫无疑问,这个位图是存在于 pcb 中的

假设,pcb 中存在一个 uint32_t signals; 这就是描述信号的位图结构。四个字节,32位,00000000 00000000 000000000 000000000 最高一位无意义,只需要用到低 31 位即可。比特位的位置,表示信号的编号(1号位置表示1号信号);比特位的内容,表示是否收到信号当进程收到操作系统的信号,就将位图对应位置的 0 改成 1,就表示该信号产生了

所以,发送信号的本质,实际上是直接修改特定进程的信号位图的特定比特位,从 0 变成 1。并且, pcb 是内核数据结构,只有操作系统可以进行修改,那么无论以何种方式产生信号,最后都要由操作系统进行“发送”。

📕 信号产生

认识 signal 函数

请添加图片描述

signal 可以改变指定信号的执行动作, SIGINT代表的是2号信号,下面代码中的 signal(SIGINT,handler1); 就是将 2 号信号的执行动作,改为 handle1()。

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

using namespace std;

void handle1(int signum)
{
    cout<<"I get a signal:"<<signum<<endl;
}


void test1()
{
    signal(SIGINT, handle1);

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

int main(int argc,char* argv[])
{
    test1();
    return 0;
}

如下运行结果,原本进程收到二号进程,应该杀死前端进程,现在变成了执行自定义动作。此外,Ctrl+C 也是一样的,发送二号进程。

signal(SIGINT,handler1); 并没有调用 handler1 方法,而是在执行**用户动作的自定义捕捉,**仅仅是改变了 2 号信号的执行动作。向该进程发送 2 号信号之后,进程才会调用 handler1 方法!

此外,在执行自定义的动作时,要求操作系统切换到用户态来执行!如果是在核心态,那么可以执行任何操作,假如自定义动作里由一些恶意代码,就会导致无法挽回的错误!!

请添加图片描述

当然,可以给所有信号设置同一个处理动作,如下。

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

using namespace std;

void handler1(int signum)
{
    cout<<"I get a signal:"<<signum<<endl;
}


void test1()
{
    for(int i=1;i<=31;++i)
    {
        signal(i,handler1);
    }

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

int main(int argc,char* argv[])
{
    test1();
    return 0;
}

通过下图运行结果可以看出,9 号信号是无法被替换的。因为 9 号信号是可以杀死任何进程,如果 9 号信号也被替换执行动作,那么就可以由此设计 bug ,让一个进程永远无法被杀死!

请添加图片描述

键盘产生信号

例如快捷键 Ctrl+C,产生 2 号信号;Ctrl+ / 产生 3 号信号……

由计组方面的知识,我们不难理解操作系统读取键盘的数据过程,首先,按下键盘,产生中断号,根据中断号在中断向量表中找到对应的函数指针,调用函数,读取键盘的数据。例如,操作系统读取到 Ctrl+C 之后,会将其解释成信号。

此外,还可以通过 kill 指令产生信号。

kill -X pid ,可以向指定进程(pid) 发送指定信号(X)。

在命令行通过 kill 指令,可以向目标进程发送信号。上面的运行结果已经有所展示。

通过系统调用产生信号

kill 系统调用

请添加图片描述
如上是 kill 系统调用,第一个参数是进程号,第二个参数是发送的信号编号

下面代码是对 kill 系统调用的一个实践,要求在命令行输入 ./mysignal 信号编号 进程号 就可以对目标进程发送相应的信号(生成的可执行文件是 mysignal ,所以 ./mysignal )。

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

using namespace std;

void Manual(char* str)
{
    cout<<"Manual"<<endl;
    cout<<str<<"信号编号 目标进程"<<endl;
}

int main(int argc,char* argv[])
{
    if(argc != 3)
    {
        Manual(argv[0]);
    }

    int signum=atoi(argv[1]);
    int procpid=atoi(argv[2]);

    //cout<<procpid<<" "<<signum<<endl;

    int n=kill(procpid,signum);
    if(n != 0)
    {
        cout<<errno<<":"<<strerror(errno)<<endl;
        exit(-1);
    }

    return 0;
}


如下是执行结果。

请添加图片描述

raise 系统调用

请添加图片描述
这个系统调用,会向调用它的进程发送信号!参数是几,就发送几号信号!

abort 系统调用

请添加图片描述

调用 abort() 可以对当前进程发送指定信号(六号)!如下是测试代码以及运行结果:

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

using namespace std;

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


int main(int argc,char* argv[])
{
    signal(SIGABRT,myhandler);

    while(true)
    {
        cout<<"test start"<<endl;
        sleep(1);
        abort();
        cout<<"test end"<<endl;
    }
    return 0;
}

根据结果我们可以看出, “test end” 并没有打印出来,也就是说——即使我们对 6 号信号设置自定义动作,但是它执行完自定义动作之后,依然会让当前进程退出!!

请添加图片描述

软件条件产生信号

软件条件,就是字面意思,即软件方面的条件。例如,在使用管道进程进程间通信的时候,如果读端关闭,那么写端就无法向管道写入数据,保持读端开启,这就是软件条件

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

如下,可以测试一秒钟能执行多少次 count++ ,以此来测试计算机的算力!

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

using namespace std;

void myhandler(int signo)
{
    cout<<"the result is:"<<count<<endl;
    exit(1);
}


int main(int argc,char* argv[])
{
    cout<<"pid:"<<getpid()<<endl;
    signal(SIGALRM,myhandler);

    alarm(1);
    while(true) count++;
    return 0;
}

如下是运行结果:

请添加图片描述

此外,如果闹钟还没有响,进程又收到了闹钟信号,会如何呢?用下面的代码进行测试!

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

using namespace std;

void myhandler(int signo)
{
    cout << "get a signal: " << signo <<endl<<;
    int n = alarm(10);
    cout << "return: " << n << endl;
}

int main(int argc,char* argv[])
{
    cout<<"pid:"<<getpid()<<endl;
    signal(SIGALRM,myhandler);

    alarm(1);
    while(true);
    return 0;
}

如下是运行结果,其实,alarm() 的返回值,是上一个闹钟还剩下的时间!

请添加图片描述

硬件异常产生信号

写 C/C++ 程序的时候,遇到野指针、除0等操作,进程就会崩溃,它们崩溃的原因,在这里就可以得到解释!

以除0为例,如下,CPU 中有一个状态寄存器,当本次计算出现溢出问题,状态寄存器中的溢出标志位置为1,进而CPU这个硬件本身发生异常,然后操作系统识别到了硬件异常,向引起硬件异常的进程发送SIGFPE信号!(CPU中保存了当前正在运行的进程的pcb,所以可以找到它)

注意,SIGFPE信号(8号信号)是由于溢出问题产生的,不同的问题会产生不同的信号,要关注的重点不是进程退出,而是收到了几号信号,因为可以根据信号来判断进程是由于什么原因退出的!!

请添加图片描述

如下代码和运行结果,为 SIGFPE 设置自定义动作,可是它却重复打印,进程没有退出。

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

using namespace std;

void handler(int signo)
{
  cout<<"我的进程收到了:"<<signo<<"信号导致崩溃"<<endl;
}


void test1()
{
  signal(SIGFPE,handler);
  int a=10;
  a/=0;
  cout<<"10 div 0 here"<<endl;
}

int main()
{
  test1(); 

  return 0;
}

请添加图片描述

重复打印的原因是:当前进程执行除0的代码,CPU的溢出标志位置为1,出现异常。而因为进程没有退出,状态标志位属于进程的上下文,所以无论这个进程被怎么调度,状态标志位始终是异常的,并且进程也没有退出,也没有去改变溢出标志位,所以操作系统就一直检测到硬件异常!一直发送信号。

进程没有退出的原因是,我们设置了自定义捕捉。在 handler里面调用 exit 就可以退出。


执行下列代码,会出现野指针异常。

#include<iostream>
using namespace std;

int main()
{
    int* p=nullptr;
    //p=100;
    *p=100;
	return 0;
}

但是其本质却并不简单。首先,指针变量 p 里面保存的是进程地址空间里的地址,也就是虚拟地址。当执行 *p=100; 这个代码的时候,不是直接向 p 这个地址的空间写入内容,而是要先完成虚拟地址到物理地址的转换!这需要 MMU 这个硬件的协助!!

页表除了有虚拟地址到物理地址的映射关系,还有访问权限(当然还有其他标志位),这表示虚拟地址对映射到的物理地址有哪些权限(rwx)。

那么,当执行 *p = 100; 先执行虚拟地址到物理地址的转化,如果操作系统通过 MMU 在页表中没有找到 p 保存的虚拟地址到物理地址的映射关系,那么 MMU 硬件报错。如果查找到了对应的映射关系,但是,并没有写的权限,那么也无法执行这行代码,所以MMU 报错! MMU 硬件报错,会被操作系统识别到,操作系统找到当前进程的 pcb,然后对它发送信号!

请添加图片描述

📕 核心转储

操作系统可以在进程收到异常的时候,将核心代码部分进行核心转储,将内存中进程的关键数据全部 dump 到磁盘中。这个功能在云服务器上是默认关闭的,需要手动打开。

如下,凡是 Action 为 Term 的,则仅仅终止进程; Action 为 Core 的,会先进行核心转储,再终止进程。

请添加图片描述

首先可以使用 ulimit -a 指令查看一些信息,如下 core file size 默认设置为0。

在这里插入图片描述

使用 ulimit -c 指令修改 core file size 的大小,设为 1024。

在这里插入图片描述
如下,是对核心转储功能的验证!

在这里插入图片描述

当然了,如何使用核心转储生成的文件才是重点。如下,生成调试文件 mysignal(g++ 指令最后要加上 -g)。运行出错,使用 gdb 调试 mysignal,在 gdb 界面 使用 core-file [filename] 指令,就可以定位错误点!

请添加图片描述

此外,之前所说的进程等待,等待得到的数据里,有一个 core dump 标志位,就和核心转储有关。如果生成了核心转储文件, core dump 标志位就为1,否则为0。

在这里插入图片描述

📕 信号保存

下面是信号其他相关常见概念。

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

如下,在操作系统的 pcb 中,会维护三张表。

pending 表,位图结构,比特位的位置表示哪一个信号,比特位的内容表示是否收到该信号(1为收到,0为未收到)。
bloc 表,位图结构,比特位的位置表示哪一个信号,比特位的内容,代表该信号是否被阻塞。
handler 表,本质上是一个函数指针数组,存储的是一个个指针。该数组下标表示信号编号,特定下标的内容,表示该信号的递达动作(默认、忽略、自定义)。

所以,这也就可以解释,为什么在没有收到信号的时候,就已经知道要对每个信号做什么动作了!就是因为 handler 表的存在!

请添加图片描述

当然了,信号递达的自定义动作已经通过 signal 函数了解了,默认动作自然不必多说。如果想要观察到忽略的动作,以2号信号为例,可以 signal(2,SIG_IGN); 这表示将2号信号忽略,这样进程收到 2 号信号就不会做任何动作。

信号集函数

由于 block 和 pending 都是位图结构,所以有一个专门的类型 —— sigset_t 来描述。可以实例化出两个 sigset_t 的对象 s1、s2 ,分别控制 block 表 和 pending 表,控制 block 表的叫做信号屏蔽字,控制 pending 表的叫做 pending信号集

但是,光有类型还不够,也要匹配对应的方法。所以,信号集函数应运而生。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置为1,表示 该信号集的有效信号包括系统支持的所有信号。
  • 注意,在使用sigset_ t类型的变量之前,一定要调 用 sigemptyset 或 sigfillset 做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用 sigaddset 和 sigdelset 在该信号集中添加或删除某种有效信号。

sigprocmask

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

请添加图片描述

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

how 参数作用
SIG_BLCOKset 包含了我们希望添加到当前信号屏蔽字的信号,相当于 mask = mask 按位或 set
SIG_UNBLOCKset 包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于 mask=mask&~set
SIG_SETMASK设置当前信号屏蔽字为 set 所指向的值,相当于 mask = set

如下代码,用来测试 sigprocmask 可以设置 block 表。

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

using namespace std;

void showBlock(sigset_t* oset)
{
  int signo=1;
  for(;signo<=31;signo++)
  {
    if(sigismember(oset,signo)) cout<<"1";
    else cout<<"0";
  }
  cout<<endl;
}


int main()
{
   // 只是在用户层面进行设置
 sigset_t set,oset;
 sigemptyset(&set);
 sigemptyset(&oset);
 sigaddset(&set,2);  // 向 set 添加 2 号信号

 // 设置进入进程,谁调用,设置谁
 sigprocmask(SIG_SETMASK,&set,&oset);
 int cnt=0;
 while(true)
 {
    showBlock(&oset);
    sleep(1);
    cnt++;
    if(cnt == 5)
    {
      cout<<"recover block"<<endl;
      sigprocmask(SIG_SETMASK,&oset,&set);
      showBlock(&set);
      exit(1);
    }
 }
  return 0;
}

一开始调用 sigprocmask 将 block 表设置为屏蔽了 2 号信号,所以在键盘按下 Ctr l+c 的时候,2号信号被阻塞,进程没有反应。后来重新设置 block 表才得以收到2号信号。

请添加图片描述

sigpending

该函数可以得到当前进程的 pending信号集。set 是输出型参数。

请添加图片描述

📕 信号处理

当进程收到一个信号,并不一定立即处理它,而是在合适的时候处理信号,因为信号的产生是异步的,当前进程可能在做更重要的事情!

当进程从内核态切换为用户态的时候,进程会在操作系统的指导下,完成信号的检测与处理!

用户态与内核态

  • 用户态:执行自己写的代码,进程所处的状态。
  • 内核态:执行操作系统的代码,进程所处的状态。

在进程地址空间中(以32位为例),并不是 4G 的虚拟地址都存储的用户代码和数据,有 1G 的内核空间,这个空间存储的数据就是操作系统的代码。当然了,也是通过页表映射到物理内存中,映射这片区域的页表就叫做内核页表

  • 所有进程的 [0,3] GB 地址空间是不同的,所以每一个进程都拥有自己的用户级页表。
  • 所有进程的 [3,4] GB 地址空间是相同的,所有进程可以看到同一张内核级页表,所有进程可以通过统一的窗口,看到同一个 OS !
  • OS 运行的本质,其实都是在进程地址空间运行的!
  • 所以,所谓的系统调用 ,其本质就如同调用动态库中的方法,在自己的地址空间进行函数跳转并返回即可!

请添加图片描述

但是,如果不对此加以限制,就会造成用户的代码,可以随意访问操作系统的代码和数据(因为在同一个进程地址空间中,可以随意跳转),这是不可以的!所以就有了用户态和内核态!想要访问OS的代码和数据,进程就必须处于内核态!

也就是说,内核态可以访问任意代码和数据,用户态只可以访问用户的代码和数据。并且,**当前进程处于什么状态,是在 CPU 的寄存器上保存的!**例如,当前代码要进行系统调用,CPU 会先查看特定寄存器的状态,确认是否处于内核态,再进行后续动作!

那么现在面临一个问题:如何更改进程的状态? 用户肯定不能直接更改,这样无法确保安全性。实际上,操作系统提供的所有系统调用,其内部在正式调用执行逻辑的时候,都会先修改进程的状态为内核态!

处理信号

如下,是信号捕捉的完整过程,以执行自定义方法为例。

  1. 进程执行系统调用,切换到内核态。
  2. 系统调用完成,检测信号,如果收到信号并且处理动作是 SIG_DEL 或者 SIG_IGN ,即默认或者忽略,那么对应的动作都可以在由操作系统在内核态直接完成。关键是处理自定义方法!
  3. 内核态的进程,当然可以执行用户写的代码。但是,处于安全性的考虑,执行自定义方法必须要切换到用户态!这是为了防止 handler 里有对操作系统进行修改或者其他操作的代码,这在内核态是可以直接执行的;并且,如果是用户态执行,可以查到是哪个用户,如果有什么错误也可以溯源。
  4. 执行完 hander ,不可以直接跳回进程。只能再陷入内核。
  5. 最后通过 sys_sigreturn() 返回用户态。

请添加图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

努力努力再努力.xx

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

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

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

打赏作者

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

抵扣说明:

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

余额充值