【Linux】进程信号全攻略(一)
🌈 个人主页:Zfox_
🔥 系列专栏:Linux
目录
- 一:🔥 信号的概念
- 二:🔥 信号产生的方式
- 🦋 使用键盘
- 🦋 系统调用函数
- 🦋 软件条件
- 🦋 进程异常
- 三:🔥 关于term和core
- 四:🔥 信号处理
- 🦋 信号处理的三种方式
- 五:🔥 信号保存
- 🦋 信号其他相关常见概念
- 🦋 在内核中的表示
- 六:🔥 信号处理
- 🦋 信号集sigset_t
- 🦋 信号集操作函数
- 🦋 sigprocmask(操作block表的函数)
- 🦋 sigpending
- 🦋 代码样例
- 七:🔥 共勉
一:🔥 信号的概念
🦁 信号是 Linux 系统提供的一种向指定进程发送特定事件的方式,进程会对信号进行识别和处理。
信号的产生是异步的,即一个进程不知道自己何时会收到信号,在收到信号之前进程只能一直在处理自己的任务
- 使用
kill -l
指令查看信号(1-30号信号为普通信号,31-64号信号为实时信号)
root@hcss-ecs-a9ee:~/code/linux/112# kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7
58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
63) SIGRTMAX-1 64) SIGRTMAX
🍉 具体的信号采取的动作和详细信息可查看:
man 7 signal
进程内核数据结构中有位图,例如 uint32_t signalbits。一共32位,除去0号位,刚好对应1-32号普通信号
0000 0000 0000 0000 0000 0000 0000 0000
例如给进程发送2号信号,则修改位图为:0000 0000 0000 0000 0000 0000 0000 0100
所以给进程发送信号,本质是修改进程内核数据结构中的位图数据
二:🔥 信号产生的方式
🦋 使用键盘
例如 ctrl+c 产生是2号信号(终止进程)、ctrl+\ 产生的是3号信号(终止进程)
注意:
- Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个 & 可以放到后台运行, 这样 Shell 不必等待进程结束就可以接受新的命令, 启动新的进程。
- Shell 可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
- 前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous) 的
🦋 系统调用函数
(1) kill 函数,用于向指定进程发送信号
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
pid
:指定进程pid,如果 pid 是负数,信号将被发送到与 pid 的绝对值相同的进程组中的所有进程。sig
:指定的信号编号
返回值:成功返回 0,失败返回 -1 并设置 errno
#include <iostream>
#include <cstring>
#include <sys/types.h>
#include <signal.h>
void Usage(std::string proc)
{
std::cout << "Usage: " << proc << " signumber processid" << std::endl;
}
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(1);
}
int signumber = std::stoi(argv[1]);
pid_t id = std::stoi(argv[2]);
int n = ::kill(id, signumber);
if (n < 0)
{
perror("kill");
exit(2);
}
exit(0);
return 0;
}
(2) raise 函数,向自己发送信号
#include <signal.h>
int raise(int sig);
sig
:发送给当前进程的信号编号
返回值:成功返回 0,失败返回 -1并设置 errno
(3) abort 函数,立即终止当前进程(本质发送的是6号信号SIGABRT)
#include <stdlib.h>
void abort(void);
6号信号SIGABRT可以被自定义捕捉处理,但是捕捉后仍然会立即退出进程,比较特殊
9号信号 SIGKILL 无法被捕捉,否则如果所有的信号都被捕捉,那么进程将无法退出
🦋 软件条件
\qquad 🦁 使用管道通信时,当读端关闭,但是写端一直写,操作系统就会给写端进程发送13号信号SIGPIPE,终止进程。SIGPIPE 就是一种由软件条件产生的信号。
现在要介绍的是 alarm 函数
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
alarm
函数用于设置一个定时器,在指定时间后向进程发送14号信号SIGALRM,终止进程seconds
:指定定时器的时间,单位为秒。如果这个值是 0,则会取消之前设置的闹钟- 返回值:alarm 函数返回自上次调用的 alarm 闹钟剩余的秒数。如果之前没有设置定时器,或者定时器已经触发,返回 0。
#include <iostream>
#include <signal.h>
#include <unistd.h>
int main()
{
alarm(1);//1秒后终止进程
int cnt = 0;
while(true)
{
std::cout << cnt << std::endl;
++cnt;
}
return 0;
}
🦋 进程异常
当进程进行非法操作、访问等就会崩溃,操作系统就给崩溃的进程发送相应的信号终止进程
- 例如进程将0作为除数时,进程崩溃,操作系统会给进程发送8号信号 SIGFPE,终止进程
root@hcss-ecs-a9ee:~/code/linux/112/lesson24/3.signal/work# ./process
Floating point exception
- 例如进程中进行野指针访问时,进程崩溃,操作系统会给进程发送11号信号 SIGSEGV,终止进程
root@hcss-ecs-a9ee:~/code/linux/112/lesson24/3.signal/work# ./process
Segmentation fault
收到这些信号,进程必须退出吗?不是,可以捕捉以上的异常信号,但是我们推荐终止进程,为什么呢?
- 除0问题
关于进程中的计算问题,一般都是交由 cpu 完成的,在计算的过程中,难免会出现错误的计算,比如说除0,那么 cpu 又是如何知道的呢?
🐮 这就要提到 cpu 中的寄存器了,cpu 中是有很多的寄存器的,其中有一个寄存器:EFLAGS 寄存器(状态寄存器)。该寄存器中有很多状态标志:这些标志表示了算术和逻辑操作的结果,如溢出(OF)、符号(SF)、零(ZF)、进位(CF)、辅助进位(AF)和奇偶校验(PF)。
除 0 操作就会触发溢出,就会标定出来运算在 cpu 内部出错了。OS 是软硬件资源的管理者!OS 就会处理这种硬件问题,向目标进程发送信号,默认终止进程。
我们要知道 cup 内部是只有一套寄存器的,
寄存器中的数据是属于每一个进程的,是需要对进程上下文进行保存和恢复的。
如果进程因为除0操作而被操作系统标记为异常状态,但没有被终止,那么它可能会被挂起,等待操作系统的进一步处理。
当操作系统决定重新调度这个进程时,会进行上下文切换,即将当前进程的上下文保存到其PCB(进程控制块)中,并加载异常进程的上下文到CPU寄存器中。
上下文切换是一个相对耗时的过程,包括保存和恢复寄存器、堆栈等信息。当切换回这个进程的时候,溢出标志位的错误信息同样会被恢复,会频繁的导致除0异常而触发上下文切换,会大大增加系统的开销。
为什么推荐呢?因为要释放进程上下文的数据,包括溢出标志数据或其他的异常数据。
- 空指针解引用(野指针)问题
这个问题就与页表,MMU及CR2,CR3寄存器有关联了。
🐮 MMU 和 页表 是操作系统实现虚拟内存管理和内存保护的关键机制,它们通过虚拟地址到物理地址的转换来确保程序的正确运行和内存安全。CR2 和 CR3 寄存器在内存管理和错误处理中扮演着重要角色。CR3 寄存器用于切换不同进程的页表,而 CR2 寄存器则用于存储引起页错误的虚拟地址,帮助操作系统定位和处理错误。
CR2 寄存器用于存储引起页错误的线性地址(即虚拟地址)。当 MMU 无法找到一个虚拟地址对应的物理地址时(例如,解引用空指针或野指针),会触发一个页错误(page fault)。此时,CPU会将引起页错误的虚拟地址保存到 CR2 寄存器中,并产生一个异常,此时就会向进程发送11号信号。
三:🔥 关于term和core
term 和 core 是某些信号默认动作的一种表示。它们之间的区别如下:
默认动作:
-
term
:这是“terminate”的缩写,表示信号的默认动作是终止进程。例如,-- SIGTERM(编号15)信号的默认操作就是请求进程正常退出。这给了进程一个机会来清理并正常终止。 -
core
:这个动作表示在终止进程的同时,还会生成一个core dump
文件。这个文件包含了进程在内存中的信息,通常用于调试。例如,SIGQUIT(编号3)和 SIGSEGV(编号11)等信号的默认动作就是终止进程并生成 core dump。
文件生成:
- 当一个进程因某个信号而 term(终止)时,通常不会生成额外的文件。
- 但当进程因某个信号而 core(终止并
核心转储
,这个动作在云服务器下是被默认关掉的)时,会生成一个 core dump 文件。这个文件包含了进程在内存中的状态信息,对于程序员来说是非常有用的调试工具。
使用场景:
- term 动作通常用于请求进程正常退出,比如当你想要优雅地关闭一个服务时。
- core 动作则更常用于在进程崩溃时生成调试信息,帮助程序员找出崩溃的原因。(以gbd为例,先使用gdb打开目标文件,然后将core文件加载进来,就直接可以定位到错误在哪一行)
信号示例:
- SIGTERM(编号15):默认动作为term,即请求进程正常退出。
- SIGQUIT(编号3)和SIGSEGV(编号11):默认动作为core,即终止进程并生成core dump。
当进程退出时,如果core dump为0就表示没有异常退出,如果是1就表示异常退出了。
eg:关于core dump的演示:
如果你是云服务器,那么就需要手动的将core dump功能打开
注意:如果是ubuntu系统的话需要在配置文件/etc/sysctl.conf 后加入如下两行指令
然后在执行一下这条指令 让其生效
sudo sysctl -p
此时就形成了 core_process 文件
四:🔥 信号处理
🦋 信号处理的三种方式
信号处理有三种方式:
-
默认处理(通常为终止、暂停、忽略等)
-
忽略处理
-
自定义处理(信号捕捉)
先介绍信号处理函数:signal
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
-
signum
:指定信号的编号 -
func
:函数指针,该函数将在接收到sig信号时被调用。这个函数必须接受一个 int 参数(信号编号),并且返回类型为 void。 -
返回值:返回值为一个函数指针,指向之前的信号处理器;如果之前没有信号处理器,则返回 SIG_ERR
(1) 默认处理
如果signal函数的 func 参数为 SIG_DFL,则系统将使用默认的信号处理动作。
指令man 7 signal查看信号的默认处理方式,Action列即为信号的默认处理方式
Core、Term即为进程终止,Stop为进程暂停……
(Core终止进程同时还会形成一个debug文件,Term仅终止进程)
(2) 忽略处理
如果signal函数的 func 参数为 SIG_IGN,则系统将忽略该信号。
将pending表中被忽略的信号置为0
(3) 自定义处理(信号捕捉)
信号自定义处理,其实是对信号进行捕捉,然后让信号执行自定义的方法
信号的捕捉,一次捕捉,一直有效
#include <iostream>
#include <signal.h>
#include <unistd.h>
void hander(int sig)
{
std::cout << "get a sig: " << sig << std::endl;
}
int main()
{
//信号捕捉
::signal(2, hander); //当进程收到2号1信号时,执行hander函数
while(true)
{
std::cout << "my pid is: " << getpid() << std::endl;
::sleep(1);
}
return 0;a
}
五:🔥 信号保存
🦋 信号其他相关常见概念
- 实际执行信号的处理动作称为
信号递达
(Delivery) - 信号从产生到递达之间的状态,称为
信号未决
(Pending)。 - 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作
🦋 在内核中的表示
信号在内核中的表示示意图
在这个阶段有以下几种情况:
-
信号未决:信号产生后,在未被处理之前,处于未决状态。这意味着信号已经被发送,但目标进程尚未对其作出响应。操作系统会检查目标进程的Pending表,确定哪些信号处于未决状态。(每个进程都有一个Pending位图,用于记录哪些信号处于未决状态。这个位图由32个比特位组成,分别代表32个不同的信号,如果对应的比特位为1,表示该信号已经产生但尚未处理。)
-
信号阻塞:如果目标进程阻塞了某些信号,那么这些信号会保持在未决状态,直到进程解除对这些信号的阻塞。(与Pending位图类似,Block位图用于记录哪些信号被进程阻塞。当信号被阻塞时,对应的比特位会被设置为1。)
-
handler表:是一个函数指针数组,每个下标都是一个信号的执行方式(有31个普通信号,信号的编号就是数组的下标,可以采用信号编号,索引信号处理方法!)如signal函数在进行信号捕捉的时候,其第二个参数就是,提供给handler的
-
如果进程选择阻塞某个信号,操作系统会在block表中设置对应信号的比特位为1。此时,即使信号已经产生(pending表中对应比特位为1),进程也不会立即处理该信号。
-
被阻塞的信号将保持在pending表中,直到进程解除对该信号的阻塞(即block表中对应比特位被重置为0)。
-
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
信号阻塞和未决的区别
- 信号阻塞(Blocking):是一个开关动作,指的是阻止信号被处理,但不是阻止信号产生。它使得系统暂时保留信号留待以后发送。阻塞只是暂时的,通常用于防止信号打断敏感的操作。
- 信号未决(Pending):是一种状态,指的是从信号的产生到信号被处理前的这一段时间。信号产生后,如果未被处理且没有被阻塞,则处于未决状态,等待被处理。
六:🔥 信号处理
🦋 信号集sigset_t
前面我们了解到,每个信号只有一个 bit 的未决标志,非 0 即 1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型
sigset_t
来存储,sigset_t
称为信号集, 这个类型可以表示每个信号的 “有效” 或 “无效” 状态,在阻塞信号集中 “有效” 和 “无效” 的含义是该信号是否被阻塞, 而在未决信号集中 “有效” 和 “无效” 的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字 (Signal Mask)
,这里的 “屏蔽” 应该理解为阻塞而不是忽略。(该类型只在 Linux 系统上有效,是 Linux 给用户提供的一个用户级的数据类型)
🦋 信号集操作函数
sigset_t
类型对于每种信号用一个 bit 表示 “有效” 或 “无效” 状态,至于这个类型内部如何存储这些 bit 则依赖于系统实现, 从使用者的角度是不必关心的, 使用者只能调用以下函数来操作 sigset_ t 变量, 而不应该对它的内部数据做任何解释, 比如用 printf 直接打印 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 所指向的信号集,使其中所有信号的对应 bit 清零,表示该信号集不包含任何有效信号。 - 函数
sigfillset
初始化 set 所指向的信号集,使其中所有信号的对应 bit 置位,表示 该信号集的有效信号包括系统支持的所有信号。
注意 : 在使用 sigset_ t 类型的变量之前,一定要调 用 sigemptyset 或 sigfillset 做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用 sigaddset 和 sigdelset 在该信号集中添加或删除某种有效信号。
这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种 信号,若包含则返回1,不包含则返回0,出错返回-1。
🦋 sigprocmask(操作block表的函数)
调用函数 sigprocmask
可以读取或更改进程的信号屏蔽字(阻塞信号集)
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
how
:指定对信号屏蔽集的操作方式,有以下几种方式:
SIG_BLOCK
:将 set 所指向的信号集中包含的信号添加到当前的信号屏蔽集中,即信号屏蔽集和set信号集进行逻辑或操作。SIG_UNBLOCK
:将 set 所指向的信号集中包含的信号从当前的信号屏蔽集中删除,即信号屏蔽集和set信号集的补集进行逻辑与操作。SIG_SETMASK
:将 set 的值设定为新的进程信号屏蔽集,即 set 直接对信号屏蔽集进行了赋值操作。set
:指向一个 sigset_t 类型的指针,表示需要修改的信号集合。如果只想读取当前的屏蔽值而不进行修改,可以将其置为 NULL。oldset
:指向一个 sigset_t 类型的指针,用于存储修改前的内核阻塞信号集。如果不关心旧的信号屏蔽集,可以传递 NULL。
🦁 如果 oset 是非空指针, 则读取进程的当前信号屏蔽字通过oset参数传出。如果 set 是非空指针, 则更改进程的信号屏蔽字, 参数 how 指示如何更改。如果 oset 和 set 都是非空指针, 则先将原来的信号屏蔽字备份到 oset里, 然后根据 set 和 how 参数更改信号屏蔽字。假设当前的信号屏蔽字为 mask,下表说明了 how 参数的可选值。
🦋 sigpending
(检查pending信号集,获取当前进程pending位图)
#include <signal.h>
int sigpending(sigset_t *set);
- 参数:set 是一个指向 sigset_t 类型的指针,用于存储当前进程的未决信号集合。
- 返回值:函数调用成功时返回 0,失败时返回 -1,并设置 errno 以指示错误原因。
🦋 代码样例
基于上面的操作方法我们来做一个实验:我们把2号信号block对应的位图置为1,那么2号信号就会被屏蔽掉了,此时我们给当前进程发送2号信号,但2号信号已经被屏蔽了,2号信号永远不会递达,发完之后我们再不断的获取当前进程的pending表,我们就能肉眼看见2号信号被pending的效果:验证
1.第一步实现对2号信号的屏蔽
int main()
{
sigset_t block_set,old_set;
sigemptyset(&block_set);
sigemptyset(&old_set);
sigaddset(&block_set,SIGINT);//向block_set信号集中添加SIGINT信号(编号为2)。
//1.屏蔽2号信号
// 1.1 设置进入进程的Block表中
sigprocmask(SIG_BLOCK, &block_set, &old_set); // 真正的修改当前进行的内核block表,完成了对2号信号的屏蔽!
while(true) sleep(1);
}
当我们运行程序的时候,对进程发送2号信号是没有作用的,因为2号信号此时已经被屏蔽了。
- 下一步我们打印pending表,之后我们给该进程发送2号信号
void PrintPending(sigset_t &pending)
{
std::cout << "curr process[" << getpid() << "]pending: ";
for (int signo = 31; signo >= 1; signo--)
{
if (sigismember(&pending, signo))//如果存在就返回1
{
std::cout << 1;
}
else
{
std::cout << 0;
}
}
std::cout << "\n";
}
int main()
{
sigset_t block_set,old_set;
sigemptyset(&block_set);
sigemptyset(&old_set);
sigaddset(&block_set,SIGINT);//向block_set信号集中添加SIGINT信号(编号为2)。
//1.屏蔽2号信号
// 1.1 设置进入进程的Block表中
sigprocmask(SIG_BLOCK, &block_set, &old_set); // 真正的修改当前进行的内核block表,完成了对2号信号的屏蔽!
while(true)
{
//2.获取当前进程的pending信号集
sigset_t pending;
sigpending(&pending);
//3.打印pending信号集
PrintfPending(pending);
sleep(1);
}
}
对该进程发送2号信号,pending表对应位置被置为1
- 解除对2号信号的屏蔽,并且捕捉2号信号,我们来看一下现象:
#include <iostream>
#include <unistd.h>
#include <cstdio>
#include <sys/types.h>
#include <sys/wait.h>
void PrintPending(sigset_t &pending)
{
std::cout << "curr process[" << getpid() << "]pending: ";
for (int signo = 31; signo >= 1; signo--)
{
if (sigismember(&pending, signo))//如果存在就返回1
{
std::cout << 1;
}
else
{
std::cout << 0;
}
}
std::cout << "\n";
}
void handler(int signo)
{
std::cout << signo << " 号信号被递达!!!" << std::endl;
std::cout << "-------------------------------" << std::endl;
sigset_t pending;
sigpending(&pending);
PrintPending(pending);
std::cout << "-------------------------------" << std::endl;
}
int main()
{
// 0. 捕捉2号信号
signal(2, handler); // 自定义捕捉
sigset_t block_set,old_set;
sigemptyset(&block_set);
sigemptyset(&old_set);
sigaddset(&block_set,SIGINT);//向block_set信号集中添加SIGINT信号(编号为2)。
//1.屏蔽2号信号
// 1.1 设置进入进程的Block表中
sigprocmask(SIG_BLOCK, &block_set, &old_set); // 真正的修改当前进行的内核block表,完成了对2号信号的屏蔽!
int cnt =10;
while(true)
{
//2.获取当前进程的pending信号集
sigset_t pending;
sigpending(&pending);
//3.打印pending信号集
PrintPending(pending);
//4.解除对2号信号的屏蔽
cnt--;
if(cnt==0)
{
std::cout << "解除对2号信号的屏蔽!!!" << std::endl;
//使用直接重置的方法
//我们之前是保存了old_set,老的屏蔽字,直接使用就行了
sigprocmask(SIG_SETMASK, &old_set, &block_set);
}
sleep(1);
}
}
我们不难发现,解除屏蔽后,信号会立即递达,pending对应位置由1置为0(这个过程,是在执行handler方法之前完成的,也就是在信号递达之前,位图就由1转为0了)。
七:🔥 共勉
以上就是我对 【Linux】进程信号(一)
的理解,觉得这篇博客对你有帮助的,可以点赞收藏关注支持一波~😉