Linux进程信号(五):可重入函数、volatile与SIGCHLD信号

一、如何理解内核态和用户态

1.1 为什么总能找到操作系统?

无论进程如何调度,操作系统始终"在场"

内核页表只有一份,所有进程共享;用户页表每个进程各有一份。因此无论CPU切换到哪个进程,通过页表映射总能找到同一个内核。

1.2 用户态 vs 内核态:权限隔离机制

问题:用户和内核在同一个4GB地址空间,用户能直接访问内核吗?

答案:不能!OS通过特权级别保护自己

状态身份标识可访问范围如何进入
用户态CPL=3只能访问[0,3GB]默认执行状态
内核态CPL=0可访问[0,4GB]全部系统调用/中断/异常

CPL(Current Privilege Level):CPU当前特权级,0表示内核,3表示用户

CPU通过状态标志位(CPL)管控运行权限,该标志位存储在代码段寄存器(CS)的低两位,0代表内核态,3代表用户态。用户态程序尝试访问内核区时,CPU会直接拦截并触发权限错误,保障内核安全。

2-2-1 如何切换状态?

用户态程序需通过预定义的陷入指令切换至内核态,例如int 0x80syscall,执行指令后CPU会自动切换CS寄存器至内核代码段,将CPL降为0,完成从用户态到内核态的切换。

1.3  中断与系统调用的执行流程

1.4 系统调用表(sys_call_table)

这是一个函数指针数组

// 系统调用号对应具体的内核函数
fn_ptr sys_call_table[] = {
    sys_setup,    // 0
    sys_exit,     // 1
    sys_fork,     // 2
    sys_read,     // 3
    sys_write,    // 4
    sys_open,     // 5
    sys_close,    // 6
    ...
    sys_pause,    // 暂停进程
    sys_signal,   // 信号处理相关
    sys_sigaction, 
    ...
};

用户程序调用 syscall 时,通过系统调用号索引到这个表,找到对应的内核函数执行。

二、可重入函数

2.1 什么是重入?

在单进程环境中,函数通常被一个控制流(主程序)调用。但是当信号发生时,进程可能正在执行某个函数,信号处理函数中也可能调用同一个函数。这就导致该函数在第一次调用尚未返回时,又被第二次调用。这种现象称为重入

2.2 实例

// 全局链表头指针
struct node *head = NULL;

void insert(struct node *new_node) {
    // 第一步:让新节点指向当前头
    new_node->next = head;
    // 假设在这里被信号中断
    // 第二步:更新头指针指向新节点
    head = new_node;
}

如果在第一步和第二步之间发生信号,信号处理函数中也调用了insert则可能导致head的更新错乱,最终链表结构被破坏。

2.3 什么函数是不可重入的?

如果一个函数满足以下任一条件,通常就是不可重入的:

  1. 使用了静态或全局变量(如上面的head)。

  2. 调用了mallocfree —— 因为堆管理使用全局链表。

  3. 调用了标准I/O库函数(如printfscanf)—— 内部有缓冲区,也是全局数据结构。

  4. 调用了其他不可重入函数

2.4 可重入函数的特点

  • 只访问自己的局部变量或参数。

  • 不修改全局数据(或者通过锁保护,但信号处理中加锁容易死锁,不推荐)。

  • 不调用不可重入的系统调用(但像readwrite等直接操作文件描述符且无缓冲区的系统调用通常是可重入的)。

📌 经验法则:在信号处理函数中,尽量只做最简单的操作(如设置一个全局标志)。如果必须做复杂处理,可以考虑在主循环中检查标志再执行。

三、volatile

volatile 是 C 语言中的一个类型修饰符,它告诉编译器:这个变量的值可能会在程序的控制流之外被意外改变。因此,编译器不能对它做任何优化(比如缓存到寄存器、省略看似多余的读写)每次访问该变量时都必须从它的原始内存地址重新读取。

【简而言之,不做优化~

man gcc ,查看优化级别 : 

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstdlib>
#include <sys/wait.h>

int flag = 0;

void handler(int signu)
{
    std::cout << "更改全局变量," << flag << "-> 1" << std::endl;
    flag = 1;
}

int main()
{
    signal(2,handler);
    while(!flag);
    std::cout << "process quit normal!" << std::endl;

    return 0;
}

按照代码的逻辑 , 按了Ctrl + c , handler执行 , flag = 1 , 主循环退出 ; 但是 !!!我们开启优化后 。 主循环永远卡死!!!

为什么while 循环没有退出 ? 

3.1 根本原因:编译器优化导致的"内存不可见"

// 源代码
while(!flag);  // main函数内flag不会被修改(编译器的判断)

// 编译器优化后(等价于)
register int tmp = flag;  // 将flag缓存到寄存器
while(!tmp);              // 永远读取寄存器,不再访问内存!
                          // 即使handler把内存中的flag改为1,
                          // 主循环也"看不见"

编译器认为main函数内部没有对flag的修改操作,因此"安全地"将其优化到寄存器中,导致内存写入对主循环不可见

3.2 volatile 的适用场景

场景说明示例
硬件寄存器映射外设寄存器值由硬件改变嵌入式开发中的GPIO状态寄存器
多线程共享变量(简单场景)一个线程写,另一个线程读标志位、状态通知
信号处理函数异步修改全局变量本例中的flag标志
中断服务程序中断中修改,主循环读取嵌入式中断标志

3.3 volatile 的局限性(重要!)

四、SIGCHLD信号

4.1 SIGCHLD信号的定义与作用

  • 信号编号:17号信号(SIGCHLD)。

  • 触发场景:当子进程终止或暂停时,操作系统自动向父进程发送此信号。

  • 关联知识:与进程管理中的“等待与回收”机制强相关。

    可是我没看到啊!!!接下来,写一个代码,证明一下,子进程在退出的时候的的确确发送了信号

    #include <iostream>
    #include <unistd.h>
    #include <signal.h>
    #include <cstdlib>
    #include <sys/wait.h>
    
    void say(int num)
    {
        std::cout << "father get a signal: " << num << std::endl;
    }
    
    int main()
    {
        // 注册SIGCHLD信号处理函数
        signal(SIGCHLD, say);
        
        pid_t id = fork();
        if (id == 0)
        {
            // 子进程
            std::cout << "I am child, exit" << std::endl;
            sleep(3);
            exit(3);
        }
        
        // 父进程等待子进程结束
        waitpid(id, nullptr, 0);
        std::cout << "I am father, exit" << std::endl;
        
        return 0;
    }

    4.2 SIGCHLD 的默认处理动作

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

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

    缺点就是 : 拿不到退出码 !!

    所以 , 如果我们想要拿到退出码 , 可以使用 waitpid , 但是waitpid 默认是阻塞式的等待 , 所以我们要使用WNOHANG 这个参数

    为什么要学习这么多的回收策略 ? 

    【根据需求进行选择】

    • 无状态跟踪 : SIG_ING
    • 需监控子进程状态 : 信号驱动 + WNOHANG

    默认动作为IGN , 为什么手动还需要用IGN ? 

    • SIGCHLD 默认行为为SIG_DEL(忽略) , 但实际表现为父进程需要显式处理
    • 本质区别
      • 系统默认忽略 : 子进程保留僵尸状态
      • 用户显示设置为 SIG_IGN:内核主动回收子进程资源

    4.3 SIGCHLD 的常见处理策略

    策略1:显式忽略(自动回收子进程)

    int main() {
        signal(SIGCHLD, SIG_IGN);  // 在fork前设置!
        
        pid_t id = fork();
        if (id == 0) {
            exit(0);  // 子进程直接退出
        }
        
        // 父进程无需wait,子进程不会变僵尸
        sleep(100);
    }

    策略2:自定义信号处理函数(异步回收)

    void handler(int sig) {
        // 在信号处理函数中调用waitpid
        // 注意:需要使用非阻塞模式或循环等待
        while (waitpid(-1, nullptr, WNOHANG) > 0);
    }
    
    int main() {
        signal(SIGCHLD, handler);
        // ... fork子进程 ...
    }

    注意:SIGCHLD是不可靠信号,多个子进程同时终止可能只触发一次信号,因此handler中要用while循环尽可能多地回收。

    评论
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

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

    抵扣说明:

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

    余额充值