【Linux操作系统】简学深悟启示录:信号
本文介绍了Linux系统中信号处理的基本概念和工作原理。 首先通过快递的比喻解释了信号处理的三个关键步骤:信号产生、信号保存和信号处理。进程能够识别和处理信号,可以选择默认处理、自定义处理或忽略信号。 文章详细区分了前台进程和后台进程,指出前台进程能接收用户输入而后台进程默默运行。介绍了通过signal函数自定义信号处理的方法。 讨论了信号的多种产生方式:键盘组合键、kill命令、系统调用(kil
文章目录
1.信号的初步认识
系统中的信号可以以生活中的例子来理解
你在网上买了很多件商品,在等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递”。当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需
5min之后才能去取快递。那么在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取”。在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“记住了有一个快递要去取”。当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:
- 执行默认动作(幸福的打开快递,使用商品)
- 执行自定义动作(快递是零食,你要送给你的女朋友)
- 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话
总的来说,信号分为三步:信号产生 | 信号保存 | 信号处理
- 进程必须能够具备识别和处理信号的能力,这属于进程内置功能的一部分
- 当进程收到一个信号的时候,可能并不会立马处理信号,合适的时候使用默认、自定义、忽略三种方式处理
- 一个信号必须当信号产生到处理的时候,具有一定的时间窗口,进程具有保存那些已经发生了的信号的能力
2.前台进程 vs 后台进程
#include <iostream>
#include <unistd.h>
using namespace std;
int main()
{
while(true)
{
cout << "I am a crazy process" << endl;
sleep(1);
}
return 0;
}
我们将通过一段死循环观察前台和后台的区别
2.1 后台进程

可以发现后台进程并不会读取键盘操作,不直接与用户交互,在后台默默运行,可以使用 ctrl + c 等命令杀死(本质是收到了 2 号信号)
2.2 前台进程

可以发现,在可执行文件后面加个 & 就是前台程序,前台进程是用户当前正在操作的进程,能接收键盘、鼠标等输入,只能使用 kill 命令杀死
系统中只允许一个进程是前台进程,可以多个进程是后台进程
🔥值得注意的是: ctrl + / 表示 3 号信号(SIGQUIT),可以发送终止信号并生成core dump 文件,用于事后调试(后面详谈),ctrl + z 表示 19 号信号(SIGSTOP),可以发送停止信号,将当前前台进程挂起到后台等
2.3 signal函数

该函数用于自定义系统的信号实现,还是以上面的循环为例

这里面最常用的信号是前 31 个,其中 9 和 19 无法被自定义使用
我们知道后台进程可以用 ctrl + c 等命令杀死,本质是收到了 2 号信号(SIGINT)
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void myhandler(int signal)
{
cout << "process get a signal: " << signal << endl;
exit(1);
}
int main()
{
signal(SIGINT, myhandler);
while(true)
{
cout << "I am a crazy process" << endl;
sleep(1);
}
return 0;
}

可以看到传入自定义方法 myhandler 确实能够改变信号的默认处理方式,这个 signal 只要一设定就一直有效,直到主函数结束,如果没有发送 2 号信号,那么 myhandler 也不会被调用
2.4 键盘数据传入系统的原理(硬件)

当键盘识别到外界的主动输入时,会立马通过信号中断打断 cpu 当前的任务,优先处理键盘的任务,并通过中断单元向 cpu 的寄存器输入中断号(外设都有属于自己的中断号),根据中断号在中断向量表中查找对应的方法地址,系统就会通过该方法对键盘的数据做处理
3.信号产生
3.1 键盘组合
ctrl + c或ctrl + \
3.2 命令
kill命令杀死进程
3.3 系统调用

kill 函数可以给一个指定的进程发送指定的信号,形如 kill -信号码 PID

向当前进程(自身)发送指定信号
int main()
{
signal(2, handler); // 先对2号信号进⾏捕捉
// 每隔1S,⾃⼰给⾃⼰发送2号信号
while(true)
{
sleep(1);
raise(2);
}
}

无条件强制异常终止当前进程,并默认生成核心转储文件(core dump),常用于程序遇到不可恢复错误时的 “紧急退出” 场景。当其对应的 6 号信号被捕获,无论是否捕获 SIGABRT ,进程必然终止
3.4 异常
void handler(int sig)
{
printf("catch a sig : %d\n", sig);
}
// v1
int main()
{
// signal(SIGFPE, handler); // 8) SIGFPE
sleep(1);
int a = 10;
a /= 0;
while (1)
;
return 0;
}
在 C/C++ 当中除零,内存越界等异常,在系统层面上,是被当成信号处理的

发现一直有 8 号信号产生并被我们捕获,这是为什么呢?其实在 CPU 中有一些控制和状态寄存器,主要用于控制处理器的操作,通常由操作系统代码使用。状态寄存器可以简单理解为一个位图,对应着一些状态标记位、溢出标记位。OS 会检测是否存在异常状态,有异常存在就会调用对应的异常处理方法
3.5 软件条件

当管道的读端关闭时,写端无论怎么写都无效,因此操作系统会选择关闭管道,写端继续写会触发 SIGPIPE 信号(默认终止写进程),管道作为进程间通信的软件抽象,属于软件条件触发,也可以说是软件异常,上面的除零异常也可以是硬件异常
3.6 core dump

在学习进程控制的时候,有一位叫做 core dump 标志位,core dump 标志位(第 7 位)为 1 时,表示进程被信号终止时生成了 core dump 文件,为 0 则未生成
core dump 文件又叫做核心转储文件,简单来说就是程序崩溃时,开发者用于调试程序错误的(如段错误、空指针访问等)
对于虚拟机一般该文件的生成功能会打开,而云服务器不会,因为要避免频繁的出错调试导致的内存溢出,进而可能操作系统挂掉

我们可以通过 ulimit -a 查看,通过调整系统的 ulimit -c 参数可以修改 core 文件的大小限制,例如设置为 unlimited 可允许生成任意大小的 core 文件,便于捕获完整的崩溃信息进行分析

假设有个除零异常,产生异常之后我们可以看到生成了一个 core.pid 的文件

在 gdb 调试时,输入 core-file core.pid 就能查看异常的出错处了
4.信号保存
4.1 信号概念补充
- 信号从产生到递达之间的状态,称为信号未决
- 进程可以选择阻塞(
block)某个信号 - 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作
- 阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作
4.2 内核中的信号

在内核中,有两个和信号有关的重要位图、和一个函数指针数组:
block表示阻塞集,也叫做当前进程的信号屏蔽字,这里的“屏蔽”应该理解为阻塞而不是忽略,若某信号位被置1,进程会暂时屏蔽该信号,即使信号被发送(进入pending),也不会立即处理,直到阻塞解除pending表示待处理集,当信号被发送到进程时,会将pending中对应位置1;若信号未被阻塞,进程会在合适时机处理该信号,并将对应位清0handler是一个函数指针数组,存放默认处理函数指针SIG_DFL、忽略处理函数指针SIG_IGN,剩下的都是信号自定义处理函数指针,指向各自的函数实现
SIGHUP 信号未阻塞也未产生过,当它递达时执行默认处理动作。SIGINT 信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。SIGQUIT 信号未产生过,一旦产生 SIGQUIT 信号将被阻塞,它的处理动作是用户自定义函数 sighandler
🔥值得注意的是: 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX 允许系统递送该信号一次或多次。Linux 是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号
4.3 信号集函数
sigset_t类型对于每种信号用一个 bit 表示“有效”或“无效”状态,至于这个类型内部如何存储这些 bit 则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用相关函数来操作 sigset_t 变量,而不应该对它的内部数据做任何解释,比如用 printf 直接打印sigset_t 变量是没有意义的
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
sigemptyset:清空信号集,使其不包含任何信号sigfillset:填满信号集,使其包含所有有效信号sigaddset:向指定信号集添加一个特定信号sigdelset:从指定信号集删除一个特定信号sigismember:判断某个信号是否属于指定信号集
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
sigprocmask:控制进程哪些信号会被阻塞
-
how定操作:是“替换”、“新增阻塞”还是“解除阻塞”,下表说明了how参数的可选值
-
set给信号:要阻塞/解除的信号集合 -
oset存旧集:保存上一个信号集合,可选,保存原来的阻塞状态(方便后续恢复)
举个例子:
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void PrintPending(sigset_t &pending)
{
for(int i = 31; i >= 1; --i)
{
if(sigismember(&pending, i))
{
cout << "1";
}
else
{
cout << "0";
}
}
cout << "\n\n";
}
void handler(int signo)
{
cout << "catch a signo : " << signo << endl;
}
int main()
{
signal(2, handler);
sigset_t bset, oset;
sigemptyset(&bset);
sigemptyset(&oset);
sigaddset(&bset, 2);
sigprocmask(SIG_SETMASK, &bset, &oset);
sigset_t pending;
int cnt = 0;
while(true)
{
int n = sigpending(&pending);
if(n < 0)
{
continue;
}
PrintPending(pending);
sleep(1);
cnt++;
if(cnt == 20)
{
cout << "unblock 2 signo" << endl;
sigprocmask(SIG_SETMASK, &oset, nullptr);
}
}
return 0;
}
💻运行结果:

5.信号捕捉
5.1 进程地址空间第三讲

首先我们要知道进程地址空间用户占四分之三,内核占四分之一,每个进程都有自己的一份用户页表,而内核的页表是共享的。对于 cpu,里面有一个 ecs 寄存器,存储当前所处状态,由 0、1 表示,当需要切换内核态或用户态时修改二进制位即可
5.2 用户态与内核态的切换
- CPU 指令集:是
CPU实现软件指挥硬件执行的媒介,具体来说每一条汇编语句都对应了一条CPU指令,而非常非常多的CPU指令在一起,可以组成一个、甚至多个集合,指令的集合叫CPU指令集。 - CPU 指令集有权限分级,大家试想,
CPU指令集可以直接操作硬件的,要是因为指令操作的不规范,造成的错误会影响整个计算机系统的。好比你写程序,因为对硬件操作不熟悉,导致操作系统内核、及其他所有正在运行的程序,都可能会因为操作失误而受到不可挽回的错误,最后只能重启计算机才行。- 对开发人员来说是个艰巨的任务,还会增加负担,同时开发人员在这方面也不被信任,所以操作系统内核直接屏蔽开发人员对硬件操作的可能,都不让你碰到这些
CPU指令集。
- 对开发人员来说是个艰巨的任务,还会增加负担,同时开发人员在这方面也不被信任,所以操作系统内核直接屏蔽开发人员对硬件操作的可能,都不让你碰到这些
针对上面的需求,硬件设备商直接提供硬件级别的支持,做法就是对 CPU 指令集设置了权限,不同级别权限能使用的 CPU 指令集是有限的,以 Inter CPU 为例,Inter 把 CPU 指令集操作的权限由高到低划为 4级:
ring 0:权限最高,可以使用所有CPU指令集ring 1ring 2ring 3:权限最低,仅能使用常规CPU指令集,不能使用操作硬件资源的CPU指令集,比如IO读写、网卡访问、申请内存都不行
要知道的是,Linux 系统仅采用 ring 0 和 ring 3 这 2 个权限。CPU 中有一个标志字段,标志着线程的运行状态,用户态为 3,内核态为 0
- ring 0被叫做内核态,完全在操作系统内核中运行
- 执行内核空间的代码,具有
ring 0保护级别,有对硬件的所有操作权限,可以执行所有CPU指令集,访问任意地址的内存,在内核模式下的任何异常都是灾难性的,将会导致整台机器停机
- 执行内核空间的代码,具有
- ring 3被叫做用户态,在应用程序中运行
- 在用户模式下,具有
ring 3保护级别,代码没有对硬件的直接控制权限,也不能直接访问地址的内存,程序是通过调用系统接口(System Call APIs)来达到访问硬件和内存,在这种保护模式下,即时程序发生崩溃也是可以恢复的,在电脑上大部分程序都是在用户模式下运行的
- 在用户模式下,具有
5.2.1 什么情况会导致用户态到内核态切换?
- 系统调用:用户态进程主动切换到内核态的方式,用户态进程通过系统调用向操作系统申请资源完成工作,例如
fork()就是一个创建新进程的系统调用。- 操作系统提供了中断指令
int 0x80来主动进入内核,这是用户程序发起的调用访问内核代码的唯一方式。调用系统函数时会通过内联汇编代码插入int 0x80的中断指令,内核接收到int 0x80中断后,查询中断处理函数地址,随后进入系统调用。
- 操作系统提供了中断指令
- 异常:当
CPU在执行用户态的进程时,发生了一些没有预知的异常,这时当前运行进程会切换到处理此异常的内核相关进程中,也就是切换到了内核态,如缺页异常 - 中断:当
CPU在执行用户态的进程时,外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令,转到与中断信号对应的处理程序去执行,也就是切换到了内核态。如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后边的操作等
5.2.2 切换时 CPU 需要做什么?
- 当某个进程中要读写
IO,必然会用到ring 0级别的CPU指令集。而此时CPU的指令集操作权限只有ring 3,为了可以操作ring 0级别的CPU指令集,CPU切换指令集操作权限级别为ring 0(可称之为提权),CPU再执行相应的ring 0级别的CPU指令集(内核代码)。 - 代码发生提权时,
CPU是需要切换栈的!前面我们提到过,内核有自己的内核栈。CPU切换栈是需要栈段描述符(ss寄存器)和栈顶指针(esp寄存器),这两个值从哪里来?CPU通过一个段寄存器(tr)确定TSS(任务状态段,struct TSS)的位置。在TSS结构中存在这么一个SS0和ESP0。提权的时候,CPU就从这个TSS里把SS0和ESP0取出来,放到ss和esp寄存器中。
5.3 信号捕捉流程

这张信号处理流程示意图,展示了操作系统中用户态与内核态在处理自定义信号时的切换过程,分以下几个关键步骤:
- 进入内核态:用户程序(如
main函数)在执行时,因中断、异常或系统调用进入内核态 - 内核处理信号:内核在返回用户态前,通过
do_signal()处理进程中可递送的信号 - 执行用户态信号处理函数:若信号处理动作是自定义的,内核会切换到用户态执行
sig handler函数(而非直接回到主控制流程) - 通过特殊系统调用重回内核:切换成用户态执行信号处理函数,执行
sigreturn(对应内核函数sys_sigreturn)再次进入内核 - 返回用户态继续执行:回到内核态,内核完成信号收尾工作后,再次回到用户态,从主控制流程上次被中断的位置继续执行用户程序
🔥值得注意的是: 当一个信号正在被捕捉时,内核⾃动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时,自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产⽣,那么它会被阻塞到当前处理结束为止

可以根据这张图简单记忆
5.4 sigaction

sigaction 是 Linux 系统中用于设置信号处理函数的系统调用,用于取代早期的 signal 函数,提供更精细的信号处理控制
signum:要处理的信号编号(如SIGINT、SIGTERM等)act:指向struct sigaction结构体的指针,用于设置新的信号处理方式

oldact:用于保存旧的信号处理方式(可为NULL,表示不保存)
示例:注册一个处理 SIGINT(Ctrl+C)的函数
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void sigint_handler(int signo) {
printf("收到信号 SIGINT,编号:%d\n", signo);
}
int main() {
struct sigaction act;
act.sa_handler = sigint_handler; // 设置处理函数
sigemptyset(&act.sa_mask); // 清空阻塞集
act.sa_flags = 0; // 无特殊标志
// 注册信号处理
if (sigaction(SIGINT, &act, NULL) == -1) {
perror("sigaction");
return 1;
}
printf("程序运行中,按 Ctrl+C 测试信号处理...\n");
while (1) {
sleep(1);
}
return 0;
}
6.可重入函数

main 函数调用 insert 函数向一个链表 head 中插入节点 node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到 sighandler 函数,sighandler 也调用 insert 函数向同一个链表 head 中插入节点 node2,插入操作的两步都做完之后从 sighandler 返回内核态,再次回到用户态就从 main 函数调用的 insert 函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main 函数和 sighandler 先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了
如果一个函数,被重复进入的情况下,出错了,或者可能出错,就是不可重入函数;否则,叫做可重入函数
如果一个函数符合以下条件之一则是不可重入的:
-
调用了
malloc或free,因为malloc也是用全局链表来管理堆的 -
调用了标准
I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
目前我们学到的大部分函数都是不可重入的
7.volatile
int flag = 0;
void handler(int signo)
{
cout << "catch a signal: " << signo << endl;
flag = 1;
}
int main()
{
signal(2, handler);
while(!flag);
cout << "process quit normal" << endl;
return 0;
}
该代码正常情况下是正常输出 process quit normal 这句话的,对于 cpu 来说,有四种编译优化方式
| 优化级别 | 说明 | 特点 | 适用场景 |
|---|---|---|---|
| -O0 | 无优化 | 编译速度最快,保留完整的调试信息,代码几乎不做变换 | 调试阶段,需要快速编译和精准调试时使用 |
| -O1(或 -O) | 基础优化 | 进行基本的优化,如精简指令、提升 CPU 流水线效率等,编译时间增加不明显 | 对性能有一定要求,但又不想过多牺牲编译时间的场景 |
| -O2 | 中级优化(推荐默认) | 在 -O1 基础上进一步优化,包含更多提升性能的策略,如充分使用寄存器、调整代码执行顺序等,编译时间有所增加 | 大多数生产环境,平衡性能和编译时间 |
| -O3 | 高级优化 | 包含 -O2 的所有优化,还增加了循环展开、函数内联、向量指令优化等激进策略,编译时间较长,可能导致二进制文件变大 | 对性能要求极高的发布版本,如高性能计算、游戏引擎等 |
假如我们对该代码进行 O3 优化,那么 flag 的值在内存里并不会被改变,这是为啥?
由于优化,像 !flag 这样的逻辑运算会被直接优化放到 cpu 中计算,所以修改的是 cpu 里的副本,而不是内存里的 flag
volatile int flag = 0;
所以可以使用 volatile 防止过度优化
希望读者们多多三连支持
小编会继续更新
你们的鼓励就是我前进的动力!

更多推荐



所有评论(0)