CSAPP 第八章 Exceptional Control Flow 读书笔记 part 1

29 篇文章 0 订阅
28 篇文章 0 订阅

CSAPP 第八章 Exceptional Control Flow part 1

Exceptional Control Flow

在这里插入图片描述
ak (kth instruction的地址) 表示 Ik (kth instruction). 从ak 到 a(k+1) 的转换叫做控制传递(control transfer)。控制传递序列被称为处理器的控制流(flow of control),或者控制流程(control flow)。最简单的控制流是“平滑”序列,其中每个I(k+1)与Ik是在内存中相邻。通常,对这种平滑流的突然变化,其中I(k+1)不与Ik相邻,是由熟悉的程序指令引起的,如跳转、调用和返回。这些指令是允许程序响应由程序变量表示的内部程序状态变化的必要机制。

在计算机程序中,跳转(jump)、调用(call)和返回(return)是指一些基本的控制流程指令,用于管理程序的执行流程。

  1. 跳转(Jump): 跳转是指程序在执行过程中直接转移到另一个指定位置的指令。这通常用于实现条件分支或循环。通过跳转,程序可以根据特定条件改变执行路径,使得程序的控制流不再是顺序执行的平滑序列。

  2. 调用(Call): 调用是指程序执行一个子程序(或函数)的指令。当程序执行到调用指令时,控制流会转移到被调用的子程序,并在子程序执行完后返回到调用点。调用机制允许程序模块化,将功能划分成独立的部分,并在需要时进行调用。

  3. 返回(Return): 返回是指子程序执行完后将控制流返回到调用点的指令。它标志着子程序的执行结束,并将控制权交还给调用者。返回指令使得程序能够从子程序中获取结果或执行后续操作。

总体而言,跳转、调用和返回是程序中用于控制执行流程的关键指令。通过它们,程序可以根据条件、需要执行不同的代码块,实现模块化设计,并在需要时进行子程序的调用和返回。这些机制允许程序根据内部程序状态的变化,即程序变量的值,动态地改变其行为。

但系统还必须能够对系统状态的变化做出反应,这些变化不被内部程序变量捕捉,也不一定与程序的执行相关。例如,硬件定时器定期触发并需要处理。数据包到达网络适配器并必须存储在内存中。程序请求从磁盘获取数据,然后在数据准备好时进入休眠状态,等待通知。创建子进程的父进程在其子进程终止时必须得到通知。

现代系统通过在控制流中进行突然的变化来应对这些情况。总体上,将这些突然的变化称为异常控制流(ECF)。ECF在计算机系统的各个层次都会发生。例如,在硬件层面,硬件检测到的事件会触发对异常处理程序的突然控制转移。在操作系统层面,内核通过上下文切换将控制从一个用户进程转移到另一个。在应用程序层面,一个进程可以向另一个进程发送信号,突然将控制转移到接收方的信号处理程序。一个单独的程序可以通过绕过通常的堆栈规则,对其他函数中的任意位置进行非局部跳转,以应对错误。

作为程序员,了解ECF的重要性有多个原因:

  • 了解ECF将帮助您理解重要的系统概念。ECF是操作系统用于实现I/O、进程和虚拟内存的基本机制。在真正理解这些重要概念之前,您需要了解ECF。
  • 了解ECF将帮助您理解应用程序与操作系统的交互方式。应用程序通过使用称为陷阱或系统调用的ECF形式向操作系统请求服务。例如,通过调用系统调用,应用程序可以将数据写入磁盘、从网络读取数据、创建新进程以及终止当前进程。了解基本的系统调用机制将有助于您理解这些服务是如何提供给应用程序的。
  • 了解ECF将帮助您编写有趣的新应用程序。操作系统为应用程序提供了强大的ECF机制,用于创建新进程、等待进程终止、通知其他进程系统中的异常事件以及检测并响应这些事件。如果您了解这些ECF机制,那么您可以使用它们来编写有趣的程序,如Unix shells和Web servers。
  • 了解ECF将帮助您理解并发性。ECF是计算机系统中实现并发性的基本机制。以下是并发性的示例:中断应用程序执行的异常处理程序;在时间上重叠执行的进程和线程;中断应用程序执行的信号处理程序。了解ECF是理解并发性的第一步。我们将在第12章中更详细地学习它。
  • 了解ECF将帮助您理解软件异常的工作原理。诸如C++和Java之类的语言通过try、catch和throw语句提供软件异常机制。软件异常允许程序对错误条件进行非局部跳转(即违反通常的调用/返回堆栈规则的跳转)。非局部跳转是应用程序级别的ECF的一种形式,在C中通过setjmp和longjmp函数提供。了解这些低级函数将有助于您理解如何实现更高级别的软件异常。

这章将了解应用程序如何与操作系统交互。有趣的是,所有这些交互都围绕着ECF展开。描述了计算机系统各个层次存在的各种ECF形式。从异常开始,它位于硬件和操作系统的交集处。还讨论了系统调用,这是为应用程序提供进入操作系统的异常。然后,上升到抽象层次,描述了进程和信号,它们位于应用程序和操作系统的交集处。最后,讨论了非局部跳转,这是一种应用程序级别的ECF形式。

Exceptions

“Processor’s state” 指的是处理器(CPU)的当前状态,这包括了一系列与处理器操作和执行相关的信息。处理器的状态通常包括以下一些方面:

  1. 程序计数器(Program Counter): 指向当前正在执行的指令的内存地址。

  2. 寄存器的内容: 寄存器是用于在处理器中存储临时数据的小型存储区。处理器状态包括各种寄存器的内容,如通用寄存器、栈指针寄存器、标志寄存器等。

  3. 栈指针(Stack Pointer): 指向当前栈顶的内存地址,用于处理函数调用和返回。

  4. 标志寄存器(Flag Register): 包含有关处理器状态的标志位,例如零标志、进位标志、符号标志等,用于控制条件分支。

  5. 内存管理单元(Memory Management Unit,MMU)状态: 如果存在,表示处理器当前的内存映射和地址转换状态。

  6. 执行状态: 处理器可能处于不同的执行状态,例如用户态和内核态,用于控制对系统资源的访问权限。

  7. 中断状态: 记录处理器当前是否允许或禁止中断的状态。

  8. 浮点数寄存器状态(如果有): 对于支持浮点运算的处理器,还包括浮点寄存器的内容和状态。

这些是处理器状态的一些典型方面,具体的处理器架构和体系结构可能会有所不同。在不同的上下文中,可能会对处理器状态的特定方面有不同的要求和关注点。
在这里插入图片描述
正常程序运行也会导致处理器状态的变化,但与异常事件(events)有一些区别。下面是它们之间的主要区别:

  1. 正常程序运行: 在正常的程序执行过程中,处理器状态会根据执行的指令而变化。例如,程序计数器(Program Counter)会递增以指向下一条指令,寄存器的内容会根据指令的要求而改变,栈指针可能会在函数调用和返回时发生变化。这些变化是程序的正常执行的一部分,不引起异常。

  2. 事件(Event): 事件通常是指在程序正常执行过程之外发生的情况。这包括中断、异常、系统调用等。事件会导致处理器从正常的执行流程中转移,将控制传递给相应的异常处理程序。处理事件可能会导致更广泛和复杂的状态变化,包括中断处理、异常处理、状态保存和恢复等。

Exception Handling

在这里插入图片描述
系统中的每种可能异常都被分配了一个唯一的非负整数异常号。这些编号中有些是由处理器的设计者分配的,而其他一些则是由操作系统内核(操作系统中驻留在内存中的部分)的设计者分配的。前者的示例包括除零、页面错误、内存访问违规、断点和算术溢出。后者的示例包括系统调用和来自外部I/O设备的信号。

在系统引导时(计算机被重置或开机时),操作系统分配并初始化一个称为异常表的跳转表,以便条目 k 包含异常 k 的处理程序的地址。图8.2显示了异常表的格式。
在这里插入图片描述
在运行时(当系统正在执行某个程序时),处理器检测到发生了某个事件,并确定相应的异常号(exception number)k。然后,处理器通过执行间接过程调用,通过异常表的第k项,触发异常并调用相应的处理程序。图8.3展示了处理器如何利用异常表来构建适当异常处理程序的地址。异常号是异常表的索引,其起始地址存储在一个特殊的CPU寄存器中,称为异常表基址寄存器(exception table base register)。

异常类似于过程调用,但存在一些重要的区别:

  1. 与过程调用类似,处理器在转到异常处理程序之前会将返回地址推送到栈上。然而,根据异常类别的不同,返回地址可以是当前指令(事件发生时正在执行的指令)或下一条指令(如果事件没有发生,则会执行的指令)。
  2. 处理器还会将一些额外的处理器状态推送到栈上,这些状态在处理程序返回时将需要重新启动中断的程序。例如,在x86-64系统上,会将包含当前条件码等信息的EFLAGS寄存器推送到栈上。
  3. 当控制从用户程序转移到内核时,所有这些项都被推送到内核的栈上,而不是用户的栈上。
  4. 异常处理程序在内核模式下运行(参见第8.2.4节),这意味着它们对所有系统资源都具有完全访问权限。

一旦硬件触发了异常,剩余的工作就由异常处理程序在软件中完成。在处理程序处理完事件后,它可以选择通过执行特殊的“从中断返回”指令返回到中断的程序,该指令将弹出适当的状态返回到处理器的控制和数据寄存器中,并在异常中断了用户模式的情况下(参见第8.2.4节)将状态还原回用户模式。

Classes of Exceptions

在这里插入图片描述

interrupt

在这里插入图片描述
中断是由来自处理器外部的I/O设备发出的信号的异步发生的结果。硬件中断是异步的,意味着它们不是由执行任何特定指令引起的。用于处理硬件中断的异常处理程序通常称为中断处理程序(interrupt handlers)。图8.5总结了中断的处理过程。诸如网络适配器、磁盘控制器和定时器芯片等I/O设备通过在处理器芯片上的一个引脚上发出信号,并将标识引起中断的设备的异常号(exception number)放置到系统总线(system bus)上来触发中断。

在当前指令执行完毕后,处理器注意到中断引脚(interrupt pin)已经变高,从系统总线读取异常号,然后调用相应的中断处理程序。当处理程序返回时,它将控制返回给下一条指令(即,如果中断没有发生,将会执行当前指令后续的指令)。其效果是,程序继续执行,好像中断从未发生过一样。
其余的异常类别(陷阱、故障和中止)是由于执行当前指令而同步发生的。将执行引起异常的当前指令称为故障指令。

Traps and System Calls

在这里插入图片描述
陷阱是由执行指令引起的有意的异常。与中断处理程序类似,陷阱处理程序将控制返回给下一条指令。陷阱最重要的用途是在用户程序和内核之间提供类似于过程的接口,称为系统调用。

用户程序经常需要向内核请求服务,例如读取文件(read)、创建新进程(fork)、加载新程序(execve)和终止当前进程(exit)。为了允许对这些内核服务进行受控访问,处理器提供了一条特殊的系统调用指令syscall n,用户程序可以在希望请求服务n时执行。执行syscall指令会导致陷阱到一个异常处理程序,该处理程序解码参数并调用适当的内核例程(kernel routine)。图8.6总结了系统调用的处理过程。

从程序员的角度来看,系统调用与常规函数调用相同。然而,它们的实现方式有很大的不同。常规函数在用户模式下运行,这限制了它们可以执行的指令类型,并且它们访问与调用函数相同的堆栈。系统调用在内核模式下运行,这允许它执行特权指令并访问在内核中定义的堆栈。第8.2.4节详细讨论了用户和内核模式。

Faults

在这里插入图片描述
故障是由错误条件引起的,而处理程序可能能够纠正这些错误。当发生故障时,处理器将控制转移到故障处理程序(fault handler)。如果处理程序能够纠正错误条件,它将控制返回到发生故障的指令,从而重新执行它。否则,处理程序将返回到内核中的一个中止例程,该例程终止导致故障的应用程序。图8.7总结了故障的处理过程。

故障的一个经典例子是页面故障异常(page fault exception),当一条指令引用一个虚拟地址,对应的页面不在内存中,因此必须从磁盘中检索。正如我们将在第9章中看到的,一个页面是虚拟内存的一个连续块(通常为4 KB)。页面故障处理程序从磁盘加载适当的页面,然后将控制返回到引发故障的指令。当指令再次执行时,适当的页面现在已经驻留在内存中,指令能够完成而无需发生故障。

Aborts

在这里插入图片描述
中止是由不可恢复的致命错误引起的,通常是硬件错误,比如在DRAM或SRAM位受损时发生的奇偶校验错误。中止处理程序永远不会将控制返回给应用程序。如图8.8所示,处理程序将控制返回到一个中止例程,该例程终止应用程序。

Exceptions in Linux/x86-64 Systems

x86-64系统定义的一些异常。最多有256种不同的异常类型。范围从0到31的数字对应于由Intel架构定义的异常,因此对于任何x86-64系统都是相同的。范围从32到255的数字对应于由操作系统定义的中断和陷阱。图8.9展示了一些示例。
在这里插入图片描述

Linux/x86-64 Faults and Aborts

除法错误。(Divide error )当应用程序试图除以零或者除法指令的结果对于目标操作数来说太大时,会发生除法错误(异常0)。Unix不会尝试从除法错误中恢复,而是选择中止程序。Linux shells通常将除法错误报告为“浮点异常”。

常规保护错误。(General protection fault) 臭名昭著的常规保护错误(异常13)发生的原因有很多,通常是因为程序引用了未定义的虚拟内存区域,或者因为程序试图写入只读文本段。Linux不会尝试从这个错误中恢复。Linux shells通常将常规保护错误报告为“段错误”。

页错误。(Page fault) 页错误(异常14)是一个例子,其中故障指令会被重新启动。处理程序将磁盘上的虚拟内存适当的页面映射到物理内存页面,然后重新启动故障指令。我们将在第9章中详细了解页错误的工作原理。

机器检查。(Machine check) 机器检查(异常18)是由在执行故障指令时检测到的致命硬件错误引起的。机器检查处理程序永远不会将控制返回给应用程序。

Linux/x86-64 System Calls

在这里插入图片描述
C程序可以通过使用syscall函数直接调用任何系统调用。然而,在实践中很少需要这样做。C标准库提供了一组方便的包装函数,用于大多数系统调用。这些包装函数将参数打包,使用适当的系统调用指令陷入内核,然后将系统调用的返回状态传递回调用程序。在本文中,将系统调用及其相关的包装函数互换地称为系统级函数。

x86-64系统通过一条称为syscall的陷阱指令提供系统调用。研究程序如何使用这个指令直接调用Linux系统调用是相当有趣的。所有Linux系统调用的参数都通过通用寄存器而不是堆栈传递。按照约定,寄存器%rax包含系统调用号,具有最多六个参数,分别是%rdi%rsi%rdx%r10%r8%r9。第一个参数在%rdi中,第二个在%rsi中,依此类推。从系统调用返回时,寄存器%rcx%r11会被破坏,而%rax包含返回值。返回值在-4,095到-1之间的负值表示与负的errno对应的错误。

例如,考虑下面这个使用write系统级函数(第10.4节)而不是printf的熟悉的hello程序版本:
在这里插入图片描述
write函数的第一个参数将输出发送到stdout(标准输出)。第二个参数是要写入的字节序列,而第三个参数指定要写入的字节数。
在这里插入图片描述
图8.11展示了一个使用syscall指令直接调用write和exit系统调用的汇编语言版本的hello程序。第9-13行调用write函数。首先,第9行将write系统调用的编号存储在%rax中,第10-12行设置参数列表。然后,第13行使用syscall指令调用系统调用。同样,第14-16行调用了_exit系统调用。

Processes

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

在现代系统上运行程序时,程序似乎对处理器和内存具有独占使用权。处理器似乎连续执行程序中的指令,没有中断。程序的代码和数据似乎是系统内存中唯一的对象。这些错觉是由进程的概念提供给我们的。

进程的经典定义是程序在执行中的实例。系统中的每个程序都在某个进程的上下文中运行。上下文包括程序需要正确运行的状态,包括存储在内存中的程序代码和数据,其栈,通用寄存器的内容,程序计数器,环境变量和打开文件描述符的集合。

每当用户通过在shell中键入可执行对象文件的名称来运行程序时,shell都会创建一个新的进程,然后在这个新进程的上下文中运行可执行对象文件。应用程序还可以创建新进程,并在新进程的上下文中运行其自身的代码或其他应用程序。

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

  • 一个独立的逻辑控制流,提供了程序独占处理器使用权的错觉。
  • 一个私有地址空间,提供了程序独占内存系统使用权的错觉。

Logical Control Flow

进程为每个程序提供一种错觉,即它独占处理器的使用权,尽管系统上通常有许多其他程序同时运行。如果使用调试器逐步执行我们程序的执行过程,将观察到一系列程序计数器(PC)值,这些值仅对应于包含在程序的可执行对象文件或在运行时动态链接到我们程序的共享对象中的指令。这个PC值的序列被称为逻辑控制流,或简称逻辑流。
在这里插入图片描述
考虑一个运行三个进程的系统,如图8.12所示。处理器的单一物理控制流被分为三个逻辑流,每个进程对应一个逻辑流。每条竖线表示一个进程的逻辑流的一部分。在这个例子中,三个逻辑流的执行是交错的。进程A运行一段时间,然后是B,它运行完成。接着,进程C运行一段时间,然后是A,它运行完成。最后,C能够完成运行。

图8.12中的关键点是进程轮流使用处理器。每个进程执行其逻辑流的一部分,然后在其他进程轮流运行时被抢占(暂时挂起)。对于在其中一个进程的上下文中运行的程序来说,它似乎独占处理器的使用权。唯一相反的证据是,如果精确测量每条指令的经过时间,会注意到CPU似乎在程序的某些指令执行之间周期性地停滞。然而,每当处理器停滞时,它随后会恢复对我们程序的执行,而不会改变程序内存位置或寄存器的内容。

Concurrent Flows

在这里插入图片描述
在计算机系统中,逻辑流以许多不同的形式存在。异常处理程序、进程、信号处理程序、线程和Java进程都是逻辑流的例子。

一个逻辑流在时间上与另一个流的执行重叠被称为并发流,这两个流被称为同时运行。更精确地说,对于流X和Y而言,只有当X在Y开始之后且在Y完成之前开始,或者Y在X开始之后且在X完成之前开始时,它们才是并发的。例如,在图8.12中,进程A和B同时运行,进程A和C也是。另一方面,进程B和C不是同时运行的,因为B的最后一条指令在C的第一条指令之前执行。

多个流并发执行的一般现象被称为并发性。进程与其他进程轮流执行的概念也被称为多任务处理。进程执行其流的一部分的每个时间段被称为时间片。因此,多任务处理也被称为时间片切片(time slicing)。例如,在图8.12中,进程A的流包含两个时间片。

注意,并发流的概念与流运行的处理器核心或计算机数量无关。如果两个流在时间上重叠,那么它们是并发的,即使它们在同一处理器上运行。然而,有时我们会发现将并发流的一个适当子集识别为并行流是有用的。如果两个流在不同的处理器核心或计算机上同时运行,那么我们称它们为并行流,它们在并行运行,并且具有并行执行(parallel execution)。

Private Address Space

一个进程为每个程序提供一种错觉,即它独占系统的地址空间使用权。在具有n位地址的计算机上,地址空间是2^n个可能地址的集合,即0、1、…、2 ^ n-1。一个进程为每个程序提供其自己的私有地址空间。这个空间是私有的,即空间中特定地址上的内存字节通常不能被任何其他进程读取或写入。
在这里插入图片描述
尽管每个私有地址空间关联的内存内容通常是不同的,但每个这样的空间具有相同的一般组织。例如,图8.13展示了x86-64 Linux进程的地址空间组织。

地址空间的底部部分保留给用户程序,包括通常的代码、数据、堆和栈段。代码段总是从地址0x400000开始。地址空间的顶部部分保留给内核(操作系统的内存驻留部分)。该地址空间的这部分包含内核在代表进程执行指令时使用的代码、数据和栈(例如,当应用程序执行系统调用时)。

User and Kernel Modes

为了使操作系统内核提供一个严密的进程抽象,处理器必须提供一种机制,限制应用程序可以执行的指令以及可以访问的地址空间部分。

处理器通常通过一些控制寄存器中的模式位(mode bit)来提供这种能力,该位表征进程当前享有的特权。当模式位被设置时,进程运行在内核模式(有时称为监管员模式)。在内核模式下运行的进程可以执行指令集中的任何指令,并访问系统中的任何内存位置。

当模式位未设置时,进程运行在用户模式。在用户模式下运行的进程不被允许执行具有特权的指令,如停止处理器、改变模式位或启动 I/O 操作。它也不允许直接引用地址空间的内核区域中的代码或数据。任何此类尝试都将导致致命的保护错误。用户程序必须通过系统调用接口间接访问内核代码和数据。

运行应用程序代码的进程最初处于用户模式。进程从用户模式切换到内核模式的唯一方式是通过异常,如中断、故障或陷阱系统调用(interrupt, a fault, or a trapping system call)。当异常发生并控制传递到异常处理程序时,处理器将模式从用户模式更改为内核模式。处理程序在内核模式下运行。当它返回到应用程序代码时,处理器将模式从内核模式切换回用户模式。

Linux 提供了一种巧妙的机制,称为 /proc 文件系统,允许用户模式进程访问内核数据结构的内容。/proc 文件系统将许多内核数据结构的内容导出为用户程序可以读取的文本文件的层次结构。例如,您可以使用 /proc 文件系统来获取一般系统属性,如 CPU 类型(/proc/cpuinfo),或特定进程使用的内存段(/proc/process-id/maps)。Linux 内核的2.6版本引入了一个 /sys 文件系统,它导出有关系统总线和设备的附加低级信息。

Context Switches

操作系统内核使用称为上下文切换的更高级别的异常控制流来实现多任务处理。上下文切换(context switch)机制建立在我们在第8.1节讨论的较低级别异常机制之上。

内核为每个进程维护一个上下文。上下文是内核需要重新启动被抢占进程的状态。它包括诸如通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈以及各种内核数据结构的值,如表征地址空间的页表、包含有关当前进程信息的进程表以及包含有关进程已打开的文件信息的文件表。

在进程执行的某些时刻,内核可以决定抢占当前进程并重新启动之前被抢占的进程。这个决定被称为调度(scheduling),由内核中的调度器代码处理。当内核选择运行一个新进程时,说内核已经调度了该进程。在内核调度了一个新进程运行后,它会抢占当前进程,并使用称为上下文切换的机制将控制传递给新进程,该机制 (1) 保存当前进程的上下文,(2) 恢复某个先前被抢占进程的保存上下文,并 (3) 将控制传递给这个新恢复的进程。

在内核代表用户执行系统调用时,上下文切换可能发生。如果系统调用因等待某个事件而阻塞,那么内核可以让当前进程进入休眠状态并切换到另一个进程。例如,如果一个读系统调用需要磁盘访问,内核可以选择执行上下文切换,并运行另一个进程,而不是等待数据从磁盘到达。另一个例子是 sleep 系统调用,它是显式请求将调用进程置于休眠状态。总的来说,即使系统调用不阻塞,内核也可以决定执行上下文切换而不是将控制返回给调用进程。

上下文切换也可能发生作为中断的结果。例如,所有系统都有一些生成定期定时器中断的机制,通常每1毫秒或10毫秒一次。每次定时器中断发生时,内核可以决定当前进程已经运行足够长时间,并切换到新进程。
在这里插入图片描述
图8.14展示了在一对进程 A 和 B 之间进行上下文切换的示例。在此示例中,最初进程 A 在用户模式下运行,直到通过执行读系统调用陷入内核。内核中的陷阱处理程序请求从磁盘控制器进行 DMA 传输,并安排磁盘在磁盘控制器完成数据从磁盘到内存的传输后中断处理器。

磁盘将花费相对较长的时间来获取数据(大约几十毫秒),因此内核在此期间执行了从进程 A 到 B 的上下文切换。请注意,在切换之前,内核正在代表进程 A 在用户模式下执行指令(即,没有单独的内核进程)。在切换的第一部分中,内核代表进程 A 在内核模式下执行指令。然后在某个时刻开始代表进程 B 执行指令(仍然在内核模式下)。切换后,内核代表进程 B 在用户模式下执行指令。

然后,进程 B 在用户模式下运行一段时间,直到磁盘发送中断信号,表示数据已从磁盘传输到内存。内核决定进程 B 已经运行足够长时间,并执行从进程 B 到 A 的上下文切换,将控制返回到进程 A 中紧随读系统调用之后的指令。进程 A 继续运行,直到发生下一个异常,如此循环。

System Call Error Handling

调用Linux的fork函数时如何检查错误的示例:
在这里插入图片描述
strerror函数返回一个描述与特定errno值相关联的错误的文本字符串。可以通过定义以下错误报告函数来简化这段代码:
在这里插入图片描述
使用unix_error的简化版本:
在这里插入图片描述
定义一个具有相同参数但名称的第一个字母大写的包装函数Foo。包装函数调用基本函数,检查错误,并在出现问题时终止。例如,这是fork函数的错误处理包装(error-handling wrapper):
在这里插入图片描述

Process Control

Obtaining Process IDs

在这里插入图片描述

Creating and Terminating Processes

从程序员的角度来看,可以将一个进程视为处于以下三种状态之一:

  1. 运行中(running):进程正在在CPU上执行,或者等待被执行,并最终将由内核进行调度。
  2. 已停止(stopped):进程的执行被暂停,不会被调度。进程停止是由于接收到SIGSTOP、SIGTSTP、SIGTTIN或SIGTTOU信号而导致的,它会一直停止,直到接收到SIGCONT信号,此时它将再次变为运行状态。(信号是一种我们将在第8.5节详细描述的软件中断形式。)
  3. 已终止(terminated):进程被永久停止。一个进程因以下三个原因之一而终止:(1)接收到一个默认行为是终止进程的信号,(2)从主程序返回,或(3)调用exit函数。
    在这里插入图片描述
    exit函数以状态status终止进程。(设置退出状态的另一种方法是从主例程中返回一个整数值。)
    在这里插入图片描述
    一个父进程通过调用fork函数创建一个新的运行中的子进程。

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

fork函数很有趣(而且通常令人困惑),因为它被父进程调用一次,但返回两次:一次给父进程,一次给新创建的子进程。在父进程中,fork返回子进程的PID。在子进程中,fork返回值为0。由于子进程的PID始终非零,返回值提供了一种明确的方法来判断程序是在父进程中执行还是在子进程中执行。
在这里插入图片描述
图8.15展示了一个简单的例子,其中父进程使用fork创建一个子进程。当第6行中的fork调用返回时,在父进程和子进程中,x的值都为1。子进程在第8行中增加并打印其x的副本。类似地,父进程在第13行中减少并打印其x的副本。

图8.15代码的运行结果:
在这里插入图片描述

  • 调用一次,返回两次(Call once, return twice)。fork函数由父进程调用一次,但返回两次:一次给父进程,一次给新创建的子进程。对于创建单个子进程的程序来说,这相当直接。但是对于具有多个fork实例的程序来说,可能会令人困惑,需要仔细思考。

  • 并发执行(Concurrent execution)。父进程和子进程是分开运行的两个进程。它们的逻辑控制流中的指令可以被内核以任意方式交错执行。在系统上运行程序时,父进程首先完成其printf语句,然后是子进程。但在另一个系统上,情况可能相反。一般来说,作为程序员,不能对不同进程中指令的交错做出假设。

  • 重复但独立的地址空间(Duplicate but separate address spaces)。如果能够在每个进程中的fork函数返回后立即停止父进程和子进程,会发现每个进程的地址空间是相同的。每个进程都有相同的用户栈、相同的局部变量值、相同的堆、相同的全局变量值和相同的代码。因此,在例子程序中,当第6行中的fork函数返回时,父进程和子进程中的局部变量x都为1。然而,由于父进程和子进程是独立的进程,它们各自拥有自己的私有地址空间。父进程或子进程对x进行的任何后续更改都是私有的,并不反映在另一个进程的内存中。这就是当它们调用各自的printf语句时,变量x在父进程和子进程中具有不同值的原因。

  • 共享文件(Shared files)。当运行示例程序时,注意到父进程和子进程都在屏幕上打印它们的输出。原因是子进程继承了父进程的所有打开文件。当父进程调用fork时,stdout文件是打开的并指向屏幕。子进程继承了这个文件,因此它的输出也指向屏幕。

在这里插入图片描述

Reaping Child Processes

当一个进程因任何原因终止时,内核并不会立即将其从系统中移除。相反,该进程被保留在一个终止状态,直到其被其父进程收回。当父进程收回终止的子进程时,内核将子进程的退出状态传递给父进程,然后丢弃终止的进程,此时它停止存在。一个尚未被收回的终止进程被称为僵尸进程。

当父进程终止时,内核会安排 init 进程成为任何孤儿子进程的被领养父进程。init 进程在系统启动时由内核创建,永远不会终止,并且是每个进程的祖先。如果父进程在没有收回其僵尸子进程的情况下终止,那么内核会安排 init 进程收回它们。然而,长时间运行的程序如 shells 或 servers 应该始终收回它们的僵尸子进程。尽管僵尸进程没有运行,它们仍然会消耗系统内存资源。

一个进程通过调用 waitpid 函数等待其子进程终止或停止。
在这里插入图片描述
waitpid 函数很复杂。默认情况下(当 options = 0 时),waitpid 暂停调用进程的执行,直到其等待集中的一个子进程终止。如果在调用时等待集中的一个进程已经终止,那么 waitpid 立即返回。无论哪种情况,waitpid 返回导致其返回的终止子进程的 PID。此时,终止的子进程已经被收回,内核从系统中删除了它的所有痕迹。

Determining the Members of the Wait Set

等待集的成员由 pid 参数确定:

  • 如果 pid > 0,则等待集是一个单一的子进程,其进程 ID 等于 pid。
  • 如果 pid = -1,则等待集包含所有父进程的子进程。

waitpid 函数还支持涉及 Unix 进程组的其他类型的等待集,但不讨论这些。

Modifying the Default Behavior

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

  • WNOHANG:如果等待集中的子进程尚未终止,则立即返回(返回值为0)。默认行为会挂起调用进程,直到一个子进程终止;这个选项在你希望在等待子进程终止时继续执行有用的工作的情况下很有用。
  • WUNTRACED:挂起调用进程的执行,直到等待集中的一个进程变为终止或停止。返回导致返回的终止或停止子进程的 PID。默认行为仅对终止的子进程返回;这个选项在你希望检查终止和停止的子进程时很有用。
  • WCONTINUED:挂起调用进程的执行,直到等待集中的一个正在运行的进程终止或直到等待集中的一个已停止的进程通过接收到 SIGCONT 信号而被恢复。(信号在第8.5节中有解释。)

可以通过对它们进行按位或操作来组合这些选项。例如:

  • WNOHANG | WUNTRACED:如果等待集中的子进程都没有停止或终止,则立即返回,返回值为0;或者返回一个等于已停止或终止的子进程之一的 PID。

Checking the Exit Status of a Reaped Child

如果 statusp 参数非空,则 waitpid 将关于导致返回的子进程的状态信息编码到 status 中,而 status 是由 statusp 指向的值。wait.h 头文件定义了几个用于解释 status 参数的宏:

  • WIFEXITED(status)。如果子进程正常终止(通过 exit 调用或返回),则返回 true。
  • WEXITSTATUS(status)。返回正常终止子进程的退出状态。仅当 WIFEXITED() 返回 true 时才定义此状态。
  • WIFSIGNALED(status)。如果子进程因未被捕获的信号而终止,则返回 true。
  • WTERMSIG(status)。返回导致子进程终止的信号编号。仅当 WIFSIGNALED() 返回 true 时才定义此状态。
  • WIFSTOPPED(status)。如果导致返回的子进程当前被停止,则返回 true。
  • WSTOPSIG(status)。返回导致子进程停止的信号编号。仅当 WIFSTOPPED() 返回 true 时才定义此状态。
  • WIFCONTINUED(status)。如果子进程通过接收到 SIGCONT 信号被重新启动,则返回 true。

Error Conditions

如果调用进程没有子进程,则 waitpid 返回 -1,并将 errno 设置为 ECHILD。如果 waitpid 函数被信号中断,则它返回 -1 并设置 errno 为 EINTR。

The wait Function

在这里插入图片描述
waitpid的简单版本。

Examples of Using waitpid

在这里插入图片描述
由于 waitpid 函数有些复杂,看一些例子会有帮助。图 8.18 展示了一个使用 waitpid 的程序,以无特定顺序等待其 N 个子进程终止。在第 11 行,父进程创建了 N 个子进程,在第 12 行,每个子进程使用唯一的退出状态退出。

在继续之前,请确保理解为什么每个子进程都执行第 12 行,而父进程没有执行。

在第 15 行,父进程通过在 while 循环的测试条件中使用 waitpid 来等待其所有子进程终止。因为第一个参数是 -1,调用 waitpid 会阻塞,直到任意一个子进程终止。每当子进程终止时,waitpid 的调用返回该子进程的非零 PID。第 16 行检查子进程的退出状态。如果子进程正常终止,即通过调用 exit 函数,在这种情况下,父进程提取退出状态并将其打印到 stdout。当所有子进程都被清理时,下一次 waitpid 的调用返回 -1,并将 errno 设置为 ECHILD。第 24 行检查 waitpid 函数是否正常终止,否则打印错误消息。当我们在 Linux 系统上运行该程序时,它产生以下输出:

linux> ./waitpid1
child 22966 terminated normally with exit status=100
child 22967 terminated normally with exit status=101

请注意,该程序以无特定顺序清理其子进程。它们被清理的顺序是特定计算机系统的属性。在另一个系统上,甚至在同一系统的另一次执行中,这两个子进程可能以相反的顺序被清理。这是关于并发推理的非确定性行为的一个例子。两种可能的结果都是正确的,作为程序员,你永远不能假设其中一种结果总是会发生,无论另一种结果看起来多么不太可能。唯一正确的假设是每种可能的结果同样有可能发生。
在这里插入图片描述
图 8.19 展示了一个简单的更改,通过以创建它们的父进程的相同顺序清理子进程来消除输出顺序上的不确定性。在第 11 行,父进程按顺序存储其子进程的 PID,然后通过在 waitpid 中使用适当的 PID 作为第一个参数来按照这个顺序等待每个子进程。

Putting Processes to Sleep

在这里插入图片描述
sleep 函数挂起一个进程,使其暂停执行一段指定的时间。

如果经过了请求的时间,sleep 返回零,否则返回仍需休眠的秒数。后一种情况可能发生在 sleep 函数由于被信号中断而提前返回。
在这里插入图片描述
pause 函数,它将调用它的进程挂起,直到该进程接收到一个信号。

Loading and Running Programs

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

execve 函数使用可执行目标文件 filename (executable object file filename)、参数列表 argv 和环境变量列表 envp 来加载和运行。只有在发生错误时,比如无法找到 filename,execve 才会返回给调用程序。因此,与 fork 被调用一次但返回两次不同,execve 被调用一次后就不再返回。
在这里插入图片描述
在这里插入图片描述
参数列表由图 8.20 中显示的数据结构表示。argv 变量指向一个以空指针结尾的指针数组,其中每个指针指向一个参数字符串。按照约定,argv[0] 是可执行目标文件的名称。
在这里插入图片描述
环境变量列表由类似的数据结构表示,如图 8.21 所示。envp 变量指向一个以空指针结尾的指向环境变量字符串的指针数组,其中每个字符串都是形如 name=value 的名称-值对。

在 execve 加载 filename 后,它调用了第 7.9 节中描述的启动代码。启动代码设置栈并将控制权传递给新程序的主程序,其原型形式为:
在这里插入图片描述
在这里插入图片描述
当 main 开始执行时,用户栈的组织如图 8.22 所示。从栈的底部(最高地址)到栈的顶部(最低地址)逐步进行解释。首先是参数和环境字符串。在栈上,这些字符串之上是一个以空指针结尾的指针数组,每个指针指向栈上的一个环境变量字符串。全局变量 environ 指向这些指针中的第一个,即 envp[0]。环境数组后面是以空指针结尾的 argv[] 数组,其中每个元素指向栈上的一个参数字符串。栈的顶部是系统启动函数 libc_start_main 的栈帧(第 7.9 节)。

函数 main 有三个参数,根据 x86-64 栈规则,它们分别存储在寄存器中:(1) argc,表示 argv[] 数组中非空指针的数量;(2) argv,指向 argv[] 数组中第一个条目;以及 (3) envp,指向 envp[] 数组中的第一个条目。

execve 函数在当前进程的上下文中加载并运行一个新程序,但它并不分配新的地址空间。相反,它用新程序的内容替换了当前进程的地址空间,将新程序加载到了当前进程的地址空间中。

具体来说,execve 函数负责加载新程序的可执行文件并覆盖当前进程的内存映像。这包括替换当前进程的代码段、数据段、堆、栈等。因此,新程序从当前进程的起始地址开始执行,而不是在一个新的地址空间中启动。

这个行为是有区别于 fork 系统调用的,fork 会创建一个新的进程,并在新进程的地址空间中复制父进程的内容,产生两个完全独立的进程。而 execve 则是将当前进程的内容替换为新程序的内容,因此在执行 execve 后,原始进程的状态被新程序的状态所取代。

总的来说,execve 不分配新的地址空间,而是加载新程序到当前进程的地址空间中。
在这里插入图片描述
Linux 提供了一些用于操作环境数组的函数:

getenv 函数搜索环境数组以查找字符串 name=value。如果找到,则返回指向 value 的指针;否则返回 NULL。

如果环境数组包含形式为 name=oldvalue 的字符串,则 unsetenv 删除它,而 setenv 仅在 overwrite 非零时用 newvalue 替换 oldvalue。如果 name 不存在,则 setenv 将 name=newvalue 添加到数组中。

举个例子,如果你有一个 C 语言的程序,其中使用了 getenv 函数,那么这个程序就会在其运行时的上下文中获取环境变量。这个程序可能是你自己编写的,也可能是一个第三方库或工具。无论是哪个程序,它都在运行时通过调用 getenv 函数来获取环境变量的值,这些值是在程序启动时由操作系统或其他配置设置的。

Using fork and execve to Run Programs

像Unix shell和Web服务器这样的程序大量使用forkexecve函数。Shell是一个交互式的应用程序级别程序,代表用户运行其他程序。最初的shell是sh程序,之后出现了变体,如csh、tcsh、ksh和bash。Shell执行一系列的读取/评估步骤,然后终止。读取步骤从用户处读取命令行。评估步骤解析命令行并代表用户运行程序。
在这里插入图片描述
图8.23显示了一个简单shell的主程序。Shell打印命令行提示符,等待用户在stdin上键入命令行,然后评估命令行。
在这里插入图片描述
图8.24显示了评估命令行的代码。其第一个任务是调用parseline函数(图8.25),该函数解析以空格分隔的命令行参数,并构建最终将传递给execve的argv向量。第一个参数被假定为要立即解释的内置shell命令的名称,或将在新的子进程上下文中加载和运行的可执行对象文件。
如果最后一个参数是’&'字符,则parseline返回1,表示应在后台执行程序(shell不等待其完成)。否则,它返回0,表示应在前台运行程序(shell等待其完成)。

如果 builtin_command 返回0,那么shell会创建一个子进程,并在子进程中执行请求的程序。如果用户要求在后台运行程序,那么shell会返回到循环的顶部,并等待下一个命令行。否则,shell会使用 waitpid 函数等待作业终止。当作业终止时,shell继续进行下一次迭代。

请注意,这个简单的shell存在一个缺陷,即它不会回收任何后台子进程。要纠正这个缺陷,需要使用信号,将在下一部分进行描述。
在这里插入图片描述
这段代码主要用于解析输入的命令行,将命令及其参数存储在一个参数数组中,并判断是否需要在后台运行。

operating system subroutine 介绍

“Operating system subroutine” 是指操作系统中的子程序(subroutine),用于执行特定的操作系统功能。这些子程序通常由操作系统提供,为应用程序和其他系统组件提供服务。以下是一些常见的操作系统子程序及其功能:

  1. 文件系统子程序:

    • 打开文件(Open File): 通过提供文件名和权限,打开文件以便读取或写入。
    • 关闭文件(Close File): 关闭先前打开的文件,释放相关资源。
  2. 进程控制子程序:

    • 创建进程(Create Process): 在操作系统中创建新的进程。
    • 终止进程(Terminate Process): 终止正在运行的进程。
  3. 内存管理子程序:

    • 分配内存(Allocate Memory): 为程序或进程分配内存空间。
    • 释放内存(Free Memory): 释放先前分配的内存空间。
  4. 设备管理子程序:

    • 打开设备(Open Device): 打开并准备使用设备。
    • 关闭设备(Close Device): 关闭设备,释放相关资源。
  5. 文件操作子程序:

    • 读文件(Read File): 从文件中读取数据。
    • 写文件(Write File): 向文件中写入数据。
  6. 进程间通信子程序:

    • 消息传递(Message Passing): 允许进程之间通过消息进行通信。
  7. 中断处理子程序:

    • 中断处理(Interrupt Handling): 处理硬件或软件中断,保障系统的正常运行。
  8. 安全性和权限子程序:

    • 权限检查(Permission Checking): 检查用户或进程是否有执行特定操作的权限。
    • 安全性控制(Security Control): 管理系统资源的访问权限,确保系统的安全性。

这些子程序是操作系统的核心组成部分,它们通过提供标准化的接口,使应用程序能够与底层硬件和系统资源进行交互。通过这些子程序,操作系统提供了一个抽象层,使得应用程序不需要直接处理底层硬件细节,从而更容易实现和维护。

exception table 介绍

“Exception table”(异常表)通常是指操作系统或程序中用于管理异常处理的数据结构。这个表格包含了处理程序或例外处理程序的地址,以及与之相关的异常类型或中断。当系统发生异常时,处理器会使用异常表中的信息来确定如何处理这个异常。

在操作系统和程序中,异常表的主要作用是提供一种机制来映射异常类型和相应的处理程序。以下是一些可能在异常表中包含的信息:

  1. 异常类型(Exception Type): 表明异常的类型,如缺页异常、除零异常等。

  2. 异常处理程序地址(Handler Address): 指定用于处理特定异常的代码的内存地址。

  3. 附加信息(Additional Information): 一些异常可能需要额外的信息,例如错误码、地址等。

在发生异常时,处理器会检查异常表以找到与异常类型对应的处理程序地址。然后,它会跳转到该地址执行相应的异常处理代码。这样的设计允许系统在异常发生时动态地调用适当的处理程序,从而更好地管理和处理不同类型的异常情况。

异常表在操作系统内核中的使用是为了确保系统能够适应各种错误和异常,提高系统的稳定性和可靠性。在用户程序中,异常表也可以用于定义自定义的异常处理逻辑,以增强程序的容错性。

exception handler 介绍

操作系统或者运行时系统的实现方式可能因系统架构和设计而异,但通常包括以下步骤:

  1. 异常触发: 异常可以由硬件、操作系统本身或者运行时系统检测到。硬件可能会在执行指令时检测到错误,或者操作系统在执行用户程序时发现异常情况。

  2. 中断向量表: 在计算机系统中,通常有一个中断向量表(Interrupt Vector Table),其中包含了不同中断或异常的处理程序的入口地址。当异常发生时,硬件或操作系统会根据异常类型查找相应的中断向量表,找到对应的异常处理程序地址。

  3. 保存上下文: 在调用异常处理程序之前,系统需要保存当前程序的上下文信息,包括寄存器的值、程序计数器的值等。这是为了在异常处理完成后能够正确地恢复程序的执行状态。

  4. 调用异常处理程序: 系统通过跳转到异常处理程序的地址,将控制权交给异常处理程序。这可能涉及到栈的操作,以便将当前上下文信息推送到栈上,为异常处理程序提供必要的信息。

  5. 执行异常处理逻辑: 异常处理程序执行与异常相关的逻辑,可能包括记录错误信息、尝试修复问题、向用户报告错误等。处理完成后,系统需要将控制权返回到原始程序的执行点。

  6. 恢复上下文: 在异常处理程序执行完成后,系统需要从保存的上下文信息中还原原始程序的状态,以便程序能够继续执行。这包括恢复寄存器的值、程序计数器的值等。

  7. 继续执行: 一旦上下文被还原,系统将控制权返回给原始程序,并允许其继续执行。

这是一个简化的异常处理过程的描述,具体实现可能会因操作系统、硬件架构和编程语言而异。在不同的系统和环境中,可能会有额外的步骤或特定的机制用于异常处理。

the memory-resident part of the operating system 介绍

操作系统的“内存驻留部分”是指在操作系统运行时始终存在于计算机内存(RAM)中的组件和数据结构。这些组件在管理系统资源、处理各种任务以及为应用程序提供服务方面发挥着关键作用。内存驻留部分负责维护操作系统的整体稳定性和功能。

操作系统内存驻留部分的关键要素包括:

  1. 内核: 内核是操作系统的核心,始终存在于内存中。它提供基本服务,如进程管理、内存管理、文件系统访问和设备驱动程序接口。内核负责直接与硬件交互,并协调各种系统进程的执行。

  2. 进程控制块(PCB): 系统中的每个活动进程都有一个相应的进程控制块,存储在内存中。PCB包含有关进程的信息,如其当前状态、寄存器值、程序计数器等重要数据。这使操作系统能够有效地管理和在进程之间切换。

  3. 设备驱动程序: 设备驱动程序是操作系统的模块,促进硬件设备与系统其余部分之间的通信。这些驱动程序保留在内存中,以处理应用程序的请求,并管理设备的输入/输出操作,如磁盘驱动器、打印机和网络接口等设备。

  4. 内存管理单元(MMU): MMU负责管理系统的内存,包括虚拟内存和物理内存。它确保为进程正确分配、释放和保护内存空间。MMU在将虚拟地址转换为物理地址方面发挥着关键作用。

  5. 中断处理程序: 中断处理程序是响应硬件或软件中断的例程。它们始终驻留在内存中,处理I/O中断、定时器中断或异常等事件。中断处理程序对于维持系统的响应性和处理意外事件至关重要。

  6. 系统调用接口: 系统调用接口提供了应用程序与内核之间的接口。系统调用允许应用程序请求操作系统提供的服务,如文件操作、进程创建和通信。系统调用接口始终驻留在内存中,以促进这些交互。

  7. 内核数据结构: 各种数据结构,如进程队列、文件表和页表,存储在内存中,支持内核的内部操作。这些数据结构有助于有效管理资源并跟踪有关系统状态的重要信息。

总体而言,操作系统的内存驻留部分对于系统的运行至关重要,因为它管理关键功能并在内存中维护必要的数据结构,以实现硬件和软件组件之间的无缝交互。

jump table

Jump table(跳转表)是一种数据结构,通常用于实现多分支的控制流程,提高程序的执行效率。跳转表是一个数组,其中每个元素都包含一个指针或跳转地址,用于执行特定的代码块或子程序。它通常用于替代长的条件语句或开关语句,以提高代码的可读性和执行效率。

跳转表的基本思想是将多个分支的目标地址存储在一个数组中,然后根据某个条件的值作为索引来直接跳转到相应的目标地址。这样,无需逐个比较条件,而是通过数组索引直接访问所需的代码块,从而减少了执行时间。

以下是跳转表的基本工作原理:

  1. 创建表: 将多个分支的目标地址按照条件值的范围或索引顺序存储在一个数组中。

  2. 确定条件值: 根据程序执行时的某个条件值,例如一个变量或计算的结果。

  3. 查找跳转地址: 使用条件值作为索引,直接从跳转表中获取相应的目标地址。

  4. 跳转: 将程序的控制流直接跳转到获取的目标地址,执行相应的代码块或子程序。

跳转表的优点包括:

  • 执行效率: 跳转表可以显著提高程序的执行效率,特别是在具有多个分支的情况下。通过直接跳转到目标地址,避免了逐个比较条件的开销。

  • 可读性: 使用跳转表可以使代码更加简洁和易读,特别是当存在大量分支时,避免了冗长的条件语句。

  • 维护性: 跳转表的结构使得添加、删除或修改分支更加容易,只需更新跳转表而无需修改条件语句。

但是,跳转表并不适用于所有情况,特别是当条件值的范围非常大或不规则时,可能会导致跳转表的尺寸过大或不便于维护。因此,使用跳转表时需要根据具体情况进行权衡和考虑。

函数指针数组

是的,函数指针数组是一个数组,其元素都是函数指针。函数指针数组的概念:

  1. 函数指针: 在C语言中,函数指针是指向函数的指针变量。函数指针可以用来存储函数的地址,从而允许通过函数指针调用相应的函数。

  2. 函数指针数组: 将函数指针放置到数组中,形成一个函数指针数组。数组的每个元素都是一个函数指针,指向特定类型和返回类型的函数。

下面是一个更简单的例子,说明如何声明和使用函数指针数组:

#include <stdio.h>

// 定义两个函数,它们的类型相同:void func()
void funcA() {
    printf("Function A\n");
}

void funcB() {
    printf("Function B\n");
}

int main() {
    // 声明一个函数指针数组,每个元素都是指向返回类型为void的函数指针
    void (*functionPointers[2])();

    // 初始化函数指针数组的元素,分别指向funcA和funcB
    functionPointers[0] = funcA;
    functionPointers[1] = funcB;

    // 调用函数指针数组的第一个元素,即调用funcA
    functionPointers[0]();

    // 调用函数指针数组的第二个元素,即调用funcB
    functionPointers[1]();

    return 0;
}

在这个例子中,functionPointers 是一个包含两个元素的函数指针数组。每个元素都是一个指向返回类型为 void 的函数指针。我们将数组的第一个元素指向 funcA,第二个元素指向 funcB。然后通过数组索引调用相应的函数。

在实际的应用中,函数指针数组常用于建立函数调用的映射表,或者实现类似于状态机的机制,其中根据特定的条件调用不同的函数。

函数指针

函数指针的语法可以看起来比较复杂,但我们可以逐步解释。以下是函数指针的基本语法:

  1. 声明函数指针:

    returnType (*pointerName)(parameterTypes);
    
    • returnType:函数的返回类型。
    • pointerName:函数指针的名称。
    • parameterTypes:函数的参数类型。

    例如,如果有一个返回 int 类型,接受两个 int 参数的函数,对应的函数指针声明如下:

    int (*addPointer)(int, int);
    
  2. 初始化函数指针:

    addPointer = &addFunction; // 或者 addPointer = addFunction;
    

    这里假设存在一个名为 addFunction 的函数,其类型符合 int addFunction(int, int);。我们将函数指针 addPointer 初始化为指向 addFunction 函数的地址。

    注意:在初始化时,& 操作符通常是可选的。

  3. 使用函数指针调用函数:

    result = (*addPointer)(a, b); // 或者 result = addPointer(a, b);
    

    通过函数指针调用函数的方式类似于直接调用函数,但要在函数指针前面加上 * 操作符。也可以省略 * 操作符,直接使用函数指针名称调用函数。

以下是一个完整的例子:

#include <stdio.h>

int add(int a, int b) {
    return a + b;
}

int subtract(int a, int b) {
    return a - b;
}

int main() {
    int (*operation)(int, int); // 声明函数指针

    operation = add; // 初始化函数指针,指向add函数
    printf("Addition: %d\n", operation(5, 3));

    operation = subtract; // 初始化函数指针,指向subtract函数
    printf("Subtraction: %d\n", operation(5, 3));

    return 0;
}

这个例子中,operation 是一个函数指针,可以根据需要指向不同的函数。在不同的初始化和调用过程中,我们可以方便地切换函数指针的目标函数。

indirect procedure call 介绍

“Indirect procedure call”(间接过程调用)通常指的是通过函数指针或函数引用来调用函数,而不是直接使用函数名称。这种方法允许在运行时动态地确定要调用的函数,从而增加了程序的灵活性。这在编程语言中是一种常见的技术,特别是在支持函数指针或函数引用的语言中。

通过间接过程调用,可以在运行时根据条件或其他因素决定调用哪个函数。这对于实现回调机制、事件处理、插件系统等应用场景非常有用。

以下是一个简单的C语言示例,演示了通过函数指针进行间接过程调用:

#include <stdio.h>

// 定义两个函数
void add(int a, int b) {
    printf("Sum: %d\n", a + b);
}

void subtract(int a, int b) {
    printf("Difference: %d\n", a - b);
}

int main() {
    // 定义一个函数指针
    void (*operation)(int, int);

    // 根据条件设置函数指针
    int choice = 1; // 1 表示加法,2 表示减法
    if (choice == 1) {
        operation = add;
    } else {
        operation = subtract;
    }

    // 通过函数指针进行间接过程调用
    operation(8, 3);

    return 0;
}

在这个例子中,operation 是一个函数指针,根据条件选择指向 add 还是 subtract 函数。然后,通过调用 operation 函数指针,可以间接调用相应的函数。

函数指针或函数引用来调用函数,和 直接使用函数名称调用函数的区别?

函数指针或函数引用来调用函数与直接使用函数名称调用函数之间有一些重要的区别。以下是它们之间的主要区别:

  1. 动态性和灵活性:

    • 函数指针或引用: 使用函数指针或函数引用调用函数时,可以在运行时动态地确定要调用的函数。这使得在程序执行过程中能够根据条件或其他因素选择不同的函数,增加了程序的灵活性。可以通过改变指针或引用指向的函数来改变调用的函数。
    • 直接使用函数名称: 直接使用函数名称调用函数是静态的,编译时确定的。函数的调用在编译阶段就已经确定了,无法在运行时更改调用的函数。
  2. 回调和事件处理:

    • 函数指针或引用: 这种机制常用于实现回调函数,事件处理等场景。通过将函数指针传递给其他函数,可以在特定事件发生时调用相应的函数。
    • 直接使用函数名称: 不太适用于回调和事件处理,因为在编译时就需要确定调用的函数。
  3. 多态性:

    • 函数指针或引用: 允许实现多态性,即在运行时选择调用不同的函数,实现不同的行为。
    • 直接使用函数名称: 静态函数调用不支持多态性,因为在编译时已经确定了调用哪个函数。
  4. 函数指针的声明和定义:

    • 函数指针或引用: 需要显式声明和定义函数指针,并将其与具体的函数绑定。
    • 直接使用函数名称: 函数的声明和定义在函数的原型或实现中进行,而调用时只需使用函数名称即可。

示例(使用C语言):

// 使用函数指针调用函数
void add(int a, int b) {
    printf("Sum: %d\n", a + b);
}

int main() {
    void (*operation)(int, int);  // 函数指针的声明

    operation = add;  // 函数指针与具体函数绑定

    operation(8, 3);  // 通过函数指针调用函数

    return 0;
}

总的来说,函数指针或函数引用提供了更大的灵活性和动态性,允许在运行时根据需要选择调用的函数,而直接使用函数名称则是静态的、在编译时确定的。

kernel routione 介绍

在计算机科学和操作系统领域,“kernel routine”(内核例程)是指操作系统内核中的一个特定函数或子程序,负责执行特定的系统级任务。内核例程通常是由操作系统开发者编写的,用于实现操作系统的核心功能和服务。

这些例程通常包括对硬件设备的访问、进程管理、内存管理、文件系统操作等。内核例程是操作系统提供的接口,用于让用户程序或应用程序能够通过系统调用请求操作系统提供的服务。

在上述文本中提到的情境中,系统调用引发一个陷阱,将控制权传递给一个异常处理程序,然后该异常处理程序调用相应的内核例程来执行用户请求的服务,例如读取文件、创建进程等。内核例程在内核模式下运行,具有访问系统资源和执行特权指令的权限,以确保对核心系统功能的有效管理。

函数参数

在函数调用中,函数参数的传递方式可以包括将参数存储在寄存器中或将其放置在堆栈上。这取决于编程语言、编译器和目标硬件架构。

  1. 参数放置在寄存器中:

    • 在某些情况下,函数调用可以使用寄存器来传递参数,这通常可以提高函数调用的性能。寄存器是处理器内部的临时存储位置,可以更快地访问。
    • 不同的体系结构和编译器可能对于哪些寄存器用于参数传递有不同的规定。例如,x86体系结构上的函数调用约定可能会使用一些通用寄存器(如eax、ebx、ecx、edx等)来传递参数。
  2. 参数放置在堆栈中:

    • 在其他情况下,特别是当参数数量超过寄存器的数量时,参数可能被放置在堆栈上。堆栈是一个内存区域,用于存储临时数据和函数调用的上下文信息。
    • 对于大型结构或数组等较大的参数,通常会选择在堆栈上传递指向该数据的指针,而不是将整个数据复制到寄存器中。

具体使用哪种方式通常由编译器的实现和目标硬件的体系结构规定。在某些情况下,编译器可能会根据参数的数量和类型选择合适的传递方式。对于一些常见的体系结构和编译器,有一些通用的函数调用约定,但这也可能因体系结构而异。

总的来说,参数是可以存储在寄存器中或堆栈上的,具体取决于编译器和目标硬件的实现。

fork汇编代码实现

在x86架构上,fork()系统调用的具体实现涉及复杂的内核操作和数据结构的处理。由于内核的实现在不同的操作系统中会有很大的不同,以下是一个简化的示例,展示了fork()在x86 Linux内核上的一个可能的汇编代码实现。

section .data
    ; 数据段

section .text
    global _start

_start:
    ; 进行 fork 系统调用
    mov eax, 20        ; fork 系统调用号是 20(在 x86 上,这个号码可能会有所不同)
    int 0x80            ; 调用中断,触发内核执行系统调用

    ; eax 中包含返回值,0 表示子进程,子进程会执行不同的代码块,非零表示父进程
    cmp eax, 0
    je child_process

parent_process:
    ; 父进程的代码块
    ; ...

    jmp end_program

child_process:
    ; 子进程的代码块
    ; ...

end_program:
    ; 程序的其他部分
    ; ...

section .bss
    ; 未初始化数据段

上述汇编代码使用了 x86 Linux 的系统调用约定(通过int 0x80触发系统调用)。请注意,实际的系统调用号可能因操作系统和架构而异。此外,这只是一个概念示例,实际的fork()实现涉及更多的内核细节,如页面表的管理、文件描述符的复制等。

要真正理解fork()系统调用的实现,您可能需要查看特定操作系统的内核源代码。在Linux上,可以在内核源代码中找到fork()的具体实现。

libc_start_main 函数

libc_start_main 函数是在程序启动时由 C 库(libc)提供的启动代码的入口点。它的作用是设置程序的运行环境,准备 C 运行时环境,并最终调用程序的 main 函数。在一个典型的 C 程序中,libc_start_main 函数的调用是由操作系统加载可执行文件时自动执行的,不需要程序员显式调用。

以下是 libc_start_main 函数的一般形式:

int __libc_start_main(
    int (*main)(int, char**, char**),
    int argc,
    char **argv,
    void (*init)(void),
    void (*fini)(void),
    void (*rtld_fini)(void),
    void *stack_end
);

参数说明:

  • main: 指向程序主函数的指针。
  • argc: 命令行参数的数量。
  • argv: 指向命令行参数数组的指针。
  • init: 指向初始化函数的指针,该函数在 main 函数之前执行。
  • fini: 指向终止函数的指针,该函数在 main 函数之后执行。
  • rtld_fini: 指向运行时链接器终止函数的指针。
  • stack_end: 栈的结束地址。

在程序启动时,libc_start_main 的任务主要包括:

  1. 初始化 C 运行时环境,包括设置标准输入输出流、处理环境变量等。
  2. 调用 init 函数,执行一些额外的初始化工作。
  3. 调用 main 函数,开始执行程序的主体逻辑。
  4. main 函数返回后,调用 fini 函数,执行一些清理工作。
  5. 最终退出程序。

需要注意的是,libc_start_main 的具体实现可能因操作系统和编译器而异。上述参数和功能可能在不同系统和编译器中略有不同。

execve 函数和fork函数的区别?

execve 函数和 fork 函数都是用于创建新进程,但它们有不同的作用和行为。以下是它们的主要区别:

  1. 目的和作用:

    • execve 用于在当前进程的上下文中加载并运行一个新程序。它会替换当前进程的内存映像,将新程序加载到当前进程的地址空间中,并开始执行新程序的代码。
    • fork 用于创建一个新的进程,该新进程是调用进程的副本。新进程的内存映像是调用进程的副本,但两个进程是独立的,有不同的进程标识符(PID)。fork 创建了一个父进程和一个子进程,它们可以并行执行不同的代码。
  2. 返回值:

    • execve 只有在出现错误时才返回,如果加载新程序成功,则不会返回到调用点,而是直接开始执行新程序的代码。
    • fork 在父程中返回子进程的PID,在子进程中返回0。如果发生错误,则返回一个负值。
  3. 创建的进程数量:

    • execve 仅创建一个进程,即当前进程被新程序替代。
    • fork 创建两个进程,父进程和子进程。这两个进程在调用 fork 之后执行相同的代码,然后可以通过条件语句来区分执行不同的逻辑。
  4. 地址空间:

    • execve 不分配新的地址空间,而是用新程序的内容替换当前进程的地址空间。
    • fork 创建一个新进程,该新进程是调用进程的副本,包括代码、数据和堆栈。这两个进程在执行后可以独立修改它们的地址空间,互不影响。

总体而言,execve 用于替换当前进程的内容,而 fork 用于创建一个新的独立进程。这两者通常一起使用,例如在调用 fork 后,子进程可以使用 execve 来加载一个新程序。

指向字符数组的指针

指向字符数组的指针可以通过使用 char * 类型的指针来表示。在C语言中,字符串通常被表示为字符数组,而指向字符串的指针就是指向这个字符数组的指针。

以下是一个简单的例子,演示了指向字符数组的指针的声明和使用:

#include <stdio.h>

int main() {
    // 声明一个字符数组
    char myString[] = "Hello, World!";

    // 声明一个指向字符数组的指针
    char *ptrToString;

    // 将指针指向字符数组
    ptrToString = myString;

    // 使用指针访问字符数组的内容并打印
    while (*ptrToString != '\0') {
        printf("%c", *ptrToString);
        ptrToString++;
    }

    return 0;
}

在上面的例子中,ptrToString 是一个指向字符数组的指针,它被设置为指向 myString 字符数组的起始位置。然后,通过使用指针,我们可以访问字符数组的每个字符并打印它们,直到遇到字符串的结束标志 \0

字符指针数组

字符指针数组通常用于存储一组字符串,每个字符串都是一个字符数组。以下是一个简单的例子,演示了字符指针数组的声明和使用:

#include <stdio.h>

int main() {
    // 声明一个字符指针数组,存储三个字符串
    char *names[] = {"Alice", "Bob", "Charlie"};

    // 使用循环打印数组中的每个字符串
    for (int i = 0; i < 3; i++) {
        printf("Name %d: %s\n", i + 1, names[i]);
    }

    return 0;
}

在这个例子中,names 是一个字符指针数组,每个元素都是一个指向字符数组的指针。每个字符串都是通过字符串常量初始化的,但你也可以将它们初始化为动态分配的字符数组或其他方式创建的字符串。

通过循环遍历数组,我们可以访问并打印数组中的每个字符串。这种表示方式在处理字符串数组时非常常见,尤其是在处理命令行参数时。

shell 前台和后台的区别?

在操作系统中,前台和后台是与进程执行和用户交互相关的两个概念。

  1. 前台进程(Foreground Process):

    • 交互性高: 前台进程通常是用户当前正在与之交互的进程。它接收用户输入,并将输出直接显示在终端上。
    • 阻塞终端: 当前台进程运行时,通常会阻塞终端,使得用户无法在同一终端中执行其他任务,直到该进程执行完毕或被挂起。
  2. 后台进程(Background Process):

    • 非交互性: 后台进程在后台执行,不与用户直接交互。用户可以继续在终端中输入其他命令,而不必等待后台进程完成。
    • 不阻塞终端: 后台进程的执行不会阻塞终端,用户可以继续在终端中输入命令,而后台任务在后台默默执行。

在shell中,可以通过在命令末尾添加 & 符号将一个命令放入后台执行。例如:

$ my_command &   # 将 my_command 放入后台执行

用户可以使用 fg 命令将一个后台任务切换到前台执行,或使用 bg 命令将一个前台任务切换到后台执行。此外,可以使用 jobs 命令查看当前终端中正在执行或挂起的任务。

  • 18
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Proxy(代理)是一种在计算机网络中广泛应用的中间服务器,用于连接客户端和目标服务器之间的通信。Proxy csapp是一个与计算机系统应用(Computer Systems: A Programmer's Perspective)相关的代理服务器。 Proxy csapp的设计目的是为了提供更高效的网络通信,增强系统的安全性,并提供更好的用户体验。在Proxy csapp中,客户端的请求首先会被发送到代理服务器,然后由代理服务器转发给目标服务器,并将目标服务器的响应返回给客户端。这种中间层的机制可以提供很多功能,如缓存、负载均衡、安全认证等。 在csapp中,Proxy csapp可以被用于优化网络数据传输的效率。代理服务器可以对客户端请求进行调度和协商,以减少网络延迟和数据传输量。通过缓存常用的数据和资源,代理服务器可以减少重复的数据传输和目标服务器的负载,提高网络性能和响应速度。 此外,Proxy csapp还可以提供安全的网络通信环境。代理服务器可以拦截和过滤网络流量,用于检测和阻止恶意攻击、垃圾邮件等网络安全威胁。代理服务器还可以对用户进行身份验证和授权,保护敏感数据的安全性。 最后,通过Proxy csapp可以实现更好的用户体验。代理服务器可以根据用户的需求进行个性化的服务,如按地理位置提供更快的网络连接、提供访问限制和控制等。代理服务器还可以对网络流量进行压缩和优化,提高网络传输效率,减少用户的等待时间。 总之,Proxy csapp在计算机系统应用中是一个重要的代理服务器,它可以提供高效的网络通信、增强系统的安全性,并带来更好的用户体验。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值