Linux系统(信号篇):信号的处理

发布于:2025-07-04 ⋅ 阅读:(16) ⋅ 点赞:(0)

本节重点

  • 什么是信号处理
  • 信号处理的三种方式
  • Corn Dump
  • 自定义捕捉信号接口
  • 信号处理的时机
  • SIGCHLD信号解决僵尸进程

一、什么是信号处理

进程处理收到的信号,指的是当进程接收到操作系统传递的信号(如 Ctrl + C 触发的 SIGINT)后,依据自身配置或系统默认规则,对该信号做出响应的完整流程。

当进程通过各种方式(终端、系统调用、系统命令....)收到信号后,内核会暂时将信号标记到目标进程的 “待处理信号集”也就是进程PCB的pending位图中。而在该PCB的另一张block位图的相应位置中提前标记了该信号是否处于阻塞状态。如果处于阻塞状态,信号会一直处于未决状态直到进程解除对该信号的阻塞,此时信号才会被递达;如果没有处于阻塞状态,进程会在“合适的时间”将信号递达。

信号递达的方式一共有三种,下面是对这三种情况的详细说明:

二、信号处理的三种方式

1.1 默认处理

当进程未主动注册信号处理函数(signal/sigaction)时,信号会按 系统预设的 “默认动作” 递达执行。对于每个待递达的信号,内核根据 信号类型 执行预设的默认行为。POSIX 标准定义了常见信号的默认动作,Linux 实现如下(部分典型信号):

信号(man 7 signal 默认动作 场景 & 行为解释
SIGINT 终止进程(Terminate) Ctrl+C 触发,默认终止前台进程。
SIGTERM 终止进程(Terminate) 优雅终止信号,允许进程清理资源(可被捕获)。
SIGKILL 强制终止(Terminate) 无法捕获 / 忽略,直接 kill 进程(紧急终止)。
SIGSTOP 暂停进程(Stop) 无法捕获 / 忽略,暂停进程(需 SIGCONT 恢复)。
SIGSEGV 终止 + 核心转储(Core) 段错误(非法内存访问),终止进程并生成 core dump 用于调试。
SIGCHLD 忽略(Ignore) 子进程退出时,父进程默认忽略(需主动处理回收僵尸进程)。
SIGALRM 终止进程(Terminate) alarm 定时器超时触发,默认终止进程。

除了上述提到的常见的信号默认处理方式,在命令行窗口我们也可以输入以下指令查看信号相关的手册页,它提供了关于信号的详细信息,包括信号的定义、用途、默认行为、可移植性等内容

man 7 signal

信号列表中,Action 列 显示了信号的默认处理行为。这些行为可分为以下几类,每类的含义如下:

Action 含义 示例信号 能否自定义处理?
Term 终止进程 SIGTERMSIGINT
Core 终止并生成核心转储 SIGSEGVSIGABRT
Ign 忽略信号(进程无任何反应) SIGCHLDSIGURG
Stop 暂停进程 SIGTSTPSIGTTIN 能(除 SIGSTOP
Cont 恢复进程 SIGCONT

Core(Core Dump) 

在这里需要特别解释一下Core(进程终止并生成核心转储)的默认处理行为:

在 Linux 系统中,Core(核心转储,Core Dump)是指当进程因某些错误(如段错误、除零错误等)异常终止时,系统将进程的内存快照和寄存器状态保存到一个文件中。这个文件称为Core 文件,通常命名为corecore.<进程ID>,用于后续调试分析。一般的,生成的Core文件通常包含以下信息:

  • 程在崩溃时的全部内存数据(代码段、数据段、堆、栈等)。
  • CPU 寄存器的值(如程序计数器PC、栈指针SP等),指示崩溃时执行的指令位置。
  • 进程 ID、信号编号、崩溃时间等元数据。

 默认情况下,系统会限制 Core 文件的生成或大小,这是因为在开发环境中我们可能会接触到大量代码组成的程序,如果不限制Core文件的生成或大小系统可能会在程序运行崩溃时生成“巨大”的Core文件可能在短时间内占满磁盘和内存影响其他进程的正常运行。

 如何启用Core Dump?

在Linux系统中我们可以通过以下指令来检查Core Dump功能是否启用:

ulimit -c
ulimit -a

 ulimit -c如果返回值为0,表示此功能处于关闭状态,不会生成 Core 文件。

ulimit -a查看所有资源限制,其中core file size项如果为0,同样表示 Core Dump 功能未启用。

在命令行中我们可以通过以下命令来临时调整Core Dump限制:

# 临时允许生成core文件(大小无限制)
ulimit -c unlimited

# 或设置具体大小(单位:块,通常1块=512字节)
ulimit -c 10240  # 允许生成最大5MB的core文件

接下来我们写个程序来简单验证一下:首先我们写一个死循环:

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>


int main()
{
    printf("开始运行,PID:%d\n",getpid());
    while(1);
    return 0;
}

然后我们使用ctrl+\来终止程序,此时会显示core dumped,然后我们就可以看到生成的core文件了。 

1.2 自定义捕捉

自定义捕捉信号,指的是程序员在程序中自行设定如何处理特定信号,而不是使用系统默认的信号处理方式 。

signal

函数原型:

#include <signal.h>
void (*signal(int signum, void (*handler)(int)))(int);

 参数解析:

signum:指定要处理的信号编号,比如常见的SIGINT(编号 2,通常由用户按下 Ctrl+C 产生)、SIGTERM(编号 15,用于正常终止程序)等。

handler:是一个函数指针,指向自定义的信号处理函数。它有三种取值:

  • 自定义的信号处理函数:当进程接收到signum信号时,就会调用这个自定义函数进行处理。SIG_IGN:表示忽略该信号,进程收到此信号后不会做任何处理。
  • SIG_DFL:表示恢复该信号的默认处理方式,例如SIGINT默认会终止进程。

功能:

signal函数用于设置指定信号的处理方式,通过指定handler,可以让进程按照自定义的逻辑去响应特定信号,而不是使用系统默认的处理动作。

代码示例:

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

// 自定义的信号处理函数
void sigint_handler(int signum) {
    printf("Caught SIGINT signal. I won't terminate immediately!\n");
}

int main() {
    // 注册SIGINT信号的处理函数
    if (signal(SIGINT, sigint_handler) == SIG_ERR) {
        perror("signal");
        return 1;
    }

    while (1) {
        printf("Program is running...\n");
        sleep(1);
    }

    return 0;
}

sigaction

sigaction函数用于获取和修改指定信号的处理方式,相比signal函数,它提供了更丰富、更精细的信号处理设置选项,能够满足复杂的信号处理需求。比如,在捕捉一个信号之后在信号递达的过程中可以阻塞其他信号的递达防止对正在递达的信号进行干扰。

函数原型:

#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

参数解析:

  • 参数 signum:用于指定要处理的信号编号,它是一个整数,代表各种不同的信号
  • 参数 act:指向一个struct sigaction结构体,用于指定新的信号处理方式。如果该参数为NULL,则不会改变信号的当前处理方式,但是会通过oldact获取当前信号的处理设置。
  • 参数 oldact:指向一个struct sigaction结构体,用于保存原来的信号处理方式。如果不关心原来的处理方式,这个参数可以设置为NULL

 struct sigaction结构体:

struct sigaction {
    void     (*sa_handler)(int);
    void     (*sa_sigaction)(int, siginfo_t *, void *);
    sigset_t   sa_mask;
    int        sa_flags;
    void     (*sa_restorer)(void);  // 已过时,一般不使用
};
  • sa_handler:和signal函数中的handler类似,是一个函数指针,指向自定义的信号处理函数。
  • sa_sigaction:当sa_flags设置了SA_SIGINFO标志时,会使用这个函数作为信号处理函数。它比sa_handler能获取更多的信号相关信息,如发送信号的进程 ID 等。
  • sa_mask:用于设置在信号处理函数执行期间,需要额外阻塞的其他信号。这样可以避免在处理某个信号时,又收到其他干扰信号而导致程序出现意外行为。
  • sa_flags:用于设置一些选项,常见的取值有:
    • SA_RESTART:使得被信号中断的系统调用自动重启,例如readwrite等函数。
    • SA_SIGINFO:表示使用sa_sigaction作为信号处理函数。

 代码示例:

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

// 自定义的信号处理函数
void sigint_handler(int signum) {
    printf("Caught SIGINT signal. I won't terminate immediately!\n");
}

int main() {
    struct sigaction act;
    // 初始化sa_handler为自定义的信号处理函数
    act.sa_handler = sigint_handler;
    // 清空信号掩码,即不额外阻塞其他信号
    sigemptyset(&act.sa_mask); 
    // 设置sa_flags为0,使用默认选项
    act.sa_flags = 0; 

    // 注册SIGINT信号的处理函数
    if (sigaction(SIGINT, &act, NULL) == -1) {
        perror("sigaction");
        return 1;
    }

    while (1) {
        printf("Program is running...\n");
        sleep(1);
    }

    return 0;
}

1.3 忽略信号

信号忽略是指进程接收到特定信号后,不执行任何默认动作或自定义处理函数,而是直接丢弃该信号并非所有信号都能被忽略,例如:

  • 不可忽略的信号:SIGKILL(编号 9)和SIGSTOP(编号 19),这两个信号用于强制终止或暂停进程,无法被忽略、阻塞或捕获。
  • 可忽略的信号:大多数信号(如SIGINTSIGTERMSIGHUP等)默认会终止进程,但可以通过编程方式忽略它们。

1.3.1 默认忽略与捕捉忽略

有些型号默认的处理方法就是忽略指当进程没有对信号进行任何特殊设置时,内核对该信号的默认处理方式为忽略。例如 SIGCHLD 信号,当子进程停止或退出时,内核会向父进程发送 SIGCHLD 信号,其默认处理方式就是忽略。还有 SIGURG、SIGIO、SIGWINCH 等信号,默认情况下也是被忽略的。

我们可以在命令行中输入

man 7 signal

来查看信号的默认处理方式,其中在action列中标记为Ign的信号表示默认被进程忽略。 

除了有些特定的信号默认处理方式就是忽略外,我们也可以通过signal或者sigaction函数对其他默认处理方式不是忽略的信号进行自定义捕捉忽略: 以下是一个忽略SIGINTSIGHUP信号的完整程序:

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

int main() {
    // 忽略SIGINT(Ctrl+C)
    if (signal(SIGINT, SIG_IGN) == SIG_ERR) {
        perror("signal(SIGINT) error");
        return 1;
    }

    // 忽略SIGHUP(终端断开)
    if (signal(SIGHUP, SIG_IGN) == SIG_ERR) {
        perror("signal(SIGHUP) error");
        return 1;
    }

    printf("进程运行中,忽略SIGINT和SIGHUP...\n");
    printf("使用kill -9 %d 强制终止进程\n", getpid());

    while (1) {
        sleep(1);
    }

    return 0;
}

这里需要特别声明的是, 在linux系统中大多数信号可以通过编程方式自定义捕捉或忽略,但有两个关键信号永远无法被捕捉、忽略或阻塞

SIGKILL(编号 9)

  • 作用:强制终止进程,无法被进程拦截。
  • 用途:作为 “终极手段” 终止顽固进程(如陷入无限循环的程序)。
  • 特性
    • 内核直接处理,不传递给进程。
    • 无法通过signalsigaction或其他机制修改其行为。

2. SIGSTOP(编号 19)

  • 作用:暂停进程执行(类似Ctrl+Z),无法被进程拦截。
  • 用途:调试或暂停后台进程。
  • 特性
    • 进程收到后立即暂停,进入 T 状态(Stopped)。
    • 只能通过SIGCONT(编号 18)信号恢复执行。

 这里我们可以简单实验一下:即使用signal函数捕捉SIGKILL信号之后,SIGKILL信号也可以杀死进程。

#include<iostream>
#include<signal.h>
#include<unistd.h>
void handle(int n)
{
    std::cout<<"进程["<<getpid()<<"]捕获了"<<n<<"号信号"<<std::endl;
    sleep(1);
}
int main()
{
    //将SIGKILL信号捕捉
    signal(SIGKILL,handle);
    while(1)
    {
        std::cout<<getpid()<<"Im runnig....."<<std::endl;
        sleep(1);
    }
    return 0;
}

 

为什么这两个信号不可以被自定义? 

  • 系统安全机制:若进程可拦截SIGKILL,可能导致系统无法终止恶意或失控进程。
  • 进程控制基础SIGSTOPSIGCONT是进程调试和作业控制的基础,若可被拦截,shell 的fgbg等功能将失效。

1.3.2 SIGCHLD

当子进程状态发生变化时(如终止、暂停、恢复运行),内核向父进程发送该信号,通知父进程处理子进程的资源回收,避免产生僵尸进程(Zombie Process)。

为什么需要SIGCHLD?

子进程终止后,若父进程未回收其资源(如通过wait()waitpid()系统调用),子进程会进入 “僵尸状态”。此时进程描述符仍保留在系统中,占用 PID 等资源,长期积累可能导致系统资源耗尽。

SIGCHLD的处理方式:

1. 默认处理方式:忽略(IGN)

传统 Unix/Linux 中,父进程对SIGCHLD的默认处理是SIG_IGN(忽略),但内核不会自动回收子进程。此时若父进程不主动调用wait(),子进程会成为僵尸进程。

 2.显式设置 SIG_IGN 

// 示例:将SIGCHLD设置为忽略,内核自动回收子进程
signal(SIGCHLD, SIG_IGN);  // 或使用sigaction

时,子进程终止时内核会直接释放其资源,父进程无需调用wait(),也不会产生僵尸。

除了将SIGCHLD信号捕捉后显式设置SIG_IGN外也可以捕捉后在函数中回收子进程,这里我们可以编写一段代码捕获SIGCHLD信号实现对多个子进程的回收:

#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
#include <sys/wait.h>
#include <cstring>
// 子进程数量,命名更语义化
static int child_process_count = 5;  

// 更明确的函数名,表明是SIGCHLD信号的处理函数
void sigchld_handler(int signum) {  
    int n;
    // 循环回收已退出的子进程
    while ((n = waitpid(-1, NULL, WNOHANG)) > 0) {  
        std::cout << "回收了一个子进程 PID:" << n << std::endl;
    }
    if (n < 0) {
        std::cout << "wait fail!" << std::endl;
        char* error=strerror(errno);
        std::cout<<"reason:"<<error<<std::endl;
    }
    // n == 0 时无需处理,可根据需求决定是否打印提示
}

int main() {
    struct sigaction sa;
    sa.sa_handler = sigchld_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;
    // 使用sigaction更健壮地注册信号处理
    if (sigaction(SIGCHLD, &sa, NULL) == -1) {  
        std::cerr << "sigaction error" << std::endl;
        return 1;
    }

    for (int i = 0; i < child_process_count; i++) {
        pid_t id = fork();
        if (id < 0) {
            // 处理fork失败情况
            std::cerr << "fork error, i = " << i << std::endl; 
            continue;
        } else if (id == 0) {
            std::cout << "子进程 PID[" << getpid() << "] 开始运行" << std::endl;
            sleep(3);
            std::cout << "子进程 PID[" << getpid() << "] 退出" << std::endl;
            // 子进程退出
            return 0; 
        }
    }

    std::cout << "父进程 PID[" << getpid() << "]开始运行" << std::endl;
    while (1) {
        // 适当休眠,降低CPU消耗,可根据实际情况调整休眠时间
        sleep(1); 
    }
    return 0;
}

运行结果:

 

三、信号处理的时机

进程并非实时检测信号,而是在特定的执行点检查未决信号队列,这个特定的执行点就是我们一直提到的“进程不会立刻处理信号,而是在适当的时刻处理”。

当我们在运行我们的进程或程序时,当遇到系统调用或者收到一系列中断时(例如,查询页表发现虚拟地址所映射的物理地址缺失引发缺页中断)会陷入内核此时权限级别很高,此时操作系统会优先调用系统调用具体的实现方法或者处理接受到的中断机制,如检查进程的时间片是否耗尽。当处理完后程序会从内核返回到用户态,此时在返回用户态之前操作系统会检查当前进程的未决信号集并开始处理进程收到但未被阻塞的信号:

  • 默认处理:根据信号类型执行默认动作(如终止进程、生成 core 文件等)
  • 忽略处理:直接丢弃该信号,不做任何操作
  • 自定义处理:调用用户注册的信号处理函数

 其中当发现进程自定义捕捉了该信号时,操作系统会从内核态返回用户态执行相应的捕捉函数,之后会通过特殊的系统调用(如sigreturn)再次陷入内核,最后才从内核态返回用户态的主控制流程中从上次中断的地方继续向下执行。

为了方便理解整个流程我们可以用图例表示如下:

这里我们需要明白的是,操作系统从用户态陷入内核的过程十分频繁,它是建立在一系列中断机制下的。这里的中断包括了硬件中断,软件中断,时钟中断等,特别地程序也会通过系统调用主动陷入内核,所以操作系统对未决信号的检查几乎是实时的。 


网站公告

今日签到

点亮在社区的每一天
去签到