当前位置:   article > 正文

Linux信号机制_linux中的信号机制

linux中的信号机制

一、引言

在Linux系统中,信号(Signal)是一种软件中断,它提供了一种机制,允许进程之间、进程和内核之间传递异步事件通知。这些事件可能是用户从终端输入的一个命令(如Ctrl+C),也可能是系统检测到的一个硬件错误(如内存访问违规)。信号机制为Linux系统提供了丰富的进程间通信手段,并且是实现多任务并发执行和进程管理的重要基础。

很多比较重要的应用程序都需处理信号。信号提供了一种处理异步事件的方法:终端用户键入中断键,则会通过信号机构停止一个程序。

信号在Linux系统中的作用和重要性不言而喻。它们为系统提供了一种强大的控制机制,允许管理员和用户通过发送信号来影响进程的执行。例如,通过发送SIGINT信号,用户可以中断一个正在运行的程序;通过发送SIGTERM信号,管理员可以优雅地关闭一个服务进程。此外,信号还用于实现进程间的同步和通信,以及处理系统错误和异常情况。


二、Linux信号基础

1、信号的定义

Linux信号是操作系统中用于进程间通信、处理异常等情况的一种机制。它是由操作系统向一个进程或者线程发送的一种异步通知,用于通知该进程或线程某种事件已经发生,需要做出相应的处理。

每个信号都有一个名字。这些名字都以三个字符SIG开头。在头文件<signal.h>中,这些信号都被定义为正整数(信号编号)。没有一个信号其编号为 0。
在这里插入图片描述

Linux 支持两种类型的信号:

  1. POSIX 标准信号(Standard Signals):这些是传统的 Unix 信号,包括 SIGINT(通常由 Ctrl+C 产生)、SIGTERM(请求终止)、SIGKILL(强制终止)等。
  2. POSIX 实时信号(Real-Time Signals):这些信号用于需要更精确控制和更高级功能的场景。实时信号的编号从 SIGRTMINSIGRTMAX。(即,信号34到64都是实时信号。)

2、常见的信号列表及其作用

SIGABRT 调用abort()函数时,产生此信号。进程异常终止。

SIGALRM 超过用alarm()函数设置的时间时产生此信号。

SIGBUS 指示一个实现定义的硬件故障。

SIGCHLD 在一个进程终止或停止时,SIGCHLD信号被送给其父进程。按系统默认,将忽略此信号。如果父进程希望了解其子进程的这种状态改变,则应捕捉此信号。信号捕捉函数中通常要调用wait()函数以取得子进程ID和其终止状态。

SIGFPE 此信号表示一个算术运算异常,例如除以0,浮点溢出等。

SIGKILL 这是两个不能被捕捉或忽略信号中的一个。它向系统管理员提供了一种可以杀死任一进程的可靠方法。

SIGQUIT 当用户在终端上按退出键(一般采用 ctrl + \)时,产生此信号,并送至前台进程组中的所有进程 。

3、信号的产生与传递过程

a、信号的产生

在Linux系统中,信号的产生主要有以下几种方式:

  1. 通过终端按键产生信号
    • 当用户在终端中按下某些特定的组合键时,会生成相应的信号。例如,用户在终端中按下Ctrl+C,会产生SIGINT信号(2号信号),用于中断前台进程的执行。终端中按下Ctrl+\,会产生SIGQUIT信号(3号信号)。
  2. 调用系统函数向进程发信号
    • kill()函数:这是一个常用的系统调用,允许用户或进程向另一个进程发送指定的信号。通过kill()函数,你可以发送几乎所有的信号到指定进程。
    • raise()函数:这个函数允许进程向自己发送信号。
    • abort()函数:当程序遇到严重错误时,可以调用此函数来发送SIGABRT信号,并生成一个核心转储文件(core dump)。
  3. 由软件条件产生信号
    • 当满足某些软件条件时,系统或库函数可能会向进程发送信号。例如,alarm()函数可以设置定时器,当定时器超时时,会向进程发送SIGALRM信号。
    • 在处理某些I/O操作时,如管道(pipe)或套接字(socket)的写操作,如果接收端已经关闭,则写操作会失败并发送SIGPIPE信号给进程。
    • alarm函数允许设置一个定时器,当定时器到期时,会向进程发送一个SIGALRM信号。这个信号是软件条件产生的,因为它不是由硬件异常或终端按键直接触发的,而是由进程内部的计时器到期触发的。
  4. 硬件异常产生信号
    • 当硬件检测到某些错误或异常情况时,如非法内存访问(SIGSEGV)、除零错误(SIGFPE)等,会向进程发送相应的信号。这些条件通常由硬件检测到,并将其通知内核。然后内核为该条件发生时正在运行的进程产生适当的信号。
  5. 用户命令发送信号
    • 用户可用kill命令将信号发送给其他进程。此程序是kill函数的界面。常用此命令终止一个失控的后台进程。

当某个事件发生时,系统会为该事件生成一个信号。事件可以是上面提到的任意一种。在产生信号时,内核通常在进程的PCB中设置某种形式的一种标志。

但是,无论信号的产生方式有多少种,最终都是操作系统向进程的PCB中写入信号的。

在操作系统中,每个进程都有一个与之关联的PCB,用于存储进程的状态信息,如程序计数器、内存指针、进程状态、信号状态等。当信号产生时,操作系统会根据信号的类型和进程的状态来决定如何处理这个信号。

如果信号没有被阻塞或忽略,并且进程没有为该信号设置特定的处理函数,那么操作系统通常会采取默认的行为,比如终止进程或停止进程的执行。如果进程为该信号设置了处理函数,那么操作系统会在合适的时机调用这个函数来处理信号。但在所有这些情况下,操作系统都需要向进程的PCB中写入或更新信号的状态信息,以确保信号能够被正确地处理。这包括设置信号的未决状态,以便在进程恢复执行时能够递达信号;以及更新信号的阻塞状态,以反映进程对特定信号的阻塞情况。

b、进程里信号的传递过程

一旦信号产生,操作系统内核并不会立即处理它,而是会将其状态保存在某个地方,直到合适的时候再进行处理。实际执行信号的处理动作被称为信号递达(delivery)。这种从信号产生到递达之间的状态被称为信号未决(Pending)状态,也就是信号的保存状态。 进程可以选择阻塞某些信号的递达。 当进程阻塞了一个信号时,即使该信号被生成,内核也不会立即将该信号递达给进程,而是将该信号保持在未决状态。直到该进程对此信号解除了阻塞,或者将对此信号的动作更改为忽略。

信号的存储在内核中的示意图如下:

在这里插入图片描述

我们用描述Linux中信号处理流程的相关表或组件之间的关系。

  1. 进程PCB
    • 进程控制块是内核为每个进程维护的一个数据结构,它包含了描述进程状态和控制进程运行所需的全部信息。在信号处理中,PCB中通常包含了与该进程相关的信号信息,例如当前哪些信号正在被等待、哪些信号被屏蔽等。
  2. 阻塞表
    • 阻塞表(或称阻塞信号集)记录了当前进程想要忽略或屏蔽的信号集。如果一个信号在阻塞表中被设置(即被屏蔽),那么即使该信号被发送到进程,它也不会被立即处理,而是会被放入未决表(pending)中等待,直到该信号从阻塞表中被移除。
  3. 未决表
    • 未决表(或称未决信号集)用于存储当前进程已接收但尚未处理的信号。当一个信号被发送到进程时,它会被放入未决表中。进程会在适当的时候(例如从内核态返回用户态时)检查未决表和阻塞表,并处理其中的信号。
  4. 处理方式表
    • 处理方式表定义了进程对各种信号的处理方式。对于每种信号,处理方式表中都有一个对应的处理函数或行为描述。当进程检查到未决表中有信号时,它会根据处理方式表来确定如何响应这个信号。处理方式可以是忽略信号、终止进程、调用自定义的处理函数等。

三张表的每一行是一一对应的。 总之,这张图描述了一个典型的Linux进程在信号处理时如何与这些表或组件进行交互。进程PCB是核心,它包含了进程的状态信息;阻塞表决定了哪些信号应该被忽略;未决表存储了待处理的信号;而处理方式表则定义了如何响应这些信号。阻塞表和未决表是一种位图,某一为置1表示该位所对应的信号被阻塞或待处理。

当递达一个原来被阻塞的信号给进程时,而不是在产生该信号时,内核才决定对它的处理方式。因此,进程在信号递达给它之前仍可改变对它的动作。进程可以调用特定的函数,将指定的信号设置为阻塞和未决。

如果在进程解除对某个信号的阻塞之前,这种信号发送了多次,那么会发生什么呢?

如果在进程解除对某个信号的阻塞之前,这种信号发生了多次,这些信号会保持在未决状态,直到进程解除对该信号的阻塞。这些信号会在阻塞解除后递达给进程,但递达的顺序可能会受到操作系统调度策略的影响,并不一定是按照它们被发送的顺序。

也就是说,当一个信号被发送到进程时,如果该信号在进程的信号阻塞集中,这个信号仍会被加入到进程的未决信号集中。如果进程在后续的时间里又收到了相同的信号(在阻塞解除之前),那么我们的未决信号集不会变化。

当进程解除对某个信号的阻塞时,内核会检查该信号的未决状态。如果未决信号集中有该信号,那么内核会按照它的调度策略依次将该信号递达给进程,但具体的处理方式(是执行默认动作、忽略还是调用信号处理函数)取决于进程为该信号注册的处理方式。

需要注意的是,虽然信号在递达之前会保持在未决状态,但某些信号(如SIGKILLSIGSTOP)是不可阻塞、不可忽略、不可捕获的,它们会立即中断进程的执行。

每个进程都有一个信号屏蔽字,它决定了哪些信号当前被阻塞,不会被内核递达给进程。这个屏蔽字是一个位图,就是阻塞信号集。其中的每一位对应于一个可能的信号,如果某信号的对应位被设置(通常是 1),那么该信号就被阻塞了。进程可以调用sigprocmask 来检测和更改其当前信号屏蔽字。这个函数允许进程指定一个新的信号屏蔽字,并(可选地)获取旧的信号屏蔽字。

总的来说,当信号产生后,它需要通过操作系统内核的干预来传递给目标进程。

  1. 信号生成:当某个事件触发信号时,内核会生成一个与该事件对应的信号。
  2. 信号传递:内核会将生成的信号添加到目标进程的信号集中。如果目标进程正在运行,内核会立即尝试将信号传递给该进程;如果目标进程当前没有运行(例如,处于睡眠状态),则内核会推迟传递信号,直到该进程恢复运行。
  3. 信号处理:当目标进程接收到信号时,它会根据预先设置的信号处理函数(也称为信号处理程序)来处理该信号。进程可以选择忽略该信号、执行默认操作(如终止进程)或执行自定义的处理函数。

在信号处理过程中,进程可能会进行一系列操作,如清理资源、保存状态、记录日志等。然后,根据处理函数的返回值或执行结果,进程可能会继续执行原来的代码、退出或执行其他操作。

需要注意的是,信号的传递和处理是异步的,进程可以在任何时间点接收到信号,不论它当时正在做什么。

在Linux系统中,信号处理是操作系统内核与进程交互的一部分。当一个信号(如SIGHUPSIGINTSIGQUIT等)被发送给进程时:

  1. 信号接收:进程PCB是内核为每个进程维护的数据结构,其中包含了进程的状态信息、地址空间等。当信号被发送到某个进程时,这个信号会被内核接收,并存储在进程PCB的相关数据结构(相应的信号集)中。
  2. 信号屏蔽检查:内核会检查该进程的阻塞表。阻塞表是用于存储当前进程希望忽略或阻塞的信号集合。如果信号在阻塞表中被设置(即被阻塞),那么该信号就不会被立即处理,而是会进入“未决表”。未决表用于存储当前进程已接收但尚未处理的信号。
  3. 信号处理:如果信号没有在阻塞表中被设置(即未被阻塞),或者信号已经从阻塞表中被移除(即不再被阻塞),内核会根据“处理方式表”中定义的处理方式来处理该信号。处理方式可以是忽略信号、终止进程、执行特定的处理程序等。

现在,我们来分析信号被阻塞时的情况:

  • 当信号被阻塞时,它不会被立即处理,而是会被放入进程的未决表中等待。未决表是一个数据结构,用于存储当前进程已接收但尚未处理的信号。
  • 当阻塞表中的某个信号被移除(即不再被阻塞)时,进程会检查未决表是否该信号存在,然后按照一定的顺序依次递达并处理该信号。
  • 如果进程在等待某个信号时被阻塞,并且该信号在之后被发送并递达,但由于某种原因(如资源竞争、调度等)进程没有被调度运行来处理该信号,那么该信号将一直处于未决状态,直到进程被调度运行并检查未决表。

总结起来,Linux信号在进程中的处理流程涉及到信号的接收、屏蔽检查、处理等多个步骤。当信号被阻塞时,它会被放入未决表等待,直到进程检查并处理这些信号。

c、sigset_t

Linux中有一个能表示多个信号的数据类型,sigset_t。我们称之为信号集,类似位图。未决表和阻塞表均是这个数据结构。处理方式表定义了进程对各种信号的处理方式。本质上是一个函数指针数组。数组的下标代表了特定的信号。数组的内容是指向一个处理函数的指针。这个处理函数定义了当信号递达时进程应该执行的动作。

阻塞表与未决表相同,中每个比特位代表一个特定的信号。例如,第0位可能代表SIGHUP信号,第1位代表SIGINT信号,以此类推。

总结:操作系统内部使用了几种数据结构来保存信号的相关信息,其中最重要的是pending表(未决信号集)和block表(阻塞信号集)。

  • pending表:用于0保存当前进程已经接收到但尚未处理的信号。当信号产生时,内核会在进程控制块(PCB)中的pending表中设置相应的位,表示该信号已经产生但尚未处理。这样,即使信号的产生和处理之间存在一定的时间差,操作系统也能确保不会丢失任何信号。
  • block表:这个表用于保存当前进程所阻塞的信号。被阻塞的信号在产生时会保持在未决状态,直到进程解除对此信号的阻塞,才会执行递达的动作。阻塞和未决是两种状态,阻塞表示进程选择不处理某个信号,而未决表示信号已经产生但尚未处理。

在 POSIX.1 中,还定义了一组函数来操作 sigset_t 类型的信号集,包括:

在这里插入图片描述

函数sigemptyset初始化由set指向的信号集,使排除其中所有信号。函数sigfillset初始化由set指向的信号集,使其包括所有信号。所有应用程序在使用信号集前,要对该信号集调用 sigemptysetsigfillset一次。这是因为C编译程序将不赋初值的外部和静态度量都初始化为 0, 而这是否与给定系统上信号集的实现相对应并不清楚。 一旦已经初始化了一个信号集,以后就可在该信号集中增、删特定的信号。函数 sigaddset 将一个信号添加到现存集中,sigdelset则从信号集中删除一个信号。对所有以信号集作为参数的函数,都向其传送信号集地址。


三、信号处理机制

1、信号的处理方式

信号是异步事件的经典实例。产生信号的事件对进程而言是随机出现的。进程不能只是测试一个变量(例如errno)来判别是否发生了一个信号,而是必须告诉内核“在此信号发生时,请执行下列操作”。可以要求系统在某个信号出现时按照下面三种方式中的一种进行操作:

  1. 忽略此信号

    • 大多数信号都可使用这种方式进行处理,但有两种信号却决不能被忽略。 它们是:SIGKILLSIGSTOP。这两种信号不能被忽略的原因是:它们向超级用户提供一种使 进程终止或停止的可靠方法。另外,如果忽略某些由硬件异常产生的信号(例如非法存储访问或除以0),则进程的行为是未定义的。
    • 为了忽略一个信号,进程可以使用signal()函数或更现代的sigaction()函数,并指定一个信号处理程序为SIG_IGN
  2. 捕捉信号

    • 为了做到这一点要通知内核在某种信号发生时,调用一个用户函数。在用户函数中,可执行用户希望对这种事件进行的处理。例如,当接收到SIGINT(通常由用户按下Ctrl+C产生)时,进程可以选择优雅地清理并退出,而不是立即终止。为了捕捉一个信号,进程使用signal()sigaction()函数,并指定一个信号处理程序函数。当信号被触发时,操作系统会暂停进程的正常执行,调用相应的处理程序,然后恢复进程的执行。
  3. 执行系统默认动作

    • 如果进程没有为某个信号指定处理程序,并且也没有忽略它,那么当该信号被发送时,操作系统会执行与该信号关联的默认动作。这些默认动作通常是终止进程(如SIGTERM)、停止进程(如SIGSTOP)、或者产生核心转储文件(如SIGSEGV)。

    • 对大多数信号的系统默认动作是终止该进程。我们可以使用man 7 signal命令,去查询信号的默认操作:

      在这里插入图片描述

在系统的默认行为中,Core表示在进程当前工作目录的core文件中复制了该进程的存储图像(该文件名为core)。gdb调试程序可以使用core文件以检查进程在终止时的状态。

那么内核是如何对收到的一个信号进行处理的呢?

我们做出如下解释:

  • 用户态是应用程序正常运行的环境,而内核态是操作系统内核(如Linux内核)运行的环境。
  • 在用户态下,应用程序不能直接访问硬件或进行某些敏感操作,必须通过系统调用来请求内核的帮助。

首先当处理信号的方式是方式或忽略时:

在这里插入图片描述

  1. 进程的状态转换
    • 当用户态的程序(main()函数)执行过程中遇到中断、异常或系统调用时,进程会进入内核态进行处理(步骤1)。
    • 内核通过处理异常或系统调用后,可能会发送一个信号给用户态(步骤2)。内核会调用do_signal()函数来处理信号。
    • 在内核处理完异常并准备返回用户态之前,内核会检查当前进程中是否有可以递达的信号(使用do_signal()函数来表示这一过程)。这些信号可能是之前被阻塞(阻塞在pending位图中)的,或者是在内核处理异常期间产生的。
    • 信号处理函数执行完毕后,待do_signal()函数执行完毕,进程会再次返回到用户态,继续执行之前被中断的指令(步骤3)。
  2. 信号的处理
    • 当内核检测到有信号需要发送给用户态时,它会先处理当前进程中可以递达的信号(即尚未被阻塞或忽略的信号),这是通过do_signal()函数完成的。无论是在哪种情况下,一旦返回到用户态,进程都会从其主控制流程中上次被中断的地方继续执行。
    • 如果信号的处理动作是默认或忽略,内核在执行完这些动作和do_signal()后会直接返回用户态,并从主控制流程中上次被中断的地方继续执行。

那么如果处理方式是用户自定义的:

在这里插入图片描述

  1. 进程的状态转换
    • 当用户态的程序(main()函数)执行过程中遇到中断、异常或系统调用时,进程会进入内核态进行处理(步骤1)。
    • 内核通过处理异常或系统调用后,可能会发送一个信号给用户态(步骤2)。内核会调用do_signal()函数来处理信号(步骤3)。
    • 由于信号的处理动作是自定义的,又从内核态切换到用户态。执行完用户自定义的处理函数后,用户态会执行一个特殊的系统调用来进入内核态,以执行信号处理函数(步骤4)。
    • 信号处理函数执行完毕后,进程会再次返回到用户态,继续执行之前被中断的指令(步骤5)。
  2. 信号的处理
    • 当内核检测到有信号需要发送给用户态时,它会先处理当前进程中可以递达的信号(即尚未被阻塞或忽略的信号),这是通过do_signal()函数完成的。
    • 当信号的处理动作是自定义的,则用户态会执行自定义的信号处理函数。这个处理函数是在用户态下执行的,而不是回到主控制流程(步骤3)。

但是如果上面两图在信号处理函数执行完后,又有信号需要递达,我们通常会根据该信号的处理方式(当前处于内核态),或切换到用户态去执行用户的自定义函数,或在内核态执行系统设定的处理方式。

那么我们进行思考:如果一个信号没有被递达,处于未决状态,且该信号没有被阻塞。此时,该信号又被发送给进程会出现什么情况?

我们先来描述关于信号递达和信号处理过程中信号屏蔽字(类似位图的数据结构,sigset_t)的变化:

当一个信号被发送给进程时,它首先被放置在进程的未决信号集中。这个未决信号集是一个内部的数据结构,用于跟踪进程尚未处理的信号。此时,即使信号已经被发送到进程,但如果进程正在执行一个不允许中断的操作(如某些系统调用),或者进程选择忽略或阻塞了该信号,那么这个信号就不会立即被递达给进程。

当进程处于一个可以接收信号的状态,并且该信号没有被阻塞或忽略时,操作系统就会将信号从未决信号集中取出,并递达给进程。在递达信号之前,操作系统会临时修改进程的信号屏蔽字,将当前被递达的信号的位置设置为阻塞状态(即置1),而且未决信号集中会把该信号的位置设为0。如若此时又接收到该信号,那么该信号在未决信号集的该位置又被设为1。这是为了确保在信号处理函数执行期间,相同类型的信号不会被再次递达给进程,从而防止信号处理函数被递归调用。

在信号处理函数执行完毕,要回到程序原执行处时,操作系统会恢复进程原来的信号屏蔽字,即将之前置为阻塞状态的信号位置重新设置为非阻塞状态(即置0)。这样,进程就可以继续接收和处理其他信号了。

简单来说,就是当一个信号被递达给进程时,操作系统会临时修改进程的信号屏蔽字,将当前被递达的信号的位置设置为阻塞状态。在信号处理函数执行完毕,要回到程序原执行处时,操作系统会恢复进程原来的信号屏蔽字。这样可以确保在信号处理函数执行期间,相同类型的信号不会被再次递达给进程。

操作系统中信号处理是一个常见过程,特别是当信号的处理动作是用户自定义函数(即信号捕捉)时。这个过程涉及用户态和内核态之间的切换,以及信号处理函数和主程序之间的独立控制流程。

  1. 信号注册:用户程序使用系统调用(如signalsigaction)注册了某个信号(如SIGQUIT)的处理函数sighandler

  2. 主程序执行:用户程序在main函数中执行正常的代码逻辑。

  3. 中断或异常:当程序运行时,如果发生中断或异常(如用户按下Ctrl+\组合键产生SIGQUIT信号),程序会切换到内核态进行中断或异常处理。

  4. 查信号递达:在中断或异常处理完毕后,内核会检查是否有待处理的信号。如果检测到有SIGQUIT信号递达,并且该信号已经被用户程序注册了处理函数sighandler,内核会决定不立即恢复main函数的执行。

  5. 执行信号处理函数:内核通过某种机制(通常是系统调用)将控制权转移给sighandler函数。注意,sighandler函数是在用户态下执行的,并且它使用与main函数不同的堆栈空间。这两个函数之间没有直接的调用关系,它们是两个独立的控制流程。

  6. 信号处理函数执行sighandler函数执行用户定义的信号处理逻辑。这个处理过程完全在用户态下进行,并且可以访问用户态的资源。

  7. 返回内核态:当sighandler函数执行完毕后,它会执行一个特殊的系统调用(如sigreturn)来返回内核态。这个系统调用通知内核信号处理已经完成。

  8. 复主程序执行:内核检查是否还有其他待处理的信号。如果没有,内核将恢复main函数的上下文,并将控制权返回给用户程序,使main函数从之前被中断的地方继续执行。

整个过程体现了操作系统在处理用户程序信号时的灵活性和安全性。通过允许用户注册信号处理函数,操作系统提供了更多的控制权给用户程序,使得用户程序能够根据需要对信号进行定制处理。同时,由于信号处理函数和主程序使用不同的堆栈空间,并且没有直接的调用关系,这保证了它们之间的独立性,防止了可能的堆栈溢出等安全问题。

a、默认信号处理行为

信号的默认处理行为取决于信号的类型和操作系统的具体实现。当进程接收到一个信号而没有特别指定如何处理这个信号时,系统会采取默认的信号处理行为。这些默认行为通常是由操作系统内核定义的,并且取决于信号的类型。

以下是一些常见信号的默认处理行为:

  1. 终止Term(如 SIGTERM, SIGINT):这些信号的默认行为是终止进程的执行。当进程接收到这些信号时,如果没有设置特定的处理函数,它会立即停止执行并退出。
  2. 核心转储Core(如 SIGSEGV, SIGBUS):当进程访问了无效的内存地址或遇到了其他严重的内存错误时,会收到这些信号。它们的默认行为是终止进程并生成一个核心转储文件(core dump),该文件包含了进程在终止时的内存映像,有助于开发者调试程序。
  3. 停止Stop和继续Cont(如 SIGSTOP, SIGCONT)SIGSTOP 信号用于停止进程的执行,而 SIGCONT 信号则用于恢复被 SIGSTOP 停止的进程。SIGSTOP 的默认行为是立即停止进程的执行,而 SIGCONT 的默认行为是恢复进程的执行。
  4. 忽略Ign:对于某些信号(如 SIGPIPE),系统默认的行为是忽略它们。当进程接收到这些信号时,它不会执行任何操作,而是继续执行其正常的工作流程。

再次再次提到,有些信号的默认行为是进程无法更改的,如 SIGSTOPSIGKILLSIGSTOP 总是用于停止进程的执行,而SIGKILL 则用于立即终止进程,这两个信号的默认行为是固定的,进程无法捕获或忽略它们。

核心转储(core dump)并非特指某一种信号,而是在操作系统中,当进程收到某些信号而终止运行时,将此时进程地址空间的内容以及有关进程状态的其他信息写出的一个磁盘文件的过程。这种信息通常用于调试。我们可以通过核心转储文件定位到进程为什么退出,以及执行到哪行代码退出。

具体来说,常将“主内存”(main memory) 称为核心(core),因为在使用半导体作为内存材料之前,人们便是使用“核心”作为内存。核心映像(core image) 就是 “进程”(process)执行当时的内存内容。当进程发生错误或收到“信号”(signal) 而终止执行时,系统会将核心映像写入一个文件,以作为调试之用,这就是所谓的核心转储。

然而,需要注意的是,如果进程没有写入核心文件的权限,那么即使收到上述信号,也不会产生核心转储文件。

我们可以使用ulimit -a 查看核心转储功能是否被打开。若未打开,可以用ulimit -c 10240。数字是自定义大小。也可以使用ulimit -c 0关闭。

zyb@myserver:~/study_code/singal_study/demo4$ ulimit -c 10240
zyb@myserver:~/study_code/singal_study/demo4$ ulimit -a
real-time non-blocking time  (microseconds, -R) unlimited
core file size              (blocks, -c) 10240
  • 1
  • 2
  • 3
  • 4

使用 gdb 加载程序的可执行文件。例如,如果程序名为 myprogram,可以运行 gdb myprogram 来启动 gdb。然后使用 core-file 命令加载 core 文件。例如,如果核心转储文件的文件名为 core(这通常是默认的文件名),可以运行 core-file core

在 Linux 系统中,当父进程使用 waitpidwait函数等待子进程结束时,子进程的终止状态的信息会被存储在 status 参数所指向的整数变量中。这个 status 变量包含了子进程退出时的各种信息,比如是正常退出还是因为某个信号而终止,以及具体的退出码或信号编号。

我们可以从status中知道当前进程是否允许核心转储。下图中core dump位为 1 意为允许核心转储。 进程的核心转储标志位标志着该进程是否进行了核心转储。

另外,进程可以通过使用 signal()sigaction() 函数来改变对特定信号的处理方式。这些函数允许进程指定一个自定义的信号处理函数来替代默认的处理行为,或者在接收到信号时忽略它们。这对于实现复杂的进程间通信和同步机制非常有用。

我们可以通过man 7 signal命令查看。以下是一些常见信号的默认处理行为:

  1. SIGFPE(浮点异常):当发生算术错误(如除零、溢出等)时,默认处理方式是终止进程并生成core文件(如果系统允许)。
  2. SIGILL(非法指令):当处理器执行了非法指令时,默认处理方式是终止进程。这个信号通常用来终止已运行的进程。
  3. SIGKILL(终止):这是一个强制终止进程的信号。它给超级用户提供了终止任何进程的能力,通常通过kill函数或命令来发送。对于SIGKILL信号,进程不能捕获、忽略或修改其默认处理行为,总是会被终止。
  4. SIGINT(中断):当用户按下Ctrl+C时,通常会发送这个信号。默认处理行为是终止进程,但进程可以选择捕获这个信号并执行自己的处理函数。

不同的操作系统和不同的进程可能具有不同的默认处理行为。此外,进程可以使用signal()sigaction()函数来改变特定信号的默认处理行为。

b、自定义信号处理函数

信号有默认处理,也可以自定义处理信号(信号的捕捉),也可以忽略信号。这都是对信号的处理,我们对信号的处理方式都可以进行更改。包括signal()sigaction()函数

在这里插入图片描述

signo参数是信号名。handler是一个指向信号处理程序函数的指针。signal() 调用本身并不会立即执行 handler 函数;它仅仅是设置了当 signum 信号递达时应该调用的函数。handler的值也可以是常数:

在这里插入图片描述

如果指定 SIG_IGN ,则向内核表示忽略此信号。(再次注意:有两个信号SIGKILLSIGSTOP不能忽略。)如果指定SIG_DFL,则表示接到此信号后的动作是系统默认动作。

当指定函数地址时,我们称此为捕捉此信号。我们称此函数为信号捕捉函数。

#include <stdio.h>
#include <signal.h>
#include <stdlib.h>

// 自定义信号处理函数
void handler(int signum)
{
    printf("Received SIGINT signal, exiting...\n");
    exit(signum); // 或者进行其他清理工作
}

int main()
{
    // 设置 SIGINT 信号的处理函数为 handler
    if (signal(SIGINT, handler) == SIG_ERR){
        perror("signal");
        return 1;
    }

    // 进入一个无限循环,等待信号
    while (1)
    {
        // ...
    }
    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26

在这里插入图片描述

在这个例子中,当程序接收到 SIGINT 信号时(通常由用户按下 Ctrl+C 触发或使用kill命令),它会调用 handler 函数,该函数会打印一条消息并退出程序。

当一个进程调用fork()时,其子进程继承父进程的信号处理方式。因为子进程在开始时复制 了父进程存储图像,所以信号捕捉函数的地址在子进程中是有意义的。

sigaction()这个函数提供了比早期 signal() 函数更多的控制和可移植性。

在这里插入图片描述

  • signum:要检查或修改的信号的编号。
  • act:如果非空,指向一个包含新信号处理器信息的 sigaction 结构的指针。
  • oldact:如果非空,指向一个用于存储先前信号处理器信息的 sigaction 结构的指针。
// The sigaction structure is defined as something like:
struct sigaction {  
    void     (*sa_handler)(int);  // 信号处理函数,或 SIG_IGN 或 SIG_DFL  
    void     (*sa_sigaction)(int, siginfo_t *, void *); // 替代信号处理函数  
    sigset_t   sa_mask;           // 在处理信号时要阻塞的信号集  
    int        sa_flags;          // 信号处理的标志  
    void     (*sa_restorer)(void); // 已过时,不使用  
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

其中,sa_handler 是一个指向信号处理函数的指针,可以是自定义的函数,也可以是 SIG_IGN(忽略信号)或 SIG_DFL(恢复默认处理行为)。sa_sigaction 是一个替代的信号处理函数,它允许接收更详细的信号信息(如 siginfo_t 结构中的信息)。

sa_mask 是一个信号集,指定了在处理信号时要阻塞的信号。这可以确保在处理一个信号时不会被其他信号中断。sa_flags 是一组标志,用于修改信号的行为。例如,SA_RESTART 标志用于指示被中断的系统调用在信号处理后应自动重启。

该已不常用,我们了解即可。

c、忽略信号

忽略信号也可以使用signal()函数。例如,如果我们想让进程忽略signo号信号,可以这样做:

signal(signo, SIG_IGN );
  • 1

2、未决信号集的获取

sigpending返回对于调用进程被阻塞不能递送和当前未决的信号集。该信号集通过set参数返回。

在这里插入图片描述

该函数调用成功返回0,出错返回-1。

3、信号阻塞与解除阻塞

sigprocmask函数用于检查和更改当前进程的信号屏蔽字(signal mask),也就是当前阻塞而不能递达给该进程的信号集。信号屏蔽字是一个位图,其中的每一位对应一个信号,如果某一位被设置(即值为1),则对应的信号就被阻塞,即不会被立即递送到该进程。

在这里插入图片描述

sigprocmask函数的第三个参数(oset)是一个指向sigset_t的指针,是输出型参数,用于保存调用sigprocmask之前的信号屏蔽字的状态。如果oset不为NULL,则当前的信号屏蔽字会被保存到*oset中。这在需要暂时更改信号屏蔽字,然后稍后恢复其原始状态的场景中非常有用。

如果set是个空指针,则不改变该进程的信号屏蔽字, how的值也无意义。 下面是针对每种how参数的详细解释:

SIG_BLOCK

how设置为SIG_BLOCK时,sigprocmask函数会将当前阻塞的信号集(即当前进程的信号屏蔽字)与set参数所指向的信号集进行并集操作。换句话说,set参数中指定的所有信号都会被添加到当前阻塞的信号集中,即使这些信号已经存在于当前的信号屏蔽字中。

SIG_UNBLOCK

how设置为SIG_UNBLOCK时,sigprocmask函数会从当前阻塞的信号集中移除set参数所指向的信号集中的所有信号。即使某个信号并不在当前的信号屏蔽字中(即它没有被阻塞),尝试解除对它的阻塞也是允许的,但这样的操作实际上不会产生任何效果,因为该信号本来就没有被阻塞。

SIG_SETMASK

how设置为SIG_SETMASK时,sigprocmask函数会将当前阻塞的信号集(即当前进程的信号屏蔽字)完全替换为set参数所指向的信号集。这意味着,无论之前的信号屏蔽字中包含了哪些信号,它们都将被set参数中的信号集所替代。只有set参数中指定的信号会被阻塞,其他信号则不会被阻塞。

下面我们试着屏蔽2号信号:

#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
#include <assert.h>
using namespace std;
void handler(int signumber)
{
    std::cout << " signal number is : " << signumber << std::endl;
}
void PrintSig(sigset_t &pending)
{
    std::cout << "Pending bitmap: ";
    for (int signo = 31; signo > 0; signo--)
    {
        if (sigismember(&pending, signo))
        {
            std::cout << "1";
        }
        else
        {
            std::cout << "0";
        }
    }
    std::cout << std::endl;
}
int main()
{
    // 1、屏蔽2号信号
    sigset_t block;  
    sigemptyset(&block);
    sigaddset(&block, 2);
    sigset_t oldblock; // 输出型参数
    int n = sigprocmask(SIG_SETMASK, &block, &oldblock);
    assert(n == 0);
    cout << "block 2 signal success" << endl;
    while (1)
    {
        // 2、获取pending位图
        sigset_t pending; // 数据类型
        sigemptyset(&pending);
        n = sigpending(&pending);
        assert(n == 0);
        // 3、打印位图中收到的信号
        PrintSig(pending);
        sleep(1);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49

我们得到下面结果,可以发现我们不能用ctrl+c退出进程了。

zyb@myserver:~/study_code/singal_study/demo8$ make
g++ -o testSig handlerSignal.cc -std=c++11
zyb@myserver:~/study_code/singal_study/demo8$ ./testSig 
block 2 signal success
Pending bitmap: 0000000000000000000000000000000
Pending bitmap: 0000000000000000000000000000000
^CPending bitmap: 0000000000000000000000000000010
Pending bitmap: 0000000000000000000000000000010
Pending bitmap: 0000000000000000000000000000010
^CPending bitmap: 0000000000000000000000000000010
^CPending bitmap: 0000000000000000000000000000010
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

下面我们试着屏蔽所有信号:

#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
#include <assert.h>
using namespace std;
void handler(int signumber)
{
    std::cout << " get a signal, number is : " << signumber << std::endl;
}
void PrintSig(sigset_t &pending)
{
    std::cout << "Pending bitmap: ";
    for (int signo = 31; signo > 0; signo--)
    {
        if (sigismember(&pending, signo))
        {
            std::cout << "1";
        }
        else
        {
            std::cout << "0";
        }
    }
    std::cout << std::endl;
}
int main()
{
    // 0、若屏蔽全部信号呢?
    // 1、屏蔽2号信号
    sigset_t block; // 数据类型
    sigemptyset(&block);
    for (int signo = 1; signo <= 31; signo++)
        sigaddset(&block, signo);
    sigset_t oldblock; // 输出型参数
    int n = sigprocmask(SIG_SETMASK, &block, &oldblock);
    assert(n == 0);
    cout << "block 2 signal success" << endl;
    while (1)
    {
        // 2、获取pending位图
        sigset_t pending; // 数据类型
        sigemptyset(&pending);
        n = sigpending(&pending);
        assert(n == 0);
        // 3、打印位图中收到的信号
        PrintSig(pending);
        sleep(1);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51

观察发现 1~8 10~18可以被屏蔽 9, 19不可以被屏蔽, 18会特殊处理

c=1; while [ $c -le 31 ]; do kill -$c 13963; echo "send $c to process"; let c++; sleep 1; done

信号递达的时候pending位图所对应的位一定会被置0;因此信号在递达前pending位图所对应的位已经被置0。

4、信号与时钟定时器

使用alarm()函数可以设置一个时间值(闹钟时间),在将来的某个时刻该时间值会被超过。当 所设置的时间值被超过后,产生 SIGALRM信号。如果不忽略或不捕捉此信号,则其默认动作是终止该进程。

alarm函数,系统调用,设定闹钟其实是OS设定的。

在这里插入图片描述

返回值是0或以前设置的闹钟时间的余留秒数。

其中,参数seconds的值是秒数,经过了指定的seconds秒后会产生信号SIGALRM。要了解的是, 经过了指定秒后,信号由内核产生,由于进程调度的延迟,进程得到控制能够处理该信号还需一段时间。

#include <iostream>
#include <unistd.h>
#include <sys/types.h>

#include <signal.h>
using namespace std;

int g_cnt = 0;
int ret = 0;

void handler(int sig)
{
    std::cout << "get a sig: " << sig << " g_cnt: " << g_cnt << std::endl;
    unsigned int n = alarm(5);

    cout << "还剩多少时间: " << n << endl;
    exit(0);
}

int main()
{
    // 设定一个闹钟
    signal(SIGALRM, handler);
    alarm(5); // 响一次
    int cnt = 0;
    while (true)
    {
        sleep(1);
        cout << "cnt : " << cnt++ << ", pid is : " << getpid() << endl;
        if (cnt == 2)
        {
            int n = alarm(0); // alarm(0): 取消闹钟
            cout << " alarm(0) ret : " << n << endl;
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36

每个进程只能有一个闹钟时间。如果在调用alarm函数时,以前已为该进程设置过闹钟时间, 而且它还没有超时,则该闹钟时间的余留值作为本次alarm函数调用的值返回。以前登记的闹 钟时间则被新值代换。

如果有以前登记的尚未超过的闹钟时间,而且seconds值是0,则取消以前的闹钟时间,其余留值仍作为函数的返回值。虽然SIGALARM的默认动作是终止进程,但是大多数使用闹钟的进程捕捉此信号。如果此时进程要终止,则在终止之前它可以执行所需的清除操作。


四、信号与进程

1、进程间信号的发送与接收

kill系统调用将信号发送给进程或进程组。raise函数则允许进程向自身发送信号。

在这里插入图片描述

将编号为sig的信号发送给运行该代码的进程。

void handler(int signumber)
{
    std::cout << "get signal :" << signumber << std::endl;
}

int main()
{
    signal(2, handler);
    int cnt = 0;
    while (true)
    {
        cout << "cnt: " << cnt++ << endl;
        sleep(1);
        if (cnt % 5 == 0)
        {
            cout << "send 2 to caller" << endl;
            raise(2);  
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

我们可以使用上述代码来观察现象。

在这里插入图片描述

将编号为sig的信号发送给进程ID为pid的进程。下面我利用kill()系统调用函数实现一个类似kill命令的程序:

// mykill -9 pid
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        cout << "Usage: " << argv[0] << " -signumber pid" << endl;
        return 1;
    }

    int signumber = stoi(argv[1] + 1);
    int pid = stoi(argv[2]);

    int n = kill(pid, signumber);
    if (n < 0)
    {
        cerr << "kill error, " << strerror(errno) << endl;
    }

    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

abort()函数可用于终止进程的给自己发送指定 信号 6) SIGABORT

在这里插入图片描述

abort() 被调用时,程序会立即停止执行,不进行任何正常的清理操作(如关闭打开的文件或释放内存),并返回一个非零值给操作系统,表示程序异常终止。该函数通常用于处理无法恢复的错误情况,或者当程序检测到某些严重问题时需要立即停止。

在调用 abort() 之后,程序会发送一个 SIGABRT 信号给自身。如果已为该信号安装了信号处理器,并且处理器没有调用 abort()_exit() 来终止程序,那么程序将继续执行。但是,在大多数情况下,SIGABRT 信号的默认行为是终止程序。

  1. 信号屏蔽(Unblocking)
    abort() 函数首先会确保 SIGABRT 信号没有被阻塞(blocked)。如果 SIGABRT 信号已经被阻塞,abort() 会取消对它的阻塞,这样该信号就可以被接收和处理了。

  2. 发送信号(Raising Signal)
    接下来,abort() 会发送一个 SIGABRT 信号给调用它的进程。这通常是通过调用 raise(SIGABRT) 来实现的。

  3. 信号处理(Signal Handling)
    如果 SIGABRT 信号被捕获并且处理函数返回了(即没有使用 longjmp(3) 或其他机制直接跳转到其他代码位置),那么进程会继续执行。但是,请注意,这通常不是一个好的做法,因为 abort() 的初衷是终止进程。

  4. 默认处理(Default Disposition)
    如果 SIGABRT 信号被忽略了(ignored),或者它的处理函数返回了,abort() 会恢复 SIGABRT 信号的默认处理行为(通常是终止进程)。然后,它会再次发送 SIGABRT 信号给进程,确保进程被终止。

因此,无论 SIGABRT 信号是否被捕获或忽略,abort() 函数都会确保进程被异常终止。这是通过首先尝试发送信号,并在必要时恢复信号的默认处理行为来实现的。

2、父进程与子进程间的SIGCHLD信号

当创建一个新的子进程时,它会继承其父进程的信号行为和任何已设置的信号处理程序。但是,一些信号(如 SIGSTOPSIGKILL)是不能被捕获、忽略或修改的,所以它们的行为在子进程中总是相同的。

当子进程终止时,它会向父进程发送一个SIGCHLD信号。在默认情况下,这个信号是被忽略的,但父进程可以选择注册一个自定义的信号处理函数来捕获这个信号,并在其中调用wait()waitpid()函数来清理子进程的资源。

以下是关于SIGCHLD信号在父进程与子进程间信号传递的详细说明:

  1. 信号的发送:当子进程的状态改变(如终止、停止或继续)时,内核会自动向该子进程的父进程发送一个SIGCHLD信号。

  2. 信号的接收与处理

    • 父进程可以选择忽略SIGCHLD信号,但这通常不是一个好的做法,因为忽略该信号可能会导致僵尸进程的产生。僵尸进程是已经终止但其父进程尚未回收其资源的进程。
    • 更好的做法是在父进程中捕获SIGCHLD信号,并在信号处理函数中调用wait()waitpid()函数来回收子进程的资源。这样做可以确保父进程能够正确地处理子进程的终止,并避免僵尸进程的产生。
  3. 处理该信号的示例

    子进程退出发送信号给父进程,通知父进程去等待:

    #include <iostream>
    #include <unistd.h>
    #include <sys/types.h>
    #include <sys/wait.h>
    #include <signal.h>
    
    using namespace std;
    int cnt = 0;
    void CleanupChild(int signo)
    {
        if (signo == SIGCHLD)
        {
            while (true)
            {
                pid_t rid = waitpid(-1, nullptr, WNOHANG); // -1 : 回收任意一个子进程
                if (rid > 0)
                {
                    std::cout << "wait child success: " << rid << " , cnt: " << ++cnt << std::endl;
                }
                else if (rid <= 0)
                    break;
            }
        }
        std::cout << "wait sub process done " << std::endl;
    }
    int main()
    {
        signal(SIGCHLD, CleanupChild);
        for (int i = 0; i < 100; i++)
        {
            pid_t id = fork();
            if (id == 0)
            {
                int cnt = 3;
                while (cnt--)
                {
                    std::cout << "I am child process: " << getpid() << ", i :" << i << std::endl;
                    sleep(1);
                }
                std::cout << "child process died" << ", i :" << i << std::endl;
                exit(0);
            }
        }
        while (true)
            sleep(1);
        return 0;
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48

    设置了一个SIGCHLD信号的处理函数CleanupChild,这个函数会在父进程中任何一个子进程终止时被调用。在CleanupChild函数中,使用了一个while循环和非阻塞的waitpidWNOHANG选项)来回收已经终止的子进程的PID。

如果将SIGCHLD的信号处理函数设置为SIG_DFL,则不会释放子进程资源,子进程退出后会进入僵尸状态;只有将SIGCHLD处理函数设置为SIG_IGN,才表示当前父进程不关心子进程状态,子进程退出由系统自动释放其资源,父进程不需要调用wait/waitpid来获取子进程退出信息。但如果父进程关心子进程的退出信息,建议使用信号捕捉函数来获取子进程退出信息。

x 1signal(SIGCHLD, SIG_IGN );
  • 1
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/酷酷是懒虫/article/detail/785274
推荐阅读
相关标签
  

闽ICP备14008679号