本文,我们要做一件大胆的事情,从零开始实现一个全新的指令集架构,以此深入理解处理器的工作原理。
指令集发展历史概况
开始我们的创造之旅前,先了解一下历史上的指令集架构都有哪些。
一个处理器支持的指令和指令的字节级编码称为它的指令集架构(Instruction Set Architecture, ISA)。
最为我们熟知的就是x86架构,因为我们日常所用的个人电脑就采用了x86架构的处理器。目前世界上最大的两个处理器制造商Intel和AMD都有基于x86架构的一系列产品。从Intel i386处理器开始,x86架构进入32位时代,称为IA32架构(Intel Architecture 32bit)。后来,32位也不能满足我们的需求了,Intel开始进军64位处理器领域,提出IA64架构。但是,这个架构并不是我们现在在用的64位处理器,而是一个与x86完全无关的新的处理器架构,不保持向后兼容。虽然可以实现很高的性能,但是由于兼容性不好,市场反应冷淡。于此同时,AMD公司抓住机会,率先提出了x86-64处理器架构,支持64位的同时保持向后兼容,一举在与Intel的市场竞争中占据了主动权。当然,Intel也不会执迷不悟,他们果断放弃了IA64,开始转向x86-64架构,并逐步收回丧失的市场份额。后来,虽然AMD将自己的架构命名为AMD64,Intel将自己的架构命名为Intel64,但人们仍然习惯性地将它们统称为x86-64。
Y86指令集
为了致敬伟大的x86指令集架构,我们将自己的指令集架构命名为Y86。其实呢,Y86的设计理念完全借鉴x86,相当于一个简化的x86架构。
要想从头设计一个指令集架构,需要先规定指令集和指令集编码,然后将每个指令划分为几个阶段分步执行,每个阶段只需要做简单的一两项工作,之后,将硬件设备结合适当的逻辑电路实现指令每个阶段的工作。下面我们详细讲解具体的实现过程。
指令集及其编码
对于一个简易的指令集来说,不需要太多的指令,能实现基本的数据转移和流程控制就够了。下图列出了Y86指令集中包含的所有指令,以及每个指令的编码。
![6b059f87a4785039d7d9f1cada156187.png](https://i-blog.csdnimg.cn/blog_migrate/9e9c28f644dae711ed037cce426418da.jpeg)
这些都是非常基本的指令,不过看起来有些奇怪,这是因为我们把x86中的movl
指令替换成了四个独立的指令rrmovl
、irmovl
、rmmovl
和mrmovl
,每个指令指明了操作数的来源,这样就避免了各种寻址方式的麻烦。
可以看到,各个指令的长度从1字节到6字节不等,这样编码可以减少程序代码占用的空间。第1个字节的高4位作为指令编码,用来区分不同的指令,低4位要么是0,要么是fn。fn称为功能代码,用来区分不同的操作。如下图所示,不同的功能码在不同的指令中有不同的含义。在运算指令中,分别代表加、减、与和异或;在分支跳转指令中,分别代表不同的跳转条件;在条件转移指令中,分别代表不同的转移条件。
![393bf9b2e4de7d00977a2a3a6278f6d0.png](https://i-blog.csdnimg.cn/blog_migrate/3e043b3518ec4f9b1ba215e6ce67d302.png)
第2个字节,对于大部分指令来说存放的是寄存器标识符,请看下图:
![a9bc2e1e6ccf3db61dea984e5b7da8e3.png](https://i-blog.csdnimg.cn/blog_migrate/57f7b11c459624ecefed31a94985b949.png)
每个寄存器与一个数字一一对应,F代表无寄存器操作数。
最后,有些指令还包含四个字节的立即数。
举一个例子来帮助我们更好地理解指令编码。例如对于如下指令
rmmovl %esp, 0x12345(%edx)
对应的编码为
40 42 45 23 01 00
其中,从左到右,40是指令编码,42分别是寄存器%esp
对应的4和寄存器%edx
对应的2,45230100是偏移量0x12345
在小端机器上的表示。
硬件控制语言HCL
处理器的各个硬件设备(比如ALU、程序计数器)之间通常需要特定功能的逻辑电路来连接,在设计阶段,我们使用一种结构化的语言来描述这些逻辑关系。
HCL(Hardware Control Language)是一种类似C的硬件控制语言,用于描述处理器的控制逻辑。
举一个简单的例子,对于如下所示的组合逻辑电路:
![1f9e40b12b777221901ad38392da23d5.png](https://i-blog.csdnimg.cn/blog_migrate/1ac3985ae1178aad4627a49120d64475.png)
可以用HCL语言表示为
e = (a && !(b||c)) || (!d && !(b||c))
这句话描述了输出和输入的逻辑关系,无论多么复杂的组合电路,都可以用最基本的与或非门来实现。HCL在后面将会有大量的应用。
存储器和时钟
细心的读者可能会注意到,上一段话讲到“无论多么复杂的组合电路”。为什么特别强调组合电路呢,因为还有另一种电路——时序电路。
大家应该都有基本的电路知识,组合电路只是完成了一个函数的功能,不同的输入导致不同的输出,电路本身并不存储任何信息。而时序电路就不一样了,它可以存储信息,而且在时钟信号的控制下对输入做出反应。
接下来,重点来了。在处理器中有两种存储设备:
- 时钟寄存器(简称寄存器) 存储单个位或字。时钟信号控制寄存器加载输入值。
- 随机访问存储器(简称存储器) 存储多个字,由地址选择读写哪个字。这里所说的存储器可以分为两种:处理器的虚拟存储器系统和寄存器文件。前者是通常意义上的内存系统,后者才是我们指令集中8个寄存器标识符对应的通用寄存器。
下图为寄存器的工作原理,寄存器输出一直保持在当前状态,直到时钟上升沿,新的输入将成为当前的寄存器状态。
![0c57442f106644ead586b844dc3b5478.png](https://i-blog.csdnimg.cn/blog_migrate/6be62a92bc303544e655e64fdd63f283.png)
寄存器文件可以看成这样一个功能块:
![549308b708670c100bae9adef4ccbe76.png](https://i-blog.csdnimg.cn/blog_migrate/25b5c5227a6509e00295509c274a0b33.png)
它有两个读端口和一个写端口,支持读写同时操作。值得注意的是,寄存器文件的读操作是即时的,而写操作是基于时钟的。也就是说,读出的值valA和valB随时根据srcA和srcB的变化而变化,而要写入的值valW只在clock的上升沿才能写入。仔细想想,寄存器文件的读写特性好像和寄存器是完全一样的,只不过是多了一个选址操作。
指令的分阶段执行
虽然宏观上来看,指令已经是程序不可分割的基本元素。但在处理器中,一条指令的执行还是要分多个阶段,这样才可以提高硬件的处理效率。在Y86架构中,我们将每个指令的执行分为6个阶段。
- 取指:从PC中取出当前要执行的指令,并按照指令编码对其分解,得到icode、ifun、rA、rB、valC等值。
- 译码:根据rA、rB取出对应寄存器的值valA、valB。
- 执行:ALU在不同指令下执行不同的操作,包括简单运算、地址加减等等,运算结果为valE,运算时会对条件码产生影响。
- 访存:从存储器读取数据或向存储器写入数据。读出的值为valM。
- 写回:将前面生成的结果写回寄存器文件。
- 更新PC:将PC设置成下一条指令的地址。
这些步骤现在看起来杂乱无章,不知有何用处。但仔细分析,可以看到,每个阶段只做与一两个硬件相关的事情,由输入决定输出,完全可以在一个时钟周期内做完。而各个阶段之间的联系就是各种信号的输入和输出,比如,译码阶段的输出valA可以作为执行阶段的输入,而执行阶段的输出又可以作为写回阶段的输入,这样就可以用简单的组合电路把这些硬件单元连接起来,实现我们需要的功能。
为了大家更清楚地理解各个阶段的作用,我们用一个例子来详细说明。
![bb66c8b99328ed6642dc64e44a2339b5.png](https://i-blog.csdnimg.cn/blog_migrate/32d9fda0b60b3ca0d22253dfbddbc901.jpeg)
上图分别为OPl rA, rB
、rrmovl rA, rB
、irmovl V, rB
这三个指令的分阶段执行过程。在取指阶段中,M表示存储器,M1[PC]表示以PC为基址从存储器中取出1字节数据。由于各个指令长短不一,因此取指阶段做的事情也不尽相同。在该阶段最后,会计算出PC的新值valP。译码阶段是从寄存器文件中取出寄存器的值,用R[rA]来表示寄存器rA的值。执行阶段对于OPl指令来说会设置状态码CC,而后两个指令则不会对状态码产生影响。访存阶段在这三个指令中都没有涉及。最后的更新PC阶段将valP的值赋值给PC。
当我读到这里的时候,我有很大的疑问:不是说每个阶段只做一件简单的事情吗,但是不同的指令在同一个阶段做的事情似乎各不相同。比如刚才的三个指令,在执行阶段只有OPl指令会设置状态码,而另外两个不会,这是为什么?包括书中后面举的其它例子,更新PC阶段并不一定是把valP的值赋值给PC,有些指令比如call和ret,它们会将valC的值或valM的值赋值给PC,这又是怎么做到的?
大家是否也想到了这些问题呢?很显然,每个阶段对不同的指令有不同的响应是很自然的事情,不然怎么适应各个指令的不同功能呢。我们前面提到的HCL硬件控制语言,就是要完成这个任务,控制每个指令在每个阶段要完成的任务。
好了,在详细说明如何用HCL控制逻辑之前,先给出完整的硬件结构图。
![8295885fd66e09c86bae967029953dad.png](https://i-blog.csdnimg.cn/blog_migrate/a12a3df1b8bcf3ccc30e390cd1f982d1.png)
我们要注意图中不同颜色的方块和不同粗细的线条,它们代表着不同的意思。绿色块代表基本的硬件单元,比如ALU、寄存器文件、PC,基本上我们都已经接触过。灰色方块将是我们下一步研究的重点,它们是HCL描述的组合逻辑电路,用于连接绿色块并实现特定的选择或逻辑运算。白色圆圈并没有特殊的含义,只是用来标识信号线的名称。图中还有三种线条,粗实线表示宽度为字长的信号线,细实线表示宽度为1个字节或更窄的信号线,而虚线表示单个位的信号线。
图中从下到上分别是刚才介绍的取指、译码(写回)、执行、访存和更新PC阶段。由于译码和写回阶段都是对寄存器文件的操作,因此它们在图中画在了同一个位置。用圆圈标出的信号就是前文提到的各个阶段产生的中间值,这些值通常在不同指令中担任着不同的角色,因此会出现一个信号分叉为两个信号的情况。例如图中valA产生之后分为两条线,一条通向ALUB控制逻辑,另一条通向Data控制逻辑。再例如图中valM产生之后分为两条线,一条通向New PC控制逻辑,另一条通向寄存器文件的输入端。我们需要明白的是,一个信号分为两个信号,意味着两个接收端都可以读取到该信号的值,但读取到该值并不意味着使用该值,接收端的控制逻辑决定是否使用该值,下文将会详细叙述。
SEQ的状态改变周期
上一张图的标题我没做解释,其实是留了个疑问。SEQ的意思是Sequential(顺序的),“SEQ硬件结构”就是说“顺序的硬件结构”或者“硬件结构的顺序实现”。什么!!难道还有其它方式的实现?答案是当然的,我们留到后面再揭开谜底。SEQ的硬件结构使得指令必须按顺序一个接一个地执行,下一条指令的开始必须晚于上一条指令的结束。这就导致处理器效率极其低下,因为一个指令必须在一个时钟周期内通过所有阶段,而由于电路延迟的固有因素,通过所有阶段需要的时间很长,也就限制了时钟周期无法提高。然而,为什么一个指令必须在一个时钟周期内通过所有阶段呢?
因为对于时序逻辑电路,比如SEQ中的存储器、寄存器文件、CC和程序计数器,它们只在时钟信号的上升沿写入数据。当前个指令结束,下个指令开始的时候,时钟信号上升沿触发这几个硬件单元的更新。如果在下一个时钟周期上升沿到来之前,需要更新的新值还没有产生,这个指令就相当于没执行或执行了一半。因此时钟周期不能提得太高,否则将造成指令执行紊乱。
下图展示了两个指令周期的过程中,由时钟控制的各个硬件单元的状态改变。
![1526dc948d1f7d92eaa01011ce63ad6d.png](https://i-blog.csdnimg.cn/blog_migrate/575f6f49f408846032e37c6013c4cc6d.jpeg)
可以看到,图中将四个时序逻辑电路之外的其它部分作为一个组合逻辑电路的整体来看待。当周期3开始时,组合逻辑电路开始运行,直到周期3结束前,所有结果都已得出,准备写入存储器等设备。当周期4开始时,存储器、寄存器文件、CC和程序计数器的值被更新,同时,这些新值被组合逻辑电路读取并开始计算结果,如此循环往复。因此,每个时钟周期SEQ的状态改变一次。
SEQ的各阶段实现
前文给出的SEQ硬件结构图只是一个大概的实现,有些细节并没有给出。现在,我们一个阶段一个阶段地分析SEQ的具体实现。
取指阶段:
![847040013f7a68d3f65b23a515be62b3.png](https://i-blog.csdnimg.cn/blog_migrate/3fbb6497f4cdf675f43518d663c1c877.jpeg)
指令从内存中取出后按字节分为了两部分:Split和Align。Split又分为icode和ifun。Align分为rA、rB和valC,这些都很容易理解。重点在于PC增加的逻辑。PC增加多少要根据本条指令的长短来决定,而本条指令的长短又在于指令中是否包含寄存器标识,以及是否包含常数valC,图中的两个组合电路Need valC和Need rigids就是用来做这个判断。
以Need rigids为例,它的HCL语言描述如下:
bool need_rigids =
icode in { IRRMOVL, IOPL, IPUSHL, IPOPL, IIRMOVL, IRMMOVL, IMRMOVL };
意即,当icode等于括号中7种指令码之一时,need_rigids为真。也就是说这7种指令中包含寄存器标识。同理,need_valC也可以用这个枚举的方法确定,只需要查前面的指令集编码表,找到包含valC的指令,放在括号里面就行了。
当need_rigids和need_valC都确定了之后,PC increment将按如下公式计算新的PC值,其实就是加上了该条指令的长度:
newPC = oldPC + 1 + need_rigids + 4*need_valC
现在我们明白了,灰色方框代表的组合电路可以用HCL语言来描述。而实际电路中这些HCL语句将通过综合成为真正的组合逻辑电路。在这里,HCL是一种很好的抽象,将原理与具体的实现相分离,方便我们的设计。
译码和写回阶段:
![8b625d97907d498ba13d544c15e2b2bb.png](https://i-blog.csdnimg.cn/blog_migrate/c589a41cee5267c8e8f63dbdc931264c.png)
这两个阶段都与寄存器文件的读写相关。从取指阶段得到的信号icode、rA和rB在这里作为输入信号,经过一些组合电路生成寄存器文件的输入。我们的目的是,在译码阶段,对于那些需要使用特定寄存器的命令,从寄存器文件中取出这些寄存器的值,地址由srcA和srcB来决定,结果输出为valA和valB;在写回阶段,将执行阶段的结果valE或访存阶段的结果valM写回特定的寄存器,寄存器的地址由dstE和dstM来决定。以组合电路srcA为例,它的HCL表述为:
int srcA = [
icode in { IRRMOVL, IRMMOVL, IOPL, IPUSHL } : rA;
icode in { IPOPL, IRET } : RESP;
1 : RNONE; #Don't need register
];
方括号类似C语言中的switch语句,当第一个分号前的条件满足时返回rA,后面的两个条件不再考虑;否则再判断第二个条件是否满足,满足则返回RESP;否则返回RNONE,表示不需要读取寄存器文件。从中可以看出,在译码阶段,当指令为第一个分号前的四种时,将读取rA寄存器的值并放入结果valA;当指令为第二个分号前的两种时,将读取RESP寄存器的值并放入结果valA;否则,不必读取任何寄存器。
与srcA类似的还有srcB、dstE和dstM三个组合逻辑电路,它们的HCL表述可以从SEQ的硬件结构和指令集编码中分析得出,不再一一叙述。
执行阶段:
![386d5ba4994e0ec3c7ff072dd97d0b23.png](https://i-blog.csdnimg.cn/blog_migrate/2d05075b820bee37d9a5a9c53622c729.jpeg)
ALU需要两个操作数和一个alufun信号,alufun信号用于指明ALU对两个操作符执行怎样的逻辑运算(加、减、与、异或)。
以第一个操作数aluA为例,它的HCL描述如下:
int aluA = [
icode in { IRRMOVL, IOPL } : valA;
icode in { IIRMOVL, IRMMOVL, IMRMOVL } : valC;
icode in { ICALL, IPUSH } : -4;
icode in { IRET, IPOPL } : 4;
# Other instructions don't need ALU
];
可以看出,操作数aluA有时取valA,有时取valC,有时取-4或4,完全决定于指令类型。
alufun信号的HCL描述如下:
int alufun = [
icode == IOPL : ifun;
1 : ALUADD;
];
仅当指令为IOPL指令(即运算指令)时,alufun由ifun决定,其它情况下ALU全部当做加法器来使用。这也就不难理解为什么刚才aluA会取-4或4,因此此时aluA作为加法器的一个加数,而另一个加数从图中可以看到只能来自于valB,虽然valB在译码阶段的HCL我们并没有给出,不过可以告诉大家valB在这四种情况下的输出都是RESP。因此对于ICALL和IPUSH来说是为了让栈指针esp-4,对于IRET和IPOPL来说是为了让栈指针esp+4。
访存阶段:
![76a852349e6331047bbcf0149fab6efa.png](https://i-blog.csdnimg.cn/blog_migrate/434ed5576293d8b386c932771b01e2ff.jpeg)
Mem read和Mem write决定当前指令对存储器是读操作还是写操作。Mem addr和Mem data决定读写操作的地址和数据。以Mem addr为例,HCL描述如下:
int mem_addr = [
icode in { IRMMOVL, IPUSHL, ICALL, IMRMOVL } : valE;
icode in { IPOPL, IRET } : valA;
# Other instructions don't need address
];
更新PC阶段:
![bbca1ca2f4df270cc8b8aaada756a90b.png](https://i-blog.csdnimg.cn/blog_migrate/8623494be32823d8c5812391ad0d2cf1.png)
新的PC值来源可以从valC、valM和valP中选择,New PC的HCL描述如下:
int new_pc = [
# Call. Use instruction constant
icode == ICALL : valC;
# Taken branch. Use instruction constant
icode == IJXX && Cnd : valC;
# Completion of RET instruction. Use value from stack
icode == IRET : valM;
# Default. Use incremented PC
1 : valP;
];
流水线的一般原则
到此为止,我们的前奏刚刚落幕,终于要步入正题了。(这个前奏的确有点长,哈哈。)
在“SEQ的状态改变周期”中埋下了一个伏笔,现在我们来揭开谜底。由于SEQ的时钟频率太低,我们需要想些办法来提高时钟频率。通常可以想到两种途径,一是缩短每条指令的执行时间,二是让多条指令同时执行。方法一不可行,因为每条指令的执行时间很难压缩,这是由电路的固有性质决定的。因此只能采用方法二,即流水线技术。
先来用一个形象的比喻来形容流水线技术。有一种带传送带的自助餐厅,食物摆在传送带上经过顾客,顾客可以随意取走自己喜欢的食品。如果我们把一盘食物当做一条指令,而传送带两旁的顾客当做指令执行的各个阶段,那么SEQ的实现就相当于每次只往传送带上放一盘食物,当这盘食物走到传送带尽头后再放下一盘食物,如果餐馆真这么做的话顾客恐怕都要饿死了。实际情况是,食物一盘接一盘地放在传送带上,每个顾客送走这一盘食物马上迎来下一盘食物,效率大大提高。
处理器架构的流水线技术也是这样,每个阶段都有一条指令正在执行,6个阶段就会有6条指令同时执行,将吞吐量提高为SEQ时的6倍。这样是不是感觉非常给力呢,不过,事情远没有想象中那么简单,最直接的问题是多个指令间会不会相互干扰?
我们回顾一下SEQ的硬件结构图,不同阶段间经常有跨阶段的连线,比如取指阶段得到的valC直接连接到了更新PC阶段的New PC。这在流水线情况下会出问题,因为后面的指令会覆盖前面指令产生的valC,因此,当先前的指令到达更新PC阶段再回头取valC的值时,已经不是当初自己在译码阶段生成的值了。怎么办呢?
解决方案也很容易想到,把每条指令后面有可能用到的值都保存下来不就行了。相当于每个阶段多加一套寄存器,在阶段开始时将这些寄存器的值更新为当前指令配套的值。在流水线技术中,这些插入到各个阶段间的寄存器称为流水线寄存器。
现在我们的处理器架构更新为PIPE-(Pipeline-,减号表示非最终版本),如下图所示。
![c4a92bb865b67b371e40b926c689293a.png](https://i-blog.csdnimg.cn/blog_migrate/7c2132b75e7d1b874b0ff37f962f64e6.jpeg)
与SEQ相比有两处变化,一是将更新PC阶段和取指阶段放在了一起,在取指之前更新PC;二是每两个阶段间插入了流水线寄存器。这些流水线寄存器是基于时钟更新的,每个时钟周期的开始将会更新这些寄存器中的数据,相当于把当前指令的状态传递到了下一个阶段。
流水线冒险
现在大功告成了吗?还没有。当我们仔细分析PIPE-的时候我们会发现仍然存在一些问题。虽然流水线寄存器隔离了各个指令之间的数据共享,但是多个指令之间仍然存在依赖,包括两个方面:
数据依赖:前一条指令写入的寄存器或存储器正好是后一条指令需要读取的寄存器或存储器。在PIPE-中,当后一条指令在译码阶段读寄存器的时候,前一条指令才刚刚到执行阶段,因此新值还没有写入寄存器,如果此时后一条指令直接读寄存器的话,读到的是旧值,这就违反了代码顺序执行的规则。
控制依赖:当一条指令是jump、call或return时,下一条指令的地址是无法提前确定的,它依赖于当前指令的执行结果。因此流水线很可能需要中断。
这些依赖可能导致流水线产生计算错误,这种现象称为流水线冒险。我们先来考虑数据冒险。下图画出了一段代码的分阶段执行过程。
![97c990faab4c668c9ee18e7c2cee8268.png](https://i-blog.csdnimg.cn/blog_migrate/61cc53f0af48c4c220ea0f0a9aa7872f.jpeg)
irmovl $3, %eax
和addl %edx, %eax
之间插入了三个空指令。这样的话,前者执行完写回阶段,后者才开始执行译码阶段,保证了读取寄存器前已经写入完毕。不发生数据冒险。
再看下图。
![2f64337659cc3c3d06bd5c4332a3efcb.png](https://i-blog.csdnimg.cn/blog_migrate/a4a4b6d4a3233ec434443a1fb69ff8ca.jpeg)
现在去掉了一个空指令,情况立马恶化。指令0x006的写回阶段和指令0x00e的译码阶段同时发生,但由于写回寄存器的操作直到第7周期的开始才会生效,因此译码阶段读出的值仍然是旧值,出现数据冒险现象。
如果把剩下的两个空指令也去掉,结果可想而知,肯定会发生更严重的数据冒险,我们在此不再验证。接下来考虑如何避免数据冒险。
仍然有两种解决方案:
暂停:与插入nop空指令类似,处理器自动向可能发生数据冒险的代码间插入bubble,使当前正在执行的指令暂停一个时钟周期。
![a80e8a2410d6427d7eefcb37294d13d4.png](https://i-blog.csdnimg.cn/blog_migrate/81facfbbfbe25cb2ee82a562ba2410e4.jpeg)
如上图所示,当addl指令执行到译码阶段时,检测到将会发生数据冒险,于是插入一个bubble,addl指令在译码阶段重复一个时钟周期。
如果把所有nop都去掉,仍然可以用插入bubble的方法解决数据冒险,只不过需要插入多个bubble而已,如下图所示。
![f3cfba98975b76e0170a72f6900d2122.png](https://i-blog.csdnimg.cn/blog_migrate/cb291f26083cac99541e583a1a8c6dd8.jpeg)
转发:暂停有一点很不好,它会降低程序执行效率,因为加入了很多无用的指令,纯粹在浪费时间。而转发可以更充分地利用每一个周期的时间。
仍然以刚才的代码段为例讲解转发如何起作用。
![e7490ae8f868e526902a9d49cfeb48fc.png](https://i-blog.csdnimg.cn/blog_migrate/56cab30f7111b2e5701cf51056031d08.jpeg)
如图,当addl到译码阶段的时候,irmovl到写回阶段,由于还没有写入寄存器,因此读取数据时发生数据冒险。不过,我们可以用一个巧妙的方法避免这个冒险。既然写回阶段需要等到下个周期开始才能写入寄存器,那不如直接把要写入的值转发给译码阶段,这样的话译码阶段也不需要再从寄存器读了,直接拿转发来的值用就行了。
接下来,如果是prog3代码段呢?
![b4ea10fc5f6af5630cb1b902fbcd916a.png](https://i-blog.csdnimg.cn/blog_migrate/ffb61408a2c290629c11b6a7c936024f.jpeg)
prog3和prog2的区别在于少了一个nop指令,这就导致当addl到译码阶段的时候irmovl指令才到访存阶段。不过似乎对转发并没有影响,因为irmovl指令并不操作内存,在下一个阶段将要写入寄存器的值现在已经产生了,就是M_valE(需要注解一下,M_valE的意思是M阶段的流水线寄存器中保存的valE的值,请查看前面的PIPE-硬件结构图),所以直接把M_valE转发给译码阶段就行了。
再接下来,如果是prog4代码段呢?
![e5dfb5dd98159367d8c98581fbda75c0.png](https://i-blog.csdnimg.cn/blog_migrate/e8588838f13519753a8171de0c17ad53.jpeg)
现在,一个nop指令都没有了,irmovl后面紧跟着addl,当addl到译码阶段的时候irmovl才到执行阶段。可是令人惊讶的是,仍然可以转发。首先,我们可以发现最后需要的寄存器的值就是在执行阶段经过计算得出的。其次,我们要考虑到执行阶段得出结果需要一定时间,这个时间会不会导致不能按时转发到译码阶段呢?答案是否定的。因为译码阶段即使很早拿到这个值,也会等到下一个周期开始才把它写入执行阶段的流水线寄存器。因此只要在下个周期开始之前计算出这个值就可以了,而这个条件是永远都能得到满足的。
有没有感觉到很神奇呢?竟然可以用转发在不降低程序效率的条件下解决数据冒险问题,简直太棒了。可是任何事情都不是完美的,刚才的例子只是irmovl后面跟addl且两者使用同一个寄存器,而实际程序有非常多种可能的组合,是不是转发可以解决所有的问题?我们看下面这个例子。
![c78287914989aa3f56a2f034abb2ed1b.png](https://i-blog.csdnimg.cn/blog_migrate/5d2963564e6b75c1cd8b2da25c9a7082.jpeg)
prog5代码段的0x018和0x01e两行代码称为加载/使用数据冒险,mrmovl将数据从存储器加载到寄存器%eax,然后紧接着addl使用寄存器%eax的值。仍然用转发,将mrmovl执行阶段的值转发给addl,却得到了错误的结果。其实原因很容易想到,因为mrmovl指令需要到访存阶段才能获取到正确的值并赋值给%eax,因此再从执行阶段转发到译码阶段已经完全不可行了。如何解决这个问题呢?我们可以把暂停和转发两种方式结合起来,先暂停一个周期,然后mrmovl到了访存阶段就可以把值正确地转发给addl了。
好了,解决了这么多问题,终于可以给出我们的最终版硬件结构图了。
![df8febf30b078c088aeedeaf6b8578f2.png](https://i-blog.csdnimg.cn/blog_migrate/afa1e8b4e501c5e96d4289cddbccf521.jpeg)
比PIPE-增加的内容就是为了解决数据冒险问题而增加的转发电路,转发的接收方基本都在译码阶段。
更完善的设计
任何事情都讲究完美,我们现在得到的PIPE其实还不够完美,有些关键细节没有考虑到。
异常处理:处理器非常重要的一个方面就是异常处理。很多指令执行过程中都可能发生各种各样的异常,比如访问存储器时无效的地址、无效的指令的编码等等。当程序发生异常时,应该立即中止程序,从外面来看的效果应该是正好停在异常发生的位置:即前面的代码已经完全执行,而后面的代码完全没有执行。看起来很简单的事情在PIPE中并不那么容易实现,因为流水线中有多个指令同时执行,如果某个指令在某个阶段发生了异常,此时很可能后面的代码已经执行了一部分,要想得到完全没执行的效果,就要消除掉已经产生的影响,这需要加强控制逻辑的功能。
控制冒险:上一节流水线冒险中我们提到了控制依赖,它会导致控制冒险。当执行到条件跳转指令时,需要做分支预测,一旦预测错误,就需要消除已经执行的若干条指令,重新执行正确分支的指令。当执行到子函数返回指令时,需要从存储器中取出返回地址,因此下一条指令直到访存阶段才能开始执行。这些特殊情况都需要我们特殊考虑,并在控制逻辑中实现。
如果详细讲解这两部分的具体实现,又会花很多篇幅,有兴趣的朋友可以访问这本书的官网进一步了解。
与真实指令集架构的差距
本文讲述了Y86指令集架构的设计过程,虽然叙述已经足够粗略,可还是写了这么长的篇幅。然而如果与真实的指令集架构(比如x86)的复杂度相比那又真是小巫见大巫了。我们只规定了一个非常简单的指令集,并完成了一个简易的实现。而真实的指令集会包含非常多的指令,包括一些多周期的指令,比如浮点数运算指令,这些指令无法在一个周期内完成,因此需要一些额外的硬件单元的支持。Y86中的存储器被我们看做是理想的存储单元,我们认为数据的存取操作都可以在一个时钟周期内完成。然而CPU速率与内存速率其实相差上千倍,通常需要多级缓存构成一个复杂的存储器层次结构才能加快存取效率。现代处理器还采用了多发射和乱序执行技术,已经不是Y86中所描述的一个阶段一个阶段地执行了,而是多条指令同时执行,而且与它们在代码中的先后顺序无关。近些年,处理器向多核方向发展,多个核具有更强的处理能力,也使指令在代码级别的并行执行成为潮流。今后,处理器会采用哪些新技术我们无从得知,但一定会变得越来越复杂。不过万变不离其宗,理解了处理器和指令集的基本原理,我们可以看透一切,再复杂的系统也是从基本形式一步步扩展得到的,把握核心才是最关键的。