从生活角度出发,人们在日常的工作生活中,为了提高工作效率,会经常接收信号也会产生信号。在操作系统体系原理中,也有类似的信号概念,其目的也是为了让计算机能够以信号为媒介,优化工作方式,提高工作效率。因此信号相关知识的学习,是深入了解操作系统,深入认识计算机工作原理过程中尤为重要的一环。
一、信号的引入与概念
在Linux中,信号是进程中事件的一种异步通知,是软中断。在日常生活,假设我们接受到了一个信号(如QQ的消息提示音),而此时我们正在做其他的工作(如写作业),于是我们并不会立刻去处理这一信号,但我们记得这一信号产生过,因此在做完当前的工作后,才会处理该信号(写完作业才打开手机看消息)。
同样的道理适用于操作系统,当一个进程正在运行时,突然接收到一个信号,操作系统并不会立刻暂停当前进程去处理信号,而是会等当前进程运行完一个小“事务”,或运行到某个标记位,再停下来处理之前产生过的信号。
Linux中的信号(长什么样)
我们可以用 kill -l 指令查看Linux中的各个信号:
我们所看到的,红色框框里的是每个信号它们自己的编号,黄色框框是信号名。每个信号都是通过 " #define 信号名 数字 " 的 宏定义的方式定义的,可以在 signal.h 头文件中找到。
还可以发现,Linux中一共有62个信号,是没有32/33号信号的。
1~31信号:标准信号(非实时信号),也就是我们上面介绍的信号类型,与进程事件是异步的。
34~64 :实时信号,是Linux中的扩展信号类型,被接受后会立即执行。
重点是标准信号!
信号的作用与几个常见信号
进程间通信:进程之间可以通过发送和接收信号的方式实现通信,例如当子进程退出时向父进程发送SIGCHLD信号。
处理任务/异常:当程序出现异常,OS会向进程发送信号处理异常;或用户通过修改信号处理方法,让系统执行自定义任务。
系统调试:用于程序的调试,例如在程序运行时,向该进程发送 SIGUSR2 信号,可以打印程序的状态信息等。
信号处理的常见动作:
1.忽略此信号。
2.执行默认操作。
3.进入用户态,处理用户提供的信号处理函数,这种方式称为信号捕捉。
常见的几个信号:
2)SIGINT:中断信号,可以由 Ctrl+C 发送
3)SIGQUIT:退出信号,可由Ctrl+ \ 发送
9)SIGKILL:强制退出信号,无法被捕捉,无法阻塞
13)SIGPIPE:管道破裂信号,当进程向已经关闭写端的管道或socket写数据时产生该信号
14)SIGALRM:闹钟信号,可以用 alarm(int seconds) 函数设定倒计时间
(全部信号的具体内容,可以通过signal手册 [ man 7 signal ] 查看)
二、信号的产生
终端按键产生
当前台进程正在运行时,我们在键盘上按下Ctrl+C(对应SIGINT)或Ctrl+\(对应SIGQUIT),就会令当前进程退出。其中,按下键盘可以产生一个硬件中断,所产生的信号被OS获取并解析,传递给前台目标进程,进程接收到并处理该信号。
系统调用函数
kill指令可以向指定pid的进程发送指定的信号
kill -9 "PID" --向指定"PID"的进程发送9号信号,即SIGKILL信号,等价于=>
kill -SIGKILL "PID"
kill命令是调用kill函数实现的,kill函数可以给指定pid的进程发信号
raise函数可以给调用自己的进程发信号,自己给自己发
#include <signal.h>
int kill(pid_t pid, int signo);
int raise(int signo);
这两个函数都是成功返回0,错误返回-1。
abort函数,使调用它的进程接收到信号就异常终止
#include <stdlib.h>
void abort(void);和exit一样,abort总会成功,因此没有返回值
软件条件产生
概念:当软件层面达到某种条件所产生的信号就是软件条件信号。
实现方法:OS先识别进程的某种软件的触发条件满足或不满足,当满足后由OS构建信号,并发送给对应的进程。
如前文所提到的SIGPIPE -- 管道破裂信号。当一个进程试图向一个已经关闭写端的进程写数据,或者读端先关闭,当再次写入时OS会自动终止写端进程,并产生SIGPIPE信号。
除此之外还有其他软件条件信号:
SIGSEGV(段错误):当程序访问无效的内存地址或者试图执行无效的内存操作时,会导致段错误信号的触发。
SIGFPE(浮点异常):当程序执行了除以零、溢出或无效的浮点运算等引起浮点异常的操作时,会触发该信号。
SIGILL(非法指令):当程序试图执行未定义或非法的指令时,例如硬件不支持的指令或者内存中的数据被解释为指令,会导致非法指令信号的触发。
SIGBUS(总线错误):当程序试图对无效的内存地址进行访问,或者在总线传输期间发生了错误时,会触发总线错误信号。
SIGALRM(定时器到期):当设置的定时器超时时,会触发定时器到期信号,通常用于实现定时功能。
SIGALRM 与 alarm 函数
想使用定时器信号,需要先在程序中定用alarm函数定义倒计时时间:
#include <stdio.h>
#include <signal.h>
void handler(int signum){
printf("捕捉到闹钟信号:%d\n",signum);
alarm(1); //在信号处理函数中要重置倒计时
}
int main(){
signal(SIGALRM,handler);//signal函数用于捕捉信号,指定信号和其对应的处理函数
alarm(1);
while(1); //使进程不能结束退出
return 0;
}
#需要在信号处理函数中需要再次设定alarm时间,相当于alarm设定一次只会执行一次倒计时,并不会自动重装。可以联想到单片机编程中的非自动重装定时器中断,在进入中断函数后需要再设定计时时间。
硬件条件产生
硬件条件产生的信号,一般是硬件工作出现异常所产生的。
如SIGFPE(浮点错误信号),当CPU的运算单元出现异常(如程序进行了除0操作时),OS内核会解析该异常并向产生该异常的进程发送SIGFPE信号。
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
void handler(int signum){
printf("捕捉到浮点异常信号:%d\n",signum);
exit(0);
}
int main(){
signal(SIGFPE,handler);
int err = 1/0;
return 0;
}
除此之外还有段错误信号,当进程访问了非法的内存空间地址(如访问0地址),MMU(内存管理单元)会产生异常,OS将异常解析为 11) SIGSEGV(段错误)信号并发送给指定进程:
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
void handler(int signum){
printf("捕捉到段错误信号:%d\n",signum);
exit(1);
}
int main(){
signal(SIGSEGV,handler);
int *p = NULL;
*p = 1; //非法访问了0x0地址
return 0;
}
三、信号的捕捉
前文提到,信号是可以被捕捉的,通过捕捉信号,可以用 用户自定义的信号处理函数 替换掉 系统默认的信号处理动作。
1. signal
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
void (*sighandler_t)(int):
是一个函数指针类型,指向 参数类型为 int,返回值类型为 void 的函数。
参数:
signum:要注册的信号类型,可以是信号名,也可以是对应的编号。
handler:函数指针,指向要注册的信号处理函数。
返回值:
函数指针,指向注册的信号处理函数。如果注册失败,则返回SIG_ERR。
前文展示了很多使用 signal 方法的案例,此处不再作演示。
2. sigaction
sigaction相比于signal拥有更强大的功能,可以更细致地设置信号的处理方式。
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
struct sigaction:
用于设置和获取信号处理函数的相关信息的结构体。
参数:
signum:要注册的信号类型。
act:输入型参数,填入指向新的信号处理函数的sigaction结构体的指针,表示新的信号处理函数和其选项。
oldact:输出型参数,获取指向之前信号处理函数的sigaction结构体的指针。
sigaction函数和sigaction结构体,两者同名却是完全不同的两者。
struct sigaction 结构体定义在 <signal.h> 文件中,其定义如下:
struct sigaction {
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
};
sa_handler:
函数指针,指定信号处理函数。若设置为SIG_IGN,则表示忽略该信号。若设置为SIG_DFL,则表示使用默认处理。
sa_sigaction:
指定信号的扩展处理函数,与 sa_handler 二选一。若设置了 sa_sigaction ,则会在调用 sa_handler 之前调用 sa_sigaction 。
sa_mask:
指定在执行信号处理函数期间要阻塞的信号集。即在信号处理函数运行期间,会将 sa_mask 中的信号添加到进程的信号屏蔽字中,防止这些信号再次中断当前的信号处理函数。
sa_flags:
附加选项标志位,可以(按位或)设置以下选项:
SA_RESTART:指定在信号处理函数返回后,被中断的系统调用会重启。
SA_NOCLDSTOP:当子进程退出时,不发送 SIGCHLD 信号。
SA_NODEFER:不阻塞当前处理信号。
SA_SIGINFO:指定使用 sa_sigaction 方法,而不是 sa_handler 。
sa_restorer:
指向恢复函数的指针,用于恢复机器的某些状态。已弃用。
案例演示:
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
void handler(int signum){
printf("捕捉到信号:%d\n", signum);
exit(1);
}
int main(){
struct sigaction newact = {
newact.sa_handler = handler
};
struct sigaction oldact;
sigaction(2, &newact, &oldact);
while(1); //死循环维持程序不退出
return 0;
}
四、阻塞信号与信号集
相关概念
信号递达(Delivery):实际开始执行 信号处理(函数) 的动作,称为递达。
信号未决(Pending):信号 产生之后递达之前 所处的状态,称为未决。
阻塞(Block):被阻塞的信号产生后,会一直处于未决状态,直到解除阻塞,才会执行递达动作。(不同于忽略的是,被阻塞信号不会递达,被忽略信号是递达后的一种处理方式。)
信号系统在内核中的示意图
如图中的例子:
SIGHUP 信号没有被阻塞,也没有产生,该信号对应的信号处理方式是 SIG_DFL 默认处理方式;
SIGINT 信号没有被阻塞,且已经产生但还未递达,等到它递达后,对应执行 SIG_IGN 忽略处理;
SIGQUIT 信号已经产生还未递达,但由于其被阻塞,所以会一直处于未决状态,直到阻塞被解除,才会在递达后执行与其对应的 handler 用户自定义信号处理函数。
当信号被阻塞时,其对应的 block 标记位将变成 1 ,若信号此时正处于未决状态,则其对应的 pending 标记位将变成 1 。
当一种信号的 block 被设为 1 时,该信号在产生后,会一直处于未决状态无法被递达,其 pending 位会一直为 1 。直到该信号的阻塞被解除,该信号才会递达,递达后其 pending 将被置为 0 。
若一种信号在被阻塞时,在递达之前,产生了很多次,操作系统如何处理?答:常规信号在递达前产生多次只记一次,实时信号递达前产生多次将被依次放入队列中。
信号集及其操作
sigset_t:
前文中的每个信号的 block 和 pending 都是由一个 bit 位标记的,且不记录产生了多少次,因此可以使用一种数据类型来存储。sigset_t 就是这种数据类型,用于表示信号集。信号集可以表示每种信号的“有效”或“无效”的状态,可以一分为二的看做 “ 阻塞信号集 + 未决信号集 ” ,其中阻塞信号集又可以称为当前进程的 “ 信号屏蔽(阻塞)字(signal mask)”。
信号集的操作:
初始化:sigemptyset(sigset_t *set) 函数将信号集初始化为空集,或 sigfillset(sigset_t *set) 函数将信号集初始化为包含所有信号。
添加信号:sigaddset(sigset_t *set, int signo)
删除信号:sigdelset(sigset_t *set, int signo)
查看信号是否在信号集中:sigismember(const sigset_t *set, int signo)
操作整个信号集:sigprocmask(int how, const sigset_t *set, sigset_t *oset) 函数可以读取或修改当前进程的阻塞信号集。sigpending(sigset_t *set) 函数读取当前进程的未决信号集,通过 set 参数传出。
值得注意的是,在修改进程的信号屏蔽字时,需要保证进程的原子性(要么执行完,要么不执行,不能执行到一半不执行)。使用 sigprocmask() 可以保证修改信号屏蔽字的操作是原子的。
五、信号处理详解 -- 内核处理信号的步骤
前文我们讲到,信号的处理主要分为几个步骤:
而在信号的整个处理过程中,依旧是由操作系统进行调度和管理的。
信号捕捉处理的过程
![](https://img-blog.csdnimg.cn/direct/f355ce74d4d34eec905398c900a57648.png)
内核是如何实现信号捕捉的?
如果信号处理动作是用户自定义函数,则在信号递达时就调用这个函数,这称为信号捕捉。由于OS在执行信号处理相关操作的程序需要在内核级中跑,而用户自定义函数在用户级空间,因此整个信号捕捉的过程就会比较复杂:
1.当一个信号产生后,进程并不会立刻停下来执行信号处理函数,而是会先将信号的未决标记位置 1 。
2.当进程运行因出现中断、异常或系统调用而进入内核后,内核会处理相关的异常,处理完异常后,内核就会着手处理可以递达的信号(有未决且未被阻塞的信号)。
3.内核着手处理信号时,先进入内核中的 do_signal() 函数,如果没有用户注册的信号处理函数,则直接在内核中运行默认的信号处理方法。如果有注册信号捕捉方法,OS则会由内核级进入用户级,进而处理用户自定义的信号处理函数。
4.此时由内核级回到用户级,并不是回到主程序main函数的上下文,而是执行 sighandler 函数,sighandler 函数和 main 函数使用不同的堆栈空间,它们之间不存在调用或被调用的关系,是两个独立的控制流程。确切地说,它们都由OS调度。
5.运行完信号处理的程序后,如果当前在用户级,则会先调用特殊的系统接口 sigreturn() 进入内核,再执行 sys_sigreturn() 方法回到进程的主程序中。若上一步没有信号捕捉,也就是说当前在内核级,则会直接执行 sys_sigreturn() 。
六、相关 -- 核心转储 & 可重入函数
核心转储
将进程的用户空间内存数据获取出并存储到磁盘中的动作,称为核心转储。
核心转储的具体操作:Core Dump :当一个进程要异常终止时,可以选择让进程进行核心转储。获取该进程用户空间内存数据,写入到名为 core 的文件中,并保存到磁盘中。这个操作就成为 Core Dump 。其产生的core文件可以用于事后调试(post-mortem debug)。一个进程可以产生多大的 core 文件,可以通过 ulimit 指令修改进程PCB中的Resource Limit信息而设置,其最大可以设置为 1024 KB :$ ulimit -c 1024
信号的核心转储功能默认是关闭的,因为 core 文件中含有用户信息,存在信息泄密的问题,而且在企业中经常会发生进程异常终止的情况,产生过多的 core 文件会占用磁盘空间,因此核心转储一般只在开发环境中需要调试时才打开。
并不是所有信号都可以执行核心转储的功能,具体哪些信号可以有核心转储,可以在 $ man 7 signal 中查看:
![](https://img-blog.csdnimg.cn/direct/43b317ef847a4128b804665dfb201c55.png)
可重入函数
可重入函数(reentrant function)是一种可以被多个进程或线程同时访问而不会产生冲突的函数。与其相反的是不可重入函数,不可重入函数可能会导致并发问题,即当多个线程或进程调用该函数时,会出现竞争条件,从而产生意外结果。
具体来说,可重入函数满足以下两个条件:
1.不使用全局变量或静态变量,或者使用时通过加锁等方式对其进行同步保护;
2.所有数据都存储在传递给函数的参数或者栈上,而不是存储在全局数据区或堆区。
由于可重入函数不依赖于全局状态,因此多个进程或线程可以同时调用该函数,而不需要担心数据竞争或其他并发问题。这使得可重入函数非常适合用于多线程应用程序或操作系统内核中。
信号的可重入性:是指当信号处理函数被中断时,可以安全地再次调用该函数,而不会引起死锁或其他问题。