目录
前置知识
捕捉信号
内核如何实现信号的捕捉
sigaction
信号的其他补充问题
可重入函数
volatile关键字
SIGCHILD信号
前置知识
什么是用户态,内核态呢?
这里我们再来看看进程的地址空间:
我们知道每一个进程都会有自己的地址空间:把0-3GB的空间叫做用户空间,3-4GB的叫做内核空间。用户空间是用来记录,我们所编写的代码,数据及定义的变量等,那么内核空间使用来干嘛的呢?
我们知道操作系统也是一个进程,也要加载到内存,并且是计算机启动后第一个执行的进程,因此操作系统的内容也要被记录到地址空间里,而内核空间就是用来记录操作系统的。与普通进程一样操作系统也有一张页面去映射物理内存上的内容。
为了区分:把操作系统的页表叫:内核级页表。普通进程的页表叫做:用户级页表。
因为在开启计算机时操作系统只执行一次,可以管理各个进程的,因此每一个进程都共享一个操作系统,一个内核级页表。
由于操作系统并不信任用户,用户不能直接访问硬件等重要资源,需要调用系统调用函数,那么是如何调用系统调用函数呢?
这里我们先看普通函数的调用,这里我们看汇编代码,一般是用call+函数的地址,在进程地址空间中找到函数,然后通过页表的映射找到物理内存中执行内容。而系统调用函数的调用也是类似的。
用call+函数地址,由于系统调用属于操作系统函数的地址空间在内核空间里。因此需要在进程用户空间,跳转到对应的内核空间,然后在内核页表的映射中找到物理内存存储的内容。在执行完系统调用时,就重新会到地址空间完成后续代码。
注:在进程空间跳转到内核空间需要完成从用户态转变成内核态。从内核空间到用户空间也要进行状态转换。
我们知道在执行代码时,CPU会进行参与运算等,而在CPU里有一个CS寄存器,里面用两个比特位来表示表示所处的状态,1为内核态,3为用户态。所以状态的转换就是修改了CS寄存器里的内容。
注:这里修改CS里的内容是在系统调用最开始的入口处完成的。
因此我们可以知道:在用户空间执行就是用户态,在内核空间执行就是内核态。
捕捉信号
在前面提到,如果进程在接受信号时,正在执行更加重要的任务,进程并不会立即处理而是在合适的时候。那么这个合适的时候是什么呢?
这里我们知道信号相关的数据字段是在进程的PCB中存储着的,PCB内部属于内核范畴,普通用户无法对信号直接进行处理。要想对信号处理,就必须是内核态。当调用系统调用或被系统调用时,进程所处的状态就是内核态,不执行操作系统代码时,进程所处的状态就是用户态。
结论:在内核中,从内核态返回用户态的时候,进行信号的检测与处理。
内核如何实现信号的捕捉
在内核态处理信号时,就会到进程的PCB里查看block,pending,handler表中的内容,这里我们知道在handler表中的动作可以分为三种:默认动作,忽略动作,用户自定义动作。我们知道默认动作,忽略动作在操作系统早已写好(在内核里),而第三种是由我们自己编写的(在用户空间里)。这里我们在处理信号时,是内核态,那么在我们执行我们自定义的函数时,是以内核态还是用户态执行的呢?
当然是用户态。如果我们用内核态去执行,一旦函数里面有恶意访问或修改内核的代码,就会直接绕过操作系统,进行破坏。所以我们在执行用户自定义信号动作时,要把内核态变为用户态。
在执行完函数时,我们还要返回到内核态里!为什么呢?
这里就不得不提到,我们学习C语言时,函数栈帧的创建于销毁这个知识点了:
在函数调用时,我们会在栈上面为函数开辟一段空间,用来记录函数的各种数据数据(如函数在结束后如何回到调用前的位置继续执行后面的代码)。当函数结束时,该函数的栈帧会被销毁,然后回到调用该函数的函数的栈帧上,然后按照这个依次执行,只到main函数结束。
这里我们在main函数上为了处理信号调用了系统调用,建立一块栈帧,在执行时,又调用了自定义函数,建立栈帧。当自定义函数结束时,我们理应继续执行系统调用函数,所以我们还要从用户态转换成内核态。其次我们知道在信号处理完后(如果信号动作不会终止程序),我们要还返回到main函数里继续执行下面的代码,但是这里如何返回main函数的方法是记录在系统调用上的,这里当自定义函数执行完后,我们只知道如何返回系统调用函数。
这里自定函数结束后,回到内核态是靠sigreturn这个系统调用再次进入内核态的。当进程所有接受的信号被处理完后(如果进程还没有结束),如果还没有新的信号被进程接受到,这时会再次从内核态返回用户态,恢复到main函数之前的地方继续执行下面的代码。
sigaction
sigaction 函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回 0, 出错则返回 - 1 。 signo 是指定信号的编号。若act 指针非空 , 则根据 act 修改该信号的处理动作。若 oact 指针非 空 , 则通过 oact 传出该信号原来的处理动作。act 和 oact 指向 sigaction 结构体 :
- 将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动 作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函 数,该函数返回 值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信 号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
- sa_sigactios是实时信号的处理方法,不许要关心。
#include<iostream>
#include<unistd.h>
#include<signal.h>
using namespace std;
void handler(int signum)
{
cout<<"handler: "<<signum<<endl;
}
int main()
{
struct sigaction act,oact;
act.sa_handler=handler;
sigaction(2,&act,&oact);//改变2号信号的处理动作
while(true)
{
sleep(1);
}
return 0;
}
在处理信号,执行自定义动作时,如果在处理信号期间,又来了同样的信号,操作系统该如何处理呢?
Linux的设计方案是:在任何时候,操作系统只能处理一层信号,不允许出现信号正在处理又来一个相同的信号再被处理的情况。虽然操作系统无法决定信号什么时候发送信号,倒是可以决定什么时候处理信号。这里就让我们来看为什么要有信号屏蔽字block!
当某个信号的处理函数被调用时 , 内核自动将当前信号加入进程的信号屏蔽字 , 当信号处理函数返回时自动恢复原来 的信号屏蔽字, 这样就保证了在处理某个信号时 , 如果这种信号再次产生 , 那么 它会被阻塞到当前处理结束为止。 如果 在调用信号处理函数时, 除了当前信号被自动屏蔽之外 , 还希望自动屏蔽另外一些信号 , 则用 sa_mask 字段说明这些需 要额外屏蔽的信号, 当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags 字段包含一些选项 , 这里代码都把sa_flags 设为 0,sa_sigaction 是实时信号的处理函数 ,就· 不详细解释这两个字段 , 有兴趣的朋友可以再了解一下。
#include<iostream>
#include<unistd.h>
#include<signal.h>
using namespace std;
void PrintPending(sigset_t* pending)//打印pending表中的内容
{
for(int i=31;i>=1;i--)
{
if(sigismember(pending,i))
cout<<'1';
else
cout<<'0';
}
cout<<endl;
}
void handler(int signum)
{
int cnt=6;
sigset_t s;
cout<<"handler: "<<signum<<endl;
while(cnt)
{
cnt--;
sigpending(&s);
PrintPending(&s);//实时打印pending表内容
sleep(1);
}
}
int main()
{
cout<<"getpid: "<<getpid()<<endl;
struct sigaction act,oact;
act.sa_handler=handler;
sigaction(2,&act,&oact);//改变2号信号的处理动作
while(true)
{
sleep(1);
cout<<"running.."<<endl;
}
return 0;
}
处理2号信号的同时,屏蔽3~7号信号:
#include<iostream>
#include<unistd.h>
#include<signal.h>
using namespace std;
void PrintPending(sigset_t* pending)
{
for(int i=31;i>=1;i--)
{
if(sigismember(pending,i))
cout<<'1';
else
cout<<'0';
}
cout<<endl;
}
void handler(int signum)
{
int cnt=20;
sigset_t s;
cout<<"handler: "<<signum<<endl;
while(cnt)
{
cnt--;
sigpending(&s);
PrintPending(&s);
sleep(1);
}
}
void handler2(int signum)
{
cout<<"handler: "<<signum<<endl;
}
int main()
{
cout<<"getpid: "<<getpid()<<endl;
struct sigaction act,oact;
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask,3);
sigaddset(&act.sa_mask,4);
sigaddset(&act.sa_mask,5);
sigaddset(&act.sa_mask,6);
sigaddset(&act.sa_mask,7);
act.sa_handler=handler;
sigaction(2,&act,&oact);//改变2号信号的处理动作
signal(3,handler2);
signal(4,handler2);
signal(5,handler2);
signal(6,handler2);
signal(7,handler2);
while(true)
{
sleep(1);
cout<<"running.."<<endl;
}
return 0;
}
注:这里我们我们可以看到当2号执行完后,就会检查pending表中还有没有信号,若有便会将信号都处理完,再回到main函数里执行剩下的代码。
但是这里我们可以看到,再执行其他信号时,并没有按照他们的接受顺序去执行。
这是因为,在信号之间也是有优先级的,默认先执行有限级高的,再执行优先级低的。
信号的其他补充问题
可重入函数
- main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因 为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函 数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从 sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步 之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只 有一个节点真正插入链表中了。
- 像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称 为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之, 如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。想一下,为什么两个不同的 控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
volatile关键字
该关键字在 C 当中我们已经有所涉猎,今天我们站在信号的角度重新理解一下
#include<iostream>
#include<unistd.h>
#include<signal.h>
using namespace std;
int flag=0;
void changeFlag(int signum)
{
(void)signum;
cout<<"flag: "<<flag<<endl;
flag=1;
cout<<"flag: "<<flag<<endl;
}
int main()
{
signal(2,changeFlag);
while(!flag);
cout<<"flag quit normal."<<endl;
return 0;
}
编译器有时会自动的给我们进行代码优化!
这里即使对flag进行修改,也没有办法结束进程。这是为什么呢?
正常情况下,每次循环通过flag进行检测时,都需要到内存里取数据,但是由于编译器的优化,导致编译器认为main函数里的代码没有对flag进行修改,所以为了提高效率,第一次从内中取出来flag的数据后就不会到内存中取数据了,而是直接读取CPU寄存器里的数据进行循环检测。
编译器的优化使CPU无法看到内存,而关键字volatile就是为了保持内存的可见性,每次都读取内存中的数据。
SIGCHILD信号
- 进程一章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻 塞地查询是否有子进 程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不 能处理自己的工作了;采用第二种方式,父 进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。
- 其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自 定义SIGCHLD信号 的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理 函数中调用wait清理子进程即可。
- 事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作 置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程。系统默认的忽 略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证 在其它UNIX系统上都可 用。
#include<iostream>
#include<unistd.h>
#include<signal.h>
using namespace std;
void handler3(int signum)
{
cout<<"子进程退出: "<<signum<<endl;
}
int main()
{
signal(SIGCHLD,handler3);
pid_t id=fork();
if(id==0)
{
cout<<"child pid:"<<getpid()<<endl;
sleep(1);
exit(0);
}
while(true)
sleep(1);
return 0;
}
自动等待进程
#include<iostream>
#include<unistd.h>
#include<signal.h>
#include<sys/wait.h>
#include<sys/types.h>
void handler3(int sig)
{
pid_t id;
//-1表示等待任意一个子进程
while(id=waitpid(-1,nullptr,WNOHANG));
{
printf("wait child success: %d\n",id);
}
printf("child is quit! %d",getpid());
}
int main()
{
signal(SIGCHLD,handler3);
if(fork()==0)
{
printf("child: %d\n",getpid());
sleep(3);
exit(1);
}
while(1)
{
printf("father proc is doing something!\n");
sleep(1);
}
return 0;
}
#include<iostream>
#include<unistd.h>
#include<signal.h>
#include<sys/wait.h>
#include<sys/types.h>
using namespace std;
//不等待子进程,并且还可以让子进程在退出之后,自动释放僵尸进程
int main()
{
signal(SIGCHLD,SIG_IGN);//手动设置对子进程进行忽略
if(fork()==0)
{
cout<<"child: "<<getpid()<<endl;
sleep(5);
exit(0);
}
while(1)
{
cout<<"parent: "<<getpid()<<endl;
sleep(1);
}
return 0;
}
这里手动释放设置忽略,操作系统会自动释放处于僵尸状态的子进程,所以并没有看见处于僵尸状态的子进程子。