CSAPP学习笔记 -- 第八章 异常控制流

现代系统通过使控制流发生突变来对这些情况做出反应。一般而言,我们把这些突变称为异常控制流(Fxceptional Control Flow,ECF)。
  • 理解ECF将帮助你理解重要的系统概念
  • 理解ECF将帮助你理解应用程序是如何与操作系统交互的
  • 理解ECF将帮助你编写有趣的新应用程序
  • 理解ECF将帮助你理解并发。
  • 理解ECF将帮助你理解软件异常如何工作
 
8.1 异常

异常是异常控制流的一种形式,它一部分由硬件实现,一部分由操作系统实现。
异常(exccption)就是控制流中的突变,用米响应处理器状态中的某些变化。
在任何情况下,当处理器检测到有事件发生时,它就会通过一张叫做异常表(exception table)的跳转表,进行一个间接过程调用(异常),到一个专门设计用来处理这类事件的操作系统子程序(异常处理程序(exception handler))。当异常处理程序完成处理后,根据引起异常的事件的类型,会发生以下3种情况中的一种:
1)处理程序将控制返回给当前指令larn,即当事件发生时正在执行的指令。
2)处理程序将控制返回给lew,如果没有发生异常将会执行的下一条指令。
3)处理程序终止被中断的程序。
 
8.1.1 异常处理
系统中的每种类型的异常都分配了一个唯一的非负整数的异常号
  • 一些由处理器设计者分配,如:被零除、缺页、内存访问违例、断点、算术运算溢出
  • 另一些是由操作系统内核设计者分配的,如:系统调用、来自外部I/O设备的信号
 
在系统启动时,操作系统分配和初始化一张异常表,使得表目k包含异常k的处理程序的地址
异常表的起始地址放在一个叫做异常表基址寄存器的特殊CPU寄存器里
 
异常与过程调用的区别
  • 过程调用时,在跳转到处理程序之前,处理器将返回地址压入栈中。然而,根据异常的类型,返回地址要么是当前指令(当事件发生时正在执行的指令),要么是下一条指令(如果事件不发生,将会在当前指令后执行的指令)。
  • 处理器也把一些额外的处理器状态压到栈里,在处理程序返回时,重新开始执行被中断的程序会需要这些状态。比如,x86-64系统会将包含当前条件码的EFLAGS寄存器和其他内容压入栈中。
  • 如果控制从用户程序转移到内核,所有这些项目都被压到内核栈中,而不是压到用户栈中。
  • 异常处理程序运行在内核模式下(见8.2.4节),这意味着它们对所有的系统资源都有完全的访问权限。
 
一旦硬件触发了异常,剩下的工作就是由异常处理程序在软件中完成。在处理程序处理完事件之后,它通过执行一条特殊的“从中断返回”指令,可选地返回到被中断的程序,该指令将适当的状态弹回到处理器的控制和数据寄存器中,如果异常中断的是一个用户程序,就将状态恢复为用户模式(见8.2.4节),然后将控制返回给被中断的程序。
 
8.1.2 异常的类别

  • 中断
  • 陷阱和系统调用
    • 陷阱的重要用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用
    • 普通的函数运行在用户模式中,用户模式限制了函数可以执行的指令的类型,而且它们只能访问与调用函数相同的栈。系统调用运行在内核模式中,内核模式允许系统调用执行特权指令,并访问定义在内核中的栈。
  • 故障
  • 终止
 
8.1.3 Linux/x86-64系统中的异常
  • Linux/x86-64 故障和终止
    • 除法错误
    • 一般保护故障
    • 缺页
    • 机器检查
  • Linux/x86-64 系统调用
 
 
8.2 进程

进程的经典定义就是一个执行中程序的实例。
系统中的每个程序都运行在某个进程的上下文中。
 
进程提供给应用程序的关键抽象
  • 一个独立的逻辑控制流,提供一个程序独占处理器的假象
  • 一个私有的地址空间,提供一个程序独占内存系统的假象
 
8.2.1 逻辑控制流
PC值的序列叫做逻辑控制流。
 
8.2.2 并发流
一个逻辑流的执行在时间上与另一个流重叠,称为并发流(concurrent flow),这两个流被称为并发地运行。
 
注意,并发流的思想与流运行的处理器核数或者计算机数无关。
 
8.2.3 私有地址空间
进程为每个程序提供它自己的私有地址空间,一般而言,和这个控件某个地址相关联的那个内存字节是不能被其他进程读或者写的,从这个意义上来说,这个地址空间是私有的。
 
8.2.4 用户模式和内核模式
为了使操作系统内核提供一个无懈可击的进程抽象,处理器必须提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。
处理器通常是用某个控制寄存器中的一个模式位(mode bit)来提供这种功能的,该寄存器描述了进程当前享有的特权。
 
一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。
 
用户模式中的进程不允许执行特权指令(privileged instruction),比如停止处理器、改变模式位,或者发起一个I/O操作。也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。任何这样的尝试都会导致致命的保护故障。用户程序必须通过系统调用接口间接地访问内核代码和数据。
 
运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。
 
8.2.5 上下文切换
操作系统内核使用一种称为上下文切换(context switch)的较高层形式的异常控制流来实现多任务。
 
内核为每个进程维持一个上下文(context)。
上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用日的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构。
 
上下文切换
  • 保存当前进程的上下文
  • 恢复某个先前被抢占的进程被保存的上下文
  • 将控制传递给这个新恢复的进程
 
如果系统调用因为等待某个事件发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程。
 
 
8.3 系统调用错误处理

错误报告函数
错误处理包装函数
 
 
8.4 进程控制

8.4.1 获取进程ID
每个进程都有一个唯一的正数(非0)进程ID(PID)
#include <sys/types.h>
#include <unistd.h>
 
pid_t getpid(void);    //返回调用进程的PID
pid_t getppid(void);    //返回创建调用进程的进程(即父进程)的PID
在Linux系统上 pid_t 被定义为 int 类型
 
8.4.2 创建和终止进程
进程状态
  • 运行
  • 停止
    • 执行中进程被挂起,且不会被调度
    • 当收到 SIGSTOP / SIGTSTP / SIGTTIN / SIGTTOU 时进程停止,知道收到 SIGCONT 信号后再次开始运行
  • 终止
    • 进程永远停止
    • 收到终止信号、从主程序返回、调用exit函数都会导致进程终止
#include <stdlib.h>
void exit(int statues);    //status表示退出状态
 
父进程通过fork函数创建子进程
#include <sys/types.h>
#include <unistd.h>
 
pid_t fork(void);
父子进程之间
  • 最大区别在于PID不同
  • 用户级虚拟地址空间相同但是相互独立,包括代码和数据段、堆、共享库以及用户栈
  • 子进程获得与父进程任何打开文件描述符相同的副本
  • 并发执行
 
fork()函数
  • 调用一次,返回两次
  • 子进程返回0,父进程返回子进程的PID
  • 嵌套fork的进程图
 
8.4.3 回收子进程
当一个进程终止后,称为僵死进程,父进程会对它进行回收,内核将子进程的退出状态传递给父进程,然后抛弃这个已终止的进程。
 
init进程
  • 所有父进程的养父,由它回收孤儿进程
  • PID = 1
  • 系统启动时创建,不会终止,是所有进程的祖先
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int* statusp, int options);
//返回:如果成功,则为子进程的PID,如果WNOHANG,则为0,如果其他错误,则为一1。
waitpid()函数
  • 默认情况下,即当options=0时,waitpid挂起调用进程的执行,直到它的等待集合中的一个子进程终止
  • 如果一个等待集合中的进程在刚调用waitpid时就已经终止,那么waitpid立即返回
  • waitpid返回已终止子进程的PID
  • 如果参数pid=-1,那么等待集合就是由父进程的所有子进程组成
 
wait()函数等待子进程,返回子进程PID或-1
 
8.4.4 让进程休眠
#include <unistd.h>
 
unsigned int sleep(unsigned int secs);    //返回剩余休眠秒数
int pause(void);    //返回-1
 
8.4.5 加载并运行程序
#include <unistd.h>
int execve(const char* filename, const char* argv[], const char* envp[]);
//如果成功,则不返回,如果错误,则返回一1。
execve()函数
  • execve函数在当前进程的上下文中加载并运行一个新的程序。它会覆盖当前进程的地址空间,但并没有创建一个新进程。新的程序仍然有相同的PID,并且继承了调用execve函数时已打开的所有文件描述符。
 
8.4.6 利用fork和execve运行程序
  • 图8-24展示了对命令行求值的代码。它的首要任务是调用parseline函数(见图8-25),这个函数解析了以空格分隔的命令行参数,并构造最终会传递给execve的argv向量。第一个参数被假设为要么是一个内置的shell命令名,马上就会解释这个命令,要么是一个可执行目标文件,会在一个新的子进程的上下文中加载并运行这个文件。
  • 如果最后一个参数是一个“&”字符,那么parseline返回1,表示应该在后台执行该程序(shell不会等待它完成)。否则,它返回0,表示应该在前台执行这个程序(shell会等待它完成)。
  • 在解析了命令行之后,eva1函数调用builtincommand函数,该函数检查第一个命令行参数是否是一个内置的shell命令。如果是,它就立即解释这个命令,并返回值1。否则返回0。简单的shell只有一个内置命令—quit命令,该命令会终止shell。实际使用的shell有大量的命令,比如pwd、jobs和fg。
  • 如果builtin command返回0,那么shell创建一个子进程,并在子进程中执行所请求的程序。如果用户要求在后台运行该程序,那么shell返回到循环的顶部,等待下一个命令行。否则,shell使用waitpid函数等待作业终止。当作业终止时,shell就开始下一轮迭代。
 
 
8.5 信号

一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。
每种信号类型都对应于某种系统事件。
低层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程而言是不可见的。
信号提供了一种机制,通知用户进程发生了这些异常。
 
 
8.5.1 信号术语
  • 发送信号
    • 内核检测到一个系统事件,比如除零错误或者子进程终止
    • 一个进程调用了kill函数,显式地要求内核发送一个信号给目的进程
    • 一个进程可以发送信号给它自己
  • 接收信号
    • 当目的进程被内核强迫以某种方式对信号的发送做出反应时,它就接收了信号。
    • 进程可以忽略这个信号,终止或者通过执行一个称为信号处理程序(signal  handler)的用户层函数捕获这个信号。
 
8.5.2 发送信号
  • 进程组
    • 每个进程都只属于一个进程组
    • 进程组是由一个正整数进程组ID来标识的
    • pid_t getpgrp(void); 返回当前进程的进程组ID
    • int setpgid(pid_t pid, pid_t pgid); 改变进程的进程组,成功返回0,否则返回-1
  • 用/bin/kill 程序发送信号
  • 从键盘发送信号
  • 用kill函数发送信号
    • int kill(pid_t pid, int sig); 
    • pid大于0,发送信号号码sig给进程pid
    • pid等于0,发送信号sig给调用进程所在进程组中的每个进程,包括自己
    • pid小于0,发送信号sig给进程组 |pid| 中的每个进程
  • 用alarm函数发送信号
    • unsigned int alarm(unsigned int secs);
    • 取消待处理闹钟
    • 在secs秒后发送SIGALRM信号给调用进程
    • 返回上次闹钟剩余秒数或0
 
8.5.3 接收信号
当内核把进程p从内核模式切换到用户模式时(例如,从系统调用返回或是完成了一次上下文切换),它会检查进程p的未被阻塞的待处理信号的集合(pending  &~blocked)。
如果这个集合为空(通常情况下),那么内核将控制传递到p的逻辑控制流中的下一条指令。然而,如果集合是非空的,那么内核选择集合中的某个信号k(通常是最小的k),并且强制p接收信号k。收到这个信号会触发进程采取某种行为。一旦进程完成了这个行为,那么控制就传递回p的逻辑控制流中的下一条指令。每个信号类型都有一个预定义的默认行为,是下面中的一种:
  • 进程终止。
  • 进程终止并转储内存。
  • 进程停止(挂起)直到被SIGCONT信号重启。
  • 进程忽略该信号。
 
进程可以通过使用signal函数修改和信号相关联的默认行为。唯一的例外是SIGSTOP和SIGKILL,它们的默认行为是不能修改的。
#include <signal.h>
typedef void(*gighandler_t)(int);
sighandler_t signal(int signun, sighandler_t handler);
//返回:若成功则为指向前次处理程序的指针,若出错则为SIG_ERR(不设置errno)。
signa1函数可以通过下列三种方法之一来改变和信号signum相关联的行为:
  • 如果handler是SIG_IGN,那么忽略类型为signum的信号。
  • 如果handler是SIG_DFL,那么类型为signum的信号行为恢复为默认行为。
  • 否则,handler就是用户定义的函数的地址,这个函数被称为信号处理程序,只要进程接收到一个类型为signun的信号,就会调用这个程序。通过把处理程序的地址传递到signal函数从而改变默认行为,这叫做设置信号处理程序。调用信号处理程序被称为捕获信号。执行信号处理程序被称为处理信号。
 
信号处理程序可以被其他信号处理程序中断。
 
8.5.4 阻塞和解除阻塞信号
Linux提供阻塞信号的隐式和显式的机制
  • 隐式阻塞机制
    • 内核默认阻塞任何当前处理程序正在处理信号类型的待处理的信号。
    • 例如,图8-31中,假设程序捕获了信号s,当前正在运行处理程序S。如果发送给该进程另一个信号s,那么直到处理程序S返回,s会变成待处理而没有被接收。
  • 显式阻塞机制
    • 应用程序可以使用sigprocmask函数和它的辅助函数,明确地阻塞和解除阻塞选定的信号。
 
8.5.5 编写信号处理程序
处理程序有几个属性使得它们很难推理分析
  • 处理程序与主程序并发运行,共享同样的全局变量,因此可能与主程序和其他处理程序互相干扰
  • 如何以及何时接收信号的规则常常有违人的直觉
  • 不同的系统有不同的信号处理语义
 
编写安全、正确和可移植的信号处理程序的一些基本规则
  • 安全的信号处理
    • 编写处理程序的原则
      • G0.处理程序要尽可能简单。
        • 避免麻烦的最好方法是保持处理程序尽可能小和简单。例如,处理程序可能只是简单地设置全局标志并立即返回;所有与接收信号相关的处理都由主程序执行,它周期性地检查(并重置)这个标志。
      • G1.在处理程序中只调用异步信号安全的函数。
        • 所谓异步信号安全的函数(或简称安全的函数)能够被信号处理程序安全地调用,原因有二:要么它是可重入的(例如只访问局部变量,见12.7.2节),要么它不能被信号处理程序中断。图833列出了Linux保证安全的系统级函数。注意,许多常见的函数(例如 printf、sprintf、malloc和exit)都不在此列。★write()在
      • G2.保存和恢复errno。
        • 许多Linux异步信号安全的函数都会在出错返回时设置errno。在处理程序中调用这样的函数可能会干扰主程序中其他依赖于errno的部分。解决方法是在进入处理程序时把errno保存在一个局部变量中,在处理程序返回前恢复它。注意,只有在处理程序要返回时才有此必要。如果处理程序调用exit终止该进程,那么就不需要这样做了。
      • G3.阻塞所有的信号,保护对共享金局数据结构的访问。
        • 如果处理程序和主程序或其他处理程序共享一个全局数据结构,那么在访问(读或者写)该数据结构时,你的处理程序和主程序应该暂时阻塞所有的信号。这条规则的原因是从主程序访问一个数据结构d通常需要一系列的指令,如果指令序列被访问d的处理程序中断,那么处理程序可能会发现d的状态不一致,得到不可预知的结果。在访问d时暂时阻塞信号保证了处理程序不会中断该指令序列。
      • G4.用volatile声明全局变量。
        • 考虑一个处理程序和一个main函数,它们共享一个全局变量g。处理程序更新g,main周期性地读g。对于一个优化编译器而言,main中g的值看上去从来没有变化过,因此使用缓存在寄存器中g的副本来满足对g的每次引用是很安全的。如果这样,main函数可能永远都无法看到处理程序更新过的值。
        • 可以用volatile类型限定符来定义一个变量,告诉编译器不要缓存这个变量。例如:volatile int g;volatile限定符强迫编译器每次在代码中引用g时,都要从内存中读取g的值。一般来说,和其他所有共享数据结构一样,应该暂时阻塞信号,保护每次对全局变量的访问。
    • G5.用sig_atomic_t声明标志。
      • 在常见的处理程序设计中,处理程序会写全局标志来记录收到了信号。主程序周期性地读这个标志,响应信号,再清除该标志。对于通过这种方式来共享的标志,C提供一种整型数据类型sig atomict,对它的读和写保证会是原子的(不可中断的),因为可以用一条指令来实现它们:volatile sig_atomic_t flag;
  • 正确的信号处理
    • 未处理的信号是不排队的
    • 因为pending位向量中每种类型的信号只对应有一位,所以每种类型最多只能有一个未处理的信号。
    • 关键思想是如果存在一个未处理的信号就表明至少有一个信号到达了。
    • 重要教训:不可以用信号来对其他进程中发生的事件计数
  • 可移植的信号处理
 
8.5.6 同步流以避免讨厌的并发错误(略、设计并发编程)
 
8.5.7显式地等待信号(略)
 
 
8.6 非本地跳转

非本地跳转(nonlocaljump),它将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用-返回序列。
非本地跳转是通过setjmp和longjmp函数来提供的。
#include<setjmp.h>
int setjmp(jmp_buf env);
int sigsetjmp(sigjmp_buf env, int savesigs);
//返回:setjmp返回0,longjmp返回非零。
  • setjmp函数在env缓冲区中保存当前调用环境,以供后面的longjmp使用,并返回0。
  • longjmp函数从env缓冲区中恢复调用环境,然后触发一个从最近一次初始化env的setjmp调用的返回。然后setjmp返同,并带有非零的返回值retval。
  • setjmp函数只被调用一次,但返回多次:一次是当第一次调用setjmp,而调用环境保存在缓冲区env中时,一次是为每个相应的1ongimp调用。
  • longjmp函数被调用一次,但从不返回。
 
非本地跳转的一个重要应用就是允许从一个深层嵌套的函数调用中立即返回,通常是由检测到某个错误情况引起的。如果在一个深层嵌套的函数调用中发现了一个错误情况,我们可以使用非本地跳转直接返回到一个普通的本地化的错误处理程序,而不是费力地解开调用栈。
非本地跳转的另一个重要应用是使一个信号处理程序分支到一个特殊的代码位置,而不是返回到被信号到达中断了的指令的位置。
 
要注意只在siglongjmp可达的代码中调用安全的函数。
 
 
8.7 操作进程的工具

  • Linux系统提供了大量的监控和操作进程的有用工具。
  • STRACE:打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。对于好奇的学生而言,这是一个令人着迷的工具。用-static编译你的程序,能得到一个更干净的、不带有大量与共享库相关的输出的轨迹。
  • PS:列出当前系统中的进程(包括僵死进程)。
  • TOP:打印出关于当前进程资源使用的信息。
  • PMAP:显示进程的内存映射。
  • /proc:一个虚拟文件系统,以ASCII文本格式输出大量内核数据结构的内容,用户程序可以读取这些内容。比如,输入“cat/proc/loadavg”,可以看到你的linux系统上当前的平均负载。
 
posted @ 2020-10-16 18:50  Yoke_cc  阅读(138)  评论(0编辑  收藏  举报