【Linux庖丁解牛】— 信号的产生!
1. 什么是信号
在将信号之前,我们有一个问题,信号和信号量有什么关系呢?? 结论就是没有任何关系。
在理解信号之前,我们先来看一看我们生活中的信号:
闹钟【当我们正在睡觉的时候,如果闹钟响了,我们就知道应该醒了】
红绿灯【当我们正在走路时,看到红灯就知道我们应该停下来了】
上课铃声【当我们正在操场玩的时候,听到上课铃响了就知道我们该上课了】
所以,生活中信号就是:中断人们正在做的事情,是一种事件的异步通知机制。
而对于Linux来说:信号是给进程发送的,用来进行事件异步通知的机制。
好,什么是事件异步呢??上面我们举得栗子其实就体现了时间的异步:我们在走路的时候,红绿灯倒数5秒后变红,这两件事情就是异步的。而信号的产生相对于进程的运行,就是异步的!
> 下面我们再输出几个基本结论:
1. 信号处理,进程在信号还没有产生的时候就知道该如何处理信号了!
2. 信号产生的时候,进程不一定会立即处理信号,而是可以等到合适的时候再处理信号。
3. 我们之所以可以识别处理信号,是因为我们是被提前“教育的”。而进程也是如此,进程是程序员设计的,也就是说进程早已内置了对信号的识别和处理方式。
4. 给进程产生信号的信号源非常多!
2. 信号的产生
2.1 键盘产生信号
我们先来写一个简单的程序:
当我们在键盘上按下ctl+c的时候,进程就终止了!
而ctl+c就是给进程发送信号【相当一部分的信号处理动作就是让自己终止!】但是,ctl+c到底发送了什么信号呢?不急,我们可以用kill -l命令来查看进程中的所有新信号:
注意红色框位置,其实信号总共只有62个,信号的本质就是一个数字,我们可以用该数字来传递信号,但是这样做可读性不好,所以我们将其定义为宏,也可以用宏来传递信号。而蓝色框中的信号部分是实时信号【需要被立即执行】,这些信号我们不需要了解。但除了它们之外的信号为普通信号【可以不被立即执行】,我们需要重点了解。
我们适才使用的ctl+c其实发送的就是2号信号SIGINT,我们刚刚说【相当一部分的信号处理动作就是让自己终止】,这其实是进程收到该信号的默认处理动作,事实上,我们还可以自定义信号处理动作!也可以忽略处理!
2.2 自定义信号处理动作signal
好了,我们前面说了一大堆,但是我们怎么见到进程处理信号的过程呢??我们可以先来尝试更改信号的默认处理动作!!要做到这一点,我们势必会使用系统调用。所以我们先来认识一个系统调用signal。
其中的第一个参数就是信号源,可以传数字,也可以传宏。第二个参数是函数指针类型,也就是说我们可以自定义函数来规定信号的处理动作【也就是更改信号的默认处理动作】。
在函数指针类型中的参数可以获得我们传递的信号,下面就简单修改一下2号信号的默认处理。
结果和预期的一样【ctl+\也可终止进程】。
我们前面说了,用signal可以更改目标进程对信号的默认处理方式。但我们怎么知道所有进程的默认处理方式呢??man 7 signal可以帮助我们查找。
2.3 什么是前后台进程
我们刚刚一直在说目标进程,但是到底什么是目标进程呢??进程可以分为前台进程和后台进程,就比如说我们刚刚./testsignal进程就是前台进程,如果我们在./testsignal 后面加一个符号&,则该进程就是后台进程了!
当该进程为后台进程时,我们发现该进程接收不到我们从键盘发送的信号。所以,这里我们可以得出一个结论:键盘产生的信号只能发送给前台进程!
既然如此,那我们该如何杀掉这个后台进程呢??
我们再认识一个命令kill -9 +进程pid可以强力杀掉一个进程。
我们至此可以得出结论:后台进程无法从标准输入中获取内容【也就是键盘】,而前台进程可以从标准输入中获取内容。但这是为什么呢?? 键盘输入的数据只有一份,所以该数据只能交给一个进程处理,所以我们将当前处理键盘数据的进程叫做前台进程,所以在每个时刻,前台进程都必须只有一个!!
现在我们已经认识了什么叫前后台进程,我们接下来认识一下如何完成前后台进程的切换,所以我们来认识以下命令:
jops->查看当前的后台进程:
fg +任务号->将特定的进程提到前台:
ctl+z->停止一个进程,只有前台进程可以获取这个从键盘输入的命令,所以,一旦前台进程停止,那么它必须切换为后台,不然就没有进程从键盘获取数据了。所以ctl+z可以让一个前台进程切换为后台进程:
最后,命令bg +任务号可以让指定后台进程继续运行起来:
2.4 给进程发送信号的本质
因为并不是在所有情况进程收到信号都会立即处理信号,所以进程必须将信号先记录下来。显然,信号是被记录在进程的PCB【task_struct】中的。直接告诉你,进程是用一个无符号的整型位图来记录的!!比特位的位置表示该信号,比特位的内容【0表示无,1表示接受】表示该信号是否接受到。所以,当用户给目标进程发送信号时,其本质是在修改目标进程PCB的内容!!而进程PCB属于内核数据结构的内容,用户可以修改内核数据结构的内容吗??显然是不可以的,内核数据的修改只能由操作系统来完成,所以操作系统势必会在上层提供相应的系统调用,来帮助我们向进程发送信号!!所以我们发送信号给进程的本质是系统和进程打交道!!因此,系统势必需要知道进程的pid和发送的信号才能和进程打交道。我们也能理解了kill命令为什么需要进程pid和信号种类了,我们也可以大胆猜测kill命令肯定调用了某种系统调用来完成信号的发送。事实也是如此:
下面我们就来用系统调用kill简单设计一个kill命令:
这里补充一个小知识点:既然我们可以用signal系统调用来完成自定义信号捕捉,那如果我们将一个进程恶意完成所有信号的自定义捕捉,那么这个进程一旦运行起来不就是刀枪不入,无法被杀掉了吗??
所以,操作系统为了防止有人这么干,9号信号就无法被自定义捕捉。所以,我们可以用9号信号来杀掉任何一个进程。其实,还有一个信号无法被捕捉,那就是19号信号SIGSTOP。
2.5 系统调用产生信号
处理我们上面说的kill给指定进程发送指定信号,我们还可以用让raise系统调用来给自己发送信号。
简单使用一下:
9号信号果然不会被捕捉:
我们再来认识一下abort函数【不是系统调用了】:
我们先来看一个现象:
abort本质上也是给自己发信号,不过它只是给自己发指定信号6号信号SIGANRT。该接口不受信 号捕捉的影响,也就是说abort发送信号后,无论6号信号是否被自定义捕捉过,进程都会采用6号信号的默认处理方式!!
2.6 硬件异常产生信号
除了以上我们所说产生异常的方式,我们程序产生异常时,系统也会发送信号终止进程。下面我们简单来见见现象:
程序出错了,系一旦识别到就会发送信号给进程。但是,这里有一个细节,系统是怎么识别进程出错了呢??
我们先来看一下系统是如何识别出浮点数异常的,进程首先被cpu执行调度,在cpu中有许多寄存器,有些寄存器来存储一个进程的代码和数据【包括进程PCB】,有些寄存器则用来做算数运算【比如浮点运算单元FPU】。FPU在执行浮点运算时会检测到异常,一旦检测到异常,则会在相应的状态寄存器中设置相应的标志【本质就是修改对应比特位】。系统通过检查FPU状态寄存器来确定异常类型,然后发送相应的信号给进程。因为cup寄存器属于硬件,而操作系统又是软硬件的管理者,所以我们又将这种产生信号的方式称为硬件异常产生信号。
同样的道理,系统识别出段错误也是通过识别硬件异常发送信号给进程的。现代CPU的内存管理单元(MMU)通过页表机制实现虚拟内存到物理内存的映射。当程序访问非法内存时,MMU会触发缺页异常(Page Fault)【这个缺页异常我们暂不做理解,上面异常是无效地址访问:访问未映射的虚拟地址】,系统捕捉到这个异常后发送对应的信号给我们的进程。
2.7 软件条件产生信号
目前我们对软件条件产生信号还是非常陌生的,不过我们其实已经接触过了。我们在说管道的时候,如果我们将通信的两个进程中的读端关闭,那么写端也会自动关闭【实际上,是系统向写端进程发送信号了】。而我们的管道是基于文件的,也就是在软件层面,写端进程由于读端进程的条件缺失而收到信号终止进程。这其实就是软件条件产生信号。
我们在理解软件条件产生信号之前先来了解一个系统调用alarm
该接口可以设置一个闹钟,在我们规定时间到达后,闹钟响起并且会给当前进程发送信号,发送什么信号呢?? 我们来写一个代码简单验证一下喽:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>void handler_signal(int sig)
{std::cout << "获得一个信号" << sig << std::endl;exit(13);
}int main()
{// 完成信号捕捉for (int i = 1; i < 32; i++){signal(i, handler_signal);}alarm(1);int cnt = 0;while (true){std::cout << "pid->" << getpid() << std::endl;sleep(1);}return 0;
}
通过上面的实验,我们已经可以简单的应用alarm接口了,接下来,我们完成一个程序,让我们的程序每隔一秒收到信号14并且不退出:这很简单做到,我们只需要在每次捕捉到一个信号的时候设置一个闹钟即可。
好,接下来我们让我们的进程每隔一秒钟收到一个信号,但是什么都不做,暂停下来,一旦接收到信号就结束暂停。如何做到呢??使用我们之前用的pause接口即可办到。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>int cnt = 0;
void handler_signal(int sig)
{std::cout << "捕捉到信号->" << sig << std::endl;alarm(1);
}int main()
{// 完成信号捕捉signal(SIGALRM, handler_signal);alarm(1);while (true){pause();}return 0;
}
接下来,我再定义一堆方法,让进程在信号的驱使下每隔一段时间就执行这些方法:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <functional>
#include <vector>using fun_t=std::function<void()>;//############################################
void f1()
{std::cout<<"我是进程调度\n";
}
void f2()
{std::cout<<"我正在周期性管理内存泄漏,正在检查有没有内存问题\n";
}
void f3()
{std::cout<<"我是刷新程序,我在定期刷新内存数据到磁盘中\n";
}
//############################################
std::vector<fun_t> funs;void handler_signal(int sig)
{// std::cout << "捕捉到信号->" << sig << std::endl;std::cout<<"-----------------------------\n";for(auto f:funs){f();}alarm(1);
}int main()
{//注册方法funs.push_back(f1); funs.push_back(f2); funs.push_back(f3); // 完成信号捕捉signal(SIGALRM, handler_signal);alarm(1);while (true){pause();}return 0;
}
如此一来,我们就完成了让进程在信号的驱使下周期性的执行一些我们定义的方法。这里有个问题,我们如此大费周章的做如上工作是为什么呢,我们直接在循环中sleep不也可以做到这样吗??没有问题,不过我们如上做的工作在本质上时系统运行的底层原理!! 系统一旦启动就是死循环,系统也是牛马,所以系统需要再各种不同信号的驱使下不断执行某些方法【检查,修复,执行……】
那这和软件条件产生信号又有什么关系呢??
在系统内部是否有许多闹钟呢??有的闹钟正在发送信号,有的闹钟已经超时,有的闹钟刚刚被创建……所以,系统内部势必有某种数据结构将这些闹钟管理起来。系统还用一种类似于小顶堆的结构维护了最短时间的闹钟,系统周期性的和最短时间的闹钟对比,一旦发现闹钟超出系统记录的当前时间,则该闹钟从堆中出来,并且系统向设置改闹钟的进程发送信号,进程收到信号做出对应的响应。
时间的对比即是条件,而我们所说的所有都是在软件层面【无论是系统还是进程】,这也就是软件条件产生信号!!
> 小总结一下:信号产生的方式有五种
1. 键盘产生 2. 系统调用产生 3. 系统命令产生 4. 硬件异常产生 5. 软件条件产生
但是,无论是哪一种方式,其本质都是系统向进程发送信号,发送信号的本质就是修改比特位。