【System Beats!】第八章 异常控制流

异常控制流

控制流与异常控制流

  • 控制流:一条条指令的执行顺序
  • 从一条指令到下一条指令的过渡称为控制转移
  • 控制转移的常见类型:跳转、分支、调用、返回,即对程序状态的变化作出反应。
  • 现代系统通过使控制流发生突变,对系统状态的变化产生反应,即异常控制流/ECF。
  • 异常控制流/ECF:程序执行过程中遇到特殊事件或条件时,改变正常指令执行顺序的机制,它发生在计算机系统的各个层次。
  • 一般包括:异常、进程控制、信号和非本地跳转

异常控制流的类型

  • 异常/Exceptions:由硬件和操作系统实现,用于系统事件并改变控制流。
  • 进程上下文切换:操作系统利用定时器硬件,在多个进程之间快速切换,营造并行执行的假象。
  • 信号/Signals:由操作系统实现,用于进程间通信或其它系统事件通知。

异常

  • 内核:操作系统常驻内存的部分,负责管理计算机硬件和软件资源。
  • 异常是控制流的突变,用于相应处理器状态中的某些变化。
  • 发生异常时,控制流会转移至操作系统内核以相应某些事件。
  • 它一部分由硬件实现,一部分由操作系统实现,因此具体细节会随系统的不同而有所不同。
  • 在处理器中,状态被编码为不同的位和信号,状态变化称为事件/event
  • 事件可能与当前指令的执行有关,例如虚拟内存缺页、算术溢出、除零。
  • 事件也可能与当前指令的执行无关,例如一个系统定时器产生信号、或一个I/O请求完成。
  • 当处理器检测到有时间发生时,就会通过一张跳转表(即异常表),进行一个间接过程调用(异常),到异常处理程序。
  • 处理完成后,可能返回到事件发生时执行的指令,也可能返回到其下一条指令,也可能终止被中断的程序。
  • 每种异常类型有一个唯一非负整数异常号,分别由处理器设计者和操作系统内核分配,系统启动时初始化异常表,其起始地址存放于一个特殊CPU寄存器,即异常表基址寄存器中。

异常与过程调用的不同之处

  • 过程调用将返回地址压入栈中,异常的返回地址是当前指令或者下一条指令。
  • 异常跟过程调用都会将处理器状态压入占中,但是异常会多压一些,处理程序返回时,重新开始执行被中断的程序会需要它们。
  • 大多数异常会转移到内核中处理,此时所有项目会被压入内核栈,而非用户栈中。而过程调用压入用户栈。
  • 异常处理程序运行在内核模式下,过程调用运行在用户模式下。
  • 如果异常处理程序决定返回,那么会将状态恢复为用户模式。

异常的分类

  • 中断:异步发生,由于来自处理器外部的I/O信号的结果。
  • 由于不是因为任何一条专门指令产生的,因此它是异步的,需要中断处理程序进行处理。
  • 总是将控制返回给下一条指令。
  • 除中断外,其它异常类型都是同步发生的,即执行当前指令的结果,称其为故障指令
  • 常见类型:I/O设备(磁盘读取完成)、定时器(周期性定时器中断)
  • 陷阱:同步发生,是有意的异常。
  • 总是将控制返回到下一条指令。
  • 最重要的用途是,在用户程序和内核之间提供一个像过程一样的接口,称为系统调用
  • 用户需要向内核请求服务时,就执行syscall n指令,其会导致一个到异常处理程序的陷阱,这个处理程序解析参数,并调用适当的内核程序。
  • 系统调用相对函数调用,函数调用因为运行在用户模式中,可以执行的指令类型受限制,而系统调用运行在内核模式中,内核模式允许系统调用执行特权指令,并访问内核栈。
  • 常见类型:系统调用,如write/read等涉及文件I/O的指令,或者fork等涉及进程控制的指令。
  • 故障:同步发生,是潜在可恢复的错误,由错误情况引起,且可能被故障处理程序修正。
  • 若能被故障处理程序修正,则返回到当前指令,若不能则返回到内核中的abort进程,会导致其终止。
  • 常见:缺页异常、除令错误
  • 缺页异常:当指令引用一个虚拟地址,而与该地址对应的物理页面不在内存中,则必须从磁盘中抽出,会引发故障。
  • 终止:不可恢复的致命错误,同步,从不返回。
  • 常见:非法操作、地址越界、算术溢出、硬件错误

系统调用

  • x86-64上,系统调用通过syscall陷阱指令提供。
  • 所有Linux系统调用的参数都由通用寄存器而不是传递的
  • 系统调用号由%rax传递,每个系统调用有唯一的整数号
  • 参数寄存器:%rdi %rsi %rdi %r10 %r8 %r9
  • 注意与过程调用不同的是,第四个参数变成了%r10,因为系统调用返回时%rcx%r11会被破坏。
  • 返回时,%rax包含返回值errno,意为错误码,一个全局变量,存放最近一次系统调用错误的原因。
  • 返回值在-4095-1之间的负数表示发生了错误,对应于负的errno
  • 例如,异常号为0的除法错误,为故障,最终abort
  • 例如,异常号为13的一般保护故障/段错误,即因为一个程序引用了一个未定义的虚拟内存区域,或者因为程序试图写一个只读的文本段,则进行abort
  • 例如,异常号为14的缺页,为故障,一般选择continue
  • 例如,异常号为18的机器检查,即检测到致命的硬件错误,则为终止,直接退出。
  • 异常号为32-255,是操作系统定义的异常,为中断或者陷阱。

进程

  • 异常是允许操作系统内核时提供进程概念的基本构造块。
  • 一个假象:好像在跑的程序是系统中唯一运行的程序,独占CPU和内存。
  • 它有独立的逻辑控制流:好像程序独占使用处理器(实际由上下文切换机制实现)
  • 它有私有的地址空间:好像程序独占使用内存(实际由虚拟内存机制实现)
  • 进程:一个执行中程序的实例,系统中的每个程序都运行在某个进程的上下文中。
  • 上下文:程序运行时所需的各种状态信息。
    • 程序的代码和数据
    • 通用目的寄存器的内容
    • 程序计数器
    • 环境变量
    • 打开文件描述符的集合
  • 逻辑控制流:程序运行时一系列的PC值
  • 进程轮流使用处理器,每个进程执行它的流的一部分,然后被抢占(暂时挂起)。
  • 对于这些进程之一的上下文中运行的程序,它看上去像是在独占地使用处理器,但是反面例证是,程序中一些指令的执行之间,CPU会周期性地停顿。
  • 并发流:一个逻辑流的执行在时间上与另一个流重叠。
  • 也就是,A的开始-A的结束在时间上与B的开始-B的结束重叠。
  • 多个流并发地执行的一般现象称为并发,一个进程与其它进程轮流执行称为多任务,一个进程执行它的控制流的一部分的每一时间称为时间片,多任务也称为时间分片。
  • 并发流之间,可能发生抢占、中断以及上下文切换。
  • 并发流于处理器核数、计算机数无关。
  • 并行流:并发流的真子集,同一时刻,多个进程在不同核上运行。

私有地址空间

  • 进程为每个程序提供它自己的私有地址空间,一般和这个空间某个地址相关联的哪个内存字节无法被其他进程读写。
  • 详见第七章的这张图,唯一的区别在于这里多讲了,这张图实际上用到的很稀疏,通过虚拟内存映射实现,详见第九章。

用户模式和内核模式

  • 用户模式的模式位为0,内核模式的模式位为1。
  • 用户模式的权限受限,内核模式的权限不受限。
  • 用户模式只能访问用户区的内存,内核模式可以访问任意地址内存。
  • 用户模式不能执行特权指令,内核模式可以执行特权指令。
  • 但是,用户模式必须通过系统调用接口间接访问内核代码和数据,比如通过./proc文件系统访问一部分内核数据结构的内容。
  • 如果直接引用地址空间内内核区的代码和数据,会引起保护故障,造成Abort
  • 进程从用户模式变为内核模式的唯一方法是通过中断、故障、系统调用这样的异常。
  • 特权指令一般指的是,修改模式位,执行I/O操作,改变内存中的指令流等。

上下文切换

  • 即内核抢占一个进程,并重新启动一个被抢占的进程所需的进程状态转换
  • 通常由以下步骤组成:
    • 保存当前进程的上下文
    • 恢复下一个进程的山下文
    • 将控制权转移给新进程
  • 上下文包括
    • 通用寄存器
    • 浮点寄存器
    • 程序计数器
    • 用户栈
    • 状态寄存器
    • 内核栈
    • 各种内核数据结构
    • 例如描述地址空间的页表、 包含有关当前进程信息的进程表、以及包含进程已打开文件的文件表。
  • 调度:内核决定抢占当前进程,并决定哪个进程来重新开始。
  • 例如:
    • DMA传输:进程切换等待磁盘数据传输
    • 无阻塞系统调用:内核决定执行上下文切换,而非返回用户态
    • 中断:周期性定时器中断
  • 下面是上下文切换的一个例子

  • 首先,进程A执行read操作,由于其为特权指令,由陷阱机制系统调用陷入内核态。
  • 然后,由于DMA直接内存访问要等很久,内核决定抢占进程A,调度为进程B
  • 接着,B正在运行,磁盘中断告知内核已经取出数据,B需要处理中断,进入内核态的中断处理程序。
  • 中断处理过程中,内核调度为进程A
  • 控制流返回到A

系统调用数据处理

  • Unix系统级函数遇到错误时,通常返回-1,并设置全局整数变量errno表示错误码。
  • 进行函数包装
pid_t Fork(){
    pid_t pid;
    if((pid=fork())<0){
        unix_error("fork error");
    }
    return pid;
}
pid=Fork();

进程控制

获取进程ID

  • 每个进程都有唯一的正数进程ID,即PID
  • PID的数据类型为pid_t,在Linux系统中被定义为int
  • getpid返回调用进程的PID,getppid返回调用进程父进程的PID。

创建和终止进程

  • 进程永远处于下面三种状态之一:
    • 运行,要么在CPU上执行,要么等待被执行且最终会被内核调度。
    • 停止,执行被挂起,且不会被调度。收到SIGSTOP/SIGTSTP/STGTTIN/SIGTTOU后,进程停止,并且保持停止知道它收到一个SIGCONT信号,再开始运行。
    • 挂起停止的区别:挂起是因为等待资源/时间,暂时不能继续执行,但没有被信号强制停住。停止则是进程因为收到了相关信号,完全暂停执行。
    • 终止:进程永远地停止了,只会因为三种行为终止,即收到默认行为是终止进程的信号、从主程序返回、或者调用exit函数。
    • 这里的从主程序返回,返回的整数值会被设为进程的退出状态,非0表示异常退出。
    • exit函数以status退出状态来终止进程。
  • 后台的进程若想从终端读取输入\写入输出时,进程会停止,直到他们转为前台进程,这是为了确保终端的输入只被前台进程调用
  • 父进程通过调用fork函数创建一个新的运行的子进程,子进程是父进程的独立副本,即它们完全相同,由相同且独立的地址空间、对战、变量值、代码、打开的文件等。同时,它们是并发的独立进程,即有部分输出顺序无法确定,可能导致后续的竞争
  • 也就是说,当父进程调用fork时,子进程可以读写父进程打开的所有文件。
  • fork函数调用一次,返回两次:
    • 一次返回在父进程中,返回子进程的PID。
    • 一次返回在子进程中,返回值为0。
    • 可以据此区分父进程和子进程,它们最大的区别就是PID不同。
  • 进程间的调度由操作系统确定,因此父进程和子进程间某些语句的执行顺序不是确定的。
  • 通常考虑画进程图对应程序语句的偏序,使用拓扑排序进行辨别。
  • 由于对一张图的拓扑排序可以得到不同的结果,因此可能存在不同的进程图及操作序列。
  • fflush(stdout)/scanf/printf+'\n'/'\r'会清空缓冲区
  • 进程退出时也会清空缓冲区

回收子进程

  • 一个进程终止后必须被其父进程回收,否则会变为僵死进程/zombie
  • 如果父进程已经终止,安排init进程作为孤儿进程的养父。
  • init进程的pid=1,是系统启动时由内核创建的,不会终止,是所有进程的祖先。
  • 父进程通过调用waitpid函数等待子进程终止或者停止。
pid_t waitpid(pid_t pid,int* statusp,int options);
  • waitpid函数的三个参数:
    • pid:若大于0,则等待集合是PID=pid的子进程;若等于-1,则等待集合是该父进程的全部子进程,若等于-x,则等待集合是pid=x的进程组,若等于0,则等待集合是与调用进程在一个进程组的任意子进程。
    • options:表示为下面常量的并集组合
      • WNOHANG:如果所有子进程都没有终止,则立即返回;默认行为为挂起调用进程直到子进程终止。
      • WUNTRACED:挂起调用进程执行,直到等待集合中一个进程变为已终止或者被停止。
      • WCONTINUED:挂起调用进程的执行,直到等待集合中一个正在运行的进程终止或等待集合中一个被停止的进程收到SIGCONT信号重新开始执行。
    • options为0,则挂起调用进程,等待子进程退出。
    • statusp:若放入一个指针,则waitpid返回后会将子进程相关状态存储于该指针指向的位置,即将status放入其指向的值。
  • status的几个宏:
    • WIFEXITED(status):若子进程通过exit或者一个返回正常终止,则返回真。
    • WEXITSTATUS(status):在WIFEXITED(status)返回真时,返回子进程的退出状态。
    • WIFSGINALED(status):若子进程因为一个未被捕获的信号终止,则返回真。
    • WTERMSIG:在WIFSGINALED(status)返回真时,返回导致子进程终止的信号的编号。
    • WIFSTOPPED(status):若引起返回的子进程当前是停止的,返回真。
    • WSTOPSIG(status):在WIFSTOPPED(status)返回真时,返回导致子进程停止的进程的编号。
    • WIFCONTINUED:如果子进程收到SIGCONT信号重新启动,返回真。
  • 若调用进程没有子进程,则waitpid返回-1,设置errnoECHILD
  • waitpid被一个函数重点,则返回-1,设置errnoEINTR
  • wait:简化版本的pid
pid_t wait(int *statusp)
  • 相当于waitpid(-1,statusp,0)
  • 程序不会按照特定顺序回收子进程,即回收有乱序性。
  • 若需要顺序回收,则需要如以下代码所示,指定waitpidpid参数。
#include "csapp.h"
#define N 2

int main()
{
    int status, i;
    pid_t pid;

    /* 父进程创建 N 个子进程 */
    for (i = 0; i < N; i++)
        if ((pid = Fork()) == 0) /* 子进程 */
            exit(100 + i);

    /* 父进程以任意顺序回收 N 个子进程 */
    while ((pid = waitpid(-1, &status, 0)) > 0) {
        if (WIFEXITED(status))
            printf("子进程 %d 正常终止,退出状态=%d\n", pid, WEXITSTATUS(status));
        else
            printf("子进程 %d 异常终止\n", pid);
    }

    /* 唯一的正常终止是没有更多的子进程 */
    if (errno != ECHILD) /* EINTR */
        unix_error("waitpid 错误");

    exit(0);
}
  • 默认行为下,使用waitpid或者wait会挂起父进程,直到子进程终止,这使得拓扑排序的可能性受限。

让进程休眠

  • sleep:让一个进程挂起一段指定的时间,接受unsigned int型变量secs,返回unsigned int型变量。
  • 若时间量到了,则返回0;否则返回剩下要休眠的秒数,例如被一个信号中断而过早返回等。
  • pause:没有参数,让调用进程休眠,直到该进程收到一个信号,总是返回-1

加载并运行程序

  • 通常使用execve函数实现,在当前进程的上下文中加载并运行一个新程序。
  • fork不同的是,只是新增了一个程序,会覆盖当前进程的地址空间,新的程序有相同的PID,同时继承了调用其打开的所有文件描述符。
int execve(const char *filename, const char *argv[], const char *envp[]);
  • filename:执行的目标文件名
  • argv:参数列表数组,每个指针指向一个参数字符串,以NULL结尾。
  • envp:环境变量数组,每个指针指向一个形如name=value的环境变量字符串,同样以NULL结尾。
  • 若成功则不返回,若失败,如找不到filename,则返回-1,并设置errno
  • execve加载了filename后,其调用上一章中的启动代码,其设置栈并将控制传递给新程序的主函数,如int main(int argc,char** argv,char** envp)
  • main函数的第一个参数指出argv[]中非空指针的数量,argv指向argv[]中的第一个条目,envp指向envp[]的第一个条目。
  • 在用户栈栈底,首先是参数和环境字符串,接着是以NULL结尾的环境数组,其中每个指针都指向栈中的一个环境变量字符串,全局变量environ指向这些指针中的第一个envp[0]
  • 在其之后,为NULL结尾的argv[]数组,其中每个元素都指向栈中的一个参数字符串,顶部为系统启动函数libc_start_main的栈帧。

  • 对于一个指令,例如LD_PRELOAD=/usr/lib/libkdebug.so ls -l /usr/include,它的参数列表和环境变量如下:
  • 参数列表,参数,即为传递给新程序的参数,比如下图
argv[0] -> "ls" // 可执行文件名
argv[1] -> "-l" // 参数 1
argv[2] -> "/usr/include" // 参数 2
argv[3] -> NULL
  • 环境变量,即key=value的键值对
envp[0] -> "LD_PRELOAD=/usr/lib/libkdebug.so"
envp[1] -> NULL
  • char* getenv(const char *name)负责搜索环境变量name=value,若找到则返回指向value的指针,若找不到返回NULL
  • void unsetenv(const char *name),如果环境变量包含一个形如name=value的字符串,unsetenv会删除它。
  • int setenv(const char* name,const char* newvalue,int overwrite),如果环境变量中包含name=value,当overwrite非零时,会用newvalue覆写,若不存在则添加name=newvalue

信号

  • 一条小信息,是一种用于通知进程发生了某些事件的机制,属于异步机制。
  • 每个信号都对应于一个系统事件,信号提供一种可通知用户进程发生异常的机制。
  • 发送:内核(检测到事件)更新目的进程上下文某个状态/进程调用Kill函数
  • 接收:目的进程被内核强迫以某种方式对信号发送做出反应,可以忽略它、终止它、或调用信号处理函数捕获它。
  • 例如,当按下Ctrl+C时,系统会发送一个SIGINT信号给正在运行的程序,通知它停止运行,程序可以选择它的行为。
  • 注意是返回到下一条指令。

Shell的操作逻辑

  • Linux系统启动时,首先启动PID=1init进程,然后启动登录Shell
  • Shell负责解释用户命令,并执行相应的操作。
  • 其核心操作逻辑是一个循环,即从命令行读取用户输入,后解析命令并执行。
  • Shell命令可以是内置命令或外部程序,对于外部程序,其会创建一个子进程来执行程序,若以&结尾,则在后台执行;否则在前台执行,有大致以下代码框架
while (true) {
    读取命令行输入;
    if (命令为空) {
        continue;
    }
    if (命令为内建命令) {
        执行内建命令;
    } else {
        pid_t pid = fork();
        if (pid == 0) { // 子进程
            execve(程序路径, 参数, 环境变量);
        } else if (pid > 0) { // 父进程
            if (前台执行) {
                waitpid(pid, &status, 0);
            } else { // 后台执行
                printf("后台进程 PID: %d\\n", pid);
            }
        } else { // fork 失败
            perror("fork");
        }
    }
}
  • 后台执行的程序不会阻塞Shell,用户可以继续输入其他命令与Shell交互。

常见信号

  • 每个信号都有唯一的整数ID,从130

  • 需要注意的事,SIGKILLSIGSTOP无法被捕获或忽略,它们的行为由操作系统内核直接处理,不依赖于用户空间的代码。
  • 这确保了系统管理员和操作系统能在必要时强制控制进程状态,而不受进程本身干扰。

待处理信号

  • 即发生而没有被接受的信号
  • 任何时刻,一个进程至多只会有一个待处理金浩。
  • 当信号被传送到进程,名为pending的位向量被置为1。
  • 当信号在对应进程得到接收时,pending位向量中对应位置的值被置零。
  • 多个信号来时,后面的会被丢弃。
  • 一个进程可以有选择性地阻塞某种信号,通过位向量blocked实现,这种信号之后被发送后不会被接收,直到进程取消对这个信号的阻塞。
  • 进程只能知道自己收到过某种信号,但是无法获知收到的次数。
  • 若在有pending的情况,设置blocked,就算当前处理的该种信号结束处理,也需要等到blocked被解除后才能开始处理。
  • 当内核将控制权传给某个进程时,会检查是否有未被阻塞的pending信号,若有则强制其处理。

进程组与作业

  • 由进程组实现。
  • 进程组:一个进程或多个进程的集合,它们共享一个共同的进程组ID,即PGID
  • 进程可以通过setpgid函数改变自己或其它进程的进程组。
  • 子进程与父进程同属于一个进程组。
int setpgid(pid_t pid, pid_t pgid);
  • 表示将进程pid的进程组改为pgid,若pid为0,则使用当前进程的PID,如果pgid为0,则用pid指定的进程的PID作为pgid
  • pidpgid同时为0,则创建一个```pgid为当前进程pid``的进程组。
  • 作业/Job:一个或多个进程的集合,通常由一个前台进程和若干后台进程组成,由Shell创建或管理。
  • 一个作业可以包含一个单独的命令或一组通过管道(即Shell中的|)连接的命令,可以在前台运行,也可以在后台运行。
  • 前台作业:占用终端,从用户处接受输入,后台作业通过fg命令转换为前台作业。
  • 后台作业:终端中启动但不占用终端的作业,不与用户交互下执行,通过&或者bg启动。

发送信号

  • 使用/bin/kill命令,通常为/bin/kill -signal -pid
  • signal是信号序号,pid为负则发送到pgid=|pid|的所有进程。
  • 从键盘发送信号
    • ctrl+c发送SIGINT信号,终止前台进程。
    • ctrl+z发送SIGTSTP信号,挂起(停止)前台进程,同时会发生调度。
    • ctrl+d发送EOF信号,指示输入结束。
  • kill函数发送信号
int kill(pid_t pid,int sig)
  • pid>0,将信号sig发送给PIDpid的进程。
  • pid=0,将信号sig发送给调用进程所述进程组的所有进程。
  • pid<0,将信号sig发送给pgid=|pid|的所有进程。
  • 若成功则返回0,若不成功则返回-1
  • alarm函数发送信号
unsigned int alarm(unsigned int secs);
  • 设置一个定时器,在指定秒数后发送SIGALRM信号给当前进程。
  • alarm的调用将取消任何任何待处理的alarm
  • 若之前有alarm,则返回剩余秒数,若无返回0

接收信号

  • 内核把进程p从内核模式切换到用户模式时,在执行代码前处理信号。
  • 接收信号类型:pending & ~blocked,若非空,强制进程接收其中之一,通常为最小的。
  • 接着,进程会采取忽略、终止、捕获并调用信号处理函数三种行为之一进行处理,直到集合为空,将控制转移为下一条指令。
  • 信号处理有以下四种默认行为:
    • 进程终止,如SIGINT、SIGKILL
    • 进程终止并转储内存,如SIGILL、SIGFPE、SIGSEGV
    • 进程停止,直到被SIGCONT重新启动:如SIGSTOP、SIGTSTP
    • 忽略该信号,如SIGCHLD
  • 可以使用signal函数修改除了SIGKILLSIGSTIOP之外的信号:
typedef void (*sighandler_t)(int); // 函数指针类型
sighandler_t signal(int signum, sighandler_t handler);
  • 参数handler可以是以下三种情况:
    • SIG_IGN:忽略信号
    • SIG_DFL:采取默认行为
    • 用户自定义的函数地址,即信号处理程序,调用它被称为捕获信号。
  • 信号处理程序可以被其他信号处理程序中断

  • 信号处理程序时用户态的一部分,当捕获信号并处理时,是在用户态运行的。

阻塞/解除阻塞信号

  • 隐式阻塞机制:内核默认阻塞任何当前处理程序正在处理信号类型的待处理信号。
  • 显式阻塞机制:使用sigprocmask函数。
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset); // 改变当前阻塞的信号集合

// 以下操作 sigset_t 的函数本质都是对位向量进行操作
int sigemptyset(sigset_t *set); // 初始化信号集合为空
int sigfillset(sigset_t *set); // 将所有信号添加到信号集合中
int sigaddset(sigset_t *set, int signum); // 将指定信号添加到信号集合中
int sigdelset(sigset_t *set, int signum); // 从信号集合中删除指定信号

int sigismember(const sigset_t *set, int signum); // 返回:若 signum 是 set 的成员则为 1,否则为 0。
  • sigprocmask:改变当前阻塞的信号集合,取决于how的值:
    • SIG_BLOCK:将set中的信号添加到blocked中。
    • SIG_UNBLOCK:从blocked中删除set中的信号。
    • SIG_SETMASKblock=set
  • 如果oldset非空,那么blocked的旧值保存在oldset中。

编写信号处理程序

  • 处理程序尽可能简单,因为信号可以在程序执行的任何时候异步发生。
  • 只调用异步信号安全函数,异步安全函数即可重入(例如只访问局部变量)或不能被信号处理程序中断的函数,本质是不干扰正在处理的程序,否则可能死锁。
  • 保存恢复errno,异步信息安全函数可能干扰主程序中其他依赖errno的函数,只有需要返回时才有必要保存。
  • 访问共享全局数据结构时,阻塞所有的信号,避免死锁与数据竞争。
  • 全局变量使用volatile关键字声明,告诉编译器不要缓存这个变量,避免错误的编译器优化。
  • 标志使用sig_atomio_t声明,保证对全局标志(处理程序读写它来记录是否收到信号)的读写是原子的,即不可中断的。
  • 不可以用信号来对其他进程中发生的事件计数,因为有阻塞机制存在。
  • 常见的非异步安全函数:
    • 动态内存分配函数:如mallocfree,它们可能修改全局状态。
    • 标准I/O函数,如printf/scanf,它们可能使用内部缓冲区和锁。
    • 非可重入函数,如strtok,可能依赖静态数据。
    • 线程相关,如pthread_mutex_lock,可能引发死锁。
  • 常见的异步安全函数:
    • _exit
    • _abort
    • signal:部分实现
    • sigaddset、sigdelset、sigemptyset、sigfillset
    • kill、sigaction、sigprocmask、sigpending
    • write、readlseek(部分实现)
  • 可重入函数:通常不使用静态或全局数据、不调用不可重入函数、仅使用全局变量、不修改共享资源,核心特点是独立性和无副作用
  • waitpid:每次只回收一个子进程,存在SIGCHLD信号不代表只有一个子进程终止。
  • 存在一个信号代表至少有一个信号到达。
  • 竞争:分出子进程后,父子进程并发,顺序不一定与代码顺序一致,只需要满足拓扑排序。
  • 请看下面两段代码:
// 错误代码
while (1) {
    if ((pid = Fork()) == 0) {
        Execve("/bin/date", argv, NULL);
    }
    // 关注下一行
    Sigprocmask(SIG_BLOCK, &mask_all, &prev_all);
    addjob(pid);
    Sigprocmask(SIG_SETMASK, &prev_all, NULL);
}
exit(0);

// 正确代码
while (1) {
    // 先阻塞 SIGCHLD
    Sigprocmask(SIG_BLOCK, &mask_one, &prev_one);
    if ((pid = Fork()) == 0) {
        // 分出子进程后解除阻塞
        Sigprocmask(SIG_SETMASK, &prev_one, NULL);
        Execve("/bin/date", argv, NULL);
    }
    Sigprocmask(SIG_BLOCK, &mask_all, NULL);
    addjob(pid);
    Sigprocmask(SIG_SETMASK, &prev_one, NULL);
}
exit(0);
  • 需要保持addjob操作的原子性。
  • 错误代码先Fork()创建子进程,再阻塞信号。
  • Sigprocmask之前,子进程可能已经终止,向父进程发送SIGCHLD信号。
  • 此时父进程还没阻塞SIGCHLD,信号会立即触发处理函数,但是addjob还没执行,导致处理了不存在的job

显式等待信号

  • 主程序有时需要显示等待某个信号处理程序运行,如Shell创建一个前台作业时,在接受下一条用户命令之前,必须等待作业终止,被SIGCHLD处理程序回收。
  • 请看以下代码:
#include "csapp.h"
volatile sig_atomic_t pid; // 定义一个易失性的原子类型变量 pid
void sigchld_handler(int s) // 定义一个处理 SIGCHLD 信号的处理函数
{
    int olderrno = errno; // 保存当前的 errno 值
    pid = waitpid(-1, NULL, 0); // 等待任意子进程结束,并将其 pid 保存到全局变量 pid 中
    errno = olderrno; // 恢复之前的 errno 值
}
void sigint_handler(int s){};

int main(int argc, char **argv)
{
    sigset_t mask, prev;

    Signal(SIGCHLD, sigchld_handler);
    Signal(SIGINT, sigint_handler);
    Sigemptyset(&mask);
    Sigaddset(&mask, SIGCHLD);

    while (1) {
        Sigprocmask(SIG_BLOCK, &mask, &prev); /* 阻塞 SIGCHLD */
        if (Fork() == 0) /* 子进程 */
            exit(0);

        /* 父进程 */
        pid = 0;
        Sigprocmask(SIG_SETMASK, &prev, NULL); /* 解除阻塞 SIGCHLD */

        /* 等待接收 SIGCHLD (没问题,但是浪费资源) */
        while (!pid);

        /* 接收 SIGCHLD 后做一些工作 */
        printf(".");
    }
    exit(0);
}
/* 等待接收 SIGCHLD 信号 (可能引发竞争条件) */
while (!pid)  /* 竞争! */
    pause();
  • 这段代码中,若SIGCHLD发生在while测试之后,pause之前,由于已被处理,pause永远不会收到信号被唤醒。
/* 等待接收 SIGCHLD 信号 (速度太慢) */
while (!pid)  /* 太慢! */
    sleep(1);
  • 这段代码逻辑正确,但是间隔不好设置,太短则类似while(!pid),高频运行,一直占用CPU,太长则等太久。

  • 正确策略:int sigsuspend(const sigset_t *mask)

  • 类似于以下代码的原子化版本

sigprocmask(SIG_SET_MASK,&mask,&prev);
pause();
sigprocmask(SIG_SET_MASK,&prev,NULL);
  • 即暂时使用提供的信号集替换当前信号屏蔽字,收到信号后恢复原来的信号屏蔽字。

  • 以下是完善后的代码:

#include "csapp.h"
volatile sig_atomic_t pid; // 定义一个易失性的原子类型变量 pid
void sigchld_handler(int s) // 定义一个处理 SIGCHLD 信号的处理函数
{
    int olderrno = errno; // 保存当前的 errno 值
    pid = waitpid(-1, NULL, 0); // 等待任意子进程结束,并将其 pid 保存到全局变量 pid 中
    errno = olderrno; // 恢复之前的 errno 值
}
void sigint_handler(int s){};

int main(int argc, char **argv)
{
    sigset_t mask, prev;

    Signal(SIGCHLD, sigchld_handler);
    Signal(SIGINT, sigint_handler);
    Sigemptyset(&mask);
    Sigaddset(&mask, SIGCHLD);

    while (1) {
        Sigprocmask(SIG_BLOCK, &mask, &prev); /* 阻塞 SIGCHLD */
        if (Fork() == 0) /* 子进程 */
            exit(0);

        /* 等待接收 SIGCHLD */
        pid = 0;
        while (!pid)
            sigsuspend(&prev);

        /* 可选地解除阻塞 SIGCHLD */
        Sigprocmask(SIG_SETMASK, &prev, NULL);

        /* 接收 SIGCHLD 后做一些工作 */
        printf(".");
    }
    exit(0);
}

非本地跳转

  • 允许程序从一个函数跳转到另一个函数,不需要通过正常的函数调用和返回机制。setjmplongjmp提供非本地跳转功能。
  • setjmp(jmp_buf env):保存当前执行环境到env中。
  • longjmp(jmp_buf env,int val):恢复之前由setjmp保存的执行环境,使setjmp返回val
#include <setjmp.h>

jmp_buf env;

if (setjmp(env) == 0) {
    // ... 代码块 1 ...
    longjmp(env, 1);
} else {
    // ... 代码块 2 ...
}
  • 第一次setjmp返回0,进入第一个分支,longjmp使程序跳转到setjmp()的位置,使其返回1。

后记

  • 终于写完了,累死了

alt text

posted @ 2025-11-15 15:47  elainafan  阅读(11)  评论(0)    收藏  举报