4.2在内核中的表示
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号
4.3 sigset_t
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。 因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号 的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有 效”和“无效”的含义是该信号是否处于未决状态。下面将详细介绍信号集的各种操作。 阻塞信号集也叫做当 前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
4.4信号集操作函数
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。
#include <signal.h>
#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置满,表示该信号集的有效信号包括系统支持的所有信号。注意,在使用sigset_ t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。
4.5 sigprocmask
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
4.6 sigpending
#include <signal.h>
sigpending
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
现在我们可以用刚才介绍的函数做实验:实验内容为屏蔽2号信号,对2号信号进行自定义捕捉,获取pending信号集并打印,过一段时间后解除对2号信号的屏蔽,再次打印pending信号集。
代码实现:
#include<iostream> #include<signal.h> #include<unistd.h> using namespace std; void printSigpending(sigset_t& pending) { for(int i=1;i<=31;++i) { if(sigismember(&pending,i)) cout<<"1"; else cout<<"0"; } cout<<endl; } void hander(int signo) { cout<<"执行对"<<signo<<"号信号的自定义捕捉动作"<<endl; } int main() { signal(2,hander); sigset_t set,oset; sigemptyset(&set); sigemptyset(&oset); sigaddset(&set,2); sigprocmask(SIG_SETMASK,&set,&oset); int cnt=0; while(true) { sigset_t pending; sigemptyset(&pending); int n=sigpending(&pending); printSigpending(pending); sleep(1); if(cnt++==10) { cout<<"解除对2号信号的屏蔽"<<endl; sigprocmask(SIG_SETMASK,&oset,nullptr); } } return 0; }
效果展示:
我们不难从上面发现当我们发送了多次2号信号并且2号信号处于屏蔽状态时只会保留一次。
此时我们想终止该进程时可以用ctrl+\
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。
5 信号的处理
5.1 问题引入
我们之前讲解过,信号可以不是被立即处理的,而是在一个合适的时候,这个合适的时候是什么时候呢?
当进程从内核态
转换到用户态
的时候会在OS的指导下进行信号的检测。
那什么是用户态,什么是内核态呢?
5.2内核态和用户态
先用通俗易懂语言来描述下:
用户态:当执行用户自己的代码时,进程所处于的状态;
内核态:当执行OS的代码时,进程所处于的状态。
那么在哪些情况下进程会从用户态转换到内核态呢?
进程的时间片到了,需要进行进程的切换时;
进行系统调用等。
我们还可以从地址空间上来理解,不知道大家忘记了下面这张图片了没有?
在32位的地址下,内存有4GB大小,其中【0,3】是用户空间,【3,4】是内核空间。执行用户空间的代码的进程就处于用户态,执行内核空间的代码的进程就处于内核态。
在用户空间中每一个进程都有一张用户级别的页表,用户级别的页表在不同的进程下有可能是不相同的;除此之外每一个进程还会为内核空间分配一个内核级别的页表,而不同的进程的内核级页表是相同的,所以不同进程就看到了同一份页表。所以OS运行的本质是在进程的地址空间上运行的,当我们调用OS的代码是直接在进程地址空间进行跳转即可。
那么问题来了,OS是如何判别用户态和内核态呢?
在CPU中有一种叫做CR3的寄存器,寄存器中3表示进程处于用户态,0表示进程处于内核态。
所以我们现在可以解释一下进程调度的过程:OS时钟硬件每隔一段时间都会给OS发送时钟中断,OS会执行对应中断的处理方法(检测时间片),然后将进程的上下文进程保存和切换,选择合适的进程,OS处理时采用的是schedule函数。
5.3 信号的捕捉
当我们自定义了信号的捕捉方式时,整个过程中进程从用户态到内核态的转换:
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态而不是恢复main函数的上下文继续执行,而是执行sighandler函数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。 sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
这时我们可能就会有一些小问题:当执行了某种信号时,pending位图是在hander方法处理完之前还是处理完之后被置为0的呢?
这个问题很好验证,我们只需要在自定义捕捉时打印一下即可,我们放在sigaction一起验证。
5.4 sigaction
我们可以查一下man手册:
#include <signal.h> int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signo是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体:
将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags字段包含一些选项,本章的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函数。这里就不在过于多说,感兴趣的同学可以自己去查询。
我们可以写代码来验证下:
static void PrintPending(const sigset_t &pending) { cout << "当前进程的pending位图: "; for(int signo = 1; signo <= 31; signo++) { if(sigismember(&pending, signo)) cout << "1"; else cout << "0"; } cout << "\n"; } static void handler(int signo) { cout << "对特定信号:"<< signo << "执行捕捉动作" << endl; int cnt = 30; while(cnt) { cnt--; sigset_t pending; sigemptyset(&pending); // 不是必须的 sigpending(&pending); PrintPending(pending); sleep(1); } } int main() { struct sigaction act, oldact; memset(&act, 0, sizeof(act)); memset(&oldact, 0, sizeof(oldact)); act.sa_handler = handler; act.sa_flags = 0; sigemptyset(&act.sa_mask); sigaddset(&act.sa_mask,3); sigaddset(&act.sa_mask,4); sigaddset(&act.sa_mask,5); sigaction(2, &act, &oldact); while(true) { cout << getpid() << endl; sleep(1); } }
效果展示:
通过上面的演示我们可以验证pending位图是在hander方法处理完之前就已经清0了。
6 可重入函数
首先我们先来看看这样一种场景:
当我们执行其中一个进程代码的insert方法时,执行了第一行代码后该进程的时间片到了,调用了另外进程同样也执行了insert方法的第一行代码,然后切回到最开始的进程执行第二行代码,此时就造成了node2的内存泄漏,而这种函数就叫做不可重入函数
反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。但是使用局部变量就不会造成上面的混乱问题。
如果一个函数符合以下条件之一则是不可重入的:
调用了malloc或free,因为malloc也是用全局链表来管理堆的。
调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
7 volatile
这个关键字我们在C语言时就已经涉猎过,现在我们在从系统的角度再来理解一下。
我们先来看这样一段代码:
#include<iostream> #include<signal.h> #include<unistd.h> using namespace std; int g_val=1; void hander(int signo) { cout<<"g_val form 1 to 0"<<endl; g_val=0; cout<<g_val<<endl; } int main() { signal(2,hander); while(g_val); cout<<" success quit"<<endl; return 0; }
Makefile中代码的编译我们加了O2
优化
当我们运行时:
我们发现当我们在终端下一直敲Ctrl+C时程序并不会运行,最后敲了Ctrl+\才退出,为什么呢?
这是由于编译器做了优化,它是怎么优化的呢?
我们知道编译器取数据都是到内存上面去取到的,当编译器识别到变量g_val时,由于在主函数里面没有直接修改该变量的值,所以编译器认为你没有修改这个变量,那我就将变量放到寄存器中,我们使用该变量时就不用在到内存上面去取了,直接在寄存器中取出数据即可,而寄存器中的数据一直保存的是1,所以该程序就死循环出不来了。
有什么办法去掉编译器的优化吗?我们可以加volatile关键字在变量前面,这就告诉编译器不要做优化了,也就是不要将该变量放在寄存器中,每次你取数据时还是老老实实到内存上面来取,这样做可不可行呢?我们接下来试试:
运行结果:
我们可以观察到这种方式是可行的。
其实不仅仅在Linux上,在VS中也会出现这样的优化,来看看下面的代码:
大家可以猜猜结果:
我们来调试一下:
在内存窗口中发现他们都是20,但是当我们看打印结果时就傻眼了:
打印结果居然是10和20,这里其实也是编译器做了优化,编译器认为既然你a加了const属性,那么我就认为你不可以直接修改,就直接把变量a放在了寄存器中,所以在打印结果中我们看到的是10而内存窗口看到的是20,我们加了volatile
关键字后看到的结果都为20了: