异常控制流

来源:互联网 发布:魔兽世界更新传输数据 编辑:程序博客网 时间:2024/05/21 14:01

从给处理器加电开始,直到断电为止,程序计数器假设一个值的序列a\0,a\1,…a\n-1,其中,每个a\k是某个相应的指令I\k的地址。每次从a\k到a(k+1)的过渡称为控制转移。这样的控制转移序列叫做处理器的控制流

现代系统通过使控制流发生突变来对这些情况作出反应,这些突变称为异常控制流(ECF)。异常控制流发生在计算机系统的各个层次。比如,在硬件层,硬件检测到的事件会触发控制突然转移到异常处理程序。在操作系统层,内核通过上下文切换将控制从一个用户进程转移到另一个进程。在应用层,一个进程可以发送信号到另一个进程,而接收者会将控制突然转移到它的一个信号处理程序。

异常:是异常控制流的一种形式,它一部分是由硬件实现的,一部分是由操作系统实现的。

异常就是控制流的突变,用来响应处理器状态中的某些变化。

系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号。在系统启动时,操作系统分配和初始化一张称为异常表的跳转表,使得条目k包含异常k的处理程序的地址。异常号是到异常表中的索引

异常的类型:

中断: 来自I/O设备的信号    异步    总是返回到下一条指令

陷阱是有意的异常,是执行一条指令的结果。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。系统调用运行在内核模式中,内核模式允许系统调用执行指令,并访问定义在内核中的栈。

Linux提供上百种系统调用,当应用程序想要请求内核服务时可以使用,包括读文件、写文件或是创建一个新进程

进程的经典定义就是一个执行中的程序的实例。系统中的每个程序都是运行在某个进程的上下文中的。上下文是由程序正确运行所需的状态组成的。

我们用单步调试器单步执行程序,我们会看到一系列的程序计数器(PC)的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或者是包含在运行时动态链接到程序的共享对象中的指令。PC值的序列叫做逻辑控制流

私有地址空间:进程也为每个程序提供一种假象,好像它独占地使用系统地址空间。在一台有n位地址的机器上,地址空间是2\n个可能地址的集合。一个进程为每个程序提供它自己的私有地址空间。

地址空间组织结构:地址空间底部是保留给用户程序的,包含通用的文本、数据、堆和栈段。地址空间顶部是保留给内核的。

处理器通常用某个控制寄存器中的一个模式位提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。当设置了模式位,进程就运行在内核模式中(或超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中任何存储器位置

操作系统内核使用一种称为上下文切换的异常控制流来实现多任务。内核为每个进程维持一个上下文。
上下文切换

  • 保持当前进程的上下文
  • 恢复某个先前被抢占的进程被保存的上下文
  • 将控制传递给这个新恢复的进程

系统调用错误处理

当Unix系统级函数遇到错误时,它们典型地会返回-1,并设置全局整数变量errno来表示什么出错了

进程控制

  • Unix提供了大量从C程序中操作进程的系统调用

    获取进程ID:每个进程都有一个唯一的正数(非负)进程ID(PID)。getpid函数返回调用进程的PID,getppid函数返回它的父进程的PID

    #include <stdlib.h>void exit(int status);

exit函数以status退出状态来终止进程

父进程通过调用fork函数创建一个新的运行子进程

#include <sys/types.h>#include <unistd.h>pid_t fork(void);返回:子进程返回0,父进程返回子进程的PID,如果出错,则为-1

父进程与新创建的子进程之间最大的区别在于它们有不同的PID

fork函数只被调用一次,却会返回两次;一次是在调用进程(父进程)中,fork返回子进程的PID,在子进程中,fork返回0,子进程的PID总是非零的

#include "csapp.h"int main(){    pid_t pid;    int x = 1;    pid = Fork();    if(pid ==0)    {        printf("child:x=%d\n",++x);        exit(0);    }    printf("parent:x=%d\n",--x);    exit(0);}

这个简单的例子有一些微妙的方面

  • 调用一次,返回两次。fork函数被父进程调用一次,但是却返回两次。一次返回到父进程,一次是返回到新创建的子进程
  • 并发执行,父进程和子进程是并发运行的独立进程。内核能够以任意方式交替执行它们的逻辑控制流中的指令。作为程序员,我们不能对不同进程中指令的交替执行做任何假设
  • 相同的但是独立的地址空间。如果能够在fork函数在父进程和子进程中返回后立即暂停这两个进程,我们会看到每个进程的地址空间都是相同的。每个进程都有相同的用户栈、相同的本地变量值,相同的堆,相同的全局变量值以及相同的代码。当fork函数在第8行返回时,本地变量x在父进程和子进程中都为1。然而,父进程和子进程都是独立的进程,它们都有自己的私有地址空间。父进程和子进程对x所做的任何改变都是独立的,不会反映在另一个进程的存储器中。

如果父进程没有回收它的僵尸子进程就终止了,那么内核就会安排init进程来回收它们。init进程的PID为1,并且是在系统初始化时由内核创建的。长时间运行的程序,总是应该回收它们的僵尸子进程。即使僵尸子进程没有运行,它们仍然消耗系统的存储器资源

一个进程可以通过调用waitpid函数来等待它的子进程终止或者停止

#include <sys/types.h>#include <sys/wait.h>pid_t waitpid(pid_t pid,int* status,int options);返回:如果成功,则为子进程的PID,如果WNOHANG,则为0,如果其他错误,则为-1

默认地(当options=0时),waitpid挂起调用进程的执行,直到它的等待集合中的一个子进程终止。如果等待集合中的一个进程在刚调用的时候就已经终止了,那么waitpid就立即返回,在这两种情况下,waitpid返回导致waitpid返回的已终止子进程的PID,并且将这个已终止的子进程从系统中去除。

如果调用进程没有子进程,那么waitpid返回-1,并且设置errno为ECHILD。如果waitpid函数被一个信号中断,那么它返回-1,并设置errno为EINTR

让进程休眠

sleep函数将一个进程挂起一段指定的时间

#include <unistd>unsigned int sleep(unsigned int secs);int pause(void);

pause函数让调用函数休眠,直到该进程收到一个信号

加载并运行程序

execve函数在当前进程的上下文中加载并运行一个新程序

#include <unistd.h>int execve(const char* filename,const char* argv[],const char* envp[]);如果成功,则不返回,如果错误,则返回-1

execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。所以,与fork一次调用返回两次不同,execve调用一次并从不返回。

fork函数在新的子进程运行相同的程序,新的子进程是父进程的一个复制品。execve函数在当前进程的上下文中加载并运行一个新的程序。它会覆盖当前进程的地址空间,但没有创建一个新进程,新的程序仍然有相同的PID,并且继承了调用execve函数时已打开的所有文件描述符

信号:它允许进程中断其他进程

一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件

  • 每种信号类型都对应于某种系统事件。底层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程而言是不可见的。
  • 信号提供了一种机制,通知用户进程发生了这些异常。
    • 比如,当一个子进程终止或者停止时,内核会发一个SIGCHLD信号给父进程
    • 如果一个进程试图除以0,那么内核就发送给它一个SIGFPE信号

传送一个信号到目的进程由两个不同步骤组成:

  • 发送信号:内核通过更新目的进程上下文中的某个状态,发送一个信号给目的进程。发送信号可以有如下两个原因:
    • 内核检测到一个系统事件,比如被零除错误或者子进程终止
    • 一个进程调用了kill函数,显式的要求内核发送一个信号给目的进程
  • 接收信号:当目的进程被内核强迫以某种方式对信号的发送作出反应时,目的进程就接收了信号。
    • 进程可以忽略这个信号,终止或者通过执行一个称为信号处理程序的用户层函数捕获这个信号

发送信号:Unix系统提供了大量向进程发送信号的机制,所有这些机制都是基于进程组这个概念的

  • 进程组:每个进程只属于一个进程组,进程组由一个正整数进程组ID来标识的

    #include <unistd.h>

    pid_t getpgrp(void);返回:调用进程的进程组ID

默认地,一个子进程和它的父进程同属一个进程组,一个进程可以使用setpgid函数来改变自己或者其它进程的进程组:

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

shell使用作业(job)这个抽象概念来表示为对一个命令行求值而创建的进程。在任何时刻,至多只有一个前台作业和0个或多个后台作业

接收信号

  • 当内核从一个异常处理程序返回,准备将控制传递给进程p时,它会检查进程p的未被阻塞的待处理信号的集合。如果这个集合为空,那么内核将控制传递到p的逻辑控制流的下一条指令
  • 如果集合是非空,那么内核选择集合中的某个信号k(通常是最小的k),并且强制p接收信号k。收到这个信号会触发进程的某种行为。一旦进程完成了这个行为,那么控制就传递回p的逻辑控制流中的下一条指令
  • 信号处理程序的执行中断main函数的执行,类似于底层异常处理程序中断当前应用程序的控制流方式。因为信号处理程序的逻辑控制流与主函数的逻辑控制流重叠,信号处理程序和主函数并发地运行

信号处理问题:

  • 待处理信号被阻塞:Unix信号处理程序通常会阻塞当前处理程序正在处理的类型的待处理信号。假设一个进程捕获了一个SIGINT信号,并且当前正在运行它的SIGINT处理程序,如果另一个SIGINT信号传递到这个进程,那么这个SIGINT将变成待处理的,但是不会被接收,知道处理程序返回
  • 待处理信号不会排队等待:任意类型至多只有一个待处理信号。因此,如果有两个类型为k的信号传送到一个目的进程,而由于目的进程当前正在执行信号k的处理程序,所以信号k是阻塞的,那么第二个信号就被简单地丢弃,它不会排队等待。关键思想是存在一个待处理的信号仅仅表明至少已经有一个信号到达了
  • 系统调用可以被中断。像read、wait和accept这样的系统调用潜在地会阻塞进程一段较长的时间,称为慢速系统调用。在某些系统中,当处理程序捕获到一个信号时,被中断的慢速系统调用在信号处理程序返回时不再继续,而是立即返回给用户一个错误条件,并将errno设置为EINTR

可移植的信号处理:不同系统之间,信号处理语义的差异(比如一个被中断的慢速系统调用时重启还是永久放弃)是Unix信号处理的一个缺陷。为了处理这个问题,Posix(可移植操作系统接口,POSIX标准定义了操作系统应该为应用程序提供的接口标准)标准定义了sigaction函数,明确的指定他们想要的信号处理语义

#include <signal.h>int sigaction(int signum,struct sigaction *act,struct sigaction *oldact);返回:若成功则为0,若出错则为-1

定义一个包装函数Signal,它调用sigaction。它的调用方式与signal函数的调用方式一样。Signal包装函数设置了一个信号处理程序,其信号语义如下:
- 只有这个处理程序当前正在处理的那种类型的信号被阻塞
- 和所有信号实现一样,信号不会排队等待
- 只要可能,被中断的系统调用会自动重启
- 一旦设置了信号处理程序,它会一直保持,直到Signal带着handler参数为SIG_IGN或者SIG_DFL被调用

handler_t *Signal(int signum,handler_t *handler){    struct sigaction action, old_action;    action.sa_handler  = handler;    sigemptyset(&action.sa_mask);    action.sa_flags = SA_RESTART;    if(sigaction(signum,&action,&old_action) < 0)    {        unix_error("Signal error");    }return (old_action.sa_handler);}

显式地阻塞和取消阻塞信号
- 应用程序可以使用sigprocmask函数显式地阻塞和取消阻塞选择的信号

同步流以避免讨厌的并发错误

  • 如何编写读写相同存储位置的并发流程序的问题

在一个fork调用之后,有些内核调度子进程先运行,而有些内核调度父进程先运行。Fork包装函数,它可以帮助暴露这样隐藏着的关于父进程和子进程执行顺序的假设。其基本思想是每次调用fork之后,父进程和子进程扔一枚硬币决定谁会休眠一会儿,因此给另一个进程先运行的机会

非本地跳转

  • C语言提供一种用户级异常控制流形式,称为非本地跳转,它将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用-返回序列。非本地跳转是通过setjmp和longjmp函数来提供的

    #include <setjmp.h>int setjmp(jmp_buf env);int longjmp(jmp_buf env,int retval); 

setjmp函数在env缓冲区中保存当前调用环境,以供后面longjmp使用,并返回0.调用环境包括程序计数器、栈指针和通用目的寄存器。

longjmp函数从env缓冲区中恢复调用环境,然后触发一个从最近一次初始化env的setjmp调用返回,然后setjmp返回,并带有非零的返回值retval

setjmp函数只被调用一次,但返回多次。一次是当第一次调用setjmp,而调用环境保存在缓冲区env中时;一次是为每个相应的longjmp调用。longjmp函数被调用一次,但从不返回

非本地跳转的一个重要应用就是允许从一个深层嵌套的函数调用中立即返回,通常是由检测到某个错误情况引起的。使用非本地跳转来规避正常的调用/返回栈规则

操作进程的工具

top
ps

原创粉丝点击