csapp之第8章:异常控制流

进击的编程喵

0 理解异常控制流

作为程序员,理解异常控制流(Exceptional Control Flow)ECF很重要,原因:

  • 理解ECF将帮助你理解重要的系统概念。ECF是操作系统实现I/O、进程和虚拟内存的基本机制
  • 理解ECF将帮助你理解应用和系统是如何交互的。程序通过trapsyscall的ECF形式,向系统请求服务
  • 理解ECF将帮助编写有趣的新应用程序
  • 理解ECF将有助于理解并发,ECF是系统中实现并发的基本机制。在运行中的并发的例子:

    • 中断程序执行的异常处理程序
    • 时间上重叠执行的进程和线程
    • 中断应用程序执行的异常处理程序

1 异常控制流

1.1 控制流

从开机到关机,处理器做的工作很简单,每个CPU内核只是简单地读和执行指令,每次一条。整个指令执行的序列就是CPU控制流

前面已经学过两种改变控制流的方式:

  • 跳转和分支
  • 调用和返回

只适用于程序状态的改变,很难应对系统状态的改变,比如:

  • 数据从磁盘或网络适配器读取
  • 指令除零
  • 用户按ctrl-c
  • 系统定时器超时

因此系统需要叫做异常控制流的机制。它存在于系统每个层面:

  • 底层机制

    • 异常(Exceptions):用以响应系统事件,通常由硬件和操作系统实现
  • 高层机制

    • 进程切换(Process Context Switch):系统软件和硬件定时器实现
    • 信号(Signal):系统软件实现
    • 非本地跳转(Nonlocal Jumps):包括setjmp()和longjmp()。C运行时库实现

2 异常

异常指的是把控制权交给系统内核以响应某些事件(如处理器状态的改变),系统内核是操作系统常驻内存的一部分,响应的事件包括:除零、运算溢出、页错误、IO请求完成或用户按ctrl-c等系统级别的事件。过程如图:

image-20220119122413641

注意:中断陷阱(最重要用途是syscall)都是返回到下条指令,故障若被处理程序修正错误则返回当前指令重新执行,否则返回到内核中abort例程,它会终止应用程序,它从不将控制权返回给应用程序。

每种事件都对应唯一的异常编号,当异常发生时,系统通过查找异常表(Exception Table)中对应的异常编号确定异常处理代码,过程如图:

image-20220119122532519

image-20220119122613855

2.1 异步异常(中断)

异步异常(Asynchronous Exception)也叫中断(Interrupt),是由处理器外部事件引起。对执行程序而言,“中断”的发生完全是异步的,因为不知道什么时候会发生。CPU对其响应也是被动的,但可以屏蔽。这种情况下:

  • 需设置CPU中断指针
  • 处理完后返回之前控制流下条指令

常见中断有:

  • 计时器中断:每隔几毫秒外部定时器芯片就会触发一次中断,被内核用来从用户程序拿回控制权
  • I/O中断:类型较多:如键盘输入ctrl-c、来自网络中包到达、来自磁盘的数据到达

2.2 同步异常

同步异常(Synchronous Exceptions)由执行指令的结果导致的事件,包括三类:

类型说明行为示例
Trap为某事有意设置返回到先前下条指令系统调用、调试断点
Fault潜在可恢复错误返回当前指令或终止页故障(page faults)
Abort不可恢复的错误终止当前执行的程序非法指令、硬件错误

image-20220119122824882

2.3 系统调用

在X86-64系统中,每个系统调用都有唯一的ID号,如:

编号名称说明
0read读取文件
1write写入文件
2open打开文件
3close关闭文件
4stat文件信息
57fork创建进程
59execve执行程序
60_exit关闭进程
62kill发送信号

2.3.1 系统调用示例:open

用户调用open打开文件,系统实际通过__open函数执行编号为2的syscall,返回值为负则出错,汇编代码如图:

image-20220119123200826

2.3.2 故障示例

以Page Fault为例说明,Page Fault发生的前提是:用户写入内存位置,但该位置暂时不在内存,系统荣过Page Fault异常把对应的页从磁盘拷贝到内存,代码和流程如图:

int a[1000];
int main()
{
    a[500] = 13;
}

但若代码改成非法地址,则整个流程会变成如图:

image-20220119123418124

系统向用户进程发送SIGSEGV信号,用户进程以segmentation fault的标记退出。

3 进程

进程是程序的运行实例,它是计算机科学中最重要的思想之一,进程给每个程序提供两个关键的抽象,使得具体的进程不需操心处理器和内存等细节,也保证不同情况下运行同样的程序能得到相同的结果。两个关键抽象如下:

  • 逻辑控制流:通过上下文切换(context switching)的内核机制让每个程序都感觉在独占处理器
  • 私有地址空间。通过虚拟内存(virtual memory)的机制让每个程序都感觉在独占内存

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

image-20220119123450788

3.1 多进程

多进程就是计算机同时运行多个进程,如web浏览器、email客户端、编辑器、检测网络和IO设备等。分为两大类:

  • 传统:单处理器交错执行多个进程(但可把并发进程看作并行运行),虚拟内存系统管理地址空间,未运行进程的寄存器值存储在内存中,以便下次执行时恢复(即上下文切换),切换时会载入已保存的将要执行的进程的寄存器值
  • 现代:现代处理器都有多个核心,多核处理器即单个芯片有多个CPU,共享主要内存和某些缓存,具体调度由内核控制,每个CPU都可执行单独进程

image-20220119123527065

image-20220119123555006

进程切换时,由内核负责调度,如图:

image-20220119123627743

3.2 用户态模式和内核模

处理器提供限制应用可执行的指令及可访问的地址空间范围的机制,通常用某个控制寄存器中的一个模式位(mode biit)提供,设置模式位时,进程就运行在内核模式,可执行任何指令且访问系统任何内存位置,否则是用户模式,必须通过系统调用接口间接访问内核代码和数据

进程初始时在用户模式,改变为内核模式的唯一方法是通过中断、陷入系统调用、故障这样的异常,异常发生时,控制传递到异常处理程序,处理器将用户模式变为内核模式,程序在内核模式中运行,当控制返回到进程时,处理器将模式从内核模式变为用户模式

3.3 上下文切换

内核用一种被称为上下文切换(context switch)的较高层形式的异常控制流实现多任务,是建立在较低层异常机制上的。内核为每个进程维持一个上下文(context),即重新启动被抢占的进程所需的状态:寄存器、用户栈、内核栈及各种内核数据结构(如页表、进程表、已打开文件的信息的文件表)

内核可决定抢占当前进程,重新开始先前被抢占的进程,该决策叫调度(scheduling),由内核中的调度器(scheduler)处理。内核调度新的进程运行后,抢占当前进程,使用上下文切换机制将控制权转移到新进程:

  1. 保存当前进程上下文
  2. 恢复先前被抢占进程保存的上下文
  3. 控制权传递给该新恢复的进程

4 进程控制

4.1 系统调用错误处理

出错时,Linux系统函数通常返回-1且设置全局变量errno表示错误原因。使用系统函数时牢记两个原则:

  • 每个系统调用都应检查返回值
  • 唯一例外是少数返回void的函数

如对于fork()函数,应检查返回值:

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

4.2 错误处理包装

若嫌麻烦可用下面错误处理包装函数,可进一步简化代码,Stevens首先提出该方法,定义相同参数的包装函数但首字母大写,包装函数内调用基本函数并检查错误:

void unix_error(char *msg) /* Unix-style error */
{
    fprintf(stderr, "%s: %s\n", msg, strerror(errno));
    exit(-1);
}
//则fork()返回错误值的部分可改写为
if ((pid = fork()) < 0)
    unix_error("fork error");

更进一步,可把整个fork()包装起来,就可自带错误处理,如:

pid_t Fork(void)
{
    pid_t pid;

    if ((pid = fork()) < 0)
        unix_error("Fork error");
    return pid;
}
pid = Fork();//调用时则直接调用包装的函数

4.3 获取进程ID

使用以下两个函数获取进程相关信息:

  • pid_t getpid(void):返回当前进程PID
  • pid_t getppid(void) - 返回当前进程的父进程的 PID

4.4 进程生命周期

从程序员的角度看,可将进程看作是处于三种状态之一:

状态说明
运行 Running进程要么正运行,要么等待被执行或最终将会被内核调度执行
停止 Stopped进程执行被挂起(suspended),并且在进一步收到SIGCONT信号前不会被调度执行
终止 Terminated进程被永久停止

当然还包括新建(new)和就绪(ready),待补充。

4.4.1 终止进程

包括三种情况:

  • 接收到终止信号
  • main函数返回
  • 调用exit函数,注意该函数被调用一次但从不返回

4.4.2 创建进程

父进程调用fork创建新进程,注意该函数执行一次,但返回两次,子进程返回0,父进程返回子进程PID,函数原型:

// 子进程,返回 0,父进程,返回子进程的 PID
int fork(void)

image-20220119123702412

注意:子进程和父进程几乎完全相同,子进程获得父进程虚拟地址空间相同但独立的副本,获得相同的父进程打开的文件描述符拷贝,但子进程有和父进程不同的PID

完全拷贝执行状态:

  • 指定一个为父,一个为子
  • 恢复父或子的执行

4.4.3 重新审视fork函数

  • 虚拟内存和内存映射解释fork如何为每个进程提供私有地址空间
  • 创建当前进程的mm_structvm_area_struct和页表的精确拷贝,标记每个进程的每个页为只读,标记每个进程的每个vm_area_struct为私有COW,每个进程有精确的虚拟内存拷贝
  • 后续写操作使用COW机制创建新页

4.4.4 fork示例

int main()
{
    pid_t pid;
    int x = 1;

    pid = Fork();
    if (pid == 0) {   // Child
        printf("I'm the child!  x = %d\n", ++x);
        return 0;
    }
    
    // Parent
    printf("I'm the parent! x = %d\n", --x);
    return 0;
}

注意:

  • 调用一次返回两次
  • 并行执行,无法预计父进程和子进程执行顺序,因此执行结果有两种
  • 双方有相同的但独立的地址空间(变量独立)
  • 共享文件,继承所有打开文件,如都把它们的输出显示在屏幕,因为父调fork时,stdout是打开,子继承并输出到指向的屏幕

补充:

  • 问题:Linux调度器不会产生太多run-to-run的变化,在不确定中隐藏潜在的竞争关系,如fork是先返回child还是parent?
  • 解决:创建自定义的库例程,在不同的分支中插入随机延迟;使用运行时定位使程序使用特殊版本的库代码
/* fork wrapper function */
pid_t fork(void) {
    initialize();
    int parent_delay = choose_delay();
    int child_delay = choose_delay();
    pid_t parent_pid = getpid();
    pid_t child_pid_or_zero = real_fork();
    if (child_pid_or_zero > 0) {
        /* Parent */
        if (verbose) {
            printf("Fork. Child pid=%d, delay = %dms. Parent pid=%d, delay = %dms\n",child_pid_or_zero, child_delay,parent_pid, parent_delay);
            fflush(stdout);
        }
        ms_sleep(parent_delay);
    } else {
        /* Child */
        ms_sleep(child_delay);
    }
    return child_pid_or_zero;
}

4.5 进程图

进程图是一个很有用的工具,可捕获并发程序中部分语句的顺序:For the process graph, as long as topological sorting is satisfied, it is a possible output

  • 每个节点表示一条执行的语句
  • a -> b 表示 a 在 b 前面执行
  • 边可以用当前变量的值来标记
  • printf 节点可用输出来标记
  • 每个图由一个入度为 0 的点起始

对于进程图来说,只要满足拓扑排序,就是可能的输出。嵌套的fork示例:

image-20220119123906902

4.6 回收子进程

4.6.1 回收子进程

  • 定义:即使进程已终止,但还未被回收的进程还在消耗系统资源,如exit状态、系统表,称之为僵尸进程,”半死不活“。
  • 回收:可以采用回收(Reaping) 的方法。父进程用 waitwaitpid 回收已终止的子进程,然后父进程给系统提供退出状态信息,kernel 就会删除 zombie child process。
  • 父进程不回收:若父进程不回收子进程的话,通常会被 init 进程(pid == 1)回收(所以一般不必显式回收),除非ppid==1,然后需要重启。所以仅长期运行的进程,需要显式回收(例如 shells 和 servers)。

4.6.2 僵尸进程和孤儿进程示例

image-20220119124008845

image-20220119124040020

孤儿进程指子进程正运行,父进程突然退出,子进程就是孤儿进程。进程都需要一父进程,否则进程退出后无法回收进程描述符,消耗资源,该进程会找到一个父进程,若所在进程组没进程收养,就作为init进程的子进程

4.6.3 waitwaitpid

  • wait:父进程回收子进程调用该函数,函数声明为:int wait(int *child_status),由syscall实现,等待当前进程直到它的一个子进程终止,返回终止进程的PID,如果child_status非空,那么它所指向的整数将被设置为表明子进程终止的原因和退出状态的值,可用WIFEXITED等宏检查,shlab中有用到。若有多个孩子进程则按任意顺序,同样可用宏检查退出状态。

image-20220119141030777

image-20220119141050821

void fork10() {
    pid_t pid[N];
    int i, child_status;

    for (i = 0; i < N; i++)
        if ((pid[i] = fork()) == 0) {
            exit(100+i); /* Child */
        }
    for (i = 0; i < N; i++) { /* Parent */
        pid_t wpid = wait(&child_status);
        if (WIFEXITED(child_status))
            printf("Child %d terminated with exit status %d\n",
                    wpid, WEXITSTATUS(child_status));
        else
            printf("Child %d terminate abnormally\n", wpid);
    }
}

注意:若多个子进程完成,按任意顺序;可用宏WIFEXITEDWEXITSTATUS获取退出状态的信息

  • waitpid:暂停当前进程直到进程描述符为pid的进程终止,函数声明为:pid_t waitpid(pid_t pid, int *status, int options)
void fork11() {
    pid_t pid[N];
    int i;
    int child_status;
    
    for (i = 0; i < N; i++)
        if ((pid[i] = fork()) == 0)
            exit(100+i); /* Child */
    for (i = N-1; i >= 0; i--) {
        pid_t wpid = waitpid(pid[i], &child_status, 0);
        if (WIFEXITED(child_status))
            printf("Child %d terminated with exit status %d\n",
                    wpid, WEXITSTATUS(child_status));
        else
            printf("Child %d terminate abnormally\n", wpid);
    }
}

可修改函数中的options为:WNOHANG、WUNTRACED、WCONTINUED各种组合修改默认行为。同样也可像wait一样,若status参数非空,该函数就会在status指向的地方放上导致返回的子进程的状态信息,可用WIFEXITED、WEXITSTATUS、WIFSIGNALED、WITERMSIG、WIFSTOPPED、WSTOPSIG、WIFCONTINUED宏检查已回收子进程的退出状态。

4.6.4 execve

想在进程载入其他的程序,就需要该函数。函数声明:int execve(char *filename, char *argv[], char *envp[]),执行filename的程序(可以是二进制文件或脚本),参数和环境变量分别为argvenvp,重写code、data和stack,保留PID、打开文件和信号上下文,调用一次且不返回除非出错。

image-20220119140326232

image-20220119140351196

image-20220119140414419

5 signal

5.1 信号

已经学习硬件和软件合作提供基本的底层异常机制,也看到利用异常来支持进程上下文切换的异常控制流形式,更高层的软件形式的异常被称为信号,允许进程和内核中断其他进程。

信号提醒进程一个事件已经发生,类似异常和中断,异常和中断是从硬件发往内核,信号由内核(在其他进程的请求下)向进程发出,可能异步也可能同步发生。例如:硬件异常、硬件中断、另一个进程中的事件、另一个进程的显式请求。

每个信号都有name和ID,大多信号能被进程处理,就像中断处理程序,函数指针表,每个进程都有一个默认的动作,若信号没被处理,通常要么被忽略(ignore)要么中断进程(terminate process)

常用信号的编号及简介:

事件名称ID默认动作
用户按ctrl-cSIGINT2终止
强制中断(不能被处理)SIGKILL9终止
段冲突SIGSEGV11终止且dump
时钟信号SIGALRM14(可变)终止
子进程停止或终止SIGCHLD17(可变)忽略

5.2 信号概念

5.2.1 发送和传递

当事件发生时,内核发送信号,如硬件异常、硬件中断、另一个进程发生某些事(如exit)、另一个进程要求发送信号。

当内核使目标进程对信号作出响应时,内核传递(delivers)信号。如执行处理程序、执行默认操作。

发送和传递信号之间可能存在延迟,通常是因为进程不能立刻被调度,延迟期间,信号处于待处理(pending)状态

5.2.2 待处理和阻塞信号

如果信号已被发送但是未被接收,那么处于待处理状态(pending),注意:信号不排队,任何时刻一个类型至多只有一个待处理信号,因此若进程有一个类型为K的待处理的信号,则后续的被发送给进程的类型为K的信号都被直接丢弃。进程也可以阻塞特定信号的接收,直到信号被解除阻塞。

5.2.3 接收信号

目标进程以某种方式对信号的传递做出响应时,进程接收信号,可能响应的操作:

  • 忽略(ignore):忽略该型号,不作任何响应
  • 终止(terminate):终止进程,可能core dump
  • 捕获(catch):执行用户层的函数:信号处理器(signal handler),类似响应异步中断而调用的硬件异常处理程序(exception handler)

image-20220119140511533

5.2.4 待处理和阻塞位

内核在每个进程的上下文中维护等待(pending)和阻塞(blocked)位集合

  • 等待信号集合:表示等待信号集合,当类型为k的信号被传递,内核将位k置为pending,当接收到类型为k的信号时,内核清除待处理的位k,任何时刻每种类型为k的信号只有一个
  • 阻塞信号集合:表示阻塞信号集合,可用sigprocmask函数设置和清除,也称为信号掩码

5.3 进程组

每个进程都只属于一个进程组,如图:

image-20220119141214993

一般使用如下函数:

  • getpgrp() - 返回当前进程的进程组
  • setpgid() - 设置一个进程的进程组

可以通过 kill 来发送信号给进程组或进程(包括自己),如图:

image-20220119141233949

void fork12()
{
    pid_t pid[N];
    int i;
    int child_status;

    for (i = 0; i < N; i++)
        if ((pid[i] = fork()) == 0) {
            /* Child: Infinite Loop */
            while(1)
                ;
        }
    for (i = 0; i < N; i++) {
        printf("Killing process %d\n", pid[i]);
        kill(pid[i], SIGINT);
    }
    for (i = 0; i < N; i++) {
        pid_t wpid = wait(&child_status);
        if (WIFEXITED(child_status))
            printf("Child %d terminated with exit status %d\n",
                    wpid, WEXITSTATUS(child_status));
        else
            printf("Child %d terminated abnormally\n", wpid);
    }    
}

5.3.1 从键盘发送信号

可通过键盘让内核向每个前台进程发送 SIGINT(SIGTSTP) 信号

  • SIGINT - ctrl+c 默认终止进程
  • SIGTSTP - ctrl+z 默认停止(挂起)进程

image-20220119141804157

5.3.2 信号传递细节

假定内核正从一个异常处理程序返回,且准备将控制权传给进程p,内核会计算进程 p 的 pnb 值:pnb = pending & ~blocked

  • 如果 pnb == 0,那么就把控制交给进程 p 的逻辑流中的下一条指令
  • 否则

    • 选择 pnb 中最小的非零位 k,并强制进程 p 接收信号 k
    • 接收到信号之后,进程 p 会执行对应的动作
    • pnb 中所有的非零位进行这个操作
    • 最后把控制交给进程 p 的逻辑流中的下一条指令

每个信号类型都有一个默认动作,可能是以下的情况:

  • 忽略信号
  • 终止进程并 dump core
  • 停止进程,收到 SIGCONT 信号之后重启
  • 若进程停止则进程重启

5.4 信号控制

5.4.1 注册信号处理程序

sigaction函数改变与接收信号相关的程序,原型是:int sigaction(int signum,const struct sigaction *sa,struct sigaction *old_sa),sa结构提设置新的处理程序,选项包括ignore、默认处理器、调用该函数,也有控制信号传递细节的选项,若old_sa非空,则old action存储在这。

#include <signal.h>
#include <stdio.h>

void sigint_handler(int sig) {
    // Doesn’t do anything but interrupt the call to pause() below.
}

int main(void) {
    struct sigaction sa;
    // Sensible defaults. Use these unless you have a reason not to.
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;
    // The handler for SIGINT will be sigint_handler.
    sa.sa_handler = sigint_handler;

    if (sigaction(SIGINT, &sa, 0) != 0)
        unix_error("signal error");

    /* Wait for the receipt of a signal */
    pause();
    puts("Ctrl-C received, exiting.");
    return 0;
}

5.4.2 作为并发流的信号处理程序

信号处理程序是独立的逻辑流(不是进程),与主程序并发运行,但该流只存在直到返回主程序

image-20220119142040551

此外,信号处理程序也可被其他信号处理程序中断,控制流如下:

image-20220119142052313

5.4.3 阻塞和非阻塞信号

  • 隐式阻塞机制:内核会阻塞与当前在处理的信号同类型的其他正等待的信号,如一个 SIGINT 信号处理器是不能被另一个 SIGINT 信号中断的。
  • 显式阻塞机制:使用 sigprocmask 函数,以及其他辅助函数:

    • sigemptyset :创建空集
    • sigfillset :把所有的信号都添加到集合中(因为信号数目不多)
    • sigaddset :添加指定信号到集合中
    • sigdelset :删除集合中的指定信号

临时阻塞信号示例:

sigset_t mask, prev_mask;

Sigemptyset(&mask); // 创建空集
Sigaddset(&mask, SIGINT); // 把 SIGINT 信号加入屏蔽列表中

// 阻塞对应信号,并保存之前的集合
Sigprocmask(SIG_BLOCK, &mask, &prev_mask);
... // 这部分代码不会被 SIGINT 中断
// 取消阻塞信号,恢复原来的状态
Sigprocmask(SIG_SETMASK, &prev_mask, NULL);

5.4.4 安全处理信号

Handler是十分棘手,因为它们和主程序并发执行且共享相同的全局数据结构,所以不被保护的数据可能会被破坏,这里提供一些基本的指南帮助避免问题:

  • 规则 1:信号处理器越简单越好

    • 例如:设置一个全局的标记,并返回
  • 规则 2:信号处理器中只调用异步且信号安全(async-signal-safe)的函数

    • 诸如 printf, sprintf, mallocexit 都是不安全的!
  • 规则 3:在进入和退出的时候保存和恢复 errno

    • 这样信号处理器就不会覆盖原有的 errno
  • 规则 4:临时阻塞所有的信号以保证对于共享数据结构的访问

    • 防止可能出现的数据破坏
  • 规则 5:用 volatile关键字声明全局变量

    • 这样编译器就不会把它们保存在寄存器中,保证一致性
  • 规则 6:用 volatile sig_atomic_t来声明全局标识符(flag)

    • flag是只能读或写的变量,这样定义后就不需像其他全局变量被保护

5.4.5 异步信号安全

指两类函数:

  • 所有变量都保存在帧栈中的可重入函数
  • 不会被信号中断的函数

Posix 标准指定了 117 个异步信号安全(async-signal-safe)的函数(可通过 man 7 signal-safety 查看),常用的printf、sprintf、malloc、exit都不是。

5.4.6 信号安全代码示例

  • 同步避免父子竞争:
int main(int argc, char **argv)
{
    int pid;
    sigset_t mask_all, mask_one, prev_one;
    int n = N; /* N = 5 */
    Sigfillset(&mask_all);
    Sigemptyset(&mask_one);
    Sigaddset(&mask_one, SIGCHLD);
    Signal(SIGCHLD, handler);
    initjobs(); /* Initialize the job list */
    
    while (n--) {
        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);
}
  • 显式等待信号:
volatile sig_atomic_t pid;

void sigchld_handler(int s)
{
    int olderrno = errno;
    pid = Waitpid(-1, NULL, 0); /* Main is waiting for nonzero pid */
    errno = olderrno;
}

void sigint_handler(int s)
{
}
int main(int argc, char **argv) {
    sigset_t mask, prev;
    int n = N; /* N = 10 */
    Signal(SIGCHLD, sigchld_handler);
    Signal(SIGINT, sigint_handler);
    Sigemptyset(&mask);
    Sigaddset(&mask, SIGCHLD);
    //Similar to a shell waiting for a foreground job to terminate.
    while (n--) {
        Sigprocmask(SIG_BLOCK, &mask, &prev); /* Block SIGCHLD */
        if (Fork() == 0) /* Child */
            exit(0);
        /* Parent */
        pid = 0;
        Sigprocmask(SIG_SETMASK, &prev, NULL); /* Unblock SIGCHLD */

        /* Wait for SIGCHLD to be received (wasteful!) */
        while (!pid)
            ;
        /* Do some work after receiving SIGCHLD */
        printf(".");
    }
    printf("\n");
    exit(0);
}

image-20220119142649442

  • 使用sigsuspend等价于不可中断版本:
sigprocmask(SIG_SETMASK, &mask, &prev);
pause();
sigprocmask(SIG_SETMASK, &prev, NULL);
int main(int argc, char **argv) {
    sigset_t mask, prev;
    int n = N; /* N = 10 */
    Signal(SIGCHLD, sigchld_handler);
    Signal(SIGINT, sigint_handler);
    Sigemptyset(&mask);
    Sigaddset(&mask, SIGCHLD);
    while (n--) {
        Sigprocmask(SIG_BLOCK, &mask, &prev); /* Block SIGCHLD */
        if (Fork() == 0) /* Child */
            exit(0);
        
        /* Wait for SIGCHLD to be received */
        pid = 0;
        while (!pid)
            Sigsuspend(&prev);
        /* Optionally unblock SIGCHLD */
        Sigprocmask(SIG_SETMASK, &prev, NULL);
        /* Do some work after receiving SIGCHLD */
        printf(".");
    }
    printf("\n");
    exit(0);
}

5.5 非本地跳转

5.5.1 非本地跳转

本地跳转的限制在于不能从一个函数跳转到另一个函数中。若突破限制,C语言提供用户级异常控制流形式,叫非本地跳转,就要使用 setjmplongjmp 来进行非本地跳转(Nonlocal Jumps)。强大(但危险)用户级机制,用于将控制权转移到任意位置,打破call/return调用机制,对错误恢复和信号处理有帮助。

setjmp 声明是:int setjmp(jmp_buf j),必须在longjmp前调用,为后续longjmp标识返回地址,调用一次返回一次或多次,保存当前程序的寄存器上下文(register context)、栈指针、PC寄存器值在jmp_buf中,注意,保存的堆栈上下文环境仅在调用 setjmp 的函数内有效,如果调用 setjmp 的函数返回,保存的上下文环境就失效了。直接返回值为 0。

longjmp 声明是:void longjmp(jmp_buf j, int i)setjmp后被调用,调用一次但永远不返回,将会从缓存j中恢复由 setjmp 保存的程序堆栈上下文,跳转到j中保存的地址,设置%eax(返回值)为i,而不是setjmp的0

/* Deeply nested function foo */
void foo(void)
{
    if (error1)
        longjmp(buf, 1);
    bar();
}
void bar(void)
{
    if (error2)
        longjmp(buf,2);
}
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);
}

5.5.2 非本地跳转的限制

只能跳转到已经调用但尚未完成(函数还在栈中)的函数环境

image-20220119142859909

image-20220119142911613

P2在跳转的时候已返回,栈帧在内存中已被清理,所以P3中的 longjmp 并不能实现期望的操作

6 操作进程的工具

  • STRACE:打印正运行的程序和它子进程调用的每个系统调用的轨迹,静态编译程序可得到干净不带大量与共享库相关的输出的轨迹
  • PS:列出当前系统中所有进程(包括僵尸进程)
  • TOP:打印当前进程资源使用信息
  • PMAP:进程内存映射
  • /proc:虚拟文件系统,以ASCII码输出大量内核数据结构内容

7 总结

异常控制流(ECF)发生在系统各层次,是系统提供并发的基本机制:

  • 硬件层:四种类型异常
  • 操作系统层:内核用ECF提供进程基本概念,进程提供两个重要抽象:逻辑控制流和私有地址空间
  • 操作系统和程序之间的接口:程序可创建子进程,等进程停止或终止,运行新程序以及捕获其他进程的信号
  • 应用层:C程序可用非本地跳转规避正常调用/返回栈规则,直接从一个函数分支道另一个函数
阅读 341
1 声望
0 粉丝
0 条评论
1 声望
0 粉丝
文章目录
宣传栏