Linux信号

目录

一、什么是信号

二、发送信号

1.从键盘产生信号

2.用kill函数发送信号

①kill

②raise

③abort

3.软件条件产生信号

alarm

4.硬件产生信号

三、接收信号

1.sigprocmask

2.sigpending​编辑

四、处理信号

1.什么时候处理信号?

2.sigaction

五、编写信号处理程序

1. 安全的信号处理

① 处理程序要尽可能简单。

②.在处理程序中只调用异步信号安全的函数。

③保存和恢复errno。

④阻塞所有的信号,保护对共享全局数据结构的访问。

⑥用sig_atomic_t声明标志。

六、SIGCHLD信号


一、什么是信号

        生活中的信号有:红绿灯、下课铃声、女朋友的眼神...等等都是信号。

        linux中的信号是:更高层的软件形式的异常,它允许进程和内核中断其他进程。        

        一个信号就是一条小消息,它通知进程系统发生了一个某种类型的事件。        

        我们可通过kill -l 查看我们linux系统上支持不同类型的信号。

        信号提供了一些机制,通知用户进程发生了这些异常。

比如:

        如果,如果一个进程试图除以0,那么内核就发送给一个SIGFPE信号。如果一个进程执行一条非法指令,那么内核就发送给他一个SIGILL信号。当然,如果当进程在前台运行时,你也可以键入Ctrl+C,那么内核就会发送一个SIGINT信号给这个前台进程。也可以用一个进程向另一个进程发送SIGKILL信号强制终止它。 

        一个信号怎么传入进程,进程又是怎么做出反映的?此篇文章将从三个方面解决问题。分别为发送信号、接收信号、处理信号。

我们先讲一个函数调用,这个函数调用的过程体现了信号的一系列过程。

        这个函数可以接受我们设定的信号,从而对这个信号,做出对信号的反应。

代码如下:

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

void fuc(int sig)
{
    cout<<"获取信号成功,获取的信号为: "<< sig<<"已调用处理信号程序"<<endl;
}

int main()
{
    signal(SIGINT,fuc);
    sleep(3);
    cout<<"已设置处理信号程序"<<endl;
    sleep(3);
    while(1)
    {
        cout<<"请传入SIGINT信号,查看调用结果"<<endl;
        sleep(1);
    }
    return 0;
}

 结果:

        很明显的步骤,在没传入信号之前,程序正常运行,键入Ctrl+C,传入信号,进程接收信号,调用fuc函数,处理信号。下面我们细分讲一下这些过程。

ps:

        既然传入信号,我可以捕捉它,然后调用自己的方法,我可不可以把所有信号都设置了,是否一个运行起来的进程就杀不掉了。我们实验一下。

    for (int sig = 1; sig <= 31; sig++)
    {
        signal(sig, fuc);
    }

        怎么办,终止不了进程了。

        我们可以通过kill -9 加上这个进程的pid,就可以将它干掉。

        不允许被捕捉,永远都是默认处理动作。 

二、发送信号

        linux系统提供了大量向进程发送信号的机制。

1.从键盘产生信号

        在键盘上输入Ctrl+C,导致内核发送一个SIGINT信号给前台进程,在默认情况下,终止前台作业。 类似地,在键盘上输入Ctrl+\,导致内核发送一个SIGQUIT信号给前台进程,终止进程。

2.用kill函数发送信号

①kill

        进程通过调用kill函数发送信号给其他进程(包括它们自己)。

        我们运行一个进程并获取它的pid,然后在另一个进程中调用kill函数,至于怎么传另一个进程的pid,可以通过main函数传参的方式传入pid。

proc.cc:
int main(int argc, char *argv[])
{
    //传入参数为 ./当前进程名字|要被杀掉的进程名字|进程pid|信号
    if (kill(static_cast<pid_t>(atoi(argv[2])), atoi(argv[3])) == -1)
    {
        cout << "kill error" << strerror(errno) << endl;
        exit(1);
    }
    else
    {
        cout << "kill success,proc: " << argv[1] << " pid: " << argv[2] << endl;
    }
}
Tobekilled.cc:
int main()
{
    while (1)
    {
        sleep(2);
        cout << "my pid id :" << getpid() << endl;
        cout << "come to kill me !"<< endl;
    }
    return 0;
}

 结果:

        除了kill函数,还有一个函数是raise,在进程内部它可以自己给自己发信号。

②raise

        我们不断给自己发信号,然后用signa函数去接收信号进而调用我们自定义函数。

代码:

void fuc(int sig)
{
    cout << "获取信号成功,获取的信号为: " << sig << "已调用处理信号程序" << endl;
}

int main(int argc, char *argv[])
{
    signal(2,fuc);
    while (1)
    {
        raise(2);
        cout<<"已发送信号"<<endl;
        sleep(1);
    }
}

 结果:

        kill函数可以向任意进程发任意信号,rasie函数可以向自己进程发任意信号, abort函数可以向自己发送SIGABRT信号。

③abort

代码:

int main(int argc, char *argv[])
{
    int cnt = 10;
    while(cnt--)
    {
        cout<<"运行ing"<<endl;
        sleep(1);
    }
    abort();
}

结果:

        再看一个现象,我设置了捕捉SIGABRT信号的方法,尽管自定义处理函数被调用了,但是还是Aborted了,说明SIGABRT信号和SIGKILL信号一样无论捕捉与否都会保持执行默认动作。

代码: 

void fuc(int sig)
{
    cout << "I got a signal,it is " << sig << endl;
}

int main()
{
    signal(SIGABRT, fuc);
    while (1)
    {
        cout << "Doing" << endl;
        abort();
    }
    return 0;
}

结果:

        注意!SIGABRT信号可以被捕获然后实行自定义函数,并且默认动作还会执行。但是SIGKILL信号不可以被捕获,只会执行默认动作。

如图:

3.软件条件产生信号

alarm

        进程可以通过调用alarm函数向它自己发送SIGALRM信号。

        alarm函数会安排内核在sec秒后发送一个SIGALRM信号给调用进程。

4.硬件产生信号

        我们每次除0、数组越界、访问野指针时程序都会奔溃,这是为什么?它们实则都是向进程发送信号,来终止进程。

例如:

        我们除0,并设置捕捉并执行自定义方法。8号信号为SIGFPE。

         我们数组越界。11信号为SIGSEGV。

         我们访问野指针。11信号为SIGSEGV。

除零:CPU内部有状态寄存器,一旦数字除零,状态寄存器被标明:浮点异常。所以os会识别到状态寄存器(硬件)有报错,并构建信号,发送给产生该错误的进程。 

数组越界&&访问野指针:都与虚拟地址有关,如果虚拟地址有问题,管理虚拟地址转化工作的MMU(硬件)+页表(软件)会出现问题,于是os会发现问题,并构建信号,发送给产生该错误的进程。 

        还有就是抛异常本质上也是发信号。

三、接收信号

        我们首先要先知道一些概念。

        实际执行信号的处理动作称为信号抵达(Delivery)。

        在信号产生到信号抵达之间的状态,称为信号未决(Pending)。

        进程可以选择阻塞(Block)某个信号。

        被阻塞的信号产生时将保持处在未决状态,知道进程接触对此信号的阻塞,才执行抵达的动作。

        阻塞与忽略是不一样的,阻塞是一种状态,而忽略是处理信号时的处理动作。

         

         每个信号都有两个标志位,分别表示阻塞和未决,还有一个函数指针表示处理动作。信号产生,内核在进程控制块中设置该信号的未决标志,直到信号抵达才解除该标志。这两个表都可以用32个比特位来实现,每一个比特位的“非0即1”,对应的可以表示阻塞和未阻塞,是否未决。

在上图的例子中:

SIGHUP: 这个信号未产生所以,Pending表中该信号对应的数字是0,Block表中该信号对应的数字是为0。如果有该信号产生,因为没有阻塞该信号,而且设置的处理方式是默认,则会实行默认处理动作。

SIGNIT:这个信号已经产生,Pending表中该信号对应的数字是1,Block表中该信号对应的数字是为1。虽然函数处理方式设为忽略,但是在没有解除阻塞之前,该信号一直会是未决状态。

SIGQUIT: 这个信号未产生所以,Pending表中该信号对应的数字是0,Block表中该信号对应的数字是为1。尽管默认动作是自定义的handler方法,但是信号产生会阻塞该信号。

        每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储。sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态。我们讲述的是这两个表是位图,其实底层是用数组来实现。下面我们了解下,对信号集的操作函数。

1.sigprocmask

        sigprocmask函数读取或改变当前进程的信号屏蔽字(阻塞信号集block)。具体的行为依赖于how的值:

SIG_BLOCK:把set中的信号添加到block中。相当于block = block | set。

SIG_UNBLOCK:把block中的set信号删除。block = block & ~ set。

SIG_SETMASK:使block = set。        

        至于oldset是一个输出型参数,你可以用它来接受未改变之前的block表。你可以用它来复位。

        还可以使用下述函数对set信号集合进行操作:sigemptyset初始化set为空集合。sigfillset函数把每个信号都添加到set中。sigaddset函数把signum添加到set中,sigdelset从set中删除signum信号,如果signum是set成员,那么sigismember返回1,否则返回0。

实例:如何使用sigprocmask来阻塞SIGINT信号。

void fuc(int sig)
{
    //如果进行到这一步说明信号抵达成功,证明阻塞该信号失败,代码出问题了
    cout << "I caught a signal,it is : " << sig << endl;
}

int main()
{
    sigset_t set, oldset;
    sigemptyset(&set);
    sigaddset(&set, SIGINT);

    // block SIGNINT and save previous block in oldset
    sigprocmask(SIG_BLOCK, &set, &oldset);

    //尽管设置捕捉信号和自定义处理动作,但是该信号已被阻塞
    signal(SIGINT, fuc);

    int cnt = 5;
    while (cnt--)
    {
        cout << ".........." << endl;
    }

    //向自己发送信号
    raise(2);

    cout<<"发送成功"<<endl;
    
    return 0;
}

        接下来我们将SIGINT信号重新放开,不在阻塞。

    //解除阻塞

    sigprocmask(SIG_SETMASK,&oldset,NULL);

2.sigpending

        这个函数可以获得当前的未决表(pending)。set为输出型参数。

        我们结合上文的一些函数,整体去试用一下。

例子:

static void ShowPending(sigset_t *pending)
{
    for (int sig = 1; sig <= 31; sig++)
    {
        if (sigismember(pending, sig))
        {
            cout << "1";
        }
        else
        {
            cout << "0";
        }
    }
    cout << endl;
}

int main()
{
    sigset_t pending;
    //将所有的信号屏蔽
    sigset_t set, oldset;
    sigemptyset(&set);
    sigemptyset(&oldset);
    sigfillset(&set);
    sigprocmask(SIG_SETMASK, &set, &oldset);

    while (1)
    {
        sigemptyset(&pending);
        if (sigpending(&pending) == 0)
        {
            //打印当前的 pending表
            ShowPending(&pending);
        }
        //使用随机数来发送信号
        srand((unsigned)time(NULL));
        int sig = rand() % 31;
        if (sig == 6 || sig == 9 || sig == 19)
        {
            //发送6和9信号,尽管屏蔽了,但是还是会结束进程
            // 19信号会让当前进程变为后台进程
            ;
        }
        else if (sig == 0)
        {
            //没有0号信号
            ;
        }
        else
        {
            //向自己发送信号
            raise(sig);
        }
        sleep(1);
    }
    return 0;
}

         注意,这里的将pending初始化,只是将我们定义的sigset_t类型的变量pending初始化,并不是将系统内部的pending表初始化。还有就是所有信号都已经被屏蔽只能用kill -9 加该进程的pid可以解决掉该进程。可以用ps axj | grep 加该进程的名字,来查看进程pid。

四、处理信号

1.什么时候处理信号?

        当当前进程从内核态,切换为用户态的时候,进行信号的检测和处理。

        内核态?我们在学习虚拟地址空间接触过。

        这里的用户级页表每一个进程都一份,而且每一个进程拥有都不一样的用户级页表。

        而内核级页表所有进程共享的是一份,前提是你要有权利访问。

        无论进程怎么切换我们都可以访问内核的代码和数据,前提是要有权力访问。

        怎么才能有权力访问内核级页表和内核的数据和代码?要进行身份切换。进程如果是用户态只能访问用户级页表,是内核态可以访问内核级和用户级页表。

        怎么查看进程是用户态还是内核态?CPU内部会有一个叫CR3的寄存器,用比特位标识当前用户的状态,0标识内核态,3标识用户态。当然我们不能随意改变这个状态。

        当进程在系统调用的时候,当时间片到了,进程之间在进行切换的时候,以及其他手段, 会改变这个状态。

         我们实际在运行程序时,会无数次直接或间接调用系统级软硬件资源(由os管控),本质上,我们并没有自己去申请资源,而是通过os来操作,则会无数次进入到内核中(1.切换身份2.切换页表),然后去调用内核的代码,完成访问,结果返回用户(1.切换身份2.切换页表),得到结果。

        那你说这段代码会有内核态和用户态的切换吗?

int main()
{
    while(1)
    {
        ;
    }
}

        我并没有去调用系统接口,就一直让它循环,会不会进行内核态和用户态的切换呢?

        每个进程都有自己的时间片,当时间片到了就会被剥离下来。时间片到了os会收到时钟中断,从而将该进程切换成内核态,更换内核级页表。然后os保护上下文,执行调度算法,选择新进程,恢复新进程的上下文。再切换用户态,更换用户态页表。最后执行新进程的代码。但是我的while还在运行啊,没有执行新的进程啊,既然可以切换出去,就可以切换回来,又是一趟用户态到内核态到用户态的旅程,然后继续进行while。

       

        至于,处理整个流程如图:

 

快速记忆:

2.sigaction

         

        Posix标准定义了sigaction函数,它允许用户再设置信号处理时,明确指定他们想要的信号语义。但sigaction函数运用并不广泛,因为它要求用户设置一个复杂结构的条目。一个更简介的方式,最初是由W.Richard Stevens提出的[110],就是定义一个包装函数,称为Signal,它调用sigaction。

        Signal包装函数设置了一个信号处理程序,其信号处理如下:

        只有这个处理程序当前正在处理的那种类型的信号被阻塞;(意思为信号处理程序正在处理一个信号,你多次发送信号,该信号会被阻塞,处于未决状态)


ps:

        我捕捉2信号,并且在处理时打印pending表,并且该处理程序在运行5秒之后结束。

 

        1.现象说明我正在处理2信号,继续发送2信号,打印pending表会看出2信号处于未决状态。

        2.此现象说明处理完2信号,紧接着会解除阻塞2信号,并处理2信号,所以打印pending表不处于未决状态。

        3.我在执行2信号时,无论发多少次2信号,我执行完当前2信号的处理程序,只会执行一次2信号的处理程序,并不会按照你发多少次我执行多少次的行为。


        和所有信号实现一样,信号不会排队等待;

        只有可能,被中断的系统调用会自动重启;

        一旦设置了信号处理程序,它就会一直保持,知道Signal带着handler参数为SIG_IGN或者SIG_DFL被调用。

五、编写信号处理程序

        以下关于安全信号处理的内容摘自《深入理解计算机系统》。

        信号处理是linux系统编程的棘手问题。1)处理程序与主程序并发运行,共享同样的全局变量,因此可能与主程序和其他处理程序互相干扰;2)如何以及何时接收信号的规则常常有违人的直觉;3)不同系统有不同的信号处理语义。

1. 安全的信号处理

        我们要知道信号处理程序可以被其他信号处理程序打断,并且主程序的运行也会被打断。

        这些情况无疑会导致严重的后果,所以我们要提前预知,编写安全的信号处理程序。

① 处理程序要尽可能简单。

        避免麻烦的手段就是保持处理程序尽可能小和简单。

②.在处理程序中只调用异步信号安全的函数。

        所谓异步信号安全的函数能够被信号处理程序安全地调用,原因有二:要么它是可重入函数,要么它不能被信号处理程序中断。

        我只罗列了一部分异步信号安全的函数,具体的可以看,man 7 signal 的Linux Programmer's Manual 。

         信号处理程序中产生输出为以安全的方法是使用write函数。特别地,调用printf或sprintf是不安全的。我们可以开发一些安全的函数,称为SIO(safe I/O)包,可以用来在信号处理程序中打印简单的消息。

ssize_t sio_puts(char s[])
{
    return write(STDOUT_FILENO,sio_strlen(s));
}

ssize_t sio_putl(long v)
{
    char s[128];

    sio_ltoa(v,s,10);
    return sio_puts(s);
}

void sio_error(char s[])
{
    sio_puts(s);
    _exit(1);
}

        sio_strlen函数返回字符串s的长度。sio_ltoa基于itoa函数,把v转换成它的基b字符串表示,保存在s中。

③保存和恢复errno。

        许多linux异步信号安全的函数都会在出错返回时设置errno。在处理程序中调用这样的函数可能干扰主程序中其他依赖于errno的部分。解决方法:

void handler(int sig)
{
    int olderrno = errno;
    ...
    errno = olderrno;
}

④阻塞所有的信号,保护对共享全局数据结构的访问。

        如果处理程序和主程序或其他程序共享一个全局数据结构,那么在访问该数据结构时,你的处理程序和主程序应该暂时阻塞所有的信号。

void fuc()
{
    sigset_t mask, prev_mask;
    sigfillset(&mask);
    sigprocmask(SIG_BLOCK,&mask,&prev_mask);
    ...
    sigprocmask(SIG_SETMASK,&prev_mask,NULL);   
}
int a = 0;

void fuc(int sig)
{
    a = 1;
    cout<<"数值a 改为 1"<<endl;
}

int main()
{
    signal(2,fuc);

    while(!a);
    cout<<"进程退出"<<endl;
    
    return 0;
}

 

        该进程的结果显而易见,我键入Ctrl+c进程就会退出。但如果编译器的优化等级过高,它会发现a的值一直没有发生变化,从而把他优化至寄存器里,便于与CPU交互,当真正修改a时,修改的只是内存中a的值,寄存器的值没有任何变化。

        现在我们更换下当前编译器等级

 则会导致以下情况:

        出现这种情况怎么办,除了修改编译器等级,还可以给变量加上关键字volatile,强制要求编译器每次在引用变量a时,都要从内存中读取a的值。

volatile int a = 0;

⑥用sig_atomic_t声明标志。

        在常见的处理程序设计中,处理程序会写全局标量来记录收到了这种信号。主程序周期性地读这个标志,响应信号,再清除该标志。对于通过这种方式来共享的标志,C提供一种整形数据类型sig_atomic_t,对它的读和写保证是原子的(不可中断的),因为可以用一条指令来实现它们:这里的一条指令类似于线程中的知识,后面的博客会讲到。

volatile sig_atomic_t a = 0;

六、SIGCHLD信号

        此板块的最后内容。

        一个子进程结束时,也会向父进程发送信号。在子进程结束之前我们会用waitpid傻傻的干等着,如果waitpid放在父进程代码最前面,在子进程结束之前,父进程根本做不了任何事情,现在我们可以用SIGCHLD信号,来改变现状。当父进程收到SIGCHLD信号之后再去调用waitpid去回收子进程。

        这次我们优化一下:

void fuc(int sig)
{
    while (waitpid(-1, NULL, 0) > 0)
    {
        cout << "回收子进程成功" << endl;
    }
}

int main()
{
    signal(SIGCHLD, fuc);

    for (int i = 0; i < 3; i++)
    {
        pid_t pid = fork();
        if (pid == 0)
        {
            // child
            int cnt = 2;
            while (cnt--)
            {
                cout << "我是子进程, 我的pid是: " << getpid() << endl;
                sleep(1);
            }
            exit(0);
        }
    }
    while (1)
    {
        cout << "我正在欢快的做事情" << endl;
        sleep(2);
    }
}

        这里的waitpid为什么要while去调用?

        因为如果多个子进程结束,会向父进程发送SIGCHLD信号,但是信号会阻塞如果在一个时间段前一个SIGCHLD信号正在被处理,该SIGCHLD信号就会放到pending表里,但如果这时又来一个SIGCHLD信号,该信号就会被丢弃,导致该子进程未被回收,称为僵尸进程。

        我们改变策略收到一个SIGCHLD信号就尽可能地去回收子进程,就可以避免这种情况。

        当然我们既不想waitpid,也不想设置自定义处理方式,我们还可以将,signal设为:

    signal(SIGCHLD, SIG_IGN);

         当父进程退出时,子进程默认会被系统回收。注意我们在没有waitpid和捕捉信号时,系统默认对SIGCHLD信号的处理方式就是忽略。所以到父进程结束之后,os会将子进程回收。 

修改之后:

        感谢观看,我们下次再见!

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值