文章目录
- 📕 信号入门
- 生活角度的信号
- 技术角度的信号
- 📕 信号产生
- 认识 signal 函数
- 键盘产生信号
- 通过系统调用产生信号
- 软件条件产生信号
- 硬件异常产生信号
- 📕 核心转储
- 📕 信号保存
- 信号集函数
- 📕 信号处理
- 用户态与内核态
- 处理信号
📕 信号入门
生活角度的信号
下面一个网购的过程,有利于我们理解信号。
-
我在网上买了很多件商品,在等待不同商品快递的到来。但即便快递没有到来,我也知道快递来临时,我该怎么处理快递。也就是我能“识别快递”。
-
当快递员到了你楼下,我也收到快递到来的通知,但是我正在打游戏,需5min之后才能去取快递。那么在在这5min之内,我并没有去取快递,但是我是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取”。
-
在收到通知,再到我拿到快递期间,是有一个时间窗口的,在这段时间,我并没有拿到快递,但是我知道有一个快递已经来了。本质上是我“记住了有一个快递要去取”。
-
当我时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,我要送给我的女朋友)3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)。
-
快递到来的整个过程,对我来讲是异步的,我不能准确断定快递员什么时候给我打电话。
技术角度的信号
当我们按下Ctrl-C ,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程。前台进程因为收到信号,进而引起进程退出。
将生活例子和 Ctrl-C 信号处理过程相结合,实际上就可以理解技术角度的信号。( 进程就是 “我”,操作系统就是快递员,信号就是快递。)
一个进程正在运行,突然操作系统给进程发送了一个信号(体现异步,进程并不关心信号何时产生),进程在接收这个信号之前,就已经认识这个信号,并且知道该如何处理这个信号了(执行默认动作、执行自定义动作、忽略),所以,收到信号之后,就按照规定好的处理方式来处理这个信号(不一定立即处理,因为可能进程在执行优先级更高的事情)。
不难理解,在信号产生到信号处理之间,有一段时间窗口。可是,进程不一定只收到一个信号,这就需要将信号保存起来,同样地,信号保存也是——先描述、再组织!
如下,使用 kill -l 可以查看各种信号, 31 号后面的是实时信号。实时信号要求产生之后立马处理,所以不需要保存下来!1-31 号信号是普通信号,要保存对应信号是否产生。
根据上述特点:1. 只需要考虑 1-31 号信号。2. 只需要保存信号有无。 3. 先描述,再组织。不难想到,可以用位图的数据结构来表示信号!!毫无疑问,这个位图是存在于 pcb 中的!
假设,pcb 中存在一个 uint32_t signals; 这就是描述信号的位图结构。四个字节,32位,00000000 00000000 000000000 000000000 最高一位无意义,只需要用到低 31 位即可。比特位的位置,表示信号的编号(1号位置表示1号信号);比特位的内容,表示是否收到信号。当进程收到操作系统的信号,就将位图对应位置的 0 改成 1,就表示该信号产生了!
所以,发送信号的本质,实际上是直接修改特定进程的信号位图的特定比特位,从 0 变成 1。并且, pcb 是内核数据结构,只有操作系统可以进行修改,那么无论以何种方式产生信号,最后都要由操作系统进行“发送”。
📕 信号产生
认识 signal 函数
signal 可以改变指定信号的执行动作, SIGINT代表的是2号信号,下面代码中的 signal(SIGINT,handler1); 就是将 2 号信号的执行动作,改为 handle1()。
#include<iostream>
#include<signal.h>
#include<sys/types.h>
#include<unistd.h>
#include<stdlib.h>
#include<cerrno>
#include<cstring>
using namespace std;
void handle1(int signum)
{
cout<<"I get a signal:"<<signum<<endl;
}
void test1()
{
signal(SIGINT, handle1);
while (true)
{
cout << "我是一个正在运行的进程,pid:" << getpid() << endl;
sleep(1);
}
}
int main(int argc,char* argv[])
{
test1();
return 0;
}
如下运行结果,原本进程收到二号进程,应该杀死前端进程,现在变成了执行自定义动作。此外,Ctrl+C 也是一样的,发送二号进程。
signal(SIGINT,handler1); 并没有调用 handler1 方法,而是在执行**用户动作的自定义捕捉,**仅仅是改变了 2 号信号的执行动作。向该进程发送 2 号信号之后,进程才会调用 handler1 方法!
此外,在执行自定义的动作时,要求操作系统切换到用户态来执行!如果是在核心态,那么可以执行任何操作,假如自定义动作里由一些恶意代码,就会导致无法挽回的错误!!
当然,可以给所有信号设置同一个处理动作,如下。
#include<iostream>
#include<signal.h>
#include<sys/types.h>
#include<unistd.h>
#include<stdlib.h>
#include<cerrno>
#include<cstring>
using namespace std;
void handler1(int signum)
{
cout<<"I get a signal:"<<signum<<endl;
}
void test1()
{
for(int i=1;i<=31;++i)
{
signal(i,handler1);
}
while (true)
{
cout << "我是一个正在运行的进程,pid:" << getpid() << endl;
sleep(1);
}
}
int main(int argc,char* argv[])
{
test1();
return 0;
}
通过下图运行结果可以看出,9 号信号是无法被替换的。因为 9 号信号是可以杀死任何进程,如果 9 号信号也被替换执行动作,那么就可以由此设计 bug ,让一个进程永远无法被杀死!
键盘产生信号
例如快捷键 Ctrl+C,产生 2 号信号;Ctrl+ / 产生 3 号信号……
由计组方面的知识,我们不难理解操作系统读取键盘的数据过程,首先,按下键盘,产生中断号,根据中断号在中断向量表中找到对应的函数指针,调用函数,读取键盘的数据。例如,操作系统读取到 Ctrl+C 之后,会将其解释成信号。
此外,还可以通过 kill 指令产生信号。
kill -X pid ,可以向指定进程(pid) 发送指定信号(X)。
在命令行通过 kill 指令,可以向目标进程发送信号。上面的运行结果已经有所展示。
通过系统调用产生信号
kill 系统调用
如上是 kill 系统调用,第一个参数是进程号,第二个参数是发送的信号编号。
下面代码是对 kill 系统调用的一个实践,要求在命令行输入 ./mysignal 信号编号 进程号 就可以对目标进程发送相应的信号(生成的可执行文件是 mysignal ,所以 ./mysignal )。
#include<iostream>
#include<signal.h>
#include<sys/types.h>
#include<unistd.h>
#include<stdlib.h>
#include<cerrno>
#include<cstring>
using namespace std;
void Manual(char* str)
{
cout<<"Manual"<<endl;
cout<<str<<"信号编号 目标进程"<<endl;
}
int main(int argc,char* argv[])
{
if(argc != 3)
{
Manual(argv[0]);
}
int signum=atoi(argv[1]);
int procpid=atoi(argv[2]);
//cout<<procpid<<" "<<signum<<endl;
int n=kill(procpid,signum);
if(n != 0)
{
cout<<errno<<":"<<strerror(errno)<<endl;
exit(-1);
}
return 0;
}
如下是执行结果。
raise 系统调用
这个系统调用,会向调用它的进程发送信号!参数是几,就发送几号信号!
abort 系统调用
调用 abort() 可以对当前进程发送指定信号(六号)!如下是测试代码以及运行结果:
#include<iostream>
#include<signal.h>
#include<sys/types.h>
#include<unistd.h>
#include<stdlib.h>
#include<cerrno>
#include<cstring>
using namespace std;
void myhandler(int signo)
{
cout << "get a signal: " << signo <<endl<<"n:"<<count<< endl;
}
int main(int argc,char* argv[])
{
signal(SIGABRT,myhandler);
while(true)
{
cout<<"test start"<<endl;
sleep(1);
abort();
cout<<"test end"<<endl;
}
return 0;
}
根据结果我们可以看出, “test end” 并没有打印出来,也就是说——即使我们对 6 号信号设置自定义动作,但是它执行完自定义动作之后,依然会让当前进程退出!!
软件条件产生信号
软件条件,就是字面意思,即软件方面的条件。例如,在使用管道进程进程间通信的时候,如果读端关闭,那么写端就无法向管道写入数据,保持读端开启,这就是软件条件!
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号(14号信号), 该信号的默认处理动作是终止当前进程。
如下,可以测试一秒钟能执行多少次 count++ ,以此来测试计算机的算力!
#include<iostream>
#include<signal.h>
#include<sys/types.h>
#include<unistd.h>
#include<stdlib.h>
#include<cerrno>
#include<cstring>
using namespace std;
void myhandler(int signo)
{
cout<<"the result is:"<<count<<endl;
exit(1);
}
int main(int argc,char* argv[])
{
cout<<"pid:"<<getpid()<<endl;
signal(SIGALRM,myhandler);
alarm(1);
while(true) count++;
return 0;
}
如下是运行结果:
此外,如果闹钟还没有响,进程又收到了闹钟信号,会如何呢?用下面的代码进行测试!
#include<iostream>
#include<signal.h>
#include<sys/types.h>
#include<unistd.h>
#include<stdlib.h>
#include<cerrno>
#include<cstring>
using namespace std;
void myhandler(int signo)
{
cout << "get a signal: " << signo <<endl<<;
int n = alarm(10);
cout << "return: " << n << endl;
}
int main(int argc,char* argv[])
{
cout<<"pid:"<<getpid()<<endl;
signal(SIGALRM,myhandler);
alarm(1);
while(true);
return 0;
}
如下是运行结果,其实,alarm() 的返回值,是上一个闹钟还剩下的时间!
硬件异常产生信号
写 C/C++ 程序的时候,遇到野指针、除0等操作,进程就会崩溃,它们崩溃的原因,在这里就可以得到解释!
以除0为例,如下,CPU 中有一个状态寄存器,当本次计算出现溢出问题,状态寄存器中的溢出标志位置为1,进而CPU这个硬件本身发生异常,然后操作系统识别到了硬件异常,向引起硬件异常的进程发送SIGFPE信号!(CPU中保存了当前正在运行的进程的pcb,所以可以找到它)
注意,SIGFPE信号(8号信号)是由于溢出问题产生的,不同的问题会产生不同的信号,要关注的重点不是进程退出,而是收到了几号信号,因为可以根据信号来判断进程是由于什么原因退出的!!
如下代码和运行结果,为 SIGFPE 设置自定义动作,可是它却重复打印,进程没有退出。
#include<iostream>
#include<signal.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
using namespace std;
void handler(int signo)
{
cout<<"我的进程收到了:"<<signo<<"信号导致崩溃"<<endl;
}
void test1()
{
signal(SIGFPE,handler);
int a=10;
a/=0;
cout<<"10 div 0 here"<<endl;
}
int main()
{
test1();
return 0;
}
重复打印的原因是:当前进程执行除0的代码,CPU的溢出标志位置为1,出现异常。而因为进程没有退出,状态标志位属于进程的上下文,所以无论这个进程被怎么调度,状态标志位始终是异常的,并且进程也没有退出,也没有去改变溢出标志位,所以操作系统就一直检测到硬件异常!一直发送信号。
进程没有退出的原因是,我们设置了自定义捕捉。在 handler里面调用 exit 就可以退出。
执行下列代码,会出现野指针异常。
#include<iostream>
using namespace std;
int main()
{
int* p=nullptr;
//p=100;
*p=100;
return 0;
}
但是其本质却并不简单。首先,指针变量 p 里面保存的是进程地址空间里的地址,也就是虚拟地址。当执行 *p=100; 这个代码的时候,不是直接向 p 这个地址的空间写入内容,而是要先完成虚拟地址到物理地址的转换!这需要 MMU 这个硬件的协助!!
页表除了有虚拟地址到物理地址的映射关系,还有访问权限(当然还有其他标志位),这表示虚拟地址对映射到的物理地址有哪些权限(rwx)。
那么,当执行 *p = 100; 先执行虚拟地址到物理地址的转化,如果操作系统通过 MMU 在页表中没有找到 p 保存的虚拟地址到物理地址的映射关系,那么 MMU 硬件报错。如果查找到了对应的映射关系,但是,并没有写的权限,那么也无法执行这行代码,所以MMU 报错! MMU 硬件报错,会被操作系统识别到,操作系统找到当前进程的 pcb,然后对它发送信号!
📕 核心转储
操作系统可以在进程收到异常的时候,将核心代码部分进行核心转储,将内存中进程的关键数据全部 dump 到磁盘中。这个功能在云服务器上是默认关闭的,需要手动打开。
如下,凡是 Action 为 Term 的,则仅仅终止进程; Action 为 Core 的,会先进行核心转储,再终止进程。
首先可以使用 ulimit -a 指令查看一些信息,如下 core file size 默认设置为0。
使用 ulimit -c 指令修改 core file size 的大小,设为 1024。
如下,是对核心转储功能的验证!
当然了,如何使用核心转储生成的文件才是重点。如下,生成调试文件 mysignal(g++ 指令最后要加上 -g)。运行出错,使用 gdb 调试 mysignal,在 gdb 界面 使用 core-file [filename] 指令,就可以定位错误点!
此外,之前所说的进程等待,等待得到的数据里,有一个 core dump 标志位,就和核心转储有关。如果生成了核心转储文件, core dump 标志位就为1,否则为0。
📕 信号保存
下面是信号其他相关常见概念。
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
如下,在操作系统的 pcb 中,会维护三张表。
pending 表,位图结构,比特位的位置表示哪一个信号,比特位的内容表示是否收到该信号(1为收到,0为未收到)。
bloc 表,位图结构,比特位的位置表示哪一个信号,比特位的内容,代表该信号是否被阻塞。
handler 表,本质上是一个函数指针数组,存储的是一个个指针。该数组下标表示信号编号,特定下标的内容,表示该信号的递达动作(默认、忽略、自定义)。
所以,这也就可以解释,为什么在没有收到信号的时候,就已经知道要对每个信号做什么动作了!就是因为 handler 表的存在!
当然了,信号递达的自定义动作已经通过 signal 函数了解了,默认动作自然不必多说。如果想要观察到忽略的动作,以2号信号为例,可以 signal(2,SIG_IGN); 这表示将2号信号忽略,这样进程收到 2 号信号就不会做任何动作。
信号集函数
由于 block 和 pending 都是位图结构,所以有一个专门的类型 —— sigset_t 来描述。可以实例化出两个 sigset_t 的对象 s1、s2 ,分别控制 block 表 和 pending 表,控制 block 表的叫做信号屏蔽字,控制 pending 表的叫做 pending信号集。
但是,光有类型还不够,也要匹配对应的方法。所以,信号集函数应运而生。sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印 sigset_t 变量是没有意义的。
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
- 函数sigemptyset 初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有效信号。
- 函数sigfillset 初始化set所指向的信号集,使其中所有信号的对应bit置为1,表示 该信号集的有效信号包括系统支持的所有信号。
- 注意,在使用sigset_ t类型的变量之前,一定要调 用 sigemptyset 或 sigfillset 做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用 sigaddset 和 sigdelset 在该信号集中添加或删除某种有效信号。
sigprocmask
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
如果 oldset 是非空指针,则读取进程的当前信号屏蔽字通过oldset参数传出。如果set是非空指针,则根据 set 更改进程的信号屏蔽字,参数how指示如何更改。如果oldset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
how 参数 | 作用 |
---|---|
SIG_BLCOK | set 包含了我们希望添加到当前信号屏蔽字的信号,相当于 mask = mask 按位或 set |
SIG_UNBLOCK | set 包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于 mask=mask&~set |
SIG_SETMASK | 设置当前信号屏蔽字为 set 所指向的值,相当于 mask = set |
如下代码,用来测试 sigprocmask 可以设置 block 表。
#include<iostream>
#include<signal.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
using namespace std;
void showBlock(sigset_t* oset)
{
int signo=1;
for(;signo<=31;signo++)
{
if(sigismember(oset,signo)) cout<<"1";
else cout<<"0";
}
cout<<endl;
}
int main()
{
// 只是在用户层面进行设置
sigset_t set,oset;
sigemptyset(&set);
sigemptyset(&oset);
sigaddset(&set,2); // 向 set 添加 2 号信号
// 设置进入进程,谁调用,设置谁
sigprocmask(SIG_SETMASK,&set,&oset);
int cnt=0;
while(true)
{
showBlock(&oset);
sleep(1);
cnt++;
if(cnt == 5)
{
cout<<"recover block"<<endl;
sigprocmask(SIG_SETMASK,&oset,&set);
showBlock(&set);
exit(1);
}
}
return 0;
}
一开始调用 sigprocmask 将 block 表设置为屏蔽了 2 号信号,所以在键盘按下 Ctr l+c 的时候,2号信号被阻塞,进程没有反应。后来重新设置 block 表才得以收到2号信号。
sigpending
该函数可以得到当前进程的 pending信号集。set 是输出型参数。
📕 信号处理
当进程收到一个信号,并不一定立即处理它,而是在合适的时候处理信号,因为信号的产生是异步的,当前进程可能在做更重要的事情!
当进程从内核态切换为用户态的时候,进程会在操作系统的指导下,完成信号的检测与处理!
用户态与内核态
- 用户态:执行自己写的代码,进程所处的状态。
- 内核态:执行操作系统的代码,进程所处的状态。
在进程地址空间中(以32位为例),并不是 4G 的虚拟地址都存储的用户代码和数据,有 1G 的内核空间,这个空间存储的数据就是操作系统的代码。当然了,也是通过页表映射到物理内存中,映射这片区域的页表就叫做内核页表。
- 所有进程的 [0,3] GB 地址空间是不同的,所以每一个进程都拥有自己的用户级页表。
- 所有进程的 [3,4] GB 地址空间是相同的,所有进程可以看到同一张内核级页表,所有进程可以通过统一的窗口,看到同一个 OS !
- OS 运行的本质,其实都是在进程地址空间运行的!
- 所以,所谓的系统调用 ,其本质就如同调用动态库中的方法,在自己的地址空间进行函数跳转并返回即可!
但是,如果不对此加以限制,就会造成用户的代码,可以随意访问操作系统的代码和数据(因为在同一个进程地址空间中,可以随意跳转),这是不可以的!所以就有了用户态和内核态!想要访问OS的代码和数据,进程就必须处于内核态!
也就是说,内核态可以访问任意代码和数据,用户态只可以访问用户的代码和数据。并且,**当前进程处于什么状态,是在 CPU 的寄存器上保存的!**例如,当前代码要进行系统调用,CPU 会先查看特定寄存器的状态,确认是否处于内核态,再进行后续动作!
那么现在面临一个问题:如何更改进程的状态? 用户肯定不能直接更改,这样无法确保安全性。实际上,操作系统提供的所有系统调用,其内部在正式调用执行逻辑的时候,都会先修改进程的状态为内核态!
处理信号
如下,是信号捕捉的完整过程,以执行自定义方法为例。
- 进程执行系统调用,切换到内核态。
- 系统调用完成,检测信号,如果收到信号并且处理动作是 SIG_DEL 或者 SIG_IGN ,即默认或者忽略,那么对应的动作都可以在由操作系统在内核态直接完成。关键是处理自定义方法!
- 内核态的进程,当然可以执行用户写的代码。但是,处于安全性的考虑,执行自定义方法必须要切换到用户态!这是为了防止 handler 里有对操作系统进行修改或者其他操作的代码,这在内核态是可以直接执行的;并且,如果是用户态执行,可以查到是哪个用户,如果有什么错误也可以溯源。
- 执行完 hander ,不可以直接跳回进程。只能再陷入内核。
- 最后通过 sys_sigreturn() 返回用户态。