Linux->信号
目录
引入:
一:信号的概念
1:信号是什么
2:为什么要有信号
3:OS是怎么设计信号的?
4:信号有哪些?
二:自定义信号
1:signal接口
2:2号信号的自定义
①:2号信号默认处理动作
②:自定义2号信号
3:2号信号的忽略
4:ctrl c的本质
三:信号的产生
1:指令
2:键盘
3:系统调用接口
①:kill接口
模拟实现kill指令:
②:raise接口
③:abort接口
三个接口的区别
4:软件条件
①:匿名管道
②:闹钟
a:IO速度VSCPU速度:
b:循环闹钟:
c:闹钟返回值:
d:闹钟总结:
5:异常
①:除零错误
②:段错误
6:信号发送给进程的本质
7:三张位图的学习
8:对信号产生方式的理解
①:键盘
②:除零错误的本质
③:野指针的本质
④:关于core的问题
四:信号的保存
1:三张位图的总结:
2:5条性质的深入理解
五:信号的处理
1:信号集
2:信号集接口
3:block位图接口
4:pending位图接口
5:三种接口混合场景
6:一些细节
六:捕捉信号时态的切换
1:用户态和内核态
2:再谈进程地址空间
3:信号捕捉态的切换图
信号动作是默认或忽略:
信号动作是自定义:
4:捕捉信号的另一个方法 sigaction
七:三个概念的补充
1:可重入函数
2:volatile
3:SIGCHLD信号
引入:
我们在之前就已经见过信号了!我们会使用kill -9来终止进程,也会使用ctrl+c组合键来终止进程,其次在讲解waitpid接口的时候,其的参数status中就有几个位是用来表示信号编号的,所以我们是已经接触过信号的,但是我们没有系统的去学习过信号!
一:信号的概念
1:信号是什么
以下是信号的5条性质,先从生活角度类比,再回到进程角度
直接站在进程的角度去看信号,难免会有些突兀,所以先从生活的角度去看信号,我们就可以把整篇博客信号的要点,分为以下五条!
①:你在网上买了很多件商品,在等待不同商品快递的到来。但即便快递还没有到来,你也知道快递到了的时候应该怎么处理快递,也就是你能“识别快递”。
②:当快递到达目的地了,你收到了快递到来的通知,但是你不一定要马上下楼取快递,也就是说取快递的行为并不是一定要立即执行,可以理解成在“在合适的时候去取”。
③:在你收到快递到达的通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间内你并没有拿到快递,但是你知道快递已经到了,本质上是你“记住了有一个快递要去取”。
④:当你时间合适,顺利拿到快递之后,就要开始处理快递了,而处理快递的方式有三种:1、执行默认动作(打开快递,使用商品)2、执行自定义动作(不按商品的作用去使用)3、忽略(拿到快递后,放在一边继续做自己的事)。
⑤:快递到来的整个过程,对你来讲是异步的,你不能确定你的快递什么时候到。
解释:把其中的"快递"换成信号,"你"换成进程,就是进程看待信号的方式!如下:
①:即使信号还没有到来,但进程也预先知道了信号来了,该如何处理它,也就是进程能“识别信号”。
②:当信号被发送给进程,进程会收到这个信号到来的通知。 但是进程不一定现在就能立即执行信号,可能会等到合适的时候去处理信号
③:在进程收到信号的通知,到它真正开始执行信号处理函数期间,是有一个时间窗口的。 这段时间,你会知道有信号需要去处理!
④:当进程准备好处理这个待处理的信号时,它就要执行信号处理动作了。 处理信号的方式主要有三种:默认,自定义,忽略!
⑤:信号到达进程的整个过程,对进程来讲完全是异步的。 进程无法预测或主动控制信号会在代码执行的哪个精确时刻到来。信号就像不速之客,随时可能打断进程的正常执行流!
注:以上5点,都会在后面的讲解中体现到,我也会在体现的时候,再次点出体现的点!
一些专业名词的补充:
①:实际执行信号的处理动作称为"信号递达"!
②:当信号已经发送给进程,但是还未被处理的时候,这个状态称为"信号未决"
在这里只需知道这两个名词就行,后面会在合适的时候,再次讲到!
2:为什么要有信号
进程在运行的期间 随意可能需要被停止 删掉 .....所以OS要求进程要有随时响应外部信号的能力,随时做出反应
3:OS是怎么设计信号的?
这里的设计信号,并不是指的设计出某个信号及其的处理方式!而是OS是怎么设计信号这一套机制的!
OS设计如下:信号的产生--->信号的保存--->信号的处理
此篇博客也是按照OS的设计去讲解的
注:
在上面的5条性质中,其中谈到的信号产生但未处理的时候,其实就是信号被保存起来了!
4:信号有哪些?
我们通过指令 kill -l可以看见所有的信号:
解释:
①:信号,即可以用后面的宏来表示,也可以用对应的数字来表示,因为都是唯一的
②:我们没有0,32,33,这三个信号
③:34及往后的这种包含RT的信号叫作实时信号,实时操作系统会用,我们用的是分时操作系统,所以不学!
注:最常见的实时操作系统----->车载系统中的自动刹车
Q1:为什么没有32,33?
A1:没有32,33是因为历史原因,一开始仅定义了 1~31
的标准信号,后来扩展实时信号时,为了避免与原有信号冲突,选择从 34 开始编号。所以32 和 33 被保留为“过渡区间”,确保旧程序不会误解析为新信号。
Q2:为什么没有0号信号?
A2:在之前我们的waitpid函数的status参数中,进程若是接收到信号,则低7位表示信号编号,那如果没有接收到信号呢?只需置低7位全为0即可,所以这就是为什么没有0号信号,因为低7位为0,代表进程正常运行退出!
前31个信号行为如下:
编号 | 信号名称 | 默认行为 | 常见触发场景 |
---|---|---|---|
1 | SIGHUP | 终止进程 | 终端断开或控制进程退出(如 SSH 会话断开) |
2 | SIGINT | 终止进程 | 用户按下 Ctrl+C (中断进程) |
3 | SIGQUIT | 终止并生成 core 文件 | 用户按下 Ctrl+\ (退出进程并生成调试转储) |
4 | SIGILL | 终止并生成 core 文件 | 进程执行了非法指令(如错误的 CPU 指令) |
5 | SIGTRAP | 终止并生成 core 文件 | 调试断点触发(如 ptrace 调试器) |
6 | SIGABRT | 终止并生成 core 文件 | 程序调用 abort() 主动终止(如断言失败) |
7 | SIGBUS | 终止并生成 core 文件 | 内存访问错误(如对齐问题、不存在的物理地址) |
8 | SIGFPE | 终止并生成 core 文件 | 算术错误(如除零、浮点溢出) |
9 | SIGKILL | 强制终止(不可捕获) | kill -9 或系统强制杀死进程(无法被阻塞/忽略) |
10 | SIGUSR1 | 终止进程 | 用户自定义信号 1(常用于进程间通信) |
11 | SIGSEGV | 终止并生成 core 文件 | 段错误(如访问非法内存地址 NULL ) |
12 | SIGUSR2 | 终止进程 | 用户自定义信号 2(用途同 SIGUSR1 ) |
13 | SIGPIPE | 终止进程 | 向已关闭的管道/套接字写入数据(如 printf 后管道读者退出) |
14 | SIGALRM | 终止进程 | 定时器超时(如 alarm() 或 setitimer() 触发) |
15 | SIGTERM | 终止进程 | 默认的 kill 命令信号(友好终止,可捕获处理) |
16 | SIGSTKFLT | 终止进程 | 协处理器栈错误(罕见,现代系统已弃用) |
17 | SIGCHLD | 忽略 | 子进程状态变化(退出/暂停/恢复) |
18 | SIGCONT | 继续进程 | 恢复被暂停的进程(如 fg 命令) |
19 | SIGSTOP | 暂停进程(不可捕获) | 用户按下 Ctrl+Z 或 kill -STOP (无法被阻塞/忽略) |
20 | SIGTSTP | 暂停进程 | 用户按下 Ctrl+Z (可捕获处理) |
21 | SIGTTIN | 暂停进程 | 后台进程尝试读取终端输入 |
22 | SIGTTOU | 暂停进程 | 后台进程尝试向终端输出 |
23 | SIGURG | 忽略 | 套接字收到紧急数据(如带外数据 OOB ) |
24 | SIGXCPU | 终止并生成 core 文件 | 进程超出 CPU 时间限制(通过 setrlimit 设置) |
25 | SIGXFSZ | 终止并生成 core 文件 | 文件大小超出限制(如 ulimit -f ) |
26 | SIGVTALRM | 终止进程 | 虚拟定时器超时(setitimer(ITIMER_VIRTUAL) ) |
27 | SIGPROF | 终止进程 | 性能分析定时器超时(setitimer(ITIMER_PROF) ) |
28 | SIGWINCH | 忽略 | 终端窗口大小改变(如调整 xterm 窗口) |
29 | SIGIO | 终止进程 | 文件描述符的异步 I/O 事件(需配置 fcntl ) |
30 | SIGPWR | 终止进程 | 电源故障(通常由 init 或 systemd 处理) |
31 | SIGSYS | 终止并生成 core 文件 | 进程执行了无效的系统调用(如 seccomp 过滤器拦截) |
关键说明:
①:SIGKILL和SIGSTOP这两种信号,不能被捕捉,不能被忽略,不能被阻塞(后面会讲)
②:终止进程和终止进程并生成core 文件,是两种不同的行为(后面会讲)
二:自定义信号
先介绍一个接口signal,因为此接口在后面都会被用到!
我们知道信号是由其对应的处理动作的,有些信号是终止进程,有些是暂停进程,但是我们之前在第④条性质中说过,进程可以对信号执行默认动作,也可以自定义,也可以忽略!如下:
④:当进程准备好处理这个待处理的信号时,它就要执行信号处理动作了。 处理信号的方式主要有三种:默认,自定义,忽略
Q:那进程如何才能让信号去执行自定义动作或者忽略动作?
A:由程序员手动的在代码中去写!接口为signal
1:signal接口
#include <signal.h>void (*signal(int signum, void (*handler)(int)))(int);
参数:
第一个参数:signum
:要捕获的信号宏或编号(如SIGINT 或 2
)。
第二个参数:handler
:信号处理函数,类型为void (*)(int)
。
也可以是特殊值:
SIG_IGN
:忽略该信号。
SIG_DFL
:恢复默认行为。返回值:
成功时返回之前的信号处理函数指针。
失败时返回
SIG_ERR
(需检查errno
)。注:signal接口修改信号动作 只需修改一次 在整个main中都有效
所以:想让某个信号执行我们给其自定义的动作,我们需要给该信号手动写个函数,函数里面我们写想让该信号执行的动作,函数的类型为void (*)(int)!
注!!!:
为某信号实现了自定义处理函数(而非忽略或保留默认行为),则称为捕获该信号。
2:2号信号的自定义
所以下面我们对2号信号进行自定义看看效果!
test.c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{int pid = getpid();while (1){printf("mypid:%d\n", pid);sleep(1);}return 0;
}
运行效果:
解释:这是一个死循环,一秒打印一次进程的pid
①:2号信号默认处理动作
解释:符合预期,因为2号信号本来就是终止进程的!
②:自定义2号信号
我们将2号信号的动作,自定义为打印语句:"get a sig,unmber is:%d\n"
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>void sigint_handler(int sig)//sigint_handler译为信号处理
{printf("get a sig,unmber is:%d\n", sig);
}int main()
{signal(SIGINT, sigint_handler);int pid = getpid();while (1){printf("mypid:%d\n", pid);sleep(1);}return 0;
}
解释:
①:因为对2号进程自定义,所以我们填写信号宏名称SIGINT
②:sigint_handler函数的类型是void (*)(int),需严格遵守
③:sigint_handler函数的参数,我们可以在函数内部使用,sig就是自定义信号的编号!
运行效果:
解释:符合预期,我们的2号信号已经变成了我们自定义的处理动作(打印语句)!
3:2号信号的忽略
忽略信号本质其实就是一种特殊的自定义信号,所以依旧要用signal接口:
include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>int main()
{signal(2, SIG_IGN);//忽略2号信号int pid = getpid();while (1){printf("mypid:%d\n", pid);sleep(1);}return 0;
}
运行效果:
解释:符合预期,达到了对2号信号忽略的效果
4:ctrl c的本质
ctrl的本质就是2号信号,只不过这些常用的信号,OS将其变成了组合键也能触发,方便用户使用!!
验证代码:
对下面代码形成的进程,执行ctrl+c组合键!
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>void sigint_handler(int sig)
{printf("get a sig,unmber is:%d\n", sig);
}int main()
{signal(2, sigint_handler);int pid = getpid();while (1){printf("mypid:%d\n", pid);sleep(1);}return 0;
}
运行效果:
解释:我们执行ctrl+c组合键,发现其会打印我们自定义函数里面的语句,所以验证成功!
经过对handler方法的学习,我们知道:
自定义的函数是不会被立即执行 是未来我们收到对应的信号 才执行handler!所以signal接口不是在调用handler,而是在设置handler!如果未来进程永远没有收到对应的信号,则handler永远不会被调用!
三:信号的产生
信号的产生有5种途径
1:指令
通过指令产生信号,是最常见的途径,kill指令加选项即可向对应的进程发送信号!
2:键盘
ctrl+c就是通过键盘产生信号!
拓展:ctrl \ :本质是3号SIGQUIT信号也是终止进程
3:系统调用接口
我们也可以通过Linux提供的接口来产生信号!会介绍三个接口:
①:kill接口
#include <sys/types.h>
#include <signal.h>int kill(pid_t pid, int sig);
参数:
第一个参数:pid
:目标进程的 PID,取值规则:
> 0
:发送给指定 PID 的进程。
= 0
:发送给当前进程组的所有进程。
= -1
:发送给所有有权限的进程(需 root 权限)。
< -1
:发送给进程组 ID 为|pid|
的所有进程。
第二个参数:sig
:信号编号(如SIGTERM=15
)或名称(需包含<signal.h>
)。
0
:不发送信号,仅检查进程是否存在。返回值:
成功返回
0
。失败返回
-1
,并设置errno
(如ESRCH
表示进程不存在)。
展示用法:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>int main()
{int pid = getpid();kill(pid, SIGKILL); // 优雅终止pid进程while (1){printf("mypid:%d\n", pid);sleep(1);}return 0;
}
解释:在进程中调用了kill接口,向本进程发送9号信号,终止进程!
这是kill的入门级用法。而OS对于kill接口的用法就很牛了,因为我们的kill指令内部就是调用的kill接口,也就是OS把kill接口封装成了一个指令,这样kill指令每次带不同的选项,就可以发送不同的信号!!!
模拟实现kill指令:
注:cpp文件 用g++编译;我们实现的指令叫作mykill用于区分OS的kill
#include <iostream> // 输入输出流(cout, cerr)
#include <cerrno> // 错误码(errno)
#include <cstring> // 字符串操作(strerror)
#include <unistd.h> // POSIX API(如kill)
#include <sys/types.h> // 进程ID类型(pid_t)
#include <signal.h> // 信号处理(kill, 信号常量)using namespace std; // 使用标准命名空间// 自定义kill命令:mykill -9 pid
int main(int argc, char *argv[])
{// 检查参数数量是否为3(程序名 + 信号 + PID)if(argc != 3) {cout << "Usage: " << argv[0] << " -signumber pid" << endl; // 打印用法return 1; // 参数错误,返回非零}// 解析信号编号(跳过argv[1]的第一个字符'-',例如"-9" -> 9)int signumber = stoi(argv[1]+1); // 解析目标进程IDint pid = stoi(argv[2]); // 调用kill发送信号int n = kill(pid, signumber);if(n < 0) {// 失败时打印错误信息(如权限不足或进程不存在)cerr << "kill error, " << strerror(errno) << endl;}return 0; // 成功返回0
}
运行效果:
用我们的mykill程序对一个死循环程序发送信号终止:
解释:符合预期,实现出了mykill指令
②:raise接口
#include <signal.h>int raise(int sig);
参数:
sig
:要发送的信号编号(如SIGINT
、SIGTERM
)。返回值:
成功返回
0
。失败返回非零(通常因信号无效)。
所以这个函数很简单,哪个进程调用这个函数,就会向次进程发送sig信号!
例子:
我们让进程内部每1秒就调用raise接口给自己发送2号信号:
#include <stdio.h>
#include <unistd.h>
#include <signal.h>void handler(int signo)
{printf("get a signal:%d\n", signo);
}
int main()
{signal(2, handler);while (1){sleep(1);raise(2);}return 0;
}
运行效果:
③:abort接口
#include <stdlib.h>void abort(void);
无参数,无返回值(直接终止进程)。
解释:abort接口用于向当前进程发送 SIGABRT
信号(信号编号 6
)!
例子:
每一秒调用一次abort函数
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>void handler(int signo)
{printf("get a signal:%d\n", signo);
}
int main()
{signal(6, handler);while (1){sleep(1);abort();}return 0;
}
解释:
与之前不同的是,虽然我们对SIGABRT信号进行了捕捉,并且在收到SIGABRT信号后执行了我们给出的自定义方法,但是当前进程依然是异常终止了。
这是因为:
无论信号处理函数是否返回,abort()
最终会调用 _exit(EXIT_FAILURE)
确保进程终止。这是标准规定的行为,无法通过捕获信号阻止。
为什么设计如此?
abort()
的核心目的是处理不可恢复的错误(如内存损坏、断言失败)。如果允许通过捕获信号阻止终止,可能导致程序在危险状态下继续运行,引发更严重问题(如数据不一致或安全漏洞)。
三个接口的区别
接口 目标 是否强制终止 是否跨进程 典型用途 kill
任意指定进程 取决于信号 ✔️ 是 进程管理、跨进程通信 raise
当前进程 取决于信号 ❌ 否 自我调试、内部事件通知 abort
当前进程 ✔️ 总是终止 ❌ 否 不可恢复错误的紧急终止
4:软件条件
软件条件是指通过程序逻辑或操作系统机制触发的信号,而非由硬件事件(如内存访问错误、除零异常等)直接引发。
①:匿名管道
我们之前学习管道的时候,讲过如果读端已经关闭,则你的写端也会被OS发送信号强制关闭,其实发送的就是13号信号:
这个我们已经在之前就验证过了收到的信号是13号,不再讲解
代码如下:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{int fd[2] = { 0 };if (pipe(fd) < 0){ //使用pipe创建匿名管道perror("pipe");return 1;}pid_t id = fork(); //使用fork创建子进程if (id == 0){//childclose(fd[0]); //子进程关闭读端//子进程向管道写入数据const char* msg = "hello father, I am child...";int count = 10;while (count--){write(fd[1], msg, strlen(msg));sleep(1);}close(fd[1]); //子进程写入完毕,关闭文件exit(0);}//fatherclose(fd[1]); //父进程关闭写端close(fd[0]); //父进程直接关闭读端(导致子进程被操作系统杀掉)int status = 0;waitpid(id, &status, 0);printf("child get signal:%d\n", status & 0x7F); //打印子进程收到的信号return 0;
}
解释:符合预期,收到的就是13号信号!
②:闹钟
#include <unistd.h>unsigned int alarm(unsigned int seconds);
参数
seconds
:定时器的延迟时间(单位:秒)。
如果
seconds
为0
,表示取消之前设置的定时器(不会触发SIGALRM
)。如果之前已经设置了定时器,
alarm()
会覆盖之前的设置,并返回剩余时间。返回值
剩余时间:返回最近一次设置的定时器的剩余秒数(如果之前没有定时器,返回
0
)。注:参数和返回值的意义很重要,在后面会体现!
解释:经过seconds后,会触发14号信号去终止去程!
注:闹钟并不会让你的进程运行到闹钟响了为止,其是异步的,和你的进程退出无关!当进程退出,则你进程设置的闹钟失效,所以测试闹钟,我们一般会加一个较长时间的循环!
a:IO速度VSCPU速度:
下面我们设置一个一秒就会响的闹钟,顺便看下一秒能让一个数从0递增到多大:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>int main()
{int count = 0;alarm(1);while (1){count++;printf("count: %d\n", count);}return 0;
}
解释:发现,可以让一个0递增到58475,当然这个数字因环境而异,但是都不会太大!
下面我们我们在闹钟响后,再打印一次变量:
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>int count = 0;
void handler(int signo)
{printf("get a signal: %d\n", signo);printf("count: %d\n", count);exit(1);
}
int main()
{signal(SIGALRM, handler);alarm(1);while (1){count++;}return 0;
}
解释:为负数,意味着其已经超出了整形范围(42亿)
所以,我想说的是:CPU执行速度极快,但是与外设进行IO时的速度是非常慢的!!一个是IO(cpu加文件) 一个是纯内存级(cpu)
b:循环闹钟:
如何让闹钟循环呢?只需在闹钟的14号信号的自定义方法里面,再次调用闹钟即可!
#include <iostream>
#include <unistd.h>
#include <signal.h>using namespace std;void handler(int sig) {alarm(5); // 重新设置 5 秒闹钟cout << "闹钟响了!!" << endl; // 闹钟响了
}int main() {signal(SIGALRM, handler);alarm(10); // 设置 100 秒闹钟,并返回之前未触发的剩余时间// 让程序保持运行,等待信号while (true) {}return 0;
}
解释:闹钟循环起来了,你想让每次循环里面做什么,自己添加即可!
c:闹钟返回值:
依旧是上面的代码,我们将handler的行为换成打印每次闹钟的返回值,且main中设置为100s
#include <iostream>
#include <unistd.h>
#include <signal.h>using namespace std;void handler(int sig) {int ret = alarm(5); // 重新设置 5 秒闹钟,并返回之前的剩余时间cout << "Handler ret: " << ret << endl; // 预期输出 0(因为 100 秒刚好用完)
}int main() {signal(SIGALRM, handler);int ret = alarm(100); // 设置 100 秒闹钟,并返回之前未触发的剩余时间cout << "Initial ret: " << ret << endl; // 预期输出 0(第一次设置)// 让程序保持运行,等待信号while (true) {}return 0;
}
解释:每次打印的值都是0,是因为每次都是当前闹钟走完了,才去执行下一个闹钟!一开始设定闹钟,则10秒之后,再回去执行闹钟的自定义函数,所以这是为什么10秒才会打印第一次!
我们现在在第一个闹钟,还没有响的时候,我直接用指令向进程发送14号指令:
#include <iostream>
#include <unistd.h>
#include <signal.h>using namespace std;void handler(int sig) {int ret = alarm(5); // 重新设置 5 秒闹钟,并返回之前的剩余时间cout << "Handler ret: " << ret << endl; // 预期输出 0(因为 100 秒刚好用完)
}int main() {signal(SIGALRM, handler);int ret = alarm(100); // 设置 100 秒闹钟,并返回之前未触发的剩余时间cout << "Initial ret: " << ret << endl; // 预期输出 0(第一次设置)// 让程序保持运行,等待信号while (true) {}return 0;
}
解释:我们在第一个闹钟(100秒)还没响的时候,我们发送的14号信号,会直接执行处理动作,此时的处理是自定义处理,所以其会进入handler函数,中打印上一次设置的闹钟的剩余秒数,也就是main中100秒的闹钟的剩余秒数,为95秒,然后就会5秒一次打印上次闹钟的剩余时间!
注:
alarm()
是单次定时器,每次调用都会覆盖之前的定时器,并返回之前的剩余时间。
这就是为什么我们上次的闹钟还剩95秒,但是后面却一直5秒一次的打印最近一次闹钟的剩余时长!因为我们的100秒闹钟,已经被取消了
关键点:
alarm()
每次调用都会覆盖前一个定时器!
d:闹钟总结:
a:明白了IO速度很慢
b:循环闹钟的设置方法
c:闹钟返回值体现了--->
alarm()
每次调用都会覆盖前一个定时器!
5:异常
异常也会产生信号,异常指的是:如内存访问错误、除零异常等
①:除零错误
代码发生除零错误,本质是代码会是收到8号信号导致进程终止!
例子:
对8号错误进行自定义,导致其一直触发打印
#include <iostream>
#include <unistd.h>
#include <signal.h>using namespace std;void handler(int sig)
{cout << "get a sig: " << sig << endl; // 异常还在
}int main()
{signal(SIGFPE, handler);// 除0int a = 10;a /= 0;while(true) sleep(1);return 0;
}
在编译的时候,警告是正常的,因为代码本来就有错....
运行效果:
解释:这证明了除零错误这个异常,导致的就是OS会这个进程发送8号信号去终止
②:段错误
最常见的段错误,就是空指针的使用!而段错误的本质,就是OS向进程发送11号信号!
例子:
对11号错误进行自定义,导致其一直触发打印
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>using namespace std;void handler(int sig)
{cout << "get a sig: " << sig << endl; // 异常还在sleep(1);
}int main()
{signal(SIGSEGV, handler);// 除0// int a = 10;// a /= 0;int *p = nullptr;*p = 100; // 野指针while(true) sleep(1);return 0;
}
解释:这证明了段错误这个异常,导致的就是OS会这个进程发送11号信号去终止
6:信号发送给进程的本质
现在学了这个么多产生信号的方式,现在我们有两个疑问:
Q1:OS怎么知道按键被按下了?怎么知道是哪些按键按下了?
A1:这个问题现在是解释不清楚的,但是可以浅显的说一段话,因为有几个名词很重要!
前提:我们要知道OS是通过键盘的文件来管理键盘的,所以OS可以找到文件对应的缓冲区,拿到我们输入的字符 或者 组合键字符!
①:CPU不是定期检测键盘文件是否有新的输入,而是用的是一种硬件中断的技术,如果键盘被按下,则CPU就会立即得知!当你按下键盘时,键盘会通过电路给CPU发个高电平信号!
②:CPU收到信号后,会获得一个中断号,而操作系统有张"中断向量表",表里存好了各种情况的处理函数,所以CPU会根据这个中断号去调用中断向量表中对应的方法!
③:而CPU通过键盘得到的中断号调用的函数是键盘处理函数,其回去读取键盘文件缓冲区的内容,此时就能读取到我们输入的是什么了!读取到用户输入的是信号,则把信号发给对应进程!!
重点:硬件中断,中断号,中断向量表
注:这也是为什么在管道学习时,管道无数据,读端会堵塞,而此时写入数据,读端会被及时唤醒,本质就是因为你写入数据,使用键盘,会硬件中断CPU,CPU此时知道有新东西写入了,当然就回去及时唤醒写端了(将写端插入到CPU的运行队列)!scanf和cin也是类似!
7:三张位图的学习
Q2:怎么才能算作OS将信号发送给了进程?
A2:
①:在A1中我们已经知道输入的哪个信号了,其会找到进程的PCB(task_struct)中的一个位图,名为pending位图,我们知道位图都是一个整形32位
②:该位图第一个位代表1号信号,以此类推,其32个位,足够表示31个信号,位图位1,代表该信号存在,反之不存在,而存在该信号我们就要处理该信号
③:所以还有一张位图,叫作handler位图,其对应的第一个位存储1号信号的方法,两张表合作,即将来不及处理的信号保存到了pending位图,又能在可以处理信号的时候,去handler表中调用对应的方法!
④:还有一张表,叫作block位图,其表示某个信号是否被阻塞!若被阻塞,则即使你的pending位图对应的位为1,也不会去handler位图中执行该信号的方法!
注:只有OS才能权限修改内核数据结构(pcb),完美符合该逻辑!且从这里能看出来,之前的5中产生信号的方式,其实本质就是OS自己向进程写入信号!
三张位图如下:
所以我们之前在第一大点中介绍了几个名词,可以在这里再提一次了:
实际执行信号的处理动作称为"信号递达"!
当信号已经发送给进程,但是还未被处理的时候,这个状态称为"信号未决"
现在我们学习了三张位图后,可以更深层次的理解一些性质了:
①:所以如果一个信号处于"信号未决",则代表其的pending位图对应的位为1(因为还未去handler位图调用对应的方法,让其状态称为"信号地带"),而一个信号的pending位图对应的位为1可能由两种情况导致:a:的确处于信号未决,b:信号被阻塞
②:被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作
③:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
④:如果一个信号被阻塞(屏蔽),则该信号永远不会被递达处理,除非解除阻塞
⑤:阻塞和忽略区别:已读不回 和 拉黑的区别
所以我们siganl接口去实现自定义信号动作的本质就是,把我们实现的函数的地址替换进该信号对应的handlr位图中的位中,这样当下次该信号来了,OS先把pending位图对应的位置为1,然后就回去执行该信号对应的handler位图中对应的位存储我们实现的函数的函数地址!
Q:那阻塞一个信号,和是否收到了指定信号,有关系吗
A:没关系!不管有没有收到信号,都可以先阻塞这个信号!两个位图各玩各的!
8:对信号产生方式的理解
①:键盘
所以现在我们知道为什么键盘能产生信号了!其通过硬件中断,让CPU得到中断号,然后调用键盘的处理函数,得到了输入的字符,如果该字符是信号,则OS就会向进程task_struct 的pending位图中写入!
②:除零错误的本质
在cpu中的er寄存器的某个比特就是负责记录是否出现除零错误的,当我们的寄存器(eax,ebx)计算得到的结果触发除零错误的时候,则er寄存器的该位为1,所以此时CPU会告知OS,然后OS来检测到了记录除零错误的位为1,则直接向进程PCB发送8号信号,向pending位图中第低8位写入!
所以这就是为什么我们之前若是对2号信号进行自定义,只有当我们自己发送信号的时候,其才会执行handler函数中的打印语句,但是若是对除零错误(8号信号)和野指针错误(11号信号)进程自定义处理,则我们代码中明明仅仅触发了一次错误,但却会一直打印我们handler函数中的语句!本质就是因为我们的寄存器er中的那个表示发生除零错误的位仍然为1,所以OS就会一直向进程发送信号!但是呢,信号又没把进程终止,进程没终止,则进程的上下文数据就会一直在寄存器中,所以就会一直套娃,一直让寄存器er的位为1,一直让OS发送信号,一直执行自定义函数打印语句!
③:野指针的本质
本质:
首先我们必须知道的是,当我们要访问一个变量时,一定要先经过页表的映射,将虚拟地址转换成物理地址,然后才能进行相应的访问操作。
其中页表属于一种软件映射关系,而实际上在从虚拟地址到物理地址映射的时候还有一个硬件叫做MMU,它是一种负责处理CPU的内存访问请求的计算机硬件,因此映射工作不是由CPU做的,而是由MMU做的,但现在MMU已经集成到CPU当中了。
当需要进行虚拟地址到物理地址的映射时,我们先将页表的左侧的虚拟地址导给MMU,然后MMU会计算出对应的物理地址,我们再通过这个物理地址进行相应的访问。
而MMU既然是硬件单元,那么它当然也有相应的状态信息,当我们要访问不属于我们的虚拟地址时,MMU在进行虚拟地址到物理地址的转换时就会出现错误,然后将对应的错误写入到自己的状态信息当中,这时硬件上面的信息也会立马被操作系统识别到,进而将对应进程发送11号信号。
此时OS就会向发生野指针错误的进程的task_struct中的pending位图对应的位置为1,然后执行其对应的方法!
而为什么自定义11号信号会,会一直执行自定义函数handler的方法,在上面已经讲过了!
④:关于core的问题
Q:core 和 term的区别是什么?
A:在前文的信号列表中,终止有两种类型,一种是term,另一种是core。term代表单纯的终止进程,core代表不仅终止进程,还要生成 core 文件,core文件的作用是:可以通过core定位到进程为什么退出,以及执行到哪行代码退出的!这意味这如果一个进程终止会生成core文件,则会利用调试该进程的bug!比如:makefile加上-g ,然后gdb,直接打开core文件,会直接帮我们定位到哪一行出问题!
以上的生成core文件的行为叫作: core dump 核心转储!
Q:进程控制中的status中的core dump位的作用
A:所以如果一个进程发生了core dump 核心转储!则waitpid函数中的status参数的第低8位为1,代表其产生了核心转储!一般代表该子进程已经发生了核心转储!!因为一般是父进程waitpid等待子进程。
注:云服务器的core功能默认是被关闭的,也及时即使你的信号是core类型的终止,仍然不会生成core文件,这是因为:避免生成大量的core文件,导致磁盘满,系统直接挂掉!
ulimit -a//查看core是否被打开
解释:图中core文件大小为0,则代表core功能没有被打开!
ulimit -c 10240// 开启core功能,数字按照需求去取,单位是blocks;
注:数字大小根据需求去写,一个block是 512字节!
四:信号的保存
1:三张位图的总结:
总结一下:
- 在block位图中,比特位的位置代表某一个信号,比特位的内容代表该信号是否被阻塞。
- 在pending位图中,比特位的位置代表某一个信号,比特位的内容代表是否收到该信号。
- handler表本质上是一个函数指针数组,数组的下标代表某一个信号,数组的内容代表该信号递达时的处理动作,处理动作包括默认、忽略以及自定义。
- block、pending和handler这三张表的每一个位置是一一对应的。
2:5条性质的深入理解
有了三个位图的学习之后,我们在这里就可以更深入的理解5条性质了:
①:即使信号还没有到来,但进程也预先知道了信号来了,该如何处理它,也就是进程能“识别信号”。
②:当信号被发送给进程,进程会收到这个信号到来的通知。 但是进程不一定现在就能立即执行信号,可能会等到合适的时候去处理信号
③:在进程收到信号的通知,到它真正开始执行信号处理函数期间,是有一个时间窗口的。 这段时间,你会知道有信号需要去处理!
④:当进程准备好处理这个待处理的信号时,它就要执行信号处理动作了。 处理信号的方式主要有三种:默认,自定义,忽略!
⑤:信号到达进程的整个过程,对进程来讲完全是异步的。 进程无法预测或主动控制信号会在代码执行的哪个精确时刻到来。信号就像不速之客,随时可能打断进程的正常执行流!
深入理解:
①:因为每种信号的处理函数的地址,已经按照信号对应的位存储进了handler表中!
②:这意味着信号处于未决状态,还未被递达(什么叫合适,后面会讲!)
③:这意味着pending表中该信号的位为1,所以进程知道这个信号一直在等待处理
④: 默认就是去执行handler表中原本存储的值,自定义和忽略都是通过signal接口来修改handler表中存储的地址!
⑤:因为信号是OS向进程的pcb的位图中写入的,所以你进程根本无权知道!
五:信号的处理
Q:那我们能对三张表进行修改吗?
A:能!OS提供了系统调用接口!其次我们之前的signal接口,不就是在修改handler表吗?!
1:信号集
但是除了handler表的修改可以直接用signal,其余block和pending位图无直接接口,我们需要先对一个OS提供的sigset_t信号集进行修改,然后再把这个sigset_t信号集作为block和pending位图的相关接口的参数传进去!所以无论是block还是pending,都是需要先设置sigset_t信号集的!
类似于我们先修改一个无关的位图,然后再用这个位图去按位与/或操作我们的block和pending位图,但是sigset_t信号集是一个结构体,其不止位图这个简单罢了!
sigset_t如下:
#define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
{unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;typedef __sigset_t sigset_t;
sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态。
- 在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞。
- 在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。
2:信号集接口
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”,至于这个类型内部如何存储这些bit则依赖于系统的实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。
#include <signal.h>int sigemptyset(sigset_t *set);int sigfillset(sigset_t *set);int sigaddset(sigset_t *set, int signum);int sigdelset(sigset_t *set, int signum);int sigismember(const sigset_t *set, int signum);
函数解释:
- sigemptyset函数:初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
- sigfillset函数:初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
- sigaddset函数:在set所指向的信号集中添加某种有效信号。
- sigdelset函数:在set所指向的信号集中删除某种有效信号。
- sigemptyset、sigfillset、sigaddset和sigdelset函数都是成功返回0,出错返回-1。
- sigismember函数:判断在set所指向的信号集中是否包含某种信号,若包含则返回1,不包含则返回0,调用失败返回-1。
注意: 在使用sigset_t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化!!!
每个接口的用法如下:
#include <stdio.h>
#include <signal.h>int main()
{sigset_t s; //用户空间定义的变量sigemptyset(&s);sigfillset(&s);sigaddset(&s, SIGINT);sigdelset(&s, SIGINT);sigismember(&s, SIGINT);return 0;
}
解释:代码中定义的sigset_t类型的变量s,与我们平常定义的变量一样都是在用户空间定义的变量,所以后面我们用信号集操作函数对变量s的操作实际上只是对用户空间的变量s做了修改,并不会影响进程的任何行为。因此,正如我们先前所说,我们还需要通过block和pending位图相关的系统调用,才能将变量s的数据设置进block和pending位图!!
3:block位图接口
注:信号屏蔽字、阻塞信号集、block位图是同一个东西,只是表达方式不同
sigprocmask函数可以用于读取或更改进程的block位图,该函数的函数原型如下:
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
参数说明:
- 如果oldset是非空指针,则读取进程当前的信号屏蔽字通过oset参数传出。
- 如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。
- 如果oldset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。
假设当前的block位图为mask,下表说明了how参数的可选值及其含义:
选项 | 含义 |
---|---|
SIG_BLOCK | set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask|set |
SIG_UNBLOCK | set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask|~set |
SIG_SETMASK | 设置当前信号屏蔽字为set所指向的值,相当于mask=set |
4:pending位图接口
sigpending函数可以用于读取进程的未决信号集,该函数的函数原型如下:
int sigpending(sigset_t *set);
解释:sigpending函数读取当前进程的未决信号集,并通过set参数传出。该函数调用成功返回0,出错返回-1
Q:为什么block位图可以有接口对其进行修改,而pending位图则只有读取接口?
A:
①:你想修改pending位图,直接向进程发送信号就好了,可以键盘组合键,可以直接kill等,多么方便? 还需要提供接口??不是多此一举?
②:信号是异步事件,其递送应由内核统一调度。如果允许进程随意修改 pending
位图,可能破坏信号的可靠递送(例如恶意清除 SIGKILL
)。
5:三种接口混合场景
下面用一个场景,来体现信号集接口,block位图接口,pending位图接口的组合使用的效果!
场景:
- 先用上述的函数将2号信号进行阻塞
- 使用sigpending函数获取此时的pending,全是0
- 再使用kill命令或组合按键向进程发送2号信号
- 此时2号信号会一直被阻塞,并一直处于pending(未决)状态
- 使用sigpending函数获取此时的pending信号集,则第低2位为一直为1
代码如下:
#include <iostream>
#include <signal.h>
#include <assert.h>
#include <unistd.h>
#include <sys/types.h>
using namespace std;void printPending(sigset_t &pending) {int i = 1;// 倒序遍历所有常规信号(31→1)for (i = 31; i > 0; i--) {// 使用sigismember检查信号i是否在pending集合中if (sigismember(&pending, i)) {printf("1 "); // 信号未决(被阻塞且已到达)} else {printf("0 "); // 信号非未决}}printf("\n"); // 换行结束当前位图输出
}
int main() {int pid = getpid();cout<<pid<<endl;// 1. 初始化信号集并屏蔽2号信号(SIGINT)sigset_t block, oblock;sigemptyset(&block); // 清空block信号集sigemptyset(&oblock); // 清空oblock信号集(用于保存旧屏蔽字)sigaddset(&block, 2); // 将2号信号(SIGINT)加入block集合// 1.1 应用信号屏蔽字(阻塞SIGINT)int n = sigprocmask(SIG_SETMASK, &block, &oblock);assert(n == 0); // 确保系统调用成功std::cout << "block 2 signal success" << std::endl;// 2. 主循环:持续检查未决信号while (true) {sigset_t pending;sigemptyset(&pending);n = sigpending(&pending); // 获取当前未决信号集assert(n == 0);// 3. 打印未决信号位图printPending(pending);sleep(1); // 每秒检查打印一次pending位图}return 0;
}
注:
我们说过,pending类型是信号集,不是一个简单位图,所以你想验证信号集这个结构体中的位图变量的某一位是否存在,不能直接和1按位与操作,而是有专门的接口sigismember!!
运行效果:
解释:因为2号信号被阻塞了,所以不管你是在另一个窗口中发送2号信号,还是本窗口中使用ctrl +c组合键,都不会其效果,位图的变化也证明了这一点!
下面我们再升级一下场景,我们在20秒后解除对2号信号的阻塞,然后再用2号信号让此进程退出!
代码如下:
#include <iostream>
#include <signal.h>
#include <assert.h>
#include <unistd.h>
#include <sys/types.h>// 打印pending位图
void printPending(sigset_t &pending)
{int i = 1;// 倒序遍历所有常规信号(31→1)for (i = 31; i > 0; i--){// 使用sigismember检查信号i是否在pending集合中if (sigismember(&pending, i)){printf("1 "); // 信号未决(被阻塞且已到达)}else{printf("0 "); // 信号非未决}}printf("\n"); // 换行结束当前位图输出
}int main()
{//打印pid 方便kill指令使用int pid = getpid();printf("%d\n",pid);//设置两个信号集sigset_t set, oset;//清空两个信号集sigemptyset(&set);sigemptyset(&oset);//向信号集set中添加2号信号。sigaddset(&set, 2); sigprocmask(SIG_SETMASK, &set, &oset); // 用block位图接口,来实现阻塞2号信号//定义一个信号集 用户存储sigpending接口返回的当前pending位图sigset_t pending;sigemptyset(&pending);//情空int count = 0;while (1){sigpending(&pending); // 获取pending位图 存储在变量pending中printPending(pending); // 打印pending位图sleep(1);//一秒count++;//累加if (count == 20)//20秒时{sigprocmask(SIG_SETMASK, &oset, NULL); // 对2号信号解除阻塞printf("恢复信号屏蔽字\n");}}return 0;
}
运行效果1:
解释:我们的语句"恢复信号屏蔽字\n"并没有打印,这是因为,你在阻塞期间,已经发送了2号信号,等取消对2号信号阻塞的时候,信号直接会被处理,所以检查还没打印,就已经终止了
运行效果2:
如果你前20秒,都没有发送2号信号,而是等到20秒后再发送,你能看见打印的语句:
所以,为了方便测试,其实你可以2号信号的自定义处理动作,这样也不会直接退出了,不再演示
6:一些细节
Q1:递达信号的时候,就一定会把该信号对应的pending位图清0,那是先递达还是先清零呢?
A1:先清零 再递达!
①:验证很简单,我们举个例子就行,对某个信号进行自定义,在handler函数中一开始就打印一次pending ,此时若是全0,则代表是先清零,反之先递达。
②:本质就是探究,到底是执行完动作,再清零,还是先清零,再执行动作,而进入handler代表还没执行完动作,此时打印出pending位图,就能知晓答案了!
例子:
#include <iostream>
#include <signal.h>
#include <assert.h>
#include <unistd.h>/*** 打印信号集位图(1-31号信号)* @param set 信号集指针*/
void PrintSig(sigset_t* set) {for (int i = 1; i <= 31; i++) {std::cout << (sigismember(set, i) ? "1 " : "0 ");}std::cout << std::endl;
}/*** 信号处理函数* @param signo 收到的信号编号*/
void handler(int signo) {sigset_t pending;sigemptyset(&pending);// 获取当前未决信号集(注意:此时正在处理2号信号)int n = sigpending(&pending);assert(n == 0);// 打印信号递送状态std::cout << "递送中... ";PrintSig(&pending); // 关键观察点:pending中2号信号的状态std::cout << signo << " 号信号被递达处理..." << std::endl;
}int main() {// 注册信号处理函数(示例为SIGINT)signal(SIGINT, handler); // 2号信号std::cout << "PID: " << getpid() << std::endl;std::cout << "按Ctrl+C测试信号处理..." << std::endl;while (true) {sleep(1); // 保持程序运行}return 0;
}
运行效果:
解释:的确是先清零,再递达!
Q2:全部信号都能被阻塞吗?
A2:9和18号不能被阻塞,
①:其次9和18号不仅不能被阻塞
②:也不能被捕捉无法通过
signal()
或sigaction()
注册处理函数。③:也不能被忽略无法设置为
SIG_IGN
。
因为这是OS留的后路,为了安全,再出现任何情况的时候,都起码要有一个信号能够暂停进程,一个信号能够终止进程!
六:捕捉信号时态的切换
在之前我们说过,进程收到信号之后,并不是立即处理信号,而是在合适的时候,这里所说的合适的时候实际上就是指,从内核态切换回用户态的时候。
重点:不是特地为了处理信号,才进行态的切换,而是因为其他原因造成态切换的时候,才会趁机其处理信号!
1:用户态和内核态
- 内核态通常用来执行操作系统的代码,是一种权限非常高的状态。
- 用户态是一种用来执行普通用户代码的状态,是一种受监管的普通状态。
Q:为什么执行我们写的handler方法,还要从内核态切换回用户态?
A:首先,内核态 OS肯定是能够执行你的方法的,但是为什么不再内核态下执行呢?
①:OS不相信用户,不想让用户能够访问内核数据,用户只能通过系统调用接口来访问内核数据!
②:所以怎么可能OS内核态亲自出来访问你的方法?万一写的方法是非法?比如删除家目录的用户等....而OS本来就有权限执行这些非法的操作,所以为了避免出现权限漏洞!!所以再执行用户的任何自定义方法的时候必须切换为用户态!!
所以从用户态切换为内核态通常有如下几种情况:
- 需要进行系统调用时。
- 当前进程的时间片到了,导致进程切换。
- 产生异常、中断、陷阱等。
与之相对应,从内核态切换为用户态有如下几种情况:
- 系统调用返回时。
- 进程切换完毕。
- 异常、中断、陷阱等处理完毕。
注:所以就是因为这些原因,变成用户态的时候,趁机去处理信号
其中,由用户态切换为内核态我们称之为陷入内核。每当我们需要陷入内核的时,本质上是因为我们需要执行操作系统的代码,比如系统调用函数是由操作系统实现的,我们要进行系统调用就必须先由用户态切换为内核态。
2:再谈进程地址空间
Q:那来回的切换态,对于OS来说,岂不是非常的麻烦?
A:再谈进程地址空间!
每一个进程都有自己的进程地址空间,该进程地址空间由内核空间和用户空间组成:
- 用户所写的代码和数据位于用户空间,通过用户级页表与物理内存之间建立映射关系。
- 内核空间存储的实际上是操作系统代码和数据,通过内核级页表与物理内存之间建立映射关系。
内核级页表是一个全局的页表,它用来维护操作系统的代码与进程之间的关系。因此,在每个进程的进程地址空间中,用户空间是属于当前进程的,每个进程看到的代码和数据是完全不同的,但内核空间所存放的都是操作系统的代码和数据,所有进程看到的都是一样的内容。
注意: 当你访问用户空间时你必须处于用户态,当你访问内核空间时你必须处于内核态。
这样对于OS来说,态的切换,效率也会变高了!
Q:如何理解进程切换?
A:
- 在当前进程的进程地址空间中的内核空间,找到操作系统的代码和数据。
- 执行操作系统的代码,将当前进程的代码和数据剥离下来,并换上另一个进程的代码和数据。
3:信号捕捉态的切换图
①:当我们在执行主控制流程的时候,可能因为某些情况而陷入内核,当内核处理完毕准备返回用户态时,就需要进行信号pending的检查。(此时仍处于内核态,有权力查看当前进程的pending位图)
②:在查看pending位图时,如果发现有未决信号,并且该信号没有被阻塞,且该待处理信号的动作是用户自定义的,则在下次变成用户态的时候,就会去执行用户的方法
③:如果待处理信号的处理动作是默认或者忽略,则执行该信号的处理动作后清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,从主控制流程中上次被中断的地方继续向下执行即可。
信号动作是默认或忽略:
信号动作是自定义:
注:sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。
巧记:类似正无穷号:
其中,该图形与直线有几个交点就代表在这期间有几次状态切换,而箭头的方向就代表着此次状态切换的方向,图形中间的圆点就代表着检查pending表
4:捕捉信号的另一个方法 sigaction
捕捉信号除了用前面用过的signal函数之外,我们还可以使用sigaction函数对信号进行捕捉,sigaction函数的函数原型如下:
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
参数说明:
- signum代表指定信号的编号。
- 若act指针非空,则根据act修改该信号的处理动作。
- 若oldact指针非空,则通过oldact传出该信号原来的处理动作。
返回值:
· 该函数调用成功返回0,出错返回-1。
其中,参数act和oldact都是结构体指针变量,该结构体的定义如下:
struct sigaction {void(*sa_handler)(int);void(*sa_sigaction)(int, siginfo_t *, void *);sigset_t sa_mask;int sa_flags;void(*sa_restorer)(void);
};
一般这样使用:
struct sigaction {void(*sa_handler)(int);void(*sa_sigaction)(int, siginfo_t *, void *);//不用sigset_t sa_mask;int sa_flags;//设置为0即可void(*sa_restorer)(void);//不用
};
解释:所以只有第一个变量和第三个需要使用!
结构体的第一个成员sa_handler:
- 将sa_handler赋值为常数SIG_IGN传给sigaction函数,表示忽略信号。
- 将sa_handler赋值为常数SIG_DFL传给sigaction函数,表示执行系统默认动作。
- 将sa_handler赋值为一个函数指针,表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数。
结构体的第三个成员sa_mask:
● 首先需要说明的是,当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字。这样就保证了在处理某个信号时,如果这种信号再次产生,它会被阻塞到当前处理结束为止。
● 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用
sa_mask
字段说明这些需要额外屏蔽的信号。当信号处理函数返回时,自动恢复原来的信号屏蔽字。
注:我们使用sigaction接口的时候,要先申请此结构体,并且根据要求初始化一和三成员变量!
场景1:
下面我们用sigaction函数对2号信号进行了捕捉,将2号信号的处理动作改为了自定义的打印动作,并在执行一次自定义动作后将2号信号的处理动作恢复为原来默认的处理动作。
体现了sigactio相较于signal,其能够取消自定义的效果
代码:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>// 定义全局的sigaction结构体(新旧动作)
struct sigaction act, oact;// 自定义信号处理函数
void handler(int signo) {printf("get a signal:%d\n", signo); // 打印收到的信号编号sigaction(2, &oact, NULL); // 关键点:恢复2号信号的原始处理方式
}int main() {// 初始化两个sigaction结构体(全部清零)memset(&act, 0, sizeof(act));memset(&oact, 0, sizeof(oact));// 设置新的信号处理动作act.sa_handler = handler; // 指定结构体的第一个参数为我们自定义handler函数的地址act.sa_flags = 0; // 不设置特殊标志 第四个变量设为0即可sigemptyset(&act.sa_mask); // 清空sa_mask(不额外屏蔽其他信号)// 注册信号处理(2号信号SIGINT)// 将新动作act应用到SIGINT,旧动作保存到oactsigaction(2, &act, &oact);// 主循环while (1) {printf("I am a process...\n");sleep(1);}return 0;
}
解释:
sigaction(2, &oact, NULL);
-
&oact
:这是你之前保存的 原始配置(含默认处理方式)。 -
NULL
:表示你不需要再保存当前配置(因为目的是恢复旧配置,无需备份当前状态)。
所以系统会将 SIGINT
的处理方式重置为 oact
中保存的原始行为(即默认终止)。
运行效果:
解释:运行代码后,第一次向进程发送2号信号,执行我们自定义的打印动作,当我们再次向进程发送2号信号,就执行该信号的默认处理动作了,即终止进程。
场景2:
我们把2号信号设置为自定义之后,让自定义的handler函数内部死循环打印pending位图,但是此时若是使用3号4号5号,都仍然可以终止进程效果,所以我们在main中用sa_mask成员变量中把3号4号5号也进行阻塞了,这样2,3,4,5都无法终止进程了,且我们可以看到pending位图的1的增加
体现了sigactio相较于signal接口,可以同时阻塞多个信号的功能
代码:
#include <iostream>
#include <string.h>
#include <unistd.h>
#include <signal.h>// 打印pending位图
void printPending(sigset_t &pending)
{int i = 1;// 倒序遍历所有常规信号(31→1)for (i = 31; i > 0; i--){// 使用sigismember检查信号i是否在pending集合中if (sigismember(&pending, i)){printf("1 "); // 信号未决(被阻塞且已到达)}else{printf("0 "); // 信号非未决}}printf("\n"); // 换行结束当前位图输出
}void handler(int signo) {std::cout << "signal : " << signo << std::endl;sigset_t pending;sigemptyset(&pending); // 初始化pending信号集// 持续获取并打印未决信号集while(true) {sigpending(&pending); // 获取当前未决信号printPending(pending); // 打印位图sleep(1);}
}int main() {struct sigaction act, oact;// 设置信号处理函数act.sa_handler = handler;act.sa_flags = 0; // 不设置特殊标志// 配置sa_mask:在处理2号信号时,额外屏蔽3/4/5号信号sigemptyset(&act.sa_mask); // 先清空sigaddset(&act.sa_mask, 3); // 屏蔽SIGQUIT (Ctrl+\)sigaddset(&act.sa_mask, 4); // 屏蔽SIGILLsigaddset(&act.sa_mask, 5); // 屏蔽SIGTRAP// 注册2号信号(SIGINT)的处理方式sigaction(2, &act, &oact);// 主循环保持进程运行while(true) sleep(1);return 0;
}
解释:符合预期,只有9号信号才终止掉了进程!
sigaction
相比 signal:
特性 | signal | sigaction |
---|---|---|
信号处理函数控制 | 仅能设置处理函数 | 可指定处理函数(sa_handler )或标志位(sa_flags ) |
信号屏蔽 | 无自动屏蔽机制 | 可通过 sa_mask 屏蔽指定信号 |
行为标志 | 无 | 支持 SA_RESTART (自动重启系统调用)等关键标志 |
可移植性 | 不同系统行为不一致(如BSD vs. System V) | 标准化,行为一致 |
旧动作保存 | 部分系统不支持 | 必须提供 oldact 参数保存旧动作 |
①:当信号处理函数执行时,内核默认阻塞同一信号,防止递归调用(signal
在某些系统中不保证这一点)。
②:sigaction还可
通过 sa_mask
可阻塞其他信号,确保关键代码段不被中断。
③:优先使用 sigaction
:功能全面、行为可靠、线程安全。避免 signal
:除非有极端简洁需求或兼容性约束。
七:三个概念的补充
1:可重入函数
如上图:main中调用insert函数向链表中插入结点node1,此时某信号的自定义函数中也调用了insert函数向链表中插入结点node2,此时就会出问题!
1、首先,main函数中调用了insert函数,想将结点node1插入链表,但插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回到用户态之前检查到有信号待处理,于是切换到sighandler函数。
2、而sighandler函数中也调用了insert函数,将结点node2插入到了链表中,插入操作完成第一步后的情况如下:
3、当结点node2插入的两步操作都做完之后从sighandler返回内核态,此时链表的布局如下:
4、再次回到用户态就从main函数调用的insert函数中继续往下执行,即继续进行结点node1的插入操作:
此时,main函数和sighandler函数先后向链表中插入了两个结点,但最后只有node1结点真正插入到了链表中,而node2结点就再也找不到了,造成了node2节点的内存泄漏!!
出错的代码逻辑如下:
像上例这样,insert函数被不同的控制流调用(main函数和sighandler函数使用不同的堆栈空间,它们之间不存在调用与被调用的关系,是两个独立的控制流程),有可能在第一次调用还没返回时就再次进入该函数,我们将这种现象称之为"重入"!
像上面这种inset函数,其叫作不可重入函数!
反之,如果一个函数只访问自己的局部变量或参数,则称之为可重入函数!
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标志I/O库函数,因为标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
2:volatile
volatile是C语言的一个关键字,该关键字的作用是保持内存的可见性。
Q:什么叫内存的可见性?
A:
-
若变量始终从内存读取(无缓存优化),则时刻看到的都是最新值 → 保持内存可见性。
-
若变量被缓存(如寄存器或CPU缓存),其可能读到旧值 → 内存可见性不保证。
例子:
我们的while一直死循环,且什么都不做,所以flag是内存可见性不保证的,也就是即使我们handler函数中将flag置为1,此时循环应该终止,但是由于CPU读取flag是一直在寄存器中读的,所以即使内存中的flag值被修改了,也不会终止循环!
#include <stdio.h>
#include <signal.h>int flag = 0;void handler(int signo)
{printf("get a signal:%d\n", signo);flag = 1;
}
int main()
{signal(2, handler);while (!flag);printf("Proc Normal Quit!\n");return 0;
}
运行结果1:
解释:但是却还是终止了循环,并没有演示出flag是内存可见性不保证的效果,这是因为我们的编译时的优化程度不够!
编译代码时携带-
O3
选项使得编译器的优化级别最高,此时再运行该代码,就算向进程发生2号信号,该进程也不会终止。(注意是英文大写O,而不是数字0)
运行效果2:
解释:符合预期,演示除了错误!
所以对于这种变量,我们加上关键字volatile即可!
#include <stdio.h>
#include <signal.h>volatile int flag = 0;void handler(int signo)
{printf("get a signal:%d\n", signo);flag = 1;
}
int main()
{signal(2, handler);while (!flag);printf("Proc Normal Quit!\n");return 0;
}
运行效果:
解释:加上了volatile,即使最高级别优化都会保持内存可见性!!
3:SIGCHLD信号
其实,子进程在终止时会给父进程发生SIGCHLD信号(17号信号),只不过对该信号的默认处理动作是忽略罢了
例子验证:
父进程一直死循环,等待3秒后子进程退出,查看是否会执行自定义的17号信号!
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <sys/wait.h>void handler(int signo)
{printf("get a signal: %d\n", signo);
}
int main()
{signal(SIGCHLD, handler);if (fork() == 0){//childprintf("child is running,%d\n", getpid());sleep(3);exit(1);}//fatherwhile (1);return 0;
}
运行效果:
解释:符合预期,的确是执行了17号信号!
所以,父进程在自定义SIGCHLD信号的函数内部调用wait或waitpid函数清理子进程即可。,这样父进程就只需专心处理自己的工作,不必关心子进程了!
代码:
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <sys/wait.h>void handler(int signo)
{printf("get a signal: %d\n", signo);int ret = 0;while ((ret = waitpid(-1, NULL, WNOHANG)) > 0){printf("wait child %d success\n", ret);}
}
int main()
{signal(SIGCHLD, handler);if (fork() == 0){//childprintf("child is running, %d\n", getpid());sleep(3);exit(1);}//fatherwhile (1);return 0;
}
运行效果:
解释:完全不用管子进程,退出即马上回收!
代码中的细节:
- SIGCHLD属于普通信号,记录该信号的pending位只有一个,如果在同一时刻有多个子进程同时退出,那么在handler函数当中实际上只清理了一个子进程,因此在使用waitpid函数清理子进程时需要使用while不断进行清理。
- 使用waitpid函数时,需要设置
WNOHANG
选项,即非阻塞式等待,否则当所有子进程都已经清理完毕时,由于while循环,会再次调用waitpid函数,此时就会在这里阻塞住。
在UNIX系统中,除了常规的wait/waitpid方法外,还存在另一种避免子进程僵尸进程的特殊机制:父进程通过将SIGCHLD信号的处理方式显式设置为SIG_IGN(忽略),可使内核自动回收终止的子进程资源,而不会产生僵尸进程。
但这是一个特列。此方法对于Linux可用,但不保证在其他UNIX系统上都可用!!
例子:
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>int main()
{signal(SIGCHLD, SIG_IGN);if (fork() == 0){//childprintf("child is running, %d\n", getpid());sleep(3);exit(1);}//fatherwhile (1);return 0;
}
运行效果:
解释:子进程从创建到回收,从未变成Z状态(僵尸)!