Jialong's Blog
沉潜 自由 追寻幸福
Csapp 8 异常控制流
书籍《深入理解计算机系统》阅读学习笔记

系统要对系统状态的变化作出反应,这些系统状态不是被内部程序变量捕获的,而且也不一定要和程序的执行相关。比如,一个硬件定时器定期产生信号,这个事件必须得到处理。包到达网络适配器后,必须存放在内存中。

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

理解ECP有很多用处:

  • 帮助理解重要的系统概念
  • 理解应用程序如何与操作系统交互。应用程序通过使用称为陷阱或系统调用的ECF形式,向操作系统请求服务。例如,向磁盘写数据、从网络读取数据、创建一个新进程,都是通过应用程序调用系统调用实现的。
  • 帮助理解并发。ECF是计算机系统中实现并发的基本机制。
  • 帮助理解软件异常如何工作。例如C++使用try、catch以及throw语句来提供软件异常机制。软件异常允许程序进行非本地跳转(即违反通常的调用/返回栈规则的跳转)来响应错误情况。

异常

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

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

事件(event):状态的变化。事件可能和当前指令的执行直接相关,比如发生虚拟内存缺页、算术溢出等。

处理器检测到有事件发生时,就通过异常表的跳转表,进行一个间接过程调用,到一个异常处理程序进行处理。处理完后,根据引起异常的事件类型,会发生以下三种情况的一种:

  • 处理程序将控制返回给当前指令
  • 处理程序将控制返回给下一条指令
  • 处理程序终止被中断的程序

异常处理

系统中可能为每种类型的异常都分配一个唯一的非负整数的异常号。其中一些号码由处理器设计者提供,另一些由操作系统内核的设计者分配。前者的例子包括:缺页、内存访问违规、被零除及算术运算溢出。后者的例子包括:系统调用和来自外部I/O设备的信号。

系统启动时,操作系统分配和初始化一张称为异常表的跳转表。使得表目k包含异常k的处理程序的地址。

运行时(系统执行某个程序)处理器检测到发生了一个事件,并确定了响应的异常号k。随后处理器触发异常,执行间接调用过程,通过异常表的表目k,转到相应的处理程序。

异常号是到异常表中的索引,异常表的起始地址放在叫做异常表基址寄存器的特殊CPU寄存器中。

异常与过程调用的区别:

  • 返回地址不同:过程调用时,跳转到处理程序之前,处理器将返回地址压入栈中。而根据异常的类型,返回地址要么是当前指令,要么是下一条指令。
  • 异常处理中处理器也把一些额外的处理器状态压入栈中,在处理程序返回时,重新开始执行被中断的程序会需要这些状态。
  • 如果控制从用户程序转移到内核,所有这些项目都被压入内核栈中,而不是压到用户栈中。
  • 异常处理程序运行在内核模式下,这意味着其对所有系统资源都有完全的访问权限。

一旦硬件触发了异常,剩下的工作就由异常处理程序在软件中完成。处理完事件后,通过执行一条特殊的从中断返回指令,可选地返回到被中断的程序,该指令将适当的状态弹回到处理器的控制和数据寄存器中。如果异常中断的是一个用户程序,就将状态恢复为用户模式,然后将控制返回给被中断的程序。

异常的类别

异常分为中断、陷阱、故障、终止。

中断

中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,因此它是异步的。硬件中观的异常处理程序常称为中断处理程序。

当前只能怪执行完后,处理器注意到中断引脚的电压变高,就从系统总线读取异常号,然后调用适当的中断处理程序。当处理程序返回时,就将控制返回给下一条指令。结果是程序继续运行,就好像没有发生过中断一样。

剩下的异常类型是同步发生的,是执行当前指令的结果,把这类指令称为故障指令

陷阱和系统调用

陷阱是有意的异常,是执行一条指令的结果。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,称为系统调用。

用户经常要向内核请求服务,比如读一个文件(read)、创建一个新进程(fork)、加载一个新程序(execve),或终止当前程序(exit)。为了允许对这些内核服务的受控访问,处理器提供了一条特殊的syscall n的指令,当用户程序想要请求服务n时,可以执行这条指令。执行syscall指令会导致一个到异常处理程序的陷阱,这个程序解析参数,并调用适当的内核程序。

故障

故障是由错误引起的,他可能被故障处理程序修正。故障发生时,处理器将控制转移给故障处理程序。如果故障处理程序能够修正这个错误,他就将控制返回到引起故障的指令,从而重新执行它。否则,处理程序返回到内核中的abort例程,该例程会终止引起故障的应用程序。

一个经典的故障示例是缺页异常。

终止

终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,例如DRAM或SRAM位被损坏时发生的奇偶错误。

Linux/x86-64中的异常

x86-64有256种不同类型的异常,0~31号对应的由Intel架构师定义的异常,对任何x86-64系统都是一样的。32~255号对应的是操作系统定义的中断和陷阱。

Linux/x86-64故障和终止

  • 除法错误(异常0):Unix不会试图从除法错误中恢复,而是选择终止程序。
  • 一般保护故障(异常13):通常因为一个程序引用了一个未定义的虚拟内存区域,或者因为程序试图写一个只读的文本段。Linux不会尝试恢复这些故障。
  • 缺页(异常14):会重新执行产生故障的指令的一个示例。
  • 机器检查(异常18):在导致故障的指令执行中检测到致命的硬件错误时发生的。

Linux/x86-64系统调用

Linux提供几百种系统调用,以下为一些常见的系统调用:

Linux常见系统调用

每个系统调用都有唯一的整数号,对应于一个到内核中跳转表的偏移量。

C程序用syscall函数可以直接调用任何系统调用。而实际中没必要这样做,对于大多数系统调用,标准C库提供了一组方便的包装函数。这些包装函数将参数打包到一起,以适当的系统调用指令陷入内核,然后将系统调用的返回状态传递回调用函数。

处理器ISA规范通常会区分异步中断和同步异常,但并不提供表述这些非常相似的概念的概括性的术语。为了避免混乱,我们使用异常作为通用术语,只要在必要时才区分异步异常(中断)和同步异常(陷阱、故障和终止)。

进程

进程的经典定义就是一个执行中的程序的实例。系统中的每个程序都运行在某个进程的上下文(context)中。上下文由程序正确运行所需的状态组成。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。

进程提供给应用程序的关键抽象:

  • 一个独立的逻辑控制流:提供我们的程序独占地使用处理器的假象
  • 一个私有的地址空间:提供我们的程序独占地使用内存系统的假象

逻辑控制流

进程为每个程序提供了一种假象,好像程序在独占地使用处理器。

进程轮流使用处理器。每个进程执行它的流的一部分,然后被抢占,然后轮到其他进程。

并发流

一个逻辑流的执行在时间上与另一个流重叠,称为并发流,这两个流被称为并发地运行。

多个流并发地执行的一般现象被称为并发。一个进程和其他进程轮流运行的概念称为多任务。

私有地址空间

进程为每个程序提供它自己的私有地址空间。和这个空间中某个地址相关联的哪个内存字节是不能被其他进程读和写的。

尽管每个私有地址空间相关联的内存一般是不同的,但每个这样的空间都有相同的通用结构。

用户模式和内核模式

处理器提供了一种限制一个应用可以执行的指令以及它可以访问的地址空间范围。

处理器通常用某个控制寄存器中的一个模式位来提供这种功能,该寄存器描述了当前享有的特权。设置了模式位后,进程就运行在内核模式中。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。

未设置模式位时,进程运行在用户模式。用户模式中的进程不允许执行特权指令,例如停止处理器、改变模式位,或者发起一个I/O操作。也不允许用户模式中的进程直接引用地址空间中内核区的代码和数据。

上下文切换

操作系统内核使用一种称为上下文切换的较高层形式的异常流控制来实现多任务切换。

内核为每个进程维持一个上下文。上下文就是内核重启一个被抢占的进程所需的状态。它由一些对象的值组成,包括通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,如页表、进程表和文件表。

进程执行的某些时刻,内核可以抢占当前当前进程,并重新开始一个先前被抢占的进程,这种决策称为调度。由内核中的调度器的代码处理的。

上下文切换:

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

中断可以引发上下文切换。例如从磁盘取完数据之后的中断。

系统调用错误处理

当Unix系统级函数遇到错误时,通常会返回-1,并设置全局整数变量errno来表示出现什么错误。

fork函数检查错误的方法:

if ((pid = fork()) < 0) {
	fprintf(stderr, "fork error: %s\n", strerror(errno));
	exit(0);
}

strerror函数返回一个文本串,描述了和某个errno值相关联的错误。通过定义以下函数,来简化代码:

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

此时对fork的调用缩减到2行:

if ((pid = fork()) < 0)
	unix_error("fork error");

使用csapp.h定义的处理包装函数可以进一步简化代码。

进程控制

描述Unix提供的大量从C程序中操作进程的系统调用函数。

获取进程ID

pid_t getpid(void);

创建和终止进程

void exit(int status);
pid_t fork(void);

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

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

回收子进程

当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。进程被保持在一种已终止的状态,直到被它的父进程回收。当父进程回收已经终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已经终止的进程,从此时开始,该进程就不存在了。一个终止了但还未被会回收的进程称为僵死进程。

让进程休眠

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

include <unistd.h>
unsigned int sleep(unsigned int secs);

如果请求的时间量已经到了,sleep返回0,否则返回还剩下的要休眠的秒数。

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

int pause(void);

加载并运行程序

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

#include <unistd.h>
int execve(const char *filename, const char *argv[],
			const char *envp[]);

该函数加载并运行可执行目标文件filename,且带参数列表argv环境变量列表envp。只有出现错误时才返回到调用程序。execve调用一次并从不返回。

execve函数参数列表和环境变量列表

在execve加载了filename后,调用7.9 加载可执行目标文件中描述的启动代码。启动代码设置栈,并将控制传递给新程序的主函数main,该函数有以下形式:

int main(int argc, char *argv[], char *envp[]);
//或者等价的
int main(int argc, char **argv, char **envp);

main开始执行时,用户栈的组织结构如下图所示:

新程序开始时的用户栈

从栈底(高地址)到栈顶(低地址)的方向依次有:

  • 以null结尾的指针数组,其中每个指针都指向栈中的一个环境变量字符串。全局变量environ指向这些指针中的第一个envp[0]
  • 以null结尾的argv[]数组,其中每个元素都指向栈中的一个参数字符串
  • 栈顶是系统启动函数libc_start_main的栈帧。

main函数的三个参数:

  • argc:给出argv[]数组中非空指针的数量
  • argv:指向argv[]数组中的第一个条目
  • envp:指向envp[]数组中的第一个条目

利用fork和execve运行程序

Unix shell这样的程序大量使用了fork和execve函数。shell是一个交互型的应用程序,他代表用户运行其他程序。最早的shell是sh程序,shell执行一系列的读/求值步骤,然后终止。读步骤读取来自用户的一个命令行。求值步骤解析命令行,并代表用户运行程序。

/* $begin shellmain */
#include "csapp.h"
#define MAXARGS   128

/* Function prototypes */
void eval(char *cmdline);
int parseline(char *buf, char **argv);
int builtin_command(char **argv); 

int main() 
{
    char cmdline[MAXLINE]; /* Command line */

    while (1) {
	/* Read */
	printf("> ");                   
	Fgets(cmdline, MAXLINE, stdin); 
	if (feof(stdin))
	    exit(0);

	/* Evaluate */
	eval(cmdline);
    } 
}
/* $end shellmain */

以上为一个简单的shell的main例程。shell打印一个命令行提示符,等待用户在stdin上输入命令,然后对这个命令行求值。

/* $begin eval */
/* eval - Evaluate a command line */
void eval(char *cmdline) 
{
    char *argv[MAXARGS]; /* Argument list execve() */
    char buf[MAXLINE];   /* Holds modified command line */
    int bg;              /* Should the job run in bg or fg? */
    pid_t pid;           /* Process id */
    
    strcpy(buf, cmdline);
    bg = parseline(buf, argv); 
    if (argv[0] == NULL)  
	return;   /* Ignore empty lines */

    if (!builtin_command(argv)) { 
        if ((pid = Fork()) == 0) {   /* Child runs user job */
            if (execve(argv[0], argv, environ) < 0) {
                printf("%s: Command not found.\n", argv[0]);
                exit(0);
            }
        }

	/* Parent waits for foreground job to terminate */
	if (!bg) {
	    int status;
	    if (waitpid(pid, &status, 0) < 0)
		unix_error("waitfg: waitpid error");
	}
	else
	    printf("%d %s", pid, cmdline);
    }
    return;
}

/* If first arg is a builtin command, run it and return true */
int builtin_command(char **argv) 
{
    if (!strcmp(argv[0], "quit")) /* quit command */
	exit(0);  
    if (!strcmp(argv[0], "&"))    /* Ignore singleton & */
	return 1;
    return 0;                     /* Not a builtin command */
}
/* $end eval */

以上为对命令行求值的代码。首要任务是调用parseline函数,该函数解析了以空格分隔的命令行参数,并构造最终会传递给execve的argv向量。第一个参数被假设为要么是一个内置的shell命令名,马上就会解释这个命令,要么是一个可执行目标文件,会在一个新的子进程的上下文中加载并运行这个文件。

如果最后一个参数是&,那么parseline返回1,表示在后台执行该程序。否则返回0,表示在前台执行该程序。

解析了命令行后,eval函数调用builtin_command函数,该函数检查第一个命令行参数是否是一个内置的shell命令。如果是,就立即解释这个命令,并返回1,否则返回0。简单的shell只有一个内置命令——quit命令,该命令会终止shell。

如果builtin_command返回0,那么shell创建一个子进程,并在子进程中执行所请求的程序。如果用户要求在后台运行该程序,那么shell返回到循环的顶部,等待下一个命令行。否则,shell使用waitpid函数等待作业终止。当作业终止时,shell开始下一轮迭代。

信号

前面的异常控制流的学习中,我们已经看到了硬件和软件如何合作以提供基本的低层异常机制,以及操作系统如何利用异常来支持进程上下文切换的异常控制流形式。本节中将研究更高层的软件形式的异常,称为Linux信号,其允许进程和内核中断其他进程。

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

每种信号类型都对应于某种系统事件。低层的硬件异常是由内核异常处理程序处理的,正常情况下是对用户进程不可见的。信号提供了一种机制,通知用户进程发生了这些异常。例如如果一个进程试图除以0,那么内核就发送给它一个SIGFPE信号。

信号术语

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

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

一个发出而没有被接收的信号叫作待处理信号。

任何时刻,一种类型至多只会有一个待处理信号。如果一个进程有一个类型为k的待处理信号,那么任何接下来发送到这个进程的类型为k的信号都会被直接丢弃。一个进程可以有选择地阻塞接收某种信号。

发送信号

Unix基于进程组提供了大量向进程发送信号的机制。

进程组

每个进程都只属于一个进程组,进程组由一个正整数进程组ID来标识。getpgrp函数返回当前进程的进程组ID:

#include <unistd.h>
pid_t getpgrp(void);

一个进程与其子进程属于同一个进程组,一个进程也可以通过使用set-pgid函数来改变自己或其他进程的进程组。

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

将进程pid的进程组改为pgid。如果pid为0就是用当前进程的PID。如果pgid是0就用pid指定的进程的PID作为进程组ID。

用/bin/kill程序发送信号

/bin/kill可以向另外的进程发送任意信号。例如:

linux> /bin/kill -9 15213

发送信号9(SIGKILL)给进程15213.

从键盘发送信号

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

linux> ls | sort

会创建一个由两个进程组成的前台作业,这两个进程通过Unix管道连接起来:一个进程运行ls程序,另一个进程运行sort程序。shell为每个作业创建一个独立的进程组。进程组ID通常取自作业中父进程中的一个。

在键盘上输入Ctrl+C会导致内核发送一个SIGINT信号到前台进程组中的每个进程。默认情况下是终止前台作业。类似地,输入Ctrl+Z会发送一个SIGTSTP信号到前台中的每个进程。默认情况下是停止(挂起)前台作业。

用kill函数发送信号

进程通过调用kill函数发送信号给其他进程(包括自己)。

#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);

如果pid大于零,那么kill函数发送信号号码sig给进程pid。如果pid等于零,那么kill发送信号sig给调用进程所在进程组中的每个进程,包括调用进程自己。如果pid小于零,kill发送信号sig给进程组|pid|中的每个进程。例如:

/* $begin kill */
#include "csapp.h"

int main() 
{
    pid_t pid;

    /* Child sleeps until SIGKILL signal received, then dies */   
    if ((pid = Fork()) == 0) {   
	Pause();  /* Wait for a signal to arrive */  
	printf("control should never reach here!\n");
	exit(0);
    }

    /* Parent sends a SIGKILL signal to a child */
    Kill(pid, SIGKILL);
    exit(0);
}
/* $end kill */

该示例中,父进程用kill函数发送SIGKILL信号给它的子进程。

用allarm函数发送信号

进程可以通过调用alarm函数向它自己发送SIGALARM信号。

#include <unistd.h>
unsigned int alarm(unsigned int secs);

alarm函数安排内核在secs秒后发送一个SIGALARM信号给调用进程。如果secs是零,那么不会调度安排新的闹钟。

任何情况下,对alarm的调用都将取消任何待处理的(pending)闹钟,并返回任何待处理的闹钟在被发送前还剩下的秒数。如果没有任何待处理的闹钟就返回零。

接收信号

当内核把进程p从内核模式切换到用户模式(从系统调用返回或完成了一次上下文切换)时,它会检查进程p的未被阻塞的待处理信号的集合。如果这个集合为空,那么内核将控制传递到p的逻辑控制流中的下一条指令。如果集合是非空,那么内核选择集合中的某个信号k,并强制p接收信号k。收到这个信号会触发进程采取某种行为。一旦进程完成这种行为,控制就传递回p的逻辑控制流中的下一条指令。每个信号都有一个预定义的默认行为:

  • 进程终止
  • 进程终止并转储内存
  • 进程停止(挂起)直到被SIGCONT信号重启
  • 进程忽略该信号

进程可以通过使用signal函数修改和信号相关联的默认行为。例外是SIGSTOP和SIGKILL,他们的默认行为不能更改。

#include <signal.h>
typedef void (*sighandler_t) (int);
sighandler_t signal(int signum, sighandler_t handler);

若成功则返回指向前次处理程序的指针,若出错则为SIG_ERR。

阻塞和解除阻塞信号

Linux提供了阻塞信号的隐式和显式的机制。

  • 隐式阻塞机制:内核默认阻塞任何当前处理程序正在处理信号类型的待处理的信号。
  • 显式阻塞机制:应用程序可以使用sigprocmask函数和它的辅助函数,明确地阻塞和解除阻塞选定的信号。

编写信号处理程序

信号处理是Linux系统编程最棘手的一个问题。处理程序有几个属性使其很难推理分析:

  • 处理程序与主程序并发运行,共享同样的全局变量,可能与主程序和其他处理程序互相干扰
  • 如何以及何时接收信号的规则常常有违直觉
  • 不同系统有不同的信号处理语义

以下是信号处理程序的一些基本规则:

安全的信号处理

正确的信号处理

可移植的信号处理

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

以某种方式同步并发流,从而得到最大的可行的交错的集合,每个可行的交错都能得到正确的结果。

显式地等待信号

有时候主程序需要显式地等待某个信号处理程序运行。例如当Linux shell创建一个前台作业时,在接受下一条用户命令前,它必须等待作业终止,被SIGCHLD处理程序回收。

非本地跳转

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

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

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

操作进程的工具

Linux系统提供的大量的监控和操作进程的有用工具。

  • STRACE:打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。

  • PS:列出当前系统中的进程

  • TOP:打印出关于当前进程资源使用的信息

  • PMAP:显示进程的内存映射

  • /proc:一个虚拟文件系统,以ASCII文本格式输出大量内核数据结构的内容,用户程序可以读取这些内容。


最后修改于 2022-01-12