计算机系统基础复习提纲 - 第三部分

  • Exceptional Control Flow: Exceptions
  • Linking
  • I/O

这一部分内容有点偏向于读书笔记了,期末复习主要参考练习题。重点文字由下划线和加粗标识。

血的教训:考前认真看书看 PPT!!!会考书上的细节!

Exceptional Control Flow: Exceptions

从给处理器加电开始,直到你断电为止,程序计数器假设一个值的序列\(a_0,a_1,\dots a_{n-1}\),其中,每个\(a_k\)是某个相应的指令的地址。每次从\(a_k\)\(a_{k+1}\)的过渡称为控制转移。这样的控制转移序列叫做处理器的控制流。

现代系统通过使控制流发生突变来对这些情况做出反应。一般而言,我们把这些突变称为异常控制流 (ECF)。

异常就是控制流中的突变,用来响应处理器状态中的某些变化。当处理器状态中发生一个事件在重要的变化时,处理器正在执行某个当前指令\(I_{curr}\)。在处理器中,状态被编码为不同的位和信号。状态变化称为事件。

当异常处理程序完成处理后,根据引起异常的事件的类型。会发生以下 3 种情况中的一种:

  1. 处理程序将控制返回给当前指令\(I_{curr}\),即当事件发生时正在执行的指令。
  2. 处理程序将控制返回给\(I_{next}\),如果没有发生异常将会执行的下一条指令。
  3. 处理程序终止被中断的程序。

系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号。在系统启动时,操作系统分配和初始化一张称为异常表的跳转表,使得表目\(k\)包含异常\(k\)的处理程序的地址。在运行时,处理器检测到发生了一个事件,并且确定了相应的异常号\(k\)。随后,处理器触发异常,方法是执行间接过程调用,通过异常表的表目\(k\),转到相应的专门设计用来处理这类事件的操作系统子程序(异常处理程序)。异常号是到异常表中的索引,异常表的起始地址放在一个叫做异常表基址寄存器的特殊 CPU 寄存器里。

异常与过程调用的区别:

  • 过程调用时,在跳转到处理程序之前,处理器将返回地址压入栈中。然而,根据异常的类型,返回地址要么是当前指令,要么是下一条指令。
  • 处理器也把一些额外的处理器状态压到栈里,在处理程序返回时,重新开始执行被中断的程序会需要这些状态。
  • 如果控制从用户程序转移到内核,所有这些项目都被压到内核中,而不是压到用户栈中。
  • 异常处理程序运行在内核模式下。

一旦硬件触发了异常,剩下的工作就是由异常处理程序在软件中完成。在处理程序处理完事件之后,它通过执行一条特殊的“从中断返回”指令,可选地返回到被中断的程序,该指令将适当的状态弹回到处理器的控制和数据寄存器中。

异常可以分为四类:中断、陷阱、故障和终止。

中断是异步发生的,是来自处理器外部的 I/O 设备的信号的结果。硬件中断的异常处理程序常常称为中断处理程序。

陷阱、故障和终止是同步发生的,是执行当前指令的结果,称作故障指令。

陷阱是有意的异常。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。用户程序经常需要向内核请求服务,为了允许对这些内核服务的受控的访问,处理器提供了一条特殊的syscall指令,当用户程序想要请求服务时,可以执行这条指令。执行syscall指令会导致一个到异常处理程序的陷阱,这个处理程序解析参数,并调用适当的内核程序。

故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误情况,它就将控制返到引起故障的指令,从而重新执行它。否则,处理程序返回到内核中的abort例程,abort例程会终止引起故障的应用程序。典型的故障:缺页异常。

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

Linking

链接的三种类型:

  • 执行于编译时,即在源代码被翻译成机器代码时的传统静态链接
  • 执行于加载时,即程序被加载器加载到内存并执行时的动态链接
  • 执行于运行时,即由应用程序来执行的动态链接

输入gcc -Og -o prog main.c sum.c调用 gcc 驱动程序,执行以下过程:

  1. GCC 驱动程序运行 C 预处理器(cpp),将 C 源程序main.c翻译为 ASCII 码的中间文件main.i
  2. GCC 驱动程序运行 C 编译器(cc1),将main.i翻译成 ASCII 汇编语言文件main.s
  3. GCC 驱动程序运行汇编器(as),将main.s翻译成一个可重定位目标文件 main.o`。
  4. sum.c执行相同的过程,得到sum.o
  5. GCC 驱动程序运行链接器(ld),将main.osum.o以及其他必要的系统目标文件组合起来,得到可执行目标文件prog

在 shell 中输入./prog,调用操作系统的加载器函数,将该可执行文件prog复制到内存中,然后将控制转移到这个程序的开头。

目标文件有三种形式:

  • 可重定位目标文件。包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件可执行目标文件。
  • 可执行目标文件,包含二进制代码和数据,其形式可以被直接复制到内存并执行共享目标文件。
  • 共享目标文件,一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载进内存并链接。

编译器和汇编器生成可重定位目标文件(包括共享目标文件)。链接器生成可执行目标文件。从技术上来说,一个目标模块就是一个字节序列,而一个目标文件就是一个以文件形式存放在磁盘中的目标模块。

现代 x86-64 Linux 和 Unix 系统的目标文件使用可执行可链接格式 (ELF)。

  • ELF 头以一个 16 字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF 头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括 ELF 头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。
  • .text:已编译程序的机器代码。
  • .rodata:只读数据,比如printf语句中的格式串和开关语句的跳转表。
  • .data:已初始化的全局和静态 C 变量。局部 C 变量在运行时被保存在栈中,既不出现在.data节中,也不出现在.bss节中。
  • .bss:未初始化的静态 C 变量,以及所有被初始化为 0 的全局或静态变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。目标文件格式区分已初始化和未初始化变量是为了空间效率:在目标文件中,初始化变量不需要占据任何实际的磁盘空间。运行时,在内存中分配这些变量,初始值为 0。
  • .symtab:一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。.symtab符号表不包含局部变量的条目。
  • .rel.text.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。
  • .rel.data:被模块引用或定义的所有全局变量的重定位信息。一般而言,任何已初始化的全局变量,如果它的初始值是一个全局变量地址或者外部定义函数的地址,都需要被修改。
  • .debug:调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的 C 源文件。只有以-g选项调用编译器驱动程序时,才会得到这张表。
  • .line:原始 C 源程序中的行号和.text节中机器指令之间的映射。只有以-g选项调用编译器驱动程序时,才会得到这张表。
  • .strtab:一个字符串表,其内容包括.symtab.debug节中的符号表,以及节头中的节名字。字符串表就是以null结尾的字符串的序列
  • 不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目。

每个可重定位目标模块\(m\)都有一个符号表,它包含\(m\)定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:

  • 由模块\(m\)定义并能被其他模块引用的全局符号。全局符号对应于非静态的 C 函数和全局变量
  • 由其他模块定义并被模块\(m\)引用的全局符号。这些符号称为外部符号,对应于在其他模块中定义的非静态 C 函数和全局变量
  • 只被模块\(m\)定义和引用的局部符号。它们对应于static属性的 C 函数和全局变量。这些符号在模块\(m\)中任何位置都可见,但是不能被其他模块引用。

定义为带有 Cstatic属性的本地过程变量是不在栈中管理的。相反,编译器在.data.bss中为每个定义分配空间,并在符号表中创建一个有唯一名字的本地链接器符号。

符号表是由汇编器构造的,使用编译器输出到汇编语言.s文件中的符号。.symtab节中包含 ELF 符号表。这张符号表包含一个条目的数组。

  • name是字符串表中的字节偏移,指向符号的以null结尾的字符串名字。
  • value是符号的地址。对于可重定位的模块來说,value是距定义目标的节的起始位置的偏移。对可执行目标文件来说,该值是一个绝对运行时地址。
  • size是目标的大小(以字节为单位)。
  • type是数据或者函数或者数据节。
  • binding字段表示符号是本地的还是全局的。
  • section字段表示符号都被分配到目标文件的某个节,该字段也是一个到节头部表的索引。有三个特殊的伪节,它们在节头部表中是没有条目的:
    • ABS代表不该被重定位的符号;
    • UNDEF代表未定义的符号,也就是在本目标模块中引用,但是却在其他地方定义的符号;
    • COMMON表示还未被分配位置的未初始化的数据目标。对于COMMON符号, value字段给出对齐要求,而size给出最小的大小。注意,只有可重定位目标文件中才有这些伪节,可执行目标文件中是没有的。COMMON表示未初始化的全局变量,.bss表示未初始化的静态变量,以及初始化为 0 的全局或静态变量。

判断符号所在的节

  • 如果是函数,则在.text节中;
  • 如果是未初始化全局变量,则在COMMON节中;
  • 如果是未初始化静态变量,以及初始化为 0 的全局变量或静态变量,则在.bss节中;
  • 如果是初始化过的全局变量或静态变量,则在.data节中。
  • 本地过程变量在符号表中没有条目。

判断符号类型

  • 如果是在当前文件中定义的函数或全局变量,则为全局符号;
  • 如果是在当前文件定义的静态函数或静态全局变量,则为局部符号;
  • 如果在别的文件中定义,则为外部符号。

局部非静态变量保存在栈或寄存器中,所以不考虑。

像 Linux LD 程序这样的静态链接器以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的、可以加载和运行的可执行目标文件作为输出。

链接器的两个主要任务:

  • 符号解析。目标文件定义和引用符号,每个符号对应于一个函数、一个全局变量或一个静态変量。符号解析的目的是将每个符号引用正好和一个符号定义关联起来。
  • 重定位。编译器和汇编器生成从地址 0 开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。链接器使用汇编器产生的重定位条目的详细指令,不加甄别地执行这样的重定位。

符号解析

  • 对于局部链接器符号,由于符号定义和符号引用都在同一个可重定位目标文件中,情况相对简单,编译器只允许每个可重定位目标文件中每个局部链接器符号只有一个定义。而局部静态变量也会有局部链接器符号,所以编译器还要确保它有一个唯一的名字。
  • 对于全局符号(包括全局符号和外部符号),编译器可能会碰到不在当前文件中定义的符号,则会假设该符号是在别的文件中定义的,就会在重定位表中产生该符号的条目,让链接器去解决。而链接器可能还会碰到在多个可重定位目标文件中定义相同名字的全局符号,也要解决这些冲突。

编译器会向汇编器输出每个全局符号是还是,而汇编器会把这些信息隐式编码在可重定位目标文件的符号表中。函数和已初始化的全局符号是强符号,未初始化的全局符号是弱符号。

然后链接器通过以下规则来处理在多个可重定位目标文件中重复定义的全局符号

  1. 不允许有多个同名的强符号,如果存在,则链接器会报错;
  2. 如果有一个强符号和多个弱符号同名,则符号选择强符号的定义;
  3. 如果有多个弱符号同名,符号就随机选择一个弱符号的定义。

当编译器在翻译某个模块时,遇到一个弱全局符号,比如说x​,它并不知道其他模块是否也定义了x,如果是,它无法预测链接器该使用x的多重定义中的哪一个。所以编译器把x分配成COMMON,把决定权留给链接器。另一方面,如果x初始化为 0,那么它是一个强符号(因此根据规则 2 必须是唯一的),所以编译器可以很自信地将它分配成.bss。类似地, 静态符号的构造就必须是唯一的,所以编译器可以自信地把它们分配成.data.bss

同名符号类型不同时,可能遇到意想不到的错误。

编译系统都提供一种机制,相关的函数可以被编译为独立的目标模块,将所有相关的目标模块打包成为一个单独的文件,称为静态库,它可以用做链接器的输入。当链接器构造一个输出的可执行文件时,它只复制静态库里被应用程序引用的目标模块,应用程序可以通过在命令行上指定单独的文件名字来使用这些在库中定义的函数。

在链接时,链接器将只复制被程序引用的目标模块,这就减少了可执行文件在磁盘和内存中的大小。另一方面,应用程序员只需要包含较少的库文件的名字。

在 Linux 系统中,静态库以一种称为存档的特殊文件格式存放在磁盘中。存档文件是一组连接起来的可重定位目标文件的集合,有一个头部用来描述每个成员目标文件的大小和位置。存档文件名由后缀.a标识。

使用 AR 工具创建这些函数的一个静态库:

1
2
gcc -c addvec.c multvec.c
ar rcs libvector.a addvec.o multvec.c

编写程序调用addvec

编译和链接:

1
2
gcc -c main2.c
gcc -static -o prog2c main2.o ./libvector.a

第二条命令等价于:

1
gcc -static -o prog2c main2.o -L. -lvector

-static表示链接器需要构建一个完全链接的可执行目标文件,可以加载到内存并运行,无需进一步链接。这里的-lvectorlibvector.a的缩写,-L.告诉链接器在当前目录中查找libvector.a静态库。

当链接器运行时,它判定main2.c引用了addvec.o定义的addvec符号,所以复制added.o到可执行文件。因为程序不引用任何由multvec.o定义的符号,所以链接器就不会复制这个模块到可执行文件。链接器还会复制libc.a中的printf.o模块,以及许多 C 运行时系统中的其他模块。

在符号解析阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件。

在这次扫描中,链接器维护一个可重定位目标文件的集合\(E\)(这个集合中的文件会被合并起来形成可执行文件),一个未解析的符号(即引用了但是尚未定义的符号)集合\(U\),以及一个在前面输入文件中已定义的符号集合\(D\)。初始时,\(E\)\(U\)\(D\)均为空。

对于命令行上的每个输入文件\(f\),链接器会判断\(f\)是一个目标文件还是一个存档文件。如果是一个目标文件,那么链接器把\(f\)添加到\(E\),修改\(U\)\(D\)来反映\(f\)中的符号定义和引用,并继续下一个输入文件。

如果\(f\)是一个存档文件,那么链接器就尝试匹配\(U\)中未解析的符号和由存档文件成员定义的符号。如果某个存档文件成员\(m\),定义了一个符号来解析中的一个引用,那么就将\(m\)加到\(E\)中,并且链接器修改\(U\)\(D\)来反映\(m\)中的符号定义和引用。对存档文件中所有的成员目标文件都依次进行这个过程,直到\(U\)\(D\)都不再发生变化。此时,任何不包含在\(E\)中的成员目标文件都简单地被丢弃,而链接器将继续处理下一个输入文件。

如果当链接器完成对命令行上输入文件的扫描后,\(U\)是非空的,那么链接器就会输出一个错误并终止。否则,它会合并和重定位\(E\)中的目标文件,构建输出的可执行文件。

关于库的一般准则是将它们放在命令行的结尾。如果各个库的成员是相互独立的(也就是说没有成员引用另一个成员定义的符号),那么这些库就可以以任何顺序放置在命令行的结尾处。另一方面,如果库不是相互独立的,那么必须对它们排序,使得对于每个被存档文件的成员外部引用的符号s,在命令行中至少有一个s的定义是在对s的引用之后的。如果需要满足依赖需求,可以在命令行上重复库。

因为存档中的成员可能会被丢弃,所以需要重复,目标文件不需要重复。

在重定位步骤中,将合并输入模块,并为每个符号分配运行时地址。

  • 重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
  • 重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中称为重定位条目的数据结构。

无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中。已初始化数据的重定位条目放在.rel.data中。

offset是需要被修改的引用的节偏移。symbol标识被修改引用应该指向的符号。type告知链接器如何修改新的引用。addend是一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整。

两个基本重定位类型:R_X86_64_PC32R_X86_64_32

重定位 PC 相对引用

重定位绝对引用

可执行目标文件的格式类似于可重定位目标文件的格式。

  • ELF 头描述文件的总体格式。它还包括程序的入ロ点,也就是当程序运行时要执行的第一条指令的地址。
  • .text.rodata.data节与可重定位目标文件中的节是相似的,除了这些节已经被重定位到它们最终的运行时内存地址以外。
  • .init节定义了ー个小函数,叫做_init,程序的初始化代码会调用它。
  • 因为可执行文件是完全链接的(已被重定位),所以它不再需要.rel节。
  • ELF 可执行文件被设计得很容易加载到内存,可执行文件的连续的片被映射到连续的内存段。段头部表描述了这种映射关系。

第 1 行和第 2 行告诉我们第一个段(代码段)有读/执行访问权限,开始于内存地址 0x400000 处,总共的内存大小是 0x69c 字节,并且被初始化为可执行目标文件的头 0x69c 个字节,其中包括 ELF 头、程序头部表以及.init.text.rodata节。

第 3 行和第 4 行告诉我们第二个段(数据段)有读/写访问权限,开始于内存地址 0x600df8 处,总的内存大小为 0x230 字节,并用从目标文件中偏移 0xdf8 处开始的.data节中的 0x228 个字节初始化。该段中剩下的 8 个字节对应于运行时将被初始化为 0 的.bss数据。

对于任何段s,链接器必须选择一个起始地址vaddr,使得\(\mathrm{vaddr}\bmod \mathrm{align}= \mathrm{off}\bmod \mathrm{align}\)

因为prog不是一个内置的 shell 命令,所以 shell 会认为prog是一个可执行目标文件,通过调用某个驻留在存储器中称为加载器的操作系统代码来运行它。任何 Linux 程序都可以通过调用execve函数来调用加载器,加载器将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序。这个将程序复制到内存并运行的过程叫做加载。

共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程称为动态链接,是由一个叫做动态链接器的程序来执行的。共享库也称为共享目标,在 Linux 系统中通常用.so后缀来表示。

共享库“共享”的两层含义:

  • 在任意文件系统中,一个库只有一个.so文件,所有引用该共享库的可执行目标文件都共享该.so文件中的代码和数据,不像静态库的内容会被复制到可执行目标文件中。
  • 在内存中,一个共享库的.text节可以被不同正在运行的进程共享。
1
gcc -shared -fpic -o libvector.so addvec.c multvec.c

-fpic选项指示编译器生成与位置无关的代码。-shared选项指示链接器创建一个共享的目标文件。

1
gcc -o prog21 main2.c ./libvector.so

这样就创建了一个可执行目标文件prog21,而此文件的形式使得它在运行时可以和libvector.so链接。基本的思路是当创建可执行文件时,静态执行一些链接,然后在程序加载时,动态完成链接过程。认识到这一点是很重要的:此时,没有任何libvector.so的代码和数据节真的被复制到可执行文件prog21中。反之,链接器复制了一些重定位和符号表信息,它们使得运行时可以解析对libvector.so中代码和数据的引用。

当加载器加载和运行可执行文件prog21时,它加载部分链接的可执行文件prog21。接着,它注意到prog21包含一个.interp节,这一节包含动态链接器的路径名,动态链接器本身就是一个共享目标。加载器不会像它通常所做地那样将控制传递给应用,而是加载和运行这个动态链接器。然后,动态链接器通过执行下面的重定位完成链接任务:

  • 重定位libc.so的文本和数据到某个内存段;
  • 重定位libvector.so的文本和数据到另一个内存段
  • 重定位prog21中所有对由libc.solibvector.so定义的符号的引用

最后,动态链接器将控制传递给应用程序。从这个时刻开始,共享库的位置就固定了,并且在程序执行的过程中都不会改变。

应用程序可能在它运行时要求动态链接器加载和链接某个共享库,而无需在编译时将那些库链接到应用中。

Linux 为动态链接器提供一个接口,使得应用程序在运行时加载和链接共享库:

dlopen函数可以打开filename指定的共享库,并返回句柄指针,而参数flag可以用来确定共享库符号解析方式以及作用范围,两个可用|相连,包括:

  • RTLD_NOW:在dlopen返回前,解析出全部没有定义符号,假设解析不出来,则返回 NULL;
  • RTLD_LAZY:在dlopen返回前,对于共享库中的没有定义的符号不运行解析,直到执行来自共享库中的代码(仅仅对函数引用有效,对于变量引用总是马上解析);
  • RTLD_GLOBAL:共享库中定义的符号可被其后打开的其他库用于符号解析;
  • RTLD_LOCAL:与RTLD_GLOBAL作用相反,共享库中定义的符号不能被其后打开的其他库用于重定位,是默认的。

该函数返回之前打开的共享库的句柄中symbol指定的符号的地址。

如果没有其他共享库还在使用这个共享库,就卸载该共享库。

编译指令:

1
gcc -rdynamic -o prog2r dll.c -ldl

我们的目的其实就是希望共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是目前被称为地址无关代码 (PIC) 的技术。对于动态库的创建,-fpic选择地址无关代码是必须的编译选项。

PIC 数据引用:

无论我们在内存中的何处加载一个目标模块(包括共享目标模块),数据段与代码段的距离总是保持不变。因此,代码段中任何指令和数据段中任何変量之间的距离都是一个运行时常量,与代码段和数据段的绝对内存位置是无关的。

想要生成对全局变量 PIC 引用的编译器利用了这个事实,它在数据段开始的地方创建了一个表,叫做全局偏移量表 (GOT)。在 GOT 中,每个被这个目标模块引用的全局数据目标(过程或全局变量)都有个 8 字节条目。编译器还为 GOT 中每个条目生成一个重定位记录。在加载时,动态链接器会重定位 GOT 中的每个条目,使得它包含目标的正确的绝对地址。每个引用全局目标的目标模块都有自己的 GOT。

PIC 函数调用:

假设程序调用一个由共享库定义的函数。编译器没有办法预测这个函数的运行时地址, 因为定义它的共享模块在运行时可以加载到任意位置。GNU 编译系统使用了延迟绑定,将过程地址的绑定推迟到第一次调用该过程时。把函数地址的解析推迟到它实际被调用的地方,能避免动态链接器在加载时进行成百上千个其实并不需要的重定位。第一次调用过程的运行时开销很大,但是其后的每次调用都只会花费一条指令和一个间接的内存引用。延迟绑定是通过两个数据结构:GOT 和过程链接表 (PLT) 的交互实现的。如果一个目标模块调用定义在共享库中的任何函数,那么它就有自己的 GOT 和 PLT。GOT 是数据段的一部分,而 PLT 是代码段的一部分。

  • 过程链接表 PLT 是一个数组,其中每个条目是 16 字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自已的 PLT 条目。每个条目都负责调用一个具体的函数。PLT[1]调用系统启动函数,它初始化执行环境,调用main函数并处理其返回值。从PLT[2]开始的条目调用用户代码调用的函数。
  • 全局偏移量表 GOT 是一个数组,其中每个条目是 8 字节地址。和 PLT 联合使用时,GOT[0]GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在ld_linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的 PLT 条目。初始时,每个 GOT 条目都指向对应 PLT 条目的第二条指令。

第一次调用addvec时:

  1. 不直接调用addvec,程序调用进入PLT[2],这是addvec的 PLT 条目。
  2. 第一条 PLT 指令通过GOT[4]进行间接跳转。因为每个 GOT 条目初始时都指向它对应的 PLT 条目的第二条指令,这个间接跳转只是简单地把控制传送回PLT[2]中的下一条指令。
  3. 在把addvec的 ID(0x1)压入栈中之后,PLT[2]跳转到PLT[0]
  4. PLT[0]通过GOT[1]间接地把动态链接器的一个参数压入栈中,然后通过GOT[2]间接跳转进动态链接器中。动态链接器使用两个栈条目来确定addvec的运行时位置,用这个地址重写GOT[4],再把控制传递给addvec

后续再调用addvec时:

  1. 和前面一样,控制传递到PLT[2]
  2. 通过GOT[4]的间接跳转会将控制直接转移到addvec

练习题:7.1 7.2 7.3 7.4 7.5 7.6 7.8 7.10 7.12

I/O

设备控制器中包含数据缓冲寄存器、状态/控制寄存器等多个不同的寄存器,用于存放外设与主机交换的数据信息、控制信息和状态信息。设备控制器是连接外设和主机的一个“桥梁”。有了设备控制器,底层 I/O 软件就可以通过设备控制器来控制外设。在底层 I/O 软件中,可以将控制命令送到控制寄存器来启动外设工作;可以读取状态寄存器来了解外设和设备控制器的状态;可以通过直接访问数据缓冲寄存器来进行数据的输入和输出。当然,这些对数据缓冲寄存器、控制/状态寄存器的访问操作是通过相应的指令来完成的,通常把这类指令称为 I/O 指令。因为这些 I/O 指令只能在操作系统内核的底层 I/O 软件中使用,因而它们是一种特权指令。

通常把设备控制器中的数据缓冲寄存器、状态/控制寄存器等统称为 I/O 端口。数据缓冲寄存器简称为数据端口,状态/控制寄存器简称为状态/控制端口。为了便于 CPU 对外设的快速选择和对 I/O 端口的寻址,必须对 I/O 端口进行编址,所有 I/O 端口编号组成的空间称为 I/O 地址空间。

  • 统一编址方式下,I/O 地址空间与主存地址空间统一编址,也即,将主存地址空间分出一部分地址给 I/O 端口进行编号,因为 I/O 端口和主存单元在同一个地址空间的不同分段中,因而根据地址范围就可区分访问的是 I/O 端口还是主存单元,因此也就无须设置专门的 I/O 指令,只要用一般的访存指令就可以存取 I/O 端口。
  • 独立编址方式对所有的 I/O 端口单独进行编号,使它们成为一个独立的 I/O 地址空间。这种情况下,指令系统中需要有专门的 I/O 指令来访问 I/O 端口,I/O 指令中地址码部分给出 I/O 端口号。

I/O 控制方式:

  • 程序直接控制 I/O 方式的基本实现思想是,直接通过查询程序来控制主机和外设的数据交换,因此,也称为程序查询或轮询方式。该方式在査询程序中安排相应的 I/O 指令,通过这些指令直接向设备控制器传送控制命令,并从状态寄存器中取得外设和设备控制器的状态后,根据状态来控制外设和主机的数据交换。CPU 需要从设备控制器中读取状态信息,并在外设未就绪时一直处于忙等待。
  • 中断控制 I/O 方式的基本思想是,当需要进行 I/O 操作时,首先启动外设进行第一个数据的 I/O 操作,然后使 CPU 转去执行其他用户进程,而请求 I/O 的用户进程被阻塞。在 CPU 执行其他进程的过程中,外设在对应设备控制器的控制下进行数据的 I/O 操作。当外设完成 I/O 操作后,向 CPU 发送一个中断请求信号,CPU 检测到有中断请求信号后,就暂停正在执行的进程,并调出相应的中断服务程序执行。CPU 在中断服务程序中,再启动随后数据的 I/O 操作,然后返回到被打断的进程继续执行。
  • DMA(Direct Memory Access,直接存储器访问)控制 I/O 方式用专门的 DMA 接口硬件来控制外设和主存之间的直接数据交换,数据不通过 CPU。通常把专门用来控制数据在主存和外设之间直接传送的接口硬件称为 DMA 控制器。DMA 控制 I/O 方式的基本思想是,首先对 DMA 控制器进行初始化,然后发送“启动 DMA 传送”命令以启动外设进行 I/O 操作,发送完“启动 DMA 传送”命令后,CPU 转去执行其他进程,而请求 I/O 的用户进程被阻塞。在 CPU 执行其他进程的过程中,DMA 控制器控制外设和主存进行数据交换。DMA 控制器每完成一个数据的传送,就将字计数器减 1,并修改主存地址,当字计数器为 0 时,完成所有 I/O 操作,此时,DMA 控制器向 CPU 发送一个“DMA 完成”中断请求信号,CPU 检测到有中断请求信号后,就暂停正在执行的进程,并调出相应的中断服务程序执行。CPU 在中断服务程序中,解除用户进程的阻塞状态而使用户进程进入就绪队列,然后中断返回,再回到被打断的进程继续执行。

I/O 子系统主要解决各种形式信息的输入和输出问题,即解决如何将所需信息(文字、图表、声音。视频等)通过不同外设输入到计算机中,或者计算机内部处理的结果如何通过相应外设输出给用户。

I/O 子系统包含 I/O 软件和 I/O 硬件两大部分。I/O 软件包括最上层提出 I/O 请求的用户空间 I/O 软件(称为用户 I/O 软件)和在底层操作系统中对 I/O 进行具体管理和控制的内核空间 I/O 软件(称为系统 I/O 软件)。系统 I/O 软件又分三个层次,分别是与设备无关的 I/O 软件层、设备驱动程序层和中断服务程序层。I/O 硬件在操作系统内核空间 I/O 软件的控制下完成具体的 I/O 操作。

I/O 子系统的三大特性:

  1. 共享性。I/O 系统被多个程序共享,须由 OS 对 I/O 资源统一 调度管理,以保证用户程序只能访问自己有权访问的那部分 I/O 设备,并使系统的吞吐率达到最佳;
  2. 复杂性。I/O 设备控制细节复杂,需 OS 提供专门的驱动程序 进行控制,这样可对用户程序屏蔽设备控制的细节;
  3. 异步性。不同设备之间速度相差较大,因而,I/O 设备与主 机之间的信息交换使用异步的中断 I/O 方式,中断导致从用户态 向内核态转移,因此必须由 OS 提供中断服务程序来处理。

用户 I/O 软件:

  1. 使用高级语言提供的标准 I/O 库函数。程序移植性很好,有以下不足:
    • 标准 I/O 库函数不能保证文件的安全性(无加/解锁机制);
    • 所有 I/O 都是同步的,程序必须等待 I/O 操作完成后才能继续执行;
    • 有时不适合甚至无法使用标准 I/O 库函数实现 I/O 功能,如,不提供读取文件元数据的函数;
    • 用它进行网络编程会造成易于出现缓冲区溢出等风险。
  2. 使用系统级 I/O 函数。

不管用户程序中调用的是 C 库函数还是系统调用封装函数,最终都是通过操作系统内核提供的系统调用来实现 I/O。每个系统调用的封装函数都会被转换为一组与具体机器架构相关的指令序列,这个指令序列中至少有一条陷阱指令,在陷阱指令之前可能还有若干条传送指令用于将 I/O 操作的参数送入相应的寄存器。

I/O 子系统工作的大致过程如下:首先,CPU 在用户态执行用户进程,当 CPU 执行到系统调用封装函数对应的指令序列中的陷阱指令时,会从用户态陷入到内核态;转到内核态执行后,CPU 根据陷阱指令执行时的系统调用号,选择执行一个相应的系统调用服务例程;在系统调用服务例程的执行过程中可能需要调用具体设备的驱动程序;在设备驱动程序执行过程中启动外设工作,外设准备好后发出中断请求,CPU 响应中断后,就调出中断服务程序执行,在中断服务程序中控制主机与设备进行具体的数据交换。

一个 Linux 文件就是一个\(m\)个字节的序列,所有的 I/O 设备都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。

  • 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
  • Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为 0)、标准输出(描述符为 1)和标准错误(描述符为 2)。键盘和显示器可以分別抽象成标准输入文件和标准输出文件。
  • 改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置\(k\),初始为 0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为\(k\)
  • 读写文件。一个读操作就是从文件复制\(n>0\)个字节到内存,从当前文件位置\(k\)开始,然后将\(k\)増加到\(k+n\)。给定一个大小为\(m\)字节的文件,当\(k\geq m\)时执行读操作会触发一个称为 end-of-file(EOF) 的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF 符号”。类似地,写操作就是从内存复制\(n>0\)个字节到一个文件,从当前文件位置\(k\)开始,然后更新\(k\)
  • 关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。

文件类型:

  • 普通文件包含任意数据。应用程序常常要区分文本文件和二进制文件,文本文件是只含有 ASCII 或 Unicode 字符的普通文件;二进制文件是所有其他的文件。对内核而言,文本文件和二进制文件没有区別。每一行以新行符“”结束。
  • 目录是包含一组链接的文件,其中每个链接都将一个文件名映射到一个文件,这个文件可能是另一个目录。每个目录至少含有两个条目:“.”是到该目录自身的链接,以及“..”是到目录层次结构中父目录的链接。
  • 套接字是用来与另一个进程进行跨网络通信的文件。

Linux 内核将所有文件都组织成一个目录层次结构,由名为/(斜杠)的根目录确定。系统中的每个文件都是根目录的直接或间接的后代。

每个进程都有一个当前工作目录来确定其在目录层次结构中的当前位置。

绝对路径名和相对路径名

通过调用open函数来打开一个已存在的文件或者创建一个新文件:

open函数将filename转换成一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符flags参数指明了进程打算如何访问这个文件。

  • O_RDONLY:只读。
  • O_WRONLY:只写。
  • O_RDWR:可读可写。

flags参数也可以是一个或者更多位掩码的或,为写提供给一些额外的指示:

  • O_CREAT:如果文件不存在,就创建它的一个截断的(空)文件。
  • O_TRUNC:如果文件已经存在,就截断它。
  • O_APPEND:在每次写操作前,设置文件位置到文件的结尾处。

mode参数指定了新文件的权限访问位,每个进程都有一个umask,它是通过调用umask函数来设置的。当进程通过带某个mode参数的open函数调用来创建一个新文件时,文件的访问权限位被设置为mode & ~umask

通过调用close函数关闭一个打开的文件:

关闭一个已关闭的描述符会出错。关闭文件后描述符会被回收。

通过分别调用readwrite函数来执行输入和输出:

read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1 表示一个错误,而返回值 0 表示 EOF。否则,返回值表示的是实际传送的字节数量。

write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。

在某些情况下,readwrite传送的字节比应用程序要求的要少。这些不足值不表示有错误。出现这样情况的原因有:

  • 读时遇到 EOF
  • 从终端读文本行。
  • 读和写网络套接字。

除了 EOF,当你在读磁盘文件时,将不会遇到不足值,而且在写磁盘文件时,也不会遇到不足值。

应用程序能够通过调用statfstat函数,检索到关于文件的信息(有时也称为文件的元数据):

stat函数以一个文件名作为输入,并填写如图 10-9 所示的一个stat数据结构中的各个成员。fstat函数是相似的,只不过是以文件描述符而不是文件名作为输入。

st_size成员包含了文件的字节数大小。st_mode成员则编码了文件访问许可位和文件类型。

可以用readdir系列函数来读取目录的内容:

函数opendir以路径名为参数,返回指向目录流的指针。流是对条目有序列表的抽象,在这里是指录项的列表。

每个目录项都是一个结构,其形式如下:

成员d_name是文件名,d_ino是文件位置。

如果出错,则readdir返回 NULL,并设置errno

函数closedir关闭流并释放其所有的资源:

内核用三个相关的数据结构来表示打开的文件:

  • 描述符表。每个进程都有它独立的描述符表,它的表项是由进程打开的文件描述符来索引的。每个打开的描述符表项指向文件表中的一个表项。
  • 文件表。打开文件的集合是由一张文件表来表示的,所有的进程共享这张表。每个文件表的表项组成(针对我们的目的)包括当前的文件位置、引用计数(即当前指向该表项的描述符表项数),以及一个指向 v-node 表中对应表项的指针。关闭一个描述符会减少相应的文件表表项中的引用计数。内核不会删除这个文件表表项,直到它的引用计数为零。
  • v-node 表。同文件表一样,所有的进程共享这张 v-node 表。每个表项包含stat结构中的大多数信息,包括st_modest_size成员。

多个描述符也可以通过不同的文件表表项来引用同一个文件。关键思想是每个描述符都有它自己的文件位置,所以对不同描述符的读操作可以从文件的不同位置获取数据。

父进程通过调用fork创建一个新的子进程,fork函数对子进程返回 0,对父进程返回子进程的 PID。子进程有父进程运行状态的完全副本。fork函数调用一次,返回两次。父子进程并发执行,无法预知父子进程执行的先后顺序。

在调用fork之后,子进程有一个父进程描述符表的副本。父子进程共享相同的打开文件表集合,因此共享相同的文件位置。一个很重要的结果就是,在内核删除相应文件表表项之前,父子进程必须都关闭了它们的描述符。

I/O 重定向,使用dup2函数:

dup2函数复制描述符表表项oldfd到描述符表表项newfd,覆盖描述符表表项newfd以前的内容。如果newfd已经打开,dup2会在复制oldfd之前关闭newfd

标准 I/O 库将一个打开的文件模型化为一个流。对于程序员而言,一个流就是一个指向 FILE 类型的结构的指针。每个 ANSI C 程序开始时都有三个打开的流stdinstdoutstderr,分别对应于标准输入、标准输出和标准错误。

类型为 FILE 的流是对文件描述符和流缓冲区的抽象。FILE 由缓冲区起始位置base,下一个可读写位置ptr以及剩下未读写的字节数cnt来描述。

虽然fread函数的功能是从文件中读信息,但实际上是从 FILE 缓冲区的ptr处开始读信息,而缓冲区中的信息则是从文件fd中预先读入的。

虽然fwrite函数的功能是向文件中写信息,但实际上是写到 FILE 输出流缓冲区的ptr处。输出缓冲区的属性有三种:全缓冲、行缓冲和非缓冲。普通文件的缓冲区属性为全缓冲,即使遇到换行符也不会写文件,只有当缓冲区满时才会将缓冲区内容真正写入文件中。

FILE 结构将义件fd封装成一个文件的流缓冲区,因而可以将文件中一批信息先读入缓冲区,然后再从缓冲区中一个一个读出,或者先写入缓冲区,写满缓冲区后再一次性把缓存信息写到文件中。

练习题:10.1 10.2 10.3 10.4 10.5 10.6

作者

xqmmcqs

发布于

2020-12-03

更新于

2023-03-29

许可协议

评论

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×