现代微处理器可以称得上是人类创造出的最复杂的系统之一。一块手指甲大小的硅片上,可以容纳一个完整的高性能处理器、大的高速缓存,以及用来连接到外部设备的逻辑电路。从性能上来说,今天在一块芯片上实现的处理器已经使 20 年前价值 1000 万美元、房间那么大的超级计算机相形见绌了。即使是在像手机、导航系统和可编程恒温器这样的日常设备中的嵌入式处理器,也比早期计算机开发者所能想到的强大很多。
到目前为止,我们看到的计算机系统只限于机器语言程序级。我们知道处理器必须执行一系列指令,每条指令执行某个简单操作,例如两个数相加。指令被编码为由一个或多个字节序列组成的二进制格式。一个处理器支持的指令和指令的字节级编码称为它的指令集体系结构(Instruction-Set Architecture,ISA)
。不同的处理器“家族”,例如 Intel IA32 和 x86-64、IBM/Freescale Power 和 ARM 处理器家族,都有不同的 ISA。一个程序编译成在一种机器上运行,就不能在另一种机器上运行。另外,同一个家族里也有很多不同型号的处理器。虽然每个厂商制造的处理器性能和复杂性不断体改,但是不同的型号在 ISA 级别上都保持着兼容。一些常见的处理器家族(例如 x86-64)中的处理器分别由多个厂商提供。因此,ISA 在编译器编写者和处理器设计人员之间提供了一个概念抽象层,编译器编写者只需要知道允许哪些指令,以及它们是如何编码的;而处理器设计者必须建造出执行这些指令的处理器。
本章将简要介绍处理器硬件的设计。我们将研究一个硬件系统执行某种 ISA 指令的方式。这会使你能更好地理解计算机是如何工作的,以及计算机制造商们面临的技术挑战。一个很重要的概念是,现代处理器的实际工作方式可能跟 ISA 隐含的计算机模型大相径庭。ISA 模型看上去应该是顺序
指令执行,也就是先取出一条指令,等到它执行完毕,再开始下一条。然而,与一个时刻只执行一条指令相比,通过同时处理多条指令的不同部分,处理器可以获得更好的性能【还记得第三章中,在一些情况下,把if条件的两种情况的结果都计算出来执行速度会更快吗】为了保证处理器能得到同顺序执行相同的结果,人们采用了一些特殊的机制。在计算机科学中,用巧妙的办法在提高性能的同时又保持了一个更简单、更抽象模型的功能,这种思想是众所周知的。在 Web 浏览器或平等二叉树和哈希表这样的信息检索数据结构中使用缓存,就是这样的例子。
你很可能永远都不会自己设计处理器,这是专家们的任务,他们工作在全球不到 100 家的公司里。那么为什么你还应该了解处理器设计呢?【这正是我想问的。。。】
从智力方面来说,处理器设计是非常有趣而且很重要的。
学习事物是怎样工作的由其内在价值。了解作为计算机科学家和工程师日常生活一部分的一个系统的内部工作原理(特别是对很多人来说这还是个迷),是一件格外有趣的事情。处理器设计包括许多好的工程实践原理。它需要完成复杂的任务,而结构又要尽可能简单和规则。理解处理器如何工作能帮助理解整个计算机系统如何工作。
在之后的第六章,我们讲述存储器系统,以及用来创建很大的内存映射同时又有快速访问时间的技术。看看处理器端的处理器——内存接口,会使那些讲述更加完整。虽然很少有人设计处理器,但是许多人设计包含处理器的硬件系统。
将处理器嵌入到现实世界的系统中,如汽车和家用电器,已经变得非常普遍了。嵌入式系统的设计者必须了解处理器是如何工作的,因为这些系统通常在比桌面和基于服务器的系统更低抽象级别上进行设计和编程。你的工作可能是处理器设计。
虽然生产处理器的公司很少,但是研究处理器的设计人员队伍已经非常巨大了,而且还在壮大。一个主要的处理器设计的各个方面大约涉及 1000 多人。
本章首先定义一个简单的指令集,作为我们处理器实现的运行示例。因为受 x86-64 指令集的启发,它被俗称为“x86”,所以我们称我们的指令集为 “y86-64”指令集。于 x86-64 相比,y86-64 指令集的数据类型、指令和寻址方式都要少一些。它的字节级编码也比较简单,机器代码没有相应的 x86-64 代码紧凑,不过设计它的 CPU 译码逻辑也要简单一些。虽然 y86-64 指令集很简单,它仍然足够完整,能让我们写一些处理整数的程序。设计一个实现 y86-64 的处理器要求我们解决许多处理器设计者同样会面对的问题。
接下来会提供一些数字硬件设计的背景。我们会描述处理器中使用的基本构件块,以及它们如何连接起来和操作。这些介绍是建立在第 2 章对布尔代数和位级操作的讨论的基础上的。我们还将介绍一种描述硬件系统控制部分的简单语言,HCL(Hardware Control Language,硬件控制语言)。然后,用它来描述我们的处理器设计。即使你已经有了一些逻辑设计的背景知识,也应该读读这个部分以了解我们的特殊符号表示方法。
作为设计处理器的第一步,我们给出一个基于顺序操作、功能正确但有点不实用的 y86-64 处理器。这个处理器每个时钟周期执行一条完整的 y86-64 指令。所以它的时钟必须足够慢,以允许一个周期内完成所有的动作。这样一个处理器是可以实现的,但是它的性能远远低于同样的硬件应该能达到的性能。
以这个顺序设计为基础,我们进行一系列的改造,创建一个流水线化的处理器(pipelined processor)
。这个处理器将每条指令的执行分解成五步,每个步骤由一个独立的硬件部分或阶段(stage)来处理。指令步经流水线的各个阶段,且每个时钟周期有一条新指令进入流水线。所以,处理器可以同时执行五条指令的不同阶段。为了使这个处理器保留 y86-64 ISA 的顺序行为,就要求处理很多冒险
或冲突(hazard)
情况,冒险就是一条指令的位置或操作数依赖于其他仍在流水线中的指令。
我们设计了一些工具来研究和测试处理器设计。其中包括 y86-64 的汇编器、在你的机器上运行 y86-64 程序的模拟器,还有针对两个顺序处理器设计和一个流水线化处理器设计的模拟器。这些设计的控制逻辑用 HCL 符号表示的文件描述。通过编辑这些文件和重新编译模拟器,你可以改变和扩展模拟器的行为。我们还提供许多练习,包括实现新的指令和修改机器处理指令的方式。还提供测试代码以帮助你评价修改的正确性。这些练习将极大地帮助你理解所有这些内容,也能使你更理解处理器设计者面临的许多不同的设计选择。
旁注 ARCH:VLOG 给出了用 Verilog 硬件描述语言描述的流水线化的 y86-64 处理器。其中包括为基本的硬件构建块和整个的处理器结构创建模块。我们自动地将控制逻辑的 HCL 描述翻译成 Verilog。首先用我们的模拟器调试 HCL 描述,能消除很多在硬件设计中会出现的棘手问题。给定一个 Verilog 描述,有商业和开源工具来支持模拟和逻辑合成(logic synthesis)
,产生实际的微处理器电路设计。因此,虽然我们在此花费大部分精力创建系统的图形和文字描述,写软件的时候也会花费同样的精力,但是这些设计能够自动地合成,这表明我们确实在创建一个能够用硬件实现的系统。
1 Y86-64 指令集体系结构
定义一个指令集体系结构包括定义各种状态单元、指令集和它们的编码、一组编程规范和异常事件处理。
1.程序员可见的状态
y86-64 程序中的每条指令都会读取或修改处理器状态的某些部分。这称为程序员可见
状态,这里的“程序员”既可以是用汇编代码写程序的人,也可以是产生机器级代码的编译器。在处理器实现中,只要我们保证机器级程序能够访问程序员可见状态,就不需要完全按照 ISA 暗示的方式来表示和组织这个处理器状态。y86-64 的状态类似于 x86-64 。有 15 个程序寄存器。每个程序寄存器存储一个 64 位的字。寄存器 %rsp 被入栈、出栈、调用和返回指令作为栈指针。除此以外,寄存器没有固定的含义或固定值。有 3 个一位的条件码,它们保存着最近的算术或逻辑指令所造成影响的有关信息。程序计数器(PC)存放当前正在执行指令的地址。
内存
从概念上来说就是一个很大的字节数组,保存着程序和数据。y86-64 程序用虚拟地址
来引用内存位置。硬件和操作系统软件联合起来将地址翻译成实际或物理地址
,指明数据实际存在内存中哪个地方。第 9 章将更详细地研究虚拟内存。现在,我们只认为虚拟内存系统向 y86-64 程序提供了一个单一的字节数组映像。
程序状态的最后一个部分是状态码 Stat,它表明程序执行的总体状态。它会指示是正常运行,还是出现了某种异常
,例如当一条指令试图去读非法的内存地址时。
2. Y86-64 指令
上图给出了 y86-64 ISA 中各个指令的简单描述。这个指令集就是我们处理器实现的目标。y86-64 指令集基本上是 x86-64 指令集的一个子集。它只包括 8 字节整数操作,寻址方式比较少,操作也少。因为我们只有 8 字节数据,所以称之为“字(Word)”不会有任何歧义。在这个图中,左边是指令的汇编码表示,右边是字节编码。
下面是 y86-64 指令的一些细节:
- x86-64 的 movq 指令分成了 4 个不同的指令:irmovq、rrmovq、mrmov、rmmovq,分别显式的指明源和目的的格式。源可以是立即数(i)、寄存器(r)或内存(m)。指令名字的第一个字母就表明了源的类型。目的可以是寄存器(r)或内存(m)。指令名字的第二个字母指明了目的的类型。在决定如何实现数据传送时,显式的指明数据传送的这 4 中类型是很有帮助的。两个内存传送指令中的内存引用方式是简单的基址的偏移量形式。在地址计算中,我们不支持第二变址寄存器(second index register)和任何寄存器值的伸缩(scaling)。同 x86-64 一样,我们不允许从一个内存地址直接传送到另一个内存地址。另外,也不允许将立即数传送到内存。
- 有 4 个整数操作指令,比如上图的 OPq。它们是 addq、subq、andq 和 xorq 。它们只对寄存器数据进行操作,而 x86-64 还允许对内存数据进行这些操作。这些指令会设置 3 个条件码 ZF、SF 和 OF(零、符号和溢出)。
- 7 个跳转指令是 jmp、jle、jl、je、jne、jge 和 jg。根据分支指令的类型和条件代码的设置来选择分支。分支条件和 x86-64 的一样。
- 有 6 个条件传送指令:cmovle、cmovl、cmove、cmovne、cmovge 和 cmovg。这些指令的格式与寄存器-寄存器传送指令 rrmovq 一样,但是只有当条件码满足所需的约束时,才会更新目的寄存器的值。
- call 指令将返回地址入栈,然后跳到目的地址。ret 指令从这样的调用中返回。
- pushq 和 popq 指令实现了入栈和出栈,就像在 x86-64 中一样。
- halt 指令停止指令的执行。x86-64 中有一个与之相当的指令 hlt。x86-64 的应用程序不允许使用这条指令,因为它会导致整个系统暂停运行。对于 y86-64 来说,执行 halt 指令会导致处理器停止,并将状态码设置为 HLT。
3.指令编码
上图还给出了指令的字节级编码。每条指令需要 1 ~ 10 个字节不等,这取决于需要哪些字段。每条指令的第一个字节表明指令的类型。这个字节分为两个部分,每部分 4 位:高 4 位是代码(code)部分,低 4 位是 功能(function)部分。如上图所示,代码值位 0 ~ 0xB。功能值只有在一组相关指令共用一个代码时才有用。下图给出了整数操作、分支和条件传送指令的具体编码。可以观察到,rrmovq 与条件传送有同样的指令代码。可以把它看作是一个“无条件传送”,就好像 jmp 指令是无条件跳转一样,它们的功能代码都是 0。
如下图,15 个程序寄存器中每个都有一个相对应的范围在 0 到 0xE之间的寄存器标识符(register ID)
。y86-64 中的寄存器编号跟 x86-64 中的相同。程序寄存器存在 CPU 中的一个 寄存器
文件中,这个寄存器文件就是一个小的、以寄存器 ID 作为地址的随机访问存储器。在指令编码中以及在我们的硬件设计中,当需要指明不应访问任何寄存器时,就用 ID 值 0xF 来表示。
有的指令只有一个字节长,而有的需要操作数的指令编码就更长一些。首先,可能有附加的寄存器指示符字节(register specifier byte)
,指定一个或两个寄存器。在上上图中,这些寄存器字段成为 rA 和 rB。从指令的汇编代码表示中可以看到,根据指令类型,指令可以指定用于数据源和目的的寄存器,或是用于地址计算的基址寄存器。没有寄存器操作数的指令,例如分支指令和 call 指令,就没有寄存器指示符字节。那些只需要一个寄存器操作数的指令(irmovq、pushq 和 popq)将另一个寄存器指示符设为 0xF。这种约定在我们的处理器实现中非常有用。
有些指令需要一个附加 4 字节常数字(constant word)
。这个字能作为 irmovq 的立即数数据,rmmovq 和 mrmovq 的地址指示符的偏移量,以及分支指令和调用指令的目的地址。注意,分支指令和调用指令的目的是一个绝对地址,而不像 IA32 中那样使用 PC 相对寻址方式。处理器使用 PC 相对寻址方式,分支指令的编码会更简洁,同时这样也能允许代码从内存的一部分复制到另一部分而不需要更新所有的分支目标地址。因为我们更关心描述的简单性,所以就使用了绝对寻址方式。同 IA32 一样,所有整数采用小端法编码。当指令按照反汇编格式书写时,这些字节就以相反的顺序出现。
用十六进制来表示指令 rmmovq %rsp,0x12345678abcd(%rdx)的字节编码。rmmovq 的第一个字节位 40。源寄存器 %rsp 应该编码放在 rA 字段中,而基址寄存器 %rdx 应该编码放在 rB 字段中。所以得到寄存器指示符字节 42。最后,偏移量编码放在 8 字节的常数字中。首先在 0x123456789abcd 的前面填充上 0 变成 8 个字节,变成字节序列 00 01 23 45 67 89 ab cd 。写成按字节反序就是 cd ab 89 867 45 23 01 00。将它们都连接起来就得到指令的编码 4042cdab896745230100。
指令集的一个重要性质就是字节编码必须有唯一的解释。任意一个字节序列要么是一个唯一的指令序列的编码,要么就不是一个合法的字节序咧。y86-64 就具有这个性质,因为每条指令的第一个字节有唯一的代码和功能组合,给定这个字节,我们就可以决定所有其他附加字节的长度和含义。这个性质保证了处理器可以无二义性地执行目标代码程序。即使代码嵌入在程序的其他字节中,只要从序列的第一个字节开始处理,我们让然可以很容易确定指令序列。反过来说,如果不知道一段代码序列的起始位置,我们就不能准确地确定怎样将序列划分成单独的指令。对于试图直接从目标代码字节序列中抽取出机器级程序的反汇编程序和其他一些工具来说,这就带来了问题。
4.Y86-64 异常
对 y86-64 来说,程序员可见的状态包括状态码 Stat,它描述程序执行的总体装填。这个代码可能的值如下图。
代码值 1,命名为 AOK,表示程序执行正常,而其他一些代码则表示发生了某种类型的一场。代码2,命名为 HLT,表示处理器执行了一条 halt 指令。代码3,命名为 ADR,表示处理器试图从一个非法内存地址读或者向一个非法内存地址写,可能是当取指令的时候,也可能是当读或者写数据的时候。我们会限制最大的地址,任何访问超出这个限定值的地址都会引发 ADR 异常。代码4,命名为 INS,表示遇到了非法的指令代码。
对于 y86-64 ,当遇到这些异常的时候,我们就简单地让处理器停止执行指令。在更完整的设计中,处理器通常会调用一个异常处理程序(exception handler)
,这个过程被指定用来处理遇到的某种类型的异常。就像在第 8 章中讲述的,异常处理程序可以被配置成不同的结果,例如,中止程序或者调用一个用户自定义的信号处理程序(signal handler)
5.y86-64 程序
x86-64 代码是由 GCC编译器产生的。y86-64代码与之泪洗,但有以下不同点:
- y86-64 将常数加载到寄存器(第 2~3行),因为它在算术指令中不能使用立即数。
- 要实现从内存读取一个数值并将其与一个寄存器相加,y86-64 代码需要两条指令(第 8~9 行),而 x86-63 只需要一条 addq 指令(第 5 行)
- 手工编写的 y86-64 实现有一个优势,即 subq 指令(第 11 行)同时还设置了条件码,因此 GCC 生成代码中的 testq 指令(第 9 行)就不是必需的。不过为此,y86-64 代码必须用 andq 指令(第 5 行)在进入循环之前设置条件码。
下图给出了用 y86-64 汇编代码编写的一个完整的程序文件的例子。这个程序既包括数据,也包括指令。伪指令(directive)指明应该将代码或数据放在什么位置,以及如何对齐。这个程序详细说明了栈的放置、数据初始化、程序初始化和程序结束等问题。
在这个程序中,以“·”开头的词是汇编器伪指令(assembler directives)
,它们告诉汇编器调整地址,以便在那儿产生代码或插入一些数据。伪指令.pos 0(第 2 行)告诉汇编器应该从地址 0 处开始产生代码。这个地址是所有 y86-64 程序的起点。接下来的一条指令(第 3 行)初始化栈指针。我们可以看到程序结尾处(第 40 行)声明了标号 stack,并且用一个 .pos 伪指令(第 39 行)指明地址 0x200。因此栈会从这个地址开始,向低地址增长。我们必须保证栈不会增长得太大以至于覆盖了代码或者其他程序数据。
程序的第 8 ~ 13 行声明了一个 4 个字的数组,值分别为
标号 array 表明了这个数组的起始,并且在 8 字节边界处对齐(用.align伪指令指定)。第 16 ~ 19行给出了 “main”过程,在过程中对那个四字数组调用了 sum 函数,然后停止。
正如例子所示,由于我们创建 y86-64 代码的唯一工具是汇编器,程序员必须执行本来通常交给编译器、链接器和运行时系统来完成的任务。型号我们只用 y86-64 来写一些小的程序,对此一些简单的机制就足够了。
上图是 YAS 的汇编器对代码进行汇编的结果。为了便于理解,汇编器的输出结果是 ASCII码格式。
我们实现了一个指令集模拟器
,称为 YIS,它的目的是模拟 y86-64 机器代码程序的执行,而不用试图去模拟任何具体处理器实现的行为。这种形式的模拟有助于在有实际硬件可用之前调试程序,也有助于检查模拟硬件或者在硬件上运行程序的结果。用 YIS 运行例子的目标代码,产生输出如下:
模拟输出的第一行总结了执行以及 PC 和程序状态的结果值。模拟器只打印出在模拟过程中被改变了的寄存器或内存中的字。左边是原始值(这里都是 0),右边是最终的值。从输出我们可以看到,寄存器 %rax 的值为 0x0000abcdabcdabcd,即传给子函数 sum 的四元素数组的和。另外,我们还能看到栈从地质 0x200 开始,向下增长,栈的使用导致内存地址 0x1f0 ~ 0x1f8 发生了变化。可执行代码的最大地址为 0x090,所以数值的入栈和出栈不会破坏可执行代码。
6.一些 y86-64 指令的详情
大多数 y86-64 指令是以一种直接明了的方式修改程序状态的,所以定义每条指令想要达到的结果并不困难。不过,两个特别的指令的组合需要特别注意一下。
pushq 指令会把栈指针减 8 ,并且将一个寄存器值写入内存中。因此,当执行 pushq %rsp 指令时,处理器的行为是不确定的,因为要入栈的寄存器会被同一条指令修改。通常有两种不同的约定:1.压入 %rsp 的原始值,2.压入减去 8 的 %rsp 的值。
对于y86-64 处理器来说,我们采用和 x86-64 一样的做法,就像下面这个练习题确定出的那样。
对 popq %rsp 指令也有类似的歧义。可以将 %rsp 置为从内存中读出的值,也可以置为增加了增量后的栈指针。
2 逻辑设计和硬件控制语言 HCL
在硬件设计中,用电子