深入理解Linux进程信号机制
“信号 (Signal)” 这词儿,相信玩 Unix 系系统的老炮儿们肯定不陌生!
这玩意儿可是 Unix 家族的 “元老级” 通信机制,从第一代 Unix 那会儿就蹲在系统里了,资格老得很!
它干的活儿也简单:要么给进程递个信儿,说 “某事儿发生了”;要么直接命令进程,“快执行这个处理函数”,就这么直接!
生活里的 “信号” 多了去了,你天天都在接收:
- 红绿灯一亮,你知道该踩刹车还是踩油门;
- 闹钟一响,你要么爬起来要么按掉继续睡;
- 你妈 / 对象脸一沉,你就知道 “坏了,可能哪儿错了”…
收到这些信号,你自然就知道该咋整。为啥?因为以前有人教过,或者事儿上练过,早把 “啥信号对应啥动作” 刻在脑子里了。就算这信号一时半会儿没出现,你也门儿清该怎么应对 —— 这跟进程处理信号一个道理!
不过从信号生出来,到被存起来等着处理,中间弯弯绕绕可不少。而且操作系统要处理它的时候,还得瞅瞅 “时机对不对”。绝大多数时候,只有 “时机合适” 了,才能动手处理,也就是调用信号对应的执行动作。
至于信号到底啥时候处理、该咋处理,咱慢慢说,保证给你讲得明明白白!
信号的核心思想一句话:
“进程之间传递‘紧急快递’,通知对方该干啥了!”
好处那是杠杠滴:
✅ 省得你老“打电话”(系统调用),效率蹭蹭涨!
✅ 进程之间“异步通信”,像微信发消息一样自由!
✅ 处理异常事件(比如Ctrl+C),直接“一键终止”!
计算机江湖里这种“快递小哥”可不少见:管道、消息队列、共享内存... 但信号这货最“野蛮”,它直接冲进目标进程的怀里,喊一声:“喂!你有事要干了!”
一、信号的本质——异步的“软中断”
信号(Signal)是Linux中唯一支持异步通信的进程间通信机制,本质是软中断。它的核心作用是:通知进程发生了某个事件,但不会立即打断进程当前的操作。
为什么需要异步通信?
想象你在煮饭时突然接到电话(信号),你不会立刻放下锅去接电话,而是先完成当前任务(比如关火),再处理电话。进程也是如此:
- 信号可能随时产生(异步性),但进程可能正在执行高优先级任务。
- 信号的处理时机由操作系统决定,通常在进程从内核态返回用户态时触发(如系统调用、中断返回)。
进程咋就认得这些信号呢?
这得归功于设计操作系统的程序员们 —— 他们早把常见的信号和对应的处理动作,像预装软件似的嵌到进程的代码和属性里了。相当于给进程提前 “培训” 好了,一看信号就知道 “哦,这玩意儿我认识,该咋处理”。
再说说 “异步” 这事儿。对进程来说,信号啥时候来完全没谱,这就是异步 —— 多个事儿各干各的,不用等前一个干完再启动。反过来,同步就是几个事儿得踩着相同的节奏,保持着特定的关联,一步一步来。
进程咋记录这些冒出来的信号呢?
还是老规矩:先描述,再组织。用 0 和 1 标记一个信号 “来没来”,然后靠 “位图” 这种数据结构来管着。说白了,发送信号其实就是 “写信号”,直接改目标进程信号位图里的特定比特位 —— 把 0 改成 1,就像在通讯录上给某人打个勾,“收到,记下来了”。
这里得提一嘴内核里的task_struct数据结构,这玩意儿只有操作系统能改。不管你用啥招儿产生信号,最后一步必须让 OS 来完成发送 —— 相当于所有信号都得经过系统 “邮局” 盖章才能送达,规矩得很!而且前面也说了,信号到了不是马上就办,得等进程腾出功夫来。
处理信号的三种方式:
- 默认动作:系统早规定好的,比如某些信号来了直接让进程退出,按既定剧本走;
- 忽略信号:进程瞅了一眼,“这信号不重要,不理它”,该干啥干啥;
- 用户自定义动作:程序员自己写了处理函数,信号一来就按自定义的逻辑执行,灵活度拉满。
就这么一套流程,信号在进程间的通信就整得明明白白了!
二、信号咋来的?
2.1、终端按键信号
先从终端按键说起—— 这可是咱平时最常碰到的场景!
就说你想结束一个程序吧,咔咔按个Ctrl + C,进程立马就停了,是不是特熟?咱来瞅段代码感受下:
#include <iostream>
#include <unistd.h>
using namespace std;
int main()
{while(true){cout << "我是一个进程,我正在运行...,pid值:" << getpid() << endl;sleep(1);}return 0;
}
跑起来之后,你按Ctrl + C,进程就乖乖退出了。其实这背后的门道是:这组合键给进程发了个2 号信号,而进程收到 2 号信号的默认操作就是 “自己终结”,就这么简单!
那键盘这玩意儿是咋跟系统搭上线的?靠的是中断机制。键盘上每个键都有对应的 “槽位编号”,再加上键盘驱动帮忙,操作系统一眼就能认出你按了啥。别说单个键了,组合键也逃不过它的 “火眼金睛”。既然都认出来了,那给指定进程发个信号还不是手到擒来?
具体到Ctrl + C:系统先识别出这组按键,然后找到当前正在前台跑的进程,把 2 号信号对应的位图位从 0 改成 1(就是之前说的 “写信号”),发送工作就完成了。剩下的就等进程啥时候方便,再去处理这个信号。
这里有俩点得拎清楚:
- Ctrl + C这种信号,只能发给前台进程。你在命令后面加个&,进程就跑到后台了,shell 不用等它结束就能接新命令。但后台进程收不到Ctrl + C这种信号,这规矩得记牢。
- shell 同一时间能跑一个前台进程 + N 个后台进程,只有前台的才能接收到Ctrl + C这类组合键信号。
- 前台进程跑着跑着,你可能随时按Ctrl + C,也就是说进程代码执行到任何地方都可能收到这个叫SIGINT的 2 号信号并终止 —— 这再次说明,信号对进程来说是异步的,完全没规律可言。
再说说signal函数—— 让你自定义信号处理动作的神器。
这函数的参数里,signum是信号编号,handler是个函数指针(返回值 void,参数 int)。调用它之后,进程收到signum信号时,就会自动调用handler函数,还会把信号编号传过去。函数返回值是这个信号之前的处理方式。
举个例子:
void handler(int signo)
{cout << "get a signal:" << signo << endl;
}
int main()
{signal(2, handler); // 告诉进程:收到2号信号就调用handlerwhile(true){cout << "我是一个进程,我正在运行...,pid值:" << getpid() << endl;sleep(1);}return 0;
}
这儿得划重点:signal函数只是改了进程对特定信号的处理动作,不是立马就执行。只有当信号真的来了,对应的处理函数才会被调用 —— 别搞反了顺序!
最后给几个常见信号补补课:
- SIGINT(2 号):就是Ctrl + C触发的,功能是让程序终止(中断)。
- SIGQUIT(3 号):跟 2 号类似,但由Ctrl + \触发,狠在会让进程产生 core 文件,跟程序出错似的。
- SIGFPE(8 号):除法除零这种算术错误,就会触发这个信号。
这些信号的脾气,摸透了才能玩转进程!
2.2、核心转储
咱接着聊信号产生的另一个重要场景 ——核心转储(core dump)。这玩意儿说白了,就是进程出错或者收到特定信号挂掉时,系统给它拍的一张 “现场快照”:把进程当时的内存映像啥的写到一个叫core的文件里,供咱们调试用。程序崩了别急着删,看看这文件,说不定就能揪出 bug 在哪儿!
进程为啥会异常终止?多半是有 bug,比如乱访问内存导致段错误。这时候core文件就派上大用场了,用调试器一扒,当时咋崩的一目了然 —— 这操作叫 “事后调试(Post-mortem Debug)”,特实用。
不过有俩点得注意:
- 一个进程能生成多大的core文件,得看它的 “资源限制(Resource Limit)”,这信息存在 PCB 里。
- 默认情况下,系统是不让生成core文件的,为啥?怕里面存着用户密码这类敏感信息,不安全呗。但开发调试的时候咱可以改,用ulimit命令就行。比如想让core文件最大 1024K,就敲:$ ulimit -c 1024。
还有个小知识点:生产环境(比如云服务器)的核心转储功能通常是关着的。毕竟正式上线的程序,崩了也不能随便留core文件泄机密。咱平时用的云服务器倒是开发、测试、部署一体化,方便是方便,但调试和生产的区别得拎清。
咱来动手验证下 core 文件咋生成的,步骤记好:
1.先开核心转储功能:ulimit -c 10240(允许最大 10240K);
2.编译的时候选 Debug 模式,不然调试信息不全;
3.整个测试代码瞅瞅:
#include <iostream>
#include <cassert>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
using namespace std;
int main()
{// signal(SIGFPE, handler); // 注释掉,用默认处理动作int id = fork();if(id == 0){sleep(2); // 等会儿,让父进程准备好int a = 10;a /= 0; // 故意除0,触发8号信号SIGFPE}int status = 0;int ret = waitpid(id, &status, 0); // 父进程等子进程assert(ret != -1);(void)ret;// 打印子进程退出信息:收到的信号、是否产生core文件cout << "父进程: " << getpid() << " 子进程: " << id << " exit signal: "\<< (status & 0x7F) << " is core: " << ((status >> 7) & 1) << endl;return 0;
}
跑起来之后,子进程因为除 0 触发 8 号信号,会崩掉并生成core文件。这时候用 gdb 调试:
gdb 可执行程序名
(gdb) core-file core文件名
gdb 直接就会告诉你:进程是因为 8 号信号(SIGFPE)挂的,详细描述是 “Arithmetic exception(算术异常)”,一下子就定位到问题了!
最后再强调一句:只有进程因为能产生核心转储的信号终止时,才会生成 core 文件。要是进程正常退出,或者收到的信号不支持 core dump,那是不会有这个文件的,别白等哦!
2.3、系统调用发信号:给进程 “递消息” 的硬路子
先看kill,这函数的声明长这样:
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
它的作用特直接:给指定进程(pid)发送指定信号(sig)。成功了返回 0,失败返回 - 1,简单明了。
咱整个小工具mykill.cc试试水:
#include <iostream>
#include <string>
#include <cstdio>
#include <cstring>
#include <sys/types.h>
#include <signal.h>
#include <unistd.h>
using namespace std;
void Usage(string proc)
{cout << "Usage: \n\t";cout << proc << " 信号编号 目标进程\n" << endl;
}
int main(int argc, char* argv[])
{if(argc != 3) // 得传对参数:程序名、信号号、目标pid{Usage(argv[0]);exit(1);}int signo = atoi(argv[1]); // 转成信号编号int target_id = atoi(argv[2]); // 转成目标进程pidint n = kill(target_id, signo); // 发信号!if(n != 0) // 失败了就报个错{cout << errno << ":" << strerror(errno) << endl;exit(2);}return 0;
}
再整个loop.cc当靶子:
#include <iostream>
#include <cstdlib>
#include <unistd.h>
using namespace std;
int main()
{while(true){cout << "我是一个进程, 我正在运行..., pid值:" << getpid() << endl;sleep(1);}return 0;
}
先跑loop拿到它的 pid,再用mykill发个 2 号信号,loop立马就停了 —— 这效果跟Ctrl + C一样,因为本质都是发 2 号信号,只是途径不同。
raise 函数:给自己发信号的 “懒人法”
raise就更省事了,声明长这样:
#include <signal.h>
int raise(int sig);
它的作用是:给调用它的进程自己发信号。说白了,raise(sig)就相当于kill(getpid(), sig),省得你自己查当前进程 pid 了。
举个例子:
int main()
{while(true){cout << "我正在运行中..." << endl;sleep(1);raise(2); // 每隔1秒给自己发个2号信号}return 0;
}
跑起来你会发现,进程跑一秒就自己退了,因为收到了自己发的终止信号,够直接吧?
abort 函数:强制终止的 “狠角色”
abort这函数更绝,声明是:
#include <stdlib.h>
void abort(void);
它会给当前进程发6 号信号(SIGABRT),而且是铁了心要终止进程 —— 就算你捕捉了这个信号,只要处理函数还会返回,它就再发一次,直到进程退出。另外,它还会把所有打开的流关掉并刷新,挺 “负责” 的。
试试这段代码:
void handler(int signo)
{cout << "get a signal:" << signo << endl; // 想捕捉6号信号?
}
int main()
{signal(6, handler); // 注册6号信号的处理函数cout << "begin..." << endl;sleep(1);abort(); // 发6号信号cout << "end..." << endl; // 这句永远执行不到return 0;
}
跑起来你会发现,handler确实被调用了,但进程还是会终止,end...压根打不出来 —— 这就是abort的狠劲,非让进程退出不可。
说到底,这些系统调用的本质是啥?
其实就是:用户调用系统接口,让操作系统执行对应的代码,最终由操作系统往目标进程的信号位图里写信号。后续进程该咋处理,还是按之前说的那套规矩来 —— 等合适的时机,执行默认、忽略或自定义动作。
说白了,这些系统调用就是给用户提供了 “使唤操作系统发信号” 的门路,绕了一圈,最终还是得靠系统内核动手写信号,没跑儿~
2.4、软件条件也能发信号?
除了手动按键、系统调用,软件条件也能触发信号,最典型的就是alarm函数 —— 这玩意儿就像个定时器,到点了就给进程发个信号,特准时!
alarm 函数:给进程装个 “闹钟”
先看alarm的用法,声明长这样:
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
它的功能简单直接:让系统在seconds秒后,给当前进程发一个SIGALRM(14 号信号)。这信号的默认动作是让进程终止,跟闹钟响了叫你起床一个道理。
要是你传个 0 进去,就会取消之前设的闹钟。而且不管你之前有没有设过闹钟,新调用alarm都会把旧的给覆盖掉。返回值也挺实在:如果之前有没响的闹钟,就返回剩下的秒数;没有的话就返回 0。
实战一下:1 秒钟能数到多少?
咱先用alarm整个小实验,看看 1 秒钟内计算机能把一个整数加到多大。
先看这段代码:
int main()
{alarm(1); // 1秒后发14号信号int count = 0;while(true) {cout << count++ << endl; // 边加边打印} return 0;
}
跑起来你会发现,1 秒顶多能数到 6 万多 —— 这锅得甩给 IO 操作!每次打印都要访问设备,速度慢得要命,严重拖后腿了。
咱改改代码,把打印放处理函数里:
int count = 0;
void handler(int signo)
{// 收到信号再打印,平时只专心计数cout << "get a signal:" << signo << "\tcount:" << count << endl;
}
int main()
{signal(SIGALRM, handler); // 注册14号信号的处理函数alarm(1); // 1秒后发信号while(true) count++; // 纯计算,不碰IOreturn 0;
}
这次就猛了,计数能翻好几十倍!这也从侧面印证了:IO 操作的效率是真的低,能少碰就少碰。
软件条件发信号的本质是啥?
说到底,还是操作系统在背后忙活:
- 系统先 “盯” 着各种软件条件,比如alarm设置的闹钟时间到了、某个计数器满了等等;
- 一旦条件触发(比如闹钟超时),系统就知道 “该发信号了”;
- 然后找到对应的进程(就像alarm会记录哪个进程设的闹钟),往它的信号位图里写对应的信号(比如 14 号SIGALRM);
- 最后等进程有空了,再按规矩处理这个信号。
就拿alarm来说,系统里肯定有个类似 “闹钟结构体” 的东西,里面记着哪个进程设的闹钟、还有多久响。时间一到,系统就照着结构体里的进程 ID,把 14 号信号发过去 —— 一套流程下来,软件条件就转换成信号了。
所以啊,不管是硬件触发还是软件触发,信号最终都得靠操作系统来 “递”,这规矩走到哪儿都不变~
2.5、硬件异常咋变成信号的?
除了软件层面,硬件异常也会触发信号,最典型的就是 “除 0 错误”—— 这玩意儿能直接让 CPU “翻脸”,逼着操作系统给进程发信号!
咱先看段作死代码:
int main()
{int a = 10;a /= 0; // 敢除0?等着瞧!cout << "div zero" << endl; // 这句根本执行不到return 0;
}
跑起来程序直接崩,还会报错。这背后的门道在 CPU 里:
CPU 内部有个 “状态寄存器”,这玩意儿就是个位图,里面藏着各种 “状态标记位”,比如 “溢出标记位”。平时安安静静的,可一旦你搞除 0 这种操作 ——
想象一下,正常计算是 “小打小闹”,除 0 直接导致 “数值爆炸”,产生了超高的进位。CPU 一算不对劲:“嘿,溢出了!” 立马把状态寄存器里的 “溢出标记位” 从 0 改成 1,相当于拉响了警报。
操作系统就像个巡逻的保安,定期检查 CPU 状态。一看到这个标记位亮了,立马明白:“哦,哪个进程搞出除 0 错误了!” 紧接着就找到那个 “肇事进程”,给它发个SIGFPE(8 号信号)。进程收到这信号,默认动作就是 “自我终结”,所以程序就崩了。
要是捕捉了这个信号会咋样?
咱改改代码,给 8 号信号整个处理函数:
void handler(int signo)
{cout << "进程确实收到了: " << signo << " 导致崩溃" << endl;
}
int main()
{signal(SIGFPE, handler); // 注册8号信号的处理函数int a = 10;a /= 0; // 还是除0cout << "div zero" << endl;return 0;
}
这下有意思了:程序不会直接崩,但会陷入死循环,一遍遍地打印 handler 里的内容。为啥?
因为硬件异常的 “根儿” 没解决!
进程收到SIGFPE后,确实执行了 handler 函数,但 CPU 状态寄存器里的 “溢出标记位” 还是 1 啊!这标记位属于进程上下文的一部分,进程切换时会被保存起来。
等这个进程再次被调度运行,操作系统一查寄存器:“哟,这溢出标记还亮着,说明又出除 0 错误了!” 于是又发一次SIGFPE信号。就这么周而复始, handler 函数被反复调用,看起来就是死循环了。
所以啊,硬件异常产生信号的本质是:
硬件(比如 CPU)先检测到错误(除 0、非法内存访问等),通过修改自身寄存器 “报案”;操作系统发现后,把这个硬件错误 “翻译” 成对应的信号,发给出问题的进程。
但要注意:信号处理函数只能处理 “信号本身”,没法修复硬件寄存器里的错误状态 —— 这也是为啥捕捉SIGFPE这类信号容易出幺蛾子的原因!
三、信号保存
信号从产生到处理,中间得有个 “暂存” 的过程,这就涉及到 “信号保存” 的学问。内核里早把规则定好了,咱得先把几个关键概念捋清楚。
3.1、信号概念及内核信号
信号的三个关键状态
- 信号递达:说白了就是 “真刀真枪执行处理动作”。不管是执行系统默认动作(比如终止进程)、忽略信号,还是跑用户自定义的捕捉函数,都算递达。
- 信号未决:信号刚产生,还没被处理,处在 “排队等号” 的状态。这时候信号被存在一个叫pending的位图里,相当于给进程发了个 “待办通知”。
- 阻塞:进程可以主动 “屏蔽” 某个信号,就像设了个 “免打扰”。被阻塞的信号就算处于未决状态,也得乖乖等着,直到进程取消阻塞,才能轮到它递达。
内核里的信号 “三兄弟”
内核里靠三个关键结构管理信号,就像三本账本:
- pending(未决位图):个位图,某个信号对应的比特位是 1,说明这信号 “已收到,待处理”;0 就是没收到。
- handler(处理动作表):一个函数指针数组,下标是信号编号,数组里存的是该信号的处理方式 —— 是用默认动作、忽略,还是用户自定义的函数,一目了然。
- block(阻塞位图):也是个位图,比特位为 1 表示这个信号被 “拉黑” 了,暂时不许递达;0 就是 “放行” 状态。
信号处理的 “流水账”
进程处理信号的流程,就像按规矩办事的流水线:
- 操作系统给进程发信号,本质就是改pending位图 —— 把对应信号的比特位从 0 改成 1,相当于在 “待办账” 上打个勾。
- 进程有空了就会查pending位图,看看哪些信号在排队。
- 查到某个信号比特位是 1,先去看block位图:
- 要是block里对应位也是 1,得,这信号被阻塞了,跳过,先不处理。
- 要是block里对应位是 0,说明可以处理了,就执行handler表里记录的处理动作(默认、忽略或自定义)。
- 处理完之后,把pending位图里该信号的比特位改回 0,相当于 “待办账” 上划掉这一项。
信号 “扎堆” 了咋办?
如果一个信号被阻塞期间,又多次产生,Linux 是这么处理的:
- 普通信号:比如 2 号SIGINT、3 号SIGQUIT这些,就算未决期间来了好几次,也只算一次。就像快递员送同一地址的多件快递,可能攒一起送一次。
- 实时信号:这类信号更 “较真”,未决期间多次产生会按顺序排进队列,递达时一个个处理,保证不遗漏。
sigset_t:操作位图的 “专用工具”
内核用sigset_t这个类型来表示pending和block位图(本质还是位图)。但咱用户不能直接用位操作改它,得用系统提供的专用函数(比如sigemptyset清空、sigaddset添加信号等),就像用指定工具才能改账本,免得瞎改出乱子。
3.2、信号集操作函数
既然内核用sigset_t这种位图结构管理信号,那咱用户想改pending或block这些位图,总不能直接上手瞎戳吧?别担心,系统早给咱准备了一套 “专用工具”—— 就是这些信号集操作函数,专门用来安全地操作sigset_t类型的信号集。
sigemptyset:给信号集 “清零”
int sigemptyset(sigset_t *set);
这函数的作用特简单:把set指向的信号集里所有信号的对应比特位全改成 0。说白了,就是初始化一个 “空集”,里面啥信号都没有。
比如你刚定义了个sigset_t set;,第一步就得用它初始化,不然里面的比特位是乱的,后续操作容易出问题。
sigfillset:给信号集 “填满”
int sigfillset(sigset_t *set);
跟上面的正好相反:把set里所有信号的对应比特位全改成 1。也就是说,这个信号集包含了系统支持的所有信号,相当于 “全集”。
同样,这也是个初始化函数,用它之后,信号集里就 “塞满” 了各种信号。
sigaddset:给信号集 “加个信号”
int sigaddset(sigset_t *set, int signo);
想给信号集里添个特定信号?用它!比如sigaddset(&set, 2),就是把 2 号信号(SIGINT)对应的比特位改成 1,让这个信号成为集合里的 “有效信号”。
sigdelset:给信号集 “删个信号”
int sigdelset(sigset_t *set, int signo);
跟sigaddset对着干:把signo信号在set里的对应比特位改成 0,从集合里移除这个信号。比如sigdelset(&set, 3),就是把 3 号信号(SIGQUIT)从集合里删掉。
sigismember:查查信号 “在不在”
int sigismember(const sigset_t *set, int signo);
想知道某个信号是不是在集合里?用它查!返回 1,说明signo在集合里(对应比特位是 1);返回 0,说明不在;出错了就返回 - 1。
比如if (sigismember(&set, 2) == 1),就表示 2 号信号在这个集合里。
关键注意点:初始化是前提!
划重点:用sigset_t变量之前,必须先用sigemptyset或sigfillset初始化!
为啥?因为sigset_t本质是个结构体 / 数组,刚定义时里面的比特位是随机的,不初始化就直接用sigaddset或sigdelset,结果根本没法预测。就像你用个没擦干净的黑板写字,之前的乱码可能干扰新内容,必须先擦干净(清零)或故意画满(填满),才能开始正经操作。
3.3、sigpending 和 sigprocmask
sigpending和sigprocmask,堪称管理信号的 “黄金搭档”:一个能查进程当前有哪些未决信号,一个能控制哪些信号被阻塞,配合起来用,信号的来龙去脉看得明明白白!
sigpending:查看 “待处理信号清单”
先看sigpending,声明长这样:
#include <signal.h>
int sigpending(sigset_t *set);
这函数的作用特实在:通过输出型参数set,把当前进程的未决信号集(就是那些已经产生但还没处理的信号)给取出来。调用成功返回 0,失败返回 - 1。
简单说,它就像给进程的 “待办事项” 拍了张照,让你知道 “哪些信号在排队等处理”。
sigprocmask:控制 “信号屏蔽开关”
再看sigprocmask,功能更核心:
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
它能帮你读取或修改进程的信号屏蔽字(也就是阻塞信号集)。成功返回 0,失败返回 - 1。
参数用法得拎清楚:
- oldset:如果非空,就把当前的信号屏蔽字 “备份” 到这里,相当于 “旧状态存档”。
- set:如果非空,就根据how的指示,用这个信号集更新进程的屏蔽字,相当于 “设置新状态”。
- how:指定修改方式,常见的有三个值:
- SIG_BLOCK:把set里的信号 “加” 到屏蔽字里(屏蔽新增的信号)。
- SIG_UNBLOCK:把set里的信号从屏蔽字里 “去掉”(解除这些信号的屏蔽)。
- SIG_SETMASK:直接用set替换当前的屏蔽字(全覆盖式修改)。
实战演示 1:信号屏蔽与解除
先整个例子看看信号被屏蔽后会咋样:
#include <iostream>
#include <cassert>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
using namespace std;
// 打印信号集中的01位图
void showBlock(sigset_t* oset)
{int signo = 1;for(; signo <=31; signo++){if(sigismember(oset, signo)) cout << "1";else cout << "0";}cout << endl;
}
int main()
{sigset_t set, oset;sigemptyset(&set); // 初始化空集sigemptyset(&oset);sigaddset(&set, 2); // 把2号信号(SIGINT)加入set// 用set替换当前屏蔽字,同时备份旧屏蔽字到osetsigprocmask(SIG_SETMASK, &set, &oset);int cnt = 0;while(true){showBlock(&oset); // 打印旧屏蔽字(初始是空的,全0)sleep(1);cnt++;if(cnt == 10){cout << "recover block" << endl;showBlock(&set); // 打印新屏蔽字(2号信号位是1)// 用旧屏蔽字恢复,解除对2号信号的屏蔽sigprocmask(SIG_SETMASK, &oset, &set);}}return 0;
}
运行后你会发现:
- 前 10 秒内,就算按Ctrl + C(发 2 号信号),进程也死不了 —— 因为 2 号信号被屏蔽了,处于未决状态。
- 10 秒后,解除屏蔽,2 号信号立马递达,进程执行默认动作(终止),直接退出。
这就直观展示了:被阻塞的信号会 “憋着”,直到解除屏蔽才会被处理。
实战演示 2:未决信号的 “生灭过程”
再整个带信号捕捉的例子,看看未决信号是咋变化的:
#include <iostream>
#include <cassert>
#include <unistd.h>
#include <signal.h>
using namespace std;
// 打印未决信号的位图
static void PrintPending(const sigset_t &pending)
{cout << "当前进程的pending位图: ";for(int signo = 1; signo <= 31; signo++){if(sigismember(&pending, signo)) cout << "1";else cout << "0";}cout << "\n";
}
// 2号信号的自定义处理函数
static void handler(int signo)
{cout << "对特定信号:"<< signo << "执行捕捉动作" << endl;
}
int main()
{signal(2, handler); // 注册2号信号的捕捉函数sigset_t set, oset;sigemptyset(&set);sigemptyset(&oset);sigaddset(&set, SIGINT); // 2号信号加入屏蔽集sigprocmask(SIG_BLOCK, &set, &oset); // 阻塞2号信号int cnt = 0;while(true){sigset_t pending;sigemptyset(&pending);sigpending(&pending); // 获取未决信号集PrintPending(pending); // 打印sleep(1);if(cnt++ == 10){cout << "解除对2号信号的屏蔽" << endl;sigprocmask(SIG_SETMASK, &oset, nullptr); // 恢复屏蔽字}}return 0;
}
操作一下会看到清晰的过程:
- 前 10 秒,进程阻塞 2 号信号。此时按Ctrl + C,pending位图中 2 号信号的位置会从 0 变 1(信号来了但被憋着)。
- 10 秒后解除屏蔽,2 号信号立马递达,触发handler函数执行(打印捕捉信息)。
- 处理完成后,pending位图中 2 号信号的位置又从 1 变回 0(信号已处理)。
这完美展示了信号从产生(未决)到递达(处理)的完整生命周期。
四、信号捕捉
聊信号捕捉,绕不开 “内核地址空间” 这茬 —— 毕竟信号的那些数据都藏在 PCB 里,那可是内核的地盘,普通用户进程想直接上手处理?门儿都没有!得先搞明白进程的 “两种状态” 和 “地址空间划分”,不然信号捕捉的门道根本摸不透。
4.1、内核地址空间
先搞懂:用户态 vs 内核态,俩 “身份” 的区别
进程跑起来,其实有俩 “身份”:
- 用户态:进程自己玩自己的,跑的是用户写的代码(比如你的 main 函数、自定义函数),权限低,碰不到内核的东西(比如 PCB 里的信号数据)。
- 内核态:进程暂时 “交权” 给操作系统,跑的是 OS 的代码(比如系统调用、处理异常),权限高,能直接操作 PCB、信号位图这些内核数据。
说白了,用户态是 “自己过日子”,内核态是 “找 OS 帮忙办事”。信号相关的数据(pending、block、handler 这些)都在 PCB 里,属于内核的 “管辖范围”,所以处理信号这活儿,必须得在内核态下干。
关键时间点:从内核态返回用户态时,才查信号!
那啥时候处理信号呢?结论是:当进程从内核态返回用户态的那一刻。
为啥是这时候?因为进程进入内核态,要么是执行系统调用(比如 read、write),要么是触发了异常(比如除 0 错误),要么是被调度器 “打断”(比如时间片到了)。等内核把这些事儿办完了,准备回用户态继续跑自己的代码前,会顺带查一查:“哎,这进程有没有未决的信号?” 有就处理,没有就直接返回。
就像你去前台(内核态)办完事,临走前前台小姐姐(OS)问一句:“还有别的事儿吗?” 有就处理,没有就走人(回用户态)。
地址空间划分:每个进程都有 “私人空间” 和 “公共大厅”
进程的地址空间(32 位系统)是这么分的:
- [0, 3GB):用户态空间。每个进程的这块空间都是 “私人订制” 的,有自己的用户级页表,所以进程 A 和进程 B 的 0-3GB 内容不一样,各玩各的。
- [3GB, 4GB]:内核态空间。这块是 “公共区域”,所有进程共用同一张内核级页表,不管哪个进程,看 3-4GB 都是同一个操作系统。
打个比方:每个进程就像一套公寓,[0,3GB) 是自己的卧室、客厅(私人空间),[3,4GB] 是公寓的公共大厅(OS 就在这儿)。不管你进哪个公寓,公共大厅都是同一个,通过大厅能找到物业(OS)处理问题。
系统调用的本质:从 “私人空间” 到 “公共大厅” 串门
你以为系统调用是 “把进程发配到 OS 那里”?错了!其实 OS 就跑在进程自己的地址空间里。
系统调用的本质:进程从自己的用户态空间([0,3GB))跳转到内核态空间([3,4GB]),让 OS 的代码在自己的地址空间里执行,完事儿再跳回来。就像你在自己家,通过一扇门(系统调用入口)走到公共大厅找物业,物业处理完,你再回自己家继续待着。
汇编里的int 80指令(中断编号 80)就是那扇 “门”,一执行就从用户态切到内核态,让 OS 接手干活。
进程调度:OS 靠 “闹钟” 换进程干活
OS 本身就是个死循环,怎么切换进程?靠时钟硬件:每隔一小会儿(比如几毫秒),时钟硬件就给 OS 发个 “闹钟”(时钟中断)。
OS 一收到这闹钟,就知道该 “查岗” 了:看看当前进程的时间片用完没(通过schedule函数)。如果用完了,就把进程的上下文(寄存器、PC 指针这些)存起来,换个新进程上,把新进程的上下文恢复好。这就是进程调度的全过程 —— 本质是 OS 借着时钟中断,定期 “换班”。
啥时候会从用户态切到内核态?
简单说,三类情况:
- 执行系统调用:比如调用printf(底层会调write系统调用)、kill这些,主动找 OS 帮忙,会切到内核态。
- 处理异常:比如除 0 错误、段错误(非法访问内存),OS 得出来收拾烂摊子,被动切到内核态。
- 进程调度:时间片到了,时钟中断触发,OS 切换进程,这时候也会进入内核态。
这些行为都是 “用户态 -> 内核态” 的切换入口,而信号处理,就藏在 “内核态返回用户态” 的那个节点上 —— 这也是为啥必须先搞懂内核地址空间和状态切换的原因。
4.2、信号捕捉的 “操作手册”
信号捕捉,说白了就是告诉进程 “收到某个信号后,按咱自定义的逻辑来处理”。之前聊过 signal 函数,但它功能有限,真正 “专业级” 的操作还得看sigaction—— 这函数能细粒度控制信号处理的各种细节,堪称信号捕捉的 “瑞士军刀”。
往期推荐
手撕线程池:C++程序员的能力试金石
解构内存池:C++高性能编程的底层密码
Linux内核学习指南,硬核修炼手册
sigaction 函数:信号处理的 “配置中心”
先看sigaction的 “身份证”:
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oldact);
它的作用是:给signo这个信号 “配置处理规则”。参数说明白:
- act:非空的话,就用它里面的配置更新信号的处理动作(相当于 “新规则”)。
- oldact:非空的话,就把信号原来的处理动作存到这里(相当于 “旧规则备份”)。
- 返回值:成功 0,失败 - 1。
核心在于struct sigaction这个结构体,它是配置信号处理的 “核心配置表”:
struct sigaction {void (*sa_handler)(int); // 信号处理函数(和signal的handler一样)sigset_t sa_mask; // 处理信号时,额外要屏蔽的信号集int sa_flags; // 标志位(控制处理行为,一般设0就行)// 其他字段暂时忽略
};
关键机制:处理信号时,自动 “关门谢客”
这里有个超重要的规则:当信号处理函数(sa_handler)执行时,内核会自动把当前信号加入进程的屏蔽字。也就是说,处理某个信号时,就算再收到同一个信号,也会被阻塞,直到处理函数结束才恢复 —— 这就避免了 “信号处理函数还没跑完,又来一个同款信号” 的嵌套灾难。
打个比方:你正在屋里处理 “快递上门”(信号),这时候门铃(同一个信号)再响,你会先把门关上(屏蔽),处理完手头的再开门,免得手忙脚乱。
如果还想在处理期间 “禁止其他访客”(屏蔽更多信号),就用sa_mask字段 —— 把想额外屏蔽的信号加进去,处理函数执行时,这些信号也会被堵住,结束后自动恢复原来的屏蔽字。
实战 1:基础用法,给信号找个 “专属处理员”
先看个简单例子,用sigaction给 2 号信号(SIGINT)挂个自定义处理函数:
#include <iostream>
#include <cstring>
#include <cassert>
#include <unistd.h>
#include <signal.h>
using namespace std;
// 自定义处理函数:收到信号就喊一声
static void handler(int signo)
{cout << "对特定信号:"<< signo << " 执行捕捉动作" << endl;
}
int main()
{struct sigaction act, oldact;memset(&act, 0, sizeof(act)); // 初始化配置表memset(&oldact, 0, sizeof(oldact));act.sa_handler = handler; // 绑定处理函数act.sa_flags = 0; // 标志位设0(默认行为)sigemptyset(&act.sa_mask); // 初始不额外屏蔽信号// 给2号信号设置新处理规则,同时备份旧规则到oldactsigaction(2, &act, &oldact);while(true) sleep(1); // 死循环等信号return 0;
}
跑起来后按Ctrl + C(发 2 号信号),就会触发handler打印信息,而不是默认终止 —— 这说明信号捕捉成功了。
实战 2:sa_mask 显神通,处理时 “屏蔽其他信号”
再整个例子,看看sa_mask的作用。比如处理 2 号信号时,额外屏蔽 3、4、5 号信号:
#include <iostream>
#include <cstring>
#include <cassert>
#include <unistd.h>
#include <signal.h>
using namespace std;
// 打印未决信号位图
static void PrintPending(const sigset_t &pending)
{cout << "当前进程的pending位图: ";for(int signo = 1; signo <= 31; signo++){if(sigismember(&pending, signo)) cout << "1";else cout << "0";}cout << "\n";
}
// 2号信号的处理函数:执行时会持续30秒,期间打印未决信号
static void handler(int signo)
{cout << "对特定信号:"<< signo << " 执行捕捉动作" << endl;int cnt = 30;while(cnt--){sigset_t pending;sigemptyset(&pending);sigpending(&pending); // 获取未决信号PrintPending(pending);sleep(1);}
}
int main()
{struct sigaction act, oldact;memset(&act, 0, sizeof(act));memset(&oldact, 0, sizeof(oldact));act.sa_handler = handler;act.sa_flags = 0;sigemptyset(&act.sa_mask);// 额外屏蔽3、4、5号信号sigaddset(&act.sa_mask, 3);sigaddset(&act.sa_mask, 4);sigaddset(&act.sa_mask, 5);sigaction(2, &act, &oldact); // 给2号信号设规则while(true) {cout << getpid() << endl; // 打印pid,方便发信号sleep(1);}return 0;
}
操作一下看看效果:
- 启动程序,先用kill -2 进程pid发 2 号信号,触发handler执行。
- 这期间,用kill -3 进程pid、kill -4 进程pid发 3、4 号信号 —— 观察打印的 pending 位图,会发现 3、4 号信号的位变成 1(被阻塞,处于未决状态)。
- 等 30 秒后handler执行完,3、4 号信号的位变回 0(被递达处理,这里它们没有自定义处理函数,会执行默认动作,比如 3 号信号默认会终止进程)。
这就直观说明了:sa_mask里的信号,在处理函数执行期间会被额外屏蔽,直到处理结束才 “放行”。
五、信号处理的同步化
异步信号变成同步信号(那不可能),而是把信号的处理方式从 “突然袭击” 改成 “主动等待”,让处理时机变得可控。这就好比把 “突然上门的快递” 变成 “预约送货上门”,你啥时候有空啥时候接,踏实多了。
5.1、信号等待:“我就在这等着,信号来了再干活”
想让信号处理变同步,第一种思路是 “主动等信号”—— 线程停下来等,直到信号来了并处理完,再继续往下走。负责这事儿的有两个函数:pause和sigsuspend。
pause 函数:最简单的 “死等”
#include <unistd.h>
int pause(void);
这函数功能特直接:让当前线程进入休眠状态,啥也不干,直到有信号过来并且被处理完(不管是默认动作、忽略还是自定义处理),它才会返回。返回值呢?永远是 - 1(因为它被信号唤醒时,会设置 errno 为 EINTR)。
打个比方:pause就像你躺在沙发上闭眼休息,不管谁敲门(信号来了),你处理完(开门、签收快递),才会睁开眼继续休息 —— 本质是 “等信号触发处理,处理完再醒”。
sigsuspend 函数:“只等特定信号,其他的别烦我”
pause太 “佛系” 了,啥信号都能叫醒它。如果想 “挑信号等”,就得用sigsuspend:
#include <signal.h>
int sigsuspend(const sigset_t *mask);
它的玩法是:先把当前的信号屏蔽字换成mask里的(临时生效),然后让线程休眠,直到有信号过来。这个信号得满足两个条件:要么不在mask的屏蔽集里(能递达),要么就算在屏蔽集里但处理动作是终止进程(直接让进程没了,不用返回)。等信号处理完,sigsuspend会恢复原来的屏蔽字,然后返回 - 1(同样设 errno 为 EINTR)。
举个例子:你想等 2 号信号(SIGINT),但不想被 3 号信号(SIGQUIT)打扰,就可以在mask里屏蔽 3 号信号。这样sigsuspend期间,只有 2 号信号能叫醒你,3 号信号会被挡住,等你醒了再算。
sigsuspend比pause灵活多了,相当于 “定制化等待”,只接你想接的信号,其他的 “免打扰”。
5.2、信号截获:“信号我直接拦下,不按默认流程走”
另一种更主动的同步化思路是 “信号截获”:不等信号走默认的处理流程(递达、执行 handler 等),而是自己主动等信号,截获之后按自己的逻辑处理,信号本身不会触发原来的处理动作。就像快递员刚到小区门口,你直接冲过去把快递接了,不用等门铃响(默认处理)。
负责截获的函数有四个,先聊最常用的三个:
sigwait:“我等信号,截到了告诉我编号”
#include <signal.h>
int sigwait(const sigset_t *restrict set, int *restrict sig);
- set:你想等的信号集(比如里面有 2 号、3 号信号)。
- sig:输出参数,截获到的信号编号会存在这里。
- 返回值:成功 0,失败非 0。
调用后,线程会阻塞等待,直到set里的某个信号产生,然后sigwait会把这个信号从 “未决状态” 里清掉(相当于 “截胡”),并通过sig告诉你 “截到的是几号信号”,之后你就可以在当前线程里直接处理这个信号了(不用写 handler)。
关键是:被 sigwait 截获的信号,不会触发它原来的处理动作(不管是默认还是自定义 handler),相当于信号被 “偷” 过来了。
sigwaitinfo:“截获信号,顺便告诉我更多细节”
#include <signal.h>
#include <time.h>
int sigtimedwait(const sigset_t *restrict set, siginfo_t *restrict info, const struct timespec *restrict timeout);
和sigwaitinfo功能差不多,但多了个timeout参数:如果过了指定时间还没等到信号,就直接返回(errno 设为 EAGAIN)。适合 “不能无限等” 的场景,比如 “等 5 秒,没来信号就不等了,先干别的”。
signalfd:“把信号变成文件描述符,用 IO 操作搞定”
这个函数更绝,直接把信号和文件描述符(fd)绑在一起,让信号处理能融入 IO 多路复用(select/poll/epoll)的流程:
#include <sys/signalfd.h>
int signalfd(int fd, const sigset_t *mask, int flags);
- mask:你想关注的信号集。
- fd:如果是 - 1,就创建一个新的 signalfd;如果是已有的 signalfd,就修改它关注的信号集。
- 返回值:创建或修改后的文件描述符。
有了这个 fd,你就可以像读文件一样 “读信号”:每次有信号产生,read(fd, buf, sizeof(...))就会返回,buf里存的是struct signalfd_siginfo结构体(包含信号编号、发送者 PID 等信息)。更妙的是,这个 fd 可以放进 select/poll/epoll 的监听集合里,和其他 IO 事件(比如网络数据、文件可读)一起等 —— 这下信号处理和 IO 处理就能 “一锅烩” 了,特方便。
同步化处理的好处:踏实、灵活、能调用更多函数
为啥费劲把异步信号处理搞成同步的?好处太多了:
- 时机可控:不用怕信号突然打断关键操作(比如修改全局变量时被信号插一脚),啥时候处理信号你说了算。
- 函数自由:异步信号的 handler 里很多函数不能用(怕重入),但同步处理时(比如 sigwait 之后的代码),基本啥函数都能调用,不用束手束脚。
- 融入 IO 框架:像 signalfd 这样的接口,能让信号处理和 select/epoll 无缝结合,不用单独开线程处理信号,代码更简洁。
往期推荐
手撕线程池:C++程序员的能力试金石
解构内存池:C++高性能编程的底层密码
Linux内核学习指南,硬核修炼手册