Linux(17)——Linux进程信号(下)
目录
三、保存信号
✍️信号相关的概念补充
✍️在内核中的表示
✍️sigset_t
✍️信号集相关操作函数
✍️sigprocmask
✍️sigpending
四、捕捉信号
✍️内核空间和用户空间
✍️用户态和内核态
✍️内核对信号的捕捉
✍️sigaction函数
三、保存信号
当前阶段:
✍️信号相关的概念补充
- 实际执行信号的处理动作,我们称之为信号递达(Delivery)。
- 信号从产生到递达之间的状态,我们称之为信号未决(Pending)。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
- 需要注意的是,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后的一种处理动作。
✍️在内核中的表示
示意图如下:
- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
- SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会在改变处理动作之后再接触阻塞。
- SIGQUIT信号未产生过,但一旦产生SIGQUIT信号,该信号将被阻塞,它的处理动作是用户自定义函数sighandler。如果在进程解除对某信号的阻塞之前,这种信号产生过多次,POSIX.1允许系统递达该信号一次或多次。Linux是这样实现的:普通信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里,这里只讨论普通信号。
敲黑板:
1、在block位图和pending位图中,比特位的位置代表某一个信号,比特位的内容分别是代表该信号是否被阻塞(block),代表是否收到该信号(pending)。
2、handler表本质上是一个函数指针数组,数组的下标代表某一个信号,数组的内容代表该信号递达时的处理动作,处理动作包括默认、忽略以及自定义。
3、block、pending和handler这三张表的每一个位置是一一对应的。
✍️sigset_t
我们在实际存储的时候,未决和阻塞标志可以使用同一个数据类型来存储,那就是sigset_t,我们打开我们的云服务器中的/usr/include/x86_64-linux-gnu/bits/types/sigset_t.h文件就可以看到:
#ifndef __sigset_t_defined
#define __sigset_t_defined 1#include <bits/types/__sigset_t.h>/* A set of signals to be blocked, unblocked, or waited for. */
typedef __sigset_t sigset_t;#endif
这里我们发现这个类型被typedef成了__sigset_t,这个时候我们打开/usr/include/x86_64-linux-gnu/bits/types/__sigset_t.h文件,就可以看到:
#ifndef ____sigset_t_defined
#define ____sigset_t_defined#define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
{unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;#endif
我们称这个sigset_t为信号集,这个类型可以表示每一个信号的有效和无效的状态。
阻塞信号集也叫当前进程的信号屏蔽字(Signal Mask)。
✍️信号集相关操作函数
我们的sigset_t类型表示的是每个信号不同标志的有效和无效,我们这里并不需要关心这些bit是什么存储的,这个依赖于具体的操作系统,我们更加关心的是使用信号集,下面是几个常用的操作函数:
/* Clear all signals from SET. */
int sigemptyset (sigset_t *__set);/* Set all signals in SET. */
int sigfillset (sigset_t *__set);/* Add SIGNO to SET. */
int sigaddset (sigset_t *__set, int __signo);/* Remove SIGNO from SET. */
int sigdelset (sigset_t *__set, int __signo);/* Return 1 if SIGNO is in SET, 0 if not. */
int sigismember (const sigset_t *__set, int __signo);
说明一下:
- sigemptyset函数:如其名,用来初始化所指向的信号集,使得其中的所有信号的对应bit清零,表示该信号集不包含任何有效的信号。
- sigfillset函数:初始化set所指向的信号集,使得其中的所有信号的bit都置位。
- sigaddset函数:在set所指向的信号集中添加某个信号。
- sigdelset函数:在set所指向的信号集中删除某个信号。
- 这上面几个函数都是成功返回0,出错返回-1。
- sigismember函数:判断在set所指向的信号集中是否包含了某个信号,若包含就返回1,不包含就返回0,调用失败就返回-1。
敲黑板:
这里要注意一点,就是在使用sigset_t类型之前一定要调用sigemptyset或是sigfillset来进行初始化,使得我们的信号集处于一种确定的状态。
我们可以可以写个代码见一见:
#include <stdio.h>
#include <signal.h>
#include <asm-generic/signal.h>int main() {sigset_t s;sigemptyset(&s);// sigfillset(&s);sigaddset(&s, SIGINT);sigdelset(&s, SIGINT);sigismember(&s, SIGINT);return 0;
}
敲黑板:
这个代码里面的s和我们平时定义的变量是一样的,都是在用户空间中定义的变量,我们后面的一系列的操作实际上都是在对用户空间的变量进行修改,不会影响到进程的任何行为,所以我们还需要通过系统调用,将变量s写透进操作系统中。
✍️sigprocmask
这个函数就是用来读取或是更改进程的信号屏蔽字的,函数原型如下:
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
参数说明:
set,如果是非空的,则更改进程的信号屏蔽字,参数how用来指示如何更改。
oldset,如果是非空的,则读取进程当前的信号屏蔽字通过oldset参数传出。
如果这两个参数都是非空指针,那么就将原来的信号屏蔽字备份到oldset中,再来通过how的选项来更改信号屏蔽字。
选项 | 含义 | 说明 |
---|---|---|
SIG_BLOCK | 将 set 中的信号 加入 屏蔽集 | 就是“阻塞这些信号” |
SIG_UNBLOCK | 将 set 中的信号 从屏蔽集移除 | 就是“解除阻塞这些信号” |
SIG_SETMASK | 用 set 替换整个屏蔽集 | 旧的屏蔽设置会被完全替换掉 |
返回值说明:
sigpromask函数调用成功返回0,出错返回-1。
✍️sigpending
sigpenging函数可以用来读取进程的未决信号集,函数的原型如下:
int sigpending(sigset_t *set);
参数说明:
这个函数就是读取当前进程的未决信号集,并通过set参数传出,调用成功返回0,出错返回-1。
下面我们来写个代码来见一见:
我们的思路是:先用上述的函数将2号信号进行阻塞,再使用kill命令或是组合按键(ctrl + c)发送2号信号,这会导致2号信号一直处在被阻塞的状态,也就是处在了pending状态,使用sigpending函数获取当前进程的pending信号集进行验证。
代码如下:
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdbool.h>void printPending(sigset_t *pending) {for(int i = 1; i <= 31; i++) {if(sigismember(pending, i)) {printf("1 ");} else {printf("0 ");}}printf("\n");
}
int main() {sigset_t set, oldset;sigemptyset(&set);sigemptyset(&oldset);sigaddset(&set, 2);sigprocmask(SIG_SETMASK, &set, &oldset);sigset_t pending;sigemptyset(&pending);while(true) {sigpending(&pending);printPending(&pending);sleep(1);}return 0;
}
我们可以看到,程序在收到了kill命令向进程发送的2号信号的时候,由于2号信号是阻塞的,所以2号信号一直处在未决的状态,我们可以看到pending标的第二个数字一直是1。
我们这里改进了一下,为了看到我们的2号信号递达之后的pending表的变化,我们设置了一个解除2号信号阻塞状态的动作,解除之后我们的2号信号就会立即被递达。因为2号信号的默认动作是终止进程,所以我们收到2号信号之后要对其进行捕捉,让它执行我们自定义的动作。
代码如下:
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdbool.h>void printPending(sigset_t *pending) {for(int i = 1; i <= 31; i++) {if(sigismember(pending, i)) {printf("1 ");} else {printf("0 ");}}printf("\n");
}void handler(int signal) {printf("我是%d, 我获得了一个信号%d\n", getpid(), signal);
}int main() {signal(2, handler);sigset_t set, oldset;sigemptyset(&set);sigemptyset(&oldset);sigaddset(&set, 2);sigprocmask(SIG_SETMASK, &set, &oldset);sigset_t pending;sigemptyset(&pending);int count = 0;while(true) {sigpending(&pending);printPending(&pending);sleep(1);count++;if(count == 20) {sigprocmask(SIG_SETMASK, &oldset, NULL);printf("这里开始恢复\n");}}return 0;
}
这里我们可以看到,进程在收到2号信号之后,该信号在一段时间内处于未决状态,当解除了2号信号就会立即递达,执行我们给出的自定义动作,而此时的pending表也会变回全是0的状态。
敲黑板:
我们这里发现在我们执行sigprocmask函数之后,我们是先执行自定义动作再是打印“这里开始恢复”的,这是因为我们的信号处理函数会打断当前正在执行的函数,而去处理信号的动作,再返回来执行后续操作。
四、捕捉信号
当前阶段:
✍️内核空间和用户空间
我们知道每一个进程都是有自己的进程地址空间的,进程等待地址空间是由内核空间和用户空间组成的:
用户写的代码和数据是位于我们的用户空间的,通过用户级页表和物理内存之间建立起映射关系。
内核空间就是存储的实际操作系统的代码和数据,通过内核级页表和物理内存之间建立映射关系。
内核页表是一个全局的页表,它是用来维护操作系统的代码和进程之间关系的,所以在每个进程的地址空间中,用户空间是属于当前的进程的,每一个进程的代码和数据都是不同的,但是内核空间所存放的都是操作系统的代码和数据,所有进程看到的都是同样的内容。
分析完之后我们应该怎么理解进程切换呢?
1、我们先要保证操作系统的代码的执行,于是我们先要在当前进程的进程地址空间中的内核空间中找到操作系统的代码和数据。
2、我们要先执行操作系统的代码和数据,将当前进程的代码和数据剥离下来,然后换上我们要切换的进程的代码和数据。
敲黑板:
我们访问用户空间必须是出于用户态的,我们访问内核空间的时候必须是要处于内核态的。
✍️用户态和内核态
其实理解用户态和内核态主要是要理解他们之间是怎么进行切换的。
我们先来分开谈一谈概念:
内核态:通常用来执行操作系统的代码,是一种高权限的状态。
用户态:是一种用来执行普通用户的代码的状态,是一种受到监管的较低权限状态。
就比如我们在处理信号的时候,我们往往是说在合适的时候处理,这个合适的时候就是从高权限的内核态切换回低权限的用户态的时候。
那么用户态和内核态之间是怎么进行交换的呢?
其实我们需要知道有哪些情况是从用户态切换到内核态,哪些是内核态切换回用户态的:
前者:
需要使用系统调用的时候。
当前进程的时间片到了的时候导致进程切换。
产生了异常、终端和陷阱等。
后者与前者一一对应:
系统调用返回的时候。
进程切换完毕。
异常、中断和陷阱等处理完。
说明一下:
这里的用户态切换到内核态我们也可以说是陷入内核,其实我们的每一次陷入内核,本质上就是因为我们要执行操作系统的代码,比如系统调用的时候,我们就要进行陷入内核的操作了。
✍️内核对信号的捕捉
我们在执行一些流程的时候,很可能会因为一些情况陷入了内核,当我们的内核处理完毕之后准备返回用户态时,就需要进行信号的未决(pending)检查。
在检查pending位图的时候,如果发现了有未决的信号,而且这个信号没有被阻塞,就对这个信号进行处理。如果这个信号的处理动作是默认或是忽略,则执行完该信号之后就要清除其对应的pending标志位,如果已经没有了新的信号要进行递达,就直接返回回用户态即可,在主流程中上一次终端的地方(也就是陷入内核的地方)继续向后执行即可。流程简图如下:
但是呢如果这里的信号是自定义捕捉了的,也就是说这个动作是用户提供的,那么我们在处理这个信号的时候就要先返回回用户态执行对应的自定义处理动作了,执行完了之后再通过某个特殊的系统调用(sigreturn)再次陷入内核中清除对应的、pending标志位,如果是已经没有了新的信号要递达了,那么就直接返回用户态,继续执行在上一次中断的地方之后的代码了。流程简图如下:
敲黑板:
这里需要注意的是,我们的sighandler和main函数是使用的不同的堆栈空间的,它们之间不存在什么调用和被调用的关系的,是相对独立的进程。
怎么记忆
其实这个过程可以类比到我们数学中的无穷符号——“♾️”,也就是下面这个图了:
✍️sigaction函数
我们的捕捉动作出了我们上面用的,signal函数之外,我们还可以使用sigaction函数对信号进行捕捉,函数的原型如下:
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
参数说明:
第一个参数signum代表的是指定的信号的编号。
第二个参数act指针非空时,根据act修改信号的处理动作。
第三个参数oldact非空时,则通过oldact传入该信号的原来的动作。
这里面的后两个参数的类型都是segaction,该结构体的定义如下:
struct sigaction {void (*sa_handler)(int); // 信号处理函数指针(简单处理)sigset_t sa_mask; // 信号掩码,指定在处理该信号时要阻塞的信号int sa_flags; // 控制信号处理行为的标志void (*sa_restorer)(void); // 过时字段,现代系统通常不使用
};
第一个成员:
如果是赋值为常数SIG_IGN传入到了sigaction函数中,表示忽略信号。
如果是赋值为常数SIG_DFL传入到了sigaction函数中,表示执行系统的默认动作。
如果是赋值为一个函数指针,表示的是自定函数捕捉信号,或是说向内核注册了一个信号处理函数。
第二个成员:
这里我们首先需要明确的是,当我们某一个信号的处理函数被调用的时候,内核会自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回的时候就又会恢复到原来的样子,这是为了保障我们在处理一个信号的时候,如果这个信号再次产生了,我们就会被阻塞到当前处理结束为止。
如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,我们还想着屏蔽一些其他的信号,则用sa_mask字段说明这些需要额外屏蔽的信号,同样的当我们的信号处理函数返回的时候,自动恢复我们之前的信号屏蔽字。
第三个成员:
我们这里不考虑那么多了,直接设置为0就行了。
第四个成员:
不使用。