
🔥个人主页:爱和冰阔乐
📚专栏传送门:《数据结构与算法》 、C++
🐶学习方向:C++方向学习爱好者
⭐人生格言:得知坦然 ,失之淡然

🏠博主简介

文章目录
前言:先解决一个混淆度拉满的问题
信号和信号量有关系吗? 没有任何关系,就是"老婆"和"老婆饼"的区别。信号量是进程同步互斥那套机制,而信号,解决的是另一件事:事件的异步通知。
生活里的信号到处都是:闹钟、红绿灯、上课铃声、狼烟。你在过马路,红灯亮了,要停下来等待——人相当于进程,正在执行"过马路"这段代码时,信号来了,就要中断当前正在做的事。
什么叫做信号:是一种事件的异步通知机制,是发给进程的。
异步怎么理解?用小明取快递说清楚:
- 老师正在讲课,快递员打电话说快递到了,老师让全班自习,等小明回来再继续讲——这是同步,取快递把讲课"卡住"了;
- 老师继续讲课,小明自己去取,讲课和取快递同一时刻同时发生,互不干扰——这是异步。
小明在睡觉,闹钟同时在计时,两者互不干扰,闹钟响了才通知小明。所以信号的产生相对于进程的运行,是异步的,而且信号是发给进程的。
还有两个更关键的点。小明闹钟还没响的时候,他就已经知道"闹钟响了要起床";还没打上课铃,就知道铃响了要上课;红绿灯还没变红,就知道变红不能过马路。这些是谁教的?父母、老师、之前的经验。进程也一样——进程是 OS 程序员设计的,识别和处理信号的方式早就内置在进程里了。
闹钟响了但起不来,可以等会再起;外卖小哥打电话,我正在打游戏出不去,可以等会去拿。
所以基本结论四条:
- 信号的处理,进程在信号还没产生的时候,早就知道该如何处理了;
- 信号的处理不是立即处理,可以等一会,在合适的时候处理;
- 进程能识别信号,是提前被"教育"过的——内置的识别和处理方式;
- 信号源非常多,给进程产生信号的途径不止一种。
这一篇就回答一个问题:信号从哪里来。从最熟悉的 Ctrl+C 开始,一路过掉 signal、前后台进程、kill、raise、abort、硬件异常、alarm,最后看看内核怎么管理一堆闹钟。
一、键盘产生信号:Ctrl+C
先写个最简单的死循环:
int main()
{
int cnt = 0;
while (true)
{
std::cout << "hello world," << cnt++ << std::endl;
sleep(1);
}
}
程序会一直跑,我们在命令行按 Ctrl+C 直接把它中断。

Ctrl+C 就是给目标进程发送信号。 键盘就相当于闹钟,我们按下 Ctrl+C 相当于闹钟响了;进程收到信号,默认处理动作就是让自己终止——就像我听到闹钟默认动作是起床。
相当一部分信号的处理动作都是终止,还有一些是让进程暂停。
1.1 信号都有哪些?
kill -l

注意看编号:31 之后直接跳到 34,少了 32 和 33,所以 Linux 一共 64 - 2 = 62 个信号。其中:
- 1-31:普通信号,我们只讨论这些;
- 34-64:实时信号,本文不考虑。
什么是普通信号?可以不被立即处理的信号——就像外卖到了等会去拿。实时信号就是需要立即处理的信号。
信号在计算机里就是一个整数。发信号可以直接发数字,但数字可读性差,所以系统把这些数字定义成了大写的宏,宏对应的值就是数字,比如 SIGINT 就是 2。写代码时用宏,别裸写数字。
二、进程收到信号的三种处理动作
- 默认处理动作:比如终止进程;
- 自定义处理动作:执行用户自己写的方法。就像小红和朋友打赌输了,约定"红绿灯变红时在原地跳舞"——红灯亮了,别人在等待,小红在跳舞;
- 忽略处理:红灯亮了,小明不管,继续过马路,赌车不敢撞。
再比如肚子饿了(信号产生):立马去吃饭是默认;听说喝西北风能喝饱、去喝西北风是自定义;肚子叫了不管它、继续做事是忽略。
一个术语要先说清:我们把信号处理的过程整体叫做信号捕捉(信号处理)。不只是自定义才叫捕捉——默认处理和忽略处理也都是信号捕捉!
2.1 那么多信号默认动作都是终止,为什么还要分那么多种?
虽然很多信号默认动作都是终止,但它们触发的来源、代表的事件完全不一样。信号不是为了"杀进程"而设计的,本质是内核给进程传递不同类型事件的通知机制,终止只是事件发生后的默认结局。
类比医院的三种警报:
- 火警警报 → 默认行为:全员撤离(终止)
- 毒气泄漏警报 → 默认行为:全员撤离(终止)
- 爆炸警报 → 默认行为:全员撤离(终止)
结局都是撤离,但三种警报代表的事故完全不同。
| 信号 | 触发事件 | 默认动作 |
|---|---|---|
| SIGINT 2 | 用户按 Ctrl+C,终端主动打断 | 终止 |
| SIGKILL 9 | 外部 kill -9 强制杀掉 | 终止 |
| SIGTERM 15 | 普通 kill 命令,礼貌请求退出 | 终止 |
| SIGALRM 14 | alarm 闹钟时间到 | 终止 |
| SIGPIPE 13 | 往已关闭读端的管道写数据 | 终止 |
默认行为只是兜底方案,真正价值是把"发生了什么事"通知进程。
2.2 为什么有的程序不能说死就死
想象一个程序:打开了文件正在写磁盘、内存里有缓冲区没刷下去、创建了子进程、申请了锁和共享内存等 IPC 资源。这时候直接执行默认终止——进程瞬间消失:
- 内存缓冲区的数据直接丢,文件损坏;
- 子进程变孤儿;
- IPC 锁没人释放,别的进程拿不到锁直接卡死。
所以大量程序需要自定义 SIGINT 处理:捕获 2 号信号,不立刻死,先刷缓存、关文件、释放锁、回收子进程,做完所有善后再自己 exit 退出。
三、signal——更改进程的默认处理动作
Ctrl+C 发的是几号信号?怎么证明?改掉进程对这个信号的默认处理动作,看它收到的是几号。用系统调用 signal:
sighandler_t signal(int signum, sighandler_t handler);
- 参数 1
signum:普通信号对应的数字或宏; - 参数 2:typedef 出来的函数指针类型,传入对应的方法。

把 SIGINT 转到定义,会发现它确实就是个宏:

代码演示:
// 传入的参数是执行该函数时进程收到的信号值
// signal 接口会将信号编号传给这个 sig 参数
void handlerSig(int sig)
{
std::cout << "获得了一个信号:" << sig << std::endl;
}
int main()
{
// 注意:只需要调用一次,不需要循环调用
signal(SIGINT, handlerSig);
int cnt = 0;
while (true)
{
std::cout << "hello world," << cnt++ << std::endl;
sleep(1);
}
}

一直 Ctrl+C,进程不再终止,而是反复执行 handlerSig——这就证明了 Ctrl+C 是给进程发送 2 号信号。
两个细节:
- 如果这种写法导致进程退不出去,用 Ctrl+\ 终止,它发的是 3 号信号;
- signal 只是提前注册方法,如果信号一直不来,这个函数永远不会被调用。
那几十个信号的默认动作怎么查?
man 7 signal

Action 列就是处理动作:Term 终止、Core 终止并 core dump、Ign 忽略、Stop 暂停、Cont 从暂停处继续。Core 的特殊性下一篇细说。
四、前台进程与后台进程
跑着上面那个死循环时,你会发现输入 ls、pwd 没有任何反应:

给进程后面加个 &:
./testsig &
这时 Ctrl+C 杀不掉它了,但 ls、pwd 又能正常执行:

- 命令行直接
./xxx:前台进程; ./yyy &:后台进程。
为什么?我们登录 Linux 时,系统创建的 bash 进程默认在前台等着接键盘输入。运行命令时 bash 创建子进程,子进程默认成为前台进程,bash 自动被放到后台。所以我们的程序在前台时,输入的 ls、pwd 是交给它的——它没实现这些指令,自然没反应。
键盘只有一个,输入数据必须给一个确定的进程。bash 要求前台进程只能有一个,后台可以有多个。前台进程的本质就是从键盘获取数据。 而无论前台后台,都可以向显示器打印。
键盘产生的信号,只能发给前台进程,所以 Ctrl+C 对后台进程无效。后台进程用命令杀:
ps ajx | head -1 && ps ajx | grep testSig | grep -v grep
kill -9 PID


9 号信号就是 SIGKILL,默认动作终止进程。
孤儿进程的结论也能对上了:父进程在时,父子都在前台,Ctrl+C 杀得掉;父进程退出后子进程被 1 号进程领养,所以孤儿进程 Ctrl+C 杀不掉。
4.1 jobs、fg、bg、Ctrl+Z
jobs # 查看后台任务
fg 任务号 # 把后台任务提到前台
bg 任务号 # 让后台停止的任务恢复运行
Ctrl+Z 的本质要搞清楚:不是"把进程放到后台",而是给前台进程发 SIGTSTP 信号让它暂停。前台进程不能被暂停——前台永远要接收用户输入,一旦暂停用户按键盘就没反应了——所以进程一暂停,就被自动提到后台,Shell 重新接管命令行。


到这里,"Ctrl+C 是给目标进程发送信号"里的目标进程指什么就很清楚了:前台进程。
五、发送信号的本质:OS 修改位图
信号可以等一会再处理——外卖到了在打游戏,等会去取。但如果忘了呢?那就永远不处理了。所以人必须把要处理的事记录下来,进程也必须把信号记录下来。
还有一个硬性规则:进程不能收到信号立刻处理,只能在"内核态→用户态切换的时机"处理,这是 Linux 内核定的,第三篇细讲。
信号记录在哪?信号是发给进程的,自然存在进程的控制块 task_struct 里。多个信号怎么存?位图:比特位的位置是信号编号,内容 0/1 表示是否收到。
struct task_struct
{
unsigned int sigs; // 教学模型:一个位图
}
于是发送信号的本质浮出水面:向目标进程写信号 = 修改位图。修改位图需要 pid + 信号编号:前台进程唯一确定,只要编号;后台进程就是 kill -9 PID 这种编号加 pid。
关键来了:task_struct 是 OS 内核的数据结构,写信号就是修改内核数据。普通用户不能改内核数据,只有 OS 自己能改。 所以不管产生信号的方式有多少种,底层必须由 OS 来发送——操作系统必须提供发送信号的系统调用,这就是 kill 存在的原因:
int kill(pid_t pid, int sig);

键盘按 Ctrl+C 同理:OS 是硬件的管理者,组合键一定先被 OS 识别,再由 OS 修改前台进程的位图。
5.1 信号和 IPC 有关系吗?
狭义上,IPC 是进程间通信,数据从用户到用户;信号是 OS 和进程之间的关系。但广义上,信号传递了事件信息,可以归入通信范畴。
5.2 自己写个 mykill
产生信号的第二种方式:系统调用。写个简化版 kill 命令:
// 使用方式:./myskill signumber pid
int main(int argc, char* argv[])
{
if (argc != 3)
{
std::cout << "格式应为:./myskill signumber pid" << std::endl;
return 1;
}
int signum = std::stoi(argv[1]); // 信号编号:字符转整数
pid_t target = std::stoi(argv[2]); // pid:字符转 pid_t
int n = kill(target, signum);
if (n == 0)
std::cout << "发送" << signum << "给" << target << "成功了!" << std::endl;
return 0;
}

kill 命令底层调用的就是这个 kill 函数——命令和系统调用接口是两回事,一个是命令行工具,一个是函数。
5.3 9 号和 19 号:捕捉不掉的底线
把 1-31 号信号的默认动作全改掉:
void handlerSig(int sig)
{
std::cout << "获得了一个信号:" << sig << std::endl;
}
for (int i = 1; i < 32; i++)
signal(i, handlerSig);
键盘、系统调用都杀不掉它了。要是病毒也这么干,电脑不就瘫痪了?kill -9 依然能杀:


大部分信号可以被自定义捕捉,但 9 号(SIGKILL)和 19 号(SIGSTOP)不能被自定义捕捉——这是系统防止恶意进程的底线。
六、raise 与 abort:给自己发信号
raise:给当前进程发送指定信号(自己给自己发)。
int raise(int sig);

for (int i = 1; i < 32; i++)
{
sleep(1);
raise(i);
}

abort:使当前进程异常终止,相当于给自己发 6 号信号(SIGABRT)。
void abort(void);

奇怪的事来了:前面 raise 循环时 6 号信号被自定义捕捉了,进程没退出;换成 abort,即使捕捉了 SIGABRT,进程还是退出了(Aborted):

abort 的特殊之处:它要求进程必须处理这个信号,内部会把自定义捕捉恢复成默认,保证最终形成异常终止语义。
七、硬件异常产生信号
程序崩掉的两大常客:除 0 和野指针。
void handlerSig(int sig)
{
std::cout << "获得了一个信号:" << sig << std::endl;
exit(13);
}
int main()
{
for (int i = 1; i < 32; i++)
signal(i, handlerSig);
int cnt = 0;
while (true)
{
sleep(1);
std::cout << "hello world," << cnt++ << ",pid:" << getpid() << std::endl;
int a = 10;
a /= 0; // 除 0 错误
}
}
进程收到 8 号信号(SIGFPE,浮点数错误),挂掉了:

换野指针,收到 11 号信号(SIGSEGV,段错误):
int *p = nullptr;
*p = 100;

这就解释了程序为什么会"崩"——因为进程收到了信号。而且再次验证:信号全部由 OS 发送,OS 识别出进程犯错的类型,给目标进程发对应信号。
那 OS 怎么知道进程犯错了?
除 0:计算在 CPU 上跑,CPU 有各类寄存器,其中**状态/标志寄存器(EFLAGS)**由 32/64 个比特位组成,有一个比特位专门表示当前计算是否溢出。CPU 寄存器保存的是当前进程的上下文,CPU 是硬件,OS 是软硬件资源的管理者——程序出错,硬件上先体现为溢出标志被置位,OS 识别到,给目标进程发 8 号信号。
野指针:访问空指针就是访问 0 号虚拟地址,0 号地址在页表里不存在映射。CPU 寄存器拿到的都是虚拟地址,CR3 寄存器记录当前进程页表的起始地址;CPU 内部有 MMU 硬件单元,虚拟地址交给 MMU、CR3 的内容也交给 MMU,做虚拟到物理的转换。MMU 转换失败(查页表查不到),硬件报错,OS 中断它,给进程发 11 号信号。
八、软件条件产生信号
进程间管道通信:A 进程往管道写,B 进程不但不读,还把读端关了——OS 识别到这种软件层面的错误,给进程发 SIGPIPE。OS 不做任何浪费时间和空间的事情:读端都没了,写端还留着干嘛。
软件条件里更重要的是 alarm。
8.1 alarm:设置闹钟
unsigned int alarm(unsigned int seconds);
告诉内核 seconds 秒之后给当前进程发 SIGALRM(默认终止当前进程)。

返回值是最容易搞混的点:返回 0 或以前设定的闹钟还余下的秒数。
打个比方:某人小睡,定闹钟 30 分钟后响;20 分钟后被人吵醒,想再睡会,重新设了 15 分钟——"以前设定的闹钟还余下的时间"就是 10 分钟。如果 seconds 为 0,表示取消以前设定的闹钟,返回值仍然是之前闹钟的余下秒数。
- 第一次调
alarm(5):返回 0; - 3 秒后再调
alarm(10):返回 2(上一个闹钟还剩 2 秒)。
8.2 用 alarm 掂一掂 IO 的斤两
void handlerSig(int sig)
{
std::cout << "获得了一个信号:" << sig << std::endl;
exit(13);
}
int main()
{
for (int i = 1; i < 32; i++)
signal(i, handlerSig);
alarm(1); // 1 秒后收到信号
int cnt = 0;
while (true)
{
std::cout << "count:" << cnt++ << std::endl;
}
}

1 秒打印 4 万次左右,效率很低。因为打印本质是 IO:xshell 获取命令到云服务器运行,运行结果还要通过网络发回显示端。
把打印换成纯计算:
int cnt = 0;
void handlerSig(int sig)
{
std::cout << "获得了一个信号:" << sig << "cnt:" << cnt << std::endl;
exit(13);
}
int main()
{
signal(SIGALRM, handlerSig);
alarm(1);
while (true) cnt++;
}

这是 5 亿次。 纯计算用 CPU,打印要走外设、走网络——冯诺依曼体系决定的,外设效率远低于 CPU。这两张图放一起,IO 慢在哪里不用背了。
8.3 闹钟驱动:每隔一秒执行任务
handler 里重新 alarm(1),闹钟就变成周期性的:
void handlerSig(int sig)
{
std::cout << "获得了一个信号:" << sig << "pid:" << getpid() << std::endl;
alarm(1); // 重新设闹钟,周期触发
}
int main()
{
signal(SIGALRM, handlerSig);
alarm(1);
while (true)
{
std::cout << ".," << "pid:" << getpid() << std::endl;
sleep(1);
}
}

pid 一直是同一个——同一个进程每隔一秒收到信号,执行自定义捕捉。
8.4 pause 与"信号驱动的进程"
想让进程平时什么都不做,一来信号就被唤醒执行方法?pause:等待信号,没有信号时进程暂停。
int pause(void);

把一组周期任务挂到闹钟上:
void Sched() { std::cout << "我是一个进程调度" << std::endl; }
void MemManger() { std::cout << "我是周期性的内存管理,正在检查有没有内存问题" << std::endl; }
void Fflush() { std::cout << "我是刷新程序,我在定期刷新内存数据到磁盘" << std::endl; }
using func_t = std::function<void()>;
std::vector<func_t> funcs;
void handlerSig(int sig)
{
std::cout << "############################" << std::endl;
for (auto f : funcs) f();
std::cout << "#############################" << std::endl;
alarm(1);
}
int main()
{
funcs.push_back(Sched);
funcs.push_back(MemManger);
funcs.push_back(Fflush);
signal(SIGALRM, handlerSig);
alarm(1);
while (true) pause(); // 平时暂停,被信号驱动
}
进程平时暂停,在外部的催促下每隔一秒被信号唤醒执行任务——这就是操作系统的样子。OS 一开机就是个死循环,它不是自己主动跑起来的,而是不断被外部刺激(时钟中断)催着,定期执行自己注册的方法。
8.5 内核怎么管理一堆闹钟
进程被 OS 调度,那 OS 自己被谁驱动?定期的外部刺激:时钟中断。OS 内部有大量闹钟,既然多,就得先描述、再组织:
struct timer_list {
struct list_head entry;
unsigned long expires; // 过期时间
void (*function)(unsigned long); // 闹钟要执行的方法
unsigned long data;
struct tvec_t_base_s *base;
};
组织方式可以按最小堆理解:堆顶就是超时时间最近的闹钟。OS 只拿堆顶的超时时间和当前时间比,当前时间一到,取出堆顶、堆自动调整,同时执行函数指针对应的方法——向目标进程发送 SIGALRM。
闹钟是软件实现的,超时就是软件条件,这就是"软件条件产生信号"。OS 的时间戳可以理解成一个计数器,被时钟中断刺激一次加一次,计数器的值就代表开机到现在多久了。
总结
信号产生的五种方式,总结收尾:
键盘(Ctrl+C/Ctrl+\)
系统调用(kill/raise/abort)
系统命令(kill -9)
硬件异常(除0 → SIGFPE,野指针 → SIGSEGV)
软件条件(SIGPIPE/alarm 超时)
↓
全部由 OS 发送
↓
本质:修改目标进程 task_struct 里的位图
不管来源是什么,发信号的动作永远由 OS 完成,本质都是修改比特位。进程收到后先记下来,合适的时候再处理。
那"记下来"具体记在哪、怎么记?为什么信号还能被"阻塞"?SIGSEGV 为什么有的机器上会生成 core 文件?下一篇从 pending、block 讲到 Core Dump。
觉得有帮助的话点个赞收藏,评论区聊聊你第一次见 abort 捕捉后还是退出时的表情。
资源分享:
【Linux】共享内存为什么快?System V 的 key、shmget、shmat 与进程通信实战
【Linux】两个毫无关系的进程怎么通信?命名管道 FIFO 从原理到 Server/Client 实战
【Linux】从匿名管道到进程池:任务派发、fd 继承 Bug 与完整实现
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2402_87731470/article/details/163795486




