linux——信号详解和实操代码

Posted 努力学习的少年

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了linux——信号详解和实操代码相关的知识,希望对你有一定的参考价值。

目录

信号的概念

信号捕捉初始

signal函数       

发送信号                

通过按键产生信号。

kill命令 

 kill函数

raise函数

abort函数

 alarm函数

信号保存的原理

信号发送的本质

sigset_t类型

sigprocmask函数

set

how

oset

sigpending函数

        

Core Dump

什么是用户态?什么是内核态?

信号处理的过程

信号捕捉函数


信号的概念

信号是进程之间事件异步通知的一种方式,属于软中断。

kill -l 查看进程所有的信号

1.每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定 义 #define SIGINT 2

2.编号1~31是普通信号,编号34以上是实时信号,在这里我们不讨论实时信号。其中每个普通信号都有对应的默认动作,可以在man 7 signal中查看。        

信号捕捉初始

当信号要处理时,每个信号都有一个默认处理动作,有的信号是直接终止进程,有的信号是直接进程core dump(下面会详解),当然,我们也可以自定义函数,修改信号执行的方式。

signal函数       

#include <signal.h>

sighandler_t signal(int signum, sighandler_t handler);

功能:捕捉一个信号编号,将该信号的处理方式设置为handler.

signum:信号的编号

handler:handler是一个函数指针,该函数指针的类型是void(*) (int)是处理信号的一个函数

,该函数是处理信号的方式。该函数的原型:

void handler(int signo)
signo是handler处理的信号的编号.

示例:

        

结果: 

其中按键【ctrl 】 + 【 c 】 发送的是2号信号。【 ctrl 】 + 【 \\ 】发送的是3号信号。

发送信号                

当我们的进程要收到某个信号,就需要有其它进程给我们的进程发送信号的过程,发送信号给我们的进程本质是操作系统给我们的进程发送的,因为操作系统的是进程的管理者,只有它有权去修改我们的进程的属性信息。当然操作系统要给进程发送信号,就需要有方式去通知操作系统去给我们的进程发送第几信号。通知操作系统有以下方式。

通过按键产生信号。

例如:

【ctrl】+【c】给前台进程发送第2号信号,即SIGINT信号

【ctrl】+【\\】给前台进程发送第3号信号,即SIGQUIT信号

【ctrl】+【z】给前台进程发送第20号信号,即SIGTSTP信号。

1.shell可以同时运行多个进程,但是shell只有一个前台进程,而我们控制键产生的信号只能发送给我们的前台进程。

2.我们的按键是产生了硬件中断,被操作系统获取后,将这个中断解释成信号,再发送给我们的前台进程。

kill命令 

kill -信号编号 进程的pid

kill -9 13717

给pid为13717的进程发送9号信号。

 kill函数

#include <signal.h>
int kill(pid_t pid, int signo);
功能:kill函数可以指定给一个进程发送一个指定的信号。
pid:进程的pid
signo:信号的编号
返回值:成功返回0,失败返回-1.

实现一个kill命令:

 编译生成mykill程序

将我们当前路径导入到我们的PATH环境变量中,这样我们运行mykill就不用在前面加上./

 运行mykill, mykill 2 10204是给pid为10204的进程发送2号信号

raise函数

#include<signal.h>
int raise(int signo);
signo :信号编号
功能 :给当前进程发送第signo信号,也就是自己给自己发送信号
返回值 :成功返回0,失败返回-1.

 示例

运行结果: 

abort函数

#include <stdlib.h>
void abort(void);
功能:使当前进程收到该信号而异常终止,终止当前信号
abort总是会成功,所以没有返回值。

abort是SIGABRT信号,为第6信号。无论该信号是否呗被signal捕捉,则它都会使该当前进程退出。捕捉后再退出。 

编译生成myraise程序 ,运行结果:遇到abort则进程退出,不会运行后面的代码。

 

 alarm函数

#include <unistd.h>
unsigned int alarm(unsigned int seconds);
功能:给进程定一个闹钟,seconds秒后给进程发送SIGALRM信号,为第14信号,该信号的默认处理动作是终止当前进程。

示例:

 编译生成myalarm程序,该程序的功能是1秒内在不断的计数,1秒到时被SIGNAL进程终止。

信号保存的原理

进程收到信号可能不会够立即的去处理信号,需要等到合适的时间才会去处理我们的信号,那么我们的进程就需要将该信号保存起来,那么进程是如何记录这些信号的?

信号是保存咋我们进程的pcb中,也就是task_struct变量中。

 每个信号都有两个标志位,一个是阻塞位图(block)未决位图(pending),还有一个函数指针数组handler,这是信号处理的方式。

其中我们先来看一下

pending的含义

pending是记录是否接收信号,我在上文说过,普通信号有31个,1~31的编号,pending本质是一个二进制的位图,我们可以将信号位图看成是由31个有效比特位构成的二进制序列,其中位图的位置表示的是信号的编号,如第6个bit位表示的是第6的信号,信号的内容表示的是否接收到该信号,当内容为1时,代表的是收到信号,为0是没有收到该信号,当进程要知道接收到哪个信号,直接扫描一下位图就行。

如下图:

 

block含义

block含义是否阻塞信号,当我们的信号被阻塞时,进程是不会立马处理该信号,将一直保持着信号未决的状态,也就是该信号是不会被递达,直到解除阻塞后,信号才会递达。

ps:

信号递达:执行信号处理动作。

信号未决:信号产生时和信号处理前这个过程叫做信号未决

在block也是一个信号位图,其中位图的编号为信号的编号,位图的内容代表的是信号是否被阻塞。

如下图: 

我们可以看到pending中的第5和第6的的位置为1,说明该进程收到了第5号信号和第6号信号,在block中第5号的位置是1,说明第5号的信号被阻塞。当我们的进程在合适的时间的时候要处理信号的时候,首先需要查看进程的pending,当发现进程的pending第5个位置是1时,则再去查看block,发现block第5个位置是1,则说明该信号被阻塞,则进程不会去处理该信号,接着进程在去查看pending发现第6个位置是1,然后发现block的第6个位置是0,则说明该信号没有被阻塞,则进程再去处理该信号。

handler的含义

hangdler是进程对应的信号的处理方式,handler本质是一个函数指针数组,其中数组的下标表示的是信号对应的编号-1,数组的内容表示的是对应的信号处理的方式,信号处理的方式有:

  • 默认的处理方式 

每种信号的都有对应的默认的处理方式,执行完动作后(也就是执行函数)直接将pending相对应的bit位由1变为0.有的信号默认的处理动作是core dump,有的信号的默认的处理动作是直接终止进程等对应的信号默认处理动作。

  • 忽略该信号

直接将pending上的相对应的bit位由1变为0.

  • 自定义处理方式

让该信号执行自定义的方式,也就是执行我们自定义的函数,如signal函数对信号进行捕捉。执行完后再将pending相对应的bit位由1变为0.

有了一些基本概念后,我们再重新看这张图。

在上面的例子中:

1.SIGHUP信号对应的block和pending都是0,说明SIGUP信号在进程中是未阻塞过和未产生过,当它递达时就会执行默认的处理动作。SIG_DFL表示默认的处理动作。

2.SIGINT信号对应的block是1,说明该信号被阻塞,对应的pending是1,说明该信号产生过,它的执行动作是忽略该信号,SIG_IGN是忽略方式,直接将我们进程的pending由1改为0,不做任何动作。

3.SIGQUIT信号对应的block是1,说明该信号被阻塞,对应的pending是1,说明该信号未产生过,该进程的当该进程接收到多次SIGQUIT,则进程只接收一次。

信号发送的本质

信号的发送本质是由操作系统直接去修改进程task_struct变量中的pending的bit位,使该信号的bit位在pending位图中是有效的,只有操作系统才有权力去修改我们进程的task_struct变量。不管是我们的快捷键还是kill命令给我们的进程发送信号,它的底层都会去调用操作系统,让操作系统去修改该进程的位图。

由以上的知识,我们可以总结出 信号的产生到信号递达的整个过程。

sigset_t类型

   在上面的概念中,每个信号只有一个bit的未决状态,非0即1,不管该信号产生了多少次,阻塞也是同样道理,所以未决和阻塞我们可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,sigset_t可以用来表示信号是否处于有效或无效的状态。        这个类型在未决的信号集中表示”有效“和”无效“的含义是否处于未决状态,在阻塞的信号集中表示”有效的含义表示该信号是否被阻塞。

sigprocmask函数

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
功能:获取进程的屏蔽信号集( 阻塞信号集:block表)或更改进程的信号屏蔽字。

set

如果set是非空指针,则更改进程的信号屏蔽字

进程是通过set变量进行增加或者删除我们的进程屏蔽字的,所以我们只要修改set就能修改我们的进程的屏蔽字,但我们不能直接对set进行修改,我们需要通过以下接口修改我们的set,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);
返回值:成功返回0,失败返回-1.
  • sigemptyset:取消set中所有的有效bit位,使其中所有信号对应的bit位清零,表示该信号不包含任何有效bit位。
  • sigfillset:将set中的所有信号对应的bit位都置为1。
  • sigaddset:将set中第signo位设置为有效。
  • sigdelset:将set中的第signo位设置为无效。
  • sigismember:判断第signo位是否有效。返回值是一个bool值。
注意:当 sigset_t变量被第一次使用的时候,需要先用sigemptyset或者sigfillset初始化sigset_t变量,使sigset_t为一个确定值。

how

指示进程如何更改进程的屏蔽字,how有三个选项可以让我们选,如下:
假设当前的信号的屏蔽字为mask
  • SIG_BLOCK:set包含我们希望添加到当前的阻塞字的信号,相当于mask=mask|set
  • SIG_UNBLOCK:set包含我们希望从当前信号屏蔽字中解除阻塞信号,相当于mask=mas&~set
  • SIG_SETMASK:设置当前信号屏蔽字,相当于mask=set。

 如果想要给进程的信号集中的第三号信号设置为阻塞,则需要将set的第3位的bit位设置为有效,然后将sigprocmask函数的参数how设置为SIG_BLOCK,这样就可以给我们的进程设置第3号信号为阻塞。如下图所示:

 如果想要解除进程的信号集中的第4号信号的阻塞,则需要将set的第4位设置为有效,然后将sigprocmask函数的参数设置为SIG_UNBLOCk,这样就可以将解除第4号信号的阻塞。

oset

oset是非空指针,则将修改前的信号屏蔽字保存到我们oset中,如果为NULL,则不对oset进行任何操作。

sigpending函数

       #include <signal.h>

      int sigpending(sigset_t *set);

    功能:读取进程的未决信号集(pending),将它保存在我们的set变量中。

    返回值:调用成功返回0,调用失败返回-1. 

示例:

阻塞进程的2号信号,然后我们给进程发送2号信号,看进程是否会执行我们的2号信号,并将进程未决图给打印出来

 结果:

当我们阻塞2号信号后,给进程发送2号信号发现进程没有处理该信号。

        

Core Dump

SIGINT信号和SIGQUIT信号的区别

SIGINT的默认处理动作是终止进程

SIGQUIT的默认处理动作是Core Dump,有些信号的默认动作是core dump。

Core Dump是在进程触动某些异常时,它会把进程在内存的核心数据和引发异常的信息保存到磁盘中的一个文件中,这个文件名称为core。我们可以通过调试器在这个文件定位到引发异常的代码和异常异常的原因。进程能够产生多大core文件的取决于于进程的Resource Limit(这个信息保存 在PCB)。

我们可以用命令ulimit -a查看我们进程是否能够产生core文件。默认情况下是不会产生core文件的。如果 core file size的大小为0,则代表产生的core文件是0,即不能产生core 文件

  ulimit -c 10240 设置进程能够产生最大的core文件为10240kb。

 测试:

产生core文件中,后面的数字是进程的id。

接下来我们用gdb调试core 

pid_ t waitpid(pid_t pid, int *status, int options);

wait和waitpid中有一个参数status,这个参数是输出型参数,将子进程的退出信息保存在这个变量中,如果忽略子进程的退出信息,则把status设置为NULL。

我们不能将status看作一个整形变量,它需要把它看作一个位图来看待,也就是说把这个变量看作是由32个比特位组成。其中我们只研究status的低16位。

 当进程正常终止的时候,低8位是0,次第8位组成一个是进程退出码,这个我在【linux】——进程控制这篇文章讲过。

当进程异常终止时,也就是被信号所杀了,低7位是终止信号的编号,第8位代表的是有没有发生core dump,也就是有没有产生core文件,当产生core文件的时候,该bit位将记录为1.

实例代码:

注意要让core dump这个比特位由0变为1,就必须设置进程能够产生core文件。 

输出:

 我们发现进程发生了core dump,并且收到了第11号的信号。

我们可以kill -l查看第11号信号是什么。

 我们kill -l发现第11号信号对应的宏定义是SIGSEGV。

man 7 signal 查看对应的信号发生的错误信息。

引发 第11信号的对应的是invalid memory reference,翻译为错误的内存访问,也就是指针越界。

什么是用户态?什么是内核态?

我曾经说过,信号只有在合适的时间里才会被处理,那么这个合适的时间里究竟是什么时候?

其实这个合适的时间是内核态切换回用户态的时候。

那么问题来了,什么是用户态?什么是内核态?

 我们先来看一张进程的虚拟内存

在进程的虚拟地址空间中,0G~3G是用户空间,这块空间通过用户的页表映射的是进程本身的代码和数据。3G~4G的虚拟内存空间是内核空间,这个内核空间是通过内核页表映射内存中的操作系统的代码和数据,也就是说,每个进程都能通过虚拟地址空间看到操作系统的代码和数据,但是每个进程不一定能够访问这个内核操作系统的代码和数据。

在intel cpu提供Ring0-Ring3三种级别的运行模式,Ring0级别最高,Ring3最低,Ring0状态是执行操作系统的状态,也就是内核态,内核态可以运行用户空间和内核空间上的代码和数据,Ring3是用户态,而运行于用户态的代码则要受到处理器的诸多检查,它们只能访问映射其地址空间的页表项中规定的在用户态下可访问页面的虚拟地址,如果运行操作系统的代码,则会被cpu给终止掉,也就是说用户态只能运行我们用户空间上的代码和数据,不能进入内核运行操作系统的代码和数据。

那么内核态既能执行操作系统的代码也能执行用户用户空间上的代码,但是当我们处于内核态的时候,是绝对不会去运行用户空间上的代码和数据,因为内核态是具有运行代码和数据的最高权限,进程是可以做一些非法的事情去终止其他的进程或者越权访问其它进程的数据和代码等其它损害操行为,所以为了保护进程和操作系统,当运行进程上用户空间的代码和数据时,就必须切换到我们的用户态。

用户态:当进程执行自己的代码的时候,则该状态称为用户态。        

内核态:当一个任务(进程)执行系统调用而陷入内核代码中执行时,我们就称进程处于内核运行态(或简称为内核态),当进程处于内核态时,执行的内核代码会使用当前进程的内核空间。

无论什么进程都是可以都能看到操作系统的代码和数据,所以进程是随时可以进入内核空间的,用户态和内核态是随时都有可能进行切换的。

什么时候用户态切换到我们的内核态?

  • 当进程调用系统接口的时侯,是需要切换到内核态运行该接口的代码和数据,例如fork函数。
  • 当进程时间片到的时候,是需要操作系统切换到下一个进程的。
  • 异常和中断这些是需要我们的操作系统去进行处理的

什么时候内核态会切换到我们的用户态?

  • 系统调用完毕时,返回时,继续执行用户空间上的代码。
  • 切换进程完毕时,继续运行切换后进程的代码和数据。
  • 异常和中断处理完毕后。

信号处理的过程

当我们的进程在执行用户主控制流程上用户空间上的代码和数据时遇到系统调用或者进程的时间片到了等动作进入到内核空间,则cpu会切换到内核态去进入内核空间去调用系统接口或者切换进程等动作,当在内核态处理完这些动作后,在准备切换为用户态之前,操作系统是会去去处理可以递达的信号,信号的处理有三种方式,它们处理的过程是不一样的。

如果信号的处理方式是自定义行为,先将进程的pending上相对应的有效bit为由0该为1,然后将cpu的运行状态切换到用户态,去用户空间上执行自定义函数,当自定义函数执行完毕的时候,会执行特殊的系统调用sigreturn,执行完后自定义函数最后再执行特殊函数调用sigretum再次切换到内核态进入到内核当中,然后再次进入我们内核中,再从内核空间返回到我们用户态上的主控制流,继续执行上次中断的地方。

 printf是需要往显示器上打印的,它封装的是系统调用接口,所以printf调用的时候是会切换到内核中,执行内核中的代码,执行完毕时,在检查我们pending,看看是否有信号要被处理,则操作系统会处理该信号。

如果处理信号的方式默认行为,直接在内核态执行,因为我们默认行为是要么是终止进程要么是core dump,这两个行为是在操作系统上完成的。

如果处理信号的方式是忽略,在内核态种则直接将pending上相对应的bit为由1变为0即可。

信号捕捉函数

我们之前已经学了一个信号捕捉的信号,将捕捉到的信号执行自定义的行为,接下来我们将学习另一个捕捉函数sigaction

#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
功能:读取和修改与信号相关联的处理动作。
返回值:成功返回0,失败返回-1。
signo:指定信号编号
act和oact:是一个struct sigaction变量指针,其中act是设置信号要处理的动作,oact是保存上一次信号的处理动作。
struct sigaction变量
           struct sigaction {
               void     (*sa_handler)(int);
               void     (*sa_sigaction)(int, siginfo_t *, void *);
               sigset_t   sa_mask;
               int        sa_flags;
               void     (*sa_restorer)(void);
           };
 

在struct sigaction中,我们重点关注两个变量:一个是sa_handler变量,另一个是sa_sigaction变量。

sa_handler:

是定义信号处理的方式,若sa_handler变量的值为SIG_IGN的信号处理方式是忽略,若为

SIG_DFL的信号处理方式是默认行为,如果该变量赋值的是一个函数指针,则信号的处理动作为自定义,也就是向内核中

sa_mask

是可以设置信号在处理过程阻塞其它信号,使该信号的处理过程中不会被其它信号的干扰
当某个信号正在被处理时,操作系统会自动将该信号的屏蔽字设置为有效,因为在该信号在处理的过程中,有可能再次收到该信号,接收的信号就会被阻塞,这样就保证了同一个信号不会同时运行两次以上。当然,如果我们想要在某个信号运行的时侯,同时不想要让其它某些信号的干扰,那么我们可以再sa_mask设置阻塞信号集,使该信号在运行的时候,阻塞其中一些信号。sa_mask是sigset_t类型,它的修改跟我们之前讲的修改阻塞信号集是一样的。

示例:将2号信号的处理动作设定为handler,执行handler函数后恢复2号信号上一次的执行动作。 

 运行结果:

 

好啦,今天的内容就到这里,喜欢的朋友给个三连呗,码字不易,你的三连将是我最大的鼓励。

往期linux文章

【linux之进程间通信】——管道

【linux】——动静态库

【linux】——基本的文件操作 

【linux】——文件系统

以上是关于linux——信号详解和实操代码的主要内容,如果未能解决你的问题,请参考以下文章

Redis对于字符串(String)知识点理解和实操过程例子的详解记录

linux进程间通信之Posix 信号量用法详解代码举例

详解linux进程间通信-信号

Linux:详解进程信号(信号的捕捉流程,信号的阻塞volatile关键字)

Linux八Linux进程信号详解

Linux信号详解:signal与sigaction函数