Linux---信号

前言

到饭点了,我点了一份外卖,然后又开了一把网游,这个时候,我在打游戏的过程中,我始终记得外卖小哥会随时给我打电话,通知我我去取外卖,这个时候游戏还没有结束。我在打游戏的过程中需要把外卖会打电话这件事给记录下来,这就是信号的保存。当外卖送到的时候我要去取外卖,这就是信号的处理过程。这个取外卖的过程,就是完整的信号的产生到处理的过程。

而我们的进程必须能够识别和处理信号,即使信号没有产生,也要具备处理信号的能力,信号的处理能力,属于进程内置功能的一部分。进程在运行的时候,如果没有收到信号,也能知道哪些信号该怎么处理,当进程真的收到了一个具体的信号的时候,进程可能并不会立即处理这个信号。一个进程必须当信号产生,到信号开始被处理,就一定会有时间窗口,进程具有临时保存哪些信号已经发生了的能力。

信号

当我们写了一个死循环的时候,在只开启一个终端的情况下,一般会使用 ctrl + c来结束这个进程。

那么我们使用 ./process &(./可执行程序 &就变成了后台进程) 来运行程序呢?这个时候你会发现 ctrl + c不能结束程序了。可以使用 kill -9 PID来结束这个进程。

ctrl + c只能结束前台进程。而 ./process &是一个后台进程,ctrl + c不能结束进程。前台进程和后台进程的区别就是,谁能或者键盘资源,当上面的死循环采取前台方式运行的时候,bash就变成了后台进程。当我们以后台进程的方式运行程序的时候,ctrl + c是发送给bash的,这个根本没有接收到ctrl + c的信号,所以不能结束进程。

Linux中,一次登陆中,一个终端,一般会配上一个bash,每一个登录,只允许一个进程为前台进程。可以允许多个进程为后台进程

1.Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程

结束就可以接受新的命令,启动新的进程。

  1. Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生

的信号。

  1. 前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行

到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步

(Asynchronous)的。

4.ctrl + c的本质就是给进程发送信号


信号是进程之间事件异步通知的一种方式,属于软中断

通过 kill -l可以查看所有的内置信号

这些信号都是宏定义,其中1~31号信号为普通信号。 34~64为实时信号。

一个信号一旦产生,可以不立即处理的是普通信号,实时信号则是信号产生就需要立即处理。

信号的处理方式

信号的处理方式

  1. 默认动作
  2. 忽略
  3. 自定义动作(捕捉)

比如所上面写的死循环程序,我是用ctrl + c来结束这个程序,进程收到了2号信号的默认动作,就是终止自己。


sighandler_t signal(int signum, sighandler_t handler);

signum:一个信号

handler:自定义信号

这个函数就是用来修改进程对信号的默认动作

现在,我写一个死循环,如果我按ctrl + c可以结束这个程序,现在,我修改2号信号,使ctrl + c不结束这个程序。

复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>

void handler(int sig)
{
    std::cout << "signal SIGINT" << std::endl;
}

int main()
{
    signal(SIGINT, handler);
    while (true)
    {
        std::cout << "while 循环" << std::endl;
        sleep(2);
    }

    return 0;
}

运行程序之后,ctrl + c变成了signal SIGINT,而不是结束进程,这个时候要退出可以用 kill -9 PID

信号的三种处理方式,必须选择其中一个。

上面代码中,signal这个函数只要设置了,在这个代码中,只要使用都有效。


信号是进程之间事件异步通知的一种方式,属于软中断

当然,不是所有的信号可以被捕捉。

复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>

void handler(int sig)
{
    std::cout << "signal SIGSTOP" << std::endl;
}

int main()
{
    signal(SIGSTOP, handler);
    while (true)
    {
        std::cout << "while 循环" << std::endl;
        sleep(2);
    }

    return 0;
}

比如这个19号信号。


可以做一个小试验,看看哪些信号可以被捕捉。

复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>

void handler(int sig)
{
    std::cout << "signal"  << sig << std::endl;
}

int main()
{
    for (int i = 1; i <= 31; i++)
    {
        signal(i, handler);
    }
    
    for (int i = 1; i <= 31; i++)
    {   
        if (i != 9 && i != 19)
            kill(getpid(), i);
    }

    return 0;
}

实验之后可以发现,只有9 和 19不能被捕捉。如果说某个程序失控了,9号和19号信号被捕捉了,不能结束进程,这个时候可就麻烦了。

kill命令

通过kill命令也可以产生信号

复制代码
参数1:进程的pid
参数2:信号
返回值
    成功返回0,失败返回-1

可以在代码中使用kill命令,杀死进程。

硬件异常产生信号

硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。

复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>

int main()
{
    int a = 10 / 0;
    return 0;
}

这个进程退出了,是因为接收到了一个信号而退出的。为什么接收到这个信号会退出?因为退出是这个信号的默认动作。

通过 man 7 signal可以查看信号的详情

8号信号跟我们的程序退出之后所输出的字符串是一样的,可以做一个实验来验证是否收到了8号信号。

复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>

void handler(int signo)
{
    std::cout << "signal :" << signo << std::endl;
}

int main()
{
    signal(8, handler);
    int a = 10 / 0;
    return 0;
}

果然收到了8号信号,因为我对八号信号进行了捕捉,所以当系统向进程发出8号信号的时候,就执行的是我所定义的行为,而不是直接结束进程。


我们也可以模拟一下野指针异常

复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>

void handler(int signo)
{
    std::cout << "signal :" << signo << std::endl;
}

int main()
{

    int *p = nullptr;
    *p = 200;

    while (1);
    return 0;
}

会报段错误,代码停止运行。

复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>

void handler(int signo)
{
    std::cout << "signal :" << signo << std::endl;
    sleep(3);
}

int main()
{
    signal(11, handler);
    int *p = nullptr;
    *p = 200;

    while (1);
    return 0;
}

我将11号信号进行一个捕捉。

发现,将11号信号进行捕捉之后,没有在执行系统的默认行为,执行的是我自定义的行为。当代码出现异常的时候,我们可以不将代码退出,将信号进行捕捉就行了。

由此可以确认,我们在C/C++中除零错误,内存越界等异常,在系统层面上,是被当作信号处理的。

软件条件产生信号

alarm函数和SIGALRM信号

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

这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。打个比方,某人要小睡一觉,设定闹钟为30分钟之后响,20分钟后被人吵醒了,还想多睡一会儿,于是重新设定闹钟为15分钟之后响,"以前设定的闹钟时间还余下的时间"就是10分钟。如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数 。

复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>

int main()
{
    
    int n = alarm(5);
    std::cout << "return alarm" << n << std::endl;

    while (true)
    {
        std::cout <<"helloworld" << std::endl;
        sleep(2);
    }
    return 0;
}

为什么这个代码跟前面的信号不一样?一旦出现错误,执行自定义的行为,会不停的执行。因为这个闹钟不是错误。

复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>

int main()
{
    
    int n = alarm(5);
    std::cout << "return alarm 1 " << n << std::endl;
    sleep(2);
    n = alarm(3);
    std::cout << "return alarm 2 "  << n << std::endl;

    while (true)
    {
        std::cout <<"helloworld" << std::endl;
        sleep(2);
    }
    return 0;
}

可以看一下函数的返回值,再结合上面对函数返回值的解析理解一下。

Core Dump

SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core Dump,现在我们来验证一下。

Core Dump

首先解释什么是Core Dump。当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部 保存到磁盘上,文件名通常是core,这叫做Core Dump。进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。 首先用ulimit命令改变Shell进程的Resource Limit,允许core文件最大为1024K: $ ulimit -c 1024

复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>

int main()
{
    int a = 10 / 0;
    while (true)
    {
        std::cout <<"helloworld" << std::endl;
        sleep(2);
    }
    return 0;
}

我们自己写了一个除零错误的代码,然后编译,运行,会发现有一个core.数字的文件名。

然后通过gdb调试功能,可以直接看到因为什么出现的错误。错误在哪一行等信息。

如果以后代码出现了问题,我们可以先运行,然后利用core调试,可以直接定位到错误的地方,这被称为事后调试。

core dump功能一般是被关掉的,因为如果服务器挂了,系统会自动重启,先让服务器启动起来,如果服务器起来就挂,系统继续重启,计算机的速度也是非长快的,如果挂了+重启进行一晚上,core文件会有很多,那么这就会造成磁盘满的情况,这个时候操作系统可能就会挂了。

阻塞信号

信号其他相关常见概念

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

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

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

被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.

注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

在内核中表示

每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。

SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。

SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号

sigset_t

这是一个位图结构,从上面的图来看,每个信号只有一个bit的未决标志,0或者1,不记录信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号机,这个类型可以表示每个信号的状态,阻塞信号集中,状态决定该信号是否被阻塞未决信号集中,状态的含义是该信号是否处于未决状态。上面的表是内核数据结构,我们并不能直接的去修改block,pending,handler这是三张表,所以操作系统提供了系统调用接口。

信号集操作函数

复制代码
#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。

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参数的可选值 。

|-------------|------------------------------------------------|
| SIG_BLOCK | set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask|set |
| SIG_UNBLOCK | set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask&~set |
| SIG_SETMASK | 设置当前信号屏蔽字为set所指向的值,相当于mask=set |

如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。

sigpending

复制代码
#include <signal.h>
sigpending
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。

下面用刚学的函数做个实验。

复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>

using namespace std;


void PrintPending(sigset_t &pending)
{
    // 这里不能对位图进行位操作,可以用sigismember
    for (int i = 1; i < 32; i++)
    {
        if (sigismember(&pending, i))
        {
            cout << "1";
        }
        else 
        {
            cout << "0";
        }
    }
    cout << endl;
}

int main()
{
    // 1.先对2号信号进行屏蔽
    sigset_t bset;
    sigset_t oldset;
    sigemptyset(&bset); // 将bset进行清空。
    sigemptyset(&oldset);
    sigaddset(&bset, 2); // 这个函数并不会把2号信号屏蔽,因为sigset_t定义出来的bset变量位于栈区,栈区是用户区,此时只是在自己的空间把变量修改了
    // 并没有进入到内核。所以才需要sigprocmask


    // 调用系统调用屏蔽信号
    sigprocmask(SIG_SETMASK, &bset, &oldset); // 这里把2号代码屏蔽了吗?屏蔽了。这个函数才能把我们上面进行的操作对内核完成修改。

    // 重复打印当前进程的pending
    sigset_t pending;
    while (true)
    {
        int n = sigpending(&pending);
        if (n < 0)
        {
            continue;
        }


        PrintPending(pending);
        sleep(2);
    }
}

此时为全0,但我发发送2号信号的时候。

会发现,2号位置变成了1。


解除信号屏蔽

复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>

using namespace std;


void PrintPending(sigset_t &pending)
{
    // 这里不能对位图进行位操作,可以用sigismember
    for (int i = 1; i < 32; i++)
    {
        if (sigismember(&pending, i))
        {
            cout << "1";
        }
        else 
        {
            cout << "0";
        }
    }
    cout << endl;
}

int main()
{
    // 1.先对2号信号进行屏蔽
    sigset_t bset;
    sigset_t oldset;
    sigemptyset(&bset); // 将bset进行清空。
    sigemptyset(&oldset);
    sigaddset(&bset, 2); // 这个函数并不会把2号信号屏蔽,因为sigset_t定义出来的bset变量位于栈区,栈区是用户区,此时只是在自己的空间把变量修改了
    // 并没有进入到内核。所以才需要sigprocmask


    // 调用系统调用屏蔽信号
    sigprocmask(SIG_SETMASK, &bset, &oldset); // 这里把2号代码屏蔽了吗?屏蔽了。这个函数才能把我们上面进行的操作对内核完成修改。

    // 重复打印当前进程的pending
    sigset_t pending;
    int cnt = 3;
    while (true)
    {
        int n = sigpending(&pending);
        if (n < 0)
        {
            continue;
        }


        PrintPending(pending);
        sleep(1);

        // 解除屏蔽
        if (!cnt)
        {
            sigprocmask(SIG_SETMASK, &oldset, nullptr);
        }
        cnt--;

    }

    return 0;
}

我们可以将所有信号都进行屏蔽,信号不就不会被处理了吗?

复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>

using namespace std;

void PrintPending(sigset_t &pending)
{
    // 这里不能对位图进行位操作,可以用sigismember
    for (int i = 1; i < 32; i++)
    {
        if (sigismember(&pending, i))
        {
            cout << "1";
        }
        else
        {
            cout << "0";
        }
    }
    cout << endl;
}

int main()
{
    sigset_t newset, oldset;
    sigemptyset(&newset);
    sigemptyset(&oldset);

    for (int i = 1; i < 32; i++)
    {
        sigaddset(&newset, i);
    }
    sigprocmask(SIG_SETMASK, &newset, &oldset);
    sigset_t pending;
    while (true)
    {
        int n = sigpending(&pending);
        if (n < 0)
        {
            continue;
        }
        PrintPending(pending);
        sleep(1);
    }

    return 0;
}

发现到9号信号的时候,会直接杀死进程,9号信号并没有被屏蔽。

19号信号也没有被屏蔽。

普通信号中,只有9号和19号没有被屏蔽。


在执行代码的时候,系统不仅仅会跑我们自己写的代码,系统还会跑库,和系统提供的程序,在一些情况下,系统是会进行用户的切换的,典型的是当我们进行系统调用的时候会由用户态到内核态,将系统调用接口进行执行。当我们的进程从内核态返回到用户态的时候,会进行信号的检测和处理。

内核如何实现信号的捕捉

如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是 两个独立的控制流程。 sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。

sigaction

复制代码
#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函数调用,而是被系统所调用

复制代码
#include <iostream>
#include <signal.h>
#include <cstring>
#include <unistd.h>

using namespace std;

void handler(int signo)
{
    cout << "signo " << signo << endl;
}

int main()
{
    struct sigaction act, oact;
    memset(&act, 0 , sizeof(act));
    memset(&oact, 0 , sizeof(oact));

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

    while (true)
    {
        cout << "i am a process: " << getpid() << endl; 
        sleep(2);
    }

    return 0;
}

当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags字段包含一些选项,本章的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函数。

相关推荐
|_⊙3 分钟前
Linux 深入理解文件(Ext2文件系统:上)
linux·运维·数据库
kidwjb5 分钟前
Linux共享内存
linux·服务器·进程间通信
红茶要加冰9 分钟前
七、正则表达式
linux·运维·正则表达式·shell
ALINX技术博客12 分钟前
【黑金云课堂】FPGA技术教程Linux开发:串行通信接口与实时时钟模块
linux·fpga开发
sulikey35 分钟前
ext2 GDT 块组描述符表 详细技术拆解
linux·操作系统·文件系统·ext2·gdt·ext·块组描述符
QuestLab36 分钟前
Ollama在Linux上安装的详细记录
linux·运维·服务器
Strugglingler1 小时前
【Linux PL011驱动支持RS485】
linux·uart·rs485·pl011
IT瑞先生1 小时前
Linux系统基础
linux·运维·服务器
modelmd1 小时前
Linux chroot命令
linux
l1t1 小时前
在WSL的ubuntu 26.04容器中用deb安装包安装使用redrock-4.1-1
linux·运维·ubuntu·postgresql