信号概念与信号产生
- 一、初识信号
- 1. 信号概念
- 2. 前台进程和后台进程
- 3. 认识信号
- 4. 技术应用角度的信号
- 二、信号的产生
- 1. 键盘组合键
- 2. kill 命令
- 3. 系统调用
- 4. 异常
- (1)观察现象
- (2)理解本质
- 5. 软件条件
- 闹钟
一、初识信号
1. 信号概念
生活中类似信号的概念也不少,例如上课铃声响,就是信号的发出,我们听到上课铃声,就是接收到信号,我们快速回到教室上课就是对信号做出处理。那么我们是怎么认识这些信号的呢?那必定是有人教我们,然后我们记住了。而且我们不单单要认识信号,还要识别信号,知道信号的处理方法!
当信号产生了,我们可能并不立即处理这个信号,我们可能会在合适的时候再去处理,因为我们可能还有更重要的事情要做,所以在信号产生之后,必定有一个时间窗口,在这个时间窗口内,我们必须记住信号的到来!
其实在计算机中,上面中的“我们”其实就是进程!所以进程必须识别并处理信号,并且信号没有产生,也要具备处理信号的能力!所以信号的处理能力,属于进程内置功能的一部分!也就是说,当进程收到了一个信号,进程也可能并不会立即处理这个信号,在合适的时候才会处理。所以,一个进程必须当信号产生,到信号开始被处理,也一定会有时间窗口,也就是说,进程具有临时保存哪些信号已经发生了的能力。
而信号的处理方式有三种:默认动作、忽略、自定义动作;其中我们把自定义动作称为信号的捕捉。
2. 前台进程和后台进程
我们先写一个死循环,如下:
int main()
{
while(true)
{
cout << "i am a process, pid: " << getpid() << endl;
sleep(1);
}
return 0;
}
当我们运行起来之后,我们可以通过 ctrl + c 杀掉这个进程。而这种进程,当我们运行起来之后,我们的 bash 就不能接收任何指令了,我们把这种进程称为前台进程。
我们也可以在运行该程序的时候,在后面加上 &,此时我们运行程序,我们可以输入指令,bash 可以接收我们的指令,也就是说我们还能正常使用 bash 命令行,但是此时我们使用 ctrl + c 就杀不掉该进程了,这种进程我们称为后台进程,如下图:
在Linux中,一次登录中,一个终端一般会配上一个 bash,每一个登录,只允许一个进程是前台进程,但是可以允许多个进程是后台进程。所以我们运行一个程序的时候,默认是在前台运行的,此时 bash 进程就变成后台进程了,所以此时我们运行指令是没有用的。所以前台进程和后台进程的区别在于谁来获取键盘输入!
那么我们在运行后台进程的时候,bash 依旧是前台进程,我们输入指令的时候,从上面的结果中我们可以看到,指令已经和打印的内容混合在一起了,此时为什么还能运行我们的指令呢?其实我们输入 ls 的时候,我们是通过键盘输入的,我们键盘输入的消息,会给我们回显出来,虽然回显出来是乱的,但键盘里输入的时候输入依旧是 ls;键盘有键盘的缓冲区,显示器有显示器的缓冲区,只是我们在输入的时候默认给显示器也拷贝了一份,但是这个并不重要,重要的是输入的内容被显示器拿到就行了。
3. 认识信号
实际上,ctrl + c 本质上是被进程解释成为收到了 2号 信号,然后进程就被中断了。我们可以查看Linux中的信号列表,指令为:
kill -l
其中我们发现,0号、32号和33号信号是没有的。也就是一共有62个信号;其中我们把 1~31 号信号称为普通信号;往后的称为实时信号,当信号产生必须立即处理就是实时信号;其中我们只学习普通信号。
其实信号本质上就是一个数字,我们看到上面的信号编号中,旁边的大写单词就是它的宏!
那么我们知道,进程收到2号信号的默认动作,就是终止自己。我们可以验证一下,此时我们需要认识一个接口:signal()
:
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
signal()
就是设置对特定一个进程的自定义处理方法;其中 typedef void (*sighandler_t)(int);
函数指针类型,返回值是空,参数是 int 的类型;那么第一个参数就是信号的编号;handler 就是对当前进程进行 signum 号信号的自定义捕捉。也就是说,当进程收到2号信号,我们就可以指定进程进行我们自定义的方法!如下代码:
void myhandler(int signum)
{
cout << "process get a signal: " << signum << endl;
}
int main()
{
signal(2, myhandler);
while(true)
{
cout << "i am a process, pid: " << getpid() << endl;
sleep(1);
}
return 0;
}
结果如下:
所以我们知道了,ctrl + c 的本质就是向进程发送2号信号!并且进程的默认动作已经被我们捕捉了,执行的是我们的自定义的方法。此时我们使用 ctrl + c 杀不掉进程了,所以我们可以使用 kill -9 pid
杀掉。我们也可以在我们自定义方法中加入 exit() 函数,直接退出。
注意,signal()
方法我们只需要设置一次,在该进程生命周期中,往后都有效。在我们的自定义方法中,为什么还要在参数加上信号的编号呢?因为我们可以将所有信号都设置为同一个方法,此时该方法就需要分辨是哪个信号了,所以需要加上信号的编号。
前台进程在运行过程中用户随时可能按下 Ctrl+C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步的!
4. 技术应用角度的信号
接下来我们了解一下,键盘数据是如何输入给内核的,ctrl + c 又是如何变成信号的。
首先我们需要知道,键盘被按下,肯定是操作系统先知道,因为键盘设备并不能被进程直接访问。那么操作系统怎么知道键盘上有数据了呢?最简单的方法是,操作系统定期去检查键盘上的数据,因为键盘也是文件,所以在操作系统内部会有键盘的描述符、缓冲区等等,所以键盘读取的本质就是将用户层的缓冲区拷贝到内存级的缓冲区中,这就是输入的过程,如下:
但是操作系统怎么知道键盘上有数据了呢?同理,操作系统怎么知道我们的外设就绪了呢?例如操作系统怎么知道我们的话筒有声音呢?怎么知道摄像头有数据了呢?
曾经我们学过,CPU是不和外设打交道的,因为冯诺依曼体系,但是在控制层面上,CPU是要读取外设的!
首先,CPU 上有许多的引脚集成在主板上,而外设各种设备也是插在主板上的,而键盘在物理上其实是可以间接地和CPU相连的,CPU虽然不在键盘中读数据,但是键盘是可以在硬件上给CPU发送一个硬件中断的!
也就是说,操作系统该干嘛就干嘛,一旦键盘上有数据了,就会通过一些硬件单元将键盘当中的信息发送给 CPU;那么当有其它外设给 CPU 发送中断的时候,CPU怎么知道是哪种设备呢?所以每一种中断都有一个中断号的概念,类似于数字,我们下面假设键盘的中断号为iptnum;其实就是给 CPU 的引脚发送高低电平,由CPU来解释这个中断号是几,所以CPU就得记录下来对应外设的中断号;而在操作系统内,会有一张中断向量表,其实就是一个数组,而中断向量表中都是方法的地址,主要是包括直接访问外设的方法,包括磁盘、键盘、显示器等。所以当CPU 收到了键盘的中断号,操作系统就立马识别到CPU收到了中断号,所以操作系统会立马以中断号为索引,去中断向量表中找对应的方法,然后执行该方法,然而这个方法就是将数据从外设中拷贝到内存级缓冲区的方法!如下图:
其实我们学习的信号,就是用软件的方式,对进程模拟的硬件中断。
那么如果我们输入的组合键呢?操作系统怎么对 ctrl + c 这样的组合键的数据拷贝到内存级缓冲区呢?其实键盘上的按键是有分类的,有的是用来输入的,有的是用来控制的,比如 ctrl + c、z等,所以操作系统在拷贝数据的时候会进行判断,输入的是数据还是控制,如果是控制,会转化为相应的信号发送给进程!
二、信号的产生
1. 键盘组合键
上面我们已经知道了,我们可以通过 ctrl + c 这样的键盘组合键产生信号。
除了 ctrl + c
外还有 ctrl + \
,其中 ctrl + \
就是发送3号信号;我们将3号信号捕捉,如下:
还有 ctrl + z 可以发送19号信号,让对应的进程暂停。如下:
如果我们把19号信号捕捉呢?如下:
如上图,它没有捕捉到19号信号;所以我们得出,不是所有的信号都是可以被 signal 捕捉的。
所以键盘组合键发送信号常见的组合就是以下三个:
- ctrl + c
- ctrl + \
- ctrl + z
我们从上面知道,不是所有的信号都是可以被 signal 捕捉的,所以我们可以尝试将所有信号都捕捉一下,然后使用 kill
指令尝试使用所有的信号编号,观察哪个信号不可以被捕捉;如下代码:
void myhandler(int signum)
{
cout << "process get a signal: " << signum << endl;
}
int main()
{
for(int i = 1; i <= 31; i++)
{
signal(i, myhandler);
}
while(true)
{
cout << "i am a process, pid: " << getpid() << endl;
sleep(1);
}
return 0;
}
最终经过实验我们得出在 1~31 号信号中只有 9号 和 19号 信号不能被捕捉,其它都可以。
2. kill 命令
kill 命令我们就不用介绍了,直接在命令行使用即可,使用格式如下,其中 signo 为信号编号,pid 为进程的 pid:
kill -signo pid
3. 系统调用
接下来我们认识两个可以产生信号的系统调用接口,kill()
和 raise()
.
-
kill()
int kill(pid_t pid, int sig);
其中 pid 为进程的 pid;sig 为信号编号。既然有了系统接口,那么我们也可以自己实现一个 kill 指令!参考代码如下:
int main(int argc, char* argv[])
{
if(argc != 3) exit(1);
int sig = stoi(argv[1]);
pid_t pid = stoi(argv[2]);
int n = kill(pid, sig);
if(n < 0) exit(2);
return 0;
}
-
raise()
int raise(int sig);
raise() 是经过封装的;参数就是信号编号;该函数就是哪个进程调用,就将指定信号发送给哪个进程。所以 raise() 函数相当于 kill(getpid(), sig);
- abort()
abort() 就是引起一个正常的进程直接终止。abort() 也是经过封装,其实它就是给调用进程发送 6 号信号。但是它内部做了处理,当我们捕捉了 6 号信号,但是调用了 abort() 后,虽然也调用了我们自定义方法,但是它还是会终止进程。
4. 异常
(1)观察现象
异常在我们的程序中也很常见,我们常见的异常有除0错误和越界访问,接下来我们模拟一下这两种场景,分析一下这两种场景。
-
除0错误
int main() { int a = 2, b = 0; cout << "...before " << endl; sleep(2); a /= b; sleep(2); cout << "...after " << endl; return 0; }
运行起来后结果如下:
其实是异常后,进程收到了操作系统发送的信号,终止了进程。我们看一看一下信号表,其实进程是收到了 8 号信号:
我们也可以使用 man 7 signal
查看信号的详细信息,如下我们找到8号信号,看到它确实是除0错误:
我们还可以将该信号捕捉进行验证:
void headler(int signo)
{
cout << "... get a signal: " << signo << endl;
sleep(1);
}
int main()
{
signal(8, headler);
int a = 2, b = 0;
cout << "...before " << endl;
a /= b;
cout << "...after " << endl;
return 0;
}
结果如下:
如上,默认的信号处理被我们捕捉后就调用了我们的方法,而且进程不退出了,更重要的是,我们的自定义方法被一直调用,也就是说,信号一直在被触发,这是为什么呢?我们下面再解释。
- 越界访问(野指针)
我们下面模拟一下越界访问的场景:
int main()
{
signal(8, headler);
const char* str;
cout << "...before " << endl;
cout << str[10] << endl;
cout << "...after " << endl;
return 0;
}
结果如下:
如上图,其实是进程接受到了11号信号而被终止,如下:
我们也对11号信号捕捉一下,结果如下:
如上,进程依旧也没有退出。所以进程一旦出异常了,不一定会退出,但是一旦异常退出了,一定是执行了信号所对应的异常处理方法。
(2)理解本质
下面我们进一步理解为什么除0错误和野指针会让进程崩溃。本质上是出现异常后,给对应的进程发信号了,而进程收到信号默认的处理动作就是终止自己,这就是进程崩溃的原因。那么为什么除0错误和野指针会给进程发信号呢?那么根据我们的理解,一定是操作系统识别到了异常问题,然后给进程发信号,那么操作系统是怎么检测到异常问题的呢?
- 除0错误
当进程执行代码的时候,我们知道,CPU中的eip或者pc指针会保存代码的下一条指令的地址;其中还有一种寄存器叫做状态寄存器,其中有一个比特位表示状态标志位,称为溢出标志位,当我们发生除0的时候,在CPU中会进行计算,但是除0之后数字变成非常大,这个溢出标志位就会溢出了,由0变成1;我们还要知道,整个CPU中的数据其实都属于当前进程的上下文,我们以前也介绍过,也就是虽然CPU只有一个,但是CPU中的数据可以有无数份,所以硬件只有一套,但是进程在被调度期间,CPU里放的都属于当前进程的上下文!所以CPU在进行调度运行的时候,一旦出现异常了,对应的状态寄存器由0置1了,该进程是否出异常与进程切换无关,也就是说,该进程必定是出异常了,但是它不会影响其它进程,因为出异常的数据是属于当前进程的上下文,当该进程被切换时,其它进程的上下文会放上CPU上正常运行!
那么当溢出标志位溢出之后,操作系统需要知道CPU出现溢出了吗?计算出错了吗?需要!操作系统在调度进程时必须要知道已经出现异常了,因为操作系统是硬件的管理者!CPU也是硬件!所以操作系统需要时刻知道CPU的状态寄存器的状态!所以当操作系统发现CPU发生了除0溢出,操作系统就会向进程发送信号,然后进程接收到信号崩溃了!
- 越界访问(野指针)
我们已经知道,进程的地址空间通过页表映射到物理内存,访问自己的代码和数据。如果我们出现野指针,我们当前访问的时候,通过页表完成对虚拟地址到物理地址的转化,查表的过程并不是操作系统直接来查的,因为对于操作系统来说很费时间,效率低下,所以这个过程是由一个叫做 MMU 的硬件(内存)管理单元完成的,它如今是集成在CPU中的。那么我们也知道,CPU里读到的都是虚拟地址,当CPU通过页表转换野指针的物理地址的时候,会转换失败!CPU中还有一个寄存器,当CPU进行对虚拟到物理地址的转换时,当发生转换失败了,它会把转换失败的虚拟地址放到该寄存器中。当转换失败时 MMU 也会发生报错,硬件报错会被操作系统识别到,因为不同种类的CPU报错信息,所以操作系统可以识别是哪种错误,所以此时操作系统就会发送对应的信号给进程!
所以我们捕捉了信号之后,没有退出,为什么会一直死循环不退出呢?因为至始至终,进程引发了硬件异常问题,也没有修正问题,所以硬件异常一直存在,随着进程被调度,上下文错误也一直存在,所以操作系统一直检测到有这个异常,就一直给该进程发信号,而我们也一直在捕捉这个信号没有处理,所以该进程才不会退出!
5. 软件条件
那么异常只会由硬件产生吗?不一定,比如我们之前学的管道,当读端关闭后,写端一直在写入的时候,操作系统就会因为效率问题关掉写端,并给写端发送 SIGPIPE 13号信号,这就算一种软件异常。
闹钟
其实软件上不仅仅是可以出异常,也可以出一些特殊事件,我们把这些特殊事件称为软件条件,下面我们介绍一种特殊事件 - - - 闹钟。我们可以给进程设置闹钟,闹钟响了,就可以给进程触发对应的条件,执行对应的动作,这个就称为软件条件。
我们可以看看闹钟的系统调用,alarm()
alarm()
就是给进程设定一个闹钟,一旦闹钟响了,就会给进程发送信号。参数就是我们设定的时间,单位为秒。那么 alarm() 发送的信号是信号编号中的 SIGALRM 14号信号。
其中返回值我们要理解一下,当我们设定好闹钟,我们可能会提前醒来,那么进程也是一样,当进程被提前发送了14号信号,就相当于提前醒来,那么返回值就是上一次设定闹钟的剩余时间。
假设我们设定一个5秒的闹钟,如下:
int main()
{
int n = alarm(5);
while(1)
{
cout << "i am a process..." << endl;
sleep(1);
}
return 0;
}
我们可以捕捉该信号验证一下,注意我们上面只设定了一次闹钟,一旦响过之后就不会再响了,所以我们下面验证的时候再设定每隔五秒响一次,如下:
void headler(int signo)
{
cout << "... get a signal: " << signo << endl;
int n = alarm(5);
}
int main()
{
int n = alarm(5);
signal(14, headler);
while(1)
{
cout << "i am a process..." << endl;
sleep(1);
}
return 0;
}
其中我们查看详细的信号信息的时候,如下图,发现闹钟在 Action 这一列中是 Term:
而有些信号却是 Core,下面我们说一下这两个的区别。我们在以前学习进程控制的时候,进程在正常终止的时候,它会有自己的退出状态,也就是wait时,获取的 status 参数,这个 status 一共是32个比特位,我们只使用低十六位,其中我们通过次低八位,用来表示进程退出时的退出码;而一旦异常了会收到退出信号,退出信号是低七位比特位;而还有一位是 core dump,我们并没有介绍,而这个字段就是当进程在终止的时候,这个标志位只有一个比特位,为0或者1,它是用来表示进程是 Term 这种终止方式还是 Core 这种终止方式。
下面我们获取一下验证一下:
int main()
{
pid_t id = fork();
// child
if(id == 0)
{
int cnt = 50;
while(cnt--)
{
cout << "i am a child process, pid: " << getpid() << endl;
sleep(1);
}
exit(0);
}
// father
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if(rid == id)
{
cout << "child quit info, rid: " << rid
<< ", exit core: " << ((status >> 8) & 0xFF)
<< ", exit signal: " << (status & 0x7F)
<< ", core dump: " << ((status >> 7) & 1) << endl;
}
return 0;
}
下面我们使用 2 号信号杀掉子进程,因为 2 号信号是 Term 的终止方式:
那么我们看到 core dump 是0;接下来我们使用 8 号信号杀掉子进程,因为 8 号信号是以 Core 方式终止进程的:
但是我们发现,core dump 也是0,这是为什么呢?这是因为默认云服务器上的 core 功能是被关闭的!虚拟机是默认没有关闭的。我们可以使用指令 ulimit -a
查看系统中一些标准的配置,其中有一个叫做 core file size 的选项,它默认是0的:
我们也可以使用 ulimit -c
直接查看它,这就是 core 功能:
我们可以使用 ulimit -c size
设置它,如下:
如上,core file size 的大小就被我们设置成 1024 了;
此时当我们再次测试上面的结果的时候,我们使用 2 号信号杀掉子进程 core dump 还是 0,但是使用 8 号信号杀掉子进程的时候 core dump 就变成了 1. 如下:
而且我们在当前目录下多了一个文件!如下:
所以我们得出结论,打开系统中的 core dump 功能,一旦进程异常退出,操作系统会将进程在内存中的运行信息,给我们 dump(转储) 到进程的当前目录!形成的 core.pid 文件的过程就是核心转储。
那么为什么要进行核心转储呢?其实当发生核心转储时,一定发生了运行时错误,当发生了运行时错误,我们肯定最想知道发生了什么错误,而且更想知道代码在哪一行出错了!所以这个 core.pid 可以告诉我们代码哪一行出错了!我们先在 makefile 中加上 -g 选项,让该程序可以被调试。然后我们测试一下,使用除0错误的代码测试:
int main()
{
int a = 2, b = 0;
cout << "...before " << endl;
a /= b;
cout << "...after " << endl;
return 0;
}
此时我们再运行程序,发现这次报错中后面多了个括号,表示当前已经被核心转储了:
那么接下来我们想知道哪一行出错,就可以直接开始调试,再输入 core-file core.pid
直接将我们的 core.pid 文件导进来即可,如下:
所以 core.pid 就是直接复现问题之后,直接定位到出错行,这种先运行,再 core-file 的我们称为事后调试。
那么云服务器上为什么要默认关闭 core dump 呢?我们可以看到形成的 core.pid 相对于其它文件非常大,而且我们的代码量还不大,如下:
但是当在服务器中,服务器挂掉后,会自动重启,但是如果一个服务器有问题,一启动就挂,又重启,那么一直重复的话,如果 core dump 打开,那么磁盘就有可能被 core.pid 文件打满了,此时的影响就更大了!所以 core dump 是默认被关闭的。