跳转至

相关文章:csapp 第九章 虚拟内存 读书笔记

第八章 异常控制流

异常控制流

处理器加电后和断电前,程序计数器会假设一个值的序列: 其中,每个a_k 是某个相应的指令I_k的地址,每次从a_ka_{k+1} 的过渡称为控制转移(control transfer)。这样的控制转移序列叫做处理器的控制流(flow of control or control flow) control flow的突变(I_kI_{k+1})的不连续,通常由类似跳转、调用、和返回等程序指令造成。突变是现代系统应对系统状态变化的机制,这些突变称为异常控制流(Exceptional Control Flow, ECF)。

理解ECF的目的

  • 理解重要的系统概念:ECF是实现I/O、进程和虚拟内存的基本机制
  • 帮助理解应用程序和操作系统的交互
  • 帮助实现新应用
  • 帮助理解并发
  • 帮助理解软件异常的工作模式

8.1 异常

异常(exception)是控制流中的突变,一部分由硬件实现,一部分由操作系统实现。当处理器检测有事件发生时,会通过一个叫做异常表(exception table)的跳转表,进行一个间接过程调用(异常),到一个专门设计用来处理这列事件的操作系统子程序(异常处理程序(exception handler))。当异常处理程序完成处理后,根据引起异常的时间的类型,会发生以下三种情况: - 处理程序将控制返回给当前指令I_{curr},即当事件发生时正在执行的指令; - 处理程序将控制返还给I_{next},如果没有发生异常将会执行的下一条指令; - 处理程序终止被中断的程序

8.1.1 异常处理

系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号(exception number)。其中一些号码是由处理器的设计者分配的,其他号码是由操作系统内核(操作系统常驻内存部分)的设计者分配的,前者的示例包括被零除、缺页、内存访问违例、断点以及算术运算溢出,后者包括系统调用和来自外部I/O设备的信号。 系统启动时,操作系统分配和初始化一张称为异常表的跳转表,使得表目k包含异常k的处理程序的地址,如下图:

在运行时,处理器检测到发生了一个事件,并且确定了相应的异常号k。随后处理器触发异常,方法是执行间接过程调用,通过异常表的表目k,转到相应的处理程序,如下图。异常表的起始地址放在一个叫做异常表基址寄存器(exception table base register)的特殊CPU寄存器里。 异常类似于过程调用,但是有一些区别: - 过程调用时,在跳转到处理程序之前,处理器将返回地址压入栈中,然而,根据异常的类型,返回地址要么是当前指令(当事件发生时正在执行的指令),要么是下一条指令(如果事件不发生,将会在当前指令后执行的指令) - 处理器也会把一些额外的处理器状态压倒栈里,在处理程序返回时,重新开始执行被中断的程序会需要这些状态。 - 如果控制从用户程序转移到内核,所有这些项目都被压到内核栈中,而不是压倒用户栈中 - 异常处理程序运行在内核模式下,这意味着它们对所有的系统资源都有完全的访问权限

8.1.2 异常的类型

1.中断

中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。图8-5概述了一个中断的处理流程。在当前指令完成执行之后,处理器注意到中断引脚的电压变高了,就从系统总线读取异常号,然后调用适当的中断处理程序,当处理程序返回时,它就将控制返回给下一条指令(即 如果没有发生中断,在控制流中会在当前指令之后的那条指令),结果是程序继续执行,就好像没有发生过中断一样。

2.陷阱和系统调用

陷阱是有意的异常,是执行一条指令的结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序在内核之间提供一个像过程一样的接口,叫做系统调用。 用户程序经常需要向内核请求服务,比如读一个文件(read)、创建一个新的进程(fork)、加载一个新的程序(execve),或者终止当前进程(exit)。为了允许对这些内核服务的受控的访问,处理器提供了一条特殊的“syscall n”指令,当用户程序想要请求服务n时,可移植性这条指令。执行syscall指令会导致一个到异常处理程序的陷阱,这个处理程序解析参数,并调用适当的内核程序,如图8-6所示。 普通函数调用是在用户模式下,系统调用是在内核模式下。

3.故障

故障是由错误情况引起的,它可能能被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误情况,它就将控制返回给引起故障的指令,从而重新执行它。否则,处理程序返回到内核中的abort例程,abort例程会终止引起故障的应用程序,如图8-7. eg:缺页异常,当指令引用一个虚拟地址,而与该地址相对应的物理页面不在内存中,因此必须从磁盘中取出时,就会发生故障。详见第九章 补上 缺页处理程序从磁盘加载适当的页面,然后将控制返回给引起故障的指令,当指令再次执行时,相应的物理页面已经驻留在内存中了,指令就可以没有故障地运行完成了。

4.终止

终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,终止程序将控制返还给一个abort例程,该例程会终止这个应用程序。

8.2 进程

异常是允许操作系统内核提供进程(process)概念的基本构造块。 进程的经典定义:一个执行中的程序的实例。系统中的每个程序都运行在某个进程的上下文(context)中,上下文是由程序正确运行所需的状态组成,状态包括:存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容,程序计数器、环境变量以及打开文件描述符的集合。 每次用户通过向shell输入一个可执行目标文件的名字,运行程序时,shell就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或者其它应用程序。

8.2.1 逻辑控制流

单步执行程序,看到的一系列程序计数器(PC)的值(这些值唯一地对应于包含在程序的可执行目标文件中的指令,或者包含在运行时动态链接到程序的共享对象中的指令),这个PC值的序列叫做逻辑控制流,或者简称控制流。 如下图,进程是轮流使用处理器的,每个进程执行它的流的一部分,然后被抢占(preempted)(暂时挂起),然后轮到其它进程。

8.2.2 并发流

一个逻辑流的执行在时间上与另一个流重叠,称为并发流(concurrent flow),这两个流被称为并发地运行。更准确地说,流X和流Y互相并发,当且仅当X在Y开始之后和Y结束之前开始开始,或者Y在X开始之后和X结束之前开始。8-12中,A和B是并发,A和C是并发,B和C不是并发。 多个流兵法的执行的一般现象被称为并发(concurrency)。一个进程和其他进程轮流运行的概念称为多任务(multitasking)。一个进程执行它的控制流的一部分的每一时间段叫做时间片(time slice)。 如果两个流并发地运行在不同的处理器核或者计算机上,称为并行流(parallel flow)

8.2.3 私有地址空间

进程为每个程序提供它自己的私有地址空间,一般来说和这个空间中某个地址相关联的那个内存字节是不能被其它进程读或者写的,从这个意义上说,这个地质空间是私有的。 尽管和每个私有地址空间相关联的内存的内容一般是不同的,但是每个这样的空间都有相同的通用结构,如8-13。

8.2.4 用户模式和内核模式

为了使操作系统内核提供一个无懈可击的进程抽象,处理器必须提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。 处理器通常是用某个控制寄存器中的一个模式位(mode bit)来提供这种功能,该寄存器描述了进程当前享有的特权。当设置了模式位,进程就运行在内核模式中(有时也叫作超级用户模式)。 没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令(privileged instruction),比如停止处理器,改变模式位,或者发起一个I/O操作。也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。 运行应用程序代码的进程初始时是在用户模式中。进程从用户模式变为内核模式的唯一方法是通过注入中断、故障或者陷入系统调用这样的异常。 Linux提供了一种叫做/proc文件系统的机制,允许用户模式进程访问内核数据结构的内容。/proc文件系统将许多内核数据结构的内容输出为一个用户程序可以读的文本文件的层次结构。比如,可以使用文件系统找出一般的系统属性,比如CPU类型(、proc/cpuinfo)。或者某个特殊的进程使用的内存段。chatgpt对/proc的介绍如下:

`/proc` 是一个虚拟文件系统,也被称为进程文件系统,它存在于内存中而不是硬盘上。在 Linux 系统中,`/proc` 目录包含了大量关于系统和正在运行的进程的实时信息。它是一个接口,通过这个接口,内核可以向用户空间程序提供信息。

以下是 `/proc` 中的一些常见文件和目录:

- `/proc/cpuinfo`:包含了处理器的相关信息,如型号、MHz、缓存大小等。

- `/proc/meminfo`:提供了关于系统内存使用情况的信息,包括物理内存、交换空间等。

- `/proc/version`:显示了系统的版本信息。

- `/proc/pid`:每一个正在运行的进程都会在 `/proc` 下有一个以其进程 ID 命名的目录。这个目录中包含了该进程的相关信息。

- `/proc/filesystems`:列出了系统支持的文件系统类型。

- `/proc/mounts`:显示了当前系统挂载的所有文件系统。

- `/proc/net`:包含了网络协议的统计信息。

通过阅读和分析 `/proc` 中的文件,我们可以了解到系统和进程的许多信息。这对于系统监控、调试和性能调优等任务非常有用。

8.2.5 上下文切换

  • 内核使用上下文切换(context switch)的较高形式的异常控制流来实现多任务。上下文切换机制建立在8.1的低层异常机制上。
  • 内核为每个进程维持一个上下文(context)。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据集,比如描述地址空间的页表,包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
  • 在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫调度(scheduling),是由内核中被称为调度器(scheduler)的代码处理的。当内核选择一个新的进程运行时,即内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程
  • 上下文切换:
  • 1)保存当前进程的上下文
  • 2)恢复某个先前被抢占的进程被保存的上下文
  • 3)将控制传递给这个新恢复的进程
  • 当内核代表用户执行系统调度时,如果系统调度因为等待某个事件而发生阻塞(eg:磁盘读取数据),那么内核可以让当前进程休眠,切换到另一个进程。
  • 中断也可能发生上下文切换
  • 进程切换示例:8-14

8.3 系统调用错误处理

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

8.4 进程控制

8.4.1 获取进程ID

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

8.4.2 创建和终止进程

  • 程序员眼中进程的三种状态
  • 运行:进程要么在CPU上执行,要么在等待被执行且最终会被内核调度
  • 停止:进程的执行被挂起(suspended),且不会被调度。当收到SIGSTOP、SIGTSTP、SIGTTIN或者SIGTTOUT信号时,进程就停止,,并且保持停止直到它收到一个SIGCONT信号,在这个时刻,进程再次开始运行。(信号是一种软件终端的形式)
  • 终止:进程永远地停止了。进程会因为三种原因终止:1)收到一个信号,该信号的默认行为是终止进程;2)从主程序返回;3)调用exit函数 这些都是 Unix 或者类 Unix 系统(如 Linux)中的信号(Signals)。信号是一种在 Unix 系统中进程间通信的方式,也用于通知进程某些系统事件。当一个信号发送给一个进程时,操作系统会中断进程的正常控制流程,然后传递这个信号,进程在接收到信号后会做出相应的响应。exit 函数以status退出状态来终止进程(另一种设置退出状态的方法是从主程序中返回一个整数值)。
SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU都是什么:

以下是这些信号的解释:

- SIGSTOP:这个信号会让接收它的进程停止运行。这是一个不能被阻塞、处理或者忽略的信号。
- SIGTSTP:这个信号通常由用户在终端中按下 Ctrl+Z 发送,导致进程停止运行,但是与 SIGSTOP 不同的是,这个信号是可以被捕获和忽略的。

- SIGTTIN:当一个后台进程试图读取终端输入时,这个信号会被发送到该进程。默认情况下,这会导致进程停止运行。

- SIGTTOU:当一个后台进程试图写入它的控制终端或者改变终端的模式时,这个信号会被发送到该进程。默认情况下,这会导致进程停止运行。

这些信号通常用于实现 Unix 系统的工作控制,例如将一个正在运行的进程暂停并放到后台,或者将一个在后台暂停的进程恢复运行并放到前台。

创建

  • 新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟内存地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与附近成人和打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程还可以读写父进程中打开的任何文件。父进程和心创建的子进程之间最大的区别在于它们有不同的PID。
  • fork函数:调用一次,返回两次:一次在调用进程中,一次在新创建的子进程中。在父进程中,fork函数返回子进程的PID,在子进程中,fork返回0.因为紫禁城的PID总是非零,返回值就提供一个明确的方法来判断程序是在父进程还是在子进程中执行。
  • 父进程调用子进程示例:8-15
    • 调用一次,返回两次: fork函数被父进程调用一次,但是返回两次:1)返回到父进程,2)返回到新创建的子进程。
    • 并发执行:父进程和子进程是并发运行的独立进程。内核能够以任意方式交替执行它们的逻辑控制流中的指令。
    • 相同但是独立的地址空间:如果能够在fork函数在父进程和子进程中返回后立即暂停这两个进程,我们会看到两个进程的地址空间是相同的。每个进程由相同的用户栈、相同的本地变量值、相同的堆、相同的全局变量值,以及相同的代码。
    • 共享文件:子进程继承了父进程所有的打开文件

8.4.3 回收子进程

当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终止的状态中,直到被它的父进程回收(reaped)。当父进程回收已终止的子进程时,内核将紫禁城的退出状态传递给父进程,然后抛弃已终止的进程,从此时开始,该进程就不存在了。一个终止了但还未被回收的进程称为僵死进程(zombie) 如果一个父进程终止了,内核会安排init进程成为它的孤儿进程的养父。init进程的PID=1,是在系统启动时由内核创建的,它不会终止,是所有进程的祖先。 一个进程可以通过调用waitpid函数来等待它的子进程终止或者停止。 默认情况下(option=0),waitpid挂起调用进程的执行,直到它的等待集合(wait set)中的一个子进程终止。如果等待集合中的一个进程在刚调用的时刻就已经终止了,那么waitpid就立即返回。在这两种情况中,waitpid返回导致waitpid返回的已终止子进程的PID,此时,已终止的子进程已经被回收,内核会从系统中删除掉它的所有痕迹。 参数说明: - 1.判定等待集合的成员: 等待集合的成员是由参数pid来确定的 - 如果pid > 0,那么等待集合就是一个单独的子进程,它的进程ID等于pid - 如果pid = -1,那么等待集合就是由父进程的所有子进程组成 - 2.修改默认行为: 可以通过将options设置为常量WNOHANG、WUNTRACED、WCONTINUED的各种组合来修改默认行为: - WNOHANG:如果等待集合中的任何子进程都还没有终止,那么就立即返回(返回的值为0)。默认的行为是挂起调用进程,直到有子进程终止。在等待子进程终止的同时,如果还想做些有用的工作,这个选项会有用。 - WUNTRACED:挂起调用进程的执行,直到等待集合中的一个进程变成已终止或者被停止。返回的PID为导致返回的已终止或者被停止子进程的PID,默认的行为是只返回已终止的子进程。当你想要检查已终止和被停止的子进程时,这个会比较管用。 - WCONTINUED:挂起调用进程的执行,直到等待集合中一个正在运行的进程终止或等待集合中一个被停止的进程收到SIGCONT信号重新开始执行。 - WNOHANG | WUNTRACED:立即返回,如果等待集合中的子进程都没有被停止或终止,则返回值为0;如果有一个停止或终止,则返回值为该子进程的PID。 - 3.检查已回收子进程的退出状态:如果statusp参数是非空的,那么waitpid就会在status中放上关于导致返回的子进程的状态信息,status是statusp指向的值。wait.h头文件中定义了解释status参数的几个宏: - WIFEXITED(status):如果子进程通过调用exit或者一个返回(return)正常终止,就返回真 - WEXITSTATUS(status):返回一个正常终止的紫禁城的退出状态,只有在WIFEXITED(status)返回为真时,才会定义这个状态 - WIFSIGNALED(status):如果子进程是因为一个未被捕获的信号终止的,那么就返回真 - WTERMSIG(status):返回导致子进程终止的信号的编号,只有在WIFSIGNALED()返回为真时,才定义这个变量 - WIFSTOPPED(status):如果引起返回的子进程当前是停止的,那么就返回真。 - WSTOPSIG(status):返回引起子进程停止的信号的编号,只有在WIFSTOPPED()返回为真时,才定义这个状态。 - WIFCONTINUED(status):如果子进程收到SIGCONT信号重新启动,则返回真 - 4.错误条件 如果调用进程没有子进程,那么waitpid就返回-1,并且设置errno为ECHILD。如果waitpid函数被一个信号中断,那么它返回-1,并设置errno为EINTR。
- 6.waitpid示例

8.4.4 让进程休眠

  • sleep:让进程挂起一段指定的时间
  • pause:让调用函数休眠,直到该进程收到一个信号

8.4.5 加载并运行程序

  • execve函数在当前进程的上下文中加载并运行一个新程序。execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,才返回。参数列表和环境变量列表分别是指向一个以null结尾的指针数组,其中每个指针都指向一个参数字符串或者一个环境变量字符串。
  • 当main开始时,用户栈的组织结构如图8-22。main函数有三个参数,1)argc:给出argv[]数组中非空指针的数量,2)argv:指向argv[]的第一条目,3)envp:指向envp[]的第一个条目。

8.4.6 利用fork和execve运行程序

8.5 信号

一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件,允许进程和内核中断其他进程。每种信号类型都对应于某种系统事件,低层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了这些异常,异常表如下

8.5.1 信号术语

传送一个信号到目的进程是由两个不同的步骤组成的: - 发送信号:内核通过更新目的进程上下文中的某个状态,发送(递送)一个信号给目的进程。发送进程可以有如下两种原因:1)内核检测到一个系统事件,比如除零错误或者子进程终止;2)一个进程调用了kill函数,显式地要求内核发送一个信号给目的进程,几个进程可以发送信号给它自己。 - 接收信号 当目的进程被内核强迫以某种方式对信号的发送做出反应时,它就接收了信号。进程可以忽略这个信号,终止或者通过执行一个称为信号处理程序(signal handler)的用户层函数捕获这个信号,下面是信号处理程序捕获信号的基本思想。

一个发出而没有被接收的信号叫做待处理信号(pending singal)。在任何时刻,一种类型至多只会有一个待处理信号。如果一个进程有一个类型为k的待处理信号,那么任何接下来发送到这个进程的类型为k的信号都不会排队等待;他们只是被简单地丢弃。一个进程可以选择性地阻塞接收某种信号。当一种信号被阻塞时,它仍可以被发送,但是产生的待处理信号不会被接受,直到进程取消对这种信号的阻塞。 一个待处理信号最多只能被接受一次,内核为每个进程在pending位向量中维护着待处理信号的集合,而在blockerd位向量中维护着被阻塞的信号集合。只要传送了一个类型为k的信号,内核就会设置pending中的第k位,而只要接收了一个类型为k的信号,内核就会清除pending中的第k位。

8.5.2 发送信号

1.进程组 每个进程都只属于一个进程组,进程组是由一个正整数进程组ID来标识的。getpgrp函数返回当前进程的进程组ID。 默认的,一个子进程和它的父进程同属于一个进程组。一个进程可以通过使用setpgid函数来改变自己或者其他进程的进程组。setpgid 函数是在 Linux/UNIX 系统下用于设置某个进程的进程组 ID 的,它的函数原型如下:

#include <unistd.h>
int setpgid(pid_t pid, pid_t pgid);
其中,pid 表示需要设置的进程 ID,pgid 表示需要设置的进程组 ID。setpgid 函数可以将一个进程设置为所指定的进程组中的一个成员,同时可以创建新的进程组。使用 setpgid 函数创建新的进程组时,若 pid 参数所指的进程尚未加入任何进程组,则可将其作为新进程组的组长进程(即进程组 ID 与该进程 ID 相同),成功时返回 0,失败时返回 -1。使用 setpgid 函数还可以实现进程的前后台切换。在 Linux/UNIX 系统中,每个终端都有一个唯一的进程组 ID,在某个终端上运行着的进程都属于该终端的进程组。一个进程组可以拥有多个进程。在前台运行的进程接收键盘输入信号,处于后台运行的进程则接收不到键盘输入信号。因此通过将进程的进程组 ID 设置为当前终端的进程组 ID,可以将其放到前台运行,在当前终端接受键盘输入信号。使用 setpgid 函数还可以实现进程的作业控制,例如将多个进程放在同一作业中,并对该作业进行统一管理。 2.用/bin/kill 程序发送信号 /bin/kill程序可以向另外的进程发送任意的信号。 3.从键盘发送信号 Unix shell 使用作业(job)这个抽象概念来表示对一条命令行求值而创建的进程。在任何时刻,至多只有一个前台作业和0个或多个后台作业。比如键入:
linux > ls | sort
会创建一个一个由两个进程组成的前台作业。这两个进程通过Unix管道连接:一个进程运行ls程序,一个进程运行sort程序。shell为每个作业创建一个独立的进程组。进程ID通常取自作业中父进程中的一个。比如,下面展示了有一个前台作业和两个后台作业的shell。前台作业中的父进程PID为20,进程组ID也是20.父进程创建两个子进程,每个也都是进程组20的成员。 在键盘上输入Ctrl+C会导致内核发送一个SIGINT信号到前台进程组中的每个进程。默认情况下,结果是终止前台作业。类似的,输入Ctrl+Z会发送一个SIGTSTO信号到前台进程组中的每个进程。默认情况下,结果是停止(挂起)前台作业。 4.用kill函数发送信号 进程通过调用kill函数发送信号给其他进程(包括自己) 下面展示了父进程用kill函数发送SIGKILL信号给它的子进程。 5.用alarm函数发送信号 进程可以通过调用alarm函数向他自己发送SIGALRM信号。 alarm函数安排内核在secs后发送一个SIGALRM信号给调用进程,如果secs=0,则不会调度安排新的闹钟(alarm)。

8.5.3 接收信号

当内核把进程p从内核模式切换到用户模式时(eg:从系统调用返回或是完成了一次上下文切换),它会检查进程p的未被阻塞的待处理信号的集合(pending & ~blocked)。如果这个集合为空(通常情况下),那么内核将控制传递到p的逻辑控制流中的下一条指令。如果集合是非空的,那么内核将控制传递到p的逻辑控制流中的下一条指令,并且强制p接受信号k。收到这个信号会触发进程采取某种行为。一旦进程完成了这个行为,那么控制就传递回p的逻辑控制流中的下一条指令(I_{next})。每个信号类型都有一个预定义的默认行为,是下面的一种: - 进程终止 - 进程终止并转储内存 - 进程停止(挂起)直到被SIGCONT信号重启 - 进程忽略该信号

进程可以通过signal函数修改和信号相关联的默认行为,唯一的例外是SIGSTOP和SIGKILL,其默认行为不能修改 signal函数可以通过下列三种方法之一来改变和信号signum相关联的行为: - 如果handler是SIG_IGN,那么忽略类型为signum的信号 - 如果handler是SIG——DFL,那么类型为signum的信号行为恢复为默认行为 - 否则,handler就是用用户定义的函数的地址,这个函数被称为信号处理程序,只要进城接收到signal函数从而改变默认行为,这个叫做设置信号处理程序(installing the handler)。调用信号处理程序被称为捕获信号,执行信号处理程序被称为处理信号。

当一个进程捕获了一个类型为k的信号时,会调用为信号k设置的处理程序,一个整数参数被设置为k,这个参数允许同一个处理函数捕获不同类型的信号。 当处理程序执行它的return语句时,控制(通常)传递回控制流中进程被信号接收中断位置处的指令。eg:8-30,修改了Ctrl-Z的默认行为 信号处理程序可以被其他信号处理程序中断,如下。

8.5.4 阻塞和解除阻塞信号

Linux提供阻塞信号的隐式和显式机制 - 隐式阻塞机制:内核默认阻塞任何当前处理程序正在处理信号类型的待处理的信号。eg:如上图中,假设程序捕获了信号s,当前正在运行处理程序S。如果发送给该进程另一个信号s,那么直到处理程序S返回,s会变成待处理而没有被接收。 - 显式阻塞机制:应用程序可以使用sigprocmask函数和它的辅助函数,明确地阻塞和解除阻塞选定的信号。 sigprocmask 是一个 POSIX 标准定义的系统调用函数,用来设置和获取进程的信号屏蔽字。可以通过 sigprocmask 函数调用设置进程阻塞某些信号或解除信号阻塞。

sigprocmask 函数的标准签名如下:

#include <signal.h>

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

其中,how 参数指定了操作方式,可以是以下几个常量中的一个:

  • SIG_BLOCK :将 set 中的所有信号添加到当前进程的信号屏蔽字中。

  • SIG_UNBLOCK :将 set 中的所有信号从当前进程的信号屏蔽字中删除。

  • SIG_SETMASK :将当前信号屏蔽字设置为 set 中的值。

set参数是一个指向 sigset_t 类型的指针,该类型是一个位向量表示信号集。如果设置信号屏蔽字,set指向的实际位向量中包含的是屏蔽信号的位,未屏蔽的信号的位则被清除。如果您想阻止信号,则需要修改该信号的位状态。另外,oldset 参数是一个输出参数,它记录了之前当前进程的信号屏蔽字状态。 例如,下面的代码存储了当前进程的信号屏蔽字并禁用 SIGINT 信号:

#include <signal.h>

int main() {
    sigset_t newmask, oldmask;

    // 初始化屏蔽字
    sigemptyset(&newmask);
    sigaddset(&newmask, SIGINT);

    // 设置屏蔽字,保存旧值
    sigprocmask(SIG_BLOCK, &newmask, &oldmask);

    // 执行某些需要屏蔽信号的操作

    // 恢复旧屏蔽字
    sigprocmask(SIG_SETMASK, &oldmask, NULL);

    return 0;
}
上面的代码首先创建一个新信号屏蔽字 newmask,它将 SIGINT 信号阻止。然后调用 sigprocmask 函数阻止了该信号,并存储了旧的屏蔽字。在此之后,执行需要被屏蔽信号的操作,然后恢复旧的信号屏蔽字,让 SIGINT 信号回到原始的可被处理状态。

8.5.5 编写信号处理程序

处理程序难以推理分析的原因: - 处理程序与主程序并发运行,共享同样的全局变量,因此可能与主程序和其他处理程序互相干扰 - 如何以及何时接收信号的规则常常有违人的直觉 - 不同系统有不同信号处理语义 基本规则 - 1.安全的信号处理 - 处理程序要尽可能简单 - 处理程序终止调用异步信号安全的函数:所谓异步信号安全的函数能够被信号处理程序安全地调用,原因有2:1)要么它是可重入的(可重入(reentrant)是计算机科学中的一个概念,指的是一个程序、代码段或者是共享资源,可以被多个并发的线程或者进程同时使用,而不会引起不一致或崩溃等问题。具体来说,可重入的代码必须满足以下条件:可重入的代码自身不跟踪任何状态,或者任何状态信息必须由调用者或环境传递进来。可重入的代码必须保证它不拥有任何全局或静态的状态变量。可重入的代码必须使用局部变量而非静态变量,因为局部变量在栈中分配,每个线程或进程都有自己独立的栈空间。可重入的代码必须使用互斥锁或信号量等线程同步机制,以防止多个线程或进程同时访问它们的临界区资源。使用可重入的代码可以提高程序的性能和可维护性,并避免竞争条件和死锁等问题。可重入代码常见的应用包括操作系统内核、库函数等),2)要么它不能被信号处理程序中断。图8-33列出了Linux保证安全的系统级函数。(信号处理程序产生输出唯一安全的方法是使用write函数,使用printf或者sprintf是不安全的) - 保存和恢复errno:进入处理程序时把出错返回时设置的errno保存在一个局部变量中 - 阻塞所有的信号,保护对共享全局数据结构的访问。如果处理程序和主程序或其他处理程序共享一个全局数据结构,那么在访问该数据结构时,你的处理程序和主程序应该暂时阻塞所有的信号。这条规则的原因是:从主程序访问一个数据结构d通常需要一系列的指令,如果指令序列被访问d的处理程序中断,那么处理程序可能会发现d的状态不一致,得到不可预知的结果。 - 用volatile声明全局变量:使用关键字“volatile”可以声明一个变量为“易变的(volatile)”,使得该变量的值可能被意外更改,即该变量的值可能被程序外部的因素更改,例如操作系统或硬件。通常,这种情况发生在多线程/多进程程序或者嵌入式系统中。当我们在使用全局变量时,如果其值会被通过其他方式修改(比如中断处理函数),我们就应该使用volatile关键字来修饰该变量,保证在程序中使用该变量时总是读取最新的值。在多线程/多进程程序中,当一个线程或进程修改了某个全局变量时,其他线程或进程会在不知情的情况下读取过期的或者不正确的值,这可能会导致不可预期的结果,为了避免这种情况,我们可以使用volatile关键字来使得该变量的值随时保持最新。需要注意的是,使用volatile关键字会影响编译器的优化过程,因为编译器不会对该变量做过多的优化,以保证程序的正确性。 - 用sig_atomic_t声明标志:保证信号的读写是原子的。 - 2.正确的信号处理 未处理的信号是不排队的,因为pending位想两种每种类型的信号只对应有一位,所以每种类型最多只能有一个未处理信号,多余的信号都会被丢弃。 - 3.可移植的信号处理 Unix信号处理的另一个缺陷在于不同的系统有不同的信号处理语义: - signal函数的语义各有不同 - 系统调用可以被中断:像read、write和accpet这样的系统调用潜在地会阻塞进程一段较长的时间,称为慢速系统调用。 为了解决以上问题,Posix标准定义了sigaction函数,允许用户在设置信号处理时,明确指定他们想要的信号处理含义。 sigaction的替代方案是定义一个叫做signal的包装函数调用sigaction

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

eg:考虑图8-39的程序,它总结了一个典型的Unix shell的结构。父进程在一个全局作业列表中记录着它的当前子进程,每一个作业一个条目。addjob和deletejob函数分别向这个作业列表添加和从中删除作业。 当父进程创建一个新的子进程后,它就把这个子进程添加到作业列表中。当父进程在SIGCHLD处理程序中回收一个终止的子进程时,它就从作业列表中删除这个子进程。 问题是,可能会发生这样的事情: - 1)父进程执行fork函数,内核调度新创建的子进程运行,而不是父进程; - 2)在父进程能够再次运行之前,子进程就终止,并且变成一个僵死进程,使得内核传递一个SIGCHLD信号给父进程; - 3)后来,当父进程再次变成可运行但又在它执行之前,内核注意到有未处理的SIGCHLD信号,并通过在父进程中运行处理程序接受这个信号; - 4)信号处理程序回收终止的子进程,并调用deletejob,这个函数什么也不做,因为父进程还没有把该子进程添加到列表中; - 5)在处理程序执行完毕之后,内核运行父进程,父进程从fork返回,通过调用addjob错误地把不存在的子进程添加到作业列表中

因此,对于父进程的main程序和信号处理流的某些交错,可能会在addjob之前调用deletejob。这会导致作业列表出现一个不正确的条目,对应于一个不再存在而且永远也不会被删除的作业。另一方面,也有一些交错,事件按照正确的顺序发生。eg:如果在fork调用返回时,内核刚好调度父进程而不是子进程运行,那么父进程就会正确地把子进程添加到作业列表中,然后子进程终止,信号处理函数把该作业从列表中删除。

这是一个称为竞争(race)的经典同步错误的示例。在这个情况下,main函数中调用addjob和处理程序中调用deletejob之间存在竞争。如果addjob赢,则结果是正确的,反之则出错。 图8-40展示了消除图8-39中竞争的一种方法,通过在调用fork之前,阻塞SIGCHLD信号,然后在调用addjob之后,取消阻塞。

8.5.7 显式地等待信号

图8-41给了一个基本思路。父进程设置SIGINT和SIGCHLD的处理程序,然后进入一个无限循环。它阻塞SIGCHLD信号,避免父进程和子进程之间的竞争。创建了子进程之后,把pid重置为0,取消阻塞SIGCHLD,然后以循环的方式等待pid变为非零。子进程终止后,处理程序回收它,把它非零的PID赋值给全局pid变量,终止循环。 上诉代码的问题是循环浪费资源,有以下解决办法: - 使用pause,会有竞争 - 使用sleep,无法确定合适的事件 - 使用sigsuspend,阔以

sigsuspend 是一个 UNIX 系统调用,用于暂停进程并等待信号。当进程执行 sigsuspend 时,它会阻塞,直到收到指定信号之一为止。这个系统调用通常用于等待异步事件,比如定时器或 Socket 等待连接或数据。

sigprocmask 和 sigaction 是 sigsuspend 的前置条件,这些系统调用可用于管理信号处理程序和控制信号的接收。在一些情况下,进程可能需要暂停其信号处理器以等待特定信号,这时就需要使用 sigsuspend。
图8-42是使用sigsuspend的例子。

8.6 非本地跳转

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

setjmp 是一个 C 语言标准库函数,它允许程序在任意位置保存当前上下文,并在后续恢复该上下文。它通常用于实现非局部跳转(longjmp)。


当调用 setjmp 时,它会保存当前的 CPU 上下文,包括栈指针、程序计数器和寄存器等信息。然后它会返回 0。此后,如果调用 longjmp 并传入相同的 jmp_buf 参数,程序将会回到 setjmp 调用的位置,并且 setjmp 的返回值将为非零值。


setjmp 的返回值和传递给 longjmp 的非零值之间不存在显式的联系。通常,开发者会使用 setjmp 的返回值作为一个状态指示器,用来判断程序是从 setjmp 返回的还是从 longjmp 跳转回来的。


一个常见的用途是处理异常。当程序发生错误需要跳转出去时,可以使用 setjmp 保存当前环境,然后在异常处理函数中使用 longjmp 返回到之前保存的环境。这样就可以避免使用一些过于复杂的结构来处理异常。
longjmp 是一个 C 语言标准库函数,用于进行非局部跳转。与 goto 不同的是,longjmp 可以跳出多层函数调用,返回到调用 setjmp 时保存的上下文状态。


当调用 longjmp 时,会使程序跳转到之前调用过 setjmp 保存的上下文状态。longjmp 的第二个参数指定跳转时的返回值。


longjmp 的使用必须在 setjmp 的作用域范围内。如果 longjmp 跳转到了不在 setjmp 的作用域内的位置,程序的行为将是未定义的。


由于 longjmp 的跳转是非常强大和危险的,因此它通常被用于处理异常情况,当程序遇到意外错误时,使用 setjmp 在当前位置进行状态保存,然后在异常处理函数中使用 longjmp 跳转到保存的状态进行处理。 但是,使用 setjmp 和 longjmp 时需要非常小心,确保它们被正确使用,否则会导致程序崩溃或产生其他严重问题。
图8.43展示了一个非本地跳转的例子。非本地跳转的一个重要应用是允许从一个深层嵌套的函数调用中立即返回,通常是由检测到某个错误情况引起的。如果在一个深层嵌套的函数调用中发现了一个错误情况,我们可以使用非本地跳转直接返回到一个普通的本地化的错误处理程序,而不是费力地解开调用栈。 longjmp允许它跳过所有中间调用的特性可能产生意外的后果。eg:如果中间函数调用中分配了某些数据结构,本来预期在函数结尾处释放它们,那么这些释放代码会被跳过,从而产生内存泄漏。

非本地跳转的另一个重要应用是使一个信号处理程序分支到一个特殊的代码位置,而不是返回到被信号到达中断了的指令的位置。图8-44展示了一个简单的技术,说明了这种基本技术:当用户在键盘上键入Ctrl + C时,这个程序用信号和非本地跳转实现软重启。sigsetimp和siglongjmp是setjmp和longjmp的可以被信号处理程序使用的版本。 在程序第一次启动时,对sigsetimp函数的初始调用保存调用环境和信号的上下文(包括待处理的和被阻塞的信号向量)。随后,主函数进入一个无限循环。当用户键入Ctril + C时,内核发送一个SIGINT信号给这个进程,该进程捕获这个信号。不是从信号处理程序返回,而是实现一个非本地跳转,回到main函数的开始处。 两个注意点: - 必须在调用了sigsetimp之后再设置处理程序。否则就会冒在初始调用sigsetimp为siglongjmp设置调用环境之前运行处理程序的风险 - sigsetjmp和siglongjmp函数不在8-33异步信号安全的函数之列,因为其可以跳到任意代码,所以最好只在siglongjmp可达的代码中调用安全的函数。

8.7 操作进程的工具

  • STRACE:
    strace 是一种 Linux 系统级调试工具,它可以跟踪和记录应用程序执行时所有系统调用和信号,以便分析和调试程序。它可以用来解决一些问题,例如程序运行慢、程序崩溃或挂起等。
    
    
    使用 strace 可以获取程序在执行时调用系统库的详细信息和调用顺序,例如文件操作、网络操作、进程管理等,可以显示系统调用的返回值、参数以及调用时间等详细信息。通过查看 strace 输出可以判断程序是否正常执行,是否有权限问题或者其他异常。
    
    
    strace 的使用非常简单,只需要在命令前加上 strace 即可,例如:
    
    
    strace ls -l
    
    这条命令会跟踪 ls -l 命令执行时所有的系统调用和信号,并将结果输出到标准输出。
    
    
    除了常见的选项,例如 -o 参数可以将输出保存到文件中,-p 参数可以指定要跟踪的进程 ID,-f 参数可以跟踪子进程等。strace 是 Linux 系统中非常有用的调试工具之一。
    
  • PS
    ps 是一个常用的 Linux 命令,用于查看系统进程信息。ps 命令可以显示与当前登录用户有关的所有进程,或者指定进程的信息。你可以使用 ps 命令来监控系统运行状态,或者找到某个进程的 PID(进程ID)。
    
    
    ps 命令的常用选项包括:
    
    
    
    ps aux 列出所有进程的详细信息,包括进程的 PID、占用 CPU 的百分比、占用内存的百分比、启动时间、命令及其参数等。
    
    ps -f 以完整格式显示进程信息。
    
    ps -ef 以完整格式显示所有进程信息,包括命令行参数,使用 UID 和 GID 显示所有者和组。
    
    ps -e 列出所有正在运行的进程。
    
    ps -C command_name 显示指定命令的进程信息。
    
    ps -p pid 显示指定进程的信息。
    
    ps -t terminal 仅显示运行在指定终端上的进程。
    
    
    其中 aux 和 ef 是最常用的选项,可以显示最详细的进程信息。例如:
    
    
    ps aux
    
    该命令会列出当前系统上所有的进程信息,并展示详细的配置信息,让你很容易找到正在运行的进程信息和相应的 PID。
    
    ps -h 是 ps 命令的一个选项,它的作用是隐藏 ps 命令输出中的标题行。这意味着当你使用 ps -h 命令时,你只会看到进程信息,而不会看到默认的标题行。
    
  • TOP
    top 是一个命令行实用程序,它可以在类 Unix 系统中可视化地显示进程活动和系统资源的使用情况。它具有实时监视的功能,可以帮助管理员了解系统的性能,并能够及时响应问题。
    
    
    当你运行 top 命令时,它会向你展示一个实时更新的进程列表,列表按照 CPU 使用率或内存消耗来排序。默认情况下,列表按照 CPU 使用率排序,最先显示最消耗 CPU 的进程。你可以按下不同的键来切换列表的排序方式。
    
    
    top 命令还会显示系统的负载情况(即 CPU 利用率)、内存使用情况、交换空间使用情况、进程数量等信息。它还会提供一些基本的交互式功能,例如在进程列表中选择进程来终止它们,修改进程的优先级等。
    
  • PMAP
    pmap 是一个命令行实用程序,它可以显示指定进程或进程 ID 的内存映射情况。它通常用于诊断和调试进程的内存使用情况,包括进程占用的内存大小、内存区域的地址范围、内存映射文件、共享库等信息。
    
    
    当你运行 pmap 命令时,可以看到如下输出:
    
    
    $ pmap 1234
    1234:   /usr/bin/python3 myscript.py
    0000555555554000     48K r-x-- myscript.py
    0000555555571000   2048K ----- myscript.py
    00007ffff7a38000   1408K r---- libc-2.27.so
    00007ffff7bd5000   1048K r-x-- libc-2.27.so
    00007ffff7cf3000     24K r---- libc-2.27.so
    00007ffff7cf9000      8K rw--- libc-2.27.so
    00007ffff7cfb000     16K rw---   [ anon ]
    00007ffff7cff000    144K r-x-- ld-2.27.so
    00007ffff7ee4000     12K rw---   [ anon ]
    00007ffff7eee000      8K rw---   [ anon ]
    00007ffff7ef0000      4K r---- ld-2.27.so
    00007ffff7ef1000      4K rw--- ld-2.27.so
    00007ffff7ef2000      4K rw---   [ anon ]
    00007ffff7ef3000      4K r---- myscript.py
    00007ffff7ef4000      4K rw--- myscript.py
    00007ffffffde000    132K rw---   [ stack ]
    ffffffffff600000      4K r-x--   [ anon ]
    ----------------  ------ 
    total            3896K
    
    输出中分别列出了进程中的内存区域,并显示了每个区域的地址范围、权限、来源以及占用的内存大小。在这个例子中,pmap 命令显示了进程 ID 为 1234 的 Python 进程的内存映射情况,其中还包括 Python 解释器使用的一些共享库和内存区域。你可以使用 pmap 命令来确定内存使用情况、查找内存泄漏或者优化进程占用的内存等。
    
  • /proc
    通过读取 /proc 目录中的文件,可以获得有关系统和进程状态的各种信息。
    
    
    例如,读取 /proc/cpuinfo 文件可以获得有关 CPU 型号、频率、核心数和缓存等信息。读取 /proc/meminfo 文件可以获得有关系统内存使用情况的信息。读取 /proc/[pid]/status 文件可以获得特定进程的状态信息,如进程 ID、用户名、运行状态、内存使用情况等。
    
    
    /proc 目录下的文件和目录通常都是只读的,但在某些情况下也可以进行写入。例如,向 /proc/sys/kernel/hostname 文件中写入一个新的主机名可以更改系统的名称。此外,通过在 /proc/sys 目录中进行写入,可以更改系统内核的一些参数和配置。
    
    
    总之,/proc 为我们提供了一种查看和更改系统状态、进程状态和内核参数的方法。但是需要注意的是,在读取和写入 /proc 目录下的文件时,需要有足够的权限才可以进行操作。
    

shlab hw:

  • op:

编译:make 测试某一个:make test{num} 查看标准答案:make rtest{num}

  • 要修改的函数:如上

  • eval: 解析判断cmdline,查看是否为builtin_cmd,如果不是,如果是后台运行,则阻塞,起子进程,子进程中解阻塞,运行,主进程addjob,解阻塞;如果是,进入builtin_cmd。

  • builtin_cmd: 给quit、&、jobs、fg、bg做结构判断,如果是bg/fg,则进入do_bgfg.
  • do_bgfg:解析cmdline,并操作
  • waitfg:等待停止
  • sigchld_handler:等待对应进程状态变化,并根据变化时信号状态进程进行deletejob(EXITED/SIGNQALED)/getjobpid(STOPPED)
  • sigint_hanler :捕获ctrl-c
  • sigtstp_handler:捕获ctrl-z
  • test:

  • 02:退出

  • 03:前台进程,退出
  • 04:run后台台进程
  • 05: 运行后台进程并列出
  • 06:运行前台进程,并给出ctrl_z信号
  • 07: 同时运行前后台进程并列出
  • 08: 同时运行前后台进程给信号到前台并列出
  • 09: 同时运行前后台进程 将前台进程变成后台,并列出
  • 10: 运行后台进程 将后台进程变成前台,并列出
  • 11: 运行后台进程 将后台进程变成前台,并列出
  • 12 - 13: ps -a
  • 14: 错误处理
  • 15: 综合
  • 16: 测试其他进程给的SIGTSTP 和 SIGINT

github:

https://github.com/liuxubit/csapp_labs/tree/shlab

reference:

https://gitee.com/sun-hongwei8011/csapp-lab


本文总阅读量