linux中对信号的认识

时间:2024-03-05 17:52:15

信号的概念与相关知识认识

信号是向目标进程发送消息通知的的一种机制。 信号可以以异步的方式发送给进程,也就是说,进程无需主动等待,而是在任何时间都可以接收到信号。

信号的种类

用kill-l命令查看系统定义的信号列表:

前台进程和后台进程的认识

我们首先要知道,打开xshell软件时,默认就已经执行了shell进程,而这就是前台进程,而且前台进程只有一个。当我们运行一个我们写的死循环进程时:

#include <iostream>
#include <unistd.h>
using namespace std;

int main()
{
    while(1)
    {
        cout << "haha" << endl;
        sleep(1);
    }
    return 0;
}

其实此时该进程就自动变成了前台进程,而操作系统会自动的将shell切换成后台进程。而且此时我们呢无论输入什么指令都不会被执行,因为我们所输入的指令默认都会被前台进程接收。但是我们可以通过kill -9 进程pidCtrl c来终止此前台进程,终止以后,操作系统还会自动的将shell设置为前台进程。记住Ctrl c并不能终止前台进程shell。

如何生成后台进程

如果我们想要将进程执行成后台进程的话可以在运行进程的后面加上&符号:

而对于这两个数字对应的分别就是:后台进程编号和该后台进程pid。

 而且我们可以同时可以执行多个后台进程。

查看后台进程 

通过jobs指令查看后台进程

后台进程与前台进程的转换 

通过fg(front ground)+后台进程编号将后台进程变成前台进程:


通过Ctrl z指令和bg(back ground)+暂停进程编号将前台进程变成后台进程:

 我们可以通过Ctrl z将前台进程暂停但是我们的前台必须要有进程执行,否则系统就会挂掉了,此时操作系统会默认将shell设置为前台进程,因此原先的前台进程就被暂停在了后台中。

 操作系统是怎么知道外设有数据的

操作系统通常通过中断机制来确定外设是否有数据可用。中断是计算机系统中的一种重要机制,它是指在计算机执行某个任务时,由于发生了某种特殊事件或接收信号,硬件或软件会中断当前任务的执行,转而去处理需要优先处理的事件或任务。中断可以打破程序的顺序执行,提供了一种异步(随时)处理的方式。而且中断处理程序执行结束后,处理器会恢复之前保存的上下文,继续原来的任务执行。

我们知道CPU包括运算器和控制器的,对于控制器其实就是其他设备的控制的(因为是要控制信息,所以也会和外设相交互)。CPU在硬件上是和内存关联,而自身会提供一个个从0开始编号的针脚。针脚是和主板直接相连,而主板上插入着很多的硬件电路(相当于USB口)。而这些硬件电路就和我们的外设直接关联着。

当我们的外设在使用的时候,此时对应的针脚就会接收外设传来的高电频,从而点亮针脚(只有能进行IO的外设才能向CPU发消息的)。而我们的每个针脚都有对应的编号(中断号),所以此时的编号就会被写到寄存器里,就可以被操作系统读取。所以我们的外设有数据的时候本质上就是通过发送中断号的方式来判断

而我们中断号的作用其实就是调用对应硬件的方法的。在操作系统内部提供了一张中断表,该表是一个函数指针数组,数组下标对应的就是中断号(0号是正常运行),其中存放的就是特定硬件的读取方法。

信号的产生 

键盘产生

信号的产生其实就是通过键盘产生。当我们运行一个死循环程序时,按住Ctrl c(其实就是发送2号信号组合键时就可以中断进程。我们一般在shell上输入的指令都会放到进程的缓冲区中进行读取,而当我们输入Ctrl c组合键,并没有将数据输入给进程,而是直接转换成某种动作,向进程发送信号的方式。而进程接收信号并处理,从而终止进程。

我们操作系统当中,每一个进程都会维护一张处理信号的方法表:其实就是函数指针数组,该数组中的每一个下标对应的数据内容都是信号的处理方法。

认识signal函数:

该函数的返回值是一个参数为int的函数指针 。而参数一是信号编号,参数二的类型和函数返回值类型一样,是一个函数指针。其实该函数的功能就是重置(自定义)进程接收信号后的反应机制,也就是重置信号处理的函数指针数组里的方法但是一些信号,如9号信号不能被自定义捕捉修改

void handler(int signo)
{
    cout << "进程收到了" << signo << "信号" << endl;
    exit(1);
}
int main()
{
    signal(2, handler); // 自定义捕捉2号信号
    while (1)
    {
        cout << "死循环" << endl;
        sleep(1);
    }

    return 0;
}


信号的本质 

 信号实质是操作系统(进程的管理者)向目标进程发送的,进程也需要对信号进行管理。而底层其实就是信息的输入与输出,操作系统向对应的进程发送信号编号,然后进程PCB中存在中管理信号的结构体,其结构体内部会存在一张位图,而每一位都对应着各个编号的信号,如果比特位内容为1就表明收到该位置对应的信号。

所以一个进程含有信号的函数指针数组和信号位图。

系统调用产生

通过kill函数对指定进程发送信号

该函数就是我们shell命令行中的kill指令,所以我们的实现使用就可以通过命令行参数:

//kill模拟实现
//kill -9 pid
int main(int argc,char* argv[])
{
    if(argc!=3)
    {
        cout<<"输入方式错误"<<endl;
        cout<<"please input "<<argv[0]<<" + "<<"signum + process_id"<<endl;
        return -1;
    }
    int signum = stoi(argv[1]+1);//去掉'-'字符
    int process_id = stoi(argv[2]);
    kill(process_id,signum);
    
    return 0;
}

 通过raise函数对当前进程发送信号

void handler(int signo)
{
    cout<<"向当前进程发送信号:"<<signo<<endl;
}
int main()
{
    signal(2,handler);//重置当前进程的信号处理表中的2号方法
    while(1)
    {
        raise(2);
        sleep(1);
    }

    return 0;
}

通过abort函数对当前进程发生终止信号

void handler(int signo)
{
    cout<<"向当前进程发送信号:"<<signo<<endl;
}
int main()
{
    signal(6,handler);//重置当前进程的信号处理表中的6号方法
    abort();
    while(1)
    {
        cout<<"运行中..."<<endl;
    }
    return 0;
}

 代码编译异常产生信号

除0异常

int main()
{
    int a=10;
    int ret=a/0;//0不能作为除数

    return 0;
}

我们知道CPU包含运算器和控制器。所以CPU会存在很多的寄存器会临时存储调度进程中需要进行运算的数据。而CPU中还存在着一种状态寄存器(status),状态寄存器中有很多的标志位,很多标志位是用来衡量运算的结果状态的。而溢出标记为也是其中。当我们的操作系统调度进程时,将改进程放到运行队列中,如果CPU的运算器发现除数为0时,状态寄存器中的溢出标志位就被置为1。而此时寄存器中的标志位出问题时,CPU中的内部模块或针脚就会向操作系统通知,而操作系统就会根据针脚为下标的函数指针数组表的内容执行对应的中断方法。操作系统接下来就会将溢出标志位解释为kill+目标进程+信号的方式向目标进程发信号。

 这对应的恰好就是信号表中的8号信号。


更改8号信号处理方式:

void handler(int signo)
{
    cout<<"进程错误信号:"<<signo<<endl;
    sleep(1);
}
int main()
{
    signal(8,handler);//自定义8号信号处理方法
    int a=10;
    a/=0;//0不能作为除数

    return 0;
}

现象与解释:

 我们发现运行可执行程序以后就开始间隔一秒的死循环输出错误信息,而并没有退出。

对此现象我们要知道,寄存器硬件是属于CPU的,而寄存器里的内容是属于当前调度的进程的,寄存器≠寄存器里的内容

当正常情况下:我们除0错误时,状态寄存器的对应标志位会置为1,所以此时就会向操作系统发送错误信号,最终终止进程,而其他进程被CPU调度运行时,对应的寄存器数据就会被覆盖改写,所以寄存器也就供当前进程使用了。

而对于以上情况时:我们进行了自定义的信号处理方式,所以我们的状态寄存器发现了除0错误以后就会向操作系统发送信号,操作系统就会解释为8号信号发送给进程,而进程会通过调用handler函数来处理8号错误信号。而该进程并没有退出,且CPU一直在调度该进程,但是异常是一直存在的,所以CPU就会一直向操作系统发送信号 

CPU发现异常会通过操作系统向进程发送信号,而进程也会对信号进行处理,但是进程没有退出,CPU中标记进程执行位置的寄存器并不会往下调度。

软件条件产生信号

#include <unistd.h>
unsigned int alarm(unsigned int seconds);
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM(14号)信号, 该信号的默认处理动作是终止当前进程。

闹钟的设置使用

int n=0;
void handler(int signo)
{
    n = alarm(0);//参数等于0表示取消之前设置的闹钟,返回值n还是之前闹钟的剩余时间
    //参数大于0表示重设闹钟
    cout<<"alarm result : "<<n<<endl;
    exit(1);
}
int main()
{
    cout<<"mypid = "<<getpid()<<endl;
    signal(14,handler);
    alarm(20);//返回值是闹钟剩余的时间,一般都是0
    while(1)
    {

    }
    return 0;
}

信号的保存 

我们操作系统向进程传输信号的时候,进程一般可能并不会立即处理会再合适的时候处理。

信号常见概念

  • 实际执行信号的处理动作称为信号递达(Delivery)
  • 信号从产生到递达之间的状态,称为信号未决(Pending)。
  • 进程可以选择阻塞 (Block )某个信号。
  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
  • 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作

信号递达 

信号递达分为信号的忽略(信号接收以后取消响应)、信号的默认(无论之前信号应答方式如何,一旦默认就恢复最初的处理方式)、信号的自定义捕捉(我们自己实现handler方法)。而这些递达方式均可以通过signal函数进行实现。


void handler(int signo)
{
    cout<<"信号自定义捕捉处理 "<<signo<<endl;
    //exit(1);
}
int main()
{
    cout<<"mypid = "<<getpid()<<endl;
    signal(2,handler);
    sleep(10);
    cout<<"默认处理"<<endl;
    //signal(2,SIG_IGN);//忽略信号处理
    signal(2,SIG_DFL);//默认信号处理
    while(1)
    {
        sleep(1);
    }
    return 0;
}

信号未决 

信号未决顾名思义就是进程收到信号但还未对信号做出决策。也就是我们的操作系统向进程信号后,将信号标识到进程PCB中的信号位图中。

信号阻塞

信号被阻塞就是指当前进程不对所接收的信号做任何处理,但是依旧记录着该信号。也就是说明该信号在未处理时一直处于未决阶段,不递达。

而递达中的信号忽略并不是阻塞,因为忽略其实是递达的一种,也就是已经对信号做了处理,而阻塞是还没做处理。

信号在内核中的表示

 内核中会存在数据结构为其进程维护这三张表,分别是block表(该位图表标识着该位置处信号是否阻塞和屏蔽),pending表(和block表的结构一样,其实就是未决表,标识该进程是否收到该信号),handler表(这是一张函数指针数组表,其中的数组小标对应着信号的编号,其中的内容就是对应信号的处理方法)。

对信号集的操作

我们的handler表可以通过我们实现的函数进行传参,从而对该表的内容进行修改。对于阻塞位图表与未决位图表,我们也有对应的函数进行修改。

既然要设置信号集,而信号集的结构又是位图结构,所以我们肯定是要用到该结构的对象,然后通过系统调用函数将该信号集对象设置到进程的内核中。

sigset_t类型

sigset_t是一种数据类型,其实底层也就是我们的位图结构,0对应的就是无该位置对应的信号,1对应的就是有。而且提供了相关函数将该类型的数据进行处理。

int sigemptyset(sigset_t *set);//初始化set所指向的信号集,表示该信号集不包含任何有效信号
int sigfillset(sigset_t *set);//初始化,表示该信号集包含所有系统支持的有效信号
int sigaddset (sigset_t *set, int signo);//添加信号signo
int sigdelset(sigset_t *set, int signo);//删除信号signo
int sigismember(const sigset_t *set, int signo);//判断是否存在信号signo

在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的
状态。 

更改屏蔽字(阻塞信号集)sigprocmask

int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1

假设当前信号屏蔽字为mask: 

how 说明
 SIG_BLOCK

set包含了我们希望阻塞的附加信号,相当

mask=mask|set

 SIG_UNBLOCK

set包含了我希望解除阻塞的信号,相当于

mask=mask&(~set)

SIG_SETMASK

设置当前信号屏蔽字为set所指向的值,相当于

mask=set

对于参数二和三,如果oset是非空指针,则读取进程的当前信号屏蔽字,通过oset参数传出。

如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。

如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字

使用该函数:

//sigprocmask信号屏蔽

void handler(int signo)
{
    cout<<"当前收到"<<signo<<"号信号"<<endl;
}
int main()
{
    cout<<"mypid = "<<getpid()<<endl;
    sigset_t set,oset;//创建临时位图block表
    signal(2,handler);//自定义二号信号的方法

    //清空临时信号集
    sigemptyset(&set);
    sigemptyset(&oset);

    //设置信号集到临时变量
    sigaddset(&set,2);

    sigprocmask(SIG_SETMASK,&set,&oset);//将信号集设置到该进程中

    while(1)
    {}

    return 0;
}

当我们发送二号信号时,该进程是屏蔽此信号的,也就是并没有做出任何的反应。 

而且我们的9号信号是不会被屏蔽的。

获取未决信号集sigpending 

其实对于pending表,我们之前已经了解可以通过键盘上的组合键等方式来设置pending表,所以我们现在想知道的就是pending表中的内容。

sigpending(sigset_t *set)
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1

函数使用:

//sigpending获取未决信号集

void handler(int signo)
{
    cout<<"当前收到"<<signo<<"号信号"<<endl;
}
void print(const sigset_t& tmp)
{
    for(int i=1;i<=32;i++)
    {
        if(sigismember(&tmp,i))
            cout<<1;
        else
            cout<<0;
    }
    cout<<endl;
}
int main()
{
    signal(2,handler);//自定义

    //屏蔽2号信号
    cout<<"mypid = "<<getpid()<<endl;
    sigset_t set,tmp,oset;
    sigemptyset(&set);
    sigemptyset(&tmp);
    sigemptyset(&oset);
    sigaddset(&set,2);
    sigprocmask(SIG_BLOCK,&set,&oset);//将信号集添加到该进程中

    int count=0;
    while(1)
    {
        sigpending(&tmp);
        print(tmp);
        //取消屏蔽,信号处理
        if(count==3)
        {
            cout<<"解除2号信号的屏蔽状态"<<endl;
            sigprocmask(SIG_UNBLOCK,&set,&oset);//解除屏蔽后,进程立马就递达了
        }
        sleep(1);
        count++;
    }

    return 0;
}


对于非屏蔽的信号,也就是在pending集中处于未决状态,当我们发送某一个信号时,该pending表的对应信号位图数据瞬间由1置为0(在执行handler方法之前)。 

信号的处理

认识进程内核态和用户态

我们对于信号的处理需要先了解进程状态,存在内核态和用户态。我们进程默认都是用户态的,也是一种受控的状态,访问的资源都是有限的。内核态是一种操作系统的工作状态,能够访问大部分系统资源,如系统调用函数。

我们的进程地址空间的0到3GB都属于用户空间,3到4GB属于内核空间。而对应的就分别是用户态和内核态。当我们进程在启动的时候,内存中的操作系统会首先被映射到内核空间的,而这之中虚拟和物理地址之间通过内核级页表进行转换。所以当我们在调用系统函数的时候就可以在地址空间从正文代码中去访问内核空间里的数据。但是此时是需要将进程从用户态切换到内核态

我们CPU是实现对进程的调度的,而且CPU中存在很多的寄存器。对于进程的用户态还是内核态,CPU会通过一个CS寄存器中两个比特位数据进行判定,01表示内核,11表示用户。所以对应状态切换就是改变CS寄存器中的数据。同时进行页表(也同样保存在寄存器中)的切换。

信号捕捉处理

我们知道信号的处理是要在内核中进行的,因为我们内核中有关于信号的三张表,block、pending、handler。所以我们信号处理实际就是在进程从内核态返回到用户态的时候

进程在执行接收的信号时,首先回去切换到内核态去遍历三张表,判断是否接受信号,是否阻塞,是否自定义了handler方法。如果没有自定义handler方法就是直接在内核中进行执行。但是如果自定义了handler方法的话就需要对信号方法进行捕捉处理。此时进程时时需要返回到用户态去遍历handler中的代码的(如果不转换成用户态而是保持内核态去访问handler方法的话,可能会造成非法资源的访问,有危险),当执行完handler方法之后依旧是要切换回来的, 而切换回来是因为通过了特殊的系统调用执行了sigreturn返回到内核此时信号处理完毕最终进程返回到用户态。

 而且对于进程在运行期间是会存在很多次的进程切换的。就例如代码执行死循环,然后通过Ctrl c命令直接终止进程的过程。此时进程运行,被CPU调度,但是每个进程都有时间片,所以在CPU执行的过程中,时钟中断会不断进行检测,如果该进程时间片到了,操作系统就会终止此进程,然后将该进程从CPU中剥离(此时进程上下文等数据都会保存到进程PCB中,好在下次调度是直接将数据拷贝到寄存器中),在运行队列重新排队。此时CPU会执行Ctrl c命令,切换到内核态执行该命令,终止进程。

sigaction读取和修改与指定信号相关联的处理动作

int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);


struct sigaction 
{    
    void     (*sa_handler)(int);
    void     (*sa_sigaction)(int, siginfo_t *, void *);
    sigset_t   sa_mask;
    int        sa_flags;
    void     (*sa_restorer)(void);
};

 我们主要了解一下第二个参数sigaction类型,其中该类型是一个结构体,其中主要认识一下sa_handler字段和sa_mask字段。

对于sigaction结构体sa_handler的使用就相当于是signal函数,对信号自定义捕捉:

void handler(int signo)
{
    cout<<" recieve a signal: "<<signo<<endl;
}
int main()
{

    struct sigaction act,oact;
    act.sa_handler=handler;
    sigaction(2,&act,&oact);
    while(1);

    return 0;
}

 对于sigaction结构体的sa_mask的使用表明需要额外屏蔽的信号:

当某个信号的处理函数被调用时(还没调用完成),内核会自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时则会自动恢复原来的信号屏蔽字。这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞,直到当前该信号handler方法处理完为止。

验证:

void print(const sigset_t& tmp)
{
    for(int i=31;i>=1;i--)
    {
        if(sigismember(&tmp,i))
            cout<<1;
        else
            cout<<0;
    }
    cout<<endl;
}
void handler(int signo)
{
    cout<<" recieve a signal: "<<signo<<endl;   
    while(1)//执行不退出
    {
       sigset_t pending;
       sigpending(&pending);
       print(pending);
       sleep(1);
    }
    
}

int main()
{
    cout<<"pid = "<<getpid()<<endl;
    struct sigaction act,oact;
    act.sa_handler=handler;
    sigaction(2,&act,&oact);
    sleep(5);

    return 0;
}

分析:当我们 第一次按住Ctrl c时,会执行2号信号的自定义方法,此时在执行之前就会将pending集中的2号由1置为0,开始执行对应的方法。而接下来再次接收该信号时就会发生阻塞,不会执行2号信号的方法,而且会将pending表中的2号位置置为1.

如果在调用信号处理函数时,除了当前信号被自动屏蔽(正在执行当前信号且没退出时)之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 

以上代码加上:同时屏蔽三四号信号

int main()
{
    cout<<"pid = "<<getpid()<<endl;
    struct sigaction act,oact;
    act.sa_handler=handler;
    sigemptyset(&act.sa_mask);//初始化
    sigaddset(&act.sa_mask,3);//同时屏蔽三四号信号
    sigaddset(&act.sa_mask,4);

    sigaction(2,&act,&oact);
    sleep(10);

    return 0;
}

 当我们进程sleep(10)的过程中接受了信号的话,那么就会执行信号的处理方法,处理完以后就会停止继续sleep,而是执行sleep之后剩余代码。

认识SIGCHLD信号 

我们知道可以通过wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,从而在此实现回收子进程的工作。

void handler(int sig)
{
    pid_t id;
    while( (id = waitpid(-1, NULL, WNOHANG)) > 0)//循环等待,回收所有子进程,防止多个进程同时退出,形成阻塞,导致不会调用handler方法。WNOHANG等待方式防止子进程一直不退出而导致父进程无法执行后续代码。
    {
        printf("wait child success: %d\n", id);
    }
    printf("child is quit! %d\n", getpid());
}

而且对于linux平台中还有一种方式:父进程不用进行等待回收子进程的资源数据,当子进程退出时,操作系统会自动的回收子进程的资源。

signal(SIGCHLD, SIG_IGN);//忽略