处理器指令编码可重定义的方法_深入理解计算机系统——处理器体系结构

处理器体系结构

67d5e5cc34a9708b991172913e5d01d6.png

简介

首先,本章不是《处理器制造从入门到精通》,也不是《沙子到i9-10980XE的过程详解》,而是旨在介绍处理器指令以及处理器硬件设计,并希望借此可以更加深刻地理解计算机系统。

一个处理器支持的指令和指令的字节级编码称为它的指令集体系结构(ISA)。一般来说,现代处理器并不是一次处理一个指令,而是把一个指令分割成多个小的执行序列,交叉执行,并使用流水线执行法来提高效率。比方说,一个把内存地值复制到寄存器并+1的指令,可以分解成从内存读数据,复制到寄存器,+1,保存到寄存器这四步。而此时又有别的指令也需要类似的步骤,那么可以把它们都取内存的部分放到一块执行,都复制到寄存器的部分放到一块执行,这样就可以更加高效。而有时,某个指令的操作数依赖于另一条指令的执行结果,这叫冲突。

在此,定义一个仅供学习使用的简单指令集,既然已经有了X86-64,那就叫它Y86-64吧!

Y86-64指令集体系结构

程序员可见的状态

Y86-64程序的每条指令都会读取或修改处理器状态的某些部分。这称为“程序员可见的”,这里程序员除了可以是程序猿,也可以是编译器。在处理器实现中,只要能保证机器级程序能够访问程序员可见状态,就可以不按照ISA暗示的方式来表示和组织这个处理器状态。

a8c3872ccf31fbb848ea3dfb926a008f.png

程序寄存器

Y86-64它有15个寄存器,除了X86-64的%r15之外它都有。每个寄存器都存储64位的字,除了%rsp作为栈指针被入栈,出栈,调用和返回指令使用外。其他的指令没有限制。

条件码

Y86-64有三个1位的条件码:ZF,SF,OF,它们和X86-64意义一样,保存着最近的算术或逻辑运算的影响。

程序状态

表示程序整体的状态,它会指示程序是否在正常运行,还是出了异常。

内存

处理器和操作系统搭配完成的虚拟地址系统把内存抽象成了很大的数组。

程序计数器

同X86-64结构一样,指出了下一条指令的地址。

Y86-64指令

Y86-64指令更像是X86-64指令的一个子集,而且由于其操作数都是8字节,所以没有相关的字节大小后缀。而且,在这里,把数据称为且字是8字节不是X86-64的2字节

edefc247d7392689aa8e76785632c6f1.png

OPq指令系列是6+fn,fn是附加值,所以整数操作指令都是60, 61, 62, 63。

c802607355310d7235215d862700f0db.png

来看一下区别: - 在这里,不使用mov指令,而是使用四个变种指令,且限制了操作类型:irmovq, rrmovq, mrmovq, rmmovq,前缀的imr分别指明了源操作数和目的操作数的类型。i是立即数,r是寄存器,m是内存。同时,在这里,对于地址操作,仅支持基址和偏移量的形式。

  • 有4个整数操作指令。对应表中的OPq,分别是addq, subq, andq, xorq。它们只对寄存器数据操作,同时还会设置条件码。
  • 这里有7个跳转指令,就是图中的jXX。根据条件码来设置分支条件并进行跳转,分支条件和X86-64一样。
  • 有6个条件传送指令,图示的cmovXX,这些指令格式和寄存器-寄存器传送指令一样,但是只有当条件码满足所需要的约束时,才会更新目的寄存器的值。
  • call指令和ret指令同X86-64一致,pushq和popq亦是如此
  • halt指令终止程序运行,并将状态码设置为HLT。

指令编码

每条指令的第一个字节表明指令的类型,这个字节被分成了两个部分,高4位是代码,低4位是功能(注意哈!由于16进制里,一位等于二进制四位,所以字节序列(16进制)就是一位,这点记得能看懂),功能值只有在一类指令里才会只用(所以有的指令编译成字节序列后,后四位是0,就代表它自成一类)。

21fec8c0044e2cb1b6c00c78acf8b02a.png

如上图所示,Y86-64只有15个寄存器,前面的数字代表寄存器编号,这和X86-64一致。程序寄存器,保存在一个称为寄存器文件的东西里,它有些像数组,而寄存器编号就是下标,所以这个寄存器文件就是一个小的,以寄存器ID为索引的随机访问器。在这里,访问ID为0xF的寄存器,就是指明不需要访问寄存器。

有些指令只有一个字节长,而有些就更长,因为需要寄存器地址来指定一个或两个寄存器,对应上述表格里的rA和rB。而有些指令没有操作数,比如call和分支指令,那些只需要一个寄存器的指令,就把另一个寄存器设为0xF。还有些指令需要一个8字节常数,比如作为rmmovq和mrmovq的地址指示符的偏移量和irmovq的立即数数据,以及分支指令和调用指令的目的地址。对应上图中的V, D和Dest。

注意,分支指令和调用指令使用的地址是绝对地址,而不是X86-64的相对地址。

同时在这里,使用小端法编码整数。

指令集的一个重要的特性就是字节编码必须有唯一的解释。任意一个字节序列要么是一个合法的,唯一的指令的序列的编码,要么就是一个非法的编码。只要从序列的第一个字节开始处理,我们仍然可以很容易地确定指令序列,反之,如果不知道一段代码的起始位置,那么就无法正确地把字节序列划分单独的指令。

RISC(复杂指令集计算机)和CISC(精简指令集计算机)指令集

前者是X86-64指令集,它的指令集更加复杂,更多,也可以处理更加复杂的操作;后者相对精简,且能进行的操作更少,但是这些少量代码执行起来往往更加高效,ARM便是精简指令集。

Y86-64异常

来看一下常见的Y86-64的程序状态。

3c4a0a9206f9fc4dfdd0836a07d521c9.png

第一个是正常操作;第二个是遇到程序执行halt指令;第三个是遇到非法地址,最后一个是遇到非法指令。

Y86-64程序

见书。

在这里,实现一个指令集模拟器,称为YIS(Y86-64 Instruction Simulation)。它的目的是模拟Y86-64机器代码程序的执行,而不试图去模拟任何具体处理器实现的行为。

来看一份对照表:

306bc1d090791591183ce6f01f12c2ec.png

Y86-64细节问题

pushq

如果把%rsp入栈,会发生什么?因为要知道,pushq指令有两步:先把%rsp-8,再把值放入新的%rsp指向的位置,所以在这里放入的是%rsp还是%rsp-8得值呢?

经过测试是%rsp的值,不过在不同的机器上可能不同,现代处理器大多是这个结果。

popq

同样,弹出一个值也有两个操作:把栈顶的值放入寄存器,%rsp+8;那么如果目标寄存器就是%rsp会发生什么呢?经过测试可以发现此时确实把值放入到%rsp里了,但是没有把其+8。同样,这取决于机器。

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

HCL语言用于描述不同处理器设计的控制逻辑。它可能和常见的语言有点像,比方说,C语言。

逻辑门

在这里介绍三个逻辑门:

cd101f987fb33829738cec7121dc52db.png

如上所示的三个门,分别是与,或,非。

逻辑门总是活动的,一旦某个门的输入变化了,输出就会发生变化,虽然这可能有一丢丢的延迟。

组合电路和HCL布尔表达式

将很多的逻辑门进行组合,就能构成计算块,称为组合电路。不过如何构架这些网络有一些条件限制:

限制

  • 每个逻辑门的输入必须是这三个选项之一:一个系统输入(主输入),一个存储器单元的输出,某个逻辑门的输出。
  • 两个或多个逻辑门的输出不能连接在一起,否则会造成信号矛盾,可能会导致一个不合法的电压或电路故障。
  • 这个网必须是无环的。

来看看HCL语言与C语言在表达计算上的区别:

  • C语言的表达式只有在需要时才会求值,而组合电路会在输入改变后,延迟一定时间后,产生对应的输出变化。
  • C语言允许逻辑表达式的参数是任何整数,而逻辑门仅仅允许0和1。
  • C语言表达式如果由多个部分组成,当某个部分为真时,后面的表达不会再求值。而组合逻辑允许逻辑门响应所有的变化。

字级的组合电路和HCL整数表达式

将逻辑门组成巨大的网,可以构造出能计算更加复杂函数的组合电路,基于此,可以实现对字级(8字节)进行操作的电路。

在HCL中,将所有字级的信号都声明为int,不指定其大小。

来看一个实例吧!

e2bddbfafefe15b46152aac855a5439c.png

组合电路还可以在位级上实现多路复用以实现对于输入的两个数A和B,根据信号s来决定输出哪个。

72c72b572e1775667d717b093e6f6f48.png

在HCL中,多路复用函数是用情况表达式来描述的。情况表达式的通用格式如下:

[
select1 : expr1;
select2 : expr2;
select3 : expr3;
.
.
.
selectN : exprN;
];

对于表达式来说,它有点像C语言的switch,不过有一点不同就是,如果它某一步求值结果为真,就不会继续下去了,否则会顺序进行下去。

允许不互斥的选择表达式使得HCL代码的可读性更好,实际的硬件多路复用器的信号必须互斥。

来看一个可能的实例:

74c5b50afa03bde67b68edab6acd25da.png

对应的表达式如下:

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

表达式可以简化,如上所述。

基于多路复用,可以实现ALU(算术/逻辑单元)。

b382ba235cc04d42bf1ede0d68c623b1.png

集合关系

对于算术表达式运算,可能会使用集合运算而不是表达式运算,比如:

bool s1 = (code == 2 || code == 3);

bool s0 = (code == 1 || code == 3);

对应的集合运算如下:

bool s1 = code in {2, 3};

bool s0 = code in {1, 3};

判断集合关系的通用格式是:

$ iexptr $ in $ left { iexptr_1, iexptr_2, iexptr_3, cdots, iexptr_k right } $

储存器和时钟

一般来说,组合电路仅仅响应输出,它们没法储存信息。为了产生时序电路,也就是有状态并且在这个状态上进行计算的系统,我们必须引入按位存储信息的设备,这些存储设备不止一个,且都是由同一个时钟控制。时钟是一个周期性信号,决定什么时候要把新值加载到设备中。当前在使用的由两类存储设备:

  • 时钟寄存器。存储单个位或字,时钟信号控制它来加载输入值。
  • 随机访问存储器(RAM)。存储多个字,用地址来选择该读或该写哪个字。它由两部分组成,一是主存(物理内存,当然现代计算机都是虚拟内存);另一个是寄存器文件,也就是程序寄存器。

在这里,出现了两个寄存器,作为区分,称时钟寄存器为硬件寄存器,寄存器文件的寄存器为程序寄存器。硬件寄存器直接把它的输入输出线连接到组合电路的其他部分。程序寄存器供程序使用寄存器ID对寄存器文件进行操作。

时钟寄存器

也是硬件寄存器,来看看它是怎么工作的。一般来讲,硬件寄存器保持某一状态X不变,它的输出也是这一状态。产生的信号沿着寄存器前面的组合逻辑传播;此时产生了一个新的寄存器输入,在这里用Y表示,但是此时由于时钟是低电位的,所以这个输入并没有被加载到这个寄存器里。当时钟变成高电位时Y才会被加载到这个硬件寄存器里。在下一次时钟上沿(从低电位到高电位)到来时,此寄存器一直保持Y输出

fb8cbb3e7219f57532cccf254d694a60.png

RAM和程序寄存器

在这里来看看第二类存储设备。

寄存器文件操作

寄存器文件有三个端口,两个读取端口,一个写端口,每个端口除了一个值接口外,都还有一个地址接口,如图:

d9825502c4073a790d1e471c80838e8e.png

W端口进行写入,valW是写入的值,dstW是目标寄存器的地址。A和B都是读取端口,srcA是当程序在A端口操作时,想要读取的寄存器地址,srcB是在B端口读取时,想要读取的地址;而valA和valB分别是读取操作得到的值。比方说srcA=%rdx,那么valA就是寄存器%rdx的值,dstW=%rax,那么valW就是打算写入到寄存器%rax的值。

向寄存器文件写数据有点像时钟寄存器,因为它也是时钟控制的,只有当时钟上升时才会真的写入。读取并不受此控制。当即在读又在写时,会看到值得变化。在设计处理器时,需要考虑这个微妙的属性。

RAM操作

另一个第二类存储设备就是主存RAM。它和寄存器文件有点像:

af95e96ec3e7d21455a328c4fabc5f3b.png

同寄存器文件,在输入的address上设置一个地址,write置为0,那么过会(内存读取一般是纳秒-微秒之间的延迟,CPU和寄存器是纳秒,HDD是十几到几十毫秒,SSD是毫秒,具体时延请移步这里)就会看到需要的值出现在了data out上。而此时如果把write置为1,在data in上放置想要写入的值,设置address,然后等待时钟上升,就可以完成对内存指定位置的写入。内存的写入也是依赖于时钟控制的。当发生了错误,error就会被置为1。

Y86-64的顺序实现

现代处理器是流水线和乱序执行的处理方式,这也是Y86-64的最终目标,不过,跑之前先学会走还是很重要的,所以在此,先来了解一下顺序执行,再考虑执行流水线执行。

在这里,把这种顺序执行法称为SEQ处理器。SEQ可以保证在一个时钟周期内完整的执行完一个指令,所以时钟频率会变得低得无法接受,但是无妨,在这里这么做的目的在于了解和学习,而不是性能。

将处理组织成阶段

一般来说,一个指令通常包含很多的操作,可以把它们划分成多个指令序列,虽然不同的指令之间相差巨大,但是还是存在一种通用的结构,可以适用于所有的指令。所以设计执行抽象的目的就是,抽象出这样的一种框架,然后更好地充分利用处理器硬件。

这个框架大致可以分为以下6个步骤来运行:

取指(Fetch)

此阶段从指令内存(就是普通内存,只是只读而已)中取出指令字节,取指的地址就是PC的值。此时会取出10个字节长度的指令序列(但是!!!不一定会用10个字节,因为Y86-64架构的指令的最长为10字节,所以先取这么多,用多少再看),解析出指令部分,也就是第一个字节,8位,前4位是icode(指令代码),后4位是ifun(功能代码)。除此之外,还可能取出两个寄存器,或常数。这取决于指令定义,详见后述和前表。

译码(Decode)

在这个阶段,机器从寄存器文件读取两个寄存器值。得到valA和valB或者valA和NA,当寄存器ID为0xF时,为后者。当然,有些指令使用的不是rA也不是rB,而是%rsp。

执行(Execute)

此时涉及到ALU的参与。执行包括基本的算术运算,逻辑运算,计算内存引用地址,增减栈指针等。在这里得到了计算结果valE,在此还会设置条件码。同时对于传送指令来说,这个阶段还会根据跳转条件和条件码设置跳转状态,以此来决定是否进行跳转。

访存(Memory)

此阶段可以把值写入到内存,也可以从中获取值,此时获取到的值为valM。

写回(Write Back)

此阶段最多可以把两个结果写回到寄存器文件。

更新(PC Update)

把PC更新成下一条指令的地址。

以上就是基本的执行了,处理器会无限循环执行这6个步骤,除非被终止。

一条指令的执行涉及许多阶段,但是好在它们能抽象成相似的过程。在设计硬件时,一个简单而一致的结构是非常重要的。降低复杂度的一种方法是让不同的指令共享尽可能多的硬件。

设计的难点之一就是如何把各个指令映射到上面的框架中,好在设计的足够巧妙,可以实现这一目标。

来分析一下各个指令的执行。

3c4286c6c691616ce46f0778e51cdfc6.png

先来看看OPq,它是整数操作通用模式:

  • 在取指阶段,由于不需要常数,所以valP仅仅是PC+2即可。
  • 在译码阶段,需要读两个操作数。
  • 在执行阶段,需要把ifun提供给ALU,获得valE并设置条件码。
  • 访存阶段啥也不做。
  • 写回阶段,valE写回到rB。
  • PC更新阶段,PC设为valP的值。

再来看看rrmovq指令:

  • 在取指阶段,依旧是因为不需要常数,valP=PC+2。
  • 在译码阶段,读取rA操作数的值。
  • 在执行阶段,把ALU设置为进行加法,然后把ALU输入之一设为0,另一个设置为valA就能得到valE=valA。
  • 访存阶段,没有操作。
  • 写回阶段,把valE写回到rB。
  • PC更新阶段,PC=valP。

最后就是irmovq指令:

  • 在取指阶段,需要常数,valP=PC+2+8=PC+10,和常数作为地址的内存中的值到valC中。
  • 在译码阶段,无操作。
  • 在执行阶段,把ALU设置为进行加法,然后把ALU输入之一设为0,另一个设置为valC就能得到valE=valC。
  • 访存阶段,没有操作。
  • 写回阶段,把valE写回到rB。
  • PC更新阶段,PC=valP。

e4813b8d87e0d88989a02429104160ef.png

现在探讨rmmovq和mrmovq指令:

  • 基本步骤和前面一样,不过这里使用了ALU来计算有效内存地址。

7b6cdde47aa4681163a12ec1056614d6.png

这里是栈指针操作指令,它们两个的实现略有复杂,在具体论述之前,要了解一下Y86-64架构遵循的原则。那就是处理器从不会为了完成某一条指令而回读由该指令更新了的状态。在这里就是,对于栈指针的增减操作应该在设置内存值或读取内存值之后。这和X86-64一致。

来看看具体的操作:

  • 取指阶段,计算valP=PC+2。并且读取rA寄存器的值。
  • 译码阶段,在pushq指令里,valA保存寄存器rA的值,valB保存寄存器%rsp的值。而在popq里面valA=valB=%rsp,这么做的原因是为了保持一致性。
  • 执行阶段,使用ALU计算新的栈指针地址并存储在valE中。
  • 访存阶段,pushq指令把rA的值写到内存,popq把内存中的值写到valM。
  • 写回阶段,pushq指令使用valE更新栈指针,popq在此基础上把valM写入到rA中。
  • PC更新阶段,令PC=valP即可。

2759c121a0372f2ae7cfa8a5bd032afb.png

还剩下跳转指令和返回指令,在这里来看看:

  • 对于跳转指令,会检查跳转条件是否符合,然后来进行决定下一条指令的位置。
  • 对于call指令,注意在访存阶段是写入返回地址。而ret就像popq指令似的,在此阶段读取返回地址并使用。

虽然指令的行为大不相同,但是我们可以将指令的处理组织成6个阶段,现在有了这些详细的步骤,就是轮到设计相关的硬件来实现这些阶段的时候了。

SEQ硬件结构

先来看一个粗略结构:

简略结构

4cbcdb8891038000d81381c80f5eabb1.png

此图中的全部硬件单元的处理,都在一个时钟周期内完成。实现所有Y86-64指令所需的计算可以被组织成6个基本阶段:取指,译码,执行,访存,写回,更新PC。

  • 取指阶段,将程序计数器寄存器的值作为寻址地址,在指令内存里读取指令字节。PC增加器计算valP。
  • 译码阶段,寄存器文件有两个读端口A和B,从这两个端口同时读寄存器值valA和valB。
  • 执行阶段,会根据指令的类型,将ALU用于不同的目的。当然了,也可以单纯的+0将输入用作输出。条件码寄存器有三个条件码位,ALU负责计算新的条件码的值。当执行条件传送指令时,根据条件码和传送条件来计算决定是否更新目标寄存器。同样当执行一条跳转指令时,会根据条件码和跳转类型来计算分支信号Cnd。
  • 访存阶段,访问数据内存,进行读取或写入。
  • 写回阶段,寄存器文件由两个写端口,E端口写入ALU计算出来的值。而M端口写入从数据内存中读到的值。
  • PC更新阶段,使用valP(下一条指令的地址),valM(返回地址),valC(跳转地址)之一来进行更新。

详细结构

49f336a6c5c0d91026df9a33afb8dbb8.png

来看一下详细的介绍:

  • 白色方框表示时钟寄存器,程序计数器PC是唯一的时钟寄存器。
  • 浅蓝色方框表示硬件单元。包括内存,ALU等。
  • 灰色圆角矩形表示控制逻辑块,它们用于从一组信号源中进行选择,或用来计算一些布尔函数。
  • 白色圆圈指明线路的名字。
  • 中等长度的线表示宽度为字长(8字节)的数据连接。
  • 细线表示宽度为字节或更窄的数据连接。
  • 虚线表示单个位大小的数据连接。

dstM和dstE是valM和valE写入的地址,并不是值,因为valM和valE已经放在寄存器文件写端口了,就差指明写到哪了。srcA和srcB分别是valA和valB的读地址。

而dstM和dstE的值在写回阶段阶段计算并设置,而访存的操作数地址也是通过计算得到的,一般是valA或valE二选一。

SEQ的时序

一个时钟变化会引发一个经过组合逻辑的流,来执行整个指令。组合逻辑不需要任何时序或控制,因为只要输入变化了,值就可以通过逻辑门网络传播。而时钟用于产生这种变化。

SEQ的实现包括组合逻辑和两种存储设备(时钟寄存器:程序计数器和条件码寄存器;随机访问存储器:RAM和寄存器文件)。

在这里把随机访问存储器看成和组合逻辑一样的操作,根据地址输入产生输出字。而对于较大的电路来说,可以使用特殊的时钟电路来模拟这个效果。由于指令内存只用来读指令,所以可以视为组合逻辑。

组合逻辑阐述完了,存储设备的RAM的只读内存阐述完了,还剩程序计数器,条件码寄存器,数据内存和寄存器文件。现在需要对它们的时序进行明确的控制。每个时钟周期,程序计数器都会装载新的指令的地址。只有执行整数运算时,才会装载条件码寄存器,只有执行rmmovq和pushq或call指令时,才会写入数据内存。寄存器文件允许在一个时钟周期内同时更新两个寄存器。

要控制处理器中活动的时序,只需要寄存器和内存的时钟控制。这样可以让所有状态的更新同时发生,且只在时钟上升开始下一个周期时。

实现这样的操作的关键是一个原则,前面已经阐述,就是从不回读原则,处理器从来不需要为了完成一条指令的执行而去读由这个指令更新了的状态

9fad630e3895c586d73881f76793a206.png

SEQ阶段的实现

先来看看规定的实现常数:

ced1054f03e57e90072c85167296eab7.png

nop只是简单的把PC+1。

现在来看看各个阶段的执行吧!

取指阶段

bb4ff997b3e4c93c94adb3b007e30f11.png

在这个阶段,把PC指示的位置作为取值的第一字节位置(标号为0字节),取出10字节序列,第一个字节被解释为指令字节,图示的Split单元,然后把这个字节解析成icode和ifun两个部分。以此来确定剩余部分的解析,也就是对于Align部分的解析。

如果指令不合法,会设置imem_error信号,然后根据icode的值设置instr_valid(是合法的Y86-64指令嘛),need_regids(包括寄存器指示符字节嘛),need_valC(需要常数嘛)这三个信号值。

在PC读取指令后,要使用PC增加器进行计算下一条指令的地址。

译码和写回阶段

把这两个阶段放在一起是因为它们都需要访问寄存器文件。

ceed9f7cce8d9708cb4b793cb865fcec.png

寄存器文件有四个端口,每个端口都有一个地址连接和数据连接,地址连接是一个寄存器ID,数据连接是一组64根线路,既可以作为寄存器文件的输出字,也可以作为输入字。

注意到寄存器文件有两个写端口M和E,那么在这两个端口同时设置写同一个寄存器,会发生什么?此时需要设定M和E的优先级,优先级高的才可以写,经过实践发现,M的优先级更高一些。

执行阶段

6cd4864bcb041ac21e8ed13d648b8e38.png

执行阶段需要ALU单元的参与,这个单元根据ALUfun的值,对输入ALUA和ALUB进行加,减,且,异或操作。根据指令的类型,ALUA可以是valA,valC,或者是-8和+8。

每次运行时,ALU都会产生三个与条件码相关的信号,不过,如果只希望在执行OPq操作时才设置条件码,可以通过设置set_cc位实现。

标号为cond的硬件单元会根据条件码和功能码来确定是否进行条件分支或者条件数据传送。它产生信号Cnd,用于设置条件传送的dstE。

访存阶段

访存阶段的任务就是读或者写程序数据。

55b46f42002ee2713c0610c710f39a28.png

可以看到内存读出的值放在valM上,内存读和写的地址是valE或valA指示的。而内存写的数据值总是valA或valP。

最后,会根据icode,imem_error,instr_valid的值以及数据内存产生的dmem_error信号来计算状态码Stat。

更新PC阶段

此阶段会从valC,valM和valP这三个数值选取一个来更新PC,至于选取的方法——根据icode和Cnd来判断。

d91f6f9acc2534e133332089dbe7f63f.png

流水线的通用原理

顺序执行就是把一条指令从头到尾全部执行完,再执行下一条指令。所以效率比较低,因为一条指令的执行分为好几个阶段,某时,某条指令执行到后面的阶段,那么它前面的阶段是空闲的;如果可以做到每个阶段都有指令,那么执行效率就会高很多。

计算流水线

f0d227e6037a5c2117903a3db4b51301.png

1ns=1000ps, 1us=1000ns, 1ms=1000us, 1s=1000ms.

非流水线化的程序如第一张图所示,其中通过组合逻辑的时间是300ps,而通过时钟寄存器的时间是20ps。

流水线化的设计,如第二张图所示。

在这里引入吞吐量这个概念。顺序化执行的程序吞吐量为1/(20+300)ps*(1000ps/1ns)=3.12GIPS。

从头到尾执行完一条指令所需的时间称为延时

6c5db8eca2ebd1efafcfa140a0083547.png

这是一张细化的流水线执行图,在这里,把一条原本需要300ps的指令拆分成三个100ps的指令,并添加两个流水线时钟寄存器来保存每个阶段的状态,那么整个指令的延时为120*3=360。而系统吞吐量增加了,为1/(20+100)*(1000ps/1ns)=8.33GIPS。

延迟变大是由于增加了时钟寄存器导致的。理想状态下,每个时钟周期都会有新的指令进入。

流水线操作的详细说明

d4036b17cafbd309ccbb8583c6516dbf.png

每次时钟上升,时钟寄存器读入新的值,搭配组合电路,便构成了处理器。

如果时钟运行得太快,那么可能新的值还没计算出来,时钟寄存器也无法获得新的值,这就会造成严重的错误。通过在不同的组合逻辑之间添加时钟寄存器来实现流水线化并通过时钟周而复始的上升下降来进行值得更新,就可以让不同的阶段互不干扰,协同完成指令的运行。

流水线的局限性

除了增加延时,还有其他的缺点,是流水线化操作需要面对的。

b629d4ec2a0bed49782347f334044bce.png

首先是不一致的划分,指令每个阶段执行所需的时间是不一样的。所以吞吐量取决于最长的那个阶段。因此需要严格的划分指令执行的阶段,这需要合理安排时钟寄存器来实现。

另一方面,由于过深的流水线划分,将大大的增加时延,导致吞吐量并没有显著的增加,边际效益降低。为了提高时钟频率,现代处理器使用很深的流水线(15+),同时需要仔细地设计时钟寄存器,让它们尽可能得快。

带反馈的流水线系统

刚刚的流水线设计,仅仅考虑不同指令之间没有依赖。但是实际的程序却不是这样。

关于指令之间的依赖,可以划分成两种:数据相关控制相关。前者顾名思义,就是后一条指令依赖于前一条指令的数据,比如第一条是写寄存器而后者刚好读这个寄存器。后者便是指令跳转,比方说跳转指令,需要条件计算的结果。

2c80251476c58eb47e57e8d6835ebe1c.png

数据相关,可以通过暂停和转发来解决(见后述)。

046510792011ba79c32886e979f5c093.png

控制相关,可以通过插入气泡来实现指令取消进行处理(见后述)。

在SEQ中,通过反馈路径来解决这样的问题:

dea690f56dde148399a963d817fa3d52.png

但是在流水线化的处理器中,盲目的进行把值反馈给后一条指令,是不可思议的也是不可能的。

在此,必须用某种方式来处理指令之间的数据和控制相关,使之行为和ISA定义的模型相符。

Y86-64的流水线实现

对于真的实现流水线化的处理器,首要一步就是把PC计算阶段挪到前面来。为什么?因为我们需要在当前指令取指完成后立马进行下一条指令的取指,而不能像SEQ那样等它执行完了再进行取指。

SEQ+:重新安排计算阶段

对于PC计算提前的设计,在这里称之为SEQ+。在SEQ+中,创建状态寄存器来保存上一条指令执行过程中所计算出来的信号,这样,当一个新的时钟周期开始时,这些信号可以像SEQ中那样,以相同的逻辑计算新的PC。在这里,使用pIcode,pCnd等等,来指明在任一给定的周期,它们保存的是前一个时钟周期中产生的控制信号。

a62a8fa58d212fafcf7bf4db52157588.png

这种改变是一种很通用的改进的例子,称之为电路重定时。重定时改变了一个系统的状态表示,但是并不改进它的逻辑行为。通常用它来平衡一个流水线系统中各个阶段之间的延迟。

插入流水线寄存器

对SEQ+各个阶段之间插入时钟寄存器,并进行信号重排序,得到PIPE-处理器(流水线的英文是pipeline),之所以是-,因为它和真正的流水线寄存器相比性能差了一点。来看看一张图:

db6b4df4c68ce108fcefd00201558273.png

74e75a197f36126b1cc5a5d4283ecb3c.png

其中,流水线寄存器使用黑色方框表示,每个寄存器包含不同的字段,使用白色方框表示。每个流水线寄存器可以保存多个字节或字。

流水线寄存器使用如下规则进行标号: - F保存程序计数器的预测值。 - D位于取指和译码之间,用于保存刚刚取出的指令的信息,即将交给译码阶段处理。 - E位于译码和执行阶段之间。它保存关于刚刚译码工作的信息,以及从寄存器文件读出的值的信息,即将交给执行阶段进行处理。 - M位于执行和访存阶段之间,保存着最新的执行结果,即将交给访存阶段处理,同时还保存着用来处理条件分支跳转相关的信息。 - W位于访存阶段和反馈路径之间,反馈路径用于把计算出来的值提供给寄存器文件写,而当完成ret指令时,它还需要向PC逻辑选择器提供返回地址(由于对于ret指令的特殊处理,在ret执行完毕前,并不会有其他的指令出现在流水线中,见后述)。

通过对比不难发现,每个时钟寄存器都保存着上一阶段计算出来的值,并提供给当前阶段使用。

27c2a4226dd8607f66e934fc17108299.png

上图就是一个典型的例子,周期1取出指令I1,然后这个指令开始通过流水线的各个阶段,直到周期5结束,将结果写回到寄存器文件;在周期2取出I2,并运行至周期6结束,写回结果,以此类推。

周期5的拆解图表明流水线顺序和流水线硬件图的顺序一样。

对信号进行重新排列和标号

在SEQ和SEQ+里,诸如valC, srcA, valE等信号,有唯一的值,但是在流水线化的设计中,这些值可能存在于多个阶段中,因此需要更好地命名机制。信号命名使用寄存器名首字母大写前缀,指明这个信号是保存着这个寄存器中的,而对于刚刚计算得到的信号,使用阶段名首字母小写前缀。

SEQ+和PIPE-中的译码阶段都产生了dstE和dstM,它们指明valE和valM的目的寄存器。在SEQ+中,可以直接把这些信号连接到寄存器文件,但是在PIPE-中,由于流水线划分的存在,导致信号没法直接被送达,它们会被不停地向前传递,直到到达写回阶段。这么做的目的是为了确保写端口的地址输入和数据输入来自同一条指令。否则可能发生A指令的值写入到B指令指定的地址上(因为dstE和valE不是产生于同一阶段)。

为此,作为一个通用原则,流水线寄存器应该保存在当前阶段运行的指令的全部信息。

PIPE-中,有一个块是SEQ+没有的,那就是Select A模块。这个模块用于把D寄存器里面的valP放到valA里面传递过去。为什么这么做呢?因为为了减少时钟寄存器E和M的状态数量,对比一下就可以发现,所有的Y86-64指令只可能使用valA和valP之一(只有call和不跳转的jXX指令需要valP,而它们刚好不需要valA(寄存器文件读出的值)),所以何尝不把他们并在一起呢?通过合并信号来减少时钟寄存器状态和线路的数量,是一件很常见的事。

预测下一个PC

|指令|周期|Select PC| |:---|:---|:---| |普通指令|0|程序第一条指令的地址(系统给出)| |普通指令|1|程序第二条指令的地址(通过计算valP得出)| |普通指令|2|程序第三条指令的地址(通过计算valP得出)| |普通指令|3|程序第四条指令的地址(通过计算valP得出)| |普通指令|4|程序第五条指令的地址(比较valP, valA得出)| |普通指令|5|程序第五条指令的地址(比较valP, valM, valA(跳转指令不进行跳转时的地址), valC得出) |...|...|...|

如果此时周期已经进行到了第四个周期之后(此时已经可以获得valA)或第五个周期之后(此时可以获得valM),就可以进行在valP(单纯的下一条指令的地址),valC(跳转地址,比如call和jmp指令),valM(返回地址,ret指令),valA(跳转指令条件不成立时的地址,其实就是valP,但是流水线化的操作通过SelectA模块把valP复制到valA中去了)之间进行选择,具体选择方式取决于实际的算法设计。而前三个周期只能通过计算出来的valP进行设置下一个PC值。

假如某时,第一个时钟寄存器放的是某个指令的数据,计算得到的valP是它的下一条指令的地址,而这时第五个时钟寄存器放的是另一条指令的数据,如果这条指令刚好是跳转条件失败的跳转指令的话,理论来说就应该执行它的跳转地址指明的指令,而此时地址放在valA中,所以PC选择器应该选择valA,而不是valP,即使valA保存的也是valP,但不是流水线开头这条指令的valP,此valP非彼valP。valA保存的是过去某个指令的valP,这就是为什么在valP和valA之间也要进行选择的原因。

流水线冒险

前面说过,当指令之间存在相关时,会导致出现问题。这种相关问题包括两种:数据相关和控制相关。有相关性导致的流水线错误称为流水线冒险,冒险分为两种:数据冒险和控制冒险。

来看一个例子:

9b6ba9c03070daadc7680c31f60a710c.png

可以看到addq指令和irmovq指令之间添加了nop指令,为什么这么做呢?因为addq需要irmovq更新的值,如果不进行插入nop进行阻塞addq指令的话,就会导致addq获得错误的值(此时irmovq还没把新的值放到对应的寄存器中)。addq会在周期7进行寄存器的读取。此时就可以读到正确的值了(因为irmovq只有在它的写回阶段才会写回寄存器,所以需要让addq等等)。

47153b9af8fb4e8f114d86705b3623d1.png

这个程序会导致addq读取到正确的%rdx和错误的%rax。

4c6fcf8f2d32de5c60e0d31285ef7474.png

这个程序得到的valA和valB都是错误的值。

612d9874b693877b6f0b59035b932b39.png

这个程序得到的valA和valB也都是错误的值。

数据冒险的类型

数据冒险有很多类型,现在来看看:

程序寄存器

这种冒险在前面的介绍里有,就是后面的指令需要前面的指令对于寄存器更新的值。造成这种情况的原因是,寄存器的读写是在不同阶段进行的。

程序计数器

当在取指阶段读取下一条指令时,如果正确预测,就不会发生冒险,而对于错误的分支和ret指令,需要特殊处理。

内存

为了简单起见,假设程序不使用自我修改代码。这样的话就不会存在内存冒险。因为访存阶段进行内存的读写,所以内存的读写是在同一阶段进行的,这样就不论指令的先后顺序,后面的指令也可以读取到正确的内存数据。

注意到取指阶段可能会从内存中取出常数字,所以可以进行自我修改的代码,还是有可能会有冒险的。

条件码寄存器

条件码寄存器的写,只能发生在执行阶段,而读,只有条件传送指令会在执行阶段读,条件转移会在访存阶段读,所以在这两个指令读之前,条件码寄存器全部更新完毕,因此不存在冒险。

状态寄存器

使用每个指令都与一个条件码相关联的机制,进行记录每条指令的状态。

以上分类表明,只有寄存器数据冒险,控制冒险,以及正确的处理异常是Y86-64架构需要考虑的。在真正的设计时,这些分类考虑是需要严格对待的,因为通过这样,可以设计出一个严谨的,可测试的处理器。

因此,来看看如何处理这些冒险:

用暂停来避免数据冒险

暂停技术是一种很常见的技术,当进行暂停时,处理器会停止流水线中的一条或多条指令,直至冒险条件不再满足。

把一条指令阻塞在译码阶段,直到它所需要的源操作数通过了写回阶段。当阻塞一条指令时,需要在这条指令的后面的阶段,也就是执行阶段插入气泡,气泡啥也不做,单纯的通过各个阶段并不会更新任何寄存器值。就像一个自动产生的nop指令一样。

通过插入气泡的方式来解决冒险问题,简单粗暴,但是性能不行,因为每个气泡都会减少一个时钟周期,严重的降低了性能。

用转发来避免数据冒险

转发,说白了就是不写回,直接把更新好的数据通过专线传给那个需要它的指令。

PIPE-的设计是,在译码阶段读取寄存器文件,然后把读到的值传送到流水线寄存器E作为执行阶段的值;可是寄存器文件的写入,只有在写回阶段才有。那么为何不直接把前面指令准备写回的值直接传送到流水线寄存器E呢?这就是转发。

转发需要额外的数据线路,所以需要稍微修改设计方式,增加一些额外的硬件。不过需要注意的是,如果指令A需要指令B更新的值,且两个指令紧挨着,那还是需要暂停一下的,因为即使是转发也必须等指令B执行到写回阶段指令A未执行到执行阶段这两个条件才行。所以编译器进行优化时可能会错开两个相邻的指令使用同一个寄存器。

同时除了从写回阶段转发到执行阶段,也可以把值从执行阶段转发到译码阶段。

除此之外,也可以转发内存读出来的值给译码阶段。

a0b6bba54172f5729fee8f3f9ad9b536.png

所以有五个转发源,分别是:e_valE, m_valM, M_valE, W_valM, W_valE和两个目的源:valA, valB。

  • 执行阶段(计算出来的值e_valE)->译码阶段
  • 访存阶段(流水线寄存器M的值M_valE)->译码阶段
  • 访存阶段(计算出来的值m_valM)->译码阶段
  • 写回阶段(流水线寄存器W的值W_valE)->译码阶段
  • 写回阶段(流水线寄存器W的值W_valM)->译码阶段,取指阶段

上图还表明,译码阶段应该确认要使用的值是来自寄存器文件还是转发过来的。通过比较目的寄存器ID与源寄存器IDsrcA和srcB来得到选择。同时为了处理多个目的寄存器ID和源寄存器ID相同的情况,需要建立优先级关系。

还可以看到。Fwd A和Fwd B用来处理转发的值。

加载/使用数据冒险

有一类冒险不能通过转发解决,因为内存读在流水线中发生的比较晚,当指令A写入内存时,下一个周期,指令B读取,因为读取和写入内存都是同一阶段,所以如果想使用转发,只能让指令A转发到未来的时间,这明显是不可能的(原书是过去的时间,那是因为它是以指令B读操作为时间轴看待的,此时指令A已经执行完毕,所以对于指令A来说只能在它处于访存阶段时转发到过去)。这个就加载/使用数据冒险。

下图是一个可能的解决方案。

17eeee51e29a6fc7a9c7b61a5ec6408b.png

通过暂停来处理加载/使用数据冒险的方式称为加载互锁。加载互锁和转发技术可以应对所有的数据冒险类型。因为只有加载互锁才会浪费时钟周期,所以实际的流水线效率很高。

避免控制冒险

当无法根据取指阶段的信息来确认下一条指令的地址时,就会出现控制冒险。控制冒险只会发生在ret指令和跳转指令。后一种只有在条件跳转预测错误时才会造成麻烦(默认预测跳转)。

先来看看怎么处理ret指令的,其实很简单,ret需要读取返回地址(访存阶段),所以在它获取返回地址之前流水线里不应该有其他的指令,所以要填充气泡,每个ret会跟着三个气泡来执行。

b0082d82298919bf1ac620f7364949fb.png

在ret获取到返回地址后,把返回地址交给PC选择器进行设置下一个指令的地址。

而对于分支选择指令,会先把跳转目的处的指令执行两个周期,因为此时设置CC的那条指令还没执行到执行阶段,等到它执行到执行阶段,跳转指令就可以读取CC来判断是否进行跳转。

如果进行跳转就什么也不做,因为默认是跳转此时流水线中已经执行了两个时钟周期的指令就是原本打算执行的。但是一旦不进行跳转,就必须通过在流水线插入气泡来进行指令取消。把刚刚已经执行的两个指令取消到,万幸的是,它们还没有执行到会设置程序员可见状态的阶段,因此直接取消也没什么后果。

92005e59ade7961a17e2daf661d4f642.png

异常处理

Y86-64指令体系包含三个不同的内部产生的异常:1. halt指令 2. 有非法指令和功能码组合的指令 3. 取指或数据读写试图访问一个非法地址。

当发现异常时,应该计时中断指令防止对程序状态产生影响。

PIPE各阶段的实现

来看看流水线设计的具体每部分的实现。

PC选择和取指阶段

ad9cefa1ad2a263b602118c8a3c61455.png

这个阶段必须选择程序计数器的当前值,并预测下一个PC。

PC选择逻辑从三个程序计数器源中进行选择: - 当一条预测错误的分支进入访存阶段时,会从流水线寄存器M中读出预测错误的指令地址,就是(M_valA,保存的是该指令的valP的值,此valP非彼valP...见前述); - 当ret指令进入访存阶段,从流水线寄存器W(信号W_valM)中读出返回地址。 - 其他情况使用存放在流水线寄存器F(信号F_predPC)中的值。

HCL表达式如下:

word f_pc = [
M_icode == IJXX && !M_Cnd : M_valA;
W_icode == IRET : W_valM;
1 : F_predPC;
];

当取出的指令为函数调用或跳转时,PC预测逻辑会选择valC,否则会选择valP。

word f_predPC = [
f_icode in { IJXX, ICALL } : f_valC;
1 : f_valP;
];

译码和写回阶段

06ba3c7aa1c9abcbc7e7ed7fb54129fb.png

寄存器写的位置是由来自写阶段的dstE和dstM信号指定的,而不是来自于译码阶段。因为它要写的是当前正在写阶段执行的指令的结果,而不是在译码阶段的指令,他们并不是同一条指令,所以不能自作聪明地更改dstE和dstM的来源。

这个阶段的复杂性主要与转发逻辑有关。来看看和Sel+Fwd A转发相关的控制:

|数据字|寄存器ID|源描述| |:---|:---|:---| |e_valE|e_dstE|ALU输出| |m_valM|M_dstM|内存输出| |M_valE|M_dstE|访存阶段中对端口E未进行的写| |W_valM|W_dstM|写回阶段中对端口M未进行的写| |W_valE|W_dstE|写回阶段中对端口E未进行的写|

如果不满足转发条件,这个块就会选择d_rvalA作为它的输出,也就是从寄存器端口A中读出的值。

综上所述,可以得到流水线寄存器E的valA新值的HCL描述:

word d_valA = [
D_icode in { ICALL, IJXX } : D_valP;
d_srcA == e_dstE : e_valE;
d_srcA == M_dstM : m_valM;
d_srcA == M_dstE : M_valE;
d_srcA == W_dstM : W_valM;
d_srcA == W_dstE : W_valE;
1 : d_rvalA;
];

上述HCL中的优先级是很重要的。如果优先级排列不准确,会造成流水线计算错误。

流水线化的实现应该总是给处于最早流水线阶段中的转发源以较高的优先级,因为它保存着程序序列中设置该寄存器的最近的指令。

当然,不会少了流水线寄存器E的valB的新值的HCL表达式:

(我还没写...这是书上的作业)

还有一个就是状态寄存器,由于流水线寄存器W保存着最近已完成的指令的状态,所以很自然的要用这个值来表示整个处理器的状态。

word Stat = [
W_stat == SBUB : SAOK;
1 : W_stat;
];

执行阶段

1dacfd52df1983f84b2c2550e3a823e6.png

一旦任何一条指令触发了异常,那么对于条件码的更新就会被禁止。所以可以看到,m_stat和W_stat作为信号输入Set CC模块,以此来决定是否进行更新条件码。同时可以看到,信号e_valE和e_dstE作为转发源,指向译码阶段。

访存阶段

95b56a0853ca12c10c3037da3a147c23.png

可以看到很多值被转发到了电路中的其他部分。

流水线控制逻辑

说完了硬件设计的细节,接下来就是流水线的控制逻辑了,在设计控制逻辑时,必须设计好对于特殊情况的处理。一共有4种特殊情况。

4种特殊情况

  • 加载/使用冒险:在一条从内存中读出一个值和一条使用该值的指令之间,流水线必须暂停一个周期。
  • 处理ret:流水线必须暂停直到ret指令到达写回阶段。
  • 预测错误的分支:在分支指令发现不应该选择跳转指令后,那些跳转指令已经进入流水线了,必须取消这些指令并载入正确的跳转指令。
  • 异常:当一条指令遇到异常时,应该禁止后面的指令更新程序员可见状态,并且在异常指令到达写回阶段时,停止执行。

特殊控制情况所期望的处理

  • 对于加载/使用冒险的处理:如果检测到触发条件,则把后一条指令阻塞在译码阶段,并往下一个周期插入一个气泡。通过把某个流水线寄存器固定在某个阶段,即可实现对于指令的阻塞。总之,保持流水线寄存器F和D状态固定不变,并在执行阶段插入气泡即可。
  • 对于ret指令的特殊处理,流水线要停顿3个时钟周期,直到ret指令经过访存阶段,读出返回地址。

看一个实际的程序:

721ec2db99d47ca8328d19b45e50a3d6.png

以及它在处理器中的实际执行:

61e7492deff3858071fced0744894d9d.png

阻塞实际程序中的ret后面那条指令的执行直到读取到返回地址去执行正确的指令。

  • 对于分支预测错误的指令,通过插入气泡来进行取消。
  • 对于导致异常的指令,暂停后面指令的执行,以来保证程序不会修改任何程序员可见状态,同时确保异常指令前面的指令执行完毕。

发现特殊控制条件

来看一张表,整合了可能的触发条件:

|条件|触发条件| |:---|:---| |处理ret|IRET in {D_icode, E_icode, M_icode}| |加载/使用冒险|E_icode in {IMRMOVL, IPOPL} && E_dstM in {d_srcA, d_srcB}| |预测错误的分支|E_icode == IJXX && !e_Cnd| |异常|m_stat in {SADR, SINS, SHLT} || W_stat in {SADR, SINS, SHLT}|

流水线控制机制

26cee6a90016d436cfb673b336ce13d7.png

假设每个流水线寄存器有两个控制输入:暂停和气泡。

正常操作时,二者均为0;当暂停设为0,即使时钟上升沿到达,也不会更新寄存器;当气泡设为0,时钟寄存器的输出等效于nop操作。二者均为1视为出错。

在时序方面,流水线寄存器的暂停和气泡控制信号是由组合逻辑块产生的。

|条件|流水线寄存器F|流水线寄存器D|流水线寄存器E|流水线寄存器M|流水线寄存器W| |:---|:---|:---|:---|:---|:----| |处理ret|暂停|气泡|正常|正常|正常| |加载/使用冒险|暂停|暂停|气泡|正常|正常| |预测错误的分支|正常|气泡|气泡|正常|正常|

控制条件的组合

e8ed98740ff1563b72c43734ac5cff90.png

只有箭头标明的可能会同时出现。

这点没看懂!

控制逻辑的实现

根据来自流水线寄存器和流水线阶段的信号,控制产生流水线寄存器的暂停和气泡控制信号,同时也决定是否要更新条件码寄存器。

ad92253e537d3c0a31e4b94c64fc01eb.png

性能分析

一句话,增加分支跳转预测的准确性,这个是影响性能的很重要的因素。

未完成的工作

详见书

多周期指令

与存储系统的接口

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值