Linux 进程信号入门——从 Ctrl+C 到 kill
之前学习进程的时候,经常会在终端里按 Ctrl+C 结束程序。平时觉得这是一个很普通的操作,直到学习 Linux 信号,才发现这个按键并不是直接把程序关掉,而是通过操作系统给进程发送了一个信号。
这篇先从最常见的信号入手,把信号的基本概念、处理方式以及几种常见的产生方式整理一下。
一、什么是信号?
信号可以理解为操作系统向进程发送的一种异步通知,用来告诉进程某个事件已经发生了。它属于一种软件层面的中断机制。
这里有两个比较重要的特点。
首先,信号的产生和进程正在执行的代码没有必然关系。比如程序正在执行一个循环,用户随时都可能按下 Ctrl+C,进程就有可能收到 SIGINT 信号。程序不需要提前执行到某个特定位置,信号也可以产生。
其次,信号产生后,不代表进程马上就会处理它。进程可能还在执行其他代码,信号需要经过操作系统的处理,在合适的时机递达进程。
可以把它理解成收到了一条通知:事情已经发生了,至于什么时候处理,要看进程当前的执行状态以及信号是否被阻塞。
二、信号的三种处理方式
当进程收到信号时,并不是所有信号都只能让程序退出。通常有三种处理方式:
- 执行默认动作:由操作系统按照该信号规定的默认行为处理,例如终止进程。
- 忽略信号:进程收到这个信号后不执行对应的处理动作。
- 捕捉信号:进程注册自己的处理函数,在信号递达时执行这段代码。
在 Linux 中,可以使用 signal() 设置某个信号的处理方式:
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
std::cout << "caught signal: " << signo << std::endl;
}
int main()
{
signal(SIGINT, handler);
while (true)
{
std::cout << "process is running" << std::endl;
sleep(1);
}
return 0;
}
运行程序后按下 Ctrl+C,可以看到程序执行了自己注册的处理函数,而不是按照 SIGINT 的默认动作退出。
这里有个细节容易忽略:signal(SIGINT, handler) 只是设置处理方式,并不是主动产生一个信号。只有后续真的收到 SIGINT,处理函数才有机会执行。
如果将注册语句改成:
signal(SIGINT, SIG_IGN);
进程就会忽略 SIGINT。改成:
signal(SIGINT, SIG_DFL);
则恢复该信号的默认处理动作。
三、终端按键也能产生信号
平时经常用到的几个组合键,其实对应着不同的信号。
| 终端操作 | 对应信号 | 常见默认行为 |
|---|---|---|
Ctrl+C | SIGINT,通常是 2 号信号 | 终止进程 |
Ctrl+\ | SIGQUIT,通常是 3 号信号 | 终止进程,并请求产生 Core Dump |
Ctrl+Z | SIGTSTP | 暂停前台进程 |
注意,Ctrl+Z 通常是暂停进程,而不是直接结束进程。暂停后的任务可以通过 Shell 的作业控制命令继续处理。
另外,Ctrl+C 这类终端控制字符通常会把信号发送给当前前台进程组。后台运行的程序并不会因为用户按下了 Ctrl+C 就自动收到同样的信号。
例如:
./sig
启动后按 Ctrl+C,通常会对前台任务产生 SIGINT。
而使用:
./sig &
把程序放到后台运行后,终端控制和前台任务就有所不同了。
四、使用 kill 和相关函数发送信号
除了终端按键,也可以主动向进程发送信号。
最熟悉的方式就是 kill 命令:
kill -2 PID
这里的 PID 要替换成目标进程的进程号,-2 表示发送 2 号信号,也就是 SIGINT。
也可以直接使用信号名称:
kill -SIGINT PID
需要注意,kill 并不意味着只能杀死进程。它的作用是发送信号,目标进程最终会怎样处理,要看信号对应的处理动作。比如目标进程捕捉了 SIGINT,收到信号后也可以执行自己的处理函数。
程序内部同样可以发送信号。
1. kill():向指定进程发送信号
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
pid 指定目标进程,sig 指定发送的信号。函数成功返回 0,出错返回 -1。
2. raise():给当前进程发送信号
#include <signal.h>
int raise(int sig);
例如:
raise(SIGINT);
这会向当前进程发送 SIGINT。如果已经为它注册了处理函数,就可以观察到对应的处理过程。
3. abort():异常终止进程
#include <stdlib.h>
abort();
abort() 会使当前进程通过 SIGABRT 异常终止。即使程序为这个信号注册了处理函数,也不能简单地认为捕捉成功后就能让 abort() 正常返回。
五、由软件条件和硬件异常产生的信号
信号不一定是用户手动发送的,程序运行时也可能因为某些条件而产生信号。
1. alarm() 和 SIGALRM
alarm() 可以设置一个定时器,让操作系统在指定秒数后向当前进程发送 SIGALRM。
#include <unistd.h>
#include <signal.h>
int main()
{
alarm(3);
while (true)
{
// 等待信号
}
return 0;
}
上面的程序在设置定时器后持续运行,通常在三秒后收到 SIGALRM。如果没有修改这个信号的默认处理方式,进程会终止。
alarm() 设置的是一次性闹钟,超时后不会自动重复。将参数设置为 0,可以取消之前设置的闹钟。
如果希望进程等待信号,而不是一直空转,可以了解一下 pause()。它会让调用进程休眠,直到有相应的信号递达并满足唤醒条件。
2. 硬件异常产生的信号
程序中的某些错误,也可能通过信号表现出来。
例如:
- 除零等运算异常可能对应
SIGFPE。 - 访问非法内存地址可能对应
SIGSEGV。
也就是说,程序出现段错误,并不只是 C++ 层面看到了一条错误提示。底层通常是处理器或内存管理单元检测到异常,由内核处理后向进程发送相应信号。
这也是为什么使用空指针访问内存时,经常会看到 Segmentation fault。
六、Core Dump 是什么?
有些信号除了终止进程,还会请求生成 Core Dump。
Core Dump 可以理解为程序异常终止时保存下来的部分进程内存信息,后续可以配合调试器分析程序崩溃前的状态,排查段错误等问题。
不过,信号的默认动作包含 Core Dump,并不代表磁盘上一定会生成 core 文件。
是否真的生成文件,还会受到进程资源限制等因素影响。可以在 Shell 中使用:
ulimit -a
查看当前 Shell 的各项资源限制,其中包括 core file size。如果 Core Dump 大小限制为 0,就可能出现进程异常终止了,但当前目录没有生成 core 文件的情况。
开发调试时,可以根据实际需要调整限制,例如:
ulimit -c 1024
该设置只影响当前 Shell 及其后续创建的进程所继承的相关限制,不会自动修改已经运行的其他进程。
七、总结
这一篇最重要的是弄清楚:信号不是简单的“杀进程命令”,而是 Linux 中用于异步通知进程的一种机制。
用户可以通过终端按键、kill()、raise() 等方式产生信号,系统也可能因为定时器超时、管道异常或者硬件异常而产生信号。进程收到信号后,可以执行默认动作、忽略信号,或者执行自己注册的处理函数。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)