Post

计算机处理器体系结构

前言

这篇文章是对 CSAPP 第四章的总结回顾,所有的理论均出自该书第四章。之前我们总结过 第二章第三章,有兴趣的话可以看看。

这一章,我们会去到硬件层面,看看处理器(CPU)是如何组合各种门电路和逻辑单元,来更好地执行我们在第三章介绍的那些机器级指令的。我们会介绍 指令集硬件单元流水线处理器架构 等等内容。你会对计算机指令有更深的了解,也会更加清楚为什么简单的门电路能一步步构建出如此庞大而复杂的计算机系统。

指令集结构(ISA)

第二章 中,我们介绍了 x84-64 的指令集,我们也说了,x86 的指令集非常庞大,我们常用的仅仅是冰山一角。因为需要考虑到不同的指令,x86 的处理器的体系结构也是极其复杂的。为了说明原理,这里我们新建了一个指令集——Y86-64,在这之中,我们仅保留常用的指令和寄存器。

其中,指令名称的字节码表示如下:

寄存器的字节码表示如下:

NumberRegister NameNumberRegister Name
0%rax8%r8
1%rcx9%r9
2%rdxA%r10
3%rbxB%r11
4%rspC%r12
5%rbpD%r13
6%rsiE%r14
7%rdiFNo register

将指令名和寄存器或者标识符组合在一起就可以形成完整的指令:

可以看到,对于非常常用的移动指令,我们将其拆分成 4 个指令—— irmovqrrmovqmrmovqrmmovq,其中 r 表示的是寄存器,m 表示的是内存。在 Y86-64 中,为了简化,我们只考虑 8 字节的操作,因而这些移动指令以 q 结尾。halt 指令会停止指令的执行,nop 只会改变 PC(指令寄存器,存放当前执行的指令所在地址),让其指向下一个要执行的指令。

这里特别指出,对于 pushq 指令,y86 会先将寄存器中的值移动到内存中,然后改变寄存器 %rsp,将其的地址减 8。这个先后次序很重要,比如对于 pushq %rsp 指令,先后次序不同,内存中存放的值也就不同。类似的,popq 指令也是先从内存读取数据,然后再更新 %rsppopq %rsp 等同于 mrmovq (%rsp), $rsp

说了这么多,来看个例子,指令 rmmovq %rsp, 0x123456789abcd(%rdx) 表示的是将 %rsp 寄存器中的内容存放到内存中去,内存的地址是通过 %rdx 中的内容和 0x123456789abcd 相加所得。将这个指令用上述的编码编解后就是 4042cdab896745230100(注意默认的系统为小端模式)。不管是 x86 还是 y86,任何一个指令集都需要保证编码的唯一性。

除了常见指令,我们也在 y86 中定义了一些状态码:

ValueNameMeaning
1AOK正常操作
2HLThalt 指令被执行
3ADR无效的地址
4INS无效的指令编码

除了 AOK,其他的状态都是异常状态,处理器会停止执行指令,当然在更复杂的系统中,异常需要交由异常处理机制处理,y86 中我们后面会做简单处理。

定义清楚了指令集,我们来实际看看 y86 与 x86 的区别,对于下面这段 C 代码:

1
2
3
4
5
6
7
8
9
long sum(long *start, long count) {
    long sum = 0;
    while (count) {
        sum += *start;
        start++;
        count--;
    }
    return sum;
}

它对应的 x86 汇编码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
# long sum(long *start, long count)
# start in %rdi, count in %rsi
sum:
    movl $0, %eax              # sum = 0
    jmp .L2                    # Goto test
.L3:                           # loop:
    addq (%rdi), %rax          # Add *start to sum
    addq $8, %rdi              # start++
    subq $1, %rsi              # count--
.L2:                           # test:
    testq %rsi, %rsi           # Test sum
    jne .L3                    # If !=0, goto loop
    rep; ret                   # Return

y86 汇编码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# long sum(long *start, long count)
# start in %rdi, count in %rsi
sum:
    irmovq $8, %r8             # Constant 8
    irmovq $1, %r9             # Constant 1
    xorq %rax, %rax            # sum = 0
    andq %rsi, %rsi            # Set CC
    jmp test                   # Goto test
loop:
    mrmovq (%rdi), %r10        # Get *start
    addq %r10, %rax            # Add to sum
    addq %r8, %rdi             # start++
    subq %r9, %rsi             # count--. Set CC
test:
    jne loop                   # Stop when 0
    ret                        # Return

对比可以明显感觉到,y86 会更长一些,比如 x86 中的 addq 指令可以将内存中的值和寄存器中的值做加法,而这在 y86 中需要两个指令,另外 x86 中有 testq 指令,y86 中没有。执行同样的逻辑,x86 明显效率更高,但这也是没有办法的,毕竟这里我们的目的是基于 y86 来看处理器是如何执行指令的,很多指令方面的优化我们均没有考虑。

逻辑门电路与 HCL

想必门电路大家之前都有所了解,这里我们来看几个常见的门电路:

与、或、非就是三个最为基础,也最为简单的门电路,我们可以将其组合得到我们想要的逻辑和运算符,比如 相等

为了描述上面的逻辑电路,我们引入了——HCL(Hardware Control Language),比如上面相等可以用 HCL 表示成下面这样:

1
bool eq = (a && b) || (!a && !b);

初看你可能会觉得这和编程语言(比如 C,Java)中的逻辑运算符很像,不过 HCL 是一个硬件语言,它和编程语言有着本质的不同。首先,对于一个编程语言,任何一个语句或者表达式,只有等到程序真正运行到该语句时,该语句才会被执行,而 HCL 其实是电路的一个描述,只要给电路通上电,任何时候,输出都由输入决定,比如上面这个式子中的 eq 在任何情况下都是由 ab 决定,并不由这个语句所在的位置决定。并且 ab 的任何变化都会即刻反应到 eq,输出与输入同步。

我们再来看一个选择器:

用 HCL 表示就是:

1
bool out = (s && a) || (!s && b);

通过改变 s 的值,我们可以在 ab 中选择一个作为最后的输出。

可以看到,上面讨论和展示的门电路均是基于单个 bit 的,可单 bit 所涵盖的信息太少了,这怎么办呢?其实也很简单,就是把单个逻辑门电路组合起来就行,比如下面这个门电路实现了 8 字节的 相等

下面这个是 8 字节的选择器:

这里的 HCL 是一个 case 语句,表示的意思是,如果 s 不为 0,选择 A,否则的话选择 B。

类似上面这种电路被称为组合电路,最为著名的组合电路是 算术逻辑单元(ALU,Arithmetic/logic unit)

时钟与存储单元

上面的我们提到的单个门电路,以及组合电路确实可以帮助我们进行逻辑计算,可计算机中的另一个问题,存储,该如何解决呢?在这之前,我们需要了解另外一个概念——时钟

众所周知,计算机中只有 0,1,就像上面的门电路一样,我们可以用 0,1 表示任何的逻辑和结果,但这里的问题是,如何控制这些门电路呢?如何调整输入来产生新的输出呢?答案就是通过电压的周期变化,处理器在一个时钟周期内可以将低电压变为高电压,再将高电压变回低电压,这就给电路带来了变化。你看看你电脑的 CPU 的主频就知道,这个时钟变化周期是相当短的,我们通常用 GHZ 这个单位,3 GHZ 表示 1 秒钟要经过 30 亿个时钟周期。时钟周期在一定程度上反应了处理器的运算速度,当然这也不绝对,处理器的速度还受电路结构、以及指令集的设计等等因素的影响。

我们再来看看两种存储单元,第一种存储单元是通过时钟来控制的,被称为 时钟寄存器(Clocked Register)

上图中的长方体就是一个存储单元,当处于低电压时,输入并不能直接反映到输出,单元的状态也没有改变。但是当时钟从低电压变成高电压,这里 “存储” 的输入就会反映到输出。在我们的 y86 中,我们会用时钟寄存器来存储 PC 和条件码。

另外一个存储单元是我们所熟知的寄存器和内存单元,我们通过地址和控制信号来对其进行读写,当然这里也少不了时钟信号:

关于这些存储单元内部是如何实现的,我们这里不需要知道的太多,把它当作一个功能模块即可。

指令的执行

指令的处理阶段

我们可能觉得一条汇编指令,能做的事情无非是做加减,做逻辑与或非,将数据从一个地方移动到另一个地方,执行这个指令应该非常简单才对。的确,跟我们所熟知的高级语言相比,汇编当中的一条指令能做的事情几乎少的不能再少,可你有没有想过,如果硬件层面要支持这些指令的执行,电路该如何设计呢?

这个电路架构必须能支持多个指令的运行,有些指令非常简单,类似 nop 这种,有些指令比较复杂,比如加减乘除等运算指令,还有些指令需要改变内存或寄存器状态,比如 mov 指令。在这个框架下,这些功能不同的指令都需要被正确地执行。考虑到一个指令的执行可能涉及到,指令的读取、寄存器的读取、内存的读取、功能模块的使用(比如前面说的 ALU)、条件码的改变、异常处理等等,我们将指令的执行分成了下面六个阶段:

  1. 读取(Fetch):将指令的编解码从内存中读取到处理器中,指令寄存器 Program Counter(PC)中的值会作为读取时的内存地址
  2. 解码(Decode):这一步将指令中的功能、操作对象解码出来,并根据操作对象,从对应的寄存器中读取数据
  3. 执行(Execute):算术逻辑单元(ALU)会依照解码出来的指令的不同做相应计算,并改变条件码(conditional code)
  4. 内存(Memory):从内存中读写数据
  5. 回写(Write Back):将指令执行的结果写回到寄存器中
  6. 指令指针更新(PC Update):将 PC 移动到下一个指令处

上面这六个阶段帮我们构建了指令执行的一个大致框架,虽然说我们还需要将每个阶段落实到硬件上,但是我们可以据此得出每个指令在每个阶段需要做的事情,这里有个例子:

这里列出了三种指令在每一阶段需要做的事,这三种指令分别是运算符指令和两个移动指令。我们以运算符指令为例来具体看看:

  1. 读取阶段,依据 PC 寄存器的值,从内存中获取了指令和操作对象的编解码
  2. 解码阶段,根据操作对象从寄存器中读取对应的值
  3. 执行阶段,根据指令让 ALU 做相应的运算,并根据运算结果来设定条件码
  4. 内存阶段,没做任何事情,因为运算符指令的执行不涉及内存的读取和写入
  5. 回写阶段,将前面执行阶段运算的结果写到对应的寄存器中
  6. 指令指针更新阶段,将 PC 寄存器更新

另外,我们之前提到的 pushqpopq 两个指令对寄存器和内存更改的先后次序也可以在这些步骤中得到保障:

你可以看到,每个阶段的执行与否,均不依赖之前阶段,这样的话每个阶段只需要负责每个阶段的事情即可。比如对于 pushq 指令,像我们前面说的,它会先将值写到内存,然后才更新栈寄存器 %rsp。假如说反过来,它先更新栈寄存器 %rsp,然后依据更新后的 %rsp 的值来操作内存,这就存在依赖了,也就是内存阶段的执行需要用到更新后的寄存器。

有了上面这些阶段,我们可以得到下面这张逻辑图:

注意,上面这只是逻辑图,并没有涉及硬件。下面这张才是一个初步的硬件架构图:

这张图一看过去非常复杂,这是因为我们没有分阶段来看。为了结合 HCL 来更好地定义状态,我们这里列出了指令的状态码:

NameValue(hex)Meaning
IHALT0Code for halt instruction
INOP1Code for nop instruction
IRRMOVQ2Code for rrmovq instruction
IIRMOVQ3Code for irmovq instruction
IRMMOVQ4Code for rmmovq instruction
IMRMOVQ5Code for mrmovq instruction
IOPL6Code for integer operation instruction
IJXX7Code for jump instruction
ICALL8Code for call instruction
IRET9Code for ret instruction
IPUSHQACode for pushq instruction
IPOPQBCode for popq instruction
FNONE0Default function code
RESP4Register ID for %rsp
RNONEFIndicates no register file access
ALUADD0Function for addition operation
SAOK1Status code for normal operation
SADR2Status code for address exception
SINS3Status code for illegal instruction exception
SHLT4Status code for halt

读取阶段(Fetch)

读取阶段要做的事情就是从内存中读取指令,我们可以得到下面这张图:

据此,我们也可以很容易写出相应的 HCL 对硬件进行描述,比如

1
2
bool need_regids = icode in { IRRMOVQ, IOPQ, IPUSHQ, IPOPQ, IIRMOVQ, IRMMOVQ, IMRMOVQ };
bool need_regid = icode in { IIRMOVQ, IRMMOVQ, IMRMOVQ, IJXX, ICALL  };

读取阶段把指令中所需要的信息进行拆分,并依据指令码生成下一个 PC 值 valP,如果有需要的话,也会生成标识符偏移量 valC

回写阶段(Write Back)与解码阶段(Decode)

这两个阶段都是围绕着寄存器进行的,所以将它们放在一起:

这里我们还是要依照指令名称的编解码来操作寄存器,HCL 的例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
word srcA = [
  icode in { IRRMOVQ, IRMMOVQ, IOPQ, IPUSHQ } : rA;
  icode in { IPOPQ, IRET } : RRSP;
  1 : RNONE; # Don’t need register
];

word dstE = [
  icode in { IRRMOVQ } : rB;
  icode in { IIRMOVQ, IOPQ} : rB;
  icode in { IPUSHQ, IPOPQ, ICALL, IRET } : RRSP;
  1 : RNONE; # Don’t write any register
];

执行阶段(Execute)

执行阶段围绕算术逻辑单元展开:

我们也可以得到对应的状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
word aluA = [
  icode in { IRRMOVQ, IOPQ } : valA;
  icode in { IIRMOVQ, IRMMOVQ, IMRMOVQ } : valC;
  icode in { ICALL, IPUSHQ } : -8;
  icode in { IRET, IPOPQ } : 8;
  # Other instructions don’t need ALU
];

word aluB = [
  icode in { IOPQ, IRMMOVQ, IMRMOVQ, ICALL, IPUSHQ, IRET, IPOPQ } : valB;
  icode in { IRRMOVQ, IIRMOVQ } : 0;
  # Other instructions don’t need ALU
];

word alufun = [
  icode == IOPQ : ifun;
  1 : ALUADD;
];

bool set_cc = icode in { IOPQ };

内存阶段(Memory)

内存阶段是对内存进行读写:

对应的 HCL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
word mem_addr = [
  icode in { IRMMOVQ, IPUSHQ, ICALL, IMRMOVQ } : valE;
  icode in { IPOPQ, IRET } : valA;
  # Other instructions don’t need address
];

bool mem_read = icode in { IMRMOVQ, IPOPQ, IRET };

bool mem_write = icode in { IRMMOVQ, IPUSHQ, ICALL };

bool stat = [
  mem_error || dmem_error: SADR;
  !instr_valid: SINS;
  icode == IHALT: SHLT;
  1: SAOK
];

指令指针更新阶段(PC Update)

这个阶段没什么好说的,就是更新指令寄存器 PC:

1
2
3
4
5
6
7
8
9
10
word 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;
];

流水线介绍

我们明确划分了阶段,知道了一个指令的执行会分成几个先后步骤,但是另外一个不得不考虑的问题就是 怎么优化性能?关于性能,有两个指标,一个是 延迟(Latency),另一个是 吞吐量(Throughput),延迟好理解,就是执行每条指令总共所需要的时间,比如我们上面将指令的执行分成了六个阶段,那么每个阶段所消耗的时间加在一起就是这个指令执行的延迟,每个指令所需要的操作不一样,延迟当然也就不一样。

吞吐量的定义是每秒钟执行的指令个数,你可能会说,这不就是延迟的倒数嘛,延迟确定了吞吐量也就确定了。这句话说对了一半,没错,吞吐量的确是延迟的倒数,但是吞吐量并不是由延迟决定的,因为我们还需要考虑并行,也就是多个指令可以同时被执行。而流水线是提升吞吐量的方式方法,什么是流水线?想象一下,在一个工厂,产品的生产包装分很多步骤,每个工人负责一个步骤,每个工人同时只能处理一个产品,这是不是和这里的指令执行很像?但是这里的重点是不同步骤上的工人可以同时开工,这样就会有多个产品同时被处理,流水线上的产品数量和延迟共同决定了吞吐量。

比如说,这里我们把前面说的六个阶段看成是一个整体,一个指令的执行需要等到下一个指令的完成,我们可以得到下面这张图:

上面的吞吐量计算为 1 instruction / (300 + 20) ps * (1000 ps / 1 ns) = 3.12 GIPS,其中 GIPS 指的是 giga-instructions per second,也就是我们前面说的每秒执行多少指令。

如果我们参考工厂的流水线生产,让不同阶段同时开工,我们可以得到下面这张图:

吞吐量计算为 1 instruction / (100 + 20) ps * (1000 ps / 1 ns) = 8.33 GIPS,这明显比之前的吞吐量要大许多。当然了,每个阶段的执行都是时钟信号在控制的:

这里还有一点需要注意,因为是分阶段的,每个阶段的执行时间是不一样的,而且不同指令执行时间也存在差别,所以,我们需要用前面说的时钟寄存器把结果保存下来,这样时钟信号可以控制每个阶段的开始和结束。这里对时钟信号的要求比较高,不能过快,不然某些阶段还没有执行完成,就开始下一个指令的执行了,当然也不能过慢,否则整个的延迟会上升。

如果说每个阶段的执行时间不一样,整个流水线的吞吐量会按照最慢的那个阶段计算,比如下面这个例子:

这里的吞吐量计算为 1 instruction / (150 + 20) ps * (1000 ps / 1 ns) = 5.88 GIPS。可以看到虽然阶段 A 执行的速度很快,但是还是要等待阶段 B,阶段 B 成了整个流水线的瓶颈。在理想情况下,每个阶段执行时间相同肯定是最优的,但是这几乎没法保证,我们只能说尽量均摊。

看到了我们可以通过流水线来提升整个处理器的吞吐量,你可能会想到,阶段的个数越多,流水线上同时处理的指令个数就越多,按照这个理论,我们需要尽可能地去拆分成更多的阶段?其实不然,虽然说我们可以同时执行多个指令,但是 整个流水线的吞吐量是由最慢的那个阶段决定的。在整个指令的执行中,我们还需要去操作寄存器,甚至是内存,这些步骤的开销本来就很大,而且没法进行拆分,即使把阶段数量拆分的再多,也解决不了问题。另外时钟信号的设计也很重要,需要和阶段的执行同步,这些都是在电路设计时需要重点考虑的。

关于流水线,还有一点很关键,就是依赖问题,比如说下面这几个连续的汇编指令:

1
2
3
irmovq $50, %rax
addq %rax , %rbx
mrmovq 100( %rbx ), %rdx

你可以看到,第二条指令需要用到第一条指令的结果,第三条指令需要用到第二条的结果。想要将流水线加到我们前面的六个阶段上,这些依赖问题需要得到处理。

流水线设计

我们前面说过,想要将指令的执行流水线化,我们需要在每个阶段加上时钟寄存器,据此,我们可以得到下面这个电路:

除了添加寄存器,我们还将第六个阶段移到了最开始,因为整个流程是循环的,所以不论是放在最开始还是最后面,电路逻辑并没有改变,只不过放在最开始更有助于理解。并且在这个阶段中,我们还新引入了 PC 预测(Predict PC)和 PC 选择(Select PC)的模块,PC 不是算好的吗,为啥还要预测呢?这里主要是考虑到一些特殊的指令,比如 ret 指令过后的 PC 需要去寄存器当中获取。还有对于条件判断指令 jXX 过后的 PC,因为前面的指令依然在流水线中,结果没有出来,我们这里只能做预测,说白了只能靠猜,条件判断无非就两种情况,即使随机猜也能有个 50% 的几率,猜对了继续执行正在执行的指令,猜错了大不了重新读取,这总比啥都不做要高效。Select PC 会从以下三种情况中选择 PC:

  • 预测的 PC(在 Y86-64 中,我们考虑条件判断恒成立,也就是猜 jXX 会执行跳转)
  • 当猜测错误时,在内存阶段中的 valA 的值,其实就是之前的 valP
  • ret 指令的返回地址(valM

我们之前也说到过,加入流水线后还需要解决指令相互之间的依赖问题。我们来看下面这个例子:

很明显,第三条指令依赖于头两条指令,如果不做任何处理的话会发生错误。我们在第二条指令和第三条指令插入三个空指令 nop

这上面的程序就正常运行了,因为当第三条指令执行时,第二条指令已经到了最后一个阶段(回写阶段),当第三条指令在解码阶段用到第二条指令的结果时,第二条指令已经执行完毕。所以,这给我们处理这种依赖问题提供了一种思路,说白了就是推迟执行(stall),如果发现依赖存在,后面的指令停在需要依赖的阶段不执行,直到依赖明确:

推迟执行的策略确实可以解决问题,但可以想像的是,这样做效率并不会很高,第三条指令等于说是在原地白白等了三个时钟周期,这会严重影响吞吐量。于是我们需要考虑另辟蹊径,对于上面这段程序来说,前面两个指令的结果其实在回写阶段之前就产生了,我们没有必要等到回写阶段结束后再从寄存器读取,中间的时候就可以让上面的阶段把结果传送回来,这样第三条指令就不需要白等上三个周期:

这种方式叫做数据推送(forwarding),这么做需要硬件电路的支持,需要在之前的电路之上加入数据推送模块(Fwd BFwd A):

数据推送确实比推迟执行要来的高效,但是在一个特殊情况下,数据推送会产生错误,比如下面这个例子:

其中指令 mrmovq 0(%rdx), %rax 会将内存中读到的结果写到寄存器 %rax 中,其后面的指令 addq %ebx, %eax 则会用到相同的寄存器 %eax (%rax)。这里的问题是,上面的指令的计算结果需要到内存阶段才能推送,而当前该指令才在执行阶段,这时内存阶段推送过来的数据其实是上上个指令的,如果直接使用就会产生错误。这个特殊情况被称之为 Load/Use data hazards

当然,解决这个特殊情况就可以考虑同时使用推迟执行和数据推送:

另外,我们之前也说了,ret 指令和 jXX 指令情况特殊,前者返回地址不确定,后者其实猜的,对不对还不知道,我们还需要考虑这两种情况。

对于 ret 指令来说,我们可以将其后面的指令推迟执行,直到返回地址明确:

如果条件判断猜测失败了怎么办?从电路设计图中可以看到,条件码(CC)是在执行阶段产生的,当条件码确定时,我们才会知道我们之前的猜测是对还是错,如果是对的就继续执行,如果是错的话,正在执行的指令需要被丢弃,类似下面这样:

可以看到,当我们发现猜测错误后,读取阶段开始读取新的指令,而错误执行的指令不会继续执行,停留在当前阶段,并推迟执行,对应阶段的状态会被后面的指令所覆盖。

异常处理与流水线下的电路

我们之前提到过,Y86-64 中有三种异常,分别是 停止执行、错误的指令码、错误的地址。另外,当我们引入流水线后,多个指令在不同阶段同时被执行,着就有可能同时发生异常,这是可能的。但我们需要保证的是,当程序发生异常后,寄存器、内存以及条件码不能再被写入,并且程序需要停止执行。

在引入了流水线和考虑异常处理后,我们再来看看每个阶段具体的情况

指令指针更新与读取阶段

我们引入了 PC 预测的模块,这个模块需要基于内存阶段和回写阶段的结果来对要读的指令进行预测,同时如果之前的预测错了也要更改:

经过 Predict PC 预测的 f_predPC 对应的 HCL 如下:

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

经过 Select PC 选择后的 f_pc 对应的 HCL 如下:

1
2
3
4
5
6
7
8
word f_pc = [
  # Mispredicted branch. Fetch at incremented PC
  M_icode == IJXX && !M_Cnd : M_valA;
  # Completion of RET instruction
  W_icode == IRET : W_valM;
  # Default: Use predicted value of PC
  1 : F_predPC;
];

可以看到的是,这个阶段存在两种异常,一是指令编解码异常,另外一个是内存读取异常,但是这个阶段我们并没有更新寄存器或者内存,所以我们要做的就是把状态(stat)记录到时钟寄存器中,到后面统一处理。

解码与回写阶段

因为解码和回写阶段都是围绕着寄存器展开的,解码阶段对寄存器读,回写阶段进行写入,因为硬件上的依赖相同这里我们把它们放到一起考虑:

这里最复杂的就是数据推送模块,下面这张表列出了数据推送的数据源:

Data wordRegister IDSource description
e_valEe_dstEALU output
m_valMM_dstMMemory output
M_valEM_dstEPending write to port E in memory stage
W_valMW_dstMPending write to port M in write-back stage
W_valEW_dstEPending write to port E in write-back stage

解码阶段的一个重点就是正确使用这些推送过来的数据,这些数据的优先级很重要,一般来说处于越早阶段的数据优先级越高,因为越早阶段执行的指令离当前指令更近。比如对于 valA 的 HCL 表示如下:

1
2
3
4
5
6
7
8
9
word d_valA = [
  D_icode in { ICALL, IJXX } : D_valP; # Use incremented PC
  d_srcA == e_dstE : e_valE;           # Forward valE from execute
  d_srcA == M_dstM : m_valM;           # Forward valM from memory
  d_srcA == M_dstE : M_valE;           # Forward valE from memory
  d_srcA == W_dstM : W_valM;           # Forward valM from write back
  d_srcA == W_dstE : W_valE;           # Forward valE from write back
  1 : d_rvalA;                         # Use value read from register file
];

执行阶段

执行阶段还是围绕着 ALU 展开的,和之前的类似,只不过这里我们引入了时钟寄存器

另外这个阶段会改变条件码 CC,这里我们加入了一个控制单元——Set CC,如果指令的执行中发生了异常,CC 将不会被更新。

内存阶段

内存阶段包括上面的回写阶段的很多结果都返回回去支持前面的阶段,比如我们之前说的数据推送、异常状态等等

控制单元

前面我们讲了很多很细节的东西,特别是一些特殊情况,这里做个总结:

  • 前面说的 Load/Use data hazards
  • ret 指令的处理
  • 如果预测失败后的处理
  • 指令执行中发生了异常

我们可以将这些特殊情况用 HCL 表示出来,以便我们得到合理的控制电路:

ConditionTrigger  
Processing retIRET ∈ {D_icode, E_icode, M_icode}  
Load/use hazardE_icode ∈ {IMRMOVQ, IPOPQ} && E dstM ∈ {d_srcA, d_srcB}  
Mispredicted branchE_icode = IJXX && !e_Cnd  
Exceptionm_stat ∈ {SADR, SINS, SHLT} W_stat ∈ {SADR, SINS, SHLT}

可以看到的是,仅仅靠我们在某个阶段引入电路模块,很难把这些特殊情况处理到位,因为处理这些情况需要暂停某些阶段的执行,这不是一个模块能做的,而需要一整个控制单元。这个控制单元能控制所有阶段的时钟寄存器,还需要能在寄存器之间发送空指令(bubble)。

在控制单元的控制下,时钟寄存器有下面三种情况:

第一种就是正常情况,当时钟信号升高,寄存器以及输出都变为输入。第二种是维持不变,也就是我们之前所说的推迟执行(stall),第三种是将寄存器清空,也就是取消指令的阶段执行。

现在回过头看除了异常之外的三种特殊情况,每种情况控制单元需要做的事情如下:

但是我们也说过,因为不同指令可能在不同阶段同时被执行,我们还需要考虑组合的情况:

比如这里的组合 A 就是当执行阶段发生了预测错误,解码阶段解码了 ret 指令,我们需要把这两个阶段放到一起考虑:

组合 B 也是同样的道理:

最后,我们可以把我们的控制单元加到之前的电路中,控制单元主要是控制时钟寄存器,这里就略去之前的模块:

当然了,我们可以用 HCL 定义出相应的控制信号,例如下面这些:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bool F_stall =
  # Conditions for a load/use hazard
  E_icode in { IMRMOVQ, IPOPQ } &&
  E_dstM in { d_srcA, d_srcB } ||
  # Stalling at fetch while ret passes through pipeline
  IRET in { D_icode, E_icode, M_icode };

bool D_stall =
  # Conditions for a load/use hazard
  E_icode in { IMRMOVQ, IPOPQ } &&
  E_dstM in { d_srcA, d_srcB };

bool D_bubble =
  # Mispredicted branch
  (E_icode == IJXX && !e_Cnd) ||
  # Stalling at fetch while ret passes through pipeline
  # but not condition for a load/use hazard
  !(E_icode in { IMRMOVQ, IPOPQ } && E_dstM in { d_srcA, d_srcB }) &&
  IRET in { D_icode, E_icode, M_icode };

性能分析与潜在的扩展

我们可以通过 CPI(cycles per instruction),也就是 执行每条指令所需的时钟周期,来预估整个电路的性能,CPI 的计算公式如下:

\[CPI = (C_i + C_b) / C_i = 1 + C_b/C_i\]

其中,$C_i$ 是需要处理的指令总数,$C_b$ 表示的是空指令执行的个数(由于需要处理特殊情况,控制单元会引入空指令)。可以看到的是 $C_b$ 和性能呈反相关,执行的无效指令越少,性能越好。

另外,我们的 Y86 的指令集是非常简单的,现代的处理器还需要考虑乘法、除法以及浮点数运算,这些运算会非常耗时。你可以想象一下,如果在我们设计的电路中考虑引入这些功能,那么这些高耗时的指令就会成为整个流水线的瓶颈,在执行它们时,后面的指令需要白白的等待,所以更好的一种方式是用不同的电路处理这些耗时的任务,然后最后再将结果进行合并。

还需要知道的是,我们这里的 CPI 计算假定每个阶段都能在一个时钟周期内完成,可事实完全不是这样,比如内存的写入和读取,内存说到底是一个层级结构,有兴趣你可以看看之前的 文章,简单来说,内存的读取和写入操作的开销可能非常大,我们需要考虑到一个时钟周期内没有完成的情况,这会改变我们的现有设计。

最后,我们没有考虑操作系统的影响,操作系统也是基于硬件电路构建的,它需要跟硬件进行配合。

This post is licensed under CC BY 4.0 by the author.