【Linux系统编程】第四十弹---深入理解操作系统:信号捕捉、可重入函数、volatile关键字与SIGCHLD信号解析

✨个人主页:熬夜学编程的小林

💗系列专栏: 【C语言详解】 【数据结构详解】【C++详解】【Linux系统编程】

目录

1、捕捉信号

1.1、内核如何实现信号的捕捉

1.2、内核态与用户态

[1.3.1、用户态(User Space)](#1.3.1、用户态(User Space))

[1.3.2、内核态(Kernel Space)](#1.3.2、内核态(Kernel Space))

1.3.3、用户态与内核态的交互

1.3.4、再谈地址空间

1.3、键盘输入数据过程

1.4、OS如何正常的运行

1.5、sigaction

2、可重入函数

3、volatile

4、SIGCHLD信号


1、捕捉信号

1.1、内核如何实现信号的捕捉

如果信号的处理动作是用户自定义函数 ,在信号递达时就调用这个函数 ,这称为捕捉信号。由于信号处理函数的代码是在**用户空间的,**处理过程比较复杂,举例如下:

用户程序注册了SIGQUIT信号的处理函数sighandler。

  • 1、当前正在执行main函数 ,这时发生中断或异常切换到内核态
  • 2、在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达
  • 3、内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是 两个独立的控制流程。
  • 4、sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。

信号可能不会被立即处理(信号被阻塞),而是在合适的时候处理。

进程从内核态返回到用户态的时候进行处理。

在内核态切回用户态时,进行信号的处理与检测。

1.2、内核态与用户态

1.3.1、用户态(User Space)

  1. 定义:用户态是应用程序运行的空间。当用户进程运行时,它会在用户态执行大部分的操作。

  2. 权限:用户态的代码具有较低的权限,不能直接访问硬件资源,也不能执行特权指令(如修改内存保护、执行I/O操作等)。这些操作必须通过系统调用(System Call)来请求内核来完成。

  3. 稳定性与安全性:由于用户态的代码权限较低,即使发生错误(如段错误、缓冲区溢出等),也不会对整个系统造成致命的影响。

  4. 示例:常见的运行在用户态的程序包括文本编辑器、浏览器、数据库等。

1.3.2、内核态(Kernel Space)

  1. 定义:内核态是操作系统内核运行的空间。内核是操作系统的核心部分,负责管理硬件、内存、进程、文件系统、网络等系统资源。

  2. 权限:内核态的代码具有较高的权限,可以直接访问硬件资源,执行特权指令。这使得内核能够执行各种底层操作,如设备驱动、中断处理、内存管理等。

  3. 稳定性与安全性:由于内核态的代码权限较高,如果内核代码出现错误(如内核崩溃、漏洞等),可能会导致整个系统崩溃或受到攻击。因此,内核代码需要格外小心地进行编写和测试。

  4. 系统调用接口:内核通过提供系统调用接口(System Call Interface, SCI)来与用户态程序进行交互。用户态程序通过系统调用请求内核执行特权操作。

  5. 示例:内核态的主要组成部分包括进程调度器、内存管理器、设备驱动程序、网络堆栈等。

1.3.3、用户态与内核态的交互

  1. 系统调用:当用户态程序需要执行特权操作时,它会通过系统调用接口请求内核完成该操作。系统调用是一种从用户态切换到内核态的机制。

  2. 中断和异常:除了系统调用外,中断和异常也是用户态与内核态交互的重要方式。例如,硬件中断(如I/O完成中断)会触发内核代码的执行,而异常(如除零异常)则可能导致内核接管并处理错误。

  3. 上下文切换:当用户态程序执行系统调用时,CPU会从用户态切换到内核态,并保存用户态的上下文(如寄存器值、堆栈指针等)。当系统调用完成后,CPU会恢复用户态的上下文并继续执行用户态程序。

1.3.4、再谈地址空间

基本认知:

1、无论进程如何切换,我们总能找到OS

2、我们访问的OS,实际上还是在我们的地址空间中进行的,和我们访问库函数没区别

3、OS不相信任何用户,因此用户访问[3,4]G的地址空间(内核空间)时,要收到一定的约束,只能使用系统调用

4、内核级页表只需要维护一份

1.3、键盘输入数据过程

键盘输入的过程是一个涉及硬件、驱动程序、操作系统以及用户界面的复杂交互过程。

1.4、OS如何正常的运行

1.5、sigaction

复制代码
sigaction - 检查和改变信号行为

#include <signal.h>

int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);

参数:
    signum:指定要设置的信号编号。这个参数可以是除SIGKILL及SIGSTOP
外的任何一个特定有效的信号。为这两个信号定义自己的处理函数,将导致信号安装错误。
    act:指向struct sigaction结构体的指针,该结构体中指定了对特定信号的处理方式。
如果为NULL,则进程会以缺省方式对信号处理。
    oldact:如果不为NULL,则保存原来对相应信号的处理方式。
如果不需要保存旧的处理方式,可以将其设置为NULL。

struct sigaction {  
    void (*sa_handler)(int);    // 或 union 中的 _sa_handler  
    void (*sa_sigaction)(int, siginfo_t *, void *); // 三参数信号处理函数  
    sigset_t sa_mask;           // 信号屏蔽字  
    int sa_flags;               // 标志位  
    // 以下成员已过时,POSIX不支持,不应再使用  
    void (*sa_restorer)(void);    
};

    sa_handler:这是一个指向信号处理函数的指针,与signal()函数的handler参数类似。
当接收到指定信号时,将调用此函数。但请注意,如果设置了SA_SIGINFO标志位,
则应使用sa_sigaction而不是sa_handler。
    sa_sigaction:这是一个三参数信号处理函数,当设置了SA_SIGINFO标志位时,
将使用此函数处理信号。它提供了关于信号的更多信息,如信号编号、信号来源等。
    sa_mask:定义了一组信号,在调用由sa_handler或sa_sigaction所定义的处理器程序时,
将阻塞这些信号,防止它们中断处理器程序的执行。
    sa_flags:位掩码,指定用于控制信号处理过程的各种选项。常用的标志位包括:
        SA_NODEFER:捕获该信号时,不会在执行处理器程序时将该信号自动添加到进程掩码中。
        SA_ONSTACK:针对此信号调用处理器函数时,使用了由sigaltstack()安装的备选栈。
        SA_RESETHAND:当捕获该信号时,会在调用处理器函数之前将信号处置重置为默认值(即SIG_IGN)。
        SA_SIGINFO:调用信号处理器程序时携带了额外参数,其中提供了关于信号的深入信息。
        SA_RESTART:执行信号处理后自动重启动先前中断的系统调用。

struct sigaction 结构体通常包含以下字段:

  • sa_handlersa_sigaction:一个指向信号处理函数的指针,或者是 SIG_IGN(忽略信号)或 SIG_DFL(采用默认行为)。
  • sa_mask:一个信号集,指定在信号处理函数执行期间应该阻塞哪些信号。
  • sa_flags:一组标志,用于修改 sigaction 的行为。例如,SA_RESTART 标志指示被信号中断的系统调用应该自动重启。
  • sa_restorer:(已废弃)用于恢复旧的信号处理机制,现代代码中不应使用。

代码演示

打印pending表

复制代码
void Print(sigset_t& pending)
{
    for(int sig = 31;sig >= 1;sig--)
    {
        if(sigismember(&pending,sig))
        {
            std::cout << "1";
        }
        else
        {
            std::cout << "0";
        }
    }
    std::cout << "\n";
}

自定义捕捉方法

复制代码
void handler(int signum)
{
    std::cout << "get a sig: " << signum << std::endl;
    while(true)
    {
        sigset_t pending;
        sigpending(&pending);

        Print(pending);
        sleep(1);
        //break;
    }
}

主函数

复制代码
int main()
{
    struct sigaction act,oact;
    act.sa_handler = handler;
    sigemptyset(&act.sa_mask); // 初始化信号集
    //sigaddset(&act.sa_mask,3); // 将3号信号添加到阻塞信号集
    // 将2号信号添加到阻塞信号集,如果进程再次接收到2号信号,它将被阻塞
    // 直到handler函数执行完毕或信号被解除阻塞
    sigaddset(&act.sa_mask,2); 
    act.sa_flags = 0;

    // 使用sigaction为1到31号的每个信号设置了一个相同的处理函数handler
    for(int i=1;i<=31;i++)
         sigaction(i,&act,&oact);
    //sigaction(2,&act,&oact);

    while(true)
    {
        std::cout << "I am a process,pid: " << getpid() << std::endl;
        sleep(1);
    }
    return 0;
}

2、可重入函数

main函数调用insert函数向一个链表head中插入节点node1 ,插入操作分为两步,刚做完第一步的 时候 ,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行 ,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了。

像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?

由于函数调用栈的独立性、线程隔离、递归调用的独立性以及作用域和生命周期的限制,两个不同的控制流程调用同一个函数时,访问其同一个局部变量或参数不会造成错乱。

如果一个函数符合以下条件之一则是不可重入的:

  • 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
  • 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

3、volatile

  • 该关键字在C当中我们已经有所涉猎,今天我们站在信号的角度重新理解一下

代码演示

复制代码
int gflag = 0;

void changedata(int sig)
{
    std::cout << "get a sig: " << sig << ",change gflag 0->1 " << std::endl;
    gflag = 1;
}

int main()
{
    signal(2,changedata);
    while(!gflag); // 不需要其他代码
    std::cout << "process quit formal!" << std::endl;
    return 0;
}

分析代码

从上面现象我们可以看到,如果对该程序编译进行优化,就会一直循环,为了解决该问题,可以使用**volatile关键字(保持内存可见性)**修饰全局变量

  • volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作

    volatile int gflag = 0; // 保持内存可见性,一直从内存加载到CPU

运行结果

4、SIGCHLD信号

进程一章讲过用wait和waitpid函数清理僵尸进程 ,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不 能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。

其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略 ,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。

代码演示

复制代码
void notice(int sig)
{
    std::cout << "get a sig: " << sig << ",pid: " << getpid() << std::endl;
}

void DoOtherThing()
{
    std::cout << "DoOtherThing()~" << std::endl;
}

int main()
{
    signal(SIGCHLD,notice);
    pid_t id = fork();
    if(id == 0)
    {
        // child
        std::cout << "I am child process,pid: " << getpid() << std::endl;
        sleep(3);
        exit(1);
    }
    // father
    while(true)
    {
        DoOtherThing();
        sleep(1);
    }
    return 0;
}

运行结果

子进程变僵尸进程

请编写一个程序完成以下功能:父进程fork出子进程,子进程调用exit(2)终止,父进程自定 义SIGCHLD信号的处理函数,在其中调用wait获得子进程的退出状态并打印。

修改上面验证子进程在终止时会给父进程发SIGCHLD信号中的notice函数代码即可。

复制代码
void notice(int sig)
{
    std::cout << "get a sig: " << sig << ",pid: " << getpid() << std::endl;
    pid_t rid = waitpid(-1,nullptr,0); // 阻塞
    if(rid > 0)
    {
        std::cout << "wait child success pid: " << getpid() << std::endl;
    }
    else if(rid < 0)
    {
        std::cout << "wait child failed!!!" << std::endl; 
    }
}

waitpid(-1,nullptr,0); -1表示等待任何子进程

运行结果

问题1: 如果一共有10个子进程,且同时退出呢?

在回收子进程的时候,打一个死循环即可,有进程就回收!

代码演示

复制代码
void notice(int sig)
{
    std::cout << "get a sig: " << sig << ",pid: " << getpid() << std::endl;
    while(true)
    {
        pid_t rid = waitpid(-1,nullptr,0); // 阻塞
        if(rid > 0)
        {
            std::cout << "wait child success pid: " << getpid() << std::endl;
        }
        else if(rid < 0)
        {
            std::cout << "wait child failed!!!" << std::endl; 
            break;
        }
    }
   
}

void DoOtherThing()
{
    std::cout << "DoOtherThing()~" << std::endl;
}

int main()
{
    signal(SIGCHLD,notice);
    for(int i=0;i<10;i++)
    {
        pid_t id = fork();
        if(id == 0)
        {
            // child
            std::cout << "I am child process,pid: " << getpid() << std::endl;
            sleep(3);
            exit(1);
        }
    }
    
    // father
    while(true)
    {
        DoOtherThing();
        sleep(1);
    }
    return 0;
}

运行结果

问题2: 如果一共有10个子进程, 5个退出,5个永远不退出呢?

回收需要退出的子进程即可,使用非阻塞等待子进程。

代码演示

复制代码
void notice(int sig)
{
    std::cout << "get a sig: " << sig << ",pid: " << getpid() << std::endl;
    while(true)
    {
        pid_t rid = waitpid(-1,nullptr,WNOHANG); // 阻塞 -> 非阻塞
        if(rid > 0)
        {
            std::cout << "wait child success pid: " << getpid() << std::endl;
        }
        else if(rid < 0)
        {
            std::cout << "wait child failed!!!" << std::endl; 
            break;
        }
        else
        {
            std::cout << "wait child success done " << std::endl;
            break;
        }
    }
   
}

事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可 用。

复制代码
int main()
{
    signal(SIGCHLD, SIG_IGN); // 收到设置对SIGCHLD进行忽略即可
    pid_t id = fork();
    if (id == 0)
    {
        int cnt = 5;
        while (cnt)
        {
            std::cout << "child running" << std::endl;
            cnt--;
            sleep(1);
        }

        exit(1);
    }
    while (true)
    {
        std::cout << "father running" << std::endl;
        sleep(1);
    }
}
相关推荐
xuanzdhc1 小时前
Linux 基础IO
linux·运维·服务器
愚润求学2 小时前
【Linux】网络基础
linux·运维·网络
不想写bug呀2 小时前
多线程案例——单例模式
java·开发语言·单例模式
bantinghy2 小时前
Linux进程单例模式运行
linux·服务器·单例模式
我不会写代码njdjnssj2 小时前
网络编程 TCP UDP
java·开发语言·jvm
小和尚同志3 小时前
29.4k!使用 1Panel 来管理你的服务器吧
linux·运维
帽儿山的枪手3 小时前
为什么Linux需要3种NAT地址转换?一探究竟
linux·网络协议·安全
李少兄9 天前
解决OSS存储桶未创建导致的XML错误
xml·开发语言·python
阿蒙Amon9 天前
《C#图解教程 第5版》深度推荐
开发语言·c#
shadon1789 天前
回答 如何通过inode client的SSLVPN登录之后,访问需要通过域名才能打开的服务
linux