Linux 进程信号:产生信号

目录

一、通过终端按键产生信号

1、signal()函数

2、核心转储

3、ulmit命令

二、调用系统函数向进程发信号

1、kill()函数

2、raise()函数

3、abort()函数

三、发送信号的过程

读端关闭、写端继续写入的情况

如何理解软件条件给进程发送信号:

四、软件条件产生信号

1、alarm()函数

2、模拟日志功能

五、硬件异常产生信号

1、除0异常:

2、野指针或内存越界问题:

总结:


一、通过终端按键产生信号

1、signal()函数

在Linux以及其他类Unix操作系统中,signal()函数是用于处理进程间通信(IPC)机制的一种方法,特别是用于处理异步发生的系统级事件,这些事件被称为"信号"。信号是内核向进程发送的通知,告知进程发生了某种预定义的重要事件。

函数原型:

cpp 复制代码
#include <signal.h>

typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);

功能

  • signal()函数允许进程注册一个信号处理函数(handler),当进程接收到指定的信号(signum)时,会调用该处理函数。
  • 如果handler是一个函数指针,则当信号signum发生时,系统会调用相应的处理函数。
  • handler可以是以下两种特殊值:
    • SIG_DFL: 将信号恢复为系统默认的行为,通常默认行为可能是终止进程、忽略信号等。
    • SIG_IGN: 忽略指定的信号,即不采取任何动作。

参数

  • signum: 这是待处理的信号编号,可以通过kill -l命令查看系统支持的所有信号名及其对应的数字。
  • handler: 这是指向信号处理函数的指针,该函数需要接受一个整型参数(通常是信号编号),并返回void

函数行为

  • signal()函数被调用后,后续接收到的指定信号会被按照新的handler来处理。
  • 需要注意的是,不同版本的signal()函数有不同的重置行为。在POSIX标准中,如果信号处理函数执行完毕且没有被捕获信号阻止其重置,则可能会自动恢复为默认行为。而有些旧版的实现(如System V)会保持已安装的处理函数,即使已经执行过。

例如,使用signal()注册一个处理函数的基本形式如下:

cpp 复制代码
void sig_handler(int signo) {
    // 对信号的处理逻辑
}

int main() {
    // 注册SIGINT(Ctrl+C)信号的处理函数
    signal(SIGINT, sig_handler);

    // 主程序逻辑...

    return 0;
}

示例:

bash 复制代码
[hbr@VM-16-9-centos signal]$ kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX

运行下面这段代码时,程序会进入一个无限循环,在循环中持续输出当前进程的ID。程序默认的行为是在接收到中断信号(SIGINT)时结束运行,而SIGINT信号通常由用户按下Ctrl+C组合键触发。

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;

void catchSig(int signum)
{
    cout<<"进程捕捉到了一个信号,正在处理中:"<<signum<<"Pid:"<<getpid()<<endl;
}

int main()
{
    signal(SIGINT,catchSig);
    while(true)
    {
        cout<<"我是一个进程,我正在运行......, Pid:"<<getpid()<<endl;
        sleep(1);
    }
    return 0;
}
  • signal函数在这里的作用是重新设定程序对于SIGINT信号的处理方式。默认情况下,接收到SIGINT信号会导致进程终止。然而,在代码中通过调用signal(SIGINT, catchSig),将SIGINT信号的处理函数替换为了自定义的catchSig函数。这样一来,每当程序接收到SIGINT信号时,不会立即退出,而是调用catchSig函数来处理这个信号。
  • catchSig函数会在每次接收到SIGINT信号时执行,并打印出相应的信息。但由于其并未阻止或重置信号,所以在处理完信号后,程序会继续执行main函数中的循环部分,也就是说,即便已经捕获并处理了SIGINT信号,程序还会继续等待下一次信号的到来。

输出kill -SIGINT 16913(进程PID)与ctrl+c的行为一致,可以证明,SIGINT确实是2号信号

如果后续没有任何SIGINT信号产生,catchSig会不会被调用?

答案是不会。只有在接收到相应的信号时,catchSig函数才会被调用。如果没有外部来源(如用户按Ctrl+C)触发SIGINT信号,catchSig函数就不会被执行。

在终端中,可以通过按下组合键Ctrl+\(3号信号)来向当前前台进程发送SIGQUIT信号来终止我们的程序。

2、核心转储

核心转储(Core Dump)是在Linux及类似操作系统中,当一个进程由于异常终止(如 segmentation fault、bus error 或接收到特定信号如SIGABRT、SIGSEGV等)时,操作系统将该进程当时内存中的内容复制到磁盘上的一个文件的过程。这个创建出来的文件通常命名为"core",或者带有进程ID的附加信息,如"core.pid"。

核心转储文件包含了进程在崩溃瞬间的完整内存映像,包括但不限于以下内容:

  1. 进程的内存布局:包括栈、堆、全局变量、静态存储区的内容,以及其他所有已分配的内存区域。
  2. 程序计数器(PC)和寄存器状态:这些信息能反映程序崩溃时刻的执行点和CPU状态。
  3. 虚拟内存映射表:记录了进程虚拟地址空间到物理内存的映射关系。
  4. 线程上下文:对于多线程程序,还包括所有活动线程的上下文信息。
  5. 共享对象信息:如果程序是动态链接的,还会包含相关共享库的信息。

通过分析核心转储文件,开发人员可以使用调试工具(如GDB)结合程序的可执行文件和相关的符号表,重现程序崩溃时的状态,从而定位和修复导致程序崩溃的错误。这对于排查复杂的软件问题尤其重要,因为它能够捕捉到实际运行时的数据和状态,而不仅仅是源代码层面的信息。为了能够正确生成和利用核心转储文件,系统必须设置适当的权限和资源限制,比如通过ulimit命令调整core文件大小上限,并确保/proc/sys/kernel/core_pattern配置允许生成core文件。

这些信号是计算机程序中用来处理特定事件或异常情况的标准机制。

在这张表格中,"Action" 列描述了每个信号所引发的动作。不同的信号可能会导致不同的行为,具体取决于系统配置以及应用程序如何处理这些信号。

  • Term:终止进程。这意味着接收到此信号后,进程将会被终止。默认情况下,进程不会保存任何数据或清理资源。
  • Core:除了终止进程外,还会创建一个核心转储文件。这个文件包含了进程在崩溃时刻的内存映像,可以帮助调试器分析问题所在。
  • Ign:忽略信号。如果一个进程选择忽略某个信号,则当其接收到该信号时,不会有任何反应。
  • Cont:继续执行。当进程被暂停时,它可以接收到一个信号来恢复运行

3、ulmit命令

ulimit -a

当你在终端中执行 ulimit -a 时,它会输出当前 shell 环境下所有可用资源的限制情况。

  • 其中,涉及到核心转储的部分,你会看到一行类似于 core file size (blocks, -c) 的输出项,后面跟着一个数值。这个数值代表了当前进程允许生成的最大核心转储文件大小,单位通常是块(在某些系统中可能是字节)。
  • 如果你设置了限制,这里就会显示具体的限制值;如果没有设置或设置为无限制,则可能会显示 unlimited 或一个较大的数值。
bash 复制代码
[hbr@VM-16-9-centos signal]$ ulimit -a
core file size          (blocks, -c) 0
//......
[hbr@VM-16-9-centos signal]$

ulimit -c

ulimit -c 专用于核心转储文件大小的控制。你可以用它来查看当前的核心转储文件大小限制,例如:

cpp 复制代码
[hbr@VM-16-9-centos signal]$ ulimit -c 10240
[hbr@VM-16-9-centos signal]$ ulimit -a
core file size          (blocks, -c) 10240
//......
[hbr@VM-16-9-centos signal]$
  • 只执行 ulimit -c 就会显示当前的限制值。
  • 若要设置新的限制,可以执行 ulimit -c <size>,这里的 <size> 是你希望设定的最大核心转储文件大小,单位通常是内存块或者字节(具体依赖于系统)。
  • 若要关闭核心转储功能,也就是不让程序在崩溃时生成核心文件,可以执行 ulimit -c 0
  • 若要允许无限大小的核心转储文件(受限于实际物理存储空间),则执行 ulimit -c unlimited
cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
using namespace std;

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() << " 子进程:" << id << \ 
    " exit sig: "<< (status & 0x7F) << " is core: " << ((status >> 7) & 1) << endl;

    return 0;
}
  1. fork()函数 :程序首先调用fork()创建一个子进程,当id == 0时,说明当前代码运行在新创建的子进程中。子进程睡眠一秒后尝试执行int a = 100; a /= 0;,这会导致除以零异常并终止子进程。

  2. waitpid()函数 :主进程中,父进程调用waitpid(id, &status, 0);等待子进程结束,并获取其退出状态。status变量包含了子进程退出时的各种信息,包括退出信号和是否生成了核心转储文件等。

  3. 输出信息 :父进程打印出自己的PID以及子进程的PID,然后通过位操作解析status变量,输出子进程的退出信号(这里是SIGFPE,即算术运算错误,通常由除以零引起,对应的十进制数是8)以及是否生成了核心转储文件(is core: 1意味着确实生成了核心文件)。

  4. ulimit命令 :开始时,通过ulimit -a可以看到系统的默认配置中,核心转储文件大小被设置为0,这意味着禁用了核心转储功能。接着通过ulimit -c 10240将其更改为10240个内存块(通常每个块512字节或取决于系统定义),这样就允许最多生成约5MB的核心转储文件。

  5. 运行程序后,由于子进程触发了段错误并且系统配置允许生成核心转储,因此产生了名为core.26825的核心转储文件,该文件的大小大约为557056字节。这个文件包含了子进程崩溃时刻的内存映像,可用于后续的故障排查与调试。

    bash 复制代码
    [hbr@VM-16-9-centos signal]$ ./mysignal 
    父进程:26824 子进程:26825 exit sig: 8 is core: 1
    [hbr@VM-16-9-centos signal]$ ll
    total 252
    -rw------- 1 hbr hbr 557056 Mar 25 20:32 core.26825
    -rw-rw-r-- 1 hbr hbr     68 Mar 25 12:38 makefile
    -rwxrwxr-x 1 hbr hbr   9312 Mar 25 20:31 mysignal
    -rw-rw-r-- 1 hbr hbr    810 Mar 25 20:31 signal.cc

二、调用系统函数向进程发信号

在Unix/Linux系统编程中,kill()raise()函数都是用来向进程发送信号的重要接口。

1、kill()函数

kill()函数允许一个进程向另一个进程发送信号。其基本原型为:

cpp 复制代码
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
  • pid:表示目标进程的ID,如果为-1,则向同一进程组的所有进程发送信号。
  • sig:表示要发送的信号,可以是SIGTERM(终止)、SIGKILL(强制终止)等各种预定义信号或其他用户自定义信号。

例如,如果我们想结束进程ID为1234的进程,可以调用kill(1234, SIGTERM);来发送一个终止信号。

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

void Usage(const char* proc)
{
    cout << "Usage:\r\n\t" << proc << " signumber processid" << endl;
}

int main(int argc, char* argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(EXIT_FAILURE);
    }

    int signumber = atoi(argv[1]);
    int procid = atoi(argv[2]);
    kill(procid, signumber);

    return 0;
}
  1. 定义了一个名为Usage的函数,它接受一个指向程序名的字符指针作为参数。这个函数的作用是打印出程序的使用方法,告诉用户应该如何正确地调用这个程序,即程序需要两个参数:信号编号和进程ID。

  2. main函数首先检查命令行参数的数量是否为3(包含程序名本身)。如果不是,则说明用户没有提供足够的参数,此时调用Usage函数显示帮助信息,并通过exit(EXIT_FAILURE)退出程序。

    bash 复制代码
    [hbr@VM-16-9-centos signal]$ ./mysignal 
    Usage:
            ./mysignalsignumber processid
  3. 如果参数数量正确,则将第二个参数(argv[1])和第三个参数(argv[2])分别转换成整数类型,它们分别代表要发送的信号编号和目标进程ID。这里使用了atoi()函数来进行这种转换。

  4. 最后,调用kill函数,传入目标进程ID(procid)和要发送的信号编号(signumber)。这个函数会尝试向指定的进程发送指定的信号,如果成功则返回0,否则返回非零值表示错误(具体错误可通过errno获取)。

bash 复制代码
//窗口二执行sleep:
[hbr@VM-16-9-centos signal]$ sleep 10000

//窗口一:
[hbr@VM-16-9-centos signal]$ ps axj | head -1 && ps axj | grep sleep
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
21405  4199 21405 21405 ?           -1 S     1003   0:00 sleep 180
20975  4926  4926 20975 pts/5     4926 S+    1003   0:00 sleep 10000
20212  5087  5086 20212 pts/4     5086 S+    1003   0:00 grep --color=auto sleep
25552 25681 25552  1522 ?           -1 Sl       0   3:30 /bin/sh -c sleep 100

[hbr@VM-16-9-centos signal]$ ./mysignal 9 4926

//窗口二
[hbr@VM-16-9-centos signal]$ sleep 10000
Killed
  1. 首先,在窗口二中执行了sleep 10000命令,这是一个持续休眠10000秒的进程,PID为4926。

  2. 接着,在窗口一中进行了以下操作:

    使用ps axj命令查看所有进程的相关信息,并使用head -1显示表头,可以看到各列分别代表什么含义。

    使用grep sleep查找所有名为sleeep的进程,找到了PID为4926的进程正在执行sleep 10000命令。

  3. 然后,窗口一中执行了编译好的mysignal程序,并传入了信号9(SIGKILL,用于强制终止进程)和目标进程PID(即4926)。

  4. 最后,在窗口二中可以看到,由于接收到SIGKILL信号,原本正在休眠的sleep 10000进程被立即强制终止,并打印出"Killed"信息。

2、raise()函数

raise()函数则是让进程发送信号给自己,其基本原型为:

cpp 复制代码
#include <signal.h>
int raise(int sig);
  • sig:同样是要发送的信号,与kill()函数中的含义相同。

例如,当一个进程需要响应某种情况而自我终止时,可以调用raise(SIGTERM);或者raise(SIGABRT);(触发异常终止)等。

cpp 复制代码
#include <iostream>
#include <string>
#include <unistd.h>
#include <signal.h>
using namespace std;
using namespace std;
 
int main(int argc, char *argv[])
{
    cout << "begin" << endl;
    sleep(1);
    raise(8);
    return 0;
}
[hbr@VM-16-9-centos signal]$ ./mysignal 
begin
Floating point exception (core dumped)
  1. 输出"begin",表示程序已开始运行。

  2. sleep(1)函数让程序暂停1秒,模拟一些延迟或等待。

  3. raise(8)调用发送信号给当前进程。数字8代表SIGFPE信号,即"浮点异常"信号,通常会在程序执行非法浮点运算(如除以零)时产生。

3、abort()函数

abort() 函数在编程中,尤其是在C/C++等语言中,是一个标准库函数,它用于强制终止(异常结束)当前进程。当调用该函数时,会产生一个SIGABRT信号发送给当前进程。这个信号通常会导致进程立即停止执行,并返回一个非零值给操作系统,指示程序异常终止。

具体来说,abort()函数执行以下操作:

  1. 异常终止程序:调用abort函数后,进程会立刻停止执行,不会进行任何清理工作,如释放内存、关闭文件等。

  2. 生成core dump(如果系统配置允许):在一些操作系统中,进程在接收到SIGABRT信号并终止时,可能会生成一个core dump文件,这个文件包含了进程在崩溃时刻的内存映像,对于后续调试非常有用。

  3. 返回状态码:abort函数使得进程以非正常方式退出,其退出状态码通常为1,表示程序异常终止。

cpp 复制代码
#include <iostream>
#include <string>
#include <unistd.h>
#include <signal.h>
using namespace std;

using namespace std;

static void Usage(string proc)
{
    cout << "Usage:\r\n\t" << proc << "signumber processid" << endl;
}

int main(int argc, char *argv[])
{
    cout << "begin" << endl;
    abort();
    return 0;
}
[hbr@VM-16-9-centos signal]$ ./mysignal 
begin
Aborted (core dumped)

当你运行这个程序时,控制台输出了"begin",然后由于abort()函数的调用,程序被异常终止,所以紧接着显示"Aborted (core dumped)",表明程序已经因为接收到abort信号而终止,并且可能产生了core dump文件(取决于系统的core dump设置)。

三、发送信号的过程

系统调用接口是操作系统为用户态进程提供的一种机制,使得用户程序能够请求操作系统内核服务。当用户程序需要执行一些特权操作(如读写文件、创建进程、发送信号等),而这些操作在用户态下无法直接完成时,就需要通过系统调用来请求内核的帮助。

  1. 用户调用系统接口 : 用户程序通过编程语言(如C/C++)提供的库函数(如kill()函数)发起一个系统调用。在底层,这个函数会生成一个特定的中断或异常,使CPU从用户态切换到内核态。

  2. 执行OS对应的系统调用代码: 当CPU进入内核态后,开始执行操作系统内核中与该系统调用对应的处理代码。对于发送信号的操作,内核会识别出这是一个发送信号的系统调用,并继续进行处理。

  3. OS提取参数/设置特定数值: 内核从寄存器或栈上获取用户程序传递过来的参数,比如要发送的信号编号(signumber)和目标进程ID(procid)。

  4. OS向目标进程写信号: 操作系统根据获取的进程ID找到目标进程,并在其内部结构体(如进程控制块PCB)中设置相应的信号信息,将指定的信号挂起或立即发送给目标进程。

  5. 修改对应进程的信号标记位: 对于待处理的信号,操作系统会在进程的信号集里置位,表示有新的信号到达。如果进程正在执行,但设置了阻塞该信号,则信号会被保存起来稍后处理;否则,进程会立刻响应这个信号。

  6. 进程后续处理信号: 进程在适当的时候(例如从系统调用返回到用户态,或执行到sigreturn指令时)检查并处理信号。处理方式取决于进程对信号的设置,可能是忽略信号、捕获并执行自定义处理函数,或者是默认行为(如终止进程)。

读端关闭、写端继续写入的情况

在Unix/Linux系统中,当进程间通过管道(pipe)进行通信时,管道的一端负责写入数据,另一端负责读取数据。如果发生以下情况:

  • 写端持续尝试向管道中写入数据;
  • 读端不仅没有读取管道中的数据,反而关闭了其对管道的读取端口;

这时会出现特定的问题:

  1. 当读端关闭后,管道中的缓冲区如果已满并且写端还在继续写入数据,内核将会阻止写端进一步写入数据,因为没有进程在读取这些数据。

  2. 如果写端仍然尝试写入数据到已关闭的管道,操作系统会检测到这一情况,并且会向试图写入管道的进程发送一个SIGPIPE信号。

  3. SIGPIPE信号默认的行为是终止接收信号的进程(即写端进程)。这意味着进程会因收到SIGPIPE信号而异常结束,返回值通常指示发生了Broken pipe错误。

在这种情况下,操作系统通过发送SIGPIPE信号确保了资源的有效管理,防止了写端进程无意义地往无法读取的管道中写入数据,同时也避免了系统资源的浪费。如果程序需要处理这种情况,可以通过信号处理函数捕获并处理SIGPIPE信号,而不是默认地让进程退出。

如何验证?步骤如下:

  1. 创建匿名管道。
  2. 父进程fork出子进程。
  3. 子进程负责写入管道,父进程负责读取管道。
  4. 父进程关闭读端管道文件描述符,并调用waitpid等待子进程。
  5. 子进程继续尝试写入管道直至完成。
  6. 子进程因SIGPIPE信号退出,父进程通过waitpid获取子进程的退出状态,并检查其原因是否为SIGPIPE。

为了验证当读端关闭时,写端进程会接收到SIGPIPE信号并退出的情况,可以按照以下步骤编写并执行一个程序:

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
#include <string.h>

// 使用signal代替sigaction设置信号处理函数
static void handle_SIGPIPE(int sig) {
    printf("Child received SIGPIPE signal.\n");
    _exit(1);
}

int main() {
    // 创建匿名管道
    int pipefd[2];
    if (pipe(pipefd) == -1) {
        perror("pipe");
        exit(1);
    }

    // 父进程fork出子进程
    pid_t child_pid = fork();
    if (child_pid == -1) {
        perror("fork");
        exit(1);
    } else if (child_pid == 0) { // 子进程
        close(pipefd[0]); // 关闭读端

        // 设置SIGPIPE信号处理函数
        if (signal(SIGPIPE, handle_SIGPIPE) == SIG_ERR) {
            perror("signal");
            exit(1);
        }

        // 尝试不断写入数据
        for (;;) {
            write(pipefd[1], "data", 4);
        }
    } else { // 父进程
        close(pipefd[1]); // 关闭写端

        // 关闭读端管道文件描述符
        close(pipefd[0]);

        // 等待子进程结束
        int status;
        while (waitpid(child_pid, &status, 0) != child_pid) {}

        // 检查子进程退出状态
        if (WIFEXITED(status)) {
            printf("Child exited normally with status %d.\n", WEXITSTATUS(status));
        } else if (WIFSIGNALED(status)) {
            if (WTERMSIG(status) == SIGPIPE) {
                printf("Child terminated by SIGPIPE signal.\n");
            } else {
                printf("Child terminated by signal %d: %s\n", WTERMSIG(status), strsignal(WTERMSIG(status)));
            }
        } else {
            printf("Child terminated abnormally.\n");
        }
    }

    return 0;
}

WIFSIGNALED(status)WTERMSIG(status) 是宏函数,它们用于检查子进程退出状态(存储在status变量中)是否是由信号导致的终止,以及具体是哪个信号导致的终止:

  • WIFSIGNALED(status):这是一个条件测试宏,用于判断进程是否是因为接收到某个信号而终止的。如果子进程是由于接收到信号而结束的,那么此宏返回非零值(真),否则返回零(假)。

  • WTERMSIG(status) :如果进程确实是因为信号终止的(即WIFSIGNALED(status)为真),那么这个宏用于提取导致进程终止的信号编号。它返回的是一个整数值,对应于信号名称,如SIGPIPE、SIGINT、SIGQUIT等。

cpp 复制代码
[hbr@VM-16-9-centos signal]$ ./mysignal 
Child received SIGPIPE signal.
Child exited normally with status 1.

如何理解软件条件给进程发送信号:

a,OS先识别到某种软件条件触发或者不满足

b,Os 构建信号,发送给指定的进程

  • 在操作系统中,当特定的软件条件触发或不满足时,操作系统首先会检测并识别到这一情况。
  • 一旦这种条件发生,操作系统就会依据预定的规则和机制,构建相应的信号对象。这个信号代表着一种软件级别的中断或事件通知,它携带着关于特定条件的信息。
  • 接下来,操作系统会立即将构建好的信号精准地发送给相关联的进程。这个过程就好比是系统给进程发送了一个内部消息,告诉进程有某种重要的事情发生,需要进程对此作出响应。
  • 例如,当管道读端关闭而写端仍在尝试写入数据时,操作系统就会向写端进程发送SIGPIPE信号,促使进程采取相应的行动,通常是终止进程,以此避免无效的系统资源消耗和潜在的错误状况。

四、软件条件产生信号

1、alarm()函数

alarm() 是 POSIX 标准中定义的一个系统调用函数,位于 <unistd.h> 头文件中。这个函数允许用户在程序中设定一个定时器,指定在未来的某个时间点(以秒为单位)向当前进程发送一个 SIGALRM 信号。

调用格式如下:

cpp 复制代码
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
  • 参数 seconds 表示等待的秒数。当你调用 alarm(seconds) 时,内核会启动一个定时器,这个定时器将在指定的 seconds 秒后到期。
  • 到期时会发生的事情是,内核会给当前进程发送一个 SIGALRM 信号。这是个异步事件,意味着即使进程正在执行其他任务,内核也会将其打断,插入这个信号事件。
  • 默认情况下,进程对 SIGALRM 信号的处理动作是终止进程(类似于接收到 SIGTERM 信号的效果)。然而,进程可以通过调用 signal() 或者 sigaction() 函数来重新设置对 SIGALRM 信号的处理方式,比如忽略信号、捕获信号并执行自定义处理函数等。

总结一下,alarm() 函数的主要作用是在指定的时间间隔后向进程发送一个信号,从而实现定时操作或者超时检测等功能。如果不对 SIGALRM 信号进行特殊处理,进程将在信号到达时结束运行。

2、模拟日志功能

cpp 复制代码
#include <iostream>
#include <string>
#include <vector>
#include <functional>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
#include <stdlib.h>

using namespace std;

// 定义一个函数类型,用于回调
typedef function<void()> CallbackFunc;
// 存储回调函数的向量
vector<CallbackFunc> callbacks;

// 全局计数器 无符号64位整数类型
uint64_t count = 0;

//该函数暂时禁用cout的同步,以提高输出速度,最后恢复同步。
 
void showCount()
{
    ios_base::sync_with_stdio(false);
    cout << "final count : " << count << endl;
    ios_base::sync_with_stdio(true);
}

void showLog()
{
    cout << "【日志功能】:触发了一次定时器事件" << endl;
}

//通过fork一个子进程,并在子进程中执行who命令,来记录当前登录的用户。
 
void logUser()
{
    pid_t childPid = fork();
    if (childPid == 0)
    {
        execl("/usr/bin/who", "who", nullptr);
        _exit(1); // 使用_exit确保不会执行exit相关的清理操作,避免影响性能
    }
    else if (childPid > 0)
    {
        wait(nullptr); // 父进程等待子进程执行完成
    }
    else
    {
        cerr << "Failed to fork a new process." << endl;
    }
}

// 当接收到SIGALRM信号时,执行回调函数队列中的所有函数,并重新设置定时器。
void catchSig(int signum)
{
    for (auto& f : callbacks)
    {
        f(); // 执行每个回调函数
    }
    alarm(1); // 重新设置1秒后的定时器
}

//初始化定时器和回调函数队列,然后进行无限循环以累加count。
int main(int argc, char* argv[])
{
    // 设置SIGALRM信号的处理函数
    signal(SIGALRM, catchSig);

    // 启动定时器,1秒后触发SIGALRM信号
    alarm(1);

    // 向回调函数队列中添加函数
    callbacks.push_back(showCount);
    callbacks.push_back(showLog);
    callbacks.push_back(logUser);

    // 无限循环,持续累加count
    while (true)
    {
        ++count;
    }

    return 0;
}
  1. 编译和运行 : 用户在终端上编译并运行该程序,得到可执行文件mysignal,然后执行它。

  2. 初始化 : 在main函数中,首先注册SIGALRM信号的处理函数catchSig,这意味着当接收到SIGALRM信号时,将调用catchSig函数。

  3. 设置定时器 : 程序调用alarm(1),设置一个1秒的定时器。当定时器到期时,系统将向当前进程发送SIGALRM信号。

  4. 回调函数 : 将三个函数showCountshowLoglogUser添加到callbacks向量中。这些函数将在接收到SIGALRM信号时按照顺序执行。

    • showCount函数输出当前的全局变量count值。
    • showLog函数输出提示信息,表示执行了日志功能。
    • logUser函数创建一个子进程,执行/usr/bin/who命令(显示当前登录用户列表),然后等待子进程结束。
  5. 主循环 : 程序进入一个无限循环,不断地递增全局变量count的值。

  6. 信号处理 : 当定时器到期时,系统发送SIGALRM信号给当前进程,从而调用catchSig函数。在catchSig函数中,依次执行callbacks向量中的所有函数,然后再重新设置一个1秒的定时器。

  7. 输出 : 因此,您在终端看到的是每隔一秒左右,showCount输出count的值,然后是showLog输出的消息。logUser执行的who命令结果没有在输出中直接显示,但该命令确实在后台执行了。

    bash 复制代码
    [hbr@VM-16-9-centos signal]$ ./mysignal 
    final count : 560421312
    这个是日志功能
    final count : 1124579773
    这个是日志功能
    final count : 1687847541
    这个是日志功能
    final count : 2251294324
    这个是日志功能
    final count : 2809612263
    这个是日志功能
    ^C
  8. 手动中断 : 用户通过按下Ctrl+C向程序发送SIGINT信号,程序因此被中断,并打印出最后一个final count的值和"这个是日志功能"的提示,然后退出。

综合起来,这个程序展示了如何使用Unix/Linux系统信号实现定时任务调度,并通过回调函数执行不同的操作。然而,由于count的递增与定时器触发的回调函数执行是异步进行的,所以showCount输出的并不是定时器触发时刻的count增量,而是每次回调执行时的累计值。

五、硬件异常产生信号

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

  • 例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。
  • 再比如当前进程访问了非法内存地址,,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。

1、除0异常

除以零是计算机系统中的一种硬件异常,操作系统通过监控硬件状态并发送适当的信号来应对这种异常,而进程通常会因该异常而终止,但也有可能在特殊情况下因错误处理而导致持续的死循环问题。

cpp 复制代码
#include "signal.h"
#include <iostream>
#include <signal.h>
#include <unistd.h>

void catchSig(int sig)
{
    std::cout << "catch signal " << sig << std::endl;
}
int main()
{
    for (int i = 1; i <= 31; i++)
        signal(i, catchSig);
    while (1)
        sleep(1);
}

[hbr@VM-16-9-centos signal]$ ./mysignal 
catch signal 1                           [hbr@VM-16-9-centos signal]$ kill -1 29748                        
catch signal 2                           [hbr@VM-16-9-centos signal]$ kill -2 29748
Killed                                   [hbr@VM-16-9-centos signal]$ kill -9 29748
[hbr@VM-16-9-centos signal]$             [hbr@VM-16-9-centos signal]$ 
  1. 硬件层面:在进行除法运算时,是由CPU这一硬件组件负责执行计算任务。当CPU检测到除数为零的异常情况时,其内部的硬件机制会立即响应并标识这一错误状态。

  2. 状态寄存器与操作系统:CPU拥有状态寄存器,其中包含了一系列状态标志位,用于反映最近一次算术或逻辑运算的结果状态。对于除以零这样的异常,CPU会设置相应的标志位(例如溢出标志或其他错误标志)。操作系统(OS)通过周期性的检查或中断机制,能够识别到这些异常状态,并据此采取行动。

  3. 信号处理与进程响应:当操作系统检测到除零异常时,它通常会生成一个特定的信号(例如SIGFPE),并将该信号发送给引发异常的进程。默认情况下,进程接收到SIGFPE信号后会终止执行,但开发人员可以通过信号处理函数自定义进程对该信号的响应。尽管如此,由于除零错误属于严重的计算错误,通常情况下,进程难以从这种错误中恢复并继续有意义的执行。

  4. 死循环的可能性 :如果不正确地处理除零异常,或者系统未能有效处理这一信号,异常状态可能保留在寄存器中而得不到清除。在这种情况下,如果程序逻辑不当,异常可能导致进程陷入死循环,反复尝试进行无效或错误的计算,无法恢复正常执行流程。

    例如:

    1. 下一次CPU尝试执行同样的或依赖于上次错误计算结果的指令时,由于寄存器中的错误状态没有重置,很可能再次触发同样的异常,形成一个无限循环。

    2. 若程序逻辑设计不合理,没有在异常发生后采取跳转到安全恢复点或者退出程序等措施,而是继续尝试执行引发异常的那部分代码,那么程序将无法跳出错误状态,陷入死循环。

    3. 举例来说,如果程序中有一个循环,在循环体内有除法运算,而循环条件没有检查除数是否为零,当发生除零异常时,若没有处理好这个异常,程序可能就会一直在循环中尝试执行除法操作,每次都触发除零异常,从而造成死循环现象。

2、野指针或内存越界问题

野指针示例:

cpp 复制代码
void catchSig(int sig) {
    if (sig == SIGSEGV) {
        // 当捕获到SIGSEGV信号时,表示程序发生了段错误。
        printf("Caught segmentation fault (SIGSEGV)!\n");
        _exit(EXIT_FAILURE);
    } else {
        // 如果捕获到的信号不是SIGSEGV,打印错误信息到标准错误输出。
        fprintf(stderr, "Caught unexpected signal %d\n", sig);
        _exit(EXIT_FAILURE);
    }
}

int main() {
    signal(SIGSEGV, catchSig);  // 注册SIGSEGV信号处理器
    int *ptr;
    // 指针未初始化,此时ptr的值是不确定的
    printf("%d\n", *ptr);  // 这一行可能触发段错误(SIGSEGV)

    return 0;
}

[hbr@VM-16-9-centos signal]$ ./mysignal 
Caught segmentation fault (SIGSEGV)!

内存越界示例:

cpp 复制代码
#include <stdio.h>

int main() {
    int arr[5] = {0, 1, 2, 3, 4}; // 定义一个大小为5的整型数组
    int *ptr = arr;

    // 访问数组的有效索引是从0到4
    for (int i = 0; i <= 5; ++i) { // 注意这里是<=5,而不是<5
        printf("%d\n", ptr[i]); // 当i=5时,访问arr[5]即越界访问
    }

    return 0;
}

在这两个例子中,当试图访问通过野指针或超过数组边界的内存地址时,操作系统会通过MMU检测到这一错误,并且通常会发送一个信号(例如SIGSEGV)给进程,如果不捕获并处理这个信号,程序通常会立即终止。

  1. 在编程过程中,访问数组或动态分配的内存区域时,如果引用了无效的内存地址(野指针)或超出合法范围(内存越界),那么同样会引发硬件级别的异常。

  2. 访问任何内存地址都需要经过地址转换,从应用程序使用的逻辑地址(即虚拟地址)转换为实际的物理内存地址。

  3. 这种地址转换是由操作系统和硬件(MMU,即内存管理单元)共同协作完成的。MMU负责维护页表,将虚拟地址映射到物理地址空间。

  4. 当尝试访问的虚拟地址无法正确映射到物理地址,比如因为地址未分配、已经释放、或者超出了分配区域的边界,这时MMU在转换过程中会检测到错误并通知操作系统。

  5. 因此,无论是由于野指针导致的非法地址引用,还是内存越界访问,都会在MMU转换过程中触发异常,进而可能导致进程接收到信号(如SIGSEGV,段错误信号),并根据预设的处理方式来决定进程是否退出、暂停或其他操作。在未妥善处理的情况下,进程也有可能因此陷入死循环或不稳定状态。

总结:

所有的信号,无论源于何种软件或硬件条件,本质上都会经过操作系统的监测和识别。一旦触发了某个信号源,操作系统便会迅速介入,对信号进行恰当的解析,并将其转发给目标进程。简而言之,无论是何种类型的信号,最终都是由操作系统统一识别并妥善送达给相应进程进行处理的。

在计算机系统中,信号是操作系统用来通知进程某些事件发生的一种机制。它们可以源自不同的软件或硬件条件,例如:

  1. 硬件层面:硬件异常,例如除零错误、内存访问违规(如越界访问)、外部设备中断等,这些情况会被硬件捕捉并通过中断机制通知操作系统。

  2. 软件层面:软件信号,又称为软件中断,是由操作系统或者其他进程故意发送给目标进程的,用于指示某种特定的事件或请求,比如用户按下Ctrl+C发起的终止进程请求、定时器到期、子进程结束等。

操作系统作为整个系统的核心管理者,承担着监测和识别所有类型信号的任务。当一个信号被触发时,操作系统会快速响应,对信号进行分类、解析,并决定如何采取行动。针对每个信号,操作系统可能会执行以下操作之一:

  • 直接处理并解决异常状况,如回收非法访问的内存。
  • 转发信号至相应的进程,并依据进程预先设置的信号处理函数来执行适当的动作。
  • 如果进程没有专门注册处理该信号的函数,则采用默认操作,比如对于某些致命信号,默认操作可能是终止进程。
相关推荐
WTT001129 分钟前
2024楚慧杯WP
大数据·运维·网络·安全·web安全·ctf
苹果醋337 分钟前
React源码02 - 基础知识 React API 一览
java·运维·spring boot·mysql·nginx
了一li1 小时前
Qt中的QProcess与Boost.Interprocess:实现多进程编程
服务器·数据库·qt
日记跟新中1 小时前
Ubuntu20.04 修改root密码
linux·运维·服务器
唐小旭1 小时前
服务器建立-错误:pyenv环境建立后python版本不对
运维·服务器·python
码农君莫笑1 小时前
信管通低代码信息管理系统应用平台
linux·数据库·windows·低代码·c#·.net·visual studio
明 庭1 小时前
Ubuntu下通过Docker部署NGINX服务器
服务器·ubuntu·docker
BUG 4041 小时前
Linux——Shell
linux·运维·服务器
007php0072 小时前
Go语言zero项目部署后启动失败问题分析与解决
java·服务器·网络·python·golang·php·ai编程