【Linux】信号三部曲: 产生、保存、处理-3. 信号的产生

时间:2024-11-02 13:02:14

3.1. kill命令

kill -信号编号 进程的PID

3.2. 终端按键

  1. 用户可以通过键盘输入特定的组合键来产生信号,如:Ctrl+c会产生SIGINT信号(2号信号,终止进程),Ctrl+\会产生SIGQUIT信号(3号信号,终止进程)、Ctrl+z会产生SIGSTP信号(20号信号,暂停进程的执行)等。
#include<iostream>
#include<cstdio>
#include<csignal>
#include<unistd.h>
#include<sys/types.h>

using namespace std;

void handle(int signo)
{
    cout << "handing signao " << signo << endl;
}

int main()
{
    for(int signo = 1; signo <= 32; signo++)
    {
        signal(signo, handle);
    }

    while(true)
    {
        printf("I am a process, pid: %d\n", getpid());
        sleep(3);
    }

    return 0;
}

3.2.1. 核心转储core dump

  1. 进程接收到信号后默认处理动作为终止时两种常见的方式,分别为term、core,两者主要区别在与core具有核心转储功能,即:生成核心转储文件。

term:进程在接收到信号后,有机会执行清理操作并优雅地退出,通常是通过SIGTERM(信号编号15)来实现的。

  1. 核心转储(core dump):是指在进程因收到了特定信号而异常终止时,OS将进程在内存的核心数据(如:地址空间、与调试有关的信息等)转储到磁盘中,形成一个核心转储文件core(Ubuntu系统)或core.pid(Centos系统)文件。

问1:为什么要存在核心转储?

核心存储文件包含了进程异常终止时的内存状态、寄存器值、调用栈等调试信息,有助于协助程序调试,从而快速定位到错误原因(如:进程为什么退出、进程执行到哪行代码退出)。

  1. 事后调试:可以通过核心存储文件对已经异常终止的进程,使用调试器进行调试,以便定位到错误原因,因为进程异常终止通常是有Bug(如:非法访问内存导致的段错误等)。

  2. 云服务器为了节省磁盘空间、避免资源浪费或出于安全考虑,对以core方式终止的进程进行了特定的设定,默认关闭core文件的生成。

ulimit -a

  • 功能:显示当前用户的所有资源限制。

ulimit -c

  • 功能:打开核心转储功能,并设置或查询core文件的大小限制。

  1. 确定子进程是否发生核心转储,需要检查这两个条件:a. 系统是否开启了核心转储功能,b.子进程异常退出的默认处理动作是生成core文件(Action : core)。

如果以上两个条件都满足,但在当前目录下未生成core文件,解决方法如下:

sudo bash -c “echo core.%p > /proc/sys/kernel/core_pattern”

  • 功能:设置核心转储文件的生成路径和格式。

当进程异常终止时,OS会根据core_pattern文件中的设置生成核心转储文件。

core.%p表示核心转储文件名为core.pid,为了防止未知的core dump一直运行,导致服务器磁盘被打满,因为程序每次运行都是全新的进程,pid均不同,因此通常将其设置为core,表示核心转储文件名为core,其大小是固定的;

3.2.2. OS如何知道键盘在输入数据

一、OS如何检测和处理键盘输入

  1. 效率问题:通过硬件中断机制,OS无需定期检测键盘是否被按下,大大提高了系统的效率和响应速度。这是因为硬件中断是异步发生的,当键盘被按键按下时,会立即触发中断,CPU会立即响应并处理该中断。

  2. 中断向量表:OS在启动时,会初始化一张中断向量表,这张表实际上是一个函数指针数组,每个下标对应一个中断编号,每个元素对应对应一个具体的中断处理函数。

  3. 硬件中断机制:当键盘上某个键被按下时,键盘控制器会向CPU发送一个硬件中断信号,这个信号通常是通过主板上一个固定的针脚发送的,该针脚与CPU某个特定中断输入引脚相连。

  4. OS响应中断:CPU收到中断信号后,会将中断编号保存在寄存器中,并且会要求OS根据中断编号查找中断向量表中的中断处理函数,并执行该函数。

对于键盘的输入,OS提供了读取键盘数据的方法,通过这个函数,OS就可获取键盘中输入的数据。

  1. 数据处理:OS读取数据后,会对数据进行判定,如果是字符,将其放入缓冲区中,供后续的程序通过read系统调用读取;如果是控制命令(组合键,如 : ctrl c),OS会将其解释为信号,并发送给当前正在运行的、与信号相关联的进程。

二、发送信号的本质

  1. 给进程发送信号的本质是将信号写入进程的PCB中,而PCB是内核数据结构,只有OS才有资格写入,用户只能通过调用OS提供的系统调用来写入信号。

  2. 无论信号的产生有多少种,最终都是OS负责将信号写入到目标进程的PCB中,并触发相应的信号处理机制。

3.3. 系统调用

3.3.1. kill

int kill(pid_t pid, int sig) ;

  • 功能:对任意进程发送任意信号。
  • 返回值:成功返回0,失败返回-1。

Tisp:kill命令底层封装了系统调用kill函数。

#include<iostream>
#include<cstdio>
#include<sys/types.h>
#include<signal.h>
#include<errno.h>
#include<cstring>

using namespace std;

void Usage(char* argv[]) 
{
    cout << argv[0] << " -signumber PID" << endl; 
}

int main(int argc, char* argv[]) //模拟实现kill命令
{
    if(argc != 3) Usage(argv); //用法错误
    int pid = stoi(argv[2]), signo = stoi(argv[1] + 1);
    int n = kill(pid, signo); //底层封装了系统调用kill
    if(n < 0) 
        cerr << "kill error: " << strerror(errno) << endl;
    
    return 0;
}

3.3.2. raise

int raise(int sig);

  • 功能:给当前进程发送任意信号。
  • 返回值:成功返回0,失败返回非0值。

#include<iostream>
#include<cstdio>
#include<unistd.h>
#include<signal.h>

using namespace std;

void handle(int signo) 
{
    cout << "get a signal, number is " << signo << endl;
}

int main()
{
    signal(2, handle); //设定信号捕捉的方法

    int cnt = 4;
    while(cnt--)
    {
        cout << "I am a process, pid: " << getpid() << endl;
        if(cnt == 2) raise(2); //给当前进程发送任意信号 —— 给自己发送2号信号
        sleep(1);
    }

    return 0;
}

3.3.3. abort

void abort(void);

  • 功能:终止进程,且给当前进程发送SIGABRT信号(6号信号)。

#include<iostream>
#include<cstdio>
#include<unistd.h>
#include<signal.h>

using namespace std;

void handle(int signo) 
{
    cout << "get a signal, number is " << signo << endl;
}

int main()
{
    signal(6, handle); //设定信号捕捉的方法

    int cnt = 4;
    while(cnt--)
    {
        cout << "I am a process, pid: " << getpid() << endl;
        if(cnt == 2) abort(); //给当前进程发送指定信号(6号信号)
        sleep(1);
    }

    return 0;
}


abort后续行为:即使捕捉了SIGABRT并返回了处理函数,abort函数仍然会尝试执行其标准的终止流程,这包括调用raise(SIGABRT),然后执行一些清理操作,并最终调用_exit(1)来终止程序,,因此它不关心循环中是否还有未执行的代码。

3.4. 软件条件

3.4.1. SIGPIPE信号

  1. SIGPIPE信号(13号信号)是由操作系统内核检测到的管道写端已关闭这一软件条件触发的信号。

  2. 产生条件:当一个进程向已经关闭写端的管道中写入数据时,OS会向该进程发送SIGPIPE信号,其默认行为是终止进程。

  3. 为什么向已关闭写端的管道写入数据,被视为软件条件?

这一过程涉及操作系统内核中软件代码来管理管道的状态、检查写入条件以及产生和处理信号。这与硬件条件(如:物理设备的状态变化)不同,是由物理设备的物理特性决定的。

管道是通过OS中特定的数据结构来实现的,但这些数据结构及其操作逻辑都是由OS软件来管理的;当一个进程进行写入时,OS会检查管道的状态,包括写端是否关闭,OS会检测到写端已关闭这一错误条件,会进行响应产生SIGPIPE信号,以通知进程发生了错误,这一过程是由操作系统内核中的软件代码来实现的,因此它属于软件条件范畴。

3.4.2. SIGALRM信号

一、SIGALRM信号

  1. SIGALRM信号(14号信号)是由软件条件触发的信号。

  2. 产生条件:通过调用alarm函数来设置一个定时器,在second秒后定时器到期,OS会给当前进程发送SIGALRM信号,其默认行为是终止进程。

二、系统调用接口alarm

unsigned int alarm(unsigned int seconds);

  1. 功能:设置一个定时器,定时器在seconds秒后到期,OS会向当前进程发送SIGALRM信号,这个信号可以被进程捕捉处理(signal函数),或执行默认处理动作(终止进程)。

  2. 返回值:调用失败:返回UINT_MAX,并设置错误码以指示错误的原因。

    调用成功:a. 如果调用alarm函数前,已经设置了一个全新的定时器且在运行,则alarm函数会取消之前的定时器,用新的定时器代替,此时,alarm函数返回值。b.如果调用alarm函数前,没有设置全新的定时器,则alarm返回值为0。

在调用alarm函数前,设置了alarm(0),表示取消之前设置的定时器,返回值为离之前设置的定时器剩余的时间。

#include<unistd.h>
#include<signal.h>

using namespace std;

void handle(int signo) 
{
    cout << "get a signal, number is " << signo << endl;
}

int main()
{
    signal(14, handle); //对SIGALAM信号进行捕捉

    alarm(50); 
    
    int cnt = 5;
    while(cnt--)
    {
        cout << "I am a process, pid: " << getpid() << endl;
        sleep(1);
        if(cnt == 3)  
        {
            size_t n = alarm(0); //取消之前设定的定时器
            cout << "alarm(0) retval " << n << endl; //返回值为离之前设置的定时器剩余的时间
        }
    }

    return 0;
}

  1. alarm只会执行一次,如果想让其在某段时间内每隔指定秒数触发SIGALRM信号,就需要在信号处理函数(signal)中捕捉SIGALRM信号,在此处理函数中设置一个全新的定时器。
#include<iostream>
#include<cstdio>
#include<unistd.h>
#include<signal.h>

using namespace std;

int cnt;

/*如果在信号处理函数中设置了alarm(2),并且接着是一个死循环,
现象:在2秒过后OS尝试发送SIGALRM信号,由于进入了死循环,
意味着处理函数不会返回,导致新的信号无法被处理,因为无法进入该信号处理函数*/
void handle(int signo) 
{
    cout << "catching..." << endl;
   int n = alarm(2); //在上一个定时器调用前,设置一个全新的定时器
   cout << "alarm(2) retval" << n << endl; //离之前设置的定时器剩余的时间
}

int main()
{
    signal(SIGALRM, handle); //设置SIGALRM信号的捕捉方法
    alarm(10); //设置一个定时器
    
    while(true)
    {
        cout << "I am a process, pid: " << getpid() << endl;
        sleep(1);
    }

    return 0;
}

  1. OS需要管理大量的定时任务,如:定期将数据从内核缓冲区刷新到外设,或执行其他需要定时控制的任务。alarm函数是一个系统调用接口,是用户空间和操作系统内核交互设置定时任务的一种方式,而在OS内部,必然存在大量的定时器,OS需要对它们进行管理,只需要判断当前时间是否超过了定时器的超时时间。

创建描述定时器的结构体,该结构体通常包含以下信息:设置定时器进程的PID、定时器的超时时间、触发时要发送给进程的信号等。

组织定时器结构体,使用最小堆,堆顶始终表示最近的一个超时的闹钟。

  1. 为什么SIGALRM信号被视为一个软件条件?它是由OS内部的软件逻辑所控制的,这个逻辑涉及到定时器的管理、检查和超时处理,最终导致SIGALRM信号的发送,而不是由外部硬件事件直接触发的。

3.5. 硬件异常

  1. 硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。

eg1:当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。

eg2:当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。

3.5.1. 除零异常

int main()
{
    int a = 0, b = 3;
    int c = b / a; //除数为0,会导致除零错误,接收到SIGFPE信号(8号信号)

    return 0;
}

  1. 除零异常

当CPU执行除法运算,如果除数为0,会导致除零错误,会触发一个除0异常;

CPU进行计算时会出现溢出的情况,这会导致CPU会更新EFLAGS寄存器中相应的标志位(如:OF、ZF等);

OS会识别到这些标志位的变化,则OS的异常处理机制会捕获这个异常,并发送SIGFPE信号(8号信号)给进程;

如果进程设置了该信号的自定义捕捉handle方法,那么该方法会被调用。

#include<iostream>
#include<cstdio>
#include<unistd.h>
#include<signal.h>

using namespace std;

void handle(int signo) //未终止进程,导致异常一直存在
{
    cout << "get signumber: " << signo << endl; 
    sleep(1); 
}

int main()
{
    signal(SIGFPE, handle);

    int a = 0, b = 3;
    int c = b / a; //除数为0,会导致除零错误

    while(true)
        sleep(1);

    return 0;
}

问:发生除零错误,触发除0异常,收到SIGFPE信号,为什么会一直执行自定义捕捉handler方法?

  • 进程中设置了对SIGFPE信号的自定义捕捉handle方法,发生除零错误时,会收到SIGFPE信号,那么该方法会被调用,因为在此方法中未终止进程,导致异常状态仍然保存在进程的上下文数据中,OS会将你这个进程切走,进程上下文会保存在PCB中,当此进程被OS再次调度时,上下文会进行恢复,OS识别到错误仍然存在,就会再次发送信号。

3.5.2. 野指针异常

  1. 野指针异常

野指针是指向无效内存地址的指针,是虚拟地址,当一个进程尝试通过野指针访问内存时,MMU会尝试将虚拟地址转化为物理地址,如果找不到对应的页表项或权限不匹配,则转化失败,MMU会触发页面错误异常。

CPU会捕获这个异常,并将导致错误的虚拟地址存储在cr2寄存器中,并设置EFLAGS寄存器中相应的标志位。

OS会识别到这些标志位的变化,并发送SIGSEGV信号(11号信号)给进程。

如果进程设置了该信号的自定义捕捉handle方法,那么该方法会被调用,若handle方法中未设置终止进程,那么每次上下文恢复时,OS识别到错误仍然存在,就会再次发送信号。

#include<iostream>
#include<cstdio>
#include<unistd.h>
#include<signal.h>

using namespace std;

void handle(int signo) //未终止进程,导致异常一直存在
{
    cout << "get signumber: " << signo << endl; 
    sleep(1); 
}

int main()
{
    signal(SIGSEGV, handle);

    int* p = NULL; //非法访问野指针,会导致野指针异常
    *p = 10;

    while(true)
        sleep(1);

    return 0;
}

  1. 总结:除零和野指针异常最终都会表现为硬件级别的异常(程序出现的错误最终都会在硬件层面上有所表现),CPU会触发相应的异常处理机制,OS通过捕获这些异常,并向进程发送信号来通知它,如果进程设置了信号的自定义捕捉方法,需要在该方法内采取适当的措施来清除异常状态或终止进程,否则异常会被重复触发。