前面的文章中我们讲述了进程间通信的部分内容,在本文中我们继续来学习进程信号相关的知识点。
信号入门
生活角度的信号
在我们的日常生活中,就有着各种各样的信号,它会给我们传递各种各样的信息,可以知道的是一个信号例如:红绿灯。在我们没有收到红绿灯这种信号的时候,就已经知道了信号的具体的内容,知道这种信号应该被怎样处理。其次,当收到信号的时候,不一定能够立马处理这个信号,因此进程还需要有记录信号的能力。
技术应用角度的信号
在我们以往的Linux编程经历中,我们在shell下启动一个进程 ,当按下 Ctrl+c 的时候键盘输入就会产生一个硬件中断,被OS获取,解释成信号,并将这个信号发送给对应的进程,从而引起进程的退出。
使用kill-l命令可以查看系统定义的信号列表
每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到。可以发现没有32、33这两个信号。编号34以上的是实时信号,在我们的文章中只讨论编号34以下的信号即普通信号,不讨论实时信号。
关于信号有这样的几点需要我们注意:
- 程序员在设计进程的时候,早就已经设计了对信号的识别能力,因此进程在没有收到一个信号的时候就知道一个信号该怎样的处理;
- 从信号的产生到信号的处理有着一个时间窗口,在进程收到信号的时候如果没有立马处理这个信号,需要进程拥有记录信号的能力;
- 信号的产生对于进程来讲是异步的;
- 由于在这里我们使用的信号只有31个,需要描述一个信号并且使用一种技数据结构管理这个信号,因此在PCB中必定要存在一个位图结构来记录信号;
- 所谓的发送信号,本质就是写入信号直接修改特定进程的信号位中的特定比特位0->1,比特位的位置表示信号的编号,比特位的内容表示是否收到该信号;
- task_struct数据内核结构,只能由OS进行修改,无论后面我们有多少种信号的产生方式,最终都必须让OS来玩成最后的发送过程;
- 信号产生之后不是立即处理的,实在合适的时候处理的。
信号处理的常见方式
一个信号的处理动作有以下三种:
- 忽略此信号。
- 执行该信号的默认处理动作。
- 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号
前台进程/后台进程
我们一般使用 ./运行我们的程序 这个程序一般是前台进程,我们屏幕上会一直打印数据,当我恩输入指令的时候是没有反应的。当我们在 ./运行程序的时候再后面添加上一个 & ,这样该程序就变为了后台进程,它也会一直向屏幕上输出但是我们同样可以输出指令。此时需要我们使用kill指令发送了一个信号才能删除该后台进程。那么Ctrl-c和kill-9都可以发送信号,怎样证明Ctrl-c发送的是一个信号呢?这里需要我们来认识一个函数
signal
这个函数的第一个参数就是信号的编号,第二个参数未来哪一个进程调用了该函数,该函数就会将收到信号的编号对应处理的默认动作改为第二个参数中对应的方法。
下面来简要的举一个例子(ctrl-c对应的就是编号为2的信号 ):
void handler(int signo)
{
std::cout << "get a singal:" << signo << std::endl;
}
int main()
{
signal(2, handler);
while (true)
{
std::cout << "我是一个进程,我正在运行 ..., pid:" << getpid() << std::endl;
sleep(1);
}
}
当我们运行程序就会看到程序从原来的终止变为执行我们自定义的方法。当我们使用 kill -2 指令的时候同样可以看到程序并没有终止,而是同样执行我们自定义的方法,因此可以确定ctrl-c对应的是2号信号。
- signal 可以对指定的信号设置自定义处理动作;
- signal(2,handler)调用完这个函数的时候,handler方法调用了吗?没有,做了什么,只是更改了2号信号的处理动作,并没有调用该方法;
- 那么handler方法,什么时候被调用?当2号信号产生的时候。
- signo: 当特定的信号被发送给当前进程的时候,执行handler方法的时候,要自动填充对应的信号给handler方法。
下面的图就是信号对应的默认动作:
下面我们进行一个大胆的尝试,如果我们把所有的信号都替换为自定义动作是否就无法进行退出了?可以发现例如2号信号,3号信号都无法退出,但是使用kill -9 还是可以退出。
产生信号
通过终端按键产生信号
例如上文中所述的 ctrl-c 表示2号信号,ctrl-\ 表示3号信号。
调用系统函数产生信号
kill
可以使用kill函数来产生信号。这里我们
想编写一个函数使用这样的方式 ./mykill 9 1234 来进行信号的产生。
void Usage(std::string) // 如果输入的参数个数不正确就进行提示
{
std::cout << "Usage: \n\t";
std::cout << "信号编号 目标进程\n" << std::endl;
}
int main(int argc, char** argv)
{
if (argc != 3)
{
Usage(argv[0]);
exit(1);
}
int signo = atoi(argv[1]);
int target_id = atoi(argv[2]);
int n = kill(target_id, signo);
if (n != 0)
{
std::cerr << errno << " : " << strerror(errno) << std::endl;
exit(2);
}
}
raise
raise函数式是谁调用该函数就给该函数发送对应的信号。
void myhandler(int signo)
{
std::cout << "get a signal: " << signo << std::endl;
}
int main(int argc, char** argv)
{
while (true)
{
signal(SIGINT, myhandler);
sleep(1);
raise(2);
}
}
通过上面我们学习的signal函数就可以对这个函数发送的指令进行捕捉,可以发现这个程序在持续运行,需要我们手动进行退出:
abort
让一个进程直接终止;
int main(int argc, char** argv)
{
while (true)
{
signal(SIGABRT, myhandler);
std::cout << "begin" << std::endl;
abort();
std::cout << "end" << std::endl;
}
}
这里可以发现while循环没有继续进行下去,而是直接终止。
由软件条件产生的信号
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动作是终止当前进程。
下面我们看一个简单的程序,我们想要知道1s计算机的计算能力:
void myhandler(int signo)
{
std::cout << "get a signal: " << signo << std::endl;
std::cout << count << std::endl;
exit(1);
}
int main(int argc, char** argv)
{
// IO的效率其实非常底下
signal(SIGALRM, myhandler);
alarm(1);
while (true)
{
count++;
// 打印,显示器打印,网络,IO
// std::cout << count++ << std::endl; // 1s我们的计算机会将一个整数累计到多少
}
}
使用IO,打印等操作:
直接++:
这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。打个比方,某人要小睡一觉,设定闹钟为30分钟之后响,20分钟后被人吵醒了,还想多睡一会儿,于是重新设定闹钟为15分钟之后响,“以前设定的闹钟时间还余下的时间”就是10分钟。如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。
硬件异常产生信号
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,,MMU会产生异常,内核将这个异常解释SIGSEGV信号发送给进程。
下面我们就来看一下一些简单的例子:
除零错误
我们知道在程序中当除数为零时,系统就会报错退出,在这里我们简单的编写一个除数为零的例子,可以看出当我们运行该程序的时候就会发生报错。那么为什么除零就会异常报错呢?这是因为运行程序的时候CPU会将代码加载到内部进行处理,那么除零的这句代码同样会发送到CPU中,当实际在进行运算的时候CPU内部还会有状态寄存器,内部有比特位来记录当前计算的状态。除零的本质是计算机在除零的时候会发生溢出,那么状态寄存器中的相关位置就会记录相关的溢出信息。然后在一定的时间切片内CPU就会发现出现了问题并且会想操作系统进行告知,操作系统一旦发现溢出就会向我们的进程发送浮点数异常信号,将进程终止。从信号列表中可以发现是8号信号出现了异常。除零的本质就是触发硬件异常。
经过我们之前学习的signal函数就可以自定义信号的处理动作,因此确实可以看到是8号信号
野指针问题
int* p = nullptr;
// p = 100; // 这里将100当做地址写给p,但是编译器可能会报错
*p = 100; // 会发生段错误,这里会向0号地址进行写入,但是我们并没有申请零号地址空间,因此会发生错误(野指针问题)
cout << "野指针问题 ... " << endl;
之前的文章中讲述过虚拟地址空间的问题,我们定义的变量都存在虚拟地址处,然后通过页表以及MMU硬件映射到物理地址中,在页表中除了对应的映射关系,还有着读写权限。当*p = 100这条指令运行时,第一步,并不是写入,而是首先进行虚拟地址到物理地址的转换 (没有映射,MMU硬件报错,有映射但是没有权限,MMU直接报错) 。操作系统发现硬件产生错误之后就会向进程发送对应的错误信号,将进程终止。
core dump以及Term、Core
当我们再次查看信号的默认动作时,可以发现,大部分的信号都是终止操作,但是终止操作也分为了两种,一种叫Term,另一种叫Core。
之前我们在学习进程控制的时候讲述过可以使用waitpid函数,通过获得输出型参数status来获取进程退出的信息,其中又一个coredump当时并没有详细的记性说明,下面我们就来进行具体的说明
Linux系统级别提供了一种能力,可以让一个进程在异常的时候,OS可以在该进程异常的时候,将核心代码部分进行核心存储 -- 将内存中进程的相关数据全部存储到磁盘中,一般会在当前进程的运行目录下,形成core.pid这样的二进制文件(核心转储文件)。使用ulimit -a 指令就能够进行查看。
core file size就是核心转储文件,然后我们可以使用ulimit -c 指令对其设置大小。
然后我们使用while循环运行一个程序,让其接受我们自己发出的终止信号来模拟发生了异常。首先发送2号信号,可以发现进程正常的终止了,但是在目录下并没有生成对应的核心转储文件。接着我们使用3号信号,此时可以发现在目录下生成了对应的核心转储文件,并且进程退出时也会有coredump对应的标识符。
同时,可以发现对应进程的默认指令一个是Term,一个是Core。Term终止的就是终止没有多余的动作,Core终止会先进行核心转储,再终止进程。
核心转储有什么用?方便异常后进行调试。我们将程序设置为debug模式,然后运行gdb通过加载对应的core文件就能够获得报错相关的位置信息,这样我们就可以不用自己定位问题的位置,由gdb自动定位。(事后调试)
core dump 就是标志着是否开启了核心转储。