目录
一. 概念
信号是进程之间事件异步通知的一种方式,是一种更高层的软件形式的异常,允许进程和内核中断其他的进程,属于软中断。
我们可以通过 kill -l 命令来查看系统定义的信号列表
每种信号类型都对应于某种系统事件。低层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了这些异常。
二. 信号产生
1. 进程组
操作系统提供了大量向进程发送信号的机制。所有机制都是基于进程组(process group)这个概念的。
每个进程都属于一个进程组,进程组是由一个正整数进程组ID来标识的。getpgrp函数返回当前进程的进程组ID。
#include<unistd.h>
pid_t getgrep(void;
默认地,一个子进程和它的父进程同属于一个进程组。
#include<iostream>
#include<unistd.h>
#include<signal.h>
#include<stdlib.h>
using namespace std;
int main(){
int id=fork();
if(id==0)
cout<<"child pgid:"<<getpgrp()<<endl;
else
cout<<"parent pgid:"<<getpgrp()<<endl;
return 0;
}
一个进程可以通过使用setpgid函数来改变自己或子进程的进程组
#include<unistd.h>
int setpgid(pid_t pid, pid_t pgid);
setpgid函数将进程pid的进程组改为pgid。如果pid是0,那么就使用当前进程的PID。如果pgid是0,那么就用pid指定的进程的PID作为进程组ID,若是不存在进程组ID为指定的进程的PID,则新建一个进程组。
#include<iostream>
#include<unistd.h>
#include<signal.h>
#include<stdlib.h>
using namespace std;
int main(){
int id=fork();
if(id>0)
{
sleep(1);
cout<<"parent pid:"<<getpid()<<endl;
cout<<"prev parent pgid:"<<getpgrp()<<endl;
setpgid(getpid(), id);
cout<<"cur parent pgid:"<<getpgrp()<<endl;
}
else
{
cout<<"child pid:"<<getpid()<<endl;
cout<<"prev child pgid:"<<getpgrp()<<endl;
setpgid(0, 0);
cout<<"cur child pgid:"<<getpgrp()<<endl;
}
return 0;
}
2. 通过终端按键产生信号
ctrl-c 发送 SIGINT 信号给前台进程组中的所有进程。常用于终止正在运行的程序。
ctrl-z 发送 SIGTSTP 信号给前台进程组中的所有进程,常用于挂起一个进程。
ctrl-\ 发送 SIGQUIT 信号给前台进程组中的所有进程,终止前台进程并生成 core 文件。
我们可以通过 signal 函数实现信号捕捉来进行验证
#include <signal.h>
sighandler_t signal(int signum, sighandler_t handler);
该函数会将signum信号的处理方法替换为handler(函数指针),并将原方法返回
#include<iostream>
#include<unistd.h>
#include<signal.h>
using namespace std;
void func(int signo){
cout<<"receive signal:"<<signo<<endl;
}
int main(){
signal(SIGINT, func);
signal(SIGTSTP, func);
signal(SIGQUIT, func);
while(1)
{
cout<<"Hello world"<<endl;
sleep(1);
}
return 0;
}
3. 调用系统函数向进程发信号
#include<signal.h>
int kill(pid_t pid, int signo);
int raise(int signo);
#include<stdlib.h>
void abort(void);
kill函数表示给指定进程发送信号signo,如果pid大于零,那么kill函数发送信号号码signo给进程pid,如果pid等于零,那么kill发送信号signo给调用进程所在进程组中的每个进程,包括调用进程自己。如果pid小于零,kill发送信号signo给进程组 |pid| 中的每个进程。
而raise只能给当前进程发送信号 ,abort则是给当前进程发送特定信号SIGABRT,alarm函数是在secs秒后给当前进程发送特定信号SIGALRM
#include<iostream>
#include<unistd.h>
#include<signal.h>
#include<stdlib.h>
using namespace std;
void func(int signo){
cout<<"receive signal:"<<signo<<endl;
}
int main(){
signal(SIGINT, func);
signal(SIGABRT, func);
for(int i=0;i<3;i++)
{
cout<<"Hello world"<<endl;
sleep(1);
switch(i){
case 0:
kill(getpid(), SIGINT);
break;
case 1:
raise(SIGINT);
break;
case 2:
abort();
}
}
return 0;
}
#include<iostream>
#include<unistd.h>
#include<signal.h>
#include<stdlib.h>
using namespace std;
void func(int signo){
cout<<"receive signal:"<<signo<<endl;
}
int main(){
signal(SIGINT, func);
int id=fork();
if(id==0)
kill(0, SIGINT);
while(1);
return 0;
}
而kill命令同样是调用的kill函数
4. 由软件条件产生信号
在进程间的管道通信中,当读端关闭之后,操作系统会自动向写端进程发送SIGPIPE信号来终止进程。
#include<iostream>
#include<unistd.h>
#include<signal.h>
#include<stdlib.h>
#include<cstring>
#include<sys/wait.h>
#include<sys/types.h>
using namespace std;
int main(){
int fd[2];
pipe(fd);
int id=fork();
if(id>0)
{
close(fd[1]);
sleep(1);
close(fd[0]);
int status;
wait(&status);
cout<<"child exit signal:"<<(status&0x7f)<<endl;
}
else
{
close(fd[0]);
char buf[20]="Hello world\n";
while(1)
{
write(fd[1], buf, strlen(buf));
sleep(1);
}
}
return 0;
}
除此之外,alarm函数会进程发送SIGALRM信号
#include<unistd.h>
unsigned int alarm(unsigned int secs);
该函数安排内核在secs秒后发送一个SIGALRM信号给调用进程。如果secs为零,则不会安排闹钟。而若是前一个alarm闹钟还没有到secs秒后就调用新一个alarm函数,那么剩余秒数会作为新调用函数的返回值(如果没有任何待处理的闹钟则返回0),重新使用新调用的函数计时。
#include<iostream>
#include<unistd.h>
#include<signal.h>
#include<stdlib.h>
#include<cstring>
using namespace std;
int main(){
int prevsecs1=alarm(10);
cout<<"prevsecs1:"<<prevsecs1<<endl;
sleep(2);
int prevsecs2=alarm(1);
cout<<"prevsecs2:"<<prevsecs2<<endl;
sleep(3);
return 0;
}
5. 硬件异常产生信号
硬件异常被硬件以某种方式检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元(寄存器)会产生异常(寄存器包括状态寄存器,通过位图的形式进行管理,包括状态标记位和溢出标记位,在每次计算完毕后,操作系统都会自动进行检测),内核将这个异常解释为SIGFPE信号发送给进程。
#include<iostream>
#include<unistd.h>
#include<signal.h>
#include<stdlib.h>
using namespace std;
void func(int signo){
cout<<"receive signal:"<<signo<<endl;
sleep(1);
}
int main(){
signal(SIGFPE, func);
int a=100;
a/=0;
return 0;
}
由于我们在对SIGFPE信号捕捉时没有处理,所以该异常一直存在,会一直发送信号给进程。
再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
#include<iostream>
#include<unistd.h>
#include<signal.h>
#include<stdlib.h>
using namespace std;
void func(int signo){
cout<<"receive signal:"<<signo<<endl;
exit(1);
}
int main(){
signal(SIGSEGV, func);
int* p=0;
*p=20;
return 0;
}
三. 信号保存
1. 信号未决
实际执行信号的处理动作称为信号递达(Delivery)
信号从产生到递达之间的状态,称为信号未决(Pending)
而信号未决的状态,以位图的方式存储在task_struct(PCB)中,每个信号都在其中有一个标记位表示是否处于未决状态。而这种位图的存储方式操作系统将其设计为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);
int sigpending (sigset_t *set);
sigemptyset 函数初始化set所指向的信号集全为无效。
sigfillset 函数初始化set所指向的信号集全为有效。
sigaddset 函数将set所指向的信号集中signo信号所代表的比特位置为有效。
sigdelset 函数将set所指向的信号集中signo信号所代表的比特位置为无效。前面四个函数返回值皆为成功返回0,失败返回-1 。
sigismember 函数判断set所指向的信号集中signo信号所代表的比特位置是否有效,有效返回1,无效返回0,出错返回-1 。sigpending 函数作为输出型函数,将task_struct中的未决信号集存储在set中。成功返回0,失败返回-1 。
信号未决是一种在信号处理时自然产生的状态,因此我们只能查看其中信号是否处于未决状态,而无法对其进行修改。
2. 信号阻塞
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
而每个信号的阻塞状态和信号未决一样使用sigset_t数据类型来存储。
Linux提供阻塞信号的隐式和显式的机制:
隐式阻塞机制:内核默认阻塞任何当前处理程序正在处理信号类型的待处理信号。
显式阻塞机制:应用程序可以使用sigprocmask函数和它的辅助函数,明确地阻塞和解除阻塞选定的信号。
#include<signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
how包括三种对阻塞信号所存储在的sigset_t的处理方式
SIG_BLOCK:将set中表示为有效的位置在阻塞信号的sigset_t中皆置为有效
SIG_UNBLOCK:将set中表示为有效的位置在阻塞信号的sigset_t中皆置为无效
SIG_SETMASK:将阻塞信号的sigset_t设为与set相同
而oset是一个输出型参数,存储更改前的阻塞信号的sigset_t(set与oset皆可为空指针)。
#include<iostream>
#include<unistd.h>
#include<signal.h>
#include<stdlib.h>
#include<assert.h>
using namespace std;
void handler(int signo){
cout<<"receive signal:"<<signo<<endl;
}
int main(){
signal(SIGINT, handler);
sigset_t set;
sigset_t pset;
sigemptyset(&set);
sigemptyset(&pset);
sigaddset(&set, SIGINT);
sigprocmask(SIG_SETMASK, &set, nullptr);
int count=0;
while(true)
{
sigpending(&pset);
for(int i=1;i<=31;i++)
{
int ret=sigismember(&pset, i);
assert(ret>=0);
cout<<ret;
}
cout<<endl;
sleep(1);
count++;
if(count==5)
{
sigemptyset(&set);
sigprocmask(SIG_SETMASK, &set, nullptr);
}
}
return 0;
}
而为了避免所有信号都被阻塞或是被捕捉导致处理信号时不退出进程而导致进程无法退出的情况,操作系统将9号信号和19号信号(1到31号信号)设置为无法被阻塞和捕捉
#include<iostream>
#include<unistd.h>
#include<signal.h>
#include<stdlib.h>
#include<assert.h>
using namespace std;
void handler(int signo){
cout<<"receive signal:"<<signo<<endl;
}
int main(){
cout<<"pid:"<<getpid()<<endl;
signal(SIGKILL, handler);
signal(SIGSTOP, handler);
sigset_t set;
sigset_t pset;
sigemptyset(&set);
sigemptyset(&pset);
sigaddset(&set, SIGKILL);
sigprocmask(SIG_SETMASK, &set, nullptr);
int count=0;
while(true)
{
sigpending(&pset);
for(int i=1;i<=31;i++)
{
int ret=sigismember(&pset, i);
assert(ret>=0);
cout<<ret;
}
cout<<endl;
sleep(1);
}
return 0;
}
3. 信号捕捉
在前面我们提到过可以使用signal函数来将对应的信号的处理方式更改
除此之外,还可以使用sigaction函数
#include<signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
与signal函数不同的是,该函数多了一个输出型参数oact存储先前的处理方式,同时,将之前的函数指针更改为结构体指针。
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:处理函数的指针
sa_sigaction:实时信号的处理函数,不做了解,置空
sa_mask:在进程阻塞中,内核默认阻塞任何当前处理程序正在处理信号类型的待处理信号。而sa_mask说明屏蔽正在处理的信号的同时还要阻塞其他的哪些信号
sa_flags:字段包含一些选项,不做了解,置0
sa_restorer:实时信号处理函数
每个信号的处理方式(函数指针)同样存储在task_struct中,分为三种方式
SIG_IGN:忽略该信号,是将整形1强转为函数指针
SIG_DFL:采用默认处理方式,是将整形0强转为函数指针
用户定义的函数的地址
四. 信号处理
用户态与内核态
在地址空间中,除开0~3G位置的进程各自的用户地址空间外,还包括3~4G位置的进程共享的内核地址空间,用于加载操作系统的代码和数据,不同于用户地址空间,当使用内核地址空间时,不同的进程使用的是同样的页表来调用内存中的同一个位置。
用户态:用户态是指进程在执行自己的应用程序代码时所处的权限级别,它具有较低的权限,不能直接访问系统资源或执行特权指令。
内核态:执行操作系统的代码和数据的时候,计算机所处的状态就叫做内核态。在内核态中,进程拥有更高的权限,可以执行特权指令和访问受保护的系统资源。
而进程默认处于用户态。
信号处理流程
首先,当我们在执行主控制流程的某条指令时因为中断、异常或系统调用会进入内核态。
当内核态处理完毕后,会切换用户态,在这个过程中,内核会检查该进程的未被阻塞的待处理信号的集合(pending&~blocked)。
如果该集合为空(不存在不被阻塞且未处理的信号),那么内核将控制传递到该进程的逻辑控制流中的下一条指令。
如果该集合不为空,那么内核选择集合中的某个信号k(通常是最小的k),并且强制该进程接受信号k。受到这个信号会触发进程采取某种行为(handler)。一旦进程完成了这个行为,那么控制就传递回该进程的逻辑控制流中的下一条指令。
而信号触发进程采取的行为handler在我们上面提到过,分为SIG_DFL、SIG_IGN、用户自定义函数。
信号默认处理方式的补充
每个信号类型都有一个预定义的默认行为,分为
进程终止
进程终止并转储内存
进程停止(挂起)直到被SIGCONT信号重启
进程忽略该信号
我们可以通过man 7 singal命令来查看各个信号的默认处理方式
Term、Core、Ign、Stop分别代表了上面的四种方式。
其中第二种方式的转储内存是将一些错误信息储存到磁盘文件当中,可以让我们调试等。
我们可以通过ulimit -a 查看转储内存的最大存储字节数(云服务器下默认为0)
也可以通过ulimit -c 命令调整上一数值
例如,当我们执行以下程序并发送3号信号时
#include<iostream>
using namespace std;
#include<unistd.h>
int main(){
cout<<getpid()<<endl;
while(true);
return 0;
}
此时,便会生成一个隐藏文件core.20104
我们可以使用gdb调试这个可执行文件,之后打出 core-file+core dump文件,在下面我们就可以看到文件的各种错误信息包括收到的终止信号、错误行数等等。这称为事后调试。
在进程等待中,当子进程被信号所杀时,父进程wait函数的status中,前7位表示终止信号,而第八位则是core dump标志,表示的就是子进程是否进行了核心转储。
#include<iostream>
using namespace std;
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
int main(){
int id=fork();
if(id==0)
{
while(true);
}
else
{
cout<<"child pid:"<<id<<endl;
int status=0;
waitpid(id, &status, 0);
cout<<"core dump:"<<((status>>7)&1)<<endl;
}
return 0;
}
若是信号触发进程采取的行为为前两种方式,那么会在内核态中处理完毕后直接返回用户态从主控制流程中上次被中断的地方继续向下执行,而这同样是从内核态转为用户态,需要检查该进程的未被阻塞的待处理信号的集合,因此,若是有多个未被阻塞的待处理信号,在处理完最小的信号后会重新检查,直到没有信号需要被处理。
若是采取用户的自定义函数,那么就需要先回到用户态执行该函数,之后在该函数返回时执行特殊的系统调用sigretum再次进入内核,之后执行与前两种方式同样的逻辑。
当然,内核态也是可以调用用户的自定义函数,但由于内核态权限大,容易引发一些问题,因此才会这样设计。
可重入函数
当一个函数被不用的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,如果一个函数只访问自己的局部变量或参数,则称为可重入函数,否则,有可能会因为重入而发生混乱。称为不可重入函数。
malloc通过全局链表来管理堆,因此调用了malloc或free的函数为不可重入函数
标准I/O库很多实现都以不可重入的方式使用全局的数据结构,因此调用标准I/O库的函数为为不可重入函数