目录
前言
在日常生活中,有许多方面都涉及到了信号的知识,例如信号弹、上下课铃声、红绿灯、闹钟等等。我们可以仔细想想由信号引发的几个问题:
- 我们是如何认识这些信号的?----有人教,随后记住了;
- 即使现在没有信号产生,我们也知道信号产生之后应该做什么;
- 信号产生了,我们可能并不会立即处理这个信号,因为我们可能在做一些更重要的事情,由此可以得出信号产生后到信号处理之间其实会有一段时间窗口,而在这个时间窗口内,我们必须记住信号的到来。
而在计算机当中,执行的主体就是进程,也就是说进程必须能够识别并处理信号,这是属于进程内置功能的一部分。同样的,一个进程由信号产生,到信号开始被处理,就一定会有时间窗口,而进程具有临时保存哪些信号已经发生了的能力。
一、初识信号
在linux中运行某一个进程时,我们可以随时按下 ctrl+c 来杀掉前台进程,如下图一个死循环的输出我们用ctrl+c使进程退出:
它为什么能够杀掉前台进程呢?
在Linux中的一次登录中,一个终端一般会配上一个bash,每一个登录只允许一个进程是前台进程,可以允许多个进程是后台进程。因此如果我们运行时在进程名后面加上&,则代表让它在后台运行,这个时候我们使用ctrl+c就没有用了,必须使用kill -9 + pid 号来杀掉进程。
所以正常我们在运行一个进程时,在对bash进行输入命令就没有用了,因为这个进程运行时就成为了前台进程。
kill -l 可以查看操作系统拥有的信号:
ctrl + c 的本质是被进程解释成为收到了2号信号SIGINT,需要注意的是,不同的操作系统可能对信号的编号有所不同,因此在跨平台开发时应当注意信号编号的兼容性。
普通信号可以不立即处理,实时信号必须立即处理。
信号的处理方式:
1. 忽略此信号。
2. 执行该信号的默认处理动作。
3. 自定义提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉 (Catch)一个信号。
二、信号的概念
信号是 Linux 操作系统中用于进程间通信、处理异常等情况的一种机制。它是由操作系统向一个进程或者线程发送的一种异步通知,用于通知该进程或线程某种事件已经发生,需要做出相应的处理。
信号的产生和我们自己的代码的运行是异步的,这意味着信号的产生与代码的执行没有直接的关联,信号属于软中断。
三、信号的发送与捕捉
3.1 信号的发送
在 Linux 中,进程可以通过向其他进程或自身发送信号的方式进行通信或处理异常情况。下面介绍几种常见的发送信号的方法。
3.1.1 kill 命令
kill [-signal] PID
其中,-signal 可选参数表示要发送的信号类型,如果省略该参数,则默认发送 SIGTERM 信号。PID 表示接收信号的进程 ID。
例如,要向进程 ID 123 发送 SIGINT 信号,可以执行以下命令:
kill -SIGINT 123
3.1.2 kill 函数
我们也可以使用系统调用的一些函数来发送信号:
其中,pid 表示接收信号的进程 ID,sig 表示要发送的信号类型。如果函数调用成功,则返回 0,否则返回 -1 并设置 errno。
例如,要向进程 ID 123 发送 SIGINT 信号,可以执行以下代码:
#include <signal.h>
#include <unistd.h>
int main() {
pid_t pid = 123;
int sig = SIGINT;
if (kill(pid, sig) == -1) {
perror("kill");
return 1;
}
return 0;
}
3.1.3 raise函数
raise 函数是一个简单的发送信号的函数,可以用来向当前进程发送信号。raise 函数的原型如下:
其中,sig 表示要发送的信号类型。如果函数调用成功,则返回 0,否则返回 -1 并设置 errno。
例如,要向当前进程发送 SIGTERM 信号,可以执行以下代码:
#include <signal.h>
int main() {
int sig = SIGTERM;
if (raise(sig) == -1) {
perror("raise");
return 1;
}
return 0;
}
3.1.4 abort函数
abort函数的作用是引起一个正常函数的终止,它会给自己发送一个6号信号SIGABRT:
3.2 信号的捕捉
在上文提到过,信号是可以被自定义捕捉的,下面介绍几种常见的捕捉信号的方法。
3.2.1 signal函数
signal 函数可以用来注册信号处理函数。signal 函数的原型如下:
其中,sig 表示要注册的信号类型,handler 是一个函数指针,指向信号处理函数。signal 函数返回一个函数指针,指向之前注册的信号处理函数。如果注册信号处理函数失败,则返回 SIG_ERR。
例如,要注册 SIGINT 信号的处理函数,自定义处理函数名称为“sigcb”, 在sigcb当中完成打印触发本次事件的信号值,可以执行以下代码:
#include<iostream>
#include<signal.h>
#include <unistd.h>
using namespace std;
void sigcb(int signo)
{
cout << "process get a SIGINT signal: " << signo <<endl;
// exit(1);
}
int main()
{
if (signal(SIGINT, sigcb) == SIG_ERR) {
perror("signal");
return 1;
}
while(true)
{
cout<<"I am a process,pid: "<<getpid()<<endl;
sleep(1);
}
return 0;
}
当我们按下ctrl+c时,可以看到程序输出,最后我们使用ctrl+\退出程序,运行结果如下:
3.2.2 sigaction函数
在Linux中,sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回 -1。
其中,sig 表示要注册的信号类型,act 是一个指向 struct sigaction 结构体的指针,表示新的信号处理函数和信号处理选项,oldact 是一个指向 struct sigaction 结构体的指针,用于获取之前注册的信号处理函数和信号处理选项。
struct sigaction 结构体的定义如下:
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 字段指定一个信号处理器函数,这个函数包含三个参数:一个整数表示信号编号,一个指向siginfo_t结构体的指针,和一个指向void类型的指针。
- sa_mask字段指定了在执行信号处理函数期间要阻塞哪些信号。
- 后面两个字段本章不做详细解释。
例如,要注册 SIGINT 信号的处理函数,自定义处理函数名称为“sigcb”, 在sigcb当中完成打印触发本次事件的信号值,可以执行以下代码:
#include<iostream>
#include<signal.h>
#include <unistd.h>
using namespace std;
void sigcb(int signo)
{
cout << "process get a SIGINT signal: " << signo <<endl;
// exit(1);
}
int main()
{
struct sigaction newact = {
newact.sa_handler = sigcb
};
struct sigaction oldact;
if (sigaction(SIGINT, &newact, &oldact) == -1)
{
perror("sigaction");
return 1;
}
while(true)
{
cout<<"I am a process,pid: "<<getpid()<<endl;
sleep(1);
}
return 0;
}
3.2.3 图示
当我们的进程从内核态返回到用户态的时候,进行信号的检测和处理:
四、信号的产生
4.1 硬件异常产生信号
硬件异常产生信号指硬件发现进程的某种异常,而硬件是被操作系统管理。硬件会将异常通知给系统,系统就会向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
4.2 软件条件产生信号
alarm函数相当于设置一个闹钟,告诉内核多少秒后,发送一个SIGALRM信号给当前进程,它的返回值是一个闹钟的剩余时间。
#include<iostream>
#include<signal.h>
#include <unistd.h>
using namespace std;
int main()
{
int n=0;
alarm(1); //1秒后给进程发送SIGALRM信号
while(true)
{
cout<<n<<endl;
n++;
}
return 0;
}
五、Core dump
5.1 core dump介绍
当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core,这叫做Core Dump。但默认云服务器上面的core功能是被关闭的,我们可以使用ulimit-a 查看, ulimit-c +字节数 设置core文件大小:
子进程的status可以当作位图来看,因此我们可以手写一段代码来提取出Core Dump的值,原理就是通过使用(status >> 8) & 0xFF 获取子进程的退出码(高8位),通过使用(status & 0x7F)获取子进程的退出信号(低7位),最后使用((status >> 7) & 1) 表达式用于判断是否发生了核心转储(第8位即core dump标志)。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
int main()
{
pid_t id = fork();
if(id == 0)
{
//child
int cnt = 500;
while(cnt)
{
cout << "i am a child process, pid: " << getpid() << "cnt: " << cnt << endl;
sleep(1);
cnt--;
}
exit(0);
}
// father
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if(rid == id)
{
cout << "child quit info, rid: " << rid << " exit code: " <<
((status>>8)&0xFF) << " exit signal: " << (status&0x7F) <<
" core dump: " << ((status>>7)&1) << endl;
}
return 0;
}
对于8号信号,默认云服务器上面的core功能是被关闭的,可以看到它的core dump为0,使用ulimit -c 设置文件大小以后,再运行可以看到core dump标志位变为1并且生成了core.pid号的文件:
5.2 core dump作用
假设我们写一段除0的错误代码:
#include <iostream>
using namespace std;
int main()
{
int a = 10;
int b = 0;
a /= b;
cout << " a = " << a << endl;
return 0;
}
运行后可以结合gdb来进行事后调试:
六、阻塞信号
6.1 相关概念
- 实际执行信号的处理动作称为信号递达(Delivery)。
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block)某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
6.2 在内核中的表示
在task_struct结构中信号的构成实质是两个位图和一个数组,我们看的顺序也是横着从左往右看。
- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。
- 信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号被阻塞未产生过,当它递达时执行默认处理动作。
- SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
- SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。
- 当一个信号被阻塞时,它仍然可以被发送到进程,并且会被添加到未决信号集合中。阻塞仅仅阻止信号的传递,即阻止信号的处理,但不阻止信号的接收。
- 如果一个信号被设置为忽略,那么即使该信号被发送到进程,它也不会被添加到未决信号集合中,因为忽略的信号不会对进程产生任何影响。
6.3 sigprocmask
函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集/BLOCK表),成功返回0,出错返回-1。
how参数:
sigset_t 称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,它用来存储未决和阻塞标志。
set参数:指向一个信号集的指针,这个信号集指定了要阻塞或解除阻塞的信号。如果 set 是一个空指针(NULL),则 how 参数没有效果指向一个信号集的指针,这个信号集指定了要阻塞或解除阻塞的信号,是一个输入型函数。如果 set 是一个空指针(NULL),则 how 参数没有效果。
oldset参数:如果不是空指针(NULL),则进程的当前信号屏蔽字会被存储在 oset 指向的位置。如果 oset 是空指针,则不返回当前的信号屏蔽字,是一个输出型参数。
6.4 sigpending
sigpending函数读取当前进程的未决信号集,通过set参数传出,即把调用进程所对应的pending表带出来,调用成功则返回0,出错则返回-1。
例如,可以使用下段代码屏蔽2号信号并通过打印pending表来观察:
#include<iostream>
#include<unistd.h>
#include<signal.h>
using namespace std;
void PrintPending(sigset_t &pending)
{
for(int signo=31;signo>=1;signo--)
{
//存在打印1,否则为0
if(sigismember(&pending,signo))
{
cout<<"1";
}
else
{
cout<<"0";
}
}
cout<<endl;
}
int main()
{
//阻塞2号新号 --数据预备
sigset_t bset,oset;
//函数sigemptyset初始化set所指向的信号集使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
sigemptyset(&bset);
sigemptyset(&oset);
//函数sigaddset在该信号集中添加2号信号
sigaddset(&bset,2);
//----系统调用,将数据设置进内核----
sigprocmask(SIG_SETMASK,&bset,&oset);
//重复打印pending信息便于观察
sigset_t pending;
while(true)
{
//获取
int n = sigpending(&pending);
if(n<0) continue;
//打印
PrintPending(pending);
sleep(1);
}
return 0;
}
注意,9号和19号信号是无法被屏蔽的:
七、可重入函数
如果一个函数在被重复进入的情况下不会出错,则是可重入函数,否则是不可重入函数。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。