当前位置: 首页 > news >正文

Linux:信号详解--醍醐灌顶

Linux:信号详解

今天要深入聊聊“信号”这个概念。在开始之前,我先问大家一个问题:你有没有在终端里跑程序时,按Ctrl+C终止过进程?有没有用kill命令关掉过不听话的程序?这些操作背后,其实都是“信号”在起作用。还有,你之前学过“信号量”,这俩名字里都有“信号”,它们是一回事吗?

今天咱们要先明确一点:信号和信号量没有半毛钱关系,就像“老婆”和“老婆饼”——名字像,但本质完全不同。信号量是用来做资源计数的同步机制,比如控制多个进程访问共享资源的次数;而信号,是操作系统给进程发的“异步通知”,告诉进程“出事儿了,该处理一下”。这是咱们今天的第一个重点

第一章 信号的基本认知

我们从生活中的一个例子出发:
假设你在宿舍打游戏,点了个外卖。外卖小哥啥时候到,你不知道——这就像信号的“异步性”,它可能随时产生。等小哥到了,会给你打电话或者敲门,这就是“信号产生”;你听到电话后,可能正在推对方高地,没法立马下楼,就会在脑子里记着“一会要取外卖”,这就是“信号保存”;等游戏打完,你下楼取外卖,这就是“信号处理”。

整个过程,就是一次信号的生命周期:产生→保存→处理。而且你发现没?信号产生后,不一定会立即处理,得等你把“更重要的事”(推高地)做完——这就是信号的核心特性:异步通知、延迟处理。

再想想生活里其他“信号”场景:门铃响了(有人敲门)、红绿灯变红灯(该停车)、古代打仗的狼烟(敌人来了)、上课铃响(该进教室)……这些都是“异步通知”,和Linux里的信号本质上是一个逻辑。

那问题来了:生活里的“信号”需要你“认识”它(比如知道红灯要停),Linux里的进程,怎么“认识”信号呢?

答案很简单:进程天生就认识信号。因为操作系统在设计进程的时候,就把信号的“识别方法”和“默认处理方式”内置到进程里了。比如,进程一出生就知道:收到2号信号(SIGINT,对应Ctrl+C)要终止自己,收到9号信号(SIGKILL)必须终止自己,收到19号信号(SIGSTOP)要暂停自己。这些都是“与生俱来”的能力,就像你天生知道“妈妈叫你吃饭”是要去餐桌一样。

第二章 信号的生命周期:产生、保存、处理

信号的生命周期分三个阶段,咱们逐个拆解,每个阶段都结合代码和实验,让大家“看得见、摸得着”。

2.1 第一阶段:信号的产生——信号从哪来?

信号不会凭空出现,它的产生一定有“触发条件”。Linux里信号产生的方式有5种,咱们一种一种说,每种都配个小实验,大家可以跟着做。

2.1.1 方式1:键盘组合键——最直观的信号产生

咱们最常用的就是键盘组合键,比如Ctrl+CCtrl+\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,管道断裂),默认动作是终止写端进程——因为写的数据没人读,继续写是“无效工作”,操作系统要回收资源。

咱们用代码模拟这个场景:

  1. 父进程创建管道,fork子进程;
  2. 子进程关闭读端(fd[0]),然后不断向写端(fd[1])写数据;
  3. 父进程关闭读端后立即退出,让子进程的写端成为“无读端的写端”。

代码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表的三种取值:

  1. 默认处理(SIG_DFL):数组元素为SIG_DFL(本质是0),表示按操作系统默认动作处理(比如终止、暂停);
  2. 忽略信号(SIG_IGN):数组元素为SIG_IGN(本质是1),表示收到信号后不做任何处理;
  3. 自定义捕捉(用户函数):数组元素为用户自己写的处理函数地址,表示收到信号后执行这个函数。

比如,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:要操作的信号集(如果howSIG_BLOCK,就是要添加的信号);
  • oldset:保存修改前的block表(可以为NULL,不保存);
  • 返回值:成功返回0,失败返回-1。

另外,sigpending函数可以获取当前进程的pending表:

int sigpending(sigset_t* set);
  • set:输出参数,用来保存当前的pending表;
  • 返回值:成功返回0,失败返回-1。
2.2.5 实验:屏蔽2号信号,观察pending表变化

咱们通过代码实验,理解block表和pending表的作用。目标:

  1. 屏蔽2号信号(SIGINT);
  2. 不断获取pending表,打印状态;
  3. Ctrl+C发2号信号,观察pending表变化;
  4. 解除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,输出如下:

  1. 程序开始,屏蔽2号信号,打印pending表(全0):
Block SIGINT (2号信号) success!
Pending set (every 1s):
0000000000000000000000000000000
0000000000000000000000000000000
0000000000000000000000000000000
  1. 在程序运行到第3秒时,按Ctrl+C发2号信号,pending表的第2位变成1:
0000000000000000000000000000001  // 按Ctrl+C前,全0
0000000000000000000000000000010  // 按Ctrl+C后,第2位为1
0000000000000000000000000000010  // 一直保持1,因为被屏蔽
  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→内核态。

进程什么时候会从用户态切换到内核态?有三种情况:

  1. 执行系统调用(比如readwritekill);
  2. 触发异常(比如除零、野指针);
  3. 触发硬件中断(比如键盘按下、时钟中断)。

切换到内核态后,操作系统会做对应的工作(比如系统调用的逻辑、处理异常),然后再从内核态切换回用户态——信号的处理,就发生在“从内核态返回到用户态”这个时机

2.3.2 信号处理时机:为什么是“从内核态返回到用户态”?

操作系统设计信号处理时机时,遵循一个原则:不打断进程的“重要工作”。进程在用户态运行自己的代码时,可能正在做关键逻辑(比如写数据到文件),打断它可能导致数据错误;而进程从内核态返回时,说明操作系统的“重要工作”(比如系统调用、异常处理)已经做完了,此时处理信号不会影响关键逻辑。

具体流程是:

  1. 进程在用户态运行,触发系统调用/异常/中断,切换到内核态;
  2. 操作系统处理完内核态的工作(比如执行系统调用逻辑);
  3. 返回用户态前,操作系统检测进程的pending表和block表:
    • 如果有“未屏蔽且未处理”的信号(pending表为1,block表为0),就处理这个信号;
    • 处理完信号后,再返回用户态,继续运行进程的代码;
  4. 如果没有这样的信号,直接返回用户态。
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_DFLSIG_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里有两个“特权信号”:

  1. 9号信号(SIGKILL):不能被捕捉、不能被屏蔽,默认动作是“强制终止进程”——这是操作系统留给用户“杀死顽固进程”的最后手段,比如进程陷入死循环,用kill -9一定能杀掉。
  2. 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号信号,自定义捕捉”为例,梳理完整流程:

  1. 进程在用户态运行,按Ctrl+C,键盘触发硬件中断,操作系统检测到是Ctrl+C,给进程发2号信号(修改pending表第2位为1);
  2. 进程继续在用户态运行,之后触发系统调用(比如sleep),切换到内核态;
  3. 操作系统处理完sleep的逻辑(比如让进程休眠),准备返回用户态;
  4. 返回前,检测pending表和block表:
    • pending表第2位为1(收到信号),block表第2位为0(未屏蔽);
    • 查看handler表第2位,发现是handle_sigint函数地址(自定义捕捉);
  5. 操作系统切换到用户态,执行handle_sigint函数;
  6. 函数执行完后,操作系统切换到内核态,把pending表第2位设为0;
  7. 再次切换到用户态,继续运行进程的代码(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)

为了方便大家记忆,咱们汇总一下常用的普通信号:

信号编号信号名触发场景默认动作能否捕捉能否屏蔽
1SIGUP终端关闭终止
2SIGINTCtrl+C终止
3SIGQUITCtrl+\终止+核心转储
8SIGFPE除零错误、浮点错误终止+核心转储
9SIGKILLkill -9强制终止
11SIGSEGV野指针、数组越界终止+核心转储
13SIGPIPE管道断裂终止
14SIGALRMalarm()超时终止
18SIGCONT恢复暂停的进程继续
19SIGSTOPCtrl+Zkill -19暂停
20SIGTSTPCtrl+Z(终端暂停)暂停
23SIGURGsocket有紧急数据忽略
24SIGXCPU超过CPU时间限制终止+核心转储
25SIGXFSZ超过文件大小限制终止+核心转储
29SIGIOI/O事件就绪忽略

第六章 总结

6.1 核心知识点总结

  1. 信号的本质:操作系统给进程的“异步通知”,用来告知进程发生了某个事件。
  2. 信号与信号量的区别:信号是异步通知,信号量是同步资源计数,无关系。
  3. 信号的生命周期:产生(5种方式)→保存(三张表:pending、block、handler)→处理(三种方式:默认、忽略、自定义,时机是内核态返回到用户态)。
  4. 关键概念
    • 位图:普通信号的保存方式,高效;
    • 用户态/内核态:权限区别,信号处理在从内核态返回时;
    • 核心转储:保存崩溃时的内存信息,用于调试;
    • 特殊信号: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:不会。进程会继续运行当前代码,直到切换到内核态(比如系统调用、异常),返回用户态时才处理信号。比如进程在执行一个死循环(没有系统调用),收到信号后会一直运行,直到时间片耗尽被调度,切换到内核态后才处理信号。

第七章 写一个“信号管理器”

为了巩固所学知识,咱们动手写一个简单的“信号管理器”,支持以下功能:

  1. 屏蔽指定信号;
  2. 解除指定信号的屏蔽;
  3. 查看当前pending表;
  4. 捕捉指定信号并执行自定义动作。

代码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,然后可以做以下实验:

  1. 屏蔽2号信号:./signal_manager block 2
  2. 查看pending表:./signal_manager pending
  3. 捕捉3号信号:./signal_manager catch 3,按Ctrl+\观察输出;
  4. 解除2号信号的屏蔽:./signal_manager unblock 2

通过这个小工具,大家可以更直观地理解信号的保存和处理机制。

结语

信号是Linux进程管理的核心机制,也是面试高频考点。咱们从生活例子入手,一步步拆解了信号的产生、保存、处理,还通过大量代码实验验证了每个知识点——希望大家不仅能“记住”信号的概念,更能“理解”背后的逻辑,比如为什么信号要在从内核态返回时处理、为什么9号信号不能被捕捉。

学习信号的关键是“动手”,大家一定要跟着文中的代码实验做一遍,感受信号的工作流程。如果遇到问题,可以回顾文中的答疑部分,或者用man命令查相关函数(比如man 2 killman 3 signal)。

最后,希望这篇详解能帮大家彻底搞懂Linux信号,为后续学习进程间通信、网络编程打下坚实的基础!

http://www.dtcms.com/a/355786.html

相关文章:

  • 基于Spring Cloud Gateway构建API网关
  • 第三章:Cesium 矢量数据可视化(点、线、面)
  • Shell脚本(1)
  • 机器学习可解释库Shapash的快速使用教程(五)
  • 全能工程软件 Siemens NX:从设计到制造的全流程解决方案,附安装指南
  • 滑台模组如何实现电子制造精密加工?
  • HVV面经总结(二)
  • 自动量化交易
  • 将Ollama应用安装至其他盘
  • 通信算法之323:verilog中带参数实体模版
  • Spotfire多表关联数据关联选择
  • 在AStar模块中加入额外的搜索条件
  • 在jdk8的spring-boot-2.7.x项目中集成logback-1.3.x
  • 【涂鸦T5】3. 录音
  • 实验项目:Kubernetes Ingress 实战演练
  • Cesium入门教程(三)环境搭建(Vue版)
  • 蓝凌研究院《2025上市公司AI数智化转型白皮书》发布
  • 【力扣】2725. 间隔取消
  • linux 环境 批量发送get请求
  • 大模型常用术语
  • 机器视觉学习-day10-图像添加水印
  • 帕萨特盘式制动器cad+设计说明书
  • TensorFlow 面试题及详细答案 120道(41-50)-- 数据输入与管道
  • workflow/http_parser源码解密:HTTP解析器的双倍扩容与零拷贝策略
  • 【C#】征服 .NET Framework 4.8 中的“古董”日期格式:/Date(1754548600000)/ 和 ISO 8601
  • 【Nacos】优雅规范的使用和管理yml配置文件
  • 苍穹外卖项目笔记day01
  • 工业级TF卡NAND + 北京君正 + Rk瑞芯微的应用
  • 本地大模型部署(下载) vs. 从头训练大模型
  • APP手游使用游戏盾SDK为何能有效抵御各类攻击?