一、认识信号
在生活中有很多熟悉的信号,比如红绿灯、下课铃声等。当听到或者想到这些信号的时候,我们就会作出或者知道我们要做什么事了。
Linux中的信号也是这样的作用。
使用命令kill -l
可以查看系统定义的信号列表。其中前31个为普通信号,后31个为实时信号。普通信号和实时的区别在于前者不支持排队,可能会造成信号丢失,而后者不会。本文章只介绍普通信号。
man 7 signal
可以查看信号的详细信息。
1.1 使用ctr c向前台进程发送信号
常用的使用键盘 ctrl + c
向进程发送终止信号
注意:
- Ctrl+C 产生的信号只能发给前台进程。一个命令后面加个&(
./signal &
)可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。 - Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
- 前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。
信号是进程之间事件异步通知的一种方式,属于软中断。
这就是因为自己写的进程被放在后台,此时bash就是前台进程,能够运行指定,同时向屏幕打印。此时输入fg 进程路径
就可以让进程从后台回到前台。
1.2 验证ctrl c是几号信号
首先需要了解一个系统调用
- 作用 :这个接口能够捕捉并重定向一个信号的默认处理动作,使信号不执行原来的动作,而是执行我们自定义的动作
- 参数:
signum:对应信号的编号(普通信号编号1-31,实时信号编号34-64)
handler:回调函数(函数指针),传一个函数的地址。这个函数就是我们自定义的处理动作。
可以看到结果是2号信号。
也可以使用Ctrl \
来退出,这个指令是发送的是3号信号SIGQUIT
1.3 常用信号
信号编号 | 信号名 | 信号含义 |
---|---|---|
1 | SIGHUP | 当用户退出终端时,由该终端开启的所有进程都退接收到这个信号,默认动作为终止进程。 |
2 | SIGINT | 程序终止(interrupt )信号, 在用户键入INTR字符(通常是Ctrl+C )时发出,用于通知前台进程组终止进程。 |
3 | SIGQUIT | 和SIGINT类似, 但由QUIT字符(通常是Ctrl+\ )来控制. 进程在因收到SIGQUIT退出时会产生core 文件, 在这个意义上类似于一个程序错误信号。 |
9 | SIGKILL | 用来立即结束程序的运行. 本信号不能被阻塞、处理和忽略。 |
15 | SIGTERM | 程序结束(terminate )信号, 与SIGKILL不同的是该信号可以被阻塞和处理。通常用来要求程序自己正常退出。 |
20 | SIGSTOP | 停止(stopped )进程的执行. 注意它和terminate以及interrupt的区别:该进程还未结束, 只是暂停执行. 本信号不能被阻塞, 处理或忽略. |
二、信号的产生
信号常见的产生方式有,键盘输入、程序出现异常问题、系统调用产生、软件产生。
信号产生的方式种类虽然非常多,但是无论产生信号的方式千差万别,但是最终,一定都是通过OS向目标进程发送的信号!
2.1 通过键盘
前面已经了解过了:
- ctrl+c 2号信号 SIGINT
- ctrl+z 20号信号 SIGTSTP
- ctrl+\ 3号信号 SIGQUIT
对应的SIGINT的默认处理动作是终止进程,SIGTSTP停止终端交互进程的运行,SIGQUIT的默认处理动作是终止进程并且Core Dump(下面会讲)。
2.2 硬件异常产生信号
进程产生异常,本质是因为硬件运算发生错误。下面列举两种程序常见的异常。
2.2.1 指针越界访问
这个问题对使用C/C++的人来说非常常见,也是做OJ是的段错误。
如果不捕捉就是报段错误。
显然虚拟地址访问数据,虚拟地址需要先转换到物理地址,如果是野指针,那么在页表之个找不到对应的映射关系,这个地址转化就会发生错误。硬件运行发生错误被OS检测到传给OS,OS再发送相关信号给进程。
2.2.2 浮点异常
常见的算数异常比如除0。 报错浮点异常。
对应为8号信号。
2.2.3 core文件
进程的崩溃的本质,是进程收到对应信号,然后执行进程收到该信号的默认动作(杀死进程)。
为什么会收到信号呢?
软件上面的错误,通常会体现在硬件或其他软件上,而OS是硬件的管理者,收到错误信息后,就会给产生错误的进程发送信号。
当进程崩溃的时候,最想知道的当然是崩溃的原因 ,可通过进程退出信号拿到。崩溃的原因即崩溃时,收到的是哪一个信号。
更想知道在哪一行崩溃了。这是就需要OS设置退出信号中的 core dump 标志位,将内存中进程的退出信息保存到磁盘方便后期调试,形成core文件。这样调试的时候就可以直接定位到错误的行。
回顾一下进程的退出:
在Linux中,当一个进程退出的时候,它的退出码和退出信号都会被设置(正常情况)
当一个进程异常的时候,进程的退出信号会被设置,表明当前进程退出的原因
父进程等待子进程可以拿到退出信息, 其中有一个输出型参数status,低8位中的低7位表示退出信号,第8位表示Core Dump,如果为0则表示不需要生成core文件,为1表示需要。
使用命令umilit -a
查看生成core文件是否打开。为 0 表示关闭。
这个功能默认是关闭的
- 一旦进程发生错误,就会产生core文件,甚至系统启动的时候也会发生错误,如果不加以控制,生成的临时文件可能会将磁盘堆满。
- 并不是所有的信号都需要Core Dump, 产生core文件。比如我们的kill -9号信号,系统直接终止进程,是不需要调试的。
- core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。 首先用ulimit命令改变Shell进程的Resource Limit,允许core文件最大为1024K
若关闭,使用ulimit -c size
设置大小为size字节。表示打开。一次登录,只能修改一次,若要再修改,需重新登录。
这下运行程序,发生错误后,打开调试器进行调试输入命令core-file core.xxx
命令即可得到错误信息。(编译程序时需加上 -g 选项,core.xxx是生成的core文件)
将信号处理方式改为默认,即不再调用自定义的处理函数。编译运行程序,可以看到有core dumped标志, 并生成一个core文件。
打开gdb调试,输入命令core-file core.3743
即可看到调试信息。
验证一下,core dump标志位是否被设置。
core dump 标志位被设置为1,会产生core文件。
2.3 系统调用
2.3.1 kill
常用命令kill 向指定进程发生信号,比如kill -9 xxx
向pid为xxx的进程发送指定9号信号。
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
参数:
pid:代表目标进程的pid
sig:代表要发送几号信号
返回值:
调用成功返回0,失败返回-1
收到9号信号退出的,没有设置core dump标志位。
2.3.2 raise
这个函数与kill不同的是,raise是自己给自己发生信号。
#include <signal.h>
int raise(int sig);
sig:给自己发送信号的名称
返回值:
调用成功返回0,失败-1
2.3.3 abort
abort使当前进程给自己发生6号信号SIGABRT,使当前进程接收到信号而异常终止。
#include <stdlib.h>
void abort(void);
就像exit函数一样,abort函数总是会成功的,所以没有返回值。
使用函数捕捉信号,可以看到发送的是6号信号。
2.4 软件产生
软件层面通常是OS来触发信号的发送,系统层面设置定时器,或者某种操作而导致条件不就绪等场景下,就会发送信号。
例如:
进程间通信的匿名管道通信中:当读端不光不读,而且还关闭了读fd(文件描述符),写端一直在写,最终写进程会受到sigpipe (13),就是一种典型的软件条件触发的信号发送。
2.4.1 alarm
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动作是终止当前进程。
再来看一下发送的几号信号。
这里也可以用来展现CPU运行和IO的速度差别,取消输出语句,在自定义的重载函数里面输出循环次数cnt 的值。
同样的时间,差距如此巨大。可见IO是非常慢的。
三、信号的保存
信号是被立即处理的吗?不是的
也需信号送达时你在做着更重要的事。等处理完事再处理信号。
3.1 信号传递相关概念
先来认识三个概念:
-
实际执行信号的处理动作称为信号递达(Delivery) — (处理方式有三种:默认(SIG_DFL),忽略(SIG_IGN), 自定义捕捉)
-
信号从产生到递达之间的状态,称为信号未决(Pending)。信号被暂存在pending位图中
-
进程可以选择阻塞 (Block )某个信号。
本质是OS,允许进程暂时屏蔽指定的信号
1.该信号依旧是未决的
2.该信号不会被递达,直到解除阻塞!方可递达 -
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
-
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
3.2 信号在内核中的表示
内核针对上面三种状态,采用三个数组来表示。
是否收到进程的表示 — (未决)pending 位图
进程中,采用位图来标识该进程是否收到信号!
pending位图 :保存已经收到但未被抵达的信号。
所谓的比特位的位置(第几个),代表的就是哪一个信号比特位的内容(0/1),代表的就是是否收到了信号。
所以,本质上OS发送信号的本质:修改目标进程的pending位图。
进程是否被阻塞 — block 位图
block表:本质上,也是位图结构
uint32_t block ;
比特位的位置:代表信号的编号
比特位的内容:代表信号是否(1/0)被阻塞 – 阻塞位图,也叫作信号屏蔽字。
如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理? 允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号。
信号的递达方式 — handler 函数指针数组
每个信号的编号就是该数组的下标,里面存放函数地址,表示对该信号的处理方法。
3.3 处理信号保存的系统调用
3.3.1 sigset_t
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
sigset_t,是一个位图结构,不同的OS的实现是不同的。
为什么要有一个特定的数据类型呢? 因为OS不相信任何人,不能让用户能直接修改变量,需要使用特定的函数来修改,但其存储方式和普通变量相同。
3.3.2 信号集操作函数
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释。不可使用位运算操作符对sigset_t操作。
sigemptyset(sigset_t *set)
: 初始化set所指向的信号集,使其中所有信号的对应bit清零(0),表示该信号集不包含 任何有效信号。
sigfillset(sigset_t *set)
: 初始化set所指向的信号集,使其中所有信号的对应bit置位(1),表示该信号集的有效信号包括系统支持的所有信号。
sigaddset (sigset_t *set, int signo)和sigdelset(sigset_t *set, int signo)
: 添加和删除指定信号。
sigismember(const sigset_t *set, int signo)
: 判断signo信号是否在set表示的信号集合里面。
这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。
在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信。
3.3.3 sigpending
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
参数是输出型参数,不修改pending 位图,只是单纯的获取pending 位图。
3.3.4 sigprocmask
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。set是输入型参数, oldset是输出型参数。
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。
假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
how值 | 作用 |
---|---|
SIG_BLOCK | set 包含了我们希望添加到信号屏蔽字的信号,相当于 mask |= set |
SIG_UNBLOCK | set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于 mask = mask&~set |
SIG_SETMASK | 设置当前信号屏蔽字为set指向的值吗, 相当于 mask = set |
返回值:若成功则为0,若出错则为-1
3.3.5 代码演示
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <sys/types.h>
void show_pending(sigset_t *set)
{
printf("current process pending: ");
int i = 1;
// 遍历每个信号如果在pending位图里面打印1,否则打印0
for(; i < 32; i ++ ){
if(sigismember(set, i)){
printf("1");
} else {
printf("0");
}
}
printf("\n");
}
void handler(int signo)
{
printf("我是%d号信号, 已正常递达\n", signo);
}
int main()
{
sigset_t iset, oset; // 和普通变量一样,存在栈上
// iset |= 1; 这样做是非法的,不能直接修改
sigemptyset(&iset);
sigemptyset(&oset);
signal(2, handler); // 自定义处理
sigaddset(&iset, 2); // 添加一个2号信号 ctrl + c
// 1. 设置当前的屏蔽字为iset, 将2号信号屏弊,无法被递达
// 2. 获取老的屏蔽字到oset中
sigprocmask(SIG_SETMASK, &iset, &oset);
int cnt = 0;
sigset_t pending;
while(1){
sigemptyset(&pending);
// 获取未决 pending 位图
sigpending(&pending);
// 打印pending位图
show_pending(&pending);
sleep(1);
cnt ++ ;
// 5秒恢复正常
if(cnt == 5){
sigprocmask(SIG_SETMASK, &oset, NULL); //设置为老的屏蔽字, 恢复默认设置
printf("2号信号恢复正常\n");
}
}
return 0;
}
四、信号的处理
之前我们说过,进程收到信号并不是立即处理而是在合适的时候。那什么时候才合适呢?
当进程从内核态非返回到用户态的时候,进行检测并处理。检测pending位图,处理的三种方式(默认、忽略、自定义)。
4.1 内核态和用户态
内核态:执行OS的代码和数据时,计算机所处的状态就叫做内核态。0S的代码的执行全部都是在内核态。
用户态:就是用户代码和数据被访问或者执行的时候,所处的状态。我们自己写的代码全部都是在用户态执行的!
主要区别:在于权限不同。
用户调用系统函数的时候后,除了进入函数,身份也会发生变化,用户身份变成内核身份
先来看一张图
用户的数据和代码一定要被加载到内存
OS的数据和代码只有一份,也是一定要被加载内存中的! 因为每个进程都需要系统代码,所以内核页表被所有进程共享! 只有一份。
进程之间无论如何切换,我们能够保证我们一定能够找到同一个OS,因为我们每个进程都有3~4G的地址空间,使用同一张内核页表所谓的系统调用;就是进程的身份转化成为内核,然后根据内核页表找到系统函数,执行就行了在大部分情况下,实际上我们OS都是可以在进程的上下文中直接运行的
那执行中怎么区分是系统代码还是用户代码,CPU内有寄存器(CP3)保存了当前进程的状态,可以区分当前用户状态。
用户态使用的是,用户级页表,只能访问用户数据和代码。
内核态使用的是,内核级页表,只能访问内核级的数据和代码。
4.2 信号处理过程
4.3 相关系统调用
在最开始的时候其实已经介绍过一个signal函数,可以将指定信号替换成自定义信号。
现在介绍一个能修改更多信息的函数。
4.3.1 sigaction
sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。
signum
是指定信号的编号。act
是传入参数,oldact
是传出参数。
若act
指针非空,则根据act修改该信号的处理动作。
若oldact
指针非空,则通过oact传出该信号原来的处理动作。
结构体变量 void (*sa_handler)(int);
将sa_handler赋值为常数SIG_IGN
传给sigaction表示忽略信号,赋值为常数SIG_DFL
表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,但不是被main函数调用,而是被系统所调用。 这个参数与signal的参数类似。
sigset_t sa_mask;
这是信号集类型的变量。作用是什么呢?
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <string.h>
void handler(int signo)
{
int cnt = 5;
while(cnt -- ){
printf("%d信号被执行\n",signo);
sleep(1);
}
}
int main()
{
struct sigaction act;
memset(&act, 0, sizeof(act));
act.sa_handler = handler;
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, 3); // 执行其他信号时屏蔽3号信号
sigaction(2, &act, NULL); // 修改2号信号的部分信息
while(1){
printf("Hello \n");
sleep(1);
}
return 0;
}
五、信号相关知识了解
5.1 可重入函数
在自己的函数体重遇到信号,在处理信号的函数内又执行一次函数,这就是重入。
一个函数一旦重入,有可能出现问题—该函数不可被重入,称为不可被重入函数,例如上面的insert
一个函数一旦重入,不会出现问题—该函数可重入,称为可被重入函数。
我们所学到的大部分函数,STL,boost库中的函数,大部分都是不可重入的!
5.2 volatile
先来看下面这部分代码。
#include <stdio.h>
#include <signal.h>
int flag = 0;
void handler(int signo)
{
flag = 1;
printf("%d号信号送达\n", signo);
}
int main()
{
signal(2, handler);
while(!flag); // 条件成立死循环
printf("进程正常退出\n"); // 按理如果发生2号信号,循环终止,程序正常结束
return 0;
}
这说明编译器进行了优化,将主函数中的 flag 的值固定了。
原本全局变量flag=0,放在内存中,执行循环判断是CPU去内存取该变量的值来判断,但是编译器优化后,使得CPU将该变量的值存放在一份寄存器中,这样就不用频繁访问主存,直接获取寄存器的值,当收到信号将flag的值改变后,寄存器的值没有变化,改变的是内存上的值。所以导致无法终止循环。这是就要用到volatile
关键字了
volatile int flag = 0;
volatile 作用 :保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量
的任何操作,都必须在真实的内存中进行操作。
5.3 SIGCHLD
之前说过,可以采用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻 塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不 能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。
子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
void GetChild(int signo)
{
waitpid(-1, NULL, 0); // 回收子进程
printf("get a signal : %d, pid: %d\n", signo, getpid());
}
int main()
{
signal(SIGCHLD, GetChild);
pid_t id = fork();
if(id == 0){
//child
int cnt = 5;
while(cnt){
printf("我是子进程: %d\n", getpid());
sleep(1);
cnt--;
}
exit(0);
}
while(1);
return 0;
}
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。
//显示设置忽略17号信号,当进程退出后,自动释放僵尸进程
//只在Linux下有效
signal(SIGCHLD, SIG_IGN);