简介

  • 深入理解计算机系统书籍的阅读,第一阶段为上下班阅读,拍下来需要记录的文字,回到公司整理到这里

第三章 程序的机器级表示

链接器

  • 链接器的任务之一就是为函数调用找到匹配的函数的可执行代码的位置

反汇编器(disassembler)

  • 要查看机器代码文件的内容,有一类称为反汇编器的程序非常有用。这些程序根据机器代码产生一种类似于汇编代码的格式
  • 在Linux系统中,带-d命令行标志的程序OBJDUMP表示(object dump)可以充当这个角色
  • 示例:
    1
    
    objdump -d mstore.o
    

C 指针

  • C语言中所谓的指针,其实就是地址。

  • 间接引用指针就是将该指针放在一个寄存器中,然后在内存引用中使用这个寄存器
  • 其次,像x这样的局部变量通常是保存在寄存器中,而不是内存中,访问寄存器比访问内存要快得多

C 指针运算

  • C语言允许对指针进行运算,而计算出来的值会根据该指针引用的数据类型的大小进行伸缩

  • 单操作数操作符 &* 可以产生指针和间接引用指针。也就是:
    • 对于一个表示某个对象的表达式Expr, &Expr是给出该对象地址的一个指针。
    • 对于一个表示地址的表达式AExpr, *AExpr给出该地址处的值
  • 因此,表达式Expr与 * &Expr是等价的。可以对数组和指针应用数据下标操作。
  • 数组引用A[i]等同于表达式 * (A + i)。它计算第 i 个数组元素的地址,然后访问这个内存位置

定长数组

  • 示例:
    1
    2
    
    #define N 16
    typedef int fix_matrix[N][N]
    
  • 当程序要用一个常数作为数组的维度或者缓冲区的大小时,最好通过 #define 声明将这个常数与一个名字联系起来,然后在后面一直使用这个名字代替常数的数值。
  • 这样一来,如果需要修改这个值,只用简单地修改这个 #define 声明就可以了

运行时栈

  • C语言过程调用机制的一个关键特性(大多数其他语言也是如此)在于使用了栈数据结构提供的后进先出的内存管理原则

  • 程序可以用栈来管理它的过程所需要的存储空间,栈和程序寄存器存放着传递控制和数据,分配内存所需要的信息。

异质的数据结构

  • C 语言提供了两种将不同类型的对象组合到一起创建数据类型的机制
    • 结构(structure),用关键字struct来声明,将多个对象集合到一个单位中
    • 联合(union),用关键字union声明,允许用几种不同的类型来引用一个对象
  • C语言的struct声明创建一个数据类型,将可能不同类型的对象聚合到一个对象中。用名字来引用结构的各个组成部分。
  • 类似于数据的实现,结构的所有组成部分都存放在内存中一段连续的区域内,而指向结构的指针就是结构第一个字节的地址
  • 编译器维护关于每个结构类型的信息,指示每个字段(field)的字节偏移。它以这些偏移作为内存引用指令中的位移,从而产生对结构元素的引用

数据对齐

  • 许多计算机系统对基本数据类型的合法地址做出了一些限制,要求某种类型对象的地址必须是某个值K(通常是2,4,8)的倍数。
  • 这种对齐限制简化了形成处理器和内存系统之间接口的硬件设计

栈随机化

  • 栈随机化的思想使得栈的位置在程序每次运行时都有变化。因此,即使许多机器都运行同样的代码,它们的站地址都是不同的。
  • 实现的方式:
    • 程序开始时,在栈上分配一段0-n字节之间的随机大小的空间,例如,使用分配函数alloca在栈上分配指定字节数量的空间。
    • 程序不适用这段空间,但是他会导致程序每次执行时后续的栈位置发生了变化。
    • 分配的范围n必须足够大,才能获得足够多的栈地址变化,但是又要足够小,不至于浪费程序太多的空间
  • 在Linux系统中,栈随机化已经变成了标准行为。它是更大的一类技术中的一种,这类技术称为地址空间布局随机化(Address-Space Layout Randomization),或者简称ASLR
  • 采用ASLR,每次运行时程序的不同部分,包括程序代码,库代码,栈,全局变量和堆数据,都会被加载到内存的不同区域。这就意味着在一台机器上运行一个程序,与在其他机器上运行同样的程序,他们的地址映射大相径庭。这样才能对抗一些形式的攻击

第三章 小结

  • 编译器C++与编译C非常相似。实际上,C++的早期实现就只是简单地执行了从C++到C的源到源的转换,并对结果运行C编译器,产生目标代码。C++的对象用结构来表示,类似于C的struct。C++的方法是用指向实现方法的代码的指针来表示的。

  • 相比而言,Java的实现方式完全不同。Java的目标代码是一种特殊的二进制表示,称为Java字节代码。这种代码可以看成是虚拟机的机器级程序。正如它的名字暗示的那样,这种机器并不是直接用硬件实现的,而是用软件解释器处理字节代码,模拟虚拟机的行为。

  • 另外,有一种称为及时编译(just-in-time compilation)的方法,动态地将字节代码序列翻译成机器指令。当代码要执行多次时(例如在循环中),这种方法执行起来更快。用字节代码作为程序的低级表示,优点是相同的代码可以在许多不同的机器上执行。

第五章 优化程序性能

理解现代处理器

  • 为了理解改进性能的方法,我们需要理解现代处理器的微体系结构。由于大量的晶体管可以被集成到一块芯片上,现在微处理器采用了复杂的硬件,试图使程序性能最大化。带来的一个后果就是处理器的实际操作与通过观察机器级程序所察觉到的大相径庭。
  • 在代码级上,看上去似乎试一次执行一条指令,每条指令都包括从寄存器或内存取值,执行一个操作,并把结果存回到一个寄存器或内存位置。在实际的处理器中,是同时对多条指令求值的,这个现象称为 指令级并行

应用: 性能提高技术

  • 优化程序性能的基本策略:
    • 高级设计。为遇到的问题选择适当的算法和数据结构。要特别警觉,避免使用那些会渐进地产生糟糕性能的算法或编码技术
  • 基本编码原则:避免限制优化的因素,这样编译器就能产生高效的代码
    • 消除连续的函数调用。在可能时,将计算移到循环外。考虑有选择地妥协程序的模块性以获取更大的效率
    • 消除不必要的内存引用。引入临时变量来保存中间结果。只有在最后的值计算出来时,才将结果存放到数组或全局变量中
  • 低级优化:结构化代码以利用硬件功能
    • 展开循环,降低开销,并且使得进一步的优化成为可能
    • 通过使用例如多个累积变量和重新结合等技术,找到方法提高指令级并行
    • 用功能性的风格重写条件操作,使得编译采用条件数据传送

程序剖析 code profiler

  • 程序剖析运行程序的一个版本,其中插入了工具代码,以确定程序的各个部分需要多少时间。这对于确认程序中我们需要集中注意力优化的部分是很有用的。
  • 剖析的一个有力之处在于可以在现实的基准数据(benchmark data)上运行实际程序的同时,进行剖析

  • Unix系统提供了一个剖析程序GPROF。这个程序产生两种形式的信息。
    • 首先,它确定程序中每个函数花费了多少CPU时间
    • 其次,它计算每个函数被调用的次数,以执行调用的函数来分类
  • 这两种形式的信息都非常有用。这些计时给出了不同函数在确定整体运行时间中的相对重要性。调用信息使得我们能够理解程序的动态行为

  • 用GPROF进行剖析需要三个步骤,就像C程序prog.c所示,它运行时命令参数为file.txt:
    • 首先,程序必须为剖析而编译和链接。使用GCC(以及其他C编译器),就是在命令行上简单地包括运行时标志-pg。确保编译器不通过内联替换来尝试执行任何优化是很重要的,否则就可能无法正确刻画函数调用。我们使用优化标志-Og,以保证能正确跟踪函数调用
      • linux> gcc -Og -pg prog.c -o prog
    • 其次,程序像往常一样执行:
      • linux> ./prog file.txt
      • 它运行得会比正常时稍微慢一点(大约慢两倍),不过除此之外唯一的区别就是它产生了一个文件gmon.out
    • 调用GPROF来分析gmon.out中的数据
      • linux> gprof prog
  • 剖析报告的第一部分列出了执行各个函数花费的时间,按照降序排列。
  • 每一行代表对某个函数的所有调用所花费的时间
    • 第一列表明花费在这个函数上的时间占整个时间的百分比
    • 第二列显示的是直到这一行并包括这一行的函数所花费的累计时间
    • 第三列显示的是花费在这个函数上的时间
    • 第四列显示的是它被调用的次数(递归调用不计算在内)
  • 剖析报告的第二部分是函数的调用历史
    • 根据这个调用信息,我们通常可以推断出关于程序行为的有用信息

小结

  • 当处理大型程序时,将注意力集中在最耗时的部分变得很重要。代码剖析程序和相关的工具能帮助我们系统地评价和改进程序性能。
  • 我们描述了GPROF,一个标准的Unix剖析工具,还有更加复杂完善的剖析程序可用,例如Intel的VTUNE程序开发系统,还有Linux系统基本上都有的VALGRIND。
  • 这些工具可以在过程级分解执行时间,估计程序每个基本块(basic block)的性能。(基本块是内部没有控制转移的指令序列,因此基本块总是整个被执行的)

第六章 存储器层次结构

  • 存储器系统(memory system)是一个具有不同容量,成本和访问时间的存储设备的层次结构。CPU寄存器保存着最常用的数据。
  • 靠近CPU的小的,快速的高速缓存存储器(cache memroy)作为一部分存储在相对慢速的主存储器(main memory)中的数据和指令的缓冲区域
  • 主存缓存,存储在容量较大的,慢速磁盘上的数据,而这些磁盘常常有作为存储在通过网络连接的其他机器的磁盘或磁带上的数据的缓冲区域

  • 这个思想围绕着计算机程序的一个称为局部性(locality)的基本属性。具有良好局部性的程序倾向于一次又一次地访问相同的数据项集合,或是倾向于访问邻近的数据项集合。
  • 具有良好局部性的程序比局部性差的程序更多地倾向于从存储器层次结构中较高层次出访问数据项,因此运行得更快

随机访问存储器

  • 随机访问存储器(Random-Access Memory, RAM)分为两类:静态的和动态的

  • 静态RAM(SRAM)比动态RAM(DRAM)更快,但也贵得多
  • SRAM用来作为高速缓存存储器,既可以在CPU芯片上,也可以在片下
  • SRAM将每个位存储在一个双稳态的(bistable)存储器单元里。每个单元是用一个六晶体管电路来实现的。这个电路有这样一个属性,它可以无限期地保持在两个不同电压配置(configuration)或状态(state)之一。其他任何状态都是不稳定的:从不稳定状态开始,电路会讯速地转移到两个稳定状态中的其中一个。这样一个存储器单元类似于一个倒转的钟摆

  • DRAM将每个位存储为一个电容的充电

  • 内存模块
    • DRAM芯片封装在内存模块(memory module)中,它查到主板的扩展槽上

非易失性存储器

  • 如果断电,DRAM和SRAM会丢失它们的信息,从这个意义上说,它们是易失的(volatile)。
  • 另一方面,非易失性存储器(nonvolatile memory)即使是在关电后,仍然保存着它们的信息。

  • 由于历史原因,虽然ROM中有的类型既可以读也可以写,但是它们整体上都被称为只读存储器(Read-Only Memory, ROM)。
  • ROM是它们能够被重编程(写)的次数和对它们进行重编程所用的机制来区分的

  • PROM(Programmable ROM, 可编程ROM)只能被编程一次。PROM的每个存储器单元有一种熔丝(fuse),只能用高电流熔断一次

  • 可擦写可编程ROM(Erasable Programmable ROM, EPROM)有一个透明的石英窗口,允许光到达存储单元。紫外线光照射过窗口,EPROM单元就被清除为0。对EPROM编程是通过使用一种把1写入EPROM的特殊设备来完成的。
  • EPROM能够被擦除和重编程的次数的数量级可以达到1000次。

  • 电子可擦除EROM(Electrically Erasable PROM, EEPROM)类似于EPROM,但是他不需要一个物理上独立的编程设备,因此可以直接在印制电路卡上编程。EEPROM能够被编程的次数的数量级可以达到10的五次方

  • 闪存(flash memory)是一类非易失性存储器,基于EEPROM,他已经称为了一种重要的存储技术。
  • 新型的基于闪存的磁盘驱动器,称为固态硬盘(Solid State Disk, SSD),它能提供相对于传统旋转磁盘的一种更快速,更强健和更低能耗的选择

  • 存储在ROM设备中的程序通常被称为固件(firmware)。当一个计算机系统通电以后,他会运行存储在ROM中的固件。一些系统在固件中提供了少量基本的输入和输出函数:例如PC的BIOS(基本输入/输出系统)例程

  • 逻辑磁盘块
    • 正如我们看到的那样,现代磁盘构造复杂,有多个盘面,这些盘面上有不同的记录区。为了对操作系统隐藏这样的复杂性,现在磁盘将它们的构造呈现为一个简单的视图,一个B个扇区大小的逻辑块的序列,编号为0,1,。。。,B-1。磁盘封装中有一个小的硬件/固件设备,称为磁盘控制器,维护着逻辑块号和实际(物理)磁盘扇区之间的映射关系。
  • 当操作系统想要执行一个I/O操作时,例如读一个磁盘扇区的数据到主存,操作系统会发送一个命令到磁盘控制器,让它读某个逻辑块号。
  • 控制器上的固件执行一个快速表查找,将一个逻辑块号翻译成一个(盘面,磁道,扇区)的三元组,这个三元组唯一的标识了对应的物理扇区,控制器上的硬件会解释这个三元组,将读/写头移动到适当的柱面,等待扇区移动到读/写头下,将读/写头感知到的位放到控制器上的一个小缓冲区中,然后将他们复制到主存中

  • 格式化的磁盘容量
    • 磁盘控制器必须对磁盘进行格式化,然后才能在该磁盘上存储数据
    • 格式化包括用标识扇区的信息填写扇区之间的间隙,标识出表面有故障的柱面并且不使用它们,以及在每个区中预留出一组柱面作为备用,如果区中一个或多个柱面在磁盘使用过程中坏掉了,就可以使用这些备用的柱面
    • 因为存在着这些备用的柱面,所以磁盘制造商所说的格式化容量比最大容量要小

连接I/O设备

  • 例如图形卡,监视器,鼠标,键盘和磁盘这样的输入/输出(I/O)设备,都是通过I/O总线,例如Intel的外围设备互连(Peripheral Component Interconnect, PCI)总线连接到CPU和主存的。

  • 系统总线和内存总线是与CPU相关的,与它们不同,诸如PCI的I/O总线设计成与底层CPU无关

  • 虽然I/O总线比系统总线和内存总线慢,但是它可以容纳种类繁多的第三方I/O设备。例如

    • 通用串行总线(Universal Serial Bus, USB)控制器是一个连接到USB总线的设备的中转机构,USB总线是一个广泛使用的标准,连接各种外围I/O设备,包括键盘,鼠标,调制解调器,数码相机,游戏操纵杆,外部磁盘驱动器和固态硬盘。USB 3.0 总线的最大带宽为625MB/s。USB 3.1 总线的最大带宽为1250MB/s
    • 图形卡(或适配器)包含硬件和软件逻辑,它们负责代表CPU在显示器上画像素
    • 主机总线适配器将一个或多个磁盘连接到I/O总线,使用的是一个特别的主机总线接口定义的通信协议。两个最常用的这样的磁盘接口是SCSI和SATA。SCSI主机总线适配器(通常称为SCSI控制器)可以支持多个磁盘驱动器,而SATA适配器与之不同,只能支持一个驱动器

局部性

  • 一个编写良好的计算机程序尝尝具有良好的局部性(locality)。也就是,它们倾向于引用邻近于其他最近引用过的数据项的数据项,或者最近引用过的数据项本身。这种倾向性,被称为局部性原理(principle of locality),是一个持久的概念,对硬件和软件系统的设计和性能都有着极大地影响。

  • 局部性通常有两种不同的形式:时间局部性(temporal locality)和空间局部性(spatial locality)
    • 在一个具有良好时间局部性的程序中,被引用过一次的内存位置很可能在不远的将来再被多次引用
    • 在一个具有良好空间局部性的程序中,如果一个内存位置被引用了一次,那么程序很可能在不远的将来引用附近的一个内存位置。
  • 量化评价程序中局部性的一些简单原则:
    • 重复引用相同变量的程序有良好的时间局部性
    • 对于具有步长为k的引用模式,步长越小,空间局部性越好。具有步长为1的引用模式的程序有很好的空间局部性。在内存中以大步长跳来跳去的程序空间局部性很差
    • 对于取指令来说,循环有好的时间和空间局部性。循环体越小,循环迭代次数越多,局部性越好。

存储器层次结构中的缓存

  • 一般而言,高速缓存(cache, 读作”cash”)是一个小而快速的存储设备,它作为存储在更大,也更慢的设备中的数据对象的缓冲区域。使用高速缓存的过程称为缓存(caching 读作”cashing”)

  • 存储器层次结构的中心思想是:对于每个k,位于k层的更快更小的存储设备作为位于k+1层的更大更慢的存储设备的缓存。换句话说,层次结构中的每一层都缓存来自较低一层的数据对象。例如,本地磁盘作为通过网络从远程磁盘取出的文件的缓存,主存作为本地磁盘上数据的缓存,以此类推,直到最小的缓存:CPU寄存器组

存储器层次结构概念小结

  • 概括来说,基于缓存的存储器层次结构行之有效,是因为较慢的存储设备比较快的存储设备更便宜,还因为程序倾向于展示局部性:
    • 利用时间局部性:由于时间局部性,同一数据对象可能会被多次使用。一旦一个数据对象在第一次不明中时被复制到缓存中,我们就会其往后面对该目标有一系列的访问命中。因为缓存比较低一层的存储设备更快,对后面的命中的服务会比最开始的不命中快很多
    • 利用空间局部性:块通过包括有多个数据对象。由于空间局部性,我们会期望后面对该块中其他对象的访问能够补偿不命中后复制该块的花费。

第七章 链接

  • 链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。

  • 链接可以执行于编译时(compile time),也就是在源代码被翻译成机器代码时;
  • 也可以执行于加载时(load time),也就是在程序被加载器(loader)加载到内存并执行时;
  • 甚至执行于运行时(run time),也就是由应用程序来执行。

  • 在早期的计算机系统中,链接是手动执行的。在现代系统中,链接是由叫做链接器(linker)的程序自动执行的

  • 大多数编译系统提供编译器驱动程序(compiler driver),它代表用户在需要时调用语言预处理器,编译器,汇编器和链接器

  • 链接器的一些基本事实:
    • 目标文件纯粹是字节快的集合。
    • 在这些块中,有些包含程序代码,有些包含程序数据,而其他的则包含引导链接器和加载器的数据结构
    • 链接器将这些块连接起来,确定被连接块的运行时位置,并且修改代码和数据块中的各种位置
    • 链接器对目标机器了解甚少,产生目标文件的编译器和汇编器已经完成了大部分工作

目标文件

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

  • 从技术上来说,一个目标模块(object module)就是一个字节序列,而一个目标文件(object file)就是一个以文件形式存放在磁盘中的目标模块
  • 目标文件是按照特定的目标文件格式来组织的,各个系统的目标文件格式都不相同。现代x86-64 Linux和Unix系统使用可执行可链接格式(Executable and Linkable Format, ELF)

符号和符号表

  • 每个可重定位目标模块m都有一个符号表,它包含m定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:
    • 由模块m定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的C函数和全局变量
    • 由其他模块定义并被模块m引用的全局符号。这些符号称为外部符号,对应于在其他模块中定义的非静态C函数和全局变量
    • 只被模块m定义和引用的局部符号。它们对应于带static属性的C函数和全局变量。这些符号在模块m中任何位置都可见,但是不能被其他模块引用

符号解析

  • 链接器解析符号引用的方法是:将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来。
  • 对那些和引用定义在相同模块中的局部符号的引用,符号解析是非常简单明了的。编译器只允许每个模块中每个局部符号有一个定义。静态局部变量也会有本地链接器符号,编译器还要确保它们拥有唯一的名字

对C++和Java中链接器符号的重整

  • C++和Java都允许重载方法,这些方法在源代码中有相同的名字,却有不同的参数列表。

  • C++和Java中能使用重载函数,是因为编译器将每个唯一的方法和参数列表组合编码成一个对链接器来说唯一的名字。这种编码过程叫做重整(mangling),而相反的过程叫做恢复(demangling)

  • 幸运的是,C++和Java使用兼容的重整策略。一个被重整的类名字是由名字中字符的整数数量,后面跟原始名字组成的。

GCC -fno-common

  • GCC -fno-common标志这样的选调用链接器,这个选项会告诉链接器,在遇到多重定义的全局符号时,触发一个错误。

  • 或者使用-Werror选项,他会把所有的警告都变为错误

与静态库链接

  • 所有的编译系统都提供一种机制,将所有相关的目标模块打包成为一个单独的文件,称为静态库(static library),它可以用做链接器的输入。
  • 当链接器构造一个输出的可执行文件时,它只复制静态库里被应用程序引用的目标模块

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

  • gcc -c main2.c
  • gcc -static -o prog2c main2.o -L. -lvector
    • -static参数告诉编译器驱动程序,链接器应该构建一个完全链接的可执行目标文件,它可以加载到内存并运行,在加载时无需更进一步的链接
    • -lvector参数是libvector.a的缩写,
    • -L.参数高速链接器在当前目录下查找libvector.a

链接器如何使用静态库来解析引用

  • 在符号解析阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件(驱动程序自动将命令行中所有的.c文件翻译为.o文件)

  • 关于库的一般准则是将它们放在命令行的结尾

加载可执行目标文件

  • 任何Linux程序都可以通过调用execve函数来调用加载器

  • 加载器将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序。这个将程序复制到内存并运行的过程叫做加载

  • 每个Linux程序都有一个运行时内存映像。
  • 在Linux x86-64系统中,代码段总是从地址0x4000000处开始,后面是数据段。
  • 运行时堆在数据段之后,通过调用malloc库往上增长。
  • 堆后面的区域是为共享模块保留的。
  • 用户栈总是从最大的合法用户地址开始,向较小内存地址增长。
  • 栈上的区域,从地址2的28次方开始,是为内核中的代码和数据保留的,所谓内核就是操作系统驻留在内存的部分

加载器实际是如何工作的

  • Linux系统中的每个程序都运行在一个进程上下文中,有自己的虚拟地址空间。当shell运行一个程序时,父shell进程生成一个子进程,它是父进程的一个复制。子进程通过execve系统调用启动加载器。加载器删除子进程现有的虚拟内存段,并创建一组新的代码,数据,堆和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中的页映射到可执行文件的页大小的片(chunk),新的代码和数据段被初始化为可执行文件的内容。最后,加载器跳转到_start地址,它最终会调用应用程序的main函数。除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制。直到CPU引用一个被映射的虚拟页时才会进行复制,此时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。

共享库

  • 共享库(shared library),是致力于解决静态库缺陷的一个现代创新产物。共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程称为动态链接(dynamic linking),是由一个叫做动态链接器(dynamic linker)的程序来执行的
  • 共享库也称为共享目标(shared object),在Linux系统中通常用.so后缀来表示。微软的操作系统大量地使用了共享库,它们称为DLL(动态链接库)

  • 共享库的一个主要目的就是允许多个正在运行的进程共享内存中相同的库代码,因而节约宝贵的内存资源

  • 可以加载而无需重定位的代码称为位置无关代码(Position-Independent Code, PIC)。
  • 用户对GCC使用-fpic选项指示GNU编译系统生成PIC代码。共享库的编译必须总是使用该选项

处理目标文件的工具

  • 在Linux系统中有大量可用的工具可以帮助理解和处理目标文件。特别的,GNU binutils包尤其有帮助,而且可以运行在每个Linux平台上

  • AR:创建静态库,插入,删除,列出和提取成员
  • STRINGS:列出一个目标文件中所有可打印的字符串
  • STRIP:从目标文件中删除符号表信息
  • NM:列出一个目标文件的符号表中定义的符号
  • SIZE:列出目标文件中节的名字和大小
  • READELF:显示一个目标文件的完整结构,包括ELF头中编码的所有信息,包含SIZE和NM的功能
  • OBJDUMP:所有二进制工具之母。能够显示一个目标文件中所有的信息。它最大的作用是反汇编.text节中的二进制指令

  • Linux系统为操作共享库还提供了LDD程序

第八章 异常控制流

异常

  • 异常,是异常控制流的一种形式,它一部分由硬件实现,一部分由操作系统实现
  • 异常(exception)就是控制流中的突变,用来相应处理器状态中的某些变化

异常的类别

  • 异常可以分为四类:
    • 中断(interrupt)
      • 原因:来自I/O设备的信号
      • 类型:异步
      • 返回行为:总是返回到下一条指令
    • 陷阱(trap)
      • 原因:有意的异常
      • 类型:同步
      • 返回行为:总是返回到下一条指令
    • 故障(fault)
      • 原因:潜在可恢复的错误
      • 类型:同步
      • 返回行为:可能返回到当前指令
    • 终止(abort)
      • 原因:不可恢复的错误
      • 类型:同步
      • 返回类型:不会返回
  • 中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。硬件中断的异常处理程序尝尝称为中断处理程序(interrupt handler)

  • 剩下的异常类型(陷阱,故障和终止)是同步发生的,是执行当前指令的结果。我们把这类指令叫做故障指令(faulting instruction)

进程

  • 进程的经典定义就是一个执行中程序的实例

  • 关于操作系统如何实现进程的细节的讨论超出了本书的范围。反之,我们将关注进程提供给应用程序的关键抽象:

    • 一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器
    • 一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统

并发流

  • 计算机系统中国逻辑流有许多不同的形式。异常处理程序,进程,信号处理程序,线程和Java进程都是逻辑流的例子

  • 一个逻辑流的执行在时间上与另一个流重叠,称为并发流(concurrent flow),这两个流被称为并发地运行

  • 多个流并发地执行的一般现象被称为并发(concurrency)。一个进程和其他进程轮流运行的概念称为多任务(multitasking)。一个进程执行它的控制流的一部分的每一个时间段叫做时间片(time slice)。因此,多任务也叫做时间分片(time slicing)

  • 注意,并发流的思想与流运行的处理器核数或者计算机数无关。如果两个流在时间上重叠,那么它们就是并发的,即使它们是运行在同一个处理器上。
  • 不过,有时我们会发现确认并行流是很有帮助的,它是并发流的一个真子集。如果两个流并发地运行在不同的处理器核或者计算机上,那么我们称它们为并行流(parallel flow),它们并行地运行(running in parallel),且并行地执行(parallel execution)

用户模式和内核模式

  • 为了使操作系统内核提供一个无懈可击的进程抽象,处理器必须提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址范围

  • 处理器通常是用某个控制寄存器中的一个模式位(mode bit)来提供这种功能,该寄存器描述了进程当前享有的特权。
  • 当设置了模式位时,进程就运行在内核模式中。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何位置

  • 运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断,故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。

  • Linux提供了一种聪明的机制,叫做/proc文件系统,它允许用户模式进程访问内核数据结构的内容。/proc文件系统将许多内核数据结构的内容输出位一个用户程序可以读的文本文件的层次结构。
  • 例如:
    • 可以使用/proc文件系统找出一般的系统属性,例如CPU类型(/proc/cpuinfo)
    • 某个特殊的进程使用的内存段(/proc/ /maps)

创建和终止进程

  • 从程序员的角度,我们可以认为进程总是处于下面三种状态之一:
    • 运行。进程要么在CPU上执行,要么在等待被执行且最终会被内核调度
    • 停止。进程的执行被挂起(supspended),且不会被调度。当收到SIGSTOP, SIGTSTP,SIGTTIN或者SIGTTOU时,进程就停止,并且保持停止直到它收到一个SIGCONT信号,在这个时刻,进程再次开始运行。
    • 终止。进程永远地停止了。进程会因为三种原因终止:
      • 收到一个信号,该信号默认行为是终止进程
      • 从主程序返回
      • 调用exit函数
  • fork函数,被调用一次,返回两次:
    • 一次是在调用进程(父进程)中,一次是在新创建的子进程中。
    • 在父进程中,fork返回子进程的PID。在子进程中,fork返回0.

回收子进程

  • 当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终止的状态中,知道被它的父进程回收(reaped)。当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已经终止的进程,从此时开始,该进程就不存在了。
  • 一个终止了但是还未被回收的进程称为僵死进程(zombie)

  • 如果一个父进程终止了,内核会安排init进程称为它的孤儿进程的养父。init进程的PID为1,是在系统启动时由内核创建的,它不会终止,是所有进程的祖先。如果父进程没有回收它的僵死进程就终止了,那么内核会安排init进程去回收它们。不过长时间运行的程序,例如shell或者服务器,总是应该回收它们的僵死子进程。即使僵死子进程没有运行,它们仍然消耗系统的内存资源

  • pause函数
    • 该函数让调用函数休眠,直到该进程收到一个信号

加载并运行程序

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

  • 原型: ```cpp #include

int execve(const char *filename, const char *argv[], const char *envp[]);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

+ execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。
+ 只有当出现错误时,例如找不到filename,execve才会返回到调用程序,所以,与fork一次调用返回两次不同,execve调用一次并从不返回

### 程序与进程

+ 确认一下所理解的程序和进程之间的区别。

+ 程序是一堆代码和数据;程序可以作为目标文件存在于磁盘上,或者作为段存在于地址空间中。
+ 进程是执行中程序的一个具体的实例;程序总是运行在某个进程的上下文中。

+ 如果想要理解fork和execve函数,理解这个差异是很重要的。
  + fork函数在新的子进程中运行相同的程序,新的子进程是父进程的一个复制品。
  + execve函数在当前进程的上下文中加载并运行一个新的程序。他会覆盖当前进程的地址空间,但并没有创建一个新的进程。新的程序仍然有相同的PID,并且继承了调用execve函数时已打开的所有文件描述符

### 信号

+ Linux信号,它允许进程和内核中断其他进程

+ 一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的时间。

+ 转储内存(dumping core)是一个历史术语,意思是把代码和数据内存段的映像写到磁盘上

### signal函数

+ 原型:
```cpp
#include <signal.h>
typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);
  • signal函数可以通过下列三种方法之一来改变和信号signum相关联的行为:
    • 如果handler是SIG_IGN,那么忽略类型为signum的信号
    • 如果handler是SIG_DFL,那么类型为signum的信号行为恢复默认行为
    • 否则,handler就是用户定义的函数的地址,这个函数被称为信号处理程序,只要程序接收到一个类型为signum的信号,就会调用这个程序。通过把处理程序的地址传递到signal函数从而改变默认行为,这叫做设置信号处理程序(installing the handler)。调用信号处理程序被称为捕获信号。执行信号处理程序被称为处理信号
  • 不可以用信号来对其他进程中发生的事件计数

C++和Java中的软件异常

  • C++和Java提供的异常机制是较高层次的,是C语言的setjmp和longjmp函数的更加结构化的版本。你可以把try语句中的catch字句看做类似于setjmp函数。相似的,throw语句就类似于longjmp函数

操作进程的工具

  • Linux程序提供了大量的监控和操作进程的有用工具

  • STRACE:
    • 打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。
    • 用-static编译你的程序,能够得到一个更加干净的,不带有大量与共享库有关的输出的轨迹
  • PS:
    • 列出当前系统中的进程,包括僵死进程
  • TOP:
    • 打印出关于当前进程资源使用的信息
  • PMAP:
    • 显示进程的内存映射
  • /proc:
    • 一个虚拟文件系统,以ASCII文本格式输出大量内核数据结构的内容,用户程序可以读取这些内容。例如,输出”cat /proc/loadavg”,可以看到你的Linux系统上当前的平均负载

小结

  • 异常控制流(ECF)发生在计算机系统的各个层次,是计算机系统中提供并发的基本机制

  • 在硬件层,异常是由处理器中的事件触发的控制流中的突变。控制流传递给一个软件处理程序,该处理程序进行一些处理,然后返回控制给被中断的控制流

  • 有四种不同类型的异常:中断,故障,终止和陷阱。
  • 当一个外部I/O设备(例如定时器芯片或者磁盘控制器)设置了处理器芯片上的中断管脚时,(对于任意指令)中断会异步地发生。控制返回到故障指令后面的那条指令。一条指令的执行可能导致故障和终止同步发生。故障处理程序会重新启动故障指令,而终止处理程序从不将控制返回给被中断的流。最后,陷阱就像是用来实现向应用提供到操作系统代码的受控的入口点的系统调用的函数调用

  • 在操作系统层,内核用ECP提供进程的基本概念。进程提供给应用两个重要的抽象:
    • 逻辑控制流,它提供给每个进程一个假象,好像它是独占地使用处理器
    • 私有地址空间,它提供给每个程序一个假象,好像它是在独占地使用主存
  • 在操作系统和应用程序之间的接口处,应用程序可以创建子进程,等待它们的子进程停止或者终止,运行新的程序,以及捕获来自其他进程的信号。信号处理的语义是微妙的,并且随系统不同而不同,然后,在与Posix兼容的系统上存在着一些机制,允许程序清楚地指定期望的信号处理语义

  • 最后,在应用层,C程序可以使用非本地跳转来规避正常的调用/返回栈规则,并且直接从一个函数分支到另一个函数。

第九章 虚拟内存

  • 为了更加有效地管理内存并且少出错,现代系统提供了一种对主存的抽象概念,叫做虚拟内存(VM)。
  • 虚拟内存是硬件异常,硬件地址翻译,主存,磁盘文件和内核软件的完美交互,它为每个进程提供了一个大的,一致的和私有的地址空间。

  • 通过一个很清晰的机制,虚拟内存提供了三个重要的能力:
    • 它将主存看成是一个存储在磁盘上的地址空间的高速缓存,它高效地使用了主存
    • 它为每个进程提供了一致的地址空间,从而简化了内存管理
    • 它保护了每个进程的地址空间不被其他进程破坏

物理和虚拟寻址

  • 计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址(Physical Address, PA)。
  • 第一个字节的地址为0,接下来的字节地址为1,在下一个为2,以此类推。给定这种简单的结构,CPU访问内存的最自然的方式就是使用物理地址。我们把这种方式称为物理寻址(physical addressing)。
  • 早期的PC使用物理寻址,而且诸如数字信号处理器,嵌入式微控制器以及Cray超级计算机这样的系统仍然继续使用这种寻址方式。

  • 使用虚拟寻址,CPU通过生成一个虚拟地址(Virtual Address, VA)来访问主存,这个虚拟地址在被送到内存之前先转换成适当的物理地址。
  • 将一个虚拟地址转换为物理地址的任务叫做地址翻译(address translation)。
  • 就像异常处理一样,地址翻译需要CPU硬件和操作系统之间的紧密合作。CPU芯片上叫做内存管理单元(Memory Management Unit, MMU)的专用硬件,利用存放在主存中的查询表来动态翻译虚拟地址,该表的内容由操作系统管理。

地址空间

  • 地址空间(address space)是一个非负整数地址的有序集合
  • 如果地址空间中的整数是连续的,那么我们说它是一个线性地址空间(linear address space)
  • 在一个带虚拟内存的系统中,CPU从一个有N=2的n次方个地址的地址空间中生成虚拟地址,这个地址空间称为虚拟地址空间(virtual address space)
  • 一个地址空间的大小是由表示最大地址所需要的位数来描述的。例如,一个包含N=2的n次方个地址的虚拟地址空间就叫做一个n位地址空间。现代系统通常支持32位或者64位虚拟地址空间
  • 一个系统还有一个物理地址空间(physical address space),对应于系统中物理内存的M个字节

  • 地址空间的概念是很重要的,因为它清楚的区分了数据对象(字节)和它们的属性(地址)。一旦认识到了这种区别,那么我们就可以将其推广,允许每个数据对象有多个独立的地址,其中每个地址都选自一个不同的地址空间。这就是虚拟内存的基本思想。主存中的每字节都有一个选自虚拟地址空间的虚拟地址和一个选择物理地址空间的物理地址

虚拟内存作为缓存的工具

  • 概念上而言,虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。每字节都有一个唯一的虚拟地址,作为到数组的索引。磁盘上数组的内容被缓存在主存中。
  • 和存储器层次结构中其他缓存一样,磁盘(较低层)上的数据被分割成块,这些块作为磁盘和主存(较高层)之间的传输单元。VM系统通过将虚拟内存分割为称为虚拟页(Virtual Page, VP)的大小固定的块来处理这个问题。类似的,物理内存被分割为物理页(Physical Page, PP)物理页也被称为页帧(page frame)

  • 在任意时刻,虚拟页面的集合都分为三个不相交的子集:
    • 未分配的:VM系统还未分配(或者创建)的页。未分配的块没有任何数据和它们相关联,因此也就不占用任何磁盘空间
    • 缓存的:当前已缓存在物理内存中的已分配页
    • 未缓存的:未缓存在物理内存中的已分配页

DRAM缓存的组织结构

  • 为了有助于清晰理解存储器层次结构中不同的缓存概念,
    • 我们将使用术语SRAM缓存来表示位于CPU和主存之间的L1,L2和L3高速缓存
    • 并且用术语DRAM缓存在表示虚拟内存系统的缓存,它在主存中缓存虚拟页
  • 虚拟内存是在20世纪60年代早期发明的,远在CPU-内存之间差距的加大引发产生的SRAM缓存之前。因此,虚拟内存系统使用了和SRAM缓存不同的术语,即使它们的许多概念是相似的。
  • 在虚拟内存的习惯说法中,块被称为页。在磁盘和内存之间传送页的活动叫做交换(swapping)或者页面调度(paging)。页从磁盘换入(或者页面调入)DRAM和DRAM换出(或者页面调出)磁盘。一直等待,直到最后时刻,也就是当有不命中发生时,才换入页面的这种策略称为按需页面调度(demand paging)

  • 可以利用Linux的getrusage函数检测缺页的数量,以及其他信息

虚拟内存作为内存管理的工具

  • 到目前为止,我们都假设有一个单独的页表,将一个虚拟地址空间映射到物理地址空间。实际上操作系统为每个进程提供了一个独立的页表,因而也就是一个独立的虚拟地址空间。
  • 注意,多个虚拟页面可以映射到同一个共享物理页面上

  • 按需页面调度和独立的虚拟地址空间的结合,对系统中内存的使用和管理造成了深渊的映像。特别的,VM简化了链接和加载,代码和数据共享,以及应用程序的内存分配

  • 简化链接:
    • 独立的地址空间允许每个进程的内存映像使用相同的基本格式,而不管代码和数据实际存放在物理内存的何处
    • 对于64位地址空间,代码段总是从虚拟地址0x400000开始。数据段跟在代码段之后,中间有一段符合要求的对其空白。栈占据用户进程地址空间最高的部分,并向下生长。这样的一致性极大地简化了链接器的设计和实现,允许链接器生成完全链接的可执行文件,这些可执行文件是独立于物理内存中代码和数据的最终位置的。
  • 简化加载:
    • 虚拟内存还使得容易向内存中加载可执行文件和共享对象文件。要把目标文件中.text和.data节加载到一个新创建的进程中,Linux加载器为代码和数据段分配虚拟页,把它们标记为无效的(即未被缓存的),将页表条目指向目标文件中适当的位置。
    • 有趣的是,加载器从不从磁盘到内存实际复制任何数据。在每个页初次被引用时,要么是CPU取指令时引用的,要么是一条正在执行的指令引用一个内存位置时应用的,虚拟内存系统会按照自动地调入数据也。
  • 将一组连续的虚拟页映射到任意一个文件中的任意位置的表示法称作内存映射(memory mapping)。Linux提供一个称为mmap的系统调用,允许应用程序自己做内存映射

  • 简化共享:
    • 独立地址空间为操作系统提供了一个管理用户进程和操作系统自身之间共享的一致机制。
    • 一般而言,每个进程都有自己私有的代码,数据,堆以及栈区域,是不和其他进程共享的。在这种情况中,操作系统创建页表,将相应的虚拟页映射到不连续的物理页面

虚拟内存作为内存保护的工具

  • 任何现代计算机系统必须为操作系统提供手段来控制对内存系统的访问。
    • 不应该允许一个用户进程修改它的只读代码段
    • 不应该允许它读或修改任何内核中的代码和数据结构
    • 不应该允许它读或者写其他进程的私有内存
    • 不允许它修改任何与其他进程共享的虚拟页面,除非所有的共享者都显示的允许它这么做(通过调用明确的进程间通信系统调用)
  • 如果一条指令违反了这些许可条件,那么CPU就触发一个一般保护故障,将控制传递给一个内核中的异常处理程序。Linux shell一般将这种异常报告为 段错误(segmentation fault)

内存映射

  • Linux通过将一个虚拟内存区域与一个磁盘上的对象(object)关联起来,以初始化这个虚拟内存区域的内容,这个过程称为内存映射(memory mapping)

  • 虚拟内存区域可以映射到两种类型的对象中的一种:
    • Linux文件系统中的普通文件:一个区域可以映射到一个普通磁盘文件的连续部分
    • 匿名文件:一个区域也可以映射到一个匿名文件,匿名文件是由内核创建的,包含的全是二进制零
  • 无论在哪种情况下,一旦一个虚拟页面被初始化了,它就在一个由内核维护的专门的交换文件(swap file)之间换来换去。交换文件也叫做交换空间(swap space)或者交换区域(swap area)

动态内存分配

  • 虽然可以使用低级的mmap和munmap函数来创建和删除虚拟内存的区域,但是C程序员还是会觉得当运行时需要额外虚拟内存时,用动态内存分配器(dynamic memory allocator)更方便,也有更好的可移植性

  • 动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)。
  • 假设堆是一个请求二进制零的区域,他紧接着再未初始化的数据区域后开始,并向上生长(向更高的地址)。
  • 对于每个进程,内核维护着一个变量brk(读做 break),他指向堆的顶部

  • 分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已经分配的,要么是空闲的。
  • 已分配的块显示地保留为供应用程序使用。空闲块可用来分配。
  • 空闲块保持空闲,直到他显示的被应用所分配。
  • 一个已经分配的块保持已经分配状态,直到它被释放,这种释放要么是应用程序显示执行的,要么是内存分配器自身隐式执行的

  • 分配器有两种基本风格。两种风格都要求应用显示地分配块。他们的不同之处在于由那个实体来负责释放已经配分的块。
    • 显示分配器(explicit allocator),要求应用显示的释放任何已经分配的块。例如,C标准库提供一种叫做malloc程序包的显示分配器。
      • C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块
      • C++中的new和delete操作符与C中的malloc和free相当
  • 隐式分配器(implicit allocator),另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。
    • 隐式分配器也叫做垃圾收集器(garbage collector),而自动释放未使用的已分配的块的过程叫做垃圾收集(garbag collection)。
    • 例如,诸如Lisp, ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块

为什么要使用动态内存分配

  • 程序使用动态内存分配的最重要的原因是经常直到程序实际运行时,才知道某些数据结构的大小。

分配器的要求和目标

  • 显式分配器必须再一些相当严格的约束条件下工作:
    • 处理任意请求序列。一个应用可以有任意的分配请求和释放请求序列,只要满足约束条件:每个释放请求必须对应于一个当前已分配块,这个块是由一个以前的分配请求获得的。因此,分配器不可以假设分配和释放请求的顺序。例如,分配器不能假设所有的分配请求都有想匹配的释放请求,或者有相匹配的分配和空闲请求是嵌套的
    • 立即响应请求。分配器必须立即响应分配请求。因此,不允许分配器为了提高性能重新排列或者缓冲请求
    • 只使用堆。为了使分配器是可扩展的,分配器使用的任何非标量数据结构都必须保存在堆里
    • 对齐块(对齐要求)。分配器必须对齐块,使得它可以保存任何类型的数据对象
    • 不修改已分配的块。分配器只能操作或者改变空闲块。特别是,一旦块被分配了,就不允许修改或者移动它了。因此,诸如压缩已分配块这样的技术是不允许使用的。

C程序中常见的与内存有关的错误

  • 间接引用坏指针
    • 再进程的虚拟地址空间中有较大的洞,没有映射到任何有意义的数据。如果我们试图间接引用一个指向这些洞的指针,那么操作系统就会以段异常中止程序。
    • 而且,虚拟内存的某些区域是只读的。试图写这些区域将会以保护异常中止这个程序。
  • 读未初始化的内存
    • 虽然bss内存位置(诸如未初始化的全局C变量)总是被加载器初始化为零,但是对于堆内存却不是这样的。
    • 一个常见的错误就是假设堆内存被初始化为零
  • 允许栈缓冲区溢出
    • 如果一个程序不检查输入串的大小就写入栈中的目标缓冲区,那么这个程序就会有缓冲区溢出错误(buffer overflow bug)
  • 假设指针和它们指向的对象是相同大小的
    • 一种常见的错误是假设指向对象的指针和他们所指向的对象是相同大小的。
  • 造成错位错误
    • 错位(off-by-one)错误是另一种很常见的造成覆盖错误的来源
  • 引用指针,而不是它所指向的对象
    • 如果不太注意C操作符的优先级和结合性,我们就会错误地操作指针,而不是指针所指向的对象。
  • 误解指针运算
    • 另一种常见的错误是忘记了指针的算术操作是以他们指向的对象的大小为单位阿里进行的,而这种大小单位并不一定是字节
  • 引用不存在的变量
    • 没有太多经验的C程序员不理解栈的规则,有时会引用不合法的本地变量
  • 引用空闲堆块中的数据
    • 一个相似的错误是引用已经被释放了的堆块中的数据。
  • 引起内存泄漏
    • 内存泄漏是缓慢,隐性的杀手,当程序员不小心忘记释放已分配块,而再堆里创建了垃圾时,会发生这种问题

小结

  • 虚拟内存是对主存的一个抽象。支持虚拟内存的处理器通过使用一种叫做虚拟寻址的间接形式来引用主存。处理器产生一个虚拟地址,在被发送到主存之前,这个地址被翻译成一个物理地址。从虚拟地址空间到物理地址空间的地址翻译要求硬件和软件紧密合作。专门的硬件通过使用页表来翻译虚拟地址,而页表的内容是由操作系统提供的。

第十章 系统级I/O

  • 输入/输出(I/O)是在主存和外部设备(例如磁盘驱动器,终端和网络)之间复制数据的过程。输入操作是从I/O设备复制数据到主存,而输入操作是从主存复制数据到I/O设备。

Unix I/O

  • 一个Linux文件就是一个m个字节的序列
  • 所有的I/O设备(例如网络,磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单,低级的应用接口,称为Unix I/O。这使得所有的输入和输出都能以一种统一且一致的方式来执行:
    • 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需要记住这个描述符。
    • Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0),标准输出(描述符为1)和标准错误(描述符为2).头文件定义了常量STDIN_FILENO, STDOUT_FILENO与STDERR_FILENO,他们可用来代替显示的描述符常量
    • 改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显示地设置文件的当前位置。
    • 读写文件。一个读操作就是从文件复制n > 0个字节到内存,从当前文件位置k开始,然后将k增加k+n。给定一个大小为m字节的文件,当k >= m时执行读操作会触发一个称为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的EOF符号
    • 关闭文件。当应用完成了对文件的访问之后,就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因中止时,内核都会关闭所有打开的文件并释放他们的内存资源。

文件

  • 每个Linux文件都有一个类型(type)来表明它在系统中的角色:

  • 普通文件(regular file)包含任意数据。应用程序常常要区分文本文件(text file)和二进制文件(binary file)。
    • 文本文件是只包含有ASCII或Unicode字符的普通文件
    • 二进制文件是所有其他的文件。
    • 对内核而言,文本文件和二进制文件没有区别
    • Linux文本文件包含了一个文本行(text line)序列,其中每一行都是一个字符序列,以一个新行符(“\n”)结束。新行符与ASCII的换行符(LF)是一样的,其数字值为0x0a
  • 目录(directory)是包含一组链接(link)的文件,其中每个链接都将一个文件名(filename)映射到一个文件,这个文件可能是另一个目录。每个目录至少包含有两个条目
    • .是到该目录自身的链接
    • ..是到目录层次结构中父目录(parent directory)的链接
  • 套接字(socket)是用来与另一个进程进行跨网络通信的文件

小结

  • Linux提供了少量的基于Unix I/O模型的系统级函数,它们允许应用程序打开,关闭,读和写文件,提取文件的元数据,以及执行I/O重定向。Linux的读和写操作会出现不足值,应用程序必须能正确地预计和处理这种情况。应用程序不应直接调用Unix I/O函数

  • Linux内核使用三个相关的数据结构来表示打开的文件。描述符表中的表项指向打开文件表中的表项,而打开文件表中的表项又指向v-node表中的表项。每个进程都有它自己单独的描述符表,而所有进程共享同一个打开文件表和v-node表。理解这些结构的一般组成就能使我们清楚的理解文件共享和I/O重定向

  • 标准I/O库是基于Unix I/O实现的,并提供了一组强大的高级I/O例程。对于大多数应用程序而言,标准I/O更简单,是优于Unix I/O的选择。然而,因为对标准I/O和网络文件的一些相互不兼容的限制,Unix I/O比之标准I/O更适用于网络应用程序

第十一章 网络编程

  • 对主机而言,网络只是又一种I/O设备,是数据源和数据接收方。
  • 一个插到I/O总线扩展槽的适配器提供了到网络的物理接口。从网络上接收到的数据从适配器经过I/O和内存总线赋值到内存,通常是tongguoDMA传送。相似的,数据也能从内存复制到网络

  • 从Linux内核的角度来看,一个套接字就是通信的一个端点。从Linux程序的角度来看,套接字就是一个有相应描述符的打开文件

Web 内容

  • 对于Web客户端和服务器而言,内容是与一个MIME(Multipurpose Internet Mail Extensions, 多用途的网际邮件扩充协议)类型相关的字节序列。

  • 常用的MIME类型:
    • text/html – HTML页面
    • text/plain – 无格式文本
    • application/postscript – Postscript文档
    • image/gif – GIF格式编码的二进制图像
    • image/png – PNG格式编码的二进制图像
    • image/jpeg – JPEG格式编码的二进制图像
  • Web服务器以两种不同的方式向客户端提供内容:
    • 取一个磁盘文件,并将它的内容返回给客户端。磁盘文件称为静态内容(static content),而返回文件给客户端的过程称为服务静态内容(serving static content)
    • 运行一个可执行文件,并将它的输出返回给客户端。运行时可执行文件产出的输出称为动态内容(dynamic content),而运行程序并返回它的输出到客户端的过程称为服务动态内容(serving dynamic content)
  • 状态码(status-code)是一个三位的正整数,指明对请求的处理,状态消息(status message)给出与错误代码等价的英文描述
  • 常见的状态码,以及它们相应的消息:
    • 200 – 成功 – 处理请求无误
    • 301 – 永久移动 – 内容已移动到location头中指明的主机上
    • 400 – 错误请求 – 服务器不能理解请求
    • 403 – 禁止 服务器无权访问所请求的文件
    • 404 – 未发现 服务器不能找到所请求的文件
    • 501 – 未实现 服务器不支持请求的方法
    • 505 – HTTP版本不支持 服务器不支持请求的版本

小结

  • 每个网络应用都是基于客户端-服务器模型的。根据这个模型,一个应用是由一个服务器和一个或多个客户端组成的。服务器管理资源,以某种方式操作资源,为它的客户端提供服务。客户端-服务器模型中的基本操作是客户端-服务器事务,它是由客户端请求和跟随其后的服务器响应组成的。

  • 客户端和服务器通过因特网这个全球网络来通信。从程序员的观点来看,我们可以把因特网看成是一个全球范围的主机集合,具有一下几个属性:
    • 每个因特网主机都有一个唯一的32位名字,称为它的IP地址
    • IP地址的集合被映射为一个因特网域名的集合
    • 不同因特网主机上的进程能够通过连接互相通信
  • 客户端和服务器通过使用套接字建立连接。一个套接字是连接的一个端点,连接以文件描述符的形式提供给应用程序。套接字接口提供了打开和关闭套接字描述符的函数。客户端和服务器通过读写这些描述符来实现彼此间的通信

第十二章 并发编程

  • 使用应用级并发的应用程序称为并发程序(concurrent program)。现代操作系统提供了三种基本的构造并发程序的方法:
    • 进程。用这种方法,每个逻辑控制流都是一个进程,由内核来调度和维护。因为进程有独立的虚拟地址空间,想要和其他流通信,控制流必须使用某种显式的进程间通信(interprocess communication, IPC)机制
    • I/O多路复用。在这种形式的并发编程中,应用程序在一个进程的上下文中显式地调度它们自己的逻辑流。逻辑流被模型化为状态机,数据到达文件描述符后,主程序显式的从一个状态转换到另一个状态。因为程序是一个单独的进程,所以所有的流都共享同一个地址空间。
    • 线程。线程是运行在一个单一进程上下文中的逻辑流,由内核进行调度。你可以把线程看成是其他两种方式的混合体,像进程流一样由内核进行调度,而像I/O多路复用流一样共享同一个虚拟地址空间

基于线程的并发编程

  • 到目前为止,我们已经看到了两种创建并发逻辑流的方法。
    • 在第一种方法中,我们为每个流使用了单独的进程。内核会自动调度每个进程,而每个进程都有它自己的私有地址空间,者使得流共享数据很困难
    • 在第二种方法中,我们创建自己的逻辑流,并利用I/O多路复用来显式的调度流。因为只有一个进程,所有的流共享整个地址空间
  • 线程(thread)就是运行在进程上下文中的逻辑流。线程由内核自动调度。每个线程都有它自己的线程上下文(thread context),包括一个唯一的整数线程ID(Thread ID, TID),栈,栈指针,程序计数器,通用目的寄存器和条件码。所有的运行在一个进程里的线程共享该进程的整个虚拟地址空间

分离线程

  • 在任何一个时间点上,线程是可结合的(joinable)或者是分离的(detached)。一个可结合的线程能够被其他线程收回和杀死。在被其他线程回收之前,它的内存资源(例如栈)是不释放的。相反,一个分离的线程是不能被其他线程回收或杀死的。它的内存资源在它终止时由系统自动释放

线程安全

  • 当用线程编写程序时,必须小心地编写那些具有称为线程安全性(thread safety)属性的函数。一个函数被称为线程安全的(thread-safe),当且仅当被多个并发线程反复调用时,它会一直产生正确的结果。如果一个线程不是线程安全的,我们就说它是线程不安全的(thread-unsafe)

  • 我们能够定义出四个(不相交的)线程不安全函数类:

    • 不保护共享变量的函数
    • 保护跨越多个调用的状态的函数
    • 返回指向静态变量的指针的函数
    • 调用线程不安全函数的函数

可重入性

  • 有一类重要的线程安全函数,叫做可重入函数(reentrant function),其特点在于它们具有这种一种属性:
    • 当它们被多个线程调用时,不会引用任何共享数据。

竞争

  • 当一个程序的正确性依赖于一个线程要在另一个线程到达y点之前到达它的控制流中的x点时,就会发生竞争(race)

  • 通常发生竞争是因为程序员假定线程将按照某种特殊的轨迹线穿过执行状态空间,而忘记了另一条准则规定:多线程的程序必须对任何可行的轨迹线都正确工作

死锁

  • 死锁,指的是一组线程被阻塞了,等待一个永远也不会为真的条件

  • 互斥锁加锁顺序规则:

    • 给定所有互斥操作的一个全序,如果每个线程都是以一种顺序获得互斥锁并以相反的顺序释放的,那么这个程序就是无死锁的

附录 错误处理

  • 系统级函数调用使用三种不同风格的返回错误:
    • Unix风格
    • Posix风格
    • GAI风格

Unix风格的错误处理

  • 像fork和wait这样Unix早期开发出来的函数返回值即包括错误代码,也包括有用的结果

Posix风格的错误处理

  • 许多较新的Posix函数,例如Pthread函数,只用返回值来表明成功(0)或者失败(非0)。任何有用的结果都返回在通过引用传递进来的函数参数中。我们称这种方法为Posix风格的错误处理

GAI风格的错误处理

  • getaddrinfo(GAI)和getnameinfo函数成功时返回零,失败是返回非零值