目录
前言
基本介绍
概念
信号列表
信号处理
产生(发送)信号
通过按键产生
系统函数产生
软件条件产生
硬件异常产生
阻塞信号
信号状态
sigset_t
状态相关函数
1.sigprocmask
2.sigpending
捕捉信号
内核态与用户态
捕捉过程
sigaction
后记
先甭说linux信号,想一想生活当中存在哪些信号,有红绿灯、发令枪,手机提示音......,比如对于红绿灯而言,我们在过马路时候会看红绿灯并肉眼收到红绿灯信号,在看到红绿灯后我们也知道该做出什么动作,红灯停下绿灯直行。那么对于linux的信号,也是如此,os约定或规定了一些信号,在某时刻os发送给进程相应的信号,进程收到信号以后根据约定做出相应的动作,这就是关于信号的一个宏观的描述。
在之前讲过地,当一个进程运行时,我们按下ctrl+c可以终止此进程,这里ctrl+c表示给进程发送2号信号,此信号地默认处理动作是终止,因此按下之后,进程就会立马终止掉,下面来看看关于信号产生和发送的具体细节吧。
信号(signal)是一种进程间通信机制,是操作系统传递给进程的一种通知。它被用来通知进程发生了某种特殊情况,如外部事件的发生,如终端输入、中断、定时器到期等等。具体地,用户或os通过发送一定的信号通知进程,某些事件已经发生,你可以立即或者后续处理,因此信号产生与接收对于两个进程来说是异步的,进程必须将信号临时记下,方便后面处理。其中,异步表示两个进程互不等待,各自干各自的事,相反同步是一个进程发信号给另一个进程,之后这个进程等待另一进程的反馈后才继续运行,而异步则是不等待,发出信号后接着继续向后运行。
如下图1,使用kill -l命令查看os定义的信号列表。其中1-31号信号属于普通信号,也是常见的一部分信号,32-64属于实时信号(不重点讨论)。其实看到这些信号都是大写的,应该知道是宏定义。当os规定这些信号时,也规定了默认的执行动作是什么,可通过man 7 signal命令查看,如下图2、3。
对于上面的大部分函数,我们可以通过signal函数捕捉某信号来决定其处理方式,有三种方式可以选择:
①忽略,也就是不作为;
②默认,执行os规定的默认执行动作;
③自定义捕捉,用户提供一个处理函数,进程处理此信号时会执行此函数。
功能:修改(允许修改的)信号处理方式,
参数:signum传入信号编号,sighandler_t是函数指针,传入SIG_IGN表示捕捉动作为忽略,传入IGN_DFL表示捕捉动作为默认动作,传入你所提供的处理函数的函数名表示执行自定义行为,
返回值:若成功,返回值也是函数指针,是指向之前的信号处理函数的指针,若失败则返回SIG_ERR。
eg:
void func(int signnum)
{
sleep(1);
printf("\n%d号信号正在处理,pid:%d\n",signnum,getpid());
}
int main()
{
signal(2,func);
while(1)
sleep(1);
return 0;
}
本质上,进程pcb内部具有保存信号的相关数据结构(位图),即信号位图字段(可以看到普通信号正好31个),通过修改0/1比特位来标识是否存在此信号,因此os向目标进程写信号——修改pcb中指定位图结构,完成信号发送,而此进程也是在合适的时候通过查看信号位图结构以进行相应动作。而信号的产生方式又分为以下几种,包括通过按键产生、系统函数产生、软件条件产生、硬件异常产生,下面分别介绍。
按键产生信号,最常见的就是在前言中说过的ctrl+c,发送2号信号SIGINT,默认处理动作是终止程序,此外还有3号信号SIGQUIT,按ctrl+\可以产生,默认处理动作是终止程序并且Core Dump。
Core Dump解释:
中文叫作核心转储,当一个进程异常终止时,用户可以选择允许产生core文件,把进程的用户内存数据结构全部保存到磁盘上,命名为【core.进程pid】。不止上面的3号信号可以core dump,很多信号都可以,具体可以通过man 7 signal查看信号手册。对于一个可以core dump的信号,默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,产生不安全的影响,但用户可以通过ulimit指令选择允许产生core文件,比如说允许core文件大小最大为1024k:ulimit -c 1024,而ulimit -c 0则是当用户不需要核心转储的时候不产生core文件,过程中可以通过ulimit -a来查看core文件大小等相关信息,如下图。
如上就生成了core文件,使用gdb指令在调试过程中,输入【core-file core文件名】加载core文件即可来到产生信号的代码处进行调试,具体可看相关文章,这里不再赘述。
之前讲过,我们可以通过指令【kill -信号编号 进程pid】给某进程发送信号,而kill指令是通过kill函数实现的,除此之外,还有raise函数、abort函数,这些都是os所提供的产生信号的系统函数,具体如下。
kill函数:给指定进程发送指定信号,
raise函数:给函数所在进程发送指定信号,
abort函数:给函数所在进程发送6号信号SIGABRT,直接终止。
前两个函数都是成功返回0,失败返回-1,abort函数就像exit函数一样,总是成功执行,无返回值。
eg:
int main()
{
int n=5;
while(n--)
{
sleep(1);
printf("%d\n",n);
}
//kill(getpid(),2);
raise(2);
n=5;
while(n--)
{
sleep(1);
printf("%d\n",n);
}
return 0;
}
在之前的管道章节中说过,如果所有管道读端对应的文件描述符被关闭,则write操作会产生SIGPIPE信号,进而导致write进程退出,这里的SIGPIPE就是一种由软件条件产生的信号,此外如下的alarm函数可以产生SIGALRM信号,也是一种由软件条件产生的信号,alarm可以设定一个闹钟,在指定秒数后给当前进程发送SIGALRM信号,默认处理动作是终止当前进程。
其中,seconds可以指定秒数,返回值是0或者是此闹钟之前所设定的闹钟还剩下的秒数。
eg:
void func(int signnum)
{
sleep(1);
printf("\n%d号信号正在处理,pid:%d\n",signnum,getpid());
}
int main()
{
signal(14,func);
alarm(10);
int n=10;
while(n--)
{
if(n==7)
{
int ret2=alarm(3);
cout <<"ret2:"<
硬件异常被硬件以某方式检测到并通知内核,然后内核像该进程发送适当的信号。比如说,
①执行到除0的代码,cpu的运算单位会发生异常,os就会发送SIGFPE信号给此进程,本质上cpu内部的状态寄存器有对应的状态标记位(溢出标记位),os在计算完毕后检测到溢出标记位是1,则为异常,之后发送信号给进程;
②进程访问非法内存地址,比如空指针、越界等,MMU(Memory Manage Unit,硬件,与页表一起进行内存映射)会产生异常,os此时发送SIGEGV信号给进程,本质上访问非法地址就是在将虚拟地址转物理地址的过程中,MMU一定会报错,之后os会给对应进程发送信号。
值得注意的是,发生硬件异常,进程不一定会退出,只是默认处理动作里有退出操作,当我们自定义行为,不进行exit等相关退出操作时,就会死循环,因为硬件异常一直未被处理。
eg:
void func(int signnum)
{
sleep(1);
printf("\n%d号信号正在处理,pid:%d\n",signnum,getpid());
}
int main()
{
signal(SIGFPE,func);
int a=10;
int b=0;
int c=a/b;
while(1)
sleep(1);
return 0;
}
信号抵达(delivery):执行信号的处理动作
信号未决(pending):信号从产生到抵达的状态
阻塞(block):使信号保持在未决状态,直到解除才执行处理动作
值得注意的是,阻塞和处理动作中的忽略不一样,信号被阻塞了就不会被抵达,而忽略是信号抵达了之后的一种处理动作。如下图是信号在内核中的相关数据结构示意图,在pcb中,存在block阻塞信号集、pending信号集、handlers处理方法表等相关数据结构。
block阻塞信号集:也叫信号屏蔽字,是一种位图结构,下标是信号编号,表中的1/0代表对应信号是否被阻塞
pending信号集:也是位图结构,下标是信号编号,表中的1/0代表对应信号是否是未决状态
handlers处理方法表:是一个函数指针数组,下标也是信号编号,当拿到信号编号signum时,并不是直接handlers[signum]()调用此函数,而是先判断(int)handers[signum]==0,则执行默认(SIG_DFL)动作,若==1(SIG_IGN)则执行忽略动作,若都不是然后才执行handlers[signum]()调用函数,其中SIG_DFL、SIG_IGN是由0、1强转成函数指针类型的宏定义,如下图:
当os给某进程发送信号,也就是将此进程中的pending表中的对应位置由0置1,在合适的时候,进程会“遍历”pending表,遇到1后,不是直接调用handlers表中的函数,而是去block表中查看对应信号是否被阻塞,若block为1则不作为(等之后解除阻塞再说),若block为0则直接去调用对应函数。
sigset_t是os提供的位图结构类型,与c++中的位图一样,但是我们不必究其实现细节,只需要记住关于它的操作函数,也就是由0置1、由1置0等相关接口,包括
#include
int sigemptyset(sigset_t *set);//将所有信号对应bit置0 int sigfillset(sigset_t *set);//将所有信号对应bit置1 int sigaddset (sigset_t *set, int signo);//将对应信号bit置1 int sigdelset(sigset_t *set, int signo);//将对应信号bit置0 int sigismember(const sigset_t *set, int signo);//判断对应信号bit是否为1 上面前四个函数都是成功返回0,失败返回-1,后一个是bool函数,出错返回-1。
注意:使用sigset_t类型的变量之前一定要先使用sigemptyset、sigfillset接口初始化。
eg:
int main()
{
sigset_t st;
sigemptyset(&st);
for(int i=31;i>=1;i--)//打印block表函数
{
if(sigismember(&st,i))
cout<<"1";
else
cout<<"0";
}
cout<=1;i--)//打印block表函数
{
if(sigismember(&st,i))
cout<<"1";
else
cout<<"0";
}
cout<
sigprocmask函数可以修改或者读取当前进程的信号屏蔽字。如下,
set:一组将要添加或删除的信号集合
how:可以填SIG_BLOCK、SIG_UNBLOCK和SIG_SETMASK,SIG_BLOCK代表将set的信号增加到信号屏蔽字中,SIG_UNBLOCK代表将set中的信号从信号屏蔽字中删除,SIG_SETMASK表示直接将信号屏蔽字设置成set
oset:输出型参数,备份修改之前的信号屏蔽字(若不需要可置空),若未修改(即set为空),则该函数可得到信号屏蔽字
#include
int sigprocmask(int how, const sigset_t *set, sigset_t *oset); 举个例子,比如说你想要阻塞1、2、3信号,就要通过sigset_t接口将set对应1、2、3比特位由0置1,再传入函数,此时how就得设置成SIG_BLOCK,同时若你想要原来的信号屏蔽字,则在外面定义一个set变量放入oset,执行函数之后,则此set就是原来的信号屏蔽字。
eg:
void handler(int signum)
{
cout<<"signum:"<
sigpending函数可以读取当前进程的未决信号集,通过set这个输出型参数传出,成功返回0,失败返回-1。
#include
int sigpending(sigset_t *set);
eg:
int main()
{
sigset_t pendingset;
sigemptyset(&pendingset);
sigset_t blockset;
sigemptyset(&blockset);
sigaddset(&blockset,2);
sigaddset(&blockset,3);
sigprocmask(SIG_BLOCK,&blockset,nullptr);//屏蔽2、3号
while (1) // 1秒打印一次pending表
{
sleep(1);
sigpending(&pendingset);
for (int i = 31; i >= 1; i--)
{
if (sigismember(&pendingset, i))
cout << "1";
else
cout << "0";
}
cout << endl;
}
return 0;
}
综上,我们可以去block一个信号,也可以去捕捉一个信号,那当我们把所有信号捕捉或者堵塞,那么是不是就写了个不会被异常或用户杀死掉的进程了?当然不是,os也想到这一点了,其中有一个信号9号信号(SIGKILL)不可被阻塞或者捕捉,可使用此信号终止任何进程。
前面提到地,进程接受到信号,可能无法立即处理,需要在合适的时候处理,那什么时候合适呢?答:从内核态返回用户态的时候,进行信号检测和处理。
内核态:os执行自己代码的状态,此状态具有很高的优先级
用户态:执行用户写的代码时的状态,是一个受管控的状态
注意:
①内核态返回用户态之前因为什么进入内核态?因为需要进行系统调用、缺陷、陷阱及异常等
②cpu内有状态寄存器CR3来标识这两种状态
我们知道,如下图,进程地址空间有4G,其中3G是用户地址空间,1G是内核地址空间,用户地址空间通过用户级页表映射到物理内存中,内核地址空间通过内核级页表(可以被所有进程看到)映射到物理内存上,顾名思义,用户写的代码“占用”的是用户地址空间,而内核地址空间“存储”的是os自带的系统调用代码等,内核地址空间在每个进程地址空间都有一份(可类比于动态库的调用模式)。因此在用户地址空间运行叫做用户态,在内核地址空间运行叫做内核态。
如果信号的处理动作是用户自定义函数,在信号递达时调用这个函数,这称为捕捉信号如下图是信号捕捉的流程,可以看出,这像一个无穷大符号,以此来简化记忆。
注意:
- 每个箭头穿过用户地址空间和内核地址空间的分界线的地方就是用户态和内核态的一次转化
- 对于③,这一步就是上面说的先检查pending表,再看block表,然后去handlers表调用函数的过程,只不过这里是捕捉信号的过程,没有谈及处理动作是忽略或默认的情况,若处理动作是忽略,则将pending表对应比特位由1置0,再返回主函数中向下执行,若处理动作是忽略,则处理动作是默认,一般是终止,则停止调度此进程,将pcb、地址空间等释放,并且不用再返回主函数处
- 对于④->⑤,想一下为什么执行完了自定义处理函数之后不直接返回到主函数的中断处,还要先回到内核地址空间再返回?因为一方面执行完handler函数后需回到内核将pending表对应位置由1置0,另一方面只有内核知道当初从主函数中断的地方,回到内核才能拿到关于此的上下文。
sigaction函数可以读取和修改与指定信号相关联的处理动作。
#include
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact); 返回值:调用成功则返回0,出错则返回-1。
参数:
signo:信号编号。
act:若act指针非空,则根据act修改该信号的处理动作;
oact:若oact指针非空,则通过oact传出该信号原来的处理动作。
其中struct sigaction是结构体,具体如下:
其中,sa_handler变量传入自定义处理函数,sa_mask是sigset_t类型的变量,可以传入一个信号集,表示若想要调用此函数时自动屏蔽一些信号,可以把想要屏蔽的信号放进此变量中,其他的成员使用与实时信号有关,暂时不考虑,其中sa_flags可以设置为0。
eg:
void handler(int signum)
{
cout << "signum:" << signum << ","
<< "pid:" << getpid() << endl;
}
int main()
{
struct sigaction act;
act.sa_handler=handler;
sigaction(2,&act,nullptr);
while(true)
sleep(1);
return 0;
}
捕捉信号不仅可以使用这里介绍sigaction函数,也可以使用前面介绍的signal函数,看以看出signal函数的使用比sigaction函数简单一些,但sigaction函数的功能比signal函数要多一些,下面看一下这两个函数的区别:
①signal函数每次设置的信号处理函数只能生效一次,在执行完此处理函数后,随即将信号处理函数恢复为默认处理方式。所以如果想多次相同方式处理某信号,就得在处理函数中再次调用signal设置,如下。但是sigaction函数设置后并且执行了处理函数后,一直有效不会重置;
②sigaction函数保证了在信号处理函数被调用时,系统建立的新信号屏蔽字会自动包括当前正在递达的信号。因此在处理一个给定信号时,如果这种信号再次发生,那么它会被阻塞到处理结束为止,同时sigaction结构体的sa_mask可以保证另外的你想要屏蔽的信号。
int handler(int signum) { //... signal(SIGINT, handler); //... } int main() { signal(SIGINT, handler); //... }
信号这一章节的知识点不算特别难,但是算比较多的,还涉及到与线程部分耦合的知识点还未讲解,将会在后面的多线程章节进一步阐述,理解信号这一领域的知识点 不能与现实情境脱离,借助现实情况可简化理解难度,比如红绿灯发出的信号、信号枪发出的信号。在面试中,信号也是一个高频的考点,包括信号产生的过程,捕捉信号的过程,及涉及到其中的系统函数,都在本文章中一一介绍过,多看几遍,拜拜!