【Linux】进程信号深度解析:从中断本质到信号捕捉全流程

目录

一、引言:信号的本质是"软中断"

[1.1 信号的核心定位](#1.1 信号的核心定位)

[1.2 信号与中断的深层关联](#1.2 信号与中断的深层关联)
二、信号基础认知:从生活场景到技术定义

[2.1 生活视角理解信号生命周期](#2.1 生活视角理解信号生命周期)

[2.2 信号的技术定义与核心属性](#2.2 信号的技术定义与核心属性)

[2.3 信号的三种处理动作](#2.3 信号的三种处理动作)
三、信号的产生:五大触发场景全解析

[3.1 终端按键触发(硬件中断衍生)](#3.1 终端按键触发(硬件中断衍生))

[3.2 系统命令/函数触发](#3.2 系统命令/函数触发)

[3.3 软件条件触发](#3.3 软件条件触发)

[3.4 硬件异常触发](#3.4 硬件异常触发)
四、信号的保存:未决与阻塞机制

[4.1 核心概念:未决、阻塞、递达](#4.1 核心概念:未决、阻塞、递达)

[4.2 内核中的信号存储结构](#4.2 内核中的信号存储结构)

[4.3 信号集操作函数实战](#4.3 信号集操作函数实战)
五、中断本质深度解析

[5.1 中断的核心作用:OS的"驱动引擎"](#5.1 中断的核心作用:OS的“驱动引擎”)

[5.2 硬件中断:外设与CPU的通信桥梁](#5.2 硬件中断:外设与CPU的通信桥梁)

[5.2.1 硬件中断的完整流程](#5.2.1 硬件中断的完整流程)

[5.2.2 中断向量表(IDT)与中断服务程序](#5.2.2 中断向量表(IDT)与中断服务程序)

[5.3 时钟中断:OS调度的"心跳"](#5.3 时钟中断:OS调度的“心跳”)

[5.3.1 时钟中断如何推动进程调度](#5.3.1 时钟中断如何推动进程调度)

[5.3.2 内核源码中的时钟中断设置](#5.3.2 内核源码中的时钟中断设置)

[5.4 软中断:系统调用与信号的底层实现](#5.4 软中断:系统调用与信号的底层实现)

[5.4.1 软中断的触发方式(int 0x80/syscall)](#5.4.1 软中断的触发方式(int 0x80/syscall))

[5.4.2 系统调用的软中断流程拆解](#5.4.2 系统调用的软中断流程拆解)

[5.5 异常:软件错误的中断化处理](#5.5 异常:软件错误的中断化处理)

[5.5.1 异常与信号的关联(除零/野指针)](#5.5.1 异常与信号的关联(除零/野指针))

[5.5.2 缺页中断:虚拟内存的核心异常机制](#5.5.2 缺页中断:虚拟内存的核心异常机制)
六、信号捕捉:从内核态到用户态的完整流程

[6.1 信号捕捉的核心流程拆解](#6.1 信号捕捉的核心流程拆解)

[6.2 sigaction函数:更强大的信号捕捉接口](#6.2 sigaction函数:更强大的信号捕捉接口)

[6.3 内核态与用户态切换的细节](#6.3 内核态与用户态切换的细节)
七、信号使用的避坑指南

[7.1 可重入函数与不可重入函数](#7.1 可重入函数与不可重入函数)

[7.2 volatile关键字的信号场景应用](#7.2 volatile关键字的信号场景应用)

[7.3 SIGCHLD信号:僵尸进程的优雅回收](#7.3 SIGCHLD信号:僵尸进程的优雅回收)
八、总结


一、引言:信号的本质是"软中断"

在Linux系统中,信号是进程间异步通信的核心机制,但其底层本质是对"中断"机制的软件模拟------硬件中断处理外设事件,而信号则处理进程间的异步事件。理解中断的工作原理,是掌握信号底层逻辑的关键;反过来,通过信号的使用,也能更深刻地理解操作系统如何通过中断驱动运行。

本文我将从信号的基础认知出发,逐步深入到中断的本质,详细拆解硬件中断、时钟中断、软中断、异常的底层实现,最终串联起信号"产生-保存-捕捉"的全流程,让你彻底搞懂信号与中断的深层关联。

1.1 信号的核心定位

信号是Linux中进程间事件异步通知的一种方式,属于"软中断"。它的核心特点是:

  • 异步性:信号的产生时机不可预知,进程执行到任意指令时都可能收到信号;
  • 内核管理:信号的产生、传递、处理均由内核统一管理,进程无法直接操作;
  • 中断特性:信号的处理流程与硬件中断类似,会打断进程当前执行流程,处理完成后恢复。

1.2 信号与中断的深层关联

中断是CPU处理外部事件的核心机制,分为硬件中断和软件中断(软中断/异常) 。信号本质是一种软中断,其处理流程完全借鉴了硬件中断的设计:

  1. 硬件中断:外设(键盘、磁盘)→ 触发CPU中断 → 内核执行中断服务程序;
  2. 信号:进程/内核 → 产生信号(软中断)→ 内核记录信号 → 合适时机执行信号处理函数。

可以说,信号是"用户态的中断",而中断是"内核态的信号",二者共享相同的底层执行逻辑。

二、信号基础认知:从生活场景到技术定义

在深入底层之前,先通过生活场景和技术定义,建立对信号的基础认知。

2.1 生活视角理解信号生命周期

用"收快递"的场景类比信号的完整生命周期,直观易懂:

  1. 信号产生:快递员到达(信号触发);
  2. 信号保存:你收到快递通知,但正在打游戏,暂时不处理(信号未决);
  3. 信号处理:游戏结束后,你去取快递(信号递达),处理方式有三种------自己拆(默认动作)、送给室友(自定义动作)、扔掉(忽略)。

对应到技术场景:

  • 信号产生:通过终端按键、函数调用等方式触发;
  • 信号保存:内核在进程PCB中记录信号(未决状态);
  • 信号处理:进程在合适时机(从内核态返回用户态前)执行处理动作。

2.2 信号的技术定义与核心属性

Linux系统中共有64种信号(34以下为常规信号,34以上为实时信号),每种信号都有明确的编号和宏定义(如SIGINT对应2号信号)。通过kill -l命令可查看所有信号:

信号的核心属性存储在进程的task_struct(PCB)中,关键结构体包括:

  • blocked:信号屏蔽字(阻塞信号集),标记哪些信号被阻塞;
  • pending:未决信号集,标记哪些信号已产生但未处理;
  • sighand:信号处理动作集合,存储每种信号的处理方式(默认/忽略/自定义)。

2.3 信号的三种处理动作

信号的处理动作由进程预先设定,共三种可选:

  1. 默认动作(SIG_DFL) :内核预定义的动作,如SIGINT(Ctrl+C)默认终止进程;
  2. 忽略动作(SIG_IGN):进程收到信号后不做任何处理;
  3. 自定义动作(信号捕捉):进程注册自定义函数,信号递达时执行该函数。

示例:通过signal函数设置SIGINT的自定义处理动作:

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

void handler(int signo) {
    std::cout << "进程[" << getpid() << "]捕获到信号:" << signo << std::endl;
}

int main() {
    std::cout << "进程PID:" << getpid() << std::endl;
    signal(SIGINT, handler); // 注册自定义处理函数
    while (true) {
        sleep(1);
        std::cout << "等待信号..." << std::endl;
    }
    return 0;
}

运行后按下Ctrl+C,进程不会终止,而是执行handler函数,证明信号被成功捕捉。若想强制终止得按Ctrl+\,发送3号信号SIGQUIT

三、信号的产生:五大触发场景全解析

信号的产生本质是"中断事件的触发",不同场景对应不同的中断来源,主要分为五大类:

3.1 终端按键触发(硬件中断衍生)

终端按键触发的信号,本质是"键盘硬件中断"的软件转化:

  1. 键盘按下(如Ctrl+C)→ 触发键盘硬件中断;
  2. 内核中断服务程序解析按键,转化为对应信号(SIGINT);
  3. 内核将信号发送给前台进程。

常见终端按键与对应信号:

  • Ctrl+CSIGINT(2号信号),默认终止进程;
  • Ctrl+\SIGQUIT(3号信号),默认终止进程并生成core dump文件;
  • Ctrl+ZSIGTSTP(20号信号),默认暂停进程。

3.2 系统命令/函数触发

通过系统命令或函数主动向进程发送信号,属于"软中断触发":

  • 命令kill -信号编号 PID(如kill -11 1234发送SIGSEGV信号);
  • 函数kill(向指定进程发信号)、raise(向当前进程发信号)、abort(向当前进程发SIGABRT信号)。

示例:用kill函数实现自定义"kill命令":

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

int main(int argc, char* argv[]) {
    if (argc != 3) {
        std::cerr << "用法:" << argv[0] << " -信号编号 PID" << std::endl;
        return 1;
    }
    int signo = std::stoi(argv[1]+1); // 去掉前缀"-"
    pid_t pid = std::stoi(argv[2]);
    return kill(pid, signo); // 发送信号
}

3.3 软件条件触发

由软件内部状态变化触发信号,典型场景:

  • 定时器超时:alarm函数设置定时器,超时后内核发送SIGALRM信号,该信号的默认处理动作是终止当前进程;
  • 管道破裂:向无读端的管道写数据,内核发送SIGPIPE信号。

示例:alarm函数实现定时信号:

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

int count = 0;

int main() {
    alarm(1); // 1秒后发送SIGALRM信号
    while (true) {
        std::cout << "count = " << count << std::endl;
        count++;
    }
    return 0;
}

3.4 硬件异常触发

硬件异常被CPU检测到后,内核转化为信号发送给当前进程,属于"异常中断触发":

  • 除零错误:CPU运算单元检测到除零,内核发送SIGFPE(8号信号);
  • 非法内存访问:MMU检测到无效地址,内核发送SIGSEGV(11号信号)。

示例:模拟非法内存访问触发SIGSEGV信号:

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

void handler(int signo) {
    std::cout << "捕获到信号:" << signo << "(非法内存访问)" << std::endl;
    exit(1);
}

int main() {
    signal(SIGSEGV, handler);
    int* p = NULL;
    *p = 100; // 非法内存访问,触发SIGSEGV
    return 0;
}

四、信号的保存:未决与阻塞机制

信号产生后并非立即处理,而是先被内核保存,等待合适的处理时机。这一过程涉及"未决"和"阻塞"两个核心概念。

4.1 核心概念:未决、阻塞、递达

  • 未决(Pending):信号从产生到递达之间的状态,内核已记录但未处理;
  • 阻塞(Block):进程可选择阻塞某个信号,被阻塞的信号会保持未决状态,直到阻塞解除;
  • 递达(Delivery):信号的处理动作被实际执行(默认/忽略/自定义)。

关键区别:阻塞是"不让信号递达",忽略是"信号递达后不处理",二者是不同层面的概念。

4.2 内核中的信号存储结构

内核在task_struct中通过三个核心结构管理信号:

  1. sigset_t blocked:信号屏蔽字,用位图表示(1位对应一个信号,1表示阻塞);
  2. struct sigpending pending:未决信号集,同样用位图表示(1位对应一个信号,1表示未决);
  3. struct sighand_struct *sighand:存储每种信号的处理动作(sa_handler指向处理函数)。

4.3 信号集操作函数实战

Linux提供专门的函数操作信号集(sigset_t),核心函数包括:

  • sigemptyset:初始化信号集为空;
  • sigfillset:初始化信号集包含所有信号;
  • sigaddset:向信号集添加指定信号;
  • sigdelset:从信号集删除指定信号;
  • sigismember:判断信号是否在信号集中;
  • sigprocmask:修改进程的信号屏蔽字(阻塞/解除阻塞);
  • sigpending:获取当前进程的未决信号集。

示例:阻塞SIGINT信号,观察未决状态变化:

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

void PrintPending(sigset_t& pending) {
    std::cout << "当前未决信号集:";
    for (int i = 31; i >= 1; --i) {
        if (sigismember(&pending, i)) {
            std::cout << "1";
        } else {
            std::cout << "0";
        }
    }
    std::cout << std::endl;
}

void handler(int signo) {
    std::cout << "捕获到信号:" << signo << std::endl;
    return;
}

int main() {
    signal(SIGINT, handler); // 注册自定义处理函数

    // 1. 初始化信号集,添加SIGINT(2号信号)
    sigset_t block_set, old_set;
    sigemptyset(&block_set);
    sigemptyset(&old_set);
    sigaddset(&block_set, SIGINT);

    // 2. 设置信号屏蔽字,阻塞SIGINT
    sigprocmask(SIG_BLOCK, &block_set, &old_set);

    int cnt = 10;
    while (cnt--) {
        // 3. 获取并打印未决信号集
        sigset_t pending;
        sigpending(&pending);
        PrintPending(pending);
        sleep(1);
    }

    // 4. 解除阻塞
    std::cout << "解除SIGINT阻塞!" << std::endl;
    sigprocmask(SIG_SETMASK, &old_set, nullptr);

    return 0;
}

运行后按下Ctrl+C,未决信号集会出现"1"(SIGINT未决),10秒后解除阻塞,信号被递达并执行handler函数。

五、中断本质深度解析

中断是操作系统的"心脏",所有异步事件(包括信号)都依赖中断机制处理。所以在讲解信号捕捉之前,我将详细拆解中断的分类、流程和底层实现。

5.1 中断的核心作用:OS的"驱动引擎"

中断的核心作用是"打破进程的连续执行",处理异步事件。操作系统的所有核心功能(进程调度、IO处理、信号传递)都依赖中断驱动:

  • 没有中断,OS会陷入死循环,无法响应任何外部事件;
  • 中断让OS具备"并发"能力,通过中断切换进程、处理外设请求。

中断的分类:

类型 触发源 示例
硬件中断 外部设备(键盘、磁盘) 键盘按下、磁盘IO完成
软中断 软件主动触发 系统调用(int 0x80)、信号
异常 软件错误(CPU检测) 除零、非法内存访问、缺页

5.2 硬件中断:外设与CPU的通信桥梁

硬件中断是外部设备与CPU的通信方式,流程完全由硬件和内核协同完成。

5.2.1 硬件中断的完整流程
  1. 外设就绪:外部设备完成操作(如键盘按下、磁盘IO完成);
  2. 触发中断:设备向CPU发送中断信号(通过中断控制器8259A);
  3. CPU响应:CPU暂停当前执行的指令,保存上下文(寄存器状态);
  4. 查找中断服务程序 :CPU通过"中断向量号"查找中断向量表(IDT),获取中断服务程序(ISR)的地址;
  5. 执行中断服务程序:内核执行对应设备的中断服务程序(如键盘中断处理函数);
  6. 恢复上下文:中断处理完成后,CPU恢复之前的上下文,继续执行原程序。
5.2.2 中断向量表(IDT)与中断服务程序

中断向量表(Interrupt Descriptor Table, IDT)是内核在内存中维护的一张表,存储所有中断的服务程序地址 。每个中断对应一个"中断向量号"(0~255),作为IDT的下标。下图展示了OS通过IDT进行中断处理的完整流程。

5.3 时钟中断:OS调度的"心跳"

时钟中断是最特殊的硬件中断,由系统时钟(如8253定时器)周期性触发,是OS调度的"心跳"。

5.3.1 时钟中断如何推动进程调度
  1. 系统时钟每10ms(可配置)触发一次时钟中断;
  2. 中断服务程序timer_interrupt被调用,内核更新系统时间和进程时间片;
  3. 若当前进程时间片耗尽,内核调用schedule函数进行进程切换;
  4. 切换完成后,恢复新进程的上下文,继续执行。

没有时钟中断,OS无法进行进程调度,所有进程会一直执行直到结束,无法实现"并发"。

5.3.2 内核源码中的时钟中断设置

Linux内核通过schedule_init函数(kernel/sched.c)注册时钟中断:

c 复制代码
void sched_init(void) {
    // 注册时钟中断服务程序
    set_intr_gate(0x20, &timer_interrupt);
    // 允许时钟中断(修改中断控制器屏蔽码)
    outb(inb_p(0x21) & ~0x01, 0x21);
    // ... 其他初始化
}

// 调度⼊⼝
void do_timer(long cpl)
{
	// ...
	schedule();
}

void schedule(void)
{
	// ...
	switch_to(next); // 切换到任务号为next 的任务,并运⾏之。
}

// system_call.s
_timer_interrupt:
    call _do_timer // 调用C语言处理函数
    iret // 恢复上下文,返回用户态

do_timer函数是时钟中断的核心处理逻辑,最终调用schedule函数完成进程切换。

5.4 软中断:系统调用与信号的底层实现

软中断是软件主动触发的中断,核心用于系统调用和信号传递。

5.4.1 软中断的触发方式
  • 32位系统:通过int 0x80指令触发软中断,0x80是系统调用的中断向量号;
  • 64位系统:通过syscall指令触发,效率更高。

软中断的本质是"软件模拟硬件中断",流程与硬件中断一致,但触发源是软件指令。

5.4.2 系统调用的软中断流程拆解

系统调用(如openreadfork)是用户态进程请求内核服务的唯一方式,底层通过软中断实现:

  1. 用户态准备 :进程将系统调用号存入eax寄存器,参数存入ebxecx等寄存器;
  2. 触发软中断 :执行int 0x80指令,CPU切换到内核态;
  3. 查找系统调用表 :内核通过eax中的系统调用号,查找sys_call_table,获取对应系统调用的实现函数;
  4. 执行系统调用 :内核执行系统调用函数(如sys_open),完成后将返回值存入eax
  5. 恢复用户态 :执行iret指令,恢复用户态上下文,进程继续执行。

内核源码中的系统调用表(include/linux/sys.h):

c 复制代码
// 系统调用函数指针表
fn_ptr sys_call_table[] = {
    sys_setup,  // 0: 系统初始化
    sys_exit,   // 1: 进程退出
    sys_fork,   // 2: 创建进程
    sys_read,   // 3: 读文件
    sys_write,  // 4: 写文件
    // ... 其他系统调用
};

信号的传递也依赖软中断:内核在合适时机(如中断处理完成后)检查进程的未决信号,若有则触发软中断,执行信号处理函数。

5.5 异常:软件错误的中断化处理

异常是CPU检测到的软件错误,属于"被动触发的软中断",流程与硬件中断类似,但触发源是软件执行错误。

5.5.1 异常与信号的关联

异常发生后,内核会将其转化为对应信号,发送给当前进程:

  • 除零错误 → 内核触发SIGFPE信号;
  • 非法内存访问 → 内核触发SIGSEGV信号;
  • 缺页错误 → 内核先处理缺页(申请内存、映射页表),处理失败则触发SIGSEGV信号。

示例:除零错误的异常处理流程:

  1. 进程执行a = 10 / 0,CPU检测到除零异常,触发中断向量号为0的异常;
  2. 内核执行divide_error中断服务程序;
  3. 服务程序将异常转化为SIGFPE信号,设置进程的未决信号集;
  4. 进程从内核态返回用户态前,检查到未决信号,执行信号处理动作(默认终止进程)。
5.5.2 缺页中断:虚拟内存的核心异常机制

缺页中断(缺页异常)作为虚拟内存的核心支撑,根据"数据位置"和"触发原因"分为三类:硬缺页(Major Fault)软缺页(Minor Fault)无效缺页(Invalid Fault),下面逐一拆解:

1. 硬缺页
  • 定义 :当程序访问的页面有效 (属于其地址空间),但尚未被加载到物理内存中时发生的缺页。这是最普遍的缺页类型。
  • 触发场景
    • 程序第一次访问一个代码或数据页。
    • 之前被交换到磁盘交换分区/文件的页面被再次访问。
  • 处理过程
    1. 操作系统需要找到一个空闲的物理页帧。
    2. 磁盘(可能是可执行文件本身,也可能是交换空间)中将所需页面读入该页帧。
    3. 更新页表,建立虚拟页到物理页帧的映射。
    4. 重新执行引发缺页的指令。
2. 软缺页
  • 定义 :所需页面已经在物理内存中 ,但当前进程的页表中没有建立有效的映射关系
  • 触发场景
    • 进程创建(写时拷贝) :在 fork() 系统调用后,父子进程最初共享所有物理页面,但页面被标记为只读。当任一进程尝试写入该页面时,会触发缺页。此时操作系统会为写入的进程分配一个新的物理页,复制原页面内容,并建立新的可写映射。
    • 内存映射文件:一个文件被映射到进程内存空间,当首次访问某个页面时,虽然该页面可能已被其他进程缓存到内存中,但当前进程的页表还没有指向它。
    • 页面在内存,但未分配:有时操作系统可能已经为进程预加载了页面到内存,但还没来得及建立页表项。
  • 处理过程
    1. 操作系统无需进行磁盘I/O。
    2. 只需在页表中建立或修改一个映射,指向已存在于内存中的页帧。
    3. 重新执行引发缺页的指令。
3. 无效缺页 / segfault
  • 定义 :程序访问了一个非法的地址,该地址不属于其合法的地址空间。
  • 触发场景
    • 访问了NULL指针或随机的未分配地址。
    • 访问了已释放的内存。
    • 试图向代码段等只读区域执行写操作。
  • 处理过程
    1. 操作系统无法解决这个错误。
    2. 它会向当前进程发送一个信号 (在Unix/Linux中是 SIGSEGV,即段错误)。
    3. 如果进程没有自定义的信号处理程序,默认行为是终止进程并可能产生核心转储文件。
总结与对比
类型 原因 页面是否在内存? 处理成本 最终结果
硬缺页 页面在磁盘上 非常高(需磁盘I/O) 加载页面,建立映射,继续执行
软缺页 页面在内存,但映射缺失 非常低(仅修改页表) 建立映射,继续执行
无效缺页 访问非法地址 不适用 无法修复(程序崩溃) 发送SIGSEGV信号,通常终止进程

六、信号捕捉:从内核态到用户态的完整流程

信号捕捉是信号处理的核心环节,当信号的处理动作是自定义函数时,内核会切换到用户态执行该函数,流程与中断处理紧密关联。

6.1 信号捕捉的核心流程拆解

假设进程注册了SIGINT的自定义处理函数handler,信号捕捉的完整流程:

  1. 信号产生 :用户按下Ctrl+C,内核产生SIGINT信号,设置进程的未决信号集;
  2. 中断触发:进程执行到任意指令时,发生时钟中断(或其他中断),切换到内核态;
  3. 中断处理:内核处理完中断(如更新时间片),准备返回用户态;
  4. 检查信号 :内核检查进程的未决信号集,发现SIGINT未决且未被阻塞;
  5. 切换处理函数 :内核不直接返回原程序,而是切换到用户态的handler函数;
  6. 执行处理函数handler函数执行完成后,调用sigreturn系统调用,再次进入内核态;
  7. 恢复原程序 :内核清除SIGINT的未决标志,恢复原程序的上下文,返回用户态继续执行。

关键特点:handler函数与原程序是两个独立的控制流程,不存在调用关系,通过内核态切换连接。

6.2 sigaction函数:更强大的信号捕捉接口

signal函数功能简单,sigaction函数是更强大的信号捕捉接口,支持设置信号屏蔽字、保存原处理动作等。

函数原型:

c 复制代码
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
  • signo:要设置的信号编号;
  • act:新的信号处理配置(若非空);
  • oact:保存原有的信号处理配置(若非空)。

struct sigaction结构体:

c 复制代码
struct sigaction {
    void (*sa_handler)(int); // 处理函数(与signal一致)
    sigset_t sa_mask; // 信号处理期间的屏蔽字(自动屏蔽当前信号)
    int sa_flags; // 标志位(如SA_RESTART重启被中断的系统调用)
    void (*sa_sigaction)(int, siginfo_t *, void *); // 实时信号处理函数
};

示例:用sigaction设置SIGINT的处理函数:

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

void handler(int signo) {
    std::cout << "捕获到信号:" << signo << std::endl;
}

int main() {
    struct sigaction act, oact;
    act.sa_handler = handler;
    sigemptyset(&act.sa_mask); // 初始化屏蔽字
    act.sa_flags = 0; // 默认标志

    // 设置SIGINT的处理动作
    if (sigaction(SIGINT, &act, &oact) < 0) {
        perror("sigaction");
        return 1;
    }

    while (true) {
        sleep(1);
        std::cout << "等待信号..." << std::endl;
    }

    return 0;
}

6.3 内核态与用户态切换的细节

信号捕捉过程中,内核态与用户态会发生三次切换:

  1. 进程执行 → 中断触发 → 内核态(处理中断);
  2. 内核态 → 信号处理 → 用户态(执行handler);
  3. 用户态 → sigreturn → 内核态(清除未决信号);
  4. 内核态 → 恢复上下文 → 用户态(执行原程序)。

切换的核心是"上下文保存与恢复":

  • 内核态切换时,保存用户态的寄存器、程序计数器(PC);
  • 恢复时,从保存的上下文恢复寄存器和PC,继续执行。

七、信号使用的避坑指南

在实际使用信号时,需要注意可重入函数、volatile关键字等细节,避免出现隐藏bug。

7.1 可重入函数与不可重入函数

信号处理函数可能在任意时刻执行,若处理函数与原程序访问同一全局资源,可能导致数据错乱,这涉及"可重入性"的概念:可重入性描述的是一个函数能否在尚未返回的情况下,被再次安全地调用(重入),导致再次调用的原因可能是:

  • 多线程并发执行。
  • 函数被中断,在中断处理程序中又被调用。
  • 函数直接或间接地调用自身(递归)。

与之对应的函数分为:

  • 可重入函数 :仅访问局部变量或参数,每次调用都在自己的栈帧中拥有独立的数据副本,互不干扰。(如strcpymemset);
  • 不可重入函数 :访问全局资源(全局变量、堆内存、标准IO),多个调用上下文共享了同一个状态(静态/全局数据),导致一个上下文的数据被另一个上下文覆盖。(如mallocprintffwrite)。

示例:不可重入函数导致的问题:

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

int global = 0;
void insert(int val) {
    // 全局变量操作(不可重入)
    global = val;
    sleep(1); // 模拟耗时操作
    std::cout << "global = " << global << std::endl;
}

void handler(int signo) {
    insert(100); // 信号处理函数调用insert
}

int main() {
    signal(SIGINT, handler);
    insert(10); // 主程序调用insert
    return 0;
}

运行后按下Ctrl+Cglobal会被信号处理函数修改,导致输出结果错乱。

7.2 volatile关键字的信号场景应用

编译器优化可能导致信号处理函数修改的变量不被主程序感知,volatile关键字可禁止编译器优化,确保变量的内存可见性。

示例:未使用volatile的问题:

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

int flag = 0; // 未加volatile
void handler(int signo) {
    flag = 1;
    std::cout << "flag = " << flag << std::endl;
}

int main() {
    signal(SIGINT, handler);
    while (!flag); // 编译器优化后,可能一直循环
    std::cout << "进程退出" << std::endl;
    return 0;
}

编译时添加-O2优化,while (!flag)会被优化为死循环(编译器认为flag不会变化)。添加volatile关键字后,编译器会每次从内存读取flag,解决问题:

cpp 复制代码
volatile int flag = 0; // 禁止优化

7.3 SIGCHLD信号:僵尸进程的优雅回收

子进程终止时会向父进程发送SIGCHLD信号,父进程可通过捕捉该信号,调用waitpid回收僵尸进程,避免轮询。

示例:SIGCHLD信号回收僵尸进程:

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

void handler(int signo) {
    pid_t id;
    // 非阻塞回收所有终止的子进程
    while ((id = waitpid(-1, nullptr, WNOHANG)) > 0) {
        std::cout << "回收子进程:" << id << std::endl;
    }
}

int main() {
    signal(SIGCHLD, handler); // 注册SIGCHLD处理函数
    pid_t pid = fork();
    if (pid == 0) {
        std::cout << "子进程PID:" << getpid() << std::endl;
        sleep(3);
        exit(0);
    }

    while (true) {
        sleep(1);
        std::cout << "父进程工作中..." << std::endl;
    }

    return 0;
}

子进程终止后,父进程收到SIGCHLD信号,在handler中回收子进程,避免僵尸进程产生。

八、总结

  1. 信号的本质是软中断:信号的处理流程完全借鉴硬件中断,通过内核态与用户态的切换实现异步处理;
  2. 中断是OS的核心驱动:硬件中断处理外设事件,时钟中断推动进程调度,软中断实现系统调用,异常处理软件错误;
  3. 信号捕捉的核心是上下文切换:信号处理函数与原程序是独立控制流程,通过内核态切换连接;
  4. 实际使用需避坑 :避免在信号处理函数中使用不可重入函数,关键变量需加volatile关键字。
相关推荐
n***84071 小时前
Linux安装RabbitMQ
linux·运维·rabbitmq
拾光Ծ3 小时前
【Linux】冯诺依曼体系结构和操作系统概述
linux·硬件架构
hfut02883 小时前
第25章 interface
linux·服务器·网络
风123456789~7 小时前
【Linux专栏】显示或隐藏行号、批量注释
linux·运维·服务器
只想安静的写会代码9 小时前
centos/ubuntu/redhat配置清华源/本地源
linux·运维·服务器
susu10830189119 小时前
ubuntu多块硬盘挂载到同一目录LVM方式
linux·运维·ubuntu
r***F2629 小时前
【漏洞复现】CVE-2019-11043(PHP远程代码执行漏洞)信息安全论文_含漏洞复现完整过程_含Linux环境go语言编译环境安装
linux·golang·php
smaller_maple10 小时前
linux问题记录1
linux·运维·服务器
报错小能手11 小时前
讲讲libevent底层机制
linux·服务器