【Linux】信号(初版)

news2025/2/28 13:03:35

信号概念

信号是进程之间发送异步信息的一种方式`

Linux命令行中,我们可以通过ctrl + c来终止一个前台运行的进程,其实这就是一个发送信号的行为。我们按下ctrl + c是在shell进程中,而被终止的进程,是在前台运行的另外一个进程。因此信号是一种进程之间的通知方式。

可以通过指令kill -l来查询信号:

以上就是Linux中的全部信号,它们分为两个区间:[1, 31][34, 64],也就是说没有32,33这两个个信号,虽然信号的最大编号为64,但实际上只有62个信号。

  • [1, 31]:这些信号称为非实时信号,当进程收到这些信号后,可以自己选择合适的时候处理
  • [34, 64]:这些信号称为实时信号,当进程收到这些信号后,必须立马处理

由于现在的操作系统基本都是分时操作系统,因此实时信号其实是不符合设计理念的,几乎用不到实时信号,我们只学习非实时信号。

上图中,所有信号都是大写的单词,在C/C++中,一般来说宏就是大写的,其实信号名就是宏。

那么进程收到信号后要怎么处理呢?

进程有三种处理信号的方式:

  • 忽略此信号
  • 执行信号的默认处理函数
  • 执行信号的自定义处理函数,这种方式也称为信号捕捉

可以通过man 7 signal来查看信号的默认处理行为:

在开头,可以看到如下页面:

其中TermIgnCoreStopCont就是信号处理的默认行为:

  • Term: 默认操作是终止进程
  • Ign: 默认操作是忽略信号
  • Core: 默认操作是终止进程并转储核心
  • Stop: 默认操作是暂停进程
  • Cont: 默认操作是,如果该进程当前已暂停,则继续该进程

以上五种

其中TermCore都是终止进程,但是Core会额外进行 core dump (核心转储)

再往下翻阅,就可以看到每个信号的描述:

各列的含义如下:

  • Signal:信号的名称
  • Standard:该信号在哪一个标准中提出
  • Action:进程收到该信号后的默认处理行为
  • Comment:对信号的简单描述

信号常见处理方式:

忽略此信号 -> 即 ActionIgn

执行信号的默认处理函数 -> 即Action 不为 Ign

提供一个信号处理函数,要求内核在处理该信号时从内核态切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)信号。

自定义信号处理方式

signal函数可以自定义信号的处理方式

#include <signal.h>

typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);

参数说明

signum:要捕获的信号的编号。例如,SIGINT、SIGTERM 等。

handler:指向信号处理函数的指针。该函数在接收到指定信号时被调用。处理函数的原型应为

void handler(int signum)

返回值:

  • 成功时,返回之前的信号处理程序的地址。
  • 失败时,返回 SIG_ERR,并设置 errno

示例:

void handler(int sig)
{
    cout << "get sig: " << sig << endl;
}

int main()
{
    signal(2, handler);

    while (true)
    {
        cout << "waiting for sig..." << endl;
        sleep(1);
    }

    return 0;
}

通过自定义2号信号的执行函数handler,此后进程收到2号信号,只会执行打印,

(2) SIGINT就是ctrl + C发送的信号,在终端输入ctrl + C进行验证

输出结果:

可以看到进程并没有终止

通过上面的文档,我们可以得出下面的结论:

  • 在信号没有发生的时候,经常已经知道怎么进行处理了
  • 进程可以识别信号
  • 信号到来时,如果暂时不能处理,需要进行临时保存
  • 信号到来可以不进行处理,在合适的时候处理
  • 信号的产生是随时的,无法准确预料,所以信号是异步发送的

信号产生

通过键盘产生信号

Ctrl+C对应 (2) SIGINT 的默认处理动作是终止进程,Ctrl+\对应(3) SIGQUIT 的默认处理动作是终止进程并且并生成一个 Core Dump 文件。这个文件包含了程序在崩溃时的内存状态,可以用于调试。Ctrl+z对应(20) SIGTSTP 信号,通常用于暂停程序的执行。

关于Core Dump(核心转储)

当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部 保存到磁盘上,文件名通常是core,这叫做Core Dump。进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。 首先用ulimit命令改变Shell进程的Resource Limit,允许core文件最大为1024 Kb: ulimit -c 1024

前台运行一个死循环程序,通过 ctrl+\ 发送(3) SIGQUIT信号,可以看到生成了core文件

ulimit命令改变了Shell进程的Resource Limittestsig进程的PCB由Shell进程复制而来,所以也具 有和Shell进程相同的Resource Limit值,这样就可以产生Core Dump了。

使用gdb分析core文件:

gdb <程序名> <core_file>

可以看到,程序由于SIGQUIT信号退出,另外,可以在gdb中通过bt命令查看崩溃时的调用栈,可以看出是在main中调用sleep时崩溃的

硬件中断

+ OS如何得知键盘输入了数据?

键盘输入是由键盘驱动和OS联合解释的,输入字符会将其放在显示器文件的缓冲区,输入组合键会被解释为命令,OS得知外设是否进行数据传输,不是轮询查询,而是通过硬件中断技术!

OS在开机时,会产生一张中断向量表,在表中提前注册对软硬件的操作方法,存放各种中断处理程序实际是一个函数指针数组,保存中断后的对应处理方法

当用户在键盘上按下一个键时,键盘硬件会生成一个中断请求信号,键盘控制器将中断请求信号发送给 8259 中断控制器。8259 中断控制器接收到来自键盘的中断请求,并将其记录在中断请求寄存器中。8259 通过对应针脚向 CPU 发送中断请求信号,CPU在REQ 寄存器中存储对应的中断请求标志,CPU 保存当前执行状态和上下文,根据中断类型(在此情况下是键盘中断)在OS中查找中断向量表,确定对应的中断处理程序地址,跳转到相应的中断处理程序,在这里,是唤醒阻塞的read(0)的进程,从此操作系统就得知键盘输入了数据,并可以将数据读取到键盘文件的缓冲区。

  • ctrl+c 的组合如何被解释为命令并发送信号给进程?

进程收到信号时,会先进行保存,保存在进程PCB的uint32_t pending位图中(31个非实时信号),给进程发送信号,写入信号实际是用户通过OS的系统调用由OS向task_struct的内核数据写入,OS在收到键盘输入的控制命令后,就会向位图对应的比特位写入,由此向进程发送了信号。

无论信号产生的方式有多少种,最终都是通过OS向进程写入信号!`

由软件条件产生信号

kill

首先在后台执行死循环程序,然后用kill命令给它发(11) SIGSEGV信号。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。

  • 786879是testsig进程的id。之所以要再次回车才显示Segmentation fault ,是因为在786879进程终止掉 之前已经回到了Shell提示符等待用户输入下一条命令,Shell不希望Segmentation fault信息和用户的输入交错在一起,所以等用户输入命令之后才显示。
  • 指定发送某种信号的kill命令可以有多种写法,上面的命令还可以写成kill -SIGSEGV 786879kill -11 786879 , 以往遇到的段错误都是由非法内存访问产生的,而这个程序本身没错,给它发SIGSEGV也能产生段错误。

kill命令是通过调用<font style="color:#AE146E;background-color:rgb(249, 242, 244);">kill函数实现的`,实际是以进程间通信的方式发送信号。kill函数可以给一个指定的进程发送指定的信号。

#include <sys/types.h>
#include <signal.h>

int kill(pid_t pid, int sig);

``raise``

`raise`函数可以给当前进程发送指定的信号(自己给自己发信号)。
#include <signal.h>

int raise(int sig);

前面两个函数都是成功返回0,错误返回-1

``abort``

`abort`函数会向进程发送`(6) SIGABRT`信号,用于强制终止当前进程并生成核心转储(core dump)
#include <stdlib.h>

void abort(void);

(13) SIGPIPE是一种由软件条件产生的信号,在“管道”中已经介绍过了。本节主要介绍alarm函数 和SIGALRM信号。

调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发(14) SIGALRM信号, 该信号的默认处理动作是终止当前进程。

#include <unistd.h>

unsigned int alarm(unsigned int seconds);

如果seconds不为0,会设置对应的秒数后发送信号,返回之前设置的定时器剩余时间并覆盖,没有之前的定时器返回0

如果seconds设置为0,会取消设定的闹钟,返回剩余秒数

int main()
{
    alarm(5);
    sleep(3);
    int ret = alarm(10);
    cout << "alarm: 5, sleep: 3, alarm: 10, ret: " << ret << endl;

    sleep(5);
    ret = alarm(0);
    cout << "sleep: 3, alarm: 0, ret: " << ret << endl;

    alarm(5);
    cout<<"alarm: 5"<<endl;
    sleep(10);

    return 0;
}

硬件异常产生信号

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

常见的硬件异常有:

段错误(Segmentation Fault)

  • 信号:(11) SIGSEGV
  • 原因: CR2 寄存器用于存储最近一次发生的页面错误(page fault)时的虚拟地址。CR3 寄存器用于存储当前正在使用的页表基址。如果进程使用了一个野指针,虚拟地址在转化为物理地址,由OS+CPU(MMU)完成,此时CR3页表和寄存器中存放的要修改的地址交由MMU硬件进行转化,MMU转化失败,就会将错误地址存放在CR2中,CPU会切换到内核态,通知OS进程出现异常,OS检查后发现CR2异常,就会设置进程PCB信号位图(11) SIGSEGV对应位

非法指令(Illegal Instruction)

  • 信号:(4) SIGILL
  • 原因:当程序尝试执行无效的机器指令时,会触发此信号。

浮点异常(Floating Point Exception)

  • 信号:(8) SIGFPE
  • 原因:在 x86 架构中,状态寄存器主要是 EFLAGS 寄存器(32 位)或 RFLAGS 寄存器(64 位),主要功能是指示当前的 CPU 状态以及控制程序的执行流。 EFLAGS 寄存器包含多个标志位,包括一个OF (Overflow Flag)溢出标志,表示算术运算是否产生溢出。发生初零/溢出错误时,OF被设置为1,计算错误就能反映在CPU寄存器(硬件)上,CPU会切换到内核态,通知OS进程出现异常,OS检查后发现OF标志位异常,就会设置进程PCB信号位图(8) SIGFPE对应位

中断(Trap)

  • 信号:(5) SIGTRAP
  • 原因:通常由调试器或程序中的断点指令引发。

信号阻塞

信号集

前面提到,信号到来时,如果不能立即处理,可以保存起来,在合适的时候处理,Linux中通过3个位图保存收到的信号

处理信号的3种状态:

信号递达(Delivery):进程处理信号的过程称为递达,递达可以是执行默认处理函数,或者执行自定义的信号处理函数,忽略信号Ign也是一种处理信号的方式,也算递达

信号未决(Pending):当进程收到一个信号,但是还没有处理这个信号,称为未决

信号阻塞(Block):当一个信号被阻塞,就会一直保留在未决状态,不会执行任何处理函数,无法递达

对应三种状态Linux中进程的PCB维护了3个表pending, block, handler

pending:该表的本质是一个位图,也称为未决信号集。当进程接收到一个信号,会把对应的比特位修改为1,表示进程已经接收到该信号

block:该表的本质是一个位图,也称为阻塞信号集。当进程收到信号,在pending中把对应的位修改1,此时就要经过block,如果block中对应的位为1,表示该信号被阻塞,不会被递达,penidng上的该位一直保持为1;如果block中对应的位为0,表示该信号未被阻塞,进程挑选合适的时候递达该信号。

handler:该表本质是一个函数指针数组,指向信号的处理函数。如果时机合适,进程会检测pending表和block表,然后检测出已经接收到的信号,若该信号未被阻塞,执行对应信号的处理函数,并把pending中的该位变回0,表示该信号已经处理完了。

以上表还有以下特性:

  • 当用户通过signal()修改信号的默认处理方式,其实就是在修改这个handler内部的函数指针。
  • 如果连续收到多个相同的非实时信号,此时pending位图只会记录一次,如果是实时信号,则会把收到的所有信号放进队列中,每个信号都会被处理。

操作信号集

简单了解了这三张表后,我们又要如何操纵这三种表呢?

对于handler表,可以通过signal()函数来修改内部的处理函数,而blockpending叫做信号集,本质是位图,要做的无非就是修改某一个位是0还是1,因此这两个表的操作是一样的。

操作这两个信号集,都依赖一个类型sigset_t,其包含在<signal.h>中,Linux中该类型的源码如下:

typedef struct
{
unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;

typedef __sigset_t sigset_t;

也就是说,sigset_t本质是一个结构体,结构体内部只有一个成员,且该成员是一个数组。这个数组就是用于存储位图的工具。__SIGSET_NWORDS 是常量,表示需要的位数,从宏观上看,可以理解为sigset_t就是一个位图,不过这不太严谨。

想要操作这张信号集,需要通过以下五个函数:

#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中的所有比特位变为0
  • sigfillset:使信号集set中的所有比特位变为1
  • sigaddset:使信号集set的第signum位变为1
  • sigdelset:使信号集set的第signum位变为0
  • sigismember:检测信号集set的第signum位是0还是1

前四个函数的返回值都是:如果成功返回0,失败返回-1。

也就是说,我们可以通过以上函数,来操作信号集这个位图,但通过这个函数操作的信号集,既不是block也不是pending,它目前只是一个进程中的变量而已。

那么我们接下来要做的,就是把我们自己创建并设置的信号集,与进程的block和pending交互。

sigprocmask

`sigprocmask`函数用于读取或者更改进程的`block`,原型如下:
#include <signal.h>

/* Prototype for the glibc wrapper function */
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

参数:

how: 指定操作的方式,可以是以下值之一:

  • SIG_BLOCK:将set中为1的比特位添加到block中,相当于block = block | set
  • SIG_UNBLOCK:将set中为1的比特位,从block中删除,相当于block = block & ~set
  • SIG_SETMASK:直接将block设置成当前set的样子,相当于block = set

set:指向自己维护的信号集sigse_t的指针

oldset:输出型参数,用于接收修改前的block (如果不需要可以传递 NULL

返回值:

成功时返回 0,失败时返回 -1,并设置 errno 以指示错误类型。

sigpending

`sigpending`函数用于读取进程的`pending`,原型如下:
#include <signal.h>

int sigpending(sigset_t *set);

参数:

set:输出型参数,将pending传入到set

返回值:

成功时返回 0,失败时返回 -1,并设置 errno 以指示错误类型。


接下来我们综合以上的所有接口,进行几个实验:

测试block可以阻塞信号,信号确实保存在pending

int main()
{
    sigset_t set;
    
    sigemptyset(&set);
    sigaddset(&set,2);
    sigprocmask(SIG_BLOCK,&set,nullptr);

    while(1)
    {
        sigset_t pending;
        sigpending(&pending);

        for(int i=31;i>0;i--)
        {
            if(sigismember(&pending,i))
                cout<<"1";
            else
                cout<<"0";
        }
        cout<<endl;

        sleep(1);
    }
}

这个pending是用于保存进程中的未决信号的,我们已经把(2) SIGINT阻塞了,如果预测没有错误的话,那么输入ctrl + C时,pending的第二位会变成1,这就说明我们已经接收到该信号了。但是block(2) SIGNAL给阻塞了,导致其一直处于pending中,无法被递达,所以pending的第二位会一直是1。

输出结果:

可以看到,输入ctrl + C之后,第二位从0变成了1,并且持续为1,即信号被阻塞了。


检测是否所有信号可以被阻塞

void shouSet(sigset_t* set)
{
    for(int i=31;i>0;i--)
    {
        if(sigismember(set,i))
            cout<<1;
        else
            cout<<0;
    }
    cout<<endl;
}

int main()
{
    sigset_t set,oldset;
    sigemptyset(&oldset);
    sigfillset(&set);

    sigprocmask(SIG_SETMASK,&set,&oldset);	//设置block
    cout<<"oldset: ";
    shouSet(&oldset);

    sigprocmask(SIG_SETMASK,&set,&oldset);	//获取我们设置后的block
    cout<<"newset: ";
    shouSet(&oldset);

    return 0;
}

set的所有位变成1,然后添加到block中,此时old_block为系统默认block,第二次设置block,通过oldblock获得我们设置后的系统block

输出结果:

第一次输出为全0,说明默认block不阻塞任何信号,第二次输出我们虽然传入了全1,但是可以看到有两个信号(9) SIGKIILL(19) SIGSTOP没有被阻塞, 这种设计确保了用户和管理员在任何情况下都可以控制进程的执行,维护系统的正常运行。


捕捉信号后,信号递达时,block表和pending表是什么状态

void handler(int signum)
{
    cout<<"catch signal: "<<signum<<endl;

    sigset_t pending;
    sigpending(&pending);
    cout<<"pending: ";
    shouSet(&pending);

    sigset_t block,oldblock;
    sigemptyset(&block);
    sigprocmask(SIG_BLOCK,&block,&oldblock);
    cout<<"block:   ";
    shouSet(&oldblock);
}

int main()
{
    signal(2,handler);
    raise(2);

    return 0;
}

设置信号(2) SIGINT的自定义处理函数,然后在handler中查看捕捉信号后的block表和pending

输出结果:

可以发现,虽然发送了信号,pending却变回0了,说明信号捕捉后会先清除,再递达

另外可以发现我们没有主动阻塞这个信号,但是block的对应位是1,也就是信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字。

void handler(int signum)
{
    cout << "catch signal: " << signum << endl;

    while (1)
    {
        sigset_t pending;
        sigpending(&pending);
        cout << "pending: ";
        shouSet(&pending);

        sigset_t block, oldblock;
        sigemptyset(&block);

        // 不修改block
        sigprocmask(SIG_BLOCK, &block, &oldblock);

        // 解除block
        // sigprocmask(SIG_SETMASK, &block, &oldblock);
        cout << "block:   ";
        shouSet(&oldblock);

        sleep(3);
    }
}

int main()
{
    signal(2, handler);
    raise(2);

    return 0;
}

在信号执行自定义处理程序的过程中,因为对应信号被阻塞,所以再发送对应信号,信号会处于未决状态,不会重新进入处理程序

如果我们在handler中解除对应信号的阻塞,再发送对应信号,可以重新进入处理程序

信号捕捉后将对应的信号阻塞设置为 1 可以防止信号处理程序的重入,确保信号处理的原子性。即 当一个信号处理程序正在执行时,不会有其他同类信号被递送到该进程。当信号处理函数返回时自动恢复原来的信号屏蔽字,这这一机制使得信号处理更加安全和可靠,也是信号处理设计中的一个重要原则。

sigaction

刚刚我们说:操作系统在处理信号的时候,会把对应的位阻塞。

sigaction 是一个用于设置信号处理程序的系统调用,提供了比 signal 更加灵活和可靠的信号处理机制。它允许程序指定如何处理特定的信号,包括自定义处理函数、信号掩码和其他选项。 原型如下:

#include <signal.h>

int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);

参数:

signum:要设置处理程序的信号编号。

act:指向一个 struct sigaction 结构的指针,用于指定新的信号处理行为。

oldact:指向一个 struct sigaction 结构的指针,用于保存之前的信号处理行为(如果不需要可以传递 NULL)。

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,则使用默认处理。

sa_mask:在信号处理期间要阻塞的信号集,避免信号处理程序被打断。

sa_flags:可以设置以下标志(一般设为0):

  • SA_RESTART:使被信号中断的系统调用自动重新启动。
  • SA_SIGINFO:使用 sa_sigaction 指定信号处理函数,以便接收更多信息。

sa_sigaction:如果 SA_SIGINFO 被设置,使用此字段指定信号处理函数,它可以接收更多参数。

sa_restorer:在早期的 Linux 内核中用于指定一个恢复函数,以恢复用户态的状态,在现代的 POSIX 标准中,这个字段通常不再使用

示例:

void handler(int signum)
{
    cout << "catch signal: " << signum << endl;

    sigset_t block, oldblock;
    sigemptyset(&block);
    sigprocmask(SIG_BLOCK, &block, &oldblock);
    cout << "block: ";
    shouSet(&oldblock);
}

int main()
{
    struct sigaction act;
    act.sa_handler = handler;
    act.sa_flags = 0;

    sigemptyset(&act.sa_mask);
    for (int i = 0; i < 6; i++)
        sigaddset(&act.sa_mask, i);

    sigaction(2, &act, nullptr);
    raise(2);

    return 0;
}

以上示例中,先定义了struct sigaction act,用于传入信号处理方式。其sa_handler 设为处理函数handlersa_flags 设为0即可。而后将sa_mask设为一个前五位为1的block位图

输出结果:

可以看到,此时进程会阻塞前五个信号

信号捕捉与处理

前面提过,在合适的时候,操作系统会处理信号,那么什么时候才是合适的时候?也就是说,什么时候操作系统会去处理`pending`中的信号?

为了解决这个问题,我们要先了解操作系统的用户态内核态

用户态与内核态

`CPU指令集 (Code Segment) `权限:

指令集是CPU实现软件指挥硬件的媒介,具体来说每一条汇编语句都对应一个CPU指令,指令的集合叫CPU指令集,使用CPU指令集需要对应权限,<font style="color:rgb(48, 48, 48);">权限分级是硬件级别的,以 ``Inter CPU 为例,Inter 把使用 CPU指令集<font style="color:rgb(48, 48, 48);">需要的权限由高到低划分为`` ring 0 - ring 3`<font style="color:rgb(48, 48, 48);"> 共4级,其中 ring 0<font style="color:rgb(48, 48, 48);"> 权限最高,可以使用所有CPU指令集,``ring 3<font style="color:rgb(48, 48, 48);"> 权限最低,仅能使用常规CPU指令集,不能使用操作硬件资源的CPU指令集<font style="color:rgb(48, 48, 48);">,比如 IO 读写、网卡访问、申请内存都不行,**<font style="color:#0C68CA;">Linux 系统仅采用 ring 0 和 ring 3 这两个权限。**CPU中的CS寄存器存储当前代码段的选择子(Selector), 指向与正在执行的代码相关的段描述符,段描述符中包含特权级(DPL)等属性。

  • 执行内核空间的代码,具有ring 0 保护级别,即内核态,有对硬件的所有操作权限,可以执行所有CPU指令集,访问任意地址的内存,在内核模式下的任何异常都是灾难性的`
  • 在用户模式下,具有ring 3 保护级别,即用户态,代码没有对硬件的直接控制权限,也没有内核空间地址的访问权限,即时程序发生崩溃也是可以恢复的`

进程地址空间被分为两部分:用户空间内核空间,每个空间都有自己的页表去映射内存,内核空间使用的页表叫做内核级页表,用户空间使用的页表叫做用户级页表`

内核空间中的虚拟地址指向操作系统的代码和数据,操作系统本身也是一个软件,有自己的代码和数据,任何用户访问操作系统的行为,都是切换到内核态通过内核空间(地址空间的[3, 4]GB)执行操作系统的代码。** 进程地址空间和页表是每个进程独有的,但内核部分的映射是共享的,OS的代码和数据在每个进程都有映射。**所有进程的内核空间([3, 4]GB)都是相同的,每个进程都可以通过自己的内核空间执行系统调用。进程从用户态切换到内核态的过程叫做陷入内核,从内核态切换到用户态的过程叫做返回用户态`。

进程从用户态切换到进程态主要有以下几种情况:

  1. 系统调用(System Call)

用户态程序需要执行一些需要内核权限的操作,例如读写文件,创建进程,访问网络等

  1. 硬件中断(Hardware Interrupt)

硬件设备(如键盘、鼠标、网络接口等)发生中断,CPU收到中断保存当前进程上下文,切换到内核态执行中断处理程序

  1. 异常处理(Exception Handling)

程序执行过程中发生错误(初零,野指针等),CPU会切换到内核态,通知OS进行处理,发送信号

  1. 定时器中断(Clock Interrupt)

操作系统使用定时器中断进行时间管理和进程调度,定时器到达预设时间后,CPU切换到内核态,操作系统检查当前进程状态决定是否进行上下文切换。

另外,之前提到的写时拷贝,只会拷贝用户空间的数据,例如C语言的用户级缓冲区,不会拷贝内核级缓冲区的数据

信号捕捉的时机

![](https://img-blog.csdnimg.cn/img_convert/800756d3e074ca46ec8dd89bccf755d9.png)

当操作系统因为某些原因陷入内核后,会先处理用户的需求,当处理完需求后,就会检测当前是否有需要处理的信号。检测的结果有三种:

  • 没有要处理的信号,直接返回用户态
  • 有要处理的信号,且该信号的处理方式是默认处理函数,那么直接在内核态处理该信号,处理完毕后返回用户态
  • 有要处理的信号,且该信号的处理方式是用户自定义函数,那么要先返回用户态执行自定义函数,执行完函数再次陷入内核,最后再返回用户态

如果信号的处理方式是默认处理方式,此时直接在内核态执行代码,主要有两个原因:

  • 信号的默认处理方式,是操作系统自己提供的,因此不会有安全性问题,可以直接以内核态的高级权限执行
  • 内核态允许执行特权操作(如修改进程状态、唤醒被阻塞的进程等),这是用户态无法直接完成的
  • 减少上下文切换的开销,<font style="color:rgb(48, 48, 48);">用户态和内核态切换的开销大,包括保留用户态现场(上下文、寄存器、用户栈等),复制用户态参数,用户栈切到内核栈,进入内核态,额外的检查(因为内核代码对用户不信任),执行内核态代码,复制内核态代码执行结果,回到用户态,恢复用户态现场(上下文、寄存器、用户栈等)一系列操作`

当信号的处理方式是用户自定义函数,那么要先切换回用户态执行,这是因为用户自定义的handler函数,其安全性是不确定的。sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。 sighandler函数返回后自动执行特殊的系统调用sigreturn`再次进入内核态。

还有一个问题,执行完handler函数后,CPU已经处于用户态了,为什么还要先到内核态,再回到用户态

当用户态进程陷入内核态时,内核会保存用户态进程的上下文信息,包括:

  • 寄存器值:例如程序计数器(PC)、堆栈指针(SP)、通用寄存器等。
  • 内存状态:例如内存页表、虚拟地址空间等。
  • 其他状态:例如进程状态、信号掩码等。

内核通过保存这些上下文信息,可以记录用户态进程执行到哪个位置,以及该进程的运行状态。当内核处理完用户态进程的请求后,会恢复用户态进程的上下文信息,并将控制权返回给用户态进程。由于恢复过程涉及操作系统的管理和资源控制,因此只能在内核态完成这些操作。用户态进程恢复执行后,会从之前中断的位置继续执行。

到这里就可以正式给出问题的答案了:每一次在从内核态返回用户态之前,操作系统都会处理信号。`

由于信号是一种异步事件,可能在进程执行的任何时刻到达。所以每次返回用户态前都进行信号检查。

OS如何正常运行`

操作系统会不断进行内核态和用户态的切换,保证系统的正常运行

信号技术就是通过软件的方式,模拟硬件中断**<font style="color:#0C68CA;">。**硬件通过高频的、短时间的给CPU发送中断,让CPU不停的处理中断,在中断向量表中执行对应的方法(例如响应外设、进程调度),让OS运行起来,这个过程叫做OS的周期时钟中断,OS是一个死循环,不断接受外部的硬件中断。

源码示例(小型操作系统):

在入口函数初始化完毕后,通过for(;;) pause();进入死循环,等待中断

中断向量表:

在初始化中有一个sched_init(),调度程序初始化,其中会执行set_intr_gate(0x20, &timer_interrupt);设置中断信号20,int timer_interrupt(void);即为汇编语言写的时钟中断处理程序

  • 源码中系统调用的触发过程

用户程序 -> 调用 read() ->if(cs&0x3)==0通过CS寄存器检查是否处于内核态,将系统调用号存放在eax中,触发中断 (int 0x80) (这个 int 指 interrupt )-> 查找中断向量表 -> 执行 sys_read() 处理函数

sched_init()调度程序初始化中也有一个set_system_gate(0x80, &system_call),表示设置系统调用中断门,int system_call(void);即为系统调用中断处理程序,实现在kernel/syztesm_call.s文件,在文件的_system_call: 中,有一个push eax,把系统调用号入栈,而后call [_sys_call_table+eax*4]``_sys_call_table即系统调用函数指针表,数组的下标即为系统调用号

可重入函数

![](https://img-blog.csdnimg.cn/img_convert/201d1bfcb1f7a80249bcbf39c2c4cbd4.png)

在上图中,main函数调用insert函数向一个链表head中插入节点node1,插入操作分两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后向链表中插入两个节点,但最后只有一个节点真正插入链表中了。

像这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之如果一个函数不依赖于任何静态或全局变量的状态,行为完全依赖于输入参数,只访问自己的局部变量和参数,不修改共享资源,则称为可重入(Reentrant) 函数

如果一个函数符合以下条件之一则是不可重入的:

  • 调用了mallocfree,因为malloc也是用全局链表来管理堆的。
  • 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

volatile

``volatile``(译为不稳定的)是一种关键字,主要用于告知编译器某个变量的值可能会在任何时间被其他因素改变,从而阻止编译器进行优化。编译器通常会假设一个变量在某个作用域内的值不会改变,但如果该变量是 ``volatile``,编译器会在 每次使用该变量时强制从内存中读取其值`,而不是使用寄存器中的缓存值。
#include <stdio.h>
#include <signal.h>

int flag = 0;

void handler(int sig)
{
    printf("chage flag 0 to 1\n");
    flag = 1;
}

int main()
{
    signal(2, handler);
    
    while(!flag);//故意这样写,编译器会默认对我们的代码自动优化
    
    printf("flag changed\n");
    return 0;
}

在通常情况下,输入ctrl+c命令,CPU每次进行逻辑判断时都会从内存中读数据,执行handler方法改变flag,可以退出循环,打印结果

如果我们执行gcc时使用 -O2 选项进行优化(如果加上# ,选项会被注释,命令行中# 通常表示注释的开始)

可以发现,即使执行了自定义动作修改了flag,while循环条件依然成立,没有退出循环,这是CPU发现逻辑判断后并没有其他代码,就进行了优化,只通过寄存器中的数据判断while循环检查的flag,并不是内存中最新的flag,这就是寄存器屏蔽了内存,产生数据二异性的问题。

接下来尝试使用volatile

可以看到读取到了内存中最新的flag

SIGCHLD

进程一章讲过用`wait`和`waitpid`函数清理僵尸进程,父进程可以`阻塞`等待子进程结束,也可以`非阻塞轮询查询`是否有子进程结束等待清理。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。

其实,子进程在终止时会给父进程发 (17) SIGCHLD 信号,该信号的默认处理动作是忽略,父进程可以自定义(17) SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。

void handler(int sig)
{
    pid_t id;
    while (true)
    {
        pid_t rid = waitpid(-1, nullptr, WNOHANG);
        if (rid > 0)
            cout << "wait child success, pid: " << rid << endl;
        else if (rid <= 0)
            break;
    }
    cout << "wait sub process done" << endl;
}

int main()
{
    //signal(SIGCHLD, handler);
    signal(SIGCHLD, SIG_IGN);
    for (int i = 0; i < 100; i++)
    {
        pid_t id = fork();
        if (id == 0)
        {
            int cnt = 5;
            while (cnt--)
            {
                cout << "I am child process, pid:" << getpid() << endl;
                sleep(1);
            }
            cout << "child process exit" << endl;
            exit(0);
        }
    }

    while (true)
        sleep(1);
}

输出结果:

事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2214030.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

[2024领航杯] Pwn方向题解 babyheap

[2024领航杯] Pwn方向题解 babyheap 前言&#xff1a; 当然这个比赛我没有参加&#xff0c;是江苏省的一个比赛&#xff0c;附件是XiDP师傅在比赛结束之后发给我的&#xff0c;最近事情有点多&#xff0c;当时搁置了一天&#xff0c;昨天下午想起来这个事情&#xff0c;才开始…

C++容器适配器1-基本使用(stack、queue)

###适配器意思是可以将一种不能用于某种场景的东西经过特殊转换&#xff0c;包装成一个新东西&#xff0c;这个新定西可以用于这个场景&#xff0c;并且还具有之前旧东西的功能作用&#xff1b; stack、queue就是C里面的容器适配器&#xff0c;这两个适配器堆vector和list两个…

前端进阶之路:推荐几本不可错过的前端开发书籍

前端开发是一个不断更新换代的领域&#xff0c;作为一名前端工程师&#xff0c;持续学习和提升自己是至关重要的。阅读专业书籍是系统学习前端知识的一种有效方式。以下是一些前端开发者不可错过的书籍推荐&#xff0c;帮助你巩固基础&#xff0c;拓宽视野&#xff0c;成为更优…

【进阶OpenCV】 (13)--视频物体跟踪

文章目录 物体跟踪一、跟踪器二、代码实现1. 创建CSRT跟踪器实例2. 打开视频文件3. 主循环3.1 读取每一帧3.2 设置跟踪目标3.3 更新跟踪器&#xff0c;获取对象位置3.4 显示视频每一帧 4. 释放资源和关闭窗口5. 完整代码展示 总结 物体跟踪 本篇我们来介绍&#xff0c;如何对移…

Nvidia Jetson Orin平台部署CenterPoint模型

最近尝试将CenterPoint模型部署到Orin平台,网络上教程很多,也很杂乱,于是便整理一版自用。 主要根据NVIDIA Lidar AI Solution进行复现。并在此基础上进行补充 Orin平台: python:3.8 CUDA:11.4 torch:1.14.0 torchvision:0.15.1 TensorRT: 8.5.2.1 在Compile &&a…

第十九篇——复盘:数学给了我什么启示?

目录 一、背景介绍二、思路&方案三、过程1.思维导图2.文章中经典的句子理解3.学习之后对于投资市场的理解4.通过这篇文章结合我知道的东西我能想到什么&#xff1f; 四、总结五、升华 一、背景介绍 数学带给这个世界什么&#xff1f;数学在我们的人生路上意味着什么&#…

Arthas 介绍,阿里开源线上JVM性能监控工具

1.官网 https://arthas.aliyun.com/ arthas (aliyun.com) Arthas 是一款由阿里巴巴开发并开源的 Java 应用诊断工具&#xff0c;主要用于帮助开发人员实时监控、诊断和调优 Java 应用程序。这款工具对于处理复杂的生产环境问题特别有效&#xff0c;尤其在定位那些难以复现的…

面网易后台开发居然遇到了一个困难难度算法题

在提供面经的同学中&#xff0c;竟然有同学在面试网易后台研发的时候遇到了一道困难难度的算法题。 一般来说&#xff0c;大多数面试的算法题都是以中等难度为主&#xff0c;遇到困难难度的算法题也许是公司现在不缺人、也许是在选拔人才、当然也很可能是面试官其实并不想要你…

基于LSTM-Transformer混合模型实现股票价格多变量时序预测(PyTorch版)

前言 系列专栏:【深度学习&#xff1a;算法项目实战】✨︎ 涉及医疗健康、财经金融、商业零售、食品饮料、运动健身、交通运输、环境科学、社交媒体以及文本和图像处理等诸多领域&#xff0c;讨论了各种复杂的深度神经网络思想&#xff0c;如卷积神经网络、循环神经网络、生成对…

【WRF工具】服务器上安装convert_geotiff

【WRF工具】服务器上安装convert_geotiff convert_geotiff简介方法1&#xff1a;下载安装包后下载convert_geotiff依赖库安装库1&#xff1a;libtiff库2&#xff1a;sqlite库3&#xff1a;curl库4&#xff1a;projcmake更新&#xff08;可选&#xff09;库5&#xff1a;geotiff…

mysql--表的约束

目录 理解表的约束和操作 如何理解&#xff1f; 1、空属性null 2、默认值default 3、列描述comment 4、自动填充zorefill 5、主键primary key &#xff08;1&#xff09;创建表时指定可以 &#xff08;2&#xff09;创建表后指定key &#xff08;3&#xff09;删除主…

Cocos Creator导出obj文件用于后端寻路

Cocos Creator 3.8.0 用这个扩展插件 【杨宗宝】两年前写的网格工具&#xff0c;今天将它开源了。 - Creator 3.x - Cocos中文社区carlosyzy_extensions_mesh: Cocos Creator 3.x mesh插件&#xff0c;负责网格数据的导出。合并&#xff0c;拆封等一系列操作 (gitee.com) 下…

Avalonia开发实践(四)——关于Setter优先级的问题

首先看一段样例代码&#xff1a; <StackPanel Orientation"Horizontal" Spacing"50" HorizontalAlignment"Center"><StackPanel.Styles><Style Selector"Button.default"><Setter Property"Background&quo…

健身俱乐部预约报名系统

这个是我新开发搭建的健身俱乐部行业的预约报名系统。 首页 焦点图大图展示 右侧联系栏目 关于我们 底部版权信息 在线咨询 一键拨号 添加微信 转发分享 预约来校 专业资质 室内环境相册 教练名片列表 教练名片详情 关于我 联系我 分享给好友 课程介绍 保存到通讯录 行业新闻 …

Vue3概述

1. Vue3概述 1.1 Vue3简介 2020年9月18日&#xff0c;Vue.js发布3.0版本&#xff0c;代号&#xff1a;One Piece。 1.2 vite简介 vite是新一代前端构建工具&#xff0c;官网地址&#xff1a;Vite中文网。 1.3 Vue2和Vue3区别 Vue2的API设计是Options&#xff08;配置&…

模拟设计工程师必知必会:一文讲透PLL学习重点

在模拟设计中&#xff0c;相位锁定环&#xff08;PLL&#xff09;是一种极其重要的技术。它在频率合成、时钟恢复、数据同步等多个方面发挥着关键作用。作为一名模拟设计工程师&#xff0c;对PLL的深入理解和熟练应用是必不可少的。本文将通过移知公开课《模拟设计工程师必知必…

QD1-P32 CSS 边框属性(3)padding(元素的内边距)

本节学习&#xff1a;CSS padding属性&#xff08;元素的内边距&#xff09; 本节视频 https://www.bilibili.com/video/BV1n64y1U7oj?p32 ‍ padding 属性的用途 ​​ ‍ 在CSS中&#xff0c;padding​ 属性用于设置元素内部的空间&#xff0c;即在元素内容和其边界&…

用html、css和js来实现冒泡排序

效果图如下 代码如下 <meta charset"UTF-8"><meta name"viewport" content"widthdevice-width, initial-scale1.0"><title>冒泡排序动画</title><style>body {display: flex;flex-direction: column;justify-con…

【Redis】zset有序集合的常见命令

zset是一个有序集合&#xff0c;有着set的特点&#xff0c;还引入了排序。每个元素额外会有一个分数(score)&#xff0c;元素不可重复&#xff0c;但是分数可以重复。排序以分数为主&#xff0c;如果分数相同&#xff0c;则按元素字典序。 推荐启动服务器时使用该命令&#x…

并发编程-CompletableFuture

并发编程-CompletableFuture 本篇主要讲述 JDK1.8 里面 CompletableFuture 的原理与源码分析。这一篇暂且作为整个章节的最后一篇(若有时间继承增加关于并发编程的其他内容)。闲话少叙&#xff0c;进入正题。在深入了解 CompletableFuture 之前我们先要看一下 Future&Call…