Linux信号机制(上)
本文介绍了Linux信号的基本概念与产生方式。信号是操作系统向进程发送的异步事件通知机制,类似于生活中的各种提醒信号。主要内容包括:1. 信号概念:通过生活类比解释信号是异步事件通知,进程可以预先定义处理方式(默认、自定义或忽略)。2. 信号产生方式:- 键盘输入(如Ctrl+C产生SIGINT)- 系统调用(kill、raise、abort等)- 硬件异常(如除零错误产生SIGFPE,段错误产生
本篇目标:
1. 掌握Linux信号的基本概念。
2. 掌握信号产⽣的⼀般⽅式。
一.初步认识信号
1.生活角度认识
首先要说明一点的是信号和信号量就如同老婆和老婆饼一样,没有任何关系。
在生活中,我们也会遇到各种各样的信号,例如:
闹钟响了,我们知道要起床了。
红绿灯,红灯亮了,我们知道要在原地暂停等绿灯。
上课铃声,铃声响了,学生知道改上课了。
古代的狼烟,点燃了狼烟,可能就是有敌人来进攻了。
敲门声,敲门声响了,我们知道门外有了,要去开门了...
那么举了这么多的例子,什么是信号呢?
在生活中,当某个信号产生时(例如电话铃声、门铃声、火灾警报),会打断我们正在做的事情,通知我们有新的事件发生,这是一种事件的异步通知机制。
对应到操作系统:
在操作系统中,信号(Signal)是一种向进程发送事件通知的机制。当某个事件发生时,操作系统会向目标进程发送一个信号,通知进程进行相应处理,这同样是一种异步通知机制。
那么什么是异步机制呢?
假设小李是一名程序员。某天项目经理发现自己的快递到了,于是让小李帮忙去取。
如果项目经理要求其他员工暂停手头工作,等小李取完快递回来后再继续工作,那么大家都需要等
待小李完成任务,这就是同步机制。
如果项目经理让其他员工继续处理自己的工作,不必等待小李回来,那么小李取快递和其他员工工
作可以同时进行,彼此互不影响,这就是异步机制。
简单来说:同步需要等待,异步无需等待。
2.基本结论
然后这里我就先接合生活中的例子,给出几个基本的结论:
• 假如小李在网上买了很多件商品,正在等待不同商品快递的到来。但是即便快递没有到来,小李
也知道快递来临时,该怎么处理快递,也就是说小李能“识别快递”,所以在操作系统中,进程在信
号还没有产生的时候,早就知道信号该如何处理了。
• 当快递员到了小李的楼下时,小李也收到了快递到来的通知,但是小李正在打游戏,需要5min之
后才能去取快递。 那么在这5min之内,小李并没有下去去取快递,但是小李是知道有快递到来了
的,也就是是说取快递的行为并不是⼀定要立即执行,可以理解成“在合适的时候去取”,也就是
说:人必须要把处理的事情记录下来,那么在操作系统中,信号的处理并不是立即处理的,而是可
以等一会再处理,在合适的时候进行信号处理。
• 在收到通知,再到小李拿到快递期间,是有⼀个时间窗⼝的,在这段时间,小李并没有拿到快
递,但是小李知道有⼀个快递已经来了。本质上是小李“记住了有⼀个快递要去取”
• 小李买了顺丰,圆通,京东等不同地方的快递,所以给他发的通知也非常的多,所以在操作系统
中信号源非常多,给进程产生信号的信号源也非常多
• 当小李时间合适,顺利拿到快递之后,就要开始处理快递了,而处理快递⼀般⽅式有三种:1.执
行默认动作(幸福的打开快递,使用商品)2.执行自定义动作(例如快递是零食,小李要送给他的
女朋友) 3. 忽略快递(快递拿上来之后,扔掉床头,继续开⼀把游戏),那么在操作系统中,进
程收到信号后,在合适的时候处理信号的动作有三种:
<1>.默认处理动作
<2>.自定义处理动作,也叫做信号捕捉。
<3>.忽略处理
• 快递什么时候到达是不可预测的,小李也不会一直盯着手机等待电话,而是在正常生活过程中突
然收到通知。同样,进程无法准确预测信号何时产生,因此信号是一种典型的异步事件通知机制。
总结:
信号是一种异步事件通知机制。进程预先定义好信号的处理方式,当信号产生后,内核会先记录该信号,并在合适的时机通知进程执行对应的处理动作。
二.信号的产生
1.键盘产生信号
<1>.看见信号
想要了解信号的产生,我们需要先在操作系统中看到这些信号,操作如下:
kill -l
如图所示:

这时有人看见有整整64个信号,可能就会产生疑问:这么多,还全是英文,那我该咋记?
对于这个问题,我的答案是:
其实这些英文都是由宏定义的,其实都是一些数字,用英文是为了好看些,并且信号也不用记,也
没必要记。
学习 Linux 信号,就像学习英语单词一样,你不可能第一天就把所有单词背下来,而是在后面的学
习和使用过程中不断接触、不断重复,最后自然而然就记住了。
实际开发中,经常接触的信号其实并不多,编号34以上的是实时信号,本篇仅讨论编号34以下的
信号,不讨论实时信号,这些信号各自在什么条件 下产⽣,默认的处理动作是什么,在signal(7)中
都有详细说明: man 7 signal,如图:

常见的例如:
SIGINT Ctrl+C产生的中断信号 SIGQUIT Ctrl+\产生的退出信号 SIGKILL 强制终止进程 SIGTERM 终止进程 SIGSEGV 非法内存访问(段错误) SIGCHLD 子进程退出
更重要的是,我们学习信号时关注的重点并不是:
这个信号叫什么名字?
这个信号编号是多少?
而是:
这个信号是怎么产生的?
它通知了什么事件?
收到之后会发生什么?
事实上,Linux 的很多信号都对应着我们日常使用 Linux 时的某个操作。接下来,我们就先从最熟
悉的一个信号开始认识。比如,当我们按下 Ctrl+C 时,到底发生了什么?
例如,在sig.cpp目录下,我们写了如下的代码:
#include <iostream>
#include <unistd.h>
int main()
{
while(true)
{
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
return 0;
}
makefile中的代码:
sig:sig.cpp
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -rf sig
当我们编译成sig时再执行程序,然后再按下ctrl+c,结果如图:

我们会发现,按下ctrl+c时,程序居然将死循环给暂停了,这是为什么呢?
原因:按下 ctrl+c后 ,这个键盘输入产生⼀个硬件中断,被OS获取,解释成信号,发送给目标
前台进程,也就是说ctrl+c是给目标进程发送信号的,并且相当一部分信号的处理动作就是让自己
终止。
<2>.如何证明产生信号?
你说产生了信号,那么你该如何证明信号产生了呢?也就是说:我想要看到信号处理的过程,因此
我们可以尝试更改进程的默认信号处理动作,这里需要用到一个系统函数。
我们在终端里面用man signal即可看见,如图所示:

参数说明:
signum :信号编号。
handler :函数指针,表示更改信号的处理动作,当收到对应的信号,就回调执行 handler 方法。
其实, Ctrl+C 的本质是向前台进程发送 SIGINT 即 2 号信号,我们证明⼀下,这里需要在代码里
面引入⼀ 个系统调用函数,如代码所示:
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
std::cout << "我是: " << getpid() << ",我获得了⼀个信号: " << signumber << std::endl;
}
int main()
{
std::cout << "我是进程: " << getpid() << std::endl;signal(SIGINT , handler);
while (true)
{
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
return 0;
}
当我们编译成sig时再执行程序,然后再按下ctrl+c,结果如图:

我们居然发现按下ctrl+c不再是停止,而是打印我是: 3983476,我获得了⼀个信号: 2,这也确实证明了ctrl+c是停止进程的信号,可目前的问题是:我该如何终止这个循环呢?其实我们还可以用ctrl+\来终止,这也是个信号。
这里其实可以引出一个很重要的结论:
一个进程可以同时收到很多种不同的信号,而我们只修改了其中某一个信号的处理方式,并不会影响其他信号。
<3>.目标进程
这时,细心的读者可能注意到一个术语:"目标进程",这究竟指的是什么呢?
那么这里就要从前后台进程讲起了
1.前台进程
形如./XXX,例如:./sig,操作,如图:
可以发现当处在前台进程时,我们的ls,ll,等命令是无法进行的。
特点:前台进程能够从终端获取标准输入(stdin),因为一个终端对应的键盘输入在同一时刻只
能交给一个确定的进程组处理,所以一个终端在同一时刻只能有一个前台进程组所以前台进程的本
质就是从键盘获取数据的。
2.后台进程
形如./XXX,例如:./sig &,操作如图所示:

可以发现此时我们执行ls,ll等命令时是可以正常执行的。
特点:后台进程是无法从标准输入中获取内容的,并且后台进程可以有多个。
不过,如何终止这个循环确实是个问题。幸运的是,程序员们早已为我们准备好了解决方案。
首先用在当前终端输入fg,将当前后台进程变为前台进程,然后按下ctrl+\就可以了。
这里补充一些关于前后进程的命令:
jobs:查看所有的后台进程
ctrl+z:进程切换到后台
bg+任务号:让后台进行恢复运行
3.bash进程
到这里可能还有人会疑惑:bash进程在前后进程中究竟属于哪一个?
默认情况下,终端的前台进程是 bash。当我们执行 ./sig 时,bash 会创建 sig 进程,并把终端
的控制权交给 sig,自己进入等待状态。因此键盘输入会发送给 sig,而不是 bash,所以无法执行
ls、pwd 等 shell 命令。当 sig 结束后,终端控制权重新回到 bash。如果使用 ./sig & 让程序在后
台运行,则终端控制权始终属于 bash,因此可以继续执行各种 shell 命令。
可以通过示意图理解:

<4>.什么叫做给进程发信号?
信号产生之后,可能并不会被立即处理。因此,操作系统必须先把该信号记录下来,等到进程合适的时候再进行处理。
那么信号记录在哪里呢?
本质上是记录在进程的 PCB(task_struct) 中。在 Linux 内核中,每个进程都有对应的内核数据结
构,里面会维护与信号相关的信息。
我们可以先把它简单理解成一个位图,例如一个无符号整数 sigs:
sigs = 0000 0000 0000 0000 0000 0000 0000 0000
其中第 0 位不用,其他比特位的位置可以用来表示信号编号:
第1位 -> 1号信号
第2位 -> 2号信号
第3位 -> 3号信号
...
比特位的内容表示该信号是否已经产生:
0:没有收到该信号
1:已经收到该信号,但还没有处理
可以通过示意图理解:

这里需要明确一个概念:task_struct属于什么?它实际上是操作系统内部的数据结构对象。修改位
图的本质就是修改内核数据结构,那么这个修改由谁来完成呢?显然只能由操作系统自身通过调用
发送信号的系统调用来实现。因此,无论信号是如何产生的,在底层实现中,信号的发送都必须由
操作系统来完成。
2.系统调用
<1>.kill
kill 命令是调用kill 函数实现的,可以给⼀个指定的进程发送指定的信号。
如图所示:

参数:
pid是进程的id号,sig是要发送给目标进程的信号编号。
返回值:

代码:
在sig.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::cout<<"你需要这样写:";
std::cerr << "Usage: " << argv[0] << " -signumber pid" << std::endl;
return 1;
}
int number = std::stoi(argv[1]);
pid_t pid = std::stoi(argv[2]);
int n = kill(pid, number);
if(n<0)
{
perror("kill failed");
exit(n);
}
return 0;
}
在test.cpp中,
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
std::cout << "我是: " << getpid() << ",我获得了⼀个信号: " << signumber << std::endl;
}
int main()
{
std::cout << "我是进程: " << getpid() << std::endl;signal(SIGINT , handler);
while (true)
{
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
return 0;
}
在makefiel中,
all:sig test
test:test.cpp
g++ -o $@ $^ -std=c++11
sig:sig.cpp
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -rf sig test
然后我们在vscode上执行test,然后在xshell上执行./sig 3 进程id,如图所示:

然后就可以杀死./test这个进程了。
<2>.raise
作用:可以给当前进程发送指定的信号(自己给自己发信号)。
如图:

代码:
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
//整个代码就只有这⼀处打印
std::cout<< "获取了⼀个信号 : " << signumber << std::endl;
}
// mykill -signumber pid
int main()
{
for(int i=1;i<=31;i++)
{
signal(i, handler);
}
for(int i=1;i<=31;i++)
{
raise(i);
sleep(1);
}
return 0;
}
结果如图所示:

当我们运行./sig时会发现,当i=9时,循环就会终止,所以这也说明9号进程不可被捕获,并且19号
进程也是一样子的,感兴趣的可以去尝试一下。
<3>.abort
作用:使当前进程接收到信号而异常终止。
如图:

代码:
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
void handler(int signumber)
{
std::cout << "获取了⼀个信号 : " << signumber << std::endl;
}
int main()
{
signal(SIGABRT, handler);
while (true)
{
sleep(1);
abort();
}
}
实验可以得知, abort 给自己发送的是固定 6 号信号,虽然捕捉了,但是还是要退出。
这时可能会有人有疑惑:为什么我之前的raise(6)时,没有终止进程?
raise(SIGABRT);
和
abort();
不是一回事。
raise(SIGABRT) 的语义只是:
给当前进程发送 6 号信号
如果你已经写了:
signal(SIGABRT, handler);
那么 6 号信号的默认终止动作被你改成了自定义处理:
raise(SIGABRT)
↓
产生 SIGABRT
↓
执行 handler
↓
handler 返回
↓
继续往后执行
所以不会退出。
但 abort() 的语义是:
异常终止当前进程
它内部也会产生 SIGABRT,但如果你的 handler 返回了,abort() 仍然会继续想办法让进程终止。
所以区别是:
raise(SIGABRT):只是发送 6 号信号,是否退出取决于 SIGABRT 当前的处理方式
abort():目标就是让进程异常终止,即使 SIGABRT 被捕捉,最终也要退出
你可以这样记:
raise(SIGABRT) ≈ 发送信号
abort() ≈ 发送 SIGABRT + 保证异常退出
总结:raise(SIGABRT) 只是向自己发送一个 6 号信号,被捕获后执行捕获的动作;而 abort() 也
会发送 6 号信号,但它的目标是保证进程最终终止。所以即使用户捕捉了 SIGABRT,abort 会在
处理捕获函数返回后恢复默认动作并再次触发 SIGABRT,从而确保进程退出。并不是直接绕过信
号机制调用某个“强制终止系统调用”。
可以通过示意图理解:

3.硬件异常
硬件异常是 CPU/MMU 在执行指令或进行地址转换时检测到的异常,随后硬件通过异常机制通知内核,内核再根据异常类型向当前进程发送合适的信号。例如当前 进程执行了除以0的指令,CPU的运算单元会产⽣异常,内核将这个异常解释为SIGFPE信号发送给进 程。再⽐如当前进程访问了⾮法内存地址,MMU会产⽣异常,内核将这个异常解释为SIGSEGV信号发送 给进程。
那么操作系统是如何知道硬件异常了呢?
<1>.模拟除0
代码:
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
void handler(int sig)
{
printf("catch a sig : %d\n", sig);
exit(1);
}
// v1
int main()
{
for(int i=1;i<=31;i++)
{
signal(i, handler);
}
sleep(1);
int a = 10;
a/=0;
return 0;
}
结果如图所示:

解释:
执行时:CPU是硬件,它知道:
除0不合法
于是CPU不会继续执行,然后CPU就会记录错误,而CPU内部有很多寄存器,例如:
PC(IP)
SP
AX
BX
EFLAGS
...
其中:
EFLAGS
是状态寄存器。
例如:
ZF 零标志
CF 进位标志
OF 溢出标志
SF 符号标志
000100就是在表达:
某个标志位被置1
例如:
OF = 1
表示发生了溢出。
然后当OS开始接管时,而操作系统是软硬件资源的管理者,它知道当前进程是谁,通过硬件出错
识别到当前进程出错了,然后读取异常原因,判断错误类型,然后发送信号给当前进程,其实就是
给当前进程发送SIGFPE。
本质就是:
task_struct
↓
pending位图
↓
第8位置1
示意图:

<2>.模拟野指针
代码:
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
void handler(int sig)
{
printf("catch a sig : %d\n", sig);
exit(1);
}
int main()
{
for (int i = 1; i <= 31; i++)
{
signal(i, handler);
}
sleep(1);
int *p = NULL;
*p = 100;
return 0;
}
结果如图:

解释:
程序执行:
*p=100;
CPU需要访问:
虚拟地址 0x0
因为:
p == NULL
CPU把地址:
0x0
交给 MMU,它可以查当前进程的页表,但是MMU发现:
0x0
对应的页表项不存在,这说明这个地址没有映射到任何物理地址,所以就导致了地址转换失败
然后MMU通知CPU:Page Fault
CPU产生:#PF异
然后:
用户态
↓
内核态
内核分析:
访问地址:0x0
异常原因:
非法内存访问
发现:这个错误无法修复
因为:
不是缺页
不是栈扩展
不是按需分配
而是真正的野指针。
硬件报错,OS向当前进程发送:
SIGSEGV
即:
11号信号
文字描述:
当程序通过野指针访问内存时,CPU会将对应的虚拟地址交给MMU进行地址转换。MMU查找当前
进程页表后发现该地址没有合法映射,于是产生Page Fault异常。CPU随后陷入内核态,由操作
系统分析异常原因。由于该异常属于非法内存访问,操作系统会向当前进程发送SIGSEGV(11号
信号)。
示意图:

总结:
信号是一种异步事件通知机制。当某个事件发生时(键盘输入、系统调用、硬件异常等),操作系统会向目标进程发送对应信号。本质上就是修改进程PCB中的信号状态,并在合适时机递达给进程执行默认处理、信号捕捉或忽略处理。所有信号最终都由操作系统发送。
下一篇结束信号。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)