L14 Exceptional Control Flow: Exceptions and Processes

14次阅读
没有评论

L14 Exceptional Control Flow: Exceptions and Processes

计算机执行的指令序列被称作 控制流。硬件正在执行的实际指令序列被称为 物理控制流。

通常,此控制流在你内存中按顺序执行单条指令。

‍

我们现在有两种手段来修改控制流:

  1. 跳转和分支
  2. 调用和返回

但是这些都是程序级别的状态。对于操作系统来说,需要能够处理系统级别的变化,比如碰到通信中断,或者按了了CtrlC,因此我们需要一场控制流(ECF),这存在于计算机系统的所有等级中(无论是硬件还是软件)。

  1. 底层的机制——异常:响应系统时间二发生的控制流改变,计算机硬件和操作系统的组合
  2. 高层次机制:1. 进程上下文切换 2. 信号 3. 非局部转跳

‍

异常:响应某些事件(即处理器状态改变)而将控制权转移给操作系统内核。(内核,是操作系统的内存驻留部分)

这些事件可以触发异常:除0,算数溢出,内存页错误,IO请求完成,输入了 Ctrl-C

L14 Exceptional Control Flow: Exceptions and Processes

这张图就展示了,当用户代码触发异常时,内核如何响应

‍

之前说过,异常处理是由硬件和软件(操作系统)共同完成的。检测异常依靠事件,每种事件都有一个唯一的“异常编号”,用作转跳表的缩影,被称作“异常表”:

L14 Exceptional Control Flow: Exceptions and Processes

‍

异常处理可分为两类:异步和同步。其中异步异常时由于处理器外部发生状态变化而引起的,这些被称作中断(CPU上有直接的中断引脚,也就是完成DMA操作的手段,需要色湖之中断引脚来通知处理器已经完成复制)。当发生中断时,程序会返回到下一条指令。

最常见的异步中断例子:

  1. 时定时器中断:计算机中存在一个硬件计时器,在操作系统启动的时候会初始化这个硬件。在程序运行的过程中,每隔几毫秒就会触发一次,当定时器被触发,他将中断引脚设为高电平,随后内核中有一个特殊的异常编号来对此进行处理。

  2. 来自设备的I/O中断:比如,在键盘按下ctrl+C,设备从网络中接收到了包,设备从硬盘中获取到了数据

‍

同步异常也有三类:

  1. 陷阱,是故意设置的异常:

    比如:system calls(应用程序调用内核提供的接口,但是是将程序控制权转移到内核),断点,特殊指令

  2. 故障,是无意设置,但是可能是可以恢复的:

    缺页故障(Page Fault,可恢复):程序访问了一个虚拟内存地址,但该地址对应的数据目前还在硬盘上,不在物理内存中。硬件触发 Fault,操作系统介入,把数据从硬盘加载到内存,然后重新执行刚才那条引发故障的指令。程序对此毫无感知,继续运行。

    保护故障(Protection Fault,不可恢复):程序试图访问不属于它的内存(比如越界访问),或者试图执行特权指令。操作系统发现这是非法操作,无法修复,通常会直接杀死这个进程(比如报 "Segmentation fault")。

    浮点异常(Floating point exception):除0,浮点数溢出。

  3. 终止,是无意设置且不可恢复的异常:

    非法指令,内存存在问题,那么就会被触发

‍

system calls: 在x86-64中每一个system call都有一个独特的ID

L14 Exceptional Control Flow: Exceptions and Processes

如果进入到汇编层面,能看syscall 指令,这是调用System Call的经典类型:

L14 Exceptional Control Flow: Exceptions and Processes

在这个调用过程中:syscall会从%rax中读取指令码,在内核处理完之后,把结果放回到 %rax。如果rax返回了负数,那么对应着错误。

这个syscall就完成了用户态到内核态的交互,用户态调用syscall(这个内核提供出来的接口),随机系统创建同步异常,内核完成业务之后,会将结果进行比较。再将结果返回

‍

内存页缺失:

L14 Exceptional Control Flow: Exceptions and Processes

当执行movl时,CPU 内部的内存管理单元(MMU)发现这个地址 0x8049d10 不在内存当中,就会发生页缺失,随后内核将磁盘中的数据拷贝到内存中,随后返回结果,用户态再执行movl

‍

无效内存访问:

L14 Exceptional Control Flow: Exceptions and Processes

当movl尝试向内存 0x804e360 写入的时候,CPU 内部的内存管理单元(MMU)首先发现这个地址不在内存中,会发出内存页缺失的错误(同步异常,缺页异常),随后,程序的控制权限被从用户态转移到了内核态,内核会尝试加载这片内存,但是发现这个地址是非法的,于是直接发出错误信号,返回Segmentation Fault

‍

L14 Exceptional Control Flow: Exceptions and Processes

进程,是正在运行的程序的实例。程序提供了两个抽象:

  1. 程序看似完全占有CPU——这依靠 上下文切换 来完成
  2. 程序看似完全占有所有内存——这依靠 虚拟内存 来实现

‍

系统同时运行了多个进程,即使可能运行操作系统的是个单核CPU也是如此。

L14 Exceptional Control Flow: Exceptions and Processes

‍

上下文切换,是这样完成的:

  1. 定时器中断(硬件触发) :假设你的程序正在 CPU 上狂奔。突然,每 1ms 一次的定时器中断响了(异步异常)。

  2. 陷入内核(控制流转移) :CPU 暂停你的程序,保存当前寄存器状态(上下文),跳转到内核的调度器。

  3. 上下文切换(内核操作) :内核一看,“哦,你(进程 A)的时间片用完了。该换进程 B 上了。”

    • 内核把进程 A 的寄存器状态(PC、栈指针等)保存到内存中的进程控制块(PCB)。
    • 内核把进程 B 之前保存的寄存器状态从 PCB 恢复到 CPU 寄存器中。
  4. 返回用户态:CPU 恢复执行,但此时执行的不再是你的程序,而是进程 B。

即便是现代的多核心CPU,也高度依赖上下文切换,操作系统将在这些多核上安排进程

‍

我们认为,每个进程代表一个“逻辑控制流”。如果两个进程的控制流在时间上发生重叠,那么他们就是并发运行的。否则就是顺序执行:

例如,下面的例子中,单核CPU运行了下列三个进程:

L14 Exceptional Control Flow: Exceptions and Processes

那么我们就可以说 ABC 三个进程是并发(Concurrent)的。

当然,上面这些都是对操作系统来说。因为实际上每次上下文切换的时间非常短,用户无法察觉,所以在用户看来是这样的:

L14 Exceptional Control Flow: Exceptions and Processes

因此,我们可以认为他们是相互“并行”的

‍

内核不是一个独立的进程。本质上是一大堆常驻在内存里的代码和数据,它运行在 CPU 的最高特权级(Ring 0 / 内核态) 。
它不是一个被操作系统调度器(Scheduler)调度的“任务(Task)”。你在 ​top​ 或任务管理器里看到的进程列表,全都是用户态的程序,你永远找不到一个叫“kernel”的 PID。

L14 Exceptional Control Flow: Exceptions and Processes

上图展示了上下文切换的流程

‍

Linux提供了很多函数,用于从用户程序来操作进程。这个操作的过程我们成为进程控制。

现代linux操作系统在程序运行遇到错误的时候通常返回-1,并且用一个全局的errno 来指示原因。

当我们使用系统级别的函数时,必须检查返回值,用来判断是成功了还是失败了。只有那些void类型的函数不需要检查

例如下面的程序:

if ((pid = fork()) < 0) {  // 1. 调用 fork(),并将返回值赋给 pid。这里的fork只是syscall的封装
                           // 2. 立刻检查:如果返回值 < 0(即 -1),说明 fork 失败了
    fprintf(stderr, "fork error: %s\n", strerror(errno)); // 3. 打印错误信息
    exit(0);               // 4. 终止程序(或者进行其他错误处理)
}

可以用下面的手段来简化输出报错的编写:

L14 Exceptional Control Flow: Exceptions and Processes

L14 Exceptional Control Flow: Exceptions and Processes

‍

‍

在C语言中,我们有这些方法来获得PID:

pid_t getpid(void) //返回当前进程的PID
pid_t getppid(void) //返回当前进程的父进程的PID

经典例子:
你在终端里运行一个程序,这个程序的父进程通常就是你的 Shell(比如 ​bash)。

printf("My PID is %d, my parent PID is %d\n", getpid(), getppid());

如果输出 ​My PID is 1234, my parent PID is 1000,说明你的程序 PID 是 1234,而启动它的那个终端 Shell 的 PID 是 1000。

‍

Linux也提供了创建和终止进程的方法,我们可以将程序看作有下列三种状态:

L14 Exceptional Control Flow: Exceptions and Processes

‍

‍

程序会因为下列三种原因被终止(Terminated):

  1. 收到了终止信号

  2. 从main 函数中return出去了

  3. call了 exit函数

    但是 一旦 exit 被执行,就不会有任何返回值。当程序调用exit时,会触发一个syscall,将程序权限移交内核,回收该进程的所有资源,随后摧毁该进程

‍

同样,进程可以被创建,父进程可以通过调用fork()来创建子进程:

L14 Exceptional Control Flow: Exceptions and Processes

fork是无参调用,会返回一个整数且创建新的进程。他会在父进程和子进程分别返回。对于子进程会返回0,对于父进程会返回子进程的PID。

子进程获得一份独立的父进程虚拟地址空间副本,子进程的一切(包括,变量,全局变量,堆,栈)都和父进程完全相同。子进程获得父进程打开文件描述符的相同副本——子进程可以访问任何已打开的文件,包括父进程拥有的标准输入和输出,唯一的区别的就是PID不同

子进程并不是从 ​​main​​ 函数的第一行开始执行的,而是从 ​​fork()​ ​ 调用的那一刻“接着”往下执行的。

更详细的解释:

1. 为什么说 "Called once but returns twice"(调用一次,返回两次)?

这是 ​fork() 最神奇的地方。

  • 当父进程执行到 ​fork() 时,它调用了内核的系统调用。
  • 内核在底层做了一件事:完全复制了父进程的上下文(包括页表、寄存器等),生成了一个子进程。
  • 当内核准备从系统调用返回时,它有两个进程要返回:父进程和子进程。
  • 所以,​这一行代码在父进程里执行了一遍,在子进程里也执行了一遍。这就是“返回两次”的物理含义。

2. 为什么返回值不一样?(如何区分父子进程)

因为底层复制出来的两个进程在物理内存中是独立的。内核在设置返回值时:

  • 给父进程:返回子进程的 PID(大于 0)。父进程需要这个 ID 来管理子进程。
  • 给子进程​:返回 0。子进程可以通过 ​getppid() 找到父进程。
  • 出错​:返回 -1(这就是你之前看那张幻灯片时,为什么要写 ​if ((pid = fork()) < 0) 的原因)。

3. 最硬核的知识点:“独立的虚拟地址空间副本”

图片下方的中文说:“子进程获得一份独立的父进程虚拟地址空间副本,子进程的一切都和父进程完全相同”。

这里其实隐藏了现代操作系统的精髓——​写时复制(Copy-On-Write,COW) 。

  • 理论上是“独立副本” :子进程有一套自己的虚拟地址空间,逻辑上它拥有父进程内存里的所有数据(全局变量、堆、栈)。
  • 物理上的“偷懒” ​:在 ​fork()​ 刚执行完的那一刻,内核并没有真的把父进程的所有内存数据拷贝一份给子进程(那样太慢了)。内核只是​把子进程的页表指向了父进程的物理内存页,并把这块内存标记为“只读” 。
  • 只有当“写”发生时才复制​:如果父进程或子进程试图修改内存里的一个变量,CPU 会触发一个​写保护异常(同步异常/Fault) 。此时内核介入,才真正为写入方分配一块新的物理内存,并把数据拷贝过去。
  • 为什么这样设计? 因为很多时候 ​fork()​ 之后马上就会跟着 ​exec()​(加载新程序),原来的内存根本用不到。写时复制极大地提高了 ​fork() 的效率。

4. 文件描述符的“相同副本”

图片说:“子进程获得父进程打开文件描述符的相同副本”。

  • 这意味着,如果父进程打开了一个文件,子进程也能直接读写这个文件。
  • 并且,由于是“副本”,父进程和子进程共享同一个​文件偏移量(File Offset) 。如果父进程读了 10 个字节,子进程接着读,会从第 11 个字节开始读。
  • 这也解释了为什么在终端里,子进程打印输出时,也能直接输出到你的屏幕上(因为标准输出 ​stdout 的文件描述符也被复制了)。

总结

这张图不仅解答了 ​fork()​ 的机制,还引出了操作系统中的三大核心概念:​系统调用、虚拟内存、写时复制(COW) 。

‍

父进程和子进程的代码既然一样,那有什么意义呢

答案是:​​fork()​ ​ 本身不是为了“复制”,而是为了“分工”。

虽然刚 ​fork()​ 出来的那一瞬间,两个进程确实一模一样,但紧接着,它们就会根据 ​fork()​ 的​返回值(父进程拿到子进程 PID,子进程拿到 0),走向完全不同的道路。

我们来看看这到底有什么实际意义:

1. 经典用法一: ​fork()​ + ​exec()​ (启动新程序)

这是 ​fork()​ 最常见的用途。Shell(终端)是怎么执行你输入的命令(比如 ​ls​、​gcc)的?

  • Shell 是一个进程(父进程)。
  • Shell 调用 ​​fork()​ ​ 复制出一个子进程。
  • 子进程拿到 ​​pid == 0​​ ,它立刻调用 ​exec()​ 系统调用,​把自己的内存全部替换成 ​​ls​​ 程序的代码。
  • 父进程(Shell)拿到子进程的 PID,继续等待用户输入,或者等待子进程结束。
  • 意义:Shell 自己不想死,但它需要执行新程序。所以它“克隆”一个自己,让克隆体去“变身”成新程序,自己则留在原地继续工作。这就是“分工”。

2. 经典用法二:并发处理(服务器模型)

假设你写了一个 Web 服务器,监听 80 端口。每当有一个用户连接过来,你希望同时处理多个用户,而不是处理完一个再处理下一个。

  • 服务器进程(父进程)调用 ​fork()。
  • 子进程拿到 ​pid == 0,它去处理这个用户的请求(读数据、发响应)。
  • 父进程拿到子进程 PID,它立刻回到 ​accept() 那里,等待下一个用户的连接。
  • 意义​:这就是经典的 “一个连接一个进程” 模型(比如早期的 Apache 服务器)。父进程负责“拉客”,子进程负责“服务”。如果没有 ​fork(),服务器只能串行处理,效率极低。

3. 经典用法三:并行计算(分治思想)

假设你要计算一个超大数组的和,或者进行复杂的密码破解。

  • 父进程调用 ​fork() 创建多个子进程。
  • 子进程 1 计算数组的前半部分。
  • 子进程 2 计算数组的后半部分。
  • 父进程调用 ​wait() 等待它们算完,然后把结果汇总。
  • 意义​:利用多核 CPU 的并行能力。虽然每个子进程都有一份完整的内存副本,但它们可以通过 ​fork​ 后的 ​if/else 逻辑,分别处理不同的数据块。

4. 为什么代码一样,却能有不同行为?(核心机制)

这就是 ​fork()​ 设计的最精妙之处:​利用返回值做条件分支。

虽然代码一样,但程序执行到 ​if 语句时,会根据返回值走向不同的分支:


pid_t pid = fork();

if (pid == 0) {
    // 只有子进程会执行这里
    // 比如:去执行 exec() 变身,或者处理具体的业务逻辑
    printf("我是子进程,我的PID是 %d\n", getpid());
} else if (pid > 0) {
    // 只有父进程会执行这里
    // 比如:继续监听端口,或者等待子进程结束
    printf("我是父进程,我创建了子进程 %d\n", pid);
} else {
    // fork 失败
    perror("fork");
}

5. 为什么不直接创建一个“完全不一样”的进程?

因为 ​fork()​ 提供了一个​极其干净的初始状态。

  • 如果从零开始创建进程,操作系统需要重新分配页表、初始化各种数据结构,非常麻烦。
  • fork() 相当于“复制当前现场”。子进程继承了父进程的环境(环境变量、打开的文件、工作目录等),这样程序员就不需要重新去配置这些环境了。
  • 而且,有了 写时复制(COW) 技术,这种“复制”在物理上非常廉价,只有在真正写数据时才分配内存。

总结

fork()​ 的意义不在于“复制”,而在于​ “分叉” ​。
它让一个进程可以分裂成两个独立的执行流。代码一样是“初始状态”,通过 ​​if (pid == 0)​ ​ 走向不同的逻辑分支,才是“最终目的”。 它是操作系统实现“并发”和“执行新程序”的基石。

L14 Exceptional Control Flow: Exceptions and Processes

例如,在这个例子中,就是用了pid作为分支的依据,父进程和子进程就做了不同的事情

但是,我们无法保证子进程和父进程到底哪个先执行,所以这些输出可能是乱序的

‍

我们用一种叫做进程图的工具,来捕捉调用forks时可能发生的情况:

L14 Exceptional Control Flow: Exceptions and Processes

‍

L14 Exceptional Control Flow: Exceptions and Processes

‍

L14 Exceptional Control Flow: Exceptions and Processes

L14 Exceptional Control Flow: Exceptions and Processes

‍

‍

当程序exit的时候,虽然内核回收了他的大部分资源,他还在占用系统的资源,比如:退出状态(Exit Status),进程表项(OS tables)。程序已经不再运行,但是PID还在被占用,所以称其为僵尸进程。直到父进程将其回收,是以下三个顺序:

  1. 回收主要依靠父进程用wait或者waitpid去读取子进程的退出状态
  2. 父进程知道了子进程的Exit Status
  3. 内核将子进程删除

如果父进程没有回收僵尸进程,会发生什么呢?比如如果父进程比子进程还先终止:

  1. 这个僵尸进程会被PID为1的init进程进行回收(现在的操作系统中为systemd)

‍

L14 Exceptional Control Flow: Exceptions and Processes

在这个例子中,父进程陷入死循环,在子进程terminated之后没有用wait()进行回收,因此产生了僵尸进程

L14 Exceptional Control Flow: Exceptions and Processes

L14 Exceptional Control Flow: Exceptions and Processes

#include<stdio.h>

void fork7();

int main(){
        fork7();
}
void fork7() {
    if (fork() == 0) {
        /* 子进程分支 */
        printf("Terminating Child, PID = %d\n", getpid());
        exit(0);  // 子进程死亡
    } else {
        /* 父进程分支 */
        printf("Running Parent, PID = %d\n", getpid());
        while (1)
            ; /* 无限循环 */
    }
}

‍

‍

L14 Exceptional Control Flow: Exceptions and Processes

在这个例子中,当父进程先终止,子进程如果无人来回收,会有init(systemd)对其进行回收。

‍

接下来则是详细讲述了wait()函数的使用:

wait()最核心的行为是,当父进程调用wait之后,会立刻放弃CPU进入睡眠状态,内核会将其从运行队列中移除,直到子进程调用exit(0),内核才会重新唤醒父进程,wait()返回,父进程继续向下执行。

如果父进程调用wait的时候,子进程已经成为了僵尸进程,那么wait会立刻返回,顺手把僵尸进程清理掉。

因此,标准的代码应该是下面这样:

#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        // 子进程干活
        printf("Child running\n");
        exit(42); // 子进程退出,返回状态码 42
    } else {
        // 父进程收尸
        int status;
        pid_t child_pid = wait(&status); // 阻塞在这里,等子进程死

        printf("Reaped child with PID:  %d\n", child_pid);

        // 解析退出状态
        if (WIFEXITED(status)) {
            printf("Child exited normally with status: %d\n", WEXITSTATUS(status));
        } else if (WIFSIGNALED(status)) {
            printf("Child killed by signal: %d\n", WTERMSIG(status));
        }
    }
    return 0;
}

‍

之前说过,fork()创建的只是父进程的副本,如果需要在子进程执行不同的代码,需要使用,execve()

L14 Exceptional Control Flow: Exceptions and Processes

函数需要传递三个参数:

  1. filename(也可以是二进制文件,也可以是bash脚本)
  2. argv[] 传参,其中argv[0]是被执行的文件名称
  3. envp[] 传入的环境变量

但是,调用execve() 不会改变子进程的PID,且一旦执行成功,永远不会返回(错误情况除外)

‍

‍

L14 Exceptional Control Flow: Exceptions and Processes

这张图展示了当我们在fork之后,调用execve()时,栈帧的情况:

Q:这个是栈帧,可是 不是说fork()会复制内存,随后 execve()不是会覆盖原本的内存吗,栈帧里面为什么还有main呢

Answer:

1. ​fork()​ 和 ​execve()​ 的“覆盖”到底覆盖了什么?

你理解得没错:

  • ​fork()​ :完全复制父进程的内存空间。
  • ​execve()​ ​:确实会彻底覆盖当前进程的用户空间内存。

但是, “覆盖”并不是把内存全部清零,而是重新建立一套全新的内存映射(Memory Mapping)。

execve() 做的事是:

  1. 把原来的代码段、数据段、堆、栈​全部丢弃(内核直接回收这些物理页)。
  2. 读取新的可执行文件(比如 ​/bin/ls),把它的代码段、数据段加载到内存。
  3. 重新分配一块全新的、干净的栈空间。
  4. 最关键的一步:把 ​​argv​​ (命令行参数)和 ​​envp​​ (环境变量)这些数据,从内核空间拷贝到这个新的栈顶。

所以,这张图里画的栈,不是父进程原来的栈,而是 ​​execve​​ 为新程序重新建立的栈。

2. 既然是新栈,为什么里面会有 ​main​ ?

这就是这张图最精髓的地方。

execve​ 加载完新程序后,它不会直接跳到 ​main 函数。为什么呢?

  • 因为 ​main​ 函数需要接收参数(​argc​, ​argv​)和环境变量(​envp)。
  • 这些参数是内核在 ​execve 时准备好的,放在了栈顶。
  • 内核在启动新程序时,会把 CPU 的指令指针(PC/RIP) 指向一个特殊的函数:​ ​_start​​(在 Linux 中通常是 ​__libc_start_main 的入口)。

这个 ​_start 函数是 C 运行时的启动代码(由 C 标准库提供)。它的任务是:

  1. 从栈顶把 ​argc​、​argv​、​envp 拿下来。
  2. 初始化 C 运行时的环境(比如设置全局变量、初始化 ​stdio 等)。
  3. 最后,调用 ​​main(argc, argv, envp)​ 。

所以,图中显示的 ​ "Future stack frame for main" ​,并不是说 ​main​ 已经执行了,而是​表示 ​ ​_start​​ 函数在完成准备工作后,将要为 ​​main​​ 函数建立的栈帧。

3. 图中各个部分的精确含义

  • 最底下(Top of stack) ​:​Future stack frame for main​。这是预留的,等 ​_start​ 调用 ​main​ 时,​main 的局部变量会放在这里。

  • 中间(Stack frame for libc_start_main) ​:这是 ​_start​(即 ​libc_start_main)自己的栈帧,它负责启动流程。

  • 上面(argc, argv, envp) ​:这是内核在 ​execve​ 时,强行推入新栈顶的数据。

    • argv[0]​ 是程序名,​argv[1]​ 是第一个参数,​NULL 结束。
    • envp[0]​ 是第一个环境变量,​NULL 结束。
    • 最上面是实际字符串内容的存储区(Null-terminated strings)。
  • 右侧的寄存器:

    • %rdi​ 存放 ​argc。
    • %rsi​ 存放 ​argv 的地址。
    • %rdx​ 存放 ​envp 的地址。
    • 这是 ​_start​ 函数调用 ​main 时,按照 x86-64 调用约定传参的方式。

4. 回到你的问题:为什么会有 ​main​ ?

因为 ​execve​ 加载程序后,​必须把执行权交给新程序的入口点​。这个入口点不是 ​main​,而是 ​_start​。​_start​ 是 C 运行时的“引导程序”,它​最终会调用 ​​main​。

所以,这张图不是在说“​fork​ 的复制被保留了”,而是在说:“​​execve​​ 成功覆盖旧内存后,新程序在启动时,栈里长什么样。 ”

总结

  • ​fork​​ 复制的内存​:在 ​execve​ 时被​完全抛弃。
  • ​execve​​ 建立的栈:是全新的,里面装着内核传进来的参数。
  • 栈里的 ​​main​​:是 ​_start 函数将要调用的目标。它是新程序的起点,而不是旧程序的残留。

你现在把进程管理和程序运行时的底层细节都打通了。这张图就是 ​execve 执行后,新程序在物理内存中的初始快照。

‍

L14 Exceptional Control Flow: Exceptions and Processes

这个例子中,使用execve()来执行ls

正文完
 0
评论(没有评论)