一.信号基础
生活中
在生活中也有诸多信号,这些信号通常不是由我们发起的,而是我们接收以后对对应的信号做处理;最常见的莫过于红绿灯了,当红绿灯发出信号时(红灯,绿灯,黄灯);我们会有对应的行为,比如绿灯我们知道当前可以行走,红灯的时候我们需要等一等。对信号产生以后我们知道该做什么,这是因为我们曾经接受了对于这些信号的教育,知道当这些信号产生以后我们需要做什么。
技术上
信号并不是由某个进程发起的,而是操作系统发给某个进程的,一个进程异常退出,必定收到了操作系统的信号。使用kill -l
可以查看全部的信号
其中1-31为普通信号,34-64被称为实时信号 进程PCB中有一个位图结构用于标明该进程是否收到信号(32个比特位使用0/1来区分是否收到信号,0代表没收到),这也就是说发送信号时需要修改进程PCB,而修改PCB的需要只有操作系统有权限。
进程对于信号的处理有三种:1.默认,2.忽略,3.自定义;
但并不是进程一收到信号就马上处理,因为信号是随时产生的(异步),可能当信号来临时进程正在处理着更重要的事情,进程对信号的处理会在合适的时机(内核态返回用户态时);因为不是马上处理的,所以进程要对信号有保存能力
使用man 7 signal
可以查看信号的默认处理行为
Term
代表是正常退出;Core
代表异常退出,可以开启核心转储功能提供错误定位(后文中会讲)lgn
代表内核级忽略
可以看到大部分信号的最终处理都是一样的(退出当前进程),系统设置这些信号主要是为了知道导致进程退出的原因是什么。
二.信号的产生
1.使用键盘组合键发送信号(只能给当前正在运行的进程发)
我们可以使用键盘组合键向进程发送信号,比如之前常用的ctrl c
其实是给进程发送二号信号
#include<iostream>
#include<cstdlib>
#include<unistd.h>
#include<signal.h>
using namespace std;
void handler(int signo)
{
cout<<"捕捉到了信号,编号为:"<<signo<<endl;
exit(1);
}
int main()
{
signal(2,handler);
while(true)
{
cout<<"I am a process,my pid is:"<<getpid()<<endl;
sleep(1);
}
return 0;
}
信号捕捉
上述代码中的signal
是一个系统调用,用来捕捉信号,给信号设置自定义处理方式的;它的第一个参数是你要捕捉的信号编号,第二个参数是一个函数指针,代表你要自定义的方法。
在上述代码中,虽然我对2号信号做了捕捉但是我在自定义方法中仍然选择让进程退出了,如果你的自定义方法中不让该进程退出,那么进程收到该信号后就不会再终止
将上述代码改成下面这样,无论是使用ctrl c
还是使用kill -2
都无法让该进程终止
#include<iostream>
#include<cstdlib>
#include<unistd.h>
#include<signal.h>
using namespace std;
void handler(int signo)
{
cout<<"捕捉到了信号,编号为:"<<signo<<endl;
//exit(1);
}
int main()
{
signal(2,handler);
while(true)
{
cout<<"I am a process,my pid is:"<<getpid()<<endl;
sleep(1);
}
return 0;
}
那如果我们使用signal
将所有的信号都捕捉起来,是否代表该进程无法再被杀死了呢?
#include<iostream>
#include<cstdlib>
#include<unistd.h>
#include<signal.h>
using namespace std;
void handler(int signo)
{
cout<<"捕捉到了信号,编号为:"<<signo<<endl;
//exit(1);
}
int main()
{
for(int signo=1;signo<=31;signo )
{
signal(signo,handler);
}
while(true)
{
cout<<"I am a process,my pid is:"<<getpid()<<endl;
sleep(1);
}
return 0;
}
看起来该进程似乎已经无法被信号杀死了,那岂不是说明只要一个进程把所有的信号都捕捉起来,那这个进程就可以在系统中肆意妄为?
操作系统不会允许某个进程将所有的信号都捕捉,至少kill -9
信号是无法被捕捉的,因为操作系统不相信任何人,它必须要留一手来保护自身的安全
2.使用kill指令(可以向任意进程发送信号)
kill
指令我们已经不是第一次使用了,只要有某个进程的pid,那么就可以通过kill向该进程发送信号,终止进程,kill指令其实是通过kill()
系统调用实现的,这里就模拟实现以下kill
mysignal.cc
代码语言:javascript复制#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
#include <string>
void Usage(const std::string& proc)
{
std::cout<<"Usige:"<<getpid()<< "Signnon"<<std::endl;
}
int main(int argc,char* argv[])//运行main函数时,需要先进行传参
{
if(argc!=3)//如果传入main函数的参数个数不为3
{
Usage(argv[0]);
exit(1);
}
pid_t pid=atoi(argv[1]);//获取第一个命令行参数,作为pid
int signo=atoi(argv[2]);//获取第二个命令行参数,作为signo
int n=kill(pid,signo);//需要发送信号的进程/发送几号信号
if(n==-1)//kill()失败返回-1
{
perror("kill");
}
while(1)
{
std::cout<<getpid()<<std::endl;
sleep(1);
}
return 0;
}
test.cc
代码语言:javascript复制#include <iostream>
#include <unistd.h>
#include <sys/types.h>
int main()
{
while(1)
{
std::cout<<"这是一个正在运行的进程"<<getpid()<<std::endl;
sleep(1);
}
return 0;
}
3.使用raise()让进程自己给自己发送信号
代码语言:javascript复制#include <iostream>
#include <cstdio>
#include<cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
#include <string>
//当计数器运行到5时,进程会因3号进程退出
int main(int argc,char* argv[])//运行main函数时,需要先进行传参
{
int cnt=0;
while(cnt<=10)
{
std::cout<<cnt <<std::endl;
sleep(1);
if(cnt>=5)
{
raise(3);
}
}
return 0;
}
raise(signo)
其实等价于kill(getpid(),signo)
,此外虽然raise看起来是进程自己给发送信号,但其实还是操作系统发的,因为发送信号本身就是操作系统对进程所做的。
此外还有一个
abort()
进程自己给自己发送六号信号
4.硬件异常产生信号
硬件异常产生信号通常是因为软件问题造成的,操作系统通过CPU中的状态寄存器的得知对应硬件的状态,即可向对应进程发送指定的信号。
a.除零引发的异常(SIGRFPE)
代码语言:javascript复制#include <iostream>
#include <cstdio>
#include<cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
#include <string>
using namespace std;
void handler(int signo)
{
cout<<"捕捉到信号,编号为:"<<signo<<endl;
sleep(1);
}
int main()
{
signal(8,handler);
int a=3/0;
while(true)
{
cout<<"当前进程正在运行ing"<<endl;
}
return 0;
}
可以看到操作系统一直在给进程发8号信号,可是操作系统是如何得知我有除零错误的呢?
当一个进程被加载进CPU中,才表明该进程正在运行,而CPU中有一套寄存器用于存放进程的上下文,其实除了存放进程上下文的寄存器外,还有寄存器存放了进程PCB的起始地址(这就是为什么CPU可以得知当前正在运行的进程是哪个),以及进程的页表地址,并且CPU中集成了MMU单元,因此将进程的虚拟地址空间转换为物理地址在CPU中就能够完成。
但操作系统之所以能得知当前进程是否有除零错误是因为有一个状态寄存器的存在,状态寄存器中有一个溢出标志位该标志位默认是零(代表正常无溢出),但除零就是除一个无限小的数,得到的结果会无限大所以就会发生溢出,状态寄存器的溢出标志位被置1,操作系统识别到了该行为就给进程发送8号信号(操作系统能识别到该行为,是因为当进程被切换时寄存器的数据也要被替换,因此状态寄存器要被恢复一次,在恢复的时候操作系统就能识别到状态寄存器的信息)。
b.段错误引发的异常(SIGSEVG)
CPU中集成了MMU单元,该单元是实现页表虚拟地址到物理地址之间的转换;一旦你尝试越界访问或者有野指针的问题,能被MMU识别到,然后MMU就会给进程发送信号来终止进程
5.软件问题导致的异常
a.匿名管道的读端关闭,写端还尝试写,操作系统会向写端发送13号SIGPIPE终止写端
b.14号SIGALRM定时器信号
代码语言:javascript复制当设定的时间到达时,操作系统向进程发送14号信号终止进程
int cnt=0;
void handler(int signo)
{
cout<<"捕捉到信号,编号为:"<<signo <<"当前进程跑的时间为:"<<cnt <<endl;
//sleep(1);
}
int main()
{
signal(14,handler);
alarm(1);
while(1)
{
cnt ;
}
return 0;
}
这就表明在1秒钟内该while循环被执行了500935048次
任何进程都可以使用闹钟,也就是说操作系统中可能同时存在多个闹钟,因此操作系统需要将闹钟给管理起来(通过先描述再组织的办法)。
三.信号退出时的核心转储
前面提到如果一个信号是Trem
则是正常退出,如果是Core
则是异常退出,异常信息会写到核心转储中。不过大部分云服务器都是默认关闭了该功能,可以使用ulimit -a
来查看核心转储是否被打开
使用ulimit -c 大小
可以打开核心转储并设置大小
核心转储的意义就是为了方便调试,当程序异常终止的时候会产生一个文件,再用gdb会该程序调试,则会直接定位到错误
四.信号保存
因为信号不是被立马处理的,所以进程要有对信号保存的能力,这个其实是保存再PCB中的pending位图中
1.基本概念
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作
2.在内核中的表示
PCB中有两个位图和一个函数指针数组,而位图的下标就代表对应的信号,其中block位图代表的是该信号是否有被阻塞(1代表被阻塞),pending位图代表该信号是否有被递达;而handler是一个函数指针数组,该数组中存放的是函数指针,代表的是该进程对这个信号的处理方法 对于普通信号来说,pending位图中一个时间内只能存放一次同一个信号,如果该信号一直处于未递达的状态,那么即使后续发送了该信号也无法收到
五.信号的处理
因为信号保存在PCB中,但PCB中的数据只有操作系统有权限访问,因此要对信号做处理必须要通过操作系统来实现。
操作系统是一个层状结构,我们做开发也只是在用户层做开发,是没有权限要求操作系统帮我们修改内核中的数据的,这就是为什么一旦我们要访问内核中的数据或者硬件的时候,总是要调用系统调用。
其实调用系统调用之所以能让我们要求操作系统帮我们获得某些数据或者访问硬件,是因为在执行系统调用的时候,首先会执行Int 80
这样的汇编代码,陷入内核,让我们从用户态切换到内核态。
在寄存器中有一个CR3寄存器,该寄存器中存放的数据代表的是当前代码的执行权限(0代表内核态,3代表用户态),陷入内核以后操作系统首先会修改CR3的数据。
当然也不用担心陷入内核以后找不到进程的代码,因为有寄存器保存了当前正在执行进程的PCB和用户级页表地址。
再谈地址空间
在前面的博客中只谈论了0–3G的用户级地址空间,现在就再将3–4G的内核级地址空间也拿出来谈论:
1.为什么用户级页表要各自有一份?
首先不同的进程拥有不同的数据,它们代码加载到内存中获得的物理地址也就不同。其次为了保证进程的独立性,每个进程都必须要有各自独立的用户级页表
2.为什么内核级页表所用进程共享一份?
因为操作系统只有一封,被加载到内存中也是独一份,因此没有必须要让每个进程都独立维护一个内核级页表
信号处理全过程
首先因为信号导致的系统调用陷入内核,从用户态切换到内核态,通过寄存器中保存的PCB地址找到PCB,再通过PCB中保存的位图和函数指针来识别信号,如果对于某一个信号的处理方式是自定义处理,那么必须要修改CR3中的权限值,回到用户态去执行自定义方法(因为操作系统不相信任何人,无法知道handler方法中是否有恶意代码); 执行完handler方法以后还需要再回一次内核态,因为进程的上下文数据是由操作系统保存的,无法直接知道之前是从哪一行代码跳转过来的,要想回到之前跳转的代码继续往后面执行,必须要有操作系统的参与。
上述的图也可以简化成下面这样
六.信号集操作函数
1.sigset_t
每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。
因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。
代码语言:javascript复制#include <signal.h>
int sigemptyset(sigset_t *set);//将所有比特位清零,表示无有效信号
int sigfillset(sigset_t *set);//使其中所有信号的对应bit置位,表示 该信号集的有效信号包括系统支持的所有信号
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
//注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态
2.sigprocmask
调用sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)
代码语言:javascript复制#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。
如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。
如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。
假设当前的信号屏蔽字为mask,下表说明了how参数的可选值 :
如果调用sigprocmask函数解除了对某个未决信号的阻塞,那么再sigprocmask返回前,该信号可能已经被递达了,一旦信号递达,则说明该进程大概率也要被终止了
3.sigpending
代码语言:javascript复制#include <signal.h>
int sigpending(sigset_t *set);//set:输出型参数,输出当前进程pending位图
sigending()在成功时返回0,在错误时返回-1。在发生错误时,将 errno 设置。
4.用以上函数写一个代码模块
该代码实现阻塞某一个信号,将这个信号的block位图由0置1,然后接触阻塞,使该信号递达,这个进程直接寄掉
代码语言:javascript复制#include <iostream>
#include<vector>
#include <cstdio>
#include<cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
#include <string>
using namespace std;
#define MAX_SIGNUM 31
static vector<int> sigarr={2};
static void handler(int signo)
{
cout<<"信号被递达,编号为:"<<signo<<endl;
//sleep(1);
}
void show_pending(const sigset_t&pending)
{
for(int i=MAX_SIGNUM;i>=1;i--)
{
if(sigismember(&pending,i))
{
cout<<"1";
}
else
cout<<"0";
}
cout<<endl;
}
int main()
{
sigset_t pending,block,oblock;
//为特定的信号更改自定义方法
for(const auto&sig:sigarr) signal(sig,handler);
//初始化
sigemptyset(&pending);
sigemptyset(&block);
sigemptyset(&oblock);
//添加要被屏蔽的信号
for(const auto &sig :sigarr) sigaddset(&block,sig);
// 开始屏蔽,设置进内核(进程)
sigprocmask(SIG_SETMASK, &block, &oblock);
// 2. 遍历打印pengding信号集
int cnt = 10;
while(true)
{
// 初始化
sigemptyset(&pending);
// 获取
sigpending(&pending);
// 打印
show_pending(pending);
sleep(1);
if(cnt-- == 0)
{
sigprocmask(SIG_SETMASK, &oblock, &block); // 一旦对特定信号进行解除屏蔽,一般OS要至少立马递达一个信号!
cout << "恢复对信号的屏蔽,不屏蔽任何信号n";
}
}
return 0;
}
但是由于我对该信号做自定义捕捉了,所以2号信号无法终止该进程了。