栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 系统运维 > 运维 > Linux

【Linux操作系统】--进程信号--信号产生前的产生方式

Linux 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

【Linux操作系统】--进程信号--信号产生前的产生方式

目录

信号入门

生活角度的信号

信号产生的各种方式

信号处理常见方式概览

产生信号

一、信号产生前

core dump

系统调用产生信号

软件条件产生信号

【alarm】

扩展


信号入门

生活角度的信号

生活中有很多信号的场景,比如红绿灯,闹钟,信号枪,鸡叫声等等...,这些信号都是给人看的,如果这些信号脱离了人类,红绿灯给牛看,信号枪给鸟打,鸡给鸡叫,这些都是没有意义的。当这些场景触发的时候,我们人类立马就知道要做什么。那么是不是这些场景真正放在我们面前,我们才知道做什么呢?其实和场景触发没有直接关联。对于信号的处理动作,我们早就知道了,甚至远远早于信号产生。那么我们是怎么做到没有信号就知道该怎么做呢?我们对特定事件的反应,是被教育的结果,本质是我们记住了。

结论1:所以信号的产生是给进程看的,进程要在合适的时候,执行对应的动作,而进程在没有收到信号的时候,进程是知道对应进程应该怎么处理!那么进程是如何做到处理对应的信号呢?其实就是曾经编写操作系统的工程师在写进程代码的时候,就已经设置好了。

在生活中,我们受到某种”信号“的时候,并不一定立即处理,下课铃响了我们就能下课吗,可能老师没有讲完这个知识点;鸡叫了我们就一定要起床吗,我们仍旧想睡觉等等这种例子。所以信号随时都可能产生,但是进程当前可能做更重要的事情,这就造成了异步。

结论2:所以进程收到信号的时候,并不是立即处理的,而是在合适的时候去处理它。信号来的时候,进程说等一等,我先把手头的进程处理完,再去处理信号。

结论3:信号既然不能被处理,那么已经到来的信号,就被保存下来了。举个例子,当外卖员给你打电话,这时候是外卖到了的信号,但是可能因为我们正在写一个代码,刚有思路不能被打断,所以我们让信号等待,不去处理它,但是我们已经将信号保存下拉,记在心里了。那么进程的信号被保存放在哪里呢?信号放在了struct tash_struct进程控制块PCB中。PCB中包含了进程的所有属性信息,包括该进程是否收到信号以及到来的信号。信号的本质也是数据,信号发送给进程,就是往task_struct中写入数据。

结论4:task_struct是一个内核数据结构,用来定义进程对象的数据结构,但是内核不相信任何人,只相信子集,所以用户是不可能修改内核的task_struct的。所以向task_struct中写入信号的是OS操作系统!所以信号的任何发送,本质都是再底层通过OS发送的。

以下是信号要了解的三个步骤,我们逐步讲解

信号产生的各种方式

我们用kill -l查看系统支持的信号列表,发现它有64个,但是其中并没有31,32这两个信号,所以一共有62个信号。前31个信号是普通信号,后31个是实时信号。

 当我们写一个死循环并执行的时候,我们要停止他可以用ctrl+c。当我们向进程写入ctrl+c的时候,实际上就是向目标进程发送2号信号。

在1-31信号中,除了有大写的名字之外,前面还带了个编号。在系统当中,信号编号就是整数,每一个信号都有它的宏值,可以理解成所有的信号就是被#define定义出来的,在使用信号的时候,既可以使用信号名,又可以使用数字。

那么怎么证明ctrl+c发送的是2号信号呢?

我们可以用一个函数signal证明,signal是一个函数,它的作用,可以修改指定的一个信号,它的类型是一个函数指针。

第一个参数是一个整数,就是用来传递信号的,第二个参数是用来修改信号的默认处理动作。我们可以将系统默认的信号操作,改成我们自定义对信号的操作。所以第二个参数传的是一个函数指针,只要传入一个函数的地址,那么我们就可以得到这个函数对信号的修改操作。我们知道如果只传函数名,不加后面的参数括号,那么就是传递函数的地址。

函数指针的返回值是void,参数是int。在下面的handler函数中,它是一个信号修改函数,因为函数指针的返回值是void,参数是int,所以handler的返回值是void,参数是一个int,用来接收signal传递的信号整数。

【测试】:

[wjy@VM-24-9-centos 7_signal]$ cat test.c
#include 
#include 
#include 

void handler(int signo)
{
  printf("get a signal:signal no:%d,pid:%dn",signo,getpid()); //打印信号树,并打印信号是给哪个进程发的
  exit(1);
}

int main()
{
  //通过signal注册对2号信号的处理动作,改成我们自定义的动作
  //当我们收到2号信号时,2这个数字就会传递给handler函数。
  signal(2,handler);//第二个参数是一个函数指针

  while(1)
  {
    printf("hello world,pid:%dn",getpid());//打印出进程号
    sleep(1);
  }

  return 0;
}

//运行结果
[wjy@VM-24-9-centos 7_signal]$ ./mytest
hello world,pid:25208
hello world,pid:25208
hello world,pid:25208
^Cget a signal:signal no:2,pid:25208

通过运行结果可以发现,当执行死循环的时候,进程pid是21608。当我们发出ctrl+c发出2号信号,进程开始执行signal函数,调用handler函数,传入2号信号,并进行相应的修改,当每次ctrl+c时都会向signal传入2号信号,每一次ctrl+c都会调用handler函数。此时发现,ctrl+c确实是2号信号,而且对应的进程也是21608。这就证明了上面的问题,ctrl+c所对应的信号是2号信号。(ps:要退出这个进程ctrl+)

那么signal本身是一个注册函数,当注册函数的时候,handler方法还没有被调用,只有当信号到来的时候,handler函数才会被调用。

信号处理常见方式概览

我们现在将上面代码改一下,当输入某个信号,就打印出信号对应的数字(在普通信号范围内)。

#include 
#include 
#include 
#include 

void handler(int signo)
{
  printf("get a signal:%dn",signo); 
  exit(1);
}

int main()
{
  int sig=1;
  for(;sig<=31;sig++)
  {
    signal(sig,handler);
  }

  while(1)
  {
    printf("hello world,pid:%dn",getpid());//打印出进程号
    sleep(1);
  }

  return 0;
}

运行结果:

[wjy@VM-24-9-centos 7_signal]$ ./mytest
hello world,pid:31108
hello world,pid:31108
hello world,pid:31108
hello world,pid:31108
chello world,pid:31108
hello world,pid:31108
^Cget a signal:2
[wjy@VM-24-9-centos 7_signal]$ ./mytest
hello world,pid:31126
hello world,pid:31126
^Zget a signal:20
[wjy@VM-24-9-centos 7_signal]$ ./mytest
hello world,pid:31139
hello world,pid:31139
^get a signal:3

当输入ctrl+c,它的信号是2;当输入ctrl+z,它的信号是20,当输入ctrl+,它的信号是3。例举了这么多的信号,我们kill-l依次找一下信号对应的宏信息。20是一个暂停信号。

当我们在运行进程的后面加一个&,这个是将进程放在后台运行,此时ctrl+c是没有效果的。因为键盘产生的信号只能用来终止前台进程。

[wjy@VM-24-9-centos 7_signal]$ ./mytest &

那么怎么终止进程呢?在上图我们看到9号信息是杀掉进程信息。我们只需要输入kill -9 进程号,技能杀掉这个后台进程。如果不知道进程号,那么使用命令ps axj | grep mytest(mytest是我们编译时给程序另外起的名字),这样就能看到对应的进程的进程号,然后用kill -9 进程号命令来杀掉这个进程。

所以前台进程用ctrl+c,后台进程用kill -9命令就可以杀掉进程。

总结:一般而言,进程收到信号的处理方案:

1.默认动作(上层发来的信号默认动作):信号的产生方式,其中一种就是通过键盘产生的。键盘产生的信号只能用来终止前台进程。那么一部分默认动作就是终止自己或暂停等。

2.忽略动作:第二种收到信号的处理方式是忽略,忽略和默认的差别:默认是当信号来临,处理方式必须要做一个的,但是忽略它对信号的处理方式是,信号来临的时候,可以对信号进行处理,也可以对信号不进行处理。忽略也是一种信号的处理方式,只不过它的动作就是什么也不干。

3.自定义动作(信号的捕捉):我们杠杆用signal方法,就是在修改信号的处理动作,它将默认信号变成自定义动作。就像上面收到信号后,自定义输出内容,并退出,就把信号默认动作变成自定义动作。我们一般把自定义动作叫做信号的捕捉。


当我们用switch case语句将每个命令都重写一下,写入自定义动作(这里我们就写三个,举三个例子)。

void handler(int signo)
{
  switch(signo)
  {
    case 2:
      printf("hello 2:%dn",signo);
      break;
    case 3:
      printf("hello 3:%dn",signo);
      break;
    case 9:
      printf("hello 9:%dn",signo);
      break;
    default:
      break;
  }
  //exit(1);这里就不退出了,让进程一直执行hello world语句
}
int main()
{
  int sig=1;
  for(;sig<=31;sig++)
  {
    signal(sig,handler);
  }

  while(1)
  {
    printf("hello world,pid:%dn",getpid());//打印出进程号
    sleep(1);
  }

  return 0;
}

我们发现,当输出信号2或3的时候,进程不会停下来,而当输入信号9,,进程没有打印相对应我们自定义的hello 9,并且进程被杀死了。这是因为:9号信号不可被自定义捕获!

产生信号

一、信号产生前

信号产生的方式都有哪些呢?包括键盘

当我们写这样一个代码:下面这段代码是一个错误代码,我们定义一个指针p,给p变量赋100,p=(int*)100;这句代码是没错的,因为p是变量,保存的是地址,地址值是0,此时p做左值,100可以给p赋值,因为p有空间。但是下一句,将p解引用赋值,*p=100;这一句代码是不对的,因为p指向NULL,没有指向一块空间,不能赋值。

int main()
{
  while(1)
  {
    int* p=NULL;
    p=(int*)100;
    *p=100;
    printf("hello worldn");
    sleep(1);
  }
  return 0;
}

当执行这一段代码,会出现Segmentation fault,这是段错误的报错,也就是进程的崩溃。

当我们将这段程序的信号打印出来,错误程序先给signal这个错误信号,然后signal将错误信号传给handler,因为不是2信号,所以执行default语句,将错误信号对应的信号码打印出来。最后它打印的是11信号。

void handler(int signo)
{
  switch(signo)
  {
    case 2:
      printf("hello 2:%dn",signo);
      break;
    default:
      printf("hello signo:%dn",signo);
      break;
  }
  exit(0);
}

int main()
{
  int sig=1;
  for(;sig<=31;sig++)
  {
    signal(sig,handler);
  }

  while(1)
  {
    int* p=NULL;
    p=(int*)100;
    *p=100;
    printf("hello worldn");
    sleep(1);
  }
}

 我们再来证明一下Segmentation fault就是11信号:将错误代码和注册信号代码屏蔽,运行进程,用kill -11命令停止进程,发现最后进程停止的提示语句就是Segmentation fault。

当我们写了int a=10;a/=0;这种代码,因为0不能做除数,很明显这个代码是错误的,当我们执行程序的时候,会显示8号信号,用kill -l命令查看一下8号信号是什么,发现8) SIGFPE,代表浮点数错误。用无错误代码用kill -8+进程号,将进程停止,发现8号信号打印出来的提示语句是Floating point exception,也就是浮点数指针异常。

所以在win/linux下,进程崩溃的本质是,进程收到了对应的信号,然后进程执行信号的默认处理动作(默认处理动作:杀死进程)。

【那么为什么我们会收到信号呢?】

当程序出错了就会收到信号,为什么程序会出错呢?是因为收到了错误信号。这样来看,我们就陷入了逻辑的死循环,出不来了。此时我们的理解不只限于软件理解,计算机中包括CPU和内存,我们刚刚的错误程序中,野指针和变量a是存在内存中,而a/0是CPU计算的。

不管是什么硬件,只要是硬件就要受CPU相关的管理工作,而我们自己的代码a/=0,是让CPU去计算除0的,CPU内部有一些状态寄存器,状态寄存器记录了计算结果,如果出问题,此时要把问题记录下来。只要计算,那么结果就要在硬件上有所表现。

当进行野指针访问,p指针解引用后,里面保存的是地址,这个地址是虚拟地址,最终要把虚拟地址转化为物理地址,转化的过程用页表+MMU的方式,对地址进行转化。但是万一野指针越界了呢,万一虚拟地址转物理地址的时候,页表没有对应的映射关系,那么此时OS底层的一些帮助我们转化的硬件:MMU、内存管理单元这样的硬件也会报错。所以软件上面的错误通常会体现在硬件或其它软件上。所以要进行野指针访问,会在内存以及MMU的虚拟地址物理映射上产生对应的硬件错误。

而OS操作系统是硬件的管理者,既然是硬件的管理者,那么OS需要知道硬件的状态,以便管理,所以OS就要对硬件的健康进行负责。我们进程中的代码运算时,当运算在硬件层面上有问题,硬件出问题,OS会立马识别到,OS发现这个硬件怎么坏了(坏了不止是硬件的算坏,还可能时代码运算错误出现异常等),OS直接去找对应的错误代码进程,并向该进程发送信号,最后执行信号默认处理动作:终止进程。

 【信号产生的方式】:程序中存在异常问题,导致我们受到信号退出。

问题1:当进程崩溃的时候,我们最想知道崩溃的原因。在进程等待的时候,父进程waitpid会收到一个statue子进程信号。而status的低七位就是进程退出的信号,知道对出信号了,推出原因我们也就知道了,status的次低八位,也就是9-16位就是它的退出码信息,退出码信息就是进程退出的原因。所以崩溃的原因可以通过waitpid的status参数得到,在信号的这里就变成了,崩溃时收到了某一个信号。

core dump

问题2:那么除了知道崩溃原因,我们还想知道,程序代码在哪一行崩溃了。

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

 但是在我们运行错误代码时候,没有看到core dump这个东西呀。这是因为在云服务器上,默认情况下core dump这个东西是被关掉的。我们通过ulimit -a选项查看,core file size是0,它的大小是被关掉的。(如果在虚拟机上,core file size默认是存在的,不是0)

那么我们想要看到core dump的file大小不是0,设置core dump,那么使用ulimit -c 大小,来设置core file的大小,我们设置file大小是10240,再使用ulimit -a 查看:

 file的大小由0变成10240,就代表允许对错误程序进行core dump。

当我们写了一个错误程序,int a=10;a/=0;这个例子。在错误程序core dump前将它编译它只显示Floating point exception,但是在设置core dump后,编译之后显示Floating point exception (core dumped),说明设置core dump确实成功了。并且ll查看文件,发现目录中多了一个core.5092的文件,5092是刚刚崩溃进程的pid。并且这个文件打开后全是乱码,因为它是OS把内存数据dump到磁盘上,我们不用关心。而这个core文件是让我们将来进行调试的。

所以总的来说,当进程异常了,会收到信号,信号的编号就表明了错误的原因。但是知道原因后,后面想方便我们去调试,此时就可以打开云服务器的core dump选项,让程序直接崩溃,崩溃之后就会在程序的错误信息后面加上一个core dumped提示,标识我们是崩溃的,由核心转储。第二,它会在当前文件下形成一个core文件。

 在设置core dump后,该怎么办呢?首先在makefile文件中,将形成可执行程序的语句中,加上一个-g选项,这个选项就代表程序可以被gdb调试。在有了core.5092这个文件和-g选项后,就可以开始gdb调试。

gdb mytest进入可执行程序调试,输入命令core-file core.xxx(刚才生成的core文件),就可以看到异常程序的错误地方,这里它告诉我们错误在第52行,我们打开程序查看,确实在52行。按q退出

这里告诉我们Program terminated with signal 8, Arithmetic exception.它是我们一个计算的异常,根本原因是你收到了8号信号。

所以我们就可以用core dump,先让程序直接出异常,出异常之后再用gdb调试,core-file命令直接得到了,错误原因和错误代码的行数,这种方案我们称之为事后调试。

所以waitpid中子进程的状态码中有一个core dump标志,是为了进程如果异常的情况,进程被core dump后,该位置就会被设置为1。

但是不一定所有的崩溃都会形成core dump文件,不是所有的信号都会收到core dump信息。就比如说对进程发送3号信号,就有core dump信息,而2号和9号就没有。

 【证明core dump标志位是被设置的】:因为父进程会收到子进程的退出信号,所以我们将错误信息写在子进程,只有在错误的情况下,status的core dump位才会被设置,才能更好的验证我们的问题。那么在父进程中,用waitpid获取子进程的退出信号,最后被写进status中。
ps:-1是任何子进程,代表waitpid获取任意子进程.在子进程的退出信息中,有16位,从左往右依次是进程退出码(8位),core dump(1位),进程退出的错误信号(7位)。所以要获取进程退出码,先让status右移8位,然后与上8个1(16进制是0xFF),就得到退出码。其它类似,core dump左移7位,然后与1;退出信号直接与7个1(16进制0x7F)。

int main()
{
  if(fork()==0)
  {
    while(1)
    {
      printf("I am child...n");
      int a=10;
      a/=0;
    }
  }

  int status=0;//子进程状态
  waitpid(-1,&status,0);
  printf("wait code:%d,core dump:%d,wait sig:%dn",(status)>>8&0xFF,(status>>7)&1,status&0x7F);//子进程退出码,core dump,退出信号
}

最后发现core dump位的信号位是1,也就证明了core dump的形成

所以信号的产生方式:第一是键盘,第二是进程异常,也能产生信号。

系统调用产生信号

【kill:系统调用】

第一个参数pid,要给哪个进程发信号,第二个参数:要给该进程发几号信号。

我们来实现一个程序,来自己实现一个kill命令,实现的方式是通过./mytest编译,向某个进程发送signo信号,这里就需要用到命令行参数,

在命令行参数中,argv[0]就是可执行程序的名字,argc是命令行参数的个数,包括argv[0],可执行程序是./mytest 要发signo信号,向谁who发,(./mytest signo who)一共三个命令行参数。所以当输入的命令参数个数不是3个的时候,就给出Usage手册,查看使用方法,并直接返回。如果是三个,那么signo是第二个参数,who是第三个参数,因为下标是从0开始,所以signo=argv[1],who=argv[2]。因为给出的信号输入的是字符串,信号都是整数,我们要将字符串转整数:atoi。那么怎么知道进程得到了信息呢?我们用一句话打印一下。

当我们只输入一个./mytest,不是三个参数,运行会给我们显示一个手册,告诉我们应该怎么用。当我们给1234号进程发9号信号,此时显示发送成功。那么怎么实现kill命令呢?通过man 2 kill查看手册:我们知道了kill接口的使用方法。(给谁发who,发信号signo)

static void Usage(const char* proc)
{
  printf("Usage:nt %s signo whon",proc);//porc是可变的,signo who是固定的
}

//./mytest signo who
int main(int argc,char* argv[])
{
  if(argc !=3)
  {
    Usage(argv[0]);
    return 1;
  }

  int signo=atoi(argv[1]);
  int who=atoi(argn[2]);
  kill(who,signo);
  printf("signo %d,who:%dn",signo,who);
}

我们来测试一下:

 所以产生信号的第三种方案,通过系统调用,产生信号。

【raise:自己给自己发】

打印一条语句,三秒之后,raise系统调用,进程自己给自己发8号信号。

int main(int argc,char* argv[])
{
  while(1)
  {
    printf("I am a processn");
    sleep(3);

    raise(8);//自己给自己发8号信号
  }
}

【abort】

:向自己发送一个确定的信号,不用传任何参数。我们可以用上面for循环signal函数来打印看一下,abort接收的固定信号是几号。

abort发送的固定信号是6号,用kill-l查看6号信号。

void handler(int signo)
{
  switch(signo)
  {
    case 2:
      printf("hello ...2:%dn",signo);
      break;
    case 3:
      printf("hello world3:%dn",signo);
      break;
    case 9:
      printf("hello 9:%dn",signo);
      break;
    default:
      printf("hello signo:%dn",signo);
      break;
  }
  exit(1);
}

void main()
{
  int sig=1;
  for(;sig<=31;sig++)
  {
    signal(sig,handler);
  }

  while(1)
  {
    printf("I am a process!n");
    sleep(3);
    abort();
  }
}

软件条件产生信号

 通过某种软件(OS),来触发信号的发送。系统层面设置定时器,或者某种操作而导致条件不就绪等这样的场景下,触发的信号发送。

进程间通信,当读端不仅不读,而且还关闭了读的fd文件描述符,写端一直在写,最终写进程会受到signpipe(13)信号,就是一种典型的软件条件触发的信号发送。

【alarm】

alarm是一个闹钟,设置的参数是秒数,也就是在second秒之后会发送一个alarm信号,这个信号用kill -l查看是14号SIGALRM。

 我们让循环不断执行,每一秒执行一次,当三秒之后开始运行alarm,alarm(3)是在3秒之后发送信号(小于等于三秒是不发送信号的)。当alarm发送信号,signal接口接收信号,并传给handler函数,来重写发送信号的方法,之后退出程序exit(1)。

 这是一种延迟性的发送信号,alarm有参数,也有返回值,如果我们设置5秒,但是在3秒就被唤醒了,那么剩余的秒数就是返回值。我们可以来验证一下。

先设置一个30秒的闹钟,然后一直循环打印语句,打印闹钟的返回值,这个时候闹钟还没有被中断,所以它的返回值就是0。在sleep(5)睡眠5秒之后,设置alarm(0)取消闹钟,那么在已经走了5秒的情况下,闹钟还剩25秒,所以我们打印来看一下,闹钟取消后的返回值,就是25。

void main()
{
  int ret=alarm(30);
  while(1)
  {
    printf("I am a process:%dnn",ret);
    sleep(5);

    int res=alarm(0);//取消闹钟
    printf("res:%dn",res);
  }
}

扩展

统计一下,一秒钟我们的计算机server能够对int递增到多少。

alarm(1),没有设置alarm的信号捕捉动作,也就是设置自定义动作,所以在alarm发送14号闹钟信号后,信号的默认动作是终止进程,在1秒之后,进程就会终止。所以在1秒内,我们不断自增count,看看1秒内能打印多少数字。

我们可以看到1秒之内打印了56k次左右。

int count=0;
void main()
{
  alarm(1);
  while(1)
  {
    printf("hello :%dn",count++);
  }
}

 当我们把代码改一下:

让count计数在signal自定义信号发送方式里面,计算1秒之内能跑多少次。我们可以看到每次跑差不多是2亿多次(每个电脑,云服务器都会造成差异)。

void HandlerAlarm(int signo)
{
  printf("hello:%dn",count);
  exit(1);
}

void main()
{
  signal(SIGALRM,HandlerAlarm);
  alarm(1);
  while(1)
  {
    count++;
  }
}

 由5万次提高到2亿次,这里的效率提高了好几倍,为什么在循环里打印太慢了呢?根据体系结构,如果在循环里打印,它不会进行外设访问,它是纯CPU并和内存交互一点点,当加了printf这个IO函数,并且这个是在云服务器上测试的,还要经过网络传输,变得更加慢。所以为什么在循环里用printf语句计算慢?因为有IO。

总结:信号产生的方式有四种:

  • 键盘产生
  • 进程异常
  • 通过系统调用--kill,raise,abort
  • 软件条件--sigpipe,sigalarm.

信号产生的方式种类虽然非常多,但是无论产生信号的方式千差万别,但是最终一定都是通过操作系统OS向目标进程发送的信号。所以产生信号的方式,本质都是OS发送的。

转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/880333.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号