接着上一次的内容,中间有一段时间在忙着搞大作业的内容,所以就没继续搞这个了。今天我们将具体实现Y86-64的设计。

异常处理

异常控制流导致程序正常的执行流程被破坏掉。异常可以有程序内部产生,也可以由某个外部中断产生。我们的指令集主要包括三种类型:

  • halt指令
  • 非法指令和功能码组合的结果
  • 取指或数据读写访问了非法地址

我们需要考虑的问题也比较简单:

  1. 可能有多条指令引起异常。例如取指阶段遇到halt指令,然后访存阶段遇到了非法地址的访问。这个时候我们的处理器应该向操作系统返回哪个异常呢?我们的基本原则是:由流水线中最深的指令引起的异常,优先级最高,所以这里我们应该返回非法地址访问的异常

  2. 当取出一条指令时,开始执行时,导致了一个异常,而后来由于分支预测错误,取消了该指令。例如:

   	xorq %rax,%rax
   	jne target			;处理器默认选择分支
   	irmovq $1,%rax
   	halt
   target:
   	.byte oxFF			;非法地址

流水线默认选择分支,在译码阶段会发现一个非法指令异常。但是之后又会发现不应该预测分支,流水线控制逻辑会取消该指令。但是我们想要避免这个异常

  1. 流水线化的处理器会在不同的阶段更新系统状态的不同部分。有可能会出现这个情况,一个指令导致了一个异常,可是后面的指令在这个异常前改变了部分的状态。比如:
irmovq $1,%rax
xorq %rsp,%rsp		;CC=b100
pushq %rax			;假设此时的栈顶在0xfffffffffffffff8
addq %rax,%rax		;CC=b000

当push时,由于栈顶的移动会导致地址异常,同时addq此时位于执行阶段,它将条件码设置成了新的值。这就违反了异常指令之后所有指令不能影响系统状态的要求。

现在我们明确了我们需要解决的问题,首先需要避免由于分支预测错误取出的指令造成异常。所以我们要在每个流水线寄存器中包括一个状态码stat。如果一个指令在某个阶段中产生了一个异常,这个状态字段就被设置为异常的种类。异常和其他信息一起随着流水线传播,直到写回才发现异常,停止执行。

为了避免异常指令之后的指令更新任何程序员可见的状态,当访存或写回阶段中导致异常时,流水线控制逻辑必须禁止更新条件码寄存器或是数据内存。

所以综上所述,当流水线有一个或多个阶段产生异常时,信息只是简单的存放在流水线寄存器的状态字段中。异常事件不会对流水线中的指令流有任何影响,除了会禁止流水线后面的指令更新程序员可见的状态(条件码寄存器和内存),直到异常指令到最后的写回阶段。由于指令到达写回阶段的顺序就是异常发生的顺序,所以我们可以保证第一个发生异常的指令可以第一个到达写回阶段。如果取出了某条指令,过后又被取消了,那么所有的关于这条指令的异常信息都会被取消。所有导致异常的指令后面的指令都不能改变程序员可见的状态。携带指令的异常状态以及所有其他信息通过流水线的简单原则是处理异常的简单可靠的机制。

PIPE各个阶段实现

PIPE的具体实现和之前SEQ的实现基本差不多,只不过PIPE的每个状态都叫上了前缀。如"D_"表示源值,信息来自流水线D寄存器,而"d_"表示结果值,表明它是在译码阶段产生的。

PC选择和取指阶段

这个阶段用于选择程序计数器的当前值,用于预测下一个PC值。由于从内存中读取指令和抽取不同的字段的硬件单元一样,我就不重复了。

image.png

PC选择逻辑从三个程序计数器源中进行选择。当程序分支预测错误时,从M_valA中读出valP(不跳转的话本应执行的地址)。当ret指令进入写回阶段时,会从W_valM中读出返回地址。其他情况则会使用F阶段寄存器中的predPC的值,我们可以选择PC的值:

# f_pc 
# 分支预测有误则回退
if M_icode == JXX && !M_Cnd:
    f_pc = M_valA
# RET在写回阶段
else if W_icode == RET:
    f_pc = W_valM
else:
    f_pc = F_predPC

PC的预测逻辑则很简单。当函数为调用或跳转时,使用valC。否则用valP

# F_predPC
if f_icode in [JXX,CALL]:
    F_predPC = valC		
else:
    F_predPC = valP

关于Instr valid Need regids Need valC的逻辑块则和SEQ一样。

同时我们需要根据这些信息来确定程序的状态:

# f_stat
# 检查指令地址越界
if imem_error:
    f_stat = SADR
# 检查icode是否存在
else if !instr_valid:
    f_stat = SINS
# 检查手动中断
else if f_icode == HALT:
    f_stat = SHLT
else:
    f_stat = SAOK

译码和写回阶段

image.png

标号为"srcA" "srcB" "dstM" "dstE"的逻辑块我们在SEQ中已经实现过,基本不需要什么改动。不过我们也需要注意到,dstE和dstM写端口的寄存器ID不再是直接使用译码阶段所产生的,而是使用来自写回阶段的信号(W_dstE和W_dstM),这是因为我们希望写的目的寄存器是由写回阶段产生的。

不过这个阶段难在转发逻辑的实现上,尤其是"Sel+FwdA"块,不仅要实现valA的转发逻辑,还要实现valA和valP的合并。这两个信号之所以可以合并是因为,只有call和跳转指令才会用到valP的值,且不需要寄存器文件中读出来的值。这个选择通过icode信号可以控制实现。

接下来我们整理一下转发源和目的寄存器的关系以实现转发逻辑:

数据字 寄存器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作为输出:

# d_valA
# 注意这里的判断是有优先级的,阶段越浅的数据优先级越高,因为后执行的指令可能会覆盖先执行的指令的数据内容。同阶段的优先级需要特殊考虑
if D_icode in [JXX,CALL]:
    d_valA = D_valP
else if d_srcA == e_dstE:
    d_valA = e_valE
# popq %rsp会试图将两个值写入同一个寄存器中,valE是计算后的数据,有限考虑会造成冲突,与事实不符
else if d_srcA == M_dstM:
    d_valA = m_valM
else if d_srcA == M_dstE
	d_valA = M_valE
# 内存访问的值W_valM比计算出的值W_valE更"新鲜"
else if d_srcA == W_dstM:
    d_valA = W_valM
else if d_srcA == W_dstE:
    d_valA = W_valE
else:
    d_valA = d_rvalA

同理我们可以写出d_valB的代码:

# d_valB
if d_srcB == e_dstE:
    d_valB = e_valE
else if d_srcB == M_dstM:
    d_valB = m_valM
else if d_srcB == M_dstE:
    d_valB = M_valE
else if d_srcB == W_dstM:
    d_valB = W_valM
else if d_srcB == W_dstE:
    d_valB = W_valE
else:
    d_valB = d_rvalB

然后就是写回阶段的逻辑,写回阶段基本是不用保持不变的。其中Stat需要根据W中的状态值计算出来,因为W保存着最近完成的指令的状态,所以我们需要用这个信号来表示整个处理器的状态。不过也要考虑写回阶段有气泡时。这也是一种正常状态,我们可以写出:

# Stat
if W_stat == SBUB:
    Stat = AOK
else:
    Stat = W_stat

执行阶段

image.png

这一部分和SEQ种基本没有什么区别,其中e_dstE和e_valE被作为了指向译码阶段的转发源。不过有一点需要注意,SetCC不仅由icode控制,同时还以m_statW_stat作为输入。这样可以实现,当一条导致异常的指令通过后面的流水线时,任何对条件码的更新都会被停止。

访存阶段

image.png

访存阶段中SEQ和PIPE的逻辑基本差不多,区别在于,这里用了很多流水线的数值用来向译码阶段做转发源。同时我们在这里来验证程序地址的合理性从而计算m_stat

# m_stat
if dmem_error:
    m_stat = SADR
else:
    m_stat = M_stat

流水线的控制逻辑

现在我们要创建我们的流水线控制逻辑,以完成我们处理器设计,我们需要处理以下四种情况,这是我们无法通过分支预测和数据妆发处理的:

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

我们先设计每种情况所期望的行为,然后再设计处理些情况的控制逻辑:

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

  • 加载/使用冒险:

    只有mrmovq和popq指令会从内存中读取数据。当这两条指令中的任一一条处于执行阶段时,并且需要该目的寄存器的指令正处在译码阶段时(此时我们无法完成数据转发)。我们需要将第二条指令阻塞在译码阶段,并在下一个周期,往执行阶段中插入一个气泡。此后转发逻辑会解决这个数据冒险,可以将流水线寄存器D保持为固定状态,从而将一个指令阻塞在译码阶段。这样做还可以保证流水线寄存器F保持在固定状态,由此第二条指令会被再取指一次。总而言之我们需要保持流水线寄存器F和D不变,并在执行阶段中差插入气泡。

  • 处理ret:

    对ret指令的处理,我们需要将流水线停顿三个时钟周期,直到ret经过了访存阶段,读出返回地址。我们遇到ret时会默认PC新值为valP,也就是下一条指令地址。然后会对下一条指令进行取指,在下一条指令的译码阶段会被插入气泡,空转三个周期。

  • 分支预测错误:

    当跳转指令执行到执行阶段时就可以检测到预测错误。然后在下一个周期,控制逻辑会在译码和执行阶段插入气泡,取消两条不正确的已取指令。在同一个时钟周期,流水线将正确的指令读取到取指阶段。

  • 异常:

    我们必须保证,在前面的所有的指令结束前,后面的指令不能影响程序的状态。当异常发生时,我们的stat信息作为指令状态的一部分记录下来,并且继续取指译码和执行命令。当异常指令到达访存阶段时,我们采取措施防止之后的指令会修改程序员可见的状态:(1)禁止执行阶段设置条件码(2)向内存中插入气泡,禁止数据向内存中写入(3)当写回阶段中有异常指令时,暂停写回阶段,暂停流水线。这样我们实现了异常发生之前的指令完成,异常发生之后的指令不对程序员可见的状态进行修改。

发现特殊控制条件

总结一下各个特殊控制触发的条件:

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

流水线控制机制

我们对流水线控制需要使用两个最简单的机制:暂停和气泡。它们分别将指令阻塞在流水线寄存器中(让真个流水线暂时停滞),或是往流水线中插入一个气泡(用空操作替换错误指令)。

image.png

  • 正常操作下,这两个输入都设为0,使得寄存器加载它的输入作为新的状态。
  • 暂停时,将暂停信号设置为1,禁止更新状态。
  • 气泡时,将气泡信号设置为1,寄存器状态会设置成一个固定的复位配置,得到一个等效于nop的状态
  • 当暂停信号和气泡信号都设为1时会导致错误

当我们遇到特定的条件时,我们可以将各个阶段的流水线状态设置为以下情况,以控制流水线逻辑:

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

暂停后面跟气泡时为了取消进入该阶段的指令,避免产生影响

控制条件的组合

我们在之前的讨论中,默认一个时钟周期只能出现一个特殊情况,实际上,一个时钟周期可能会同时出现多种特殊情况的组合。我们把所有可能出现的特殊情况列出来,讨论它们组合的可能性。

image.png

从这里我们可以看出大多数的控制条件之间是互斥的。加载/使用要求执行阶段是加载指令,预测错误要求执行阶段是跳转指令,所以是冲突的。ret的另外两种情况也是同理。所以实际上只有组合A和组合B可能会出现。

组合A中执行阶段有一条不选择分支的跳转指令,而译码阶段有一条ret指令,这种组合要求ret位于不选择分支的目标处。流水线控制逻辑发现分支预测错误,因此要取消ret指令。由此我们可以得出控制逻辑的控制动作

条件/流水线寄存器 F D E M W
处理ret 暂停 气泡 正常 正常 正常
预测错误的分支 正常 气泡 气泡 正常 正常
组合A 暂停 气泡 气泡 正常 正常

因为下一个周期,PC选择逻辑会跳转到后面那条指令的地址,所以流水线寄存器F的保存的内容是无所谓的,因为正确的取指会覆盖他,错误的旧值会被取消。

组合B中包括一个加载/使用冒险,其中加载指令设置%rsp,然后ret用这个寄存器作为原操作数,因为它必须从栈中弹出返回地址。所以流水线控制逻辑应该将ret指令阻塞在译码阶段。我们看下组合B的控制逻辑的控制动作:

条件/流水线寄存器 F D E M W
处理ret 暂停 气泡 正常 正常 正常
加载/使用冒险 暂停 暂停 气泡 正常 正常
组合B 暂停 气泡+暂停 气泡 正常 正常
期望的情况 暂停 暂停 气泡 正常 正常

这里我们发现组合B需要进行特殊的处理。我们可以看到,在组合B的译码阶段,控制动作将寄存器的气泡信号和暂停信号同时设置成了1,这会导致错误。

实际上,我们在组合B中应该优先处理加载/使用冒险,我们要优先确保数据被成功加载后,再进行使用。所以这里将ret的处理推迟了一个周期。

控制逻辑的实现

下图是流水线控制逻辑的整体结构,根据流水线寄存器和流水线阶段的信号,控制逻辑产生流水线寄存器的暂停和气泡控制信号,同时决定是否更新条件码寄存器。

image.png

接下来我们将控制条件和控制动作结合起来,产生各个流水线控制信号的描述:

# F_stall
	# 加载/使用冒险
if (E_icode in [MRMOVQ,POPQ] and E_dstM in [d_srcA,d_srcB]):
    F_stall = 1
	# ret处理
else if RET in [D_icode,E_icode,M_icode]:
    F_stall = 1
    
    
# D_stall
	# 加载/使用冒险
if (E_icode in [MRMOVQ,POPQ] and E_dstM in [d_srcA,d_srcB]):
    D_stall = 1

遇到预测错误和ret指令时,流水线寄存器D必须设置成气泡。不过前面提到的对于加载/使用冒险和ret的组合,我们需要将流水线寄存器设置成暂停

# D_bubble
	# 排除组合状态
if not (E_icode in [MRMOVQ,POPQ] and E_dstM in [d_srcA,d_srcB]):
	if RET in [D_icode,E_icode,M_icode]:
        D_bubble = 1
	# 分支预测错误
else if E_icode == JXX && !e_Cnd:
    D_bubble
  

# E_bubble
	# 分支预测错误
if E_icode == JXX && !e_Cnd:
    E_bubble = 1
	# 加载/冒险使用
else if (E_icode in [MRMOVQ,POPQ] and E_dstM in [d_srcA,d_srcB]):
    E_bubble = 1

同时为了避免异常后的指令更新了程序状态,我们设置条件码不被整数操作设置:

# set_cc
if E_icode == OPq:
    if (not m_stat in [SADR,SINS,SHLT] and not W_stat in [SADR,SINS,SHLT]):
        set_CC = 1

同时,在下一个周期需要向访存阶段插入气泡,因为如果访存或写回阶段有异常时,我们不希望其他的指令改变了内存状态:

# M_bubble
if m_stat in [SADR,SINS,SHLT]:
    M_bubble = 1
else if W_stat in [SADR,SINS,SHLT]:
    M_bubble = 1

为了在写回阶段将异常提交到异常处理程序,所以我们也需要在异常指令到达W阶段时,阻塞整个流水线。从而实现异常之前的指令都被完成,后续的指令像没执行过一样:

# W_stall
if W_stat in [SADR,SINS,SHLT]:
    W_stat

至此,我们处理器的流水线控制逻辑就实现了。