【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);
    }
}
相关推荐
前端拾光者3 分钟前
利用D3.js实现数据可视化的简单示例
开发语言·javascript·信息可视化
程序猿阿伟4 分钟前
《C++ 实现区块链:区块时间戳的存储与验证机制解析》
开发语言·c++·区块链
运维老司机11 分钟前
Jenkins修改LOGO
运维·自动化·jenkins
傻啦嘿哟22 分钟前
如何使用 Python 开发一个简单的文本数据转换为 Excel 工具
开发语言·python·excel
大数据编程之光26 分钟前
Flink Standalone集群模式安装部署全攻略
java·大数据·开发语言·面试·flink
初九之潜龙勿用27 分钟前
C#校验画布签名图片是否为空白
开发语言·ui·c#·.net
D-海漠27 分钟前
基础自动化系统的特点
运维·自动化
我言秋日胜春朝★36 分钟前
【Linux】进程地址空间
linux·运维·服务器
爱摸鱼的孔乙己42 分钟前
【数据结构】链表(leetcode)
c语言·数据结构·c++·链表·csdn
Dola_Pan44 分钟前
C语言:数组转换指针的时机
c语言·开发语言·算法