《深入理解计算机系统》第四章 处理器体系结构
我们看到的计算机系统都只限于机器语言程序级。处理器执行一系列指令每天指令执行某个简单操作,它们被编码为由一个或多个字节序列组成的二进制格式。在本章的学习中,我们主要了解ISA抽象的作用以及了解流水线和实现方式。
4.1 Y86-64指令集体系结构
字节序列转换为Y86-64指令的方法总结如下:
- 通过代码部分确定指令长度,从而以指令为单位划分字节序列;
- 通过功能部分确定具体的指令;
- 通过寄存器指示符字节确定指令中涉及的寄存器;
通过转换数值部分以小段法编码的数字来确定立即数、偏移量、绝对地址等值。
ISA
一个处理器支持的指令和指令的字节级编码称为它的指令集体系结构ISA。
ISA模型(概念抽象层):CPU允许的指令集编码,且顺序地执行指令,也就是先取出一条指令,等到她执行完毕,再开始下一条。然而,现代处理器的实际工作方式可能跟ISA隐含的计算模型大相径庭。通过同时处理多条指令的不同部分,处理器可以获得较高的性能。但其必须对外表现出符合ISA模型的执行结果。4.2逻辑设计和硬件控制语言HCL
CPU硬件简介
大多数现代电路设计都是用信号线上的高电压和低电压来表示不同的位值。
要实现一个数字系统需要三个主要的组成部分:- 计算对位进行操作的函数的组合逻辑(ALU)
- 存储位的存储器元素(寄存器)
- 控制存储器元素更新的时钟信号
逻辑门是数字电路的基本计算元素,它们产生的输出,等于它们输入位值的某个布尔函数。将很多逻辑门组合成一个网,就能构建计算块,称为组合电路。(相当于一个表达式)
算术/逻辑单元(ALU)是一种很重要的组合电路,这个电路有三个输入:两个数据输入及一个控制输入。根据控制输入的设置,电路会对数据输入执行不同的算术或逻辑操作。
存储器和时钟
组合电路从本质上讲,不存储任何信息。它们只是简单地响应输入信号,产生等于输入的某个函数的输出。为了产生时序电路,也就是有状态并且在这个状态上进行计算的系统,我们必须引入按位存储信息的设备。
存储设备都是由同一个时钟控制,时钟是一个周期性信号,决定了什么时候要把新值加载到设备中。
大多数时候,寄存器都保持在稳定状态(用x表示),产生的输出等于它的当前状态。信号沿着寄存器前面的组合逻辑传播,这时,产生了一个新的寄存器输入(用y表示),但只要时钟是低电位的,寄存器的输出就仍然保持不变。当时钟变成高电位的时候,输入信号才加载到寄存器中,成为下一个状态y,直至下一个时钟的上升沿。
寄存器是作为电路不同部分中的组合逻辑之间的屏障。每当每个时钟到达上升沿时,值才会从寄存器的输入传送到输出。
寄存器文件(通用寄存器组成的逻辑块) 有两个读端口,还有一个写端口。电路可以读两个程序寄存器的值,同时更新第三个寄存器的状态。每个端口都有一个地址输入,表明选择哪个程序寄存器。
虽然寄存器文件不是组合电路,因为它有内部存储。不过,从寄存器文件读数据就好像它是一个以地址为输入、数据为输出的一个组合逻辑块。
指令编码
指令集的一个重要性质就是字节编码必须有唯一的解释。任意一个字节序列要么是一个唯一的指令序列的编码,要么就不是一个合法的字节序列。因为每条指令的第一个字节有唯一的代码和功能组合,给定这个字节,我们就可以决定所有其他附加字节的长度和含义。
每条指令需要1——6个字节不等,这取决于需要哪些字段。每条指令的第一个字节表明指令的类型:高4位是代码部分(例:6为整数类操作指令),低4位是功能部分(例:1为整数类中的减法指令) 61合起来即为sub指令。
处理一条指令的序列: - 取指(fetch)
取值阶段从存储器读取指令字节,放到指令存储器(CPU中)中,地址为程序计数器(PC)的值。
它按顺序的方式计算当前指令的下一条指令的地址(即PC的值加上已取出指令的长度) - 译码(decode)
ALU从寄存器文件(通用寄存器的集合)读入最多两个操作数。(即一次最多读取两个寄存器中的内容) - 执行(execute)
在执行阶段会根据指令的类型,将算数/逻辑单元(ALU)用于不同的目的。对其他指令,它会作为一个加法器来计算增加或减少栈指针,或者计算有效地址,或者只是简单地加0,将一个输入传递到输出。
条件码寄存器(CC)有三个条件位。ALU负责计算条件码新值。当执行一条跳转指令时,会根据条件码和跳转类型来计算分支信号cnd。 - 访存(memory)
访存阶段,数据存储器(CPU中)读出或写入一个存储器字。指令和数据存储器访问的是相同的存储器位置,但是用于不同的目的。 - 写回(write back)
写回阶段最多可以写两个结果到寄存器文件。寄存器文件有两个写端口。端口E用来写ALU计算出来的值,而端口M用来写从数据存储器中读出的值。 更新PC(PC update)
根据指令代码和分支标志,从前几步得出的信号值中,选出下一个PC的值。
我们以SEQ(sequential 顺序的)处理器为例讲解CPU的基本原理。每个时钟周期上,SEQ执行处理一条完整指令所需的所有步骤。不过这需要一个很长的时钟周期时间,因此时钟周期频率会低到不可接受。4.3 Y86的顺序实现
SEQ的时序
组合逻辑不需要任何时序或控制——只要输入变化了,值就通过逻辑门网络传播。
我们也将读随机访问存储器(寄存器文件、指令存储器和数据存储器)看成和组合逻辑一样的操作。(写随机访问存储器需要等待高电平)
由于指令存储器只用来读指令,因此我们可以将这个单元看成是组合逻辑。(内存向指令存储器中写指令是CPU外部的事件 不属于CPU内的时序)
每个时钟周期,程序计数器都会装载新的指令地址。
只有在执行整数运算指令时,才会装载条件码寄存器。
只有在执行mov、push、call指令时,才会写数据存储器。
要控制处理器中活动的时序,只需要寄存器和存储器的时钟控制。
因为指令运行计算的结果,写入寄存器或存储器中。
我们可以把取指、译码、执行等过程看做是组合逻辑的处理过程(因为它们不涉及写入寄存器)。把写回看做是另一个过程。4.4 流水线的通用原理
流水线原理
我们通过将执行每条指令所需的步骤组织成一个统一的流程,就可以用很少量的各种硬件单元以及一个时钟来控制计算的顺序,从而实现整个处理器。不过这样一来,控制逻辑就必须要在这些单元之间路由信号,并根据指令类型和分支条件产生适当的控制信号。(CPU内有三种总线:控制总线、地址总线、数据总线)
SEQ处理器不能充分利用硬件单元,因为每个单元只在整个时钟周期的一部分时间内才被使用。我们会看到引入流水线能获得更好的性能。在流水线化的系统中,待执行的任务被划分成了若干独立的阶段。
流水线化的一个重要特性就是增加了系统的吞吐量,也就是单位时间内服务的顾客总数,不过它也会轻微地增加延迟,也就是服务一个用户所需要的时间。(我们之前的设计是一条指令执行完,下条指令才能进入CPU,(所不同的是时钟周期的粒度)。流水线化是允许多条指令在CPU中,每条指令在CPU中的时间是一样的,哪怕你一个周期就执行完了,你也得等剩下的阶段结束,使后面的指令被延迟了。
虽然流水线化,所有指令在CPU中待的时间都一样(且都按最耗时指令算的),但它们的时间是重叠的。假设一条指令在CPU中待6ms,那么12ms能处理7条指令,而非流水线,虽然一条指令最多执行6ms,但它们的时间是相加的,12ms可能只执行3条。12=6+2+4)
流水化的硬件系统
假设将系统执行的计算分成三个阶段(A、B和C),每个阶段需要100ps,然后在各个阶段之间放上流水线寄存器,这样每条指令都会按照三步经过这个系统,从头到尾需要三个时钟周期。
(流水线寄存器的作用:作为电路不同部分中的组合逻辑之间的屏障。保存每步组合逻辑的运算结果。这是为了分割流水而插入的寄存器。)
流水线,在稳定状态下,三个阶段应该都是活动的,每个时钟周期,一条指令离开系统,一条新的进入。
这样,我们一个阶段的时间,相当于运行了一条指令,在这个系统中,我们将时钟周期设为100+20=120ps,得到的吞吐量大约为8.33GIPS。因为处理一条指令需要3个时钟周期,所以这条流水线的延迟就是3*120=360ps。非流水运行一条完整指令需要320ps。(从宏观整体上看,一个时钟周期运行了一条指令(这条指令是由多条指令的各阶段拼合的),而从单条指令的执行看,需要3个时钟周期执行一条完整指令。)我们将系统吞吐量提高到原来的8.33/3.12=2.67倍,代价是增加一些硬件(流水线寄存器),以及延迟的少量增加(360/320=1.12)。延迟变大是由于增加的流水线寄存器的时间开销。时钟周期的时间就是流水线分割的一个阶段的时间,这样,从宏观上看,是一个时钟周期执行一条指令。
流水线的局限性
1、不一致的划分
之前的是一个理想的流水线化的系统,每个阶段需要的时间都相同。而实际系统通过各阶段的延迟一般是不同的。且运行时钟的速率是由最慢阶段的延迟限制的。(即系统吞吐量受最慢阶段的速度所限制)
2、流水线过深,收益反而下降
例如,我们把计算分成6个阶段,每个阶段需要50ps。在每对阶段之间插入流水线寄存器就得到了一个六阶段流水线。这个系统的最小时钟周期为50+20=70ps,吞吐量为14.29GIPS。性能比3阶段流水提高了14.29/8.33=1.71倍。由于通过流水线寄存器的延迟,吞吐量并没有加倍。这个延迟成了流水线吞吐量的一个制约因素。为了提高时钟频率,现代处理器采用了很深的(15或更多的阶段)流水线。4.5 Y86-64的流水线实现
- 分支预测
流水线化设计的目的就是每个时钟周期都发射一条新指令,要做到这一点,我们必须在取出当前指令之后,马上确定下一条指令的位置。
但如果取出的指令是条件分支指令,要到几个周期后,也就是指令通过执行阶段之后,我们才能知道是否要选择分支。类似的,如果取出的指令是ret,要到指令通过访存阶段,才能确定返回地址。
对条件转移来说,我们既可以预测选择了分支,那么新PC值应为valC,也可以预测没有选择分支,那么新PC值应为valP。
对ret指令,可能的返回值几乎是无限的,因为返回地址位于栈顶的字,其内容可以是任意的。在设计中,我们不会试图对返回地址做任何预测。只是简单地暂停处理新指令,直到ret指令通过写回阶段。
无论哪种情况,我们都必须以某种方式来处理预测错误的情况,因为此时已经取出并部分执行了错误的指令。
(流水线惩罚待写) 流水线冒险
使用流水线技术,当相邻指令间存在相关时会导致出现问题。
这些相关有:
1、数据相关:下一条指令会用到这一条指令计算出的结果
2、控制相关:一条指令要确定下一条指令的位置,例如在执行跳转、调用或返回指令时。
这些相关可能会导致流水线产生计算错误,称为冒险。
用暂停来避免数据冒险
暂停(stalling)是避免冒险的一种常用技术。让一条指令停顿在译码阶段,直到产生它的源操作数的指令通过了写回阶段,这样我们的处理器就能避免数据冒险。
暂停技术就是让一组指令阻塞在它们所处的阶段,而允许其他指令继续通过流水线。
用转发来避免数据冒险
在译码阶段从寄存器文件中读入源操作数,但是对这些源寄存器的写有可能要在写回阶段才能进行。与其暂停直到写完成,不如简单地将要写的值传到流水线寄存器E作为源操作数。
(即,我们不必等到irmovl $10, %edx和irmovl $3, %eax 完成对寄存器的写更新之后再继续addl,而是在addl译码阶段发现需要%edx、%eax值,译码逻辑不从寄存器文件中去读,而是用前面阶段未写入寄存器的值。)这种将结果直接从一个流水线阶段传到较早阶段的技术称为数据转发。在周期4中,译码阶段逻辑发现有在访存阶段中对寄存器%edx未进行的写,还发现在执行阶段中正在计算寄存器%eax的新值。它用这些值,而不是从寄存器文件中读出的值,作为valA和valB的值。
加载/使用数据冒险
有一类数据冒险不能单纯用转发来解决,因为存储器读(访存阶段)在流水线发生的比较晚。
我们可以将暂停和转发结合起来,避免加载/使用数据冒险。(既然是来不及发送给后面的指令,那就让后面的指令暂停几个周期,再发送)
当mrmovl指令通过执行阶段时,流水线控制逻辑发现译码阶段中的指令(addl)需要从存储器中读出的结果。它会将译码阶段中的addl指令暂停一个周期,导致执行阶段中插入一个气泡。 mrmovl指令从存储器中读出的值可以从访存阶段转发到译码阶段中的addl指令。
这种用暂停来处理加载/使用冒险的方法称为加载互锁。加载互锁和转发技术结合起来足以处理所有可能类型的数据冒险。
异常处理
异常可以由程序执行从内部产生,也可以由某个外部信号从外部产生。
简单的三种内部异常:
1、halt指令
2、非法指令
3、访问非法地址
(还有一些外部异常:网口收到新包、用户点击鼠标等)
在简化的ISA模型中,当处理器遇到异常时,会停止,设置适当的状态码,且应该是到异常指令之前的所有指令都已经完成,而其后的指令都不应该对程序员可见的状态产生任何影响。在一个更完整的设计中,处理器会继续调用异常处理程序,这是操作系统的一部分。
一般地,通过在流水线结构中加入异常处理逻辑,我们会在每个流水线寄存器中包括一个状态码Stat。如果一条指令在其处理器中于某个阶段产生了一个异常,这个状态字段就被设置成指示异常的种类。
异常状态和该指令的其他信息一起沿着流水线传播,直到它到达写回阶段。在此,流水线控制逻辑发现了异常,并停止执行。
异常事件不会对流水线中的指令流有任何影响,除了会禁止流水线中后面的指令更新程序员的可见状态(条件码寄存器和存储器),直到异常指令到达最后的流水线阶段。
因为指令到达写回阶段的顺序与它们在非流水化的处理器中执行的顺序相同,所以我们可以保证第一条遇到异常的指令会第一个到达写回阶段,此时程序执行会停止,流水线寄存器(W写回)中的状态码会被记录为程序状态。