第 1 章 漫游计算机系统
#include <stdio.h> int main() {
printf ( "Hello, world\n") ; return 0; }
- 尽管hello程序很简单,但是为了它的运行,系统的每个主要组成部分都需要协调。这本书是为了系统执行的理解hello在程序中,系统发生了什么,问什么会发生。
- 这一章是通过跟踪hello程序的生命周期开始学习系统——从程序员创建到在系统上运行,输出简单信息,然后终止的过程。
1.1 信息就是位 上下文
- 源程序实际上是一个由值0和1组成的位(又称比特)序列,8个位组织成一组,称为字节(表示程序中的文本字符)。该程序以字节序列的形式存储在文件中,每个字节都有一个完整的值,对应于某些字符,每个文本行都有一个看不见的行。\n来结束ASCII由字符组成的文件称为文本文件,所有其他文件均称为二进制文件。
- 系统中的所有信息,包括磁盘文件、内存程序、存储在内存中的用户数据和在线传输的数据,都由一串比特表示。区分不同数据对象的唯一方法是阅读这些数据对象的上下文。
1.2 其他程序将程序翻译成不同的格式
- 为了在系统上运行,程序必须转换为一系列低级机器语言指令,然后这些指令以可执行目标程序的格式包装,并以二进制磁盘文件的形式存储。目标程序也被称为可执行目标文件。
- Unix从源文件到目标文件的系统转换是由完成编译器驱动程序:
linux> gcc -o hello hello.c
1.3 了解编译系统如何工作是非常有益的
- 优化程序性能
- 理解链接时出现的错误
- 避免我去安全漏洞
1.4 处理器读并解释存储在内存中的指令
- 1.4.1 系统的硬件组成
- 总线
- 贯穿整个系统的一组电子管道,它携带信息字节并负责在各个部件间传递。现在大多数机器的字长4个字节(32位)或8个字节(64位).
- I/O设备
- 它是系统与外界连接的通道。通过控制器或适配器I/O总线连接。控制器和适配器的区别主要在于它们的包装方式。控制器是I/O设备本身或系统主印刷电路板(主板)上的芯片组。适配器是插在主板插槽上的卡片。两者的功能都在I/O总线和I/O在设备之间传递信息。
- 主存
- 它是存储程序和程序处理数据的临时设备。在物理上,内存是由动态随机访问存储器(DRAM)芯片组成;从逻辑上讲,内存可以看作是一个从零开始的大数组,每个字节都有相应的地址。
- 处理器
- 中央处理单位(CPU,简称处理器)是解释或执行主存储指令的引擎。处理器的核心是一个大小为一个字的存储设备(或寄存器),即程序计数器(PC)。 CPU可执行指令的操作:加载、储存、操作、跳转。
- 1.4.2 运行hello程序
- 1)通过键盘输入"./hello" 的字符串,shell 程序将输入的字符逐一读入寄存器,处理器将 hello 将字符串放入内存中。
- 2)然后执行一系列指令加载可执行文件 hello ,这些指令将 hello 从磁盘代码从磁盘复制到内存。
- 3)CPU会将 “hello , world\n” 字符串从内存复制到寄存器文件。然后从寄存器文件复制到显示设备,最后hello , world显示在屏幕上。
1.5 高速缓存至关重要
- 一般来说,大容量存储设备的访问速度比小容量慢,运行速度快的设备比低速设备贵。例如,在一个系统中,磁盘的容量通常是 TB 等级,内存容量一般为 GB 等级,磁盘的容量可能是内存 1000 倍。
- 对于处理器来说,从磁盘上读取一个单词的时间成本是从内存中读取的1000万倍。寄存器文件只能存储数百个字节信息,而内存可以存储数十亿字节信息(GB等级),从寄存器文件读取数据几乎比从内存读取快 100 倍。
- 针对处理器和内存之间的差异,系统设计师在寄存器文件和内存之间引入了高速缓存(cache),处理能力强的处理器一般有三级高速缓存 L1 cache , L2 cache 以及L3 cache。
- L1 cache 的访问速度与访问寄存器文件几乎一样快,容量大小为数万字节(KB 级别);L2 cache 访问速度为 L1 cache 五分之一,容量在几十万到几百万字节之间;L3 cache 同样的访问速度和容量更大 L2 cache 也比较慢。
1.6 存储设备形成层次结构
- 从这个层次结构来看,从上到下,设备的访问速度越来越慢,容量越来越大,每字节的造价也越来越便宜。这个层次结构的主要思想就是:上一层存储设备是下一层存储设备的高速缓存。例如,寄存器文件就是 L1 的高速缓存,L1 是 L2 的高速缓存,内存是磁盘的高速缓存等等。
1.7 操作系统管理硬件
-
可以把操作系统看成是应用程序和硬件之间插入的一层软件。
-
操作系统的两个基本功能:(1)防止硬件被失控的应用程序滥用;(2)向应用程序提供简单一致的的机制来控制复杂而又通常大不相同的低级硬件设备。主要通过进程、虚拟内存、文件来实现这两个功能。
-
文件是对I/O设备的抽象表示,虚拟内存是对主存和磁盘I/O设备的抽象表示,进程则是对处理器、主存和I/O设备的抽象表示。
-
1.7.1 进程
- 进程是操作系统对一个正在运行的程序的一种抽象。在一个系统上可以同时运行多个进程,而每个进程就好像独占地使用硬件。
- 并发运行是指一个进程的指令与另一个进程的指令是交错执行。
- 无论实在单核还是多核系统,,一个CPU看上去都像是在并发的执行多个进程,这是通过处理器在进程间来回切换的。操作系统的这种机制叫做上下文切换。
- 进程的上下文切换
-
1.7.2 线程
- 一个进程实际上由多个线程的执行单元组成,每个线程都运行在进程的上下文中,并共享同样的代码和全局的数据,线程一般比进程更高效。
-
1.7.3 虚拟内存
- 虚拟内存是一个抽象的概念,,它为每个进程提供了一种抽象,即每个进程独占的使用主存。每个进程看到的内存地址是一致的,称为虚拟地址空间。
- 逐步向上介绍
-
1.7.4 文件
- 文件就是字节序列,包括每个I/O设备、键盘、显示器,甚至网络都可以看成是文件。系统中所有的输入输出都是通过使用一小组称为Unix I/O的系统函数调用读写文件来实现的。
1.8 系统之间利用网络通信
1.9 重要主题
-
1.9.1 Amdahl 定律
Amdahl 定律(也叫阿姆达尔定律)的主要思想是:当我们对系统的某个部分加速时, 其对系统整体性能的影响取决于该部分的重要性和加速程度。
- 若系统执行某应用程序需要的时间为Told。假设系统某部分所需执行时间与该时间的比例为α,而该部分性能提升为k。即该部分初始所需时间为αTold,现在所需时间为(αTold)/k。因此,总的执行时间应为:
- 例题:
-
1.9.2 并发和并行
- 并发指一个同时具有多个活动的系统,并行指的是用并发来使一个系统运行得更快。
- 1、线程级并发
-
使用线程可以在一个进程中执行多个控制流。
-
当构建一个由单操作系统内核控制的多处理器组成的系统时,我们就得到了一个多处理器系统
-
多核处理器将多个CPU(核)集成到一个电路芯片上。
-
超线程也称为同时多线程,是一项允许一个CPU执行多个控制流的技术。
-
- 2、指令级并行
- 可以同时执行多条指令
- 如果处理器可以达到比一个周期一条指令更快的执行效率,就称之为超标量(super-scalar)处理器。大多数现代处理器都支持超标量操作。
- 3、单指令、多数据并行
- 允许一条指令产生多个可以并行执行的操作,这种方式称为单指令、多数据,即是SIMD并行
-
1.9.3 计算机系统中抽象的重要性
- 在处理器里,指令集架构提供了对实际处理器硬件的抽象。使用这个抽象,机器代码程序表现得就好像运行在一个一次只执行一条指令的处理器上。
- 文件是对I/O设备的抽象表示,虚拟内存是对主存和磁盘I/O设备的抽象表示,进程则是对一个正在运行的程序的抽象表示。虚拟机提供对整个计算机的抽象。
第 2 章 信息的表示和处理
2.1 信息存储
- 通常情况下,程序将内存视为一个非常大的数组,数组的元素是由一个个的字节组成,每个字节都由一个唯一的数字来表示,我们称为地址(address),这些所有的地址的集合就称为虚拟地址空间(virtual address space)。
- 一个字节是由8个位(bit)组成,在二进制表示法中,每一个位的值可能有两种状态:0或者1。当这8个位全为0时,表示一个字节的最小值;当这8个位全为1时,表示最大值。如果用十进制来表示,那么一个字节的取值范围就在0~255(包含0和255)之间。
- 2.1.1 十六进制表示
- 2.1.2 字数据大小
- 每台计算机都有一个字长,指明指针数据的标称大小。字长决定了虚拟地址空间的最大大小。即,对于一个字长为w位的机器而言,虚拟地址的范围为0~2w - 1,程序最多访问2w。
- C语言中,支持整数和浮点数多种数据格式,如图列式了不同数据类型在32位机器与64位机器上所占字节数的大小。
- 2.1.3 寻址和字节顺序
- 例如:一个int类型的变量 x(0x01234567),假设地址位于0x100处,由于int类型占4个字节,因此变量x被存储在地址为 0x100,0x101,0x102,0x103 的内存处。
- 内存中有两种方式存储对象:大端法(最高有效字节在最前面)、小端法(最低有效字节在最前面)
- 2.1.4 表示字符串
- C语言中的字符串被编码为以NULL(其值为0)字符结尾的字符数组,例如字符串 “abcde" ,这个字符串虽然只有5个字符,但是长度却为6,就是因为结尾字符的存在。
- 2.1.5 表示代码
- 2.1.6 布尔代数简介
- 详情参考离散数学中的介绍: http://www.vue5.com/discrete_mathematics/boolean_expressions_functions.html
- 2.1.7 C语言中的位级运算
- 事实上,我们在布尔运算中使用的那些符号就是C语言所使用的:|就是OR(或),&就是AND(与),~就是NOT(取反),^就是EXCLUSIVE-OR(异或)。
- 2.1.8 C语言中的逻辑运算
- C语言中的逻辑运算有:||、&&、!,分别对应命题逻辑中的OR、AND、NOT运算。但是逻辑运算认为所有非零的参数都表示TRUE,而参数0表示FALSE,返回1或者0。
- 2.1.9 C语言中的移位运算
- 逻辑左移、逻辑右移;算数左移、算数右移
- 算术左移和逻辑左移一样都是右边补0
- 逻辑右移很简单,只要将二进制数整体右移,左边补0即可
- 算术右移符号位要一起移动,并且在左边补上符号位,也就是如果符号位是1就补1符号位是0就补0
斜体的数字表示移位填充的值
- C语言没有明确规定使用移位过程中是逻辑右移还是算术右移,但是几乎所有的编译器/机器组合都对有符号数使用算术右移。
- 与C相比,Java对于如何进行右移有明确的定义。表达式x>>k会将x算术右移k位,而x>>>k会对x做逻辑右移。
- 如果移位量很大的话,可以直接采用公式kmodw计算实际的位移量(w是数据类型的位数,k为移位量)。
2.2 整数表示
- 2.2.1 整型数据类型
- C语言支持多种整型数据类型——表示有限范围的整数,如下图所示:
- 2.2.2 无符号数的编码
-
例如:
-
函数B2Uw是一个双射。即,反过来,我们称其为U2Bw(无符号数到二进制)。
-
- 2.2.3 补码编码
- 例如:
- 函数B2Tw是一个双射。函数T2Bw(补码到二进制)作为B2Tw的反函数。
- 2.2.4 有符号数与无符号数之间的转换
- C语言允许在各种不同的数字数据类型之间做强制类型转换。表达式(unsigned)x会将x的值转换成一个无符号数值,(int)x会将x装换成一个有符号数值。
- 例如:
- 在上图中可以看到T2U16(-12345)= -12345 + 216,同时T2Uw(-1) = -1 + 2w = UMaxw。
- 2.2.5 C语言中的有符号数与无符号数
- 2.2.6 扩展一个数字的位表示
- 2.2.7 截断数字
2.3 整数运算
- 2.3.1 无符号加法
- 2.3.2 补码加法
- 2.3.3 补码的非
- 对于w位的补码来说,TMinw是自己加法的逆,而对其他任何数值x都要-x作为其加法的逆。
- 补码非的位级表示 在C语言中,对于任意整数,计算表达式-x和~x+1得到的结果完全一样。 下面是一些示例,字长为4:
- 2.3.4 无符号乘法
- 在范围0≤x,y≤2w - 1内的整数相乘的取值范围为0到(2w - 1)2 = 2w - 2w+1 之间。
- 将一个无符号数截断为w位等价于计算该值模2w,得到:
- 2.3.5 补码乘法
- 在范围-2w-1 ≤x,y≤2w-1 - 1内的整数相乘的取值范围为-2w-1 · (2w-1 - 1)=-22w-2 +2w-1到 - 2w-1 · - 2w-1 = - 22w-2之间。
- 将一个补码数截断为w位等价于计算该值模2w,得到:
- 示例:
- 2.3.6 乘以常数
- 由于乘法指令的执行需要多个时钟周期,很多C语言的编译器试图用移位、加法以及减法来代替整数乘法的操作。
- 首先我们看一下乘以2的幂的情况。x乘以2,对应于左移一位;x乘以4,对应于左移两位;x乘以2的k次方,就对应左移k位。
- 例如:x乘以14,14的二进制表示如图所示。
- 2.3.7 除以2的幂
- 对于除以2的幂也可以用移位来实现,不过除法的移位采用的是右移,而不是左移。
- 除以2的幂无符号数除法
- 示例:
- 示例:
2.4 浮点数
- 2.4.1 二进制小数
- 二进制小数示例:
- 2.4.2 IEEE浮点表示
-
V = (-1)s x M x 2e
-
这个表达式,涉及三个变量:符号s、阶码E和尾数M。
-
采用移码表示阶码E ,将浮点数的指数真值e变成阶码E时, 应将指数e加上一个固定的偏移值127(01111111),即 E=e+127。
-
一个规格化的32位浮点数x的真值表示为 x=(-1)S×(1.M)×2E-127 e=E-127
-
规格化的64位浮点数x的真值为: x=(-1)s×(1.M)×2E-1023 e=E-1023
-
IEEE754标准(规定了浮点数的表示格式,运算规则等) 规则规定了单精度(32)和双精度(64)的基本格式。 规则中,尾数用原码,指数用移码(便于对阶和比较)
- 例如C语言中float类型的变量占4个字节,32个比特位,这32个比特位被划分成3个字段来解释,具体表示如图所示。
- 对于64位双精度浮点数,其二进制位与浮点数的关系如图所示。
-
例题:
-
当表示规格化的值时,其中阶码字段的取值范围如图所示。
-
当阶码字段的二进制位全为0时,所表示的是非规格化的值,关于非规格化的数有两个用途: 一是提供了表示数值0的方法,当符号位s等于0,阶码字段全为0,小数字段也全为0时,此时表示正零。当符号位s等于1,阶码字段全为0,小数字段也全为0时,此时表示负零。根据IEEE的浮点规则,正零和负零在某些方面被认为不同,而其他方面是相同的。
-
最后一类数值是当指阶码全为1的时候出现的。当小数域全为0时,得到的值表达无穷,当s=0时是+∞,当s=1时是-∞。
-
- 2.4.3 数字示例
- 2.4.4 舍入
- 对于值x,可能无法用浮点形式来精确的表示,因此我们希望可以找到“最接近的值 x’ 来代替x,这就是舍入操作的任务。
- IEEE浮点格式定义了四种不同的舍人方式,分别是:向偶数舍入、向零舍入、向下舍入以及向上舍入。
- 2.4.5 浮点运算
- 列如图中的两个表达式,其中表达式1的计算结果等于0.0,而表达式二的计算结果等于3.14。
- 这是由于表达式1在计算3.14与1e10相加时,对结果进行了舍入,值3.14会丢失,因此,对于浮点数的加法是不具有结合性的。
- 同样由于计算结果可能发生溢出,或者由于舍人而失去精度,导致浮点数的乘法也不具有结合性。同样也不具备分配性。
- 2.4.6 C语言中的浮点数
- C语言提供了两种不同的浮点数据类型:单精度float类型和双精度double类型。当int,float、double不同数据类型之间进行强制类型转换时,得到的结果可能会超出我们的预期。
第 3 章 程序的机器级表示
3.1 历史观点
3.2 程序编码
- 假设有个C程序,有两个文件p1.c和p2.c。我们用Unix命令行编译这些代码:
其中gcc指的就是GCC编译器,它是linux系统上默认的编译器,其中编译选项-Og是用来告诉编译器生成符合原始C代码整体结 构的机器代码。在实际项目中,为了获得更高的性能,会使用-01或者-02,甚至更高的编译优化选项。但是使用高级别的优化产生的代码会严重变形,导致产生的机器代码与最初的源代码之间的关系难以理解,这里为了理解方便,因此选择-Og这个优化选项。 -o后面跟的参数p表示生成可执行文件的文件名。linux> gcc -Og -o p p1.c p2.c
- 3.2.1 机器级代码
- 两种抽象: 1、有指令集体系机构或指令集架构来定义机器级程序的格式和行为;2、机器级程序使用的内存地址是虚拟地址。
- x86-64的机器代码和原始的C代码差别非常大。一些通常对C语言程序员隐藏的处理状态都是可见的: 1、(称为“PC”,在x86-64中用%rip表示)给出将要执行的下一条指令在内存中的地址。 2、包含16个命名位置,分别存储64位的值。 3、保存着最近执行的算术或逻辑指令的状态信息。 4、一组向量寄存器可以存放一个或多个整数或浮点数值。
- 3.2.2 代码示例
- 假设一个C语言代码文件mstore.c,如下所示:
- 使用以下命令可以生成对应的汇编文件mstore.s:
linux> gcc -Og -S mstore.c
- 汇编代码文件包含各种声明,包括下面几行:
- 使用以下命令可以生成对应的汇编文件mstore.o:
linux> gcc -Og -c mstore.c
- 这就是产生目标代码文件mstore.o,它是二进制格式,无法直接查看。机器执行的程序只是一个字节序列,它是对一系列指令的编码。
- 要查看机器代码文件的内容,有一类称为反汇编器的程序非常有用。这些程序根据机器代码产生一种类似于汇编代码的格式。使用以下命令行可以实现:
linux> objdump -d mstore.o
- 结果如下:
- 3.2.3 关于格式的注解
- 首先以源文件mstore.c为例,看一下C代码与汇编代码之间的关系,使用以下命令可以生成对应的汇编文件mstore.s:
linux> gcc -Og -S mstore.c
- 其中以.开头的行都是指导汇编器和链接器工作的伪指令,也就是说我们完全可以忽略这些以.开头的行,删除了无关的信息之后,剩余这些汇编代码与源文件中C代码是相关的。
3.3 数据格式
3.4 访问信息
- 一个x86-64的中央处理单元(CPU)包含一组16个存储64位值的通用目的寄存器。这些寄存器用来存储整数数据和指针。
- 如图所示:
- 3.4.1 寻址方式
- 3.4.2 数据传送指令
- 下面的MOV指令示例给出了源和目的类型的五种可能的组合。第一个是源操作数,第二个是目的操作数:
- 3.4.3 数据传送示例
- 3.4.4 压入和弹出栈数据
- 栈在处理数据的过程中遵循“后进先出”的原则。栈向下增长,这样一来,栈顶元素的地址是所有栈中元素地址最低的。栈指针%rsp保存着栈顶元素的地址。
3.5 算数和逻辑操作
- 3.5.1 加载有效地址
- 加载有效地址指令leap实际上是movq指令的变形。它的指令形式是从内存读数据到寄存器,但实际上它根本没有引用内存。例如,如果寄存器%rdx的值为x,那么指令leap7(%rdx,%rdx,4),%rax 将设置寄存器%rax的值为7 + x + 4x = 5x + 7。
- 看下面的C程序:
- 编译时产生的汇编代码如下:
- 3.5.2 一元和二元操作
- 一元操作只有一个操作数,既是源又是目的。
- 二元操作,源操作数是第一个,目的操作数是第二个。
- 3.5.3 移位操作
- 移位量可以是立即数。x86-64中,移位操作对w位长的数据值进行操作,移位量是由%cl寄存器的低m位决定的。 -3.5.5 特殊的算术操作
- 下图描述的是支持产生两个64位数字的全128位乘积以及整数除法的指令。
- 上图中的两条乘法指令都要求一个参数必须在寄存器%rax中,而另一个作为指令的源操作数给出。然后乘积存放在寄存器%rax(高64位)和%rax(低64位)中。
- 有符号除法指令idivl将寄存器%rdx(高64位)和%rax(低64位)中的128位数作为被除数,而除数作为指令的操作数给出。指令将商存储在寄存器%rax中,将余数存储在寄存器%rdx中。
- 除数常常是一个64位的值,这个值应该存放在%rax中,%rdx的位应该设置为全0(无符号运算)或者%rax的符号位(有符号运算)。后面的操作可以用指令cqto来完成,这条指令不需要任何操作数-----它隐含读出%rax的符号位,并将它复制到%rdx的所有位。
- 乘法运算例子: C代码 汇编代码
3.6 控制
- 3.6.1 条件码
- CPU还维护一组单个位的条件码。可以检测这些寄存器来执行条件分支指令。最常用的条件码有:
- 3.6.2 访问条件码
- 条件码通常不会直接读取,常见的使用方法有三种: 1)可以根据条件码的某种组合,将一个字节设置为0或者1; 2)可以条件跳转到程序的某个其他的部分; 3)可以有条件的传送数据。
- 一条set指令的目的操作数是低位单字节寄存器元素之一,或是一个字节的内存位置,指令会将这个字节设置成0或者1.一个计算C语言表达式a<b的典型指令序列如下所示,这里的a和b都是long类型:
- 3.6.3 跳转指令
- 无条件跳转指令jmp
- 3.6.4 跳转指令的编码
- 汇编器产生的“.o”格式的反汇编版本如下:
- 这例子说明,当执行PC相对寻址时,程序计数器的值是跳转指令后面的那条指令的地址,而不是跳转指令本身的地址。
- 3.6.5 用条件控制实现条件分支
- 3.6.6 用条件传送来实现条件分支
- 基于条件传送数据的代码会比基于条件控制转移的代码性能要好。这里涉及到现代处理器通过流水线来获得高性能。当遇到条件跳转时,处理器会根据分支预测器来猜测每条跳转指令是否执行,当发生错误预测时,会浪费大量的时间,导致程序性能严重下降。
- 下图列举了x86-64上一些可用的条件传送指令。每条指令都有两个操作数:源寄存器或者内存地址S,和目的寄存器R。
- 3.6.7 循环
- 1. 示例:
- 2.
- 3.
- 3.6.8 switch 语句
3.7 过程
- 过程形式多样:函数、方法、子例程、处理函数等等。
- 假设过程P调用过程Q,Q执行后返回到P。这些动作包括下面一个或多个机制: 传递控制:设置PC的起始地址。 传递数据:P向Q传递参数,Q向P返回值。 分配和释放内存:调用Q分配内存,返回前,释放内存。
- 3.7.1 运行时栈
- 程序可以用栈来管理它的过程所需要的存储空间,栈和程序寄存器存放着传递控制的数据、分配内存所需的信息。
- x86-64的栈向低地址方向增长,而栈指针%rsp指向栈顶元素。
- 3.7.2 转移控制
- call指令相当于C语言中的函数调用
- 3.7.3 数据传送
- x86-64中,可以通过寄存器最多传递6个整型参数。寄存器的使用是有特殊顺序的,寄存器使用的名字取决于要传递的数据类型的大小,如下图所示,会根据参数在参数列表中的顺序为它们分配寄存器。
- 如果有一个函数参数超过6个,超出6个的部分就要通过栈来传递。
- 3.7.4 栈上的局部存储
- 局部数据必须存放在内存中,常见的情况包括: 寄存器不足存放所有本地数据。 对一个局部变量使用地址运算符‘&’,因此必须能够为它产生一个地址。 某些局部变量是数组或结构,因此必须通过数组或结构引用被访问到。
- 3.7.5 寄存器中的局部存储空间
- 为了防止被调用者会覆盖使用的寄存器的值,x86-64采用了统一的寄存器使用惯例。
- 根据惯例,寄存器%rbx、%rbp和%r12~%r15被划分为被调用者保存寄存器。P调用Q,Q保存这些寄存器的值不变。
- 所有其他的寄存器,除了栈指针%rsp,都分类为调用者保存寄存器。这意味着任何函数都能修改它们,所以P在调用过程Q时,必须好这些寄存器的值不被改变。
- 3.7.6 递归过程
3.8 数组分配和访问
- 3.8.1 基本原则
- 对于数据类型T和整型常数N,声明如下: T A[N]; 起始位置表示为xA。这个声明有两个效果。首先,它在内存分配了L·N字节的连续区域,这里L是数据类型T的大小(单位为字节)。其次,引入了标识符A。 示例: 这些声明会产生带下列参数的数组:
- 3.8.2 指针运算
- 单操作数操作符‘&’和‘*’可以产生指针和间接引用指针。
- 如下表例子
- 3.8.3 嵌套数组
- 声明 int A[5][3]等价于下面的声明
- 声明如下数组: T D[R][C]; 它的数组元素D[i][j]的内存地址为: &D[i][j] = xD + L(C · i + j)
- 定长数组
- 假设如下声明一个定长数组:
- 3.8.5 变长数组
- 程序员需要变长数组时不得不用malloc或calloc这样的函数为这些数组分配存储空间。在变长数组的C版本中,我们可以将一个数组声明如下:
- 它可以作为一个局部变量,也可作为一个函数参数,通过对表达式expr和expr2求值来确定数组的维度。例如要访问n×n数组的元素i,j,我们可以写一个如下函数: GCC为这个引用函数产生的代码如下所示:
3.9 异质的数据结构
- 结构,用关键字struct来声明,将对各对象集合到一个单位中;联合,用关键字union来声明,允许用几种不同的类型来引用一个对象。
- 3.9.1 结构
- 考虑下面这样的结构声明: 这个结构包括四个字段:两个4字节int、一个由两个类型为int的元素组成的数组和一个8字节整型指针,总共是24个字节:
- 3.9.2 联合
- 联合提供了一种方式,能够规避C语言的类型系统,允许以多种类型来引用一个对象。联合与结构声明语法一样,但语义相差比较大。它们是用不同的字段来引用相同的内存块》
- 考虑下面的声明:
- 在一台x86-64Linux机器上编译时,字段的偏移量、数据类型S3和U3的完整大小如下: 联合体中的所有字段共享同一存储区域,因此联合体的大小取决于它最大字段的大小。
- 3.9.3 数据对齐
- 无论数据是否对齐,x86-64硬件都能正确工作。不过,Intel还是建议要对齐数据以提高内存系统的性能。对齐原则是任何K字节的基本对象的地址必须是K的倍数。
- 例如,考虑下面的结构声明: 假设编译器用最小的9字节分配,画出来的图是这样的: 显然这样的分配是不符合对齐原则的。取而代之地,编译器在字段c和j之间插入一个3字节的间隙: 结果整个结构的大小为12字节,但是保证了对齐原则。
- 另外,编译器结构的末尾可能需要一些填充,这样结构数组的每个元素都满足对齐要求。例如,如下声明: 编译器会为结构S2分配12字节,最后3个字节是浪费的空间:
3.10 在机器级程序中将控制与数据结合起来
- 3.10.1 理解指针
- 这个类型表明该指针指向的是哪一类对象。例如: 变量ip是一个指向int类型对象的指针,而cpp指针指向的对象自身就是一个指向char类型对象的指针。通常,如果对象类型为T,那么指针类型为T *。特殊的void * 类型代表通用指针。
- 这个值是某个指定类型的对象的地址。特殊的NULL(0)值表示该指针没有指向任何地方。
- &运算符额机器代码实现常常用这条指令来计算表达式的值。
- * 其结果是一个值,它的类型与该指针的类型一致。间接引用是用内存引用来实现的,要么是存储到一个指定的地址,要么是从指定的地址读取。
- 一个数组的名字可以像一个指针变量一样引用(但是不能修改)。例如a[3]与*(a+3)有一样的效果。
- 强制类型转换的一个效果是改变指针运算的伸缩。例如:p是一个char * 类型的指针,那么表达式(int *)p+7计算为p + 28,而(int *)(p+7)计算为p+7。
- 例如,一个函数用下面的原型定义: 然后,可以声明一个指针fp,将它赋值为这个函数,代码如下: 然后用这个指针来调用函数: 函数指针的值是该函数机器代码表示中的第一条指令的地址。
- 3.10.2 应用:使用GDB调试器
- 用下面的命令来启动GDB:
linux> gdb prog
- 3.10.3 内存越界引用和缓存区溢出
- 缓冲区溢出,通常,在栈中分配某个字符数组来保存一个字符串,但是字符串的长度超出了为数组分配的空间。
- 缓冲区溢出的一个致命使用就是让程序执行它本来不愿意执行的函数。通常,输入给程序一个字符串,这个字符串包含一些可执行代码的字节编码,称为攻击代码(exploit code),还有一些字节会用一个指向攻击代码的指针覆盖返回地址。那么ret指令的效果就是跳转到攻击代码。
- 3.10.4 对抗缓冲区溢出攻击
- 1. 栈随机化的思想使得栈的位置在程序每次执行时都有变化。实现方式:程序开始时,在栈上分配一段0~n字节之间的随机大小的空间。 在Linux系统中,栈随机化已经变成了标准行为。这类技术称为地址空间随机化,简称ASLR 一个攻击可以用蛮力克服随机化,反复用不同的地址进行攻击。 在实际的攻击代码中插入很长的一段nop(读作“no op”,no operating的缩写)指令。执行这种指令除了可以使程序计数器PC加1之外没有任何效果。这个序列用术语是“空操作雪橇(no sled)”,意思是程序会滑过这个序列。
- 2. 计算机的第二道防线是能够检测到何时栈已经被破坏。在C语言中没有可靠的方法来防止对数组的越界写。但是,能够在发生了越界写的时候,在造成任何有害结果之前,尝试检测到它。 最近的GCC版本在产生的代码中加入了一种栈保护者(stack protector) 机制,来检测缓冲区越界。其思想是在栈帧中任何局部缓冲区与栈状态之间存储一个特殊的==金丝雀(canary) ==值,也称为哨兵值(guard value),是在程序每次运行时随机产生的,因此,攻击者没有简单的办法能够知道它是什么。在恢复寄存器状态和从函数返回前,程序检查这个金丝雀的值是否被该函数的某个操作或者该函数调用的某个函数的操作改变。如果是的,则程序中止。
- 2. 这种方法是限制哪些内存区域能够存放可执行代码。在典型的程序中,只有保存编译器产生的代码的那部分内存才需要是可执行的,其他部分可以限制为只允许读和写。许多系统允许控制三种访问形式:读(从内存读数据)、写(写存储数据到内存)和执行(将内存的内容看作机器级代码)。以前x86体系结构将读和执行访问控制合并成1位的标志,这样任何被标记为可读的页也都是可执行的,栈上的字节也是可执行的。限制一些页可读但是不可执行的机制会带来严重的性能损失。 AMD为它的64位处理器的内存保护引入了“NX”(No-Execute,不执行)位,将读和执行访问模式分开,Intel也跟进了。有了这个特性,栈可以标记为可读和可写,但是不可执行,而检查页是否可执行由硬件来完成,效率上没有损失。 上面所讲的技术——随机化、栈保护和限制哪部分内存可以存储可执行代码——是用于最小化程序缓冲区溢出攻击漏洞三种最常见的机制。它们带来额性能代价少、不需要程序员做任何努力,它们组合起来变得更加有效。但是还是仍然有方法攻击计算机 。
- 3.10.5 支持变长栈帧
- 有些函数,需要的局部存储是变长的。例如标准库函数alloca,可以在栈上分配任意字节数量的存储。
- 为了管理变长栈帧,x86-64代码使用寄存器%rbp作为帧指针(frame pointer)(有时称为基指针(base pointer),这也是%rbp中bp两个字母的由来)
- 在较早的x86代码中,每个函数调用都使用了帧指针。而现在,只在栈帧长可变的情况下才使用。
3.11 浮点代码
- 处理器的浮点体系结构包括多个方面,会影响对浮点数据操作的程序如何被映射到机器上,包括:
- 如何存储和访问浮点数值。通常是通过某种寄存器方式来完成。
- 对浮点数据操作的指令
- 向函数传递浮点参数和从函数返回浮点数结果的规则。
- 函数调用过程中保护寄存器的规则——例如,一些寄存器被指定为被调用者保护,而其他的被指定为被调用者保存。
- 如下图所示,AVX浮点体系结构允许数据存储正在16个YMM寄存器中,当对标量数据操作时,这些寄存器只保存浮点数,而且只使用低32位或64位。汇编代码用寄存器的SSE XMM寄存器名字%xmm0~%xmm15来引用它们,每个XMM寄存器都是对应YMM寄存器的低128位(16字节)。
- 3.11.1 浮点传送和转换操作
- 如下图给出了一组在内存和XMM寄存器之间以及从一个XMM寄存器到另一个不做任何转换的传送浮点数的指令。
- 下面是一个不同浮点传送操作的例子,考虑以下C函数
- 与之相关的x86-64汇编代码为
- 如下图中的指令从一个XMM寄存器或内存中读出的浮点值进行转换,并将结果写入一个通用寄存器(例如:%rax、%ebx等)。把浮点值转换成整数时,指令会进行截断(truncation),把值向0进行舍入,这是C和大多数其他编程语言的要求。
- 如下图中的指令把整数转换成浮点数。它们使用的是不同常见的三操作数格式,有两个源和一个目的。第一个操作数读自预内存或一个通用目的寄存器。这里可以忽略第二个操作数,因为它的值之后影响结果的高位字节,而我们的目标必须是XMM寄存器。
- 3.11.2 过程中的浮点代码
- 在x86-64中,XMM寄存器用来向函数传递浮点参数,以及从函数返回浮点值。
- XMM寄存器%xmm0~%xmm7最多可以传递8个浮点参数,额外的浮点参数通过栈传递。
- 函数使用寄存器%xmm0来返回浮点值。
- 所有的XMM寄存器都是调用者保存的。被调用者可以不用保存这些寄存器中任意一个。
- 当函数包含指针、整数和浮点数混合的参数时,指针和整数通过寄存器传递,而浮点值通过XMM寄存器传递。
- 例子:
- 3.11.3 浮点运算操作
- 下图描述了一组执行算术运算的标量AVX2浮点指令。每条指令有一个(S1)或两个(S1,S2)源操作数,和一个目的操作数D。第一个源操作数S1可以是一个XMM寄存器或一个内存位置。第二个源操作数和目的操作数都必须是XMM寄存器。每个操作都有一条针对单精度的指令和一条针对双精度的指令,结果存放在目的寄存器中。
- 3.11.4 定义和使用浮点常数
- 和整数运算操作不同,AVX浮点操作不能以立即数值作为操作数。相反,编译器必须为所有的常量值分配和初始化存储空间。然后代码再把这些值从内存读入。
- 3.11.5 在浮点代码中使用位级操作
- 3.11.6 浮点比较操作
- AVX2提供了两条用于比较浮点数值的指令:
- 浮点比较指令会设置三个条件码:零标志位ZF、进位标志位CF和奇偶标志位PF。根据惯例,C语言中如果有个参数为NaN,就认为比较失败了,当x为NaN时,比较x==x都会得到0。条件码的设置条件如下:
- 3.11.7 对浮点代码的观察结论
- 用AVX2为浮点数上的操作产生的机器代码风格类似于为整数上的操作产生的代码风格。它们都使用一组寄存器来保存和操作数据值,也都使用这些寄存器来传递函数参数。同时,AVX2代码包括许多只执行整数运算的函数更加不同的指令和格式,还能在封装好的数据上执行并行操作,使计算执行更快。
第 4 章 处理器体系结构
4.1 Y86-64 指令集体系结构
- 4.1.1 程序员可见的状态
- 4.1.2 Y86-64 指令
- x86-64的movq指令分成了4个不同的指令:irmovq、rrmovq、mrmovq和rmmovq,分别显示地指明源和目的的格式。源可以是立即数(i)、寄存器(r)或内存(m)。
- 4.1.3 指令编码
- 指令的字节级编码,每条指令的第一个字节表明指令的类型。这个字节分为两个部分,每部分4位:== 高4位是代码部分,低4位是功能部分==。如上图所示,代码值为0~0xB。功能值只有在一组相关指令共用一个代码时才有用。图4-3给出了整数操作、分支和条件传送指令的具体编码。
- 如图4-4所示,15个程序寄存器中每个都有一个相对应的范围在0到0xE之间的寄存器标识符(register ID)。Y86-64中的寄存器编号跟x86-64中的相同。
- 4.1.4 Y86-64 异常
- 4.1.5 Y86-64 程序
- 图4-6 给出了下面这个C函数的x86-64和Y86-64汇编代码:
- x86-64 代码是由GCC编译器产生的。Y86-64 代码与之类似,但有以下不同点:
- Y86-64 将常数加载到寄存器,因为它在算术指令中不能使用立即数。
- 要实现从内存读取一个数值并将其与一个寄存器相加,Y86-64代码需要两条指令,而x86-64只需要一条addq指令。
- 我们手工编写的Y86-64实现有一个优势,即subq指令同时还设置了条件码,因此GCC生成代码中的testq指令就不是必须的。不过为此,Y86-64 代码必须用addq指令在进入循环之前设置条件码。
- 4.1.6 一些Y86-64 指令的详情
- 大多数Y86-64 指令是以一种直接明了的方式修改程序状态的,不过,两个特别的指令组合需要特别注意一些。
- pushq指令会把栈指针减去8,并且将一个寄存器值写入内存中。因此当执行pushq%rsp指令时,处理器的行为是不确定的。通常有两种不同的约定:1)压入%rsp的原始值,2)压入减去8的%rsp的值。
4.2 逻辑设计和硬件控制语言HCL
- 4.2.1 逻辑门
- 4.2.2 组合电路的HCL布尔表达式
- 将很多的逻辑门组合成一个网,就能构建计算块,称为组合电路。构建这些网友以下限制:
- 每个逻辑门的输入必须连接到下述选项之一:1)一个系统输入(称为主输入),2)某个存储单元的输出,3)某个逻辑门的输出。
- 两个或多个逻辑门的输出不能连接在一起。否则它们可能会使线上的信号矛盾,可能会导致一个不合法的电压或电路故障。
- 这个网必须是无环的。也就是在网中不能形成一个回路。
- 图4-10用HCL来写这个网的函数就是: bool eq = (a && b) || (!a && !b);
- 图4-11的组合电路称为==多路复用器(multiplexor,通常称为“”MUX)。HCL表达式为: bool out = (s && a) || (!s && b);
- HCL 表达式与C语言中的逻辑表达式有以下区别:
- 将很多的逻辑门组合成一个网,就能构建计算块,称为组合电路。构建这些网友以下限制:
- 4.2.3 字级的组合电路和HCL整数表达式
- 执行字级计算的组合电路根据输入字的各个位,用逻辑门来计算出字的各个位。例如下图中的一个组合电路,它测试两个64位字A和B是否相等。也就是,当且仅当A的每一位都和B的相应位相等时,输出才为1。
- 在上图中右边所示,在画字级电路的时候,我们用中等粗度的线来表示携带字的每个位的线路,而用虚线来表示布尔信号结果。
- 图4-13是字级的多路复用器电路。这个电路根据控制输入位s,产生一个64位的字Out,等于两个输入字A或者B中的一个。这个电路由64个相同的子电路组成,每个子电路的结构类似图4-11中的位级多路复用器。不过这个字级的电路并没有简单地复制64次位级多路复用器,它只产生一次!s,然后再每个位的地方都重复使用它,从而减少反相器或非门(inverters)的数量。
- 在HCL中,多路复用函数是用情况表达式来描述的。情况表达式的通用格式如下:
- 同C的switch语句不同,我们不要求不同的选择表达式之间互斥。允许不互斥的选择表达式使得HCL代码的可读性更好。实际的硬件多路复用器的信号必须互斥,它们要控制哪个输入字应该被传送到输出,就像图4-13中的信号s和!s。例如,图4-14中所示的四路复用器的图。这个电路根据控制信号s1和s0,从四个输入字A、B、C和D中选择一个,将控制信号看作一个两位的二进制数。我们可以用HCL来表示这个电路,用布尔表达式描述控制位模式的不同组合:
- word Out4 = [ !s1 && !s0 : A; #00 !s1 : B; #01 !s0 : C; #10 1 : D; #11 ]
- 最后一个例子,假设想设计一个逻辑电路来找一组字A、B和C中最小值,如下图所示:
- 用HCL来表达就是:
- **算术/逻辑单元(ALU)**是一种很重要的组合电路。图4-15是它的一个抽象的图示。这个电路有三个输入:标号为A和B的两个数据输入,以及一个控制输入。这个ALU中画的四个操作对应于Y86-64指令集支持的四种不同的整数操作,而控制值和这些操作的功能码相对应(图4-3)。操作数的顺序是输入B减去输入A,这是为了使这个顺序与subq指令的参数顺序一致。
- 4.2.4 集合关系
- 在处理器的设计中,很多时候都需要将一个信号与许多可能匹配的信号做比较,以此来检测正在处理某个指令代码是否属于某一类指令代码。
- 例子: 假设想从一个两位信号code中选择高位和低位来为图4-14中的四路复用器产生信号s1和s0,
- 如下图所示:
- 在这个电路中,两位信号code就可以用来控制4个数据字A、B、C和D做选择。根据可能的code值,可以用相等测试来表示信号s1和s0的产生:
- 可以注意到:当code在集合{2,3}中时,s1为1,而code在集合{1,3}中时,s0为1:
- 因此更简洁的表达式为:
- 4.2.5 存储器和时钟
- 组合电路不存储任何信息。只是简单的响应输入,产生等于输入的某个函数的输出。为了产生时序电路,必须引入按位存储信息的设备。存储设备都是由同一个时钟控制的,时钟是一个周期性信号,决定什么时候要把新值加载到设备中。以下两种存储器设备:
- 图4-16更详细的说明了一个硬件寄存器是如何工作的。大多数时候,寄存器都保持在稳定状态(用x表示),产生的输出等于它当前状态。信号沿着寄存器前面的组合逻辑传播,这时,产生了一个新的寄存器输入(用y表示),只要时钟是低电位的,寄存器的输入仍然保持不变。当时钟变成高电位的时候,输入信号就加载到寄存器中,成为下一个状态y,直到下一个时钟上升沿,这个状态就一直是寄存器的新输出。每当每个时钟到达上升沿时,值才会从寄存器的输入传送到输出。Y86-64处理器会用会用时钟寄存器保存程序计数器(PC)、条件码(CC)和程序状态(Stat)。
4.3 Y86-64的顺序实现
- 首先,描述一个称为SEQ(“se-quential”顺序的)的处理器。每个时钟周期上,SEQ执行处理一条完整的指令所需的所有步骤。
- 4.3.1 将处理组织成阶段
- 处理一条指令包括很多操作。将它们组织成某个特殊的阶段序列,下面是关于各个阶段以及各阶段内执行操作的简略描述:
- 处理器无限循环,执行这些阶段。在我们简化的实现中,发生任何异常时,处理器就会停止:它执行 halt 指令或非法指令,或它试图读或者写非法地址。在更完整的设计中,处理器会进人异常处理模式,开始执行由异常的类型决定的特殊代码。
- 从前面的讲述可以看出,执行一条指令是需要进行很多处理的。我们不仅必须执行指令所表明的操作,还必须计算地址、更新栈指针,以及确定下一条指令的地址。幸好每条指令的整个流程都比较相似。因为我们想使硬件数量尽可能少,并且最终将把它映射到一个二维的集成电路芯片的表面,在设计硬件时,一个非常简单而一致的结构是非常重要的。降低复杂度的一种方法是让不同的指令共享尽量多的硬件。
- 图4-18给出了对OPq(整数和逻辑运算)、rrmovq(寄存器-寄存器传送)和irmovq(立即数-寄存器传送)类型的指令所需的处理。
- 4.3.2 SEQ硬件结构
- 图4-22 给出了一个能执行这些计算的硬件结构的抽象表示。程序计数器放在寄存器中,在图中左下角(标明为“PC”)。然后,信息沿着线流动,先向上,再向右。
- 硬件单元与各个处理阶段相关联:
- :将程序计数器寄存器作为地址,指令内存读取指令的字节。 PC 增加器(PC incremcntcr )计算 valP ,即增加了的程序计数器。
- :寄存器文件有两个读端口 A 和 B , 从这两个端口同时读寄存器值 valA 和 valB。
- :执行阶段会根据指令的类型,将算术/逻辑单元( ALU )用于不同的目的。对整数操作,它要执行指令所指定的运算。对其他指令,它会作为一个加法器来计算增加或减少栈指针,或者计算有效地址,或者只是简单地加 0 ,将一个输人传递到输出。条件码寄存器( CC )有三个条件码位。 ALU 负责计算条件码的新值。当执行条件传送指令时,根据条件码和传送条件来计算决定是否更新目标寄存器。同样,当执行一条跳转指令时,会根据条件码和跳转类型来计算分支信号 cnd 。
- :在执行访存操作时,数据内存读出或写人一个内存字