linux信号 | 学习信号三步走 | 全解析信号的产生方式
前言:本节内容是信号, 主要讲解的是信号的产生。信号的产生是我们学习信号的第二个阶段。 我们已经学习过第一个阶段——信号的概念与预备知识(没有学过的友友可以查看我的前一篇文章)。 以及我们还没有学习信号的第三个阶段——信号的保存与处理。
ps:本节内容主要为信号的产生, 需要友友们了解信号的概念以及信号的预备知识后再进行学习。
信号的产生有很多种, 但是无论信号如何产生, 最终一定是由 OS 发出的。 ——为什么? 因为OS是进程的管理者。 就比如弟弟惹到了姐姐, 然后姐姐向爸爸告状, 爸爸收到姐姐的告状后就会去找弟弟, 然后教育一顿。 这里面爸爸就是执行者OS, 然后弟弟就是被管理者, 姐姐就是发送的那个信号。
目录
键盘组合键
kill命令
系统调用
kill 系统调用
接口
应用
raise——发送一个信号给调用者
接口
应用
abort
接口
应用
异常
异常:为什么除零, 或者野指针会给进程发送信号呢?
除零
野指针
软件条件
alarm
dump
键盘组合键
产生信号的第一种方法是键盘组合键, 信号组合健种最常见ctrl + C, 我们在运行一个程序,如果程序陷入死循环, 我们这个时候无论输入任何指令都是没有用的。 所以就要使用ctrl + C将程序退出。 这里我们点击ctrl + C, 本质其实就是给进程发送了一个信号。 这个信号是2号信号。
同样的, 键盘组合键还有另外一种比较常见的组合键就是ctrl + \。 同样也是给进程发送信号, 不过信号序号是3号信号。 当进程识别到这些信号, 就会终止, 退出自己。
这里有一个函数, 用来捕捉我们的信号, 叫做signal
这里面有两个参数, 第一个参数是signum,意思是要捕捉的信号编号。 第二个参数是handler, 意思是捕捉到信号后,重新定义的信号的处理方式。
如下是一个2号信号的捕捉操作:
kill命令
kill 命令可以用来终止正在运行的进程。kill命令通过发送不同的信号给目标进程, 如我们经常使用的kill -9。 kill命令的使用方法是kill 命令序号 PID。
这里博主利用信号的捕捉来验证我们的kill命令。(注意, 接下来实验过程中我们可以发现signal并不能捕捉9号和19号命令), 下面是代码:
#include<iostream>
using namespace std;
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<signal.h>
void myhander(int x)
{
cout << "signal is " << x << endl;
}
int main()
{
for (int i = 1; i <= 31; i++)
{
signal(i, myhander);
}
while (true)
{
cout << "hello signal , PID : " << getpid() << endl;
sleep(1);
}
return 0;
}
接下来我们就完成编译。 并且打开两个终端, 一个终端用来发送信号即可:
博主上面对于1 ~ 31个信号没有全部进行测试, 但是友友们可以全部测试一下。 然后就能发现, 除了9号和19号, 其他的信号都可以被捕捉。 并且也验证了我们的kill命令可以给进程发送信号。——那么, 为什么9号和19号信号要暴露出来, 不能被捕捉。 是因为这两个信号, 一个是杀掉进程, 一个是暂停进程。 如果我们今天想写一个恶意病毒, 恶意软件。那么我们把9号、19号信号一捕捉, 操作系统就杀不掉这个病毒了, 那怎么办? 所以, 必须要将9号和19号暴露出来。
系统调用
kill 系统调用
接口
kill系统调用是发送一个信号给进程。这里的第一个参数进程pid, 意思是发给哪一个进程。第二个参数是sig, 表示发送哪一个信号。
kill调用的返回值是如果成功, 零被返回。 如果失败, -1被返回。
应用
这里我们定义一个proc程序, 用来循环打印一条语句:
#include<iostream> using namespace std; #include<unistd.h> #include<sys/types.h> #include<signal.h> void myhander(int signum) { cout << "signal is : " << signum << endl; } int main() { for (int i = 1; i <= 31; i++) { signal(i, myhander); } while (true) { cout << "i am a process, pid : " << getpid() << endl; sleep(1); } return 0; }
这里之所以要signal捕捉信号是因为要检测当前进程是否收到了信号, 正确的信号。 并且这个语句执行后, 会循环打印进程自己当前的pid。
然后, 我们创建另一个程序, mykill, 用来封装kill系统调用。
#include<iostream> using namespace std; #include<sys/types.h> #include<signal.h> #include<unistd.h> #include<cstring> int main(int argc, char* argv[]) { if (argc == 3) { kill(stoi(argv[0]), stoi(argv[1])); } else { cout << "please set mykill.exe + pid + sig" << endl; exit(1); } return 0; }
运行结果:
raise——发送一个信号给调用者
接口
这里面的参数只有一个sig, 意思是要给当前进程发送哪一个信号。 返回值int, 当成功时返回零, 失败时返回非零。
应用
#include<iostream>
using namespace std;
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<signal.h>
void myhander(int x)
{
cout << "signal is " << x << endl;
}
int main()
{
signal(2, myhander);
int cnt = 5;
while (true)
{
cout << "hello signal , PID : " << getpid() << endl;
sleep(1);
cnt--;
if (cnt == 0) raise(2);
}
return 0;
}
运行结果(然后我们运行起来可以发现, 5秒之后, 确实给我们发送了一个2号信号。
abort
接口
这一个调用是引起一个正常的进程进行终止。
应用
运行结果:
我们运行这个程序, 就会发现, 这个进程5秒之后, abort了。 这个abort其实就是6号信号, 所以我们这里可以直接捕捉他。
但是问题是, 我们捕捉了, 但是也退出了!!注意, 这个退出不是信号的问题, 这个是abort的问题。 abort不仅仅捕捉了我们的6号信号, 而且还做了一个工作——必须让我们这个进程终止。 这个abort在底层其实也相当于是有一个kill(getpid(), 6)。 但是还多做了一些工作, 比如exit之类的。
异常
异常博主认为是最重要的信号的产生方式。 因为我们在运行程序的时候, 遇到的信号, 基本都是由异常引起的。 我们的野指针发送信号, 我们的除零错误要发送信号。
我们平时运行程序,退出的方式无非就是三种——代码跑完, 结果正确; 代码跑完,结果不正确; 代码没跑完, 出现异常。
这里我们先写一段代码, 看一下异常:
上面的运行结果就能看出发生了异常, 直接报错。 但是问题是, 上面的报错是31个信号的哪一种呢? ——这里其实是八号信号, 如下图:
我们可以使用自定义捕捉这个异常:
然后运行, 就能看到下图的情况:
这里我们会发现, 虽然我们知道了这个错误是八号信号。 但是问题是他怎么一直在死循环打印呢? 我们知道, 我们的捕捉函数里面, 没有退出进程, 只有一条语句, 那么也就是说, 我们的程序发生除零错误不会退出, 进程一直再跑, 所以OS一直发送信号, 进程也就一直都在打印信号。
我们换成下面的代码:
void myhander(int signum)
{
cout << "get a signum : " << signum << endl;
}
int mian()
{
signal(SIGFPE, myhander);
cout << "pointer error before " << endl;
sleep(1);
int* p = nullptr;
*p = 100;
sleep(1);
cout << "pointer error after " << endl;
return 0;
}
这个进程我们会发现, 两秒之后也退出了!并且发生了段错误。 段错误在信号中是11信号,SIGSEGV, 如何验证和上面的八号信号的验证方式是一样的。这里直接贴运行结果。 同样会死循环打印语句, 不发生退出。 ——因为11号信号被捕捉。
上面捕捉信号也是在处理异常信号。 那么问题来了, 处理异常信号, 一定会退出吗? 答案是不一定, 一般情况下, 如果是默认动作, 异常会推出。 但是如果是自定义动作, 也就是信号被捕捉, 这个时候异常不一定会推出。 但是一旦异常推出, 一定是执行了某个信号的处理方法。
但是我们虽然可以捕捉信号, 不让它退出,只不过这个意义不大, 因为我们的进程已经发生了错误了。那么我们大概率还是要让这个异常终止。
异常:为什么除零, 或者野指针会给进程发送信号呢?
除零
我们上面说过, 进程接收到信号, 一定是OS发出的, 一旦我们的计算机里面出现了除零错误或者野指针问题, 操作系统又识别到了这些问题, 那么操作系统就会给我们的进程发送信号。 收到信号后我们的进程对于信号的默认处理动作就是终止自己, 所以它就直接崩溃了。 但是这里的问题不是操作系统会检测到除零或者野指针,而是如何检测到除零或者野指针:
首先, 我们知道cpu中有着许许多多的寄存器。 其中, EIP、PC指针指向我们的进程的上下文。
其中, 状态寄存器有标记位, 是把寄存器按照比特位级别设计的。 状态寄存器里面的位数各自代表什么含义是由芯片制造商定好的。
状态寄存器里面有一个标记位, 叫做一处标记位。 当我们除零的时候, 结果就会非常大。 那么就溢出了。 这个标记为就从0变成1了。
而且,要知道, 我们cpu中的数据, 其实都是进程的上下文。 虽然我们修改的是cpu中的状态寄存器。 但是进程只影响他自己。 ——什么意思? 就是说,我们的进程是不断切换的, 所以寄存器里面的数据也是不断切换的。 当我们的进程切换的时候, 会把我们自己的上下文带走, 下一个进程将自己的上下文放上去。 所以进程之间是不会有影响的。 所以, 不要认为cpu发生了异常, 就说明操作系统出问题了。 因为我们的用户的各种行为, 都是被进程包裹的, 硬件异常是代表这个进程出现了异常, 并不会波及我们的操作系统。 也就是说, 引起出错的永远是进程, cpu出错, 我们的系统照跑不误。
那么问题来了, 我们的cpu这个时候发生溢出了, 我们的操作系统要不要知道呢? 为什么呢? ——必须要知道!操作系统必须得知道!因为操作系统是硬件的管理者!!!cpu也是硬件!!操作系统在运行我们的进程的时候, 会有类似检查或者中断的方法, 得知我们的cpu出现溢出了, 然后操作系统向进程发送信号, 进程收到信号后, 就崩溃了。 ——简而言之就是我们这个除零问题被转化为了硬件问题, 表现在硬件上面, 进而被操作系统识别到。 被操作系统识别, 操作系统就能对信息做处理。 操作系统的处理并不影响整个系统的稳定性, 影响的是当前进程, 因为cpu内的问题, 是属于进程的问题。
野指针
上面的进程我们的进程在通过页表进行查表的时候, 这个查表不是操作系统直接来查的, 因为查表是很费时间的。 ——这个查表是由一个MMU, 内存的管理单元来完成的。
在上面的图中, 我们的cpu读到的都是虚拟地址。 从虚拟地址到物理地址, 需要经过MMU的转化, 而野指针就是这个转化失败了——要么没有映射关系, 要么越界了, 越权了。 转化失败, MMU报错, 代表里面的硬件发生了报错, 转化失败后的地址会放到另一个寄存器里面, 这个报错, 也能被cpu识别到。
那么, 操作系统是怎么知道, 我们的cpu是溢出了, 还是野指针了呢? ——因为对应的是cpu内不同类型的寄存器的报错!!!
那么既然报错后, 操作系统会发送信号让进程崩溃, 但是如果我们将信号捕捉, 不让进程崩溃呢? ——那么就意味着我们的进程在异常的时候也要一直被调度运行!!!所以为什么我们上面会看到死循环呢? 就是因为我们的进程发生了问题, 但是我们没有修正这个问题, 硬件问题一直存在, 随着我们的调度, 那么我们的上下文的错误就一直存在。 所以操作系统就一直检测到这个错误, 它就会一直发信号, 我们就能一直捕捉这个信号。 !!!
对于异常的进程来讲, 即便向后运行, 结果可能也是错误的, 所以进程发生异常, 就应该是终止掉, 所以大部分信号的默认动作都是终止掉进程。 ——但是为什么会有捕捉信号呢? 因为捕捉信号, 并不是用来解决问题, 而是用来告诉用户, 你是为什么挂掉的!!
那么, 为什么操作系统这么温和? 当硬件报错的时候, 不直接将进程的pcb, 页表, 地址空间全部干掉呢? ——这是因为进程里面有可能保存着许多的重要的数据, 如果直接将这些东西干掉,这些数据会丢失, 所以要设置信号, 告诉上层哪里出错了, 为什么出错了, 然后让上层自己想办法解决。
软件条件
alarm
上面我们讲述的异常也可以成为软件异常。现在有另外一种, 叫做软件条件。 ——这个软件条件叫做闹钟。
alarm是在我们的系统里面设置一个闹钟, 一旦闹钟响了, 那么就会给我们的进程传送一个信号。 这个参数就是多少秒之后会给我们发送这个信号。下面是这个接口的返回值:
这个返回值是提前醒来, 距离闹钟响之间剩余的时间。
下面我们写这么一串代码:
然后运行它
我们会发现, 虽然闹钟到达事件后会发送信号, 但是仅仅只是发送一次!这是因为这个闹钟不是异常, 它只响一次。 那么问题来了, 当我们重复设置闹钟的时候, 加入设定了一个5秒的闹钟, 但是过了三秒又设置了一个闹钟, 这个时候会发生什么情况呢? ——答案是第一个闹钟剩余的时间会返回。
为了验证, 我们可以使用下面这串代码进行测试。
按照上面的结论, 我们猜测最终打印出来的是6:
我们的闹钟是如何确定时间的呢? ——使用时间戳和参数, 我们的进程, 我们知道进程可以直接获取时间, 我们的闹钟里面一定有一个时间戳的成员变量的。 未来我们进程获取时间戳变量, 填到闹钟里, 闹钟再根据参数, 两者一加, 就是未来时间了!!!
那么知道了未来时间, 我们操作系统里面又维护着当前时间, 当这个时间 >= 未来时间的时候, 就能知道闹钟到没到时间了。
那么我们未来系统中一定存在着大量的闹钟, 我们如果想要确定哪个闹钟响过了, 就把他去掉。 可以使用什么数据结构呢? ——堆
dump
我们在进程控制的时候, 曾经说过wait的返回值status, 这个返回值当中的前八个比特位是代表的退出码, 第0 ~ 7是代表的终止信号, 第8个比特位我们说过叫做core dump标志。
这个core dump是什么当时我们并没有说过, 这里可以说了。 我们看下面的图:里面有term, 有core。 还有其他的动作, 但是其他的动作我们不考虑, 我们只看core和term。 这个dump比特位, 就是代表的是core或者term。为零是term, 为1是core。
现在我们来重新写一下代码, 看一下我们的dump比特位:
现在我们来看一下:
对于2号信号:
对于8号信号:
两次都是dump0, 但是我们的8是core, 2是term。 这是为什么呢? 是什么呢 ? 怎么办? ——这是因为云服务器上面的core默认是关闭的。
我们可以看一下ulimit -a, 这个是查看系统当中的标准的配置。
这里面有一个core file size的选项, -c就是用来查看。 当前它是设为0的。 我们可以使用 -c选项后边加一个数字, 可以用来设置core。
这一次, 我们再来使用8号信号。 就能看到结果变了。
更重要的是, 这里生成了一个core.4017, 也就是刚刚进程的PID——打开系统的core dump功能, 一旦进程出现异常, 我们的OS会将进程在内存中的运行信息, 给我们dump(转储)到进程的当前目录(磁盘)形成core.pid文件——这个就叫做核心转储(core dump)
上面的core.pid有什么用? ——我们的程序在运行时发生错误, 我们最想要知道的是什么呢? ——是不是定位错误的位置, 也就是在哪一行出错了。 我们可以使用core dump数据, 来定位原始代码当中, 在运行过程中哪方面出错了。
我们使用gdb调试左边的代码, 然后进入core.pid文件里面, 就能看到错误信息了。
core文件能够让我们复现问题之后, 直接定位到出错行。 就是先运行, 再core-file。 ——这个叫做事后调试。 而我们边跑边调叫做事中调试。
现在的问题是, 为什么云服务器是要把它进行关闭的。 我们知道core文件比较大。 在当代服务器上面, 如果一个服务器挂掉了, 是要让运维重新起来的。 可是大公司的后端服务器集群非常多。 如果运维手动去起, 那么太慢了。 所以大公司要做很多很多自动化运维的手段, 比如说我们的服务器挂掉了, 那么我们要自动的检测服务器出问题了, 第二点就是先将服务器启动起来, 最后才是根据日志排查问题。
所以, 一旦服务器挂掉了, 在很多系统当中会自动重启。 可是, 问题是一般人一年才挂一次, 半年挂一次, 结果呢你写的服务只要跑起来就挂。 虽然在大部分公司, 这样的程序员根本不会有, 但是可能会出现这样的问题。 要知道, 我们的计算机的速度是很快的, 我们的计算器一晚上的重启, 如果每一次重启都会生成一个core-pid文件, 那么就会导致本来是云服务器上面的一个服务挂掉了, 结果后来变成了磁盘空间被core-pid文件打满了。 那么就可能连操作系统都挂掉了。 所以, core dump的功能在线上一般都要禁掉, 保证我们的系统重启功能一直都要有效。 不要让core dump冲击磁盘, 影响服务。
——————以上就是本节全部内容哦, 如果对友友们有帮助的话可以关注博主, 方便学习更多知识哦!!!