关于操作系统:深入理解计算机系统异常

4次阅读

共计 10382 个字符,预计需要花费 26 分钟才能阅读完成。

  古代操作系统通过使控制流产生渐变来对某些意外状况(磁盘读写数据准备就绪、硬件定时器产生信号等)做出反馈。一般而言,咱们把这些渐变命名为异样控制流(Exceptional Contral Flow ECF)。异样控制流产生在计算机系统的各个档次。比方,在硬件层,硬件检测到工夫会触发管制忽然转移到异样处理程序。在操作系统层,内核通过上下文切换将管制从一个用户过程转移到另一个用户过程。在应用层,一个过程能够发送信号到另一个过程,而接受者会将管制转移到一个信号处理程序。一个程序能够通过回避通常的栈规定,并执行到其余函数中任意地位的非本地跳转谬误做出反馈。

为什么须要了解 ECF?

  • 有助于了解重要的零碎概念
  • 有助于了解应用程序是如何与操作系统交互
  • 有助于了解并发
  • 有助于了解软件异样如何工作(如 C ++/JAVA try-cache-throw 软件异样机制)

异样

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

  异样就是控制流的一种渐变,用来响应处理器状态中的某些变动。上图中,当处理器状态产生一个重要的变动时,处理器正在执行某个以后指令 Icur。在处理器中,状态被编码为不同的位和信号。状态的变动称为事件。事件可能和以后执行的执行间接相干,比方虚拟内存缺页、算术溢出、除以零,也可能和以后指令没有关系,比方零碎定时器产生信号、I/ O 申请实现等。

  在任何状况下,当处理器检测到有事件产生时,它会通过一张叫做异样表(exception table)的跳转表,进行一个间接过程调用。到一个专门波及用来解决这类事件的操作系统子程序(异样处理程序(exception handler))。当异样处理程序实现解决后,依据引起异样的事件类型,会产生以下状况:

  • 从新执行 Icur(如产生缺页中断)
  • 继续执行 I_next(如收到 I / O 设施信号)
  • 终止程序(如收到 kill 信号)

异样解决

  零碎中可能的每种类型的异样都调配了一个惟一的非负整数的异样号(exception number)。其中一些号码有处理器的设计者调配,其余号码由操作系统内核(操作系统常驻内存的局部)的设计者调配。前者的示例包含除以零、缺页、内存拜访违例(如 segment fault)、断点、算术运算溢出等,后者包含零碎调用和来自内部 I / O 设施的信号。在系统启动时(重启或加电时),操作系统调配和初始化一张称为异样的跳转表。每个条目 k 蕴含了异样 k 的处理程序的跳转地址。异样表的起始地址放在一个叫做异样表基址地址寄存器的特俗 cpu 寄存器内。

  异样相似于过程调用,但仍旧有重要的不同之处:

  • 过程调用时,在跳转到处理程序之前,处理器会将返回地址压入栈中。然而对于不同的异样类型,返回地址可能时以后指令,也可能时下一条指令
  • 处理器也会把一些额定的处理器状态压入栈里,在处理程序返回时,从新开始执行被中断的程序须要这些状态
  • 如果管制从用户程序转移到内核,那么所有这些我的项目都会压到内核栈中
  • 异样处理程序运行在内核模式下,意味着异样处理程序对所有的系统资源都有齐全的拜访权限(问:用户指定的异样处理程序呢?)

  当异样处理程序实现后,它通过执行一条非凡的“从中断返回”指令,可选地返回被中断的程序,该指令将适当的状态弹回处理器的管制和数据寄存器中。如果异常中断的是一个用户程序,就将状态复原为用户模式,而后将管制返回给被中断的程序。

异样的类别

异样分为 4 类:中断(interrupt)、陷阱(trap)、故障(fault)、终止(abort):

  • 中断:异步产生,是来自处理器内部的 I / O 设施的信号的后果(如硬盘数据读取实现)。个别这种信号是内部硬件设施向处理器上的一个引脚发信号,并将异样号(标识了引起中断的设施)放到系统总线,来触发中断。以后指令实现后,处理器留神到中断引脚电压变高,就从系统总线读取异样号,并调用适当的异样处理程序。异样解决实现后,执行下一条指令 I_next。
  • 陷阱和零碎调用:是无意的异样,是执行一条指令的后果(如执行 malloc、读、写文件、fork、execve、exit 等),处理器提供一条非凡的“syscall n”(n 是零碎调用的编号,操作系统有对应的零碎调用表,表中条目 i 标识零碎调用 i 的处理程序地址)来处理器这些零碎调用。中断处理程序执行实现后,将程序切换为用户态,执行下一条指令 I_next。运行在用户模式的一般函数只能拜访与调用函数雷同的栈,然而零碎调用运行在内核模式,因而容许一行特权指令,并拜访定义在内核中的栈。
  • 故障:由谬误引起,通常可能被故障处理程序修改。当故障产生,处理器将管制转移给故障处理程序。如果故障处理程序可能修改这个谬误,就见管制返回到因而故障的指令,并从新执行它。否处理程序返回到内核中的 abort 历程,abort 会终止引起故障的应用程序。常见的故障如:缺页。
  • 终止:不可复原的致命谬误造成后果,通常是一些硬件谬误,如比方 DRAM/SRAM 为被损坏时产生的奇偶谬误。终止处理程序从不将管制返回给应用程序,而是间接返回到内核的 abort 历程。
linux 零碎调用函数先将零碎调用好写入寄存器 %rax,而后将参数(如 mallo 的字节数量)写入寄存器 %rdi 等,而后调用“syscall”指令来调用零碎调用。

过程

  过程的经典定义就是一个执行中的程序的实例。零碎中的每个程序都运行在某个过程的上下文中。上下文由程序正确运行所需的状态组成的。这个状态包含寄存在内存中的程序的代码和数据,它的栈、通用目标寄存器的内容、程序计数器、环境变量、曾经关上文件描述符的汇合等。

逻辑控制流

  过程是轮流应用处理器的。每个过程执行它的流的一部分,而后被抢占,而后轮到其余过程。对于一个运行在这些过程之一的上下文的程序,它看上去就像是在独占地应用处理器。

并发流

  一个逻辑流的执行工夫上与另一个流重叠,称为并发流。这个两个流被称为并发地运行。多个流并发地执行的个别景象被称为并发(concurrency)。一个过程和其余过程轮流地运行的概念称为多任务(multitasking)。一个过程执行它的控制流的一部分的每一个时间段叫做工夫片。

公有地址空间

  过程也为每个程序提供了一种假象:如同它独占地应用零碎地址空间。过程为每个程序提供它本人的公有地址空间。一般而言,和这个空间(也就是咱们所说的虚拟地址空间)中某个地址关联的那个内存字节是不能被其余过程读写的。

  只管和每个公有地址空间相关联的内存的内容个别是不同的,然而每个这样的空间都有雷同的通用构造。地址空间境地是保留给用户程序的,包含通常的代码、数据、堆和栈段。代码段总是从 0x400000 开始。地址空间顶部保留给内核(操作系统常驻内存的局部)。地址空间的这个局部蕴含内核在带白继承执行指令时(比方当应用程序执行零碎调用时)应用的代码、数据和栈。

用户模式和内核模式

  为了限度一个利用能够执行的指令以及它能够拜访的地址空间范畴,处理器应用某个管制寄存器中的一个模式位来形容过程以后享有的权限:模式位为 1 标识过程运行在内核模式中,能够执行指令集中的任何指令,并拜访零碎中的任何内存地位。如果没有设置模式位,则标识处于用户模式,不容许执行特权指令(如进行处理器、扭转位模式、发动 I / O 操作、援用地址空间中内核区的代码和数据)。用户程序必须通过零碎调用拜访内核代码和数据。

  过程从用户模式变为内核模式的惟一办法是通过诸如中断、故障或者陷入零碎调用这样的异样。当异样产生,管制传递到异样处理程序,处理器将模式从用户模式变为内核模式。当异样处理程序返回到利用程序代码时,处理器就将模式从内核模式改为用户模式。

  linux 中的 /proc 文件系统容许用户模式过程拜访内核构造的内容。/proc 文件系统将许多内核数据结构的内容输入为一个用户程序能够读的文本文件的层次结构。

  • /proc/cpuinfo
  • /proc/$pid/maps 等

思考:

  • /proc 是否存储到磁盘中?如果不是,那它是怎么实现的?
  • 实现一个程序,仿照 /proc,将以后程序应用过程 id、内存应用状况写入到某个文件中。

上下文切换

  内核为每个过程维持一个上下文(context)。上下文就是内核重新启动一个被抢占的过程所需的状态(通用目标寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈、内核数据结构),比方形容地址空间的页表、蕴含无关以后过程信息的过程表、已关上的文件的描述符等。

  零碎调用可能导致上下文切换,如 I / O 读写。中断也可能引起上下文切换。比方,所有操作系统都有周期性定时器中断的机制,通常为 1ms 或者 10ms。每次产生定时器中断。内核就断定以后过程曾经运行了足够长的工夫,并切换到一个新的过程。

零碎调用错误处理

  当 unix 零碎级函数产生谬误时,它们通常会返回 -1,并设置全局变量 errno 来标识出错。程序用应该总是查看谬误。

if ((pid = fork()) < 0){
    // strerror 返回一个文本串,形容了和某个 error 值相关联的谬误。fprintf(stderr, "fork error: %s\n", strerror(errno));
    exit(0)
}

过程管制

unix 零碎提供了大量从 C 程序操作过程的零碎调用。

pid_t getpid<void>;
pid_t getppid<void>;
pid_t fork(void);
void exit(int status);

  新创建的子过程简直但不齐全和父过程雷同。子过程失去与父过程用户及虚拟地址空间雷同的(且独立的)一份正本,包含代码和数据段、堆、共享库以及用户栈。子过程还取得与父过程任何关上文件描述符雷同的正本,象征这子过程能够读写父过程关上的任何文件。后续父子两个过程所做的任何扭转都是独立的,都有本人的公有地址空间,不会反映在另一个过程的内存中。

  fork 函数只被调用一次,然而会返回两次。一次在父过程,一次在新创建的子过程中。在具备多个 fork 实例的程序中,这很容易使人蛊惑。如下例一共输入多少次 hello?

int main(){fork();
    fork();
    printf("hello\n");
    exit(0);
}

  当一个过程因为某种原因终止时,内核并不是立刻把它从零碎中革除。相同,过程被爱护在一种已终止的状态,晓得被它的父过程回收(raped,即子过程退出的信号被父过程解决)。当父过程回收已终止的子过程时,内核将子过程的退出状态传给父过程,而后摈弃已终止的过程,从此时开始,该过程就不存在了。一个终止了但还未被回收的过程被称为僵死过程(zombie)。(僵死过程仍会占用内存,因而咱们总应该小心回收本人创立的子过程)。

如果一个父过程终止了,内核会安顿 init 过程称为它的孤儿过程的养父。init 过程的 PID 为 1,是系统启动时由内核创立的,它不会终止,是所有过程的先人。如果父过程没有回收它的僵死子过程就终止了,那么内核会安顿 init 过程去回收它们。

// 胜利则返回子过程 pid,pid=-1,示意期待所有子过程。statusp 用来存储子过程的退出状态
// 如果繁盛谬误,则返回 -1,并设置 errnos(无子过程 errno 则为 ECHILD)pid_t waitpid(pid_t pid, int *statusp, int options);
// waitpid 的简化版本,等价于 waitpid(-1, &status, 0)
pid_t wait(int *statusp)
// 将过程刮起一段指定的工夫
unsigned int sleep(unsigned int secs);
// 将过程休眠,直到该过程收到一个信号
int pause(void);
// 加载并运行可执行指标文件 filename,argv 为参数列表,envp 为环境变量列表
int execve(const char *filename, const char *argv[], const char *envp[]);

  execve 在以后过程的上下文中加载并运行一个新的程序。它会笼罩以后过程的地址空间,然而并没有创立一个新的过程,并且继承了调用 execve 函数时已关上的所有文件描述符。可参考《链接》一节。

信号

  Linux 是一种更高层次的软件模式的异样,它容许过程和内核中断其余过程。

  上图展现了 linux 零碎上反对的信号,前 30 几种在理论利用种最为常见。每种信号类型对应某种零碎工夫。低层的硬件异样由内核异样处理程序实现,失常状况下,对用户过程不可见。信号提供了一种机制,告诉用户过程产生了这些异样,比方,如果一个过程试图除以 0,那么内核就发送一个 SIGFPE 信号;Ctrl- C 发送的 SIGINT 信号;Ctrl- Z 则示意发送 SIGSTP 信号;SIGKILL 是强制终止(该信号无奈被捕捉,处理程序无奈被重写);SIGCHLD 是子过程终止。

  传送一个信号到目标程序是由两个不同步骤组成的。

  • 发送信号:内核通过更新目标程序上下文中的某个状态(过程的信号位表),发送一个信号给目标程序。发送信号能够有以下两种起因:内核检测到零碎事件,如除零谬误或子过程终止;一个过程调用 kill,显式地要求内核发送一个信号给目标过程。一个过程能够发送信号给它本人。
  • 接管信号:当目标过程被内核强制以某种形式对信号的发送做出反馈,它就接管了信号。过程能够疏忽这个信号、终止、或者通过执行一个称为信号处理程序的用户层函数来捕捉这个信号。

  一个收回而没有被接管的信号叫做待处理信号。在任何时刻,一种类型至少只会有一个待处理信号。因而,如果你反复发送多个信号 k 给某个过程,如果过程没解决前一个,那么后续的信号 k 都将被抛弃。

  内核为每个过程在 pending 位嘹亮种保护着一个待处理的信号的汇合。而 blocked 位嘹亮种保护着被阻塞的信号汇合。因而,所谓的发送,即内核将 pending 的第 k 地位为 1,接管则置为 0。

发送信号

// 给过程 pid 发送信号 sig
kill -$sig $pid
int kill(pid_t pid, int sig)

  当咱们在 shell 种启动一个 job(比方 ls|sort),会启动两个过程,二者同属一个过程组。当咱们进行 Ctrl- C 的时候,内核会发送 SIGINT 给该过程组种的每个过程。

接管信号

  当内核把过程 p 从内核模式切换到用户模式时(例如,从零碎调用返回或是实现了一次上下文切换),他会查看过程 p 的未被阻塞的待处理信号的汇合。如果汇合为空,那么内核将管制传递到 p 的逻辑控制流中的下一条指令 I_next。然而,如果汇合非空,那么内核抉择汇合种的某个信号 k(通常先选取值最小的信号),并强制过程 p 接管信号 k。收到信号会触发过程采取某种口头(信号处理程序)。一旦实现这个行为,过程就将管制传递会 p 的逻辑控制流的下一条指令 I_next。每个信号类型都有一个预约义的默认行为(局部信号的行为容许被用户程序重写,SIGSTOP、SIGKILL 不容许被重写),是上面的一种:

  • 终止:如收到 SIGKILL 信号
  • 终止并转储到内存
  • 进行直到被 SIGCONT 信号重启
  • 疏忽:如收到 SIGCHLD

信号处理程序是能够被其余信号处理程序中断的。

阻塞和接触阻塞信号

// how: SIG_BLOCK 示意屏蔽信号,SIG_UNBLOCK 示意接管信号
// set:须要操作的信号汇合
// oldset:非空,则将 blocked 位向量的值保留在 oldset
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset)

准则

  信号处理是很麻烦的工作:处理程序与主程序并发运行,共享同样的全局变量,因而可能相互烦扰;不同的零碎有不同的信号处理语义;信号处理程序可能会被其余信号中断。因而,个别咱们在编写信号处理程序的时候须要遵循以下准则:

  • 处理程序尽可能简略
  • 处理程序中仅调用异步信号平安的函数(即可重入或无奈被中断的函数)。printf、malloc、exit 等均不是异步信号平安
  • 保留和复原 errno。许多异步信号平安的函数都会在出错返回是设置 errno,因而可能烦扰主程序中其余以来 errno 的局部
  • 阻塞所有信号,爱护对共享全局数据结构的拜访。如果处理程序和主程序会共享一个全局数据结构,那么在拜访在构造前,应阻塞所有信号
  • 用 voliatile 申明全局变量
  • 用 sig_atomic_t 申明标记
  • 当咱们收到一个信号,仅代表该类型事件仅产生过一次(因为反复的待处理信号是会被抛弃的)

下例中,对于 job 的操作是一个全局操作,而且理论利用中,对于 job 的操作个别不是原子性的。

#include "csapp.h"

void initjobs()
{
}

void addjob(int pid)
{
}

void deletejob(int pid)
{
}

/* $begin procmask2 */
void handler(int sig)
{
    int olderrno = errno;
    sigset_t mask_all, prev_all;
    pid_t pid;

    Sigfillset(&mask_all);
    while ((pid = waitpid(-1, NULL, 0)) > 0) { /* Reap a zombie child */
        // 阻塞其余信号,避免 job 列表被并发批改
        Sigprocmask(SIG_BLOCK, &mask_all, &prev_all);
        deletejob(pid); /* Delete the child from the job list */
        Sigprocmask(SIG_SETMASK, &prev_all, NULL);
    }
    if (errno != ECHILD)
        Sio_error("waitpid error");
    errno = olderrno;
}
    
int main(int argc, char **argv)
{
    int pid;
    sigset_t mask_all, mask_one, prev_one;

    Sigfillset(&mask_all);
    Sigemptyset(&mask_one);
    Sigaddset(&mask_one, SIGCHLD);
    Signal(SIGCHLD, handler);
    initjobs(); /* Initialize the job list */

    while (1) {Sigprocmask(SIG_BLOCK, &mask_one, &prev_one); /* Block SIGCHLD */
        if ((pid = Fork()) == 0) { /* Child process */
            Sigprocmask(SIG_SETMASK, &prev_one, NULL); /* Unblock SIGCHLD */
            Execve("/bin/date", argv, NULL);
        }
        Sigprocmask(SIG_BLOCK, &mask_all, NULL); /* Parent process */  
        addjob(pid);  /* Add the child to the job list */
        Sigprocmask(SIG_SETMASK, &prev_one, NULL);  /* Unblock SIGCHLD */
    }
    exit(0);
}
/* $end procmask2 */

非本地跳转

  C 提供了一种用户级异样控制流模式,称为非本地跳转(nonlocal jump),它将管制间接从一个函数转移到另一个正在执行的函数,而不须要通过失常的调用 - 返回序列。非本地跳转是通过 setjmp 和 longjmp 函数来提供的。

int setjmp(jmp_buf env);
int longjmp(jmp_buf env, int retval);
// sigsetjmp 和 siglongjmp 是 setjmp 和 longjmp 的能够被信号处理程序应用的版本
int sigsetjmp(sigjmp_buf env, int savesigs);
int siglongjmp(sigjmp_buf, int retval);

  setjmp 函数在 env 缓冲区中保留以后调用环境,以供前面的 longjmp 应用,并返回 0。调用环境包含程序计数器、栈指针和通用目标寄存器。留神 setjmp 返回的值不能赋值给变量(具体起因可自行思考),不过它能够平安地用在 switch 或条件语句中测试。

rc = setjmp(env);       // Wrong

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

  setjmp 函数只被调用一次,但返回屡次:一次是当第一次调用 setjmp,将调用环境保留在缓冲区 env 时;一次是为每个相应的 longjmp 调用时。另一方面,longjmp 函数被调用一次,但从不返回。非本地跳转的一个重要利用是运行一个深层嵌套的函数调用中立刻返回,通常是由检测到某个谬误状况引起的。咱们能够应用非本地跳转间接返回到一个一般的本地化的谬误处理程序,而无需费劲地解开调用栈。

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

jmp_buf buf;

int error1 = 0; 
int error2 = 1;

void foo(void), bar(void);

int main() 
{switch(setjmp(buf)) {
    case 0: 
    foo();
        break;
    case 1: 
    printf("Detected an error1 condition in foo\n");
        break;
    case 2: 
    printf("Detected an error2 condition in foo\n");
        break;
    default:
    printf("Unknown error condition in foo\n");
    }
    exit(0);
}

/* Deeply nested function foo */
void foo(void) 
{if (error1)
    longjmp(buf, 1); 
    bar();}

void bar(void) 
{if (error2)
    longjmp(buf, 2); 
}
/* $end setjmp */

  longjmp 容许它跳过两头调用地个性可能产生重大地结果。如果两头函数调用中调配了某些资源(内存、网络连接等),原本预期在函数结尾开释它们,那么这些开释代码会被跳过,因此产生资源透露。非本地跳转地另一个重要利用是使一个信号处理程序分支到一个非凡的代码地位,而不是返回到被信号达到中断了的指令的地位,比方,咱们能够应用 sigsetjmp 和 siglongjmp 来实现软重启。

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

sigjmp_buf buf;

void handler(int sig) 
{siglongjmp(buf, 1);
}

int main() 
{
    // 首次调用返回 0。当 jump 回到这里后,返回非 0
    if (!sigsetjmp(buf, 1)) {Signal(SIGINT, handler);
        Sio_puts("starting\n");
    }
    else 
    Sio_puts("restarting\n");

    while(1) {Sleep(1);
    Sio_puts("processing...\n");
    }
    exit(0); /* Control never reaches here */
}
/* $end restart */

C++、JAVA 提供的异样机制是较高层次的,是 C 语言的 setjmp、longjmp 函数的更加结构化的版本。你能够把 try 语句中的 catch 看作相似于 setjmp 函数。类似得,trhow 语句就相似于 longjmp 函数。

以下是一个 try-catch-throw 的样例。该程序会始终打印“KeyboardInterrupt”。

jmp_buf ex_buf__;

#define TRY do{if(!setjmp(ex_buf__)) {#define CATCH} else {#define ETRY} } while(0)
#define THROW longjmp(ex_buf__, 1)

void sigint_handler(int sig) {THROW;}

int main(void) {if (signal(SIGINT, sigint_handler) == SIG_ERR) {return 0;}

  TRY {// raise(sig) 成果等同 kill(getpid(), sig)
    raise(SIGINT); 
  } CATCH {printf("KeyboardInterrupt");
  }
  ETRY;
  return 0;
}

宏开展后,代码如下:

jmp_buf ex_buf__;

void sigint_handler(int sig) {longjmp(ex_buf__, 1);
}

int main(void) {if (signal(SIGINT, sigint_handler) == ((_crt_signal_t)-1)) {return 0;}

  do{if(!_setjmp(ex_buf__)) { {raise(SIGINT);
      } } else { {printf("KeyboardInterrupt");
      }
    } } while(0);

  return 0;
}

操作过程的工具

Linux 零碎提供了大量的监控和操作过程的有用工具。

  • strace:打印一个正在运行的程序和它的子过程调用的每个零碎调用的轨迹。如:strace cat /dev/null
  • ps:列出以后零碎中的过程(包含僵死过程)
  • top:打印对于以后过程资源应用的信息
  • pmap:显示过程的内存映射
  • /proc:一个虚构文件系统,以 ASCII 文本格式输入大量内核数据结构的内容,用户程序能够读取这些内容。如“cat /proc/loadavg”能够看到以后零碎的均匀负载

咱们所经验的每个平庸的异样,兴许就是间断产生的奇观。

正文完
 0