《深入理解计算机系统》
前言
重温了下《深入理解计算机系统》这本书,有了些收获。
可执行文件生成的过程
以 “hello world” 程序为例, hello 程序的生命周期是从一个高级 C 语言程序开始的,因为这种形式能够被人读懂。然而,为了在系统上运行 hello.c 程序,每条 C 语句都必须被其他程序转化为一系列的低级机器语言指令。然后这些指令按照一种称为 “可执行目标程序” 的格式打好包,并以二进制磁盘文件的形式存放起来。目标程序也称为 “可执行目标文件” 。
在 linux 上 ,从源文件到目标文件的转化是由编译器驱动程序完成的:
gcc hello.c -o hello
这个过程可以分为四个阶段:
1.预处理阶段。预处理器(cpp)根据以字符 # 开头的命令,修改原始的 C 程序。比如 hello.c 中第一行的 #include<stdio.h> 命令告诉预处理器读取系统头文件 stdio.h 的内容,并把它直接插入程序文本中。结果就得到了另一个 C 程序,通常是以 .i 作为文件扩展名。
2.编译阶段。编译器(ccl)将文本文件 hello.i 翻译成文本文件 hello.s,它是个汇编语言程序。
3.汇编阶段。汇编器(as)将 hello.s 翻译成机器语言指令,把这些指令打包成一种叫做 “可重定位目标程序” 的格式,并将结果保存在目标文件 hello.o 中。
4.链接阶段。hello 程序调用了 printf 函数,它是每个 C 编译器都提供的标准 C 库中的一个函数。 printf 函数存在于一个名为 printf.o 的单独的预编译好了的目标文件中,而这个文件必须以某种方式合并到我们的 hello.o 程序中。链接器(ld)就负责处理这种合并。结果就得到了 hello 文件,它是一个 “可执行目标文件”/“可执行文件” ,可以被加载到内存中,由系统执行。
shell
想要在 linux 系统上运行该可执行文件,我们将它的文件名输入到称为 shell 的应用程序中:
linux> ./hello
hello,world
linux>
shell 是一个命令行解释器,它输出一个提示符,等待输入一个命令行,然后执行这个命令。如果该命令行的第一个单词不是一个内置的 shell 命令,那么 shell 就会假设这是一个可执行文件的名字,它将加载并运行这个文件。所以在此例中, shell 将加载并运行 hello 程序,然后等待程序终止。 hello 程序在屏幕上输出它的消息,然后终止。 shell 随后输出一个提示符,等待下一个输入的命令行。
操作系统实现功能的方式
操作系统有两个基本功能:
1.防止硬件被失控的应用程序滥用。
2.向应用程序提供简单一致的机制来控制复杂而又通常大不相同的低级硬件设备。操作系统通过几个基本的抽象概念(进程、虚拟内存和文件)来实现这两个功能。
简单来说,文件是对 I/O 设备的抽象表示,虚拟内存是对主存和磁盘 I/O 设备的抽象表示,进程则是对处理器、主存和 I/O 设备的抽象表示。
进程
进程是操作系统对于一个正在运行的程序的一种抽象。在一个系统上可以同时运行多个程序,而每个进程都好像在独占地使用硬件。而并发运行,则是说一个进程的指令和另一个进程的指令是交错执行的,这是通过处理器在进程间切换实现的。操作系统实现这种交错执行的机制称为上下文切换。
操作系统保持跟踪进程运行所需的所有状态信息。这种状态,也就是上下文,包括许多信息,比如 PC 和寄存器文件的当前值,以及主存的内容。在任何一个时刻,单处理器系统都只能执行一个进程代码。当操作系统决定要把控制权从当前进程转移到某个新进程时,就会进行上下文切换,即保存当前进程的上下文、恢复新进程的上下文,然后将控制权传递到新进程。新进程就会从它上次停止的地方开始。
从一个进程到另一个进程的转换是由操作系统内核管理的。内核是操作系统代码常驻主存的部分。当应用程序需要操作系统的某些操作时,比如读写文件,他就执行一条特殊的系统调用指令(system call),将控制权传递给内核。然后内核执行被请求的操作并返回应用程序。注意,内核不是一个独立的进程。相反,他是系统管理全部进程所用代码和数据结构的集合。
线程
尽管通常我们认为一个进程只有单一的控制流,但是在现代系统中,一个进程实际上可以由多个称为线程的执行单元组成,每个线程都运行在进程的上下文中,并共享同样的代码和全局数据。
虚拟内存
虚拟内存是一个抽象概念,它为每个进程提供了一个假象,即每个进程都在独占地使用主存。每个进程看到的内存都是一致的,称为虚拟地址空间。每个进程由大量准确定义的区构成,每个区都有专门的功能。大致如下:
1.程序代码和数据。对于所有进程来说,代码是从同一固定地址开始,紧接着的时和 C 全局变量相对应的数据位置。代码和数据区时直接按照可执行目标文件初始化的。
2.堆。代码和数据区后紧随着的时运行时堆。代码和数据区在进程一开始运行时就被制定了大小,与此不同,当调用向 malloc 和 free 这样 C 标准库函数时,堆可以在运行时动态地扩展和收缩。
3.共享库。大约在地址空间的中间部分是一块用来存放像 C 标准库和数学库这样的共享库的代码和数据区域。
4.栈。位于用户虚拟地址空间顶部的是用户栈,编译器用它来实现函数调用。和堆一样,用户栈在程序执行期间可以动态扩展和收缩。特别地,每次我们调用一个函数时,栈就会增长;从一个函数返回时,栈就会收缩。
5.内核虚拟内存。地址空间顶部的区域时为内核保留的。不允许应用程序读写这个区域的内容或者直接调用内核代码定义函数。相反,它们必须调用内核来执行这些操作。
文件
文件就是字节序列,仅此而。每个 I/O 设备,包括磁盘、键盘、显示器,甚至网络,都可以看成时文件。系统中的所有输入输出都是通过使用一小组称为 Unix I/O 的系统函数调用读写文件来实现的。
文件这个简单而精致的概念时非常强大的,因为它向应用程序提供了一个统一的视图,来看待系统中可能含有的所有各式各样的 I/O 设备。
链接的定义
链接(linking)是将各种代码和数据片段收集并组合称为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时,也就是在源代码被翻译成机器码时;也可以执行于加载时,也就是程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。
静态链接
像 Linux LD 程序这样的静态链接器(static linker)以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的、可以加载和运行的可执行目标文件作为输出。输入的可重定位目标文件由各种不同的代码和数据节(section)组成,每一节都是一个连续的字节序列。指令在这一节中,初始化了的全局变量在另一节中,而未初始化的变量又在另外一节中。
为构造可执行文件,连接器必须完成两个主要任务:
1.符号解析(symbols resolution)。目标文件定义和引用符号,每个符号对应于一个函数、一个全局变量或一个静态变量。符号解析的目的是将每个符号引用正好和一个符号定义关联起来。
2.重定位(relocation)。编译器和汇编器生成从地址 0 开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。链接器使用汇编器产生的重定位条目(relocation entry)的详细指令,不加甄别地执行这样的重定位。
目标文件
目标文件有三种形式:
1.可重定位目标文件。包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。
2.可执行目标文件。包含二进制代码和数据,其形式可以被直接复制到内存并执行。
3.共享目标文件。一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载进内存并链接。
编译器和汇编器生成可重定位目标文件(包括共享目标文件)。链接器生成可执行目标文件。
符号和符号表
每个可重定位目标模块 m 都有一个符号表,它包含 m 定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:
1.由模块 m 定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的 C 函数和全局变量。
2.由其他模块定义并被模块 m 引用的全局符号。这些符号称为外部符号,对应于在其他模块中定义的非静态 C 函数和全局变量。
3.只被模块 m 定义和引用的局部符号。它们对应于带 static 属性的 C 函数和全局变量。这些符号在模块 m 中任何位置都可见,但是不能被其他模块所引用。
符号解析
链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来。对那些和引用定义在相同模块中的局部符号的引用,符号解析是非常简单明了的。编译器只允许每个模块中每个局部符号有一个定义。静态局部变量也会有本地链接器符号,编译器还要确保它们拥有唯一的名字。
不过,对全局符号的引用解析就棘手得多。当编译器遇到一个不是当前模块中定义的符号(变量或函数名)时,会假设该符号是在某个模块中定义的,生成一个链接器符号表条目,并把它交给链接器处理。如果链接器在它的任何输入模块中都找不到这个被引用符号的定义,就输出一条错误信息并终止。
重定位
一旦链接器完成了符号解析这一步,就把代码中的每个符号引用和正好一个符号定义(即它的一个输入目标模块中的一个符号表条目)关联起来。此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。现在就可以开始重定位步骤了,在这个步骤中,将合并输入模块,并为每个符号分配运行时地址。重定位由两步合成:
1.重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为统一类型的新的聚合节。例如,来自所有输入模块的 .data 节被全部合并成一个节,这个节成为输出的可执行目标文件的 .data 节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
2.重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中称为重定位条目(relocation entry)的数据结构。
重定位条目
当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在内存中的什么位置它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用,他就会生成一个重定位条目,告诉链接器在将目标文件合成可执行文件时如何修改这个引用。代码的重定位条目放在 .rel.text 中。已初始化的数据的重定位条目放在 .rel.data 中。
位置无关代码
可以加载而无需重定位的代码称为位置无关代码(Position-Independent Code,PIC)。用户对 GCC 使用 -fpic 选项指示 GNU 编译系统生成 PIC 代码。共享库的编译必须总是使用该选项。
在一个 x86-64 系统中,对同一个目标模块中符号的引用是不需要特殊处理使之成为 PIC。可以用 PC 相对寻址来编译这些引用,构造目标文件时由静态链接器重定位。然而,对共享模块定义的外部过程和对全局变量的引用需要一些特殊技巧。
无论我们在内存的何处加载一个目标模块,数据段于代码段的距离重视保持不变。因此,代码段中任何指令和数据段中任何变量直接的距离都是一个运行时常量,与代码段和数据段的绝对内存位置无关的。编译器利用了这个事实来成成全局变量 PIC 的引用,它在数据段开始的地方创建了一个表,叫做全局变量偏移表(Global Offset Table,GOT)。在 GOT 中,每个被这个目标模块引用的全局数据目标(过程或全局变量)都有一个 8 字节的条目。编译器还为 GOT 中每个条目生成一个重定位记录。在加载时,动态链接器会重定位 GOT 中的每个条目,使得它包含目标的正确的绝对地址。每个引用全局目标的目标模块都有自己的 GOT 。
延迟绑定
使用延迟绑定的动机是对于像 libc.so 这样的共享库输出的成百上千个函数中,一个典型的应用程序只会使用其中很少的一部分。把函数地址的解析推迟到它实际被调用的地方,能避免动态链接器在加载时进行成百上千个其实不需要的重定位。
延迟绑定时通过两个数据结构之间简洁但又有些复杂的交互来实现的,这两个数据结构是: GOT 和 过程链接表(Procedure Linkage Table, PLT)。如果一个目标模块定义在共享库中的任何函数,那么它就有自己的 GOT 和 PLT 。GOT 是数据段的一部分,而 PLT 是代码段的一部分。
1.过程链接表(PLT)。PLT 是一个数组,其中每个条目是 16 字节代码。PLT[0] 是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的 PLT 条目。每个条目都负责调用一个具体的函数。PLT[1] 调用系统启动函数(__libc_start_main),它初始化执行坏境,调用 main 函数并处理其返回值。从 PLT[2] 开始的条目调用用户代码调用的函数。
2.全局偏移量表(GOT)。GOT 是一个数组,其中每个条目是 8 字节地址。和 PLT 联合使用时,GOT[0] 和 GOT[1] 包含动态链接器在解析函数地址时会使用的信息。GOT[2] 是动态链接器在 ld-linux.so 模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要运行时被解析。每个条目都有一个相匹配的 PLT 条目。
在函数第一次被调用时,GOT 和 PLT 协同延迟解析它的运行地址:
1.不直接调用函数,程序调用进入 PLT[n] ,是函数的 PLT 条目。
2.第一条 PLT 指令通过 &GOT[n+2] (函数的 GOT 条目)进行间接跳转。因为每个 GOT 条目初始时都指向它对应的 PLT 条目的第二条指令,这个简介跳转只是简单地把控制传送回 PLT[n] 中的下一条指令。
3.把函数的 ID 压入栈中,PLT[2] 跳转到 PLT[0]。
4.PLT[0] 通过 GOT[1] 间接地把动态链接器的一个参数压入栈中,然后通过 GOT[2] 间接跳转进动态链接器中。动态链接器使用两个栈条目来确定函数的运行时位置,用这个地址重写 GOT[n+3] ,再把控制传递给函数。
到这里函数的重定位就已经完成,下次再调用函数时可以直接跳转到 GOT[n+3] 中的地址执行函数。
异常控制流
每次从某个指令的地址到另一个指令的地址的过渡称为控制转移(control transfer)。多个这样的过渡就形成了控制转移序列,也就是处理器的控制流。
系统必须能够对系统状态变化做出反应(如子进程终止时,创造这些子进程的父进程必须得到通知),现代系统通过使控制流发生突变来对这些情况做出反应。我们把这些突变称为异常控制流(Exceptional Contronl Flow ,ECF)。
异常
异常就是控制流中的突变,用来响应处理器状态中的某些变化。当处理器发生了一个重要变化时,处理器正在执行某个当前指令 Icurr 。在处理器中,状态编码为不同的位和信号。状态变化称为事件。事件可能和当前指令的执行直接相关。比如,发生虚拟内存缺页、算术溢出,或者一条指令试图除以 0。另一方面,事件也可能和当前指令的执行没有关系。比如,一个系统定时器产生一个信号或者一个 I/O 请求完成。
在任何情况下,当处理器检测到有时间发生时,它就会通过一张叫做异常表(exception table)的跳转表,进行一个间接的过程调用,到一个专门设计用来处理这类事件的操作系统子程序(异常处理程序(exception handler))。当异常处理程序完成处理以后,根据引起异常的事件的类型,会发生以下三种情况中的一种:
1.处理程序将控制返回给当前指令 Icurr,即当事件发生时正在执行的指令。
2.处理程序将控制返回给 Inext,如果没有发生异常将会执行的下一条指令。
3.处理程序终止被中断的程序。
异常类别
异常可以分为四类:
1.中断。中断是异步发生的,是来自处理器外部的 I/O 设备的信号的结果。
2.陷阱。陷阱是有意的异常,是执行一条指令的结果。陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。
3.故障。故障由错误情况引起,他可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误情况,它将控制返回到引起故障的指令,从而重新执行它。否则,处理程序返回内核中的 abort 例程,abort 例程会终止引起故障的应用程序。缺页异常就是经典的故障。
4.终止。终止是不可恢复的致命错误造成的结果,通常是一些硬件错误。
进程
在现代系统上运行一个程序时,我们会得到一个假象,就好像我们的程序是系统中当前运行的唯一的程序一样。我们的程序好像是独占地使用处理器和内存。处理器就好像是无间断地一条接一条地执行我们程序中的指令。最后,我们程序中的代码和数据好像是系统内存中唯一的对象。这些假象都是通过进程的概念提供给我们的。
进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
每次用户通过向 shell 输入一个可执行目标文件的名字,运行程序时,shell 就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码和其他应用程序。
信号
Linux 信号是一种更高层的软件形式的异常,它允许进程和内核中断其他进程。一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。每种型号类型都对应某种系统事件。底层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程是不可见的。信号提供了一种机制,通知用户进程发生了这些异常。
物理空间和虚拟寻址
计算机系统的主存被组织成一个由 M 个连续的字节大小的单元组成的数组。每个字节都有一个唯一的物理地址。第一个字节的地址为 0,接下来的字节地址为 1,再下一个为 2,依此类推。给定这种简单的结构,CPU 访问内存最自然的方式就是使用物理地址。我们把这种方式称为物理寻址。
现代处理器使用的是一种称为虚拟寻址的寻址形式。使用虚拟寻址,CPU 通过生成一个虚拟地址来访问主存,这个虚拟地址在被送到内存之前先转换成适当的物理地址。将一个虚拟地址转换为物理地址的任务叫做地址翻译。就像异常处理一样,地址翻译需要 CPU 硬件和操作系统之间的紧密合作。CPU 芯片上叫做内存管理单元的专用硬件,利用存放在主存中的查询表来动态翻译虚拟地址,该表的内容由操作系统管理。
虚拟内存作为缓存的工具
概念上而言,虚拟内存被组织为一个由存放在磁盘上的 N 个连续的字节大小的单元组成的数组。每个字节都有一个唯一的虚拟地址,作为到数组的索引。磁盘上的内容被缓存在主存中。和存储器结构中其他缓存一样,磁盘上的数据被分割成块,这些块作为磁盘和主存之间的传输单元。VM 系统通过将虚拟内存分割为称为虚拟页(VP)的大小固定的块来处理这个问题。每个虚拟页的大小为 P = 2^p 字节。类似地,物理内存被分割为物理页,大小也为 P 字节。
在任意时刻,虚拟页面的集合都分为三个不相交的子集:
1.未分配的:VM 系统还未分配(或者创建)的页。未分配的块没有任何数据和它们关联,因此也就不占用任何磁盘空间。
2.缓存的:当前已缓存在物理内存中的已分配页。
3.未缓存的:未缓存在物理内存中的已分配页。
页表
同任何缓存一样,虚拟内存系统必须有某种方法来判定一个虚拟页是否缓存在 DRAM 的某个地方。如果是,系统还必须确定这个虚拟页从存放在哪个物理页中。如果不命中,系统必须判断这个虚拟页存放在磁盘的哪个位置,在物理内存中选择一个牺牲页,并将虚拟页从磁盘复制到 DRAM 中,替换这个牺牲页。
这些功能是由软硬件联合提供的,包括操作系统软件、MMU(内存管理单元)中的地址翻译硬件和一个存放在物理内存中叫做页表的数据结构,页表将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。操作系统负责维护页表内容,以及在磁盘与 DRAM 之间来回传送页。
内容来源
《深入理解计算机系统》