当我们想要强行终止掉前台进程的时候,只需要按下Ctrl+c即可,但是Ctrl+c是如何精准杀掉前台进程的?
一、信号概念
1.如何理解信号
假设点了一份外卖,外卖员到了楼下会给你发信息或者打电话,那么这通电话或者这个信息就是信号,也就是**用来提醒你需要去做某事的一种通知手段。**那么对照Linux系统,信号就是内核向进程发送的通知,提醒进程需要去完成某个任务。
2.信号的特点
对照外卖例子,当点完外卖,我们肯定不会一直在门口等着外卖员,而是先忙手中的事情,比如打游戏,那么这个过程就叫做异步 (信号由外卖员(内核)随时发出,但是对比同步 的阻塞等待,我们会先解决手头的事情),等外卖员真正的到了楼下打来电话,如果我们手头事情没忙完,我们也会先等手头事情完成才会去取外卖,这就是Linux中信号的延迟性/合适时机处理, 当然取外卖是默认动作,外卖也可能是买来送给女神的或者送给兄弟的,此时对于外卖就有了多种处理方式,这也是Linux中信号处理的可配置性。
综上,初步地可以了解到信号具有异步性 、延迟性 以及可配置性。
3.认识信号
聊完了信号的抽象模型(外卖),那么真正的信号长什么样呢?在bash中我们输入kill -l就可以调出全部信号
bash
kill -l

可以看到一共有62个信号(没有32、33),前31个信号叫做普通信号,后31个信号称为实时信号,这里只谈普通信号。
1 SIGHUP(挂起信号):终端断开触发,默认终止进程,nohup可让进程忽略实现后台常驻。
2 SIGINT(中断信号):Ctrl+C手动触发,默认终止进程,支持捕获实现优雅退出。
3 SIGQUIT(退出信号):Ctrl+\触发,默认终止进程并生成core核心转储文件,用于调试。
4 SIGILL(非法指令信号):进程执行CPU无法识别指令触发,默认终止+核心转储。
5 SIGTRAP(陷阱信号):调试断点专用,gdb断点触发,默认终止+核心转储。
6 SIGABRT(异常终止信号):调用abort()函数触发,默认终止+核心转储,程序主动报致命错误。
7 SIGBUS(总线错误信号):内存访问对齐/硬件总线故障触发,默认终止+核心转储。
8 SIGFPE(浮点异常信号):浮点运算错误(除0/溢出)触发,默认终止+核心转储。
9 SIGKILL(终止信号):kill -9专用,强制终止进程,不可捕获、不可忽略。
10 SIGUSR1(用户自定义信号1):无默认触发场景,用户自定义使用,默认终止进程。
11 SIGSEGV(段错误信号):非法内存访问(越界/空指针)触发,默认终止+核心转储。
12 SIGUSR2(用户自定义信号2):无默认触发场景,用户自定义使用,默认终止进程。
13 SIGPIPE(管道破裂信号):向无读端的管道/套接字写数据触发,默认终止进程。
14 SIGALRM(闹钟信号):alarm()设置的定时到期触发,默认终止进程。
15 SIGTERM(终止信号):kill默认发送信号,默认终止进程,支持捕获实现优雅退出。
16 SIGSTKFLT(栈错误信号):协处理器栈溢出触发,默认终止进程,x86架构基本不用。
17 SIGCHLD(子进程状态变化信号):子进程退出/暂停/恢复触发,默认忽略,用于父进程回收子进程资源。
18 SIGCONT(继续信号):bg/fg/kill -CONT触发,恢复暂停进程,不可忽略。
19 SIGSTOP(暂停信号):Ctrl+Z/kill -STOP触发,强制暂停进程,不可捕获、不可忽略。
20 SIGTSTP(终端暂停信号):Ctrl+Z手动触发,默认暂停进程,支持捕获。
21 SIGTTIN(终端读信号):后台进程尝试读终端输入触发,默认暂停进程。
22 SIGTTOU(终端写信号):后台进程尝试写终端输出触发,默认暂停进程。
23 SIGURG(紧急数据信号):套接字收到紧急数据触发,默认忽略。
24 SIGXCPU(CPU超时信号):进程超出CPU使用限制触发,默认终止+核心转储。
25 SIGXFSZ(文件大小超限信号):进程写入文件超出大小限制触发,默认终止+核心转储。
26 SIGVTALRM(虚拟闹钟信号):进程虚拟运行时间定时到期触发,默认终止进程。
27 SIGPROF(性能分析信号):进程CPU使用+系统调用时间定时到期触发,默认终止进程。
28 SIGWINCH(窗口大小变化信号):终端窗口大小调整触发,默认忽略,用于程序适配窗口尺寸。
29 SIGIO(IO就绪信号):文件/套接字IO就绪触发,默认终止进程,用于异步IO。
30 SIGPWR(电源故障信号):系统电源异常触发,默认终止进程,部分系统用于电源管理。
31 SIGSYS(非法系统调用信号):进程执行无效系统调用触发,默认终止+核心转储。
(内容参考AI大模型)
4.信号状态
对于一个进程而言,收到信号可以延迟处理或者条件满足的时候立即处理,那么此时信号就有了几种不同的状态:
未决 :信号已产生并由内核记录,因进程屏蔽该信号或正处理同类型信号,暂未被进程处理的状态,信号处于未决队列中。
递达:信号从内核传递到进程,且进程成功执行该信号对应处理动作(默认 / 自定义 / 忽略)的过程,是信号处理的最终完成状态。
阻塞:进程通过信号集主动设置 "屏蔽",让内核暂不将指定信号递达,被阻塞的信号会进入未决状态,解除阻塞后才会正常递达处理(阻塞≠忽略,信号不会丢失)。
二、信号相关接口调用
1.信号的捕获
1.1信号的处理方式
前面提到进程对于一个信号可以有多种处理方式,但是内核需要有默认处理方式保证信号的存在意义,其实对于信号处理可以大致分为三类:
默认:执行内核为该信号预设的处理动作,如终止、暂停、核心转储等,是信号的基础处理逻辑,保证无自定义处理时信号仍有实际意义。
忽略:进程收到信号后不执行任何操作,信号直接被丢弃,仅标记为处理完成,无任何附加行为。
其他:进程自定义信号处理函数,收到信号后执行开发者编写的业务逻辑,实现信号的个性化处理,如优雅退出、资源释放等。
1.2信号的捕获
已知信号有多种处理方式,但是我们如何改变信号处理方式使其按照我们的预期进行处理呢?
接口认识
这里需要引入一个新的接口:signal()

通过手册查找可以看到signal接口需要传入两个参数,第一是signum,对照前面的信号表(kill -l),这里的signum也就是信号对应的编号,可以直接传入数字也可以使用编号对应的宏定义,第二个参数则是一个返回值为void,带有一个int参数的函数指针,接口执行后会将信号的默认处理改为指定函数。若传入SIG_DFL(宏,代表默认处理)或SIG_IGN(宏,代表忽略信号),则会恢复信号的默认行为或设置为忽略。
代码实践
有了以上认识,我们就可以对进程接收到的信号进行捕获并且执行自定义函数,由于前面的概念,Ctrl+c发送的是SIGINT信号,我们对其进行尝试捕捉:
cpp
#include<signal.h>
#include<iostream>
void handler(int sig){
std::cout<<"我是进程"<<getpid()<<",我收到了信号"<<sig<<std::endl;
}
int main(){
signal(SIGINT,handler);
while(true){}
}
编译成可执行程序并且运行:

可以看到此时我们进行Ctrl+c组合键强行杀掉进程的时候进程并没有被杀掉,而是执行了我们的自定义函数,此时我们可以用Ctrl+\(SIGQUIT)进行进程关闭,但是此时也衍生了一个问题:假如所有的信号都被捕获,那么进程岂不是可以肆无忌惮的运行,一直消耗系统资源?其实操作系统对此早有预防,Linux内核规定kill -9(SIGKILL)信号无法被捕获,为系统稳定性留了一张底牌,同样可以进行代码验证:

通过手册查询,可以看到若signal()调用出错,则返回 SIG_ERR,并且留下一个错误码:
cpp
#include<signal.h>
#include<iostream>
void handler(int sig){
std::cout<<"我是进程"<<getpid()<<",我收到了信号"<<sig<<std::endl;
}
int main(){
for(int i=1;i<=31;i++){
if(signal(i,handler)==SIG_ERR){
perror("signal failed : ");
}
else std::cout<<i<<"signal success"<<std::endl;
}
}
bash
lsir@iZ2vc8wsdyzvsm68d9hqgbZ:~/learing/sig$ ./a.out
1signal success
2signal success
3signal success
4signal success
5signal success
6signal success
7signal success
8signal success
signal fiiled : : Invalid argument
10signal success
11signal success
12signal success
13signal success
14signal success
15signal success
16signal success
17signal success
18signal success
signal fiiled : : Invalid argument
20signal success
21signal success
22signal success
23signal success
24signal success
25signal success
26signal success
27signal success
28signal success
29signal success
30signal success
31signal success
可以看到,31个信号,绝大多数都成功修改处理,但是其中9(SIGKILL)和19(SIGSTOP)无法进行修改,显示Invalid argument(无效参数),说明传入的9和19参数不支持修改,其中信号19是进程暂停的信号,同信号9属于内核强制管控的信号,防止程序恶意利用。
补充
当我们使用键盘触发的信号(如 Ctrl+C、Ctrl+\、Ctrl+Z) 只能发送给前台进程,当我们在使用一个终端的时候,只能存在一个前台进程,而我们通过键盘触发的信号也只会发送给这个前台,结合信号知识就解释了开头的问题:为什么Ctrl+c能精准杀掉进程?因为当我们进行Ctrl+c操作的时候,**内核会向唯一的前台进程发送SIGINT信号,待进程收到信号进行默认处理的时候,进程就会自动退出。**而对于终端中的前后台进程,我们可以通过 fg、bg 命令灵活实现进程的前后台状态切换,配合 jobs 命令可查看当前终端的所有进程作业状态。
bash
# 1. 启动前台进程,按Ctrl+Z暂停(触发SIGTSTP,状态变为Stopped)
sleep 100
^Z # 按下Ctrl+Z,输出如下
[1]+ Stopped sleep 100
# 2. 用jobs查看作业(确认作业号和状态)
jobs -l
[1]+ 12345 Stopped sleep 100 # [1]是作业号,12345是PID
# 3. bg:将暂停的作业恢复为后台运行
bg %1 # 或简写bg 1、bg(无参数,默认处理+标记的作业)
[1]+ sleep 100 & # 输出表示作业已在后台运行
# 4. fg:将后台作业调回前台
fg %1 # 或简写fg 1、fg
sleep 100 # 此时进程回到前台,占用终端,按Ctrl+C可终止
2.信号发送
kill接口
kill在Bash和C语言层面都有对应封装,但是用法大同小异
Bash命令行解释器
在Bash我们可以直接进行kill命令向指定进程发送指定信号,格式:
kill [信号] <PID/作业号>,例如kill -9 12345就代表向pid为12345的进程发送9(SIGKILL)信号,使其强行终止。
C语言

同样通过手册查询可以看到kill接口需要进行两个参数传入:pid和sig,与Bash中的kill用法大同小异kill -9 12345->kill(12345,9)。虽然和Bash中的kill是不同层面的封装,但是其系统调用接口一致,因此可以尝试用C语言封装的kill接口自制Bash中的kill:
cpp
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
// mykill -signumber pid
int main(int argc, char *argv[])
{
if(argc != 3)
{
std::cerr << "Usage: " << argv[0] << " -signumber pid" << std::endl;
return 1;
}
int number = std::stoi(argv[1]+1);
pid_t pid = std::stoi(argv[2]);
int n = kill(pid, number);
return n;
}
raise接口

raise是C语言封装的一个向caller(当前进程)发送信号的一个接口,参数传入需要发送的信号编号即可,代码示例:
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
std::cout << "获取了一个信号: " << signumber << std::endl;
}
// mykill -signumber pid
int main()
{
signal(2, handler); // 先对2号信号进⾏捕捉
// 每隔1S,⾃⼰给⾃⼰发送2号信号
while(true)
{
sleep(1);
raise(2);
}
}
bash
$ g++ raise.cc -o raise
$ ./raise
获取了⼀个信号: 2
获取了⼀个信号: 2
获取了⼀个信号: 2
abort接口

abort() 是 C 标准库函数(头文件 <stdlib.h>),核心作用是让进程立即异常终止,底层是向当前进程发送信号6(SIGABRT),并且该终止行为无法被捕获(即便执行前使用signal进行捕捉设置,会先执行你注册的 SIGABRT 自定义处理函数,执行完后再强制终止进程)。
alarm接口

调⽤ alarm 函数可以设定⼀个闹钟,也就是告诉内核在 seconds 秒之后给当前进程发
SIGALRM 信号,该信号的默认处理动作是终⽌当前进程。
这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。打个⽐⽅,某⼈要⼩睡⼀觉,设定闹
钟为30分钟之后响,20分钟后被⼈吵醒了,还想多睡⼀会⼉,于是重新设定闹钟为15分钟之后响,"以
前设定的闹钟时间还余下的时间"就是10分钟。如果seconds值为0,表⽰取消以前设定的闹钟,函数
的返回值仍然是以前设定的闹钟时间还余下的秒数。
利用该接口可以简单感受一下IO操作与单纯CPU计算之间的速度差距:
cpp
//IO多
#include <iostream>
#include <unistd.h>
#include <signal.h>
int main()
{
int count = 0;
alarm(1);
while(true)
{
std::cout << "count : "
<< count << std::endl;
count++;
}
return 0;
}
//纯计算,IO少
#include <iostream>
#include <unistd.h>
#include <signal.h>
int count = 0;
void handler(int signumber)
{
std::cout << "count : " <<
count << std::endl;
exit(0);
}
int main()
{
signal(SIGALRM, handler);
alarm(1);
while (true)
{
count++;
}
return 0;
}
bash
//多IO
... ...
count : 107148
count : 107149
Alarm clock
//纯计算,少IO
$ g++ alarm.cc -o alarm
$ ./alarm
count : 492333713
CPU 运算属于计算机内部核心硬件的指令执行与数据处理 ,全程在主机箱内的处理器 / 内存总线上完成;而 IO(输入 / 输出)是计算机核心计算部件与外部存储设备的数据交换,属于主机与外设的通信行为。通过这个简单的实验可以清楚感知到IO操作相对于内部硬件在操作效率上清晰的差距。