一个进程退出有两种情况:1.正常执行完毕。2.程序执行中异常退出。第一种情况可以通过进程退出码来获取进程执行结果,第二种情况需要通过信号来判断进程异常退出原因。那么进程在什么样的条件下会产生信号,进程又是怎样处理产生的信号呢?
我们可以类比生活中的信号,比如接收到了取快递的短信,而你正在打游戏,等你打完游戏后在处理。这个短信就是一个信号,当你接收到短信时,你有可能在忙,所以需要记住这个短信(信号保存),当你处理这个信号时,有可能会自己取快递(默认动作),有可能忽略这个短信(忽略动作),也有可能让别人取(自定义动作)。综上所述,要学习信号,就要从信号的产生,信号的保存,信号的处理三个方面着手。
在Linux中,可以用kill -l
查看信号列表,man 7 signal
可以查看7号手册中的信号信息。
SIGHUP 是宏,它的值为1,其余类似。我们学的是1-31的信号,34-64属于实时信号。
一.信号的产生
信号的产生方式有五种:1.键盘 2. 系统调用 3.命令行 4.软件条件 5.硬件异常。
为了便于验证,这里提前引入一个系统调用signal
,该函数的功能是将指定信号的处理动作修改为自定义行为。handler函数
即为信号的自定义行为,它是一个回调函数
void handler(int signo)
{// 自定义行为
}int main()
{signal(2, handler);///.....return 0;
}
1.1 键盘
- **ctrl + c **:给前台进程发送一个
SIGINT
信号,这个信号的默认处理动作是退出。可以用man 7 signal
来查看。
- **ctrl + \ ** :给前台进程发送
SIGQUIT
信号,默认处理动作也是退出。Core与Term的区别后面会讲,但它们都会让进程退出。
验证程序:
当执行下面程序时,用键盘输入ctrl c
ctrl \
不会执行默认处理动作(退出),而是执行handler函数打印信号编号。
void handler(int signo)
{cout << signo << endl;
}
int main()
{signal(SIGINT, handler);signal(SIGQUIT, handler);while (1) ;return 0;
}
1.2 命令行
- kill signo pid 给指定pid的进程发送signo信号
int main()
{while (1){std::cout << "我是一个进程 %d:" << getpid() << std::endl;sleep(1);}return 0;
}
1.3 系统调用
-
kill:给指定进程发送sig信号
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>int main()
{int cnt = 5;while (cnt){sleep(1);std::cout << cnt-- << std::endl;}kill(getpid(), SIGINT);std::cout << "begin -----" << std::endl;while (1){;}return 0;
}
这个程序在运行5秒后会立即退出,不会打印begin,因为它自己给自己发送2号信号
-
raise:谁调用这个函数,给谁发送信号
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>int main()
{int cnt = 5;while (cnt){sleep(1);std::cout << cnt-- << std::endl;}raise(SIGINT);std::cout << "begin -----" << std::endl;while (1){;}return 0;
}
- abort:谁调用这个函数,给谁发送
**SIGABRT**
信号,终止当前进程。自定义捕捉后也会退出。
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>int main()
{int cnt = 5;while (cnt){sleep(1);std::cout << cnt-- << std::endl;}//这是一个c库函数,内部封装了系统调用,不论你捕不捕捉SIGABRT信号,调用该函数程序都会退出abort();std::cout << "begin -----" << std::endl;while (1){;}return 0;
}
1.4 软件条件
- 功能:在seconds秒后,给当前进程发送一个
SIGALRM
信号 - 返回值:返回上一个闹钟剩余的秒数,当一个闹钟
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>using std::cout;
using std::endl;void handler(int signo)
{cout << signo << endl;
}int main()
{signal(SIGALRM, handler);alarm(3);sleep(1);cout << alarm(1) << endl; //此时打印的是2 ,因为创建alarm(3)这个闹钟后// 休眠了1秒,所以创建alarm(1)闹钟时,它的返回值是上一个//闹钟的剩余时间,2秒sleep(1);return 0;
}
计算机内部有一个计时器,它每时每刻都在运行,电脑时间也是根据它来确定的,并且它每隔一定时间都会给os发送一个时钟中断。
由于系统中的闹钟不只一个,所以操作系统为了管理闹钟而创建了一个结构体,结构体内部有一个timestamp(currtime+seconds)字段,保存的是这个闹钟什么时候唤醒。操作系统会遍历这个闹钟队列,当检测到哪个闹钟的timestamp和当前时间相同,便唤醒这个闹钟。
1.5 硬件异常
当程序中出现除0错误时,操作系统会给进程发送SIGFPE
信号。OS是怎样知道程序中有除0错误的呢?当a /= 0
这样的语句被cpu执行时,cpu识别到除数为0时,会将状态寄存器中的溢出位置1,然后操作系统识别到状态寄存器中的值,会根据cpu中的寄存器找到当前进程的task_struct
并写入信号SIGFPE
。
虚拟地址是通过页表映射到内存当中的,其中从虚拟地址到物理地址的转换是由mmu硬件来完成的,mmu转化的时候有两种情况:1.页表中没有映射关系,mmu直接报错。2.页表中有映射关系,但是没有访问权限,mmu也会报错。操作系统识别到mmu报错,则会向当前进程发送SIGSEGV信号,表明错误原因是段错误。
上述两种情况,看似是由软件引发的错误,但实际上是硬件异常而引发的操作系统向进程发送信号的过程。
1.6 Core与Term
之前研究进程执行情况时,需要获取进程退出码和退出信号,其中有一个字段core dump
这个字段的作用是表示核心转储,即将程序的数据都转储到磁盘。当程序异常退出时,如果默认退出动作为Core
则会将core dump设置为1,然后在当前路径下创建一个core.pid
的转储文件,在gdb中可以使用core-file
指令导入这个转储文件,方便定位异常原因。如果默认退出动作为Term
则不会创建转储文件。
生产环境(云服务器)默认不开启核心转储,所以
core
也不会生产转储文件。
- ulimit -a 可以打印服务器的资源上线
- 从上图可以看出,core file size =0 ,使用
ulimit -c 10024
将core file size 修改为 10024即可开始转储功能
二.信号的保存
信号可能随时产生,也就是说信号的产生和进程的执行是异步的。当一个信号产生时,程序有可能会执行更加重要的任务,比如IO,所以不能立即处理信号,于是就需要将信号保存到一个地方,以便于后续处理。1-31的信号,短时间内我们只需要保存有无产生即可,故可以用位图来保存信号。下面介绍三个概念:
- 信号递达(Delivery):执行信号的处理动作就叫做信号递达
- 信号未决(Pending):从信号的产生到信号递达之间的状态就叫做信号未决
- 阻塞(block):当一个信号被阻塞时,它将永远保持未决状态,直到解除阻塞,才会递达。
在每一个进程控制块中,有三张表:block,pending,handler。block和pending表是位图结构,handler表是函数指针数组,存放的是信号处理行为(signal函数修改的就是这个表)。block表也叫做信号屏蔽字(阻塞信号集)。
block和pending由于只需要保存两种状态,故此用位图来表示block表和pending表即可。在内核中,这种结构叫做sigset_t
也叫信号集。
2.1 信号集函数
- int sigemptyset(sigset_t *set);
- 功能:将set信号集置0
- int sigfillset(sigset_t *set);
- 功能:将set信号集全部置1
- int sigaddset (sigset_t *set, int signo);
- 功能:给set信号集添加signo信号
- int sigdelset(sigset_t *set, int signo);
- 功能:在set信号集中删除signo信号
- int sigismember(const sigset_t *set, int signo);
- 功能:signo信号是否在set信号集中
int main()
{sigset_t set;sigemptyset(&set);sigfillset(&set);sigdelset(&set, 4);sigaddset(&set, 4);if (sigismember(&set, 4)) {/// ...}return 0;
}
上述代码只是在栈区修改信号集(局部变量),还没有将信号添加到内核中,所以需要调用系统调用将信号添加到内核中。
2.2 block表修改函数
- sigprocmask:
- how:以何种方式添加
- SIG_BLOCK:block |= set 将set中的信号添加到block表中
- SIG_UNBLOCK: block &= ~set 在block表中删除set中的信号
- SIG_SETMASK:block = set 将block表改为set表
- oldset:输出型参数,保存的是上一次信号屏蔽字。
- 返回值:成功返回0,失败返回-1
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>using std::cout;
using std::endl;int main()
{sigset_t set, oset;sigemptyset(&set);sigemptyset(&oset);// 给set添加3号信号sigaddset(&set, 3);// 阻塞三号信号sigprocmask(SIG_SETMASK, &set, &oset);while (1) ;return 0;
}
在上面程序中,由于阻塞了三号信号,所以`ctrl \`不会导致程序退出。
2.3 pending查看函数
操作系统不允许用户修改pending信号集,只能查看pending表
- sigpending:
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>using std::cout;
using std::endl;void printPending(const sigset_t& set)
{for (int i = 1; i <= 31; i++){if (sigismember(&set, i)) cout << "1";else cout << "0";}cout << endl;
}
int main()
{sigset_t set;sigemptyset(&set);sigpending(&set);while (1){sleep(1);printPending(set);}return 0;
}
2.4 handler 表修改函数
信号的处理动作有三种,默认(SIG_DFL),忽略(SIG_IGN) ,自定义。
修改信号处理动作的函数有两个:
1.signal :
2.sigaction:
- 参数中sigaction又是一种数据结构,它的字段有如下图
我们使用这个数据结构时,只需要初始化sa_handler, sa_mask,sa_flags即可
- sa_handler:自定义处理函数
- sa_mask:当你正在执行某一个信号的处理函数时,该信号自动被os阻塞,防止出现递归调用的情况,如果你想在处理handler函数时屏蔽其他信号,就需要设置这个参数。
- sa_flags:初始化0即可
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>using std::cout;
using std::endl;void handler(int signo)
{cout << signo << endl;
}
int main()
{sigset_t set;sigemptyset(&set);struct sigaction act, oact;act.sa_handler = handler;act.sa_flags = 0;act.sa_mask = set;//将3号信号的处理动作自定义为handler函数,oact保存上一次该信号的属性。sigaction(3, &act, &oact);while(1) ;return 0;
}
三.信号的处理
当进程接收到一个信号时,会在合适的时间处理,那么什么是合适的时候呢?当进程执行状态由内核态转变为用户态的时候,就会处理信号。
用户态:执行用户程序的状态叫做用户态。
内核态:执行操作系统程序的状态叫做内核态。
- 一个进程时间片到了,需要切换为内核态
- 一个进程调用系统调用,需要切换为内核态
在cpu中,有一个CR3寄存器,当值为3时,表明当前进程为用户态,当值为0时,表明为内核态。
3.1 进程地址空间
在32位系统中,进程地址空间占4GB,其中1G是内核空间,3G是用户空间。内核空间保存的是操作系统的代码和数据,当进程调用系统调用时,便会跳转到内核空间中,此时进程的状态由用户态切换为内核态。一个进程中的页表分为内核级页表和用户级页表,由于操作系统只有一份,需要保证每一个进程看到同一个操作系统,所以在内存中,只有一份内核级页表和操作系统代码,所有进程共享这个资源。
一个进程是如何被调度?
当时钟硬件发送时钟中断时,os检查当前执行进程的时间片,如果时间片到了,操作系统就会调用
schedule
函数,保存当前进程的上下文,然会切换另一个进程。
3.2 信号处理原理
当程序执行系统调用时,状态由用户态变为内核态。当执行完系统调用后,os会访问进程pcb中的三个有关信号的表:block,pending,handler,如果一个信号在pending表中并且没有被阻塞,那么os会暂时将block置1,然后去调用handler表中的自定义函数,从内核态又变为用户态,执行完这个信号处理函数后,又会调用sigreturn
函数从用户态切换为内核态,将block表中的阻塞信号置0,调用sys_sigreturn()
接口,恢复上下文,返回一开始的系统调用处。
根据上图可知,每一次调用系统,状态改变了4次。信号检测时机在交点处。
四.可重入函数
当一个函数被重复进入,没有任何问题时,该函数便是可重入函数;当出现问题时,该函数便是不可重入函数。使用了全局变量的函数大部分是不可重入函数,如输入输出函数,STL容器,库,malloc/free等都是不可重入函数。
例子如上图,当链表插入被重复进入,就会导致一个节点丢失,内存泄漏。
五.SIGCHLD
当父进程创建子进程后,父进程可以调用wait/waitpid
回收子进程,这样父进程会时刻关注子进程的状态。如果我们不想用这种方法回收子进程,也可以接收子进程退出时给父进程发送的信号SIGCHLD
,这种信号的处理动作是默认SIG_DFL
但行为是啥都不做,所以可以用信号的方式回收子进程。
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
#include <sys/wait.h>using std::cout;
using std::endl;pid_t id = 0;
void waitProcess(int signo)
{sleep(3);while (1){int n = waitpid(-1, nullptr, WNOHANG);if (n > 0){cout << id << " wait success !" << endl;}else{break;}}
}int main()
{signal(SIGCHLD, waitProcess);for (int i = 0; i < 10; ++i){id = fork();if (id == 0){// 子进程sleep(5);exit(1);}}sleep(10);return 0;
}
- 除了上面这种方法,在Linux中,还可以使用
signal(SIGCHLD, SIG_IGN)
表明父进程不想回收子进程,子进程可以直接退出。
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
#include <sys/wait.h>int main()
{//特殊组合,操作系统识别到这种组合会直接退出子进程。signal(SIGCHLD, SIG_IGN);for (int i = 0; i < 10; ++i){id = fork();if (id == 0){// 子进程sleep(5);exit(1);}}sleep(10);return 0;
}