【Linux】信号(一文学会,近万字好文深度讲解信号)

目录

1.信号的初步理解

2.信号处理

信号的产生  

信号的保存

前台进程和后台进程 

信号处理以及产生信号

对于信号的处理方式有三种

产生信号:

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

 2.调用系统函数向进程发信号​编辑

​编辑 3. 由软件条件产生信号

 4.硬件异常产生信号

除0

野指针

 3.核心转储

4.信号递达 未决 阻塞 和 忽略

pending表 block表 handler表

信号集 

函数接口介绍

信号处理

 可重入函数


1.信号的初步理解

首先生活中我们肯定使用过信号去传达信息,比如妈妈做好饭菜和你说吃饭了,那么你会立刻放下手里的工作,然后走到餐厅。

那你怎么证明linux是有信号的?

kill -l

查看linux中的所有信号

不难发现,linux的信号被分成 1-31(普通信号) 34-64(实时信号

 上面说去吃饭这个简单的过程就体现了信号的产生和处理

要知道,即使妈妈没有喊你吃饭,你也知道:如果她喊你,你会做出什么样的应答(去吃饭),所以程序员设计进程时早就设计好了对信号的识别能力,进程在没收到信号的时候,早就知道一个信号该被如何处理(就像你知道吃饭该去餐厅)

但是今天可能工作很紧张,必须要先完成才能吃饭,所以即使妈妈叫你,你也会暂时忽略,但是心里记住 饭好了 这件事,所以信号如果没被立即处理,进程需要有保存信号的能力

所以信号的产生对于进程是异步的

2.信号处理

信号的产生  

假设今天我运行起来一个进程,此时我想终止,按下ctrl c键

键盘中ctrl c,这个键盘输入产生一个硬件中断,被OS获取到解释成信号,发送给目标前台进程,前台进程收到信号,进而引起进程退出

计算机如何得知我从键盘中输入了数据?硬件中断

可以理解为CPU中有寄存器,当这个针脚为高电平的时候(表示外部有设备就绪)向寄存器写9

而OS维护了一张中断向量表

信号的保存

进程对接收到的信号一定要记录,那么怎么记录?先抽象化描述然后使用一定的结构体组织 ,使用0 1来描述普通信号,因为普通信号只要记录有无即可,实时信号稍微复杂,不在本文的讨论范围,用什么数据结构?位图!!!!!!!因为刚才说普通信号一共31个,那么32位bit的int刚好可以盛装!!!!!!!!!!这个位图保存在进程的task_struct中,比特位的位置——>信号的编号,比特位的0/1——>是否收到信号

 所以,所谓的发送信号本质是写入信号,直接修改特定进程的信号位图中特定比特位的0——>1即可

前台进程和后台进程 

linux的进程分为:前台进程,后台进程

前台进程是在终端中运行的命令,那么该终端就为进程的控制终端,一旦这个终端关闭,这个进程也随之消失

linux只允许一个进程作为前台进程运行,默认是bush,当你 ./a.out 的时候把该可执行程序以前台方式运行起来,再输入ls pwd等指令根本没效果,因为a.out不认识这些指令——也就是这个进程没有被设计对应ls/pwd等指令(信号)的处理方式

那么默认运行的前台进程bush,是被程序员设计好的,里面有对应各种诸如 ls  pwd 指令的处理方法,当你ls,这个指令会被OS获取解释成信号,发送给bush,他自己就会处理,最后在终端呈现处理结果,也就是你输入ls,自然会看到当前目录内容!

后台进程:也叫守护进程(Daemon),是运行在后台的一种特殊进程,不受终端控制它不需要终端的交互;Linux的大多数服务器就是使用守护进程实现的。无法被ctrl C 只能kill -9

这就解释了为什么大多数服务器是使用后台进程实现,如果是前台进程,一旦今天关闭终端,那么整个服务器就瘫痪了.......众所周知 服务器是万万不能关机的,也就是不能受终端控制,所以linux使用守护进程实现服务器

比如我们自己写代码运行起来变成进程,这个进程是前台进程,一旦Xshell关闭,那进程肯定会被终止

当然可以通过运行时加上&,让进程变成后台进程

实操:

  • 前台进程 

首先 我们运行一个程序

./cond

 此时这个进程在终端开始运行

while :;do ps -axj|head -1&&ps -axj|grep cond|grep -v grep;sleep 1;done

发现他确实在运行

  • 后台进程 

 现在运行一个后台进程 

./a.out&

但是当我^C的时候根本杀不掉,只能kill -9 PID

kill -9 18671

 

此时这个后台进程被终止

信号处理以及产生信号

对于信号的处理方式有三种

1.默认动作

2.忽略信号

3.用户自定义捕捉(用户可以自己设置对于某个信号的处理方式)

产生信号:

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

 这个系统调用是把收到的signum信号默认动作改为handler方法

实操:

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

void handler(int signo)
{
  cout<<"我收到了信号%d"<<signo<<endl;
}
int main()
{
  signal(2,handler);
  return 0;
}

运行起来 

发现什么都没有啊? 

一定要注意:我们这个函数只是改变了对信号2的默认动作,你得现有信号2 才能选择执行/不执行,或者执行什么动作,上面的代码都没有发送信号2 怎么可能看到现象?

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

void handler(int signo)
{
  cout<<"%d收到了信号"<<getpid()<<signo<<endl;
}
int main()
{
  signal(2,handler);
  while(true)
  {
    cout<<"hello"<<endl;
    sleep(1);
  }
  return 0;
}

为什么2号信号就是对应ctrl c??你可以试验一下把其他信号默认动作改成handler,然后ctrl c返现这个快捷键还是好使的

当然你也可以查看信号表

man 7 signal

 然后下翻

这个信号是键盘中断,对应的就是ctrl c

信号3是ctrl \ 对应也是退出进程

 那么既然可以改处理信号默认动作,是不是把所有信号都改掉,就没有任何信号可以用来终止进程?

当然不是,你能想到的大佬早就预判了,kill -9 是管理员信号,不能对他修改默认的动作


上面说的是通过键盘发信号,下面的系统调用也可以发信号

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

很简单,向pid进程发送sig 

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

void handler(int signo)
{
  cout<<"%d收到了信号"<<getpid()<<signo<<endl;
}
int main()
{
  signal(2,handler);
  int cnt=1;
  pid_t pid=getpid();
  while(cnt++)
  { 
    sleep(1);
    if(cnt==3)
    kill(pid,2); //给当前进程发送2号信号
    else if(cnt==5)
    kill(pid,9); //给当前进程发9号信号
    else 
    cout<<"hello"<<endl;
  }
  return 0;
}

 C语言的函数:

 谁调用raise,就给谁发信号

C语言函数: 

该函数的作用是异常终止一个进程,即该函数之后的代码不会再执行

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

void handler(int signo)
{
  cout<<"%d收到了信号"<<getpid()<<signo<<endl;
}
int main()
{
  signal(2,handler);
  int cnt=1;
  pid_t pid=getpid();
  while(true)
  {
    cout<<"hello"<<endl;
    if(cnt++==2)
     abort();
    sleep(1);
  }
  // while(cnt++)
  // { 
  //   sleep(1);
  //   if(cnt==3)
  //   kill(pid,2); //给当前进程发送2号信号
  //   else if(cnt==5)
  //   kill(pid,9); //给当前进程发9号信号
  //   else 
  //   cout<<"hello"<<endl;
  // }
  return 0;
}

 3. 由软件条件产生信号

当两个进程正在利用管道进行读写,此时把读端关闭,操作系统就会终止掉写进程(发送SIGPIPE信号)。这种情况称为软件条件产生信号

 所以SIGPIPE就是由软件条件产生的信号

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

所以SIGALRM信号是软件条件产生的信号

软件:闹钟,条件:一定秒数之后就会响

我们可以自己给自己设定闹钟,这闹钟会帮我们每隔一秒完成一个动作

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

void handler(int signo)
{
  cout<<getpid()<<"收到了信号"<<signo<<endl;
  alarm(1);
}

int main()
{
  signal(SIGALRM,handler);
  alarm(1); //1
  while(true)
  sleep(1);
  return 0;
}

我们还可以利用这个函数来验证:IO效率低下

#include <iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <signal.h>
using namespace std;
#define LOG "log.txt"
void handler(int signo)
{
  cout<<getpid()<<"收到了信号"<<signo<<endl;
  alarm(1);
}

int main()
{
  umask(0);
  // signal(SIGALRM,handler);
  int cnt=1;
  alarm(1); //1
  int fd=open(LOG,O_CREAT|O_WRONLY|O_TRUNC,0666);
  while(cnt++)
  {
    write(fd,"hello",sizeof(char)*5);
    cout<<cnt<<endl;
  }
  return 0;
}

这个函数会在1s之后终止进程,而1s我们的IO次数是

 其实我们计算机1s的运行效率比这个高多了,但是io的效率就是很低

 其实OS的闹钟是用一个结构体维护起来的,看伪代码

 

 alarm函数的返回值很值得我们看一下

如果之前没有设定闹钟/之前的闹钟全部被唤醒,返回0

如果历史有还没到时间的闹钟,返回之前闹钟的剩余秒数

#include <sys/stat.h>
#include <fcntl.h>
#include <time.h>
#include <unistd.h>
#include <signal.h>
using namespace std;
#define LOG "log.txt"
void handler(int signo)
{
  cout<<getpid()<<"收到了信号"<<signo<<endl;
  alarm(1);
}

int main()
{
  int n1 = alarm(20); //设置一个20s的闹钟
  cout<<n1<<endl; //由于历史没有闹钟,所以n1=0
  
  sleep(15);
  //休息了15s之后被叫醒
  int n2 = alarm(30); //前一个闹钟还剩5,所以n2=5,

  cout<<n1<<" "<<n2<<endl;
  return 0;
}

 4.硬件异常产生信号

除0

#include <iostream>
#include <sys/types.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <time.h>
#include <unistd.h>
#include <signal.h>
using namespace std;
int main()
{
  int cnt=0;
  int num=1/cnt;
  cout<<num<<endl;
  return 0;
}

 发现除0之后能编过但是有告警(浮点数溢出)

内存中的一段代码被load到寄存器,CPU中有一个寄存器是状态寄存器,里面是比特位,用来记录本次计算是否有溢出问题,有则将对应位置比特位记为1

 CPU告诉OS某个程序有溢出问题

OS向进程PCB中发送信号:Floating point exception,该信号是8号信号

 所以除0的本质就是触发硬件异常(此时的硬件是CPU)

野指针

int* p = nullptr;
*p = 10;

在*p的时候,第一步不是写入,而是先进行虚拟到物理地址的转换

OS通过MMU(硬件)结合你要访问目标的虚拟地址,在页表中搜索

如果没有映射,那么MMU报错,如果发现有映射,但是没有访问权限,MMU也会直接报错

报错被软硬件管理者OS发现,向目标进程PCB发信号,从而终止进程

补充知识:

MMU是什么?

MMU(Memory Management Unit),即内存管理单元,被集成在CPU中,是现代CPU架构中不可或缺的一部分,MMU主要包含以下几个功能:

虚实地址翻译,访问权限控制,引申的物理内存管理 

 3.核心转储

是linux系统级别提供的一种能力,可以将一个进程在异常的时候,OS将核心代码部分进行核心转储,全部dump到磁盘,一般在当前进程运行的目录下形成core.pid(核心转储文件)这样的二进制文件

但是我们刚才演示那么多进程异常,却没有看见core.pid文件

这个文件确实在云服务器上看不到,因为云服务器默认关闭查看的功能

怎么证明?

ulimit -a

这个指令用于查看当前系统中特定资源对应的上限大小 

 看到这个文件显示大小是0,单位是块——>不允许当前系统形成core文件

那么我们怎么在云服务器上打开核心存储?

ulimit -c 10240

证明已经打开了核心转储文件

补充:

之前我们说ctrl c和ctrl \都是终止一个进程

但是怎么回事,这两个指令还有action的区别

一个是Term 一个是Core

 我们可以看到,Term:就是终止进程 Core:先核心转储之后再终止进程

#include <iostream>
#include <sys/types.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <time.h>
#include <unistd.h>
#include <signal.h>
using namespace std;
int main()
{
  while(1)
  {
    sleep(1);
    cout<<"hello"<<endl;
  }
  return 0;
}

 可以看到,Term信号是不会生成core文件的(前提是你打开了核心转储),但是Core信号会

后面的.2010就是当前进程的pid

自己随便验证一下就行

 那么核心转储有什么用?方便异常之后调试

我们写的代码默认是release,不支持调试

最需要在makefile的编译选项加上-g

	g++ -o $@ $^ -std=c++11 -g

然后运行起来刚才的程序 进入gdb调试

./test
gdb 3699  //gdb 你程序的pid
core-file core.3699 //gdb自动定位,无需自己定位问题——>事后调试

 他告诉我们是因为收到3号信号导致进程退出

为什么云服务器默认关闭核心转储文件?

生产环境的core dump一般是关闭的

嘶这个core dump有点熟悉啊(快去看进程等待的时候,我们提到过core dump但是没细讲进程等待

status位图:

 我们可以写个试验代码

#include <iostream>
#include <sys/types.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <time.h>
#include <unistd.h>
#include <signal.h>
using namespace std;
int main()
{
  pid_t id=fork();
  if(id == 0)
  {
    int cnt=5;
    while(cnt--)
    {
      cout<<"我是子进程:"<<getpid()<<endl;
      sleep(1);
    }
    kill(getpid(),3);
  }
  int status=0;
  waitpid(id,&status,0);
  printf("我的子进程status中core dump:%d\n",(status>>7)&1);
  return 0;
}

 这份代码就是想让子进程打印,然后父进程阻塞式等待,然后向子进程发送3信号,注意:此时我们是打开了核心存储的,然后打印core dump

 关闭核心存储:

ulimit -c 0

4.信号递达 未决 阻塞 和 忽略

实际执行信号的处理动作称为信号递达

信号到产生到递达之间的状态:信号未决(pending)

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

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

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

pending表 block表 handler表

一个进程会维护三张表,如标题

这就是进程还没有收到信号就知道如果收到应该怎么做的原因——>进程是可以识别这个信号的

 block表:位图结构,比特位的位置表示哪个信号,比特位的内容表示是否对应信号被阻塞

pending表:位图结构,比特位的位置表示哪个信号,比特位的内容表示是否收到信号

handler表:函数指针数组:数组的下标表示信号的编号,数组特定的下标的内容 表示该信号的递达动作

我们发现 SID_DFL表示默认动作,SIG_IGN表示忽略信号

此外还能看到,如果定义了用户自定义方法,那么实现他

否则 每个信号有自己的默认动作(使用条件编译完成的) 

信号集 

信号集:sigset_t是位图结构,属于系统但是不属于语言,可以控制block和pending两张表,是一种数据类型

 所以不难看出,他是一个struct结构体

函数接口介绍

sigprocmask

 

其中oset是输出型参数,可以帮我们保存用该函数设置block表之前的block表状态 

读取 更改block表

注意:在使用sigset_t 类型的变量之前一定要对他初始化(sigemptyset/sigfillset)

  检测pending信号集

信号处理

 信号可以被立即处理吗?如果一个信号之前被block,当前解除block 对应信号会立即被抵达

但是,信号处理可以不是立即处理,而是在合适的时候,什么是合适的时候?进程从内核态切回到用户态

为什么?信号的产生时异常的 当前进程可能在做更重要的事情

用户态 执行你写的代码时进程所处的状态

内核态 .......OS代码时...

时间片:CPU分配给每个进程执行的时间段

什么时候从用户态->内核态

1.进程的时间片到了,需要切换,要执行进程切换的逻辑

2.系统调用

那么CPU怎么知道当前是用户态/内核态

 OS运行的本质 :在进程地址空间内运行,故无论进程如何切换[3,4G]不变——>看到OS内容和进程无关

所谓系统调用本质:如同调用动态库中的方法,在自己的地址空间中进行函数跳转并返回即可!!!!

那么我们不就可以随意访问OS的代码?为了解决这个问题 我们定义用户态和内核态

CPU中有CR3寄存器来表征正在运行的进程执行级别是用户态/内核态

谁来更改执行级别?用户肯定是没有权限的

所以,OS提供的所有系统调用内部都会在执行正式调用逻辑时修改执行级别!!!!

那么进程如何被调度?

1.OS是一个软件,是一个死循环

OS时钟(硬件)每隔很短时间向OS发送时间中断——>OS要执行对应的中断处理方法(检测当前进程的时间片)

2.进程被调度:就是时间片到了,然后将进程对应的上下文等进行保存并且切换,选择合适的进程

——这一系列的动作就是系统函数 schedule()

所以上面说到的 信号处理所谓合适的时间就是内核态切换到用户态的时候左信号检测并进行处理

 可重入函数

函数重入:不同执行流中同一个函数被重复进入

如果函数重入没有问题:可重入函数

否则:不可重入函数

之前学过的很多函数都是不可重入的,一般内部使用了链表,顺序表,STL容器,全局数据结构,调用标准IO库 都是不可重入的

volatile关键字

首先我们铺垫一下

看一段代码

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

int quit=0;

void handler(int signo)
{
    cout<<"change quit from 0 to 1"<<endl;
    quit = 1;
}
int main()
{
    signal(2,handler);
    while(!quit)
    {
        sleep(1);
        cout<<"退出"<<endl;
    }
    return 0;
}

很简单的代码,只要是收到2号信号就会把quit改成1,然后循环条件不满足直接退出 

 

他的原理是这样的

首先执行代码时,把内存中的quit变量加载到寄存器 然后执行计算语句(while(!quit)) ,接收到2号信号之后,把quit更改,CPU不断把quit加载到内存,检测到quit的更新,遂更改寄存器中的quit值,然后让PC指针后移 执行下面的代码

补充:

编译代码时优先级别

其实gcc编译的时候是有优先级的 gcc 提供了为了满足用户不同程度的的优化需要,提供了很多优化选项

man gcc

可以查到一般的优先级是 -O0 -O1 -O2 -O3,数字越大表示优化程度越高

我们更改g++的优先级为-O2 再编译

	g++ -o $@ $^ -std=c++11 -O3

  我们发现,这个程序并不会退出了

为什么?是因为quit是高频访问的变量,编译器自作主张优化了之前反复load到CPU的过程,所以CPU中quit只保存一次,始终为0,所以进程并没有退出

我们把这种CPU看不到内存中quit值的现象称为,内存位置不可见

所以要告诉编译器保证每次都要尝试从内存中读取数据,不要用寄存器中数据——>让内存可见

volatile 修饰quit——>杜绝对quit做内存级别的优化 保证内存的可见性

volatile int quit = 0;

 如何理解编译器的优化:本质是在用户的代码上动手脚,CPU其实很笨 用户喂给他什么数据他只负责运行

子进程退出 父进程如何得知? 

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

using namespace std;

void handler(int singo)
{
    cout<<"17号信号被捕捉"<<endl;
}
int main()
{
    signal(SIGCHLD,handler);
    pid_t id=fork();
    if( id == 0)
    {
        cout<<"我是子进程"<<endl;
        exit(1);
    }
    while(true)
    {
        cout<<"我是父进程"<<endl;
        sleep(1);
    }
    return 0;
}

 所以这个17信号就是子进程退出时,向父进程发送的信号

但是父进程对这个信号的默认处理动作SIG_DFL是什么都不做

今天内容属实爆炸,但是细节一定要把握哦

猜你喜欢

转载自blog.csdn.net/weixin_71138261/article/details/130940292