自学内容网 自学内容网

Linux_信号

信号的概念 && 知识补充

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

标准信号:编号为1-31之间都是标准信号,这些都是预定义信号,用于通知进程发生的各种事件。
实时信号:编号从32开始起均是实时信号,与标准信号相对应。  

当OS收到相关信号时,要通知相应的进程,但是当前进程可能正在做着别的事情,没空处理这个信号。此时就要记录下来该信号,那么如何记录呢?普通信号的编号是从 1 - 31,并没有0号信号,我们将0号位置作为位图来记录信号。所以发送信号的本质就是OS修改目标进程的PCB中的信号位图:0 ->1。

发送信号的只能是OS,因为task_struct的管理者是OS。 

信号的处理方式:

  1. 忽略信号:进程可以选择忽略某些信号,即不对该信号进行任何处理。
  2. 捕捉信号(自定义处理):进程可以注册一个信号处理函数来捕捉特定的信号,并在接收到该信号时执行相应的处理逻辑。
  3. 默认处理:如果进程没有注册信号处理函数且没有选择忽略信号,则系统会按照默认的处理方式来处理该信号。通常情况下,默认处理方式会导致进程终止或停止。

前台进程与后台进程

信号的产生

键盘产生信号

在信号概念模块 我们使用键盘 ctrl + c 组合键,触发的就是2号信号(SIGINT)。

signal函数

  • signum:要处理的信号类型,它是一个整数,对应于某个具体的信号。
  • handler:函数指针类型,用来接收自定义的函数。执行调用的函数就是执行自定义的操作。
// 修改2号信号的默认处理动作
void Handler(int signo)
{
    std::cout << "Get a signal, signal number is : " << signo << std::endl;
}
int main()
{
    // 如果没有产生2号信号,则Handler将不被调用!
    signal(SIGINT, Handler);
    while(1)
    {
        std::cout << "hello world" << std::endl;
        sleep(1);
    }
    return 0;    
}

 在所有的普通信号里面,除了9号信号外,所有信号都可以被signal函数捕捉!

调用系统指令发送信号

指令底层使用的也是系统调用(下面的kill函数) 

使用函数产生信号 

kill函数(系统调用)

// 使用kill函数模拟实现一个kill命令
#include <iostream>
#include <cstdlib>
#include <sys/types.h>
#include <signal.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
    if(argc != 3)
    {
        std::cerr << "Usage: " << argv[0] << " signum pid" << std::endl;
        return 1;
    }
 
    int signum = std::stoi(argv[1]);
    pid_t id = std::stoi(argv[2]);
    int n = ::kill(id, signum);
    if(n < 0){
        perror("kill");
        exit(2);
    }
    exit(0);
}

运行结果: 

 raise函数(系统调用)

raise 函数给当前进程发送指定的信号(自己给自己发信号)。

  • 参数
    • sig:要发送的信号编号。如果,该参数为SIGKILL(信号编号为9),则该进程会自杀。
  • 返回值
    • 如果成功,返回0。
    • 如果失败,返回-1并设置errno以指示错误。
// 3秒后该进程自杀
int main(int argc, char *argv[])
{
    int cnt = 3;
    while(1)
    {
        std::cout << "alive" << std::endl;
        cnt--;
        if(cnt <= 0)    raise(9);
        sleep(1);
    }
    return 0;
}

实际上是调用kill函数:kill(getpid(), signo)

abort函数(系统调用)

abort();

abort是用来终止进程的,实际上就是自己给自己发送6号信号。实际上也是调用kill函数:kill(getpid(), 6)

软件条件产生信号

alarm函数

调用 alarm 函数可以设定一个闹钟,也就是告诉内核在 seconds 秒之后给当前进程发SIGALRM 信号,该信号的默认处理动作终止当前进

函数的返回值是0或者是以前设定的闹钟时间还余下的秒数

// 统计我的服务器1S可以将计数器累加多少
int number = 0;
void die(int signumber)
{
    printf("get a sig : %d, count : %d\n", signumber, number);
    exit(0);
}
int main(int argc, char *argv[])
{   
    alarm(1); // 我自己,会在1S之后收到一个SIGALRM信号(14号信号)
    signal(SIGALRM, die);

    while (true)    number++;
    return 0;
}

闹钟的功能

  • 当调用alarm(seconds)时,内核会启动一个定时器,当定时器到期时,内核会向调用进程发送一个SIGALRM信号。当操作系统中多处要用到alarm的时候,OS就会借助最小堆进行判断,谁的定时器到期,就向谁发送SIGALRM信号。
  • 如果在定时器到期之前再次调用alarm,之前的定时器会被取消,新的定时器开始计时。
  • 如果进程在定时器到期之前终止,定时器也会被取消。

如果之前已经设置了一个定时器,alarm会返回之前定时器剩余的时间(以秒为单位)。如果没有之前设置的定时器,返回0。

int n = alarm(0); // 0:取消闹钟

 Demo代码

// 设置重复闹钟 
#include <iostream>
#include <string>
#include <functional>
#include <vector>
#include <unistd.h>
#include <signal.h>

using func_t = std::function<void()>;

int gcount = 0;
std::vector<func_t> gfuncs;

void hanlder(int signo)
{
    for(auto &f : gfuncs) { f(); }
    std::cout << "gcount : " << gcount << std::endl;
    alarm(1);
}

int main()
{
    gfuncs.push_back([]()
                     { std::cout << "我是一个内核刷新操作" << std::endl; });

    gfuncs.push_back([]()
                     { std::cout << "我是一个检测进程时间片的操作,如果时间片到了,我会切换进程" << std::endl; });

    gfuncs.push_back([]()
                     { std::cout << "我是一个内存管理操作,定期清理操作系统内部的内存碎片" << std::endl; });

    alarm(1); // 一次性的闹钟,超时alarm会自动被取消
    signal(SIGALRM, hanlder);

    while (true)
    {
        pause();
        std::cout << "我醒来了..." << std::endl;
        gcount++;
    }
}

异常产生信号

// 空指针 / 除0 操作引发异常产生信号
int main()
{
    int *p = nullptr;
    *p = 100;       // 对空指针解引用
    // int a = 10;
    // a /= 0;      // 除0
    while (true)
    {
        std::cout << "hello bit, pid: " << getpid() << std::endl;
        sleep(1);
    }
}

运行结果: 

 Core VS Term

term(termination)

  • 定义:term信号的动作是直接终止进程。当进程接收到term信号时,它会立即停止运行,并且不会生成core dump文件。
  • 用途:通常用于正常终止进程,或者在进程已经处于异常状态时强制终止它,以避免进一步的资源占用或系统不稳定。

core

  • 定义:core信号的动作同样是终止进程,但与term不同的是,core在终止进程的同时会生成core dump文件。这个文件包含了进程在内存中的核心数据(主要是与调试有关的数据),如变量值、函数调用栈等。
  • core dump文件:当进程因为接收到core信号而异常退出时,它会将内存中的核心数据转储到磁盘上,形成core dump文件。这个文件对我们程序员来说非常有用,因为它可以帮助定位程序为什么退出以及是在哪一行退出的。通过分析core dump文件,程序员可以找出导致程序崩溃的原因,并修复相应的bug。
  • 用途:主要用于调试和错误分析。程序员可以利用core dump文件来重现程序崩溃时的场景,以便更好地理解和修复问题。此外,core dump文件还可以用于检查内存泄漏等问题。

总结

  • 进程终止方式:term信号直接终止进程,不生成core dump文件;而core信号在终止进程的同时生成core dump文件。
  • 调试和错误分析:term信号不提供额外的调试信息;而core信号通过生成的core dump文件为程序员提供了丰富的调试信息,有助于定位和解决程序中的问题。

 信号捕捉的三种方式

  • 默认捕捉
::signal(2, handler); // 自定义捕捉
  • 忽略捕捉
::signal(2, SIG_IGN);   // ignore: 忽略:本身就是一种信号捕捉的方法,动作是忽略
  • 自定义捕捉
::signal(2, SIG_DFL);   // default: 默认。

信号的保存

补充概念

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

 信号在内核中的表示:

相关函数接口

sigset_t: sigset_t称为信号集(一个位图)。信号集用于表示每个信号的状态,即该信号是有效的(未被阻塞)还是无效的(被阻塞)。通过使用sigset_t类型的变量,可以进行信号集的操作,例如添加信号、删除信号、检查信号是否存在等操作。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”是阻塞而不是忽略。

信号集操作函数

#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函数可以 读取或更改 进程的信号屏蔽字(阻塞信号集,block表)

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
// 返回值:若成功则为0,若出错则为-1

how:指定对信号屏蔽集的操作方式,有以下几种方式:

  • SIG_BLOCK:将set所指向的信号集中包含的信号添加到当前的信号屏蔽集中,即信号屏蔽集和set信号集进行逻辑或操作。
  • SIG_UNBLOCK:将set所指向的信号集中包含的信号从当前的信号屏蔽集中删除,即信号屏蔽集和set信号集的补集进行逻辑与操作。
  • SIG_SETMASK:将set的值设定为新的进程信号屏蔽集,即set直接对信号屏蔽集进行了赋值操作。

set:指向一个sigset_t类型的指针,表示需要修改的信号集合。如果只想读取当前的屏蔽值而不进行修改,可以将其置为NULL。

oldset:指向一个sigset_t类型的指针,用于存储修改前的内核阻塞信号集。如果不关心旧的信号屏蔽集,可以传递NULL。

如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。


 sigpending函数

 sigpending函数用于 读取 当前进程的未决信号集(pending表),通过set参数传出。

#include <signal.h>
int sigpending(sigset_t *set);
// 调⽤成功则返回0,出错则返回-1

 ps. signal函数修改handler表。

Demo代码

结合上面介绍的相关函数做一个Demo:我们把2号信号block对应的位图置为1,即将2号信号屏蔽掉;此时我们给当前进程发送2号信号,因为2号信号已经被屏蔽了,所以2号信号永远不会递达;之后我们不断的获取当前进程的pending表,我们就能肉眼看见2号信号被pending的效果:验证

1. 对2号信号的屏蔽 

// 1.屏蔽2号信号
int main()
{
    sigset_t block_set,old_set;
    sigemptyset(&block_set);
    sigemptyset(&old_set);
    // 1.1 添加SIGINT信号(编号为2)
    sigaddset(&block_set,SIGINT);
  
    // 1.2 设置进入进程的Block表中
    // 真正的修改当前进行的内核的block表,完成了对2号信号的屏蔽!
    sigprocmask(SIG_BLOCK, &block_set, &old_set); 

    while(true) sleep(1);
}

2. 打印pending表,并给该进程发送2号信号

void PrintPending(sigset_t &pending)
{
    std::cout << "curr process[" << getpid() << "]pending: ";
    for (int signo = 31; signo >= 1; signo--){
        if (sigismember(&pending, signo))//如果存在就返回1
            std::cout << 1;
        else    std::cout << 0;
    }
    std::cout << "\n";
}

int main()
{
    // 1.屏蔽2号信号
    sigset_t block_set,old_set;
    sigemptyset(&block_set);
    sigemptyset(&old_set);
    // 1.1 添加SIGINT信号(编号为2)
    sigaddset(&block_set,SIGINT);
  
    // 1.2 设置进入进程的Block表中
    // 真正的修改当前进行的内核的block表,完成了对2号信号的屏蔽!
    sigprocmask(SIG_BLOCK, &block_set, &old_set); 
    while(true) {
        //2.获取当前进程的pending信号集
        sigset_t pending;
        sigpending(&pending);
        //3.打印pending信号集
        PrintfPending(pending);
        sleep(1);
    }
}
 

我们将2号信号屏蔽后,打印pending表,首先打印出来的是31个0;当我们按下ctrl + c组合键后,向该进程发送2号信号,该进程没有被终止,因为信号产生之后,pending表2号位置由 0 变 1 ,又因为2号信号被屏蔽,即永远不会递达,所以我们可以看到panding表发生变化,但进程不会被终止;如果不解除屏蔽,pending表2号位置永远都是1!

3. 解除屏蔽,打印pending表

void PrintPending(sigset_t &pending)
{
    std::cout << "curr process[" << getpid() << "]pending: ";
    for (int signo = 31; signo >= 1; signo--)
    {
        if (sigismember(&pending, signo))//如果存在就返回1
            std::cout << 1;
        else    std::cout << 0;
    }
    std::cout << "\n";
}
int cnt = 0;
int main()
{
    // 解除屏蔽后,2号信号正常递达,在该进程中就会直接退出
    // 我们还想看到后续的现象,所以使用signal忽略掉此信号
    ::signal(2, SIG_IGN);
    // 1.屏蔽2号信号
    sigset_t block_set,old_set;
    sigemptyset(&block_set);
    sigemptyset(&old_set);
    // 1.1 添加SIGINT信号(编号为2)
    sigaddset(&block_set,SIGINT);
  
    // 1.2 设置进入进程的Block表中
    // 真正的修改当前进行的内核的block表,完成了对2号信号的屏蔽!
    sigprocmask(SIG_BLOCK, &block_set, &old_set); 
    while(true) 
    {
        //2.获取当前进程的pending信号集
        sigset_t pending;
        sigpending(&pending);
        //3.打印pending信号集
        PrintfPending(pending);
        sleep(1);

        cnt++;
        // 4. 解除屏蔽
        if(cnt == 5)
        {
            std::cout << "解除对2号信号的屏蔽" << std::endl;
            sigprocmask(SIG_SETMASK, &oblock, nullptr);
        }
    }
}

通过运行结果我们可以发现,一开始panding表全为0,当我们按下ctrl + c组合键后,变为1。几秒后,解除屏蔽又变为0。

信号的处理

当产生一个信号,进程需要将改进好保存起来,在合适的时候在执行该信号。那么这个合适的时候是什么时候呢?其实是在进程在从 内核态 切换回 用户态 的时候,检测当前进程的pending表&& block表,决定是否处理handler表处理信号!

用户态内核态

操作系统是怎样运行的

硬件中断

  • 中断向量表就是操作系统的一部分,启动就加载到内存中了
  • 通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询
  • 由外部设备触发的,中断系统运行流程,叫做硬件中断

时钟中断

进程可以在操作系统的指挥下,被调度,被执行,那么操作系统自己被谁指挥,被谁推动执行呢?答案是时钟中断!在每一个系统中都有一个叫做 “时钟源” 的外设,在极短的时间内一直向CPU发送中断请求,进而CPU获取中断号,查中断向量表,OS就不断的进行中断服务!只不过在现代计算机中, “时钟源” 都被集成在CPU内部(主频),因为在外部,时间上太慢了,空间上一直占用中断控制器。

基于上面的理解,操作系统是什么?操作系统就是基于中断向量表,进行工作的!

操作系统自己不做任何事情,需要什么功能,就向中断向量表里面添加方法即可。操作系统的本质:就是一个死循环!

什么是时间片?时间片就是一个int count;如果时间片没有减为0,就什么也不做;如果减为0,就进行进程切换!

软中断

软中断则是由软件(即系统中的某个进程或程序)触发的,常用于系统调用、异常处理以及中断服务的下半部处理。

为了让操作系统支持进行系统调用,CPU设计了对应的汇编指令(int 或者 syscall),可以让CPU内部触发中断逻辑,即软中断。

系统调用的过程,其实就是先int 0x80、syscall陷入内核,本质就是触发软中断,CPU就会自动执行系统调用的处理方法,而这个方法会根据系统调用号(数组下标),自动查表,执行对应的方法

  • CPU内部的软中断,比如int 0x80或者syscall,CPU内部没有错误,我们叫做 陷阱。
  • CPU内部的软中断,比如除零/野指针等,CPU内部出现错误, 我们叫做 异常。

信号捕捉的操作

sigaction函数

 参数说明

  • signum:指定要设置或获取处理程序的信号编号。可以指定SIGKILL和SIGSTOP以外的所有信号。
  • act:指向sigaction结构体的指针,用于指定新的信号处理方式。如果此参数非空,则根据此参数修改信号的处理动作。
  • oldact:如果非空,则通过此参数传出该信号原来的处理动作。(如果你想恢复以前的方式,此参数就是保存之前的操作方式)

 Demo样例代码

//act指向的sigaction结构体(我们只考虑第一个和第三个参数)
//struct sigaction {  
//    void (*sa_handler)(int);    // 指向信号处理函数的指针,接收信号编号作为参数  
//    void (*sa_sigaction)(int, siginfo_t *, void *);  // 另一个信号处理函数指针,支持更丰富的信号信息
//    sigset_t sa_mask;           // 设置在处理该信号时暂时屏蔽的信号集  
//    int sa_flags;               // 指定信号处理的其他相关操作,一般为0  
//    void (*sa_restorer)(void);  // 已废弃,不用关心  
//};

void handler(int signo)
{
    std::cout << "get a sig: " << signo << std::endl;
    exit(1);
}
int main()
{
    // 定义struct sigaction结构体对象
    struct sigaction act, oact;
    // 获得结构体中的方法
    act.sa_handler = handler;
    // 调用sigaction函数
    ::sigaction(2, &act, &oact);
    while (true)
        pause();
    return 0;
}


注意:如果在处理信号期间,又来了一个该信号需要处理。这时OS会自动把对应信号的block位设置为1(阻塞住该信号),等信号处理完成,OS又会自动把对应信号的block位由1置为0。

// 打印Block表
void PirintBLock()
{
    sigset_t set, oset;
    // 将信号集set、oset全部位置为0
    sigemptyset(&set);
    sigemptyset(&oset);

    sigprocmask(SIG_BLOCK, &set, &oset);
    std::cout << "block: ";
    for (int signo = 31; signo > 0; signo--)
    {
        if (sigismember(&oset, signo))    std::cout << 1;
        else    std::cout << 0;
    }
    std::cout << std::endl;
}
void handler(int signo)
{
    static int cnt = 0;
    cnt++;
    while (true)
    {
        std::cout << "get a sig: " << signo << ", cnt: " << cnt << std::endl;
        PirintBLock();
        sleep(1);
    }
    exit(1);
}
int main()
{
    struct sigaction act, oact;
    act.sa_handler = handler;

    ::sigaction(2, &act, &oact);

    while (true)
    {
        PirintBLock();
        pause();
    }
}

其他问题

volatile关键字

#include <stdio.h>
#include <signal.h>
#include <unistd.h>

int flag = 0;
void change(int signo) // 信号捕捉的执行流
{
    flag = 1;
    printf("change flag 0->1, getpid: %d\n", getpid());
}

int main()
{
    printf("I am main process, pid is : %d\n", getpid());
    signal(2, change);

    while(!flag); // 主执行流--- flag我们没有做任何修改!
    printf("我是正常退出的!\n");
}

上面代码中共有两个执行流,但它们同属一个进程。

当我们使用 signal 捕捉了2号信号,进而执行了change自定义方法后,全局变量flag就被更改为1,再回到main函数,其里面的while循环条件不成立,就会停止执行,因为CPU在执行while循环的时候,是实时的从内存中取flag来进行比较的,所以程序正常退出。

我们再次运行,并在编译时进行优化,优化会让CPU保存 之前在内存中取的flag的值,即,取flag在内存中最开始的值,这就会导致虽然flag的值发生了变化,但是CPU一直取得都是flag最开始的值,就导致while循环无法结束。

如果将上面代码中的int flag = 0;改为

volatile int flag = 0;    // 易变关键字

再运行。

我们发现可以正常退出了。volatile关键字表示保持flag变量的内存可见性即确保变量每次访问时都直接从内存中读取。(ps. 所有的关键字都是给编译器看的)

SIGCHLD信号(17号信号)

子进程退出时,不是静悄悄的退出的,会给父进程发送 SIGCHLD信号。

通过man 7 signal可以查到SIGCHLD信号的默认处理动作是Ign(忽略)

 Signal      Standard   Action   Comment
SIGCHLD      P1990      Ign     Child stopped or terminated

父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心⼦进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。

// 验证子进程退出,给父进程发送SIGCHLD,父进程通过该信号回收子进程
void handler(int signo)
{
    std::cout << "get a sig: " << signo << " I am : " << getpid() << std::endl;
    // 等待进程的pid为-1时,表示回收最近退出的子进程
    pid_t rid = ::waitpid(-1, nullptr, 0);
    if(rid > 0)
    {
        std::cout << "子进程退出了,回收成功,child id: " << rid << std::endl;
    }
}
int main()
{
    // 捕捉并自定义动作
    ::signal(SIGCHLD, handler);
    if(fork() == 0)
    {
        sleep(3);
        std::cout << "子进程退出" << std::endl;
        // 子进程
        exit(0);
    }
    while(1)    sleep(1);
    return 0;
}


signal(SIGCHLD, SIG_IGN); 

也可以通过上面一行代码来实现父进程对子进程的回收。这行代码的作用是设置信号处理函数,以便当子进程结束时(即发送SIGCHLD信号给父进程时),父进程忽略这个信号。通常,当子进程结束时,父进程需要处理这个信号以回收子进程的资源,但在这里,通过将其设置为SIG_IGN,父进程选择忽略这个信号,这意味着子进程的资源将由操作系统自动回收(这通常被称为“僵尸进程”的避免,尽管在这种情况下,由于子进程正常退出并设置了退出码,它实际上不会成为僵尸进程,因为操作系统会注意到并清理它)。

注意:如果你不关心子进程的退出信息就可以使用这种方法,否则还是要进行等待。


原文地址:https://blog.csdn.net/2302_77644537/article/details/145099700

免责声明:本站文章内容转载自网络资源,如侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!