目录
一、信号的引入
二、信号捕捉
三、核心转储
四、系统调用发送信号
五、软件条件产生信号
六、硬件异常产生信号
Linux信号本质是一种通知机制,用户 or 操作系统通过发送一定的信号,通知进程,某些事件已经发生,可以在后续进行相应的处理。
例如,当用户输入命令,在Shell下启动一个前台进程后,我们便可以通过信号的方式终止进程。
接下来我们运行一下test.c程序,因为是一个死循环,然后我们使用Ctrl+C停止该进程。
当我们按下Ctrl+C时,实际上就是给进程发送了一个信号。
- 当用户按下Ctrl+C时,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程,前台进程收到信号,进而引起进程退出。
那系统是如何将组合键编程信号的呢?
- 实际上当用户按Ctrl+C时,这个键盘输入会产生一个中断,被操作系统获取并解释成信号(Ctrl+C被解释成2号信号),然后操作系统将2号信号写入对应的信号到进程PCB内部的位图结构中,然后进程检测到位图中数据的变化,做出相应的操作。
- 信号发送的本质:OS 向目标进程写信号,OS直接修改PCB中的指定位图结构,就完成了”发送“信号的过程。
我们可以使用 kill -l 命令 查看 Linux 中的信号列表。
一共有62个信号(没有32、33号信号),其中白色区域【1-31】的为普通信号。【35-64】为实时信号。
信号的可选动作有以下三种:
- 忽略此信号。
- 执行该信号的默认处理动作。
- 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式被称为捕捉(catch)一个信号。
即信号的处理方式有三种:1.默认处理,2.忽略,3.自定义捕捉
接下来我们先了解一下第三种方式——自定义捕捉,OS为我们提供了一个系统调用 signal。
第一个参数:要传入信号编号或宏(即62个信号中的其中一个),用于捕捉该信号, 部分如下:
第二个参数:要求传入一个函数指针,这个传入的函数是当信号产生时执行的自定义功能了。调用一个函数传入另一个函数的函数指针,这种操作在C语言中被称为回调函数。
接下来写一段简单代码看一下:
当SIGINT(即Ctrl+C)信号产生时,不去执行该信号的默认操作,而是去执行我们的catchSig函数。
然后我们来看看运行结果,并从键盘按下Ctrl+C.
说明signal函数,修改了当前进程对特定信号的处理动作,转而执行我们指定的函数。
我们可以输入man 7 signal 查看信号的默认处理行为。这里不同信号的Action不同,有Term、Core、Ign、Cont、Stop等状态行为。
接下来就是了解一下Core动作——核心转储。
关于进程等待中,status 中如果是正常终止就保存返回值、错误码。
如果被信号所杀,第7位上保存的这个就叫做core dump,如果是0表示没有发生核心转储,为1则是发生了核心转储。
我们可以打印code_dump位的信息 (左移7位然后与上1即可)。
int main()
{
pid_t id = fork();
if (id == 0)
{
sleep(1);
int a = 100;
a /= 0;
exit(0);
}
int status = 0;
waitpid(id, &status, 0);
cout << "父进程:" << getpid() << "子进程:" << getppid() << endl;
//退出信号:
cout << "exit sig" << (status & 0x7f) << endl;
// 打印core dump位
cout << "core dump" << (status > 7 & 1) << endl;
}
如果使用的云服务器,其默认核心转储功能是关闭的。
然后使用命令 ulimit -c 10240 就可以打开(这种打开方式仅针对当前会话)
打开之后,运行mysingal程序,然后使用kill -8 信号终止该进程。
此时当前目录下就多了一个 core.4530 文件。
这个文件就叫做核心转储:
- 当进程出现某种异常的时候,是否由OS将当前进程在内存中的相关核心数据,转存到磁盘中。大白话就是将内存中的重要数据保存起来,主要作用是用于调试。
所以在信号的默认处理行为中action我们就知道是什么意思了。(ign表示忽略,Cont表示继续)
那这个核心转储生成的文件有什么用呢?
其主要作用是用于调试,接下来我演示core文件进行调试。
接下来我编写一段代码,其中有句代码执行了整数除以0,会触发8号信号生成core文件。我们加上-g选项生成可执行文件。
#include
#include
using namespace std;
int main()
{
cout<<"i am a process,pid:"<
然后使用gdb打开core文件。
操作系统提供了许多系统调用接口让我们产生并发送信号:
- kill 接口
- raise 接口
- abort 接口
接下来让我们先来认识 kill 接口。
第一个参数为指定的进程pid,第二个参数为对应的信号编码。
其实本质kill命令就是调用的系统调用kill,所以我们也可以实现一个kill命令:
int main(int argc, char *argv[])
{
if (argc != 3)
{
usage(argv[0]);
exit(1);
}
int procid = atoi(argv[1]);
int signumber = atoi(argv[2]);
// 系统调用kill
kill(signumber, procid);
return 0;
}
检验步骤步骤如下:
1. 让当前会话的前台进程中直接sleep 10000秒,然后在会话2中查看该进程的pid
2.调用 mykill ,传入信号和sleep 10000的pid,观察结果。
结果:
raise命令:
kill 是给指定进程发送信号,而如果想让自己给自己发信号,可以使用 raise 命令
接下来我们写一段程序,让进程本身给自己发送8号信号。
举例使用如下:
int main()
{
cout << "pid: " << getpid() << "run......" << endl;
raise(8);
return 0;
}
abort接口:
给自己发送abort信号,也就是6号信号。相当于代码:raise(6) 或 kill(getpid(),6)
举例代码如下:
int main()
{
cout << "pid: " << getpid() << " run......" << endl;
abort(); //raise(6) 或 kill(getpid(),6)
return 0;
}
举一个例子:
当管道,读端不进行读取,还关闭了文件描述符,而写端一直写入,会发生什么问题?
操作系统会自动终止对应写端进程,通过发送信号的方式,发送SIGPIPE信号。
验证步骤:
1.创建匿名管道
2.让父进程进行读取,子进程进行写入
3.让父进程关闭读端 && waitpid(),子进程一直进行写入
4.子进程退出,父进程waitpid拿到子进程的退出status。
5.提取退出信号。
SIGPIPE便是一种软件条件产生的信号,除了管道中会发出SIGPIPE信号,接下来我们学习其它软件产生的信号—— alarm 函数与SIGALRM 信号。
系统调用中的 alarm 函数会产生 SIGALRM 信号。
接下来让我们了解一下 alarm 接口。
调用 alarm 函数可以设定一个闹钟,也就是告诉内核再 seconds 秒之后给当前进程发 SIGALRM 信号,该信号的默认处理动作是终止当前进程。
有了 alarm 这个计时接口,我们可以写一段代码来验证1秒内,服务器一共能进行多少次count++;
int main()
{
alarm(1);
int count=0;
while(1)
{
cout<<"count:"<
运行程序,看看结果是多少:
观察结果:1秒钟为什么count只能++到6万左右,为什么这么慢?
因为1.对屏幕进行IO浪费了太多时,
2. 云服务器是网络传输,消耗太多时间。
如果想单纯计算算力,我们对代码进行改写,让其在服务器上进行计算,然后将结果返回给我们。
int count = 0;
void catchSig(int signum)
{
cout << "final count: " << count << endl;
}
int main()
{
// 1秒后发送消息
alarm(1);
signal(SIGALRM, catchSig);
while (1)
{
++count;
}
return 0;
}
这时结果就是5亿多。
因为singal的原因,如果alarm时间到了,就会执行catchSig函数。所以我们可以设置一个周期程序,每一秒种打印计算机累计算了多少,改动如下:
所以每隔一秒,就自动打印count的值,这样,我们就实现了一个定时器的功能。
有了这个定时器的功能,我们就可以实现定时打印信息。
每隔一秒钟就打印我们想要检测的内容
typedef function func;
vector callbacks;
long long count = 0;
void showCount()
{
cout << "final count: " << count << endl;
}
void showLog()
{
cout << "打印日志……" << endl;
}
// 打印登录的用户
void logUser()
{
if (fork() == 0) // 子进程进行程序替换执行who命令
{
execl("/usr/bin/who", "who", nullptr);
exit(1);
}
wait(nullptr);
}
void catchSig(int signum)
{
// 定时执行以下任务:
for (auto &f : callbacks)
{
f();
}
alarm(1);
}
int main()
{
// 1秒后发送消息
alarm(1);
signal(SIGALRM, catchSig);
callbacks.push_back(showCount);
callbacks.push_back(showLog);
callbacks.push_back(logUser);
while (1)
++count;
return 0;
}
那如何理解软件条件给进程发送信号?
- OS先识别到某种软件条件触发或不满足。
- OS构建信号,发送给指定的进程。
硬件怎么产生信号呢?我们现在先写一段整数除以0的代码进行引入:
void handler(int signum)
{
sleep(1);
cout << "signal is : " << signum << endl;
}
int main()
{
signal(SIGFPE, handler);
int a=100;
a/=0;
while (1)
sleep(1);
return 0;
}
那如何理解整数除以0这个操作呢?
- 因为计算的是CPU,如果CPU计算出现错误,会将错误信息放入到状态寄存器中,状态寄存器中有对应的状态标记位(类比成 位图),其中会存在溢出标记位,OS会自动进行计算完毕之后的检查。
- 如果OS识别到有溢出问题,根据 current指针(指向当前正在运行的进程) 找到进程,然后提取出 PID,O S再进行信号发送到该进程,进程则会再合适的时候,进行信号的处理。
- 立即找到当前 task_struct中有一个current指针,当程序进行执行时,current内的内容也会被加载到CPU的寄存器中。
- 所以,整数除以零是一个硬件异常的问题。
那一旦出现硬件异常,进程一定会退出吗?
- 不一定,一般默认是退出,但是如果我们不进行退出,我们也不能进行任何操作,因为无权访问CPU中的寄存器数据。
为什么会发生死循环?
- 因为寄存器中的异常一直没有被解决。
- 所以一般我们出现除0等错误,一般就直接exit()退出了。
指针越界、野指针一般被称为段错误 (11号信号SIGSEGV)
那如何理解野指针或越界问题?
- 都必须通过地址,找到目标位置,
- 语言上的地址,全部都是虚拟地址
- 将虚拟地址转化为物理地址
- 页表+MMU(Memmory Manager Unit——硬件)
- 野指针,越界->非法地址->MMU转化的时候,一定会报错。因为MMU这个硬件其中也有寄存器,注意,外设也有寄存器的,不只是CPU有寄存器。
结论:
- 所以说,硬件也能产生信号。所有的信号,都有其来源,但最终全部都是被OS被识别、解释、发送的。
最后就是几个信号的常见问题:
- 为什么所有的信号产生,最终都要由OS来执行?
因为OS是进程的管理者。
- 信号的处理是否是立即处理的?
由OS在合适的时机进行处理。
- 信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里?
需要被记录下来,记录在进程PCB中对应的信号记录位图。
- 如何理解OS向进程发送信号?
本质是OS直接修改PCB中的信号位图,根据信号编号修改特定的比特位(由0置1)。