文章目录
- 三、信号的阻塞(信号的保存)
- 1. 信号相关其他常见概念
- 2. 在内核中的表示
- 3. sigset_t类型
- 4. 信号集操作函数
- 函数列表
- 注意事项
- 5. 读取/修改block位图 - sigprocmask
- 6. 读取pending位图 - sigpending
- 四、信号捕捉
- 1. 信号捕捉的初步认识
- 自定义捕捉
- 总结思考
- 2. 再谈进程地址空间
- 内核空间与用户空间
- 用户态和内核态
- 3. 内核如何实现信号的捕捉
- 4. sigaction函数
- 五、信号部分的总结
信号的概念和信号如何产生已经在 【Linux】进程信号概念 | 核心转储 | 信号的产生 中介绍了,本文来介绍剩下的信号的保存(阻塞)和信号的捕捉。
三、信号的阻塞(信号的保存)
1. 信号相关其他常见概念
- 实际执行信号的处理动作,称为信号递达(delivery)。
- 信号从产生到递达之间的状态,称为信号未决(pending)。
- 进程可以选择阻塞(block)某个信号,表示该进程当前不想收到这个信号,收到后把它标记为未决,但不处理它,直到解除阻塞。换句话说,被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
- 需要注意的是,阻塞和忽略是不同的,只要信号被阻塞就暂时不会递达,而忽略是在递达之后的一种处理动作。
产生一个信号,信号是未决的,这个信号不一定是阻塞的。
一个信号如果被进程阻塞,进程收到该信号之后,一定会标记它为未决。
2. 在内核中的表示
-
信号在内核中的表示示意图如下:
-
对于每个进程的pcb(task_struct对象),其中的每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作:
-
信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。
-
在示意图的例子中:
SIGHUP
信号未阻塞也未产生过,当它递达时执行默认处理动作。SIGINT
信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。SIGQUIT
信号未产生过,一旦产生SIGQUIT
信号将被阻塞,它解除阻塞后会执行的处理动作是用户自定义函数sighandler()
。
-
如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?
POSIX.1允许系统递送该信号一次或多次。
Linux是这样实现的:- 常规信号在递达之前产生多次只计一次,毕竟未决只能是0或1,不可能记录次数。
- 实时信号在递达之前产生多次可以依次放在一个队列里,本章不讨论实时信号。
用表格整理信号位图比特位的含义和现象:
信号状态 | 比特位为1 (有效) 成因 | 比特位为0 (有效) 成因 | 运行现象 |
---|---|---|---|
阻塞 | 进程使用系统调用 sigprocmask 或类似方法设置阻塞标志位 | 进程使用系统调用 sigprocmask 或类似方法解除阻塞标志位 | 阻塞的信号不会被处理,直到解除阻塞 |
未决 | 信号产生,且未被进程处理 | 进程处理了一个未决信号 | 未决的信号等待被处理,直到进程执行相应信号的处理动作 |
用流程图整理信号传递的过程:
3. sigset_t类型
上文提到,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。
未决和阻塞的标志可以用相同的数据类型sigset_t
来存储:
typedef unsigned long sigset_t;
sigset_t
就是信号位图,这个类型的每个比特位可以表示对应信号的“有效”或“无效”状态:
- 在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞。
- 在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。
阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
未决标志是在信号产生时,如果该信号没有被阻塞,并且进程没有设置信号的忽略处理方式,内核会将相应信号的未决比特位置1。
阻塞是进程的主动行为,可以通过系统调用(如sigprocmask
)来设置阻塞,将特定信号的阻塞比特位置1。
4. 信号集操作函数
在Linux中,sigset_t
类型用一个 bit 表示每种信号的“有效”或“无效”状态。使用者不需要关心该类型内部的数据存储方式,只能通过以下函数来操作 sigset_t
变量,而不应直接解释其内部数据。使用 printf
直接打印 sigset_t
变量是没有意义的。
函数列表
<signal.h> 中的系统调用 | 描述 |
---|---|
int sigemptyset(sigset_t *set); | 初始化 set 所指向的信号集,将其中所有信号的对应 bit 清零,表示该信号集不包含任何有效信号。 |
int sigfillset(sigset_t *set); | 初始化 set 所指向的信号集,将其中所有信号的对应 bit 置位,表示该信号集的有效信号包括系统支持的所有信号。 |
int sigaddset(sigset_t *set, int signo); | 向 set 所指向的信号集中添加某个有效信号。 |
int sigdelset(sigset_t *set, int signo); | 从 set 所指向的信号集中删除某个有效信号。 |
int sigismember(const sigset_t *set, int signo); | 判断一个信号集的有效信号中是否包含某个信号。若包含则返回 1,不包含则返回 0,出错返回 -1。 |
使用一下上面的函数:
#include <stdio.h>
#include <signal.h>
#include <stdio.h>
int main()
{
sigset_t s; // 用户空间定义的变量
sigemptyset(&s); // 初始化 `set` 所指向的信号集,将其中所有信号的对应 bit 清零,表示该信号集不包含任何有效信号
sigfillset(&s); // 初始化 `set` 所指向的信号集,将其中所有信号的对应 bit 置位,表示该信号集的有效信号包括系统支持的所有信号
sigaddset(&s, SIGINT); // 向 `set` 所指向的信号集中添加某个有效信号
sigdelset(&s, SIGINT); // 从 `set` 所指向的信号集中删除某个有效信号
bool test = sigismember(&s, SIGINT); // 判断一个信号集的有效信号中是否包含某个信号。若包含则返回 1,不包含则返回 0,出错返回 -1
if (test)
printf("true\n");
else
printf("false\n");
return 0;
}
结果:false
注意事项
在使用 sigset_t
类型的变量之前,必须调用 sigemptyset
或 sigfillset
进行初始化,以确保信号集处于确定的状态。初始化后,可以调用 sigaddset
和 sigdelset
在该信号集中添加或删除某个有效信号。
这四个函数都是成功返回 0,出错返回 -1。sigismember
是一个布尔函数,用于判断一个信号集的有效信号中是否包含某个信号,若包含则返回 1,不包含则返回 0,出错返回 -1。
5. 读取/修改block位图 - sigprocmask
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
使用一下:
#include <iostream>
#include <unistd.h>
#include <signal.h>
signed main()
{
std::cout << "getpid: " << getpid() << std::endl;
sigset_t block, oblock;
sigemptyset(&block);
sigemptyset(&oblock);
for (int signo = 1; signo <= 31; signo++) // 把1-31信号全写入block
{
sigaddset(&block, signo); // 在这里只是修改了block变量,没有让OS真正屏蔽signo号信号;
}
sigprocmask(SIG_BLOCK, &block, &oblock); // 将该进程的block位图替换成我们的block位图
while (true)
{
std::cout << "我已经屏蔽了所有的信号,来打我呀!" << std::endl;
sleep(1);
}
}
使用下面的bash脚本,在另外一个bash下每隔一秒kill一下该进程:
i=1; while :; do echo "send signal:${i}..."; kill -${i} 7422; sleep 1; let i++; done
发现除了9号和19号信号,其他信号都能成功屏蔽:
印证了之前说的SIGKILL (9号信号)
和SIGSTOP (19号信号)
不能被捕获、阻止或忽略。
6. 读取pending位图 - sigpending
sigpending函数可以用于读取进程的未决信号集,该函数的函数原型如下:
int sigpending(sigset_t *set);
sigpending函数读取当前进程的未决信号集,通过set参数传出。该函数调用成功返回0,出错返回-1。
实验一下:
- 先用上述的函数将2号信号进行屏蔽(阻塞)。
- 使用kill命令或组合按键向进程发送2号信号。
- 此时2号信号会一直被阻塞,并一直处于pending(未决)状态。
- 使用sigpending函数获取当前进程的pending信号集进行验证。
代码:
#include <iostream>
#include <unistd.h>
#include <signal.h>
void PrintPending(const sigset_t& pending)
{
for (int signo = 1; signo <= 32; signo++)
{
if (sigismember(&pending, signo))
{
std::cout << "1";
}
else
{
std::cout << "0";
}
}
std::cout << "\n";
}
signed main()
{
sigset_t set, oset;
sigemptyset(&set);
sigemptyset(&oset);
//1. 阻塞2号信号
sigaddset(&set, 2); //SIGINT
sigprocmask(SIG_SETMASK, &set, &oset);
//2. 让进程不断获取当前自己的pending位图
sigset_t pending;
sigemptyset(&pending);
while (1)
{
sigpending(&pending); //获取pending
PrintPending(pending); //打印pending位图(1表示未决)
sleep(1);
}
return 0;
}
可以看到,程序刚刚运行时,因为没有收到任何信号,所以此时该进程的pending表一直是全0,而当我们使用kill命令向该进程发送2号信号后,由于2号信号是阻塞的,进程收到但不处理2号信号,因此2号信号一直处于未决状态,所以我们看到pending表中的第二个数字一直是1,且进程不会退出:
问题:一个信号被递达,pending位图会将该信号的标志位从1改成0,这个修改发生在执行递达动作前还是递达动作执行完成后?
我们可以让进程自定义捕捉2号信号,让handler函数打印pending位图,就可以验证修改pending位图的动作的发生时机:#include \<iostream> #include <unistd.h> #include <signal.h> void PrintPending(const sigset_t& pending) { std::cout << "\n"; for (int signo = 1; signo <= 32; signo++) { if (sigismember(&pending, signo)) { std::cout << "1"; } else { std::cout << "0"; } } std::cout << "\n"; } void handler(int signo) { sigset_t pending; sigpending(&pending); PrintPending(pending); std::cout << "handler of signal " << signo << std::endl; } signed main() { signal(2, handler); while (true) { sleep(1); } return 0; }
实际上,键盘按下 Ctrl+C 之后,向当前进程发送2号信号,信号执行自定义的递达动作handler之前,OS已经将pending位图由1置0了。
四、信号捕捉
1. 信号捕捉的初步认识
自定义捕捉
实际上当用户按Ctrl+C时,这个键盘输入会产生一个硬件中断,被操作系统获取并解释成信号(Ctrl+C被解释成2号信号),然后操作系统将2号信号发送给目标前台进程,当前台进程收到2号信号后就会退出。
我们可以使用signal函数对2号信号进行捕捉,证明当我们按Ctrl+C时进程确实是收到了2号信号。使用signal函数时,我们需要传入两个参数,第一个是需要捕捉的信号编号,第二个是对捕捉信号的处理方法,该处理方法的参数是int,返回值是一个函数指针,指向原来的信号处理函数:
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
例如,下面的代码中将2号信号进行了捕捉,当该进程运行起来后,若该进程收到了2号信号就会打印出收到信号的信号编号。
#include <stdio.h>
#include <signal.h>
void handler(int sig)
{
printf("catch a sig : %d\n", sig);
}
int main()
{
signal(2, handler); //信号是可以被自定义捕捉的,siganl函数就是来进行信号捕捉的,提前了解一下
while(1);
return 0;
}
总结思考
-
上面所说的所有信号产生,最终都要有OS来进行执行,为什么?
因为OS是进程的管理者。 -
信号的处理是否是立即处理的?
不是所有信号的处理都是立即进行的,而是在合适的时候处理,“合适的时候”是指进程从内核态返回到用户态的时候。有些信号,例如SIGKILL
,会立即终止进程。但对于其他信号,处理可能会延迟,具体取决于进程的状态以及是否被阻塞。 -
信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适呢?
是的,如果信号不能立即处理,需要被暂时记录下来。这通常在进程的pending
结构体中进行记录,其中包括一个位图用于表示未决信号的状态。 -
一个进程在没有收到信号的时候,能否知道自己应该对合法信号作何处理呢?
进程可以在收到信号之前,通过注册信号处理函数来定义对合法信号的处理方式。这通常通过使用signal()
或sigaction()
系统调用来实现。进程可以指定默认的处理动作,或者自定义处理函数。 -
如何理解OS向进程发送信号?能否描述一下完整的发送处理过程?
当发生触发信号的事件时(例如按下 Ctrl+C 产生SIGINT
),操作系统会向相应进程发送信号。该信号会被记录在进程的pending
位图中。如果信号不被阻塞,进程会根据信号的处理方式(默认动作、自定义处理函数等)来执行相应的处理。如果信号被阻塞,信号会在解除阻塞后递达,然后按照相应的处理方式进行处理。
2. 再谈进程地址空间
内核空间与用户空间
每一个进程都有自己的进程地址空间,该进程地址空间由内核空间和用户空间组成:
- 用户所写的代码和数据位于用户空间,通过用户级页表与物理内存之间建立映射关系。
- 内核空间存储的实际上是操作系统代码和数据,通过内核级页表与物理内存之间建立映射关系。内核级页表是一个全局的页表,它用来维护操作系统的代码与进程之间的关系。因此:
- 在每个进程的进程地址空间中,用户空间是属于当前进程的,每个进程看到的代码和数据是完全不同的,只能看到自己的那一份;
- 内核空间所存放的都是操作系统的代码和数据,所有进程看到的都是一样的内容,OS的代码和数据被所有内存共享。
虽然每个进程都能够看到操作系统,但并不意味着每个进程都能够随时对其进行访问,当进程访问用户空间时进程必须处于用户态,当进程访问内核空间时进程必须处于内核态。
用户态和内核态
用户态:执行用户所写的代码时,就属于 用户态
内核态:执行操作系统的代码时,就属于 内核态
从用户态切换为内核态通常有如下几种情况:
- 需要进行系统调用时。
- 当前进程的时间片到了,导致进程切换。
- 产生异常、中断、陷阱等。
与之相对应,从内核态切换为用户态有如下几种情况:
- 系统调用返回时。
- 进程切换完毕。
- 异常、中断、陷阱等处理完毕。
其中,由用户态切换为内核态我们称之为陷入内核。每当我们需要陷入内核的时,本质上是因为我们需要执行操作系统的代码,比如系统调用函数是由操作系统实现的,我们要进行系统调用就必须先由用户态切换为内核态。
陷入内核和切换回用户态的底层实现:
在
CPU
中,存在一个CR3
寄存器,这个 寄存器 的作用就是用来表征当前处于 用户态 还是 内核态
- 当寄存器中的值为
3
时:表示正在执行用户的代码,也就是处于用户态- 当寄存器中的值为
0
时:表示正在执行操作系统的代码,也就是处于 内核态
3. 内核如何实现信号的捕捉
当我们在执行主控制流程的时候,可能因为某些情况而陷入内核,当内核处理完毕准备返回用户态时,一定会进行信号pending表的检查。(此时仍处于内核态,有权力查看当前进程的pending位图)
在查看pending位图时,如果发现有未决信号,并且该信号没有被阻塞,那么此时就需要该信号进行处理,因为处理方式有默认和自定义两种情况,我们分情况讨论一下:
- 情况1:信号的默认处理(如果没有用
signal()
或sigaction()
来注册信号的自定义捕捉行为)
如果待处理信号的处理动作是默认或者忽略,则执行该信号的处理动作后清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,从主控制流程中上次被中断的地方继续向下执行即可。
- 情况2:自定义捕捉(用户代码中用
signal()
或sigaction()
注册了信号的自定义捕捉行为)
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这就是信号的捕捉。用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。 sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
上面两次从内核态回到用户态的过程中,都会检查pending表,因为要检查有没有需要处理的信号。
简化一下上面这张图:
4. sigaction函数
捕捉信号除了用前面用过的signal()
函数之外,我们还可以使用sigaction()
函数对信号进行捕捉,sigaction
比 signal
功能更丰富,sigaction()
函数的函数原型如下:
NAME
sigaction - examine and change a signal action
SYNOPSIS
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
sigaction结构体:
struct sigaction
{
void (*sa_handler)(int); //自定义动作
void (*sa_sigaction)(int, siginfo_t *, void *); //实时信号相关
sigset_t sa_mask; //待屏蔽的信号集
int sa_flags; //一些选项,一般设为 0
void (*sa_restorer)(void); //实时信号相关
};
返回值:成功返回 0
,失败返回 -1
并将错误码设置
参数1:待操作的信号
参数2:sigaction
结构体,具体成员如上所示
参数3:保存修改前进程的 sigaction
结构体信息
我们可以像使用signal()
一样使用sigaction()
:
#include <iostream>
#include <signal.h>
using namespace std;
void PrintPending(const sigset_t& pending)
{
std::cout << " 当前进程的 pending 表为: ";
for (int signo = 1; signo <= 32; signo++)
{
if (sigismember(&pending, signo))
{
std::cout << "1";
}
else
{
std::cout << "0";
}
}
std::cout << "\n";
}
void handler(int signo)
{
sigset_t pending;
sigpending(&pending);
while (true)
{
PrintPending(pending);
sleep(2);
}
}
int main()
{
cout << "当前进程的pid:" << getpid() << endl;
struct sigaction act, oact;
// 初始化自定义动作
act.sa_handler = handler;
// 给2号信号注册自定义动作
sigaction(2, &act, &oact);
while (true);
return 0;
}
只对二号信号进行了自定义捕捉,收到二号信号后不断打印pending位图,此时发别的信号依然能正常终止该进程,运行现象和用signal()
一样:
但是如果设置了sa_mask
字段,则当进程递达信号并执行用户自定义动作handler时,可以将部分信号进行屏蔽,直到用户自定义动作执行完成。
加入对3,4,5信号的屏蔽:
//初始化 屏蔽信号集
sigaddset(&act.sa_mask, 3);
sigaddset(&act.sa_mask, 4);
sigaddset(&act.sa_mask, 5);
完整代码:
#include <iostream>
#include <signal.h>
#include <assert.h>
using namespace std;
void PrintPending(const sigset_t& pending)
{
std::cout << " 当前进程的 pending 表为: ";
for (int signo = 1; signo <= 32; signo++)
{
if (sigismember(&pending, signo))
{
std::cout << "1";
}
else
{
std::cout << "0";
}
}
std::cout << "\n";
}
void handler(int signo)
{
cout << signo << "号信号递达了" << endl;
sigset_t pending;
int cnt = 15;
while (cnt--)
{
int ret = sigpending(&pending);
assert(ret == 0);
(void)ret; // 假装用一下ret,欺骗编译器,避免 release 模式中出错
PrintPending(pending);
sleep(1);
}
}
int main()
{
cout << "当前进程的pid:" << getpid() << endl;
struct sigaction act, oact;
// 初始化 自定义动作
act.sa_handler = handler;
//初始化 屏蔽信号集
sigaddset(&act.sa_mask, 3);
sigaddset(&act.sa_mask, 4);
sigaddset(&act.sa_mask, 5);
// 给2号信号注册自定义动作
sigaction(2, &act, &oact);
while (true);
return 0;
}
当 2
号信号的循环结束(10
秒),3、4、5
信号的 阻塞 状态解除,立即被 递达,进程就被干掉了:
五、信号部分的总结
信号的知识按照:
的顺序展开:
- 信号产生阶段:有四种产生方式,包括 键盘键入、系统调用、软件条件、硬件异常。
- 信号保存阶段:内核中存在三张表,
blcok
表、pending
表以及handler
表,信号在产生之后,存储在pending
表中。 - 信号处理阶段:信号在 内核态 切换回 用户态 时,才会被处理,处理方式或默认或忽略或自定义。