目录
前言
一、信号概念
1、生活中的信号
2、计算机中的信号
3、实现信号捕捉
二、产生信号的方式
1. 通过终端按键产生信号
格外拓展:核心转储(core dump)
status
2、调用系统函数向进程发信号
3. 由软件条件产生信号
4、硬件异常产生信号
三、阻塞信号
1、概念
2、内核中的表示
3、信号集操作函数
3.1用户层函数
3.2 系统接口
四、信号捕获
1、用户态和内核态
2、内核如何捕获进程信号
2.1捕获信号过程
2.2 sigaction
五、函数的重入
六、c语言关键字volatile
总结
哈喽,小伙伴们大家好,本篇文章我将和大家一起学习进程信号。进程在某种意义上其实和我们人很像。在大多数情况下,我们的日常生活就是起床、上课、学习、休闲、休息,按部就班的度过每一天。但有时候也会遇到突发状况,比如生病啦,或者家里突然有事需要回家啦,这些突发状况是不可避免的,因为往往是事情推着人走的,我们要有处理突发状况的能力。进程也是如此,通常情况下按部就班的运行着,但保不齐什么时候就遇到突发状况,为了保证进程能在突发状况中做出相应的处理,信号由此产生。操作系统会根据突发状况给进程传递不同的信号,让进程做出相应的处理。看到这里,大家是不是感觉到了信号是一个非常有用的东西呢,那么事不宜迟,拿好小本本,和我一起开始信号的学习吧。
信号不仅仅存在计算机中,同样存在我们的日常生活中。在谈计算机中的信号前,我们先来聊聊生活中的信号。
在日常生活中,信号与我们的关系有如下特征:
在计算机中,进程就好比生活中的人,信号有以下特征:
可以看到,计算机中的信号和生活中的信号其实是很相似的。下面我们思考两个问题:
信号是如何记录的?
我们先来谈谈信号是如何记录的,在学习进程时候,我们应该了解了每个进程都对应一个task_struct(PCB),在这个task_struct中,记录着进程的各种信息,各种信息中同样也包括信号的记录。信号在task_struct中是以位图的方式记录的,task_struct有变量signal,可以把它的类型理解成无符号整数。比特位的位置为信号编号,比特位的内容为是否收到信号,假如收到6号信号就会把第六个比特位置1。我们可以通过下面的命令查看信号编号。
命令:kill -l 功能:查看系统定义的信号列表
信号是如何发送的?
进程收到信号,本质上是进程的信号位图被改了。那么谁有权限改进程内的东西呢?答案当然是OS,操作系统是进程的管理者,拥有绝对的权限。所以信号发送的本质就是操作系统修改了进程的信号位图,进程根据修改后位图的值做出相应处理。
signal函数
原型:signal(int sig, void (*func)(int))
功能:用来自定义信号处理方式
参数:
- sig:信号编号
- func为函数指针,传入的函数为信号的处理方式
代码如下:
代码运行结果如下,ctrl+c对应的是信号2,终止进程。我们可以发现信号2被我们自定义了。
注意:有些信号是不能被捕捉的,因为如果所有的信号都被捕捉,那么操作系统就再也没有办法杀死进程了。
通过终端按键也就是通过键盘产生信号,比如我们常用的ctrl+c。ctrl+/。
注意:
man 7 signal 功能:查看信号详细信息
从上面我们可以看出:SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core Dump,接下来我们来验证一下。
再验证之前我们先来学习一个概念:Core Dump
在程序正常结束后,我们可以通过错误码判断程序是否运行正确。但代码在运行过程中出错,我们也要有办法判断,其中一个方法就是调试,除此之外,linux为我们提供了核心转储功能。当一个进程要异常终止时,可以选择把进程的核心内存数据全部保存到磁盘上,文件名通常是core,这叫做Core Dump。
核心转储功能一般在云服务器,线上生产时是关闭的。
ulimit -a 查看系统资源
可以发现core文件大小为0。想要使用此功能,我们首先要改一下core文件大小。
可以发现在发送信号后进行了(core dumped),并且多出了一个core.6229文件。我们使用gdb调试myproc,再查看core文件,可以得到进程异常终止信息。这种方法也适用于代码中内存越界,除0等错误,可以快速定位到第几行。
大家是否还记得在进程等待的时候有一个status,被信号所杀时候,其中第低八位就是core dump标志,如果发生了core dump,则该位为1,否则为0。
kill函数
kill命令:kill 进程pid 信号编号
kill命令是调用kill函数实现的。kill函数可以给一个指定的进程发送指定的信号。
int kill(pid_t pid, int signo);
signo为信号编号。成功返回0,失败返回-1。
我们也可以尝试自己来写一个kill命令。代码如下:
#include
#include
#include
void usage(char* proc)
{
printf("usage:%s pid signo\n ",proc);
}
int main(int argc,char* argv[])
{
if(argc!=3)
{
usage(argv[1]);
return 1;
}
pid_t pid=atoi(argv[1]);
int signo=atoi(argv[2]);
kill(pid,signo);
return 0;
}
raise函数
raise函数可以给当前进程发送指定的信号(自己给自己发信号)。
int raise(int signo);
成功返回0,失败返回-1。
abort函数
void abort(void);
功能:向自己发送6号信号SIGABRT
注意:SIGABRT可以被捕捉,但是捕捉之后依然会让进程终止,这就是SIGABRT的特点
就像exit函数一样,abort函数总是会成功的,所以没有返回值。
alarm函数
#include
unsigned int alarm(unsigned int seconds);
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动作是终止当前进程。
alarm的返回值是0或者是剩余秒数。如果闹钟被提前唤醒,返回值为剩余秒数,否则是0。
写一个计数程序,看看cou一秒钟可以计多少次数。
结果如下:
在代码中有野指针,除0等操作时程序会出现异常,这时候会产生信号,然后程序崩溃。程序崩溃的本质就是收到了信号。
下面对这个过程进行具体解释:以除0操作为例,我们知道计算都是在cpu中的,cpu中有一个状态寄存器,当进行除0操作后,状态寄存器会异常。os是软硬件的管理者,当检测到cpu状态异常后,会定位到相应的进程,更改进程的信号位图,进程通过位图识别到信号发生崩溃。
再比如当前进程访问了非法内存地址,负责虚拟地址与物理内存的一个硬件MMU会产生异常,之后和上述过程一样。
sigset_t类型
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。
sigset_t每一位都代表一个信号的“有效”或“无效”状态,至于sigset_t内部是如何存储这些数据的我们不需要关心,是由操作系统去维护的。所以我们想要操作sigset_t变量要通过下面的这些函数,而不应该对内部数据有任何操作,因为这是没有意义的。
#include
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);
sigismember是一个bool函数,用来判断某个信号集中是否包含某个有效信号,包含返回1,不包含返回0。
我们上面定义的sigset_t类型变量是在栈中的,本质上是在用户层,没有进操作系统。我们对它的操作仅仅是改变这个变量的值,并不会影响进程的任何行为。我们想借助sigset_t类型变量影响系统和进程需要调用下面的接口。
sigprocmask函数
#include
原型:int sigprocmask(int how, const sigset_t *set, sigset_t *oset);功能:来读取或更改进程的阻塞信号集。
返回值: 若成功则为0,若出错则为-1
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
sigpending函数
#include
原型:int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
下面我们写一个进程来完成以下操作:
代码如下:
void hander(int signo)
{
printf("%d阻塞结束\n",signo);
}
int main()
{
sigset_t set,oset;
sigemptyset(&set);
sigemptyset(&oset);
sigaddset(&set,2);
sigprocmask(SIG_SETMASK,&set,&oset);//将2阻塞
signal(2,hander);//捕获2信号
sigset_t pending;
int count=0;
while(1)
{
sigpending(&pending);//获取未决信号集
//每隔一秒打印未决信号集
signal_printf(&pending);
sleep(1);
count++;
if(count==10)
{
//十秒后解除阻塞
sigprocmask(SIG_SETMASK,&oset,NULL);
}
}
return 0;
}
用户态:执行用户自己的代码,系统所处的状态叫做用户态。用户态是一种受监管的普通状态。
内核态:有时候我们写的代码中,调用了系统接口,本质上就是调用了内核级的代码,这时候就需要内核态权限。内核态通常用来执行os代码,是一种权限非常高的状态。
用户态->内核态:系统调用,时间片到了导致进程切换,异常,中断,陷阱,这些情况会切换到内核态。
内核态->用户态:系统调用,进程切换,异常,中断,陷阱处理完毕后,会切换回用户态。
在用户态时,访问用户的代码和数据,切换到内核态后,比如调用系统接口后,往往会执行操作系统内核中的代码。那么进程是怎么找到操作系统的呢?首先要明确一个概念,操作系统也是一款软件,既然是软件运行的时候就会被加载到内存中。每个进程都有一张地址空间表,这张表下半部分为用户区,通过用户页表映射找到内存中用户的代码和数据。上方为内核空间,保存了内核的虚拟地址,可以通过内核页表映射找到内存中操作系统内核的代码和数据。
进程无论如何切换,都能看到操作系统,但不一定能访问,只有处在内核态时才能够访问。那么处于内核态还是用户态的标志是什么呢?我们知道代码是加载到cpu中进行运算的,在cpu中有一个cr3寄存器,里面记录了是否此时的状态是用户态还是内核态。同时cpu中还有寄存器记录着用户页表和内核页表的值,当在某种状态下访问越界,cpu可以检索到。
信号递达时处理的动作为用户自己定义的函数,这称为捕捉信号。
通过前文的学习我们知道,进程在收到信号后不一定是立刻处理的,而是等到适合的时候。那么什么是适合的时候呢?答案就是系统从用户态切到内核态后。
假设用户程序注册了信号2的处理函数sighandler。下面我来阐述捕获进程信号的过程:
思考一个问题:在内核态是否能调用用户的代码和数据呢?
理论上以内核的权限是可以的,但实际上并不能这么做,因为操作系统不信任任何人,它担心用户会越权执行一些非法操作。
#include
原型:int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);功能:sigaction函数可以读取和修改与指定信号相关联的处理动作。
返回值:调用成功则返回0,出错则返回- 1。
参数:signo是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非空,则通过oact传出该信号原来的处理动作。
act和oact指向sigaction结构体:
sa_handler为我们自己定义的处理函数。当某个信号的处理函数被调用时,内核会自动将该信号加入当前进程的阻塞信号集,等调用结束后再恢复。这就保证了在处理某个信号时,如果这种信号再次产生,会被阻塞到当前信号处理完毕为止。如果在调用信号处理函数时,除了阻塞当前信号外,还想阻塞其它信号,则用sa_mask设置,调用完毕后自动恢复。sa_flags涉及一些选项,而siginfo是实时信号的处理函数,本章不做解释。
测试代码如下:
struct sigaction act, oact;
void handler(int signo)
{
printf("get a signal: %d\n", signo);
sigaction(SIGINT, &oact, NULL);
}
int main()
{
memset(&act, 0, sizeof(act));
memset(&oact, 0, sizeof(oact));
act.sa_handler = handler;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
sigaction(SIGINT, &act, &oact);
while(1){
printf("I am a process!\n");
sleep(1);
}
return 0;
}
我们来看下面的过程:
mian函数正在调用insert函数向链表中插入节点。insert函数分为两步,刚刚执行完第一步时此时硬件发生中断,使进程切换到内核。中断处理完毕切换到用户态之前发现有信号未决,于是进入了信号的处理函数,信号的处理函数中同样有insert操作,于是向链表中插入了一个新节点。进行完毕后返回到main函数,按照上下文接着执行main函数中insert函数的第二步,令头节点指向node1。从图中可以看出,这时候出现了一个问题,明明我们想要两个节点插入,可实际仅仅插入了一个,node2丢失了,也就是我们平常说的内存泄漏。
像上例这样,insert函数被不同的控制流调用,在第一次调用还没有返回时就再次进入该函数,这称为重入。如果insert访问的是一个全局链表,就可能因为重入而产生错误,这称为不可重入函数。相反,如果一个函数只访问自己的参数和局部变量,则称为可重入函数。
如果一个函数符合以下条件之一则是不可重入的:
我们平常所调用的函数,自己写的函数,STL中的库函数基本都是不可重入的。
volatile是c语言中的一个关键字,在语言层面我们很难理解它,今天从信号角度我们来理解一下。
我们来看下面一段代码:
从代码表面看上去运行逻辑应该是,flag为0时候一直while循环,当有信号2产生进入到信号2的处理函数中,flag改成1,循环结束,进程退出。但是事实真的是如此吗?
我们用编译器O3的优化级别来编译(默认是O1或O2),然后运行,结果如下:
我们发现收到信号后进程并没有退出。原因是main函数和和信号处理函数是两个执行流,while循环在main函数中,编译器只会检查main函数,发现flag并没有main函数中改变。在优化级别较高的时候,flag会被直接写进cpu的寄存器中,cpu读取flag时直接从寄存器里读,信号处理函数改变flag改变的是flag在内存中的值,寄存器中flag的值始终保持0。
为了解决这个问题,我们可以在定义flag的时候前面加上volatile。
volatile int flag=0;
再次编译运行,发现可以退出:
volatile的作用:
避免变量被写到寄存器中,就算写到内存器中,读取时也要先读取内存中的值,然后刷新到寄存器里。
本文主要介绍了linux系统下信号的相关知识,希望能给大家带来帮助。如果觉得写的还不错的话可以点赞支持一下博主,我也将努力为大家带来更加优质的内容。感谢阅读,山高路远,来日方长,我们下次见。