您现在的位置是:首页 >技术杂谈 >【Linux】进程信号“疑问?坤叫算信号吗?“网站首页技术杂谈

【Linux】进程信号“疑问?坤叫算信号吗?“

朵猫猫. 2024-06-14 17:17:59
简介【Linux】进程信号“疑问?坤叫算信号吗?“

鸡叫当然也算信号啦~

文章目录

  • 前言
  • 一、认识信号量
  • 二、信号的产生
    • 1.调用系统函数向进程发信号
    • 2.由软件条件产生信号
    • 3.硬件异常产生信号
  • 总结


前言

信号在我们生活中很常见,下面我们举一举生活中信号的例子:

你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“ 识别快递
当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需 5min 之后才能去取快递。那么在在这5min 之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“ 在合适的时候去取
在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“ 记住了有一个快递要去取
当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种: 1. 执行默认动作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你的朋友) 3. 忽略快递(快递拿上来之后,扔掉床头,继续睡觉)
快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话
在讲进程信号之前我们先引入四个重要的概念:
1.互斥,任何一个时刻,都只允许一个执行流在进行共享资源的访问(这样的操作可以通过加锁来实现)
2.我们把任何一个时刻,都只允许一个执行流在进行访问的共享资源,叫做临界资源。
3.临界资源是要通过代码访问的,凡是访问临界资源的代码,叫做临界区。
4.原子性 :只有两种确定状态的属性  (就比如1和0,能存在中间值0.5)

一、认识信号量

感性的认识:

信号量也被称为信号灯,本质上就是一个描述资源数量计数器,下面我们举个生活中的例子来理解信号量:

在生活中我们会去电影院看电影,但是在看电影之前我们必须先买票,而买票的本质功能有两个,第一个是对座位资源的预订机制,第二个是确保不会因为多放出去特定的座位资源而导致座位冲突。而信号量其实对应的就是买票,因为任何一个执行流,想访问临界资源中的任何一个子资源的时候是不能直接访问的,必须得先申请信号量资源(也就是买票),而我们前面说过信号量的本质就是个计数器,所以我们在申请信号量资源的时候只需要让这个计数器加加或减减即可(如果申请成功,那么计数器需要--,因为我们的信号量资源少了一个。如果申请成功后不想用了,那么就让计数器++,代表有人将我们的信号量资源归还了)。也就是说只要我们申请信号量成功,我就一定能在未来拿到一个子资源。同样的例子,如果我们的电影院只有一个座位仅供专属VIP座,那么这个情况就叫做互斥,因为在这期间只有一个VIP能使用这个座位,没有其他的人来抢座位。刚刚我们说了信号量本质是个计数器,既然是计数器就必须让所有的进程都看到,否则无法保证自己的操作是原子的。可以理解为:让不同的进程看到同一份资源(这个资源就是信号量)。

下面我们来认识一下信号量的接口:

首先第一个接口是获取信号量semget:

 如果看了我们上一篇共享内存的文章的话,一定可以认识semget这个接口的参数,因为和获取共享内存接口shmget一模一样。第二个参数nsems的含义是代表信号量的个数,也就是说我们一次可以申请多个信号量。要查看我们的信号量的命令是ipcs -s:

同样和共享内存一样,删除某个信号量的指令是ipcrm -s +semid。下面我们看看删除信号量的系统调用接口,不出意外的话就是semctl这个函数了:

 这个函数与共享内存的删除接口不一样的地方是多了一个可变参数列表,第二个参数semnum是代表对哪一个信号量做操作(因为刚刚我们说过了可以同时申请多个信号量)。

semop这个函数可以完成对信号量的计数器-1+1操作:

 这个函数的第二个参数结构体就是完成我们对信号量的-1+1操作的,下面我们看看这个结构体:

 比如说我们要对一个信号量做减操作,那么就可以在num这个下标填0(num是一个数组),sem_op填-1(因为要减减),flag默认即可。

对于信号量的接口我们差不多已经看完了,下面我们来理解一下IPC:

 我们可以发现不管是共享内存还是信号量,系统用来描述他们的结构体都是XXXid_ds:

那么操作系统是分开管理这些IPC资源的还是一起管理的呢?

 我们以左边三个结构体为例,在操作系统中有一个这个结构体类型的指针数组,这个数组按下标依次存放右边三个不同的ipc结构体的地址,对于这个指针数组来讲,要保存其他类型的ipc结构体只需要将这个结构体类型强转为系统用于管理的这个结构体指针类型,这样就完成了将内核中的所有ipc资源统一以数组的方式进行管理。以上就是操作系统管理这个IPC资源的原理,上面的操作不知道有没有看出是什么原理呢,其实这就是多态!

二、信号的产生

红绿灯,闹钟,下课铃都是信号,而这些信号被看懂前是需要我们被培养过,比如说有人告诉我们红灯停,所以我们知道红灯要停下,我们可以把进程比作自己,信号就是一个数字,进程在没有收到信号的时候其实进程早就知道该如何处理信号了(因为这是程序员教的,程序员写代码让进程认识信号),而由于信号可能会随时产生,所以在信号产生前,进程可能在做优先级更高的事情,这个时候进程是可以不用立马处理这个信号的,但是要在后续合适的时间处理刚刚没有处理的信号,由于这样的原因所以我们必须将信号保存起来,这样即使当时没有处理信号也能在后续的时间处理这个信号。总结:进程收到信号的时候,如果没有立马处理这个信号,需要进程具有记录信号的能力。

首先我们要知道查看信号的命令  kill -l:

 在这些信号中,只有1-31是我们要学的,因为1-31叫做基本信号,34-64叫做实时信号,而我们现在的操作系统都是分时的,所以我们只学习基本信号。因为信号的产生对于进程来说是异步的,那么进程该如何记录对应产生的信号呢?答案是先描述再组织。怎么描述呢?简单的说0 1就能描述一个信号,用位图来管理这个信号。如下图:

下面我们用代码来对信号进行简单的测试:

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


int main()
{
    while (true)
    {
        std::cout<<"我是一个进程,我正在运行 ...,pid:"<<getpid()<<std::endl;
        sleep(1);
    }
    return 0;
}

 下面我们将程序运行起来试一试信号:

 首先我们看到的现象是我们成功用9号信号杀死了一个进程,这就是通过指令的方式发信号。

当然对于前台进程而言,我们可以从键盘上输入ctrl +c 终止前台进程:

 而如何将一个进程变为后台进程我们也说过了,就是在后面加上&符号:

 后台进程是无法被ctrl+c这样的命令杀死的,所以最后我们用kill-9杀死了这个进程。其实ctrl+c也是操作系统像进程发信号,只不过我们看不到,下面我们通过signal函数的方式查看操作系统给进程发的信号:

 signal这个函数的第一个参数为信号编号,第二个参数为如果操作系统像这个进程发了一个信号,这个函数会将这个信号拿走用于自定义的功能,而不是再像以前一样听取操作系统的指令。下面我们演示一下:

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

void handler(int sig)
{
    std::cout<<"get a signal: "<<sig<<std::endl;
}


int main()
{
    signal(2,handler);
    while (true)
    {
        std::cout<<"我是一个进程,我正在运行 ...,pid:"<<getpid()<<std::endl;
        sleep(1);
    }
    return 0;
}

 这段代码的意思是当操作系统向我们发送2号信号的时候(ctrl + c 就发送的2号信号)我们不在执行原来的终止程序,而是去打印出来signal信号:

 也就是说我们通过signal函数成功捕获了操作系统向我们发送的2号信号(这个2号信号就是我们按下ctrl+c的时候操作系统转化为信号发送给进程的)当然如果上面的图片还是没看懂那么我们也可以这样:

 下面这两张图就清楚的证明了我们发送的ctrl+c信号就是2号信号,因为我们发送2号信号不会中断程序,ctrl+c也不会中断程序。下面我们要说一下,在我们用回调函数的时候,就像我们上面的代码,在调用signal函数的时候是不会调用handler函数的,这里只是更改了2号信号的处理动作,并没有调用handler方法。比如下面这样:

 在我们调用show方法的时候是不会调用print的函数的,下面我们将代码运行起来:

 我们可以看到只打印了hello show,那么如何在调用show的时候还调用print函数呢?其实很简单,在show中调用函数指针即可:

 这也就证明了我们调用signal函数的时候是不会调用handler函数的。

下面我们将所有信号都自定义捕捉,这样是不是这个进程就无敌了没有指令可以杀掉这个进程了呢?

int main()
{
    //signal(2,handler);
    for (int i = 1;i<=31;i++)
    {
        signal(i,handler);
    }
    while (true)
    {
        std::cout<<"我是一个进程,我正在运行 ...,pid:"<<getpid()<<std::endl;
        sleep(1);
    }
    return 0;
}

 我们可以看到其他信号确实都被捕捉了,但是kill -9还是会杀掉进程,因为操作系统不允许有进程不被杀死。

下面我们讲一下信号的产生原理:

我们平时在输入的时候,计算机怎么知道我从键盘输入了数据呢?键盘是通过硬件中断的方式通知系统我们的键盘已经被按下了。:

 上图中的圆圈代表CPU,边上的毛代表CPU的针脚,而键盘会通过中断控制器找到对应与CPU的针脚:

 当我们从键盘输入指令后cpu的寄存器会存储键盘的中断号,然后CPU通过中断号去中断向量表中查找与之中断号相对应的函数方法,这样就完成了我们从键盘输入ctrl + c然后转化为2号信号并且杀死进程的操作。

下面我们将上面所讲的知识先小小的总结一下:

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

当然除了上面我们用数字当信号,也可以用宏来使用:

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

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


void Usage(std::string proc)
{
    std::cout<<"Usage: 
	";
    std::cout<<proc<<"信号编号 目标进程
"<<std::endl;
}
int main(int argc,char* argv[])
{
    if (argc!=3)
    {
        Usage(argv[0]);
        exit(1);
    }
    return 0;
}

我们要完成的工作是写一个和kill-9命令一样的函数,所以我们在main函数中判断如果用户使用我们的kill命令用的参数不对的话,就给用户发一个使用手册然后退出程序,这个使用手册就是教用户如何使用这个kill函数:

 当我运行程序参数用的不对就会给我发一个使用手册,下一步我们完善代码:

在使用kill接口前我们先看看kill接口需要的参数和返回值:

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstdlib>
#include <string>
#include <sys/types.h>
#include <cerrno>
#include <cstring>
void Usage(std::string proc)
{
    std::cout<<"Usage: 
	";
    std::cout<<proc<<"信号编号 目标进程
"<<std::endl;
}
int main(int argc,char* argv[])
{
    if (argc!=3)
    {
        Usage(argv[0]);
        exit(1);
    }
    int signo = atoi(argv[1]);
    int target_id = atoi(argv[2]);
    int n = kill(target_id,signo);
    if (n!=0)
    {
        std::cerr<<errno<<" : "<<strerror(errno)<<std::endl;
        exit(2);
    }
    return 0;
}

首先我们要用的参数都在参数列表中,所以我们需要拿到用户提供的参数,但是由于参数列表为char*类型,所以我们需要将字符串式的信号转为整数,所以我们用了atoi函数,这个函数可以将字符串转为整数,拿到了信号和进程编号后我们就可以使用kill函数了,由于函数成功后返回0,失败返回-1所以我们用了if条件判断,然后我们再写一个死循环的程序等会让这个程序挂着然后用我们的kill程序杀死这个进程:

 同时因为要生成两个可执行程序所以我们将makefile修改一下:

 下面我们将程序运行起来:

 程序运行起来后我们可以看到我们写的程序成功杀死了一个进程。下面我们看一下raise函数:

 raise函数是谁调用我我就给谁传几号命令,这里的命令是参数,下面我们演示一下:

 我们可以看到raise函数的作用确实是谁调用了我我就给谁发信号。

下面我们再看一下abort函数:

 abort这个函数的含义是给自己发送指定的结束信号:

 我们可以看到本来应该打印的end由于abort被迫停止,所以abort是给自己发送指定的结束信号。

2.由软件条件产生信号

软件条件产生信号其实我们在学管道的时候就学过了,我们在学管道的时候讲过,如果管道的读端关闭了写端一直在写,这个时候操作系统就会给管道发送13号信号关闭管道,这就是由软件条件产生信号。下面我们主要讲解alarm函数,这个函数的意思是:

这个函数的返回值是 0 或者是以前设定的闹钟时间还余下的秒数。打个比方 , 某人要小睡一觉 , 设定闹钟为 30 分钟之后响,20 分钟后被人吵醒了 , 还想多睡一会儿 , 于是重新设定闹钟为 15 分钟之后响 ,“ 以前设定的闹钟时间还余下的时间 就是10 分钟。如果 seconds 值为 0, 表示取消以前设定的闹钟 , 函数的返回值仍然是以前设定的闹钟时间还余下的秒数。
下面我们写代码来使用一下这个函数:

 下面我们用这个代码测试1秒钟CPU可以记多少数:

 当我们计数了7万多后程序被alarm函数叫停,而这个数据是真实的吗?CPU1秒才能记这么多数吗?其实不是,因为我们计算要打印出来包含了IO包含了网络,有很多影响的因素,所以这样的测试并不准确,如果测最准确的呢?如下代码:

 首先我们定义一个全局的count计数器,然后用signal捕捉alarm函数,在这期间计数器会一直加加,当1秒达到后alarm发送命令然后被myhandler方法捕捉打印出信号和计数器的数值:

 这次的计数才是正常的五亿多。

3.硬件异常产生信号

不知道大家有没有发现,当我们写代码有对空指针进行解引用,或者数组越界,或者右除0操作时,编译的时候都会提示我们出现错误,那么错误是如何被发现的呢?下面用代码来验证:

首先我们重新创建一个文件,然后写一段简单的代码:

#include <iostream>
using namespace std;

int main()
{
    int a = 10;
    a/=0;    //进行除0操作引发异常
    cout<<"div zero ..... here"<<endl;
    return 0;
}

接下来我们直接编译一下:

 可以看到在编译的时候直接给我们发出警告了,当然我们还是可以继续编译的:

 运行后我们发现输出了一行Floating point exception也就是浮点数异常,下面我们讲一下原理:

 如上图所示,代码是在内存中的某个位置存储,假设a/=0这个代码存储在内存的某个位置,而在CPU中有各种各样的寄存器,我们的代码在运行的时候会被加载到CPU当中,然后CPU会将刚刚那个代码加载到寄存器里,比如上图:将a加载到一个寄存器,将0加载到另一个寄存器,而在CPU做计算时是有一个状态寄存器的,这个状态寄存器会报错我们本次计算是否会有溢出问题,一旦溢出了,那么状态寄存器中的溢出标志位就被置为1了,只要被置为1就说明计算有问题,CPU就立马告知操作系统,一旦操作系统发现确实状态寄存器中的标志位被置为1了,那么操作系统就会向目标进程发送信号,这个信号就是Floating point exception浮点数异常,我们可以在信号中查看这个这个是几号信号:

经过查询我们发现8号信号就是浮点数异常,因为信号的后三位字母是刚刚报错信号的每个单词的首元素。当然我们也可以验证一下,直接捕捉信号即可:

当然我们也可以先看看这个信号的作用:

 我们以前用的九号信号作用就是终止进程,term就是terminal的缩写终止的意思,core是什么呢我们等会再讲:

#include <iostream>
#include <signal.h>
using namespace std;

void handler(int signo)
{
    cout<<"我们的进程确实收到了"<<signo<<"号信号"<<endl;
}

int main()
{
    signal(8,handler);
    int a = 10;
    a/=0;    //进行除0操作引发异常
    cout<<"div zero ..... here"<<endl;
    return 0;
}

 当我们将异常信号捕捉后程序就不会停止了:

 运行后一直死循环打印,下面我们在捕捉的时候让这个进程退出:

 运行后不再死循环了并且打印了我们要求的返回值。下面我们再试试其他异常:

 我们发现程序正常编译但是同样直接结束了,下面我们讲一下关于地址的问题:

 首先有一个0000~FFFF的进程地址空间,进程地址空间上的红色方框是指针的虚拟地址,实际上是在最右边的物理内存上开辟空间的,当我们对空指针解引用的时候其实访问的是进程地址空间的0号地址,比如向0号地址写100,要经过页表转化到物理内存中,但是页表实际上是做KV关系的,做转化的动作不是由软件完成的,而是由硬件完成的,这个硬件叫MMU,MMU被称为内存管理单元,所以从虚拟地址转化到物理地址采用软硬件结合的方式(以上方式是正常情况),而我们对空指针进行解引用首先指针与页表没有对应的映射关系,对0号地址是没有写权限的,所以我们对空指针写入是非法的。*p = 100这句代码第一步并不是写入,而是首先进行虚拟到物理地址的转换,在转换的时候要进程地址空间是否和页表有映射关系,如果没有映射则MMU会硬件报错,如果有映射还需要看是否有对应的权限,如果没有权限也会报错,如果MMU报错也就是硬件报错操作系统就会识别到,然后操作系统向当前进程的PCB发送信号,以上就是对空指针解引用的报错原理。


总结

以上就是linux信号产生的所有知识,下一篇我们将详细讲解linux信号是如何保存和处理的。

上面所说的所有信号产生,最终都要有 OS 来进行执行,为什么? OS是进程的管理者。
信号的处理是否是立即处理的? 不是。是在合适的时候。
信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适呢?
需要被记录下来,记录在进程PCB中
一个进程在没有收到信号的时候,能否能知道,自己应该对合法信号作何处理呢? 能知道,因为程序员教了。
风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。