CS

Linux中的信号机制探究

Posted by Sagiring on 2024-05-29
Estimated Reading Time 6 Minutes
Words 1.8k In Total
Viewed Times

Linux中的信号机制探究

引言

​ 在Linux操作系统中,进程间的通信和控制是系统稳定性和效率的关键。信号机制是Linux进程间通信的一种古老而强大的方式。信号是一种软中断,信号机制是进程间通信的一种方式,采用异步通信方式。本文将对信号使用,机制、原理、实现方式进行探究分析。

信号作用

​ Linux中存在信号机制,它是一种非常基础的操作Linux系统的方式。它给所有的进程之间提供了一种相互”沟通“的和响应的方式。其中每一个信号都对应着一种特定的动作,比如控制、提醒、请求等。下图是Man pages中列出的每一种信号量的解释。

​ Linux信号分为POSIX标准信号和POSIX实时信号。对应于 Linux 的信号值为 1-31 和 34-64。信号是异步的,一个进程不必通过任何操作来等待信号的到达。事实上,进程也不知道信号到底什么时候到达。

  • POSIX标准信号,不支持排队,信号可能会丢失, 比如发送多次相同的信号, 进程只能收到一次. 信号值取值区间为1~31。
  • POSIX实时信号,支持排队, 信号不会丢失, 发多少次, 就可以收到多少次. 信号值取值区间为32~64。

常见POSIX标准信号作用

  • SIGHUP - 挂起信号
    • 通常由终端挂起或控制进程发送给其子进程。
    • 默认行为是终止进程,但进程可以捕获此信号并执行清理操作。
  • SIGINT - 中断信号
    • 通常由用户通过Ctrl+C发送。
    • 默认行为是终止进程,但进程可以捕获此信号以优雅地终止。
  • SIGQUIT - 退出信号
    • 通常由用户通过Ctrl+。
    • 默认行为是终止进程,并生成核心转储(core dump),以便调试。
  • SIGUSR1 - 用户定义信号1
    • 没有默认行为,完全由应用程序定义用途。
    • 常用于进程间通信或触发自定义事件。
  • SIGSEGV - 段错误信号
    • 当进程访问非法内存时触发。
    • 默认行为是终止进程并生成核心转储。
  • SIGKILL - 终止信号
    • 不可被进程捕获、阻塞或忽略。
    • 总是立即终止接收进程。
  • SIGTSTP - 停止信号
    • 通常由用户通过Ctrl+Z发送。
    • 默认行为是停止进程,进程可以被SIGCONT信号恢复。

​ 这些信号各自有着各自的含义,你也可以通过自己的程序去捕获他们(可以捕获的话),去自定义这些信号的行为。如果没有自定义行为,内核将根据信号的默认行为来处理它。

​ 对于自定义信号,这里有一个很简单的例子

from signal import *
import time

def catched(signal,frame):
    print("Signal => ", signal, " <=catched" )

def main():
    signal(SIGINT,catched)
    while True:
        time.sleep(1)
        print("Waiting for signal")

if __name__ == "__main__":
    main()

程序会一直等待信号。

当使用Ctrl + C

image-20240528191216858

可以发现,Ctrl+C对应的信号SIGINT对应数字被捕获了,程序并未被终止。打印的数字为2是因为,每一个信号都有对应的数字与它匹配。

当使用Ctrl + \发送SIGTSTP信号时,未被我们的程序捕获,从而被内核捕获,执行默认行为终止进程。

当然,为了避免恶意程序捕获终止信号,导致无法退出的情况出现,Linux中有类似SIGKILL的信号,不能被捕获,从而杀死进程。

信号产生

信号的产生通常有以下二种情况

  • 硬件方式产生

    • 硬件异常 => SIGSEGV 程序段错误,CPU检测到内存非法访问等异常,通知内核生成相应信号,并发送给发生事件的进程。
    • 硬件中断 => SIGINT 使用 Ctrl + C
  • 软件方式产生

    • 通过系统调用,显示发送 => SIGKILL 使用kill -n9 pid

    • Man pages中对于软件发送信号量的简介。

信号状态

  • 信号阻塞 - (signal block)

    • 一个信号被阻塞意味着,他不能被传递给其他进程(线程),直到他解除阻塞。
  • 信号的待传递 - (Pending signals)

    • 一个信号的待传递是指从一个信号被产生后,到一个信号被传递到目标进程(线程)前的阶段。
  • 信号屏蔽集 - (Signal mask)

    • 每一个进程里的线程都有一个信号屏蔽集,传递的信号如果在该集合中的信号会被阻塞。
    • 通过fork产生的子线程会继承父线程的屏蔽集

信号执行

​ 无论何时,当Linux内核从内核态转换用户态执行时(比如从系统调用返回或者分配线程到CPU执行时),内核都会检查是否存在一个处于待传递未被屏蔽、传递的对象注册了一个信号处理器的信号。如果存在,内核会执行以下准备步骤:

  1. 从待传递的信号集合中移除该信号
  2. 设置信号标志和用户设定的可替换信号栈(如果存在的话)
  3. signalFrame的形式保存当前进程(线程)的上下文,将其存储在栈中。保存的信息包括
    • 程序计数器寄存器,即主程序中断时的下一条指令地址。
    • 恢复中断程序所需的特定于架构的寄存器状态。
    • 线程当前的信号屏蔽集。
    • 线程的替代信号栈设置。
  4. 更新信号屏蔽集。

signalFrame结构如图所示

而后内核开始执行信号。具体步骤如下:

  1. 内核创建为信号处理器创建帧栈,将当前的程序计数器修改为信号处理器的第一条指令,设置信号处理器的返回地址指向用户空间的代码,称为信号蹦床(signal trampoline)
  2. 内核会把控制还给用户空间的信号处理器。
  3. 当信号处理结束后,会返回信号蹦床(signal trampoline),其负责执行sigreturnsigreturn会使用前面存储在栈中的signalFrame,返回到执行信号之前的上下文。

从内核的视角来看,对于信号处理器的执行和执行其他用户空间中的代码相同,内核并不记录下任何状态信息,所有需要的状态信息都在用户空间的栈和注册器中。

测试

修改信号处理器为睡眠5s,并打印。在处理信号时再次发送SIGINT信号

def catched(signal,frame):
    print("Signal => ", signal, " <=catched" )
    cnt = 5 
    while cnt >= 0:
        print(f"Waiting {cnt}s")
        cnt -= 1
        time.sleep(1)

结果如下

这里有两个细节

  1. 当信号被传递后,程序的控制转移到了信号处理器中,不再打印Waiting for signal到输出流。
  2. 当程序在信号处理器中时,程序依然可以被传递信号(该信号不在信号屏蔽集中),且程序控制流会再次传递到新的信号处理器中,就像函数的递归调用一样。但是返回的方式不相同,存储的上下文信息也不相同。