当前位置: 首页 > news >正文

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个信号行为如下:

编号信号名称默认行为常见触发场景
1SIGHUP终止进程终端断开或控制进程退出(如 SSH 会话断开)
2SIGINT终止进程用户按下 Ctrl+C(中断进程)
3SIGQUIT终止并生成 core 文件用户按下 Ctrl+\(退出进程并生成调试转储)
4SIGILL终止并生成 core 文件进程执行了非法指令(如错误的 CPU 指令)
5SIGTRAP终止并生成 core 文件调试断点触发(如 ptrace 调试器)
6SIGABRT终止并生成 core 文件程序调用 abort() 主动终止(如断言失败)
7SIGBUS终止并生成 core 文件内存访问错误(如对齐问题、不存在的物理地址)
8SIGFPE终止并生成 core 文件算术错误(如除零、浮点溢出)
9SIGKILL强制终止(不可捕获)kill -9 或系统强制杀死进程(无法被阻塞/忽略)
10SIGUSR1终止进程用户自定义信号 1(常用于进程间通信)
11SIGSEGV终止并生成 core 文件段错误(如访问非法内存地址 NULL
12SIGUSR2终止进程用户自定义信号 2(用途同 SIGUSR1
13SIGPIPE终止进程向已关闭的管道/套接字写入数据(如 printf 后管道读者退出)
14SIGALRM终止进程定时器超时(如 alarm() 或 setitimer() 触发)
15SIGTERM终止进程默认的 kill 命令信号(友好终止,可捕获处理)
16SIGSTKFLT终止进程协处理器栈错误(罕见,现代系统已弃用)
17SIGCHLD忽略子进程状态变化(退出/暂停/恢复)
18SIGCONT继续进程恢复被暂停的进程(如 fg 命令)
19SIGSTOP暂停进程(不可捕获)用户按下 Ctrl+Z 或 kill -STOP(无法被阻塞/忽略)
20SIGTSTP暂停进程用户按下 Ctrl+Z(可捕获处理)
21SIGTTIN暂停进程后台进程尝试读取终端输入
22SIGTTOU暂停进程后台进程尝试向终端输出
23SIGURG忽略套接字收到紧急数据(如带外数据 OOB
24SIGXCPU终止并生成 core 文件进程超出 CPU 时间限制(通过 setrlimit 设置)
25SIGXFSZ终止并生成 core 文件文件大小超出限制(如 ulimit -f
26SIGVTALRM终止进程虚拟定时器超时(setitimer(ITIMER_VIRTUAL)
27SIGPROF终止进程性能分析定时器超时(setitimer(ITIMER_PROF)
28SIGWINCH忽略终端窗口大小改变(如调整 xterm 窗口)
29SIGIO终止进程文件描述符的异步 I/O 事件(需配置 fcntl
30SIGPWR终止进程电源故障(通常由 init 或 systemd 处理)
31SIGSYS终止并生成 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:要发送的信号编号(如 SIGINTSIGTERM)。

  • 返回值

    • 成功返回 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:三张位图的总结:

总结一下:

  1. 在block位图中,比特位的位置代表某一个信号,比特位的内容代表该信号是否被阻塞。
  2. 在pending位图中,比特位的位置代表某一个信号,比特位的内容代表是否收到该信号。
  3. handler表本质上是一个函数指针数组,数组的下标代表某一个信号,数组的内容代表该信号递达时的处理动作,处理动作包括默认、忽略以及自定义。
  4. 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_BLOCKset包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask|set
SIG_UNBLOCKset包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于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位图接口的组合使用的效果!

场景:

  1. 先用上述的函数将2号信号进行阻塞
  2. 使用sigpending函数获取此时的pending,全是0
  3. 再使用kill命令或组合按键向进程发送2号信号
  4. 此时2号信号会一直被阻塞,并一直处于pending(未决)状态
  5. 使用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本来就有权限执行这些非法的操作,所以为了避免出现权限漏洞!!所以再执行用户的任何自定义方法的时候必须切换为用户态!!

所以从用户态切换为内核态通常有如下几种情况:

  1. 需要进行系统调用时。
  2. 当前进程的时间片到了,导致进程切换。
  3. 产生异常、中断、陷阱等。

与之相对应,从内核态切换为用户态有如下几种情况:

  1. 系统调用返回时。
  2. 进程切换完毕。
  3. 异常、中断、陷阱等处理完毕。

注:所以就是因为这些原因,变成用户态的时候,趁机去处理信号

其中,由用户态切换为内核态我们称之为陷入内核。每当我们需要陷入内核的时,本质上是因为我们需要执行操作系统的代码,比如系统调用函数是由操作系统实现的,我们要进行系统调用就必须先由用户态切换为内核态。

2:再谈进程地址空间

Q:那来回的切换态,对于OS来说,岂不是非常的麻烦?

A:再谈进程地址空间!

每一个进程都有自己的进程地址空间,该进程地址空间由内核空间和用户空间组成:

  • 用户所写的代码和数据位于用户空间,通过用户级页表与物理内存之间建立映射关系。
  • 内核空间存储的实际上是操作系统代码和数据,通过内核级页表与物理内存之间建立映射关系。

内核级页表是一个全局的页表,它用来维护操作系统的代码与进程之间的关系。因此,在每个进程的进程地址空间中,用户空间是属于当前进程的,每个进程看到的代码和数据是完全不同的,但内核空间所存放的都是操作系统的代码和数据,所有进程看到的都是一样的内容。

注意: 当你访问用户空间时你必须处于用户态,当你访问内核空间时你必须处于内核态。

这样对于OS来说,态的切换,效率也会变高了!

Q:如何理解进程切换?

A:

  1. 在当前进程的进程地址空间中的内核空间,找到操作系统的代码和数据。
  2. 执行操作系统的代码,将当前进程的代码和数据剥离下来,并换上另一个进程的代码和数据。

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:

特性signalsigaction
信号处理函数控制仅能设置处理函数可指定处理函数(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函数,其叫作不可重入函数!

反之,如果一个函数只访问自己的局部变量或参数,则称之为可重入函数!

如果一个函数符合以下条件之一则是不可重入的:

  1. 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
  2. 调用了标志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;
}

运行效果:

解释:完全不用管子进程,退出即马上回收!

代码中的细节:

  1. SIGCHLD属于普通信号,记录该信号的pending位只有一个,如果在同一时刻有多个子进程同时退出,那么在handler函数当中实际上只清理了一个子进程,因此在使用waitpid函数清理子进程时需要使用while不断进行清理。
  2. 使用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状态(僵尸)!

http://www.lryc.cn/news/612737.html

相关文章:

  • 如何在 VS Code 中进行 `cherry-pick`
  • 计算机毕业设计java疫情防控形势下的高校食堂订餐管理系统 高校食堂订餐管理系统在疫情防控背景下的设计与实现 疫情防控期间高校食堂线上订餐管理平台
  • 【已解决】-bash: mvn: command not found
  • 2025数字马力一面面经(社)
  • [优选算法专题一双指针——两数之和](双指针和哈希表)
  • git branch -a无法查看最新的分支
  • 垃圾桶满溢识别准确率↑32%:陌讯多模态融合算法实战解析
  • 计算机基础·linux系统
  • 一篇文章入门TCP与UDP(保姆级别)
  • Android Auto开发指南
  • KUKA库卡焊接机器人氩气节气设备
  • shell脚本while只循环一次,后续循环失效
  • Android 之 Kotlin 扩展库KTX
  • Linux SSH 日志分析详解:从原理到实战
  • 基于人眼视觉特性的相关图像增强基础知识介绍
  • k8s中pod如何调度?
  • Python day37
  • 【AI】——SpringAI通过Ollama本地部署的Deepseek模型实现一个对话机器人(二)
  • 数据结构(循环顺序队列)
  • java 生成pdf导出
  • iOS 文件管理实战指南,用户文件、安全访问与开发调试方案
  • OpenCv对图片视频的简单操作
  • Flutter 局部刷新方案对比:ValueListenableBuilder vs. GetBuilder vs. Obx
  • PPT漏斗图,让数据更美观!
  • OpenAI重磅发布:GPT最新开源大模型gpt-oss系列全面解析
  • 【沉浸式解决问题】mysql-connector-python连接数据库:RuntimeError: Failed raising error.
  • 计算机视觉(opencv)——图像本质、数字矩阵、RGB + 基本操作(实战一)
  • Java面试宝典:JVM的垃圾收集算法
  • Linux中chmod命令
  • JAVA,Maven分模块设计