进程信号【Linux】(下)

简介: 进程信号【Linux】
测试2

除了上面两个信号集操作函数之外,还有一些操作信号集的函数:

#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
int sigismember(const sigset_t *set, int signum);
  • sigemptyset 函数:初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
  • sigfillset 函数:初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
  • sigaddset 函数:在set所指向的信号集中添加某种有效信号。
  • sigdelset 函数:在set所指向的信号集中删除某种有效信号。
  • sigemptyset、sigfillset、sigaddset 和 sigdelset 函数都是成功返回0,出错返回-1。
  • sigismember 函数:判断在set所指向的信号集中是否包含某种信号,若包含则返回1,不包含则返回0,调用失败返回-1。

注意: 在使用 sigset_t 类型的变量之前,一定要调用 sigemptyset 或 sigfillset 初始化,使信号处于确定的状态。set就是“集”的意思。

示例代码:

#include <stdio.h>
#include <signal.h>
int main()
{
  sigset_t s; //用户空间定义的变量
  sigemptyset(&s);
  sigfillset(&s);
  sigaddset(&s, SIGINT);
  sigdelset(&s, SIGINT);
  sigismember(&s, SIGINT);
  return 0;
}

注意: 代码中定义的 sigset_t 类型的变量s,与我们平常定义的变量一样都是在用户空间定义的变量,所以后面我们用信号集操作函数对变量s的操作实际上只是对用户空间的变量s做了修改,并不会影响进程的任何行为。因此,我们还需要通过系统调用,才能将变量s的数据设置进操作系统。


要打印 pending 中的某个比特位的变化:

  1. 先 block 2号信号:用 sigset_t 定义两个信号集:bset 和 obset。表示新的信号集和老的信号集(o也有 output 的意思,b是block的意思)。它们存在于当前进程的(用户层栈属于用户空间)栈区(局部变量存放在栈区)。
  2. 初始化两个(信号集)变量:sigemptyset 函数,传指针,对应比特位0->1。
  3. 添加要屏蔽的信号:sigaddset 函数,注意参数。

上面的操作都是在(用户层)栈上对对象的修改,下面将对内核中的数据修改:

  1. 设置 set 到内核中对应的进程内部:sigpromask 函数,注意参数要传选项,老的和新的 set。默认情况下进程是不会屏蔽任何信号。
  2. 循环打印当前进程的 pending信号集的32个比特位:
  1. 前提是获取当前进程的 pending 信号集:在最前面定义一个 sigset_t 变量 pending,用于保存 pending 信号集,也要记得初始化。当然也可以放在循环里。用 sigpending 函数获取信号集合
  2. 显示 pending 信号集中没有被递达的信号:showPending函数,这个函数是自定义的,它的功能是遍历 pending 的所有位数,判断1-31位置是否在pending集合中。
static void showPending(sigset_t &pending)
{
    for (int sig = 1; sig <= 31; sig++)
    {
        if (sigismember(&pending, sig))
            cout << "1";
        else
            cout << "0";
    }
    cout << endl;
}
int main()
{
    // 1. 定义信号集对象
    sigset_t bset, obset;
    sigset_t pending;
    // 2. 初始化信号集对象
    sigemptyset(&bset);
    sigemptyset(&obset);
    sigemptyset(&pending);
    // 3. 添加要阻塞的信号
    sigaddset(&bset, 2); // 即SIGINT
    // 4. 设置set到内核中对应的进程内部(默认情况进程不会阻塞任何信号)
    int n = sigprocmask(SIG_BLOCK, &bset, &obset);
    assert(n == 0);
    (void)n;
    cout << "阻塞2号信号成功,PID: " << getpid() << endl;
    // 5. 打印当前进程的pending信号集
    while (1)
    {
        // 5.1 获取当前进程的pending信号集
        sigpending(&pending);
        // 5.2 显示pending信号集中的没有被递达的信号对应的比特位
        showPending(pending);
        sleep(1);
    }
    return 0;
}

这段代码演示了如何使用信号集来阻塞特定的信号,并在循环中显示当前进程的未决信号集,下面将解释它们的作用:

首先,程序定义了一个自定义函数showPending,它接受一个信号集作为参数,并在循环中遍历1到31号信号,使用sigismember函数检查每个信号是否在信号集中。如果在,就输出1,否则输出0。最后输出一个换行符。

接下来,在main函数中,程序定义了三个信号集对象:bsetobsetpending。它们分别用来存储要阻塞的信号、原来阻塞的信号和当前未决的信号。

然后,程序使用sigemptyset函数初始化这三个信号集对象,然后使用sigaddset函数将信号2(即SIGINT)添加到bset中。

接下来,程序使用sigprocmask函数将进程的阻塞信号集设置为bset,并将原来的阻塞信号集保存在obset中。这样,信号2就被阻塞了。

接下来,程序进入一个无限循环。在每次循环中,程序首先使用sigpending函数获取当前进程的未决信号集,并将其存储在pending中。然后,程序调用自定义的函数showPending来显示未决信号集中未被递达的信号对应的比特位。

这里为了演示时能直接使用 ctrl + C 给进程发送2号信号,所以在代码中屏蔽了2号信号,打印 PID 是为了能 kill 方便一些。通过演示,可以看到进程接收到2号信号以后, pending 中的第二个比特位就被从0改写为1。最后是随便用了1号信号终止了进程。

补充:

一般判断返回值是意料之中的用assert,意料之外用if判断返回值。(void)n的原因是 release 版本下 assert 失效,这个 n 就会被标记为定义却未被使用,消除编译器告警。

如果想看见比特位1->0,并且同时看到之前的变化,可以做出以下改变:

void handler(int sigNum)
{
  sleep(1);
  cout << "捕捉到信号: " << sigNum << endl;
}
int main()
{
  // 0. 为了验证方便,捕捉2号信号
    signal(2, handler);
    // 1. 定义信号集对象
    sigset_t bset, obset;
    sigset_t pending;
    // 2. 初始化信号集对象
    sigemptyset(&bset);
    sigemptyset(&obset);
    sigemptyset(&pending);
    // 3. 添加要屏蔽的信号
    sigaddset(&bset, 2); // 即SIGINT
    // 4. 设置set到内核中对应的进程内部(默认情况进程不会阻塞任何信号)
    int n = sigprocmask(SIG_BLOCK, &bset, &obset);
    assert(n == 0);
    (void)n;
    cout << "阻塞2号信号成功,PID: " << getpid() << ",10秒后解除阻塞..." << endl;
    // 5. 打印当前进程的pending信号集
    int count = 0;
    while (1)
    {
        cout << "count: " << count++ << "  ";
        // 5.1 获取当前进程的pending信号集
        sigpending(&pending);
        // 5.2 显示pending信号集中的没有被递达的信号对应的比特位
        showPending(pending);
        // 10秒以后解除阻塞
        if (count == 10)
        {
            // 默认情况解除2号信号阻塞时,会递达它
            // 但是2号信号的默认处理动作是终止进程
            // 为了观察现象,需要对2号信号进行捕捉
            int n = sigprocmask(SIG_SETMASK, &obset, nullptr);
            assert(n == 0);
            (void)n;
            cout << "解除2号信号阻塞状态" << endl;
        }
        sleep(1);
    }
    return 0;
}

用计数器控制2号信号只有10秒的阻塞状态,在这10秒期间,一旦进程接收到2号信号,pending 信号集的第二个比特位就会0->1,但是不会调用 handler 处理信号,因为它被阻塞了;10秒过后解除对2号的阻塞,那么这个比特位就会复原为0,并调用 handler 对信号进行处理。

注意:

  • 因为这里使用2号信号演示,而2号信号一旦被解除阻塞状态,它的默认处理方式是终止进程,所以必须要事先用 signal 捕捉2号信号,并绑定 handler 函数打印信号编号。否则10秒后进程会终止,无法观察现象。
  • 在打印时,需要注意打印语句的先后顺序:先打印“捕捉”后打印“解除”。所以可以把打印语句放在 sigpromask 之前。原因是它解除以后可能就立马递达了,调用了 handler 函数,然后才会继续执行代码。

貌似没有一个接口可以修改 pending 信号集,但我们可以获取 sigpending。所有信号的发送方式都是修改 pending 信号集的过程(如q,abort,键盘,异常…即产生信号的方式)。所以手动修改它没必要,它在传递信号的过程中就已经被修改了。

测试3
  1. 首先将屏蔽信号的逻辑封装为一个接口,取名为 blockSig,它的作用是屏蔽指定的信号。
  2. 用循环调用上述接口屏蔽所有信号。
  3. 获取 pending 信号,可以不用初始化,因为后面直接覆盖了。
  4. 打印 pending 的1-31比特位。
static void blockSig(int sig)
{
    sigset_t bset;
    sigemptyset(&bset);
    sigaddset(&bset, sig);
    int n = sigprocmask(SIG_BLOCK, &bset, nullptr);
    assert(n == 0);
    (void)n;
}
int main()
{
  for(int sig = 1; sig <= 31; sig++)
    {
        blockSig(sig);
    }
    sigset_t pending; // 获取pending信号
    while(1)
    {
        sigpending(&pending);
        showPending(pending);
        sleep(1);
    }
  return 0;
}

上述逻辑本身就是进程在运行的。pidof + 进程名称,可以直接获取进程pid,用一个脚本查看每个进程自动发送1-31信号,把脚本保存在:SendSig.sh:

#!/bin/bash
i=1
id=$(pidof signalTest)
while [ $i -le 31 ]
do
    if [ $i -eq 9 ];then # 跳过了9号
        let i++
        continue
    fi
    if [ $i -eq 19 ];then # 跳过了19号
        let i++
        continue
    fi
    kill -$i $id
    echo "kill -$i $id"
    let i++
    sleep 1
done

可能遇到的问题:

在 Linux 系统中,无法运行 .sh 脚本的原因可能有很多。一个常见的原因是脚本文件没有执行权限。你可以使用 chmod 命令来给予脚本文件执行权限,例如 chmod u+x script.sh 。

此外,你也可以通过将脚本文件作为参数传递给 shell 来运行它,例如 bash script.sh。

这段代码首先阻塞了所有信号,然后每隔一秒钟检查一次 pending 信号集,并打印出pending 信号集中的所有信号。如果有未决信号,它会在屏幕上显示为1,否则显示为0。本来只有9和19号不打印1,即只有2列0,但是这里有3列0。多出来的是20号信号。

20号信号是 SIGTSTP,它是一个终端上发出的停止信号,通常是由用户键入 SUSP 字符(通常是Ctrl + Z)发出的1。这个信号可以被处理和忽略。在这里应该是被忽略了。

但最主要的是要知道9号和19号是不能被捕捉、屏蔽的。

4. 捕捉信号

4.1 内核空间和用户空间

操作系统会给进程一个大小为4G的进程地址空间,在这个虚拟地址空间中划分为两种:

  • 0-3G:用户地址空间;
  • 3G-4G:内核地址空间。

内核空间和用户空间是操作系统中虚拟地址空间的两个部分。内核空间是操作系统内核访问的区域,独立于普通的应用程序,是受保护的内存空间。用户空间是普通应用程序可访问的内存区域,以供进程使用。

内核如何使用内核地址空间?

物理内存中只有一份操作系统的代码,另外还有一份内核级页表,它可以被所有进程共享,不同进程就可以看到同一个物理内存中的操作系统。任何一个进程调用了系统接口,只要从用户地址跳转到内核地址中(用户态->内核态),然后通过内核级页表找到系统调用对应的代码执行即可。

这个“跳转”的动作和动态库是类似的。进程切换的代码也是这样执行的,当前进程在被 CPU 执行,因此当前进程的上下文、地址空间在当前执行流中,所以 OS 是能找到进程的,一旦发生时钟中断,OS 去 CPU 中找当前正在执行的进程,去它的地址空间找到进程切换的函数(即系统调用),然后在进程上下文中切换(跳转)。因此CPU将正在执行的进程的临时数据压到进程的 PCB 中,以保证跳转回来时继续使用数据。OS 对每个进程都会执行同样的工作。

4.2 内核态和用户态

遗留问题:

信号产生之后,可能无法被立即处理,“合适的时候”是什么时候?

首先我们知道,在 操作系统中某些操作需要 root 权限,文件的权限划分等级,以保护重要的文件不被轻易修改,这是一种保护机制。有些信号是硬件产生由操作系统捕捉的,而程序员无法直接从软件层面直接访问硬件,这就是操作系统通过划分权限的限制用户对文件的行为。

内核态与用户态是操作系统的两种运行级别,表示不同的权限等级:

  • Kernel Mode:当进程运行在内核空间时就处于内核态,是一种权限非常高的状态。此时CPU可以执行任何指令。运行的代码也不受任何的限制,可以自由地访问任何有效地址,也可以直接进行端口的访问。
  • User Mode:进程运行在用户空间时则处于用户态,用来执行普通用户代码的状态,是一种受监管的普通状态。进程运行在用户地址空间中,被执行的代码要受到 CPU 的很多检查。

解答上面的问题:

信号产生之后,可能无法被立即处理,合适的时候就是从内核态切换为用户态时。在内核态返回用户态之前,信号才会被处理。但是由于信号处理函数的代码在用户空间,所以这增加了内核处理信号捕捉的复杂度。如果不是紧急信号,是不会立即处理的。信号相关的数据字段都在进程的 PCB 内部,属于内核,在用户态是无法获取的。所以检测信号(是否被屏蔽),必须是内核状态。在内核态处理好后,再返回用户态。

状态切换

从内核态到用户态的过程是权限缩小的过程,操作系统只信任它自己,想要访问操作系统内核或硬件必须通过系统调用。操作系统的代码只能由操作系统执行,用户的代码就只能由用户进程执行,实际情况用户也会有访问操作系统内部的需求,所以进程会在内核态和用户态两种状态之间切换。

什么时候用户态->内核态?

  1. 系统调用:当用户程序需要操作系统提供的服务时,会通过系统调用进入内核态。
  2. 异常和中断:当发生异常或中断时,CPU会从用户态切换到内核态,以便内核能够处理异常或中断。
  3. 陷阱(Traps):陷阱是一种特殊的中断,它通常由于执行了特定的指令或者违反了某些规则而触发。例如,当程序试图执行一条特权指令或访问受保护的内存地址时,就会触发陷阱。

在这些情况下,CPU会从用户态切换到内核态,并开始执行内核代码。当内核完成处理后,它会将控制权返回给用户程序,并从内核态切换回用户态。

一般地,我们将用户态切换为内核态称之为陷入内核,对象一般是 CPU。进程要陷入内核的原因是要调用系统接口,执行内核中的代码。

什么时候内核态->用户态?

  1. 系统调用完成:当内核完成对系统调用的处理后,它会将控制权返回给用户程序,并从内核态切换回用户态。
  2. 异常和中断处理完成:当内核完成对异常或中断的处理后,它会将控制权返回给用户程序,并从内核态切换回用户态。
  3. 陷阱(Traps)处理完成:当内核完成对陷阱的处理后,它会将控制权返回给用户程序,并从内核态切换回用户态。

在这些情况下,CPU会从内核态切换回用户态,并开始执行用户程序代码。

操作系统如何切换进程状态

操作系统如何确认进程的优先级状态?这个状态是谁确定的?

特权级

CPU 的执行权限是由特权级(Ring)来控制的。x86 架构中有4个特权级,分别为Ring 0、Ring 1、Ring 2和Ring 3。数字越大,权限越小,Ring 0是最高特权级,具有最高的执行权限,可以访问所有的指令和资源。Ring 3是最低特权级,只能访问受限制的指令和资源。

操作系统内核(内核态)通常运行在Ring 0,具有最高的执行权限。而用户程序(用户态)通常运行在Ring 3,只能访问受限制的指令和资源。我们知道,当用户程序需要访问受保护的资源时,它必须通过系统调用进入内核态,由内核代表它执行相应的操作。

CR3 寄存器

CPU 中有2套寄存器,一套是可见的,一套是它自己用的。其中CR3寄存器是 x86 架构中的一个控制寄存器,它用于存储页表的物理地址。当 CPU 需要访问虚拟地址时,它会使用 CR3 寄存器中存储的页表地址来查找对应的物理地址来访问实际的内存。在进程切换时,操作系统会更新 CR3 寄存器的值,以便下一个进程能够使用正确的页表。这样,每个进程都有一个独立的虚拟地址空间,它们可以互不干扰地运行。

CS 寄存器

在 x86 架构中,CPU 的执行权限是由其当前的特权级别(Current Privilege Level,CPL)决定的。CPL是一个2位字段,存储在代码段寄存器(CS)的隐藏部分。CPL的值可以是0到3,数字越大,权限越小。例如,Ring 0具有最高权限,而Ring 3具有最低权限。1代码段寄存器(CS)的隐藏部分包含一个2位字段,称为当前特权级别(CPL),用于存储CPU的当前特权级别。

Ring 和 CPL 是密切相关的概念。CPL是用来表示当前正在执行的代码所处的特权级别(即 Ring),它一个2位字段,即一个由两个二进制位组成的字段。每个二进制位可以是0或1,因此2位字段可以表示4种不同的状态(00、01、10和11)。在 x86 架构中,CPL的值可以是0到3,对应于四个 Ring 级别。

int 0x80

特权级和 int 0x80 指令之间有一定的关系。特权级用于控制 CPU 的执行权限,而 int 0x80 指令是 x86 架构中用于发起系统调用的指令

当用户程序需要使用操作系统提供的服务时,它会通过 int 0x80 指令发起系统调用。这会触发一个软中断,使得 CPU 从用户态切换到内核态。在内核态下,CPU 具有最高的执行权限,可以访问所有的指令和资源。

操作系统内核会根据系统调用号和参数,执行相应的系统调用处理程序。当系统调用处理完成后,内核会将控制权返回给用户程序,并从内核态切换回用户态。

系统调用号是一个整数,用于标识特定的系统调用。操作系统内核使用这个号码来确定应该执行哪个系统调用处理程序。


小结

用户态程序请求内核态服务时,操作系统执行 int 0x80 指令,CPU会将控制权从用户态程序转移到内核态中断处理程序。在这个过程中,CPU 会将代码段寄存器(CS)中的 CPL 修改为0,表示当前正在执行的代码处于Ring 0(最高特权级别)。

在内核态中断处理程序完成系统调用后,它会通过执行iret指令将控制权返回给用户态程序。在这个过程中,CPU会将CS寄存器中的CPL恢复为3,表示当前正在执行的代码处于Ring 3(最低特权级别)。

清理进程资源时的状态切换

进程终止时,操作系统会执行一系列清理工作,包括释放进程占用的资源(如内存、文件描述符等),更新进程状态等。这些工作通常是在内核态中完成的。在进程终止之前,操作系统可能会允许进程执行一些清理代码,例如调用进程注册的退出处理程序(exit handler)。这些代码是在用户态中执行的。因此,进程终止时可能会在用户态和内核态之间切换。首先,在用户态执行进程的清理代码;然后,在内核态执行操作系统的清理工作。

为什么进程在终止时要切换状态清理资源?只在某一个状态清理不方便吗?难道是因为资源的类型不同吗?

进程在终止时可能会切换到用户态执行清理代码,这主要是为了让进程有机会释放它在用户态分配的资源,或者完成一些其他的清理工作。例如,进程可能会在用户态分配一些动态内存,或者打开一些文件。这些资源是由进程自己管理的,操作系统并不知道它们的存在。因此,在进程终止时,操作系统会允许进程在用户态执行一些清理代码,以便进程能够释放这些资源。此外,进程可能还会注册一些退出处理程序(exit handler),用于在进程终止时执行一些特定的清理工作。这些处理程序也是在用户态中执行的。

当进程从用户态切换到内核态时,操作系统会接管进程的控制权,并执行内核态代码来管理和清理进程占用的资源。操作系统内核包含一组用于管理系统资源和进程的代码。当进程从用户态切换到内核态时,操作系统会调用这些代码来完成各种系统管理任务,包括清理进程占用的资源。例如,当进程在内核态执行exit系统调用以终止自身时,操作系统会调用内核中的do_exit函数来完成进程终止的相关工作。这些工作包括释放进程占用的内存、关闭进程打开的文件描述符、更新进程状态等。这些工作都是由内核中的代码完成的。

总之,进程在终止时切换到用户态执行清理代码,主要是为了让进程有机会释放它在用户态分配的资源,或者完成一些其他的清理工作。进程在内核态清理,是因为管理系统资源和进程的代码在内核中。

4.3 什么是捕捉信号

捕捉信号(catching a signal)是指进程接收到信号后(信号被递达),调用为该信号注册的处理程序来处理信号。

当进程接收到信号时,它可以选择忽略信号、执行信号的默认行为,或者调用为该信号注册的处理程序。如果进程选择调用用户自定义的处理程序,则称为捕捉信号。

进程可以使用signalsigaction函数为特定的信号注册处理程序。当进程接收到该信号时,操作系统会调用进程注册的处理程序来处理信号。处理程序是一个用户态函数,可以在其中执行任意的代码,以响应信号。

4.4 内核如何协助进程捕捉信号

实际上内核不会直接捕捉信号,而是负责将信号传递给目标进程。进程接收到信号后,可以在用户态中决定如何处理信号:

  • 当内核接收到一个信号时,它会检查信号的目标进程。如果目标进程当前正在执行,则内核会将信号传递给该进程。如果目标进程当前处于阻塞状态,则内核会将信号保存在进程的信号队列中,等待进程恢复执行后再传递。
  • 当进程接收到信号时,它可以选择忽略信号、执行信号的默认行为,或者调用为该信号注册的处理程序。这些操作都是在用户态中完成的。

捕捉信号属于异常和中断。当一个进程收到一个信号时,操作系统会中断该进程的正常执行流程,并调用该进程注册的信号处理函数。在这个过程中,进程会从用户态切换到内核态,以便内核能够将信号传递给进程并调用相应的信号处理函数。当信号处理函数执行完毕后,控制权会返回给进程,进程会从内核态切换回用户态,继续执行。

处理信号是默认动作

在了解内核如何协助进程捕捉信号之前,需要了解进程处理信号的默认动作,这也可能需要内核的参与。设置一个需要内核参与的情景:

当进程在执行代码时,可能会因为调用了系统接口而陷入内核,在内核中处理完毕即将切换回用户态之前,需要检查 pending 信号集,以确定是否有信号需要传递给进程。如果目标进程当前正在执行,则操作系统会立即将信号传递给该进程。如果目标进程当前处于阻塞状态,则操作系统会将信号保存在进程的信号 pending 信号集中,等待进程恢复执行后再传递。

如果有信号需要传递给进程,则操作系统会根据进程是否有自定义捕捉方式,以不同方式将信号传递给进程:

  • 如果待处理信号有自定义处理方式,操作系统会调用进程为该信号注册的处理程序;
  • 如果待处理信号的处理动作是默认或者忽略,则执行该信号的默认处理动作后就清除 pending 信号集中对应位置的比特位(1->0),如果没有新的信号要递达,就直接返回用户态,从主控制流程中上次被中断的地方继续向下执行即可。

信号可以由内核内部产生,也可以由其他进程发送。无论信号的来源如何,操作系统都会使用相同的方式来传递信号。例如:

  • 当进程执行除以0的操作时,内核会向进程发送SIGFPE信号;当进程试图访问非法内存地址时,内核会向进程发送SIGSEGV信号。
  • 除了内核内部产生的信号外,进程还可以使用killraise函数向其他进程或自身发送信号。

当操作系统检测到信号被 pending 但是没有被 blocked,而且信号有自定义处理方式,此时进程处于内核态,它可以调用自定义处理函数吗?

  • 这是因为信号的自定义处理函数是由用户程序定义的,它运行在用户空间中。内核需要将控制权交还给用户程序,以便用户程序能够执行自定义的信号处理函数。这样做可以让用户程序对信号做出响应,例如执行特定的操作或更新程序状态。
  • 从权限的角度来说,当然可以,内核态是最高等级权限,原则上内核态的进程可以访问整个0-4G地址空间,包括用户地址空间。用户为信号自定义的函数属于用户地址空间,这样做需要谨慎,可能会造成未知错误(尽管内核代码已经很健壮)。

有一个我们常见的现象,它随时有可能发生(包括上面的测试)。例如,当进程正在执行读取文件或写入文件的系统调用时,用户按下了 Ctrl + C 组合键来发送中断信号,按了好多次都没办法中断,只有执行完系统调用以后才会被终止。并且,在上面测试遇到死循环捕捉信号时,在10s之前信号被阻塞,多次按下 Ctrl + C 也不会打印“捕捉”,只有10s解除阻塞才能被捕捉。原因是:

  • 当进程在内核态执行系统调用时,操作系统会暂时阻止信号的传递。如果在这段时间内有信号发送给进程,则操作系统会将信号保存在进程的 pending 信号集中,等待进程从内核态切换回用户态后再传递。
  • 这样的机制是确保内核代码能够安全地执行完毕,避免出现问题。当进程在内核态执行系统调用时,它正在执行关键的操作,例如读取或写入文件、分配内存等。如果在这个时候立即传递信号并执行信号处理函数,可能会导致内核代码的执行被中断,从而导致系统状态不一致或其他问题。

对于上面这种未决但未被阻塞的信号,且信号的处理动作是默认,进程的状态切换流程时这样的:

其中,以中间的横线分隔用户态和进程态的进程地址空间。

处理信号是自定义动作

前面都是铺垫和知识上的补充,下面才是内核协助进程捕捉信号的内容。进程接收到信号后,调用为该信号注册的处理程序来处理信号,自定义处理函数属于用户空间,所以为了安全(上面有说明原因),需要切换到用户态执行自定义动作。执行完毕以后再回到系统调用代码内中断的地方,执行完系统调用程序后,再回到main函数中的主执行流。

其中的逻辑增加了内核态->用户态执行自定义处理函数、回到内核态继续执行系统调用和执行完系统调用后回到用户态继续执行主执行流。其中切换状态的函数我们暂时不必关心,这是操作系统完成的。

handler 和 main 函数在不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。

上面的流程就像一个∞ ∞符号,因此可以这样记忆处理信号是自定义动作的进程切换流程:

图片来源于龙哥的博客Linux进程信号_2021dragon的博客-CSDN博客

结论:

  • 4次状态切换:4个交点,箭头代表方向。
  • 圆点代表在切换回用户态调用自定义动作之前检查 pending 信号集。

4.5 捕捉函数

sigaction

除了用前面用过的 signal 函数之外,我们还可以使用 sigaction 函数捕捉信号,它允许调用进程检查和/或指定与特定信号相关联的动作。原型:

#include <signal.h>
int sigaction(int sig, const struct sigaction *restrict act, struct sigaction *restrict oact);

参数:

  • sig :指定信号编号。
  • act :指定处理信号的动作(非空)。
  • oact :修改之前原来的处理信号动作(非空)。如果 act 参数是空指针,则信号处理不变;因此,调用可用于查询给定信号的当前处理方式 。

总的来说,它的第二个参数的类型是一个结构体指针,结构体名称也是 sigaction,是一个输入型参数。第三个参数是输出型参数,可以取出旧的(未修改之前) sigaction 结构体。

返回值:

  • 成功:返回0;
  • 失败:返回-1。

struct sigaction 结构体用于描述要采取的动作,它的原型:

struct sigaction {
  void(*sa_handler)(int);// 捕捉对应的回调函数
  void(*sa_sigaction)(int, siginfo_t *, void *); 
  sigset_t   sa_mask; // 
  int        sa_flags;
  void(*sa_restorer)(void);
};

成员:

  • sa_handler:指向信号捕获函数的指针,或者是宏 SIG_IGN 或 SIG_DFL 之一:
  • 函数指针:自定义处理函数;
  • SIG_IGN:忽略信号;
  • SIG_DFL:执行默认动作。
  • sa_sigaction:指向信号捕获函数的指针。
  • 是实时信号的处理函数,在此处不做讨论。
  • sa_mask:在执行信号捕获函数期间要阻塞的附加信号集。
  • sa_flags:影响信号行为的特殊标志。
  • 一般设置为0

sa_handler 和 sa_sigaction 占用的存储空间可能重叠,符合规范的应用程序不应同时使用两者。

对于 sa_mask,当信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字(pending 信号集),当信号处理函数返回时,自动恢复原来的信号屏蔽字,这样就保证了在处理信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用 sa_mask 字段说明这些需要额外屏蔽的信号,当信号处理函数返回时,自动恢复原来的信号屏蔽字。

以2号信号为例,用 sigaction 函数捕捉信号:

void handler(int sigNum)
{
  cout << "捕捉到信号: " << sigNum << endl;
}
int main()
{
  // signal(2, SIG_IGN);
  // 虽然是内核数据类型,但对象储存在用户栈中(局部对象)
  struct sigaction act, oact;
  act.sa_flags = 0;
  sigemptyset(&act.sa_mask);
  act.sa_handler = handler;
  // 设置进当前进程的PCB中
  sigaction(2, &act, &oact);
  cout << "默认处理动作: " << (int)(oact.sa_handler) << endl;
  while(1)
  {
    sleep(1);
  }
  return 0;
}

gcc比较严格会报错,因为强转操作会造成精度损失,编译时可加上 -fpermissive 选项。

定义两个 struct sigaction 类型的局部变量 act 和 oact,将 act 的 sa_flags 字段设置为0,使用 sigemptyset 函数初始化其 sa_mask 字段,并将其 sa_handler 字段设置为指向前面定义的 handler 函数的指针。oact.sa_handler 是一个指向函数的指针,它指向先前与信号2相关联的动作(即默认处理动作)。

然后,使用 sigaction 函数将信号2的处理方式设置为 act 所描述的动作,并将先前与信号2相关联的动作存储在 oact 中,以便在接收到信号2(即SIGINT)时调用handler函数。最后,输出先前与信号2相关联的动作(即默认处理动作)。

(int)(oact.sa_handler) 是将 oact 结构体中的 sa_handler 成员强制转换为整数类型并输出,以检查默认的信号处理动作是否符合预期。

如果将代码中的 signal 去掉注释,捕捉2号信号,默认处理动作会发生变化:

as_mask

处理信号时,执行自定义动作,如果在处理信号期间又接收到可能不止一个相同的信号,OS 如何处理?如果自定义捕捉方法中有系统调用的话,一直有相同信号就会一直不断调用,进入内核。OS 无法阻止传入多少个信号,但是能限制什么时候处理信号。

这就是 blocked 屏蔽的意义。

测试:

static void showPending(sigset_t *pending)
{
    for (int sig = 1; sig <= 31; sig++)
    {
        if (sigismember(pending, sig)) // 如果信号在 pending 信号集中
            cout << "1";
        else
            cout << "0";
    }
    cout << endl;
}
void handler(int sigNum)
{
  cout << "捕捉到信号: " << sigNum << endl;
  sigset_t pending;
  int count = 10;
  while(1)
  {
    sigpending(&pending);
    showPending(&pending);
    count--;
    if(!count)
      break;
    sleep(1);
  }
}
int main()
{
  struct sigaction act, oact;
  act.sa_flags = 0;
  sigemptyset(&act.sa_mask);
  act.sa_handler = handler;
  sigaction(2, &act, &oact);
  cout << "默认处理动作: " << (int)(oact.sa_handler) << endl;
  while(1)
  {
    sleep(1);
  }
  return 0;
}

这就验证了在默认情况下,同时发送多个信号,进程会屏蔽后面的信号,避免信号递归处理。如果想屏蔽3/5/6号信号呢?

void handler(int sigNum)
{
  cout << "捕捉到信号: " << sigNum << endl;
  sigset_t pending;
  int count = 200;
  while(1)
  {
    sigpending(&pending);
    showPending(&pending);
    count--;
    if(!count)
      break;
    sleep(1);
  }
}
int main()
{
  //signal(2, SIG_IGN);
  // 虽然是内核数据类型,但对象储存在用户栈中(局部对象)
  cout << "PID:" << getpid() << endl;
  struct sigaction act, oact;
  act.sa_flags = 0;
  sigemptyset(&act.sa_mask);
  act.sa_handler = handler;
  sigaddset(&act.sa_mask, 3);
  sigaddset(&act.sa_mask, 4);
  sigaddset(&act.sa_mask, 5);
  sigaddset(&act.sa_mask, 6);
  sigaddset(&act.sa_mask, 7);
  // 设置进当前进程的PCB中
  sigaction(2, &act, &oact);
  cout << "默认处理动作: " << (int)(oact.sa_handler) << endl;
  while(1)
  {
    sleep(1);
  }
  return 0;
}

注意为了测试,将 count 计数器增大到了20。

5. 可重入函数

本小节为学习线程做铺垫。

可重入函数主要用于多任务环境中。一个可重入的函数简单来说就是可以被中断的函数,也就是说,可以在这个函数执行的任何时刻中断它,转入 OS 调度下去执行另外一段代码,而返回控制时不会出现什么错误。

首先要明确一点:信号捕捉并未创建新的进程或线程,信号处理是单进程的,只在一个进程的上下文处理。

5.1 引例1

假设有一个函数用于计算两个数的和,它使用了一个全局变量来存储结果。如果这个函数在执行过程中被中断,并且中断处理程序也调用了这个函数,那么全局变量的值就会被改变,导致原来的计算结果出错。这样的函数就是不可重入的。

相反,如果这个函数不使用全局变量,而是将结果作为返回值返回,那么即使在执行过程中被中断,也不会影响计算结果。这样的函数就是可重入的。

5.2 引例2

光看文字难以理解,有头结点的链表插入操作也可以用来说明可重入函数的概念。

假设有一个函数用于在有头结点的链表中插入一个新节点。如果这个函数使用了一个全局变量来存储链表的头结点,那么当这个函数在执行过程中被中断,并且中断处理程序也调用了这个函数时,全局变量的值就会被改变,导致原来的插入操作出错。这样的函数就是不可重入的。

相反,如果这个函数不使用全局变量,而是将链表的头结点作为参数传递给函数,那么即使在执行过程中被中断,也不会影响插入操作。这样的函数就是可重入的。

为了方便,下面用一个有 head 指针的链表头插操作为例。对于一个有头结点的链表,它定义在全局,两个新结点 node2 和 node3 也是全局的。

在 main 函数中调用了 insert 函数插入新结点 node2,而某个自定义的信号处理函数中也插入了新结点 node3 。插入的步骤分为两步:

这里并未强调插入的步骤是如何的,只是为了说明插入步骤是需要一定时间的,也就是说,插入操作有可能被中断。

首先要明确的是,当 main 函数在执行插入操作时遇到硬件中断,它会被暂停,CPU 会切换到内核态,开始执行中断处理程序。在中断处理程序执行完毕后,CPU 会切换回用户态,继续执行 main 函数中被暂停的插入操作。如果硬件中断触发的信号有用户自定义的信号捕捉函数,那么在中断处理程序执行完毕后,操作系统会调用用户自定义的信号捕捉函数。

在本文的 2.4 小节中说明了硬件中断的发生。

在中断信号发生之前,main函数中的 insert 函数优先被调用,用于插入 node2 结点。假如它只进行了插入操作的第一步,中断信号就发生了,此时 CPU 会陷入内核。执行完中断程序后便调用自定义信号捕捉函数 handler,而handler中也调用了 insert 用于插入node 3。注意,此时main函数中的 insert 还未执行完。

当 CPU 处于内核态执行完自定义信号捕捉函数 handler 中的 insert 的两个步骤后,整个链表的状态是这样的。CPU 切换回用户态后,继续执行 main 函数的 insert 操作剩下的第二步:头插后更新头结点。

可见,虽然 main函数的 insert 操作和 handler 的 insert 操作都被完整地执行完毕,但是就是执行的顺序上的错误造成了 node3 结点无法被链接到链表中,造成了内存泄漏。

通过头结点 head 无法找到 node3 ,那么在释放资源时也无法释放 node3 结点的资源,造成内存泄漏。

正确的顺序:①②①②

上面的顺序:① ①② ②,其中中间的①②是 handler 中 insert 的操作,而 main 函数中的 insert 被拆分了。错就错在最后执行了两次更新 head 头结点的指针。

像这样,insert 被不同的控制流调用(main 函数和 handler 函数属于不同栈帧,是两个独立的控制流程),中断上一次 insert 的执行后再次调用相同的函数 insert 的现象,就叫做重入。

在此例中,insert 函数操作的是一个全局定义的链表,它对不同函数是可见的,因此有可能因为重入现象出错,像这样的函数我们称之为不可重入函数,反之,如果一个函数只访问局部变量或参数,则称之为可重入(Reentrant)函数。

5.3 特点

大多数函数都是不可重入函数,可重入函数和不可重入函数没有明显的标志,它们之间的区别在于它们是否依赖于全局变量或其他共享资源。

  • 可重入函数不依赖于全局变量或其他共享资源,因此它们可以在多线程或多任务环境中安全地使用。它们通常只使用局部变量和函数参数,并且不调用不可重入的函数。
  • 不可重入函数依赖于全局变量或其他共享资源,因此它们在多线程或多任务环境中可能会出现问题。它们可能会使用全局变量、静态变量或调用不可重入的函数,例如 malloc、free 和标准 I/O 函数。

总之,判断一个函数是否可重入需要检查它的实现,看它是否依赖于全局变量或其他共享资源。如果一个函数符合以下条件之一则是不可重入的:

  1. 调用了 malloc 或 free,因为 malloc 也是用全局链表来管理堆的。
  2. 调用了标准I/O库函数,因为标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
  3. STL 容器、被 static 修饰的函数…

6. volatile 关键字

volatile (易变的)是一种类型修饰符,用于保持变量的内存可见性。

volatile 关键字通常用于多线程环境中,volatile 提醒编译器它后面所定义的变量随时都有可能改变,当一个变量被多个线程访问和修改时,使用 volatile 可以防止编译器对该变量的读取和存储进行优化。这样可以确保每次读取该变量时都是从内存中读取最新的值,而不是使用寄存器中的缓存值。

这么做的原因是:寄存器中的值可能会被其他进程或线程修改,这是 CPU 无法察觉的。当一个变量被多个线程访问和修改时,如果编译器对该变量的读取和存储进行优化,可能会使用寄存器中的缓存值,而不是从内存中读取最新的值。这样可能会导致读取到过期的数据。

例如,在 C 语言中,volatile 关键字可以用于修饰并行设备的硬件寄存器、中断服务程序中修改的供其它程序检测的变量、多任务环境下各任务间共享的标志等。

6.1 示例1

#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
int flag = 0; // 定义一个全局变量
void changeFlag(int signum) // 打印变量的变化
{
  (void)signum;
  cout << "change flag: " << flag;
  flag = 1;
  cout << "->" << flag << endl;
}
int main()
{
  signal(2, changeFlag);
  // 接收到2号信号,在changeFlag中
  // flag: 0->1, 循环
  // !flag:1->0, 终止循环
  while(!flag);
  cout << "进程退出, flag: " << flag << endl;
  return 0;
  return 0;
}

它定义了一个全局变量 flag,并在 changeFlag 函数中将其值从0改为1。当程序接收到2号信号时,会调用 changeFlag 函数。程序会一直循环,直到 flag 的值被改变为1。

这是未被优化的情况,说明 CPU 察觉到了全局变量 flag 从0->1,才能结束循环,终止进程。

注意,这里的while(!flag)中并未 sleep,稍后会解释它存在与否对结果的影响。

对于 gcc/g++ 编译器 -O3 选项是用来开启编译器的最高级别优化,其中之一就是通过寄存器保存并获取变量的值,而不从内存中获取,以此提高速度。

-O3选项的位置应该在源文件之前:

g++ -std=c++11 -O3  -o $@ $^

即使捕捉到2号信号在自定义处理函数中第一次打印了0->1,继续执行 while 时,并不能终止循环。首先要知道,CPU 运算数据,首先要将内存中的数据 load 到 CPU 上,对于全局变量也是一样的,不过编译器优化后,它只会检查在 main 函数中修改它的语句,如果有修改才会重新 load 到 CPU上,然而这里没有在 main 修改,而是在回调函数中修改的。优化以后,CPU 只在第一次使用全局变量时将它 load 到 CPU 中,并用寄存器保存着,main 中无修改的语句,它就会一直使用寄存器中的值,所以这里在 CPU 眼中的全局变量 flag 一直是初识状态的1。

要避免这种情况,就要用 volatile 修饰全局变量 flag,告诉编译器让 CPU 每次使用这个变量时都到内存中 load。

除此之外,在 while 中使用 sleep 也能达到同样的效果。原因是:sleep 函数会让程序暂停执行一段时间,这样可以让操作系统有机会调度其他进程运行。当使用 g++ 编译器加上 -O3 选项来编译这段代码时,编译器会进行更多的优化,其中之一就是循环展开。如果没有在循环中加入 sleep 函数,那么编译器可能会认为这个循环永远不会终止,因此它会将循环展开成一个无限循环,导致程序无法退出,也就无法抽出空隙更新 flag 的值,虽然 -O3 选项的存在也使得 CPU 无法获取内存中变量的实时值,但单纯的while死循环会占用 CPU 大量的算力。

优化的时机是编译时还是运行时?

在编译时。CPU 根本不关心程序要做什么,它只是单纯地执行编译后的可执行程序,这些优化处理是编译器应该做的事。不要因为结果运行后才能知道,就认为优化的时机是运行时。

7. SIGCHLD 信号

当一个进程终止时,会发送 SIGCHLD 信号给其父进程。子进程终止时会向父进程发送SIGCHLD信号,告知父进程回收自己,但该信号的默认处理动作为忽略,因此父进程仍然不会去回收子进程,但是父进程可以实现自定义处理 SIGCHLD 信号的函数。这一机制使得父进程不再需要通过 wait 或 waitpid 函数回收子进程资源了,因为这两种方式都会占用父进程一定的资源:wait 必须让父进程阻塞等待子进程结束;waitpid 必须让父进程对子进程轮询。

只要父进程自定义 SIGCHLD 信号的处理动作,在信号处理函数中调用 wait 或 waitpid 函数清理子进程即可,子进程终止时也会通知父进程。这样父进程就只需专心处理自己的工作,不必关心子进程,提高效率。

发送信号的本质是操作系统发送信号给进程。

下面这段代码演示了使用 signal 函数来处理子进程退出时发送的 SIGCHLD 信号。

#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void handler(int signum)
{
  cout << "子进程退出, 信号编号: " << signum << ", 父进程PID: " << getpid() << endl;
}
int main()
{
  signal(SIGCHLD, handler);
  int n = fork();
  if(n == 0) // 子进程
  {
    int count = 5;
    cout << "子进程PID: " << getpid() << endl;
    cout << count << "秒后子进程终止" << endl;
    while(count)
    {
      sleep(1);
      cout << count-- << endl;
    }
    exit(0);
  }
  // 父进程
  while(1)
  {
    sleep(1);
  }
  return 0;
}

在 main 函数中,首先使用 signal 函数将 SIGCHLD 信号与 handler 函数关联起来,然后使用 fork 函数创建一个子进程。

在子进程中,程序会打印出子进程的 PID,并等待5秒后退出。在父进程中,程序会一直循环等待。当子进程退出时,操作系统会向父进程发送一个 SIGCHLD 信号,父进程接收到这个信号后会调用 handler 函数来处理这个信号。在 handler 函数中,程序会打印出信号的编号和父进程的 PID。

通过演示过程证明了子进程退出会向父进程发送 17 号信号,同时可以知道,即使在进程外部给父进程发送 17 号信号,它也是能够识别的。


这段代码与上一段代码类似,不同之处在于,在 handler 函数中,程序使用了一个循环来调用 wait 函数。

void handler(int signum)
{
  cout << "子进程退出, 信号编号: " << signum << ", 父进程PID: " << getpid() << endl;
  while(wait(nullptr));
}
int main()
{
  signal(SIGCHLD, handler);
  pid_t id = fork();
  if(id == 0) // 子进程
  {
    int count = 5;
    cout << "子进程PID: " << getpid() << endl;
    cout << count << "秒后子进程终止" << endl;
    while(count)
    {
      sleep(1);
      cout << count-- << endl;
    }
    exit(0);
  }
  // 父进程
  while(1)
  {
    sleep(1);
  }
  return 0;
}

父进程调用 wait 函数来回收子进程的资源。此外,wait 函数还可以让父进程获取子进程的退出状态,以便根据子进程的运行结果来执行相应的操作。

可见子进程发送信号 SIGCHLD 信号时,如果同时收到多个 wait,OS 只会保留一个。


如果不想让父进程等待子进程,并且还想在子进程退出以后自动回收僵尸子进程:

int main()
{
  pid_t id = fork();
  if(id == 0) // 子进程
  {
    cout << "子进程PID: " << getpid() << endl;
    sleep(5);
    exit(0);
  }
  // 父进程
  while(1)
  {
    cout << "父进程PID: " << getpid() << ", 执行任务" << endl;
    sleep(1);
  }
  return 0;
}


如果想不等待子进程,既自动让子进程退出,又想让它自己回收资源。可以用 signal 函数手动设置捕捉到 SIGCHLD 信号后以忽略方式处理:

signal(SIGCHLD, SIG_IGN);

目录
相关文章
|
2月前
|
网络协议 Linux
Linux查看端口监听情况,以及Linux查看某个端口对应的进程号和程序
Linux查看端口监听情况,以及Linux查看某个端口对应的进程号和程序
139 2
|
2月前
|
Linux Python
linux上根据运行程序的进程号,查看程序所在的绝对路径。linux查看进程启动的时间
linux上根据运行程序的进程号,查看程序所在的绝对路径。linux查看进程启动的时间
47 2
|
5天前
|
Linux Shell
6-9|linux查询现在运行的进程
6-9|linux查询现在运行的进程
|
28天前
|
Linux C语言
C语言 多进程编程(四)定时器信号和子进程退出信号
本文详细介绍了Linux系统中的定时器信号及其相关函数。首先,文章解释了`SIGALRM`信号的作用及应用场景,包括计时器、超时重试和定时任务等。接着介绍了`alarm()`函数,展示了如何设置定时器以及其局限性。随后探讨了`setitimer()`函数,比较了它与`alarm()`的不同之处,包括定时器类型、精度和支持的定时器数量等方面。最后,文章讲解了子进程退出时如何利用`SIGCHLD`信号,提供了示例代码展示如何处理子进程退出信号,避免僵尸进程问题。
|
29天前
|
NoSQL
gdb中获取进程收到的最近一个信号的信息
gdb中获取进程收到的最近一个信号的信息
|
2月前
|
消息中间件 Linux
Linux进程间通信
Linux进程间通信
35 1
|
2月前
|
Linux 调度
Linux0.11 信号(十二)(下)
Linux0.11 信号(十二)
20 1
|
18天前
|
存储 监控 安全
探究Linux操作系统的进程管理机制及其优化策略
本文旨在深入探讨Linux操作系统中的进程管理机制,包括进程调度、内存管理以及I/O管理等核心内容。通过对这些关键组件的分析,我们将揭示它们如何共同工作以提供稳定、高效的计算环境,并讨论可能的优化策略。
21 0
|
1月前
|
Unix Linux
linux中在进程之间传递文件描述符的实现方式
linux中在进程之间传递文件描述符的实现方式
|
2月前
|
开发者 API Windows
从怀旧到革新:看WinForms如何在保持向后兼容性的前提下,借助.NET新平台的力量实现自我进化与应用现代化,让经典桌面应用焕发第二春——我们的WinForms应用转型之路深度剖析
【8月更文挑战第31天】在Windows桌面应用开发中,Windows Forms(WinForms)依然是许多开发者的首选。尽管.NET Framework已演进至.NET 5 及更高版本,WinForms 仍作为核心组件保留,支持现有代码库的同时引入新特性。开发者可将项目迁移至.NET Core,享受性能提升和跨平台能力。迁移时需注意API变更,确保应用平稳过渡。通过自定义样式或第三方控件库,还可增强视觉效果。结合.NET新功能,WinForms 应用不仅能延续既有投资,还能焕发新生。 示例代码展示了如何在.NET Core中创建包含按钮和标签的基本窗口,实现简单的用户交互。
50 0