Linux下信号

x33g5p2x  于2022-05-05 转载在 Linux  
字(10.3k)|赞(0)|评价(0)|浏览(622)

一.认识的信号

日常生活中的信号

  • 我们现在比较喜欢在网上购物,在等待不同商品到来。即便快递还没有来,我们也知道快递来了我们要干什么,这是因为我们能够识别到“快递”。
  • 当快递送到时我们也不是立马就去拿快递,也许我们正在干着更重要的事情。也就是说取快递的行为不是立即被执行的,而是在当到了合适的时候我们才去拿快递。
  • 快递到来的时间,对我们来说时间是不确定的我们也不知道它什么时候来。
  • 当我们觉得时间合适了去拿快递,开始处理快递。处理快递的三种方式:(1)拿到快递拆了使用.(2)拿到寝室先放到一边.(3)是帮室友拿的快递,拿了就给室友
  • 当快递到来时我们会收到通知,而拿快递是有时间限制的。在这段时间内我们可以在任何时间内去处理快递,虽然我们知道快递到了但是我们没有去拿,也就是我们没有处理。但是我们心里已经知道快递以及到了。

技术角度的信号

首先我们编写一段代码:

我们知道程序的允许结果是不断的打印“I am a process"如果我们想要终止它我们可以使用ctr +c 终止这个进程:

为什么我么使用ctr+c进程就终止了?

实际了是当我们按ctr+c时键盘会产生一个硬件中断被OS捕获将其解释为信号即(2号信号)然后OS就给目标前台进程发送信号,而对应前台进程收到信号时,就会退出。那到底是不是这样呢?下面我么通过代码验证,在验证之前我们认识一个函数

sighandler_t signal(int signum, sighandler_t handler);这个函数我么需要传入两个参数第一个参数是你想捕捉那个信号。第二个参数是函数指针(也就是对信号的处理方法,注意:该函数的返回值为void,参数int).下面我么对上面的现象编写代码进行验证

下面我们将这个程序跑起来:

此时我们使用ctr时我们发现此时我们无法终止进程。

在这里我们需要注意的是:

  • ctr+c 产生的信号只能终止前台进程。我们运行命令后面加一个&就可以将其放到后台运行
  • shell可以运行多个后台进程和一个前台进程但是只有前台进程才能被像ctr+c这种控制键产生的信号终止
  • 9号信号不可被捕捉,9号信号是管理员信号如果所有信号都可以被捕捉那么有一些进程就可以无法无天连OS也管不了他

信号的记录和发送

在linux下我们可以使用kill -l查看信号列表:

其中131信号为普通信号3464为实时信号,在linux当中每个信号都对应有自己的宏名称和编号:

那么信号是如何记录的呢?实际上当一个进程收到信号时,信号是被记录在该进程的进程控制块(task_struct)中。在PCB中对应了一张位图来记录信号,我们可以理解为用一个32位的无符号整型来存储:大致如图所示

其中比特为的位置为对应信号的编号,而比特位的内容代表是否收到信号。如果为1则代表收到对应编号的信号,为0则代表没有收到信号。一个进程收到信号其实就是在对应的信号位图中找到对应的位置将其由0置为1,那又是谁来修改的了?答案是肯定的OS是进程的管理者,只有OS才能修改进程里面的信号位图。现在我们也就能够理解信号的产生本质是OS修改PCB当中的 信号位图。

信号的几种处理方式

在linux当中我们可以使用man 7 signal查看信号的处理方式。在这里就不演示了.处理信号的几种方式:

  • 执行该信号的默认处理动作(终止进程)
  • 忽略该信号
  • 提供一个自定义的信号处理方式(后面会重点讲述)

二.产生信号

通过键盘产生信号

面对下面这个死循环程序我们可以使用ctr+c 或者ctr+\将其终止:

使用ctr+c 和ctr+\终止进程有什么区别?ctr+c其实是OS向进程发送2号信号,ctr+\其实是OS向进程发送3号信号

通过系统调用和命令

我们可以kill命令向特定的进程发送信号即kill -信号名或者编号 进程ID的形式发送:

下面我们用系统调用向特定进程发送信号即kill函数。kill函数的原型如下:

int kill(pid_t pid, int sig);

kill函数用于向进程为id的发送sig信号,如果发送成功则返回0,否则就返回-1.下面我们来模拟一个kill命令。代码如下:

raise函数可以给当前进程发送信号(也就是自己给自己发送信号),如果成功返回0失败返回-1.函数原型如下:

int raise(int sig);

下面通过一段代码进行演示:先对2号信号进行捕捉然后使用raise函数每隔2秒向自己发送一个2号信号

我们发现每隔2秒就会收到一个2号信号

void abort(void)
abort函数一个无参无返回值的函数其实就是向自己发送SIGABRT(6号)信号。下面我么通过一段代码进行测试:

我们发现虽然我们以及将信号捕捉了但是进程依然退出了。在这里说明一下:

abort用于异常终止进程而exit是正常终止进程,abort是向进程发送6号信号终止进程,总是成功的。而exit函数中止进程可能失败。

软件条件产生信号

SIGPIPE信号。SIGPIPE信号其实是由一种软件条件产生的信号。在进程中通信之中当我们将读端进程将读端关闭,而写端进程依然往管道里面写入数据。此时读端进程就会收到SIGPIPE(13号信号)。下面我们来学习一下SIGALRM信号和函数alarm.函数原型如下:unsigned int alarm(unsigned int seconds);

alarm的作用是让OS在seconds秒后向当前进程发送SIGALRM信号,而SIGALRM的默认处理动作是终止进程。下面说一下alarm的返回值:

  • 调用alarm函数前设定了闹钟,则返回上一个闹钟的剩余时间,并且这次设定的闹钟会覆盖上一次设定的闹钟。

下面我们来测试一下自己的云服务器1ms之内能将一个全局变量累加到多少:

我们发现此时差不多就是4万左右,但实际上CPU的运算速度是要远远大于这个速度的这到底是为什么了。下面我们将SIGALRM信号捕捉在捕捉函数里面在将其打印我们在来看结果如何:

此时我们发现这次为什么累加到了4亿多,这个差距也太大了吧。这是因为IO的速度是很慢的,我们在使用printf进行打印时,是要打印到显示器上去也就是外设。而IO的速度特别慢所以才造成了这种现象

由硬件异常产生信号

我们之前在学习数据结构中的链表时总是遇到空指针解引用导致程序崩溃越界之类的错误或者在书写一些数学题时出现除0错误,那为什么会发生崩溃了?本质上是进程收到了OS发送的信号而被中止的。OS是如何识别到程序方式越界和空指针解引用了?我们之前在学地址空间时学习到的页表,当我们要访问一个变量时需要通过页表将虚拟地址转换为物理地址之后我们才能对其进行操作。而进行转换的工作是一个叫做MMU的硬件。当需要进行虚拟地址到物理地址的转换时如果MMU发现转换之后不是合法地址会发生错误,而这种错误会立马被OS识别并向对应的进程发送SIGSEGV信号。下面我们通过一段代码进行验证:

总结

c/c++程序崩溃的原因是当程序出现的各种错误最终会引起硬件异常,进而被OS识别,然后OS向对应的进程发送信号将进程终止.

三.阻塞信号

与信号相关的常见概念

  • 实际执行信号的处理动作称为信号递达(Delivery)
  • 信号从产生到递达之间的状态,称为信号未决(Pending)。
  • 进程可以选择阻塞 (Block )某个信号。
  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
  • 需要注意的是阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

信号在内核中的表示

信号在内核当中其实是通过位图结构进行存储的,下面通过一张图描述一下:

说明:

  • 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
  • SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
  • SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。在这里我们只研究普通信号

总结:

1.在pending位图中比特问的问题代表某一个信号,比特位的内容代表是否收到信号.为1代表收到了这个信号.

2.在block位图中比特位的位置表示是否某个信号是否被阻塞,比特位内容为1代表被阻塞,为0则代表没有被阻塞。(block位图也被称为信号屏蔽字)

3.handler表本质是一张函数指针数组,数组的下标代表某个特定的信号。数组的内容代表处理这个信号的方法处理方法有:默认,自定义,忽略。

sigset_t

现在我们以及清楚了信号在内核之中的表示方法,每个信号的未决标志位只有一个比特位不是0就是1.不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的"有效"或"无效"状态,在阻塞信号集中“有效"和"无效"的含义是该信号是否被阻塞,而在未决信号集中"有效"和"无效"的含义是该信号是否处于未决状态,阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的"屏蔽"应该理解为阻塞而不是忽略。

注意:sigset_t的实现在不同的操作系统中实现是不相同的,可能是一个无符号整数,可能是一个结构体。在我当前的云服务器上的定义如下:

信号集操作函数

上面我们提及到sigset_t的实现在不同的操作系统中的实现是不一样的,我们只知道每种信号用一个比特位表示有还是没有至于底层如何存储看OS的实现。从使用者的角度是不关心的所以我们只能调用OS提供的接口对应信号集进行操作,如果我们直接打印或者使用位操作对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);

函数说明:

  • sigemptyset函数用来初始化set所指向的信号集,将所有信号对应的别特位清0表示该信号集不存在任何有效的信号
  • sigfillset函数:初始化set所指向的信号集让信号集中每个信号都存在。表示该信号集的有效信号包括系统支持的所有信号。从位图的角度理解就是将位图的每一个比特位都置1
  • sigaddset函数:在set所指向的信号集当中添加某种信号
  • sigdelset函数:在set所指集合当中删除某种有效信号
  • sigismember:判断set所指向的集合当中是否包含某种信号如果包含返回1,不包含返回0调用失败返回-1.
  • sigemptyset,sigfillset,sigaddset和sigdelset都是调成功返回0,出错返回-1

下面我们通过一段代码进行以上函数的使用

在这里需要注意的是我们定义的s依然是一个变量和我们写c和c++代码的变量一样。我们使用这些函数对s进行了修改但并不会影响进程中的信号集只有我们使用系统调用才能将进程当中的信号集进行修改。

sigprocmask

sigprocmask函数用于读取或者更改进程信号的信号屏蔽字(阻塞信号集)函数原型如下:

int sigprocmask(int how,const sigset_t *set, sigset_t *oset);

函数说明:

  • set如果不是空指针则是更改当前进程的信号屏蔽字,参数how代表如何修改。
  • 如果oset不是空指针那么就是提取当前进程的信号屏蔽字。
  • 如果oset和set都不是空指针那么就是先将进程原来的信号屏蔽字先拷贝一份到oset里面然后在根据how修改进程的信号屏蔽字

假设当前进程的信号屏蔽字为mask.下表代表how参数的含义:

选项含义
SIG_BLOCKset中包含了我们希望添加到当前进程的信号屏蔽字的信号通俗一点就是mask=mask|set
SIG_UNBLOCKset中包含了我们希望从当前信号屏蔽字中解除阻塞的信号通俗一点就是mask=mask|~set
SIG_SETMASK设置当前信号屏蔽字为set所指向的的值就相当于:mask=set

函数返回值:调用成功返回0出错返回-1

下面我们进行一个小的演示使用sigprocmask将2号信号屏蔽掉:

我们发现此时我们在使用ctr+c进程不会有任何反应干嘛干嘛。这是因为我们将2 号信号屏蔽了,此时2号信号不会被递达

sigpending

sigpending 函数可以用来读取进程的未决 信号集。函数原型如下

int sigpending(sigset_t*set)

sigpending函数可以读取当前进程未决的信号集放到set中。如果调用成功就返回0,失败返回-1.

下面我们来做一个小测试:

1.利用上面学过的函数将2号信号屏蔽。

2.使用kill命令向该进程发送2号信号 .

3由于我们已经将2号信号屏蔽了所以该信号一直处于未决状态。

4使用sigpending 获取当前进程的未决信号集并将其打印出来

代码如下:

程序刚开始运行由于我们没有发送信号所以pending表一直是全0,当我们使用kill命令向该进程发送2号信号,但是由于2号信号已经被我们给屏蔽了所有2号信号不会被递达一直处于未决状态,因此我们可以看到第二个比特位变成1.

为了看到peding表出现由1变为0的现象我们可以让进程跑一段时间后,解除对2号信号的屏蔽。那么此时2号信号就会被递达而信号默认的处理动作是终止进程,这样我们就看不到这个现象了。所以我们需要对2号信号进行捕捉执行我们自定义的处理方法。

代码如下:

内核空间和用户空间

每个进程都有自己的地址空间,而该地址空间由内核空间和用户空间组成:

  • 用户空间主要存放的是用户写的代码和数据通过用户级页表建立虚拟地址和物理地址的映射关系
  • 内核空间主要存储的是OS的代码和数据通过内核级页表建立虚拟地址和物理地址的映射关系

内核级页表是一个全局的页表所有的进程都能够看到用来维护OS和进程之间的关系。不属于某一个进程在用户的地址空间中由于每个进程代码和数据是不同的,所以用户级地址空间是进程独有的。但是内核空间存放的是OS的代码每个进程看到的都是一样的。

注意:虽然每个进程都能够看到OS的代码但这并不意味着你可以随时执行OS的代码

我们之间说的进程切换我们现在就可以了理解了,进程切换:

1.在当前进程的地址空间中找到OS的代码。

2.执行OS的代码将当前进程的代码和数据剥离下来并换上另外一个进程的代码和数据

注意:当我们访问用户级空间时我们所处的状态必须是用户态访问内核级空间是所处状态必须是内核态

用户态和内核态

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

  • 用户态是一种用来执行普通用户代码的状态是一种受监管的状态
  • 内核态通常是用来执行OS的代码,通常权限比较高

我们之前说进程收到信号不是马上就处理的,而是在合适的时候进行处理。这里的合适的时候就是从内核态访问用户态时。

那些情况下会从用户态切换为内核态:

1.执行系统调用

2.进程切换

3.产生中断,异常,陷阱。

与之对应从内核态切换到用户态的情况:

1.系统调用完毕时

2.进程切换完毕

3.异常中断陷阱处理完毕

其中:由用户态切换到内核态我们叫做陷入内核当我们要陷入内核时本质是我们要执行OS的代码。我们要执行系统调用就必须将我们的状态由用户态切换为内核态

内核对信号的捕捉

当我们在执行主执行流时,可能会因为一些情况进入内核当内核处理完毕后准备返回用户态时会对pending表进行检测看是否有信号需要处理(注意此时状态仍然处于内核态用权力查看进程的pending表)在查看pending表时看是否收到了信号如果发现收到了,在看该信号是否被阻塞如果没有则对该信号进行处理。

如果待处理的信号的处理动作是默认或者是忽略,则执行完该信号的处理动作后清除对应pending位图的标志位,如果没有新的信号需要递达之间从内核态返回到用户态,继续执行主执行流的代码。

如果处理动作是自定义那么此时需要从内核态切换为用户态执行该自定义动作,然后通过特殊的系统调用sig_return在次陷入内核并将对应pending位图的标志位清除如果没有其他信号需要处理直接就返回用户态继续执行主执行流的代码。

在这里需要注意的是:sighandler和handler不是调用和被调用的关系是连个独立的执行流。

可能有很多老铁觉得这也太难记了下面博主介绍一种比较好记的方法:

其中直线和这个图行有4个交点代表四次状态切换而箭头所指向的方向是状态切换的方向,而中间的交点代表检测pending位图是否有信号要处理。

可能有老铁想说为什么在内核态的时候不直接指向用户自定义的处理方式了?

理论上这是可以的但是我们说内核态是一种权限比较高的状态,如果用户自定义的这个捕捉函数是一个非法的操作如果在内核态执行它就可以干很多在用户态干不了的操作比如说删库。也就是说OS不会直接执行用户的代码因为OS也不知道用户的代码是合法的还是不合法的也在一次验证了一句话(OS不相信任何人只相信自己)

sigaction

对信号进行捕捉除了可以使用我们之前使用过的signal函数之外我们还可以使用sigaction函数对信号进行捕捉。该函数原型如下:int sigaction(int signum,const struct sigaction*act,struct sigaction*oldact)

sigaction可以读取和修改指定信号想关联的处理动作调用成功返回0失败返回-1

函数说明:

1.signum代码信号的编号

2.act指针非空则根据act修改对该信号的处理动作

3.若oldact非空则通过oldact获取进程原来处理该信号的处理方法。

其中act和oldact是一个结构体变量该结构体定义如下:

struct sigaction {
              void     (*sa_handler)(int);
              void     (*sa_sigaction)(int, siginfo_t *, void *);
              sigset_t   sa_mask;
              int        sa_flags;
              void     (*sa_restorer)(void);
          };

在这里说明一下结构体的成员:首先是第一个成员sa_handler.

1.如果我们将sa_handler赋值为SIG_IGN传递给sigaction函数表示忽略该信号

2.如果我们将sa_handler赋值为SIG_DFL传给sigaction函数表示执行系统的默认动作

3.将sa_handler赋值为一个函数的地址表示用自定义函数捕捉信号。(注意给函数的返回值必须为void参数只有一个并且为int)

第二个成员:第二个成员是用来处理实时信号的在这里不研究。

第三个成员:sa_mask
首先当一个信号的处理函数被调用,内核会自动将当前信号加入到信号屏蔽字当中当信号处理完毕返回时自动将其清除,这样就避免了出现当我们真正处理这个信号但是又收到了这个信号。当处理完毕之后将该信号的屏蔽解除。如果我们在调用信号处理函数时除了当前信号被自动屏蔽之外我们还想屏蔽其他信号当信号处理函数返回时自动恢复原来的信号屏蔽字。

下面通过一个例子进行演示:

首先我们将2号信号进行捕捉将2号信号的处理动作改为自定义捕捉同时我们在处理2号信号的同时将3号信号也给屏蔽了:

volatile关键字

volatile是c语言里面的一个关键字其作用是保持内存的可见性下面我们通过一段代码进行简单的验证:

首先我们对2号信号进行捕捉,我们在信号处理函数里面将全局的flag由1置0也就是说当进程收到2号信号时flag一直为1则主执行流一直处于死循环当中。知道收到2号信号flag才会由1置0.

对应代码:

我们发现当我们使用ctr+c 给进程发送2号信号时此时flag由1置为0时主执流不在死循环,这没有问题都在我们的意料之中.那这和保持内存可见性又有什么区别.我们之前说main函数和handler函数是不同的执行流编译器只能检测到main函数是否对flag进行了修改,此时编译器检测到main函数中并没有对flag变量进行修改,当编译器优化程度比较高时可能将flag放入寄存器之中此时main函数检测flag时就去寄存器里面去检测了而不是去内存当中.即使当进程收到了2号信号已经将flag改了进程也不会跳出死循环此时我们使用volatile就可以避免这种情况发生.

SIGCHLD信号

我们在学习进程的时候用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻 塞地查询是否有子进

程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不 能处理自己的工作了;采用第二种方式,父

进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。

其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自 定义SIGCHLD信号

的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理

函数中调用wait清理子进程即可。

请编写一个程序完成以下功能:父进程fork出子进程,子进程调用exit(2)终止,父进程自定 义SIGCHLD信号的处理函数,

在其中调用wait获得子进程的退出状态并打印。

事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作

置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程。系统默认的忽

略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证

在其它UNIX系统上都可 用。

对应代码:

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
void handler(int sig)
{
 pid_t id;
 while( (id = waitpid(-1, NULL, WNOHANG)) > 0){
 printf("wait child success: %d\n", id);
 }
 printf("child is quit! %d\n", getpid());
}
int main()
{
 signal(SIGCHLD, handler);
 pid_t cid;
 if((cid = fork()) == 0){//child
 printf("child : %d\n", getpid());
 sleep(3);
 exit(1);
 }
 while(1){
 printf("father proc is doing some thing!\n");
 sleep(1);
 }
 return 0;
}

以下功能:父进程fork出子进程,子进程调用exit(2)终止,父进程自定 义SIGCHLD信号的处理函数,

在其中调用wait获得子进程的退出状态并打印。

事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作

置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程。系统默认的忽

略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证

在其它UNIX系统上都可 用。

对应代码:

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
void handler(int sig)
{
 pid_t id;
 while( (id = waitpid(-1, NULL, WNOHANG)) > 0){
 printf("wait child success: %d\n", id);
 }
 printf("child is quit! %d\n", getpid());
}
int main()
{
 signal(SIGCHLD, handler);
 pid_t cid;
 if((cid = fork()) == 0){//child
 printf("child : %d\n", getpid());
 sleep(3);
 exit(1);
 }
 while(1){
 printf("father proc is doing some thing!\n");
 sleep(1);
 }
 return 0;
}

相关文章