目录
- 1. 键盘组合键
- 2. kill 命令
- 3. 系统调用
- 4. 异常
- 5. 软件条件
- 6. Term 和 Core 的区别
本篇文章介绍五种信号产生的方式,键盘组合键、kill 命令、系统调用、代码异常(进程异常)、软件条件来产生信号。
1. 键盘组合键
信号(一)【概念篇】 在第一篇信号的文章,我们就介绍了 Ctrl + c 的组合键,可以向进程发送 2 号信号,终止前台进程。
Crtl + \
:也是终止进程的一种键盘组合键,会向进程发送 3 号信号,与 2 号信号不同的是,3 号信号不仅会终止进程,还会产生核心转储(core dump)文件,用于调试目的。
void myhandler(int signo)
{
cout << "process get a signal: " << signo << endl;
}
int main()
{
signal(3, myhandler); // 捕捉三号信号
....
return 0;
}
在代码中调用 signal 捕捉三号信号时,当我们键盘按下 Crtl + \
,就产生了 3 号信号,因此证明 Crtl + \
就是向进程发送 3 号信号(SIGQUIT)。
Crtl + z
:暂停一个进程,向进程发送 19 号信号,等同于 kill -19 命令。
void myhandler(int signo)
{
cout << "process get a signal: " << signo << endl;
}
int main()
{
signal(19, myhandler); // 捕捉19号信号
....
return 0;
}
执行 Crtl + z
与 kill -19 向一个进程发送信号的结果是一样的,因此可以证明 Crtl + z
就是向一个进程发送 19 号信号的键盘组合键。
但与 2 号、3 号信号不同的是,不管是通过键盘产生的信号,还是 kill 命令,都没有看到信号被捕捉(因为自定义信号处理的动作没有被执行),这也就说明了,并不是所有的信号都可以被 signal 捕捉的,比如此处的 19 号信号,还有 9 号信号,这两个信号都无法被捕捉(1 ~ 31 普通信号,不讨论 34 以上的实时信号)。
操作系统为什么要这样设计呢? ---- 很显然这两个信号都跟进程的执行有关,像 2、3 号信号都只能作用与前台进程,9 号信号是掌管所有类型的进程的生死信号。如果有人捕捉了 9 号信号,再自定义信号处理,那可以做到让所有用户都杀不掉某个进程。而 19 号暂停信号,当一个进程执行的工作很重要时,并且此时有点失控了,我可以选择不杀掉它,而是暂停它,把它的影响先降到最低。如果 19 号被捕捉了,我还怎么暂停?
2. kill 命令
kill -signo pid # 向pid进程发送一个signo信号
3. 系统调用
NAME
kill - send signal to a process // 向一个进程发送指定信号
SYNOPSIS
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
RETURN VALUE
On success (at least one signal was sent), zero is returned. On error, -1 is returned, and errno is set appropriately.
// 模拟 kill 命令
void Usage(string proc)
{
cout << "Usage:\n\t" << proc << " signum pid\n\n";
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
char* sigstr = argv[1] + 1; // 去掉 -19 的 - 符号
int signum = stoi(argv[1]);
pid_t pid = stoi(argv[2]);
int n = kill(pid, signum); // 调用成功返回0,失败返回-1
if(n == -1)
{
perror("kill");
exit(1);
}
return 0;
}
NAME
raise - send a signal to the caller // 发送一个信号给调用者,即发送给自己这个进程
SYNOPSIS
#include <signal.h>
int raise(int sig);
RETURN VALUE
raise() returns 0 on success, and nonzero for failure.
int main(int argc, char* argv[])
{
signal(2, myhandler);
int cnt = 0;
while(true)
{
cout << "I am a process, pid: " << getpid() << endl;
sleep(1);
if(cnt & 1) raise(2); // 每两次发送一次 2 号信号
}
return 0;
}
raise 这个系统调用,其实是对 kill 的一个封装,raise(2)
的本质就是 kill(getpid(), 2)
。
NAME
abort - cause abnormal process termination // 使一个正常运行的进程终止,即 6 号信号(SIGABRT)
SYNOPSIS
#include <stdlib.h>
void abort(void);
RETURN VALUE
The abort() function never returns.
int main(int argc, char* argv[])
{
signal(SIGABRT, myhandler);
int cnt = 0;
while(true)
{
cout << "I am a process, pid: " << getpid() << endl;
sleep(1);
if(cnt & 1) abort();
++cnt;
}
}
这里有点奇怪了。在 信号(一)【概念篇】 介绍信号的三种处理方式时,不是讲的,只能三选一吗??不是说有了自定义信号处理,就不会触发信号的默认处理动作了吗??那为什么我向进程发送了一个 6 号信号,也调用 signal 注册捕捉了,但为什么还是触发了 6 号信号的默认动作??
这就是 abort() 这个函数内部做的行为了,在对信号处理这件事上,不同的函数内部可能都会对信号处理做一些特别的处理,当我们不调用 abort(),转而通过 kill 命令向进程发送 6 号信号,是不会同时执行自定义动作和默认动作的,因此可以确定这是 abort() 内部做的工作了。
- 以上这些都是信号产生的方式,但无论哪种方式产生的信号,最终都是操作系统将信号发送个进程的。因为进程是操作系统的一部分,因此对于经常的一切管理,只能由操作系统来完成。
4. 异常
int main()
{
int a = 10;
a /= 0;
return 0;
}
在介绍 进程的创建、终止 的时候,我们曾经说过,一个进程异常终止的本质是收到了信号。诸如上述代码发生的除0错误,而导致的进程异常退出,本质就是收到了 8 号信号,8 号信号的默认信号处理动作即终止进程,所以我们的进程才会退出。
void myhandler(int signo)
{
cout << "process get a signal: " << signo << endl;
}
int main()
{
signal(8, myhandler);
int a = 10;
a /= 0;
return 0;
}
而如果我们对除零错误所受到的信号做捕捉,并且自定义信号处理中不退出该进程,那么即便进程异常了,它也不会终止退出(因为我们做了信号捕捉,自定义了处理方式,因此不会执行默认的处理方式)!
但是!捕捉了信号,不仅进程没有退出,而且信号一直在被触发,这是为什么呢?
首先我们可以排除,这是针对代码异常后做信号捕捉的特性,而不是除零错误才会这样的
int main()
{
signal(11, myhandler);
int* p = nullptr;
*p = 100;
return 0;
}
诸如对野指针 或者 内存越界访问等问题,它也会导致进程异常终止,而对信号做捕捉,它也同样会出现信号一直被触发的现象。
因此,我们可以得出结论,代码发生异常了,进程不一定退出!因为异常(信号)可以被用户捕捉。而如果发生异常时,进程退出了,那一定是执行了信号处理方法。
所以如果你愿意,进程异常时,对信号做捕捉,然后不让进程退出。但是,进程一旦异常了,继续执行下去是没有任何意义的,它也无法继续执行下去,会一直处于在异常信号触发那里。
-
为什么诸如上述的除零错误、野指针访问等异常错误,会向进程发送信号呢?
简单概括,即这些异常引起系统问题,然后操作系统向进程发送信号,而进程受到信号后,对特定进程的默认处理动作就是终止自己。
-
操作系统如何得知,进程会出现除零错误 或 野指针访问等异常的?(如果操作系统不知道,那肯定就不会向进程发送特定喜欢,然后进程再做信号处理)
在 cpu 内部会有一些寄存器,诸如 eip/pc,用于记录下一条指令的地址;通用寄存器 eax、ebx、ecx、edx 等等,用于配合 cpu 做硬件计算;还有一种状态寄存器,这个寄存器是按照比特位来设置标志位的,其中就有一个溢出标志位,而例如除零错误,本质就是除以一个无限小的数,那么就会发生数值溢出问题,因此溢出标志位就由 0 (表示无效) 变为 1 (表示有效)。
这是当进程发生除零异常,在状态寄存器(硬件层面)表现出来的处理动作。而除了意识到这是在硬件上对进程异常的处理动作,还要清楚 cpu 内部很多的寄存器内的数据,都属于进程的上下文数据。所以当发生进程切换时,这个进程出现了异常,此时的状态寄存器的标志位是异常的,但是这并不影响其它进程!因为这个发生异常的进程在被切换时,会带走所有它的上下文,包括状态寄存器中的标志位数据,而新来的进程,同样会有自己硬件上下文,在切换到 cpu 上执行时,也会把自己状态寄存器的内容放上来。因此,虽然进程异常时,修改的是 cpu 内部的状态寄存器,但是这并不影响其它进程。这也就是从调度、硬件层面上保证了进程的独立性!
所以不要认为只要硬件出错了,整个计算机就会出问题。因为有进程的存在,又因为所有用户的任务都是被进程包裹起来的,所以任何异常只会影响进程本身,并不会波及至操作系统。
当前进程在运行时,状态计存器中的溢出标志位出问题了,操作系统必须要知道它溢出了。因为操作系统也是硬件的管理者,状态计存器属于 cpu 内部的寄存器硬件单元,cpu 也属于硬件,那么操作系统要管理硬件,就必须要清楚它们的健康状态!而当一个进程出现异常了,也就没必要继续往后执行了(都异常了,即便执行出来一个结果,用户敢用这个结果吗?),所以当操作系统发现 cpu 的状态出问题了,它的状态寄存器溢出了,便直接向进程发送信号,然后进程收到信号后,就终止了自己这个进程。
换言之,操作系统为何能够得知除零或者野指针等异常发生了,本质就是进程异常都会被转化成硬件问题,而操作系统作为硬件的管理者,能够识别硬件的状态,然后处理问题。而操作系统处理异常,并不影响整个操作系统的稳定性,只会影响出现异常的进程。
而对于野指针等内存越界访问的异常错误,访问真实的物理内存之前,都是需要先经过页表,即先查表,而查表这件事并不是操作系统直接做的,而是由 MMU(内存管理单元) 来完成的,这个 MMU 是被集成到 cpu 内部的。而当访问野指针时查表,可能是页表中没有对应的映射关系,也可能是权限问题导致无法映射访问,本质都是虚拟到物理地址的转换失败所导致的野指针异常!当转换失败时,MMU 内存管理单元报错,然后会把转换失败的虚拟地址存储到 cpu 内部的寄存器中。并且同样的,野指针造成的进程异常,不会影响其它进程。因为在 cpu 内部会存在一个 cr3 寄存器,这个寄存器会保存当前进程的页表的起始地址(物理地址),当异常发生,进程被 cpu 切换时,cr3 寄存器的内容也是进程的硬件上下文,也要被该进程一起带走!
所以,不管什么异常,只要进程异常,cpu 内部的硬件都会报错!因此操作系统作为硬件的管理者,就能够识别到硬件出问题了,进而向异常进程发送特定信号,然后终止进程。
-
如果对进程异常做了信号捕捉,但是不退出进程呢??
操作系统并没有限制进程异常一定得退出,因为其提供了捕捉信号、信号自定义处理等功能。但是进程异常之后不退出,也要一直被调度。但是当被 cpu 调度运行时,cpu 内部的硬件立马报错,操作系统识别到进程异常,又一次把进程换下了,就这样循环反复。因为自始至终,这个进程引发的硬件问题并没有得到修正,这也为什么我们进程异常不退出时,信号会一直被触发,因为操作系统一直识别到进程异常,一直给进程发送信号!
但是,用户(进程) 也没有权限修正 cpu 内部硬件的数据,所以进程异常了,除了退出,别无所择。异常了为什么还要继续往后执行呢,就算真的执行出来个结果,用户它敢信这个结果是正确的吗?用户不敢!
-
那进程异常终止就完事了,为什么用户还要捕捉异常呢?
捕捉信号不是为了解决问题,而是为了让用户更加清楚的确定进程异常出错的原因,甚至可以在进程退出之前完善错误日志等善后工作。
5. 软件条件
-
异常只会由硬件产生吗??
不一定。还记得管道通信吗,当管道没有写入数据时,读端阻塞;那么当写端一直往管道写入数据,而读端的文件描述符被关闭了,那么写端就会被操作系统干掉!并且向写端发送 SIGPIPE 信号(即 13 号信号),由类似管道的这种情况导致的进程终止,称为软件异常!所以异常也可以是软件产生的。(为什么这是软件异常?因为管道是操作系统创建的,进程也是操作系统的,而管道读端关闭了,操作系统识别到后就会干掉写端,操作系统是一个软件,所以这是一个由软件产生的异常)。
int main() { char buffer[1024]; int n = read(4, buffer, sizeof(buffer)); cout << "n = " << n << endl; perror("read"); return 0; }
像这个案例,读取一个没有打开的文件,它并不像我们所说的读端不存在,写端直接被操作系统异常终止,而是以函数返回式的形式报错。无论是写还是读,这其实都属于文件操作的范畴,但是我们可以看到操作系统是有着截然不同的态度的。这其实是与操作系统的设计有关,对于有些地方,操作系统会以产生异常形式通知,有些地方则是直接以函数调用出错的形式返回,一个问题如何反馈到用户层,都是取决于操作系统本身的。
而软件不只可以产生异常,还可以产生一些特殊事件,即软件条件,比如闹钟。
操作系统既然能够识别到管道写端还是读端某一端关闭,以此来决策处理这些情况,说明操作系统具备对软件状态信息检测的能力。其中,操作系统就可以通过对软件条件做检测来完成一些功能,比如闹钟。
NAME
alarm - set an alarm clock for delivery of a signal // 设置一个闹钟,闹钟响了即向进程传递一个信号,即 14) SIGALRM
SYNOPSIS
#include <unistd.h>
unsigned int alarm(unsigned int seconds); // seconds 秒之后闹钟响起
RETURN VALUE
// alarm() 返回上一次设置的那个闹钟,距离现在还剩下的秒数。
alarm() returns the number of seconds remaining until any previously scheduled alarm was due to be delivered,
or zero if there was no previously scheduled alarm.
int main()
{
int n = alarm(5);
....
}
现象:闹钟 5 秒后响起,向进程发送了 14) SIGALRM 信号,信号的默认处理动作是终止进程。
int main()
{
signal(14, myhandler);
int n = alarm(5);
....
}
现象:当我们对信号做捕捉,信号的默认处理动作就不会被触发,因此进程没有被终止,转而执行信号的自定义处理动作,并且闹钟只响一次,没有一直响起,因为代码中只设置了一次闹钟,闹钟不是异常,因此也不会像异常一样,一直处于信号触发的状态。
而我们设置闹钟时可能不止设置了一次,可能是重复设置的,因此 alarm 的返回值即表示设置的上一个闹钟距离现在还剩余多少时间才会响起。
void myhandler(int signo)
{
...
int n = alarm(5);
cout << "上一次设置的闹钟的剩余时间:" << n << endl;
}
int main()
{
signal(14, myhandler);
int n = alarm(50);
....
}
在操作系统中是存在大量进程的,并且每一个进程都可以设置闹钟,因此操作系统中是可能存在大量的闹钟的,那么操作系统就需要对闹钟进行管理。所以对于每一个闹钟,都在内核当以类似 struct Alarm 的结构体描述起来,并且里面一定会包含一个指向 task_struct 的指针或者进程的 pid,用于指明该闹钟属于哪个进程设置的,并且在闹钟响起时,能够快速的找到该进程,然后向该进程发送信号;还得记录一下闹钟超时的时间(以时间戳的形式记录),将来就可以根据系统中维护的当前时间与闹钟的描述结构体中记录的未来的超时时间进行比对,即可确定该闹钟有没有超时,然后决策何时向对应进程发送信号的工作。在这之后,在通过链表把各个 struct Alarm 描述结构体组织起来,以后操作系统对闹钟的管理就转变为对链表的增删查改。
但是系统中可能有非常多的闹钟,如果每次都去遍历链表查看闹钟的状态,可能很多闹钟都处于未触发时刻,那么操作系统不就白忙活了,遍历了半天,结果一个闹钟没响。而又因为时间是线性增长的数据,因此操作系统可以借助最小堆数据结构来管理每一个闹钟对象,之后检查闹钟状态时,如果发现堆顶的闹钟都没有超时,那么系统中其它所有闹钟就没有超时!如果堆顶的闹钟超时了,那么向该进程发送信号,然后把该闹钟对象从堆顶 pop 出去,再调整一下堆结构,直到检测堆顶的闹钟没有超时为止,这样操作系统即完成了对闹钟管理的工作。
6. Term 和 Core 的区别
在 linux 中的 7号手册中就记录了普通信号的默认处理动作,其中我们可以看到有一部分信号都是 Term 或者 Core,这两者都表示终止的意思。那么 Term 和 Core 有什么区别呢?
在 waitpid 进程等待中,就有一个 status 参数,记录了等待的进程的退出情况。这个整数被分为了几部分使用,次低8位用于表示进程的退出码,而低7位表示进程退出时收到的信号,那么第8个比特位 —— core dump 是什么呢??
int main()
{
pid_t id = fork();
if(id == 0)
{
int cnt = 500;
while(cnt)
{
cout << "I am a child process, pid: " << getpid() << endl;
sleep(1);
cnt--;
}
exit(0);
}
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 >> 7) & 0x7F1) << " core dump: " << ((status >> 7) & 1 ) << endl; }
return 0;
}
现象:向进程发送 2 号信号,如 man 手册说明一样,信号的处理动作是 Term,因此 core dump 的标志位为 0,而当向进程发送一个处理动作是 Core 的信号,core dump 的标志位即为 1。
-
所以什么是 core dump 呢?
当进程异常终止时,操作系统会将进程在内存中的运行信息,给用户 dump(转储) 到进程的当前目录(即磁盘)形成一个形如 core.pid 文件,这个文件就是核心转储 (core dump)。
如果是云服务器运行的,上述两种情况可能 core dump 都为 0,这是因为云服务器的 core 文件默认被关闭了,因此系统不会形成核心转储(core dump) 文件。
ulimit -a
:可查看系统标准配置,其中就有 core file size
ulimit -c 数值
:设置 core file 的大小,这样即可打开系统的 core dump 功能。
-
为什么要有 core dump?
进程运行时出问题了,终止信号、退出码,我们都可以通过 status 获取,但是有了 core dump,我们就能够直接复现问题,定位到源码中具体哪一行代码出错。这种先运行,在 core-file,即为事后调试,本质都是为了提高解决 bug 的效率。
-
如何利用 core dump 定位错误?
// 示例代码 int main() { int a = 10; int b = 0; a /= b; .... }
编译源文件时带上 -g 选项,生成调试版可执行程序,先跑一遍程序,生成核心转储(core dump) 文件。 然后 gdb 启动程序,直接输入 core-file core.pid 文件,即可定位到源码中具体出错位置。
-
拓展:为什么云服务要默认禁用 core 文件呢?
因为每一次进程异常终止了,都会当前的目录下形成一个比较大的文件(core 文件)。而在大型企业,后端服务器集群主机是非常多的,所以一般服务挂掉了,都是由自动化运维立马把服务恢复起来的,后续运维部门在根据日志等信息排查问题。所以假如公司混了几位很水的程序员,写的服务漏洞百出,测试也是水,也没测出来,然后服务就这样上线了,然后马上就挂掉了,之后又立刻被自动恢复,然后又挂,又恢复(并且这种挂掉之后被自动恢复的过程是非常快速的,基本无感知的),但是每次挂掉,又会形成一个不小的 core 文件,那么磁盘可能一下子就被填满了,之后可能连操作系统都直接挂掉了。这样的影响,可比没有core dump 文件来的影响大的多。而服务器都是线上的生产环境,所以一般都默认关闭了 core dump 的转储。
如果感觉该篇文章给你带来了收获,可以 点赞👍 + 收藏⭐️ + 关注➕ 支持一下!
感谢各位观看!