基础

硬件控制语言HCL

硬件描述语言(Hardware Description Language,HDL)可以用来描述硬件结构,是一种文本语言,类似于编程语言,包括Verilog和VHDL。逻辑合成程序(Logic Synthesis)可以根据HDL的描述来生成有效的电路设计。所以从手工设计电路到合成生成的转变,就好像从手写汇编到高级语言的转变。

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

这门课开发了将HCL翻译成Verilog的工具,然后结合基本硬件单元的Verilog代码,就能产生HDL描述,由此就能合成实际能工作的微处理器了。可以参考这里

逻辑设计

当前逻辑1是由1.0伏特左右的高电压表示,逻辑0是由0.0伏特左右的低电压表示。

实现一个数字系统主要有三个组成部分:

  1. 计算对位进行操作的函数的组合逻辑
  2. 存储位的存储器单元
  3. 控制存储器单元更新的时钟信号

组合逻辑

逻辑门是数字电路的基本计算单元。如下图所示

img

其中第一行是布尔表达式,第二行是标准符号,第三行是HCL表达式。我们可以将AND和OR扩展到多个输入的版本。

注意:1. 逻辑门只对单个位的值进行操作。2. 当一个门的输入发生变化时,输出会很快相应变化。

将很多逻辑门组合成一个实现某种功能的网,就能构成计算块(Computational block),称为组合电路(Combinational Circuits)。想要构建有效的组合电路,有以下限制:

  • 每个逻辑门的输入必须连接到以下其中之一:

    • 一个系统输入
    • 某个存储器单元的输出
    • 某个逻辑门的输出
  • 两个或多个逻辑门的输出不能连接在一起

  • 网络不能形成回路

简单的组合电路示例:

例1:

img

HCL表达式:bool eq = (a && b) || (!a && !b);

功能:用来判断输入ab是否相同,结果保存在eq中。

例2:

img

HCL表达式:bool out = (s && a) || (!s && b);

功能:该组合电路称为多路复用器(Multiplexor,MUX), 当s=1时,out的值就是a的值;当s=0时,out值就是b的值。

HCL表达式和C语言逻辑表达式区别:

  • 组合电路中输出会持续响应输入的变化,而C语言只有在程序执行过程中遇到了才进行求值
  • 逻辑门只允许对0和1进行操作,而C语言将0表示为FASLE,将其余任意值表示为TRUE
  • C语言中逻辑表达式存在部分求值的特点(例如,如果一个AND或OR操作的结果只用对第一个参数求值就能确定,那么就不会对第二个参数求值了),组合逻辑中不存在

以上HCL都是对单个位进行运算的,可以很容易对其进行扩展实现对数据字(Word)进行操作的电路。

例1:

img

直接合并64个位级相等的组合电路,再加上一个逻辑与门,就能得到一个64位的字级相等组合电路。右边是对其的抽象,其中实线表示字级信号,虚线表示布尔信号。

为了简单,可以将所有字级信号都声明为int,不指定字的大小,则该组合电路可以由两个int类型的参数A和B构成的HCL表达式描述bool Eq = (A == B);

例2:

img

这是一个64位字级多路复用器电路。这里只产生了一次!s,可以减少需要的逻辑门数量。在HCL中,多路复用器函数可以用情况表达式(Case Expression)来描述

1
2
3
4
5
6
[
select1 : expr1;
select2 : expr2;
...
selectk : exprk;
]

其中,select是布尔表达式,expr是字级表达式。

注意:1. 在HCL中,不要求不同的选择表达式之间是互斥的,但是实际的多路复用器的信号必须互斥。2. 选择表达式是顺序求值的,所以后续的选择表达式可以在之前的选择表达式的基础上进行简化。

右侧是字级多路复用器的抽象,根据HCL表达式可知,首先根据s的值来判断是否选择A,如果不选择,就一定会选择B

例3:

img

这是一个通过两个信号来控制的四路复用器。对应的HCL表达式为

1
2
3
4
5
6
word Out4 = [
!s1 && !s2 : A; #00
!s1 : B; #01
!s2 : C; #10
1 : D; #11
]

而我们需要从两位code中提取出两个信号,可以通过相等测试:

1
2
bool s1 = code == 2 || code == 3;
bool s0 = code == 1 || code == 3;

也可以通过判断集合关系的方式,其通用格式为iexpr in {iexpr1, iexpr2, ..., iexprk},所以可以表示为

1
2
bool s1 = code in {2,3};
bool s0 = code in {1,3};

比较重要的一个字级组合电路是算数/逻辑单元(ALU),它包含3个输入:标号为A和B的两个数据输入,以及一个控制输入。根据控制输入的值来选择要对A和B进行的运算。如下图所示,是位Y86-64设计的ALU模型。

img

注意:组合电路值进行逻辑运算,不涉及存储信息,当某个抽象能够存储信息,就不是组合电路。

存储器和时钟

为了产生时序电路(Sequential Circuit),即存在状态并且能在这个状态上进行计算的系统,我们必须引入按位存储信息的设备。而这些存储设备是由同一个具有周期性信号的时钟控制的,决定什么时候将新值保存到存储器中。

主要有两类存储器设备:

  • 时钟寄存器(寄存器):存储单个位或字,主要作为电路不同部分的组合逻辑之间的屏障。

  • 随机访问存储器(内存):存储多个字,用地址来选择读写哪个字。包括:

    • 处理器的虚拟内存系统:通过操作系统对存储器进行抽象,使得处理器可以在很大的地址空间中访问,地址为虚拟内存的索引值。

    • 寄存器文件:是一个以寄存器标识符为地址,存储着对应程序寄存器值的随机访问存储器。在IA32或Y86-64处理器中,有15个程序寄存器(%rax~`%r14`)。

这里要注意区分机器级编程中的寄存器和硬件中的寄存器

  • 硬件:寄存器指的是时钟寄存器,直接将它的输入和输出连接到电路的其他部分。这里称为硬件寄存器
  • 机器级编程:寄存器代表的是存储在寄存器文件中的,CPU中少数可寻址的字,地址为寄存器标识符。这里称为程序寄存器

硬件寄存器

img

如上图所示,硬件寄存器大多数时候会保持在稳定状态x,产生的输出也是它当前的状态。当寄存器的输入发生改变时,寄存器的输出也不会马上变化,而是等时钟变成高电位时,才会将当前状态修改为输入值。由此将当前寄存器两侧的逻辑电路分隔开来。

Y86-64处理器会使用硬件寄存器老保存程序计数器(PC)、条件代码(CC)和程序状态(Stat)。

寄存器文件

img

寄存器文件包含两个读端口和一个写端口,意味着能读取两个程序寄存器的同时对第三个程序寄存器进行写操作。这里的地址就是程序寄存器标识符。

寄存器文件的写入操作受时钟信号控制,只有当时钟为高电平时,才将valW中的值写入dstw指示的程序寄存器中。

虚拟内存系统

img

处理器用虚拟内存来保存程序数据。readwrite是两个标志位,用来控制当前是要读还是写。包含通过逻辑电路实现的边界检查,如果地址超过虚拟内存地址空间,就会使得error=1

虚拟内存的写入操作受时钟信号控制,只有当write=1并且时钟为高电平时,才会将data in的数据保存到对应地址的位置。

注意:向存储器(时钟寄存器、随机访问存储器)写入值时会受到时钟的控制,所以存在时序,而向存储器读取值时不受到时钟的控制,不存在时序,可以直接将其近似于逻辑电路,输入地址,一段延迟后,就会将值返回到输出中。

顺序实现

Y86-64指令集体系结构

想要定义一个指令集体系结构,需要包含:

  • 定义状态单元
  • 指令集和他们的编码
  • 编程规范和异常事件处理

这几个方面的具体内容感觉没必要记,详细内容可以查看书中对应章节,这里给出一个Y86-64程序示例:

image-20210724161150116

Y86-64的顺序实现

处理指令的阶段

处理一条指令我们可以将其划分成若干个阶段:

  1. 取指(Fetch):根据程序计数器PC从内存中读取指令字节。然后完成以下步骤

    1. 从指令中提取出指令指示符字节,并且确定出指令代码(icode)和指令功能(ifun
    2. 如果存在寄存器指示符,则从指令中确定两个寄存器标识符rArB
    3. 如果存在常数字,则从指令中确定ValC
    4. 根据指令指令长度以及指令地址,可确定下一条指令的地址valP
  2. 译码(Decode):如果存在rArB,则译码阶段会从寄存器文件中读取rArB的值valAvalB。对于pushpop指令,译码阶段还会从寄存器文件中读取%rsp的值。

  3. 执行(Execute):算术逻辑单元(ALU)会根据ifun的值执行对应的计算,得到结果valE,包括

    1. 计算运算结果,会设置条件码的值,则条件传送和跳转指令会根据ifun来确定条件码组合,确定是否跳转或传送。
    2. 计算内存引用的有效地址
    3. 增加或减少栈指针
  4. 访存(Memory):写入内存或从内存读取数据valM

  5. 写回(Write Back):将结果写入寄存器文件中。

  6. 更新PC(PC Update):将PC更新为valP,使其指向下一条指令。

我们要做的工作就是将每条不同指令所需要的计算放入到上述那个通用框架中,以OPqrrmovqirmovq为例:

image-20210724164106626

这里可以发现,相同icode具有相同的步骤,而相同的ifun在执行阶段具有相同的计算方式,比如addqjmprrmovqifun都是0,所以都进行加法计算。

SEQ硬件结构

我们可以得到顺序实现的SEQ硬件结构:

image-20210724172704176
  • 白色方框为时钟寄存器;蓝色方框为硬件单元,当做黑盒子而不关心细节设计;白色圆圈表示线路名字。
  • 宽度为字长的数据使用粗线;宽度为字节或更窄的数据用细线;单个位的数据用虚线,主要表示控制值。
  • 灰色圆角矩形表示控制逻辑块,能在不同硬件单元之间传递数据,以及操作这些硬件单元,使得对每个不同的指令执行指定的运算。是本章的重点,会给出对应的HCL表达式

SEQ的实现包括组合逻辑和两种存储器:时钟寄存器(程序计数器和条件码寄存器)和随机访问存储器(寄存器文件、指令内存和数据内存)。我们知道组合逻辑和存储器的读取是没有时序的,只要输入一给定,输出就会发生对应的变化。但是存储器的写入是受到时钟的控制的,只有当时钟为高电位时,才会将值写入存储器中。

所以涉及到写数据的存储器(程序计数器、条件码寄存器、寄存器文件和数据内存)就需要对时序进行明确的控制,才能控制好指令各阶段的执行顺序。为了保证每条指令执行的结果能和上一节中介绍的顺序执行的结果相同,我们要保证指令的计算不会回读,即处理器不需要为了完成一条指令的执行而去读取由该指令更新的状态。因为该指令更新的状态是写入数据,需要经过一个时钟周期,如果该指令需要读取更新过的状态,就需要空出一个时钟周期。

我们通过寄存器和内存的时钟控制,由此设计了上一节中的指令执行阶段,这样能够保证即使所有状态同时更新,也能等价于顺序执行各个阶段,也保证了能够在一个周期中完成一条指令。

具体示例可看书中图4-25。

SEQ阶段的实现

SEQ所需要的控制块的逻辑用HCL进行描述。

由于内容过多,在这里我们仅对SEQ取指阶段中各控制块的逻辑,其余阶段可看书中相应章节。

首先给出各指令的编码:

img

取指阶段:

img

该部分访问内存硬件单元。首先以PC作为第一个字节的地址,一次从内存中读取10个字节。灰色部分是我们需要确定的HCL表达式

  • icode为第一字节的高4位,当指令地址越界时,指令内存会返回imem_error信号,此时直接将其表示为nop指令,否则获得高4位值
1
2
3
4
word icode = [
imem_error : INOP;
1 : imem_icode;
];
  • ifun为第一字节的低4位,当出现imem_error信号时,会使用默认功能码,否则获得低4位值
1
2
3
4
word ifun = [
imem_error : FNONE;
1 : imem_ifun;
];
  • instr_valid表示是否为合法指令
1
2
3
bool instr_valid = icode in {
INOP, IHALT, IRRMOVQ, IIRMOVQ, IRMMOVQ, IMRMOVQ, IOPQ, IJXX, ICALL, IRET, IPUSHQ, IPOPQ
};
  • need_regids表示该指令否包含寄存器指示符字节,如果指令不含有寄存器指示符字节,则会将其赋值为0xFF
1
2
3
bool need_regids = icode in {
IRRMOVQ, IOPQ, IPUSHQ, IPOPQ, IIRMOVQ, IRMMOVQ, IMRMOVQ
};
  • need_valC表示该指令是否含有常数字节
1
2
3
bool need_valC = icode in {
IIRMOVQ, IRMMOVQ, IMRMOVQ, IJXX, ICALL
};

PC增加器会根据PC值、need_valCneed_regids来确定valP值,则

1
valP = PC+1+need_regids+8*need_valC

SEQ性能

我们通过将指令执行过程划分成了若干个阶段,使得我们能通过统一框架来描述各个指令执行的过程,也能进一步减少需要的硬件。但是由于每次时钟变为高电平时需要写入数据,使得需要在一个时间周期内完成所有步骤,所以我们要求时钟周期特别慢。

比如执行ret时,当前PC指向ret指令的地址,当时钟变为高电平时,我们需要在下一次时钟变为高电平之前,完成:两次从寄存器文件读取%rsp内容,通过ALU计算%rsp上移8字节的地址,根据%rsp从内存中获得返回地址,然后将新的%rsp值写回寄存器文件中(此时由于时钟还是低电平,所以还没有真实写入,只是设置为了值)。由此能够保证在下一个时钟变为高电平时,能够把正确的%rsp值写回寄存器文件中。

而且我们可以发现,指令执行的不同阶段是在处理器的不同硬件部分,所以完全可以让不同指令同时运行,只要求他们处于不同阶段,这也是下一节中流水线的主要思想。

流水线

流水线的通用原理

如上图所示是一个非流水线化的计算硬件。当信号输入到组合逻辑中时,通过一系列逻辑门经过300ps获得输出信号,然后经过20ps将结果加载到寄存器中,由于时钟周期控制存储器写入的频率,为了保证当时钟变为高电平之前,能够得到将计算好的结果放到寄存器的输入端口,则这里的时钟周期设定为300+20=320ps。

我们将从头到尾执行一条指令所需的时间称为延迟(Delay),则这里延迟为320ps。我们将系统在单位时间内能执行的指令数目称为吞吐量(Throughput),则

image-20210724191419109

意味着一秒能执行3.12G条指令。

由于这个是非流水线化的计算硬件,所以从流水线图中可以看到在开始下一条指令之前必须完成上一条指令。如果我们将组合逻辑根据不同功能,通过流水线寄存器(Pipline Register)划分成独立的三阶段,就能得到简易的流水线化计算硬件。

img

由于每阶段的组合逻辑实现独立的功能,并且能通过流水线寄存器来控制进入下一阶段的时机,所以如上图的流水线图所示,只需要通过流水线寄存器控制每个阶段只执行一条指令,就能流水线化地执行指令。

对于每个阶段,我们需要100ps的组合逻辑计算时间以及20ps加载到寄存器的时间,所以我们这里能将时钟周期设定为120ps。并且我们可以发现每过一个时钟周期就有一条指令完成,所以吞吐量变为了8.33GIPS,提高了2.67倍。但是每条指令需要经过3个时钟周期,所以延迟为360ps,变为原来的1.12倍。

所以流水线特点为:提高系统的吞吐量,但是会轻微增加延迟。

流水线的局限性

不一致的划分

处理器中的某些硬件单元,比如ALU或内存,是无法划分成多个延迟较小的单元的,这使得我们划分的不同阶段的组合逻辑具有不同的延迟。

img

如上图所示,其中组合逻辑B需要150ps进行计算。由于整个系统共用一个时钟周期,为了保证组合逻辑B能在一个时钟周期内计算出正确结果,使得保存到流水线寄存器中,我们就需要将时钟周期设定为150+20=170ps,这使得系统吞吐量变为5.88GIPS,而运行一条指令需要的延迟为3*170=510ps。

注意:想要吞吐量最大,我们需要使得时钟周期尽可能小,而时钟周期受到最慢的组合逻辑的限制,所以我们可以将最小的组合逻辑的时间加上一个寄存器的时延作为时钟周期。想要延迟最小,就不使用流水线。

流水线过深,收益下降

我们将每个组合逻辑进一步划分成更小的部分,构建更深的流水线

img

这里时钟周期变为70ps,则吞吐量为14.29GIPS。这里我们可以发现,虽然我们将组合逻辑分成了更小的单元,使得组合逻辑的时延缩小了两倍,但是吞吐量的性能并没有提升两倍。这是由于更深的流水线,会扩大寄存器时延的影响,在70ps的时钟周期中,寄存器的时延就占了28.6%,意味着更深的流水线的吞吐量会依赖于寄存器时延的性能。

指令相关

我们之前考虑流水线时,只有当指令之间是不相关时才是完全正确的。但是真实系统中,指令之间存在两种形式的相关:数据相关(Data Dependency),下一条指令会用到这条指令计算出来的结果;控制相关(Control Denpendency),一条指令要确定下一条指令的位置。这些相关可能会导致流水线产生计算错误,称为冒险(Hazard),包括:数据冒险(Data Hazard)控制冒险(Control Hazard)

Y86-64流水线实现

SEQ+和PIPE-

为了平衡一个流水线系统各个阶段的延迟,需要使用电路重定时(Circuit Retiming)在不改变逻辑行为的基础上,修改系统的状态表示。如下图所示,顺序实现的SEQ中,更新PC阶段是在时钟周期结束时才执行的,通过组合电路计算得到的icodeCndvalCvalMvalP通过组合电路计算得到新的PC,将其保存到PC的时钟寄存器中。但是这些值是在不同阶段中计算出来的,所以SEQ+新增了一系列状态寄存器来保存之前计算出来的结果,然后将更新PC阶段放到了时钟周期开始执行,这样在每个阶段时钟周期变成高电平时就会将该阶段计算出来的值保存到状态寄存器中,然后PC逻辑电路就能根据当前的状态寄存器的值来预测下一步的PC值。

对应的SEQ+硬件结构在这里就不给出了(书中图4-40),可以发现将更新PC阶段移到了时钟周期开始的位置。

我们可以在各个阶段中加入流水线寄存器,并将信号重新排列来将SEQ+转换成初步的流水线处理器PIPE-,硬件结构如下图所示

preview

处理控制相关

对于calljmp指令,下一条指令的地址就是valC,而除了条件分支和ret指令外,下一条指令的地址就是valP,这些指令不存在控制相关,使得流水线处理器能够每个时钟周期就处理一条指令。如果出现了条件分支,则需要该指令运行到执行阶段后才知道是否选择该分支,如果出现了ret指令,则需要该指令运行到访存阶段,才知道返回地址,此时就存在了控制相关,使得处理器要经过几个时钟周期才知道要运行的下一条指令的地址,所以控制冒险只会出现在条件分支和ret指令中,我们可以通过预测下一条PC来处理这个问题。

  • 条件分支:我们可以通过分支预测技术来预测分支方向,并根据预测开始取值。常见的技术包括:

    • 总是选择(always taken,AT):总是预测处理器选择了条件分支,因此预测PC值为valC,成功率大约为60%。
    • 从不选择(never taken,NT):总是预测处理器不选择条件分支,因此预测PC值为valP,成功率大约为40%。
    • 反向选择、正向不选择(backward taken, forward not-taken,BTFNT):条件分支通常用于循环操作,当跳转地址比下一条指令地址小,说明进入了循环,否则退出循环,而循环通常会执行多次,因此当跳转地址比下一条指令地址低就选择分支,否则就不选择分支, 成功率大约为65%。
  • ret指令:常见的技术包括

    • 暂停处理新指令,直到ret指令通过写回阶段知道下一条指令的地址
    • 在取指单元中放入一个硬件栈,保存过程调用指令产生的返回地址

当预测PC出现错误时出现控制冒险,会执行错误的指令,所以会极大影响流水线处理器的性能,后面再讨论这个问题。

在本文中,条件分支使用AT策略,ret指令使用第一条策略。从PIPE-硬件结构中可知,在取值阶段首先根据icodevalPvalC中选出预测的PC值,对于call和条件分支使用valC,其他指令使用valP。然后Select PC逻辑电路再从predPCM_valAW_valM中进行选择。我们推测为什么是这样的

  • 条件分支:首先条件分支在取指阶段会直接选择条件分支,使得predPCvalC,则当条件分支执行到译码阶段时,valC对应的指令就会在取指阶段开始执行。当条件分支执行到执行阶段时,可以通过CC知道是否真的要选择条件分支,如果真的选择分支,则继续执行,否则条件分支的下一条指令地址应该是valP,此时该条件分支对应的valP保存在M_valA中,所以可以让Select PC选择M_valA来重新执行条件分支的部分。
  • ret指令:当执行ret指令时,会暂停传入新的指令,知道ret指令执行到访存阶段时,才从内存中读取了下一条指令的返回地址,保存在W_valM中,所以Select PC可以选择W_valM来执行返回地址对应的指令。

流水线冒险

流水线冒险主要包含数据冒险和控制冒险,当程序状态的读写不处于同一阶段,就可能出现数据冒险,当出现分支预测错误或ret指令时,会出现控制冒险。

在Y86-64中,程序状态包含程序寄存器、内存、条件码寄存器和状态寄存器。程序寄存器的读取处于译码阶段,而写入处于写回阶段,因此程序寄存器会出现数据冒险的可能,以以下代码为例

img

我们在代码中插入了三行nop指令,则当addq %rdx, %rax处于译码阶段读取寄存器时,第一行和第二行指令已经完成了对寄存器%rdx%rax的写入操作,因此该代码不会出现数据冒险,但是如果减少nop指令,第一行和第二行指令还没完成对寄存器的写入操作时,addq %rdx, %rax已经处于译码阶段读取寄存器了,此时就会读取到错误的值而出现数据冒险。由于读取操作和写入操作相差3个时钟周期,所以如果一条指令的操作数被它前面三条指令中的任何一条修改时,就会出现数据冒险。

而内存的读写都处于访存阶段、条件码寄存器的读写都处于执行阶段因此它们不会出现数据冒险的情况,而我们为每个阶段都在流水线寄存器中保留了stat值,所以当异常发生时,处理器就能有条理地停止。

所以这里我们主要探讨程序寄存器数据冒险和控制冒险。

用暂停来避免数据冒险

我们可以在执行阶段中插入一段自动产生的nop指令,来保持寄存器、内存、条件码和程序状态不变,使得当前指令停在译码阶段,并且会控制程序计数器不变,使得下一条指令停在取指阶段,直到产生指令的源操作数的指令通过了写回阶段。

该方法指令要停顿最少一个最多三个时钟周期,严重降低整体的吞吐量。

用转发来避免数据冒险

对于以下代码我们可以发现,在第四个周期I1处于访存阶段而I2处于执行阶段,都还没有将valE保存在%rdx%rax中,所以I3的译码阶段无法从寄存器文件中读取到正确的%rax%rdx

img

但是即使还没有将valE保存到对应的寄存器文件中,其实I1在执行阶段已经将%rdx的值保存到流水线寄存器M中M_valE,而I2在执行阶段通过ALU计算得到了%rax的值e_valE,所以即使没有写入对应的寄存器文件中,已经能从M_valEe_valE得到%rax%rbx的值了,所以I3的译码阶段可以从以下形式

1
2
valA = R[%rdx]
valB = R[%rax]

变成

1
2
valA = M_valE
valB = e_valE

此时就不存在数据冒险,以及暂停了。

除了通过ALU的计算结果来转发,还能通过内存来进行转发,并且通过当前阶段的dstEdstM与目标指令的srcAsrcB进行判断来决定是否转发。在处理器中,valAvalB一共有5个转发源:

  • e_valE:在执行阶段,ALU中计算得到的结果valE,通过E_dstEd_srcAd_src_B进行比较决定是否转发。
  • M_valE:将ALU计算的结果valE保存到流水线寄存器M中,通过M_dstEd_srcAd_src_B进行比较决定是否转发。
  • m_valM:在访存阶段,从内存中读取的值valM,通过M_dstMd_srcAd_src_B进行比较决定是否转发。
  • W_valM:将内存中的值valM保存到流水线寄存器W中,通过W_dstMd_srcAd_src_B进行比较决定是否转发。
  • W_valE :将ALU计算的结果valE保存到流水线寄存器W中,通过W_dstEd_srcAd_src_B进行比较决定是否转发。

加载/使用数据冒险

有一类数据冒险不能单纯用转发完成。我们考虑以下代码,可以发现在执行0x032指令的译码阶段时,%rbx的值通过转发技术可以从M_valE中获得,但是%rax的值需要0x028指令执行到访存阶段,才能从内存中读取到%rax的值,但是当前0x028指令处于执行阶段,所以无法通过转发技术来解决这个数据冒险。

img

我们可以通过加载互锁(Load Interlock)方法来处理这种加载/使用数据冒险,其实就是引入了暂停,如下图所示,当0x032指令执行到译码阶段时,对该指令暂停一个时钟周期(所谓的插入气泡),此时0x028指令就能执行到访存阶段,此时就能从m_valM中获得%rax的值。

img

结合加载互锁和转发技术足以解决所有类型的数据冒险,并且对模型的吞吐量不会造成很大的影响。

避免控制冒险

控制冒险只会出现在ret指令和跳转指令预测错方向时产生。

  • ret指令
img

对于以上代码,对应的流水线图为

img

可以发现,当执行call proc时,在取指阶段就能获得valC表示下一条指令的地址,所以会取到ret指令。而ret指令只有运行到访存阶段时才能获得返回地址valM,并且在写回阶段的时钟电平变高时,才会写入PC寄存器中,所以需要在ret指令后添加3个bubble。

  • 跳转指令
img

对于以上代码,对应的流水线图为

img

首先对于跳转分支,我们采用AT策略,所以在执行jne target的取指阶段时获得的valC会直接作为下一条指令的地址。当跳转指令运行执行阶段时,就会通过CCifun得知是否预测正确,此时已经将下一条指令运行到译码阶段,第二条指令运行到了取指阶段,如果预测错误,就会分别插入两个bubble,避免运行到后续阶段,改变程序员可见状态,会浪费两个时钟周期

异常处理

异常可以由程序执行从内部产生,也可以由某个外部信号从外部产生。当前的ISA包含三种内部产生的异常:1. halt指令;2. 非法指令码和功能码组合的指令;3. 取值或数据读写访问非法地址。外部产生的异常包括:接收到一个网络接口受到新包的信号、点击鼠标的信号等等。

在我们的ISA中,希望处理器遇到异常时,会停止并设置适当的状态码。要求:异常指令之前的所有指令已经完成,后续的指令都不能修改条件码寄存器和内存。流水线系统包含以下问题:

img
  1. 当同时多条指令引起异常时,处理器应该向操作系统报告哪个异常?基本原则:由流水线中最深的指令引起的异常,优先级最高,因为指令在流水线中越深的阶段,表示该指令越早执行。
  2. 在分支预测中,当预测分支中出现了异常,而后由于预测错误而取消该指令时,需要取消异常。

在PIPE硬件架构中,我们对每个流水线寄存器中都设置了一个stat信号,用来保存当前阶段的异常信号,随着流水线的进行,就能解决以上问题:

  1. stat信号只是简单存放在流水线寄存器的状态字段中,不会对流水线中的指令流有任何影响,保证了异常指令之前的指令都能完成,但是要进制流水线中后面的指令不能更新条件码寄存器和内存。
  2. 当出现异常的指令到达写回阶段时,由于流水线中的指令是顺序执行的,所以能保证当前异常是最早出现的异常。
  3. 当条件分支预测错误时,直接取消该指令后,stat信号就不会保存下去了。
  4. 最终流水线寄存器W中的stat信号会被记录为程序状态。

流水线控制逻辑

会讨论流水线中低级机制,使得流水线控制逻辑能将指令阻塞在流水线寄存器或往流水线中插入一个气泡。并且在流水线中,还有些特殊情况是其他机制不能处理的,包括:加载/使用冒险、处理ret、预测错误的分支、异常等情况。

暂停和气泡

暂停和气泡是流水线中低级的机制,暂停能将指令阻塞在某个阶段,往流水线中插入bubble能使得流水线继续运行,但是不会改变当前阶段的寄存器、内存、条件码或程序状态。这两个状态决定了当时钟电平变高时,如何修改流水线寄存器。

对于正常状态,即不是用暂停和bubble时,只要时钟电平变高,就会将流水线寄存器的状态修改为输入值,并作为新的输出。

img
  • 暂停

通过加入流水线寄存器,我们将指令的执行划分成了不同的阶段,并且每个阶段的输入就是流水线寄存器中的内容,所以如果我们想要将指令暂停在某个阶段时,我们可以直接将该阶段的流水线寄存器固定不变,使得该阶段的输入信息保持不变,就能在该阶段反复地执行指令,就是的指令阻塞在当前阶段了。

所以将指令暂停在某个阶段,就是当时钟电平变高时,保持该阶段的流水线寄存器的状态不变

img
  • bubble

当时钟电平变高时,上一阶段指令的执行结果会保存到当前阶段的流水线寄存器,执行当前阶段后就会修改程序员可见状态,当我们想要保持程序员可见状态不变,可以插入一个bubble,使得寄存器状态设置成某个固定的复位配置,得到一个等效于nop指令的状态,相当于取消指令的运行

img

加载/使用冒险

mrmovqpopq指令I1会从内存中读取值保存到寄存器中,但是是在访存阶段才会读取到内存的值,所以如果下一条指令I2会读取这个寄存器的值,就会出现加载/使用冒险,因为当I2处于译码阶段读取寄存器值时,I1还是处于执行阶段,所以无法读取到内存的值。触发条件

1
E_icode in {IMRMOVQ, IPOPQ} && E_dstM in {d_srcA, d_srcB} 

理想处理方式为:固定流水线寄存器D和F,使得指令I2和下一条指令I3能分别阻塞在译码阶段和取指阶段,然后在译码阶段后面插入一个时钟周期的bubble,使得I1和前面的指令可以继续向后执行一个时钟周期,则I1此时处于访存阶段,就能读取到内存的值了。

img

所以当触发了加载/使用冒险时,流水线寄存器会如下设置一个时钟周期

img

处理ret指令

执行ret指令时,会从栈中读取返回地址作为下一条指令的地址,所以当ret执行到访存阶段时,才能读取到下一条指令的地址,然后在写回阶段的时钟电路变成高电平时,才会将其写入流水线寄存器M中,然后将M_valM传回去到Select PC逻辑模块。触发条件为:

1
IRET in {D_icode, E_icode, M_icode}

理想处理方式为:当ret执行到译码阶段时,会触发触发条件,此时就固定流水线寄存器F,就能保持不断读取下一条指令I2,并且后面在译码阶段插入3个时钟周期的bubble(根据取指阶段的HCL,会不断执行valP的错误指令,但是通过插入bubble,使得它只能执行到取指阶段),使得ret指令能向后执行3个时钟周期到达写回阶段,此时就能直接通过W_valM获得下一个PC的地址。

img

所以当触发了ret指令时,流水线寄存器会如下设置3个时钟周期

img

预测错误的分支

我们采用AT分支预测策略,所以当遇到条件分支指令I1时,会直接跳转到对应的地址开始执行,只有当I1执行到执行阶段时,才能通过e_Cnd判断是否发生跳转,此时已经执行了后续的两个指令I2I3,分别处于译码阶段和取指阶段。预测错误的触发条件为:

1
E_icode == IJXX && !e_Cnd

当出现预测错误时,说明我们并不需要执行已经执行了的I2I3指令,理想的处理方式为:直接在译码阶段插入bubbl中断I3,在执行阶段插入bubble中断I2,然后将正确的指令放入取指阶段开始执行,所以分支预测错误最多损耗两个时钟周期。

img

所以当触发了预测错误的分支时,流水线寄存器就会如下设置一个时钟周期

img

异常指令

当出现halt指令、错误的指令码和函数码组合的指令或内存地址错误时,就会出现异常,所以异常通常在取指阶段和访存阶段被发现,对于异常理想的处理方式为:异常指令之前的指令都能完成,之后的指令都不会修改程序员可见状态,异常指令到达写回阶段时停止执行。

但是存在以下困难:异常在取指阶段和访存阶段被发现,程序员可见状态在执行阶段、访存阶段和写回阶段被修改。

我们首先在所有阶段的流水线寄存器中都包含一个程序状态信号stat,即使出现了异常,也只是将其当做普通信号传到下一阶段。当异常指令到达访存阶段时,后续的三条指令分别处于执行阶段、译码阶段和取指阶段,只有处于执行阶段的指令会修改条件码寄存器,所以要禁止执行阶段中的指令设置条件码。并且在访存阶段插入bubble,使得异常指令执行到写回阶段时,下一条指令就阻塞在执行阶段,不会到达访存阶段来修改内存。由于流水线处理器是按顺序处理指令的,所以第一次在写回阶段检测到异常指令就是最新的异常,所以只要在写回阶段检测到异常指令,就暂停写回,并暂停流水线。

触发条件为:

1
m_stat in {SADR, SINS, SHLT} || W_stat in {SADR, SINS, SHLT}

特殊情况组合

特殊情况在这里不记录,详情可以看书中对应章节。

额外内容

多周期指令

我们提供的Y86-64指令集只有简单的操作,在执行阶段都能在一个时钟周期内完成,但是如果要实现整数乘法和除法以及浮点数运算,我们首先要增加额外的硬件来执行这些计算,并且这些指令在执行阶段通常都需要多个时钟周期才能完成,所以执行这些指令时,我们需要平衡流水线各个部分之间的关系。

实现多周期指令的简单方法是直接暂停取指阶段和译码阶段,直到执行阶段执行了所需的时钟周期后才恢复,这种方法的性能通常比较差。

常见的方法是使用独立于主流水线的特殊硬件功能单元来处理复杂的操作,通常会有一个功能单位来处理整数乘法和除法,还有一个功能单位来处理浮点数运算。在译码阶段中遇到多周期指令时,就可以将其发射到对应的功能单元进行运算,而主流水线会继续执行其他指令,使得多周期指令和其他指令能在功能单元和主流水线中并发执行。但是如果不同功能单元以及主流水线的指令存在数据相关时,就需要暂停系统的某部分来解决数据冒险。也同样可以使用暂停、转发以及流水线控制。

与存储系统的接口

我们假设了取指单元和数据内存都能在一个时钟周期内读写内存中的任意位置,但是实际上并不是。

  1. 处理器的存储系统是由多种硬件存储器和管理虚拟内存的操作系统共同组成的,而存储系统包含层次结构,最靠近处理器的一层是高速缓存(Cache)存储器,能够提供对最常使用的存储器位置的快速访问。典型系统中包含一个用于读指令的cache和一个用于读写数据的cache,并且还有一个翻译后备缓冲器(Translation Look-aside Buffer,TLB)来提供从虚拟地址到物理地址的快速翻译。将TLB和cache结合起来,大多数时候能再一个时钟周期内读指令并读写数据。
  2. 当我们想要的引用位置不在cache中时,则出现高速缓存不命中(Miss),则流水线会将指令暂停在取指阶段或访存阶段,然后从较高层次的cache或处理器的内存中找到不命中的数据,然后将其保存到cache中,就能恢复指令的运行。这通常需要3~20个时钟周期。
  3. 如果我们没有从较高层次的cache或处理器的内存中找到不命中的数据,则需要从磁盘存储器中寻找。硬件会先产生一个缺页(Page Fault)异常信号,然后调用操作系统的异常处理程序代码,则操作系统会发起一个从磁盘到主存的传送操作,完成后操作系统会返回原来的程序,然后重新执行导致缺页异常的指令。其中访问磁盘需要数百万个时钟周期,操作系统的缺页中断处理程序需要数百个时钟周期。