Linux进程信号(3)--信号的处理

目录

前置知识

捕捉信号

内核如何实现信号的捕捉

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) 函数。想一下,为什么两个不同的 控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?
如果一个函数符合以下条件之一则是不可重入的:
  • 调用了mallocfree,因为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信号

  • 进程一章讲过用waitwaitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻 塞地查询是否有子进 程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不 能处理自己的工作了;采用第二种方式,父 进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。
  • 其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自 定义SIGCHLD信号 的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理 函数中调用wait清理子进程即可。
  • 事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigactionSIGCHLD的处理动作 置为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;
}

这里手动释放设置忽略,操作系统会自动释放处于僵尸状态的子进程,所以并没有看见处于僵尸状态的子进程子。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

慢慢走,慢慢等

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值