目录
一、什么是信号
生活中的信号有:红绿灯、下课铃声、女朋友的眼神...等等都是信号。
linux中的信号是:更高层的软件形式的异常,它允许进程和内核中断其他进程。
一个信号就是一条小消息,它通知进程系统发生了一个某种类型的事件。
我们可通过kill -l 查看我们linux系统上支持不同类型的信号。
信号提供了一些机制,通知用户进程发生了这些异常。
比如:
如果,如果一个进程试图除以0,那么内核就发送给一个SIGFPE信号。如果一个进程执行一条非法指令,那么内核就发送给他一个SIGILL信号。当然,如果当进程在前台运行时,你也可以键入Ctrl+C,那么内核就会发送一个SIGINT信号给这个前台进程。也可以用一个进程向另一个进程发送SIGKILL信号强制终止它。
一个信号怎么传入进程,进程又是怎么做出反映的?此篇文章将从三个方面解决问题。分别为发送信号、接收信号、处理信号。
我们先讲一个函数调用,这个函数调用的过程体现了信号的一系列过程。
这个函数可以接受我们设定的信号,从而对这个信号,做出对信号的反应。
代码如下:
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void fuc(int sig)
{
cout<<"获取信号成功,获取的信号为: "<< sig<<"已调用处理信号程序"<<endl;
}
int main()
{
signal(SIGINT,fuc);
sleep(3);
cout<<"已设置处理信号程序"<<endl;
sleep(3);
while(1)
{
cout<<"请传入SIGINT信号,查看调用结果"<<endl;
sleep(1);
}
return 0;
}
结果:
很明显的步骤,在没传入信号之前,程序正常运行,键入Ctrl+C,传入信号,进程接收信号,调用fuc函数,处理信号。下面我们细分讲一下这些过程。
ps:
既然传入信号,我可以捕捉它,然后调用自己的方法,我可不可以把所有信号都设置了,是否一个运行起来的进程就杀不掉了。我们实验一下。
for (int sig = 1; sig <= 31; sig++)
{
signal(sig, fuc);
}
怎么办,终止不了进程了。
我们可以通过kill -9 加上这个进程的pid,就可以将它干掉。
不允许被捕捉,永远都是默认处理动作。
二、发送信号
linux系统提供了大量向进程发送信号的机制。
1.从键盘产生信号
在键盘上输入Ctrl+C,导致内核发送一个SIGINT信号给前台进程,在默认情况下,终止前台作业。 类似地,在键盘上输入Ctrl+\,导致内核发送一个SIGQUIT信号给前台进程,终止进程。
2.用kill函数发送信号
①kill
进程通过调用kill函数发送信号给其他进程(包括它们自己)。
我们运行一个进程并获取它的pid,然后在另一个进程中调用kill函数,至于怎么传另一个进程的pid,可以通过main函数传参的方式传入pid。
proc.cc:
int main(int argc, char *argv[])
{
//传入参数为 ./当前进程名字|要被杀掉的进程名字|进程pid|信号
if (kill(static_cast<pid_t>(atoi(argv[2])), atoi(argv[3])) == -1)
{
cout << "kill error" << strerror(errno) << endl;
exit(1);
}
else
{
cout << "kill success,proc: " << argv[1] << " pid: " << argv[2] << endl;
}
}
Tobekilled.cc:
int main()
{
while (1)
{
sleep(2);
cout << "my pid id :" << getpid() << endl;
cout << "come to kill me !"<< endl;
}
return 0;
}
结果:
除了kill函数,还有一个函数是raise,在进程内部它可以自己给自己发信号。
②raise
我们不断给自己发信号,然后用signa函数去接收信号进而调用我们自定义函数。
代码:
void fuc(int sig)
{
cout << "获取信号成功,获取的信号为: " << sig << "已调用处理信号程序" << endl;
}
int main(int argc, char *argv[])
{
signal(2,fuc);
while (1)
{
raise(2);
cout<<"已发送信号"<<endl;
sleep(1);
}
}
结果:
kill函数可以向任意进程发任意信号,rasie函数可以向自己进程发任意信号, abort函数可以向自己发送SIGABRT信号。
③abort
代码:
int main(int argc, char *argv[])
{
int cnt = 10;
while(cnt--)
{
cout<<"运行ing"<<endl;
sleep(1);
}
abort();
}
结果:
再看一个现象,我设置了捕捉SIGABRT信号的方法,尽管自定义处理函数被调用了,但是还是Aborted了,说明SIGABRT信号和SIGKILL信号一样无论捕捉与否都会保持执行默认动作。
代码:
void fuc(int sig)
{
cout << "I got a signal,it is " << sig << endl;
}
int main()
{
signal(SIGABRT, fuc);
while (1)
{
cout << "Doing" << endl;
abort();
}
return 0;
}
结果:
注意!SIGABRT信号可以被捕获然后实行自定义函数,并且默认动作还会执行。但是SIGKILL信号不可以被捕获,只会执行默认动作。
如图:
3.软件条件产生信号
alarm
进程可以通过调用alarm函数向它自己发送SIGALRM信号。
alarm函数会安排内核在sec秒后发送一个SIGALRM信号给调用进程。
4.硬件产生信号
我们每次除0、数组越界、访问野指针时程序都会奔溃,这是为什么?它们实则都是向进程发送信号,来终止进程。
例如:
我们除0,并设置捕捉并执行自定义方法。8号信号为SIGFPE。
我们数组越界。11信号为SIGSEGV。
我们访问野指针。11信号为SIGSEGV。
除零:CPU内部有状态寄存器,一旦数字除零,状态寄存器被标明:浮点异常。所以os会识别到状态寄存器(硬件)有报错,并构建信号,发送给产生该错误的进程。
数组越界&&访问野指针:都与虚拟地址有关,如果虚拟地址有问题,管理虚拟地址转化工作的MMU(硬件)+页表(软件)会出现问题,于是os会发现问题,并构建信号,发送给产生该错误的进程。
还有就是抛异常本质上也是发信号。
三、接收信号
我们首先要先知道一些概念。
实际执行信号的处理动作称为信号抵达(Delivery)。
在信号产生到信号抵达之间的状态,称为信号未决(Pending)。
进程可以选择阻塞(Block)某个信号。
被阻塞的信号产生时将保持处在未决状态,知道进程接触对此信号的阻塞,才执行抵达的动作。
阻塞与忽略是不一样的,阻塞是一种状态,而忽略是处理信号时的处理动作。
每个信号都有两个标志位,分别表示阻塞和未决,还有一个函数指针表示处理动作。信号产生,内核在进程控制块中设置该信号的未决标志,直到信号抵达才解除该标志。这两个表都可以用32个比特位来实现,每一个比特位的“非0即1”,对应的可以表示阻塞和未阻塞,是否未决。
在上图的例子中:
SIGHUP: 这个信号未产生所以,Pending表中该信号对应的数字是0,Block表中该信号对应的数字是为0。如果有该信号产生,因为没有阻塞该信号,而且设置的处理方式是默认,则会实行默认处理动作。
SIGNIT:这个信号已经产生,Pending表中该信号对应的数字是1,Block表中该信号对应的数字是为1。虽然函数处理方式设为忽略,但是在没有解除阻塞之前,该信号一直会是未决状态。
SIGQUIT: 这个信号未产生所以,Pending表中该信号对应的数字是0,Block表中该信号对应的数字是为1。尽管默认动作是自定义的handler方法,但是信号产生会阻塞该信号。
每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储。sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态。我们讲述的是这两个表是位图,其实底层是用数组来实现。下面我们了解下,对信号集的操作函数。
1.sigprocmask
sigprocmask函数读取或改变当前进程的信号屏蔽字(阻塞信号集block)。具体的行为依赖于how的值:
SIG_BLOCK:把set中的信号添加到block中。相当于block = block | set。
SIG_UNBLOCK:把block中的set信号删除。block = block & ~ set。
SIG_SETMASK:使block = set。
至于oldset是一个输出型参数,你可以用它来接受未改变之前的block表。你可以用它来复位。
还可以使用下述函数对set信号集合进行操作:sigemptyset初始化set为空集合。sigfillset函数把每个信号都添加到set中。sigaddset函数把signum添加到set中,sigdelset从set中删除signum信号,如果signum是set成员,那么sigismember返回1,否则返回0。
实例:如何使用sigprocmask来阻塞SIGINT信号。
void fuc(int sig)
{
//如果进行到这一步说明信号抵达成功,证明阻塞该信号失败,代码出问题了
cout << "I caught a signal,it is : " << sig << endl;
}
int main()
{
sigset_t set, oldset;
sigemptyset(&set);
sigaddset(&set, SIGINT);
// block SIGNINT and save previous block in oldset
sigprocmask(SIG_BLOCK, &set, &oldset);
//尽管设置捕捉信号和自定义处理动作,但是该信号已被阻塞
signal(SIGINT, fuc);
int cnt = 5;
while (cnt--)
{
cout << ".........." << endl;
}
//向自己发送信号
raise(2);
cout<<"发送成功"<<endl;
return 0;
}
接下来我们将SIGINT信号重新放开,不在阻塞。
//解除阻塞
sigprocmask(SIG_SETMASK,&oldset,NULL);
2.sigpending![](https://img-blog.csdnimg.cn/209e89cc56004617bd0d20f677395c09.png)
这个函数可以获得当前的未决表(pending)。set为输出型参数。
我们结合上文的一些函数,整体去试用一下。
例子:
static void ShowPending(sigset_t *pending)
{
for (int sig = 1; sig <= 31; sig++)
{
if (sigismember(pending, sig))
{
cout << "1";
}
else
{
cout << "0";
}
}
cout << endl;
}
int main()
{
sigset_t pending;
//将所有的信号屏蔽
sigset_t set, oldset;
sigemptyset(&set);
sigemptyset(&oldset);
sigfillset(&set);
sigprocmask(SIG_SETMASK, &set, &oldset);
while (1)
{
sigemptyset(&pending);
if (sigpending(&pending) == 0)
{
//打印当前的 pending表
ShowPending(&pending);
}
//使用随机数来发送信号
srand((unsigned)time(NULL));
int sig = rand() % 31;
if (sig == 6 || sig == 9 || sig == 19)
{
//发送6和9信号,尽管屏蔽了,但是还是会结束进程
// 19信号会让当前进程变为后台进程
;
}
else if (sig == 0)
{
//没有0号信号
;
}
else
{
//向自己发送信号
raise(sig);
}
sleep(1);
}
return 0;
}
注意,这里的将pending初始化,只是将我们定义的sigset_t类型的变量pending初始化,并不是将系统内部的pending表初始化。还有就是所有信号都已经被屏蔽只能用kill -9 加该进程的pid可以解决掉该进程。可以用ps axj | grep 加该进程的名字,来查看进程pid。
四、处理信号
1.什么时候处理信号?
当当前进程从内核态,切换为用户态的时候,进行信号的检测和处理。
内核态?我们在学习虚拟地址空间接触过。
这里的用户级页表每一个进程都一份,而且每一个进程拥有都不一样的用户级页表。
而内核级页表所有进程共享的是一份,前提是你要有权利访问。
无论进程怎么切换我们都可以访问内核的代码和数据,前提是要有权力访问。
怎么才能有权力访问内核级页表和内核的数据和代码?要进行身份切换。进程如果是用户态只能访问用户级页表,是内核态可以访问内核级和用户级页表。
怎么查看进程是用户态还是内核态?CPU内部会有一个叫CR3的寄存器,用比特位标识当前用户的状态,0标识内核态,3标识用户态。当然我们不能随意改变这个状态。
当进程在系统调用的时候,当时间片到了,进程之间在进行切换的时候,以及其他手段, 会改变这个状态。
我们实际在运行程序时,会无数次直接或间接调用系统级软硬件资源(由os管控),本质上,我们并没有自己去申请资源,而是通过os来操作,则会无数次进入到内核中(1.切换身份2.切换页表),然后去调用内核的代码,完成访问,结果返回用户(1.切换身份2.切换页表),得到结果。
那你说这段代码会有内核态和用户态的切换吗?
int main()
{
while(1)
{
;
}
}
我并没有去调用系统接口,就一直让它循环,会不会进行内核态和用户态的切换呢?
每个进程都有自己的时间片,当时间片到了就会被剥离下来。时间片到了os会收到时钟中断,从而将该进程切换成内核态,更换内核级页表。然后os保护上下文,执行调度算法,选择新进程,恢复新进程的上下文。再切换用户态,更换用户态页表。最后执行新进程的代码。但是我的while还在运行啊,没有执行新的进程啊,既然可以切换出去,就可以切换回来,又是一趟用户态到内核态到用户态的旅程,然后继续进行while。
至于,处理整个流程如图:
快速记忆:
2.sigaction
Posix标准定义了sigaction函数,它允许用户再设置信号处理时,明确指定他们想要的信号语义。但sigaction函数运用并不广泛,因为它要求用户设置一个复杂结构的条目。一个更简介的方式,最初是由W.Richard Stevens提出的[110],就是定义一个包装函数,称为Signal,它调用sigaction。
Signal包装函数设置了一个信号处理程序,其信号处理如下:
只有这个处理程序当前正在处理的那种类型的信号被阻塞;(意思为信号处理程序正在处理一个信号,你多次发送信号,该信号会被阻塞,处于未决状态)
ps:
我捕捉2信号,并且在处理时打印pending表,并且该处理程序在运行5秒之后结束。
1.现象说明我正在处理2信号,继续发送2信号,打印pending表会看出2信号处于未决状态。
2.此现象说明处理完2信号,紧接着会解除阻塞2信号,并处理2信号,所以打印pending表不处于未决状态。
3.我在执行2信号时,无论发多少次2信号,我执行完当前2信号的处理程序,只会执行一次2信号的处理程序,并不会按照你发多少次我执行多少次的行为。
和所有信号实现一样,信号不会排队等待;
只有可能,被中断的系统调用会自动重启;
一旦设置了信号处理程序,它就会一直保持,知道Signal带着handler参数为SIG_IGN或者SIG_DFL被调用。
五、编写信号处理程序
以下关于安全信号处理的内容摘自《深入理解计算机系统》。
信号处理是linux系统编程的棘手问题。1)处理程序与主程序并发运行,共享同样的全局变量,因此可能与主程序和其他处理程序互相干扰;2)如何以及何时接收信号的规则常常有违人的直觉;3)不同系统有不同的信号处理语义。
1. 安全的信号处理
我们要知道信号处理程序可以被其他信号处理程序打断,并且主程序的运行也会被打断。
这些情况无疑会导致严重的后果,所以我们要提前预知,编写安全的信号处理程序。
① 处理程序要尽可能简单。
避免麻烦的手段就是保持处理程序尽可能小和简单。
②.在处理程序中只调用异步信号安全的函数。
所谓异步信号安全的函数能够被信号处理程序安全地调用,原因有二:要么它是可重入函数,要么它不能被信号处理程序中断。
我只罗列了一部分异步信号安全的函数,具体的可以看,man 7 signal 的Linux Programmer's Manual 。
信号处理程序中产生输出为以安全的方法是使用write函数。特别地,调用printf或sprintf是不安全的。我们可以开发一些安全的函数,称为SIO(safe I/O)包,可以用来在信号处理程序中打印简单的消息。
ssize_t sio_puts(char s[])
{
return write(STDOUT_FILENO,sio_strlen(s));
}
ssize_t sio_putl(long v)
{
char s[128];
sio_ltoa(v,s,10);
return sio_puts(s);
}
void sio_error(char s[])
{
sio_puts(s);
_exit(1);
}
sio_strlen函数返回字符串s的长度。sio_ltoa基于itoa函数,把v转换成它的基b字符串表示,保存在s中。
③保存和恢复errno。
许多linux异步信号安全的函数都会在出错返回时设置errno。在处理程序中调用这样的函数可能干扰主程序中其他依赖于errno的部分。解决方法:
void handler(int sig)
{
int olderrno = errno;
...
errno = olderrno;
}
④阻塞所有的信号,保护对共享全局数据结构的访问。
如果处理程序和主程序或其他程序共享一个全局数据结构,那么在访问该数据结构时,你的处理程序和主程序应该暂时阻塞所有的信号。
void fuc()
{
sigset_t mask, prev_mask;
sigfillset(&mask);
sigprocmask(SIG_BLOCK,&mask,&prev_mask);
...
sigprocmask(SIG_SETMASK,&prev_mask,NULL);
}
int a = 0;
void fuc(int sig)
{
a = 1;
cout<<"数值a 改为 1"<<endl;
}
int main()
{
signal(2,fuc);
while(!a);
cout<<"进程退出"<<endl;
return 0;
}
该进程的结果显而易见,我键入Ctrl+c进程就会退出。但如果编译器的优化等级过高,它会发现a的值一直没有发生变化,从而把他优化至寄存器里,便于与CPU交互,当真正修改a时,修改的只是内存中a的值,寄存器的值没有任何变化。
现在我们更换下当前编译器等级
则会导致以下情况:
出现这种情况怎么办,除了修改编译器等级,还可以给变量加上关键字volatile,强制要求编译器每次在引用变量a时,都要从内存中读取a的值。
volatile int a = 0;
⑥用sig_atomic_t声明标志。
在常见的处理程序设计中,处理程序会写全局标量来记录收到了这种信号。主程序周期性地读这个标志,响应信号,再清除该标志。对于通过这种方式来共享的标志,C提供一种整形数据类型sig_atomic_t,对它的读和写保证是原子的(不可中断的),因为可以用一条指令来实现它们:这里的一条指令类似于线程中的知识,后面的博客会讲到。
volatile sig_atomic_t a = 0;
六、SIGCHLD信号
此板块的最后内容。
一个子进程结束时,也会向父进程发送信号。在子进程结束之前我们会用waitpid傻傻的干等着,如果waitpid放在父进程代码最前面,在子进程结束之前,父进程根本做不了任何事情,现在我们可以用SIGCHLD信号,来改变现状。当父进程收到SIGCHLD信号之后再去调用waitpid去回收子进程。
这次我们优化一下:
void fuc(int sig)
{
while (waitpid(-1, NULL, 0) > 0)
{
cout << "回收子进程成功" << endl;
}
}
int main()
{
signal(SIGCHLD, fuc);
for (int i = 0; i < 3; i++)
{
pid_t pid = fork();
if (pid == 0)
{
// child
int cnt = 2;
while (cnt--)
{
cout << "我是子进程, 我的pid是: " << getpid() << endl;
sleep(1);
}
exit(0);
}
}
while (1)
{
cout << "我正在欢快的做事情" << endl;
sleep(2);
}
}
这里的waitpid为什么要while去调用?
因为如果多个子进程结束,会向父进程发送SIGCHLD信号,但是信号会阻塞如果在一个时间段前一个SIGCHLD信号正在被处理,该SIGCHLD信号就会放到pending表里,但如果这时又来一个SIGCHLD信号,该信号就会被丢弃,导致该子进程未被回收,称为僵尸进程。
我们改变策略收到一个SIGCHLD信号就尽可能地去回收子进程,就可以避免这种情况。
当然我们既不想waitpid,也不想设置自定义处理方式,我们还可以将,signal设为:
signal(SIGCHLD, SIG_IGN);
当父进程退出时,子进程默认会被系统回收。注意我们在没有waitpid和捕捉信号时,系统默认对SIGCHLD信号的处理方式就是忽略。所以到父进程结束之后,os会将子进程回收。
修改之后:
感谢观看,我们下次再见!