linux之进程信号(初识信号,信号的产生)

目录

引入

一、初识信号(信号预备知识)

1.生活中的信号

  • 当你在打着永杰无间的时候,你的外卖到了,外卖员给你打电话去取,而你此时正在打游戏,你心里想着这把打完了去取,过了一会,你打完了这把游戏,你想起来你还有外卖没取,你就马上去取了
  • 信号弹
  • 我们根据红绿灯的颜色过马路
  • 上课铃声响了,我们取上课
  1. 你怎么认识这些信号?

    有人教过我,我就记住了 -> 1.识别信号 2.知道信号的处理方法

  2. 即使我们现在没有信号产生,我们也知道信号产生之后,我该干什么?

  3. 信号产生了之后,我们可能不会立即处理这个信号,我们可能有更重要的事情要做,但是我们必须要把信号产生这个信息保存下来,在合适的时候去处理

2.Linux中的信号

概念:Linux信号通常由操作系统或其他进程发送给目标进程,可以用于多种目的,例如中断进程、终止进程或请求进程执行某个特定操作。本质是一种通信机制。

用kill -l命令可以察看系统定义的信号列表

可以看到全是大写的,因为linux是用c语言写的,所以就是c语言中的 ,所以我们也可以使用数字也可以使用以上宏都是一个意思

一共有62个信号,因为32、33不存在,其中,1-31是普通信号 ,也是本文重点讲解的, 34-64属于实时信号,优先级比较高,立即处理,本文不做讲解。

3.信号+进程得出的初步结论

所以进程信号?

  1. 进程必须识别并能够处理信号 ,信号即使没有产生。也要具备处理信号的能力,怎么做到呢?信号的处理能力,是操作系统给进程内置的功能的一部分

  2. 进程即使没有收到信号,也能知道哪些信号该如何处理

  3. 当进程真的收到了一个具体的信号的时候,进程可能并不会立即处理这个信号,会在合适的时候区里这个信号 处理动作:1.默认动作 2.忽略 3.自定义动作

  4. 一个进程必须当信号产生,到信号开始处理,就会有一定的时间窗口,这段窗口,进程需要保存哪些信号已经发送了的能力

所以信号要经历: 信号产生 -> 信号保存 -> 信号处理 三个阶段

ctrl + c 为啥能杀死我们的前台进程呢?

  • linux中,一次登录中,一般会配上一个bash,每一个登录,只允许一个进程是前台进程,可以允许多个进程是后台进程。
  • 当我们用键盘输入ctrl + c 快捷键时候,会被解释成2号信号 然后发给前台进程
  • 启动进程的时候 加上 & 表示以后台进程的方式启动
  • 所以,当我们以后台进程状态运行时,我们用键盘输入ctrl + c 的时候进程收不到信号,也就不会终止进程,我们只能使用kill命令杀死该进程

二、信号的产生

通过以上对linux信号的简单了解后,我们再来看一下信号是如何产生的,以下是信号产生的几种方式

1.通过终端输入产生信号

比如ctrl + c 就是2号信号SIGINT,我们如何来验证呢?

介绍一个函数

SIGNAL(2)                                                                      Linux Programmer's Manual                                                                      SIGNAL(2)

NAME
       signal - ANSI C signal handling

SYNOPSIS
       #include <signal.h>

       typedef void (*sighandler_t)(int);  //函数指针

       sighandler_t signal(int signum, sighandler_t handler); 
       //表示当我们收到signum信号后,处理动作为handler方法
       //,涉及信号处理,此处先简单讲解

实验一:

c 复制代码
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
using namespace std;
//num:收到了哪个信号
void handler(int num)
{
    cout << num << " handler ..." << endl;
    //exit(1);
}
int main()
{
    signal(SIGINT, handler);//只需设置一次,以后都有效
    while(1)
    {
        cout << "i am a process, mypid: " << getpid() << endl;
        sleep(1);
    }
    return 0;
}

可以看到输入的ctrl + c被转换成2号信号


其实还有一些快捷键也可以表示为信号,比如ctrl + \ 表示3号信号 即SIGQUIT
实验二:

code:

c 复制代码
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
using namespace std;
void handler(int num)
{
    cout << "signal: " <<  num << " handler ..." << endl;
    //exit(1);
}
int main()
{
    signal(SIGINT, handler);
    signal(3, handler);

    while(1)
    {
        cout << "i am a process, mypid: " << getpid() << endl;
        sleep(1);
    }
    return 0;
}

我们可以发现,2号和3号信号的处理动作都是同一个函数,这也就是为handler函数要有一个参数为int,就是为了标识是哪个信号正在处理。


但是有些信号不会使用signal定义的处理动作,比如19号信号SIGSTOP
实验三:

code:

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
using namespace std;
void handler(int num)
{
    cout << "signal: " <<  num << " handler ..." << endl;
    //exit(1);
}
int main()
{
    signal(SIGINT, handler);
    //signal(3, handler);
    signal(19, handler);
    while(1)
    {
        cout << "i am a process, mypid: " << getpid() << endl;
        sleep(1);
    }
    return 0;
}

可以看到ctrl + z 并没有调用自定义的signal方法,仍然使进程停止了,这是为什么呢?想知后续请继续往下看!


实验四:

对所有信号都实验signal捕捉

c 复制代码
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
using namespace std;
void handler(int num)
{
    cout << "signal: " <<  num << " handler ..." << endl;
    //exit(1);
}
int main()
{
    //signal(SIGINT, handler);
    //signal(3, handler);
    //signal(19, handler);

    for(int i = 1; i <= 31; ++i)
    {
        signal(i, handler);

    }
    while(1)
    {
        cout << "i am a process, mypid: " << getpid() << endl;
        sleep(1);
    }
    return 0;
}

可以看到1-8都被捕捉了,而9号信号没有。我们继续重启进程,继续向后发信号。

可以看到10-18都被捕捉了,19不会,也就是之前做的实验三。

继续重启进程,发信号,可以发现都被捕捉了(这里不做演示了)。

所以只有9号和19号信号不会被signal捕捉,这是为什么呢?

可以发现这俩一个是杀死进程,一个是停止进程,都跟进程的执行有关,你觉得操作系统为啥不让所有信号被捕捉呢?很简单,操作系统又不是傻子,万一有个病毒软件什么的,它把所有信号都捕捉了,那它不就永远都杀不掉了嘛。


拓展: 硬件中断

看了上面的,你有什么疑问嘛?
键盘数据是如何传入给内核的?,ctrl + c 又是如何变成信号交给进程呢?
接下来就要谈谈硬件了

首先,进程是由操作系统管理的,所以键盘被按下,肯定是操作系统先知道?那么操作系统又是如何知道键盘上有数据了????

  1. 操作系统会在启动的时候加载进内存
  2. linux下一切皆文件,每个文件都有自己的缓冲区,即struct file结构体
  3. os怎么知道键盘上有数据?os会对外设进行轮询监测嘛?os系统没这么蠢,系统中这么多设备,操作系统这样的化效率太低了
  4. cpu和键盘通过主板连接在一起,cpu上有很多针脚,和各种设备间接或直接连接着,当键盘中有数据产生的时候,键盘会给cpu发送硬件中断,将cpu特定的针脚发送高低电频,即充放电,然后cpu感受到了。
  5. cpu中的寄存器如何存储数据呢?也就是上述充放电的过程
  6. os内核中有一个中断向量表,就和文件描述符表向上,中断向量表中存储各个设备的读写方法
  7. 当键盘中有数据产生,给cpu发送硬件中断,cpu让操作系统执行中断相量表中的方法去读取键盘中的数据
  8. 当键盘中的数据是一般数据的时候,会被读入缓冲区中,当是快捷键比如ctrl + c 这样的,会被解释成信号由操作系统发送给进程

那么以上所讲硬件中断和我们讲的信号有什么联系嘛?
我们学习的信号,就是利用软件的方式,对进程模拟硬件中断

再谈缓冲区

  1. 平常我们输入命令的时候,我们可以看到我们自己的输入,其实是操作系统将键盘输入缓冲区中的数据拷贝到显示器输出缓冲区中去了,我们就可以看到我们输入的内容了,而linux中输入密码的时候我们看不到密码,也就是os没有把此时的输入缓冲区中的内容拷贝到输出缓冲区中去
  2. 当我们以后台进程的方式启动进程时,我们即使隔一段时间才输入一个完整的命令,也可以正常执行,因为我们我们看到的很长时间才输入完整的命令实在显示器缓冲区中,而输入缓冲区中的数据时连续完整的,所以可以正常执行

2.调用系统函数向进程发信号

可以看到,kill系统调用的作用是发送一个信号给指定进程,就不就是和我们命令行中的kill命令一样的嘛,其实我们命令行中的kill命令底层就是调用的kill系统调用。

那么我们直接来做一个kill命令

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <cstdio>
#include <signal.h>
using namespace std;
static void Usage(const string& cmd)
{
    cout << "\n\r " << cmd << " signo process_pid " << endl << endl;
}
int main(int argc, char* argv[])
{
    if(argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }
    int sig = stoi(argv[1]);
    pid_t id = stoi(argv[2]);
    int ret = kill(id, sig);
    if(ret == -1)
    {
        perror("kill");
        exit(2);
    }
    return 0;
}

raise作用:发送一个信号给调用者,实际上就是kill的封装,就是kill(getpid(), sig).


abort作用: 给调用者发送6号信号(SIGABRT,实际也是对kill的封装,但是他有一些特性,直接看实验

c 复制代码
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <cstdio>
#include <signal.h>
using namespace std;
// static void Usage(const string& cmd)
// {
//     cout << "\n\r " << cmd << " signo process " << endl << endl;
// }
void handler(int signum)
{
    cout << "get a sig : " << signum << endl;
}
int main(int argc, char* argv[])
{
    // if(argc != 3)
    // {
    //     Usage(argv[0]);
    //     exit(1);
    // }
    // int sig = stoi(argv[1]);
    // pid_t id = stoi(argv[2]);
    // int ret = kill(id, sig);
    // if(ret == -1)
    // {
    //     perror("kill");
    //     exit(2);
    // }
    signal(6, handler);
    for(int i = 0; i < 3; ++i)
    {
        cout << "i am a process pid: " << getpid() << endl;
        sleep(1);
    }
    abort();

    return 0;
}

可以看到,即使signal捕捉了6号信号,进程也执行了handler方法,但进程最终还是终止了,所以abort内部采用一定的手段使进程强制停止了。

3.硬件异常产生信号

1.除0错误

code:

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <cstdio>
#include <signal.h>
using namespace std;

void handler(int signum)
{
    cout << "get a sig : " << signum << endl;
}
int main()
{
    // signal(SIGFPE, handler);
    cout << "zero before" << endl;
    sleep(1);
    int a = 10;
    cout << 10 / 0 << endl;;
    cout << "zero after" << endl;
    return 0;
}

实验现象:

放开注释后,自定义捕捉SIGFPE信号

实验现象:

可以看到我们捕捉了8号信号(SIGFPE),所以发生除0错误后,会收到8号信号,但是我们一直死循环重复捕捉,这是为什么呢?

  • 我们从硬件角度来分析,cpu内有一套寄存器,保存着进程的上下文数据,其中一些特殊的寄存器比如eip/pc记录着进程执行的代码情况。
  • 最重要的是有一个状态寄存器,他就是一个位图结构,用来记录进程的运行状态。每一个比特位代表不同的状态。
  • 当发生除0错误的时候,状态寄存器中溢出标记位由0变1,发生异常。
  • 而操作系统是硬件的管理者,它发现cpu中状态寄存器发生异常,就会给进程发送特定的信号,而进程本身无法处理这个异常,即使捕捉了信号, 操作系统就会不断的发信号。

2.野指针异常

code:

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <cstdio>
#include <signal.h>
using namespace std;

void handler(int signum)
{
    cout << "get a sig : " << signum << endl;
}

int main()
{
	//signal(SIGSEGV, handler);
    cout << "before" << endl;
    sleep(1);
    int *p = nullptr;
    *p = 30;
    cout << "after" << endl;
    return 0;
}

实验现象:

放开注释,实验现象:

可以看到,发生野指针的时候,也会不断收到信号。

我们再从底层的角度解释一下:

  • 发生野指针的本质是访问无效的空间,由进程地址空间可以知道,我们语言上访问的空间其实是虚拟地址,实际上我们要通过页表进行虚拟地址到物理地址的转化,而这一过程os系统不会参与,会有页表和mmu(memory manager unit)即内存管理单元(cpu中硬件),当转化失败的时候mmu会报错。
  • os系统是硬件的管理者,它识别到mmu的报错,而进程无法修正错误,即使捕捉了信号,就不断给进程发信号

总结一下:

当我们出现异常的时候,操作系统为啥选择给进程发信号,然后去执行特定的代码,而不是直接干掉进程呢?

其实向我们发信号是为了让进程知道进程出现了什么异常,而且让进程有一定的缓存时间,去处理日志信息等,如果操作系统直接把进程干掉了,就可能处理不了这些情况了。

补充:

上述过程一个进程导致cpu中的寄存器发生了异常,也就是硬件发生了异常,为啥其他进程还能正常运行呢?

  • cpu中的寄存器只有一套,存储的是进程的上下文信息,当发生进程切换的时候,进程会带走它的上下文数据,然后其他进程将它之前访问的上下文数据放进寄存器,上一个进程带来的寄存器异常已经被带走了,而新来的进程上下文数据并没有异常,所以不会影响其他进程的运行。
  • 所以信号的存在不是让我们解决问题,操作系统都解决不了,你让进程自己解决码?
  • 信号的存在是为了让我们知道程序异常的原因,并且给我们一定的缓冲时间,用来打印日志信息等等。

4.软件条件产生信号

比如我们写管道的时候,当读端关闭的时候,写端还有继续写入,此时操作系统就会向写端进程发送SIGPIPE信号,然后终止进程。

调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动作是终止当前进程。

返回值代表上次设定闹钟的剩余时间,比如你定一个闹钟30分钟后响,20分钟后你就行了,此时你查看时间闹钟还有10分钟才响。

实验:如果我们自定义处理闹钟的动作,进行一些日志打印等操作,然后再在捕捉函数内定一个闹钟,不就做到了不断做日志打印的业务了嘛?

code:

cpp 复制代码
#include <signal.h>
using namespace std;
void work()
{
    cout << "print a log..." << endl;
}
void handler(int signum)
{
    cout << "get a sig : " << signum << endl;
    work();
    alarm(5);
}
int main()
{
    signal(SIGALRM, handler);
    alarm(5);
    while(1)
    {
        cout << "i am a process pid : " << getpid() << endl;
        sleep(1);
    }
    return 0;
}

实验现象:

返回值验证:可以看到上面实验现象每次闹钟剩余时间都是0,因为我们没有利用其他手段让闹钟提前苏醒,我们使用kill命令来验证

实验现象:

补充:

  • 操作系统内部有很多进程,每个进程都可以设置闹钟,那么操作系统内部就有很多闹钟了,操作系统就要管理它们,管理的本质是先描述再组织,用特定的结构体来描述闹钟,比如 struct alarm.
  • struct alarm中需要存储设置这个alarm的进程,以及什么时候响,一般用当前时间戳+设计多久响来表示
  • 那么操作系统用什么组织alarm呢?很明显可以用我们常见的数据结构小堆来管理,堆顶存最早响的闹钟,os只需要查看堆顶的时间,如果超时了,给struct alarm中存的进程pid发送信号,然后将堆顶的数据删除,继续查看堆顶,重复以上动作,直到堆顶没有超时。

拓展: 核心转储技术

之前讲进程退出状态的时候,正常退出时次第8为为退出码,低8位为0,

收到信号退出的时候,次第0位无意义,低7位表示退出信号,其中第8个比特位之前没有讲,其实是core dump(核心转储)标识。

什么是核心转储?

将进程异常退出时的状态保存下来形成一个core.pid文件,放在当前路径下(磁盘)可以配合gdb进行时候调试,一会做实验。

一般在项目上线后是关闭的,为什么?

一般服务即使出现异常挂掉了,会有一定的手段自动重启,如果一些程序员写的代码太水,一上线就挂掉,不断自动重启,不断形成core dump文件,把磁盘写满,挂的就是操作系统了,那问题就严重了。

并不是所以的信号都会使进程挂掉后形成核心转储,通过man 7 signal可以查看

其中term表示只终止进程,core 表示终止进程并形成core dump核心转储文件。

ign表示忽略动作,stop表示停止,这俩现在不详细讲。

实验验证:

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cstdio>
#include <signal.h>
using namespace std;


int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        int cnt = 500;
        while(cnt)
        {
            cout << "i am child process, pid: " << getpid() << "cnt: " << cnt-- << endl;
            sleep(1);
        }
        exit(0);
    }
    int status = 0;
    pid_t rid = waitpid(id, &status, 0);
    cout << " child quit info, rid: " << rid << " exit code: " << 
    ((status >> 8) & 0xff) << " exit signal: " << 
    (status & 0x7f) << " core dump: " << (status >> 7 & 1) << endl;
    return 0;
}

默认情况下core dump是关闭的,ulimit -a可以查看

此时实验现象:

可以看到此时无论发送term信号还是core信号都不会形成core dump。

当我们把核心转储打开

开始重做实验:

可以看到当我们使用core信号时,形成了核心转储文件,并且很大。

那么我们如何将他和gdb配合使用呢?

code:

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cstdio>
#include <signal.h>
using namespace std;


int main()
{
    int a = 10;
    a /= 0;
    cout << "a: " << a << endl;
    return 0;
}

当我们直接运行的时候,可以看到报错多了一段core dumped,并形成了core.pid文件

直接开始gdb

可以看到直接定位到了异常的位置

总结一下:

  1. 上面所说的所有信号产生,最终都要有OS来进行执行,为什么?

    OS是进程的管理者

  2. 信号的处理是否是立即处理的?

    普通在合适的时候进行处理,但是对于实时信号来说,必须立马处理。

  3. 信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适呢?

    可以发现普通信号一共有31个,一个int有32个比特位,不觉得很巧嘛?每一个比特位代表着一个信号,0和1表示着是否收到该信号,其实信号就是用类似这样的结构保存的,也就是位图,但是可以发现只能存0和1那就只能表示是否收到该信号,而不能知道收到该信号的数量的,实际上内核也是这样的。

    而实时信号需要立即处理,所以发几个信号就要处理几个,所以就不能用位图结构来存储了,而是用一个队列(先到先处理)。

  4. 一个进程在没有收到信号的时候,能否能知道,自己应该对合法信号作何处理呢?

    必须知道。

  5. 如何理解OS向进程发送信号?能否描述一下完整的发送处理过程?

    与其说os向进程发信号,不如说向进程的pcb发信号,信号在pcb中用位图方式存储,那么os向进程发信号就是修改进程pcb中的位图,将对应的信号比特位由0置1.

相关推荐
人工糖精2 小时前
云计算实验1——基于VirtualBox的Ubuntu安装和配置
linux·运维·ubuntu
明 庭2 小时前
在 Azure 100 学生订阅中新建 Ubuntu VPS 并通过 Docker 部署 pSQL 服务器
服务器·ubuntu·azure
会写代码的健身爱好者3 小时前
钉钉消息推送()
java·服务器·钉钉
找了一圈尾巴3 小时前
Spring Boot Web技术栈(官网文档解读)
服务器·前端·spring boot
加点油。。。。4 小时前
ubuntu22.4 ROS2 安装gazebo(环境变量配置)
linux·python·ubuntu·ros
testtraveler5 小时前
(双系统)Ubuntu+Windows解决grub引导问题和启动黑屏问题
linux·windows·ubuntu
卡比巴拉—林5 小时前
如何在openEuler中编译安装Apache HTTP Server并设置服务管理(含Systemd和Init脚本)
linux·运维·服务器
LuckyLay7 小时前
Linux网络知识——路由表
linux·服务器·网络·路由·ip route
ydswin7 小时前
Chrony:让你的服务器时间精准到微秒级的神器!
linux