《深入理解计算机系统》学习笔记

文章目录

第1章:计算机系统漫游

所有计算机系统都有相似的硬件和软件组件,它们又执行着相似的功能。

#include <stdio.h>

int main()
{
  printf("hello, world\n");
  return 0;
}

我们将以一个hello程序的生命周期来开始对系统的学习——从它被程序员创建开始,到在系统上运行,输出简单的消息,然后终止。

1.1 信息就是位+上下文

源程序实际上就是一个由值0和1组成的位(比特)序列,8个位被组织成一组,称为字节。每个字节表示程序中的某些文本字符。

大部分的现代计算机系统都使用ASCII标准来表示文本符,这种方式实际上就是用一个唯一的单字节大小的整数值来表示每个符号。像hello.c这样只由ASCII字符构成的文件称为文本文件,所有其他文件都称为二进制文件。

区分不同数据对象的唯一方法是我们读到这些数据对象时的上下文。比如,在不同的上下文中,一个同样的字节序列可能表示一个整数、浮点数、字符串或者机器指令。

C语言是系统级编程的首选,同时它也非常适用于应用级程序的编写。然而,它也并非适用于所有的程序员和所有的情况。C语言的指针是造成程序员困惑和程序错误的一个常见原因。同时,C语言还缺乏对非常有用的抽象的显式支持,例如类、对象和异常。像C++和Java这样针对应用级程序的新程序语言解决了这些问题。

1.2 程序被其他程序翻译成不同的格式

为了在系统上运行hello.c程序,每条C语句都必须被其他程序转化成一系列的低级机器语言指令。然后这些指令按照一种称为可执行目标程序的格式打包好,并以二进制磁盘文件的形式存放起来。目标程序也称为可执行目标文件。

linux> gcc -o hello hello.c

上面的语句将源程序文件翻译成一个可执行的目标文件hello。这个翻译过程可分为四个阶段完成。

IMG_7332
  • 预处理阶段。预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。比如hello.c中第1行的#include <stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。结果就得到了另一个C程序,通常是以.i作为文件扩展名。

  • 编译阶段。编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。该程序包含函数main的定义,如下所示:

    main:
    	subq $8, %rsp
    	movl $.LCO, %edi
    	call puts
    	movl $0, %eax
    	addq $8, %rsp
    	ret
    

    定义中2~7行的每条语句以一种文本格式描述了一条低级机器语言指令。汇编语言为不同高级语言的不同编译器提供了通用的输出语言。

  • 汇编阶段。接下来,汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatable object program)的格式,并将结果保存在目标文件hello.o中。hello.o是一个二进制文件,它包含的17字节是函数main的指令编码。如果用文本编辑器打开hello.o文件,将看到一堆乱码。

  • 链接阶段。hello程序调用了printf函数,它是每个C编译器都提供的标准C库中的一个函数。printf函数存在于一个名为printf.o的单独的预编译好了的目标文件中,而这些文件必须以某种方式合并到我们的hello.o程序中。链接器(ld)就负责处理这种合并。结果就得到hello文件,它是一个可执行目标文件(或称为可执行文件),可被加载到内存中,由系统执行。

GUN环境包括EMACS编辑器、GCC编译器、GDB调试器、汇编器、链接器、处理二进制文件的工具以及其他一些部件。GCC编译器已经发展到支持许多不同的语言,能够为许多不同的机器生成代码。支持的语言包括C、C++、Fortran、Java、Pascal、面向对象C语言(Objective-C)和Ada。

1.3 了解编译系统如何工作是大有益处的

有一些重要原因促使程序员必须知道编译系统是如何工作的:

  • 优化程序性能。
  • 理解链接时出现的错误。
  • 避免安全漏洞。

1.4 处理器读并解释储存在内存中的指令

1.4.1 系统的硬件组成
IMG_7335
1.总线

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

2.I/O设备

I/O(输入/输出)设备是系统与外部世界的联系通道。每个I/O设备都通过一个控制器或适配器与I/O总线相连。区别主要在于它们的封装方式。控制器是I/O设备本身或者系统的主印制电路板(称为主板)上的芯片组。而适配器则是一块插在主板插槽上的卡。

3.主存

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

4.处理器

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

从系统通电开始,直到系统断电,处理器一直在不断地执行程序计数器指向的指令,再更新程序计数器,使其指向下一条指令。在上面的模型中,指令按照严格的顺序执行,而执行一条指令包括执行一系列的步骤。处理器从程序计数器指向的内存处读取指令,解释指令中的位,执行该指令指示的简单操作,然后更新PC,使其指向下一条指令,而这条指令并不一定和在内存中刚刚执行的指令相邻。

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

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

现在介绍当我们运行示例程序时到底发生了什么。这里会省略很多细节,稍后做补充。

当我们在键盘上输入./hello后,shell程序将字符逐一读入寄存器,再把它存放到内存中。

IMG_7336

当我们在键盘上敲击回车键时,shell执行一系列指令来加载可执行的hello文件,这些指令将hello目标文件中的代码和数据从磁盘复制到主存。数据包括最终会被输出的字符串hello,world/n

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

IMG_7337

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

IMG_7338

1.5 高速缓存至关重要

上述简单的示例中,系统需要花费大量的时间把信息从一个地方挪到另一个地方,这些复制就是开销,减慢了程序“真正”的工作。因此,系统设计者的一个主要目标就是使这些复制操作尽可能快地完成。

一个典型的寄存器文件只存储几百字节的信息,而主存里可存放几十亿字节。然而,处理器从寄存器文件中读数据比从主存中读取几乎要快100倍。

针对这种处理器与主存之间的差异,系统设计者采用了更小更快的存储设备,称为高速缓存存储器(cache memory,简称为cache或高速缓存),作为暂时的集结区域,存放处理器近期可能会需要的信息。

IMG_7339

位于处理器芯片上的L1高速缓存的容量可以达到数万字节,访问速度几乎和访问寄存器文件一样快。进程访问L2高速缓存的时间要比访问L1高速缓存的时间长5倍,但是这仍然比访问主存的时间快5~10倍。L1和L2高速缓存是用一种叫做**静态随机访问存储器(SRAM)**的硬件技术实现的。比较新的处理器甚至有三级高速缓存。通过让高速缓存存放可能经常访问的数据,大部分的内存操作都能在快速的高速缓存中完成。

1.6 存储设备形成层次结构

每个计算机系统中的存储设备都被组织成一个存储器层次结构。

IMG_7342

上图中,在这个层次结构中,从上至下,设备的访问速度越来越慢、容量越来越大,并且每字节的造价也越来越便宜。存储器层次结构的主要思想是上一层的存储器作为低一层存储器的高速缓存。

1.7 操作系统管理硬件

我们可以把操作系统看成是应用程序和硬件之间插入的一层软件。所有应用程序对硬件的操作尝试都必须通过操作系统。

IMG_7343

操作系统有两个基本功能:1.防止硬件被失控的应用程序滥用;2.向应用程序提供简单一致的机制来控制复杂而又通常大不相同的低级硬件设备。操作系统通过几个基本的抽象概念(进程、虚拟内存和文件)来实现这两个功能。

1.7.1 进程

进程是操作系统对一个正在运行的程序的一种抽象。在一个系统上可以同时运行多个进程,而每个进程都好像在独占地使用硬件。而并发运行是说一个进程的指令和另一个进程的指令是交错执行的。在大多数系统中,需要运行的进程数是多于可以运行它们的CPU个数的。这个通过处理器在进程间切换来实现的。操作系统实现这种交错执行的机制称为上下文切换

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

IMG_7344

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

实现进程这个抽象概念需要低级硬件和操作系统软件之间的紧密合作。

1.7.2 线程

在现代系统中,一个进程实际上可以由多个称为线程的执行单元组成,每个线程都运行在进程的上下文中,并共享同样的代码和全局数据。由于网络服务器中对并行处理的需求,线程成为越来越重要的编程模型,因为多线程之间比多进程之间更容易共享数据,也因为线程一般来说比进程更高效。

1.7.3 虚拟内存

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

IMG_7345

每个进程看到的虚拟地址空间由大量准确定义的区构成,每个区都有专门的功能。

  • 程序代码和数据。对所有的进程来说,代码是从同一固定地址开始,紧接着的是和C全局变量相对应的数据位置。代码和数据区是直接按照可执行目标文件的内容初始化的。
  • 堆。代码和数据区在进程一开始运行时就被指定了大小,当调用像malloc和free这样的C标准库函数时,堆可以在运行时动态地扩展和收缩。
  • 共享库。大约在地址空间的中间部分是一块用来存放像C标准库和数据库这样的共享库的代码和数据的区域。
  • 栈。编译器用它来实现函数调用。和堆一样,用户栈在程序执行期间可以动态地扩展和收缩。每当我们调用一个函数时,栈就会增长;从一个函数返回时,栈就会收缩。
  • 内核虚拟内存。地址空间顶部的区域是为内核保留的。不允许应用程序读写这个区域的内容或者直接调用内核代码定义的函数。相反,它们必须调用内核来执行这些操作。
1.7.4 文件

文件就是字节序列。每个I/O设备,包括磁盘、键盘、显示器,甚至网络,都可以看成是文件。系统中的所有输入输出都是通过使用一小组称为Unix I/O的系统函数调用读写文件来实现的。

1.8 系统之间利用网络通信

IMG_7346

当系统从主存复制一串字节到网络适配器时,数据流经网络到达另一台机器。

IMG_7347

1.9 重要主题

系统是硬件和系统软件互相交织的集合体,它们必须共同协作以达到运行应用程序的最终目的。

接下来强调几个贯穿计算机系统所有方面的重要概念

1.9.1 Amdahl定律

该定律的主要思想是,当我们对系统的某个部分加速时,其对系统整理性能的影响取决于该部分的重要性和加速程度。

若系统执行某应用程序需要时间为 T o l d T_{old} Told,假设系统某部分所需执行时间与该事件的比例为α,而该部分系统提升比例为k。即该部分初始所需时间为 α T o l d αT_{old} αTold,现在所需时间为 ( α T o l d ) / k (αT_{old})/k (αTold)/k。因此,总的执行时间为 T n e w = ( 1 − α ) T o l d + ( a T o l d ) / k = T o l d [ ( 1 − α ) + α / k ] T_{new}=(1-α)T_{old}+(aT_{old})/k=T_{old}[(1-α)+α/k] Tnew=(1α)Told+(aTold)/k=Told[(1α)+α/k]。由此可以计算加速比 S = T o l d / T n e w S=T_{old}/T_{new} S=Told/Tnew S = 1 ( 1 − α ) + α / k S=\frac{1}{(1-α)+α/k} S=(1α)+α/k1

相对性能表示有两种方式,1.用 T o l d / T n e w T_{old}/T_{new} Told/Tnew,然后用后缀“X”来表示比例。2.用百分比,这种方法用于变化小的情况。 100 ∗ ( T o l d − T n e w ) / T n e w 100*(T_{old}-T_{new})/T_{new} 100(ToldTnew)/Tnew

1.9.2 并发与并行

**并发(concurrency)**指一个同时具有多个活动的系统;**并行(parallelism)**指用并发使一个系统运行得更快。并行可以在计算机系统的多个抽象层次上运用。我们按照系统层次结构中由高到低的顺序重点强调三个层次。

1.线程级并发

构建在进程这个抽象之上。使用线程,我们能够在一个进程中执行多个控制流。传统意义上,并发执行只是模拟出来的,是通过使一台计算机在它正在执行的进程间快速切换来实现的。这种并发形式允许多个用户同时与系统交互。

IMG_7348

多核处理器是将多个CPU(称为“核”)集成到一个集成电路芯片上。

IMG_7349

上图显示的是一个多核处理器的组织结构,其中微处理器芯片有4个CPU核,每个核都有自己的L1和L2高速缓存,L1高速缓存分为两个部分——一个保持最近取到的指令,另一个存放数据。这些核共享更高层次的高速缓存,以及到主存的接口。

超线程,有时称为同时多线程(simultaneous multi-threading),是一项允许一个CPU执行多个控制流的技术。举例来说,Intel Core i7处理器可以让每个核执行两个线程,所以一个4核的系统实际上可以并行地执行8个线程。

多处理器的使用可以从两方面提高系统性能。首先,它减少了执行多个任务时模拟并发的需求。其次,它可以使应用程序运行得更快,当然这要求应用程序是以多线程方式来书写的,这些线程可以并行地高效执行。

2.指令级并行

在较低抽象层次上,现代处理器可以同时执行多条指令的属性称为指令级并行。早期的微处理器,如1978年的Intel 8086,需要多个(通常是310个)时间周期来执行一条指令。最近的处理器可以保持每个时钟周期24条指令的执行速率。

如果处理器可以达到比一个周期一条指令更快的执行速率,就称之为**超标量(superscalar)**处理器。大多数现代处理器都支持超标量操作。

3.单指令、多数据并行

在最低层次上,许多现代处理器拥有特殊的硬件,允许一条指令产生多个可以并行执行的操作,这种方式称为单指令、多数据,即SIMD并行。

提供SIMD指令多是为了提高处理影像、声音和视频数据的执行速度。

1.9.3 计算机系统中抽象的重要性

抽象的使用是计算机科学中最为重要的概念之一。不同的编程语言提供不同形式的抽象支持,例如Java类的声明和C语言的函数原型。

IMG_7351

上图中,在处理器里,指令集架构提供了对实际处理器硬件的抽象。使用这个抽象,机器代码程序表现得就好些运行在一个一次只执行一条指令的处理器上。底层的硬件远比抽象描述得要复杂精细,它并行地执行多条指令,但又总是与那个简单有序的模型保持一致。只要执行模型一致,不同的处理器实现也能执行同样的机器代码,而又提供不同的开销和性能。

文件是对I/O设备的抽象,虚拟内存是对程序存储器的抽象,进程是对一个正在运行的程序的抽象,而虚拟机是提供对整个计算机的抽象,包括操作系统、处理器和程序。

第一部分 程序结构和执行

第2章:信息的表示和处理

现代计算机存储和处理的信息以二值信号表示。在计算中最重要的三种数字表示,**无符号(unsigned)**编码基于传统的二进制表示法,表示大于或等于零的数字。**补码(two’s-complement)**编码是表示有符号整数的最常见的方式,有符号整数就是可以为正或者为负的数字。**浮点数(floating-point)**编码是表示实数的科学记数法的以2为基数的版本。

计算机的表示法是用有限数量的位来对一个数字编码,因此,当结果太大以至不能表示时,某些运算就会溢出(overflow)。溢出会导致某些令人吃惊的结果。例如,在今天的大多数计算机上(使用32位来表示数据类型int),计算表达式 200 ∗ 300 ∗ 400 ∗ 500 200*300*400*500 200300400500会得出结果 − 884 , 901 , 888 -884,901,888 884,901,888

浮点运算有完全不同的数学数学。虽然溢出会产生特殊的值+∞,但是一组正数的乘机总是正的。由于表示的精度有限,浮点运算是不可结合的。整数运算和浮点运算会有不同的数学属性是因为它们处理数字表示有限性的方式不同——整数的表示虽然只能编码一个相对较小的数值范围,但是这种表示是精确的;而浮点数虽然可以编码一个较大的数值范围,但是这种表示只是近似的。

2.1 信息存储

大多数计算机使用8位的块,或者字节(byte),作为最小的可寻址的内存单元,而不是访问内存中单独的位。机器级程序将内存视为一个非常大的字节数组,称为虚拟内存。内存的每个字节都由一个唯一的数字来标识,称为它的地址(address),所有可能地址的集合就称为虚拟地址空间(virtual address space)。顾名思义,这个虚拟地址空间只是一个展现给机器级程序的概念性映像。实际的实现是将动态随机访问存储器(DRAM)、闪存、磁盘存储器、特殊硬件和操作系统软件结合起来,为程序提供一个看上去统一的字节数组。

每个程序对象可以简单地视为一个字节块,而程序本身就是一个字节序列。

C语言中指针的作用

指针是C语言的一个重要特性。它提供了引用数据结构(包括数组)的元素的机制。与变量类似,指针也有两个方面:值和类型。它的值表示某个对象的位置,而它的类型表示那个位置上所存储对象的类型(比如整数或浮点数)。

2.1.1 十六进制表示法

二进制和十进制对于描述位模式来说都不是非常方便,二进制表示法太冗长,十进制表示法与位模式的互相转化很麻烦。替代的方法是用十六进制来表示位模式。十六进制(简写为“hex”),使用数字“0”“9”以及字符“A”“F”来表示。

在C语言中,以0x0X开头的数字常量被认为是十六进制的值。字符既可以大写也可以小写。

2.1.2 字数据大小

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

32位字长限制虚拟地址空间位4千兆字节(4GB),64位字长的虚拟地址空间为16EB,大约是1.84× 1 0 19 10^{19} 1019字节。

大多数64位机器也可以运行32位机器编译的程序,这是一种向后兼容。

IMG_7895

大部分数据类型都编码为有符号数值,除非有前缀关键字unsigned或对确定大小的数据类型使用了特定的无符号声明。数据类型char是一个例外,在很多情况下,程序行为对数据类型char是有符号还是无符号的并不敏感。

程序员应该力图使他们的程序在不同的机器和编译器上可移植。

2.1.3 寻址和字节顺序

在几乎所有的机器上,多字节对象被存储为连续的字节序列,对象的地址为所使用字节中最小的地址。例如,假设一个int的变量x的地址为0x100,那么,x的4个字节(int类型为4字节)将被存储在内存的0x100、0x101、0x102、0x103位置。

最高有效字节在最前面的方式,称为大端法。最低有效字节在最前面的方式,称为小端法

IMG_7904

选择何种字节顺序没有技术上的理论,对于那种字节排序的选择都是任意的。

IMG_7905

可以观察到,尽管浮点型float和整型数据int都是对数值12 345编码,但是它们有截然不同的字节模式。

C语言中,强制类型转换不会改变真实的指针,它们只是告诉编译器以新的数据类型来看待被指向的数据,

2.1.4 表示字符串

C语言中字符串被编码为一个以null(其值为0)字符结尾的字符数组。每个字符都由某个标准编码来表示,最常见的是ASCII字符码。

Java编程语言使用Unicode来表示字符串,C语言默认用ASCII码表示,但也有支持Unicode的程序库。

2.1.5 表示代码
IMG_7906

不同的机器类型使用不同的且不兼容的指令和编码方式。即使是完全一样的进程,运行在不同的操作系统上也会有不同的编码规则,因此二进制代码是不兼容的。二进制代码很少能在不同机器和操作系统组合之间移植。

计算机系统的一个基本概念就是,从机器的角度来看,程序仅仅只是字节序列。机器没有关于原始源程序的任何信息。

2.1.6 布尔代数简介

二进制值是计算机编码、存储和操作信息的核心,所以围绕数值0和1的研究已经演化出了丰富的数学知识体系。

IMG_7945
2.1.7 C语言中的位级运算

确认一个位级表达式的结果最好的方法,就是将十六进制的参数扩展为二进制表示并执行二进制运算,然后再转换回十六进制。

位级运算的一个常见用法就是实现掩码运算,这里掩码是一个位模式,表示从一个字中选出的位的集合。

2.1.8 C语言中的逻辑运算

C语言提供了一组逻辑运算||、&&和!,逻辑运算认为所有非零的参数都表示TRUE,而参数0表示为FALSE。逻辑运算符对第一个参数求值就能确定表达式的结果,那么逻辑运算符就不会对第二个参数求值。

IMG_7953
2.1.9 C语言中的移位运算
IMG_7954

几乎所有的编译器/机器都对有符号数使用算术右移,对于无符号,右移必须是逻辑的。

2.2 整数表示

2.2.1 整数数据类型
IMG_7955 IMG_7956

C和C++都支持有符号(默认)和无符号数。Java只支持有符号数。

2.2.3 补码编码

最常见的有符号数的计算机表示方式就是补码形式。最高有效位也称为符号位,当符号位被设置为1时,表示值为负,而当设置为0时,值为非负。

IMG_7957 IMG_7958

从图中可以看出,有符号数的最小值和最大值是不对称的,最小值的绝对值=最大值的绝对值+1,也就是说,最小值没有与之对应的正数。

关于整数数据类型的取值范围和标识,Java标准中非常明确。它要求采用补码表示。在Java中,单字节数据类型称为byte,而不是char。

有符号数的其他表示方法

反码(Ones’ Complement)和原码(Sign-Magnitude)。

2.2.4 有符号数和无符号数之间的转换
short int v = -12345;
unsigned short uv = (unsigned short) v;
printf("v=%d, uv=%u\n", v, uv); // v=-12345, uv=53191

上述式子中,强制类型转换的结果保持位值不变,只是改变了解释这些位的方式。

补码转换为无符号数:对满足 T M i n ω ≤ x ≤ T m a x ω TMin_ω\le x \le Tmax_ω TMinωxTmaxω的x有: T 2 U ω = { x + 2 ω , x < 0 x , x ≥ 0 T2U_ω=\begin{cases} x+2^ω,x < 0 \\ x, x \ge 0 \end{cases} T2Uω={x+2ω,x<0x,x0

IMG_7959

无符号数转换为补码(有符号):对满足 0 ≤ u ≤ U m a x ω 0 \le u \le Umax_ω 0uUmaxω的u有: U 2 T ω ( u ) = { u , u ≤ T M a x ω u − 2 ω , u > T M a x ω U2T_ω(u)=\begin{cases} u,u \le TMax_ω \\ u-2^ω, u > TMax_ω \end{cases} U2Tω(u)={u,uTMaxωu2ω,u>TMaxω

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

C语言标准没有指定有符号数要采用某种表示,但是几乎所有的机器都使用补码。通常,大多数数字都默认是有符号的。要创建一个无符号常量,必须加上后缀’U’或’u’,例如,12345U或0x1A2Bu。

int x = -1;
unsigned u = 2147483648;

printf("x=%u = %d\n", x, x); // x = 4294967295 = -1
printf("u=%u = %d\n", u, u); // u = 2147483648 = -2147483648

上面代码中,在这两种情况下,printf首先将这个字当做一个无符号数输出,然后把它当作一个有符号数输出。

在C语言中,当执行一个运算时,如果它的一个运算数是有符号而另一个是无符号的,那么C语言会隐式地将有符号参数强制类型转换为无符号数,并假设这两个数都是非负的来执行这个运算。

IMG_7960
2.2.6 扩展一个数字的位表示

将一个无符号数转换为一个更大的数据类型,只需要简单地在表示的开头添加0,这种运算被称为零扩展。

将一个补码数字转换为一个更大的数据类型,在表示中添加最高有效位的值,这种称为符号扩展。例如:为short类型的-12345扩展为int类型,十六进制将从cf c7变为ff ff cf c7。

IMG_7961
2.2.7 截断数字

截断无符号数, x ′ = x ( m o d 2 k ) x'=x \pmod {2^k} x=x(mod2k)

截断补码数值, x ′ = U 2 T k ( x ( m o d 2 k ) ) x'=U2T_k(x \pmod {2^k}) x=U2Tk(x(mod2k))

有符号数到无符号数的隐式强制类型转换导致了某些非直观的行为。而这些非直观的特性经常导致程序错误,并且这种包含隐式强制类型转换的细微差别的错误是很难被发现。

有符号数到无符号数的隐式转换,会导致错误或漏洞的方式。避免这类错误的一种方法是绝不使用无符号数。实际上,除了C以外很少有语言支持无符号整数。

2.3 整数运算

2.3.1 无符号加法

对于无符号加法,对满足 0 ≤ x , y ≤ 2 ω 0 \le x, y \le 2^ω 0x,y2ω的x和y有: x + y = { x + y , x + y < 2 ω  正常 x + y − 2 ω , 2 ω ≤ x + y ≤ 2 ω + 1  溢出 x+y= \begin{cases} x+y,x+y<2^ω \space 正常 \\ x+y-2^ω, 2^ω \le x+y \le 2^{ω+1} \space 溢出 \end{cases} x+y={x+y,x+y<2ω 正常x+y2ω,2ωx+y2ω+1 溢出

算术运算溢出是指完整的整数结构不能放到数据类型的字长限制中去。

检测无符号加法中的溢出,当x+y=s<x或x<y时,即发生溢出。

2.3.2 补码加法

对于满足 − 2 ω − 1 ≤ x , y ≤ 2 ω − 1 − 1 -2^{ω-1} \le x,y \le 2^{ω-1} -1 2ω1x,y2ω11的整数x和y,有: x + y = { x + y − 2 ω , 2 ω − 1 ≤ x + y  正溢出 x + y , − 2 ω − 1 ≤ x + y < 2 ω − 1  正常 x + y + 2 ω , x + y ≤ − 2 ω − 1  负溢出 x+y= \begin{cases} x+y-2^ω, 2{ω-1} \le x+y \space 正溢出\\ x+y, -2{ω-1} \le x+y < 2{ω-1} \space 正常 \\ x+y+2^ω, x+y \le -2{ω-1} \space 负溢出 \end{cases} x+y= x+y2ω,2ω1x+y 正溢出x+y,2ω1x+y<2ω1 正常x+y+2ω,x+y2ω1 负溢出

检测补码加法中的溢出。当且仅当x>0,y>0,但s≤0时,发生正溢出。当且仅当x<0,y<0,但s≥0时,发生负溢出。

2.3.3 补码的非

对于满足 T M i n ω ≤ x ≤ T M a x ω TMin_ω \le x \le TMax_ω TMinωxTMaxω的x,有 x = { T M i n ω , x = T M i n ω − x , x > T M i n ω x=\begin{cases} TMin_ω, x=TMin_ω \\ -x, x > TMin_ω \end{cases} x={TMinω,x=TMinωx,x>TMinω

计算补码,对于任意整数值x,有-x和x+1得到的结果一样,可以利用x+1来求得补码的值。

另一种方法是对k左边的所有位取反。例如:

x-x
[1100] -4[0100] 4
[1000] -8[1000] -8
[0101] 5[1101] -5
2.3.4 无符号乘法

对于满足 0 ≤ x , y ≤ U M a x ω 0 \le x,y \le UMax_ω 0x,yUMaxω的x和y有: x ∗ y = ( x ⋅ y ) m o d 2 ω x*y=(x·y)mod 2^ω xy=(xy)mod2ω

2.3.5 补码乘法

C语言中的有符号乘法是通过2ω位的乘积截断为ω位来实现的。

对于满足 T M i n ω ≤ x , y ≤ T M a x ω TMin_ω \le x,y \le TMax_ω TMinωx,yTMaxω的x和y有: x ∗ ω t y = U 2 T ( ( x ⋅ y ) m o d 2 ω ) x*{^t_ω}y=U2T((x·y)mod 2^ω) xωty=U2T((xy)mod2ω)

IMG_7982
2.3.6 乘以常数

由于整数乘法指令相当慢,需要10个时钟周期。因此,编译器使用了一项重要的优化,试着用移位和加法运算的组合来代替乘以常数因子的乘法。

与2的幂相乘的乘法, 0 ≤ k < ω 0 \le k < ω 0k<ω,则 x ∗ ω u 2 k x*{^u _ω}2^k xωu2k

例:假设ω=4时,[1101]×4 = [1101] << 2 = [110100] = 11×4 = 44

用下面两种不同形式种的一种来计算位对乘机的影响:

  • A: ( x < < n ) + ( x < < ( n − 1 ) ) + . . . + ( x < < m ) (x<<n)+(x<<(n-1))+...+(x<<m) (x<<n)+(x<<(n1))+...+(x<<m)
  • B: ( x < < ( n + 1 ) ) − ( x < < m ) (x<<(n+1))-(x<<m) (x<<(n+1))(x<<m)
2.3.7 除以2的幂

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

原理:除以2的幂的无符号除法。

C表达式x>>k产生数值 └ x / 2 k ┘ └x/2^k┘ x/2k

IMG_8006

**原理:**除以2的幂的补码除法,向下舍入

C表达式x>>k产生数值 └ x / 2 k ┘ └x/2^k┘ x/2k。对于x>0,效果与逻辑右移一样。

IMG_8007

**原理:**除以2的幂的补码除法,向上舍入

C表达式 ( x + ( 1 < < k ) − 1 ) > > k (x+(1<<k)-1)>>k (x+(1<<k)1)>>k产生数值 ┌ x / 2 k ┐ ┌x/2^k┐ x/2k

IMG_8008

2.4 浮点数

2.4.1 二进制小数

十进制表达法: d m d m − 1 . . . d 1 d 0 . d − 1 d − 2 . . . d − n d_md_{m-1}...d_1d_0.d_{-1}d_{-2}...d_{-n} dmdm1...d1d0.d1d2...dn

小数点左边的数字的权是10的正幂,得到整数值,而小数点右边的数字的权是10的负幂,得到小数值。例如 12.3 4 10 12.34_{10} 12.3410表示数字 1 × 1 0 1 + 2 × 1 0 0 + 3 × 1 0 − 1 + 4 × 1 0 − 2 = 12 34 100 1×10^1+2×10^0+3×10^{-1}+4×10^{-2}=12\frac{34}{100} 1×101+2×100+3×101+4×102=1210034

IMG_8013

二进制小数点向左移动一位相当于这个数被2除。类似,二进制小数点向右移动一位相当于讲该数乘2。

增加二进制的长度可以提高表示的精度:

IMG_8012
2.4.2 IEEEE浮点表示

上一节提到的定点表示法不能很有效地表示非常大的数字。IEEE浮点标准用 V = ( − 1 ) s × M × 2 E V=(-1)^s×M×2^E V=(1)s×M×2E的形式来表示一个数。

  • 符号(sign)s决定这数是负数还是正数。
  • 尾数(significand)M是一个二进制小数,它的范围是12-ε,或者是01-ε。
  • 阶码(exponent)E的作用是对浮点数加权,这个权重是2的E次幂(可能是负数)。

将浮点数的位表示划分为三个字段,分别对这些值进行编码:

  • 一个单独的符号位s直接编码符号s。
  • k位的阶段字段 e x p = e k − 1 . . . e 1 e 0 exp=e_{k-1}...e_1e_0 exp=ek1...e1e0编码阶段E。
  • n位小数字段 f r a c = f n − 1 . . . f 1 f 0 frac=f_{n-1}...f_1f_0 frac=fn1...f1f0编码尾数M。
IMG_8014

根据exp的值,被编码的值可以分成三种不同的情况。

IMG_8015
情况1:规格化的值

当exp的位模式即不全为0,也不全为1时,都属这类情况。在这种情况下,阶码字段被解释为以偏置形式表示的有符号整数。

阶码的值是 E = e − B i a s E=e-Bias E=eBias,其中e是无符号数,而Bias是一个等于 2 k − 1 − 1 2^{k-1}-1 2k11的偏置值。由此产生指数的取值范围,对于单精度是 − 126   + 127 -126~+127 126 +127,而对于双精度是 − 1022   + 1023 -1022~+1023 1022 +1023

小数字段frac被解释为小数值f,其中 0 ≤ f < 1 0\le f<1 0f<1,其二进制表示为 0. f n − 1 . . . f 1 f 0 0.f_{n-1}...f_1f_0 0.fn1...f1f0,也就是二进制小数点在最高有效位的左边。尾数定义为 M = 1 + f M=1+f M=1+f。这种方式也叫做隐含的以1开头的表示。

情况2:非规格化的值

当阶码域全为0时,所表示的数是非规格化形式。阶码值是 E = 1 − B i a s E=1-Bias E=1Bias,而尾数的值是 M = f M=f M=f,也就是小数字段值,不包含隐含的开头的1。

非规格化数有两个用途。它们提供了一种表示数值0的方法,因为使用规格化数,我们必须总是使 M ≥ 1 M \ge 1 M1,因此不能表示0。

实际上,+0.0的浮点表示的位模式为全0:符号位是0,阶码字段全为0(表明是一个非规格化值),而小数域也全为0,得到 M = f = 0 M=f=0 M=f=0。当符号位为1,而其他域全为0时,得到值-0.0。根据IEEE的浮点格式,值+0.0和-0.0在某些方面被认为是不同的,而在其他方面是相同的。

非规格化的另一个用途就是表示那些非常解决于0.0的数。它们提供了一种属性,称为逐渐下溢(gradual underflow),可能的数值分布均匀地接近于0.0。

情况3:特殊值

当阶码全为1的时候出现。当小数域全为0时,得到的值表示无穷,当s=0时是+∞,或者当s=1时是-∞。当我们把两个非常大的数相乘,或者除以零时,无穷能够表示溢出的结果。当小数域为非零时,结果值被称为“NaN”,即“不是一个数(Not a Number)”。

一些运算的结果不能是实数或无穷,就会返回NaN值,比如当计算 − 1 \sqrt{-1} 1 ∞ − ∞ \infty - \infty

2.4.3 数字实例
IMG_8016

上图看出,可表示的数不是均匀分布的——越靠近原点处它们越稠密。

IMG_8017

可以观察到最大非规格化数 7 512 \frac{7}{512} 5127和最小规格化数 8 512 \frac{8}{512} 5128之间的平滑转变。

IEEE格式如此设计是为了浮点数能够使用整数排序函数来进行排序。

IMG_8019
  • 值+0.0总有一个全为0的位的表示。
  • 最小的正非规格化值的位表示,是由最低有效位为1而其他所有位为0构成的。它具有小数(和尾数)值 M = f = 2 − n M=f=2^{-n} M=f=2n和阶码值 E = − 2 k − 1 + 2 E=-2^{k-1}+2 E=2k1+2。因此它的数字值是 V = 2 − n − 2 k − 1 + 2 V=2^{-n-2^{k-1}+2} V=2n2k1+2
  • 最大的非规格化数值的位模式是由全为0的阶码字段和全为1的小数字段组成的。 V = ( 1 − 2 − n ) × 2 − 2 k − 1 + 2 V=(1-2^{-n})×2^{-2^{k-1}+2} V=(12n)×22k1+2,这仅比最小的规格化值小一点。
  • 最小的正规格化值的位模式的阶码字段的最低有效位为1。 V = 2 − 2 k − 1 + 2 V=2^{-2^{k-1}+2} V=22k1+2
  • 值1.0的位表示的阶码字段除了最高有效位等于0以外,所有其他都等于1。它的尾数值是 M = 1 M=1 M=1,而它的阶码值是 E = 0 E=0 E=0
  • 最大的规格化值的位表示的符号为为0,阶码的最低有效位等于0,其他位等于1。它的小数值 f = 1 − 2 − n f=1-2^{-n} f=12n,尾数 M = 2 − 2 − n M=2-2^{-n} M=22n。它的阶码值 E = 2 k − 1 − 1 E=2^{k-1}-1 E=2k11,得到数值 V = ( 2 − 2 − n ) × 2 2 k − 1 − 1 = ( 1 − 2 − n − 1 ) × 2 2 k − 1 V=(2-2^{-n})×2^{2^{k-1}-1}=(1-2^{-n-1})×2^{2^{k-1}} V=(22n)×22k11=(12n1)×22k1
2.4.4 舍入

因为表示方法限制了浮点数的范围和精度,所以浮点运算只能近似地表示实数运算。IEEE浮点格式定义了四种不同的舍入方式。默认的方法是找到最接近的匹配,而其他三种可用于计算上界和下界。

IMG_8020

向偶数舍入方式的方法是:它将数字向上或向下舍入,使得结果的最低有效数字是偶数。向偶数舍入的方法可以避免统计的偏差。

2.4.5 浮点运算

当参数中有一个是特殊值(如-0、-∞或NaN)时,IEEE标准定义了一些合理的规则。例如,定义1/-0将产生-∞,而定义1/+0会产生+∞。

浮点运算与整数运算的区别:

  • 加法和乘法由于舍入的原因所以不可结合。
  • 浮点加法(和乘法)满足单调性属性:如果 a ≥ b a\ge b ab,那么对于任何a、b以及x的值,除了NaN,都有 x + a ≥ x + b x+a \ge x+b x+ax+b。无符号和补码加法不具有这个实数(和整数)加法的属性。
2.4.6 C语言中的浮点数

C语言提供两种不同的浮点数据类型:floatdouble。在支持IEEE浮点格式的机器上,这些数据类型对应单精度和双精度浮点,使用向偶数舍入方式。

int、float和double格式之间进行强制转换:

  • 从int转换成float,数字不会溢出,但可能被舍入。
  • 从int或float转换成double,因为double有更大的范围,也有更高的精度,所以能够保留精确的数值。
  • 从double转换成float,因为范围要小一些,所以值可能溢出成+∞或-∞。另外,由于精度较小还可能被舍入。
  • 从float或double转换成int,值将会向零舍入。例如,1.999将被转换成1。值可能会溢出。

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

计算机执行机器代码,用字节序列编码低级的操作,包括处理数据、管理内存、读写存储设备上的数据,以及利用网络通信。编译器基于编程语言的规则、目标及其的指令集和操作系统遵循的惯例,经过一系列的阶段生成机器代码。

GCC C语言编译器以汇编代码的形式产生输出,汇编代码是机器代码的文本表示,给出程序中的每一条指令。然后GCC调用汇编器和链接器,根据汇编代码生成可执行的机器代码。

高级语言提供的抽象级别比较高,大多数时候,在这种抽象级别上工作效率会更高,也更可靠。编译器提供的类型检查能帮助我们发现许多程序错误,并能够保证按照一致的方式来引用和处理数据。高级语言最大的优点是编写的程序可以在很多不同的机器上编译和执行,而汇编代码则是与特定机器密切相关的。

学习机器代码和汇编代码,能让我们理解编译器的优化能力,并分析代码中隐含的低效率。

IA32 编程

IA32,x86-64的32位的前身,是Intel在1985年提出的。今天运行的x86-64支持向后兼容执行IA32程序。

32位机器只能使用大概4GB( 2 32 2^{32} 232字节)的随机访问存储器。64位机器能够使用256TB( 2 64 2^{64} 264字节)的内存空间。

3.1 历史观点

Intel处理器系列俗称x86,经历了一个长期的不断进化的发展过程。

  • 8086(1978年,29K个晶体管)。它是第一代单芯片、16位微处理器之一。8088是8086的一个变种,在8086上增加了一个8位外部总线,构成最初的IBM个人计算机的心脏。1980年,Intel提出了8087浮点协处理器(45K晶体管),它与一个8086或8088处理器一同运行,执行浮点指令。8087建立了x86的浮点模型,通常被称为“x87”。
  • 80286(1982年,134K晶体管)。
  • i386(1985年,275K晶体管)。将体系结构扩展到32位。增加了平坦寻址模式,Linux和最近版本的Windows操作系统都是使用的这种模式。
  • i486(1989年,1.2M个晶体管)。改善了性能,同时将浮点单元集成到处理器芯片上。
  • Pentium(1993年,3.1M个晶体管)。
  • PentiumPro(1995年,5.5M个晶体管)。引入了全新的处理器设计。指令集中增加了一类“条件传送(conditional move)”指令。
  • Pentium/MMX(1997年,4.5M个晶体管)。添加了处理整数向量的指令。每个数据大小可以是1、2或4个字节。每个向量总长64位。
  • Pentium Ⅱ(1997年,7M个晶体管)。
  • Pentium Ⅲ(1999年,8.2M个晶体管)。引入了SSE,这是一类处理整数或浮点数向量的指令。每个数据大小可以是1、2或4个字节,打包成128位的向量。
  • Pentium 4(2000年,42M个晶体管)。SSE扩展到了SSE2,增加了新的数据类型(包括双精度浮点数),以及针对这些格式的144条新指令。有了这些扩展,编译器可以使用SSE指令(而不是x87指令),来编译浮点代码。
  • Pentium 4E(2004年,125M个晶体管)。增加了超线程(hyperthreading),这种技术可以在一个处理器上同时运行两个程序;还增加了EM64T,它是Intel对AMD提出的对IA32的64位扩展的视线,称为x86-64。
  • Core 2(2006年,291M个晶体管)。Intel的第一个多核微处理器,即多处理器实现在一个芯片上。但不支持超线程。
  • Core i7,Nehalem(2008年,781M个晶体管)。既支持超线程,也有多核,最初的版本支持每个核上执行两个程序,每个芯片上最多四个核。
  • Core i7,Sandy Bridge(2011年,1.71G个晶体管)。引入了AVX,这是对SSE的扩展,支持把数据封装进256位的向量。
  • Core i7,Haswell(2013年,1.4G个晶体管)。将AVX扩展到AVX2,增加了更多的指令和指令格式。

每个后继处理器的设计都是向后兼容的。

摩尔定律

晶体管数量以每年37%的速率增加,也就是说,晶体管数量每26个月就会翻一番。

3.2 程序编码

linux> gcc -Og -o p p1.c p2.c

-Og告诉编译器使用会生成符合原始C代码整体结构的机器代码的优化等级。使用较高级别优化产生的代码会严重变形,以至于产生的机器代码和初始源代码之间的关系非常难以理解。实际中,从得到的程序的性能考虑,较高级别的优化(例如-O1或-O2指定)被认为是较好的选择。

gcc命令调用一整套的程序,将源代码转换成可执行代码。首先,C预处理扩展源代码,插入所有用#include命令指定的文件,并扩展所有用#define声明指定的宏。其次,编译器产生两个源文件的汇编代码,名字分别为p1.s和p2.s。接下来,汇编器会将汇编代码转化成二进制目标文件p1.o和p2.o。目标代码是机器代码的一种形式,它包含所有指令的二进制表示,但是还没有填入全局值的地址。最后,链接器将两个目标代码文件与实现库函数(例如printf)的代码合并,并产生最终的可执行代码文件p。可执行代码是我们要考虑的机器代码的第二种形式,也就是处理器执行的代码格式。这将会在第7章介绍这些不同形式的机器代码之间的关系以及链接的过程。

3.2.1 机器级代码

对于机器级编程来说,两种抽象尤为重要。

  • 第一种是由指令集体系结构指令集架构(ISA)来定义机器级程序的格式或行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响。大多数ISA,包括x86-64,将程序的行为描述成好像每条指令都是按顺序执行的,一条指令结束,下一条开始。处理器的硬件远比描述的精细复杂,它们并发地执行许多指令,但是可以采取措施保证整体行为与ISA指定的顺序执行的行为完全一致。
  • 第二种抽象是机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是一个非常大的字节数组。存储器系统的实际实现是将多个硬件存储器和操作系统软件组合起来。

在整个编译过程中,编译器会将C语言提供的相对比较抽象的执行模型表示的程序转化成处理器执行的非常基本的指令。汇编代码表示非常接近于机器代码。与机器代码二进制格式相比,汇编代码的主要特点是它用可读性更好的文本格式表示。

x86-64的机器代码和原始的C代码差别非常大。一些通常对C语言程序员隐藏的处理器状态都是可见的:

  • 程序计数器(PC,在x86-64用%rip表示)给出将要执行的下一条指令的内存中的地址。
  • 整数寄存器文件包含16个命名的位置,分别存储64位的值。这些寄存器可以存储地址(对应于C语言的指针)或整数数据。有的寄存器被用来记录重要的程序状态,而其他的寄存器用来保存临时数据,例如过程的参数和局部变量,以及函数的返回值。
  • 条件码寄存器保存着最近执行的算术或逻辑指令的状态信息。它们用来实现控制或数据流中的条件变化,比如说用来实现if和while语句。
  • 一组向量寄存器用来存放一个或多个整数或浮点数值。

虽然C语言提供了一种模型,可以在内存中声明和分配各种数据类型的对象,但是机器代码只是简单地将内存看成一个很大的、按字节寻址的数组。C语言中的聚合数据类型,例如数组和结构,在机器代码中用一组连续的字节来表示。即使是对标量数据类型,汇编代码也不区分有符号和无符号整数,不区分各种类型的指针,甚至不区分指针和整数。

程序内存包含:程序的可执行机器代码,操作系统需要的一些信息,用来管理过程调用和返回的运行时栈,以及用户分配的内存块(比如说用malloc库函数分配的)。程序内存用虚拟地址来寻址。在任意给定的时刻,只有有限的一部分虚拟地址被认为是合法的。例如,x86-64的虚拟地址是由64位的字来表示的。在目前的实现中,这些地址的高16位必须设置为0,所以一个地址实际上能够指定的是 2 48 2^{48} 248或256TB范围内的一个字节。较为典型的程序只会访问几兆字节或几千兆字节的数据。操作系统负责管理虚拟地址空间,将虚拟地址翻译成实际处理器内存中的物理地址。

一条机器指令只执行一个非常基本的操作。例如,将存放在寄存器中的两个数字相加,在存储器和寄存器之间传送数据,或是条件分支转移到新的指令地址。编译器必须产生这些指令的序列,从而实现(像算术表达式求值、循环或过程调用和返回这样的)程序结构。

3.2.2 代码示例

命令行上使用-S选项就能看到C语言编译器产生的汇编代码,gcc -0g -S xxx.c,生成xxx.s汇编文件。

-c命令行选项用来编译并汇编该代码,gcc -Og -c xxx.c,生成xxx.o目标代码文件。

机器执行的程序只是一个字节序列,它是对一系列指令的编码。机器对产生这些指令的源代码几乎一无所知。

要查看机器代码文件的内容,有一类称为**反汇编器(disassembler)**的程序非常有用。这些程序根据机器代码产生一种类似于汇编代码的格式。-d命令行标志的程序OBJDUMP可以充当这个角色。objdump -d xxx.o会将可执行目标文件反汇编成汇编代码。

image-20230103084529525
  • x86-64的指令长度从1到15个字节不等。常用的指令以及操作数较少的指令所需的字节数少,而那些不太常用或操作数较多的指令所需字节数较多。
  • 设计指令格式的方式是,从某个给定位置开始,将字节唯一地解码成机器指令。例如,指令pushq %rbx是以字节值53开头的。
  • 反汇编器只是基于机器代码文件中的字节序列来确定汇编代码。它不需要访问该程序的源代码或汇编代码。
  • 反汇编器使用的指令命名规则与GCC生成的汇编代码使用的有些细微的差别。

生成实际可执行的代码需要对一组目标代码文件运行链接器,而这一组目标代码文件中必须含有一个main函数。使用gcc -Og -o prog main.c mstore.c生成可执行文件prog。prog不仅包含了两个过程的代码,还包含了用来启动和终止程序的代码,以及用来与操作系统交互的代码。

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

3.2.3 关于格式的注解

GCC产生的汇编代码对我们来说有点难读。它包含一些我们不需要关心的信息和不提供任何程序的描述或它是如何工作的描述。

image-20230103223715883

所有以.开头的行都是指导汇编器和链接器工作的伪指令。我们通常可以忽略这些行。书中用另外一种格式来表示汇编语言。

C语言支持插入汇编代码。

3.3 数据格式

由于是从16位体系结构扩展成32位的,Intel用术语“字(word)”表示16位数据类型。因此,32位数称为“双字(double words)”,称64位数为“四字(quad words)”。

IMG_8119

3.4 访问信息

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

IMG_8120

16个寄存器都是%r开头,这是由于指令集历史演化造成的。最初的8086中有8个16位的寄存器,即图中的%ax到%sp。每个寄存器都有特殊的用途,它们的名字就反映了这些不同的用途。扩展到IA32架构时,这些寄存器也扩展成32位寄存器,标号从%eax到%esp。扩展到x86-64后,原来的8个寄存器扩展成64位,标号从%rax到%rsp。除此之外还增加了8个新的寄存器,它们的标号是按照新的命名规定制定的:从%r8到%r15。

上图中嵌套的方框标明的,指令可以对这16个寄存器的低位字节中存放的不同大小的数据进行操作。字节级操作可以访问最低的字节,16位操作可以访问最低的2个字节,32位操作可以访问最低的4个字节,而64位操作可以访问整个寄存器。

当这些指令以寄存器作为目标时,对于生成小于8字节结果的指令,寄存器中剩下的字节有两条规则:生成1字节和2字节数字的指令会保持剩下的字节不变;生成4字节数据的指令会把高位4字节置为0。

3.4.1 操作数指示符

大多数指令有一个或多个操作数(operand),指示出执行一个操作中要使用的源数据值,以及放置结果的目的位置。

IMG_8121

x86-64支持多种操作数格式。源数据值可以以常数形式给出,或是从寄存器或内存中读取。结果可以存放在寄存器或内存中。因此,各种不同的操作数的可能性被分为三种类型。第一种类型是立即数(immediate),用来表示常数值。第二种类型是寄存器(register),它表示某个寄存器的内容,16个寄存器的低位1字节、2字节、4字节或8字节中的一个作为操作数,这些字节数分别对应于8位、16位、32位或64位。

第三类操作数是内存引用,它会根据计算出来的地址(通常称之为有效地址)访问某个内存位置。上表中 I m m ( r b , r i , s ) Imm(r_b, r_i, s) Imm(rb,ri,s)表示的是最常用的形式。这样的引用有四个组成部分:一个立即数偏移 I m m Imm Imm,一个基址寄存器 r b r_b rb,一个变址寄存器 r i r_i ri和一个比例因子s,这里s必须是1、2、4或8。基址和变址寄存器都必须是64位寄存器。引用数组元素时通常会用到这种通用格式。其他形式都是这种通用形式的特殊情况,只是省略了某些部分。

3.4.2 数据传送指令

最频繁使用的指令是将数据从一个位置复制到另一个位置的指令。操作数表示的通用性使得一条简单的数据传送指令能够完成在许多机器中要好多条不同指令才能完成的功能。

IMG_8122

上图列出最简单形式的数据传送指令——MOV类。这些指令把数据从源位置复制到目的位置,不做任何变化。MOV类由四条指令组成:movb、movw、movl和movq。这些指令都执行同样的操作,主要区别在于它们操作的数据大小不同:分别是1、2、4和8字节。

源操作数指定的值是一个立即数,存储在寄存器中或内存中。目的操作数指定一个位置,要么是一个寄存器,要么是一个内存地址。x86-64加了一条限制,传送指令的两个操作数不能都指向内存位置。将一个值从一个内存位置复制到另一个内存位置需要两条指令——第一条指令将源值加载到寄存器中,第二条将该寄存器值写入目的位置。这些指令的寄存器操作数可以是16个寄存器有标号部分中的任意一个,寄存器部分的大小必须与指令最后一个字符(b、w、l或q)指定的大小匹配。大多数情况中,MOV指令只会更新目的操作数指定的那些寄存器字节或内存位置。唯一的例外是movl指令会把该寄存器的高位4字节设置为0.

IMG_8123

图3-4中记录的最后一条指令是处理64位立即数数据的。常规的movq指令只能以表示为32位补码数字的立即数作为源操作数,然后把这个值符号扩展得到64位的值,放到目的位置。movabsq指令能够以任意64为立即数值作为源操作数,并且只能以寄存器作为目的。

IMG_8124

上图记录的两类数据移动指令,在将较小的源值复制到较大的目的时使用。所有这些指令都把数据从源(在寄存器或内存中)复制到目的寄存器。MOVZ类中的指令把目的中剩余的字节填充为0,而MOVS类中的指令通过符号扩展来填充,把源操作的最高位进行复制。

3.4.3 数据传送示例
IMG_8126

上图中,参数通过寄存器传递给函数,函数通过把值存储在寄存器%rax或该寄存器的某个低位部分中返回。

当过程开始执行时,过程参数xp和y分别存储在寄存器%rdi和%rsi中。然后,指令2从内存中读出x,把它存放到寄存器%rax中,直接实现了C程序中的x=*xp。稍后,用寄存器%rax从这个函数返回一个值,因而返回值就是x。指令3将y写入到寄存器%rdi中的xp指向的内存位置,直接实现了操作*xp=y。

C语言中所谓的“指针”其实就是地址。间接引用指针就是将该指针放在一个寄存器中,然后在内存引用中使用这个寄存器。其次,像x这样的局部变量通常是保存在寄存器中,而不是内存中。访问寄存器比访问内存要快得多。

C操作符*执行指针的间接引用,分为读操作和写操作,当指针间接引用在赋值语句的左边即为写操作,在右边则为读操作。

C操作符&创建一个指针。

3.4.4 压入和弹出栈数据
IMG_8128

栈在处理过程调用中起至关重要的作用。在x86-64中,程序栈存放在内存中某个区域。

IMG_8129

栈顶元素的地址是所有栈中元素地址中最低的。栈指针%rsp保存着栈顶元素的地址。

将一个四字值压入栈中,首先要将栈指针减8,然后将值写到新的栈顶地址。因此,指令pushq %rbp的行为等价于下面两条指令:

subq $8,%rsp
movq %rbp,(%rsp)

它们之间的区别是在机器代码中pushq指令编码为1个字节,而上面那两条命令一共需要8个字节。图3-9前两栏给出的是,当%rsp为0x108,%rax为0x123时,执行命令pushq %rax的效果。首先%rsp会减8,得到0x100,然后会将0x123存放到内存地址0x100处。

弹出一个四字的操作包括从栈顶位置读出数据,然后将栈指针加8。因此,指令popq %rax等价于:

movq (%rsp),%rax
addq $8,%rsp

因为栈和程序代码以及其他形式的程序数据都是放在同一内存中,所以程序可以用标准的内存寻址方法访问栈内的任意位置。例如,假设栈顶元素是四字,指令movq 8(%rsp),%rdx会将第二个四字从栈中复制到寄存器%rdx。

3.5 算术和逻辑操作

IMG_8130

上图列出了x86-64的一些整数和逻辑操作。大多数操作都分成了指令类,这些指令类有各种带不同大小操作数的变种(只有leaq没有其他大小的变种)。例如,指令类ADD由四条加法指令组成:addb、addw、addl和addq。

3.5.1 有效加载地址

加载有效地址(load effective address)指令leaq实际上是movq指令的变形。它的指令形式是从内存读数据到寄存器,但实际上它根本就没有引用内存。该指令不是从指定的位置读入数据,而是将有效地址写入到目的操作数。这条指令可以为后面的内存引用产生指针。另外,它还可以间接地描述普通的算术操作。目的操作数必须是一个寄存器。leaq指令能执行加法和有限形式的乘法。

IMG_8131
3.5.2 一元和二元操作

一元操作的操作数既可以是寄存器也可以是内存位置。二元操作的第一个操作数可以是立即数、寄存器或是内存位置。第二个操作数可以是寄存器或是内存位置。

3.5.3 移位操作

移位操作,先给出移位量,然后第二项给出的是要移位的数。可以进行算术和逻辑右移。移位量可以是一个立即数,或者放在单字节寄存器%cl中(这些指令只允许以这个特定的寄存器作为操作数。)。原则上来说,1字节的移位量可以达到 2 8 − 1 = 255 2^8-1=255 281=255

在x86-64中,移位操作对ω位长的数据值进行操作,移位量是由%cl寄存器的低m位决定的,这里 2 m = ω 2^m=ω 2m=ω。高位会被忽略。所以,例如当寄存器%cl的十六进制为0xFF时,指令salb会移7位,salw会移15位,sall会移31位,而salq会移63位。

移位操作的目的操作数可以是一个寄存器或是一个内存位置。

IMG_8138

上图中,寄存器%rax中的值先后对应于程序值3*z、z*48和t4(作为返回值)。通常,编译器产生的代码中,会用一个寄存器存放多个程序值,还会在寄存器之间传送程序值。

3.5.5 特殊的算术操作

x86-64指令集对128位(16字节)数的操作提供有限的支持。Intel把16字节称为八字(oct word)。

IMG_8139

x86-64提供了两条不同的“单操作数”乘法指令,以计算两个64位值的全128位乘积——mulq和imulq。这两条指令都要求一个参数必须在寄存器%rax中,而另一个作为指令的源操作数给出。然后乘积存放在寄存器%rdx(高64位)和%rax(低64位)中。

类似于单操作数乘法指令,有符号除法指令idivl将寄存器%rdx(高64位)和%rax(低64位)中的128位数作为被除数,而除数作为指令的操作数给出。指令将商存储在寄存器%rax中,将余数存储在寄存器%rdx中。

cqto指令不需要操作数,它隐含读出%rax的符号位,并将它赋值到%rdx的所有位。

3.6 控制

机器代码提供两种基本的低级机制来实现有条件的行为:测试数据值,然后根据测试的结果来改变控制流或者数据流。

通常,C语言中的语句和机器代码中的指令都是按照它们在程序中出现的次序,顺序执行的。用jump指令可以改变一组机器代码指令的执行顺序,jump指令指定控制应该被传递到程序的某个其他部分,可能是依赖于某个测试的结果。

3.6.1 条件码

除了整数寄存器,CPU还维护着一组单个位的条件码(condition code)寄存器,它们描述了最近的算术或逻辑操作的属性。可以检测这些寄存器来执行条件分支指令。最常用的条件码有:

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

leaq指令不改变任何条件码,因为它是用来进行地址计算的。除此之外,图3-10中列出的所有指令都会设置条件码。

IMG_8141

除了图3-10中的指令会设置条件码,还有两类指令只设置条件码而不改变任何其他寄存器,如上图所示。CMP指令根据两个操作数之差来设置条件码。除了只设置条件码而不更新目的寄存器之外,CMP指令与SUB指令的行为是一样的。如果两个操作数相等,这些指令会将零标志设置为1,而其他的标志可以用来确定两个操作数之间的大小关系。

TEST指令的行为与AND指令一样,除了它们只设置条件码而不改变目的寄存器的值。

3.6.2 访问条件码

条件码通常不会直接读取,常用的使用方法有三种:1)可以根据条件码的某种组合,将一个字节设置为0或者1,2)可以条件跳转到程序的某个其他的部分,3)可以有条件地传送数据。

IMG_8142

上图描述的指令根据条件码的某种组合,将一个字节设置为0或者1.我们将这一整类指令称为SET指令。它们之间的区别就在于它们考虑的条件码的组合是什么,这些指令名字的不同后缀指明了它们所考虑的条件码的组合。这些指令的后缀表示不同的条件而不是操作数大小。

一条SET指令的目的操作数是低位单字节寄存器元素之一,或是一个字节的内存位置,指令会将这个字节设置成0或1。

3.6.3 跳转指令

正常执行的情况下,指令按照它们出现的顺序一条一条地执行。跳转(jump)指令会导致执行切换到程序中一个全新的位置。在汇编代码中,这些跳转的目的地通常用一个标号(label)指明。

IMG_8144 IMG_8145

上图中,jmp指令是无条件跳转。它可以是直接跳转,即跳转目标是作为指令的一部分编写的;也可以是间接跳转,即跳转目标是从寄存器或内存位置中读出的。

表中的其他跳转指令都是有条件的——它们根据条件码的某种组合,或者跳转,或者继续执行代码序列中下一条指令。

3.6.4 跳转指令的编码

在汇编代码中,跳转目标用符号标号书写。汇编器,以及后来的链接器,会产生跳转目标的适当编码。跳转指令有几种不同的编码,但是最常用都是PC相对的(PC-relative)。它们会将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码。这些地址偏移量可以编码为1、2或4个字节。第二种编码方法是给出“绝对”地址,用4个字节直接指定目标。汇编器和链接器会选择适当的跳转目的编码。

当执行PC相对寻址时,程序计数器的值是跳转指令后面的那条指令的地址,而不是跳转指令本身的地址。

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

将条件表达式和语句从C语言翻译成机器代码,最常用的方式是结合有条件和无条件跳转。

IMG_8147

在C语言中的if-else语句的通用形式模板如下:

if (test-expr)
  then-statement
else
  else-statement

这是test-expr是整数表达式,它的取值为0(false)或非0(true)。对于这种通用形式,汇编实现通常会用下面这种形式,这里我们用C语法来描述控制流:

	t = test-expr;
	if (!t)
    goto false;
	then-statement
  goto done;
false:
	else-statement
done:
3.6.6 用条件传送来实现条件分支

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

一种替代的策略是使用数据的条件转移。这种方法计算一个条件操作的两种结果,然后再根据条件是否满足从中选取一个。只有在一些受限制的情况中,这种策略才可行,但是如果可行,就可以用一条简单的条件传送指令来实现它,条件传送指令更符合现代处理器的性能特性。

IMG_8150

上图可以看到,cmovge指令实现了cmovdiff的条件赋值(第8行)。只有当第6行的cmpq指令表明一个值大于等于另一个值时,才会把数据源寄存器传送到目的。

为了理解为什么基于条件传送的代码会比基于条件控制转移的代码性能要好,我们必须了解一些关于现代处理器如何运行的知识。处理器通过使用流水线(pipelining)来获得高性能,在流水线中,一条指令的处理要经过一系列的阶段,每个阶段执行所需操作的一小部分(例如,从内存取指令、确定指令类型、从内存读数据、执行算术运算、向内存写数据,以及更新程序计数器)。这种方法通过重叠连续指令的步骤来获得高性能。例如,在取一条指令的同时,执行它前面一条指令的算术运算。要做到这一点,要求能够事先确定要执行的指令序列,这样才能保持流水线中充满了待执行的指令。当机器遇到条件调整时,只有当分支条件求值完成之后,才能决定分支往哪走。处理器采用非常精密的分支预测逻辑来猜测每条跳转指令是否会执行。只要它的猜测还比较可靠(现代微处理器设计试图达到90%以上的成功率),指令流水线中就会充满着指令。但如果错误预测一个跳转,处理器会丢掉它为该跳转指令后所有指令已做的工作,然后再开始用从正确位置处起始的指令去填充流水线。这样一个错误预测会招致很严重的惩罚,浪费大约15~30个时钟周期,导致程序性能严重下降。

IMG_8165

上图列举了x86-64上一些可用的条件传送指令。每条指令都有两个操作数:源寄存器或者内存地址S,和目的寄存器R。与各种SET和跳转指令一样,这些指令的结果取决于条件码的值。源值可以从内存或寄存器中读取,但是只有在指定的条件满足时,才会被复制到目的寄存器。

同条件跳转不同,处理器无需预测测试的结果就可以执行条件传送。处理器只是读源值,检查条件码,然后要么更新目的寄存器,要么保持不变。

条件传送的抽象代码描述:

v = then-expr;
ve = else-expr;
t = test-expr;
if (!t) v = ve;

不是所有的条件表达式都可以用条件传送来编译。抽象代码会对then-expr和else-expr都求值。如果这两个表达式中的任意一个可能产生错误条件或者副作用,就会导致非法的行为。

使用条件传送也不总是会提高代码的效率。例如,如果then-expr或else-expr的求值需要大量的计算,那么当相对应的条件不满足时,这些工作就白费了。编译器必须考虑浪费的计算和由于分支预测错误所造成的性能处罚之间的相对性能。

总的来说,条件传送提供了一种用条件控制转移来实现条件操作的替代策略。它们只能用于非常受限的情况,但是这些情况还是相当常见的,而且与现代处理器的运行方式更契合。

3.6.7 循环

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

1.do-while
do
	body-statement
  while (test-expr);

这个循环的效果就是重复执行body-statement,对test-expr求值。这种通用形式可被翻译如下的goto语句:

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

逆向工程循环

C语言编译器常常会重组计算,因为有些C代码中的变量在机器代码中没有对应的值。有时,机器代码又会引入源代码中不存在的新值。此外,编译器还常常试图将多个程序值映射到一个寄存器上,来最小化寄存器的使用率。

2.while循环

while循环的通用形式和goto代码:

while (test-expr)
  body-statement

// -------------
// 跳转到中间(jump to middle)
	goto test;
loop:
	body-statement
test:
	t = test-expr;
	if (t)
		goto loop;

// -------------
// guarded-do
// 当使用命令行选项-O1,GCC会采用这种策略
t = test-expr;
if (!t)
  goto done;
loop:
	body-statement
  t = test-expr;
	if (t)
    goto loop;
done:
IMG_8168 image-20230113083435277
3. for循环

for循环的通用格式如下:

for (init-expr; test-expr; update-expr)
	body-statement

for循环与下面使用的while循环的代码行为一样:

init-expr;
while (test-expr) {
	body-statement
	update-expr;
}

GCC为for循环产生的代码是while循环的两种翻译之一,这取决于优化的等级。

3.6.8 switch语句

switch(开关)语句可以根据一个整数索引值进行多重分支(multiway branching)。在处理具有多种可能结果的测试时,这种语句特别有用。它们不仅提高了C代码的可读性,而且通过使用跳转表(jump table)这种数据结构使得实现更加高效。

跳转表是一个数组,表项i是一个代码段的地址,这个代码段实现当开关索引值等于i时程序应该采取的动作。程序代码用开关索引值来执行一个跳转表内的数组引用,确定跳转指令的目标。和使用一组很长的if-else语句相比,使用跳转表的优点是执行开关语句的时间与开关情况的数量无关。GCC根据开关情况的数量和开关情况值的稀疏程序来翻译开关语句。当开关情况数量比较多(例如4个以上),并且值的范围跨度比较小时,就会使用跳转表。

IMG_8180 IMG_8181

上图可以看出,数组jt包含7个表项,每个都是一个代码块的地址。这些位置由代码中的标号定义,在jt的表项中由代码指针指明(&&运算符创建一个指向代码位置的指针)。执行switch语句的关键步骤是通过跳转表来访问代码位置。汇编代码第5行,jmp指令执行间接跳转。

C代码将跳转表声明为一个有7个元素的数组,每个元素都是一个指向代码位置的指针。

IMG_8182

标号.L4标记出这个分配地址的起始。与这个标号相对应的地址会作为间接跳转的基地址。

原始C代码中102会落到情况103中,可以从汇编代码看到.L5下没有jmp指令,这样代码就会继续执行下一个块。

3.7 过程

过程是软件中一种很重要的抽象。它提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能。可以在程序中不同的地方调用这个函数。不同编程语言中,过程的形式多样:函数(function)、方法(method)、子例程(subroutine)、处理函数(handler)等等,它们有一些共有的特性。

假设过程P调用过程Q,Q执行后返回到P。这种动作包括下面一个或多个机制:

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

x86-64的过程实现包括一组特殊的指令和一些对机器资源(例如寄存器和程序内存)使用的约定规则。

3.7.1 运行时栈

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

在过程P调用过程Q时,P以及所有在向上追溯到P的调用链中的过程,都是暂时被挂起的。当Q运行时,它只需要为局部变量分配新的存储空间,或者设置到另一个过程的调用。当Q返回时,任何它所分配的局部存储空间都可以被释放。因此,程序可以用栈来管理它的过程所需要的存储空间,栈和程序寄存器存放着传递控制和数据、分配内存所需要的信息。当P调用Q时,控制和数据信息添加到栈尾。当P返回时,这些信息会释放掉。

IMG_8183

当x86-64过程需要的存储空间超出寄存器能够存放的大小时,就会在栈上分配空间。这个部分称为过程的栈帧(stack frame)。当前正在执行的过程的帧总是在栈顶。

当过程P调用过程Q时,会把返回地址压入栈中,指明当Q返回时,要从P程序的哪个位置继续执行。Q的代码会扩展当前栈的边界,分配它的栈帧所需的空间。在这个空间中,它可以保存寄存器的值,分配局部变量空间,为它调用的过程设置参数。大多数过程的栈帧都是定长的,在过程的开始就分配好了。通过寄存器,过程P可以传递最多6个整数值(指针和整数),但是如果Q需要更多的参数,P在调用Q之前在自己的栈帧里存储好这些参数。

为了提高空间和时间效率,x86-64过程只分配自己所需要的栈帧部分。例如,许多过程有6个或者更少的参数,那么所有的参数都可以通过寄存器传递。实际上,许多函数甚至根本不需要栈帧。当所有的局部变量都可以保存在寄存器中,而且该函数不会调用任何其他函数时,就可以这样处理。

3.7.2 转移控制

将控制从函数P转移到函数Q只需要简单地把程序计数器(PC)设置为Q的代码的起始位置。当稍后从Q返回时,处理器必须记录好它需要继续P的执行的代码位置。在x86-64机器中,这个信息是用指令call Q调用过程Q来记录的。该指令会把地址A压入栈中,并将PC设置为Q的起始地址。压入的地址A被称为返回地址,是紧跟在call指令后面的那条指令的地址。对应的指令ret会从栈中弹出地址A,并把PC设置为A。

IMG_8184 IMG_8185 IMG_8186 IMG_8187

上图中,main调用top(100),然后top调用leaf(95)。函数leaf向top返回97,然后top向main返回194。上表说明了运行时栈在管理支持过程调用和返回所需的存储空间中的重要作用。这种把返回地址压入栈的简单的机制能够让函数在稍后返回到程序中正确的点。

3.7.3 数据传送

x86-64中,大部分过程间的数据传送是通过寄存器实现的。例如,当过程P调用过程Q时,P的代码必须首先把参数复制到适当的寄存器中。当Q返回到P时,P的代码可以访问寄存器%rax中的返回值。

寄存器的使用是有特殊顺序的,寄存器使用的名字取决于要传递的数据类型的大小。

IMG_8188

如果一个函数有大于6个整型参数,超出6个的部分就要通过栈来传递。假设过程P调用过程Q,有n个整型参数,且n>6。P的代码分配的栈帧必须要能容纳7到n号参数的存储空间。通过栈传递参数时,所有的数据大小都向8的倍数对齐。参数到位以后,程序就可以执行call指令将控制转移到过程Q了。过程Q可以通过寄存器访问参数,有必要的话也可以通过栈访问。相应地,如果Q也调用了某个有超过6个参数的函数,它也需要在自己的栈帧中为超出6个部分的参数分配空间。

IMG_8189 IMG_8190
3.7.4 栈上的局部存储

局部数据存放在内存中的常见情况包括:

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

过程通过减小栈指针在栈上分配空间。分配的结果作为栈帧的一部分,标号为“局部变量”。

IMG_8191

上图中,caller的代码开始时把栈指针减掉16,实际上这就是在栈上分配了16字节。汇编第11行把栈指针加16,释放栈帧。

image-20230117082936957

上图中,第2-15行是为调用proc做准备。其中包括为局部变量和函数参数建立栈帧,将函数参数加载至寄存器。

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

寄存器组是唯一被所有过程共享的资源。虽然在给定时刻只有一个过程是活动的,但必须确保当一个过程(调用者)调用另一个过程(被调用者)时,被调用者不会覆盖调用者稍后会使用的寄存器值。为此,x86-64采用了一组统一的寄存器使用惯例,所有的过程(包括程序库)都必须遵循。

根据惯例,寄存器%rbx、%rbp和%r12~%r15被划分为被调用者保存寄存器。当过程P调用过程Q时,Q必须保存这些寄存器的值,保证它们的值在Q返回到P时与Q被调用时是一样的。过程Q保存一个寄存器的值不变,要么就是根本不去改变它,要么就是把原始值压入栈中,改变寄存器的值,然后在返回前从栈中弹出旧值。压入寄存器的值会在栈帧中创建标号为“被保存的寄存器”的一部分。有了这条惯例,P的代码就能安全地把值存在被调用者保持寄存器中。

所有其他的寄存器,除了栈指针%rsp,都分类为调用者保存寄存器。这意味着任何函数都能修改它们。

IMG_8195

上图中,%rbp保存x和%rbx保存计算出来的Q(y)的值。在函数开头,把这两个寄存器的值保存到栈中(2~3行)。在第一次调用Q之前,把参数x复制%rbp。在第二次调用Q之前,把这次调用的结果复制到%rbx(8行)。在函数的结尾,把它们从栈中弹出,恢复这两个被调用者保存的寄存器值。

3.7.6 递归过程

每个过程调用在栈中都有它自己的私有空间,因此多个未完成调用的局部变量不会相互影响。此外,当过程被调用时分配局部存储,当返回时释放存储。

IMG_8196

上图是一个递归的阶乘函数,寄存器%rbx保存参数x,先把已有的值保存到栈上,随后在返回前恢复该值。

从这个例子可以看到,递归调用一个函数本身与调用其他函数是一样的。栈规则提供了一种机制,每次函数调用都有它自己私有的状态信息(保存的返回位置和被调用者保存寄存器的值)存储空间。如果需要,它还可以提供局部变量的存储。栈分配和释放的规则与函数调用-返回的顺序匹配。这种实现函数调用和返回的方法甚至对更复杂的情况也适用,包括相互递归调用。

3.8 数组分配和访问

C语言中的数组是一种将标量数据聚集成更大数据类型的方式。C语言的一个不同寻常的特点是可以产生指向数组中元素的指针,并对这些指针进行运算。在机器代码中,这些指针会被翻译成地址计算。

3.8.1 基本原则

对于数据类型T和整型常数N,声明如下:T A[N];

起始位置表示为 X A X_A XA。这个声明会在内存中分配一个L·N字节的连续区域,L指数据类型T的大小(单位为字节)。它引入了标识符A,A用来作为指向数组开头的指针,这个指针的值就是 X A X_A XA。可用0~N-1的整数索引来访问该数组元素。数组元素会被存放在地址为 X A + L ⋅ i X_A+L·i XA+Li的地方。

IMG_8207
3.8.2 指针运算

C语言允许对指针进行运算,而计算出来的值会根据该指针引用的数据类型的大小进行伸缩。也就是说,如果p是一个指向类型为T的数据的指针,p的值为 x p x_p xp,那么表达式p+i的值为 x p + L ⋅ i x_p+L·i xp+Li,这里L是数据类型T的大小。

单操作数操作符&和*可以产生指针和间接引用指针。对于一个表达式Expr,&Expr是给出该对象地址的一个指针。对于一个表示地址的表达式AExpr,*AExpr给出该地址处的值。因此,表达式Expr与* &Expr是等价的。

IMG_8208

假设整型数组E的起始地址和整数索引i分别在寄存器%rdx和%rcx中。上面是一些与E有关的表达式。

3.8.3 嵌套的数组

当我们创建数组的数组时,数组分配和引用的一般原则也是成立的。例如,声明int A[5][3]等价于下面的声明

typedef int row3_t[3];
row3_t A[5];

数据类型row3_t被定义为一个3个整数的数组。数组A包含5个这样的元素,每个元素需要12个字节来存储3个整数。整个数组的大小就是4×5×3=60字节。

数组元素在内存中按照“行优先”的顺序排序,意味着第0行的所有元素,可以写成A[0],后面跟着第1行的所有元素(A[1])。

IMG_8209

要访问多维数组的元素,编译器会以数组起始为基地址,(可能需要经过伸缩的)偏移量为索引,产生计算期望的元素的偏移量,然后使用某种MOV指令。通常来说,对于一个声明如下的数组:T D[R][C],它的数组元素D[i][j]的内存地址为&D[i][j]=x_D+L(C*i+j),这里L是数据类型T以字节为单位的大小。

IMG_8210
3.8.4 定长数组

C语言编译器能够优化定长多维数组上的操作代码。

IMG_8211

上图中的代码是计算矩阵A和矩阵B乘积,这段代码包含很多聪明的优化,它去掉了索引j,并把所有的数组引用都转换成了指针间接引用。

3.8.5 变长数组

历史上,C语言只支持大小在编译时就能确定的多维数组,程序员需要变长数组时不得不用malloc或calloc这样的函数为这些数组分配存储空间。ISO C99引入了一种功能,允许数组的维度是表达式,在数组被分配的时候才计算出来。

声明如下:int A[expr1][expr2]。它可以作为一个局部变量,也可以作为一个函数的参数,然后在遇到这个声明的时候,通过对表达式expr1和expr2求值来确定数组的维度。

IMG_8257

3.9 异质的数据结构

C语言提供了两种将不同类型的对象组合到一起创建数据类型的机制:结构(structure),用关键字struct来声明,将多个对象集合到一个单位中;联合(union),用关键字union来声明,允许用几种不同的类型来引用一个对象。

3.9.1 结构

结构的所有组成部分都存放在内存中一段连续的区域内,而指向结构的指针就是结构第一个字节的地址。编译器维护关于每个结构类型的信息,指示每个字段(field)的字节偏移。它以这些偏移作为内存引用指令中的位移,从而产生对结构元素的引用。

IMG_8258 IMG_8259
3.9.2 联合

联合的总的大小等于它最大字段的大小。联合可以用来访问不同数据类型的位模式:

unsigned long double2bits(double d) {
  union {
    double d;
    unsigned long u;
  } temp;
  temp.d = d;
  return temp.u;
}
3.9.3 数据对齐

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

无论数据是否对齐,x86-64硬件都能正常工作。不过,Intel还是建议要对齐数据以提高内存系统的性能。对齐原则是任何K字节的基本对象的地址必须是K的倍数。

对于包含结构的代码,编译器可能需要在字段的分配中插入间隙,以保证每个结构元素都满足它的对齐要求。

IMG_8260

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

3.10.1 理解指针

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

  • 每个指针都对应一个类型。这个类型声明该指针指向的是哪一类对象。指针类型不是机器代码中的一部分,它们是C语言提供的一种抽象,帮助程序员避免寻址错误。

  • 每个指针都有一个值。这个值是某个指定类型的对象的地址。特殊的NULL(0)值表示该指针没有指向任何地方。

  • 指针用&运算符创建。这个运算符可以应用到包含变量以及结构、联合和数组的元素。leaq指令是设计用来计算内存引用的地址的,&运算符的机器代码实现常常用这条指令来计算表达式的值。

  • *操作符用于间接引用指针。其结果是一个值,它的类型与该指针的类型一致。间接引用是用内存引用来实现的,要么是存储到一个指定的地址,要么是从指定的地址读取。

  • 数组与指针紧密联系。数组引用和指针运算都需要用对象大小对偏移量进行伸缩。

  • 将指针从一种类型强制转换成另一种类型,只改变它的类型,而不改变它的值。强制类型转换的一个效果是改变指针运算的伸缩。

  • 指针也可以指向函数。这提供了一个很强大的存储和向代码传递引用的功能,这些引用可以被程序的某个部分调用。

    例如:

    int fun(int x, int *p);
    
    // 声明一个指针fp,将它赋值为这个函数
    int (*fp)(int, int *);
    fp = fun;
    
    int y = 1;
    int result = fp(3, &y);
    

    函数指针的值是该函数机器代码表示中第一条指令的地址。

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

C对于数组引用不进行任何边界检查,而且局部变量和状态信息(例如保存的寄存器值和返回地址)都存放在栈中。这两种情况结合到一起会导致严重的程序错误,对越界的数组元素的写操作会破坏存储在栈中的状态信息。当程序使用这个被破坏的状态并试图重新加载寄存器或执行ret指令时,就会出现很严重的错误。

缓冲区溢出的一个更加致命的使用就是让程序执行它本来不愿意执行的函数。这是一种最常见的通过计算机网络攻击系统安全的方法。通常,输入给程序一个字符串,这个字符串包含一些可执行代码的字节编码,称为攻击代码(exploit code),另外,还有一些字节会用一个指向攻击代码的指针覆盖返回地址。

任何到外部环境的接口都应该是“防弹的”,这样,外部代理的行为才不会导致系统出现错误。

蠕虫和病毒

蠕虫和病毒都试图在计算机中传播它们自己的代码段。蠕虫(worm)可以自己运行,并且能够将自己的等效副本传播到其他机器。病毒(virus)能将自己添加到包括操作系统在内的其他程序中,但它不能独立运行。

3.10.4 对抗缓冲区溢出攻击
  1. 栈随机化

    栈随机化的思想使得栈的位置在程序每次运行时都有变化。因此,即使许多机器都运行同样的代码,它们的栈地址都是不同的。实现的方式是在程序开始时,在栈上分配一段0~n字节之间的随机大小的空间。

    在Linux系统重,栈随机化已经变成了标准行为。它是更大的一类技术中的一种,这种技术称为空间布局随机化(Address-Space Layout Randomization),简称ASLR。采用ASLR,每次运行时程序的不同部分,包括程序代码、库代码、栈、全局变量和堆数据,都会被加载到内存的不同区域。

    然而,执著的攻击者可以用蛮力克服随机化,他可以反复地用不同的地址进行攻击。一种常见的把戏就是在实际的攻击代码前插入很长一段的nop指令,执行这种指令只会让PC加一。只要攻击者能够猜中这段序列的某个地址,就能到达攻击代码。这个序列常用的术语是“空操作雪橇(nop sled)”。栈随机化和其他一些ASLR技术能够增加成功攻击一个系统的难度,但不能提供完全的安全保障。

  2. 栈破坏检测

    计算机的第二道防线是能够检测到何时栈已经被破坏。其思想是在栈帧中任意局部缓冲区与栈状态之间存储一个特殊的*金丝雀(canary)*值(哨兵值),是在程序每次运行时随机产生的。在恢复寄存器状态和从函数返回之前,程序检查这个金丝雀值是否被该函数的某个操作或者该函数调用的某个函数的某个操作改变了,如果是就让程序异常中止。

  3. 限制可执行代码区域

    最后一招是消除攻击者向系统中插入可执行代码的能力。一种方法是限制哪些内存区域能够存放可执行代码。在典型的程序中,只有保存编译器产生的代码的那部分内存才需要是可执行的。其他部分可以被限制为只允许读和写。虚拟内存空间在逻辑上被分成了页(page),典型的每页是2048或4096字节。硬件支持多种形式的内存保护,能够指明用户程序和操作系统内核所允许的访问方式。许多系统允许控制三种访问形式:读、写和执行。

    有些类型的程序要求动态产生和执行代码的能力。例如,“即时(just-in-time)”编译技术为解释语言编写的程序动态地产生代码,以提高执行性能。是否能够将可执行代码限制在由编译器在创建原始程序时产生的那个部分中,取决于语言和操作系统。

3.10.5 支持变长栈帧

大部分函数的机器级代码,编译器能够预先确定需要为栈帧分配多少空间。但有些函数,需要的局部存储是变长的。例如,当函数调用alloca为栈上分配任意字节数量的存储时就会发生这种情况,或者声明一个局部变长的数组。

IMG_8263

为了管理变长栈帧,x86-64代码使用寄存器%rbp作为帧指针(frame pointer)(有时称为基指针(base pointer))。

IMG_8264

上图可以看到代码必须把%rbp之前的值保存到栈中,因为它是一个被调用者保存寄存器。然后在函数的整个执行过程中,都使得%rbp指向那个时刻栈的位置,然后用固定长度的局部变量(例如i)相对于%rbp的偏移量来引用它们。

图3-43b中,在函数的开始,代码建立栈帧,并为数组p分配空间。然后,在栈上分配16个字节,其中前8个字节用于存储局部变量i,而后8个字节是未被使用的。在函数的结尾,leave指令将帧指针恢复到它之前的值。

3.11 浮点代码

1997年出现了Pentium/MMX,Intel和AMD都引入了持续数代的媒体(media)指令,支持图形和图像处理。这些指令本意是允许多个操作以并行模式执行,称为单指令多数据或SIMD。近年来,从MMX到SSE(Streaming SIMD Extension,流式SIMD扩展),以及最新的AVX(Advanced Vector Extension,高级向量扩展)。每一代中,都有一些不同的版本。每个扩展都是管理寄存器组中的数据,这些寄存器组在MMX中成为“MM”寄存器(64位),SSE中称为“XMM”寄存器(128位),AVX中称为“YMM”寄存器(256位)。每个YMM寄存器可以存放8个32位值,这些值可以是整数,也可以是浮点数。

2000年Pentium 4 中引入了SSE2,媒体指令开始包括那些对标量浮点数据进行操作的指令,使用XMM或YMM寄存器的低32位或64位中的单个值。这个标量模式提供了一组寄存器和指令,它们更类似于其他处理器支持浮点数的方式。因此x86-64浮点数是基于SSE或AVX的,包括传递过程参数和返回值的规则。

IMG_8265

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

3.11.1 浮点传送和转换操作
IMG_8266

代码优化规则建议32位内存数据满足4字节对齐,64位数据满足8字节对齐。

IMG_8267 IMG_8268

上图给出了在浮点数和整数数据类型之间以及不同浮点格式之间进行转换的指令集合。这些都是对单个数据值进行操作的标量指令。图3-47把浮点数转换成整数时,指令会执行截断(truncation),把值向0进行舍入,这是C和大多数其他编程语言的要求。

图3-48中的指令把整数转换成浮点数。它们使用的是不太常见的三操作数格式,有两个源和一个目的。源2的值只会影响结果的高位字节,一般源2和目的的操作数是一样的。

IMG_8269
3.11.2 过程中的浮点代码

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

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

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

3.11.3 浮点运算操作
IMG_8270

上图中,第一个源操作数可以是一个XMM寄存器或一个内存位置,第二个源操作数和目的操作数都必须是XMM寄存器。每个操作都有一条针对单精度的指令和一条针对双精度的指令。结果存放在目的寄存器中。

3.11.4 定义和使用浮点常数

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

3.11.5 在浮点代码中使用位级操作
IMG_8274
3.11.6 浮点比较操作
IMG_8275
3.11.7 对浮点代码的观察结论

用AVX2为浮点数上的操作产生的机器代码风格类似于为整数上的操作产生的代码风格。它们都使用一组寄存器来保存和操作数据值,也都使用这些寄存器来传递函数参数。

第4章:处理器体系结构

Y86-64指令集的数据类型、指令和寻址方式都要比x86-64少一些,字节编码也比较简单,没有x86-64代码紧凑,它的CPU译码逻辑也简单一些。

4.1 Y86-64指令集体系结构

IMG_8276

Y86-64(Y86-64是假设的指令集)程序中的每条指令都会读取或修改处理器状态的某些部分。这称为程序员可见状态。这里的“程序员”指的是用汇编代码写程序的人或产生机器级代码的编译器。

IMG_8277 IMG_8278

下面是Y86-64指令的一些细节:

  • x86-64的movq指令分成了4个不同的指令:irmovq、rrmovq、mrmovq和rmmovq,分别指明源和目的的格式。源可以是立即数(i)、寄存器(r)或内存(m)。
  • 有4个整数操作符,addq、subq、andq和xorq。
  • 7个跳转指令。
  • 有6个条件传送指令:cmovle、cmovl、cmove、cmovne、cmovge和cmovg。只有当条件码满足所需要的约束时,才会更新目的寄存器的值。
  • call指令将返回地址入栈,然后跳到目的地址。ret指令从这样的调用中返回。
  • pushq和popq指令实现了入栈和出栈。
  • halt指令停止指令的执行。x86-64的应用程序不允许使用这条指令,因为它会导致整个系统暂停运行。
4.1.3 指令编码

图4-2给出了指令的字节级编码。每条指令需要1~10个字节不等。每条指令的第一个字节表明指令的类型。这个字节分为两个部分,每部分4位:高4位是代码(code)部分,低4位是功能(function)部分。

IMG_8279

如上图所示,15个程序寄存器中每个都有一个对应的范围在0到0xE之间的寄存器标识符(register ID)。程序寄存器存在CPU中的一个寄存器文件中,这个寄存器文件就是一个小的、以寄存器ID作为地址的随机访问存储器。当需要指明不应访问任何寄存器时,就用ID值0xF来表示。

从图4-2可以看到,没有寄存器操作数的指令,比如分支指令和call指令,就没有寄存器指示符字节。

指令集的一个重要性质就是字节编码必须有唯一的解释。任意一个字节序列要么是一个唯一的指令序列的编码,要么就不是一个合法的字节序列。

4.1.4 Y86-64异常
IMG_8286

对Y86-64来说,程序员可见的状态包括状态码Stat,它描述程序执行的总体状态。当遇到这些异常的时候,我们就简单地让处理器停止执行指令。在更完整的设计中,处理器通常会调用一个异常处理程序(exception handler),这个过程被指定用来处理遇到的某种类型的异常。

4.2 逻辑设计和硬件控制语言HCL

在硬件设计中,用电子电路来计算对位进行运算的函数,以及在各种存储器单元中存储位。大多数现代电路技术都是用信号线上的高电压或低电压来表示不同的位值。要实现一个数字系统需要三个主要的组成部分:计算对位进行操作的函数的组合逻辑、存储位的存储器单元,以及控制存储器单元更新的时钟信号。

现代逻辑设计

曾经的硬件设计者通过描绘示意性的逻辑电路图来进行电路设计(最早是纸和笔,后来是计算机图形终端)。现在,大多数设计都是用硬件描述语言(Hardware Description Language,HDL)来表达。HDL是一种文本表示,看上去和编程语言类似,但是它是用来描述硬件结构而不是程序行为的。

20世纪80年代,研究者开发出了逻辑合成程序,它可以根据HDL的描述来生成有效的电路设计。从手工设计电路到合成生成的转变就好像从写汇编程序到写高级语言程序,再用编译器来产生机器代码的转变一样。

HCL(Hardware Control Language)语言只表达硬件设计的控制部分,只有有限的操作集合,也没有模块化。

4.2.1 逻辑门
IMG_8287

逻辑门是数字电路的基本计算单元。它们产生的输出,等于它们输入位值的某个布尔函数。C语言中运算符的逻辑门下面是对应的HCL表达式:AND用&&表示,OR用||表示,而NOT用!表示。

逻辑门总是活动的(active)。一旦一个门的输入变化了,在很短的时间内,输出就会相应地变化。

4.2.2 组合电路和HCL布尔表达式

将很多的逻辑门组合成一个网,就能构建计算块(computational block),称为组合电路(combinational circuits)。构建这些网友几个限制:

  • 每个逻辑门的输入必须连接到下述选项之一:1)一个系统输入(称为主输入),2)某个存储器单元的输出,3)某个逻辑门的输出。
  • 两个或多个逻辑门的输出不能连接在一起。否则它们可能会使线上的信号矛盾,可能会导致一个不合法的电压或电路故障。
  • 这个网必须是无环的。也就是网中不能有路径经过一系列的门形成一个回路,这样的回路会导致该网络计算的函数有歧义。
IMG_8288

上图的电路用HCL表示:bool eq = (a && b) || (!a && !b);

IMG_8289

上图的组合电路称为多路复用器(multiplexor)。多路复用器根据输入控制信号的值,从一组不同的数据信号中选出一个。控制信号s决定输出a或b,HCL表达式为:bool out = (s && a) || (!s && b);

HCL表达式表明了组合逻辑电路和C语言中逻辑表达式的对应之处。它们都是用布尔操作来对输入进行计算的函数。这两种表达计算的方法之间有以下区别:

  • 组合电路由一系列逻辑门组成,它的属性是输出会持续地响应输入的变化。而C表达式只会在程序执行过程中被遇到时才进行求值。
  • C的逻辑表达式允许参数的任意整数,0表示FASLE,其他任何值都表示TRUE。而逻辑门只对位值0和1进行操作。
  • C的逻辑表达式可能只被部分求值。而组合逻辑没有部分求值这条规则,逻辑门只是简答地响应输入的变化。
4.2.3 字级的组合电路和HCL整数表达式

通过将逻辑门组合成大的网,可以构造出能计算更加复杂函数的组合电路。通常,我们设计能对数据*字(word)*进行操作的电路。有一些位级信号,代表一个整数或一些控制模式。

IMG_8290

执行字级计算的组合电路根据输入字的各个位,用逻辑门来计算输出字的各个位。例如,上图中,它测试两个64位字A和B是否相等。当且仅当A的每一位和B的相应位相等时,输出才为1。这个电路是用64个图4-10中所示的单个位相等电路实现的。在HCL中,我们将所有字级的信号声明为int,不指定字的大小(为了简单)。字级表达式为:bool Eq = (A == B);。这里的参数A和B是int型的。

IMG_8291

上图是字级的多路复用器电路。这个电路根据控制输入为s,产生一个64位的字Out,等于两个输入字A或B的一个。处理器中会用到很多种多路复用器,使得我们能根据某些控制条件,从许多源中选出一个字。

IMG_8292

上图用HCL来表示这个电路:

word Out4 = [
	!s1 && !s0 : A; # 00
	!s1 : B; # 01
	!s0 : C; # 10
	1 : D; # 11
];
IMG_8296

算术/逻辑单元(ALU)是一种很重要的组合电路。这个电路有三个输入:标号为A和B的两个数据输入,以及一个控制输入。根据控制输入的设置,电路会对输入执行不同的算术或逻辑操作。

4.2.4 集合关系

在处理器设计中,很多时候都需要将一个信号与许多可能匹配的信号做比较,以此检测正在处理的某个指令代码是否属于某一类指令代码。

IMG_8297

在上图这个电路中,两位的信号code可以用来控制对4个数据字做选择。

4.2.5 存储器和时钟

组合电路从本质上讲,不存储任何信息。相反,它们只是简单地响应输入信号,产生等于输入的某个函数的输出。按位存储信息的设备可以产生时序电路(sequential circuit),也就是是有状态并且在这个状态上进行计算的系统。存储设备都是由同一个时钟控制的,时钟是一个周期性信号,决定什么时候要把新值加载到设备中。有两类存储器设备:

  • 时钟寄存器(简称寄存器)存储单个位或字。时钟信号控制寄存器加载输入值。
  • 随机访问存储器(简称内存)存储多个字,用地址来选择该读或该写哪个字。随机访问存储器的例子包括:1)处理器的虚拟内存系统,硬件和操作系统软件结合起来使处理器可以在一个很大的地址空间内访问任意的字;2)寄存器文件,在此,寄存器标识符作为地址。在IA32或Y86-64处理器中,寄存器文件有15个程序寄存器(%rax~%r14)。

在硬件和机器级编程中,“寄存器”这个词有细微差别。在硬件中,寄存器直接将它的输入和输出连接到电路的其他部分。在机器级编程中,寄存器代表的是CPU中为数不多的可寻址的字,这里的地址是寄存器ID。这些字通常都存在寄存器文件中。

IMG_8298

上图显示一个硬件寄存器以及它是如何工作的。大多数时候,寄存器都保持在稳定状态(用x表示),产生的输出等于它的当前状态。信号沿着寄存器前面的组合逻辑传播,这时,产生了一个新的寄存器输入(用y表示),但只要时钟是低电位的,寄存器的输出就仍然保持不变。当时钟变成高电位时,输入信号就加载到寄存器中,成为下一个状态y,直到下一个时钟上升沿,这个状态就一直是寄存器的新输出。我们的Y86-64寄存器会用时钟寄存器保存程序计数器(PC)、条件代码(CC)和程序状态(Stat)。

IMG_8304

上图的寄存器文件有两个读端口(A和B)和一个写端口(W)。这样一个多端口随机访问存储器允许同时进行多个读和写操作。电路可以读两个程序寄存器的值,同时更新第三个寄存器的状态。每个端口都有一个地址输入,表明该选择哪个程序寄存器,另外还有一个数据输出或对应该程序寄存器的输入值。读端口有地址输入和数据输出。写端口有地址输入和数据输入。

向寄存器文件写入字是由时钟信号控制的,控制方式类似于将值加载到时钟寄存器。每次时钟上升时,输入valW上的值会被写入输入dstW上的寄存器ID指示的程序寄存器。

IMG_8305

上图是一个随机访问存储器。这个内存有一个地址输入、一个写的数据输入和一个读的数据输出。如果地址超出范围,error信号会设置为1,否则就设置为0。写内存是由时钟控制的。

处理器还包括一个只读存储器,用来读指令。在大多数实际系统中,这两个存储器被合并为一个具有双端口的存储器:一个用来读指令,另一个用来读或者写数据。

4.3 Y86-64的顺序实现

现在我们已有实现Y86-64处理器所需要的部件。每个时钟周期上,SEQ(se-quential顺序的处理器)执行处理一条完整指令所需的所有步骤。不过,这需要一个很长的时钟周期时间,因此时钟周期频率会低到不可接受。我们最终目的是实现一个高效的、流水线化的处理器。

se-quential顺序处理器是一种类型的处理器,其中指令按照顺序执行,一次执行一条指令。这种处理器通常使用单独的控制单元来管理指令流程,以确保指令按预定顺序执行。在顺序处理器中,如果某个指令需要花费很长时间才能执行完毕,则整个处理器将长时间停顿,等待该指令执行完毕。然而,顺序处理器是一种简单的,易于理解和设计的处理器类型,因此仍然在许多系统中使用。

流水线是计算机系统设计的一种重要思想。它的基本原理是把一个复杂的任务分解成若干个相对独立的子任务,并对每个子任务分别进行处理,然后把它们的处理结果依次组装起来,形成最终的处理结果。它利用了并行计算的思想,通过同时处理多个任务,实现对整个系统的加速。这样可以有效利用计算机的资源,提高处理效率。

4.3.1 将处理组织成阶段

通常,处理一条指令包括很多操作。下面是关于各个阶段以及各阶段内执行操作的简略描述:

  • 取指(fetch):取指阶段从内存读取指令字节,地址为程序计数器(PC)的值。从指令中抽取出指令指示符字节的两个四位部分,称为icode(指令代码)和ifun(指令功能)。它可能取出一个寄存器指示符字节,指明一个或两个寄存器操作数指示符rA和rB。它还可能取出一个8字节常数字valC。它按顺序方式计算当前指令的下一条指令valP(等于PC的值加上已取出指令的长度)。
  • 译码(decode):译码阶段从寄存器文件读入最多两个操作数,得到值valA和/或valB。
  • 执行(execute):在执行阶段,算术/逻辑单元(ALU)要么执行指令指明的操作(根据ifun的值),计算内存引用的有效地址,要么增加或减少栈指针。得到的值我们称为valE。在此,也可能设置条件码。
  • 访存(memory):访存阶段可以将数据写入内存,或者从内存读出数据。读出的值为valM。
  • 写回(write back):写回阶段最多可以写两个结果到寄存器文件。
  • 更新PC(PC update):将PC设置成下一条指令的地址。

处理器无限循环,执行这些阶段。在我们简化的实现中,发生任何异常时,处理器就会停止:它执行halt指令或非法指令,或它试图读或者写非法地址。在更完整的设计中,处理器会进入异常处理模式,开始执行由异常的类型决定的特殊代码。

  • valA: 该寄存器中保存的值是从寄存器A读取的值。这个值通常用于指令执行的操作数之一。
  • valB: 该寄存器中保存的值是从寄存器B读取的值。这个值通常用于指令执行的操作数之一。
  • valC: 该寄存器中保存的值是从指令中读取的立即数。这个值通常用于指令执行的操作数之一。
  • valE: 该寄存器中保存的值是指令执行产生的结果。这个值通常用于更新寄存器或存储器。
  • valP: 该寄存器中保存的值是下一条指令的地址。这个值通常在指令执行结束后用于更新程序计数器(PC)。
  • valM: 该寄存器中保存的值是从内存中读取的值。这个值通常用于指令执行的操作数之一或用于更新寄存器。
  • dstE: 该寄存器指定了指令执行的结果应该被写入的目标寄存器的编号。
  • dstB: 该寄存器指定了指令执行的结果应该被写入的目标寄存器的编号。
  • srcA: 该寄存器指定了指令执行的第一个操作数的寄存器编号。
  • srcB: 该寄存器指定了指令执行的第二个操作数的寄存器编号。
IMG_8309 IMG_8310 IMG_8311 IMG_8312 IMG_8314
4.3.2 SEQ硬件结构
IMG_8315

程序计数器(PC)放在寄存器中。在SEQ中,所有硬件单元的处理都在一个时钟周期内完成。上面的图省略了一些小的组合逻辑块,还省略了所有用来操作各个硬件单元以及将相应的值路由到这些单元的控制逻辑。

硬件单元与各个处理阶段相关联:

  • **取指:**将程序计数器寄存器作为地址,指令内存读取指令的字节。PC增加器(PC increamenter)计算valP,即增加了的程序计数器。

  • **译码:**寄存器文件有两个读端口A和B,从这两个端口同时读寄存器值valA和valB。

  • **执行:**执行阶段会根据指令的类型,将算术/逻辑单元(ALU)用于不同的目的。对整数操作,它要执行指令所指定的运算。对其他指令,它会作为一个加法器来计算增加或减少栈指针,或者计算有效地址,或者只是简单地加0,将一个输入传递到输出。

    条件码寄存器(CC)有三个条件码位。ALU负责计算条件码的新值。当执行条件传送指令时,根据条件码和传送条件来计算决定是否更新目标寄存器。同样,当执行一条跳转指令时,会根据条件码和跳转类型来计算分支信号Cnd。

  • **访存:**在执行访存操作时,数据内存读出或写入一个内存字。指令和数据内存访问的是相同的内存位置,但是用于不同的目的。

  • **写回:**寄存器文件有两个写端口。端口E用来写ALU计算出来的值,而端口M用来写从数据内存中读出的值。

  • **PC更新:**程序计数器的新值选择自:valP,下一条指令的地址;valC,调用指令或跳转指令指定的目标地址;valM,从内存读取的返回地址。

IMG_8316

上图中的画图惯例如下:

  • 白色方框表示时钟寄存器。程序计数器PC是SEQ中唯一的时钟寄存器。
  • 浅蓝色方框表示硬件单元。这包括内存、ALU等等。
  • 控制逻辑块用灰色圆角矩形表示。这些块用来从一组信号源中进行选择,或者用来计算一些布尔函数。
  • 线路的名字在白色圆圈中说明。
  • 宽度为字长的数据连接用中等粗度的线表示。每条这样的线实际上都代表一簇64根线,并列地连在一起,将一个字从硬件的一个部分传送到另一部分。
  • 宽度为字节或更窄的数据连接用细线表示。根据线上要携带的值的类型,每条这样的线实际上都代表一簇4根或8根线。
  • 单个位的连接用虚线表示。这代表芯片上单元与块之间传递的控制值。
4.3.3 SEQ的时序

图4-23显示出,一个时钟变化会引发一个经过组合逻辑的流,来执行整个指令。

SEQ的实现包括组合逻辑和两种存储器设备:时钟寄存器(程序计数器和条件码寄存器),随机访问存储器(寄存器文件、指令内存和数据内存)。组合逻辑不需要任何时序或控制——只要输入变化了,值就通过逻辑门网络传播。由于指令内存只是用来读指令,因此我们可以将这个单元看成是组合逻辑。

剩下4个硬件单元(程序计数器、条件码寄存器、数据内存和寄存器文件)需要对它们的时序进行明确的控制。这些单元通过一个时钟信号来控制,它触发将新值装载到寄存器以及将值写到随机访问存储器。每个时钟周期,程序计数器都会装载新的指令地址。只有在执行整数运算指令时,才会装载条件码寄存器。只有在执行rmmovq、pushq或call指令时,才会写数据内存。寄存器文件的两个写端口允许每个时钟周期更新两个程序寄存器。

要控制处理器中活动的时序,只需要寄存器和内存的时钟控制。即使所有的状态更新实际同时发生,且只在时钟上升开始下一个周期时。之所以能保持这样的等价性,是由于Y86-64指令集的本质。**原则:从不回读。**处理器从来不需要为了完成一条指令的执行而去读由该指令更新了的状态。

IMG_8317

上图中不同颜色的代码表明电路信号是如何与正在被执行的不同指令相联系的。在时钟周期3开始的时候(点1),状态单元保持的是第二条irmovq指令更新过的状态,该指令用浅灰色表示。组合逻辑用白色表示,表示它还没来得及对变化了的状态做出反应。时钟周期开始时,地址0x014载入程序计数器中。这样就会取出和处理addq指令。值沿着组合逻辑流动,包括读随机访问存储器。在这个周期末尾(点2),组合逻辑为条件码产生了新的值(000),程序寄存器%rbx的更新值,以及程序计数器的新值(0x016)。在此时,组合逻辑已经根据addq指令被更新了,但是状态还是保持着第二条irmovq指令(用浅灰色表示)设置的值。

当时钟上升开始周期4时(点3),会更新程序计数器、寄存器文件和条件码寄存器,我们用蓝色表示,但是组合逻辑还没有对这些变化做出反应,所以用白色表示。在这个周期内,会取出并执行je指令,在图用深灰色表示,因为条件码ZF为0,所以不会选择分支。在这个周期末尾(点4),程序计数器已经产生了新值0x01f。组合逻辑已经根据je指令(用深灰色表示)被更新过了,但是直到下个周期开始之前,状态还是保持着addq指令(用蓝色表示)设置的值。

如此例所示,用时钟来控制状态单元的更新,以及值通过组合逻辑来传播,足够控制我们SEQ实现中每条指令执行的计算了。每次时钟由低变高时,处理器开始执行一条新指令。

4.3.4 SEQ阶段的实现
IMG_8319

nop指令只是简单地经过各个阶段,除了要将PC加1,不进行任何处理。halt指令使得处理器状态被设置为HLT,导致处理器停止运行。

1.取指阶段
IMG_8320

取值阶段包括指令内存硬件单元。以PC作为第一个字节(字节0)的地址,这个单元一次从内存读出10个字节。第一个字节被解释成指令字节,分为两个4位的数。然后,标号为“icode”和“ifun”的控制逻辑块计算指令和功能码,或者使之等于从内存读出的值,或者当指令地址不合法使(由信号imem_error指明),使这些值对应于nop指令。根据icode的值,我们可以计算三个一位的信号(用虚线表示):

  • instr_valid:这个字节对应于一个合法的Y86-64指令吗?
  • need_regids:这个指令包括一个寄存器指示符字节吗?
  • need_valC:这个指令包括一个常数字吗?

从指令内存中读出的剩下9个字节是寄存器指示符字节和常数字的组合编码。标号为“Align”的硬件单元会处理这些字节,将它们放入寄存器字段和常数字中。当need_regids为1时,字节1被分开转入寄存器指示符rA和rB中。否则这两个字段会被设置为0xF(RNODE),表明这条指令没有指明寄存器。“Align”单元还产生常数字valC,根据信号need_regids的值,要么根据字节18来产生valC,要么根据字节29来产生。

PC增加器硬件单元根据当前的PC以及两个信号need_regids和need_valC的值来产生valP。

2.译码和写回阶段
IMG_8321

上图中,把译码和写回阶段联系在一起是因为它们都要访问寄存器文件。

寄存器文件有四个端口。它支持同时进行两个读(在端口A和端口B)和两个写(在端口E和M)。每个端口都有一个地址连接和一个数据连接,地址连接是一个寄存器ID,而数据连接是一组64根线路,即可以作为它的输入字,也可以作为它的输出字。两个读端口的地址输入为srcA和srcB,两个写端口的地址输入为dstE和dstM。如果某个地址端口上的值为特殊标识符0xF(RNODE),则表明不需要访问寄存器。

3.执行阶段
IMG_8322

执行阶段包括算术/逻辑单元(ALU)。这个单元根据alufun信号的设置,对输入aluA和aluB执行ADD、SUBTRACT、AND或EXCLUSIVE-OR运算。

标号为“cond”的硬件单元会根据条件码和功能码来确定是否进行条件分支或者条件数据传送。它产生信号Cnd,用于设置条件传送的dstE,也用在条件分支的下一个PC逻辑中。

4.访存阶段
IMG_8323

访存阶段的任务就是读或写程序数据。两个控制块产生内存地址和内存输入数据(为写操作)的值。另外两个块产生表明应该执行读操作还是写操作的控制信号。当执行读操作时,数据内存产生值valM。

5.更新PC阶段
IMG_8326

SEQ中最后一个阶段会产生程序计数器的新值。

6. SEQ小结

SEQ唯一的问题就是它太慢了。时钟必须非常慢,以使信号能在一个周期内传播所有的阶段。

4.4 流水线的通用原理

在流水线化的系统中,待执行的任务被划分成了若干个独立的阶段。流水线化的一个重要特性是提高了系统的吞吐量(throughput),也就是单位时间内服务的顾客总数,不过它也会轻微地增加延迟(latency),也就是服务一个用户所需要的时间。

4.4.1 计算流水线
IMG_8327

在现代逻辑设计中,电路延迟以微微秒或皮秒(picosecond,ps)为单位来计算。

上图是一个很简单的非流水线化的硬件系统的例子。这个实现中,在开始下一条指令之前必须完成前一个。

IMG_8328

假设将系统执行的计算分为三个阶段,每个阶段需要100ps。然后在各个阶段之间放上流水线寄存器(pipeline register),这样每条指令都会按照三步经过这个系统,从头到尾需要三个完整的时钟周期。在稳定状态下,三个阶段都应该是活动的,每个时钟周期,一条指令离开系统,一条新的进入。这条流水线的吞吐量是非流水线化的2.67倍,代价是增加了一些硬件以及延迟的少量增加(流水线寄存器的时间开销)。

4.4.2 流水线操作的详细说明
IMG_8329

如上图所示,流水线阶段之间的指令转移是由时钟信号来控制的。每个120ps,信号从0上升到1,开始下一组流水线阶段的计算。

IMG_8330

上图是240~360之间的电路活动。随着时钟周而复始地上升和下降,不同的指令就会通过流水线的各个阶段,不会相互干扰。

4.4.3 流水线的局限性
1.不一致的划分
IMG_8331

上图表明,每个时钟周期,阶段A都会空闲100ps,阶段C会空闲50ps。我们必须将时钟周期设为150+20=170ps。

2.流水线过深,收益反而下降
IMG_8332

上图说明了流水线技术的另一个局限性。延迟成了流水线吞吐量的一个制约因素。

4.4.4 带反馈的流水线系统

到目前为止,我们只考虑一种系统,其中传过流水线的对象,相互都是完全独立的,但是,对于Y86-64这样执行机器程序的系统来说,相邻指令之间很可能是相关的,例如:

irmovq $50, %rax 
addq %rax, %rbx 
mrmovq 100(%rbx), %rdx 

在这包含三条指令的序列中,每对相邻的指令之间都有数据相关(data dependency)。

另一种相关是由于指令控制流造成的顺序相关。

第5章:优化程序性能

编写高效程序需要做到以下几点:第一,我们必须选择一组适当的算法和数据结构。第二,我们必须编写出编译器能够有效优化以转换成高效可执行代码的源代码。第三项技术针对处理运算量特别大的计算,将一个任务分成多个部分,这些部分可以在多核和多处理器的某些组合上并行地计算。

现代编译器采用了复杂的分析和优化形式,而且变得越来越好。然而,即使是最好的编译器也受到*妨碍优化的因素(optimization blocker)*的阻碍,妨碍优化的因素就是程序行为中那些严重依赖于执行环境的方面。

程序优化的第一步就是消除不必要的工作,让代码尽可能有效地执行所期望的任务(不必要的函数调用、条件测试和内存引用)。

5.1 优化编译器的能力和局限性

什么是内联替换?

  1. 定义: 内联替换是编译器的一种优化技术,用于减少函数调用的开销。当你将一个函数标记为inline,编译器尝试将该函数的代码直接嵌入到每个调用点,而不是进行常规的函数调用。
  2. 如何工作: 在正常的函数调用中,程序在调用函数时需要跳转到函数的代码块,执行完毕后再跳回。这个过程涉及到一些开销,如保存调用点的信息、传递参数等。内联替换通过将函数的代码直接放置在调用点来避免这些开销。

它的作用是什么?

  1. 减少开销: 内联可以减少函数调用的开销,特别是对于小型、频繁调用的函数。
  2. 提高性能: 通过减少跳转和参数传递,内联可以提升程序的运行速度。
  3. 代码膨胀: 内联可能会导致代码大小的增加,因为相同的函数代码会被复制到每个调用点。因此,编译器通常会评估是否进行内联,以平衡性能和代码大小。

5.2 表示程序性能

**“每元素的周期数”(CPE, Cycles Per Element)**是一个衡量计算性能的指标,用于评估在处理数据元素(如数组中的每个数)时,计算机花费了多少个处理器周期。

通俗解释

想象你在工厂流水线上工作,每个产品(元素)都需要通过你进行一些处理。CPE就好比是你处理每个产品所花费的时间。如果你每处理一个产品需要5秒,那么你的“每产品的时间”是5秒。同理,在计算机中,如果处理每个数据元素需要5个处理器周期,那么CPE就是5。

为什么重要

  1. 性能评估: CPE是理解程序性能的一个关键指标。通过测量处理每个元素所需的周期数,可以判断程序的效率。
  2. 优化参考: 通过分析CPE,开发者可以识别性能瓶颈,并进行针对性的优化。比如,如果CPE很高,可能意味着需要优化算法或者改善数据访问方式。

影响CPE的因素

  • 处理器架构: 不同的处理器有不同的性能特征,这直接影响CPE。
  • 内存访问: 数据访问模式(如缓存命中和缺失)会显著影响CPE。缓存命中率高,CPE通常较低。
  • 编译器优化: 编译器的优化设置会改变代码的执行方式,从而影响CPE。
  • 并行化: 利用并行处理技术(如多线程或向量化)可以降低CPE,因为可以同时处理多个数据元素。

实际应用

在性能调优和算法分析中,CPE是一个很实用的指标。比如,在对科学计算、大数据处理等任务进行优化时,了解并尽量降低CPE是提高整体性能的关键。

总之,CPE提供了一个量化的方法来衡量和理解程序在处理每个数据元素时的效率,它是性能分析和优化的一个重要工具。

最小二乘拟合是一种数学方法,用于找到一组数据点的最佳拟合线或曲线。这种方法广泛应用于统计学、数据分析和工程领域,用于从实验或观测数据中提取趋势或模式。

通俗解释

想象你在一张纸上随机画了很多点,这些点代表你收集的数据(比如一周内每天的温度和时间)。现在,你想找到一条直线,尽可能接近这些点,以此来总结温度随时间变化的大致趋势。最小二乘拟合就是帮助你找到这样一条直线的方法。

最小二乘拟合的工作原理

  1. 目标: 最小化所有数据点到拟合线(或曲线)的距离的平方和。这些距离称为“残差”。
  2. 过程: 算法通过调整拟合线的斜率和截距,尽量减少所有点到这条线的垂直距离的总和。

它的作用

  1. 数据趋势分析: 最小二乘拟合帮助我们理解数据的总体趋势。例如,它可以显示随时间变化的温度或股票价格的趋势。
  2. 预测: 一旦我们有了拟合线,就可以使用它来预测未来的值。比如,根据过去的销售数据预测未来的销售趋势。
  3. 误差估计: 它还提供了一种估计模型与实际数据匹配程度的方法,通过查看残差,我们可以了解模型的准确性。

实际应用

最小二乘拟合在科学和工程领域非常有用。比如,它被用于从实验数据中提取物理定律,或在经济学中分析消费者行为。它也是机器学习和数据科学中重要的工具之一,用于建立和验证模型。

总的来说,最小二乘拟合是一种强大的工具,可以帮助我们从杂乱的数据中提取有用的信息,理解数据背后的趋势和模式,并进行有效的预测。

5.4 消除循环的低效率

未经优化的代码是从C语言代码到机器代码的直接翻译,通常效率明显较低。推荐养成至少用“-O1”级别的优化,可以显著提高程序性能。

在循环for的判断中,禁止在里面调用返回结果一样的函数,因为这样会显著增加性能开支,造成性能瓶颈。

5.6 消除不必要的内存引用

通过使用局部变量来减少对内存的频繁访问。

5.7 理解现代处理器

当代码中的数据相关限制了处理器利用指令集并行的能力时,延迟界限(latency bound)能够限制程序性能。

整体操作

流水线并行执行(Pipelining)和乱序并行执行(Out-of-Order Execution)执行的区别:

  1. 执行顺序
    • 流水线并行:遵循指令的原始顺序。
    • 乱序并行:允许改变指令的执行顺序。
  2. 性能优化
    • 流水线并行:通过并行化指令的不同阶段来提高性能。
    • 乱序并行:通过智能地调整指令执行顺序来减少等待和闲置时间。
  3. 复杂性
    • 流水线并行:结构相对简单。
    • 乱序并行:结构更复杂,需要复杂的逻辑来跟踪和管理指令的依赖性和状态。
  4. 应用场景
    • 流水线并行:适用于指令顺序性强、依赖关系简单的场景。
    • 乱序并行:更适用于指令之间依赖关系复杂或存在许多数据等待的场景。

超标量处理器(superscalar),它可以在一个时钟周期内同时执行多条指令,而且是乱序的(out-of-order),意思是执行的顺序不一定要与它们在机器级程序中的顺序一致。整体设计有两个主要部分:指令控制单元(Instruction Control Unit,ICU)和执行单元(Execution Unit,EU)。前者负责从内存中读取指令序列,并根据这些指令序列生成一组对程序数据的基本操作;而后者执行这些操作。

指令控制单元ICU和执行单元EU的关系可以看作是计划者和执行者的关系。ICU负责“计划”阶段,即理解和准备指令的执行。一旦指令被解码并准备好,它们就被送往EU进行“执行”阶段。这个过程涉及到紧密的协作和通信,因为ICU需要确保EU始终有指令可执行,而EU需要向ICU反馈执行状态,以便ICU可以做出适当的调整和优化。

IMG_0799

**分支预测技术(branch prediction)**是现代计算机处理器用来提高效率的一种方法。要理解分支预测,我们可以用一个日常生活中的比喻来说明。

想象你每天早上出门上班,有两条路可以选择:一条是经常堵车的主路,另一条是较少人知道的小路。如果你每天都选择同一条路,你可能会根据过去的经验来判断哪条路更快。比如,如果你发现小路通常不堵车,你可能会倾向于选择小路。这就是一种“预测”。

在计算机处理器中,分支预测的原理也类似。处理器在执行程序时会遇到很多“分支”——这就像是你每天早上选择路线一样。这些分支通常是基于某些条件的决策点,比如“如果这个条件成立,就执行这部分代码,否则执行另一部分代码”。

处理器使用分支预测技术来“猜测”这些分支将如何执行。它会根据以前遇到的类似情况来预测这次分支会怎样。如果预测正确,那么处理器就可以提前准备好接下来要执行的指令,这样就不需要等待实际的分支决策了。就像你提前选择了小路一样,这样可以节省时间。

但是,如果预测错误,就像你选错了路一样,处理器就需要回到分支点,选择正确的路径,这会造成一些时间上的损失。

**投机执行(Speculative Execution)**是一种现代计算机处理器中用来提高性能的技术。要理解这个概念,我们可以用一个日常生活的比喻来帮助说明。

想象你正在准备晚餐,而你的食谱包含多个步骤。有些步骤需要等待之前的步骤完成(比如,等烤箱预热完成)。如果你按照食谱上的步骤顺序一步一步来,你可能会在等待的时候无所事事。但是,如果你预测接下来的步骤,并在等待的同时开始准备接下来的食材(比如切蔬菜),你可以节省时间。如果你的预测是正确的,当烤箱预热完成时,你已经准备好了下一步要用的食材。但如果你的预测是错误的(比如,你忘记了你需要先搅拌食材而不是切它们),你可能需要重新调整。

在计算机处理器中,投机执行的原理类似。处理器在执行程序时会遇到需要等待的情况,比如等待数据从内存加载。在这种情况下,处理器会尝试“预测”接下来的指令,并开始执行这些预测的指令,而不是空闲等待。

例如,当遇到一个分支指令(比如一个“如果”语句)时,处理器会尝试预测这个分支将会如何执行,并在等待实际结果的同时,提前执行接下来的指令。如果预测正确,这就节省了时间,因为处理器已经提前完成了一些工作。但如果预测错误,处理器就需要撤销它所做的工作,并转而执行正确的指令。

总的来说,投机执行是一种通过提前执行可能需要的指令来减少等待时间的技术,它可以显著提高处理器的效率。不过,这种技术也带来了一些安全隐患,比如著名的“幽灵”(Spectre)和“熔断”(Meltdown)漏洞,这些漏洞利用了投机执行的特点来攻击计算机系统。

在现代微处理器的设计中,指令的执行过程通常被分解为多个阶段,这些阶段包括取指令(fetching instructions)、指令译码(decoding instructions)、执行(executing)和退役(retiring)。在这个上下文中,**退役单元(retirement unit)**是处理器中负责管理指令生命周期末尾阶段的组件。

退役单元的主要功能包括:

  1. 指令结果的最终确认:一旦指令执行完成,退役单元负责确保执行的结果是正确的,并将结果提交到处理器的状态中。这意味着将执行结果写入寄存器或内存。
  2. 维护程序顺序的完整性:尽管现代处理器可能会并行或乱序执行多个指令,但退役单元需要确保从程序员的角度看,所有指令似乎是按顺序执行的。这涉及处理任何数据依赖和处理器状态的更新。
  3. 处理异常和中断:如果在执行指令的过程中发生了异常或中断,退役单元负责处理这些情况,并确保处理器能够正确响应。
  4. 清理和资源回收:在指令执行完毕后,退役单元还负责清理和释放在执行过程中使用的资源,比如清空指令队列中的条目,释放寄存器等。

总体而言,退役单元是保证指令正确、有效执行的重要部分,同时确保即使在复杂的执行环境(如乱序执行、并行处理等)下,处理器的行为仍符合程序的预期逻辑。

指令译码时,关于指令的信息被放置在一个先进先出的队列中。这个信息会一直保持在队列中,直到发生以下两个结果中的一个。首先,一旦一条指令的操作完成了,而且所有引起这条指令的分支点都被确认为预测正确,那么这条指令就可以退役(retired)了,所有对程序寄存器的更新都可以被实机执行了。如果引起该指令的某个分支点预测错误,这条指令会被清空(flushed)丢弃所有计算出来的结果。任何对程序寄存器的更新都只会在指令退役时才发生。

功能单元的性能
IMG_0800

从图中可以看到,浮点运算的延迟比整数运算更高,发射时间为1说明该功能单元是完全流水线化的,即每个时钟周期可以开始一个新的运算。

在计算机处理器的设计中,**功能单元(Functional Unit)**是执行特定操作的硬件部分。每个功能单元专门用于处理特定类型的操作,如算术运算、逻辑运算、数据访问等。这些功能单元是现代微处理器中实现高效指令执行的关键组件。以下是一些常见的功能单元类型及其作用:

  1. 算术逻辑单元(ALU):这是最常见的功能单元之一,用于执行基本的算术运算(如加、减、乘、除)和逻辑运算(如与、或、非)。几乎所有的处理器都至少包含一个ALU。
  2. 浮点单元(FPU):专门用于执行浮点运算,即涉及小数的运算。这种类型的运算在科学计算和图形处理中非常重要。
  3. 载入/存储单元:这些单元负责从内存中载入数据到处理器中,或者将数据从处理器存储到内存中。
  4. 分支预测单元:用于优化程序中的条件分支指令的处理。它通过预测程序执行的路径来减少等待分支决策的时间。
  5. 乱序执行单元:在支持乱序执行的处理器中,这些单元负责执行指令的调度和缓冲,以允许指令以非程序顺序执行。
  6. 向量处理单元:在一些处理器中,特别是用于图形处理和科学计算的处理器,向量处理单元可以同时处理一组数据,这通常称为SIMD(单指令多数据)操作。

功能单元的设计和数量直接影响处理器的性能和效率。在现代多核和超标量处理器中,通常会有多个这样的单元,允许处理器同时执行多个操作,从而提高了处理器的并行处理能力。在处理器设计中,功能单元的配置和优化是实现高性能计算的关键。

处理器操作的抽象模型
IMG_0808

函数的性能是由所执行的求和或者乘积计算所主宰。

IMG_0801 IMG_0807 IMG_0809

从上面的图可以得出,combine4的关键路径 L * n 是由对程序值 acc 的连续更新造成的,这条路径将 CPE 限制为最多 L。

5.8 循环展开

**循环展开(Loop Unrolling)**是一种编程优化技术,用于减少程序运行时循环的迭代次数,从而提高效率。这种技术通过减少循环中的控制指令(如跳转和比较)的执行频率,以及通过增加每次迭代中执行的操作数量来实现性能提升。

在没有展开的循环中,每次迭代通常只完成一小部分工作。循环展开通过在每次迭代中完成更多的工作来减少循环的总迭代次数。这可以通过几种方式实现,例如:

  1. 手动循环展开:程序员直接在代码中重写循环,以在每次迭代中执行更多的操作。例如,一个简单的循环,每次迭代加1,可以被展开为每次迭代加2、加4等。

  2. 自动循环展开:一些现代编译器能够自动识别循环展开的机会,并在编译时对循环进行优化。

循环展开的优点

  1. 减少循环开销:通过减少迭代次数,循环展开减少了每次迭代所需的控制指令的执行(如循环的比较和跳转指令)。

  2. 提高流水线效率:在每次迭代中执行更多的工作可以更好地利用处理器的指令流水线,尤其是在处理器可以同时执行多个独立操作的情况下。

  3. 增加指令级并行:循环展开有时可以提高指令级并行性,允许现代处理器同时执行更多独立的操作。

循环展开的缺点

  1. 增加代码大小:展开循环会增加程序的代码大小,这可能会导致更大的缓存占用和潜在的缓存不命中率增加。

  2. 可能影响缓存利用:更大的代码量可能不适合缓存,从而影响整体性能。

  3. 并非总是有效:在某些情况下,如循环体本身非常大或者循环迭代次数很少时,循环展开可能不会带来性能提升。

总之,循环展开是一种在许多情况下都能有效提升性能的技术,但它需要谨慎使用,特别是在处理器的缓存和流水线行为多样化的现代计算环境中。

IMG_0821 IMG_0821 2 IMG_0822 IMG_0823

由于acc值必须等待前面的计算完成后才能计算新值,因此它只会每 L (合并操作的延迟)个周期开始一条心操作。

让编译器展开循环

编译器可以很容易地执行循环展开。只要优化级别设置得足够高,许多编译器都能做到这一点。

5.9 提供并行性

多个累计变量
IMG_0824 IMG_0825 IMG_0827

只有保持能够执行该操作的所有功能单元的流水线都是满的,程序才能达到这个操作的吞吐量界限。

重新结合变换
IMG_0828 IMG_0830 IMG_0829 IMG_0831

5.11 一些限制因素

寄存器溢出

如果并行度p超过了可用的寄存器数量,那么编译器会诉诸溢出(spilling)。

IMG_0832

一旦循环变量的数量超过了可用寄存器的数量,程序就必须在栈上分配一些变量。

分支预测和预测错误处罚

当分支预测错误时,处理器会遇到几个问题:

  1. 指令流中断:处理器需要丢弃错误预测路径上已加载的指令。
  2. 流水线清空:处理器流水线中的指令需要被清空,重新从正确的分支加载指令。
  3. 时间损耗:重新加载正确指令路径导致的延迟。

这些处罚会降低处理器的执行效率,特别是在深流水线和高性能处理器中更为显著。

5.12 理解内存性能

现代处理器有专门的功能单元来执行加载和存储操作,这些单元有内部的缓冲区来保存未完成的内存操作请求集合。

加载的性能

加载操作不会成为限制性能的关键路径的一部分。

IMG_0835

存储缓冲区的作用使得一系列存储操作不必等待每个操作都更新高速缓存就能够执行。当执行一个加载操作时,首先检查存储缓冲区中的条目,如果有地址相匹配(写的字节与读的字节有相同的地址),它就取出相应的数据条目作为加载操作的结果。

IMG_0839 IMG_0836 IMG_0837 IMG_0838

标号1的弧线表示存储地址必须在数据被存储之前计算出来。标号2的弧线表示需要load操作将它的地址与所有未完成的存储操作的地址进行比较。标号3的虚弧线表现条件数据相关,当加载和存储地址相同时会出现。

IMG_0840

上图可以看出,当操作的地址相同时,s_data和load指令之间的数据相关使得运行周期更长。

内存操作的实现包括许多细微之处。对于寄存器操作,在指令被译码成操作的时候,处理器就可以确定哪些指令会影响其他那哪些指令。对于内存操作,只有到加载和内存的地址被计算出来以后,处理器才能确定哪些指令会影响其他的哪些。

5.13 应用:性能提高技术

策略:

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

5.14 确认和消除性能瓶颈

使用 GPROF 程序来对程序进行性能剖析。

第6章:存储器层次结构

存储器系统(memory system)是一个具有不同容量、成本和访问时间的存储设备的层次结构。

存储器层次访问周期:

  • 寄存器——0个周期
  • 高速缓存——4~75个周期
  • 主存——上百个周期
  • 磁盘——几千万个周期

6.1 存储技术

随机访问存储器

随机访问存储器(Random-Access Memery,RAM)分为静态RAM(SRAM)和动态RAM(DRAM)。

RAM优点和缺点如下:

优点:

  1. 快速访问

    • RAM提供非常快速的读写能力,是CPU执行程序和处理数据时的主要工作区。
    • 它允许计算机快速访问正在运行的应用程序和当前处理的数据,从而提高整体性能。
  2. 直接数据访问

    • 与硬盘和其他形式的存储相比,RAM允许处理器直接访问其存储的任何地址,无需顺序访问。
  3. 易于集成和升级

    • RAM模块通常设计得易于安装和升级,使得提升计算机性能相对简单。
  4. 多任务处理

    • 充足的RAM容量允许计算机同时运行多个应用程序,提高了多任务处理能力。
  5. 无机械部件

    • RAM是纯电子设备,无机械运动部件,这减少了故障率并提高了响应速度。

缺点:

  1. 易失性

    • RAM是易失性存储,这意味着一旦断电,其中的数据就会丢失。这限制了它作为长期数据存储的用途。
  2. 成本

    • 相比于硬盘等其他形式的存储,RAM的成本相对较高,尤其是在大容量需求时。
  3. 能耗

    • RAM在运行时需要持续供电,这意味着它会持续消耗电力,尤其是在大容量配置下。
  4. 容量限制

    • 相比硬盘驱动器(HDDs)或固态驱动器(SSDs),RAM的存储容量通常较小,限制了它能够处理和存储的数据量。
  5. 物理大小

    • 虽然现代RAM密度不断提高,但较大容量的RAM模块仍可能占用较多的物理空间,这在小型设备中可能成为限制因素。

总之,RAM在提高计算性能和多任务处理能力方面起着关键作用,但它的易失性、成本和能耗是需要考虑的限制因素。随着技术的发展,不断有新的RAM技术出现,旨在解决这些挑战,比如通过提高能效和存储密度,以及降低成本。

SRAM(Static Random Access Memory)和DRAM(Dynamic Random Access Memory)是两种常见的随机访问存储器(RAM),它们在结构、性能和应用方面有着显著的区别:

  1. 技术和结构

    • SRAM:使用双稳态的触发器来存储每个比特。一个SRAM单元通常由六个晶体管组成(有时更多),但不需要定期刷新。这种结构使得SRAM比DRAM快,但也更昂贵。
    • DRAM:使用一个晶体管和一个电容来存储每个比特。电容会存储电荷来表示位值,但由于电荷会逐渐泄漏,所以需要定期刷新以维持数据。这种结构使得DRAM在制造上更简单和成本更低,但速度较慢。
  2. 速度和性能

    • SRAM:提供更高的访问速度,通常用于CPU的缓存(如L1、L2和L3缓存)。
    • DRAM:由于需要定期刷新和较简单的结构,其访问速度较慢,但足以作为主内存使用。
  3. 成本和密度

    • SRAM:由于其复杂的结构,制造成本较高,且在相同的物理空间内存储的数据较少。
    • DRAM:更经济且密度更高,可以在较小的空间内存储更多数据。
  4. 功耗

    • SRAM:由于不需要定期刷新,其功耗相对较低。
    • DRAM:需要定期刷新,尤其在大容量模块中,这会增加功耗。
  5. 应用

    • SRAM:由于其高速度和低延迟,通常用于需要快速数据访问的场景,如CPU的缓存。
    • DRAM:由于其成本效益和高密度,通常用作计算机的主内存。

总结来说,SRAM提供更高的速度和更低的功耗,但成本更高,密度更低;而DRAM则提供更高的存储密度和成本效益,但速度较慢,功耗较高。这些特性决定了它们在不同类型的计算系统中的应用。

IMG_1360
传统的DRAM
IMG_1361 IMG_1362

如上图中,要从DRAM中读取数据,内存控制器会发送行地址,DRAM把对应的行存储到内部行缓冲区,内存控制器发送列地址,DRAM从行缓冲区复制对应的8位到内存控制器。

电路设计者将DRAM设计成二维阵列的好处是降低芯片上地址引脚的数量

内存模块

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

IMG_1363

上图中,要取出内存地址A的一个字,内存控制器将A转换成一个超单元地址(i, j),并将它发送到内存模块,然后内存模块再将i和j广播到每个DRAM。作为响应,每个DRAM输出它的(i,j)超单元的8位内容。模块中的电路收集这些输出,并把它们合并成一个64位字,再返回给内存控制器。

增强的DRAM

增强DRAM都是基于传统的DRAM单元,并进行一些优化,提高访问基本DRAM单元的速度。

  • 快页模式DRAM (FPM DRAM)
    • 这是一种早期的DRAM技术,它改进了传统DRAM的访问时间。
    • FPM DRAM通过保持行地址(row address)激活来快速访问连续的列地址(column address),这样可以更快地读取一行中的多个数据。
    • 它主要用于1990年代的早期个人计算机。
  • 扩展数据输出DRAM (EDO DRAM)
    • EDO DRAM是FPM DRAM的改进版本,提供了更快的数据访问。
    • 它的主要改进在于可以在进行下一个“读”操作的同时保持数据输出,从而减少等待时间。
    • EDO DRAM在1990年代中期的计算机中较为常见。
  • 同步DRAM (SDRAM)
    • SDRAM与系统的时钟同步运行,这使得它比之前的异步DRAM(如FPM和EDO)更高效。
    • 它可以在每个时钟周期读取或写入数据,提供更高的数据传输速率。
    • SDRAM广泛应用于1990年代后期到2000年代初的计算机和其他电子设备。
  • 双倍数据速率同步DRAM (DDR SDRAM)
    • DDR SDRAM是SDRAM的进一发展,能够在每个时钟周期的上升沿和下降沿各传输一次数据,从而实现“双倍数据速率”。
    • DDR SDRAM相较于标准的SDRAM,在相同的外部时钟频率下,能够提供几乎两倍的带宽。
    • DDR SDRAM有多个发展版本,如DDR2、DDR3和DDR4,每个版本都在速度、功耗和性能方面有所提升。
    • 它广泛应用于现代计算机、游戏机和高性能计算设备中。
  • 视频DRAM (VRAM)
    • VRAM专为图形显示设计,特别适用于高分辨率和高刷新率的显示系统。
    • VRAM拥有双端口功能,即它可以同时进行读写操作。一端连接到帧缓冲区,用于刷新显示屏幕;另一端则由图形处理器用于更新存储的图像。
    • VRAM由于其较高的成本,在个人计算机中较少使用,但在专业图形和游戏系统中比较常见。
非易失性存储器
  1. ROM (Read-Only Memory)
    • ROM是只读存储器,其内容通常在制造过程中被编程,并且不能被用户修改。
    • 它用于存储不变的数据和程序,如固件和系统启动程序。
    • ROM不需要电源就能保持数据,因此是一种非易失性存储。
  2. PROM (Programmable ROM)
    • PROM是一次性编程的ROM。一旦编程,其内容就不能更改。
    • 用户可以使用特殊的设备编程PROM,但一旦写入数据后就不能被擦除或重写。
    • 它适用于那些只需要单次编程的应用。
  3. EPROM (Erasable Programmable ROM)
    • EPROM允许用户擦除和重新编程其内容。
    • 数据的擦除是通过将EPROM芯片暴露在紫外线下进行的,这通常需要特殊的设备。
    • EPROM在需要周期性更新固件或数据的场合中很有用。
  4. EEPROM (Electrically Erasable Programmable ROM)
    • EEPROM可以在不移除芯片的情况下通过电信号进行擦除和重写。
    • 它允许对存储的数据进行细粒度的修改,而无需擦除整个芯片的内容。
    • EEPROM用于需要频繁更新数据但数据量不大的场景,如在配置设置或小型设备中。
  5. 闪存 (Flash Memory)
    • 闪存是一种电子可擦除和可编程的存储器,它是EEPROM的一种形式,但提供了更高的速度和更大的存储密度。
    • 与EEPROM相比,闪存通常以块为单位进行擦除和编程,而不是逐字节。
    • 闪存广泛用于USB闪存驱动器、固态硬盘(SSD)、智能手机和平板电脑中。

存储在ROM设备中的程序通常被称为固件(firmware)。当一个计算机系统通电以后,它会运行存储在ROM中的固件。

访问主存

数据流通过称为总线(bus)的共享电子电路在处理器和DRAM主存之间来来回回。

总线事务(Bus Transaction)是指在计算机系统的总线上进行的一系列操作,用于在系统的不同部分之间传输数据或信号。总线是计算机各个组件间通信的共享传输路径,这些组件包括处理器、内存、输入/输出设备等。总线事务通常涉及以下几个关键步骤:

  1. 寻址(Addressing)
    • 总线事务的发起方(例如CPU)会在总线上放置一个特定的地址,用来指定事务的目标(如某块内存或某个I/O设备)。
  2. 命令或控制信号(Command or Control Signal)
    • 发起方还会提供一个命令或控制信号,指示事务的类型,例如是读取数据、写入数据还是其他类型的操作。
  3. 数据传输(Data Transfer)
    • 数据随后会在发起方和目标之间通过总线传输。这可以是从内存到CPU的读操作,从CPU到内存的写操作,或者涉及其他设备的数据传输。
  4. 应答(Acknowledgement)
    • 在事务完成后,目标设备或总线本身可能会发送一个应答信号,表明事务已成功完成或出现错误。

总线事务的类型和性质取决于所使用的总线协议和架构。比如,在不同的计算机架构中,如PCI(Peripheral Component Interconnect)、PCI Express、AGP(Accelerated Graphics Port)等,总线事务的实现细节会有所不同。

IMG_1364 IMG_1365 IMG_1366
磁盘存储(HDD)
Choosing the right hard disk drive - Buying Guides DirectIndustry IMG_1367
磁盘容量

磁盘容量由以下技术因素决定:

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

对扇区的访问时间有三个主要部分:

  1. 寻道时间(Seek Time)
    • 这是磁头从一个数据轨道移动到另一个数据轨道所需要的时间。
    • 它通常是指磁头从当前位置移动到目标位置的平均时间。
    • 寻道时间是评估磁盘性能的重要指标之一,因为在数据读写过程中,磁头必须定位到正确的轨道上。
    • 寻道时间的长短受多种因素影响,包括磁头移动机制的速度和加速度,以及磁头当前位置与目标位置的距离。
  2. 旋转延迟(Rotational Latency)
    • 这是磁盘盘片转动使数据扇区移动到读写头下方所需的时间。
    • 它的值取决于盘片的旋转速度,即每分钟转数(RPM)。转速越快,旋转延迟越短。
    • 这个参数通常是以盘片旋转半圈的平均时间来衡量的,因为数据扇区可能位于磁头正下方,也可能正好在磁头的对面。
    • 对于7200 RPM的硬盘,旋转延迟大约在4到5毫秒左右。
  3. 传输时间(Transfer Time)
    • 这是数据实际从磁盘传输到计算机(或反之)所需的时间。
    • 传输时间由两部分组成:首先是磁盘内部的数据传输时间,即数据从盘片表面读取到硬盘缓存的时间;其次是数据从硬盘缓存通过接口(如SATA)传输到主机的时间。
    • 传输速度取决于磁盘的内部速率和接口的带宽。例如,一个SATA III接口理论上可以提供6 Gb/s的传输速率,但实际速度还要考虑硬盘自身的读写速度和其它系统因素。

磁盘控制器对硬盘驱动器(HDD)的低级格式化:

低级格式化的目的是将磁盘分为可用的扇区,以便操作系统可以在其上存储数据。

这个过程涉及以下几个关键步骤:

  1. 扇区创建:硬盘被分成数千个小块,称为扇区。每个扇区通常存储512字节到4KB的数据。低级格式化定义了这些扇区的物理位置和大小。
  2. 磁道和柱面的创建:扇区被组织成磁道,磁道又被进一步组织成柱面。这种组织方式帮助磁盘控制器有效地定位数据。
  3. 坏扇区标记:在低级格式化过程中,硬盘会检查坏扇区(即无法可靠存储数据的扇区),并将它们标记为不可用。这确保了操作系统不会尝试在这些损坏的扇区上写入数据。
  4. 控制数据的写入:每个扇区除了用户数据外,还包含了用于错误检测和纠正的控制信息(如CRC校验和)。这些信息也是在低级格式化过程中写入的。
  5. 准备服务区域:现代硬盘还包含一个服务区域,用于存储固件和诊断工具。这个区域也是在低级格式化时设置的。

磁盘控制器在这个过程中起着核心作用,它负责将这些低级格式化命令发送到磁盘,并确保这些操作正确无误地执行。磁盘控制器还负责在实际读写操作中管理数据流向硬盘的物理部分。

需要注意的是,对于大多数用户而言,低级格式化是不需要的,因为这个过程通常只在制造过程中进行一次。对于日常用途,高级格式化(即在操作系统级别创建文件系统)就足够了。低级格式化通常只在特定的维修或诊断情况下由专业人员执行。

连接 I/O 设备

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

系统总线和内存总线是与CPU相关的,与它们不同,诸如PCI这样的I/O总线与底层CPU无关。

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

IMG_1372

系统总线、内存总线、和I/O总线是计算机架构中关键的总线类型,它们各自承担不同的角色,确保计算机组件之间有效地交换数据。下面详细介绍它们之间的关系和区别:

系统总线
系统总线(有时也称为前端总线或FSB)是连接中央处理单元(CPU)和主内存(RAM)的主要通道。它是计算机所有总线类型中最为关键的,因为它直接影响到处理器和内存之间的数据交换效率。

特点:

  • 通常具有较高的数据传输速率。
  • 它的性能直接影响到整个系统的性能。
  • 是不同组件之间数据通信的主要通道。

内存总线
内存总线专门用于连接CPU和物理内存。它是系统总线的一部分,负责处理CPU和内存之间的所有数据传输。内存总线的宽度和速度决定了CPU访问内存的速度。

特点:

  • 直接连接CPU和RAM,允许快速的数据交换。
  • 其性能(例如宽度和频率)对系统性能至关重要。
  • 通常优化以减少延迟和提高数据传输效率。

I/O总线
I/O总线连接CPU与系统的输入/输出设备,如硬盘、USB设备、图形卡等。它负责管理和控制数据在CPU和这些外部设备之间的流动。

特点:

  • 提供多种接口标准,例如PCI、PCIe、USB等。
  • 相较于系统总线和内存总线,I/O总线的速度通常较慢。
  • 它支持大量不同类型的外部设备和扩展卡。

关系和区别

  • 关系:这三种总线在计算机系统中共同工作,以确保数据有效地在处理器、内存和外部设备之间传输。系统总线提供了主要的数据通道,而内存总线和I/O总线则分别专注于特定类型的数据传输。
  • 区别:主要区别在于它们连接的组件和传输的数据类型。系统总线是最广泛的,涵盖CPU和主内存之间的所有交互。内存总线专注于CPU和RAM之间的交互,而I/O总线则处理CPU和外部设备之间的数据传输。

通用串行总线(USB)、图形适配器(显卡)和主机总线适配器(HBA)

通用串行总线(USB

通用串行总线(USB)是一种广泛使用的接口技术,用于连接计算机与外部设备。USB允许数据传输、设备供电,甚至可以通过USB实现设备间的充电。

特点

  1. 即插即用:USB设备可在计算机运行时连接和识别,无需重新启动。
  2. 热拔插:支持在不关闭设备电源的情况下安全移除或连接设备。
  3. 数据传输速度:不同版本的USB(如USB 1.0, 2.0, 3.0, 4.0)提供不同的数据传输速率,最新的USB 4.0能提供高达40Gbps的速度。
  4. 多种用途:用于数据传输、充电、连接输入设备、存储设备、打印机等。

图形适配器(显卡)

图形适配器,通常称为显卡,是计算机的一个组件,负责生成和输出图像到显示设备。显卡可以是集成在主板上的,也可以是作为独立硬件安装的。

特点

  1. GPU:显卡包含一个图形处理单元(GPU),专门设计用于处理图形和视频数据。
  2. 内存:显卡拥有自己的视频内存(VRAM),用于存储渲染图像和视频帧。
  3. 输出接口:提供不同类型的视频输出接口,如HDMI, DisplayPort, DVI等,以连接到显示设备。
  4. 性能:高性能显卡对于游戏、图形设计、视频编辑和3D渲染等资源密集型任务至关重要。

主机总线适配器(HBA)

主机总线适配器(HBA)是一种用于连接计算机主机和外部存储设备(如硬盘驱动器、固态硬盘、磁带驱动器)的接口卡。它主要用于服务器和高性能计算机系统。

特点

  1. 接口类型:可能包括SAS(串行附加SCSI)、SATA(串行ATA)或光纤通道等。
  2. 性能:提高数据传输效率,尤其是在连接多个高速存储设备时。
  3. 高可用性:在企业级存储解决方案中,HBA提供了高可用性和冗余功能。
  4. RAID支持:某些HBA卡提供RAID功能,允许多个硬盘以不同方式配置,以提高性能或数据可靠性。

这三种适配器都是计算机硬件的关键组成部分,它们各自在系统中承担特定的角色,确保硬件设备的有效连接和数据传输。

IMG_1377

CPU使用一种称为内存映射I/O的技术来向I/O设备发射命令。

设备可以自己执行读或者写总线事务而不需要CPU干涉的过程,称为直接内存访问(Direct Memory Access,DMA)。

CPU从磁盘扇区读取数据的过程:

  1. 指令发起

    • 这个过程开始于CPU接收到一个读取硬盘特定扇区的指令。这通常是由运行在CPU上的操作系统或应用程序发出的。
  2. 计算扇区地址

    • CPU或操作系统会计算出需要读取的数据所在的硬盘扇区的确切地址。这通常涉及文件系统的映射,将文件系统的逻辑地址转换为硬盘的物理扇区地址。
  3. 发送指令到硬盘控制器

    • CPU通过主板上的I/O总线将读取指令发送给硬盘控制器。这通常通过内置的存储控制器或主机总线适配器(HBA)进行。
  4. 硬盘控制器处理指令

    • 硬盘控制器接收指令后,控制硬盘开始读取操作。这涉及将磁头定位到包含目标数据的扇区。
  5. 数据读取

    • 一旦磁头定位正确,硬盘开始读取扇区的数据。这些数据从硬盘表面的磁性材料中读取出来。
  6. 数据传输回CPU

    • 读取的数据首先被传输到硬盘控制器,然后通过I/O总线传回CPU。在某些情况下,数据可能会先被暂存到DMA(直接内存访问)控制器指定的内存区域,然后CPU再从这个内存区域中读取数据。
  7. 数据处理

    • CPU接收到数据后,会根据需要进行处理。例如,如果数据是文件内容,它可能会被加载到内存中供程序使用。
  8. 错误检查

    • 在整个过程中,还会进行错误检查,确保数据的完整性和准确性。例如,硬盘和控制器通常会使用错误更正代码(ECC)来检测和更正数据传输过程中的错误。

这个过程涉及硬件(CPU、硬盘、总线、控制器)和软件(操作系统、驱动程序)的紧密协作,确保数据能够准确且高效地从硬盘读取到CPU。

固态硬盘

固态硬盘(Solid State Disk,SSD)是一种基于闪存的存储技术。

IMG_1378

一个SSD封装由一个或多个闪存芯片和内存翻译层(flash translation layer)组成,闪存芯片替代传统旋转磁盘中的机械驱动器,而闪存翻译层是一个硬件/固件设备,扮演与磁盘控制器相同的角色,将对逻辑块的请求翻译成对底层物理设备的访问。

读SSD比写要快。一个闪存由B个块的序列组成,每个块由P个页组成。通常,页的大小是512字节4KB,块由32128页组成,块的大小为16KB~512KB。数据以页为单位读写。在大约进行10w次重复写之后,块就会磨损坏。

闪存是一种非易失性存储技术,广泛用于USB闪存盘、固态硬盘(SSD)和其他形式的电子数据存储设备中。它基于固态技术,这意味着与传统的磁盘存储(如硬盘驱动器)不同,闪存不含有移动部件。

以下是闪存的读写原理

闪存的基本组成

  1. 存储单元:闪存由许多存储单元组成,每个存储单元是一个浮栅晶体管。
  2. 单元状态:每个浮栅晶体管可以存储一位数据(0或1),这取决于它是否被电荷(充电状态代表一个值,未充电状态代表另一个值)。

写入数据(编程)

  1. 充电:在写入操作中,闪存单元的浮栅会被充电。特定的电压被应用到控制门上,导致电子通过隧道效应越过氧化层,并被困在浮栅中。
  2. 存储数据:电子的存在改变了晶体管的阈值电压,从而表示数据。充电的浮栅代表一个二进制值(通常是“1”),而未充电的代表另一个值(通常是“0”)。

读取数据

  1. 检测电荷:在读取操作中,对存储单元施加一个较低的电压。如果浮栅中有电子(即单元被编程),晶体管将不导电,从而能够检测到不同的电压阈值。
  2. 确定状态:控制器检测这种变化来确定存储单元的状态,从而读取存储的数据。

擦除数据

  1. 去电荷:闪存的擦除操作涉及从浮栅中移除电子。这是通过应用一个适当的电压到源极和控制门,使电子回流到源极或排出到耗尽区。
  2. 批量擦除:不同于普通的读写操作,擦除通常是在较大的数据块或整个芯片上进行的。

优点与局限

  • 优点:闪存的主要优点是它非易失性(断电后数据依然保留),且没有移动部件,使其更耐用、更快速、且耗电量更低。
  • 局限:一个关键的局限是写入和擦除周期的有限次数。每次擦除和重新编程都会对存储单元造成一定的损耗,最终导致存储单元无法使用。

总体来说,闪存的读写原理基于对存储单元中电子充放电的控制,这种机制使得闪存成为了现代电子设备中重要的存储技术。

6.2 局部性

计算机程序的局部性(locality),它描述了程序在运行时对数据和指令访问的模式。

局部性主要分为两类:时间局部性(Temporal Locality)和空间局部性(Spatial Locality)。

  1. 时间局部性(Temporal Locality):这种类型的局部性指的是程序在短时间内对同一数据或指令的多次访问。如果一个数据项被访问,那么在不久的将来它很可能再次被访问。这种局部性是基于程序中循环和重复执行的特性。

  2. 空间局部性(Spatial Locality):空间局部性是指当一个数据项被访问时,其附近的数据项很快也会被访问。例如,在数组处理或连续的内存块操作中很常见。

计算机系统的设计充分利用了这些局部性原则。例如,CPU 缓存(Cache)就是基于时间局部性和空间局部性设计的。当CPU访问某个内存地址时,它不仅会把这个地址的数据加载到缓存中,还会预加载附近的地址数据。这样做是因为空间局部性告诉我们,这些附近的地址很可能在接下来的执行中被访问。同样地,一旦数据被加载到缓存中,时间局部性保证了这些数据可能会被再次访问,从而减少了访问主内存的次数。

局部性原理对于提高计算机程序的性能至关重要,它影响了从微观的处理器设计到宏观的系统架构。

拥有良好局部性的程序和没有良好局部性的程序的区别:

以下是这两类程序的主要区别:

  1. 性能

    • 良好局部性的程序:由于频繁访问的数据和指令更可能被缓存在CPU的快速访问存储区(如L1、L2或L3缓存),因此减少了对慢速主存储(如RAM)的访问需求。这样,CPU可以更快地访问需要的数据和指令,从而提高了执行速度。
    • 局部性差的程序:频繁地从主内存中取数据和指令,因为缓存命中率低。这导致较慢的数据访问速度和增加的处理时间,从而影响整体性能。
  2. 资源利用效率

    • 良好局部性的程序:更有效地利用了CPU缓存和其他层次的存储系统,因为数据和指令的重用率高。这种高效的缓存使用减少了内存带宽的需求和能源消耗。
    • 局部性差的程序:由于频繁的缓存未命中,导致了CPU缓存的效率降低。这种情况下,即使有大量的缓存资源,也无法得到充分利用。
  3. 系统响应时间

    • 良好局部性的程序:由于高缓存命中率,程序的响应时间通常更短,用户体验更好。
    • 局部性差的程序:可能会遇到更多的缓存未命中和页面错误,导致程序响应时间变长,用户体验较差。
  4. 预测性和优化

    • 良好局部性的程序:由于其访问模式较为可预测,使得硬件和操作系统可以更有效地进行预取和其他优化操作。
    • 局部性差的程序:难以预测其数据和指令访问模式,使得硬件和操作系统优化更加困难。

在软件开发中,了解和利用局部性原理可以帮助开发者编写出更高效的代码。例如,通过优化数据结构和算法来提高时间和空间局部性,或者重新组织数据访问模式以减少缓存未命中。

对程序数据引用的局部性

良好局部性的程序通常表现为两种形式:

  1. 时间局部性(Temporal Locality):如果一个程序或算法在短时间内多次访问同一数据元素,则说明它具有良好的时间局部性。这意味着一旦一个数据项被访问,很可能在不久的将来它会被再次访问。缓存机制就是基于时间局部性的概念设计的,因为缓存可以保留最近访问的数据,从而加速对这些数据的再次访问。
  2. 空间局部性(Spatial Locality):如果一个程序在执行过程中所访问的数据元素在内存中彼此靠近,那么它就表现出空间局部性。例如,对数组元素的顺序访问就是空间局部性的一种表现。空间局部性允许系统在访问一个数据项时,预先将其附近的数据也载入缓存,从而加快后续邻近数据的访问速度。
IMG_1392
取指令的局部性
  1. 时间局部性:如果一个指令被执行了,那么在不久的将来这个指令可能会再次被执行。这种情况常见于循环和重复调用的函数中。因为这些代码块会被多次执行,所以它们的指令具有较高的时间局部性。
  2. 空间局部性:程序在执行过程中倾向于从连续的内存地址中读取指令。大多数现代程序都是顺序执行的,即一个指令后紧跟着的内存地址中通常存放着下一个要执行的指令。因此,程序代码的物理布局通常会在内存中呈现出很高的空间局部性。

这两种局部性在指令缓存(Instruction Cache)设计中起着至关重要的作用。指令缓存利用这种局部性,通过预先加载最近执行过的指令和它们附近的指令来提高处理速度。当处理器需要执行一个指令时,它首先检查这个指令是否已经在指令缓存中。如果是,那么就可以直接从缓存中获取这个指令,这比从主内存中读取要快得多。

量化评价程序局部性的原则:

  • 重复引用相同变量的程序有良好的局部性。
  • 对于具有步长为k的引用模式的程序,步长越小,空间局部性越好。具有步长为1的引用模式的程序有很好的空间局部性。在内存中以大步跳来跳去的程序空间局部性会很差。
  • 对于取指令来说,循环有好的时间和空间局部性。循环体越小,循环迭代次数越多,局部性越好。

了解如何看一眼源代码就能获得对程序中局部性的高层次的认识,是程序员要掌握的一项有用而且重要的技能。

6.3 存储器层次结构

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

存储器层次结构的中心思想是,对于每个k,位于k层的更快更小的存储设备作为位于k+1层的更大更慢的存储设备的缓存。换句话说,层次结构中的每一层都缓存来自较低一层的数据对象。

IMG_1394

数据总是以块(block)大小为传送单元(transfer unit)在第k层和第k+1层之间来回复制的。虽然在层次结构中任何一对相邻的层次之间块大小是固定的,但是其他的层次对之间可以有不同的块大小。例如,L0和L1之间的传送通常使用的是1个字大小的块,而L1和L2之间的传送通常使用的是几十字节的块。越往底层,块通常越大,因为底层的存储访问速度慢。

管理缓存的策略可以是软件、硬件,或是两者的结合。在大多数时候,缓存都是自动运行的,不需要程序采取特殊的或显式的行动。

IMG_1395

6.4 高速缓存存储器

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

通用的高速缓存存储器(Cache)的组织结构主要包括以下几个关键部分:

  1. 缓存行(Cache Line):缓存存储器是按照缓存行(也称为块)组织的。每个缓存行存储一定量的数据,这些数据是从主存中复制过来的,常见的缓存行大小有 32 字节、64 字节等。

  2. 标签(Tag):为了确定缓存中的数据与主存中的哪部分数据相对应,每个缓存行都有一个标签。这个标签存储了主存中数据的地址信息的一部分。

  3. 索引(Index):缓存内部有很多缓存行,索引用来确定一个特定的数据应该存放在哪个缓存行中。

  4. 组织方式:高速缓存的组织方式有几种,最常见的包括直接映射缓存、组相联缓存和全相联缓存。

    • 直接映射缓存:每个主存地址只能映射到一个特定的缓存行。
    • 组相联缓存(Set-Associative Cache):将缓存分为几组,每个主存地址可以映射到一组中的任何一个缓存行。
    • 全相联缓存(Fully Associative Cache):每个主存地址可以映射到缓存中的任何一个缓存行。
  5. 替换策略:当缓存满了而需要加载新的数据时,需要选择一个缓存行来替换。常见的替换策略包括最近最少使用(LRU)、随机替换(Random)和先进先出(FIFO)等。

  6. 写策略:决定数据是如何从缓存写回到主存的。主要的写策略有写回(Write Back)和写直达(Write Through)。

  7. 一致性机制:在多核处理器系统中,用于保持多个缓存之间数据的一致性。常见的一致性协议包括MESI协议等。

这些组成部分和组织方式共同决定了高速缓存的效率和复杂性。缓存的设计对于整个计算机系统的性能有重要影响,尤其是在处理器速度远超过主存访问速度的当代计算机系统中。

IMG_1401

当CPU试图从主存地址A读取一个字时,高速缓存(Cache)通过以下步骤来确定它是否包含地址A的那个字的副本:

  1. 地址分解:首先,CPU发出的地址A被分解为几个部分,通常包括标签(Tag)、索引(Index)和块内偏移(Block Offset)。

    • 标签(Tag):用于标识主存中的一个特定数据块。
    • 索引(Index):用于在缓存中定位到一个特定的缓存行或一组缓存行。
    • 块内偏移(Block Offset):用于在缓存行内定位到具体的数据。
  2. 索引定位:使用地址A的索引部分来定位到缓存中的一个特定缓存行(在直接映射缓存中)或一组缓存行(在组相联或全相联缓存中)。

  3. 标签比对:检查定位到的缓存行(或行组中的每一行)的标签。如果某个缓存行的标签与地址A的标签部分相匹配,这意味着所需数据已经在缓存中。

  4. 命中与未命中

    • 命中(Hit):如果找到匹配的标签,表明缓存包含了所需的数据,这被称为缓存命中。然后根据块内偏移从缓存行中读取具体的数据字。
    • 未命中(Miss):如果没有找到匹配的标签,这被称为缓存未命中。在这种情况下,CPU必须从主存中读取数据,并可能将其加载到缓存中以供后续使用。

整个过程是高度自动化和优化的,旨在最小化CPU等待数据的时间。缓存的有效性极大地依赖于其能够预测和存储CPU接下来可能需要的数据。

直接映射缓存、组相联缓存、全相联缓存可以视为一种递进关系,每种类型相对于前一种都有一定的改进。以下是每种缓存类型的特点以及它们相对于前一种的改进:

  1. 直接映射缓存
    • 特点:每个主存地址只能映射到缓存中的一个特定缓存行。这是通过将主存地址的一部分作为缓存中的索引来实现的。
    • 优点:简单,实现成本低,访问速度快。
    • 缺点:冲突率高,特别是在访问模式具有固定模式时,容易出现缓存行频繁被替换的情况。
  2. 组相联缓存(Set-Associative Cache)
    • 特点:将缓存分为多个组,每个主存地址可以映射到一个组中的任何一个缓存行。组内每行的数量(相联度)通常是2、4或更高。
    • 优点:相对于直接映射缓存,组相联缓存减少了缓存冲突,提高了缓存的命中率。
    • 缺点:比直接映射缓存复杂,成本更高,访问速度可能略慢。
  3. 全相联缓存(Fully Associative Cache)
    • 特点:每个主存地址可以映射到缓存中的任何一个缓存行。
    • 优点:最大限度减少了缓存冲突,提供了最高的命中率。
    • 缺点:实现复杂度高,成本高,访问速度相比其他类型慢。

总结来说,从直接映射缓存到全相联缓存,缓存的复杂度和成本逐渐增加,但同时也提高了缓存的灵活性和命中率。实际中,组相联缓存因其在性能和成本之间提供了良好的平衡,被广泛应用于现代处理器设计中。

直接映射高速缓存
IMG_1402

每个组只有一行(E=1)的高速缓存称为直接高速缓存(direct-mapped cache)。

IMG_1403 IMG_1404
组相联高速缓存
IMG_1405

组相联高速缓存中的行匹配比直接映射高速缓存中的更复杂,它必须检查多个行的标记位和有效位,以确定所请求的字是否在集合中。

IMG_1407
全相联高速缓存
IMG_1408 IMG_1408 IMG_1409
真实的高速缓存层次结构的解剖

只保存指令的高速缓存称为i-cache,只保存程序数据的高速缓存称为d-cache。既保存指令又包括数据的高速缓存称为统一的高速缓存(unified cache)。

IMG_1410 IMG_1411
高速缓存参数的性能影响

高速缓存(Cache)的性能受其多个参数的影响。理解这些参数如何影响缓存的性能对于设计高效的计算机体系结构和优化软件性能非常重要。下面是一些关键的高速缓存参数及其对性能的影响:

  1. 缓存大小(Cache Size)

    • 影响:增加缓存大小通常可以提高命中率,因为更多的数据可以被存储在缓存中。
    • 权衡:更大的缓存可能意味着更长的访问时间和更高的能耗。同时,边际效益递减,即在一定点后增加缓存大小带来的性能提升会减少。
  2. 缓存行大小(Cache Line Size)

    • 影响:增加缓存行大小可以提高空间局部性,但如果缓存行太大,则可能导致不必要的数据被加载进缓存(过度抓取)。
    • 权衡:较大的缓存行可以提高数据传输效率,但也可能增加缓存未命中的代价。
  3. 相联度(Associativity)

    • 影响:增加缓存的相联度可以减少缓存冲突和提高命中率。
    • 权衡:高相联度缓存需要更复杂的硬件实现和更高的能耗。它还可能导致访问时间的增加。
  4. 替换策略(Replacement Policy)

    • 影响:替换策略决定了当缓存满时哪个缓存行被替换。常见策略有最近最少使用(LRU)、随机(Random)和先进先出(FIFO)等。
    • 权衡:更智能的替换策略可能提高命中率,但也可能增加实现的复杂性和成本。
  5. 写策略(Write Policy)

    • 影响:写策略(如写回(Write Back)和写直达(Write Through))决定了数据如何从缓存写回到主存。
    • 权衡:写回策略可以减少对主存的访问次数,但需要更复杂的缓存一致性控制。写直达策略简单但可能导致更高的内存带宽需求。
  6. 缓存级别(Cache Levels)

    • 影响:现代处理器通常有多级缓存(如L1、L2、L3)。每级缓存在大小、速度和距离CPU的远近上有所不同。
    • 权衡:多级缓存可以提高性能,因为它们允许更快的缓存更靠近CPU,而更大的缓存可以存储更多数据,但更远离CPU。

理解这些参数如何影响高速缓存的性能对于处理器设计、系统配置以及软件优化至关重要。有效的缓存设计需要在性能、成本和功耗之间找到平衡点。

高速缓存行、组和块的区别:

  • 块是一个固定大小的信息包,在高速缓存和主存(或下一层高速缓存)之间来回传送。
  • 行是高速缓存中的一个容器,存储块以及其他信息。
  • 组是一个或多个行的集合。

6.5 编写高速缓存友好的代码

方法:

  1. 让最常见的情况运行得快,把注意力集中在核心函数里的循环上,而忽略其他部分。
  2. 尽量减少每个循环内部的缓存不命中数量。
    • 对局部变量的反复引用是好的,因为编译期能够将它们缓存在寄存器文件中(时间局部性)。
    • 步长为1的引用模式是好的,因为存储器层次结构中所有层次上的缓存都是将数据存储为连续的块(空间局部性)。
  3. 将注意力专注于内循环上,大部分计算和内存访问都发生在这里。
  4. 一旦从存储器中读入了一个数据对象,就尽可能去使用它,从而增加程序的时间局部性。
  • 30
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值