Linux 信号详解:从 Ctrl+C 到进程异常退出,真正理解信号机制

引入信号概念

在 Linux 中,信号是一个非常重要的概念。我们平时写程序、调试程序、管理进程时,经常会在不知不觉中用到它。

比如按下:

bash 复制代码
Ctrl + C

正在运行的程序会退出。

再比如执行:

bash 复制代码
kill -9 pid

某个进程会被强制杀死。

还有程序访问空指针时出现的:

bash 复制代码
Segmentation fault

这些现象背后,其实都和 Linux 的信号机制有关。

信号这个东西刚开始看起来有点抽象,但如果用一句话来理解,它其实就是:

信号是 Linux 提供的一种异步通知机制,用来告诉进程:某件事情发生了,你需要处理一下。

这篇文章就从最熟悉的 Ctrl+C 开始,一步一步讲清楚信号是什么、怎么产生、怎么保存、怎么处理,以及它在实际编程中的使用。


一、从 Ctrl+C 开始理解信号

先看一个最简单的程序:

c 复制代码
#include <stdio.h>
#include <unistd.h>

int main()
{
    while (1)
    {
        printf("I am running...\n");
        sleep(1);
    }

    return 0;
}

编译运行:

bash 复制代码
gcc test.c -o test
./test

程序会一直打印:

bash 复制代码
I am running...
I am running...
I am running...

这时候如果我们按下 Ctrl+C,程序就会退出。

表面上看,好像是键盘直接让程序停止了。实际上并不是这样。

当我们按下 Ctrl+C 时,终端驱动会向当前前台进程发送一个信号:

bash 复制代码
SIGINT

SIGINT 的默认动作就是终止进程,所以程序退出了。

也就是说,Ctrl+C 本身并不是"魔法按键",它只是触发了一个信号。

信号可以来自很多地方,比如用户按键、命令发送、程序异常、定时器到期、子进程退出等。进程收到信号以后,可以执行默认动作,也可以选择忽略,或者执行自己写好的处理函数。


二、常见信号先有个印象

Linux 中的信号有很多,可以通过下面的命令查看:

bash 复制代码
kill -l

常见信号如下:

信号 编号 常见来源 默认动作
SIGINT 2 按下 Ctrl+C 终止进程
SIGQUIT 3 按下 Ctrl+\ 终止进程并产生 core dump
SIGKILL 9 kill -9 pid 强制终止进程
SIGSEGV 11 访问非法内存 终止进程并产生 core dump
SIGPIPE 13 管道读端关闭后继续写 终止进程
SIGALRM 14 定时器到期 终止进程
SIGTERM 15 默认 kill pid 终止进程
SIGCHLD 17 子进程退出 默认忽略
SIGSTOP 19 暂停进程 暂停进程
SIGCONT 18 继续运行暂停的进程 继续进程

这里有几个信号需要重点记一下。

SIGINT 是按下 Ctrl+C 时产生的信号,它的默认动作是终止进程。但是这个信号可以被捕捉,所以程序可以选择不退出,而是执行自己定义的处理函数。

SIGKILLkill -9 发送的信号,它非常特殊。这个信号不能被捕捉、不能被忽略、也不能被阻塞。系统必须保留这样一种强制终止进程的手段,否则某些进程可能永远杀不掉。

SIGSEGV 就是常见的段错误。比如:

c 复制代码
int *p = NULL;
*p = 10;

这段代码访问了非法内存,程序一般就会收到 SIGSEGV 信号,然后异常退出。

SIGCHLD 在多进程编程中非常重要。子进程退出时,操作系统会给父进程发送 SIGCHLD,父进程可以借助它回收子进程,避免僵尸进程。

刚开始学习时,不需要把所有信号编号都背下来。先记住几个高频的:SIGINTSIGKILLSIGTERMSIGSEGVSIGCHLD 就够了。


三、信号从产生到处理,中间发生了什么

信号不是一产生就一定马上被处理。

一个信号从产生到真正执行处理动作,中间大致会经历这样的流程:

这里面有几个关键概念需要连在一起理解。

首先是信号产生。信号可以由键盘产生,比如 Ctrl+C;可以由命令产生,比如 kill pid;也可以由程序异常产生,比如空指针访问;还可以由程序自己给自己发送,比如调用 raise

信号产生之后,会被记录到进程中。如果这个信号已经产生,但是还没有被处理,就叫做未决信号,也就是 pending signal。

但是进程还可以选择暂时屏蔽某些信号。被屏蔽的信号即使产生了,也不会马上被处理,而是继续保持未决状态。这个过程叫做信号阻塞。

这里要特别注意:

阻塞不是忽略。

阻塞的意思是:

text 复制代码
信号来了,但是我现在先不处理,等解除阻塞之后再说。

忽略的意思是:

text 复制代码
信号来了,但是我直接丢掉,不处理。

这两个概念一定不要混淆。

从内核角度看,每个进程都有自己的进程控制块,也就是 PCB。信号相关的信息也会保存在 PCB 中。

可以简单理解成:进程内部有几张"表"。

pending 信号集记录哪些信号已经来了;block 信号集记录哪些信号现在被屏蔽;handler 表记录每个信号应该怎么处理。

比如 SIGINT 已经产生了,并且它又被阻塞了,那么它就会同时出现在 pending 和 block 中。

这时候它的状态就是:

SIGINT 已经来了,但是暂时不能被处理。

等以后解除阻塞时,这个信号才有机会被递达。


四、发送信号与捕捉信号

理解完信号的基本流程之后,再来看相关命令和函数就清晰很多了。

很多人看到 kill 命令,第一反应是"杀进程"。其实 kill 的本质不是杀进程,而是向指定进程发送信号。

语法如下:

bash 复制代码
kill -信号 pid

比如:

bash 复制代码
kill -2 pid

表示向指定进程发送 SIGINT

也可以写成:

bash 复制代码
kill -SIGINT pid

如果不指定信号,默认发送的是 SIGTERM

bash 复制代码
kill pid

SIGTERM 可以理解成一种比较礼貌的退出请求。它是在告诉进程:"你该退出了。"

而:

bash 复制代码
kill -9 pid

发送的是 SIGKILL,属于强制终止。进程没有机会执行清理逻辑。

所以实际使用时,一般建议先用:

bash 复制代码
kill pid

如果进程确实无法正常退出,再考虑:

bash 复制代码
kill -9 pid

程序中也可以捕捉信号,最简单的方式是使用 signal 函数:

c 复制代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void handler(int signo)
{
    printf("捕捉到信号:%d\n", signo);
}

int main()
{
    signal(SIGINT, handler);

    while (1)
    {
        printf("程序正在运行,按 Ctrl+C 试试...\n");
        sleep(1);
    }

    return 0;
}

运行程序后,按下 Ctrl+C,程序不会直接退出,而是执行 handler 函数。

原本 SIGINT 的默认动作是终止进程,现在我们把它改成了执行自己的处理函数。

信号递达后,大体有三种处理方式:

不过需要注意,signal 虽然简单,但是在实际工程中更推荐使用 sigaction,因为它的行为更加明确,也能设置更多选项。

c 复制代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void handler(int signo)
{
    printf("catch signal: %d\n", signo);
}

int main()
{
    struct sigaction act;

    act.sa_handler = handler;
    sigemptyset(&act.sa_mask);
    act.sa_flags = 0;

    sigaction(SIGINT, &act, NULL);

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

    return 0;
}

sigaction 中常见的几个成员如下:

c 复制代码
struct sigaction {
    void (*sa_handler)(int);
    sigset_t sa_mask;
    int sa_flags;
};

其中,sa_handler 表示信号处理函数;sa_mask 表示执行处理函数期间临时屏蔽哪些信号;sa_flags 用来设置额外选项。

初学阶段不需要把所有选项都背下来,先掌握这种基本写法就够了。

还要注意一点,信号处理函数不是普通函数。

普通函数是我们主动调用的,而信号处理函数是信号到来后,由操作系统安排执行的。它可能在程序执行的任意位置被调用,所以处理函数里面不要写太复杂的逻辑。

教学代码里经常用 printf,但严格来说,printf 并不是异步信号安全函数。更稳妥的做法是只修改一个标志位,然后让主流程去处理真正的逻辑。

例如:

c 复制代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

volatile sig_atomic_t quit = 0;

void handler(int signo)
{
    quit = 1;
}

int main()
{
    signal(SIGINT, handler);

    while (!quit)
    {
        printf("running...\n");
        sleep(1);
    }

    printf("收到退出信号,开始清理资源...\n");

    return 0;
}

这种写法更接近实际开发习惯。


五、信号阻塞:让信号先等一等

信号可以被阻塞。所谓阻塞,就是信号已经来了,但是进程暂时不处理它。

阻塞信号需要用到信号集,常见函数有:

c 复制代码
sigemptyset();
sigaddset();
sigdelset();
sigismember();
sigprocmask();
sigpending();

这些函数名字看起来比较多,但思路很简单:

text 复制代码
先定义一个信号集
把要阻塞的信号加入信号集
调用 sigprocmask 修改进程的阻塞信号集
用 sigpending 查看哪些信号处于未决状态

下面这个程序会阻塞 SIGINT,也就是 Ctrl+C 对应的信号:

c 复制代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void print_pending()
{
    sigset_t pending;
    sigpending(&pending);

    for (int i = 1; i <= 31; i++)
    {
        if (sigismember(&pending, i))
            printf("1");
        else
            printf("0");
    }

    printf("\n");
}

int main()
{
    sigset_t set;
    sigemptyset(&set);
    sigaddset(&set, SIGINT);

    sigprocmask(SIG_BLOCK, &set, NULL);

    while (1)
    {
        print_pending();
        sleep(1);
    }

    return 0;
}

运行程序后,按下 Ctrl+C,你会发现程序没有退出。

但是 pending 信号集中对应的位置会变成 1,大概类似这样:

bash 复制代码
0000000000000000000000000000000
0000000000000000000000000000000
^C0100000000000000000000000000000
0100000000000000000000000000000

这说明 SIGINT 已经来了,但是因为它被阻塞了,所以暂时没有被处理。

再看一个解除阻塞的例子:

c 复制代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void handler(int signo)
{
    printf("处理信号:%d\n", signo);
}

int main()
{
    signal(SIGINT, handler);

    sigset_t set;
    sigemptyset(&set);
    sigaddset(&set, SIGINT);

    sigprocmask(SIG_BLOCK, &set, NULL);

    printf("SIGINT 已被阻塞,10 秒内按 Ctrl+C 不会立即处理\n");

    sleep(10);

    printf("解除 SIGINT 阻塞\n");

    sigprocmask(SIG_UNBLOCK, &set, NULL);

    while (1)
    {
        sleep(1);
    }

    return 0;
}

程序运行后,在前 10 秒内按下 Ctrl+C,不会马上执行处理函数。等到 10 秒之后解除阻塞,之前处于 pending 状态的 SIGINT 就会被递达。

这就能看出阻塞和忽略的区别:

text 复制代码
阻塞:信号来了,先放着,之后可能处理。
忽略:信号来了,直接丢掉,不再处理。

还有一个细节也很重要:

普通信号在未决状态下不会重复排队。

也就是说,如果 SIGINT 被阻塞了,你连续按很多次 Ctrl+C,内核一般只记录"SIGINT 来过",而不会记录"SIGINT 来了 10 次"。

普通信号更像是一个位图:

text 复制代码
0 表示没来
1 表示来了

至于来了几次,普通信号并不关心。


六、信号在实际编程中的几个典型场景

信号不是只存在于课本里的概念,在实际 Linux 编程中经常会遇到。

一个很常见的场景是定时器。

alarm 函数可以设置一个闹钟:

c 复制代码
#include <unistd.h>

unsigned int alarm(unsigned int seconds);

它的意思是:seconds 秒后,系统向当前进程发送 SIGALRM 信号。

c 复制代码
#include <stdio.h>
#include <unistd.h>

int main()
{
    alarm(3);

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

    return 0;
}

这个程序运行 3 秒后会被终止。原因是 alarm(3) 到期后,系统发送 SIGALRM,而 SIGALRM 的默认动作就是终止进程。

如果我们捕捉 SIGALRM,就可以实现一个简单的周期性任务:

c 复制代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void handler(int signo)
{
    printf("闹钟响了\n");
    alarm(1);
}

int main()
{
    signal(SIGALRM, handler);

    alarm(1);

    while (1)
    {
        pause();
    }

    return 0;
}

这里的 pause 函数表示让进程暂停,直到有信号到来。每次 SIGALRM 到来后,执行处理函数,然后在处理函数里重新设置下一次闹钟。

另一个很常见的场景是回收子进程。

当子进程退出时,操作系统会给父进程发送 SIGCHLD。如果父进程不回收子进程,子进程就可能变成僵尸进程。

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>

void handler(int signo)
{
    while (waitpid(-1, NULL, WNOHANG) > 0)
    {
        printf("回收一个子进程\n");
    }
}

int main()
{
    struct sigaction act;

    act.sa_handler = handler;
    sigemptyset(&act.sa_mask);
    act.sa_flags = 0;

    sigaction(SIGCHLD, &act, NULL);

    for (int i = 0; i < 3; i++)
    {
        pid_t pid = fork();

        if (pid == 0)
        {
            printf("child %d exit\n", getpid());
            exit(0);
        }
    }

    while (1)
    {
        sleep(1);
    }

    return 0;
}

这里处理函数中用了:

c 复制代码
while (waitpid(-1, NULL, WNOHANG) > 0)

为什么是 while

因为多个子进程可能几乎同时退出,而普通信号不会排队。父进程可能只收到一次 SIGCHLD,但实际上有多个子进程需要回收。所以需要循环调用 waitpid,把已经退出的子进程都回收干净。

还有一个常见场景是 SIGPIPE

比如管道中,读端已经关闭,写端还继续写,就可能触发 SIGPIPE

例如:

bash 复制代码
cat bigfile | head -n 1

head 读完一行后就退出了,管道读端关闭。如果 cat 继续向管道写数据,就可能收到 SIGPIPE

网络编程中也会遇到类似情况。如果对端连接已经关闭,本端还继续写数据,也可能因为相关问题导致程序异常退出。因此一些网络程序会选择忽略 SIGPIPE

c 复制代码
signal(SIGPIPE, SIG_IGN);

这样程序不会因为一次写操作直接被信号终止,而是可以通过返回值和错误码自己处理异常。


七、程序崩溃和 core dump 也和信号有关

平时调试程序时,经常会看到:

bash 复制代码
Segmentation fault

它背后对应的信号就是 SIGSEGV

有些信号的默认动作不只是终止进程,还会产生 core dump。core dump 可以理解成程序崩溃时留下的一份"现场快照"。

常见会产生 core dump 的信号有:

text 复制代码
SIGSEGV
SIGQUIT
SIGABRT

可以通过下面的命令开启 core dump:

bash 复制代码
ulimit -c unlimited

然后写一个故意崩溃的程序:

c 复制代码
#include <stdio.h>

int main()
{
    int *p = NULL;
    *p = 10;

    return 0;
}

运行后,如果生成了 core 文件,就可以使用 gdb 调试:

bash 复制代码
gdb ./a.out core

这样就能看到程序崩溃的位置。

所以信号不仅能控制进程退出,也能帮助我们定位程序异常。


八、最后把信号机制串起来

学信号时,不建议一开始死记函数。更好的方式是抓住这条主线:

text 复制代码
信号为什么产生
        |
        v
信号产生后保存在哪里
        |
        v
信号是否被阻塞
        |
        v
信号什么时候递达
        |
        v
递达后执行默认动作、忽略,还是自定义处理函数

再用一句话总结:

信号是 Linux 中的一种异步事件通知机制。

它不是普通函数调用,而是操作系统在某些事件发生后通知进程的一种方式。信号产生后可能暂时处于未决状态,如果没有被阻塞,就会在合适的时机递达给进程。进程可以选择默认处理、忽略信号,或者执行自定义处理函数。

刚开始学信号时,最重要的不是记住所有信号编号,而是理解这几个核心问题:

text 复制代码
信号从哪里来?
信号来了之后保存在哪里?
为什么信号来了不一定马上处理?
阻塞和忽略有什么区别?
自定义信号处理函数什么时候执行?

把这几个问题想明白之后,再去看 signalsigactionsigprocmasksigpendingwaitpid 这些函数,就不会觉得零散了。

Linux 信号机制看起来复杂,但本质上就是一句话:

操作系统通过信号告诉进程:有事情发生了,接下来怎么处理,由系统默认规则和进程自身设置共同决定。

相关推荐
keyipatience1 小时前
27,28,29进程通信和匿名管道详解
linux·ubuntu·centos
Shadow(⊙o⊙)1 小时前
QT常用控件3.0,font字体设置,toolTip提示,focusPolicy焦点定位原则,中型控件StyleSheet样式表。
服务器·开发语言·前端·c++·qt
勇宝趣学前端1 小时前
RustDesk 私有远程控制服务器部署
运维·服务器
Shadow(⊙o⊙)1 小时前
QT常用控件2.0,windowOpacity窗口透明度,Cursor光标设置
开发语言·c++·qt
Jtti1 小时前
怎么判断攻击者主要在打高防服务器哪个端口或协议
运维·服务器·网络
中讯慧通1 小时前
微型无人机通信模块:低空链路核心,保障飞行与传输全程稳定
服务器·人工智能·机器人·无人机
Lazionr1 小时前
类和对象(上):走进面向对象编程
c++
载数而行5201 小时前
Linux 10 防火墙
linux
神仙别闹1 小时前
基于C语言处理机调度算法的实现
服务器·c语言·算法