课程主页:http://www.cs.cmu.edu/afs/cs/academic/class/15213-f15/www/schedule.html

课程资料:https://github.com/EugeneLiu/translationCSAPP

课程视频:https://www.bilibili.com/video/av31289365/

这一讲介绍了异常。

异常控制流

控制流
  • 处理器只做一件事:
    • 从启动到关闭,CPU只需读取并执行(解释)一系列指令,一次一条命令
    • 此序列是CPU的控制流

改变控制流
  • 到目前为止,有两种用于更改控制流的机制:
    • 跳跃和分支
    • call和return
      对程序状态的变化做出反应
  • 对于有用的系统而言不够:难以对系统状态的变化做出反应
    • 数据来自磁盘或网络适配器
    • 除以零的指令
    • 用户在键盘上按Ctrl+C
    • 系统计时器到期
  • 系统需要“异常控制流”的机制
异常控制流
  • 存在于计算机系统的各个级别
  • 低级机制
    • 1.异常
      • 响应系统事件而改变控制流(即系统状态改变)
      • 结合使用硬件和OS软件来实现
  • 更高级别的机制
    • 2.进程上下文切换
      • 由OS软件和硬件计时器实现
    • 3.信号
      • 由OS软件实现
    • 4.非本地跳转:setjmp()和longjmp()
      • 由C运行库实现

异常

异常
  • 一个异常是响应某些事件(即处理器状态更改)将控制权转移到OS内核。
    • 内核是操作系统的内存驻留部分
    • 事件示例:除以0,算术溢出,页面错误,$\text{I/O}$请求完成,键入Ctrl+C
  • 处理完引起异常的事件的类型,会发生以下三种情况
    • 处理程序将控制返回给当前指令$I_{\text {curr} }$,即当事件发生时正在执行的指令。
    • 处理程序将控制返同给 $I_{\text {next } },$ 如果没有发生异常将会执行的下一条指令。
    • 处理程序终止被中断的程序。

异常表
  • 每种类型的事件都有一个唯一的异常号k​
  • k=异常表的索引(也称为中断向量)
  • 每次发生异常k时都调用程序k

异步异常(中断)
  • 由处理器外部的事件引起
    • 通过设置处理器的中断引脚来指示
    • 处理程序返回到“下一条”指令
  • 例子:
    • 定时器中断
      • 每隔几毫秒,一个外部计时器芯片就会触发一个中断
      • 内核用来从用户程序取回控制权
    • 来自外部设备的$\text{I/O}$中断
      • 在键盘上按Ctrl+C
      • 来自网络的数据包到达
      • 磁盘中的数据到达
同步异常
  • 由执行指令引起
    • 陷阱
      • 有意的异常
      • 示例:系统调用,断点陷阱,特殊说明
      • 将控制权返回“下一条”指令
    • 故障
      • 无意但可能可以恢复
      • 示例:页面错误(可恢复),保护错误(不可恢复),浮点异常
      • 重新执行产生故障的“当前”指令或中止
    • 终止
      • 意外和无法恢复
      • 示例:非法指令,奇偶校验错误,机器检查
      • 中止当前程序
系统调用
  • 每个x86-64系统调用都有一个唯一的ID号
  • 例子:

进程

进程
  • 定义:进程是正在运行的程序的实例。
    • 计算机科学中最深刻的想法之一。
    • 与“程序”或“处理器”不同。
  • 进程为每个程序提供了两个关键的抽象:
    • 逻辑控制流程
      • 每个程序似乎都专用于CPU。
      • 由称为上下文切换的内核机制提供。
    • 专用地址空间
      • 每个程序似乎都专用于主存储器。
      • 由称为虚拟内存的内核机制提供。

多进程
  • 计算机同时运行多个进程
    • 一个或多个用户的应用程序
      • Web浏览器,电子邮件客户端,编辑器……
    • 后台任务
      • 监视网络和$\text{I/O}$设备

进程并发
  • 每个进程都是一个逻辑控制流。
  • 如果两个进程的时间重叠,则两个进程将同时运行(并发)。
  • 否则,它们是顺序的。
  • 例子(运行在单核上):
    • 并发:$A \& B, A \& C$
    • 顺序:$B\& C$
用户视角下的并发
  • 并发进程的控制流在时间上实际上是不相交的
  • 但是,我们可以将并发进程视为彼此并行运行

用户模式和内核模式

为了使操作系统内核提供一个无懈可击的进程抽象,处理器必须提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。处理器通常是用某个控制寄存將中的一个模式位(mode bit)来提供这种功能的,当设置了该模式,进程就运行在内核模式中,该模式下执行指令集中的任何指令;当没有设置模式位时,进程就运行用户模式中,该模式下不允许执行特权指令。

上下文切换
  • 进程由共享的内存驻留OS代码块(称为内核)管理
    • 重要提示:内核不是一个独立的进程,而是作为某些现有进程的一部分运行的。
  • 控制流通过上下文切换从一个进程传递到另一个进程

进程控制

系统调用错误处理
  • 遇到错误时,Linux系统级函数通常返回-1并设置全局变量errno以指示原因。

  • 硬性规定:

    • 必须检查每个系统级函数的返回状态
    • 唯一的例外是少数返回void的函数
  • 例子:

    if ((pid = fork()) < 0) {
        fprintf(stderr, "fork error: %s\n", strerror(errno));
        exit(0);
    }
错误报告函数
  • 使用错误报告函数可以稍微简化一下:

    void unix_error(char *msg) /* Unix-style error */
    {
        fprintf(stderr, "%s: %s\n", msg, strerror(errno));
        exit(0);
    }
  • 调用的代码为:

    if ((pid = fork()) < 0)
        unix_error("fork error");
错误包装处理函数
  • 我们使用Stevens风格的错误处理包装器进一步简化了我们提供的代码:

    pid_t Fork(void)
    {
        pid_t pid;
    
        if ((pid = fork()) < 0)
            unix_error("Fork error");
        return pid;
    }
  • 调用的代码为:

    pid = Fork();
获得进程ID
  • pid_t getpid(void)
    • 返回当前进程的PID
  • pid_t getppid(void)
    • 返回父进程的PID

备注:pid_t在Linux系统上的types.h中被定义为int

创建和终止进程
  • 从程序员的角度来看,我们可以认为进程处于以下三种状态之一
  • 运行
    • 进程正在执行中或正在等待执行,并且最终将由内核调度(即选择执行)
  • 停止
    • 进程执行被暂停,并且只有在另行通知之前才会调度
  • 终止
    • 进程永久停止
终止进程
  • 进程由于以下三个原因之一而终止:
    • 接收到其默认操作将终止的信号
    • 从main函数返回
    • 调用exit函数
  • void exit(int status)
    • 以status退出状态来终止进程
    • 约定:正常返回状态为0,出错时为非零
    • 显式设置退出状态的另一种方法是从主进程返回整数值
  • exit只会被调用一次,但永远不会返回。
创建进程
  • 父进程通过调用fork创建一个新的正在运行的子进程
  • int fork (void)
    • 返回0给子进程,子PID返回给父进程
    • 子进程几乎与父进程相同:
      • 子进程获得父进程虚拟地址空间的相同(但独立)副本。
      • 子进程获得父进程打开文件描述符的相同副本。
      • 子进程的PID与父进程的PID不同。
  • fork很有趣(并且经常令人困惑),因为它被调用了一次,但是返回两次
fork例子
int main()
{
    pid_t pid;
    int x = 1;

    pid = Fork(); 
    if (pid == 0) {  /* Child */
        printf("child : x=%d\n", ++x); 
	    exit(0);
    }

    /* Parent */
    printf("parent: x=%d\n", --x); 
    exit(0);
}
linux> ./fork
parent: x=0
child : x=2
  • 调用一次,返回两次
  • 并发执行
    • 无法预测父进程和子进程的执行顺序
  • 相同但独立的地址空间
    • 当fork在父进程和子进程中返回时,x的值为1
    • x的后续更改是独立的
  • 共享的打开文件
    • 父进程和子进程双方的标准输出相同
使用过程图建模fork
  • 流程图是捕获并发程序中语句的半序的有用工具:
    • 每个顶点都是一条语句的执行
    • a->b表示a在b之前发生
    • 边可以用变量的当前值标记
    • printf顶点可以用输出标记
    • 每个图都以没有入边的顶点开始
  • 图的任何拓扑排序都对应于可行的全序排列。
    • 所有边从左到右指向的顶点的全序排列。
例1
int main()
{
    pid_t pid;
    int x = 1;

    pid = Fork(); 
    if (pid == 0) {  /* Child */
        printf("child : x=%d\n", ++x); 
	    exit(0);
    }

    /* Parent */
    printf("parent: x=%d\n", --x); 
    exit(0);
}
  • 原始图:

  • 排序后的图:

例2
void fork2()
{
    printf("L0\n");
    fork();
    printf("L1\n");
    fork();
    printf("Bye\n");
}

原始图:

例3
void fork4()
{
    printf("L0\n");
    if (fork() != 0) {
        printf("L1\n");
        if (fork() != 0) {
            printf("L2\n");
		}
    }
    printf("Bye\n");
}

原始图:

例4
void fork5()
{
    printf("L0\n");
    if (fork() == 0) {
        printf("L1\n");
        if (fork() == 0) {
            printf("L2\n");
        }
    }
    printf("Bye\n");
}

原始图如下:

回收子进程
  • 理念
    • 当进程终止时,它仍然消耗系统资源
      • 示例:退出状态,各种OS表
    • 被称为“僵死进程”
  • 回收
    • 由父进程对终止的子进程执行(使用wait或waitpid)
    • 父进程被给予退出状态信息
    • 内核然后删除“僵死进程”
  • 如果父进程不回收怎么办?
    • 如果任何父进程在没有回收子进程的情况下终止,那么内核会安排init进程(pid == 1)成为孤儿进程的养父,然后进行回收操作。
    • 因此,长期运行的进程中会进行显式回收,否则会消耗系统的内存资源。
      • 例如,shells和服务器
例1
void fork7() {
    if (fork() == 0) {
        /* Child */
        printf("Terminating Child, PID = %d\n", getpid());
        exit(0);
    } else {
        printf("Running Parent, PID = %d\n", getpid());
        while (1)
            ; /* Infinite loop */
    }
}

运行结果

linux> ./forks 7 &
[1] 6639
Running Parent, PID = 6639
Terminating Child, PID = 6640
linux> ps
  PID TTY          TIME CMD
 6585 ttyp9    00:00:00 tcsh
 6639 ttyp9    00:00:03 forks
 6640 ttyp9    00:00:00 forks <defunct>
 6641 ttyp9    00:00:00 ps
linux> kill 6639
[1]    Terminated
linux> ps
  PID TTY          TIME CMD
 6585 ttyp9    00:00:00 tcsh
 6642 ttyp9    00:00:00 ps
  • ps将子进程显示为“已终止”(即“僵死进程”)
  • 杀死父进程可以让子进程被init回收
例2
void fork8()
{
    if (fork() == 0) {
        /* Child */
        printf("Running Child, PID = %d\n",
               getpid());
        while (1)
            ; /* Infinite loop */
    } else {
        printf("Terminating Parent, PID = %d\n",
               getpid());
        exit(0);
    }
}

运行结果

linux> ./forks 8
Terminating Parent, PID = 6675
Running Child, PID = 6676
linux> ps
  PID TTY          TIME CMD
 6585 ttyp9    00:00:00 tcsh
 6676 ttyp9    00:00:06 forks
 6677 ttyp9    00:00:00 ps
linux> kill 6676
linux> ps
  PID TTY          TIME CMD
 6585 ttyp9    00:00:00 tcsh
 6678 ttyp9    00:00:00 ps

  • 即使父进程已终止,子进程仍处于活动状态
  • 必须明确杀死子进程,否则将无限期运行
wait:与子进程同步
  • 父进程通过调用wait为子进程回收
  • int wait (int * child_status)
    • 暂停当前进程,直到其子进程之一终止
    • 返回值是终止的子进程的pid
    • 如果child_status != NULL,则它将指向的整数将设置为一个值,该值指示子进程终止的原因和退出状态:
      • 使用wait.h中定义的宏进行检查
        • WIFEXITED,WEXITSTATUS,WIFSIGNALED,WTERMSIG,WIFSTOPPED,WSTOPSIG,WIFCONTINUED
例1
void fork9() {
    int child_status;

    if (fork() == 0) {
        printf("HC: hello from child\n");
		exit(0);
    } else {
        printf("HP: hello from parent\n");
        wait(&child_status);
        printf("CT: child has terminated\n");
    }
    printf("Bye\n");
}

进程图如下:

waitpid:等待特定进程
  • pid_t waitpid(pid_t pid, int &status, int options)
    • 挂起当前进程,直到特定进程终止
    • 各种选项(请参阅教科书)
execve:加载和执行程序
  • $\text{int execve(char }\star \text{filename, char }\star \text{argv[], char }\star\text{envp[])}$
  • 在当前进程中加载并运行:
    • 可执行文件filename
      • 可以是以#!interpreter开头的目标文件或脚本文件(例如,#!/bin/bash)
    • 带有参数列表argv
      • 按照约定argv [0]==filename
    • 以及环境变量列表envp
      • “名称=值”字符串(例如USER=droh)
      • getenv,putenv,printenv
  • 覆盖代码,数据和堆栈
    • 保留PID,打开的文件以及信号上下文
  • 调用一次,永不返回
    • 除非有错误
新程序开始时的栈结构

execve例子
  • 在子进程执行$\text{“/bin/ls –lt /usr/include”}$

  • 当前使用环境:

总结

  • 异常
    • 需要非标准控制流程的事件
    • 由外部(中断)或内部(陷阱和故障)生成
  • 进程
    • 在任何给定时间,系统都有多个活动的进程
    • 虽然一次只能在一个内核上执行一个
    • 每个进程似乎都可以完全控制处理器+专用内存空间
  • 复制进程
    • 调用fork
    • 调用一次,返回两次
  • 结束进程
    • 调用exit
    • 一次调用,无return
  • 回收并等待进程
    • 调用wait或waitpid
  • 加载并运行程序
    • 调用execve(或变体)
    • 一次调用,(通常)无返回