[操作系统] 信号

信号 vs IPC

板书最后提到了 "信号 vs IPC",暗示了信号也是一种进程间通信 (Inter-Process Communication, IPC) 的机制。虽然信号的主要目的是事件通知,但它也可以携带少量的信息(即信号的类型)。

初探"信号"------操作系统的"信使"

  1. 预备知识:拨开迷雾

在我们正式开始学习操作系统中的"信号"(Signal)之前,有几个概念需要先厘清:

  • 信号 vs 信号量 (Signal vs Semaphore) :请注意,虽然名字听起来有点像,但"信号"和"信号量"在操作系统中是完全不同的概念。就像"老婆"和"老婆饼"一样,它们之间没有任何直接关系。我们这部分将专注于"信号"。
  • 异步 (Asynchronous) vs 同步 (Synchronous)
    • 异步:指的是两个或多个事件或操作不是同时发生的,它们之间没有严格的时间依赖关系。比如,我们正在上课(一个操作),同时张三去取了个快递(另一个事件),这两个动作是独立进行的,上课不需要等待快递取回。
    • 同步 :指的是程序按照代码的顺序依次执行,每个操作必须等待前一个操作完成后才能开始。比如,我们决定自习一会儿,必须等张三回来后,大家再一起继续讲课。
  1. 生活中的"信号"与进程的"信号"

想象一下你的日常生活:

  • 闹钟响起,打断你的睡眠。
  • 路口的红绿灯,指示你停止或前行。
  • 上课铃声,通知你课程的开始或结束。
  • 手机铃声响起,告诉你有人来电。
  • 肚子咕咕叫,提醒你该吃饭了。
  • 有人敲门,告诉你门外有人。

这些生活中的"信号"有什么共同点?它们都在中断 你当前正在做的事情,并向你传递一个事件 已经发生的通知

现在,让我们把这个概念映射到操作系统中:人 就像 进程 (Process)

操作系统中的信号 ,就是一种**发送给进程的、用来进行事件异步****通知**的机制。它告诉进程,某个特定的事件发生了,需要进程注意或处理。

关键点

  • 信号是发给进程的。
  • 信号的产生,相对于进程当前正在执行的任务来说,是异步的。就像你正在专心看书时电话铃突然响起一样,电话(信号)的到来与你看书的进度无关。
  1. 关于信号的基本认知(重要结论)

在我们深入学习具体细节之前,先建立几个关于信号的基本认知:

  1. 预先定义的处理方式 :进程并非在收到信号时才临时决定如何应对。实际上,在信号产生之前,进程(由操作系统或程序开发者设计)就已经被告知内置了对于可能收到的各种信号应该如何处理的规则。就像我们从小被教育听到火警铃声要疏散一样。
  2. 处理时机的选择 :信号的处理不一定是即时、立刻马上执行的。进程可以在接收到信号后,不必立即中断当前最重要的任务,而是可以等待一个合适的时机再去处理这个信号。这有点像你收到一个非紧急邮件,可以先记下来,等忙完手头的事情再回复。
  3. 识别能力是内置的:人能识别各种信号(铃声、灯光等)是因为我们通过学习获得了这种能力。同样地,进程能够识别不同的信号,是因为操作系统的设计者和程序员在编写程序时,就已经赋予了进程这种识别和处理不同信号含义的能力。
  4. 信号来源的多样性 :如同生活中各种事件都能产生"信号"一样,在操作系统中,能够给进程发送信号的信号源 (Signal Source) 也非常多。它可以是来自用户的按键(如 Ctrl+C)、硬件的异常、其他进程的通知,或是操作系统内核本身。

小结

通过以上预备知识,我们对"信号"有了初步的印象:它是一种异步 的、发送给进程事件通知机制 。进程对信号的处理方式是预先定义 的,处理时机可以稍有延迟 ,并且进程拥有内置的识别能力 。同时,信号的来源是多种多样的。

带着这些基本概念,让我们接下来深入探索操作系统中信号的具体类型、产生方式、以及进程如何捕获和处理它们。


信号的产生

正如我们之前所说,信号是操作系统用来通知进程发生了某些事件的异步机制。那么,这些"信使"是如何被发送到进程手中的呢?产生信号的方式非常多,下面我们逐一进行详细讲解:

键盘产生信号

这是用户与操作系统交互时最直接产生信号的方式。当用户在终端操作时,按下特定的组合键,操作系统会将这些按键操作转化为相应的信号,并发送给当前正在运行的**前台进程**。

信号有哪些&基本概念

我们所经常使用的Ctrl + C就是通过键盘的组合键向进程发送信号,中断进程。不是有很多信号吗,那么Ctrl + C具体是发送哪一个呢?

bash 复制代码
kill -l // 查看所有信号

通过查询可以得知,信号实际上就是数字,宏定义的!

Ctrl + C 通常会发送 SIGINT 信号,其信号编号为 2,含义是"交互式注意信号"。这个信号通常被解释为用户希望中断当前正在运行的程序。


这么多的信号,要怎么进行记忆和学习?

实际上有相当一部分信号的默认处理动作都是让进程终止。并且我们学习的是前31个信号,34-64信号是实时信号,会在发送后接收后立即处理。


为什么又说是默认处理动作呢?

进程在收到信号后,会在合适的时候处理信号,而处理信号的动作有三种:

  1. 默认处理动作
  2. 自定义信号处理动作
  3. 忽略处理

大部分信号的默认处理动作是中断进程 。而有一部分进程支持自定义信号处理动作,也就是说可以自定义进程在接收信号后的行为,也就是自定义捕捉忽略处理就是不做任何处理。

信号具体的处理过程(signal函数)

c 复制代码
#include <signal.h>

typedef void (*sighandler_t)(int); // 函数指针类型

sighandler_t signal(int signum, sighandler_t handler);
  • 功能signal() 函数用于设置自定义信号处理程序,即当特定信号发生时,程序应该执行的函数。
  • 头文件#include <signal.h>
  • 原型void (*signal(int signum, void (*handler)(int)))(int);

参数:

  • signum要处理的信号的编号。
  • handler:指向信号处理函数的指针,或者是一些预定义的信号处理常量,用于自定义信号的处理函数。

返回值:

  • 如果成功,signal() 返回先前与指定信号关联的信号处理函数的指针。
  • 如果出错,返回 SIG_ERR

信号处理方式:

handler 参数可以设置为以下几种值:

  • **SIG_DFL**:使用信号的默认处理方式。
  • **SIG_IGN**:忽略该信号。
  • 用户定义的信号处理函数:当信号发生时,调用该函数。

示例:

c 复制代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void sigint_handler(int signum)  // signum 为实际接收到的信号
{
  printf("Caught signal %d, exiting...\n", signum);
  _exit(0);
}

int main() {
  signal(SIGINT, sigint_handler); // SIGINT 为要修改触发动作的信号

  while (1) {
    printf("Running...\n");
    sleep(1);
  }

  return 0;
}

当按下ctrl+c时就会触发sigint_handler这个信号处理函数,打印对应信息。

如此就可以把捕捉的信号处理方式改变,但是也有无法捕捉的信号,如9号信号,用来防止恶意进程占用前台

目标进程,前台进程,后台进程

程序运行进行信号读取或者发送时进行操作的目标进程只有一个shell命令行运行的就是前台进程,也就是所谓的目标进程。

后台进程不是目标进程,所以可以有多个后台进程。

为什么前台进程无法从键盘获取数据(标准输入)?

当执行一个程序时候,它成为了前台进程,运行时可以发现无法从键盘读取数据(除非程序设定)。

因为目标进程只能有一个 。当执行这个程序的时候,命令行提示符,也就是bash进程会被操作系统自动放到后台,所以通过键盘输入的时候没有命令行bash来读取。

后台进程,无法从标准输入中读取内容


那为什么可以使用键盘组合键?

键盘组合键产生的信号就是发送给前台进程的,所以可以使用。

前台进程,能从键盘获取标准输入,键盘只有一个,输入的数据一定是给一个确定的进程的。

所以:

  • 前台进程必须只有一个
  • 后台进程可以有多个

相关命令

  1. jobs:查看所有后台任务
  2. fg 任务号:将特定的任务提到前台
  3. ctrl + z:将当前进程切换到后台
  4. bg 任务号:让后台进程恢复运行
  5. &:通过在命令末尾添加 & 符号放到后台运行的进程

发送信号的本质是什么?

之前所述,信号产生后并不是立即处理,那么一定会存在一个数据结构将信号存储记录。

task_struct中有这类似成员变量:

c 复制代码
struct task_struct
{
    unsigned int sigs;
}

其中**sigs**为位图,比特位的位置为信号编号,内容为是否收到该信号。

task_struct作为操作系统内核数据结构,一切操作由操作系统执行,当发送信号,操作系统修改sigs位图,从而达到给进程发送信号的目的。

会提供系统调用供上层用户使用,如kill(),封装了系统调用kill

结论

向目标进程写信号就是修改位图!


系统调用产生信号

**1. **int kill(pid_t pid, int sig);

  • 名称 (NAME): kill
  • 作用: 向指定的进程发送一个信号。
  • 概要 (SYNOPSIS):
    • #include <signal.h>:需要包含 <signal.h> 头文件。
    • int kill(pid_t pid, int sig);:函数原型。
  • 参数:
    • pid_t pid:要发送信号的进程的进程ID(PID)。
    • int sig:要发送的信号的编号。
  • 说明:
    • kill 系统调用允许一个进程向另一个进程(或进程组)发送信号。
    • 通常用于终止进程(例如,SIGTERMSIGKILL),但也可以用于其他目的,例如通知进程发生特定事件。
    • 需要足够的权限才能向其他进程发送信号。
    • 用于终止进程,实际上还是发信号。kill函数并不是直接杀死进程,而是给进程发送一个能够终止进程的信号,接受到信号的进程才会进行终止。

**2. **void abort(void);

  • 名称 (NAME): abort
  • 作用: 导致程序异常终止。
  • 概要 (SYNOPSIS):
    • #include <stdlib.h>:需要包含 <stdlib.h> 头文件。
    • void abort(void);:函数原型。
  • 说明:
    • abort 函数会立即终止当前进程。
    • 它通常用于在检测到严重错误时强制程序退出。
    • abort 函数会发送 SIGABRT 信号给当前进程,这个信号默认行为就是终止进程。
    • 关于将发送的信号线回复正常,取消自定义,然后再发送,说明abort会重置SIGABRT信号的自定义处理方式,然后发送SIGABRT信号,从而保证进程能够终止
c 复制代码
#include <stdio.h>  // 包含标准输入输出库,用于 printf
#include <stdlib.h> // 包含标准库,其中定义了 abort() 函数

int main() {
    printf("程序开始运行。\n"); // Program starts running.

    // 模拟一个临界错误情况:例如,一个重要的资源指针为空
    // 在实际程序中,这可能是 malloc 失败,或者其他原因导致一个本应有效的指针变成了 NULL
    int* critical_resource = NULL;

    printf("正在检查临界资源...\n"); // Checking critical resource...

    // 检查是否存在无法处理的严重错误
    if (critical_resource == NULL) {
        printf("错误:临界资源为NULL,这是一个无法恢复的错误!即将调用 abort() 终止程序。\n"); // Error: Critical resource is NULL, this is an unrecoverable error! About to call abort() to terminate the program.

        //  调用 abort() 函数 
        // 当 abort() 被调用时,程序会立即异常终止
        abort();

        // 注意:下面的这行代码永远不会被执行到,因为程序已经在上面终止了
        printf("这行代码在调用 abort() 后不会被打印出来。\n"); // This line of code will not be printed after calling abort().

    } else {
        // 如果临界资源有效(在这个例子中不会发生),程序会继续
        printf("临界资源检查通过。\n"); // Critical resource check passed.
        // ... 实际程序中会在这里处理资源 ...
    }

    // 这行代码只会在上面 if 条件为 false (即 abort() 没有被调用) 时执行
    printf("程序正常结束。(如果你看到这行,说明前面的 abort() 没有被触发)\n"); // Program ends normally. (If you see this line, it means the preceding abort() was not triggered)

    return 0; // 正常退出,但在本例中如果 abort() 被调用,return 0 不会被达到
}

**3. **int raise(int sig);

  • 名称 (NAME): raise
  • 作用:当前进程发送一个信号。
  • 概要 (SYNOPSIS):
    • #include <signal.h>:需要包含 <signal.h> 头文件。
    • int raise(int sig);:函数原型。
  • 参数:
    • int sig:要发送的信号的编号。
  • 说明:
    • raise 函数允许一个进程向自身发送信号。
    • 它通常用于模拟信号的发生,或者在程序内部处理信号。
    • kill 不同,raise 只影响调用它的进程本身。

总结

  • kill 用于向其他进程发送信号。
  • abort 用于立即终止当前进程。
  • raise 用于向当前进程发送信号。

调用系统命令向进程发送信号

bash中使用指令

硬件异常产生信号

效果:程序崩溃

无效内存访问 (野指针)

当程序试图访问一个未分配或不允许访问的内存地址时,CPU会产生一个硬件异常。例如,试图读取或写入一个野指针指向的内存区域。

硬件异常会发送11号信号,段错误。

代码示例:

cpp 复制代码
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>

void handle_sigsegv(int signum) {
    printf("Caught SIGSEGV (Invalid memory access)!\n");
    exit(1);
}

int main() {
    signal(SIGSEGV, handle_sigsegv); // 设置信号处理函数

    int *ptr = NULL;
    *ptr = 10; // 无效内存访问

    printf("Value: %d\n", *ptr); // 这行代码不会被执行

    return 0;
}

结果:

cpp 复制代码
[fz@VM-20-14-centos 信号]$ ./a.out 
Caught SIGSEGV (Invalid memory access)!

说明硬件异常后发送信号,触发自定义行为。

除零错误 (Division by zero)

当程序试图将一个数除以零时,CPU会产生一个硬件异常,因为这是非法的数学运算。

触发过程:

  • 当程序执行除法运算时,如果除数为零,CPU的算术逻辑单元(ALU)会检测到这个错误。
  • CPU无法执行除以零的运算,因此会产生一个硬件异常,通常是SIGFPE(浮点异常)信号。
  • CPU将控制权交给操作系统,操作系统会处理这个异常,并可能向程序发送SIGFPE信号。

示例代码:

cpp 复制代码
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>

void handle_sigfpe(int signum) {
    printf("Caught SIGFPE (Division by zero)!\n");
    exit(1);
}

int main() {
    signal(SIGFPE, handle_sigfpe); // 设置信号处理函数

    int a = 10;
    int b = 0;
    int result = a / b; // 除零错误

    printf("Result: %d\n", result); // 这行代码不会被执行

    return 0;
}

结果:

cpp 复制代码
[fz@VM-20-14-centos 信号]$ ./a.out 
Caught SIGFPE (Division by zero)!

除零错误后触发硬件异常,发送信号,触发自定义行为。

关键点:

  • 硬件异常由CPU或MMU等硬件单元检测并触发。
  • 操作系统是硬件和软件之间的桥梁,负责处理硬件异常,并将其转化为信号。
  • 处理过程小结:
cpp 复制代码
程序访问非法地址 → MMU 地址转换失败 → CPU 异常 → EFLAGS 设置 → 操作系统介入
→ 通过 current->task_struct 获取当前进程 → 给进程发送信号 → 信号处理函数 or 杀死进程
  • 访问变量时实际上是访问虚拟地址,所以:
cpp 复制代码
用户程序访问虚拟地址
     ↓
MMU 尝试转换 → 失败
     ↓
CPU 触发 Page Fault 异常
     ↓
操作系统接管 → 保存现场
     ↓
读取进程上下文 current->task_struct
     ↓
发送信号给进程(如 SIGSEGV)
     ↓
进程终止 or 执行信号处理函数

所以:无论是什么信号,都是由操作系统发送的

软件条件产生的信号

这意味着信号的产生不是由于硬件故障或外部事件,而是由于操作系统内部的状态变化或特定的软件操作而触发的。

常见的软件条件产生的信号

重点介绍两种由软件条件产生的信号:SIGPIPESIGALRM

1. SIGPIPE 信号

简单来说,当一个进程尝试向一个已经关闭的管道的写入端写入数据时,操作系统会向该进程发送 SIGPIPE 信号。这通常表明管道的读取端已经关闭,继续写入数据已经没有意义。**SIGPIPE**** 信号的默认行为是终止进程**。

2. SIGALRM 信号和 alarm 函数

SIGALRM 信号,它与 alarm 函数密切相关。


**alarm**** 函数的作用:**

alarm 函数允许进程设置一个闹钟 。当设定的时间到达后,操作系统会向该进程发送 SIGALRM 信号。


alarm** 函数的原型和返回值:**

c 复制代码
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
  • **参数 **seconds: 指定了在多少秒之后发送 SIGALRM 信号。
  • 返回值 :
    • 如果之前没有设置过闹钟,则返回 0。
    • 如果之前设置过闹钟,但新的闹钟时间会覆盖之前的设置,则返回之前闹钟剩余的时间(以秒为单位)。
    • 如果 seconds 的值为 0,则会取消之前设置的闹钟,并返回之前闹钟剩余的时间。

SIGALRM** 信号的默认行为:**

SIGALRM 信号的默认处理动作是终止当前进程


基本 alarm 验证 - 体会 IO 效率问题

两个简单的示例代码,用于演示 alarm 函数和 SIGALRM 信号的基本使用,强调了 I/O 操作对程序效率的影响。

  • IO 多 (效率低) 这个程序在一个无限循环中不断地打印 "count : " 和当前的计数值。程序开始时调用 alarm(1) 设置了一个 1 秒后的闹钟。由于 SIGALRM 的默认行为是终止进程,所以程序会在运行大约 1 秒后被终止。
cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>
int main()
{
    int count = 0;
    alarm(1);
    while(true)
    {
        std::cout << "count : "
                  << count << std::endl;
        count++;
    }
    return 0;
}

运行结果显示,在 1 秒内,程序执行了大量的 std::cout 操作,但计数值相对较小。这说明频繁的 I/O 操作会显著降低程序的执行效率。

  • IO 少 (效率高) 这个程序与上一个类似,但是它捕获了 SIGALRM 信号,并在信号处理函数中打印最终的计数值,然后退出。
cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>
int count = 0;
void handler(int signumber)
{
    std::cout << "count : " << count << std::endl;
    exit(0);
}
int main()
{
    signal(SIGALRM, handler);
    alarm(1);
    while (true)
    {
        count++;
    }
    return 0;
}

运行结果显示,在 1 秒内,count 的值远大于前一个程序。这是因为这个程序在循环中只进行了简单的自增操作,避免了大量的 I/O 操作,因此效率更高。

结论:

  • alarm 函数设置的闹钟会响一次,并且默认会终止进程。
  • 频繁的 I/O 操作会显著降低程序的执行效率。
3. 设置重复闹钟

alarm 函数设置的闹钟是一次性的,超时后会自动被取消。如果需要周期性地执行某个任务,可以利用信号处理函数来重新设置闹钟。

设置重复闹钟的示例:

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <vector>
#include <functional>

int gcount = 0;

void hanlder(int signo)
{
    for(auto &f : gfuncs)
    {
        f();
    }
    std::cout << "gcount : " << gcount << std::endl;
    int n = alarm(1); // 重设闹钟,会返回上一次闹钟的剩余时间
    std::cout << "剩余时间 : " << n << std::endl;
}

int main()
{
    alarm(1); // 一次性的闹钟,超时alarm会自动被取消
    signal(SIGALRM, hanlder);
    while (true)
    {
        pause();
        std::cout << "我醒来了..." << std::endl;
        gcount++;
    }
}

在这个例子中:

  1. 程序首先使用 signal(SIGALRM, hanlder) 注册了 SIGALRM 信号的处理函数 hanlder
  2. 然后在 main 函数中调用 alarm(1) 设置了一个 1 秒后的闹钟。
  3. 程序进入一个无限循环,并调用 pause() 函数。pause() 函数会使进程进入睡眠状态,直到接收到一个信号。
  4. 当 1 秒后闹钟到期,操作系统会向进程发送 SIGALRM 信号,进程被唤醒,执行 hanlder 函数。
  5. hanlder 函数中,会执行一些预定义的回调函数(被注释掉了),打印 gcount 的值,然后再次调用 alarm(1) 重新设置了一个 1 秒后的闹钟
  6. hanlder 函数执行完毕后返回,pause() 函数也会返回(返回值为 -1,errno 被设置为 EINTR),然后程序继续执行循环中的 std::cout << "我醒来了..." << std::endl;gcount++;

通过在信号处理函数中再次调用 alarm,就实现了每隔 1 秒执行一次特定操作的效果,从而创建了一个重复的闹钟。

pause** 函数**

c 复制代码
#include <unistd.h>
int pause(void);
  • 作用 : pause() 函数会使调用它的进程(或线程)睡眠,直到接收到一个信号,该信号要么终止进程,要么调用一个信号处理函数。
  • 返回值 : pause() 只有在信号被捕获并且信号处理函数返回时才会返回。在这种情况下,pause() 返回 -1,并且 errno 被设置为 EINTR

联系操作系统:

循环的闹钟不就是操作系统吗。

操作系统从开机的那一刻开始,作为一个软件程序,一直处于死循环状态,每一次循环都会进行内核刷新、进程时间片处理、内存管理等操作。

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <vector>
#include <functional>
using func_t = std::function<void()>;
int gcount = 0;
std::vector<func_t> gfuncs;

void hanlder(int signo)
{
    for(auto &f : gfuncs)
    {
        f();
    }
    std::cout << "gcount : " << gcount << std::endl;
    int n = alarm(1); // 重设闹钟,会返回上一次闹钟的剩余时间
    std::cout << "剩余时间 : " << n << std::endl;
}

int main()
{
    gfuncs.push_back([](){ std::cout << "我是⼀个内核刷新操作" << std::endl; });
    gfuncs.push_back([](){ std::cout << "我是⼀个检测进程时间⽚的操作,如果时间⽚到了,我会切换进程" << std::endl; });
    gfuncs.push_back([](){ std::cout << "我是⼀个内存管理操作,定期清理操作系统内部的内存碎⽚" << std::endl; });
    alarm(1); // 一次性的闹钟,超时alarm会自动被取消
    signal(SIGALRM, hanlder);
    while (true)
    {
        pause();
        std::cout << "我醒来了..." << std::endl;
        gcount++;
    }
}

结论:

  • alarm 设置的闹钟只会生效一次。
  • 可以通过在信号处理函数中再次调用 alarm 来实现重复闹钟的效果。
  • 可以使用 alarm(0) 来取消之前设置的闹钟。

如何简单快速理解系统闹钟

从操作系统层面解释系统闹钟的实现原理:

实现闹钟这样的技术,本质上是操作系统必+须自身具有定时功能,并且能够让用户设置这种定时功能。

现代 Linux 提供了定时功能,操作系统内会存在多个闹钟。内核需要管理这些定时器,所以存在数据结构来管理闹钟。先描述,后组织!

内核中使用 timer_list 结构来描述定时器:

c 复制代码
struct timer_list {
    struct list_head entry; // 链表管理所有的闹钟
    unsigned long expires;      // 定时器超时时间
    void (*function)(unsigned long); // 超时后执行的处理方法
    unsigned long data;
    struct tvec_t_base_s *base;
};

timer_list 结构中包含了定时器超时的时间 (expires) 和超时后需要执行的处理方法 (function)。

操作系统管理定时器通常采用时间轮 这种数据结构,但为了简单理解,可以将其组织成堆结构。这两种方式都是为了高效地管理大量的定时器,并在设定的时间到达时触发相应的操作。


怎么理解这个堆结构呢?

这个·堆结构应该是一个最小堆。expires作为超时的时间,比如有一个进程的expires是1005,而当前操作系统的启动时间为1000,也就是说再过5秒就会轮到这个进程。而这个进程又是现有的进程中expires最小的,也就是说会是最早启动的进程,所以会存在于堆顶,当系统运行时间到1005的时候就会触发闹钟,执行超时后执行的处理方法void (*function)(unsigned long)

如何理解软件条件

在操作系统中,信号的软件条件指的是由软件内部状态或特定软件操作触发的信号产生机制。这些条件包括但不限于:

  • 定时器超时 : 例如 alarm 函数设定的时间到达,触发 SIGALRM 信号。
  • 软件异常 : 例如向已关闭的管道写入数据,产生 SIGPIPE 信号。

当这些软件条件满足时,操作系统会向相关的进程发送相应的信号,以通知进程进行相应的处理。简单来说,软件条件是由操作系统内部或外部软件操作而触发的信号产生。

信号的产生总结

所以不论产生信号的原因是什么,所有的信号都是由操作系统进行发送,即便是用户主动发送,那也是使用的上层的接口,实际上还是操作系统来修改对应的比特位来实现信号的产生。

细节补充:核心转储

核心转储 (Core Dump) 是什么?

  1. 定义:当一个进程异常终止时(例如收到了某些特定的信号,如段错误 SIGSEGV、浮点异常 SIGFPE 等),操作系统可以将该进程在内存中的状态(核心映像)完整地复制(转储)到一个文件中。这个文件就被称为 Core Dump 文件。
  2. 内容:这个文件包含了进程崩溃那一刻的内存布局、寄存器状态、堆栈信息等。它就像是程序死亡瞬间的一张"快照"。
  3. 位置:生成的 Core Dump 文件通常保存在进程崩溃时所在的当前工作目录下。
  4. 命名 :文件名可能根据系统有所不同, 可能有centos7: core.pid (pid 是进程ID) 或 core.1234,以及 ubuntu: core.XX 等形式。

为什么会发生核心转储?

  • 当进程收到某些信号(如 SIGQUIT, SIGILL, SIGABRT, SIGFPE, SIGSEGV 等)且没有自定义处理这些信号时,操作系统的默认行为就是终止该进程并产生一个 Core Dump 文件。
  • 命令行示例 Floating point exception (core dumped) 就是一个典型的例子,程序因为浮点数运算错误收到了 SIGFPE 信号,进而触发了 Core Dump。

Core Dump 的用途是什么?

  • 事后调试 (Post-mortem Debugging) :这是 Core Dump 最主要的用途。程序崩溃后,开发者可以使用调试工具(如 GDB)加载 Core Dump 文件和对应的程序可执行文件。
    • 命令类似:gdb <executable_file> <core_file>·
    • 这样可以在程序已经结束后,回溯检查崩溃时的变量值、函数调用栈等信息,帮助定位导致程序崩溃的根本原因(例如,是哪一行代码出了问题)。

如何控制 Core Dump 的生成?

  1. 资源限制:Core Dump 文件可能会很大,系统通常通过资源限制 (resource limits) 来控制是否生成以及允许生成的最大大小。
  2. **ulimit**** 命令**:使用 ulimit -a 命令查看所有资源限制,其中 core file size 就是控制 Core Dump 文件大小的参数。
    • core file size (blocks, -c) 0:表示不允许生成 Core Dump 文件(大小限制为 0)。这就是为什么很少见过coredump文件,因为默认可能是关闭的。特别在云服务器上会关闭,因为云服务器是生产环境,如果一个项目在运行的实际一直生成Core Dump文件,可能会造成磁盘爆满。
    • ulimit -c unlimited:这个命令可以解除大小限制,允许生成 Core Dump 文件。先查看限制为 0,然后使用 ulimit -c unlimited 打开限制,再次查看就变成了 unlimited
  3. 开启 Core Dump 的流程 :要使用 Core Dump 进行调试,一般需要:
    • 使用 ulimit -c unlimited (或设置一个足够大的值) 开启 Core Dump 功能。
    • 运行程序直到它崩溃并生成 Core Dump 文件。
    • 使用 GDB 加载程序和 Core Dump 文件进行分析。

总结来说,Core Dump 是操作系统在程序异常崩溃时记录其内存状态的一种机制,主要目的是为了方便开发者进行事后调试,找出程序崩溃的原因。它的生成受到系统资源限制的控制,可以使用 ulimit 命令来管理。

信号的保存

概念补充

信号处理机制

  • **信号递达(Delivery):**实际实行信号的处理动作称为信号递达。(自定义、默认、忽略)
  • 信号未决(Pending):信号从产生到递达之间的状态称为信号未决。此时信号已经产生,但尚未被进程接收处理,处于等待状态。也就是信号在位图中已经添加,但是还没来得及处理。
  • 信号阻塞 | 屏蔽(Block):进程可以选择阻塞(屏蔽)某个信号。当信号被阻塞时,即使该信号产生,也不会立即递达进程,而是保持在未决状态。直到进程解除对该信号的阻塞,信号才会继续执行递达的动作。
  • 信号忽略(Ignore):忽略是在信号递达之后可选的一种处理动作。与阻塞不同,忽略不是阻止信号递达,而是允许信号递达后,进程选择不对其进行处理,相当于对信号的响应是"不做任何操作"。

阻塞与忽略的区别

  • 阻塞:阻止信号递达,信号产生后不会被进程感知,处于未决状态,直到解除阻塞。
  • 忽略:允许信号递达,但进程不对信号进行处理,信号已经到达进程,只是进程选择不响应。

信号在内核中示意图

在进程PCB中有三个表:

  • **block**(阻塞)unsigned int block位图,比特位位置表示第几个信号,内容表示是否阻塞。
  • **pending**(未决): unsigned int pending位图,比特位表示第几个信号,内容表示是否收到该信号。
  • **handler** **sighandler_t handler[31]****,**函数指针数组,下标索引对应信号编号,每个函数指针也就是之前所讲述的typedef void (*sighandler_t)(int),当信号递达时执行对应的下标的函数动作。

例如提前阻塞SIGINT,此时blockSIGINT对应的位置数值改为1。然后发送SIGINT信号,pendingSIGINT对应的位置数值改为1,但是不会被递达,因为SIGINT被阻塞!

再举例,可以提前将一个进程PCB中的handler中各个信号对应的递达后操作函数的实现改变,然后当之后发送对应信号递达后就会执行提前设定好的信号。

所以,三个表互相独立,处理函数可以提前设定,并且也说明了为什么信号不会立即触发。


内核代码:

c 复制代码
// 内核结构 2.6.18
struct task_struct {
    // 其他进程控制块字段
    // 信号处理相关字段
    struct sighand_struct *sighand; // 指向信号处理结构的指针
    sigset_t blocked;        // 被阻塞的信号集
    struct sigpending pending;    // 未决信号集
    // 其他进程控制块字段
};

// 信号处理结构体
struct sighand_struct {
    atomic_t count;          // 原子计数器,用于同步访问
    struct k_sigaction action[_NSIG]; // 信号处理动作数组,_NSIG 定义了信号的数量
    spinlock_t siglock;       // 自旋锁,用于保护信号处理结构的一致性
};

// 新的信号处理动作结构体
struct __new_sigaction {
    __sighandler_t sa_handler;  // 用户定义的信号处理函数
    unsigned long sa_flags;     // 信号处理标志
    void (*sa_restorer)(void); // 用于恢复信号处理状态的回调函数,Linux/SPARC未使用
    __new_sigset_t sa_mask;    // 信号集掩码,用于屏蔽不需要处理的信号
};

// 内核信号动作结构体
struct k_sigaction {
    struct __new_sigaction sa;  // 新的信号处理动作结构
    void __user *ka_restorer;    // 用户定义的恢复函数指针
};

// 信号处理函数类型定义
typedef void (*__sighandler_t)(int); // 定义信号处理函数的类型

如果在进程接触对某个信号的阻塞之前这个信号产生过很多次,那该如何处理?

  • 常规信号在递达之前如果产生过很多次,总共只记一次。
  • 实时信号在递达前产生的多次可以放在一个队列里,咱不做详细讲解。

之后的关于存储的知识点将以这三张表为基础。

位图与sigset_t

位图(Bitmap)的结构

想象你有一排开关,每个开关可以处于两种状态:开(ON)或关(OFF)。我们可以用数字 1 来表示开,用 0 来表示关。

位图(或者更准确地说是位掩码,Bitmask)就是这样一种思想的延伸。它使用一个或多个连续的比特位(0 或 1)来表示一组离散的项或者状态。

  • 基本单元:比特位 (Bit):位图的最小单位是比特位,每个比特位只能存储 0 或 1 两种状态。
  • 组合形成掩码 (Mask) :多个比特位可以组合在一起形成一个"掩码"。这个掩码通常存储在一个整数类型的数据中(比如 char, int, long 等)。
  • 表示集合或状态:假设我们有 N 个不同的项,我们可以分配给每个项一个唯一的比特位。如果某个比特位被设置为 1,就表示对应的项存在或处于某种"真"的状态;如果比特位是 0,则表示该项不存在或处于"假"的状态。

举个简单的例子:

假设我们有 8 个不同的权限,我们可以用一个 8 位的二进制数(例如一个 unsigned char)来表示哪些权限被启用。

比特位位置(从右往左,从 0 开始) 代表的权限
0 读权限
1 写权限
2 执行权限
3 删除权限
4 修改权限
5 查看权限
6 管理员权限
7 特殊权限

那么,如果一个字节的值是 00000110(二进制),它就表示写权限(第 1 位为 1)和执行权限(第 2 位为 1)被启用,而其他权限没有启用。

位图的优点:

  • 空间效率高:使用少量的内存就可以表示大量的状态或项。
  • 操作高效:可以使用位运算(如与、或、非、异或、移位等)来快速地设置、清除、检查某个特定项的状态。

sigset_t 与位图

现在,我们将上面讲解的位图概念应用到 sigset_t 上。

在 POSIX 系统中,存在着多种不同的信号(例如 SIGINTSIGTERMSIGKILL 等),每种信号都有一个唯一的整数编号。sigset_t 的作用就是用来表示一组信号的集合。

sigset_t** 本质上就是一个位图。** 操作系统使用 sigset_t 类型的变量来跟踪哪些信号需要被阻塞(暂时忽略)或等待。

  • 每个比特位代表一个信号sigset_t 内部使用一个或多个整数来存储比特位。每一个比特位的位置都对应着一个特定的信号编号。
  • 比特位的值表示信号是否在集合中 :如果 sigset_t 中的某个比特位被设置为 1,则表示该比特位对应的信号是这个信号集的成员;如果该比特位是 0,则表示该信号不在这个集合中。

可能的 **sigset_t** 的实现方式 ,使用一个名为 bitmap 的整数数组:

c 复制代码
struct bits
{
    int bitmap[10]; // 32 * 10 = 320
};

现在,让我们来看一下信号编号是如何映射到这个 bitmap 数组中的特定比特位的:

c 复制代码
int index = 39 / 32 = 1 --- bitmap[1]
int pos = 39 % 32 = 7 --- bitmap[1]第7个比特位

假设我们要表示信号编号为 39 的信号是否在信号集中:

  1. 计算数组索引 ( **index**): 信号编号除以每个整数所占的比特数(这里是 32)。结果的整数部分就是该信号对应的 bitmap 数组的索引。
    • 39 / 32 = 1。这表示信号 39 的信息存储在 bitmap 数组的第二个元素中(索引为 1,因为数组索引从 0 开始)。
  2. 计算比特位位置 ( **pos**): 信号编号对每个整数所占的比特数取模。结果就是该信号在对应整数中的比特位偏移量。
    • 39 % 32 = 7。这表示信号 39 对应于 bitmap[1] 这个整数的第 7 个比特位(通常从 0 开始计数)。

可以将 bitmap 数组想象成一个连续的比特序列。bitmap[0] 存储着信号 0 到 31 的状态(第 0 到 31 位),bitmap[1] 存储着信号 32 到 63 的状态(第 32 到 63 位),以此类推,直到 bitmap[9] 存储着信号 288 到 319 的状态。

当我们需要判断某个信号(比如信号 39)是否在信号集中时,我们会:

  1. 找到它对应的数组元素索引(通过除以 32)。
  2. 找到它在该数组元素中的比特位位置(通过对 32 取模)。
  3. 检查该比特位的值是 1 还是 0。如果是 1,则表示该信号在信号集中;如果是 0,则表示不在。

因此, sigset_t** 就像一个"信号开关盒",每个开关(比特位)对应一个特定的信号。当我们需要阻塞某些信号或者等待某些信号时,我们就可以通过操作这个"开关盒"中相应的开关来实现。**

操作系统提供的 sigemptyset(), sigfillset(), sigaddset(), sigdelset(), sigismember() 等函数,实际上就是在操作 sigset_t 内部的这些比特位,将上述的三个表进行管理和使用,从而方便我们管理信号集合,而无需直接关心底层的位操作细节。

信号集操作函数

核心概念: sigset_t

  • sigset_t 类型用于表示一组信号的集合。
  • 对于每种信号,sigset_t 内部使用一个比特位来表示该信号的"有效"(在集合中)或"无效"(不在集合中)状态。
  • sigset_t 类型的内部存储结构依赖于系统实现,使用者无需关心。
  • 重要原则: 只能通过提供的特定函数来操作 sigset_t 变量,不应该直接对其内部数据做任何解释或操作(例如,直接使用 printf 打印 sigset_t 变量是无意义的)。

头文件:

c 复制代码
#include <signal.h>

信号集操作函数:

  1. int sigemptyset(sigset_t *set);
    • 功能: 初始化 set 所指向的信号集,将其中所有信号对应的比特位清零,表示该信号集不包含任何有效信号(空集)。
    • 返回值: 成功返回 0,出错返回 -1。
  2. int sigfillset(sigset_t *set);
    • 功能: 初始化 set 所指向的信号集,将其中所有信号对应的比特位置位,表示该信号集的有效信号包括系统支持的所有信号。
    • 返回值: 成功返回 0,出错返回 -1。
  3. int sigaddset(sigset_t *set, int signo);
    • 功能: 将指定的信号 signo 添加到 set 所指向的信号集中,即将对应信号的比特位置位。
    • 返回值: 成功返回 0,出错返回 -1。
  4. int sigdelset(sigset_t *set, int signo);
    • 功能:set 所指向的信号集中移除指定的信号 signo,即将对应信号的比特位清零。
    • 返回值: 成功返回 0,出错返回 -1。
  5. int sigismember(const sigset_t *set, int signo);
    • 功能: 判断指定的信号 signo 是否是 set 所指向的信号集的成员,即检查对应信号的比特位是否被置位。
    • 返回值: 若包含该信号则返回 1,不包含则返回 0,出错返回 -1。

重要注意事项:

  • 在使用 sigset_t 类型的变量之前,必须 调用 sigemptysetsigfillset 进行初始化,以确保信号集处于确定的状态。
  • 初始化之后,可以使用 sigaddsetsigdelset 函数在信号集中添加或删除特定的信号。

进程信号屏蔽字操作函数:

  1. int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
    • 功能: 读取或更改进程的信号屏蔽字(阻塞信号集)。
    • 返回值: 成功返回 0,出错返回 -1。
    • 参数:
      • how: 指示如何更改信号屏蔽字。
      • set: 指向包含新信号屏蔽字的 sigset_t 变量的指针。如果为 NULL,则不更改信号屏蔽字。
      • oset: 指向用于存储原始信号屏蔽字的 sigset_t 变量的指针。如果为 NULL,则不保存原始信号屏蔽字。
    • 参数 how 的可选值(根据常见用法):
      • SIG_BLOCK: 将 set 中指定的信号添加到当前信号屏蔽字中(相当于执行 mask = mask | set)。
      • SIG_UNBLOCK: 将 set 中指定的信号从当前信号屏蔽字中移除(相当于执行 mask = mask & ~set)。
      • SIG_SETMASK: 将当前信号屏蔽字设置为 set 所指向的信号集(相当于执行 mask = set)。
    • 行为:
      • 如果 oset 是非空指针,则将进程当前的信号屏蔽字通过 oset 参数传出。
      • 如果 set 是非空指针 ,则根据 how 参数和 set 的内容更改进程的信号屏蔽字。
      • 如果 osetset 都是非空指针,则先将原来的信号屏蔽字备份到 oset 中,然后根据 sethow 参数更改信号屏蔽字。
    • 重要特性: 如果调用 sigprocmask 解除了对当前若干个未决信号的阻塞,则在 sigprocmask 返回前,至少会将其中一个信号递达给进程。

获取未决信号集函数:

int sigpending(sigset_t *set);

复制代码
- **功能:** 读取当前进程的未决信号集,并通过 `set` 参数传出。未决信号是指已经产生但由于进程信号屏蔽字的设置而被阻塞而尚未递达的信号。
- **返回值:** 调用成功则返回 0,出错则返回 -1。

综合代码示例:

c 复制代码
#include <stdio.h> 
#include <signal.h>     // 包含信号处理相关的函数和数据结构,如 signal, sigset_t, sigemptyset, sigaddset, sigprocmask, sigpending, sigismember, SIGINT
#include <iostream>     // 包含输入输出流库,用于 std::cout 和 std::endl
#include <unistd.h>     // 包含 UNIX 标准函数库,用于 getpid 和 sleep 函数

// 函数用于打印当前进程的挂起信号集
void PrintPending(sigset_t &pending)
{
    printf("我是一个进程(%d), pending: ", getpid()); // 打印当前进程的 ID
    for (int signo = 31; signo >= 1; signo--)      // 遍历所有可能的信号编号(从 31 到 1)
    {
        if (sigismember(&pending, signo)) // 检查信号 'signo' 是否是挂起信号集中的成员
        {
            std::cout << "1"; // 如果是,打印 '1'
        }
        else
        {
            std::cout << "0"; // 如果不是,打印 '0'
        }
    }
    std::cout << std::endl; // 打印换行符
}

// 信号处理函数,用于处理 SIGINT 信号 (Ctrl+C)
void handler(int sig)
{
    std::cout << "#######################" << std::endl;
    std::cout << "递达" << sig << "信号!" << std::endl; // 表明信号 'sig' 已经被接收(递达)
    sigset_t pending;                             // 声明一个信号集,用于存储挂起信号
    int m = sigpending(&pending);                 // 获取当前进程的挂起信号集
    (void)m;                                     // 将 m 强制转换为 void 类型,以消除可能出现的未使用变量警告
    PrintPending(pending);                        // 打印挂起信号集
    // 下面的注释解释了在此处观察到的输出:
    // 0000 0010 (如果信号被阻塞,并且刚刚被递达,它可能在被清除之前短暂地出现在挂起状态中)
    // 0000 0000 (如果信号被阻塞,然后解除阻塞并递达,在处理函数运行时,该信号的挂起状态可能已经被清除)
    std::cout << "#######################" << std::endl;
}

int main()
{
    signal(SIGINT, handler); // 注册 'handler' 函数,使其在接收到 SIGINT 信号时被调用

    // 1. 屏蔽 SIGINT 信号
    sigset_t block, oblock; // 'block' 用于存储需要屏蔽的信号集,'oblock' 用于存储原始的信号掩码,用于后续恢复
    sigemptyset(&block);  // 将 'block' 初始化为空集
    sigemptyset(&oblock); // 将 'oblock' 初始化为空集

    sigaddset(&block, SIGINT); // 将 SIGINT 信号(信号编号为 2)添加到 'block' 集合中。
                               // 这意味着我们打算屏蔽这个信号。

    // 下面被注释掉的循环会屏蔽所有编号从 1 到 31 的信号。
    // for(int i = 1; i<32; i++)
    //     sigaddset(&block, i);

    int n = sigprocmask(SIG_SETMASK, &block, &oblock); // 设置进程的信号掩码为 'block'。
                                                       // SIG_SETMASK 表示 'block' 中的信号将被屏蔽。
                                                       // 'oblock' 将存储之前'block'的信号掩码。
    (void)n;    // 将 n 强制转换为 void 类型,以消除可能出现的未使用变量警告

    // 4. 重复获取并打印挂起信号集
    int cnt = 0;
    while (true)
    {
        // 2. 获取挂起信号集
        sigset_t pending; // 声明一个信号集,用于存储挂起信号
        int m = sigpending(&pending); // 获取当前进程的挂起信号集
        (void)m;                     // 将 m 强制转换为 void 类型,以消除可能出现的未使用变量警告

        // 3. 打印挂起信号
        PrintPending(pending);

        if (cnt == 10)
        {
            // 5. 恢复原始的信号掩码,有效地解除对 SIGINT 的屏蔽
            std::cout << "解除对2号的屏蔽" << std::endl;
            sigprocmask(SIG_SETMASK, &oblock, nullptr); // 将进程的信号掩码恢复为存储在 'oblock' 中的原始掩码。
                                                         // 这将允许在 SIGINT 信号处于挂起状态时将其递达。
        }

        sleep(1); // 暂停执行 1 秒
        cnt++;    // 增加计数器
    }

    return 0;
}

当运行程序,先屏蔽SIGINT信号,然后开始打印,在打印时如果向进程发送SIGINT信号,会在打印的时候显示在pending中的修改结果,但不会直接递达,因为SIGINT被屏蔽了。当打印结束后,将屏蔽的SIGINT取消屏蔽,这样就会递达,触发自定义的信号处理函数handler

信号的捕捉(处理)

信号捕捉流程

一直灌输的是,信号的处理不是立即处理,而是在合适的时候进行处理。

那么这个合适的时候是什么时候?

信号递达方式有三种,每一种在处理的时候具体是怎样的?

当程序代码中遇到中断、异常、系统调用的时候就会进入内核,进入内核后将异常处理完成后会先将当前进程中可以递达的信号给处理,然后返回用户态,然后执行程序的运行会在用户态和内核态之间切换。

用户态 (User Mode):

  1. 在执行主控制流程的某条指令时,因为中断、异常或系统调用进入内核: 程序在正常执行其 main 函数中的指令,处于用户态。在执行过程中,由于发生了外部中断(例如硬件设备发出的信号)、程序内部的异常(例如除零错误)或者程序主动发起的系统调用(例如读写文件),程序的执行会从用户态切换到内核态,以便操作系统进行相应的处理。

内核态 (Kernel Mode):

  1. 内核处理完毕异常,准备返回用户模式之前,先处理当前进程中可以递送的信号: 当内核完成对中断、异常或系统调用的处理,即将返回到用户态继续执行进程之前,会检查当前进程是否有待处理(可以递送)的信号。这些信号可能在进程处于内核态时产生并被记录下来。
  2. **do_signal()**: 这是一个内核函数,负责实际的信号递达过程,通过查看pending表来判断应该处理哪些信号。以下操作都是由它完成:
    1. 如果信号处理动作是忽略: do_signal() 会直接丢弃或忽略这个信号 。
    2. 如果信号信号处理动作是默认: do_signal()(或内核中负责信号处理的相应部分)会执行与该特定信号关联的默认行为。
    3. 如果信号的处理动作是用户自定义的信号处理函数,则控制流跳转到用户模式执行信号处理函数(而不是回到主控制流程): 如果被递达的信号设了用户自定义的处理函数(通过 signalsigaction 系统调用),那么内核会暂时中断进程的主控制流程,并将控制权转移到用户态去执行这个信号处理函数。注意,此时程序的执行流并没有直接返回到 **main** 函数中被中断的地方。

用户态 (User Mode - 信号处理函数):

  1. void sighandler(int): 这是用户自定义的信号处理函数。当内核决定递达某个信号并且该信号有自定义的处理函数时,就会跳转到这个函数执行。
  2. 信号处理函数返回时,执行特殊的系统调用 sigreturn 再次进入内核: 当用户自定义的信号处理函数执行完毕后,它不会像普通的函数调用那样直接返回。而是需要通过一个特殊的系统调用 sigreturn 来通知内核信号处理已经完成。这个系统调用会再次将程序的执行切换到内核态。

要注意:

  • 当我们执行用户写的信号处理函数的时候使用的是用户的身份 ,而不是从内核态切换后用操作系统的身份来执行函数
    • 因为如果用操作系统的身份来执行的函数的话,如果在函数中有非法操作或者危险的操作的话,以操作系统的权限来说过于强大,可能会触发危险的操作。

内核态 (Kernel Mode - 返回信号处理):

  1. sys_sigreturn(): 这是 sigreturn 系统调用的内核实现。
  2. 返回用户模式,从主控制流程中上次被中断的地方继续向下执行: sigreturn 系统调用会告诉内核恢复进程在接收到信号之前被中断时的状态(包括程序计数器等),然后内核会将控制权返回到用户态,进程的主控制流程会从之前被中断的那条指令之后继续执行。

总结:

  1. 用户态和内核态的切换点:在执行主控制流程的某条指令时,因为中断、异常或系统调用进入内核。

如果该程序没有触发进入内核的指令,要如何处理信号?

  • 因为进程会被调度,只要进程在运行的时候和内核产生了关系就会处理信号,在一定的时间片限制下,进程调度的时候一定会与内核联系,这时候就是普通程序处理信号的时机!

2、 信号处理的整个过程可以概括为:

  1. 进程在用户态正常执行。
  2. 某个信号产生并被操作系统记录为该进程的挂起信号。
  3. 进程因为某种原因进入内核态。
  4. 内核在返回用户态之前检查到有可以递达的信号。
  5. 如果该信号有用户自定义的处理函数,内核会中断当前执行,切换到用户态执行该处理函数。
  6. 信号处理函数执行完毕后,通过 sigreturn 系统调用返回内核态。
  7. 内核恢复进程被中断前的上下文,然后进程返回用户态,继续执行主控制流程。

3、 下图可以很形象的描述该过程在用户态和内核态之间切换:

**四个红圈:**在用户态和内核态之间切换的时机。

**中间的交点:**检查**pending**表的时机,

转至同目录下《操作系统是如何运行的》阅读

如何理解内核态和用户态

问题一:

操作系统无论在怎么切换进程,都能找到同一个操作系统。不管在哪个进程中使用系统调用等涉及操作系统的操作时都可以找到对应的操作系统内核程序代码。

  • 先说明:操作系统一定不会蠢到给每一个进程都实际分配内存去存放操作系统。
  • 实际上,所有的操作系统调用方法的执行,是在进程地址空间中执行的,所有的函数调用都是在进程地址空间之间进行跳转,而且对于使用内核权限的代码地址也是映射的真实的地址!

实际情况是:所有的用户页表中映射的关于进程地址空间中的[3, 4GB]的内核区的地址都是同一个物理地址,也就是每个进程的进程地址空间中的内核区都是同一块内核区。

**结论:**无论进程进程如何调度,我们总能找到操作系统!

问题二:

用户和内核都在同一个[0, 4GB]的进程地址空间上,如果用户随便那一个地址恰好是[3, 4GB]内核区的地址,那么用户岂不是可以随便访问内核中的代码和数据了吗?

操作系统有自己的保护机制,不会相信任何用户,用户必须使用系统调用的方式访问!


用户态:以用户身份,只能访问自己的[0, 3GB]

内核态:以内核的身份,运行用户通过系统调用的方式,访问[3, 4GB]

操作系统也是软件,一定也存在内存,现在对上文说所有地址空间中的内核区只有一个的说法进行完善。

有这全局段描述符表和局部段描述符表:

  1. 全局段描述符表(GDT, Global Descriptor Table)
    • "全局" 表示这张表是整个系统唯一的一份,所有运行在 CPU 上的任务(内核态或用户态进程)都共用同一个 GDT。
    • 操作系统启动后会初始化好 GDT,往里放入内核的各个关键段(如内核代码段、内核数据段、任务状态段 TSS 等)的描述符。
  2. 局部段描述符表(LDT, Local Descriptor Table)
    • "局部" 则表示每个进程(或任务)可以有自己的一张表,用来存放该进程特有的段描述符。
    • LDT 一般由操作系统在创建进程时分配并初始化,描述该进程的代码段、数据段、堆栈段等用户空间段。
    • 当进程切换时,CPU 会加载该进程对应的 LDT(通过任务状态段或专门的指令),使之成为当前可用的"局部"表。

为什么要分全局/局部?

  • GDT 放那些系统全局通用、权限固定不变的段,方便内核快速访问和切换。
  • LDT 则给每个进程留出灵活的空间,让系统能为不同进程定义不同的用户态段布局,增强隔离性和灵活性。

所以所以!

内核页表作为全局段描述符表,只存在一份;而用户页表作为局部段描述符表,可以存在多份,每个进程持有一份。


问题三:

在操作系统中,用户和操作系统怎么知道当前处于内核态还是用户态?

在操作系统中,用户态和内核态的切换是通过 CPU 的特权级(CPL, Current Privilege Level) 来管理的。

  1. CPU 寄存器中的 cs(代码段寄存器)
    • cs 寄存器用于存储当前执行的代码段的选择子(segment selector)。它的低两位决定了当前的 特权级 (CPL)。在操作系统中,CPL 是一个重要的概念,用来区分当前执行的是用户态代码还是内核态代码:
      • CPL = 0 :表示当前运行在 内核态,即操作系统代码正在执行。
      • CPL = 3 :表示当前运行在 用户态,即用户程序正在执行。
  2. 用户态和内核态的切换
    • 在用户程序运行时,它会在用户态下执行,CPL 为 3。当程序需要执行一个系统调用(比如读取文件、分配内存等)时,它会触发一个 软中断 (如 int 0x80),并通过这个中断请求操作系统的帮助。
    • 触发中断后,CPU 会从用户态切换到内核态,执行操作系统内核中的相关处理程序。此时,CPL 会被设置为 0,表示内核态。
    • 当内核处理完系统调用后,控制权会返回给用户程序,CPL 会切换回 3,恢复到用户态。
  3. 如何知道当前处于内核态还是用户态?
    • 操作系统通过检查 cs** 寄存器的低两位(CPL)** 来判断当前是内核态还是用户态。如果 CPL 为 0,操作系统知道当前处于内核态;如果 CPL 为 3,则说明当前处于用户态。
    • 当用户程序执行系统调用时,操作系统可以通过特定的机制(比如 int 0x80)检查和控制该切换,确保用户程序不能直接访问内核空间,从而实现安全的进程隔离。
  4. 内核态与用户态的权限
    • 内核态,操作系统可以执行任何指令并访问所有内存区域,具有最高权限。
    • 用户态,程序只能访问其自身的内存空间,不能直接访问硬件或内核空间,且权限较低。

简而言之,操作系统和用户程序通过 CPL 来区分当前是否在内核态或用户态,0是内核态,3是用户态,并通过中断(如 int 0x80)来进行状态切换。

sigaction

<font style="color:rgb(31,35,41);">sa_flags</font>字段包含⼀些选项,本章的代码都把<font style="color:rgb(31,35,41);">sa_flags</font>设为0,<font style="color:rgb(31,35,41);">sa_sigaction</font>是实时信号的处理函数,本章请根据需要自行跳过这两个字段,有兴趣的同学可以了解⼀下。

sigaction 是 POSIX 标准定义的一个系统调用(及其库函数封装),用于检视或修改某个信号的处理动作。相比老式的 signal 函数,它更加灵活、安全,支持:

  • 指定在信号处理期间还要屏蔽哪些额外的信号
  • 选择是否在处理完一次后恢复默认行为
  • 支持"实时信号"并额外传递信号源信息
  • 控制系统调用被信号打断后的重启行为

函数原型

c 复制代码
#include <signal.h>

int sigaction(int signo,
              const struct sigaction *act,
              struct sigaction *oact);
  • signo
    要操作的信号编号(如 SIGINTSIGTERMSIGUSR1、实时信号 SIGRTMIN+n 等)。
  • act
    (可选)指向新的动作描述结构;若为 NULL,表示不修改,纯粹查询当前设置。
  • oact
    (可选)若非 NULL,函数会写回调用前该信号的旧动作设置,方便后续恢复。

调用成功返回 0,出错返回 -1 并设置 errno(例如 EINVALEFAULTEDEADLK 等)。


struct sigaction 详解

c 复制代码
struct sigaction {
    union {
        void     (*sa_handler)(int);              /* 传统信号处理函数 */
        void     (*sa_sigaction)(int,             /* 扩展的信号处理函数 */
                                 siginfo_t *, 
                                 void *);
    } __sigaction_handler;

    sigset_t   sa_mask;     /* 在处理本信号期间额外屏蔽的信号集 */
    int        sa_flags;    /* 行为选项(SA_*) */
    void     (*sa_restorer)(void);  /* 仅内核使用,对用户不可见 */
};
#define sa_handler   __sigaction_handler.sa_handler
#define sa_sigaction __sigaction_handler.sa_sigaction
  1. sa_handler** vs **sa_sigaction
    • 如果 sa_flags 中不包含 SA_SIGINFO,则使用 sa_handler(int signo),这是最常见的简单形式。
    • 如果设置了 SA_SIGINFO,则使用 sa_sigaction(int signo, siginfo_t *info, void *context),可获取发送者 PID、UID、信号代码、附带值等额外信息。
  2. sa_mask
    表示在执行信号处理函数期间,除了内核自动屏蔽的该信号本身之外,还要额外屏蔽哪些信号。通常用:
c 复制代码
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, SIGUSR2);    /* 处理 SIGUSR1 时,屏蔽 SIGUSR2 */
  1. sa_flags
    常用选项有:
    • SA_RESTART
      使某些被信号打断的系统调用自动重启(比如 read, write),避免返回 EINTR
    • SA_NODEFER
      默认情况下,在正在处理的信号,其自身会被自动屏蔽直到 handler 返回;加上此标志后,可在 handler 内再次接收同一信号。
    • SA_RESETHAND
      处理一次后,自动将该信号的动作用重置为默认(相当于一次性 handler)。
    • SA_NOCLDSTOP
      如果对 SIGCHLD 设此标志,则子进程停止/继续时不会发送 SIGCHLD 给父进程。
    • SA_NOCLDWAIT
      SIGCHLD 生效,自动回收子进程,父进程不会因其僵尸化而产生信号或僵尸进程。
    • SA_SIGINFO
      启用 sa_sigaction 接口,获得更丰富的 siginfo_t 信息。
  2. sa_restorer
    供内核架构相关地恢复现场用,用户无需设置或调用。

使用示例

下面是一个典型的例子:捕获 SIGINT(Ctrl+C),打印一条消息,然后优雅退出;并且在处理该信号时屏蔽 SIGTERM

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>

void handle_sigint(int signo) {
    printf("Caught SIGINT, exiting gracefully...\n");
    exit(0);
}

int main(void) {
    struct sigaction act, old;

    /* 1. 设置 sa_handler */
    act.sa_handler = handle_sigint;
    /* 2. 清空 sa_mask 增加要屏蔽的信号 */
    sigemptyset(&act.sa_mask);
    sigaddset(&act.sa_mask, SIGTERM);
    /* 3. 设置行为选项 */
    act.sa_flags = SA_RESTART;   /* 被打断的系统调用自动重启 */

    /* 安装新的信号处理动作,并保存旧动作 */
    if (sigaction(SIGINT, &act, &old) < 0) {
        perror("sigaction");
        return 1;
    }

    /* 模拟长时间运行 */
    for (;;) {
        printf("Working...\n");
        sleep(1);
    }
    return 0;
}
  • 当用户按下 Ctrl+C 时,内核切换到内核态,调用 handle_sigint
  • 在 handler 内,SIGINT 会被自动屏蔽,且由于我们加了 sigaddset(SIGTERM),此时 SIGTERM 也会被屏蔽。
  • handler 返回后,屏蔽字恢复到调用前状态,系统调用(如 sleep)若被打断将自动重启(因 SA_RESTART)。

小结

  • 查询当前动作sigaction(signo, NULL, &old)
  • 设置新动作并保存旧动作sigaction(signo, &act, &old)
  • 只修改,不保存旧动作sigaction(signo, &act, NULL)
  • 只读取,不修改sigaction(signo, NULL, &old)
字段 作用
sa_handler 传统回调 void handler(int)
sa_sigaction 扩展回调 void handler(int, siginfo_t*,void*)
sa_mask 处理期间额外屏蔽的信号集
sa_flags 行为控制(如 SA_RESTARTSA_SIGINFO等)
oact 输出参数,用于存放老的动作设置

这样你就可以灵活、可靠地控制信号处理的各种细节,避免信号到来时状态不一致或系统调用被意外中断等问题。

可重入函数

"可重入"(reentrant)函数,指的是在任意时刻,函数被打断(比如被信号处理程序、中断或另一个线程)后再次进入调用,都不会因共享数据竞态而出问题。反之,如果函数在执行过程中修改了某些全局或静态数据,再次重入时就可能破坏这些数据,就叫不可重入

下面结合图示,逐步说明为什么这个 insert 函数 不可重入,以及"可重入"函数究竟是怎样的。

1. insert 的两步插入操作

c 复制代码
void insert(node_t *p) {
    // Step 1: p->next = head;
    p->next = head;
    // ......(假设在此可能被打断)
    // Step 2: head = p;
    head = p;
}
  • 第 1 步 :把新节点 pnext 指向当前的 head
  • 第 2 步 :再把全局变量 head 更新成 p

正常调用时,这两步连贯执行,就能把 p 插到链表头部。


2. 多流程重入导致错乱的时序

  1. 初始状态(图示 0)
latex 复制代码
head → [...]   (head 指向旧链表)
node1, node2 都尚未链接
  1. main 调用 insert(&node1) 并做完第 1 步(图示 1)
plain 复制代码
node1.next = head;
head ------┐
       ↓
      [...旧链表...]

但还没执行 head = node1,函数即将完成前被硬件中断打断。

  1. **跳转到内核,检查到有 pending 信号,执行 **sighandler
    信号处理函数里也调用 insert(&node2),进入同一个 insert 函数。
  2. sighandler** 的 insert(&node2) 执行完两步**(图示 2→3)
    • 第 1 步:node2.next = head;
      这里的 head 还是原来的旧链表指针。
    • 第 2 步:head = node2;
      于是链表头变成 node2,且 node2.next 指向旧链表。
  3. **返回到 main 的 **insert(&node1)(图示 4)
    恢复到未完成第 2 步的状态,继续执行:
c 复制代码
head = node1;

这一步把 head 又改成 node1覆盖掉了 sighandler 插入的 node2,最终链表只剩 node1


3. 为什么 insert 不是可重入的?

  • **它依赖并修改全局变量 **head
  • 在"第 1 步"与"第 2 步"之间,函数的数据状态不一致(p->next 已改,head 还没改)。
  • 如果此时被打断并再调用,就会在中断上下文里也修改同一份全局状态,最后回到原调用继续修改,导致前后操作冲突。

4. 可重入函数的特点

一个函数若要称为可重入,通常要满足:

  1. 只访问自身的局部变量和参数,不读写全局或静态数据。
  2. 不依赖不可重入的库函数 ,如 malloc/free(内部用全局结构管理堆),printf/fopen(标准 I/O 常用全局缓冲区)。
  3. 如果必须操作共享资源,要么使用原子操作/锁(但信号处理程序里锁也容易死锁),要么走"返回新状态,让调用者自己做赋值"的函数式方式。

举例,一个可重入版的插入函数可以改成:

c 复制代码
node_t *insert_reentrant(node_t *head, node_t *p) {
    // 只改 p->next,不改全局 head
    p->next = head;
    // 返回新的 head,由调用者赋值
    return p;
}

/* main 或 sighandler 里:
   head = insert_reentrant(head, &node1);
*/

这样,每次调用都只是操作局部的 head 副本,最后再赋值,相当于让"写全局"在外层集中完成,避免在中间被打断造成半成品状态。


  • 不可重入函数:在执行过程中修改全局/静态数据,可能会被打断并重入,导致并发冲突。
  • 可重入函数:只操作自己的栈上数据或通过返回值让外层做写共享,内部不修改全局状态,也不调用不可重入的库函数。
  • 在异步(信号)或并发(多线程)环境中,优先考虑用可重入函数或者在外层做好同步、切换屏蔽(signal mask)等措施。

volatile(了解)

在信号(异步事件)处理的场景下,volatile 关键字的作用可以简单归纳为------告诉编译器:这个变量随时可能在"外部"被改变,千万不要对它做"缓存"或"优化"


为什么需要 volatile

  1. 编译器优化会把变量缓存到寄存器
    当你写下这样一个循环:
c 复制代码
while (!flag);

在开启 -O2 等较高级别优化时,编译器会觉得:

结果,程序一进入循环就再也不会去内存读 flag,即使后续信号处理函数里真的把内存中的 flag 改成了 1,循环也看不到这个变化------变成了"死循环"。

复制代码
- "`flag` 在这个函数里没其他地方写,只读且永远不变"
- "那我把它从内存中加载一次,存在寄存器里,后面都用寄存器的拷贝就好了"
  1. 信号处理函数会异步修改变量
    当你按下 Ctrl‑C ,内核异步调用你的 handler 函数,执行:
c 复制代码
flag = 1;

但如果主循环里对 flag 做了寄存器缓存,就永远读不到这次修改。


volatile 的作用

c 复制代码
volatile int flag = 0;
  • 在每一次对 flag 的读写操作时,编译器都会强制生成对应的内存访问指令,绝不"偷懒"用寄存器拷贝。
  • 因此,主循环中的
c 复制代码
while (!flag);

会在每次迭代都去内存重新加载 flag,才能及时观察到信号处理函数里赋 flag = 1 的结果,从而跳出循环。


完整示例

c 复制代码
#include <stdio.h>
#include <signal.h>

volatile int flag = 0;   // 保证内存可见性

void handler(int sig) {
    printf("change flag 0 to 1\n");
    flag = 1;            // 异步写内存
}

int main() {
    signal(SIGINT, handler);
    while (!flag)        // 每次都去内存读 flag
        ;                // 不会被寄存器"钉死"
    printf("process quit normal\n");
    return 0;
}
bash 复制代码
gcc -O2 -o sig sig.c
./sig
^Cchange flag 0 to 1
process quit normal

注意事项

  • volatile** 只保证对变量访问不被优化**,并不提供原子性,也不保护多线程间的内存可见性(在多线程场景下一般需要使用 C11 原子类型或锁)。
  • 在信号处理函数中,只能调用**可重入(async‑safe)**的函数;尽量别在 handler 里用 printfmalloc 等非可重入接口,否则可能引起死锁或未定义行为。
  • 对于简单的"主循环等待信号更改某个标志位"这种模式,volatile 已足够;更复杂的跨线程/跨 CPU 缓存同步,需要更高级的内存屏障或原子操作。

SIGCHLD(了解)

在 UNIX/Linux 下,每当子进程终止(正常 exit、被信号杀掉,或者被 stop/continue)时,内核都会给它的父进程发一个 SIGCHLD 信号。默认情况下,SIGCHLD 信号的动作是忽略(SIG_IGN),也就是父进程不会因此中断,也不会自动去清理子进程的僵尸状态------子进程变成僵尸后,父进程必须调用 wait 或 waitpid 来回收它,否则就留在进程表里。


方法一:自定义 SIGCHLD 处理函数

  1. 在父进程里,用 signal(SIGCHLD, handler)(或者更安全的 sigaction)安装一个处理函数 handler,当收到 SIGCHLD 时被调用。
  2. handler 里循环调用
c 复制代码
while ((pid = waitpid(-1, NULL, WNOHANG)) > 0) {
    printf("reaped child %d\n", pid);
}

这样既不会阻塞父进程,又能及时回收所有已经终止的子进程,避免僵尸进程累积。

  1. 测试时,父进程可以在主循环里做自己的工作(比如打印"father proc is doing something"),当子进程退出时,handler 会被异步触发去收尸。

方法二:将 SIGCHLD 设为 SIG_IGN(仅 Linux 保证有效)

在父进程里直接写:

c 复制代码
signal(SIGCHLD, SIG_IGN);

或者用 sigaction

c 复制代码
struct sigaction sa = { .sa_handler = SIG_IGN };
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGCHLD, &sa, NULL);

此时,子进程退出时,内核会自动替父进程调用 wait,把子进程的资源立即回收掉------父进程既不收不到信号,也不会产生僵尸进程。但要注意,这种"自动清理"只是 Linux 上的特殊行为,不一定在所有 UNIX 平台都可用。


小结
  • SIGCHLD:子进程状态变化(exit、stop、continue)时内核发给父进程的信号。
  • 默认行为:忽略(父进程不会被打断,也不会自动回收子进程)。
  • 自定义 handler:父进程安装 handler,收到信号时在其中调用 wait/waitpid 回收子进程,不影响主流程。
  • SIG_IGN:在 Linux 下直接忽略并自动回收子进程,父进程既不打断也不留僵尸;可写法与自定义 handler 类似,但不提供状态通知。

这样,父进程既能专心做自己的事,又不用担心僵尸进程的问题。

记得看课件附录

相关推荐
敖云岚2 小时前
【Redis】分布式锁的介绍与演进之路
数据库·redis·分布式
LUCIAZZZ2 小时前
HikariCP数据库连接池原理解析
java·jvm·数据库·spring·springboot·线程池·连接池
我在北京coding2 小时前
300道GaussDB(WMS)题目及答案。
数据库·gaussdb
小Tomkk3 小时前
阿里云 RDS mysql 5.7 怎么 添加白名单 并链接数据库
数据库·mysql·阿里云
明月醉窗台3 小时前
qt使用笔记二:main.cpp详解
数据库·笔记·qt
沉到海底去吧Go4 小时前
【图片自动识别改名】识别图片中的文字并批量改名的工具,根据文字对图片批量改名,基于QT和腾讯OCR识别的实现方案
数据库·qt·ocr·图片识别自动改名·图片区域识别改名·pdf识别改名
老纪的技术唠嗑局4 小时前
重剑无锋,大巧不工 —— OceanBase 中的 Nest Loop Join 使用技巧分享
数据库·sql
未来之窗软件服务5 小时前
JAVASCRIPT 前端数据库-V6--仙盟数据库架构-—-—仙盟创梦IDE
数据库·数据库架构·仙盟创梦ide·东方仙盟·东方仙盟数据库
一只爱撸猫的程序猿6 小时前
构建一个简单的智能文档问答系统实例
数据库·spring boot·aigc
nanzhuhe6 小时前
sql中group by使用场景
数据库·sql·数据挖掘