Loading

CSAPP(八)——异常控制流

异常

就像我们通常通过判断和循环,根据程序中某些状态来让程序做出不同的反应一样,操作系统也需要一种机制对某些由操作系统掌管的状态做出不同的反应,比如用于支持多道程序设计的进程切换;网络包到达网络适配器;磁盘数据已经就绪等,此时,操作系统需要暂停当前正在执行的进程,去做一些其它工作,同时做完之后还可能恢复被暂停的进程的执行......

程序计数器值的序列称为控制流,操作系统通过使控制流发生突变来对这些情况做出反应,这些突变被称为异常控制流(EOF)。

不管产生突变的原因是操作系统是真的出现了错误还是只是某些外部设备发起了一个通知,或是什么其它的,它们都被抽象成异常,虽然感觉不太合理......请不要和编程语言中的异常控制所混淆。

异常处理

异常处理需要软件和硬件合作,每种异常都有一个明确的唯一的无符号异常号,其中一些异常是CPU设计者分配的,比如除零,一些是操作系统内核分配的,比如外部IO设备信号。

操作系统发现异常后,会根据异常号调用对应的异常处理程序。操作系统维护一张异常表,条目\(k\)代表异常号为\(k\)的异常的处理程序的地址。由于异常表存在内存中的某个位置中,所以想要根据\(k\)来找到条目实际的位置,还要将\(k\)加上异常表在内存中的起始位置,这个地址在异常表基址寄存器中保存。

异常处理程序工作在内核模式下,这说明,它对所有资源都有完全的访问权限。

异常的类别

中断(Interrupt)

中断代表来自某种外部IO设备的信号,IO设备希望CPU中断手头的工作,来处理自己的信号,而异步是说它并不是由某条正在执行的指令发起的。中断的异常处理程序通常称为中断处理程序

通常,一条指令执行完成后,CPU会检测中断引脚的电压,如果该引脚处于高电平,就是发生了中断,CPU会去调用对应的中断处理程序。

陷阱(Trap)

陷阱是一种故意的,预定义的异常类型。它的作用是让进程发起对某些系统内核中定义好的,权限比较高的过程(或者说服务更加贴切)的调用,这种调用称为系统调用

但是为什么要这样呢?直接使用通常类型的函数调用不好吗?不要忘了,用户进程本来是执行在用户模式下的。操作系统为了提供更加稳定的服务,用户模式下的程序对系统中所有资源的访问都被限制的死死的,但这些资源还必须被用户进程所需要,所以,操作系统通过系统调用来提供对这些资源的安全利用,系统调用运行在内核模式下。

目前,异常和陷阱都不是真正发生了错误,所以它们在执行完异常处理程序后还需要恢复之前暂停执行的进程。

故障

真的发生了错误,比如缺页异常。

根据故障是否能被正确修正,故障处理程序会返回引起故障的指令上重新执行或者执行内核中的abort例程来中止引起故障的应用。

终止

不可恢复的致命错误,从不恢复原来应用程序的执行。

Linux/X86-64中的异常

下面我们看看使用X86-64架构CPU的Linux操作系统中的异常。031是Intel架构师定义的异常,32255是操作系统定义的。

Linux x86-64的常见系统调用:

进程

进程的概念都不陌生,它为我们的程序提供了一种抽象,即我们的程序独立的运行在系统上,程序具有独立的CPU和内存,处理器按我们预期的顺序执行程序中的每条指令……不过,这一节的内容主要是认识异常这种手段对于进程来说有多么的重要。

逻辑控制流

进程运行中PC寄存器值的序列称为逻辑控制流,之所以加上逻辑这两个词,是因为从进程本身的角度来看,这个控制流不间断的反映了进程中每条指令的执行过程,但是实际上,操作系统并发的运行着多个进程,每个进程都有一个逻辑控制流,在操作系统层面,它们交错的占用CPU,所以PC寄存器实际的控制流中,一会儿是A的逻辑控制流,一会儿是B的,一会儿是C的......

并发流

两个逻辑控制流的执行时间互相重叠,就称它们是并发流

\(X\)\(Y\)是并发流,当且仅当\(X\)\(Y\)开始后和结束前开始,或\(Y\)\(X\)开始后和结束前开始。

并行流是并发流的真子集,它们是特殊的并发流,如果两个流并发的运行在不同处理器上那么它们就是并行流,因为它们可以真正意义上的同时执行。

私有地址空间

操作系统还将每个进程的地址空间隔离,仿佛这个进程独占内存,内存中并没有其它进程一样。下面是一个私有地址空间的逻辑结构,在物理上它们可不一定是连续的。

用户模式和内核模式

处理器中通常有一个模式位来控制当前的进程运行在用户模式还是内核模式,这通常导致进程控制块中也要有一个对应的模式位以便它处于等待状态下记录它的模式,不过这不是本节讨论的内容。

用户模式中的进程不允许执行特权指令,也不允许直接访问地址空间中内核区的代码和数据。当异常发生时,控制传递给异常处理程序,CPU会将模式从用户模式变成内核模式,当异常处理程序返回时,CPU会将模式从内核模式切换为用户模式。

上下文切换

操作系统会由于种种原因暂停一个进程的执行,切换到下一个进程:

  1. 一个进程由于系统调用而必须等待某些资源或信号到达,如readsleep
  2. 分配给当前进程的时钟周期用完,接收到时钟中断

进程在执行中会产生很多临时数据,比如寄存器数据,页表,进程表文件表,各种状态信息等等,这被称作进程的上下文信息,所以,操作系统暂停该进程切换下一个进程时,必须要妥善处理好这些上下文信息:

  1. 保存当前进程的上下文
  2. 恢复即将执行的进程的上下文
  3. 将控制权传递给这个即将执行的进程

系统调用错误处理

Unix系统级函数出错后会返回-1,并设置一个全局整数错误编号errno,所以我们应该这样检测系统调用的结果。

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

为了方便,书上将错误处理封装到了每个系统调用函数的首字母大写版本中:

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

然后,我们可以直接放心的调用该函数的大写版本而不用做任何处理:

pid = Fork();

这里是本书的所有代码,你可以通过这个链接找到它们。

进程控制

主要介绍了进程控制相关的几个系统调用:

  1. fork:创建一个子进程,子进程具有和父进程资源的副本。子进程中返回0,父进程中返回创建的子进程的pid。
  2. waitpid(pid, statusp, options):等待进程集合中的某一个进程终止,返回这个终止进程的pid。
    1. pid > 0时,进程集合中只有进程pid
    2. pid == -1时,等待集合是该父进程创建的所有子进程
    3. options == WNOHANG时,该函数不会因为当前没有已经终止的子进程而挂起,而是直接返回0
    4. options == WUNTRACED时,该函数不仅会响应子进程的终止,还会响应停止(停止后稍后会恢复执行)
    5. options == WCONTINUED时,该函数响应子进程的终止以及某个被停止的子进程收到SIGCONT信号重新开始执行

    waitpid用于回收子进程,进程终止时不会立即被操作系统清除,而是等待它的父进程回收它,内核会将子进程的退出状态交付给父进程。

    如果父进程终止,内核会安排init进程作为终止进程的所有子进程的父进程。init进程会回收其中的僵死进程。

  3. execve(filename, argv, envp):执行一个可执行程序,由filename指定,会在当前进程中执行,argv对应程序的参数,envp时环境变量

编写简易Linux Shell——利用forkexecve运行程序

下面的代码大部分是书上的,我扩展了根据PATH环境变量来搜索程序的功能,这样就不用每次都编写程序的全路径了。推荐自己跟着编写这个例子。

#include "csapp.h"
#define MAXARGS 128

void eval(char *cmdline, int pathc, char **pathlist);
int parseline(char *buf, char **argv);
int builtin_command(char **argv);
int readpath(char **pathlist);
void auto_add_tail(char *entry);

int main()
{
  char cmdline[MAXLINE]; // 保存用户输入的命令
  char *pathlist[1024]; // 保存环境变量 `path`列表
  int i, pathc = readpath(pathlist); // 读取环境变量中的`path`,返回的`pathc`是`path`中有多少个条目

  // 轮询接收用户输入
  while(1) {
    printf("> ");
    Fgets(cmdline, MAXLINE, stdin); // 读取命令行
    if (feof(stdin)) // 判断EOF
      exit(0);
    
    eval(cmdline, pathc, pathlist); // 调用eval来执行读取进来的命令行
  }
}

/**
*
* eval(cmdline)
*   1. 调用parseline将cmdline解析成按空格分割的命令行参数
*   2. 根据parseline的返回结果(1后台,0前台)决定是否等待任务执行完毕
*   3. 此时,命令行参数中的第一个参数决定着如何处理,调用builtin_command来判断
*   4. 如果是shell内置命令,builtin_command立即解释执行该命令,并返回1,否则返回0
*   5. 如果并非shell内置命令,则第一个参数是待执行的外部程序,shell新建一个子进程并执行它,是否等待它完成主要看该任务是否是前台
*/

void eval(char *cmdline, int pathc, char **pathlist)
{
  char *argv[MAXARGS];
  char buf[MAXLINE];
  int bg, i; // cmdline是否需要在后台执行,1后台,0前台
  pid_t pid; 

  strcpy(buf, cmdline);
  // 解析分割命令
  bg = parseline(buf, argv);

  // 空行
  if (argv[0] == NULL)
    return;
  
  // 非内建命令
  if (!builtin_command(argv)) {
    if ((pid = Fork()) == 0) {
      // 尝试根据每个path来调用程序
      for (i = 0; i < pathc; i++) {
        char path[1024] = {0};
        strcat(path, pathlist[i]);
        // 如果该path最后没有`/`分割符,添加上
        auto_add_tail(path); 
        strcat(path, argv[0]);
        // 尝试执行
        if (execve(path, argv, environ) >= 0) {
          break;
        }
      }
      // 如果最后都没找到,那么就是没找到命令
      if (i==pathc) {
        printf("%s : Command not found\n", argv[0]);
        exit(0);
      }
    }
    
    // 这里回到父进程,如果命令不是后台的,等待命令执行完成
    if (!bg) {
      int status;
      // 如果是前台任务,等待任务结束才能继续
      if (waitpid(pid, &status, 0) < 0) 
        unix_error("waitfg: waitpid error");
    } 
    // 如果是后台任务,打印执行任务的pid和命令行
    else printf("[%d] %s\n", pid, cmdline);
  }

  return;
}


/**
* buf是用户输入
* argv是我们要分割好后填进去的参数列表
*/
int parseline(char *buf, char **argv) {
  char *delim;
  int argc;
  int bg;

  buf[strlen(buf) - 1] = ' '; // 将最后的\n换成空格
  while (*buf && (*buf == ' ')) buf++; // 跳过前置空格

  // 构建argv列表
  argc = 0;
  while ((delim=strchr(buf, ' '))) {
    argv[argc++] = buf;
    *delim = '\0';
    buf = delim + 1;
    while (*buf && (*buf == ' ')) 
      buf++;
  }

  argv[argc] = NULL;

  if (argc == 0) 
    return 1;

  if ((bg = (*argv[argc-1] == '&')) != 0) 
    argv[--argc] = NULL;

  return bg;
}

int builtin_command(char **argv) {
  if (!strcmp(argv[0], "quit")) 
    exit(0);
  if (!strcmp(argv[0], "exit")) 
    exit(0);
  if (!strcmp(argv[0], "&")) 
    return 1;
  return 0;
}

/**
* 读取path,返回值是path个数
*/
int readpath(char **pathlist) {
  int pathc = 0, i;
  char *path;
  if ((path = getenv("PATH")))  {
    pathlist[pathc++] = strtok(path, ":");
    while((pathlist[pathc++] = strtok(NULL, ":"))) {}
  }
  
  return pathc - 1;
}

void auto_add_tail(char *entry) {
  if (entry[strlen(entry) - 1] != '/') 
    strcat(entry, "/");
}

目前,对于后台命令,我们的shell还不能做到回收子进程,我们只回收了前台命令,这可能会存在一些进程已经终止但仍然占用系统资源,未被回收。

信号

异常控制流是一种硬件和操作系统合作来完成的底层异常机制,我们的进程在这种异常机制中完全被动,进程看不到异常处理的过程,没法参与到其中......信号是一个让我们的进程拿回一点点主动权的用户级工具。

  1. 内核可以使用信号来通知一个进程发生了某种类型的事件
  2. 一个进程也可以发送某些信号要求内核来通知某些进程

内核会通过修改目标进程上下文中的某个状态来发送一个信号给目标进程,当一个进程接收到一个信号时,它可以使用信号处理程序来捕获这个信号,也可以忽略这个信号。

发出但未被处理的信号称作待处理信号,待处理信号是无排队的,同类型信号中只会保存最先到达的未处理信号,其它的都会被丢弃。

进程可以阻塞某种信号的接收,阻塞的意思是这种信号不会被进程接收,而不是某种类似停止运行的东西,请注意区别。

内核为每个进程维护两个位向量:

  1. pending位向量维护待处理信号的集合
  2. blocked位向量维护被阻塞信号的集合

发送信号

下面看看进程如何发送信号,即要求内核以某种信号来通知某些进程。

在这之前,我们要先看看进程组的概念。

进程组

Unix中的每个进程都属于一个进程组,进程组由一个正整数的进程组ID唯一标识。可以通过getpgrp函数来获得当前进程所在的进程组,默认情况下,一个进程和父进程属于同一个进程组

#include <stdio.h>
#include <unistd.h>

int main() 
{
  printf("groupd id : %d\n", getpgrp());
}

setpgid函数用来改变当前进程的进程组。

pid为0,使用当前进程pid作为新的进程组id,若pgid为0,则使用参数pid作为新的进程组id。

/bin/kill发送信号

有些shell有内置的kill命令,它可能和我们所说的/bin/kill不一样

我一直以为kill命令就是结束进程,其实不然,它是向进程发送一个信号而已,只不过默认的信号是SIGTERM(终止进程),而kill -9则代表SIGKILL信号。

下面是能够发送的所有信号列表:

下面是/bin/kill的usage,可以看到我们在后面可以跟一堆pid来指定都发送信号给哪些进程。

Usage:
 kill [options] <pid> [...]

如果pid为负数,则pid的绝对值会被当作组id,内核会给该组中所有进程发送信号。

从键盘发送信号

当按下Ctrl C时,内核会发送SIGINT信号到前台进程组中的每个进程。

当按下Ctrl Z时,内核会发送SIGTSTP信号到前台进程组中的每个进程。

UnixShell提供了作业(Job)的概念,任意时刻只有1个前台作业,但可以有0个到多个后台作业,如下图:

这样,我们就可以通过进程组的概念来向组中所有实际执行工作的进程来发送信号,或编写它们的信号处理程序。

请注意作业和工作进程的区别,进程用来实际执行命令所需要执行的工作,一个命令可能包含多个要执行的工作,比如ls | sort,这需要创建两个由管道连接的进程来执行,而作业则会包含执行命令所需要的所有进程。一个作业具有一个pgid,它其中的所有工作进程都使用这个pgid来共同响应信号。

用kill函数发送信号

进程可以使用kill函数发送信号给其它进程,pid==0时,信号被发给调用者进程组中的所有进程,pid<0时,pid的绝对值作为要发送的组id。sig是要发送的信号。

用alarm函数发送信号

alarm在指定时间后向自己发送SIGALRM信号。

alarm会清除还尚未发送的SIGALRM通知,并且返回被清除的通知剩余的秒数。

接收信号

当内核把进程从内核模式切换到用户模式时会检查进程的待处理信号集合,当这个集合为空时,进程的下一条指令被正常执行,否则会选择其中的某个信号,强制进程接收它,进程会为信号采取某种行为,这个行为可以由程序员来定义。然后,执行完毕后,继续执行进程的下一条指令。

信号的默认行为有:

  1. 进程终止
  2. 进程终止并转储内存
  3. 进程停止(挂起)直到收到SIGCONT信号
  4. 忽略信号

下面的signal函数用于注册一个信号的行为,当signum代表的信号发生,handler函数将被触发。

下面的代码覆盖了SIGINT信号的默认行为,现在运行这个程序,然后按下Ctrl CSIGINT信号会被捕获。

#include "csapp.h"

void sigint_handler(int sig) {
  printf("Caught SIGINT!\n");
  exit(0);
}

int main()
{
  if (signal(SIGINT, sigint_handler) == SIG_ERR) 
    unix_error("signal error");
  
  pause();

  return 0;
}

阻塞和解除阻塞信号

复习一下,阻塞信号就是让进程暂时忽略某种信号,而不是真的产生了某种阻塞(停止挂起)。

  1. 默认情况下,内核会阻塞当前正处于待处理信号列表中的所有同类型信号。
  2. 程序可以通过sigprocmask来显式阻塞某种信号。

sigprocmask函数就是用来操控当前阻塞信号集合的函数,参数how决定了参数set如何影响当前阻塞集合,oldset保存了调用该方法前的阻塞信号集合。

  1. how == SIG_BLOCKset中的信号会被添加到blocked
  2. how == SIG_UNBLOCKset中的信号会被从blocked中删除
  3. how == SIG_SETMASKblocked=set

下面的代码临时阻塞SIGINT信号的接收

编写信号处理程序

安全的信号处理

信号处理程序和主程序以及其它信号处理程序并发运行,所以下面是一些帮助你写出安全的信号处理程序的指导原则:

  1. 处理程序要尽可能简单
  2. 处理程序中只调用异步信号安全的函数(可重入或不能被其它信号处理程序中断)
  3. 保存和恢复errno。如果你的处理函数中调用的某些函数发生了异常,设置了errno,那么你有义务读取这个errno并恢复错误发生之前的errno(在进入时就记录初始errno
  4. 阻塞所有信号,保护对共享全局数据结构的访问
  5. volatile声明全局变量
  6. sig_atomic_t声明标志

正确的信号处理

像类似shell这样的程序,必须面对一些后台子进程,当后台子进程终止时,你需要将它回收,否则它们就会成为僵死子进程。子进程终止时会发送SIGCHLD信号给父进程。

下面的函数,我们只需要看这些代码,main中给SIGCHLD信号设置了处理器,这个处理器调用waitpid回收一个已经终止的子进程。

这个函数是有问题的,如果你将书上的全部代码复制并运行,会发现创建了三个子进程,但只正确回收了两个,有一个僵死了。这是因为同类型信号是无排队的,一般情况下,信号处理程序执行的很快,所以即使你写出不正确的代码也不会出现什么问题,但这里Sleep(1)特意让这个函数执行的很慢,导致某个信号由于前面已有一个同类型信号正在处理而被忽略。

下面的代码将if改成while,争取每次接到信号都回收最多的子进程

后面略了...

posted @ 2022-05-31 18:28  yudoge  阅读(136)  评论(0编辑  收藏  举报