CSAPP-08异常控制流

Posted by SH on May 22, 2020

CSAPP-08异常控制流

========================================================

要点:异常进程信号

  • 异常
    • 异常类别
  • 进程
    • 私有地址空间
    • 用户模式和内核模式
    • 上下文
  • 进程操作
    • fork()
    • waitpid()、wait()
    • sleep()、pause()
    • execve()
  • 信号

========================================================

简单的控制流是一个“平滑的”序列,每个指令I_k 和 I_k+1 在内存中都是相邻的。平滑流的突变(两个指令不相邻)通常是由诸如跳转、调用和返回这样一些熟悉的程序指令造成的。

系统也必须能对系统状态的变化做出反应,这些系统状态不是被内部程序变量捕获的,而且也不一定要和程序的执行相关。比如:

  • 一个硬件定时器定期产生信号,这个事件必须得到处理;
  • 包到达网络适配器后,必须存放在内存中;
  • 程序向磁盘请求数据,然后休眠,直到被通知说数据已就绪;
  • 当子进程终止时,创造这些子进程的父进程必须得到通知。

现代系统通过使控制流发生突变来对这些情况做出反应。一般而言,把这些突变称为异常控制流(Exceptional Control Flow,ECF)。异常控制流发生在计算机系统的各个层次,比如:

  • 硬件层,硬件检测到的事件会触发控制突然转移到异常处理程序;
  • 在操作系统层,内核通过上下文切换将控制从一个用户进程转移到另一个用户进程。
  • 应用层,一个进程可以发送信号到另一个进程,而接收者会将控制突然转移到它的一个信号处理程序;一个程序可以通过回避通常的栈规则,并执行到其他函数中任意位置的非本地跳转来对错误做出反应。

作为程序员,理解ECF很重要:

  • ECF是操作系统用来实现I/O、进程和虚拟内存的基本机制。
  • 理解ECF将帮助理解应用程序是如何与操作系统交互的。应用程序通过使用一个叫做陷进(trap)或者系统调用(system call)的ECF形式,向操作系统请求服务。比如:向磁盘写数据、从网络读取数据、创建一个新进程、终止当前线程等,都是通过应用程序调用系统调用来实现的。
  • 利用ECF机制来编写诸如 Unix shell 和 Web服务器之类的有趣程序。
  • 理解ECF将帮助理解并发。ECF是计算机系统中实现并发的基本机制。比如:中断应用程序执行的异常处理程序,在时间上重叠执行的进程和线程,以及中断应用程序执行的信号处理程序。理解ECF是理解并发的第一步。
  • 理解ECF将帮助理解软件异常如何工作。像C++和Java语言通过try、catch以及throw语句来提供软件异常机制。软件异常允许程序进行非本地跳转(即违反通常的调用/返回栈规则的跳转)来响应错误情况。非本地跳转是一种应用层ECF,在C中通过setjmp和longjmp函数提供的。理解这些低级函数将帮助理解高级软件异常如何得以实现。

异常

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

image-20210115161049131

在处理器中,状态被编码为不同的位和信号,状态变化称为事件。事件可能和当前指令的执行直接相关,比如,发送虚拟内存缺页、算术溢出,或者一条指令试图除以零。另一方面,事件也可能和当前指令的执行没有关系,比如一个系统定时器产生信号或者一个I/O请求完成。

在任何情况下,当处理器检测到有事件发生时,它就会通过一张叫做异常表(exception table)的跳转表,进行一个间接过程调用(异常),到一个专门设计用来处理这类事件的操作系统子程序(异常处理程序,exception handler)。当异常处理程序完成处理后,根据引起异常的事件的类型,会发生以下3中情况之一:

  • 处理程序将控制返回给当前指令I_curr,即当事件发生时正在执行的指令。
  • 处理程序将控制返回给I_next,如果没有发生异常将会执行的下一条指令。
  • 处理程序终止被中断的程序。

异常处理

系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号(exception number)

  • 其中一些号码由处理器的设计者分配的,包括:被零除、缺页、内存访问违例、断点、算术运算溢出;
  • 其他号码是由操作系统内核的设计者分配的,包括:系统调用、来自外部I/O设备的信号。

系统启动时(重启或加电时),操作系统分配和初始化一张称为异常表的跳转表:

image-20210115232613463

异常表的起始地址放在一个叫做异常表基址寄存器(exception table base register)的特殊CPU寄存器里。

image-20210115232923807

异常处理过程类似于调用但有一些不同:

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

异常的类别

可分为四类:中断(interrupt)、陷阱(trap)、故障(fault)和终止(abort)。

类别 原因 异步/同步 返回行为
中断 来自I/O设备的信号 异步 总是返回到下一条指令
陷阱 有意的异常 同步 总是返回到下一条指令
故障 潜在可恢复的错误 同步 可能返回到当前指令
终止 不可恢复的错误 同步 不会返回

中断

中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。硬件中断的异常处理程序常常称为中断处理程序(interrupt handler)

image-20210115233855765

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

剩下的异常类型是同步发生的,是执行当前指令的结果,把这类指令叫做故障指令(faulting instruction)。

陷阱和系统调用

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

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

image-20210115234542272

cpu在检测到陷阱时,会在异常表中找到其异常处理程序并执行,执行完之后将控制返还给陷阱发生时正在执行指令的下一条指令,即控制流中的下一条指令。

陷阱的处理是应用程序开发者所无需关心的,所以对于上层开发者来说,系统调用和普通函数的调用没什么区别。

故障

故障由错误情况引起,它可能能够被故障处理程序修正。如果能修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它。否在,处理程序返回到内核中的abort例程,abort例程会终止引起故障的应用程序。

image-20210116001559056

比较典型的故障如缺页故障,当指令引用一个虚拟地址,而该虚拟地址对应的物理页面不在内存中时,就会发生缺页故障。缺页处理程序会先将该页面从磁盘加载到内存中,然后重新运行引起故障的指令。

终止

终止是可以理解为不可恢复的故障。通常是一页硬件故障,比如DRAM或者SRAM位被损坏时发生的奇偶错误。当发生终止时,终止处理程序从不将控制返回给应用程序,而是将控制返回给abort进程,该进程会终止引起故障的应用程序。

image-20210116001951027

Linux/x86-64系统中的异常

image-20210116002102102

进程

异常是允许操作系统内核提供进程概念的基本构造块,进程是计算机科学中最深刻、最成功的概念之一。

在现代操作系统上运行一个程序时,会得到一个假象,就好像程序是目前系统中唯一的程序,程序似乎独占处理器和内存,这些假象都通过进程的概念提供给我们的。

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

每次用户通过向shell输入一个可执行目标文件的名字,运行程序时,shell会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件,应用程序也能够创建新进程,并在这个新进程的上下文中运行它们自己的代码或者其他应用程序。

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

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

逻辑控制流

即使在系统中通常有许多其他程序在运行,进程也可以像每个程序提供一种假象,好像它在独占的使用处理器,如果想用调试器单步执行程序,会看到一系列PC值,这些值唯一的对应包含在程序的可执行目标文件中的指令,或是包含在运行时动态连接到程序的共享对象的指令,这个PC值的序列叫做逻辑控制流

image-20210116003026309

轮流使用处理器。

并发流

计算机系统中逻辑流有许多不同形式,异常处理程序、进程、信号处理程序、线程和Java进程都是逻辑流的例子。

一个逻辑流的执行在时间上与另一个流重叠,称为并发流(concurrent flow)。这两个流称为并发执行

多个流并发的执行的一般现象称为并发(concurrency)。一个进程和其他进程轮流运行的概念称为多任务(multitasking),一个进程执行它的控制流的一部分的每一时间段叫做时间片(time slice),因此多任务也叫做时间分片(time slicing)。在上图中,A由两个时间片组成。

注意,并发流的思想与流运行的处理器核数或计算机数无关,如果两个流在时间上重叠,那就是并发的,即使运行在同一个处理器。如果两个流并发地运行在不同的处理器核或者计算机上,那么称它们为并行流(parallel flow),它们并行地运行,且并行地执行。并行流是并发流的一个真子集。

私有地址空间

进程也为每个程序提供一种假象,好像独占的使用系统地址空间。在一台n位地址的机器上,地址空间是2^n个可能地址的集合,进程为每个程序提供自己的私有地址空间。一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其他进程读或写的,从这个意义上来说,这个地址空间是私有的。

尽管和每个私有地址空间相关联的内存的内容一般是不同的,但这个这样的空间都有相同的通用结构,下图为一个x86-64 linux进程的地址空间的组织结构。

image-20210116003754212

用户模式和内核模式

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

通常是通过控制寄存器的一个模式位(mode bit)来提供这种功能,该寄存器描述了进程当前享有的特权。设置模式位之后,进程就运行在内核模式中(有时叫做超级用户模式),这个进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。

没有设置模式位时,进程运行在用户模式,用户模式中的进程不允许执行特权指令,比如停止处理器、改变模式位、发起IO操作、不允许引用地址空间中内核区的代码和数据。用户程序必须通过系统调用接口间接的访问内核代码和数据。

进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障、或者陷入系统调用这样的异常,当异常发生时,控制传递到异常处理程序,处理器会从用户模式变为内核模式,处理程序运行在内核模式,当返回到应用程序代码时,处理器就从内核模式换回用户模式。

​ linux提供了一种机制,叫做/proc文件系统,允许用户模式进程访问内核数据结构中的内容,/proc文件系统将许多内核数据的内容输出为一个用户程序可以读的文本文件的层次结构。例如,可以使用/proc文件系统找出一般的系统属性,比如CPU(/proc/cpuinfo),或者某个特殊的进程使用的内存段(/proc//maps)。

上下文切换

操作系统内核使用一种称为上下文切换的较高层次的异常控制流来实现多任务。上下文切换机制是建立在上面讨论过的低层的异常机制上的。

内核为每个进程维持一个上下文,上下文就是内核重新启动一个被抢占的进程所需要的状态,由一些对象的值组成, 包括通用目的寄存器浮点寄存器程序计数器用户栈状态寄存器内存栈各种内核数据结构,比如描述地址空间的页表、包含当前进程信息的进程表,以及包含进程已打开文件的信息的文件表

在进程执行的某些时刻,内核可以决定抢占当前的进程,并重新开始一个先前被抢占的进程,这种决策叫做调度,是内核中成为调度器的代码处理的。当内核选择一个新的进程运行时,我们会说内核调度了这个进程,在内核调度一个新的进程运行后,它就抢占当前进程,并使用一种上下文切换的机制将控制转移到新的进程。

上下文切换:

  • 1)保存当前进程的上下文。
  • 2)恢复某个先前被强占的进程被保存的上下文。
  • 3)将控制传递给这个新恢复的进程。

当内核代表用户执行系统调用时,可能会发生上下文切换,如果系统调用因为等待某个事件发生阻塞。那么内核可以让当前进程休眠,切换到另一个进程。比如:

  • 如果一个read系统调用需要访问磁盘,内核可以选择执行上下文切换,运行另外一个进程,而不是等待数据从磁盘到达;
  • sleep系统调用,它显示地请求让调用进程休眠。

一般而言,系统调用即使没有阻塞,内核也可以执行上下文切换,而不是将控制返回给调用进程。

中断也可以引发上下文切换。比如,发生定时器中断时,内核就能判定当前进程已经运行了足够长的时间,并切换到一个新的进程。

下图为一对进程上下文切换的示例,进程A初始运行在用户模式在,直到执行系统调用read陷入内核,内核中的陷阱处理程序请求来自磁盘控制器的DMA传输,并安排在磁盘控制器完成从磁盘到内存的数据传输后,磁盘中断处理器。

image-20210116005213473

磁盘读取数据要用一段相对较长的时间,所以内核执行从进程A到进程B的上下文切换,而不是在这个时间内等待,什么也不做。

注意切换之前内核正代表A在用户模式下执行指令,切换的第一部分代表A在内核模式下执行指令,某一时刻又开始代表B(内核模式)执行指令,在切换之后内核代表进程B在用户模式下执行指令。随后B在用户模式下运行一会直到磁盘发出一个中断信号,表示数据已经从磁盘传送到内存,内核判定B已经运行足够长事件,执行一个从B到A的上下文切换,将控制返回给A紧随read之后的指令,A继续运行。

系统调用错误处理

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

程序员应该总是检查错误。

通常使用错误处理包装函数,可以更进一步地简化代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
pid_t Fork(void)
{
    pid_t pid;

    if ((pid = fork()) < 0)
        unix_error("Fork error");
    printf("fork pid = %d\n", pid);
    return pid;
}

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

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

进程控制

Unix提供了大量从C程序中操作进程的系统调用。

获取进程ID

每个进程都有唯一的正数(非0)进程ID(PID),getpid函数返回调用进程的PID,getppid函数返回他的父进程的PID(创建调用进程的进程)。

1
2
3
4
5
6
7
8
9
int main() 
{
    pid_t pid = getpid();
    pid_t ppid = getppid();

    printf("pid=%d ppid=%d\n", pid, ppid);

    exit(0);
}

创建和终止进程

从程序员角度,可以认为进程总是处于下面三种状态之一:

  • 运行。进程要么在CPU上执行,要么在等待被执行且最终会被内核调度。
  • 停止。进程的执行被挂起(suspended),且不会被调度,当收到SIGSTOP、SIGTSTP、SIGTTIN或者SIGTTOU信号时,进程会停止,并保持停止直到它收到一个SIGCONT信号,在这个时刻,进程再次开始运行。
  • 终止。进程永远的停止,进程会因为三种原因终止:
    • 1)收到一个信号,该信号的默认行为是终止进程。
    • 2)从主程序返回。
    • 3)调用exit函数。
1
2
3
#include <stdlib.h>
void exit(int status);
// exit函数以status退出状态来终止进程(另一种设置退出状态的方法是从主程序返回一个整数值)。

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

1
2
3
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);

新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一个副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程最大的区别在于他们有不同的PID。

fork函数是有趣的,它只被调用一次,却会返回两次:一次是在调用进程(父进程)中,一次是在新创建的子进程中。在父进程中fork返回的是子进程的PID,在子进程中fork返回0。因为子进程的PID总是非0,返回值就明确的分辨出程序是在父进程还是在子进程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int main()
{
    pid_t pid;
    int x = 1;

    pid = Fork();
    /* Child */
    if (pid == 0)
    {
        printf("child : x=%d\n", ++x); // x=2
        exit(0);
    }

    /* Parent */
    printf("parent: x=%d\n", --x); // x=0
    exit(0);
}

可以看到:

  • 调用一次,返回两次。fork函数被父进程调用一次,却返回两次。一次是返回到父进程,一次是返回到新创建的子进程。有多个fork实例需要仔细。
  • 并发执行。父进程和子进程是并发运行的独立进程,内核能以任意方式交替执行它们的逻辑控制流中的指令。
  • 相同但是独立的地址空间。
  • 共享文件。子进程共享了父进程打开的文件,stdout。所以子进程输出也是指向屏幕。

image-20210118105924481

两次调用fork:会输出四次hello。

image-20210118110129314

如下程序输出什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main()
{
    int x = 1;

    if (Fork() == 0)
    {
        printf("p1: x=%d\n", ++x);
    }
    printf("p2: x=%d\n", --x);
    exit(0);
}
// 输出
// p2: x=0
// p1: x=2
// p2: x=1

回收子进程

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

如果一个父进程终止了,内核会安排init进程成为它(子进程)的孤儿进程的养父。init进程的PID为1,是系统在启动时由内核创建的,它不会终止,是所有进程的祖先。如果父进程没有回收它的僵尸进程就终止了,那么内核会安排init进程回收它们。不过,长时间运行的程序,比如shell或者服务器,总是应该回收它们的僵尸子进程,即使僵尸进程没有运行,仍然会消耗系统的内存资源。

一个进程可以通过调用waitpid函数等待它的子进程终止或停止。

1
2
3
4
5
#include <sys/types.h>
#include <sys/wait.h>
 
// 成功则返回子进程pid,如果WNOHANG则为0,其他错误返回-1
pid_t waitpid(pid_t pid, int *statusp, int options);

默认情况下(options=0时),waitpid挂起调用进程的执行,直到它的等待集合(wait set)中一个子进程终止。如果等待集合中的一个进程在刚调用的时候就已经终止,那么waitpid立即返回。在这两种情况下,waitpid返回导致waitpid返回的已终止子进程的PID。此时,已终止的子进程已经被回收,内核会从系统中删除掉它的所有痕迹。

判定等待集合的成员

等待集合的成员是由参数pid来确定。

  • 如果pid>0,那么等待集合就是一个单独的子进程,它的进程ID等于pid。
  • 如果pid=-1,那么等待集合就是由父进程所有的子进程组成。

修改默认行为

可以通过将options设置为常量WNOHANG、WUNTRACED和WCONTINUED的各种组合修改默认行为。

  • WNOHANG:如果等待集合中的任何子进程都还没有终止,那么就立即返回,返回值为0,默认的行为是挂起调用进程,直到有子进程终止,在等待子进程终止的同时如果还有想做的工作,这个选项会有用。
  • WUNTRACED:挂起调用进程的执行,直到等待集合中一个进程变成已终止或者已经被停止,返回的PID为呆滞返回的已终止或被停止的子进程的PID,默认的行为是只返回已终止的子进程。当想要检查已终止和被停止的子进程时会有用。
  • WCONTINUED:挂起调用进程的执行,直到等待集合中一个正在运行的进程终止,或等待集合中一个被停止的进程收到SIGCONT信号重新开始执行。

​ 可以用或用算把这些选项组合起来。

检查已回收子进程的退出状态

如果statusp参数是非空的,那么waitpid就会在status中放上关于导致返回子进程的状态信息,status是statusp指向的值。wait.h头文件中定义了status参数的几个宏:

  • WIFEXITED:如果子进程调用exit或者返回正常终止,就返回真。
  • WEXITSTATUS:返回一个正常终止的子进程的退出状态,只有在WIFEXITED()返回为真时,才定义这个状态。
  • WIFSIGNALED:如果子进程是因为一个未被捕获的信号终止的,那么就返回真。
  • WTERMSIG:返回导致子进程终止的信号的编号,只有在WIFSIGNALED()返回为真时,才定义这个状态。
  • WIFSTOPPED:如果引起返回的子进程当前是停止的,那么返回真
  • WSTOPSIG:返回引起子进程停止的信号的编号。只有在WIFSTOPPED()返回为真才定义这个状态。
  • WIFCONTINUED:如果子进程收到SIGCONT信号重新启动,则返回真。

错误条件

如果调用进程没有子进程,那么waitpid返回-1,并设置errno为ECHILD。如果waitpid函数被一个信号中断,那么返回-1,并设置erron为EINTR。

如下程序可能输出是什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int main(int argc, char const *argv[])
{
    if (fork() == 0)
    {
        printf("a");
        fflush(stdout);
    }
    else
    {
        printf("b");
        fflush(stdout);
        waitpid(-1, NULL, 0);
    }
    printf("c");
    fflush(stdout);
    exit(0);
}
// 输出:abcc 或者 bacc 或者 acbc

image-20210118113054931

wait函数

wait函数是waitpid函数的简单版本。

1
2
3
4
#include <sys/types.h>
#include <sys/wait.h>
 
pid_t wait(int *status);

调用wait(&status)等价于调用waitpid(-1,&status,0)。

使用waitpid示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include "csapp.h"
#define N 2

void unix_error(char *msg)
{
    fprintf(stderr, "===unix_error: %s: %s\n", msg, strerror(errno));
    exit(0);
}

int main()
{
    int status, i;
    pid_t pid;
    // 创建N个子进程
    for (i = 0; i < N; i++)
        if ((pid = fork()) == 0) // 进程
            exit(100 + i);       // 退出子进程
    // waitpid第一个参数为-1,等待集合是父进程的所有子进程
    while ((pid = waitpid(-1, &status, 0)) > 0)
    {
        if (WIFEXITED(status))
            printf("child %d terminated normally with exit status=%d\n", pid, WEXITSTATUS(status));
        else
            printf("child %d terminated abnormally \n", pid);
    }

    if (errno != ECHILD)
        unix_error("waitpid error");
    exit(0);
}
// 上图程序不按照特定顺序等待它的所有子进程终止,每个子进程以唯一的退出状态退出。
// while里检查子进程退出状态,如果是正常退出那么提取退出状态,输出。
// 当回收了所有子进程后再调用waitpid就返回-1,并设置errno为ECHILD,并检查程序是否正常终止,否则输出一个错误信息。
// 该程序不会按照特定的顺序回收子进程,这种不确定性使得并发推理非常困难,所以需要尽量避免。

下图对之前程序做些许改变,可以消除回收的不确定性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include "csapp.h"
#define N 2

void unix_error(char *msg)
{
    fprintf(stderr, "===unix_error: %s: %s\n", msg, strerror(errno));
    exit(0);
}

int main()
{
    int status, i;
    // pid[N] 按顺序存储
    pid_t pid[N], retpid;
    for (i = 0; i < N; i++)
        if ((pid[i] = fork()) == 0)
            exit(100 + i);
    // 按照父进程创建子进程的相同顺序回收子进程。
    i = 0;
    while ((retpid = waitpid(pid[i++], &status, 0)) > 0) // i++
    {
        if (WIFEXITED(status))
            printf("child %d terminated normally with exit status=%d\n", retpid, WEXITSTATUS(status));
        else
            printf("child %d terminated abnormally \n", retpid);
    }

    if (errno != ECHILD)
        unix_error("waitpid error");
    exit(0);
}

让进程休眠

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

1
2
3
#include<unistd.h>
 
unsigned int sleep(unsigned int secs);

如果请求的时间量到了,sleep返回0,否则返回还剩下的要休眠的秒数,后一种情况是可能的,如果sleep函数被一个信号中断而过早的返回。

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

1
2
3
#include <unistd.h>

int pause(void);

加载并运行程序

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

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

execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序,所以,与fork一次调用两次返回不同,execve调用一次不返回。

参数列表是如下图的数据结构,argv变量指向一个以null结尾的指针数组,其中每个指针都指向一个参数字符串,按惯例,argv[0]是可执行文件的名字。环境变量的列表是由一个类似的数据结构表示的,envp变量指向一个以null结尾的指针数组,其中每个指针指向一个环境变量字符串,每个串都是形如“name=value”的名字值对。

image-20210118114859819

在exceve加载了filename之后,调用一些启动代码,启动代码设置栈,并将控制传递给新程序的主函数,该主函数有如下形式:

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

当main开始执行时,用户的栈组织结构如下图所示,从栈底(高地址)往栈顶(低地址)看,首先是参数和环境字符串,栈网上紧随其后的是以null结尾的指针数组,其中每个指针都指向栈中一个环境变量字符串,全局变量environ指向这些指针中的第一个envp[0],紧跟环境变量数组之后的是以null结尾的argv[]数组,其中每个元素都指向栈中的一个参数字符串,在栈顶部是系统启动函数libc_start_main的栈帧。

image-20210118115147863

main函数有三个参数,

  • 1)argc,它给出argv[]数组中非空指针的数量;
  • 2)argv指向argv[]数组中第一个条目;
  • 3)envp,指向envp[]数组中第一个条目。

linux提供了几个函数操作环境数组:

1
2
3
4
5
6
7
8
#include <stdlib.h>
// getenv函数在环境数组中搜索字符串“name=value”,找到了返回一个指向value的指针,否则返回NULL。
char *getenv(const char *name);

// 如果setenv会用newvalue代替oldenv,但只有在overwirte非0时才会这样,如果name不存在,那么setenv就把“name=newvalue”添加到数组。
int setenv(const char *name, const char *value, int overwrite);
// 如果环境数组包含一个形如“name=oldvalue”的字符串,那么usetenv会删除它。
int unsetenv(const char *name);

程序与进程:

  • 程序是一堆代码和数据,程序可以作为目标文件存在于磁盘上,或者作为段存在于地址空间中。
  • 进程是执行中程序的一个具体的实例,程序总是运行在某个进程的上下文中。
  • fork函数在新的子进程中运行相同的程序,新的子进程是父进程的一个复制品。
  • execve函数会在当前进程上下文中加载并运行一个程序,会覆盖当前进程的地址空间,但是并没有创建新的进程,新的程序仍然有相同的PID。

利用fork和execve运行程序

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

信号

信号是一种更高层的软件形式的异常,它允许进程和内核中断其他进程。

一个信号就是一条小消息,它通知进程系统中发生一个某种类型的事件。每种信号对应某种系统事件,低层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程而言是不可见的。信号提供一种机制,通知用户进程发生了这些异常,比如,一个进程试图除以0,那么内核就会发送一个SIGFPE信号,如果一个进程执行一条非法指令,那么内核就发送给它一个SIGSEGV信号。

image-20210118135509311

信号术语

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

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

image-20210118135755657

一个发出而没有被接收的信号,叫做待处理信号(pending signal)。在任何时刻,一种类型至多只会有一个待处理的信号,如果一个进程有一个类型为k的待处理信号,那么任何接下来发送到这个进程的类型为k的信号都不会排队等待,它们只是被简单地丢弃。一个就能成可以有选择性地阻塞接收某种信号,当一种信号被阻塞时,它仍可以发送,但是产生地待处理信号不会被接收,直到进程取消对这种信号的阻塞。

一个待处理信号最多只能被接收一次,内核为每个进程在pending位向量中维护着待处理信号的集合,而在blocked位向量(信号掩码)中维护着被阻塞的信号集合,只要传送了一个类型为k的信号,内核就会设置pending中的第k位。而只要接受一个类型为k的信号,内核就会清除pending中的第k位。

发送信号

进程组

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

1
2
3
#include <unistd.h>
 
pid_t getpgid(void);

默认的,一个子进程和它的父进程属于同一个进程组,一个进程可以通过使用setpgid函数改变自己或其他进程的进程组。

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

setpgid函数将进程pid的进程组改为pgid,如果pid是0,那么就使用当前进程的PID,如果pgid是0,那么就用pid指定的进程的PID作为进程组ID。

用/bin/kill程序发送信号

/bin/kill程序可以向另外的进程发送任意的信号,比如

1
linux> /bin/kill -9 15213

​ 发送信号9(SIGKILL)给进程15213。一个负的PID会导致信号被发送到进程组PID中的每个进程。如果上面的15213变成负的,那么会发送信号9给进程组15213的每一个进程。

从键盘发送信号

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

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

image-20210118141048798

在键盘上输入:

  • Ctrl+C:会导致内核发送一个SIGINT信号到前台进程组中的每个进程,默认情况下,结果是终止前台作业,
  • Ctrl+Z:会发送一个SIGTSTP信号到前台进程组中的每个进程,结果是停止(挂起)前台作业。

用kill函数发送信号

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

1
2
3
4
#include <sys/types.h>
#include <signal.h>
 
int kill(pid_t pid, int sig);
  • 如果pid大于0,那么kill函数发送信号号码sig给进程pid。
  • 如果pid等于0,那么kill发送信号sig给调用进程所在进程组的每个进程,包括调用进程自己。
  • 如果pid小于0,那么kill发送信号sig给进程组 pid (pid绝对值)中的每个进程。

用alarm函数发送信号

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

1
2
3
#include <unistd.h>
 
unsigned int alarm(unsigned int seconds);

alarm函数安排内核在seconds秒之后发送一个SIGALRM信号给调用进程,如果seconds是0,那么就不会调度安排新的闹钟。任何情况乱下,对aralm的调用都将取消任何待处理的(pending)闹钟,并且返回任何待处理的闹钟在被发送前还剩下的秒数(如果这次对aralm的调用没有取消它的话),如果没有任何待处理的闹钟,就返回0。

接收信号

当内核把进程p从内核模式切换到用户模式时,它会检查进程p的未被阻塞的待处理信号的集合

  • 如果这个集合为空,那么内核将控制传递到p的逻辑控制流中的下一条指令。
  • 如果集合是非空的,那么内核选择集合中的某个信号k(通常是最小的k),并且强制p接收信号k,收到这个信号会触发进程采取某种行为,一旦进程完成了这个行为,那么控制就传递回p的逻辑控制流中的下一条指令

每个信号类型都有一个预定的默认行为,是下面的一种:

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

当一个进程捕获了一个类型为k的信号时,会调用为信号k设置的处理程序,一个整数参数被设置为k。这个参数允许同一个处理函数捕获不同类型的信号。

当处理程序执行它的return语句,控制通常传递回控制流中进程被信号接收中断位置处的指令。

信号处理程序可以被其他信号处理程序中断。例程:捕获键盘中断,并且编写信号处理程序,信号处理程序也有可能被中断。

image-20210118142124575

阻塞和解除阻塞信号

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

  • 隐式阻塞机制。内核默认阻塞任何当前处理程序正在处理信号类型的待处理的信号。例如,上图,假设程序捕获了信号s,当前正在运行处理程序S,如果发送给该进程另一个信号s,那么直到处理程序S返回,s会变成待处理而没有被接收。
  • 显式阻塞机制。应用程序可以使用sigprocmask函数和它的辅助函数,明确地阻塞和解除阻塞选定的信号。

编写信号处理程序

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

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

安全的信号处理

  • 1)处理程序尽可能简单
  • 2)在处理程序中只调用异步信号安全的函数。所谓的异步信号安全的函数能够被信号处理程序安全调用,要么它是可重入的(只访问局部变量),要么是不能被信号处理程序中断。 信号处理函数中产生唯一安全的方法是使用write函数,特别的调用printf和sprintf是不安全的。
  • 3)保存和恢复errno:许多linux异步信号安全的函数都会在出错返回设置errno。在处理程序中调用这样的函数会干扰主程序其他依赖errno的部分,解决办法是在进入处理程序时把errno保存在一个局部变量中,在处理程序返回前恢复它,只有在处理程序要返回时才有必要,如果处理程序调用exit终止进程就不需要这样做。
  • 4)阻塞所有信号,保护对全局数据结构的访问
  • 5)用volatile声明全局变量。保证可见性。
  • 6)用sig_atomic_t声明标志,C提供这种类型保证对它的读写是原子的。

正确的信号处理

信号的一个与直觉不符的方面是未处理的信号是不排队的,因为pending位向量中每种类型的信号只对应一位,所以每种类型最多只能有一个未处理的信号,因此如果两种类型k信号发送给一个目的进程而因为目的进程当前正在执行信号k的处理程序,所以信号k被阻塞了,那么第二个信号就简单的丢弃了。它不会排队,关键思想是如果存在一个未处理的信号,就表示至少有一个信号到达了

这里需要注意的是如果信号处理函数正在处理一个信号,而又有一个同类型信号处于阻塞状态,那么在这个时候如果还有一个同类型信号到来,那么这个信号会被丢弃。所以,不可以用信号来对其他进程中发生的事件进行计数。

可移植的信号处理

Unix信号处理的另一个缺陷在于不同的系统有不同的信号处理语义。

Posix标准定义了一个sigaction函数,允许用户设置信号处理时,明确指定他们想要的信号处理语句。

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

如何编写相同存储位置的并发流程序的问题是比较困难的。流的交错可能会造成一些问题,基本问题是以某种同步并发流,从而得到最大的可行的交错的集合,每个可行的交错都能得到正确的结果。

如果主程序中的一个调用函数和信号处理函数中一个调用函数存在竞争,那么就会导致一个很经典的同步错误,也就是竞争。原因是,父进程main程序和信号处理流之间的某些交错会导致事件的发生顺序发生变化,我们可以采用一个很简单的办法消除竞争。就是在使用fork之前,阻塞需要SIGCHID信号,然后在主程序调用了函数之后,再取消阻塞,这样就可以保证子进程的调用函数发生在主程序调用函数之后。

显示地等待信号

有时候主程序需要显式地等待某个信号处理程序运行。例如,当linux shell创建一个前台作业时,在接收下一条用户命令之前,它必须等待作业终止, 被SIGCHLD处理程序回收。有一个合理的解决办法是使用sigsuspend

1
2
3
4
#include <signal.h>
 
//返回-1
int sigsuspend(const sigset_t *mask);

sigsuspend函数暂时用mask替换当前的阻塞集合,然后挂起该进程,直到收到一个信号,其行为要么是运行一个处理程序,要么是终止该进程,如果它的行为是终止,那么该进程不从sigsuspend返回,就直接终止。如果行为是运行一个处理程序,那么sigsuspend从处理程序返回,恢复调用sigsuspend时原有的阻塞集合。

​ sigsuspend相当于下面的代码的原子版本:

1
2
3
sigprocmask(SIG_SETMASK, &mask, &prev);
pause();
sigprocmask(SIG_SETMASK, &prev, NULL);

原子属性保证对sigprocmask和pause的调用总是一起发生的,不会被中断,这样就消除了潜在的竞争,即在调用sigprocmask之后但在调用pause之前收到一个信号。

非本地跳转

c语言提供了一种用户级异常控制流形式,称为非本地跳转(nonlocal jump)。它将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要正常的调用-返回序列,非本地跳转是通过setjumplongjump函数提供的。

1
2
3
4
5
6
7
#include <setjmp.h>
 
int setjmp(jmp_buf env);
int sigsetjmp(sigjmp_buf env, int savesigs);

void longjmp(jmp_buf env, int val);
void siglongjmp(sigjmp_buf env, int val);
  • setjmp函数在env缓冲区保存当前调用环境,以供后面的longjmp使用,并返回0。调用环境包括程序计数、栈指针和通用目的寄存器,setjmp返回的值不能被赋值给变量。不过可以安全的使用在switch或条件语句测试中。
  • longjmp函数从env缓冲区中恢复调用环境,然后触发一个最近一次初始化env的setjmp调用的返回,然后setjmp返回,并带有非0的返回值retval。

setjmp调用一次返回多次,一次是当第一次调用setjmp而调用环江保存在缓冲区env中,一次是为每个相应的longjmp,另一方面,longjmp函数被调用一次,从不返回。

应用:

  • 非本地跳转的一个重要应用是允许从一个深层嵌套的函数调用中立即返回,通常是检测到某个错误情况引起的,如果在一个深层嵌套的函数调用中发现一个错误情况,我们可以使用非本地跳转直接返回到一个普通的本地化错误程序
  • 另一个重要应用是使一个信号处理程序分支到一个特殊的代码位置,而不是返回到被信号到达中断了的指令的位置。

c++和Java中的软件异常:

c++和Java提供的异常机制是较高层次的,是C语言的setjmp和longjmp函数的更加结构化的版本。可以把try语句中的catch子句看做类似于setjmp函数,相似的,throw语句就类似于longjmp函数。

操作进程的工具

  • STRACE:打印一个正在运行的程序和它的子进程调用的每个系统的调用轨迹。
  • PS:列出当前系统中的进程(包括僵尸进程)
  • TOP:打印出关于当前进程资源使用的信息。
  • PMAP:显示进程的内存映射。
  • /proc:一个虚拟的文件系统,以ASCII文本格式输出大量内核数据结构的内容,用户程序可以读取这些内容。