Linux:信号详解--醍醐灌顶
Linux:信号详解
今天要深入聊聊“信号”这个概念。在开始之前,我先问大家一个问题:你有没有在终端里跑程序时,按Ctrl+C
终止过进程?有没有用kill
命令关掉过不听话的程序?这些操作背后,其实都是“信号”在起作用。还有,你之前学过“信号量”,这俩名字里都有“信号”,它们是一回事吗?
今天咱们要先明确一点:信号和信号量没有半毛钱关系,就像“老婆”和“老婆饼”——名字像,但本质完全不同。信号量是用来做资源计数的同步机制,比如控制多个进程访问共享资源的次数;而信号,是操作系统给进程发的“异步通知”,告诉进程“出事儿了,该处理一下”。这是咱们今天的第一个重点
第一章 信号的基本认知
我们从生活中的一个例子出发:
假设你在宿舍打游戏,点了个外卖。外卖小哥啥时候到,你不知道——这就像信号的“异步性”,它可能随时产生。等小哥到了,会给你打电话或者敲门,这就是“信号产生”;你听到电话后,可能正在推对方高地,没法立马下楼,就会在脑子里记着“一会要取外卖”,这就是“信号保存”;等游戏打完,你下楼取外卖,这就是“信号处理”。
整个过程,就是一次信号的生命周期:产生→保存→处理。而且你发现没?信号产生后,不一定会立即处理,得等你把“更重要的事”(推高地)做完——这就是信号的核心特性:异步通知、延迟处理。
再想想生活里其他“信号”场景:门铃响了(有人敲门)、红绿灯变红灯(该停车)、古代打仗的狼烟(敌人来了)、上课铃响(该进教室)……这些都是“异步通知”,和Linux里的信号本质上是一个逻辑。
那问题来了:生活里的“信号”需要你“认识”它(比如知道红灯要停),Linux里的进程,怎么“认识”信号呢?
答案很简单:进程天生就认识信号。因为操作系统在设计进程的时候,就把信号的“识别方法”和“默认处理方式”内置到进程里了。比如,进程一出生就知道:收到2号信号(SIGINT,对应Ctrl+C
)要终止自己,收到9号信号(SIGKILL)必须终止自己,收到19号信号(SIGSTOP)要暂停自己。这些都是“与生俱来”的能力,就像你天生知道“妈妈叫你吃饭”是要去餐桌一样。
第二章 信号的生命周期:产生、保存、处理
信号的生命周期分三个阶段,咱们逐个拆解,每个阶段都结合代码和实验,让大家“看得见、摸得着”。
2.1 第一阶段:信号的产生——信号从哪来?
信号不会凭空出现,它的产生一定有“触发条件”。Linux里信号产生的方式有5种,咱们一种一种说,每种都配个小实验,大家可以跟着做。
2.1.1 方式1:键盘组合键——最直观的信号产生
咱们最常用的就是键盘组合键,比如Ctrl+C
、Ctrl+\
、Ctrl+Z
,这些组合键会被操作系统解释成特定信号,发给“前台进程”(当前终端里正在运行的进程)。
先问大家一个问题:Ctrl+C
为什么能终止前台进程?咱们通过代码验证一下。
首先,写一个简单的死循环程序my_signal.cc
:
#include <iostream>
#include <unistd.h>
using namespace std;int main() {while (true) {cout << "I'm running... PID: " << getpid() << endl;sleep(1); // 每秒打印一次,避免刷屏}return 0;
}
再写个Makefile
编译:
my_signal: my_signal.ccg++ -o my_signal my_signal.cc -std=c++11
.PHONY: clean
clean:rm -f my_signal
编译运行:make && ./my_signal
,你会看到进程不断打印PID。此时按Ctrl+C
,进程就退出了——这背后是操作系统给进程发了2号信号SIGINT
(Interrupt,中断),进程默认处理动作是“终止自己”。
那Ctrl+\
呢?咱们再试一次:运行程序后按Ctrl+\
,进程也会退出,但退出信息里会多一句Quit (core dumped)
——这是3号信号SIGQUIT
(Quit,退出),默认动作也是终止,还会触发“核心转储”(后面会讲)。
还有Ctrl+Z
,运行程序后按Ctrl+Z
,进程会暂停,提示[1]+ Stopped ./my_signal
——这是19号信号SIGSTOP
(Stop,暂停),默认动作是暂停进程,后续可以用fg
命令恢复。
这里要注意:只有前台进程能收到键盘组合键发的信号。如果把进程放到后台(运行时加&
,比如./my_signal &
),再按Ctrl+C
,进程不会退出——因为后台进程不接收键盘输入,信号发不到它身上。
2.1.2 方式2:kill命令——手动给进程发信号
除了键盘,我们还能通过kill
命令手动给指定进程发信号。kill
命令的格式是:kill -信号编号 进程PID
。
比如,我们先运行./my_signal
,用ps ax | grep my_signal
找到它的PID(假设是12345),然后在另一个终端执行kill -2 12345
——这和按Ctrl+C
效果一样,进程会终止,因为-2
就是发2号信号SIGINT
。
再试9号信号SIGKILL
(Kill,强制终止):运行./my_signal
,执行kill -9 12345
,进程会立即终止,而且无法抗拒——这是Linux里唯一能“强制杀死”进程的信号,后面会讲它为什么不能被捕捉。
咱们可以用kill -l
命令查看系统里所有信号(l
是list的缩写),Linux系统里一共有64个信号,131是“普通信号”,3464是“实时信号”(咱们重点学普通信号)。
2.1.3 方式3:系统调用——在代码里发信号
除了命令,我们还能通过系统调用在代码里发信号,常用的有3个:kill()
、raise()
、abort()
。
(1)kill():给指定进程发信号
kill()
的原型是:int kill(pid_t pid, int sig);
pid
:目标进程的PID(正数);如果是0,发给当前进程组的所有进程;如果是-1,发给所有有权限发的进程。sig
:要发送的信号编号。- 返回值:成功返回0,失败返回-1。
咱们写个程序my_kill.cc
,实现类似kill
命令的功能:
#include <iostream>
#include <cstdlib>
#include <signal.h>
#include <unistd.h>
using namespace std;// 用法提示
void usage(const char* argv0) {cout << "Usage: " << argv0 << " <signal_num> <pid>" << endl;cout << "Example: " << argv0 << " 2 12345 (send SIGINT to PID 12345)" << endl;
}int main(int argc, char* argv[]) {if (argc != 3) { // 需要3个参数:程序名、信号编号、PIDusage(argv[0]);exit(1);}int sig = atoi(argv[1]); // 字符串转整数(信号编号)pid_t pid = atoi(argv[2]); // 字符串转整数(PID)int ret = kill(pid, sig);if (ret == -1) {perror("kill"); // 打印错误信息exit(1);}cout << "Send signal " << sig << " to PID " << pid << " success!" << endl;return 0;
}
编译运行:g++ -o my_kill my_kill.cc -std=c++11
,然后找一个运行中的my_signal
进程(PID 12345),执行./my_kill 2 12345
,就能看到my_signal
进程终止——和kill -2 12345
效果一样。
(2)raise():给自己发信号
raise()
的原型是:int raise(int sig);
功能很简单:给当前进程(调用raise()
的进程)发信号sig
,相当于kill(getpid(), sig)
。
写个测试程序my_raise.cc
:
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;// 自定义信号处理函数
void handle(int sig) {cout << "Received signal " << sig << "!" << endl;
}int main() {// 给2号信号设置自定义处理(默认是终止,现在改成打印)signal(SIGINT, handle);int count = 5;while (count--) {cout << "Countdown: " << count << endl;sleep(1);}// 5秒后,给自己发2号信号raise(SIGINT);// 信号处理后,进程继续运行cout << "Process continues..." << endl;sleep(3);cout << "Process exit!" << endl;return 0;
}
编译运行:g++ -o my_raise my_raise.cc -std=c++11 && ./my_raise
,你会看到:
Countdown: 4
Countdown: 3
Countdown: 2
Countdown: 1
Countdown: 0
Received signal 2!
Process continues...
Process exit!
5秒后,raise(SIGINT)
给自己发2号信号,进程没有终止,而是执行了handle
函数打印信息——这说明raise()
确实能给自己发信号,而且信号处理后进程可以继续运行。
(3)abort():给自己发6号信号(SIGABRT)
abort()
的原型是:void abort(void);
功能:给当前进程发6号信号SIGABRT
(Abort,异常终止),进程默认会终止并触发核心转储。它相当于kill(getpid(), SIGABRT)
,但abort()
会确保进程终止,即使SIGABRT
被捕捉,处理完后还是会终止。
写个程序my_abort.cc
:
#include <iostream>
#include <cstdlib>
#include <signal.h>
#include <unistd.h>
using namespace std;void handle(int sig) {cout << "Received signal " << sig << " (SIGABRT)!" << endl;// 即使在这里不退出,abort()也会让进程终止
}int main() {signal(SIGABRT, handle); // 捕捉6号信号cout << "Before abort()" << endl;abort(); // 发6号信号cout << "After abort() (不会执行)" << endl;return 0;
}
编译运行:g++ -o my_abort my_abort.cc -std=c++11 && ./my_abort
,输出:
Before abort()
Received signal 6 (SIGABRT)!
Aborted (core dumped)
可以看到,abort()
触发了6号信号,执行了handle
函数,但之后进程还是终止了——这是abort()
的特性,不管信号是否被捕捉,最终都会终止进程。
2.1.4 方式4:异常——硬件错误触发信号
咱们写代码时经常遇到“异常”,比如除零错误、野指针访问,这些异常本质上是“硬件错误”,操作系统检测到这些错误后,会给进程发信号,让进程处理(默认是终止)。
(1)除零错误→8号信号(SIGFPE)
FPE是“Floating Point Exception”的缩写,虽然叫“浮点异常”,但整数除零也会触发它。
写个除零错误的程序my_div0.cc
:
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;void handle(int sig) {cout << "Received signal " << sig << " (SIGFPE) - 除零错误!" << endl;// 这里不退出,看看进程会怎样
}int main() {signal(SIGFPE, handle); // 捕捉8号信号cout << "Before division by zero" << endl;sleep(2); // 给时间观察int a = 10;int b = 0;int c = a / b; // 除零错误cout << "After division by zero (不会执行)" << endl;return 0;
}
编译运行:g++ -o my_div0 my_div0.cc -std=c++11 && ./my_div0
,输出:
Before division by zero
Received signal 8 (SIGFPE) - 除零错误!
Received signal 8 (SIGFPE) - 除零错误!
Received signal 8 (SIGFPE) - 除零错误!
...(无限循环打印)
为什么会无限循环?因为除零错误触发硬件层面的“溢出”——CPU的“状态寄存器”里有个“溢出标志位”,除零时这个位会从0变1,操作系统检测到后发8号信号。但如果我们捕捉了信号却不修复硬件错误(比如改b的值),进程再次被调度时,还是会执行a/b
这行代码,再次触发溢出,操作系统再次发信号——就陷入了死循环。
这说明:信号捕捉不能修复异常,只能做“收尾工作”(比如打印日志、释放资源),之后还是要让进程退出,否则会一直触发异常。
(2)野指针访问→11号信号(SIGSEGV)
SIGSEGV是“Segmentation Violation”的缩写,意为“段错误”,通常是访问了非法内存(比如野指针、数组越界)。
写个野指针程序my_segv.cc
:
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;void handle(int sig) {cout << "Received signal " << sig << " (SIGSEGV) - 段错误!" << endl;exit(1); // 这次让进程退出,避免死循环
}int main() {signal(SIGSEGV, handle); // 捕捉11号信号cout << "Before wild pointer access" << endl;sleep(2);int* p = (int*)0x0; // 野指针:指向0号地址(非法内存)*p = 100; // 写非法内存,触发段错误cout << "After wild pointer access (不会执行)" << endl;return 0;
}
编译运行:g++ -o my_segv my_segv.cc -std=c++11 && ./my_segv
,输出:
Before wild pointer access
Received signal 11 (SIGSEGV) - 段错误!
为什么会触发11号信号?因为CPU访问内存时,会通过“MMU(内存管理单元)”把虚拟地址转换成物理地址。野指针指向的0号地址是“非法地址”,MMU转换失败,会给CPU发“地址转换异常”,操作系统检测到后,就给进程发11号信号SIGSEGV
。
2.1.5 方式5:软件条件——特定软件逻辑触发信号
除了硬件异常,某些软件条件满足时,操作系统也会发信号。最典型的例子是“管道断裂”(SIGPIPE
)和“闹钟超时”(SIGALRM
)。
(1)管道断裂→13号信号(SIGPIPE)
管道(pipe)是进程间通信的方式,当“写端”不断写数据,而“读端”已经关闭时,操作系统会给写端进程发13号信号SIGPIPE
(Pipe,管道断裂),默认动作是终止写端进程——因为写的数据没人读,继续写是“无效工作”,操作系统要回收资源。
咱们用代码模拟这个场景:
- 父进程创建管道,fork子进程;
- 子进程关闭读端(fd[0]),然后不断向写端(fd[1])写数据;
- 父进程关闭读端后立即退出,让子进程的写端成为“无读端的写端”。
代码my_pipe.cc
:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
using namespace std;void handle(int sig) {cout << "Child process received signal " << sig << " (SIGPIPE) - 管道断裂!" << endl;exit(1);
}int main() {int fd[2];if (pipe(fd) == -1) { // 创建管道perror("pipe");exit(1);}pid_t pid = fork();if (pid == -1) {perror("fork");exit(1);}if (pid == 0) { // 子进程:写端close(fd[0]); // 关闭读端signal(SIGPIPE, handle); // 捕捉13号信号char buf[1024] = "Hello, pipe!";while (true) {// 不断写数据到管道ssize_t ret = write(fd[1], buf, sizeof(buf));if (ret == -1) {perror("write");break;}cout << "Child write " << ret << " bytes" << endl;sleep(1);}close(fd[1]);exit(0);} else { // 父进程close(fd[0]); // 关闭读端close(fd[1]); // 关闭写端wait(nullptr); // 等待子进程退出cout << "Parent exit!" << endl;return 0;}
}
编译运行:g++ -o my_pipe my_pipe.cc -std=c++11 && ./my_pipe
,输出:
Child process received signal 13 (SIGPIPE) - 管道断裂!
Parent exit!
子进程刚写一次数据,就收到13号信号——因为父进程已经关闭了所有管道的读端和写端,子进程的写端成了“孤儿写端”,操作系统发SIGPIPE
终止它。
(2)闹钟超时→14号信号(SIGALRM)
咱们生活里的闹钟会在指定时间响,Linux里也有“闹钟”机制:用alarm()
系统调用设置一个“超时时间”,时间到了,操作系统会给进程发14号信号SIGALRM
(Alarm,闹钟),默认动作是终止进程。
alarm()
的原型是:unsigned int alarm(unsigned int seconds);
seconds
:超时时间(秒),如果是0,取消之前的闹钟。- 返回值:如果之前有闹钟,返回剩余时间;如果没有,返回0。
写个测试程序my_alarm.cc
:
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;void handle(int sig) {cout << "Received signal " << sig << " (SIGALRM) - 闹钟响了!" << endl;// 闹钟响后,再设一个5秒的闹钟,实现周期性闹钟alarm(5);
}int main() {signal(SIGALRM, handle); // 捕捉14号信号alarm(3); // 设置3秒后响闹钟int count = 0;while (true) {cout << "Count: " << count++ << endl;sleep(1);}return 0;
}
编译运行:g++ -o my_alarm my_alarm.cc -std=c++11 && ./my_alarm
,输出:
Count: 0
Count: 1
Count: 2
Received signal 14 (SIGALRM) - 闹钟响了!
Count: 3
Count: 4
Count: 5
Count: 6
Count: 7
Received signal 14 (SIGALRM) - 闹钟响了!
...(每5秒响一次)
3秒后,闹钟响,执行handle
函数;函数里又设了5秒的闹钟,所以之后每5秒响一次——这就是“周期性闹钟”的实现方式。
2.1.6 小结:所有信号最终都由操作系统发送
不管是键盘、kill
命令、系统调用、异常还是软件条件,最终都是操作系统给进程发信号。因为进程的“状态”(比如是否收到信号、如何处理信号)都存在PCB(进程控制块)里,而PCB只有操作系统能修改——就像你家的门只有你有钥匙,别人想进门得你开门,进程的“信号状态”也只有操作系统能修改。
2.2 第二阶段:信号的保存——信号来了先存哪?
信号产生后,进程不一定会立即处理(比如正在做更重要的事),这时候信号需要“暂时保存”起来,等进程“有空”了再处理。那信号存在哪?怎么保存?
这里要先明确一个前提:**普通信号(131)用“位图”保存**,实时信号(3464)用“队列”保存(不会丢失)。咱们重点讲普通信号的保存,核心是三张表(都存在进程的PCB里):
2.2.1 第一张表:pending表——记录“收到了哪些信号”
pending
是“未决”的意思,pending表是一个位图(bitmap),共32位(对应1~31号信号,0号不用),每一位代表一个信号:
- 位为1:进程收到了这个信号,还没处理;
- 位为0:进程没收到这个信号,或者信号已经处理完了。
比如,pending表的第2位(对应2号信号)为1,说明进程收到了SIGINT
,还没处理;第8位为0,说明没收到SIGFPE
。
位图的优点是“高效”:判断是否收到信号、标记收到信号,都只要一次位操作(比如pending & (1 << (sig-1))
判断是否收到信号sig
)。
2.2.2 第二张表:block表——记录“屏蔽了哪些信号”
block
是“屏蔽”的意思,也叫“信号屏蔽字”,同样是位图,32位对应1~31号信号:
- 位为1:这个信号被屏蔽了,即使收到(pending表为1),也暂时不处理;
- 位为0:这个信号没被屏蔽,收到后可以处理。
注意:“屏蔽信号”不是“不接收信号”,而是“接收了但暂时不处理”。比如block表第2位为1,进程收到2号信号后,pending表第2位会设为1,但不会处理,直到block表第2位设为0(解除屏蔽)。
2.2.3 第三张表:handler表——记录“怎么处理每个信号”
handler
是“处理函数”的意思,handler表是一个函数指针数组,数组下标对应信号编号(1~31),每个元素是一个函数指针,指向这个信号的“处理函数”。
信号的处理方式有三种,对应handler表的三种取值:
- 默认处理(SIG_DFL):数组元素为
SIG_DFL
(本质是0),表示按操作系统默认动作处理(比如终止、暂停); - 忽略信号(SIG_IGN):数组元素为
SIG_IGN
(本质是1),表示收到信号后不做任何处理; - 自定义捕捉(用户函数):数组元素为用户自己写的处理函数地址,表示收到信号后执行这个函数。
比如,handler表第2位是SIG_DFL
,说明2号信号按默认处理(终止);第2位是handle
函数地址,说明收到2号信号后执行handle
函数。
2.2.4 信号集:操作pending/block表的“工具”
pending表和block表都是位图,但我们不能直接操作位图(比如直接改某一位),操作系统提供了“信号集”(sigset_t
)和相关系统调用,让我们安全地操作这两张表。
sigset_t
是一个“封装后的位图”,本质还是32位整数,操作系统提供了5个常用函数来操作信号集:
函数 | 功能 |
---|---|
sigemptyset | 清空信号集(所有位设为0) |
sigfillset | 填满信号集(所有位设为1) |
sigaddset | 向信号集添加一个信号(某一位设为1) |
sigdelset | 从信号集删除一个信号(某一位设为0) |
sigismember | 判断信号是否在信号集里(某一位是否为1) |
还有一个核心函数sigprocmask
,用来修改进程的block表(信号屏蔽字):
int sigprocmask(int how, const sigset_t* set, sigset_t* oldset);
how
:修改方式,有三种:SIG_BLOCK
:把set
里的信号“添加”到block表(block |= set
);SIG_UNBLOCK
:把set
里的信号“从block表中删除”(block &= ~set
);SIG_SETMASK
:用set
直接“覆盖”block表(block = set
);
set
:要操作的信号集(如果how
是SIG_BLOCK
,就是要添加的信号);oldset
:保存修改前的block表(可以为NULL,不保存);- 返回值:成功返回0,失败返回-1。
另外,sigpending
函数可以获取当前进程的pending表:
int sigpending(sigset_t* set);
set
:输出参数,用来保存当前的pending表;- 返回值:成功返回0,失败返回-1。
2.2.5 实验:屏蔽2号信号,观察pending表变化
咱们通过代码实验,理解block表和pending表的作用。目标:
- 屏蔽2号信号(
SIGINT
); - 不断获取pending表,打印状态;
- 按
Ctrl+C
发2号信号,观察pending表变化; - 解除2号信号的屏蔽,观察信号处理。
代码my_sigpending.cc
:
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;// 打印信号集(pending表或block表)
void print_sigset(const sigset_t& set) {for (int sig = 1; sig <= 31; sig++) {// 判断信号sig是否在信号集里if (sigismember(&set, sig)) {cout << "1";} else {cout << "0";}}cout << endl;
}// 自定义处理函数(处理2号信号)
void handle(int sig) {cout << "Received signal " << sig << " (SIGINT), handling..." << endl;
}int main() {// 1. 给2号信号设置自定义处理(避免默认终止)signal(SIGINT, handle);sigset_t block_set, old_block_set, pending_set;// 2. 清空信号集sigemptyset(&block_set);sigemptyset(&old_block_set);sigemptyset(&pending_set);// 3. 向block_set添加2号信号(要屏蔽的信号)sigaddset(&block_set, SIGINT);// 4. 修改block表:用block_set覆盖(SIG_SETMASK),保存旧的block表到old_block_setint ret = sigprocmask(SIG_SETMASK, &block_set, &old_block_set);if (ret == -1) {perror("sigprocmask");exit(1);}cout << "Block SIGINT (2号信号) success!" << endl;cout << "Pending set (every 1s):" << endl;// 5. 不断获取并打印pending表int count = 10;while (count--) {// 获取当前pending表sigpending(&pending_set);print_sigset(pending_set);sleep(1);}// 6. 解除2号信号的屏蔽:用旧的block表覆盖(恢复原样)ret = sigprocmask(SIG_SETMASK, &old_block_set, NULL);if (ret == -1) {perror("sigprocmask");exit(1);}cout << "Unblock SIGINT success!" << endl;// 7. 继续运行,观察信号处理while (true) {cout << "Running..." << endl;sleep(1);}return 0;
}
编译运行:g++ -o my_sigpending my_sigpending.cc -std=c++11 && ./my_sigpending
,输出如下:
- 程序开始,屏蔽2号信号,打印pending表(全0):
Block SIGINT (2号信号) success!
Pending set (every 1s):
0000000000000000000000000000000
0000000000000000000000000000000
0000000000000000000000000000000
- 在程序运行到第3秒时,按
Ctrl+C
发2号信号,pending表的第2位变成1:
0000000000000000000000000000001 // 按Ctrl+C前,全0
0000000000000000000000000000010 // 按Ctrl+C后,第2位为1
0000000000000000000000000000010 // 一直保持1,因为被屏蔽
- 10秒后,解除2号信号的屏蔽,pending表的第2位变回0,同时执行
handle
函数:
Unblock SIGINT success!
Received signal 2 (SIGINT), handling...
Running...
Running...
这个实验完美验证了block表和pending表的作用:
- 屏蔽期间,收到信号→pending表设为1,不处理;
- 解除屏蔽后,检测到pending表为1→处理信号→pending表设为0。
2.2.7 注意:普通信号会“丢失”
因为pending表是位图,每一位只能表示“有/无”,不能表示“有多少个”。如果进程在屏蔽某个信号期间,多次收到这个信号,最终pending表只会设为1一次——也就是说,普通信号会丢失。
比如,屏蔽2号信号后,连续按3次Ctrl+C
,pending表第2位还是1,解除屏蔽后只处理一次。这就像你妈妈叫你吃饭,叫了3次,你只需要去一次餐桌——普通信号的核心是“通知”,不是“计数”。
2.3 第三阶段:信号的处理——信号该怎么处理?
信号保存后,进程什么时候处理?怎么处理?这是信号机制的核心,涉及“用户态”和“内核态”的概念,咱们一步步拆解。
2.3.1 先搞懂:用户态和内核态——进程的两种“身份”
你写的代码(比如main
函数、自定义函数)运行在“用户态”,操作系统的代码(比如系统调用、调度)运行在“内核态”。这两种状态的区别,本质是“权限”:
- 用户态:权限低,只能访问用户自己的代码和数据(03G地址空间),不能访问操作系统的代码和数据(34G地址空间);
- 内核态:权限高,可以访问操作系统的代码和数据,也能访问用户的代码和数据。
怎么区分当前是用户态还是内核态?靠CPU的“CS寄存器”(代码段寄存器):
- CS寄存器的最低2位为3→用户态;
- 最低2位为0→内核态。
进程什么时候会从用户态切换到内核态?有三种情况:
- 执行系统调用(比如
read
、write
、kill
); - 触发异常(比如除零、野指针);
- 触发硬件中断(比如键盘按下、时钟中断)。
切换到内核态后,操作系统会做对应的工作(比如系统调用的逻辑、处理异常),然后再从内核态切换回用户态——信号的处理,就发生在“从内核态返回到用户态”这个时机。
2.3.2 信号处理时机:为什么是“从内核态返回到用户态”?
操作系统设计信号处理时机时,遵循一个原则:不打断进程的“重要工作”。进程在用户态运行自己的代码时,可能正在做关键逻辑(比如写数据到文件),打断它可能导致数据错误;而进程从内核态返回时,说明操作系统的“重要工作”(比如系统调用、异常处理)已经做完了,此时处理信号不会影响关键逻辑。
具体流程是:
- 进程在用户态运行,触发系统调用/异常/中断,切换到内核态;
- 操作系统处理完内核态的工作(比如执行系统调用逻辑);
- 返回用户态前,操作系统检测进程的pending表和block表:
- 如果有“未屏蔽且未处理”的信号(pending表为1,block表为0),就处理这个信号;
- 处理完信号后,再返回用户态,继续运行进程的代码;
- 如果没有这样的信号,直接返回用户态。
2.3.3 信号处理的三种方式
根据handler表的取值,信号有三种处理方式:
(1)默认处理(SIG_DFL)
大多数信号的默认处理动作是“终止进程”(比如2号SIGINT
、3号SIGQUIT
、8号SIGFPE
),少数是“暂停”(19号SIGSTOP
)、“继续”(18号SIGCONT
)或“忽略”(比如17号SIGCHLD
,子进程退出时给父进程发这个信号,默认忽略)。
可以用man 7 signal
命令查看每个信号的默认动作,比如:
man 7 signal
里面会写:
SIGINT
(2) : Term (终止)SIGQUIT
(3) : Core (终止并核心转储)SIGFPE
(8) : Core (终止并核心转储)SIGSTOP
(19): Stop (暂停)SIGCONT
(18): Cont (继续)
(2)忽略信号(SIG_IGN)
用signal(sig, SIG_IGN)
可以设置信号sig
的处理方式为“忽略”,收到信号后不做任何处理,直接把pending表对应位设为0。
比如,忽略2号信号:
signal(SIGINT, SIG_IGN); // 按Ctrl+C不会终止进程
编译运行后,按Ctrl+C
,进程不会退出——因为信号被忽略了。
(3)自定义捕捉(用户函数)
用signal(sig, handle)
可以设置信号sig
的处理方式为“自定义函数handle
”,收到信号后会执行handle
函数,执行完后把pending表对应位设为0。
signal
函数的原型是:
typedef void (*sighandler_t)(int);
sighandler_t signal(int sig, sighandler_t handler);
sig
:要设置的信号编号;handler
:处理函数指针(SIG_DFL
、SIG_IGN
或用户函数);- 返回值:成功返回之前的处理函数指针,失败返回
SIG_ERR
。
咱们再写个例子my_sigcatch.cc
,捕捉2号和3号信号:
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;// 处理2号信号
void handle_sigint(int sig) {cout << "Caught signal " << sig << " (SIGINT) - Ctrl+C pressed!" << endl;
}// 处理3号信号
void handle_sigquit(int sig) {cout << "Caught signal " << sig << " (SIGQUIT) - Ctrl+\\ pressed!" << endl;// 处理完后,恢复3号信号的默认处理(下次按Ctrl+\会终止)signal(SIGQUIT, SIG_DFL);
}int main() {// 设置2号信号的处理函数if (signal(SIGINT, handle_sigint) == SIG_ERR) {perror("signal SIGINT");exit(1);}// 设置3号信号的处理函数if (signal(SIGQUIT, handle_sigquit) == SIG_ERR) {perror("signal SIGQUIT");exit(1);}cout << "Process running... PID: " << getpid() << endl;while (true) {sleep(1);}return 0;
}
编译运行:g++ -o my_sigcatch my_sigcatch.cc -std=c++11 && ./my_sigcatch
,输出:
Process running... PID: 12345
^CCaught signal 2 (SIGINT) - Ctrl+C pressed!
^CCaught signal 2 (SIGINT) - Ctrl+C pressed!
^\Caught signal 3 (SIGQUIT) - Ctrl+\ pressed!
^\Quit (core dumped)
- 按
Ctrl+C
,执行handle_sigint
,进程不退出; - 第一次按
Ctrl+\
,执行handle_sigquit
,并恢复3号信号的默认处理; - 第二次按
Ctrl+\
,3号信号按默认处理,进程终止并核心转储。
2.3.4 特殊信号:不能被捕捉、不能被屏蔽
不是所有信号都能被自定义捕捉或屏蔽,Linux里有两个“特权信号”:
- 9号信号(SIGKILL):不能被捕捉、不能被屏蔽,默认动作是“强制终止进程”——这是操作系统留给用户“杀死顽固进程”的最后手段,比如进程陷入死循环,用
kill -9
一定能杀掉。 - 19号信号(SIGSTOP):不能被捕捉、不能被屏蔽,默认动作是“暂停进程”——用来暂停进程,后续可以用
fg
恢复。
咱们验证一下9号信号不能被捕捉:
修改my_sigcatch.cc
,添加捕捉9号信号的代码:
void handle_sigkill(int sig) {cout << "Caught signal " << sig << " (SIGKILL) - This line won't print!" << endl;
}int main() {// 尝试捕捉9号信号if (signal(SIGKILL, handle_sigkill) == SIG_ERR) {perror("signal SIGKILL"); // 会打印错误信息exit(1);}// ... 其他代码
}
编译运行:g++ -o my_sigcatch my_sigcatch.cc -std=c++11 && ./my_sigcatch
,输出:
signal SIGKILL: Invalid argument
signal
函数返回错误,因为9号信号不能被捕捉。再用kill -9 12345
发送9号信号,进程会立即终止——验证了9号信号不能被捕捉。
2.3.5 信号处理的完整流程(以自定义捕捉为例)
咱们以“进程收到2号信号,自定义捕捉”为例,梳理完整流程:
- 进程在用户态运行,按
Ctrl+C
,键盘触发硬件中断,操作系统检测到是Ctrl+C
,给进程发2号信号(修改pending表第2位为1); - 进程继续在用户态运行,之后触发系统调用(比如
sleep
),切换到内核态; - 操作系统处理完
sleep
的逻辑(比如让进程休眠),准备返回用户态; - 返回前,检测pending表和block表:
- pending表第2位为1(收到信号),block表第2位为0(未屏蔽);
- 查看handler表第2位,发现是
handle_sigint
函数地址(自定义捕捉);
- 操作系统切换到用户态,执行
handle_sigint
函数; - 函数执行完后,操作系统切换到内核态,把pending表第2位设为0;
- 再次切换到用户态,继续运行进程的代码(
sleep
之后的逻辑)。
整个过程中,用户态和内核态切换了4次——这就是信号处理的完整链路。
第三章 核心转储(Core Dump):让进程“死得明白”
咱们在讲3号信号SIGQUIT
时,提到过“核心转储”(Core Dump),它是信号处理的一个重要特性,用来在进程异常终止时,把进程的内存信息(比如栈、寄存器)保存到磁盘文件(默认叫core.PID
),方便后续调试。
3.1 为什么需要核心转储?
当进程异常终止(比如除零、野指针)时,只知道“收到了某个信号”还不够,我们还想知道“在哪行代码出错了”。核心转储文件就像“黑匣子”,保存了进程崩溃时的内存状态,用调试工具(比如gdb
)分析这个文件,就能定位到具体的错误代码行。
3.2 如何开启核心转储?
Linux默认关闭核心转储(避免频繁崩溃生成大量文件占用磁盘),可以用ulimit
命令开启:
- 查看当前核心转储大小限制:
ulimit -c
,默认是0(关闭); - 开启核心转储,设置最大大小(比如1024块,1块=512字节):
ulimit -c 1024
; - 永久开启(重启后生效):修改
/etc/security/limits.conf
,添加* soft core unlimited
(所有用户软限制核心转储大小无限制)。
3.3 实验:生成并分析核心转储文件
咱们用之前的除零错误程序my_div0.cc
,开启核心转储后运行:
3.3.1 步骤1:开启核心转储
在终端执行:
ulimit -c 1024 # 开启核心转储,最大1024块
3.3.2 步骤2:编译程序时加调试信息
编译时加-g
选项,生成调试信息(否则gdb
看不到代码行):
g++ -o my_div0 my_div0.cc -std=c++11 -g
3.3.3 步骤3:运行程序,生成核心转储文件
运行程序,触发除零错误:
./my_div0
程序会终止,并生成core.12345
文件(12345是进程PID):
Before division by zero
Received signal 8 (SIGFPE) - 除零错误!
Received signal 8 (SIGFPE) - 除零错误!
...(按Ctrl+C终止)
ls -l core.12345
-rw------- 1 user user 123456 Jul 20 10:00 core.12345
3.3.4 步骤4:用gdb分析核心转储文件
用gdb
加载可执行程序和核心转储文件:
gdb ./my_div0 core.12345
在gdb
中执行bt
(backtrace,打印调用栈):
(gdb) bt
#0 0x000055555555523a in main () at my_div0.cc:22
22 int c = a / b; // 除零错误
可以清晰看到,错误发生在my_div0.cc
的第22行——这就是核心转储的价值,帮我们快速定位异常代码行。
3.4 注意:核心转储的适用场景
核心转储主要用于“调试运行时错误”,比如除零、野指针、数组越界等。但在生产环境中,通常会关闭核心转储——因为频繁崩溃会生成大量core
文件,占用磁盘空间;而且生产环境的程序一般会有日志系统,通过日志也能定位错误。
第四章 实时信号:为什么普通信号会丢失?
咱们前面讲的都是普通信号(131),它用位图保存,会丢失;而实时信号(3464)用队列保存,不会丢失。咱们简单对比两者的区别:
4.1 普通信号 vs 实时信号
特性 | 普通信号(1~31) | 实时信号(34~64) |
---|---|---|
保存方式 | 位图 | 队列 |
是否丢失 | 是(多次收到只记一次) | 否(每次收到都入队) |
优先级 | 无(按编号处理) | 有(编号大的先处理) |
用途 | 常见场景(终止、暂停) | 高响应场景(实时控制) |
比如,给进程连续发3次2号普通信号,进程只会处理一次;而发3次34号实时信号,进程会处理3次。
4.2 实时信号的应用场景
实时信号主要用于“实时系统”(比如工业控制、车载系统),这些场景需要“高响应性”,不能丢失信号。比如车载系统中,“刹车信号”是实时信号,必须每次都处理,否则会出安全事故。
咱们平时开发很少用到实时信号,重点掌握普通信号即可。
第五章 常见信号汇总(普通信号1~31)
为了方便大家记忆,咱们汇总一下常用的普通信号:
信号编号 | 信号名 | 触发场景 | 默认动作 | 能否捕捉 | 能否屏蔽 |
---|---|---|---|---|---|
1 | SIGUP | 终端关闭 | 终止 | 是 | 是 |
2 | SIGINT | Ctrl+C | 终止 | 是 | 是 |
3 | SIGQUIT | Ctrl+\ | 终止+核心转储 | 是 | 是 |
8 | SIGFPE | 除零错误、浮点错误 | 终止+核心转储 | 是 | 是 |
9 | SIGKILL | kill -9 | 强制终止 | 否 | 否 |
11 | SIGSEGV | 野指针、数组越界 | 终止+核心转储 | 是 | 是 |
13 | SIGPIPE | 管道断裂 | 终止 | 是 | 是 |
14 | SIGALRM | alarm() 超时 | 终止 | 是 | 是 |
18 | SIGCONT | 恢复暂停的进程 | 继续 | 是 | 是 |
19 | SIGSTOP | Ctrl+Z 、kill -19 | 暂停 | 否 | 否 |
20 | SIGTSTP | Ctrl+Z (终端暂停) | 暂停 | 是 | 是 |
23 | SIGURG | socket有紧急数据 | 忽略 | 是 | 是 |
24 | SIGXCPU | 超过CPU时间限制 | 终止+核心转储 | 是 | 是 |
25 | SIGXFSZ | 超过文件大小限制 | 终止+核心转储 | 是 | 是 |
29 | SIGIO | I/O事件就绪 | 忽略 | 是 | 是 |
第六章 总结
6.1 核心知识点总结
- 信号的本质:操作系统给进程的“异步通知”,用来告知进程发生了某个事件。
- 信号与信号量的区别:信号是异步通知,信号量是同步资源计数,无关系。
- 信号的生命周期:产生(5种方式)→保存(三张表:pending、block、handler)→处理(三种方式:默认、忽略、自定义,时机是内核态返回到用户态)。
- 关键概念:
- 位图:普通信号的保存方式,高效;
- 用户态/内核态:权限区别,信号处理在从内核态返回时;
- 核心转储:保存崩溃时的内存信息,用于调试;
- 特殊信号:9号(SIGKILL)和19号(SIGSTOP)不能捕捉、不能屏蔽。
6.2 常见问题答疑
Q1:信号的pending表和block表都是位图,它们的区别是什么?
A1:pending表记录“收到了哪些信号”,block表记录“屏蔽了哪些信号”。比如,pending表为1表示“收到信号”,block表为1表示“收到了但暂时不处理”。
Q2:为什么信号处理要在从内核态返回到用户态时进行?
A2:因为此时操作系统的“重要工作”已经做完,不会打断进程的关键逻辑;而且内核态有权限访问PCB里的pending/block/handler表,能安全地检测和处理信号。
Q3:普通信号会丢失,那怎么避免?
A3:如果需要“不丢失”的信号,用实时信号(34~64);如果必须用普通信号,可以在信号处理函数里加“确认机制”(比如进程处理完信号后,给发送方发一个“处理完成”的信号)。
Q4:核心转储文件很大,怎么减小?
A4:可以用strip
命令去掉可执行程序的调试信息(strip ./my_program
),或者用gcore
命令手动生成核心转储(gcore PID
),只保存当前进程的内存状态。
Q5:进程收到信号后,会立即暂停当前代码去处理信号吗?
A5:不会。进程会继续运行当前代码,直到切换到内核态(比如系统调用、异常),返回用户态时才处理信号。比如进程在执行一个死循环(没有系统调用),收到信号后会一直运行,直到时间片耗尽被调度,切换到内核态后才处理信号。
第七章 写一个“信号管理器”
为了巩固所学知识,咱们动手写一个简单的“信号管理器”,支持以下功能:
- 屏蔽指定信号;
- 解除指定信号的屏蔽;
- 查看当前pending表;
- 捕捉指定信号并执行自定义动作。
代码signal_manager.cc
:
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <cstdlib>
using namespace std;// 打印信号集
void print_sigset(const sigset_t& set) {cout << "Signal set (1~31): ";for (int sig = 1; sig <= 31; sig++) {if (sigismember(&set, sig)) {cout << "1";} else {cout << "0";}}cout << endl;
}// 自定义处理函数
void handle(int sig) {cout << "=== Caught signal " << sig << " ===" << endl;cout << "Current pending set: " << endl;sigset_t pending_set;sigpending(&pending_set);print_sigset(pending_set);
}// 用法提示
void usage(const char* argv0) {cout << "Usage: " << argv0 << " <cmd> [sig]" << endl;cout << "Cmd list:" << endl;cout << " block <sig> : Block signal <sig>" << endl;cout << " unblock <sig> : Unblock signal <sig>" << endl;cout << " pending : Show current pending set" << endl;cout << " catch <sig> : Catch signal <sig> with custom handler" << endl;cout << "Example: " << argv0 << " block 2 (block SIGINT)" << endl;
}int main(int argc, char* argv[]) {if (argc < 2) {usage(argv[0]);exit(1);}string cmd = argv[1];sigset_t block_set, old_block_set;sigemptyset(&block_set);sigemptyset(&old_block_set);if (cmd == "block") {// 屏蔽指定信号if (argc != 3) {usage(argv[0]);exit(1);}int sig = atoi(argv[2]);sigaddset(&block_set, sig);if (sigprocmask(SIG_BLOCK, &block_set, &old_block_set) == -1) {perror("sigprocmask block");exit(1);}cout << "Block signal " << sig << " success!" << endl;} else if (cmd == "unblock") {// 解除指定信号的屏蔽if (argc != 3) {usage(argv[0]);exit(1);}int sig = atoi(argv[2]);sigaddset(&block_set, sig);if (sigprocmask(SIG_UNBLOCK, &block_set, NULL) == -1) {perror("sigprocmask unblock");exit(1);}cout << "Unblock signal " << sig << " success!" << endl;} else if (cmd == "pending") {// 查看pending表sigset_t pending_set;sigpending(&pending_set);print_sigset(pending_set);} else if (cmd == "catch") {// 捕捉指定信号if (argc != 3) {usage(argv[0]);exit(1);}int sig = atoi(argv[2]);if (signal(sig, handle) == SIG_ERR) {perror(("signal catch " + to_string(sig)).c_str());exit(1);}cout << "Catch signal " << sig << " success! Waiting for signal..." << endl;while (true) {sleep(1);}} else {usage(argv[0]);exit(1);}return 0;
}
编译运行:g++ -o signal_manager signal_manager.cc -std=c++11
,然后可以做以下实验:
- 屏蔽2号信号:
./signal_manager block 2
; - 查看pending表:
./signal_manager pending
; - 捕捉3号信号:
./signal_manager catch 3
,按Ctrl+\
观察输出; - 解除2号信号的屏蔽:
./signal_manager unblock 2
。
通过这个小工具,大家可以更直观地理解信号的保存和处理机制。
结语
信号是Linux进程管理的核心机制,也是面试高频考点。咱们从生活例子入手,一步步拆解了信号的产生、保存、处理,还通过大量代码实验验证了每个知识点——希望大家不仅能“记住”信号的概念,更能“理解”背后的逻辑,比如为什么信号要在从内核态返回时处理、为什么9号信号不能被捕捉。
学习信号的关键是“动手”,大家一定要跟着文中的代码实验做一遍,感受信号的工作流程。如果遇到问题,可以回顾文中的答疑部分,或者用man
命令查相关函数(比如man 2 kill
、man 3 signal
)。
最后,希望这篇详解能帮大家彻底搞懂Linux信号,为后续学习进程间通信、网络编程打下坚实的基础!