第十一周(11.16-11.22):
学习计时:共10小时
读书:5
代码:2
作业:2
博客:1
一、学习目标
从给处理器加电开始,直到断电为止,程序计数器假设一个值的序列 。
a0,a1 …, an-1
其中,每个 ak 是某个相应的指令 ι 的地址。每次从 ak 到如1 的过渡称为控制转移 。这样的控制转移序列叫做处理器的控制流。
最简单的一种控制流是一个"平滑的"序列,其中每个Ik 和 Ik+1 在存储器中都是相邻的。
现代系统通过使控制流发生突变来对这些情况做出反应,这些突变称为异 常控制流 (Exceptional Control Flow, ECF)。异常控制流发生在计算机系统的各个层次。在硬件层,硬件检测到的事件会触发控制突然转移到异常处理程序。在操作系统层,内核通过上 下文转换将控制从一个用户进程转移到另一个用户进程。在应用层,一个进程可以发送信号到另一个进程,而接收者会将控制突然转移到它的一个信号处理程序。一个程序可以通过回避通常的栈规则,并执行到其他函数中任意位置的非本地跳转来对错误做出反应。
异常位于硬件和操作系统交界的部分。系统调用,它们是为应用程序提供到操作系统的入口点的异常。抽象的层次,描述进程和信号,它们位于应用和操作系统的交界之处。非本地跳转,这是 ECF 的一种应用层形式。
异常是异常控制流的一种形式,它一部分是由硬件实现的,一部分是由操作系统实现的。有一部分是由硬件实现的,所以具体细节将随系统的不同而有所不同。
异常(exception )就是控制流中的突变,用来响应处理器状态中的某些变化。
在处理器中,状态被编码为不同的位和信号。状态变化称为事件 (event)。 事件可能和当前指令的执行直接相关。 在任何情况下,当处理器检测到有事 件发生时,它就会通过一张叫做异常在 (exception table)时的跳转表,进行一个间接过程调用(异常),到一个专门设计用 来处理这类事件的操作系统子程序(异常处理程序 (exception handler))。
当异常处理程序完成处理后,根据引起异常的事件的类型,会发生以下三种情况中的一种:
系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号。
其中一些号码是由处理器的设计者分配的:包括被零除、缺页、存储器访问违例、断点以及算术溢出。
其他号码是由操作系统内核(操作系统常驻存储器的部分〉的设计者分配的:包括系统调用和来自外部I/O 设备的信号。
在系统启动时(当计算机重启或者加电时),操作系统分配和初始化一张称为异常在的跳转表,使得条目 k包含异常 k的处理程序的地址。
在运行时(当系统在执行某个程序时),处理器检测到发生了一个事件,并且确定了相应的异常号k。随后,处理器触发异常,方法是执行间接过程调用,通过异常表的条目 k转到相应的处理程序。
异常号是到异常表中的索引,异常表的起始地址放在一个叫做异常在 基址寄存器 (exception table base register) 的特殊 CPU 寄存器里。
异常类似于过程调用,但是有一些重要的不同。
一旦硬件触发了异常,剩下的工作就是由异常处理程序在软件中完成。在处理程序处理完事件之后,它通过执行一条特殊的"从中断返回"指令,可选地返回到被中断的程序,该指令将适当的状态弹回到处理器的控制和数据寄存器中,如果异常中断的是一个用户程序,就将状态恢复为用户模式,然后将控制返回给被中断的程序。
异常可以分为四类:中断(interrupt)、陷阱 (trap)、故障 (fault) 和终止 (abort)。
中断是异步发生的,是来自处理器外部的I/O 设备的信号的结果。
异常的类别:
(陷阱、故障和终止)是同步发生的,是执行当前指令的结果。我们把这类 指令叫做故障指令 (faulting instructiott)。
陷阱是有意的异常,是执行一条指令的结果。
陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。
用户程序经常需要向内核请求服务,比如读一个文件(read)、创建一个新的进程 任何k)、加载一个新的程序(execve)时,或者终止当前进程(exit)。为了允许对这些内核服务的受控的访问,处理器提供了一条特殊的 "syscall n" 指令,当用户程序想要请求服务 n 时,可以执行这条指令。执行 syscall 指令会导致一个到异常处理程序的陷阱,这个处理程序对参数解码,并调用适当的内核程序。
从程序员的角度来看,系统调用和普通的函数调用是一样的。然而,它们的实现是非常不同的。普通的函数运行在用户模式 (user mode) 中,用户模式限制了函数可以执行的指令的类型,而且它们只能访问与调用函数相同的栈。系统调用运行在内核模式 (kernel mode) 中,内核模式允许系统调用执行指令,并访问定义在内核中的栈。
故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它。否则,处理程序返回到内核中的 abort 例程, abort 例程会终止引起故障的应用程序。
故障处理:
终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,终止处理程序从不将控制返回给应用程序。
终止处理:
除法错误。当应用试图除以零时,或者当一个除法指令的结果对于目标操作数来说太大了 的时候,就会发生除法错误(异常0)。Unix不会试图从除法错误中恢复,而是选择中止程序。Linux外壳通常会把除法错误报告为"浮点异常" (Floating exception)。
IA32 系统中的异常示例:
一般保护故障。许多原因都会导致不为人知的一般保护故障(异常13),通常是因为一个程序引用了一个未定义的虚拟存储器区域,或者因为程序试图写一个只读的文本段。Linux不会尝试恢复这类故障。Linux外壳通常会把这种一般保护故障报告为"段故障.. (segmentation fault).
缺页(异常 14) 是会重新执行产生故障的指令的一个异常示例。处理程序将磁盘上物理存 储器相应的页面映射到虚拟存储器的一个页面,然后重新开始这条产生故障的指令。
机器检查。机器检查(异常 18) 是在导致故障的指令执行中检测到致命的硬件错误时发生 的。机器检查处理程序从不返回控制给应用程序。
每个系统调用都有一个唯一的整数号,对应于一个到内核中跳转表的偏移量。
Linux/IA32 系统中常用的系统调用示例:
在IA32 系统上,系统调用是通过一条称为 int n 的陷阱指令来提供的,其中 n 可能是 IA32 异常表中 256 个条目中任何一个的索引。
C 程序用 syscall 函数可以直接调用任何系统调用。然而,实际中几乎没必要这么做。对 于大多数系统调用,标准 C 库提供了一组方便的包装函数。这些包装函数将参数打包到一起, 以适当的系统调用号陷入内核,然后将系统调用的返回状态传递回调用程序。系统调用和与它们相关联的包装函数称为系统级函数。
异常是允许操作系统提供进程 (process) 的概念所需要的基本构造块。
系统中的每个程序都是运行在某个进程的上 下文 (context) 中的。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在存储器 中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件 描述符的集合。
应用程序的关键抽象:
程序计数器 (PC) 的 值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或者是包含在运行时动态链接 到程序的共享对象中的指令。这个 PC 值的序列叫做逻辑控制流,或者简称逻辑流。
逻辑控制流:
逻辑控制流关键点在于进程是轮流使用处 理器的。每个进程执行它的流的一部分,然 后被抢占 (preempted) (暂时挂起),然后轮 到其他进程。
进程为每个程序提供了一种假象,好像程序在独占地使用处理器。每个竖直的条表示一个进程的逻辑控制流的一部分
一个逻辑流的执行在时间上与另一个流重叠,称为并发流 (concurrent fiow) ,这两个流被称 为并发地运行。
多个流并发地执行的一般现象称为并发 (concurrency)。一个进程和其他进程轮流运行的 概念称为多任务 (multitasking). 一个进程执行它的控制流的一部分的每一时间段叫做时间片 (time slice)。因此,多任务也叫做时间分片(time slicing)。
两个流并发地运行在不同的处理器核或者计算机 上,那么我们称它们为并行流 (parallel flow),它们并行地运行 (running in parallel),且并行地 执行 (parallel execution)。
n 位地址的机器上,地祉空间是 2n次方个可能地址的集合。一个进程为每个程序提供它自己的 私有地址空间。一般而言,和这个空间中某个地址相关联的那个存储器字节是不能被其他进程读 或者写的,从这个意义上说,这个地址空间是私有的。 尽管和每个私有地址空间相关联的存储器的内容一般是不同的,但是每个这样的空间都有相 同的通用结构。
###8.2.4 用户模式和内核模式
处理器通常是用某个控制寄存器中的一个模式位 (mode bit 来提供这种功能的,该寄存器 描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中(有时叫做超级用户 模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中任何存 储器位置。
进程地址空间:
没有设置模式位时,进程就运行在用户模式中。
运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。
/proc 文件系统,它允许用户模式进程访问内核数据结构的内容。 /proc 文件系统将许多内核数据结构的内容输出为一个用户程序可以读的文 本文件的层次结构。
操作系统内核使用一种称为上下文切换 (context switch) 的较高层形式的异常控制流来实现多任务。
内核为每个进程维持一个上下文 (context)。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、 用户栈、状态寄存器、内核栈和各种内核数据结构,比如描绘地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程。这种决定就叫做调度(schedule),是由内核中称为调度器(scheduler )的代码处理的。当内核选择一个新的进程运行时,我们就说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换
1)保存当前进程的上下文
2)恢复某个先前被抢占的进程被保存的上下文
3)将控制传递给这个新恢复的进程。
当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为等待某个事件发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程。
一般而言,即使系统调用没有阻塞,内核也可以决定执行上下文切换,而不是将控制返回给调用进程。
中断也可能引发上下文切换
进程上下文切换的剖析:
磁盘取数据要用一段相对较长的时间(数量级为几十毫秒),所以内核执行从进程 A 到进程 B 的上下文切换,而不是在这个间歇时间内等待,什么都不做。
当 Unix 系统级函数遇到错误时,它们典型地会返回-1,并设置全局整数变量 errno 来表示什么出错了。
用错误处理包装 (error-handling wrapper)函数更进一步地简化我们的代码。
Unix提供了大量从 C 程序中操作进程的系统调用。
每个进程都有一个唯一的正数(非零)进程 ID (PID). getpid 函数返回调用进程的 PID. getppid 函数返回它的父进程的 PID (创建调用进程的进程)。
getpid 和 getppid 函数返回一个类型为 pid_t 的整数值,在 Linux 系统上它在 types. h 中被定义为 int。
三种状态:
exit 函数以 status 迫出状态来终止进程(另一种设置退出状态的方法是从主程序中返回 一个整数值〉。
父进程通过调用 fork 函数创建一个新的运行子进程 。
新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同 的(但是独立的)一份拷贝,包括文本、数据和 bss 段、堆以及用户栈。子进程还获得与父进程任何打开文件描述符相同的拷贝,这就意味着当父进程调用fork。此时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的 PID。
fork只被调用一次,却会返回两次 : 一次是在调用进程(父进程〉中,一次是在新创建的子进程中。在父进程中, fork 返回子进程的 PID。在子进程中, fork 返回 0。因为子进程的 PID 总是非零的,返回值就提供一个明确的方 法来分辨程序是在父进程还是在子进程中执行。
当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种己终止的状态中,直到被它的父进程回收。
一个终止了但还未被回收的进程称为僵死进程。
如果父进程没有回收它的僵死子进程就终止了,那么内核就会安排 init 进程来回收它们。 init 进程的 PID 为1,并且是在系统初始化时由内核创建的。
长时间运行的程序,总是应该回收它们的僵死子进程。即使僵死子进程没有运行,它们仍然消耗系统的存储器资源。
一个进程可以通过调用 waitpid 函数来等待它的子进程终止或者停止。
waitpid:
默认地(当 options = 0 时), waitpid 挂起调用进程的执行, 直到它的等待集合中的一个子进程终止。如果等待集合中的一个进程在刚调用的时刻就已经终止了,那么 waitpid 就立即返回。在这两种情况下, waitpid 返回导致 waitpid 返回的已终止子进程的 PID,并且将这个已终止的子进程从系统中去除。
可以通过将 optioins 设置为常量 WNOHANG 和 WUNTRACED 的各种组合,修改默认行为:
如果 status 参数是非空的,那么 waitpid 就会在 status 参数中放上关于导致返回的子进程的状态信息:
如果调用进程没有子进程,那么 waitpid 返回-1,并且设置 errno 为 ECHILD。如果 waitpid 函数被一个信号中断,那么它返回一1,并设置 errno 为 EINTR。
调用 wait(&status) 等价于调用 waitpid(-l, &status, 0) 。
程序不会按照特定的顺序回收子进程。子进程回收的顺序是这台特定的计算机的属性。在另一个系统上,甚至在同一个系统上再执行一次,两个子进程都可能以相反的顺序被回收。这是非确定性的。
sleep 函数将一个进程挂起一段指定的时间。
如果请求的时间量已经到了, sleep 返回 0,否则返回还剩下的要休眠的秒数。
pause 函数,该函数让调用函数休眠,直到该进程收到一个信号。
###8.4.5 加载并运行程序
execve 函数在当前进程的上下文中加载并运行一个新程序。
execve 函数加载并运行可执行目标文件 filename,且带参数列表 argv 和环境变量列表 envp。只有当出现错误时execve 才会返回到调用程序。所以,与 fork 一次调用返回两次不同, execve 调用一次并从不返回。
外壳先是一个交互型的应用级程序,它代表用户运行其他程序,最早的外壳是 sh 程序,后面出现了一些变种,比如 csh、 tcsh、 ksh 和 bash. 外壳执行一系列的读/求值(read/evaluate) 步骤,然后终止。读步骤读取来自用户的一个命令行。求值步骤解析命令行,并代表用户运行程序。
Unix 信号,它允许进程中断其他进程。
一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。
Linux信号:
每种信号类型都对应于某种系统事件。低层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了这些异常。
传送一个信号到目的进程是由两个不同步骤组成的 :
一个只发出而没有被接收的信号叫做待处理信号 (pending signal)。 在任何时刻,一种类型至多只会有一个待处理信号。
一个进程可以有 选择性地阻塞接收某种信号。当一 种信号被阻塞时,它仍可以被发送, 但是产生的待处理信号不会被接收, 直到进程取消对这种信号的阻塞。
一个待处理信号最多只能被接收一次。
Unix 系统提供了大量向进程发送信号的机制。所有这些机制都是基于进程纽 (process group) 这个概念的。
getpgrp:
一个子进程和它的父进程同属于一个进程组。一个进程可以通过使用 setpgid 函数来改变自己或者其他进程的进程组:
setpgid 函数将进程 pid 的进程组改为 pgid。如果 pid 是 0,那么就使用当前进程的 PID。 如果 pgid 是 0,那么就用 pid 指定的进程的 PID 作为进程组 ID。
/bin/kill 程序可以向另外的进程发送任意的信号。
Unix 外壳使用作业 (job) 这个抽象概念来表示为对一个命令行求值而创建的进程。在任何 时刻,至多只有一个前台作业和 0 个或多个后台作业。
外壳为每个作业创建一个独立的进程组。
进程通过调用 kill 函数发送信号给其他进程(包括它们自己)。
如果 pid 大于零,那么 kill 函数发送信号 sig 给进程 pid。如果 pid 小于零,那么 kill 发送信号 sig 给进程组 abs (pid) 中的每个进程。
进程可以通过调用 alarm 函数向它自己发送 SIGALRM 信号。
alarm:
alarm 函数安排内核在 secs 秒内发送一个 SIGALRM 信号给调用进程。如果 secs 是零,那么不会调度新的闹钟 (alarm)。在任何情况下,对 alarm 的调用都将取消任何待处理的 (pending) 闹钟,并且返回任何待处理的闹钟在被发送前还剩下的秒数〈如果这次对 alarm 的 调用没有取消它的话),如果没有任何待处理的闹钟,就返回零。
当内核从一个异常处理程序返回,准备将控制传递给进程p 时,它会检查进程p 的未被阻塞 的待处理信号的集合 (pending&-blocked)。如果这个集合为空(通常情况下),那么内核将 控制传递到p 的逻辑控制流中的下一条指令。
然而,如果集合是非空的,那么内核选择集合中的某个信号 k (通常是最小的 k), 并且强制 p接收信号 k.
每个信号类型都有一个预定义的默认行为,是下面中的一种:
signal 函数可以通过下列三种方法之一来改变和信号 signum 相关联的行为:
不同系统之间,信号处理语义的差异〈比如一个被中断的慢速系统调用是重启还是永久放弃) 是 Unix 信号处理的一个缺陷。为了处理这个问题, Posix 标准定义了 sigaction 函数,它允许像 Linux 和 Solaris 这样与 Posix 兼容的系统上的用户,明确地指定他们想要的信号处理语义。
sigaction 函数:
Signal 包装函数设置了一个信号处理程序,其信号处理语义如下:
sigprocmask 函数改变当前已阻塞信号的集合,具体的行为依赖于 how 的值:
sigemptyset 初始化 set 为空集。 sigfillset 函数将每个信号添加到 set 中。 sigaddset 函数添加 signum 到 set, sigdelset 从 set 中删除 signum,如果 signum 是 set 的成员,那么 sigismember 返回 1,否则返回 0。
addjob 和 deletejob 函数分别向这个作业列表添加和从中删除作业。
###8.6 非本地跳转
C 语言提供了一种用户级异常控制流形式,称为非本地跳转 (nonlocal jump),它将控制直 接从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用-返回序列。非本 地跳转是通过 setjmp和 longjmp 函数来提供的。
setjmp 函数在 env 缓冲区中保存当前调用坏境,以供后面 longjmp 使用,并返回 o. 调 用环境包括程序计数器、樵指针和通用目的寄存器。
longjmp 函数从 env 缓冲区中恢复调用环境,然后触发一个从最近一次初始化 env 的 setjmp调用的返回。然后 setjmp返回,并带有非霉的返回值 retval.
setjmp 函数只被调用一次, 但返回多次:一次是当第一次调用 setjmp,而调用环境保存在缓冲区 env 中时:一次是为每个相应的 longjmp 调用。另一方面, longjmp 函数被调用一次,但从不返回。
非本地跳转的另一个重要应用是使一个信号处理程序分支到一个特殊的代码位置,而不是 返回到被信号到达中断了的指令的位置。
在程序第一次启动时,对 sigsetjmp 函数的初始调用保存调用环境和信号的上下文(包 括待处理的和被阻塞的信号向量)。随后,主函数进入一个无限处理循环。当用户键人 ctrl-c 时,外壳发送一个 SIGINT 信号给这个进程,该进程捕获这个信号。不是从信号处理程序返回, 如果是这样信号处理程序会将控制返回给被中断的处理循环,反之,处理程序执行一个非本地跳转,回到主函数的开始处。
异常控制流 (ECF) 发生在计算机系统的各个层次,是计算机系统中提供并发的基本机制。
在硬件层,异常是由处理器中的事件触发的控制流中的突变。控制流传递给一个软件处理程 序,该处理程序进行一些处理,然后返回控制给被中断的控制流。
有四种不同类型的异常 : 中断、故障、终止和陷阱。
中断会异步地发生。控制返回到故障指令后面的那条指令。一条指令的执行可能导致故障和终止同时发生。故障处理程序会重新启动故障指令,而终止处理程序从不将控制返回给被中断的流。最后,陷阱就像是用来实现向应用提供到操作系统代码的受控的人口点的系统调用的函数调用。
操作系统层,内核用 ECF 提供进程的基本概念。进程提供给应用两个重要的抽象;1)逻 辑控制流,它提供给每个程序一个假象,好像它是在独占地使用处理器, 2) 私有地址空间,它提供给每个程序一个假象,好像它是在独占地使用主存。
操作系统和应用程序之间的接口处,应用程序可以创建子进程,等待它们的子进程停止或者终止,运行新的程序,以及捕获来自其他进程的信号。
信号处理的语义是微妙的,并且随系统不同而不同。然而,在与 Posix 兼容的系统上存在着一些机制,允许程序清楚地指定期望的信号处理语义。
应用层, C 程序可以使用非本地跳转来规避正常的调用/返回栈规则,并且直接从一个函数分支到另一个函数。