1.信号的初步认识

系统中的信号可以以生活中的例子来理解

你在网上买了很多件商品,在等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递”。当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需 5min 之后才能去取快递。那么在这 5min 之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取”。在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“记住了有一个快递要去取”。当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:

  1. 执行默认动作(幸福的打开快递,使用商品)
  2. 执行自定义动作(快递是零食,你要送给你的女朋友)
  3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话

总的来说,信号分为三步:信号产生 | 信号保存 | 信号处理

  • 进程必须能够具备识别和处理信号的能力,这属于进程内置功能的一部分
  • 当进程收到一个信号的时候,可能并不会立马处理信号,合适的时候使用默认、自定义、忽略三种方式处理
  • 一个信号必须当信号产生到处理的时候,具有一定的时间窗口,进程具有保存那些已经发生了的信号的能力

2.前台进程 vs 后台进程

#include <iostream>
#include <unistd.h>

using namespace std;

int main()
{
    while(true)
    {
        cout << "I am a crazy process" << endl;
        sleep(1);
    }
    return 0;
}

我们将通过一段死循环观察前台和后台的区别

2.1 后台进程

在这里插入图片描述

可以发现后台进程并不会读取键盘操作,不直接与用户交互,在后台默默运行,可以使用 ctrl + c 等命令杀死(本质是收到了 2 号信号)

2.2 前台进程

在这里插入图片描述

可以发现,在可执行文件后面加个 & 就是前台程序,前台进程是用户当前正在操作的进程,能接收键盘、鼠标等输入,只能使用 kill 命令杀死

系统中只允许一个进程是前台进程,可以多个进程是后台进程

🔥值得注意的是: ctrl + / 表示 3 号信号(SIGQUIT),可以发送终止信号并生成core dump 文件,用于事后调试(后面详谈),ctrl + z 表示 19 号信号(SIGSTOP),可以发送停止信号,将当前前台进程挂起到后台等

2.3 signal函数

在这里插入图片描述

该函数用于自定义系统的信号实现,还是以上面的循环为例

在这里插入图片描述

这里面最常用的信号是前 31 个,其中 919 无法被自定义使用

我们知道后台进程可以用 ctrl + c 等命令杀死,本质是收到了 2 号信号(SIGINT

#include <iostream>
#include <unistd.h>
#include <signal.h>

using namespace std;

void myhandler(int signal)
{
    cout << "process get a signal: " << signal << endl;
    exit(1);
}
int main()
{
    signal(SIGINT, myhandler);
    while(true)
    {
        cout << "I am a crazy process" << endl;
        sleep(1);
    }
    return 0;
}

在这里插入图片描述

可以看到传入自定义方法 myhandler 确实能够改变信号的默认处理方式,这个 signal 只要一设定就一直有效,直到主函数结束,如果没有发送 2 号信号,那么 myhandler 也不会被调用

2.4 键盘数据传入系统的原理(硬件)

在这里插入图片描述

当键盘识别到外界的主动输入时,会立马通过信号中断打断 cpu 当前的任务,优先处理键盘的任务,并通过中断单元向 cpu 的寄存器输入中断号(外设都有属于自己的中断号),根据中断号在中断向量表中查找对应的方法地址,系统就会通过该方法对键盘的数据做处理

3.信号产生

3.1 键盘组合

ctrl + cctrl + \

3.2 命令

kill 命令杀死进程

3.3 系统调用

在这里插入图片描述
kill 函数可以给一个指定的进程发送指定的信号,形如 kill -信号码 PID

在这里插入图片描述

向当前进程(自身)发送指定信号

int main()
{
	signal(2, handler); // 先对2号信号进⾏捕捉
	// 每隔1S,⾃⼰给⾃⼰发送2号信号
	while(true)
	{
		sleep(1);
		raise(2);
	}
}

在这里插入图片描述

无条件强制异常终止当前进程,并默认生成核心转储文件(core dump),常用于程序遇到不可恢复错误时的 “紧急退出” 场景。当其对应的 6 号信号被捕获,无论是否捕获 SIGABRT ,进程必然终止

3.4 异常

void handler(int sig)
{
    printf("catch a sig : %d\n", sig);
}
// v1
int main()
{
    // signal(SIGFPE, handler); // 8) SIGFPE
    sleep(1);
    int a = 10;
    a /= 0;
    while (1)
        ;
    return 0;
}

C/C++ 当中除零,内存越界等异常,在系统层面上,是被当成信号处理的

在这里插入图片描述

发现一直有 8 号信号产生并被我们捕获,这是为什么呢?其实在 CPU 中有一些控制和状态寄存器,主要用于控制处理器的操作,通常由操作系统代码使用。状态寄存器可以简单理解为一个位图,对应着一些状态标记位、溢出标记位。OS 会检测是否存在异常状态,有异常存在就会调用对应的异常处理方法

3.5 软件条件

在这里插入图片描述

当管道的读端关闭时,写端无论怎么写都无效,因此操作系统会选择关闭管道,写端继续写会触发 SIGPIPE 信号(默认终止写进程),管道作为进程间通信的软件抽象,属于软件条件触发,也可以说是软件异常,上面的除零异常也可以是硬件异常

3.6 core dump

在这里插入图片描述
在学习进程控制的时候,有一位叫做 core dump 标志位,core dump 标志位(第 7 位)为 1 时,表示进程被信号终止时生成了 core dump 文件,为 0 则未生成

core dump 文件又叫做核心转储文件,简单来说就是程序崩溃时,开发者用于调试程序错误的(如段错误、空指针访问等)

对于虚拟机一般该文件的生成功能会打开,而云服务器不会,因为要避免频繁的出错调试导致的内存溢出,进而可能操作系统挂掉

在这里插入图片描述

我们可以通过 ulimit -a 查看,通过调整系统的 ulimit -c 参数可以修改 core 文件的大小限制,例如设置为 unlimited 可允许生成任意大小的 core 文件,便于捕获完整的崩溃信息进行分析

在这里插入图片描述

假设有个除零异常,产生异常之后我们可以看到生成了一个 core.pid 的文件

在这里插入图片描述

gdb 调试时,输入 core-file core.pid 就能查看异常的出错处了

4.信号保存

4.1 信号概念补充

  • 信号从产生到递达之间的状态,称为信号未决
  • 进程可以选择阻塞(block)某个信号
  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作
  • 阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作

4.2 内核中的信号

在这里插入图片描述

在内核中,有两个和信号有关的重要位图、和一个函数指针数组:

  • block 表示阻塞集,也叫做当前进程的信号屏蔽字,这里的“屏蔽”应该理解为阻塞而不是忽略,若某信号位被置 1,进程会暂时屏蔽该信号,即使信号被发送(进入 pending),也不会立即处理,直到阻塞解除
  • pending 表示待处理集,当信号被发送到进程时,会将 pending 中对应位置 1;若信号未被阻塞,进程会在合适时机处理该信号,并将对应位清 0
  • handler 是一个函数指针数组,存放默认处理函数指针 SIG_DFL、忽略处理函数指针 SIG_IGN,剩下的都是信号自定义处理函数指针,指向各自的函数实现

SIGHUP 信号未阻塞也未产生过,当它递达时执行默认处理动作。SIGINT 信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。SIGQUIT 信号未产生过,一旦产生 SIGQUIT 信号将被阻塞,它的处理动作是用户自定义函数 sighandler

🔥值得注意的是: 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX 允许系统递送该信号一次或多次。Linux 是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号

4.3 信号集函数

sigset_t类型对于每种信号用一个 bit 表示“有效”或“无效”状态,至于这个类型内部如何存储这些 bit 则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用相关函数来操作 sigset_t 变量,而不应该对它的内部数据做任何解释,比如用 printf 直接打印sigset_t 变量是没有意义的

int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
  • sigemptyset:清空信号集,使其不包含任何信号
  • sigfillset:填满信号集,使其包含所有有效信号
  • sigaddset:向指定信号集添加一个特定信号
  • sigdelset:从指定信号集删除一个特定信号
  • sigismember:判断某个信号是否属于指定信号集
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);

sigprocmask:控制进程哪些信号会被阻塞

  • how 定操作:是“替换”、“新增阻塞”还是“解除阻塞”,下表说明了 how 参数的可选值
    在这里插入图片描述

  • set 给信号:要阻塞/解除的信号集合

  • oset 存旧集:保存上一个信号集合,可选,保存原来的阻塞状态(方便后续恢复)

举个例子:

#include <iostream>
#include <unistd.h>
#include <signal.h>

using namespace std;

void PrintPending(sigset_t &pending)
{
    for(int i = 31; i >= 1; --i)
    {
        if(sigismember(&pending, i))
        {
            cout << "1";
        }
        else
        {
            cout << "0";
        }
    }
    cout << "\n\n";
}

void handler(int signo)
{
    cout << "catch a signo : " << signo << endl;
}

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

    sigset_t bset, oset;
    sigemptyset(&bset);
    sigemptyset(&oset);
    sigaddset(&bset, 2);

    sigprocmask(SIG_SETMASK, &bset, &oset);

    sigset_t pending;
    int cnt = 0;
    while(true)
    {
        int n = sigpending(&pending);
        if(n < 0)
        {
            continue;
        }
        PrintPending(pending);
        sleep(1);
        cnt++;

        if(cnt == 20)
        {
            cout << "unblock 2 signo" << endl;
            sigprocmask(SIG_SETMASK, &oset, nullptr);
        }
    }
    return 0;
}

💻运行结果:

在这里插入图片描述

5.信号捕捉

5.1 进程地址空间第三讲

在这里插入图片描述

首先我们要知道进程地址空间用户占四分之三,内核占四分之一,每个进程都有自己的一份用户页表,而内核的页表是共享的。对于 cpu,里面有一个 ecs 寄存器,存储当前所处状态,由 01 表示,当需要切换内核态或用户态时修改二进制位即可

5.2 用户态与内核态的切换

  • CPU 指令集:是 CPU 实现软件指挥硬件执行的媒介,具体来说每一条汇编语句都对应了一条 CPU 指令,而非常非常多的 CPU 指令在一起,可以组成一个、甚至多个集合,指令的集合叫 CPU 指令集。
  • CPU 指令集有权限分级,大家试想,CPU 指令集可以直接操作硬件的,要是因为指令操作的不规范,造成的错误会影响整个计算机系统的。好比你写程序,因为对硬件操作不熟悉,导致操作系统内核、及其他所有正在运行的程序,都可能会因为操作失误而受到不可挽回的错误,最后只能重启计算机才行。
    • 对开发人员来说是个艰巨的任务,还会增加负担,同时开发人员在这方面也不被信任,所以操作系统内核直接屏蔽开发人员对硬件操作的可能,都不让你碰到这些 CPU 指令集。

针对上面的需求,硬件设备商直接提供硬件级别的支持,做法就是对 CPU 指令集设置了权限,不同级别权限能使用的 CPU 指令集是有限的,以 Inter CPU 为例,InterCPU 指令集操作的权限由高到低划为 4级:

  • ring 0:权限最高,可以使用所有 CPU 指令集
  • ring 1
  • ring 2
  • ring 3:权限最低,仅能使用常规 CPU 指令集,不能使用操作硬件资源的 CPU 指令集,比如 IO 读写、网卡访问、申请内存都不行

要知道的是,Linux 系统仅采用 ring 0ring 32 个权限。CPU 中有一个标志字段,标志着线程的运行状态,用户态为 3,内核态为 0

  • ring 0被叫做内核态,完全在操作系统内核中运行
    • 执行内核空间的代码,具有 ring 0 保护级别,有对硬件的所有操作权限,可以执行所有 CPU 指令集,访问任意地址的内存,在内核模式下的任何异常都是灾难性的,将会导致整台机器停机
  • ring 3被叫做用户态,在应用程序中运行
    • 在用户模式下,具有 ring 3 保护级别,代码没有对硬件的直接控制权限,也不能直接访问地址的内存,程序是通过调用系统接口(System Call APIs)来达到访问硬件和内存,在这种保护模式下,即时程序发生崩溃也是可以恢复的,在电脑上大部分程序都是在用户模式下运行的

5.2.1 什么情况会导致用户态到内核态切换?

  • 系统调用:用户态进程主动切换到内核态的方式,用户态进程通过系统调用向操作系统申请资源完成工作,例如 fork()就是一个创建新进程的系统调用。
    • 操作系统提供了中断指令 int 0x80 来主动进入内核,这是用户程序发起的调用访问内核代码的唯一方式。调用系统函数时会通过内联汇编代码插入 int 0x80 的中断指令,内核接收到 int 0x80 中断后,查询中断处理函数地址,随后进入系统调用。
  • 异常:当 CPU 在执行用户态的进程时,发生了一些没有预知的异常,这时当前运行进程会切换到处理此异常的内核相关进程中,也就是切换到了内核态,如缺页异常
  • 中断:当 CPU 在执行用户态的进程时,外围设备完成用户请求的操作后,会向 CPU 发出相应的中断信号,这时 CPU 会暂停执行下一条即将要执行的指令,转到与中断信号对应的处理程序去执行,也就是切换到了内核态。如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后边的操作等

5.2.2 切换时 CPU 需要做什么?

  • 当某个进程中要读写 IO,必然会用到 ring 0 级别的 CPU 指令集。而此时 CPU 的指令集操作权限只有 ring 3,为了可以操作 ring 0 级别的 CPU 指令集,CPU 切换指令集操作权限级别为 ring 0(可称之为提权),CPU 再执行相应的 ring 0 级别的 CPU 指令集(内核代码)。
  • 代码发生提权时,CPU 是需要切换栈的!前面我们提到过,内核有自己的内核栈。CPU 切换栈是需要栈段描述符(ss 寄存器)和栈顶指针(esp 寄存器),这两个值从哪里来?
    • CPU 通过一个段寄存器(tr)确定 TSS(任务状态段,struct TSS)的位置。在 TSS 结构中存在这么一个 SS0ESP0。提权的时候,CPU 就从这个 TSS 里把 SS0ESP0 取出来,放到 ssesp 寄存器中。

5.3 信号捕捉流程

在这里插入图片描述

这张信号处理流程示意图,展示了操作系统中用户态与内核态在处理自定义信号时的切换过程,分以下几个关键步骤:

  1. 进入内核态:用户程序(如main函数)在执行时,因中断、异常或系统调用进入内核态
  2. 内核处理信号:内核在返回用户态前,通过do_signal()处理进程中可递送的信号
  3. 执行用户态信号处理函数:若信号处理动作是自定义的,内核会切换到用户态执行sig handler函数(而非直接回到主控制流程)
  4. 通过特殊系统调用重回内核:切换成用户态执行信号处理函数,执行sigreturn(对应内核函数sys_sigreturn)再次进入内核
  5. 返回用户态继续执行:回到内核态,内核完成信号收尾工作后,再次回到用户态,从主控制流程上次被中断的位置继续执行用户程序

🔥值得注意的是: 当一个信号正在被捕捉时,内核⾃动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时,自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产⽣,那么它会被阻塞到当前处理结束为止

在这里插入图片描述

可以根据这张图简单记忆

5.4 sigaction

在这里插入图片描述
sigactionLinux 系统中用于设置信号处理函数的系统调用,用于取代早期的 signal 函数,提供更精细的信号处理控制

  • signum:要处理的信号编号(如 SIGINTSIGTERM 等)
  • act:指向 struct sigaction 结构体的指针,用于设置新的信号处理方式

在这里插入图片描述

  • oldact:用于保存旧的信号处理方式(可为 NULL,表示不保存)

示例:注册一个处理 SIGINT(Ctrl+C)的函数

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

void sigint_handler(int signo) {
    printf("收到信号 SIGINT,编号:%d\n", signo);
}

int main() {
    struct sigaction act;
    act.sa_handler = sigint_handler;  // 设置处理函数
    sigemptyset(&act.sa_mask);        // 清空阻塞集
    act.sa_flags = 0;                 // 无特殊标志

    // 注册信号处理
    if (sigaction(SIGINT, &act, NULL) == -1) {
        perror("sigaction");
        return 1;
    }

    printf("程序运行中,按 Ctrl+C 测试信号处理...\n");
    while (1) {
        sleep(1);
    }
    return 0;
}

6.可重入函数

在这里插入图片描述

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

如果一个函数,被重复进入的情况下,出错了,或者可能出错,就是不可重入函数;否则,叫做可重入函数

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

  • 调用了 mallocfree,因为 malloc 也是用全局链表来管理堆的

  • 调用了标准 I/O 库函数,标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构

目前我们学到的大部分函数都是不可重入的

7.volatile

int flag = 0;

void handler(int signo)
{
    cout << "catch a signal: " << signo << endl;
    flag = 1;
}

int main()
{
    signal(2, handler);
    while(!flag);

    cout << "process quit normal" << endl;
    return 0;
}

该代码正常情况下是正常输出 process quit normal 这句话的,对于 cpu 来说,有四种编译优化方式

优化级别 说明 特点 适用场景
-O0 无优化 编译速度最快,保留完整的调试信息,代码几乎不做变换 调试阶段,需要快速编译和精准调试时使用
-O1(或 -O) 基础优化 进行基本的优化,如精简指令、提升 CPU 流水线效率等,编译时间增加不明显 对性能有一定要求,但又不想过多牺牲编译时间的场景
-O2 中级优化(推荐默认) 在 -O1 基础上进一步优化,包含更多提升性能的策略,如充分使用寄存器、调整代码执行顺序等,编译时间有所增加 大多数生产环境,平衡性能和编译时间
-O3 高级优化 包含 -O2 的所有优化,还增加了循环展开、函数内联、向量指令优化等激进策略,编译时间较长,可能导致二进制文件变大 对性能要求极高的发布版本,如高性能计算、游戏引擎等

假如我们对该代码进行 O3 优化,那么 flag 的值在内存里并不会被改变,这是为啥?
在这里插入图片描述
由于优化,像 !flag 这样的逻辑运算会被直接优化放到 cpu 中计算,所以修改的是 cpu 里的副本,而不是内存里的 flag

volatile int flag = 0;

所以可以使用 volatile 防止过度优化


希望读者们多多三连支持

小编会继续更新

你们的鼓励就是我前进的动力!

请添加图片描述

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐