【Linux系统】信号:认识信号 与 信号的产生
信号快速认识
1、生活角度的信号
异步:你是老师正在上课,突然有个电话过来资料到了,你安排小明过去取资料,然后继续上课,则小明取资料这个过程就是异步的
同步:小明取快递,你停下等待小明回来再接着上课,这就是同步
2、预备知识
1、为什么能识别信号:你怎么能识别信号呢? 识别信号,是内置的。进程认识信号,是程序员内置的特性
2、如何处理信号:信号产生之后,怎么处理你知道吗? 知道! 信号的处理方法,在信号产生之前,已经准备好了。
3、信号是否立即处理:处理信号? 立即处理吗? 我在做我的事情,优先级很高,信号处理,可能并不是立即处理。合适的时候:我就需要记录信号,等待下次处理
4、处理信号的方式:怎么处理信号呀? a. 默认行为 b. 忽略信号 c. 自定义动作
3、知识框架
进程信号分为三个部分:
- 信号产生
- 信号保存
- 信号处理
信号产生
1、键盘产生
1.1 前台进程和后台进程的概念和区别
前台进程和后台进程是操作系统中用于描述进程运行状态的两个概念。它们的区别主要在于进程与终端的交互方式以及系统的调度策略。下面详细解释这两个概念及其区别:
前台进程
- 定义:
- 前台进程是指当前正在与用户交互的进程。用户可以通过终端输入命令来启动或控制这些进程。
- 前台进程独占终端,用户必须等待前台进程完成或暂停才能继续输入新的命令。
- 特点:
- 用户可以直接与前台进程进行交互,例如输入数据或接收输出。
- 前台进程通常会响应用户的键盘输入,如 Ctrl+C(中断)、Ctrl+Z(暂停)等。
- 前台进程在运行期间会占用终端,用户不能在同一终端上启动其他前台进程。
后台进程
定义:
- 后台进程是指在后台运行的进程,不与用户直接交互。这些进程可以在用户不知情的情况下运行,不会占用终端。
- 后台进程通常用于执行长时间运行的任务或服务,如数据库服务器、Web服务器等。
特点:
- 后台进程不独占终端,用户可以在同一终端上启动多个后台进程。
- 后台进程通常不会响应用户的键盘输入,除非它们被配置为监听特定的信号。
- 后台进程可以通过任务调度器(如 cron)或守护进程管理工具(如 systemd)来启动和管理。
示例:
- 使用
&
符号将命令放到后台运行,例如sleep 100 &
。- 使用
nohup
命令使进程在后台运行并且不受终端关闭的影响,例如nohup myscript.sh &
。- 使用
bg
命令将已暂停的前台进程放到后台继续运行。
如何管理前后台进程
将前台进程放到后台:
使用
Ctrl+Z
暂停前台进程,然后使用bg
命令将其放到后台继续运行。例如:
sleep 100 # 启动一个前台进程 ^Z # 暂停前台进程 bg # 将暂停的进程放到后台继续运行
将后台进程放到前台:
使用
fg
命令将后台进程放到前台。例如:
sleep 100 & # 启动一个后台进程 jobs # 查看当前终端中的所有作业 fg %1 # 将作业编号为1的后台进程放到前台
查看当前的前后台进程:
- 使用
jobs
命令查看当前终端中的所有作业。- 使用
ps
命令查看系统中的所有进程。
1.2 拓展:默认作业
在 Unix 和 Linux 系统中,
jobs
命令的输出中,默认作业(即最后一个在后台暂停的作业)通常会有一个特殊的标记。这个标记通常是+
号,表示它是当前的默认作业。如果还有其他作业,它们可能会被标记为-
号,表示它们是上一个默认作业。
杀死默认作业: 使用kill
命令终止默认作业。默认作业可以用%+
来表示。
c++ kill %+
1.3 nohup
命令
nohup
是一个常用的 Unix/Linux 命令,用于使进程在后台运行并且不受终端关闭的影响。具体来说,nohup
命令可以让进程忽略挂断(SIGHUP)信号,这样即使用户退出终端或断开连接,进程仍然会继续运行。
nohup
的全称是 “no hang up”。这个名称来源于它的功能:使进程在启动后忽略挂断(SIGHUP)信号,从而能够在终端关闭后继续运行。
no: 表示“不”或“没有”。
hang up (SIGHUP): 挂断信号,通常在用户退出终端或断开远程连接时发送给所有与该终端关联的进程。
SIGHUP 信号
- SIGHUP(Hang Up)信号通常在用户退出终端或断开远程连接时发送给所有与该终端关联的进程。
- 默认情况下,进程接收到 SIGHUP 信号会终止运行。
nohup
命令的作用
忽略 SIGHUP 信号:
nohup
命令会修改进程的行为,使其忽略 SIGHUP 信号,这样即使终端关闭,进程也不会被终止。
后台运行:
nohup
命令本身并不会默认将进程放到后台运行,但它通常与&
符号一起使用,以便将进程放到后台运行。前台运行:如果你只是使用
nohup
命令而没有添加&
符号,进程将在前台运行。这意味着你会看到进程的输出,并且终端会被占用,直到进程结束或你手动停止它
使用方法
nohup command [arguments] &
command
:你要运行的命令。
arguments
:命令的参数。
&
:将命令放到后台运行(可选)。
重定向输出
默认情况下,
nohup
会将标准输出和标准错误输出重定向到一个名为nohup.out
的文件中。你可以指定不同的输出文件:nohup myscript.sh > output.log 2>&1 &
这条命令会将标准输出和标准错误输出重定向到
output.log
文件中。
注意事项
进程管理:
- 使用
nohup
启动的进程可以在后台长时间运行,但你需要确保这些进程不会消耗过多的系统资源。输出文件:
如果你不希望输出被重定向到
nohup.out
文件,可以将输出重定向到/dev/null
来丢弃输出:nohup myscript.sh > /dev/null 2>&1 &
1.4 实验 nuhup
命令时遇到的问题
我运行下面的程序,使用 nuhup
命令默认输出到 nuhup.out
文件,但是好像 cat nuhup.out
查询时没有输出结果
这是因为在默认情况下,标准输出在连接到终端时是行缓冲的,而在连接到文件或管道时是全缓冲的。因此可以主动使用 fflush(stdout)
刷新
#include<iostream>
#include<unistd.h>
using namespace std;
int main()
{
while(1)
{
cout << "hello world!" << '\n';
sleep(1);
}
return 0;
}
1.5 补充一些前后台进程的操作知识
五种命令及用法
在 Unix/Linux 系统中,你可以使用多种命令来管理进程的前台和后台运行状态。以下是一些常用命令及其用法:
1. 暂停进程
Ctrl + Z
功能:发送一个
SIGTSTP
(Signal TSTop)信号给当前的前台进程,暂停(挂起)当前正在运行的进程,并将其放到后台。示例:
cat ^Z # 按 Ctrl + Z 暂停 `cat` 命令
2. 查看后台任务
jobs
命令
功能:列出当前终端会话中所有的后台任务及其状态。
示例:
jobs
jobs -l
命令
功能:列出所有正在运行的作业,并显示每个作业的进程 PID。
示例:
jobs -l
3. 将任务恢复到前台
fg
命令
功能:将后台任务恢复到前台继续运行。
示例:
如果只有一个后台任务,直接使用
fg
命令:fg
如果有多个后台任务,可以通过指定任务编号来恢复特定的任务。例如,恢复任务编号为
[1]
的任务:fg %1
4. 将后台任务放到后台继续运行
bg
命令
功能:将暂停的任务恢复到后台继续运行。
示例:
如果有多个后台任务,可以通过指定任务编号来恢复特定的任务。例如,恢复任务编号为
[1]
的任务:bg %1
5. 直接将命令放到后台运行
&
符号
功能:将命令放到后台运行。
示例:
假设你有一个长时间运行的命令,如
sleep 60
,你可以在命令末尾加上&
来将其放到后台运行:sleep 60 &
终端会立即返回提示符,并显示类似以下的输出:
[1] 12345
这里,
[1]
是后台任务的编号,12345
是该后台进程的 PID(进程标识符)。总结
Ctrl + Z
:暂停当前前台进程并将其放到后台。jobs
:列出当前终端会话中的所有后台任务。jobs -l
:列出所有后台任务及其 PID。fg
:将后台任务恢复到前台继续运行。bg
:将暂停的任务恢复到后台继续运行。&
:将命令直接放到后台运行。通过这些命令,你可以灵活地管理进程的前台和后台运行状态,提高工作效率。
1.6 键盘 ctrl+c
:2号信号 SIGINIT
该命令用于终止正在运行的前台进程,本质是向该进程发送信号
证明是信号
系统调用:signal()
用于自定义信号处理,当信号发送给进程,进程会有一些默认固定的信号的处理方法,如kill -9
信号就是用于杀死该进程
而该系统调用可以自定义信号处理,使其处理信号时执行自定义的方法,而不是系统规定好的默认处理方法
用这个来检测 ctrl+c
是信号
前面讲解的信号处理,更准确来说是:信号捕捉
而系统调用:signal()
是信号自定义捕捉
Ctrl + C
发送的是 SIGINT
(Signal Interrupt)信号。这个信号通常用于请求程序中断当前的操作,通常是终止或停止一个正在运行的进程。
SIGINT
:这是一个中断信号,通常由用户通过按下Ctrl + C
组合键来发送。这个信号的默认行为是终止进程。- 用途:
SIGINT
通常用于优雅地终止一个正在运行的进程,允许进程在退出前进行一些清理工作,如释放资源、保存状态等。
kill -l
:查看系统中常见信号,其中 1~31 号是我们学习需要的信号,其他 32~64 号信号是实时信号,我们不学习
Ctrl + C
被OS接收并解释成为2号信号 SIGINT
使用系统调用:signal()
可以直接传递信号编号,或则信号名称:因为这些信号名底层其实就是宏,对应着信号编号,因此意义一致
signal(2, handler);
signal(SIGINT, handler);
代码如下:在循环打印语句时,我不断 Ctrl + C
,程序就会信号捕捉到该信号并执行我的自定义信号捕捉函数:打印语句 "get a signal, signum: 2"
#include<iostream>
#include<unistd.h>
#include <signal.h>
using namespace std;
typedef void (*sighandler_t)(int);
void handler(int signum)
{
cout << "get a signal, signum: " << signum << '\n';
}
int main()
{
//sighandler_t signal(int signum, sighandler_t handler);
signal(2, handler);
while(1)
{
cout << "hello world!" << '\n';
sleep(1);
}
return 0;
}
代码运行结果如下:
如何终止这个进程:ctrl+\
(后面解释)
这就是:将2号信号的默认终止->执行自定义方法:handler
当对应的信号被触发,内核会将对应的信号编号,传递给自定义方法
查看每种信号的默认处理方法
命令:man 7 signal
,然后一直向下翻
其中,2号信号 SIGINT
的行为为 Term
就是 terminal
终止的意思
1.7 键盘ctrl+\
:3号信号 SIGQUIT
3号信号 SIGQUIT
就是键盘:ctrl+\
验证一下:将3号信号也自动捕捉一下
代码如下
#include<iostream>
#include<unistd.h>
#include <signal.h>
using namespace std;
typedef void (*sighandler_t)(int);
void handler(int signum)
{
cout << "get a signal, signum: " << signum << '\n';
}
int main()
{
//sighandler_t signal(int signum, sighandler_t handler);
signal(3, handler);
while(1)
{
cout << "hello world!" << '\n';
sleep(1);
}
return 0;
}
运行结果
1.8 问题:signal怎么不放在循环里面?
答:因为这个信号捕捉只需要设定一次,一次就能完成信号自定义处理的设置,参考文件重定向(只需要重定向一次)
1.9 问题:全部信号自定义捕捉,是否可以实现进程永生?
我们 man 7 signal
查看手册,可以发现,几乎所有信号都是用于终止进程,当我们使用系统调用 signal
将 1~31 号信号全捕捉了,岂不是这个进程就杀不死了?
代码:循环自定义捕捉所有信号
运行结果演示:在新终端上通过 kill
命令尝试杀掉该进程
新终端
旧终端
回答原因:
上面的运行结果展示了较大一部分的信号可以被捕捉以自定义处理,但还是有例外的。
其实操作系统设计者早就考虑了这点,这些信号中有几个信号是无法被捕捉的,其中 9
号信号一定不能被捕捉,因此我们一定可以通过 9
号信号杀死该进程
1.10 问题:在软件层面,如何理解键盘信号处理?
键盘如何发送信号给进程:准确来说,是键盘的组合键被操作系统先识别到的,因为操作系统是键盘真正的管理者,所以当你的进程在运行时,操作系统检测键盘上有没有信息,当键盘上 ctrl+c
,操作系统把 ctrl+c
这样的组合键解释成了对应的信号发送给进程
因为操作系统本身就属于硬件的管理者,硬件上做任何行为首先是一定是先给操作系统识别到,所以不要看到有人说键盘上可以发信号,其实最根本的是键盘先把对应的组合键信息交给了操作系统
1.11 进程如何保存信号
前面讲过:信号不一定要被立即处理,可以先保存下来,再合适的时机再处理
那进程如何保存信号:位图
信号产生有很多种,但是信号发送只能由 OS 来做
进程内部还会维护一张函数指针数组,对应每种信号需要的信号处理函数
通过信号位图查询哪些信号需要被处理,则到这张表中查询并执行对应的 处理方法
我们自定义信号处理函数也是写在这张表中(具体后面再讲解)
1.12 硬件中断机制
问题:OS怎么知道键盘上面有数据了?
有人说:操作系统会不断轮询键盘设备,当有数据产生时,就会被操作系统读取
答:其实这样非常消耗性能
实际上,根据冯诺依曼体系,硬件设备会和CPU中的控制器相连,当外部设备产生数据时,
外部设备会通过一些连接的针脚,给 cpu 发送我们对应的硬件中断,则操作系统就会知道外部设备数据就绪,然后操作系统就拷贝该数据,操作系统无需主动轮询所有的外设
- 硬件设备与中断控制器:
- 键盘等外部设备通过中断控制器(Interrupt Controller)与 CPU 连接。
- 中断控制器负责管理来自各种外部设备的中断请求,并将这些请求传递给 CPU。
- 中断请求:
- 当键盘上有按键被按下时,键盘控制器会生成一个中断请求(Interrupt Request, IRQ)。
- 这个中断请求通过中断控制器传递给 CPU。
- 中断处理:
- CPU 收到中断请求后,会暂停当前正在执行的指令,保存当前的状态(如寄存器内容),然后跳转到中断处理程序(Interrupt Service Routine, ISR)。
- 中断处理程序通常是由操作系统提供的,负责读取键盘缓冲区中的数据,并进行相应的处理。
- 处理完毕后,CPU 恢复之前的状态,继续执行被中断的指令。
其他硬件也是如此:
网卡:当网卡接收到网络数据时,通过中断控制器将硬件中断传递给 CPU,操作系统才会拷贝读取对应数据
磁盘:我们需要通过 LBA 地址在磁盘中寻找对应位置数据时,也是先让磁盘自己找,找到了,磁盘才会通过中断控制器将硬件中断传递给 CPU,操作系统才会拷贝读取对应数据
这样使得,硬件和OS并行执行
1.13 信号 vs 硬件中断
- 信号,纯软件,模拟中断的行为
- 硬件中断,纯硬件
硬件中断像不像一种信号机制,通过中断向CPU发送”信号“,告知有数据需要读取,其实软件层面的信号就是模仿了中断的行为
中断是信号的老祖宗
2、指令发信号
也就是通过命令行命令,至于一个命令如何让系统向对应进程发送信号,后文会提及
3、系统调用发信号
3.1 系统调用 kill
通过这个我们可以实现自制 kill 命令
代码演示使用该系统调用自制 kill 命令程序:
#include <iostream>
#include <sys/types.h>
#include <signal.h>
using namespace std;
int main(int argc, char *argv[])
{
if (argc != 3)
{
cout << "usage:./mykill signum pid\n";
return 1;
}
pid_t pid = atoi(argv[2]);
int sig = atoi(argv[1]+1); // 因为输入选项的形式为:-9,因此数字是从第二个字符开始的
// int kill(pid_t pid, int sig);
int n = kill(pid, sig);
if(n < 0){
cout << "kill error\n";
return 1;
}
return 0;
}
运行结果如下:启动一个休眠进程放到后台运行,通过自制 kill 命令,选择 9 号信号杀掉该进程
这里可以讲解一个结论:指令底层也是使用这个 kill 系统调用!
3.2 系统调用 raise
谁调用我,我就给自己发送某信号
代码演示使用该系统调用:
#include <iostream>
#include <signal.h>
int main(int argc, char *argv[]) {
int cnt = 5;
while(true) {
std::cout << "hahaha alive" << std::endl;
cnt--;
if(cnt <= 0) {
raise(9);
}
}
return 0;
}
运行结果如下:
3.3 系统调用 abort
实际上,这个系统调用已经被C库封装了
作用:谁调用我,我就给谁发 abort
信号终止掉谁,相当于给终止自己
代码演示使用该系统调用:
#include <iostream>
#include <signal.h>
#include <stdlib.h>
int main(int argc, char *argv[]) {
int cnt = 5;
while(true) {
std::cout << "hahaha alive" << std::endl;
cnt--;
if(cnt <= 0) {
abort();
}
}
return 0;
}
运行结果如下:
前面文章我们讲解了 信号产生的硬件条件,下面我们讲解一下,在软件层面,如何由软件触发信号:
4、软件条件
4.1 软件条件一:管道被信号杀死
匿名管道:当管道的读端关闭,操作系统会识别到当前管道里没有读端了,此时写端如果还要写就是一个非法操作,此时操作系统会把这个进程给杀掉
怎么杀掉该进程:其实是操作系统向我们的目标进程发送 13 号信号
管道是文件,文件是软件
管道文件写入条件不具备,就叫做软件条件不具备
那么软件条件的字面意思是:在操作系统当中某些对应的软件本身没有准备好或者条件不具备时,我们可以向目标进行发送信号
4.2 软件条件二:闹钟(重点)
alarm
是一个系统调用,用于在指定的时间后向进程发送一个 SIGALRM
信号。这个信号通常用于实现定时任务或超时机制。下面是 alarm
系统调用的详细解释和使用方法。
- 函数原型
unsigned int alarm(unsigned int seconds);
-
参数
seconds:指定在多少秒后发送
SIGALRM
信号。如果seconds
为 0,则取消任何已设置的定时器。 -
返回值
返回前一次调用alarm
设置的剩余时间(以秒为单位)。如果之前没有设置定时器,则返回 0。
多次调用alarm
:如果在定时器到期前再次调用alarm
,新的定时器会覆盖旧的定时器。alarm
函数会返回前一次设置的剩余时间。 -
信号处理
当指定的时间到达时,内核会向进程发送一个SIGALRM
信号。默认情况下,SIGALRM
信号会导致进程终止。但是,你可以在程序中设置信号处理函数来捕获和处理SIGALRM
信号。
上面这些概念,后续文章会对某些概念进行进一步讲解:
一秒的闹钟计数器
我们定一个一秒的闹钟计数器,一秒后,发送 SIGALRM
信号终止本进程:
#include <iostream>
#include <cstdio>
#include <signal.h>
#include <unistd.h>
int number = 0;
int main()
{
alarm(1); // 我自己,会在 1s 之后收到一个SIGALRM信号
while (true)
{
printf("count: %d\n", number++);
number++;
}
return 0;
}
在这一秒中,计数器 cnt 不断计数:最后大概一秒钟累计 9万多次
但是这样有点奇怪,我们的计算机是不是有点慢了!!!
问题:现代计算机计算速度能达到上亿级别的,为什么这个好像有点慢:
答:因为 printf 进行 IO交互,IO 是比较耗时的!!
新版本:去掉 IO,仅在最后打印
代码:捕获信号 SIGALRM
,自定义处理
#include <iostream>
#include <cstdio>
#include <signal.h>
#include <unistd.h>
int number = 0;
void handler(int sig)
{
printf("Received signal %d, count = %d\n", sig, number);
kill(getpid(), SIGKILL); // kill 自己给自己发信号,杀死自己
}
int main()
{
alarm(1); // 我自己,会在1S之后收到一个SIGALRM信号
signal(SIGALRM, handler);
while (true)
{
//printf("count: %d\n", number++);
number++;
}
return 0;
}
运行结果如下:直接累加到 4 亿多次
OS对定时器的管理
通过 alarm
设定个闹钟,最终其实在底层操作设置了一个定时器
如何去理解这个定时器:张三可以设置一个 5 秒的闹钟,李四可以设置一个 10 秒的闹钟……
操作系统内可以同时存在多个定时器,操作系统就要管理对应的定时器。
如何管理:先描述再组织!
操作系统会给我们创建一个定时器对象,通常包含下面几种属性:对应进程的pid(who)、进程的 task_struct
、时间戳、链表节点指针、对应的处理方法(如默认向对应进程发送信号SIGALRM
)
操作系统将定时器描述成一个个结构,并连接组织成链表结构,将对定时器的管理转为对链表节点的增删查改操作
检测定时器是否超时
有这么多定时器,都是不同的时间,是不是需要遍历一遍所有的定时器才能知道是否超时,这样比较影响效率
因此,我们一般会通过排序的方式管理定时器结构,操作系统底层是通过哈希等结构管理的
我这里为了方便理解,可以理解成,系统将定时器结构以一个小顶堆的方式管理起来
每次只需查看最小的定时器是否超时即可,这样提高了效率
闹钟的返回值
返回值:返回前一次调用 alarm
设置的剩余时间(以秒为单位)。如果之前没有设置定时器,则返回 0。
多次调用 alarm
:如果在定时器到期前再次调用 alarm
,新的定时器会覆盖旧的定时器。alarm
函数会返回前一次设置的剩余时间。
总结:表示上一次设置闹钟的剩余时间。
闹钟和信号产生有什么关系
设置定时器,当软件条件就绪时比如超时,那么我们的操作系统就可以向目标进行发送信号
所以闹钟定时器那么它本身属于软件条件满足或不满足而触发的让操作系统向目标进程发信号
这种策略叫软件条件!
说白了,就是因为软件问题,而导致的操作系统项目标进行发信号
不管是管道读端关闭或者是定时器超时,操作系统把你干掉了,这都叫做软件条件,跟硬件无关
4.3 闹钟的小项目:理解定时器的真正作用
#include<iostream>
#include<functional>
#include<vector>
#include<unistd.h>
#include <signal.h>
using namespace std;
// 定义一个函数指针类型,用于处理信号
typedef void (*sighandler_t)(int);
// 定义一个函数对象类型,用于存储要执行的函数
using func = function<void()>;
// 定义一个函数对象向量,用于存储多个要执行的函数
vector<func>funcV;
// 定义一个计数器变量
int count = 0;
// 信号处理函数,当接收到信号时,执行向量中的所有函数
void Handler(int signum)
{
// 遍历函数对象向量
for(auto& f : funcV)
{
// 执行每个函数
f();
}
// 输出计数器的值和分割线
cout << "—————————— count = " << count << "——————————" << '\n';
// 设置一个新的闹钟,1 秒后触发
alarm(1);
}
int main()
{
// 设置一个 1 秒后触发的闹钟
alarm(1);
// 注册信号处理函数,当接收到 SIGALRM 信号时,调用 Handler 函数
signal(SIGALRM, Handler); // signal用于整个程序,只会捕获单个信号
// 向函数对象向量中添加一些函数
funcV.push_back([](){cout << "I am 存储 work" << '\n';});
funcV.push_back([](){cout << "I am 数据库更新 work" << '\n';});
funcV.push_back([](){cout << "I am 拷贝 work" << '\n';});
// 进入一个无限循环,程序不会退出
while(1){
count++;
}; // 死循环,不退出
return 0;
}
这个代码的作用是:
先向进程定一个 1 秒的定时器,通过 signal
捕获定时器信号,进入自定义处理函数
在 main 函数结尾定义死循环,使程序不会退出
同时 signal
的自定义处理函数又定义 1 秒的定时器
使得整个程序处于无限循环:不断定义 1 秒的定时器,不断触发 signal
捕获定时器信号
同时死循环中的计数器不断递增,最后打印出来,为了能看到时间的变化
相当于每一秒钟执行一次 signal
的自定义处理函数
进一步优化:添加 pause
当信号没有产生时,通过 pause
不让死循环跑,只有信号来了才继续
pause()
函数
在C语言中,
pause()
函数是一个系统调用,用于使当前进程暂停执行,直到接收到一个信号(signal)。这个函数通常用于等待某个外部事件的发生,比如用户输入或定时器到期等。
函数原型
pause()
函数的原型定义在<unistd.h>
头文件中:#include <unistd.h> int pause(void);
功能
- 暂停进程:调用
pause()
后,进程会进入等待状态,直到接收到一个信号。- 信号处理:当进程接收到一个信号并且该信号没有被忽略(即有对应的信号处理函数),则
pause()
会返回,并且控制权会传递给相应的信号处理函数。- 返回值:
pause()
总是返回-1
,并且设置errno
为EINTR
,表示调用被中断。
因为进程 alarm
设置定时器,定时器是操作系统在进程外管理的,定时器到时了自然会发信号给原进程,此时进程就会被该信号唤醒
优化部分:
while(1){
pause();
cout << "我醒来了~" << '\n';
count++;
};
运行结果如下:计数器count如愿的一秒一秒的递增
到这里我们就完成了一个:需要靠外部信号唤醒去执行对应工作的进程,只有外部信号到来才能唤醒该进程,否则进程暂停
最后的升华:操作系统运行的本质?
我们将执行函数的打印代码换一下:
funcV.push_back([](){cout << "我是一个内核刷新操作" << '\n';});
funcV.push_back([](){cout << "我是一个检测进程时间片的操作,如果时间片到了,我会切换进程" << '\n';});
funcV.push_back([](){cout << "我是一个内存管理操作,定期清理操作系统内部的内存碎片" << '\n';});
运行结果如下:
实际上,这就是模拟了操作系统的底层运行!!!
操作系统就是通过不断发送时间中断(看作一种信号),来使操作系统进程一直运行,不断进行:进程调度、进程切换、内存管理等操作!!!!
关于真正讨论操作系统的底层原理,后面再讲解
5、异常
5.1 野指针:段错误
代码:
#include<iostream>
using namespace std;
int main()
{
int *p = nullptr;
*p = 10;
while(true){};
return 0;
}
程序遇到野指针直接崩溃,报段错误
实际上,程序是因为接收到操作系统发送的 11 号信号 SIGSEGV
验证确实是 11 号信号 SIGSEGV
的原因:通过信号自定义捕捉
#include<iostream>
#include<signal.h>
using namespace std;
void handler(int signo)
{
std::cout << "get a signo: " << signo << std::endl;
// 我捕捉了11号新号,没执行默认
}
int main()
{
signal(11, handler);
int *p = nullptr;
*p = 100;
while (true){};
}
运行结果:确实没有程序退出,而是循环打印自定义处理函数的语句
5.2 除零异常:浮点异常
代码:
#include<iostream>
#include<signal.h>
using namespace std;
int main()
{
int a = 10;
a /= 0;
while (true){};
}
程序遇到除零直接崩溃,报浮点异常
实际上,程序是因为接收到操作系统发送的 8 号信号 SIGFPE
验证确实是 8 号信号 SIGFPE
的原因:通过信号自定义捕捉
#include<iostream>
#include<signal.h>
using namespace std;
void handler(int signo)
{
std::cout << "get a signo: " << signo << std::endl;
// 我捕捉了8号新号,没执行默认
}
int main()
{
signal(8, handler);
int a = 10;
a /= 0;
while (true){};
}
运行结果:确实没有程序退出,而是死循环打印自定义处理函数的语句
5.3 问题:OS 怎么知道程序运行出错了?为什么会死循环?
先拿除零异常解释一下:
溢出标记位
CPU中使用多个寄存器参与运算工作
其中有一个状态寄存器中存有一个 “溢出标记位”:用于记录当前运算是否溢出或出错,若是则记为 1,否则为 0
溢出标记位记录到出错了,CPU就会向OS发信息,并说明这是由除零导致的异常,OS会向该进程发送8号信号,而我们程序把8号信号捕捉了(自定义处理过了),
当 CPU 继续调度进程,继续执行程序剩余部分时,会把进程的上下文数据放到CPU的各个寄存器中,其中状态寄存器也属于上下文数据的一部分!!!
上一轮溢出标记位记录到出错了,此时的还是记录为 1,则后果可想而知,CPU 继续报错!!
因为你处理了我的 8 号信号,但并没有将 CPU 内部的 溢出标记位 重置为 0,从而导致操作系统不断循环的向 进程发送 信号的现象
野指针异常也同理:
CPU 内部有专门处理内存地址的寄存器,其中一个关键是指向特定地址数据的虚拟地址指针,这个指针会放到寄存器 EIP
中。通过内存管理单元 MMU
(一种硬件设施)以及 CR3
寄存器来查找页表,如果发现该虚拟地址没有对应的物理地址映射,MMU
里类似状态寄存器的东西就会记录下这次错误的查询。
这时候,硬件 MMU
也会通知操作系统出现了错误,操作系统接着会给相关的进程发送信号,通常情况下会导致该进程被终止。
但是,如果我们捕捉并自定义处理了这个信号,当 CPU 再次尝试调度这个进程时,由于导致问题的野指针还未得到解决,MMU
中记录的错误依旧存在,这将导致 CPU 持续报错。最终造成操作系统不断循环地向该进程发送信号的现象。
总结一下,操作系统是如何知道我们的进程内部出错了呢?实际上,并不是你在编程语言层面直接让进程崩溃了,而是程序内部的问题反映为硬件级别的错误,这些错误信息反馈给操作系统,再由操作系统发送信号给进程,从而导致进程被终结或崩溃!
明白了这一点很重要!!并不是因为进程自身的语法错误直接导致其崩溃,而是操作系统基于硬件报告的错误决定终止进程的运行!
5.4 总结一下
C/C++中,常见的异常,导致进程崩溃了,其实是OS给目标进程发送对应信号,进而导致该进程退出
6、信号的两种 Action
:Core
/ Term
查看手册:man 7 signal
可以看到信号有几种 Action
,这里讲解一下 Core
/ Term
6.1 认识 Core
直入主题:
Term
就是正常的终止
Core
除了终止,还会多做一件事,程序崩溃时生成了一个核心转储文件(core dump),该文件用于调式代码。
核心转储是程序崩溃时内存状态的一个快照,包括程序计数器、寄存器状态以及内存数据等信息。
这个文件对于调试是非常有用的,因为它可以用来分析程序崩溃的原因。
我们演示 “野指针” 和 “除零错误” 的信号都是 Core
的信号:
但是我们根据前面文章的讲解:
好像这两个错误信号发送给进程了,但是并没有生成什么核心转储文件???
答:这是因为云服务器上的 Linux 系统,默认将生成 核心转储文件 的服务给禁用掉
6.2 为什么要禁用 Core
?
命令:ulimit -a
ulimit -a
命令用于显示当前 shell 及其子进程的所有资源限制。这些限制通常由操作系统或系统管理员设置,以防止某个进程占用过多资源,影响系统的稳定性和性能。
我们可以手动设置 core
文件的允许生成:命令 ulimit -c 10240
ulimit -c 10240
命令用于设置最大核心文件大小为 10240 块。这里的“块”通常是指 512 字节,因此 10240 块等于 5120 KB(即 5 MB)。
此时我们再次触发野指针或除零异常
发现报错多了一个词:core dumped
同时当前目录下生成了一个 core
文件
那为什么要禁用 core
文件呢?
当程序运行出现除零错误或野指针这类问题时,这会导致程序直接崩溃。作为程序员,我们通常会主动处理这些问题。但如果这个服务是在半夜挂掉的,并且没有人去处理,它可能会不断重启又不断崩溃,每次崩溃都会生成一个 core
文件。如果这种情况持续一段时间,磁盘空间会被这些 core
文件迅速占满,不仅没有给你留下调试和排错的机会,还可能导致服务器先一步挂掉。
因此,较新的 Linux 系统内核默认会禁用生成核心转储文件的服务,就是为了防止上述情况的发生。
另一种处理方法是,如上面提到的,在 Ubuntu
下生成的 core
文件就简单地命名为 core
。无论有多少个程序生成了 core
文件,它们都统一叫做 core
。这意味着在同一目录下最多只会有一个 core
文件存在,这样就不会因为多个 core
文件而造成磁盘空间阻塞的问题。
在不同的 Linux 平台和不同版本的 Linux 内核中,核心转储文件(core dump)的命名规则可能有所不同:
- 在
Ubuntu
的某些内核版本下:生成的core
文件就直接命名为core
。 - 在
CentOS
的某些内核版本下:生成的core
文件则会带上进程的 PID,例如命名为core.<PID>
。
6.3 Core
文件的作用演示
为了方便演示效果,我这里加上多几句打印语句,其中真正出错的语句在第25行:除零异常
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
int main()
{
cout << "hello world" << endl;
cout << "hello world" << endl;
cout << "hello world" << endl;
cout << "hello world" << endl;
cout << "hello world" << endl;
cout << "hello world" << endl;
cout << "hello world" << endl;
cout << "hello world" << endl;
cout << "hello world" << endl;
cout << "hello world" << endl;
cout << "hello world" << endl;
cout << "hello world" << endl;
cout << "hello world" << endl;
cout << "hello world" << endl;
int a = 10;
a /= 0;
while (true)
{};
}
编译该文件:同时加上 -g
如果你的可执行文件没有包含调试信息,GDB 将无法提供源代码级别的调试信息。确保在编译时添加
-g
选项以包含调试信息。
gcc -g -o myprogram myprogram.c
运行该可执行文件:生成核心转储文件 core
GDB 调试该可执行文件,输入 core-file core
:表示将 核心转储文件 加载到GDB调试器中
作用:将我们代码出错程序的行号及相关信息直接展示出来!
印证了我们前面讲解的,核心转储文件的作用就是方便我们调试
6.4 进程退出码中的 core
当进程被信号所杀时,进程退出码的 0~7
为信号编号
当系统的 core
开放后,当进程被 core
类型的信号所杀时,进程退出码就是 core dump
标志,等于 1
当系统的 core
不开放, core dump
标志一直置为 0
综上所述,一个进程是否会出现 core dump
,取决于两个条件:
- 1、退出信号是否终止动作是core
- 2、服务器是否开启core功能!
验证:
代码:创造除零错误的场景
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
using namespace std;
int main()
{
pid_t id = fork();
if(id < 0)
{
perror("fork");
return 1;
}
if(id == 0)
{
// 子进程除零错误,会被信号杀死
int a = 10;
a /= 0;
}
else if(id > 0)
{
int status = 0;
waitpid(id, &status, 0);
cout << "eixt signal: " << WTERMSIG(status) << endl;
cout << "core dump: " << ((status >> 7) & 1) << endl;
}
}
代码结果如下:父进程获取子进程退出码,得知 8 号信号杀死该进程,同时 core dump = 1
如果我将系统的 core
关掉
命令:ulimit -c 0
core dump
就置为 0 了!