chapter08_异常控制流

本文介绍了异常控制流(ECF)的概念,包括中断、陷阱、故障和终止,以及它们在处理器和操作系统中的处理方式。还详细讲解了进程的基本概念,如进程的定义、上下文、并发性和地址空间,以及用户模式和内核模式的区别。此外,讨论了上下文切换、系统调用、错误处理、进程控制(如fork和exit)以及信号机制。最后提到了非本地跳转在异常处理中的应用。
  1. 基本概念

    1. 控制流

       a0, a1, ..., an-1
      

      其中,ai代表指令Ii的地址。ai->ai+1的过渡称为控制转移,这样的控制转移队列称为控制流

    2. ECF(Exceptional Control Flow, 异常控制流)

      现代os通过使得控制流发生突变,来对特殊情况做出反应,称为异常控制流

    3. ECF发生在计算机系统的各个层次

       硬件层 --- 硬件检测到的事件会触发控制转移到异常处理程序
      
       操作系统层 --- 内核上下文切换将控制从一个用户进程转向另一个用户进程
      
       应用层 --- 一个进程向另一个进程发**信号**,接收进程会将控制转向信号处理程序
      
    4.  异常 --- 硬件和操作系统交界
      
       进程和信号 --- 应用和操作系统交界
      
       非本地跳转(例如try/catch/throw) --- 应用层
      
  2. 异常

    1. 处理器的状态变化称为事件

      (1) 事件可能和当前指令相关

       eg. 缺页、除0
      

      (2) 事件可能和当前指令无关

       eg. 定时器信号、IO请求完成
      
    2. 异常处理的原理

      (1) 系统为每种可能出现的异常分配一个异常号

      (2) 在系统启动时,系统初始化一个异常表:异常表的索引是异常号,对应的是异常处理程序的地址

      (3) 异常处理程序运行在内核态

    3. 异常的类别

      (1) 中断

      1° IO设备(网络适配器、磁盘控制器、定时器),向处理器芯片的一个管脚发信号触发中断

      2° 唯一一种异步类型的异常

      (2) 陷阱

      有意的异常,目标是用户程序向内核请求服务,陷阱提供了这个接口

      2° 执行

       syscall n 
      

      指令,请求某个内核服务(例如读文件read、创建新进程fork、加载一个新程序execve、终止当前进程exit)

      系统调用的过程类似函数调用。但是函数调用在用户模式下,系统调用在内核模式

      (3) 故障

      1° 执行故障处理程序,如果能修复就重新执行,修复不了就返回内核的abort例程终止程序

      2° 典型的故障异常是缺页异常

      (4) 终止

      1° 典型的终止异常是硬件错误

      2° 终止处理程序会直接返回内核的abort例程终止程序

      (5) 总结

      类别原因同步or异步返回行为
      中断IO设备的信号异步总是返回到下一条指令
      陷阱有意进行的系统调用同步总是返回到下一条指令
      故障潜在可恢复的错误同步要么返回到当前指令重新执行,要么abort终止应用程序
      终止不可恢复的错误同步abort终止应用程序
  3. 进程

    1. 基本概念

      (1) 定义

      一个执行中程序的实例。

      系统中的每个程序都是运行在某个进程的上下文

      (2) 上下文(context)

      由程序正确运行所需的状态组成。

      包括:

       1° 存储器中程序的代码和数据
      
       2° 栈
      
       3° 通用目的寄存器中的内容
      
       4° 程序计数器
      
       5° 环境变量
      
       6° 打开文件的描述符集合
      

      (3) 进程提供的抽象

      1° 一个独立的逻辑控制流

       ---- 提供了一个假象,让人们觉得程序在独占处理器
      

      2° 一个私有的地址空间

       ---- 提供了一个假象,让人们觉得程序在独占存储器系统
      
    2. 逻辑控制流

      (1) 进程轮流使用处理器,然后被抢占

       A    ------
      
       B          -----
      
       C               --
      
       A                 -------
      

      但是每个进程看上去都像是在独占处理器

      (2) 并发进程

      任何逻辑流在时间上和其他逻辑流有重叠的进程之间称为并发进程

      eg.

       A 和 B
      
       A 和 C
      
       但是 B 和 C 就不是并发进程
      

      (3) 时间片

      一个进程在执行它的控制流的一部分的每一时间段叫做时间片

    3. 私有地址空间

      (1) 一个进程为每个程序提供它自己的私有地址空间,一般而言,和这个空间中某个地址相关联的存储器字节不可被读写

      (2) 对于n位地址的机器,地址空间是2^n个可能地址的集合

      (3) 每个地址空间的结构类似

      顶部1/4预留给内核,底部3/4预留给用户程序,包括通常的文本、数据、堆、栈。

    4. 用户模式和内核模式

      (1) 内核模式

      一个运行在内核模式的进程,可以

      1° 执行指令集中的任何指令

      2° 访问任何存储器的位置

      (2) 用户模式

      1° 不允许执行特权指令

      2° 不允许直接使用地址空间中的内核区的代码和数据 —> 必须通过系统调用接口的方式

      (3) 初始时应用程序都在用户模式,进程从用户模式到内核模式的唯一方法是通过异常

    5. 上下文切换

      (1) 上下文是内核重新启动一个被抢占进程所需的状态,由一些对象的值组成,包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈、各种内核数据结构(页表、进程表、文件表等)

       页表 --- 描述地址空间
      
       进程表 --- 包含有关当前进程信息
      
       文件表 --- 包含进程已打开文件的信息
      

      (2) 调度

      内核中的调度器决定抢占当前进程,并重新开始一个先前被抢占的进程

      (3) 上下文切换

      工作:

      1° 保存当前进程的上下文

      2° 恢复某个先前被抢占进程的上下文

      3° 将控制传递给这个新恢复的进程

      典型的发生时刻:

      1° 系统调用

      2° 阻塞

      3° 定时器中断

      (4) 一般而言,cache不能和中断/上下文切换这样的ECF很好的交互,cache中的数据往往在ECF之后被污染,难以存放有效数据

  4. 系统调用和错误处理

    1. man syscalls

      查看完整的系统调用列表

    2. 标准C库提供了一组针对常用系统调用的包装函数,一般直接用这种包装函数。系统调用和它们的包装函数可以互换的称为系统级函数

    3. 当Unix系统级函数遇到错误时,会典型的返回-1,并设置全局整数变量errno来表示错误类型

    4. Unix风格的错误处理包装函数(自己写的)

       #include <stdio.h>
       #include <stdlib.h>
       #include <unistd.h>
       #include <sys/types.h>
       #include <errno.h>
       #include <string.h>
      
       pid_t Fork(void) {
           pid_t pid;
           if (pid = fork() < 0) {
               unix_error("Fork error");
           }
      
           return pid;
       }
      
       void unix_error(char* msg) {
           fprintf(stderr, "%s: %s\n", msg, strerror(errno));
      
           exit(0);
       }
      

      在错误处理包装函数Fork()中,既完成了和fork()相同的功能,同时记录错误类型,并exit程序。

  5. 进程控制

    1. 系统调用函数可以靠

       man xxx
      

      的方式获取必须包含的include文件

    2. 获取进程pid

      (1) 每个进程都有一个唯一的正数PID

      (2) 相关系统调用

       #include <unistd.h>
       #include <sys/types.h>
      
       pid_t getpid(void)    --- 返回调用进程的pid
       pid_t getppid(void)   --- 返回调用进程父进程的pid
      
    3. 创建和终止进程

      (1) 每个进程一定处于以下三种状态之一

      运行

      在CPU上执行或者等待被调度

      暂停

      进程的执行被挂起

       运行 --- 收到 SIGSTOP/SIGTSTP/SIDTTIN 信号 --- 暂停 --- 收到 SIGCONT 信号 --- 运行
      

      信号是一种软件中断

      终止

      进程永远的停止了。有三种原因:

      I. 收到一个信号,这个信号的默认行为是终止进程

      II. 从主程序返回

      III. 调用 exit() 函数

      (2) 相关系统调用

       #include <stdlib.h>
      
       void exit(int status);    --- 终止进程,并以status状态退出(在主程序中return status也可以设置退出状态)
      
       ---------
      
       #include <unistd.h>
       #include <sys/types.h>
      
       pid_t fork(void);  --- 父进程创建新的运行子进程
      

      (3) 关于fork系统调用

      1° fork函数会被调用1次,但是会返回2次:一次是在父进程中返回,一次是在子进程中返回

      2° 出错时fork函数的返回值是-1,不出错时子进程中的返回值为0,父进程中的返回值为子进程的PID,以此来区分父子进程

       示例
      
       #include <stdio.h>
       #include <stdlib.h>
       #include <unistd.h>
       #include <sys/types.h>
       #include <errno.h>
       #include <string.h>
      
       void unix_error(char* msg) {
           fprintf(stderr, "%s: %s\n", msg, strerror(errno));
           exit(0);
       }
      
       pid_t Fork(void) {
      
           pid_t pid;
           if (pid = fork() < 0) {
               unix_error("Fork error");
           }
           return pid;
       }
      
       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);
      
           return 0;
       }
      

      3° 父子进程最大的区别在于PID不同

      4° 子进程得到与父进程用户级虚拟地址空间相同的一份拷贝(但是独立的),包括文本、数据、bss段、堆、用户栈、打开的文件描述符(子进程可以读写父进程打开的任意文件)。它们拥有相同(fork刚调用完)但独立(从此以后空间私有)的地址空间

      5° 父子进程是并发执行独立进程

      6° 画进程图的方式可以方便理解fork函数

       int main() {
           int x = 1;
           if (fork() == 0) {
               printf("f1: x = %d\n", ++x);
           }
      
           printf("f2: x = %d\n", --x);
           exit(0);
       }
                子进程  ------------x = 1---> f1, x=2 ----> f2, x=1 ---->
                       |
                       |fork()
                       |
       ---------父进程--------------x = 1---> f2, x=1 ---->
      
    4. 回收子进程

      (1) 当进程终止时,内核并不是立刻清除,而是保持终止状态,直到被它的父进程回收

      (2) 父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程,此时开始子进程才不复存在

      (3) 僵尸进程

      已经终止但是未被回收的进程

      (4) 若父进程没有回收它的僵尸子进程就终止了,内核会安排init进程(PID恒等于1)来回收僵尸子进程

      (5) 等待子进程终止或暂停(然后将子进程从系统中去除)

       #include <sys/types.h>
       #include <sys/wait.h>
      
       pid_t waitpid(pid_t pid, int* status, int options);
      

      (6) 检查已回收子进程的退出状态

      P519 有几个宏,例如

       WIFEXITED(status);
       WIFSIGNALED(status);
      

      (7) 示例

       #include <sys/types.h>
       #include <unistd.h>
       #include <stdlib.h>
       #include <wait.h>
       #include <stdio.h>
       #include <errno.h>
      
       #define N 2
      
       int main() {
      
           int status, i;
      
           pid_t pid[N+1], retpid;
      
           for (int i = 0; i < N; i++) {
               if ((pid[i] = fork()) == 0) { // child process
                   exit(100 + i);
               }
           }
      
           for (int i = 0; i < N; i++) {
               printf("%d\n", pid[i]);
           }
      
           // parent process collects N zombie child processes in order
           i = 0;
           while ((retpid = waitpid(pid[i++], &status, 0)) > 0) {
      
               // judge if a process exited by exit(status) or return
               if (WIFEXITED(status)) {
                   printf("child %d terminated normally with exit status %d\n", retpid, WEXITSTATUS(status));
               } else {
                   printf("child %d terminated abnormally\n", retpid);
               }
           }
      
           exit(0);
       }
      
    5. 让进程休眠

       #include <unistd.h>
      
       unsigned int sleep(unsigned int secs);  // 休眠一段secs秒,直到休眠结束或是被信号中断提前返回;返回剩余要休眠的秒数
      
       int pause(void);  // 让调用函数休眠,直到被信号唤醒
      
    6. 加载并运行程序

      (1)

       #include <unistd.h>
       
       int execve(char* filename, char* []argv, char* []envp);
      
       filename: 可执行目标文件名
       argv: 参数列表数组,以null结尾,argv[0]是文件名
       envp: 环境变量数组,以null结尾,形式是"name=value"
      

      几个操作环境变量数组的系统调用

       #include <stdlib.h>
      
       char* getenv(const char* name);
       int setenv(const char* name, const char* newvalue, int overwrite);
      
       void unsetenv(const char* name);
      

      (2) 程序与进程

      程序:代码和数据的集合。可以作为目标模块存在于磁盘上,也可以作为段存在于地址空间。

      进程:执行中程序的一个特殊实例。 —> 程序总是运行在某个进程的上下文中

      fork: 在新的子进程中,运行相同的程序。 – 调用一次,返回两次

      execve: 在当前进程的上下文中,运行一个新的程序,覆盖当前进程的地址空间,具有相同的PID,并且继承了调用函数的所有打开的文件描述符。 – 调用一次,成功了就不返回

      (3) Unix shell 的原理

       while (1) {
      
           read_user_input();   // 读取用户输入
      
           ifBackGround, cmd, argv, env = parse_input();  // 解析用户输入
      
           // 如果是内置shell命令(例如pwd这种),立刻解释这个命令
           if (built_in_cmd(cmd)) {
               do_built_in_cmd(cmd);
               continue;
           }
      
           pid = fork();    // fork()出一个子进程,用于执行非内置的可执行文件
      
           // 子进程中调用execve将地址空间完全替换成新程序
           if (pid == 0) {
               execve(argv[0], argv, env);
               exit(0);
           }
      
           // 父进程根据用户输入的是否在后台执行的请求,决定是否等待子进程返回再进行下一轮循环
           if (!ifBackGround) {
               waitpid(pid, &status, 0);
           }
       }
      
  6. 信号

    1. 低级的硬件异常是由内核异常处理程序处理的,通常对用户进程不可见。信号提供了一种机制,向用户进程通知这些异常的发生。

      eg.

       某个进程除零错误 ---> 内核给它发送 SIGFPE 信号
      
    2. Linux系统上支持30种不同类型的信号,它们有号码,名字,默认行为,相应事件(P529)

      示例

       号码      名字      默认行为   相应事件
      
        9      SIGKILL      终止     杀死程序
      
    3. 步骤

      (1) 发送信号

      1° 内核通过更新目的进程上下文的某个状态,将信号发送给目的进程

      2° 发送信号的可能原因

      I. 内核检测到系统事件(eg. 除零错误、子进程终止)

      II. 一个进程调用了kill函数,显式要求内核发送信号给目的进程

      进程组

      I. 每个进程都属于一个进程组

      II. 默认地,一个子进程和它的父进程同属于一个进程组

      III. 改变进程组

       #include <unistd.h>
      
       pid_t setpgid(pid_t pid, pid_t pgid);
      

      4° 发送信号的方式

      I. 用kill程序发送信号

       unix> kill .9 12213    // 发送信号9(对应着SIGKILL)给PID为12213的进程
      
       unix> kill .9 .12213    // 发送信号9(对应着SIGKILL)给PGID为12213的进程组的每个进程
      

      II. 从键盘发送信号

       ctrl-c    ----  SIGINT, 终止前台作业
       
       ctrl-z    ----  SIGTSTP, 暂停前台作业
      

      III. 用kill函数发送信号

       #include <sys/types.h>
       #include <signal.h>
      
       int kill(pid_t pid, int sig);  // 发送sig信号给pid进程,如果pid小于0,发送给abs(pid)的进程组
      

      IV. 用 alarm 函数发送信号

       #include <unistd.h>
      
       unsigned int alarm(unsigned int secs);  // 安排内核在secs秒内,发送一个SIGALRM信号给调用进程
      

      示例

       #include <unistd.h>
       #include <signal.h>
       #include <stdio.h>
       #include <stdlib.h>
      
       void handler(int sig) {
      
           static int beeps = 0;
           printf("beep\n");
      
           if (++beeps < 5) {
               alarm(1);
           } else {
               printf("boom\n");
               exit(0);
           }
       }
      
       int main() {
      
           signal(SIGALRM, &handler);
           alarm(1);
      
           while(1) {
               ;
           }
      
           exit(0);
       }
      

      (2) 接收信号

      待处理信号阻塞信号

      一个只发出而没有被接收的信号称为待处理信号,任何时刻一种类型的待处理信号只有一个,如果一种类型已经有一个待处理信号了,那么同一类型的其他信号会被直接丢弃

      一个进程可以选择性阻塞某种类型信号的接收,此时这种类型的信号可以发送但是不会被接收。

      2° 原理

      内核每个进程在pending位向量中维护着待处理信号集合,在blocked位向量中维护着被阻塞信号集合

      每次只要一个类型为k的信号被传送,内核就检查一下blocked对应的位,如果没有被阻塞就set pending的第k个位;只要一个类型为k的信号被接收,内核就会在pending中reset第k个位

      3° 每个信号类型都有一个预定义的默认行为 P529

      I. 进程终止

      II. 进程终止并转储存储器

      III. 进程暂停直到被SIGCONT信号重启

      IV. 进程忽略该信号

      4° 改变和信号相关联的行为 – signal函数

       #include <signal.h>
      
       typedef void handler_t(int)
      
       handler_t* signal(int signum, handler_t* handler);   // 若成功则返回前次信号处理程序的函数指针
      

      I. 如果handler是SIG_IGN,那么忽略signum的信号

      II. 如果handler是SIG_DFL,那么signum的信号恢复默认行为

      III. 否则,handler是用户定义的函数指针——称为信号处理程序

    4. 信号处理的问题

      (1) 待处理信号被阻塞

      一个k信号正在被handler处理,同样的k信号到达,只能在待处理信号集合中等待

      (2) 待处理信号不会排队等待

      一个k信号在待处理集合中,同样后来的k信号再到达时,直接丢弃

      (3) 系统调用可以被中断

      类似read/write/accept一类的慢速系统调用,在某些系统中(例如Solaris)一旦被中断且handler返回以后,慢速系统调用不再继续而是直接返回错误

    5. 可移植的信号处理

      (1) 想要解决的问题:

      中断后的慢速系统调用重启or放弃,在不同的系统上表现不一致

      (2) 解决办法:POSIX标准定义的sigaction函数

       #include <signal.h>
      
       int sigaction(int signum, struct sigaction* act, struct sigaction* oldact);
      

      一般使用的是它的Wrapper函数Signal

       handler_t *Signal(int signum, handler_t *handler) {
      
           struct sigaction action, old_action;
      
           action.sa_handler = handler;  
           
           sigemptyset(&action.sa_mask); /* Block sigs of type being handled */
      
           action.sa_flags = SA_RESTART; /* Restart syscalls if possible */
      
           if (sigaction(signum, &action, &old_action) < 0) {
               unix_error("Signal error");
           }
      
           return (old_action.sa_handler);
       }
      

      Signal函数的语义是:

      I. 只有这个处理程序当前正在处理的那种类型的信号被阻塞

      II. 信号不会排队,直接丢弃

      III. 只要可能,被中断的系统调用会自动重启

      IV. 一旦设置了信号处理程序就会一直保持,直到Signal带着handler参数为SIG_IGN或者SIG_DFL被调用

    6. 显式地阻塞信号

       #include <signal.h>
      
       int sigpromask(int how, const sigset_t* set, sigset_t* oldset);
      
       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);
      
  7. 非本地跳转

    1. 作用

      将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用-返回序列

    2. 系统调用

       #include <setjmp.h>
      
       int setjmp(jmp_buf env);
       int sigsetjmp(sigjmp_buf env, int savesigs);
      
      
       void longjmp(jmp_buf env, int retval);
       void siglongjmp(sigjmp_buf env, int retval);
      

      (1) setjmp和sigsetjmp函数在env缓冲区中保存当前栈的内容,返回0

      (2) longjmp和siglongjmp会从env缓冲区中恢复栈的内容,然后触发从最近一次初始化env的setjmp调用的返回,然后从setjmp返回,并带有非0的返回值retval

      (3) 关系

      1° setjmp函数只被调用一次,但返回多次——一次是第一次调用setjmp,后面是每个相应的longjmp

      2° longjmp函数只被调用一次,从不返回

    3. 非本地跳转的应用

      (1) 从深层嵌套的函数调用中立刻返回,而不是费力解开各层栈

       #include <setjmp.h>
       #include <stdio.h>
       #include <stdlib.h>
      
       jmp_buf buf;
      
       int error1 = 0; 
       int error2 = 1;
      
       void foo(void), bar(void);
      
       int main() {
      
           int rc;
           rc = setjmp(buf);
      
           if (rc == 0) {
               foo();
           } else if (rc == 1) {
               printf("Detected an error1 condition in foo\n");
           } else if (rc == 2) {
               printf("Detected an error2 condition in foo\n");
           } else {
               printf("Unknown error condition in foo\n");
           }
      
           exit(0);
       }
      
       /* Deeply nested function foo */
       void foo(void) {
      
           if (error1) {
               longjmp(buf, 1); 
           }
      
           bar();
       }
      
       void bar(void) {
           if (error2) {
               longjmp(buf, 2); 
           }
       }
      

      (2) 中断发生时,使一个信号处理程序分支到一个特殊位置,而不是返回到被信号中断了的指令的位置

      下面这个示例程序的效果是按下ctrl-c时程序重新到sigsetjmp指令处

       #include <stdlib.h>
       #include <stdio.h>
       #include <setjmp.h>
       #include <signal.h>
       #include <unistd.h>
      
       sigjmp_buf buf;
      
       void handler(int sig) {
           siglongjmp(buf, SIGINT);
       }
      
       int main() {
      
           signal(SIGINT, &handler);
      
           if (!sigsetjmp(buf, SIGINT))  {
               printf("starting\n");
           } else {
               printf("restarting\n");
           }
      
           while(1) {
               sleep(1);
               printf("processing...\n");
           }
      
           exit(0);
       }
      
    4. C++和Java中提供的异常机制是更高层次

      catch 类似于 setjmp 的封装

      throw 类似于 longjmp 的封装

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值