【Linux信号篇】进程控制的幕后英雄:信号概念和信号的产生

W...Y的主页 😊

代码仓库分享 💕 


前言:Linux进程我们已经掌握,其中在system v中有一篇信号量的,这与今天我们学习的信号没有任何关系。信号量是我们的一种通信方式,而信号是让用户给进程发送的异步信息的一种方式。千万不要混淆。

目录

信号

生活角度的信号

技术应用角度的信号

信号处理常见方式概览

 产生信号

通过终端按键产生信号

Core Dump

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

由软件条件产生信号

硬件异常产生信号

信号捕捉初识

硬件中断

信号的底层逻辑

 重谈异常信号 


信号

生活角度的信号

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

技术应用角度的信号

1.在我们发生的时候,就已经知道如何处理。
2.信号我们肯定知道,很早之前就有人在我们的大脑中灌输了处理信号的方法
以上两种特征证明我们可以识别一个信号。
3.信号来的时候,我们正在处理更重要的事情暂时处理不了信号,我们要将收到的信号进行保存。
4.信号到来时我们可以不立即处理,等到合适的时候处理。
5.信号的产生时随时的我们无法预料到所以信号是异步发送的。
上述三条是进程看待信号的方式!

信号的产生是由(用户或进程)产生的,信号与进程是并发执行的。

 之所以有信号,是因为系统必须要求进程有随时响应外部信号的能力,随后做出反应。

所以我们在讲解信号时,会从三个角度来讲解信号,分别时信号的产生,信号的保存以及信号的处理。

首先让我们来认识信号: 在Linux中输入kill -l指令就可以看到这62个信号,其中我们看到每个信号前面都有一个数字标识并且信号的标识都是相连的,其实就是一个数组进行存储。这些数组或者名称都可以作为信号的标识。名字其实就是宏,这些宏定义可以在signal.h中找到,例如其中有定义 #defineSIGINT 2。

其中没有0、32、33信号,从34~64的信号都是实时信号,其中我们现在这种分时调度的操作系统基本不去使用。这些信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说明: man 7 signal

信号处理常见方式概览

(sigaction函数稍后详细介绍),可选的处理动作有以下三种:
1. 忽略此信号。
2. 执行该信号的默认处理动作。
3. 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。 

 产生信号

通过终端按键产生信号

首先在后台执行死循环程序,然后用kill命令给它发SIGINT信号终止其进程。

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

using namespace std;

int main()
{
    while(true)
    {
        cout << "I am activing..., pid: " << getpid() << endl;
        sleep(1);
    }
    return 0;
}

 kill命令是调用kill函数实现的。kill函数可以给一个指定的进程发送指定的信号。raise函数可以给当前进程发送指定的信号(自己给自己发信号)。

SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core Dump,现在我们来验证一下。

Core Dump

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

信号中有90%以上的信号都是直接终止进程,但是我们在man 7 -signal中可以看到终止进程方式有两种第一个是core一个是term,其中core 为core dump退出,正常就是term。

所以在被信号所杀是有一个标志位core dump,为0就是term 为1是core。

我们发现SIGFPE是除0错误,退出时core,SIGINT是Term,我们就拿他俩举例说一说区别:

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

 这个程序会给发送SIGFPE信号,而其默认处理方式为Core,但是我们却发现这个程序的退出方式与Trem相同,那是因为我们使用云服务器给Core给予特殊的设定,默认Core是关闭的。

我们使用指令ulimit -a就可以查看core file size是否被打开,现在为0就是关闭的。 我们使用ulimit -c + 开辟空间大小就可以将core dump功能打开:

我们再次运行此程序就可以发现在当前目录下出现了一个core文件(上述实在ubuntu下,在centos下生成的是core.***,***代表进程的pid)。 有了core文件我们就可以通过core定位到进程为什么退出,以及执行到哪段代码退出的。因为core是将进程在内存中的核心数据转储到磁盘中形成core文件。我们打开gdb进行调试不用使用gdb语句,直接使用core-file core就直接显示错误。

回到最初最简单的还是同样的进程死循环,当我们想要进行终止时我们可以按下键盘组合键ctrl + c -> OS会解释为2号信号,然后向目标进程发送进程收到后会响应。

而ctrl + \ 会被OS解释为3号信号SIGQUIT。

其实键盘的组合键来产生信号的组合还有很多,这里我们不一一讲解了,有兴趣的可以进行百度查询。

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

kill命令其实是调用系统函数kill执行的,所以我们可以在程序中使用kill函数来执行kill命令。

kill函数第一个参数是所使用进程的pid,第二个参数是所使用的哪一个信号。成功返回0,失败返回-1.

所以我们就可以通过kill函数自己写一个kill命令:

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


// // mykill -9 pid
int main(int argc, char *argv[])
{
    if(argc != 3)
    {
        cout << "Usage: " << argv[0] << " -signumber pid" << endl;
        return 1;
    }

    int signumber = stoi(argv[1]+1);
    int pid = stoi(argv[2]);

    int n = kill(pid, signumber);
    if(n < 0)
    {
        cerr << "kill error, " << strerror(errno) << endl;
    }

    return 0;
}

 除过kill系统调用,我们还有raise系统调用:

对自己发送任意信号。

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

int main()
{
    int cut = 0;
    while(true)
    {
        cout << "cut:" << cut++ << endl;
        sleep(1);
        if(cut == 5)
        {
            cout << "send 9 to caller" << endl;
            raise(9);
        }
    }
    return 0;
}

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

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

由软件条件产生信号

 SIGPIPE是一种由软件条件产生的信号,在“管道”中已经介绍过了。当我们的写端继续写但读端已经关闭时,OS会发送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 cnt = 0;
  
        alarm(1);
        while(true)
        {
            cout << "cnt:" << cnt++ << endl;
        }
        return 0;
}

这个程序的作用是1秒钟之内不停地数数,1秒钟到了就被SIGALRM信号终止。 

而闹钟一般只会响一次,如果我们想让其反复去响可以继续设置或者使用信号捕捉,后面我们会讲。alarm(0)代表的是取消闹钟。 

硬件异常产生信号

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

信号捕捉初识

在信号捕捉这里,我们可以使用signal函数,其对应第一个参数就是需要捕捉的信号,第二个参数为一个函数指针,我们可以对捕捉的信号进行自定义操作。就如我们的闹钟信号,当我们捕捉后,我们可以对其再继续设定一个闹钟,这样就是一个重复的闹钟。

当我们写一个死循环时,再对2号信号进行捕捉,这样我们使用ctrl + c时就不会终止程序,而会执行handler函数的内容。 

void handler(int sig)
{
    printf("catch a sig : %d\n", sig);
}
int main()
{
    signal(2, handler); //前文提到过,信号是可以被自定义捕捉的,siganl函数就是来进行信号捕捉的,提前了解一下
    while(1);
    return 0;
}

 模拟内存越界异常:

void handler(int sig)
{
    cout << "get a sig: " << sig << endl; // 异常还在
    // exit(1);
}

int main()
{
    signal(SIGFPE, handler);
    // 除0
    int a = 10;
    a /= 0;

    // int *p = nullptr;
    // *p = 100; // 野指针

    while(true) sleep(1);

    return 0;
}

 代码除0会发送8号SIGFPE,野指针发送11号SIGSEGV信号。

硬件中断

 在我们键盘按下组合键时,系统怎么知道我们输入的是信号还是内容呢?
键盘的驱动和OS会联合解释其中,但是键盘怎么捕获我们输入的信息呢,当我们输入时OS是以极快的速度进行相应,但是我们不能使用快速的检测方法进行检测。

其实这里使用的是硬件中断技术,当我们在打开操作系统时就会创建一个中断向量表。向量表中是以数组形式存储的函数指针,2号下标存储的是读取键盘数据的函数指针。而CPU中有很多针脚,针脚也是有自己的编号的,物理上针脚可以和键盘进行连接,键盘在进行输入时会有高电频的产生从而产生硬件中断。cpu中的寄存器会存入对应针脚编号。这里硬件的任务就完成了,CPU在读到寄存器中的内容时会让OS去对应的中断向量表中获取对应的方法来执行。

所以就不用去主动检测键盘了!!! 

信号的底层逻辑

首先我们来回忆一下什么是信号:

1.在我们发生的时候,就已经知道如何处理。
2.信号我们肯定知道,很早之前就有人在我们的大脑中灌输了处理信号的方法
3.信号来的时候,我们正在处理更重要的事情暂时处理不了信号,我们要将收到的信号进行保存。
4.信号到来时我们可以不立即处理,等到合适的时候处理。
5.信号的产生时随时的我们无法预料到所以信号是异步发送的。

当信号不能被即使处理时,我们就要讲信号临时保存。我们就是所谓的进程,那我们要保存在哪里,怎么保存呢?
每一个进程都有一个PCB被OS进行管理,所以我们的信号都会保存在PCB里。当一个普通信号被发送多次时,每一个普通信号会在PCB中保存一次。这里进程会采用位图的数据结构对信号进行保存处理。我们只需要在PCB中添加一个类似uint32_t pendign的数据就可以将我们31个普通信号进行储存。比特位的位置表示信号的编号,比特位的内容(0/1)是/否收到指定信号。

所以我们对信号的管理变成了对位图的增删查改。所以我们可以临时将信号进行保存,这样我们就可以等到合适的时候对信号进行处理。所以我们对进程发送信号就是对指定进程的PCB写入对应信号。

所以在上篇博客中写入的键盘信号中断,我们就可以进一步解释为当我们输入ctrl + c 时,cpu识别到信号中断,然后采用中断向量的方法读到数据,OS对读到内容进行判断就是在PCB将2号比特位的0置为1,CTRL + c 就被解释为2号信号,所以发信号本质是写入信号。

而PCB属于内核数据结构,只有OS才能进行写入,操作系统为用户提供系统调用!!!所以无论信号的产生有多少种,最终都是由操作系统进行写入信号。

 重谈异常信号 

硬件异常也会产生信号,产生信号必然与操作系统有关,现在我们来详谈一下。因为我们已经知道信号的产生是OS往进程PCB中的位图写入内容,所以异常产生的信号也会被写入进去。

异常信号主要是SIGFPE代码除零、SIGSEGV野指针,而我们先拿SIGPE错误进行举例。在计算机的CPU中有许多寄存器,而通用寄存器eax、ebx用于各种操作,其中CPU中有一个标志寄存器EFLAGS,32位的标志寄存器,包含控制标志、状态标志和系统标志。而在X86下第11位上就有一个OF标记位,溢出标志。表示算术运算是否有溢出。

当我们在程序中写入a/=0;这段代码时,首先会将a 和 0放入eax、ebx寄存器中,进行计算时会出现溢出异常,溢出标志位为1,计算错误表现到了CPU寄存器的硬件上,所以CPU停止调度进程 并告诉OS出现异常,OS检测寄存器中的溢出标志位的确出现错误然后就向PCB中的位图第8位由0变成1写入当前信号即可。

 然后是SIGSEGV野指针,在CPU中有CR2和CR3寄存器,CR2页错误线性地址:当发生页错误时,CR2寄存器保存了导致错误的线性地址,CR3页目录基地址:存储当前页目录的基地址,是内存管理的关键寄存器。而我们所用的指针地址都是虚拟地址,需要OS+CPU(MMU)进行转换成物理地址的,当我们将页表的起始地址放入CR3中再将0放入exa中进行转换时发现0号地址是只读或者页表中没有0号地址的映射关系,就会发生错误存入CR2中并终止进程。 


以上就是我们本次全部内容,感谢大家观看。

  • 14
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

W…Y

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

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

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

打赏作者

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

抵扣说明:

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

余额充值