序言
在本篇内容中,将为大家介绍在操作系统中的一个重要的机制 — 信号。大家可能感到疑惑,好像我在使用 Linux 的过程中并没有接触过信号,这是啥呀?其实我们经常遇到过,当我们运行的进程当进程尝试访问非法内存地址时,我们的进程会被中断,这是因为操作系统向该进程发送了中断信号。
Linux 操作系统离不开信号机制,在这篇文章中,让我们走进信号,了解信号的从哪里来,又到哪里去。
1. 信号的概念
1.1 定义
信号是操作系统向进程发送的一种通知,表示某个特定事件已经发生。在Unix、类Unix 以及其他系统中,信号被广泛使用。
1.2 特点
信号具有如下的特点:
异步性
:信号的产生对进程来说是异步的,即进程无法预知信号何时到来
。通知机制
:信号是一种软件中断(由软件程序触发的中断方式)
,用于中断进程的正常执行流程,使其处理特定事件。进程间通信
:虽然信号主要用于异常处理和系统调试,但也可以用于进程间的基本通信。
1.3 种类
在 Linux 系统下,使用指令:kill -l
即可查看所有的信号:
信号是使用宏定义的,每个信号前面的数字,就是该信号宏对应的值。
前 31 个信号为常规信号,其余为实时信号。在本篇文章中,我们主要讨论前 31 个常规信号。
你也可以使用指令: man 7 signal
查看每一个详细信息:
补充知识点:Core && Term
在描述信号的字段中,有一个叫做 Action
的特征,他的值大多都是 Core Term
这是什么呢?
Term
term
是 terminate
的缩写,表示默认动作是终止进程
。当进程接收到一个默认动作为 term
的信号时,进程会被立即终止。
Core
core
表示 默认动作是终止进程并生成一个核心转储(core dump)文件
。核心转储是一个包含进程在终止时的内存映像的文件,它对于调试程序非常有用,因为它 提供了进程终止时的状态信息
。
咦?就比如,SIGSEGV 段错误
当我的程序非法访问被终结时,被没有产生传说中的核心转储文件呀?这是因为你的服务器默认关闭了该功能,使用指令 ulimit -a
查看:
现在我们使用指令 ulimit -c 4096
开启该功能:
现在我们运行下一段程序:
8 int main()
9 {
10
11 int *ptr = NULL;
12 *ptr = 1;
13
14 return 0;
15 }
程序不负众望地报错并退出了,产生了一个文件:
这个文件可以干嘛呢?当我们的程序出现异常时,相当该文件保存了案发现场,具体的用法是:
- 首先使用
gdb
调试你的程序:
- 之后输入指令
core-file your_core
可以看到,直接就复原了事故现场。
区别
- 进程终止:
term
信号会终止进程,但不生成核心转储文件
。term
信号通常是用于请求进程正常终止的情况。 - 调试信息:
core
信号不仅会终止进程,还会生成核心转储文件
,这包含了进程的内存映像、寄存器状态、堆栈跟踪等信息,用于调试目的。
2. 信号的产生
信号是从哪里产生的呢?虽然最后都是操作系统来执行对一个进程发送信号,但是是谁告诉操作系统这样做的呢?
2.1 用户操作 — kill
指令
当我们运行一个程序时,可以通过指令 kill
来让操作系统对该进程发送相应的信号,就比如,我们可以手动发送 SIGKILL 9号
信号将该进程终结,这里有一个程序:
1 TestSig1.cc X
1 #include <iostream>
2 #include <unistd.h>
3
4
5 int main()
6 {
7 while(true)
8 {
9 std::cout << "I am Running, my pid is " << getpid() << std::endl;
10 sleep(1);
11 }
12 return 0;
13 }
该程序会每秒打印相应的内容,现在我们可以使用相关的指令 kill -9 [pid]
来杀掉该进程:
可以看到该进程被杀掉了!!!
2.2 用户操作 — 按键操作
我们也可通过按键来让操作系统发送相关的信号,就比如我们平时终止一个进程的方式更多的是通过键盘按键,就比如 ctrl + c
:
其实这个按键对应的就是 3 号信号 SIGQUIT
。
2.3 用户操作 — 系统调用
操作系统提供一个系统调用 int kill(pid_t pid, int sig);
该函数你可以想指定进程发送信号:
pid
: 表示要发送信号的进程ID
sig
: 表示要发送的信号- 返回值:成功返回 0 ,失败返回 -1 ,错误码被设置
还有一个函数是 int raise(int sig);
,该函数是向当前进程发送指定信号,简单来说,相当于简化的 kill
=> kill(getpid(), int sig);
2.4 触发软件条件
在之前管道的学习中,我们了解到如果 读端被关闭了,写端一直再写
,那么操作系统就会认为这是一个坏掉的管道,就会发送 13 号信号 SIGPIPE
终止该进程,这就是触发了某种软件条件。
现在,在这里先向大家介绍几个非常重要的函数:
signal
函数
该函数允许程序员定义当特定信号发生时,程序应该如何响应, 简单说,这个函数用于捕获特定信号,然后执行指定操作的
。sighandler_t signal(int signum, sighandler_t handler);
:
signum
:指定要处理的信号类型。注意,SIGKILL
和SIGSTOP
这两个信号不能被捕获、阻塞或忽略。handler
:指定信号的处理方式。它可以是一个函数指针,指向一个用户定义的信号处理函数;也可以是SIG_IGN
,表示忽略该信号;或者是SIG_DFL
,表示采用信号的默认处理方式。- 返回值:成功时,
signal
函数返回之前为该信号设置的信号处理函数的指针。如果之前没有为该信号设置过处理函数,则返回SIG_DFL
。失败时,返回SIG_ERR
,并设置errno
以指示错误原因。
看着描述这么多,其实用起来不复杂,比如,现在我要捕获 2 号信号 SIGINT
,他的默认操作是退出,现在我不想要推出,想要执行我的逻辑:
1 TestSig1.cc X
1 #include <iostream>
2
3 #include <unistd.h>
4 #include <sys/types.h>
5 #include <signal.h>
6
7 void signal_handle(int signum)
8 {
9 std::cout << "I got you signal: " << signum << std::endl;
10 }
11
12 int main()
13 {
14
15 // 2号信号的捕获
16 signal(2, signal_handle);
17
18 while(true)
19 {
20 std::cout << "I am Running, my pid is " << getpid() << std::endl;
21 sleep(1);
22 }
23 return 0;
24 }
现在我们使用 ctrl + c
已经不能终止该进程了:
你也可将 signal(2, signal_handle);
中的函数换成 SIG_IGN
这样就会忽略该信号。
现在我有个想法就是将全部信号都捕获,在写个死循环,是不是就没有人把我停下来了!!!我们能想到的,人家肯定也想到了,规定 9 号信号 SIGKILL 和 19 号信号 SIGSTOP 这两个信号不能被捕获、阻塞或忽略。
保证系统的稳定性和管理员的控制权。
alarm
函数
大家为了早起都设置过闹钟吧,闹钟的作用就是时间一到就提醒我们执行某件任务。在 Linux
中的闹钟 alarm
也是一样的,我们设置一个定时,当时间一到执行某项任务,unsigned int alarm(unsigned int seconds);
:
seconds
:定时器应该等待的秒数。如果seconds
是 0,则任何当前设置的定时器都会被取消(你可以同时设置多个闹钟),但不会发送SIGALRM
信号。- 返回值:如果之前已经设置了定时器,
alarm
函数返回之前设置的剩余时间(秒)
,直到定时器到期。如果之前没有设置定时器,则返回 0。
当 alarm
定时器到期时,会向进程发送 SIGALRM
信号,终止进程:
12 int main()
13 {
14
15 // 设置一个闹钟,执行默认操作
16 alarm(2);
17
18 while(true)
19 {
20 std::cout << "I am Running, my pid is " << getpid() << std::endl;
21 sleep(1);
22 }
23 return 0;
24 }
两秒之后,进程自动终止:
但更多情况下,我们想要闹钟解释后执行我们的逻辑,而不是终止进程,那咋办呢? 捕获该信号,自定义处理信号
,这就需要我们上面说的 signal
函数了:
7 void signal_handle(int signum)
8 {
9 std::cout << "Your alarm clock is ringing." << std::endl;
10 }
11
12 int main()
13 {
14
15 // 设置一个闹钟,执行默认操作
16 alarm(2);
17 // 捕获闹钟信号
18 signal(SIGALRM, signal_handle);
19
20 while(true)
21 {
22 std::cout << "I am Running, my pid is " << getpid() << std::endl;
23 sleep(1);
24 }
25 return 0;
26 }
现在闹钟时间到了,就不会终止进程啦!但是,我还有一个疑问,你这个闹钟只能执行一次呀,之后就失效了,我想要一个一直生效的定时任务,怎么做到呢?当捕获并执行自定义函数时再设置一个闹钟不就好啦:
7 void signal_handle(int signum)
8 {
9 std::cout << "Your alarm clock is ringing." << std::endl;
10 alarm(2);
11 }
这样就得到一个持续的定时任务啦:
在这里的闹钟就是一个触发了软件条件(倒计时),从而产生信号发送给进程!
2.4 硬件异常
段错误
在我们的程序中,很可能涉及到 段错误(非法内存访问)
,具体触发错误的细节如下:
- 现代计算机使用内存管理单元(
MMU
)来管理内存。MMU
负责将虚拟地址(程序使用的地址)映射到物理地址(实际内存地址)
。 - 当我们尝试访问一个地址时,
MMU
尝试将虚拟地址翻译为物理地址,并检查该虚拟地址对应的页表项,以确定是否有权限访问该地址,以及地址是否有效 - 当
CPU
发现该块地址是无效的,或者是不具有写权限的,或者是无权限访问的
,将触发异常 - 操作系统向该进程发送
SIGSEG
的信号
这就是简单的硬件异常触发流程。
3. 信号的保存
现在我们已经基本了解了信号是从哪里来的,那么信号被一个进程接受过后,是以什么形式存在于进程当中呢?
在介绍信号的保存之前,希望大家记住这几个概念:
- 实际执行信号的处理动作称为信号递达(
Delivery
) - 信号从产生到递达之间的状态,称为信号未决(
Pending
)。
信号的信息被保存在一个进程的 task_struct
中:
我们来好好的介绍这 3 个结构:
3.1 block 位图
Block
位图用于指示哪些信号当前被进程 阻塞
。如果一个信号在 Block
位图中对应的位被设置(为 1 ),那么即使该信号已经到达,它也不会被立即处理,而是会 保持在未决状态,直到进程解除对该信号的阻塞。
3.2 pending 位图
Pending
位图(通常不是直接暴露给用户的,而是作为进程控制块 task_struct
的一部分)用于 跟踪哪些信号已经到达进程但尚未被处理
。每个位代表一个信号,如果该位被设置(通常为 1 ),则表示对应的信号已经到达且处于 未决状态
。
3.3 handler 函数指针数组
用户可以通过系统调用来设置特定信号的处理函数。当用户为某个信号注册了一个自定义的处理函数时,操作系统就会将该函数的地址存储在 handler
表中对应信号编号的位置。如果用户没有为某个信号设置自定义处理函数,那么当该信号发生时,操作系统就会 调用默认的处理函数
。
所以,我们总结一下,当一个信号传递给进程时,操作系统会将
pending
表中该信号对应的值置 1,如果block
表中的对应的值 也是 1,代表该信号被阻塞,不会被立即处理,直至解除阻塞;当解除阻塞或者一开始就不是阻塞状态的话,就会执行handle
表中该信号对应的函数操作。
3.4 验证结论
现在我们准备验证我们的想法,我们先阻塞一个信号,然后发送该信号,查看是否执行相关操作,在解除对该信号的阻塞,再次观察现象:
在这里会涉及到对信号集的操作,大家可以简单理解为
对信号集进行的对某个信号的阻塞操作最终会保存到阻塞表中
,在这里就不具体说明操作了,感兴趣的小伙伴,我找了一篇比较好的文章 👉信号集操作指南。
6 // 自定义函数
7 void signal_handler(int signum)
8 {
9 std::cout << "Recived signal SIGINT!!!" << std::endl;
10 }
11
12 int main()
13 {
14 // 捕获信号
15 signal(SIGINT, signal_handler);
16
17 sigset_t sigset;
18 // 初始化信号集
19 sigemptyset(&sigset);
20 // 添加指定信号到信号集
21 sigaddset(&sigset, SIGINT);
22
23 // 阻塞该信号
24 if(sigprocmask(SIG_BLOCK, &sigset, NULL) == -1)
25 {
26 perror("sigprocmask");
27 }
28
29 std::cout << "SIGINT is blocked. Try pressing Ctrl+C after 5s!!!\n" << std::endl;
30 sleep(5);
31
32 // 解除阻塞
33 if(sigprocmask(SIG_UNBLOCK, &sigset, NULL) == -1)
34 {
35 perror("sigprocmask");
36 }
37
38 std::cout << "SIGINT is unblocked. Try pressing Ctrl+C!!!\n" << std::endl;
39
40 while(true)
41 {
42 sleep(1);
43 }
44 }
第一次我们按 ctrl + c
没什么反应,过了 5s 后,函数自动被执行,可以看出我们的结论是正确的。
4. 信号的处理
现在我们知道信号哪里来的了,也知道保存在哪里了,现在我们来看看信号的处理方式。
4.1 执行默认方式
对于没有为其注册信号处理函数的信号,进程会执行该信号的默认操作。就比如,SIGTERM
信号的默认操作是请求进程终止,而 SIGSEGV(段错误)
信号的默认操作是生成core文件并终止进程。
4.2 调用信号处理函数
如果进程为某个信号注册了信号处理函数(也称为信号处理器,上面代码中的 signal_handler
函数),那么当该信号到达时,内核会暂停进程的正常执行流程,转而调用该处理函数。
4.3 忽略信号
进程可以选择忽略某些信号。这意味着当这些信号到达时,进程不会执行任何特别的操作,而是继续执行其当前的代码路径。
然而,需要注意的是,并非所有信号都可以被忽略。例如,SIGKILL和SIGSTOP等信号是不能被忽略的
。
4.4 阻塞信号
进程可以选择屏蔽某些信号,以 避免在关键操作期间接收到这些信号
。通过调用sigprocmask
等系统调用,进程可以设置其信号屏蔽字,以决定哪些信号能够传递到进程中。被屏蔽的信号将保持在未决状态,直到屏蔽被解除后才会被处理。
4.5 阻塞和忽略的区别
这两个概念相当容易混淆,从定义上来说:
- 阻塞是指
进程选择性地阻止某些信号的传递
。当这些被阻塞的信号发生时,它们会被内核记录下来(处于未决状态
),但不会立即执行信号的处理函数或执行默认操作。 - 忽略是指进程对收到的某些信号
不执行任何操作
,即不调用处理函数也不执行默认操作,而是简单地丢弃这些信号。
大家可以这样理解:信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
前者是处于未决的状态,后者是被递达后选择了忽略,不做其他处理。
总结
在这篇文章中介绍了信号的概念,也介绍了信号从哪里来,到哪里去,被接受处理的过程,希望大家有所收获😁。