凌云的博客

行胜于言

处理器体系结构

分类:os| 发布时间:2025-05-26 20:59:00

概述

一个处理器支持的指令和指令的字节级编码称为它的指令集体系结构(Instruction-Set Architecture,ISA)。 不同的处理器“家族”,例如 Intel IA32、IBM/Freescale PowerPC 和 ARM 处理器家族,都有不同的 ISA。 一个程序编译成在一种机器上运行,就不能在另一种机器上运行。

我们将研究一个硬件系统执行某种 ISA 指令的方式。 这会使你更好理解计算机时如何工作的,以及计算机制造商们面临的技术挑战。 一个很重要的概念是,现代处理器的实际工作方式可能跟 ISA 隐含的计算模型大相径庭。 ISA 模型看上去应该是顺序指令执行,也就是先去除取出一条指令,等到它执行完毕,再开始下一条。 然而,与一个时刻只执行一条指令相比,通过同时处理多条指令的不同部分,处理器可以获得较高的性能。

Y86 指令集体系结构

定义一个指令集体系结构,例如 Y86,包括定义各种状态元素、指令集和它们的编码、一组程序规范和异常事件处理。

程序员可见的状态

如 图 1 所示,Y86 程序中的每条指令都会读取或修改处理器状态的某些部分。 这称为程序员可见状态,这里的“程序员”既可以是用汇编代码写程序的人,也可以是产生机器级代码的编译器。 在处理器实现中,只要我们保证机器级程序能够访问程序员可见状态,就不需要完全按照 ISA 隐含的方式来表示和组织这个处理器状态。 Y86 的处理器状态类似于 IA32。 有 8 个程序寄存器:%eax、%ecx、%edx、%esi、%edi、%esp 和 %ebp。 寄存器 %esp 被入栈、出栈、调用和返回指令作为栈指针。

在其他情况中,寄存器没有固定的含义或固定值。 有 3 个一位的条件码:ZF、SF 和 OF,它们保存最近的算术或逻辑指令所造成影响的有关信息。 程序计数器(PC)存放当前正在执行指令的地址。

图 1 Y86 程序员可见状态

存储器,从概念上来说就是一个很大的字节数组,保存着程序和数据。 Y86 程序用虚拟地址来引用存储器位置。 硬件和操作系统软件联合起来将虚拟地址翻译成实际或物理地址。

程序状态的最后一个部分是状态码 Stat,它表明程序执行的总体状态。 它会指示是正常运行,还是出现了某种异常。

Y86 指令

图 2 给出了 Y86 ISA 中各个指令的简单描述。 这个指令集就是我们处理器实现的目标。 Y86 指令集基本上是 IA32 指令集的一个子集。 它只包括四字节整数操作,寻址方式比较少,操作也比较少。 这个图中,左边是指令的汇编码表示,右边是字节编码。 汇编代码格式类似于 IA32 的 ATT 格式。

图 2 Y86 指令集

以下是 Y86 指令的更多细节:

  • IA32 的 movl 指令分成了 4 个不同指令:irmovl、rrmovl、mrmovl 和 rmmovl,分别显式地知名源和目的的格式。源可以是立即数(i)、寄存器(r)或存储器(m)。目的可以是寄存器(r)或存储器(m)。
    同 IA32 一样,不允许从一个存储器地址直接传送到另一个存储器地址。另外,也不允许将立即数传送到存储器。
  • 有 4 个整数操作指令,如图 2 所示的 OPl。它们是 addl、subl、andl 和 xorl。 它们只对寄存器数据进行操作,这些指令会设置 3 个条件码 ZF、SF 和 OF(零、符合和溢出)。
  • 7 个跳转指令(图 2 中的 jXX)是 jmp、jle、jl、je、jge 和 jg。根据分支指令的类型和条件码的设置来选择分支。
  • 有 6 个条件传送指令(图 2 中的 cmovXX): cmovle、cmovl、cmove、cmovne、cmovge 和 cmovg。 这些指令的格式与寄存器——寄存器传送指令 rrmovl 一样,但是只有当条件码满足所需要的约束时,才会更新目的寄存器的值。
  • call 指令将返回地址入栈,然后跳到目的地址。ret 指令从这样的过程调用中返回。
  • pushl 和 popl 指令实现了入栈和出栈,就像在 IA32 中一样。
  • halt 指令停止指令的执行。 IA32 中有一个与之相当的指令 hlt。 IA32 的应用程序不允许使用这条指令,因为它会导致整个系统暂停运行。 对于 Y86 来说,执行 halt 指令会导致处理器停止,并将状态码设置为 HLT。

指令编码

每条指令的第一个字节表明指令的类型。 这个字节分为两个部分,每部分 4 位:高 4 位是代码(code)部分,低 4 位是功能(function)部分。 图 3 给出了整数操作、条件传送和分支指令的具体编码。 可以观察到,rrmovl 与条件传送有同样的指令代码。 可以把它看作是一个“无条件传送”,就好像 jmp 指令是无条件跳转一样,它们的功能代码都是 0。

图 3 Y86 指令集的功能码

如图 4 所示,8 个程序寄存器中每个都有相应的 0~7 的寄存器标识符(register ID)。 Y86 中的寄存器编号跟 IA32 中的相同。 程序寄存器存在 CPU 中的一个寄存器文件中,这个寄存器文件就是一个小的、以寄存器 ID 作为地址的随机访问存储器。 在指令编码中以及我们的硬件设计中,当需要指明不应访问任何寄存器时,就用 ID 值 0xF 来表示。

图 4 Y86 程序寄存器标识符

有的指令只有一个字节长,而有的需要操作数的指令编码就更长一些。 首先,可能有附加的寄存器指示符字节(register specifier byte),指定一个或两个寄存器。 在图 2 中,这些寄存器字段为 rA 和 rB。 没有寄存器操作数的指令,例如分支指令和 call 指令,就没有寄存器指示符字节。 而那些只需要一个寄存器操作数的指令(irmovl、pushl 和 popl)将另一个寄存器指示符设为 0xF。

有些指令需要一个附加的 4 字节常数字。 这个字能作为 irmovl 的立即数数据,rmmovl 和 mrmovl 的地址指示符的偏移量,以及分支指令和调用指令的目的地址。 注意,分支指令和调用指令的目的地址是一个绝对地址,而不是像 IA32 中那样使用 PC(程序计数器)相对寻址方式。 同 IA32 一样,所有整数采用小端法(little-endian)编码。 当指令按照反汇编格式书写时,这些字节就以相反的顺序出现。

指令集的一个重要特质就是字节编码必须有唯一的解释。 任意一个字节序列要么是一个唯一的指令序列的编码,要么就不是一个合法的字节序列。 Y86 就具有这个性质。 即使代码嵌入在程序的其他字节中,只要从序列的第一个字节开始处理,我们仍然可以很容易地确定指令序列。 反过来说,如果不知道一段代码序列的起始位置,我们就不能准确地确定怎样将序列划分为单独的指令。

比较 IA32 和 Y86 的指令编码
同 IA32 中的指令编码相比,Y86 的编码简单得多,但是没那么紧凑。 在所有的 Y86 指令中,寄存器字段的位置都是固定的,而在不同的 IA32 指令中,它们的位置是不一样的。 即使最多只有 8 个可能的寄存器,我们也对寄存器采用了 4 位编码。 IA32 只用了 3 位编码。 所以 IA32 能将入栈或出栈指令放进一个字节里,5 位字段表明指令的类型,剩下的 3 位是寄存器指示符。 IA32 可以将常数编码成 1、2 或 4 个字节,而 Y86 总是将常数值编码成 4 个字节。

CISC 早期的 RISC
指令数量很多。Intel 描述全套指令的文档有 1200 多页。 指令数量少很多。通常少于 100 个。
有些指令延迟很长。包括将一个整块从存储器的一个部分复制到另一部分的指令,以及其他一些将多个寄存器的值复制到存储器或从存储器复制到多个寄存器的指令。 没有较长延迟的指令。有些早期的 RISC 机器甚至没有整数乘法指令,要求编译器通过一系列加法来实现乘法。
编码是可变长度的。IA32 的指令长度可以使 1~15 个字节。 编码是固定长度的。通常所有的指令都编码为 4 个字节。
指定操作数的方式很多样。在 IA32 中,存储器操作数指示符可以有许多不同的组合,这些组合由偏移量、基址和变址以及伸缩因子组成。 简单寻址方式。通常只有基址和偏移量寻址。
可以对存储器和寄存器操作数进行算术和逻辑运算。 只能对寄存器操作数进行算术和逻辑运算。允许使用存储器引用的只有 load 和 store 指令,load 从存储器读到寄存器,store 从寄存器写到存储器。这种方法被称为 load/store 体系结构。
对机器级程序来说实现细节是不可见的。ISA 提供了程序和如何执行程序之间的清晰的抽象。 对机器级程序来说实现细节是可见的。有些 RISC 机器禁止某些特殊的指令序列,而有些跳转要到下一条指令执行完了以后才会生效。编译器必须在这些约束条件下进行性能优化。
有条件码。作为指令执行的副产品,设置了一些特殊的标志位,可以用于条件分支检测。 没有条件码。相反,对条件检测来说,要用明确的测试指令,这些指令会将测试结果放在一个普通的寄存器中。
栈密集的过程链接。栈被用来存取过程参数和返回地址。 寄存器密集的过程链接。寄存器被用来存取过程参数。因此有些过程能完全避免存储器引用。通过处理器有更多的(最多的有 32 个)寄存器。

Y86 异常

对 Y86 来说,程序员可见的状态(见 图 1)包括状态码 Stat,它描述程序执行的总体状态。 这个代码可能的值如图 5 所示。

名字 含义
1 AOK 正常操作
2 HLT 处理器执行 halt 指令
3 ADR 遇到非法地址
4 INS 遇到非法指令

图 5 Y86 状态码(在我们的设计中,任何 AOK 以外的代码都会使处理器停止)

Y86 程序

图 6 是下面这个 C 函数的 IA32 和 Y86 汇编代码:

int Sum(int *Start, int Count)
{
    int sum = 0;
    while (Count) {
        sum += *Start;
        Start++;
        Count--;
    }
    return sum;
}

图 6 Y86 汇编程序与 IA32 汇编程序的比较

这段 IA32 代码是 GCC 编译器产生的。 Y86 代码实质上是一样的,只不过 Y86 有时需要两条指令来完成 IA32 一条指令就能完成的事情。 然而,如果用数组索引来写这个程序,要转换成 Y86 代码就困难多了,因为 Y86 没有伸缩寻址模式。

图 7 给出了用 Y86 汇编代码编写的一个完整的程序文件的例子。 这个程序既包括数据,也包括指令。 命令(directive)指明应该将代码或数据放在什么位置,以及如何对齐。 这个程序详细说明了栈的位置、数据初始化、程序初始化和程序结束等问题。

命令 .pos0 (第 2 行)告诉汇编器应该从地址 0 处开始产生代码。 这个地址是所有 Y86 程序的起点。 接下来的两条指令(第 3 行和第 4 行)初始化栈指针和帧指针。 程序结尾处(第 47 行)声明了标号 Stack,并且用一个 .pos 命令(第 46 行)指明地址 0x100。 因此栈会从这个地址开始,向低地址增长。

 1  # Execution begins at address 0
 2          .pos 0
 3  init:   irmovl Stack, %esp      # Set up stack pointer
 4          irmovl Stack, %ebp      # Set up base pointer
 5          call Main               # Execute main program
 6          halt                    # Terminate program
 7
 8  # Array of 4 elements
 9          .align 4
10  array:  .long 0xd
11          .long 0xc0
12          .long 0xb00
13          .long 0xa000
14
15  Main:   pushl %ebp
16          rrmovl %esp,%ebp
17          irmovl $4,%eax
18          pushl %eax # Push 4
19          irmovl array,%edx
20          pushl %edx # Push array
21          call Sum # Sum(array, 4)
22          rrmovl %ebp,%esp
23          popl %ebp
24          ret
25
26  # int Sum(int *Start, int Count)
27  Sum:    pushl %ebp
28          rrmovl %esp,%ebp
29          mrmovl 8(%ebp),%ecx     # ecx = Start
30          mrmovl 12(%ebp),%edx    # edx = Count
31          xorl %eax,%eax          # sum = 0
32          andl %edx,%edx          # Set condition codes
33          je End
34  Loop:   mrmovl (%ecx),%esi      # get *Start
35          addl %esi,%eax          # add to sum
36          irmovl $4,%ebx          #
37          addl %ebx,%ecx          # Start++
38          irmovl $-1,%ebx         #
39          addl %ebx,%edx          # Count--
40          jne Loop                # Stop when 0
41  End:    rrmovl %ebp,%esp
42          popl %ebp
43          ret
44
45  # The stack starts here and grows to lower addresses
46          .pos 0x100
47  Stack:

图 7 用 Y86 汇编代码编写的一个例子程序

图 8 是 YAS 汇编器对图 7 中代码进行汇编的结果。

                    |   # Execution begins at address 0
0x000:              |           .pos 0
0x000: 30f400010000 |   init:   irmovl Stack, %esp      # Set up stack pointer
0x006: 30f500010000 |           irmovl Stack, %ebp      # Set up base pointer
0x00c: 8024000000   |           call Main               # Execute main program
0x011: 00           |           halt                    # Terminate program
                    |
                    |   # Array of 4 elements
0x014:              |           .align 4
0x014: 0d000000     |   array:  .long 0xd
0x018: c0000000     |           .long 0xc0
0x01c: 000b0000     |           .long 0xb00
0x020: 00a00000     |           .long 0xa000
                    |
0x024: a05f         |   Main:   pushl %ebp
0x026: 2045         |           rrmovl %esp,%ebp
0x028: 30f004000000 |           irmovl $4,%eax
0x02e: a00f         |           pushl %eax              # Push 4
0x030: 30f214000000 |           irmovl array,%edx
0x036: a02f         |           pushl %edx              # Push array
0x038: 8042000000   |           call Sum                # Sum(array, 4)
0x03d: 2054         |           rrmovl %ebp,%esp
0x03f: b05f         |           popl %ebp
0x041: 90           |           ret
                    |
                    |   # int Sum(int *Start, int Count)
0x042: a05f         |   Sum:    pushl %ebp
0x044: 2045         |           rrmovl %esp,%ebp
0x046: 501508000000 |           mrmovl 8(%ebp),%ecx     # ecx = Start
0x04c: 50250c000000 |           mrmovl 12(%ebp),%edx    # edx = Count
0x052: 6300         |           xorl %eax,%eax          # sum = 0
0x054: 6222         |           andl %edx,%edx          # Set condition codes
0x056: 7378000000   |           je End
0x05b: 506100000000 |   Loop:   mrmovl (%ecx),%esi      # get *Start
0x061: 6060         |           addl %esi,%eax          # add to sum
0x063: 30f304000000 |           irmovl $4,%ebx          #
0x069: 6031         |           addl %ebx,%ecx          # Start++
0x06b: 30f3ffffffff |           irmovl $-1,%ebx         #
0x071: 6032         |           addl %ebx,%edx          # Count--
0x073: 745b000000   |           jne Loop                # Stop when 0
0x078: 2054         |   End:    rrmovl %ebp,%esp
0x07a: b05f         |           popl %ebp
0x07c: 90           |           ret
                    |
                    | # The stack starts here and grows to lower addresses
0x100:              |           .pos 0x100
0x100:              |   Stack:

我们实现了一个指令集模拟器,称为 YIS,它的目的是模拟 Y86 机器代码程序的执行,而不用试图去模拟任何具体处理器实现的行为。 这种形式的模拟有助于在有实际硬件可用之前调试程序,也有助于检查模拟硬件或者在硬件上运行程序的结果。 用 YIS 运行例子的目标代码,产生下面这样的输出:

Stopped in 52 steps at PC = 0x11. Status ’HLT’, CC Z=1 S=0 O=0
Changes to registers:
%eax:   0x00000000      0x0000abcd
%ecx:   0x00000000      0x00000024
%ebx:   0x00000000      0xffffffff
%esp:   0x00000000      0x00000100
%ebp:   0x00000000      0x00000100
%esi:   0x00000000      0x0000a000

Changes to memory:
0x00e8: 0x00000000      0x000000f8
0x00ec: 0x00000000      0x0000003d
0x00f0: 0x00000000      0x00000014
0x00f4: 0x00000000      0x00000004
0x00f8: 0x00000000      0x00000100
0x00fc: 0x00000000      0x00000011

一些 Y86 指令的详情

大多数 Y86 指令是以一种直接的方式修改程序状态的,不过两个特别的指令组合需要特别注意一下。

pushl 指令会把栈指针减 4,并且将一个寄存器值写入存储器中。 因此,当执行 pushl %esp 指令时,处理器的行为是不确定的,因为要入栈的寄存器会被同一条指令修改。

通常有两种约定:

  • 压入 %esp 的原始值
  • 压入减去 4 的 %esp 的值

对于 Y86 处理器来说,我们采用和 IA32 一样的方法:压入 %esp 的原始值。

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

HCL(Hardware Control Language,硬件控制语言)。 在硬件设计中,用电子电路来计算对位进行运算的函数,以及在各种存储器元素中存储位。 要实现一个数字系统需要三个主要的组成部分:计算对位进行操作的函数的组合逻辑、存储位的存储器元素,以及控制存储器元素更新的时钟信号。

逻辑门

逻辑门是数字电路的基本计算元素。 它们产生的输出,等于它们输入位值的某个布尔函数。 图 9 是布尔函数 AND、OR 和 NOT 的标准符号,C 语言中运算符的逻辑门下面是对应的 HCL 表达式:AND 用 && 表示,OR 用 || 表示,而 NOT 用 ! 表示。 我们用这些符号而不用 C 语言中位运算符 &、| 和 ~,是因为逻辑门只对单个位进行操作,而不是整个字。 虽然图中只说明了 AND 和 OR 们的两个输入的版本,但是常见的是它们作为 n 路操作,n>2。

逻辑门总是活动的。一旦一个门的输入变化了,在很短的时间内,输出就会相应地变化。

图 9 逻辑门类型

组合电路和 HCL 布尔表达式

将很多的逻辑门组合成一个网,就能构建计算块,称为组合电路。 构建这些网有两条限制:

  • 两个或多个逻辑门的输出不能连接在一起。否则它们可能会使线上的信号矛盾,可能会导致一个不合法的电压或电路故障
  • 这个网必须是无环的。也就是在网中不能有路径经过一系列的们而形成一个回路,这样的回路会导致该网络计算的函数有歧义

图 10 是一个非常有用的简单组合电路例子。 它有两个输入 a 和 b,有唯一的输出 eq,当 a 和 b 都是 1 或都是 0 时,输出位 1。 用 HCL 来写这个网的函数就是:

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

图 10 检测位相等的组合电路

从这个例子可以看出 HCL 使用了 C 语言风格的语法,'=' 将一个信号名与一个表达式联系起来。

图 11 给出了另一个简单但很有用的组合电路,称为多路复用器(multiplexor,通常称为 “MUX”)。 输出信号的 HCL 表达式为:

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

图 11 单个位的多路复用器电路

HCL 表达式很清楚地表明了组合逻辑电路和 C 语言中逻辑表达式的对应之处。 它们都是用布尔操作来对输入进行计算的函数。 值得注意的是,这两种表达计算的方法之间有以下区别:

  • 因为组合电路是由一系列的逻辑门组成,它的属性是输出会持续地响应输入的变化。 如果电路的输入变化了,在一定的延迟之后,输出也会相应地变化。 相比之下,C 表达式只会在程序执行过程中被遇到时才进行求值。
  • C 的逻辑表达式允许参数是任意整数,0 表示 FALSE,其他任何值都表示 TRUE。 而逻辑门只对位值 0 和 1 进行操作。
  • C 的逻辑表达式有个属性就是它们可能只被部分求值。 如果一个 AND 或 OR 操作的结果只用对第一个参数求值就能确定,那么就不会对第二个参数求值了。

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

通过将逻辑门组合成大的网,可以构造出能计算更加复杂函数的组合电路。 通常,我们设计能对数据字(word)进行操作的电路。

执行字级计算的组合电路根据输入字的各个位,用逻辑门来计算输出字的各个位。 如图 12 中的一个组合电路,它测试两个 32 位字 A 和 B 是否相等。 也就是,当且仅当 A 的每一位都和 B 的相应位相等时,输出才为 1。

图 12 字级相等测试电路

在 HCL 中,我们将所有字级的信号都声明为 int,不指定字的大小。 这样做是为了简单。 在全功能的硬件描述语言中,每个字都可以声明为有特定的位数。 HCL 允许比较字是否相等,因为 图 12 所示电路的函数可以表达成:

bool Eq = (A == B);

这里参数 A 和 B 是 int 型的。 注意我们使用和 C 语言一样的语法习惯,‘=’ 表示赋值,而 ‘==’ 是相等运算符。

如图 12 中右边所示,在画字级电路的时候,我们用中等粗度的线来表示携带字的每个位的线路,而用虚线来表示布尔信号的结果。

图 13 是字级的多路复用器电路。 合格电路根据控制输入位 s,产生一个 32 位的字 Out,等于两个输入字 A 或者 B 中的一个。 这个电路由 32 个相同的子电路组成,每个子电路的结构都类似于图 11 中位级多路复用器。

处理器中会用到很多种多路复用器。 使得我们能根据某些控制条件,从许多源中选出一个字。 在 HCL 中,多路复用函数是用情况表达式(case expression)来描述的。 格式如下:

[
    select_1 : expr_1
    select_2 : expr 2
             .
             .
             .
    select_k : expr_k
]

这个表达式包含一系列情况,每种情况 i 都有一个布尔表达式 selecti 和一个整数表达式 expri

同 C 语言的 switch 语句不同,我们不要求不同的选择表达式之间互斥。 从逻辑上讲,这些选择表达式是顺序求值的,且第一个求值为 1 的情况会被选中。 图 13 中的字级多路复用器用 HCL 来描述就是:

int Out = [
    s: A;
    1: B;
];

在这段代码中,第二个选择表达式就是 1,表明如果前面没有情况被选中,那就选择这种情况。 这是 HCL 中一种指定默认情况的方法。 几乎所有的情况表达式都是以此结尾的。

图 13 字级多路复用器电路

允许不互斥的选择表达式使得 HCL 代码可读性更好。实际硬件多路复用器的信号必须互斥。 要将一个 HCL 情况表达式翻译成硬件,逻辑合成程序需要分析选择表达式金河,并解决任何可能的冲突,确保只有第一个满足的情况才会被选中。

选择表达式可以是任意的布尔表达式,可以有任意多的情况。 这就使得情况表达式能描述带复杂选择标准的、多种输入信号的块。 如图 14 所示的四路复用器的图。 这个电路根据控制信号 s1 和 s0,从 4 个输入字 A、B、C 和 D 中选择一个,将控制信号看作一个两位的二进制数。 可以用 HCL 来表示这个电路,用布尔表达式描述控制位模式的不同组合:

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

图 14 四路复用器

组合逻辑电路可以设计成在字级上执行许多不同类型的操作。 具体的设计已经超出了讨论的范围。 算术/逻辑单元(ALU)是一种很重要的组合电路,图 15 是它的一个抽象图示。 可以看到,这个 ALU 的四个操作对应于 Y86 指令集支持的四种不同的整数操作,而控制值和这些操作的功能码相对应(图 3)。 我们还注意到减法的操作顺序,是输入 B 减去输入 A。 之所以这样做,是为了使这个顺序与 subl 指令的参数顺序一致。

图 15 算术/逻辑单元(ALU)

存储器和时钟

组合电路从本质上讲,不存储任何信息。 它们只是简单地响应输入信号,产生等于输入的某个函数的输出。 为了产生时序电路,也就是有状态并且在这个状态上进行计算的系统,我们必须引入按位存储信息的设备。 存储设备都是由同一个时钟控制,时钟是一个周期性信号,决定什么时候要把新值加载到设备中。

考虑两类存储器设备:

  • 时钟寄存器(简称寄存器)存储单个位或字。时钟信号控制寄存器加载输入值。
  • 随机访问存储器(简称存储器)存储多个字,用地址来选择该读或写哪个字。随机访问存储器的例子包括:1)处理器的虚拟存储器系统,硬件和操作软件系统结合起来使处理器可以在一个很大的地址空间内访问任意的字;2)寄存器文件,在此,寄存器标识符作为地址。在 IA32 或 Y86 处理器中,寄存器文件有 8 个程序寄存器(%eax、%ecx 等)。

在硬件和机器级编程时,“寄存器”这个词是两个有细微差别的事情。 在硬件中,寄存器直接将它的输入和输出线连接到电路的其他部分。 在机器级编程中,寄存器代表的是 CPU 中为数不少的可寻址的字,这里的地址是寄存器 ID。 需要避免歧义时,我们会分别称呼这两类寄存器为 “硬件寄存器” 和 “程序寄存器” 。

图 16 更详细地说明了一个硬件寄存器以及它是如何工作的。 大多数时候,寄存器都保持在稳定状态(用 x 表示),产生的输出等于它的当前状态。 信号沿着寄存器前面的组合逻辑传播,这时,产生了一个新的寄存器输入(用 y 表示),但只要时钟是低电位的,寄存器的输出就仍然保持不变。 当时钟变成高电位的时候,输入信号就加载到寄存器中,成为下一个状态 y,直到下一个时钟上升沿,这个状态就一直是寄存器的新输出。 关键是寄存器是作为电路不同部分中的组合逻辑之间的屏障。 每当每个时钟到达上升沿时,值才会从寄存器的输入传送到输出。

图 16 寄存器操作

Y86 的顺序实现

现在已经有了实现 Y86 处理器所需要的部件。 首先,将一个 SEQ(“sequential” 顺序的)处理器。

每个时钟周期上,SEQ 执行处理一条完整指令所需要的所有步骤。 不过,这需要一个很长的时钟周期时间,因此时钟周期频率会低到不可接受。 我们开发 SEQ 的目标就是提供实现最终目标的第一步,我们的最终目标是实现一个高效的、流水线化的处理器。

将处理组织成阶段

通常,处理一条指令包括很多操作。 将它们组织成某个特殊的阶段序列,即使指令的动作差异很大,但所有的指令都遵循统一的序列。 每一步的具体处理取决于正在执行的指令。 创建这样一个框架,我们便能够设计一个能充分利用硬件的处理器。 下面是关于各个阶段以及各阶段内执行操作的简略描述:

  • 取值(fetch):取指阶段从存储器读取指令字节,地址为程序计数器(PC)的值。 从指令中抽取出指令指示符字节的两个四位部分,称为 icode(指令代码)和 ifun(指令功能)。 它可能取出一个寄存器指示符字节,指明一个或两个寄存器操作数指示符 rA 和 rB。 它还可能取出一个四字节常数字 valC。 它按顺序方式计算当前指令的下一条指令的地址 valP。 也就是说,valP 等于 PC 的值加上已取出指令的长度。
  • 译码(decode):译码阶段从寄存器文件读入最多两个操作数,得到值 valA 和/或 valB。 通常,它读入指令 rA 和 rB 字段指明的寄存器,不过有些指令是都寄存器 %esp 的。
  • 执行(execute):在执行阶段,算术/逻辑单元(ALU)要么执行指令指明的操作(根据 ifun 的值),计算存储器引用的有效地址,要么增加或减少栈指针。得到的值我们称为 valE。 在此,也可能设置条件码。对一条跳转指令来说,这个阶段会检验条件码和(ifun 给出的)分支条件,看是不是应该选择分支。
  • 访存(memory):访存阶段可以将数据写入存储器,或者从存储器读出数据。读出的值为 valM。
  • 写回(write back):写回阶段最多可以写两个结果到寄存器文件。
  • 更新 PC(PC update):将 PC 设置成下一条指令的地址。

我们面临的一个挑战是将每条不同的指令所需要的计算放入到上述那个通用框架中。我们会使用图 17 中所示的代码来描述不同 Y86 指令的处理。 图 18~图 21 表描述了不同 Y86 指令在各个阶段是怎样处理的。

 1      0x000: 30f209000000 |   irmovl $9, %edx
 2      0x006: 30f315000000 |   irmovl $21, %ebx
 3      0x00c: 6123         |   subl %edx, %ebx         # subtract
 4      0x00e: 30f480000000 |   irmovl $128,%esp        # Problem 4.11
 5      0x014: 404364000000 |   rmmovl %esp, 100(%ebx)  # store
 6      0x01a: a02f         |   pushl %edx              # push
 7      0x01c: b00f         |   popl %eax               # Problem 4.12
 8      0x01e: 7328000000   |   je done                 # Not taken
 9      0x023: 8029000000   |   call proc               # Problem 4.16
10      0x028:              | done:
11      0x028: 00           |   halt
12      0x029:              | proc:
13      0x029: 90           |   ret                     # Return

图 17 Y86 指令序列示例

图 18 是对 OPl(整数和逻辑运算)、rrmovl(寄存器-寄存器传送)和 irmovl(立即数-寄存器传送)类型的指令所需的处理。 回顾 图2,可以看到我们小心挑选了指令编码,这样四个整数操作(addl、subl、andl 和 xorl)都有相同的 icode 值。 我们可以用相同的步骤来处理它们,除了 ALU 计算必须根据 ifun 中编码的具体的指令操作来设定。

阶段 OPl rA, rB rrmovl rA, rB irmovl V, rB
取指 icode:ifun ← M1[PC]
rA:rB ← M1[PC+1]

valP ← PC+2
icode:ifun ← M1[PC]
rA:rB ← M1[PC+1]

valP ← PC+2
icode:ifun ← M1[PC]
rA:rB ← M1[PC+1]
valC ← M4[PC+2]
valP ← PC+6
译码 valA ← R[rA]
valB ← R[rB]
valA ← R[rA]
执行 valE ← valB OP valA
Set CC
valE ← 0 + valA valE ← 0 + valC
访存
写回 R[rB] ← valE R[rB] ← valE R[rB] ← valE
更新PC PC ← valP PC ← valP PC ← valP

图 18 Y86指令 OPl、rrmovl 和 irmovl 在顺序实现中的计算

作为一个例子,让我们看看一条 subl 指令的处理过程,这条指令是 图 17 所示目标代码的第 3 行中的 subl 指令。

阶段通用具体
OPl rA, rBsubl %edx, %ebx
取指icode:ifun ← M1[PC]
rA:rB ← M1[PC+1]

valP ← PC+2
icode:ifun ← M1[0x00c]=6:1
rA:rB ← M1[0x00d]=2:3

valP ← 0x00c + 2 = 0x00e
译码valA ← R[rA]
valB ← R[rB]
valA ← R[%edx] = 9
valB ← R[%ebx] = 21
执行valE ← valB OP valA
Set CC
valE ← 21 - 9 = 12
ZF ← 0, SF ← 0, OF ← 0
访存
写回R[rB] ← valER[%ebx] = valE = 12
更新PCPC ← valPPC ← valP = 0x00e

这个跟踪表明,我们达到了理想的效果,寄存器 %ebx 设成了 12,三个条件码都设成了 0,而 PC 加了 2。

图 19 给出了存储器读写指令 rmmovlmrmovl 所需要的处理。 基本流程也和前面的一样,不过是用 ALU 来加 valC 和 valB,得到存储器操作的有效地址(偏移量与基址寄存器值之和)。 在访存阶段,会将寄存器值 valA 写到存储器,或者从存储器中读出 valM。

阶段 rmmovl rA, D(rB) mrmovl D(rB), rA
取指 icode:ifun ← M1[PC]
rA:rB ← M1[PC+1]
valC=M4[PC+2]
valP ← PC+6
icode:ifun ← M1[PC]
rA:rB ← M1[PC+1]
valC=M4[PC+2]
valP ← PC+6
译码 valA ← R[rA]
valB ← R[rB]
valB ← R[rB]
执行 valE ← valB + valC valE ← valB + valC
访存 M4[valE] ← valA valM ← M4[valE]
写回 R[rA] ← valM
更新PC PC ← valP PC ← valP

图 19 Y86 指令 rmmovl 和 mrmovl 在顺序实现中的计算

图 20 给出了处理 pushlpopl 指令所需的步骤。

阶段 pushl rA popl rA
取指 icode:ifun ← M1[PC]
rA:rB ← M1[PC+1]

valP ← PC+2
icode:ifun ← M1[PC]
rA:rB ← M1[PC+1]

valP ← PC+2
译码 valA ← R[rA]
valB ← R[%esp]
valA ← R[%esp]
valB ← R[%esp]
执行 valE ← valB + (-4) valE ← valB + 4
访存 M4[valE] ← valA valM ← M4[valA]
写回 R[%esp] ← valE R[%esp] ← valE
R[rA] ← valM
更新PC PC ← valP PC ← valP

图 20 Y86 指令 pushl 和 popl 在顺序实现中的计算

图 21 表明了三类控制转移指令的处理:各种跳转、callret

阶段 jXX Dest call Dest ret
取指 icode:ifun ← M1[PC]

valC ← M4[PC+1]
valP ← PC+5
icode:ifun ← M1[PC]

valC ← M4[PC+1]
valP ← PC+5
icode:ifun ← M1[PC]


valP ← PC+1
译码
valB ← R[%esp]
valA ← R[%esp]
valB ← R[%esp]
执行 Cnd ← Cond(CC, ifun) valE ← valB + (-4) valE ← valB + 4
访存 M4[valE] ← valP valM ← M4[valA]
写回 R[%esp] ← valE R[%esp] ← valE
更新PC PC ← Cnd ? valC : valP PC ← valC PC ← valM

图 21 Y86 指令 jXX、call 和 ret 在顺序实现中的计算

SEQ 硬件结构

实现所有 Y86 指令所需要的计算可以组织成六个基本阶段:取指、译码、执行、访存、写回和更新 PC。 图 22 是一个能执行这些计算的硬件结构的抽象表示。 程序计数器放在寄存器中,在图的左下角,信息沿着线流动,先向上,再向右。 同各个阶段相关的硬件单元负责执行这些出来。 在右边,反馈线路向下,包括要写到寄存器文件的更新值,以及更新程序计算器值。 在 SEQ 中,所有硬件单元的处理都在一个时钟周期内完成。 这张图省略了一些小的组合逻辑块,还省略了所有用来操作各个硬件单元以及将相应的值路由到这些单元的控制逻辑。 稍后会补充这些细节。

图 22 SEQ 的抽象视图

硬件单元与各个处理阶段相关联:

  • 取指:将程序计数器寄存器作为地址,指令存储器读取指令的字节。 PC 增加器(PC incrementer)计算 valP,即增加了的程序计数器。
  • 执行:执行阶段会根据指令的类型,将算术/逻辑单元(ALU)用于不同目的。 对整数操作,它要执行指令所指定的运算。 对其他指令,它会作为一个加法器来计算增加或减少栈指针,或者计算有效地址,或者只是简单地加 0,将一个输入传递到输出。
    条件码寄存器(CC)有三个条件码位。ALU 负责计算条件码的新值。 当执行一条跳转指令时,会根据条件码和跳转类型来计算分值信号 Cnd。
  • 访存:在执行访存操作时,数据存储器读出或写入一个存储器字。 指令和数据存储器访问的是相同的存储器位置,但是用于不同的目的。
  • 写回:寄存器文件有两个写端口。 端口 E 用来写 ALU 计算出来的值,而端口 M 用来写从数据存储器中读出的值。

图 23 更详细给出了实现 SEQ 所需要的硬件(分析每个阶段时,我们会看到完整的细节)。

这幅图以及其他的硬件图,都使用以下的画图惯例:

  • 浅灰色方框表示硬件单元。这包括寄存器、ALU 等等。 在我们所有的处理器实现中,都会使用这一组基本的单元。 我们把这些单元当作 “黑盒子”,不关心它们的细节设计。
  • 控制逻辑块是用灰色圆角矩形表示的。 这些块用来从一组信号源中进行选择,或者用来计算一些布尔函数。 我们会非常详细地分析这些块,包括给出 HCL 描述。
  • 线路的名字在白色圆角方框中说明。
  • 宽度为字长的数据连接用中等粗度的线表示。
  • 宽度为字节或更窄的数据连接用细线表示。
  • 单个位的连接用虚线来表示。

图 23 SEQ 的硬件结构,一种顺序实现

SEQ 的时序

在介绍 图 18~图 21 时,我们说过要把它们看成是用程序符号写的,那些赋值是从上到下顺序执行的。 然而,图 23 中硬件结构的操作运行完全不同,一个时钟变化会引发一个经过组合逻辑的流,来执行整个指令。

SEQ 的实现包括组合逻辑和两种存储器设备:时钟寄存器(程序计数器和条件码寄存器),随机访问存储器(寄存器文件、指令存储器和数据存储器)。 组合逻辑不需要任何时序或控制——只要输入变化了,值就通过逻辑门网络传播。 我们也将随机访问存储器看成和组合逻辑一样的操作,根据地址输入产生输出字。 由于指令存储器只用来读指令,因此我们可以将这个单元看成是组合逻辑。

现在还剩四个硬件单元需要对它们的时序进行明确的控制——程序计数器、条件码寄存器、数据存储器和寄存器文件。 这些单元通过一个时钟信号来控制,它触发将新值装载到寄存器以及将值写到随机访问存储器。 每个时钟周期,程序计数器都会装载新的指令地址。 只有在执行整数运算指令时,才会装载条件码寄存器。 只有在执行 rmmovlpushlcall 指令时,才会写数据存储器。 寄存器文件的两个写端口允许每个时钟周期更新两个程序寄存器,不过我们可以用特殊的寄存器 ID 0xF 作为端口地址,来表明此端口不应该执行写操作。

要控制处理器中活动的时序,只需要寄存器和存储器的时钟控制。 硬件获得了如 图 18~图 21 的表中所示的那些赋值顺序执行一样的效果,即使所有的状态更新实际上同时发生,且只在时钟上升沿才开始下一个周期。 之所以能保持这样的等价性,是由于 Y86 指令集的本质,因为我们遵循以下原则组织计算:

处理器从来不需要为了完成一条指令的执行而去读由该指令更新了的状态。

阶段 计算 OPl rA, rB mrmovl D(rB), rA
取指 icode, ifun
rA, rB
valC
valP
icode: ifun ← M1[PC]
rA:rB ← M1[PC+1]

valP ← PC+2
icode: ifun ← M1[PC]
rA:rB ← M1[PC+1]
valC ← M4[PC+2]/br>valP ← PC+6
译码 valA, srcA
valB, srcB
valA ← R[rA]
valB ← R[rB]

valB ← R[rB]
执行 valE
Cond, codes
valE ← valB OP valA
Set CC
valE ← valB+valC
访存 read/write valM ← M4[valE]
写回 E port, dstE
M port, dstM
R[rB] ← valE R[rA] ← valM
更新PC PC PC ← valP PC ← valP

图 24 标识顺序实现的不通计算步骤

以下是汇编代码,左边列出的是指令地址,图 25 给出了 SEQ 硬件如何处理其中第 3 和第 4 行指令:

1       0x000:      irmovl $0x100,%ebx  # %ebx <-- 0x100
2       0x006:      irmovl $0x200,%edx  # %edx <-- 0x200
3       0x00c:      addl %edx,%ebx      # %ebx <-- 0x300 CC <-- 000
4       0x00e:      je dest             # Not taken
5       0x013:      rmmovl %ebx,0(%edx) # M[0x200] <-- 0x300
6       0x019:  dest: halt

标号为 1~4 的各个图是 4 个状态元素,还有组合逻辑,以及状态元素之间的连接。

图 25 中标有颜色的diamante表明电路信号是如何与正在被执行的不同指令相联系的。

图 25 跟踪 SEQ 的两个执行周期

SEQ 阶段的实现

本节会设计实现 SEQ 所需要的控制逻辑块的 HCL 描述。

图 26 是我们使用的常数。按照习惯,常数值都是大写的。

名称 值(十六进制) 含义
INOP 0 nop指令的代码
IHALT 1 halt指令的代码
IRRMOVL 2 rrmovl指令的代码
IIRMOVL 3 irmovl指令的代码
IRMMOVL 4 rmmovl指令的代码
IMRMOVL 5 mrmovl指令的代码
IOPL 6 整数运算指令的的代码
IJXX 7 跳转指令的代码
ICALL 8 call指令的代码
IRET 9 ret指令的代码
IPUSHL A pushl指令的代码
IPOPL B popl指令的代码
FNONE 0 默认函数代码
RESP 4 %esp的寄存器ID
RNONE F 表明没有寄存器文件访问
ALUADD 0 加法运算的功能
SAOK 1 ①正常操作状态码
SADR 2 ②地址异常状态码
SINS 3 ③非法指令状态码
SHLT 4 ④halt状态码

图 26 HCL 描述中使用的常数值

除了 图 18~图 21 中所示的指令以外,还包括了对 nop 和 halt 指令的处理。 nop 指令只是简单地经过各个阶段,除了要将 PC 加 1,不进行任何处理。 halt 指令使得处理器状态被设置为 HLT,导致处理器停止运行。

1. 取指阶段

如图 27 所示,取值阶段包括指令存储器硬件单元。 以 PC 作为第一个字节(字节0)的地址,这个单元一次从存储器读出 6 个字节。 第一个字节被解释成指令字节,(标号为 “Split” 的单元)分为两个 4 位的数。 然后,标号为 “icode” 和 “ifun” 的控制逻辑块计算指令和功能码,等于从存储器读出的值,或者当指令地址不合法时(由信号 imem_error 指明),此时这些值对应于 nop 指令。 根据 icode 的值,我们可以计算三个一位的信号(用虚线表示):

  • instr_valid:这个字节对应于一个合法的 Y86 指令吗?这个信号用来发现不合法的指令。
  • need_regids:这个指令包括一个寄存器指示符字节吗?
  • need_valC:这个指令包括一个常数字吗?

(当指令地址越界时会产生的)信号 instr_valid 和 imem_error 在访存阶段被用来产生状态码。

need_regids 的 HCL 描述只确定了 icode 的值是否为一条带有寄存器指示值字节的指令。

bool need_regids =
    icode in { IRRMOVL, IOPL, IPUSHL, IPOPL,
                IIRMOVL, IRMMOVL, IMRMOVL };

need_valC 的 HCL 描述了 icode 的值是否为一条带有 valC 的指令。

bool need_valC =
    icode in { IIRMOVL, IRMMOVL, IMRMOVL, IJXX, ICALL };

图 27 SEQ 的取指阶段

2. 译码和写回阶段

图 28 是 SEQ 中实现译码和写回节点的逻辑的详细情况。 把这两个阶段联系在一起是因为它们都要访问寄存器文件。

图 28 SEQ 的译码和写回阶段

根据指令代码 icode 以及寄存器指示值 rA 和 rB,可能还会根据执行阶段计算出的 Cnd 条件信号,图 28 底部的四个块产生四个不同的寄存器文件和寄存器 ID。 如 图 18~图 21 中译码阶段第一行所示。

将所有这些条目整合到一个计算中就得到以下 srcA 的 HCL 描述(RESP 是 %esp 的寄存器 ID):

int srcA = [
    icode in { IRRMOVL, IRMMOVL, IOPL, IPUSHL } : rA;
    icode in { IPOPL, IRET } : RESP;
    1 : RNONE; # Don’t need register
];

srcB 的 HCL 描述为:

int srcB = [
    icode in { IRMMOVL, IMRMOVL, IOPL } : rB;
    icode in { IPUSHL, IPOPL, ICALL, IRET } : RESP;
    1 : RNONE; # Don’t need register
];

寄存器 ID dstE 表明写端口 E 的目的寄存器,计算出来的值 valE 将放在那里。 图 18~图 21 写回阶段第一步表明了这一点。 如果我们暂时忽略条件移动指令,综合所有不同指令的目的寄存器,就得到下面的 dstE 的 HCL 描述:

# WARNING: Conditional move not implemented correctly here
int dstE = [
    icode in { IRRMOVL } : rB;
    icode in { IIRMOVL, IOPL} : rB;
    icode in { IPUSHL, IPOPL, ICALL, IRET } : RESP;
    1 : RNONE; # Don’t write any register
];

dstM 表明写端口 M 的目的寄存器,valM 将放到那里。 当 dstE 和 dstM 指定同一个寄存器时,valM 具有较高优先级。

int dstM = [
    icode in { IMRMOVL, IPOPL } : rA;
    1 : RNONE; # Don’t write any register
];
3. 执行阶段

执行阶段包括算术/逻辑单元(ALU)。 这个单元根据 alufun 信号的设置,对输入 aluA 和 aluB 执行 ADD、SUBTRACT、AND h或 EXCLUSIVE-OR 运算。如图 29 所示,这些数据和信号由三个控制块产生。 ALU 的输出就是 valE 信号。

图 29 SEQ 执行阶段

图 18~图 21 中,执行阶段的第一步就是每条指令的 ALU 计算。 列出的操作数 aluB 在前面,后面是 aluA,这样是为了保证 subl 指令是 valB 减去 valA。 根据指令的类型,aluA 的值可以是 valA、valC,或者 -4 或 +4。 因此我们可以用下面的方式来表达产生 aluA 的控制块的行为:

int aluA = [
    icode in { IRRMOVL, IOPL } : valA;
    icode in { IIRMOVL, IRMMOVL, IMRMOVL } : valC;
    icode in { ICALL, IPUSHL } : -4;
    icode in { IRET, IPOPL } : 4;
    # Other instructions don’t need ALU
];

aluB 的 HCL 描述为:

int aluB = [
    icode in { IRMMOVL, IMRMOVL, IOPL, ICALL, IPUSHL, IRET, IPOPL } : valB;
    icode in { IRRMOVL, IIRMOVL } : 0;
    # Other instructions don’t need ALU
];

观察 ALU 在执行阶段执行的操作,可以看到它通常是作为加法器来使用。 不过,对应 OPL 指令,我们希望它使用指令 ifun 字段中编码的操作。 因此,可以将 ALU 控制的 HCL 描述写成:

int alufun = [
    icode == IOPL : ifun;
    1 : ALUADD;
];

执行阶段还包括条件码寄存器。 每次运行时,ALU 都会产生三个与条件码相关的信号——零、符号和溢出。 不过,我们只希望在执行 OPL 指令时才设置条件码。 因此产生了一个信号 set_cc 来控制是否应该更新条件码寄存器:

bool set_cc = icode in { IOPL };

标号为 “cond” 的硬件单元会根据条件码和功能码来确定是否进行条件分支或者条件数据传送(见 图 3)。 它产生信号 Cnd,用于设置条件传送的 dstE,也用在条件分钟的下一个 PC 逻辑中。 对于其他指令,取决于指令的功能码和条件码的设置,Cnd 信号可以被设置为 1 或者 0。 但是控制逻辑会忽略它。我们省略这个单元的详细设计。

下面是考虑条件传送指令的 dstE HCL 描述:

int dstE = [
    icode in { IRRMOVL } && Cnd : rB;
    icode in { IIRMOVL, IOPL} : rB;
    icode in { IPUSHL, IPOPL, ICALL, IRET } : RESP;
    1 : RNONE; # Don’t write any register
];
4. 访存阶段

如图 30 所示,两个控制块产生存储器地址和存储器输入数据(为写操作)的值。 另外,两个块产生控制信号表明应该执行读操作还是写操作。 当执行读操作时,数据存储器产生值 valM。

图 30 SEQ 访存阶段

图 18~图 21 的存储器阶段给出了每个指令类型所需要的存储器操作。 可以看到存储器读和写的地址总是 valE 或 valA。 这个块用 HCL 描述就是:

int mem_addr = [
    icode in { IRMMOVL, IPUSHL, ICALL, IMRMOVL } : valE;
    icode in { IPOPL, IRET } : valA;
    # Other instructions don’t need address
];

观察 图 18~图 21 所示的不同指令的存储器操作,我们可以看到存储器写的数据总是 valA 或 valP。 SEQ 中信号 mem_data 的 HCL 描述为:

int mem_data = [
    # Value from register
    icode in { IRMMOVL, IPUSHL } : valA;
    # Return PC
    icode == ICALL : valP;
    # Default: Don’t write anything
];

我们希望只从存储器读出数据的指令设置信号 mem_read,用 HCL 代码表示就是:

bool mem_read = icode in { IMRMOVL, IPOPL, IRET };

我们希望只为向存储器写数据的指令设置控制信号 mem_write。 SEQ 中信号 mem_write 的 HCL 代码为:

bool mem_write = icode in { IRMMOVL, IPUSHL, ICALL };

Stat 的 HCL 代码为:

## Determine instruction status
int Stat = [
      imem_error || dmem_error : SADR;
      !instr_valid: SINS;
      icode == IHALT : SHLT;
      1 : SAOK;
];
5. 更新 PC 阶段

SEQ 中最后一个阶段会产生程序计数器的新值(见图 31)。 如 图 18~图 21 中最后步骤所示,依据指令类型和是否要选择分支,新的 PC 可能是 valC、valM 或 valP。 用 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;
];

图 31 SEQ 更新 PC 阶段

流水线的通用原理

流水线化的一个重要特性就是增加了系统的吞吐量,也就是单位时间内服务的顾客总数,不过它也会轻微地增加延迟,也就是服务一个用户所需要的时间。

计算流水线

图 32 是一个很简单的非流水线化的硬件系统例子。 它是由一些执行计算的逻辑以及一个保存计算结果的寄存器组成的。 时钟信号控制在每个特定的时间间隔加载寄存器。 图中的计算块是用组合逻辑来实现的,意味着信号会穿过一系统逻辑门,在一定时间的延迟之后,输出就成为了输入的某个函数。

图 32 非流水线化的计算硬件

在现代逻辑设计中,电路延迟以微微秒(picosecond,简写成 “ps”),也就是 10-12 秒为单位来计算。 图 32 还给出了一种时序图,称为流水线图。 在图中,时间从左向右流动。 从上到下写着一组操作(在此称为 I1、I2 和 I3)。实心的长方形表示这些指令执行的时间。 这个实现中,在开始下一条指令之前必须完成前一个。 因此,这些方框在垂直方向上并没有相互重叠。下面这个公式给出了运行这个系统的最大吞吐量:

吞吐量= 1 instruction (20 + 300) picosecond × 1000 picosecond 1 nanosecond 3.12GIPS

我们以每秒千兆条指令(GIPS),也就是每秒十亿条指令,为单位描述吞吐量。 从头到尾执行一条指令所需要的时间称为延迟(latency)。 在此系统中,延迟为 320ps,也就是吞吐量的倒数。

假设将系统执行的计算分成三个阶段(A、B 和 C),每个阶段需要 100ps,如图 33 所示。 然后在各个阶段之间放上流水线寄存器,这样每条指令都会按照三步经过这个系统。 在这个系统中,我们将时钟周期设为 100+20=120ps,得到的吞吐量大约为 8.33GIPS。 我们将吞吐量提高到原来的 8.33/3.12=2.67 倍,代价是增加一些硬件,以及延迟的少量增加(360/320=1.12)。 延迟变大是由于增加的流水线寄存器的时间开销。

图 33 三阶段流水线化的计算硬件

流水线操作的详细说明

图 34 是前面我们看到过的三阶段流水线(见 图 33)的流水线图。 流水线阶段之间的指令转移是由时钟信号来控制的。 每隔 120ps,信号从 0 上升至 1,开始下一组流水线阶段的计算。

图 34 三阶段流水线的时序

图 35 跟踪了时刻 240~360 之间的电路活动,指令 I1 经过阶段 C,I2 经过阶段 B,而 I3 经过阶段 A。 在时刻 240(点 1)时钟上升之前,阶段 A 中计算的指令 I2 的值已经到达第一个流水线寄存器的输入,但是该寄存器的状态和输出还保持为指令 I1 在阶段 A 中计算的值。 指令 I1 在阶段 B 中计算的值已经到达第二个流水线寄存器的输入。 当时钟上升时,这些输入被加载到流水线寄存器中,成为寄存器的输出(点 2)。 另外,阶段 A 的输入被设置成发起指令 I3 的计算。然后信号传播通过各个阶段的组合逻辑(点 3)。 就像图中点 3 出的曲线化的波阵面表明的那样,信号可能以不同的速率通过各个不同的部分。 在时刻 360 之前,结果值到达流水线寄存器的输入(点 4)。 当时刻 360 时钟上升时,各条指令会前进经过一个流水线阶段。

图 35 流水线操作的一个时钟周期

流水线的局限性

图 33 的例子是一个理想的流水线化的系统,在这个系统中,我们可以将计算分成三个相互独立的阶段,每个阶段需要的时间是原来逻辑需要时间的三分之一。 不幸的是,会出现其他一些因素,降低流水线的效率。

1. 不一致的划分

图 36 展示的系统和前面一样划分了三个阶段,但是通过这些阶段的延迟从 50ps 到 150ps 不等。 只有阶段 B 会一直处于活动状态,必须将时钟周期设为 150=20=170ps,吞吐量为 5.88 GIPS,延迟为 510ps。

图 36 由不一致的阶段延迟造成流水线技术的局限性

2. 流水线过深,收益反而下降

图 37 说明了流水线技术的另一个局限性。 我们把计算分成了 6 个阶段,每个阶段需要 50ps。这个系统的最小时钟周期为 50+20=70ps,吞吐量为 14.29 GIPS。 我们将性能提高了 14.29/8.33=1.71。 虽然我们将每个计算时钟的时间缩短了两倍,但是由于通过流水线寄存器的延迟,吞吐量并没有加倍。

图 37 由开销造成的流水线技术的局限性

带反馈的流水线系统

到目前为止,我们只考虑一种系统,其中传过流水线的指令,相互是完全独立的。 但是,对于像 IA32 或 Y86 这样执行机器程序的系统来说,相邻指令之间很可能是相关的。

比如:

1   irmovl $50, %eax
2   addl %eax, %ebx
3   mrmovl 100(%ebx), %edx

在这个包含三条指令的序列中,每对相邻指令之间都有数据相关。

另一种相关是由于指令控制流造成的顺序相关。来看看下面这个 Y86 指令序列:

1   loop:
2       subl %edx, %ebx
3       jne targ
4       irmovl $10, %edx
5       jmp loop
6   targ:
7       halt

jne 指令(第三行)产生了一个控制相关,因为条件测试的结果会决定要执行的新指令是 irmovl 指令还是 halt 指令。 在我们的 SEQ 设计中,这些相关都是由反馈路径来解决的。 如 图 22 右边所示。这些反馈将更新后的寄存器值向下传递到寄存器文件,将新的 PC 值向下传递到 PC 寄存器。

图 38 举例说明了将流水线引入含有反馈路径的系统中的危险。 在原来的系统中(见图 38a)中,每条指令的结果都反馈给下一条指令。 流水线图(见图 38b)就说明了这个情况 I1 的结果称为 I2 的输入,依次类推。 如果试图以最直接的方式将它转换成一个三阶段流水线(见图 38c),我们将改变系统的行为,I1 的结果成为 I4 的输入,这改变了系统的行为。

当我们将流水线技术引入 Y86 处理器时,必须正确处理反馈的影响。 像图 38 中的例子那样改变系统的行为是无法接受的。 必须以某种方式来处理指令间的数据和控制相关,以使得到的行为与 ISA 定义的模型相符。

图 38 由逻辑相关造成的流水线技术的局限性

Y86 的流水线实现

现在开始设计一个流水线化的 Y86 处理器。 首先,对顺序的 SEQ 处理器做一点儿小的改动,将 PC 的计算挪到取指阶段。 然后,在各个寄存器之间加上流水线寄存器。

SEQ+:重新安排计算阶段

作为实现流水线化设计的一个过渡步骤,我们必须稍微调整一下 SEQ 中五个阶段的顺序,使得更新 PC 阶段在一个时钟周期开始时执行,而不是结束时才执行。 只需要对整体硬件结构做最小的改动,对于流水线阶段中的活动的时序,它能工作得更好。 我们称这种修改过的设计为“SEQ+”。

图 39 移动计算 PC 的时间

图 40 是 SEQ+ 硬件更为详细的说明。

SEQ+ 中的 PC 在哪里
SEQ+ 有一个很奇怪的特性,那就是没有硬件寄存器来存放程序计数器。 而是根据从前一条指令保存下来的一些状态信息动态地计算 PC。 这就是一个小小的证明——我们可以以一种与 ISA 隐含着的概念模型不同的方式实现处理器,只要处理器能正确执行任意机器语言程序。 我们不需要按照程序员可见的状态表明的方式来对状态进行编码,只要处理器能正确地执行任意的机器语言程序。 我们不需要将状态编码成程序员可见的状态指定的形式,只要处理器能够为任意的程序员可见状态(例如程序计数器)产生正确的值。 在创建流水线化的设计中,我们会更多地使用这条原则。 后面章节描述的乱序处理技术,以一种完全不同于机器级程序中出现顺序的次序来执行指定,将这一思想发挥到了极致。

SEQ 到 SEQ+ 中对状态元素的改变是一种通用的改进的例子,这种改进称为电路重定时(circuit retiming)。

图 40 SEQ+ 的硬件结构

插入流水线寄存器

在创建一个流水线化的 Y86 处理器的最初尝试中,我们要在 SEQ+ 的各个阶段之间插入流水线寄存器,并对信号重新排列,得到 PIPE- 处理器,这里的 “-” 代表这个处理器和最终的处理器设计相比,想能要差一点。PIPE- 的抽象结构如图 41 所示。 每个流水线寄存器可以存放多个字节和字。

图 41 PIPE- 的硬件结构,一个初始的流水线化实现

可以看到,PIPE- 使用的硬件单元与顺序设计 SEQ(图 40)几乎一样,只是有流水线寄存器分隔开这些阶段。

流水线寄存器按以下方式标号:

  • F 保存程序计数器的预测值
  • D 位于取值和译码阶段之间。它保存关于最新取出的指令的信息,即将由译码阶段进行处理。
  • E 位于译码和执行阶段之间。它保存关于最新译码的指令和从寄存器文件读出的值的信息,即将由执行阶段进行处理。
  • M 位于执行和访存阶段之间。它保存最新执行的指令的结果,即将由访存阶段进行处理。它还保存关于用于处理条件转移的分支条件和分支目标的信息。
  • W 位于访存和反馈路径之间,反馈路径将计算出来的值提供给寄存器文件写,而当完成 ret 指令时,它还要向 PC 选择逻辑提供返回地址。

图 42 表明以下代码序列如何通过我们的五阶段流水线,其中注释将各条指令标识为 I1~I5 以便引用:

1   irmovl  $1,%eax # I1
2   irmovl  $2,%ebx # I2
3   irmovl  $3,%ecx # I3
4   irmovl  $4,%edx # I4
5   halt            # I5

图 42 指令流通过流水线的示例

从图 42 中还可以判断我们画处理器的习惯是合理的,这样,指令是自底向上的流动的。 周期 5 时的扩展图表明的流水线阶段,取指阶段在底部,写回阶段在最上面,同流水线硬件图(见图 41)表明的一样。 如果看看流水线各个阶段中指令的顺序,就会发现它们出现的顺序与在程序中列出的顺序一样。 因为正常的程序是从上到下列出的,我们保留这种顺序,让流水线从下到上进行。

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

顺序实现 SEQ 和 SEQ+ 在一个时刻只处理一条指令,因此诸如 valC、srcA 和 valE 这样的信号值有唯一的值。 在流水线化的设计中,与各个指令相关联的这些值由多个版本,会随着指令一起流过系统。 例如,在 PIPE- 的详细结构中,有 4 个标号为 “stat” 的方框,保存着 4 条不同指令的状态码(见图 41)。 我们需要很小心以确保使用的是正确版本的信号,否则会导致很严重的错误。 我们采用的命名机制,通过在信号名前面加上大写的流水线寄存器名字作为前缀,存储在流水线寄存器中的信号可以唯一的被标识。 我们还需要引用某些在一个阶段内刚刚计算出来的信号。 它们的命名是在信号名前面加上小写的阶段名的第一个字母作为前缀。 我们还可以看到整个处理器的实际状态 Stat 是根据流水线寄存器 W 中的状态值,由写回阶段中的块计算出来的。

信号 M_stat 和 m_stat 的差别
在命名系统中,大写的前缀 “D”、“E”、“M” 和 “W” 指的是流水线寄存器,所以 M_stat 指的是流水线寄存器 M 的状态码字段。 小写的前缀 “f”、“d”、“e”、“m” 和 “w” 指的是流水线阶段,所以 m_stat 指的是在访存阶段由控制逻辑块产生的状态信号。
理解这个命名规则对理解我们的流水线化的处理器的操作是至关重要的。

SEQ+ 和 PIPE- 的译码阶段都产生信号 dstE 和 dstM,它们指明值 valE 和 valM 的目的寄存器。 在 SEQ+ 中,可以将这些信号直接连到寄存器文件写端口的地址输入。 在 PIPE- 中,会在流水线中一直携带这些信号穿过执行和访存阶段,直到写回阶段才送到寄存器文件。 我们这样做是为了确保写端口的地址和数据输入是来自同一条指令。 否则,会将处于写回阶段的指令的值写入,而寄存器 ID 却来自于译码阶段的指令。 作为一条通用原则,我们要保存处于一个流水线阶段中的指令的所有信息。

PIPE- 中有一个块在相同表示形式的 SEQ+ 中是没有的,那就是译码阶段中标号为 “Select A” 的块。 我们可以看出,这个块会从来自流水线寄存器 D 的 valP 或从寄存器文件 A 端口中读出的值中选择一个,作为流水线寄存器 E 的值 valA。 包含这个块是为了减少要携带给流水线寄存器 E 和 M 的状态数量。 在所有的指令中,只有 call 在访存阶段需要 valP 的值。 只有跳转指令在执行阶段(当不需要进行跳转时)需要 valP 的值。 而这些指令又都不需要从寄存器文件中读出的值。 因此我们合并这两个信号,将它们作为信号 valA 携带穿过流水线,从而减少流水线寄存器的状态数量。 这样做就消除了 SEQ(见 图 23)和 SEQ+(见 图 40)中标号为 “Data” 的块。

预测下一个 PC

在 PIPE- 设计中,我们采取了一些措施来正确处理控制相关。 流水线化设计的目的就是每个时钟周期都发射一条新指令,也就是说每个时钟周期都有一条新指令进入执行阶段并最终完成。 要是达到这个目的就意味着吞吐量是每个时钟周期一条指令。 要做到这一点,我们必须在取出当前指令之后,马上确定下一条指令的位置。 不幸的是,如果取出的指令是条件分支指令,要到几个周期后,也就是指令通过执行阶段之后,我们才能知道是否要选择分支。 类似地,如果取出的指令是 ret,要到指令通过访存阶段,才能确定返回地址。

除了条件转移指令和 ret 之外,根据取指阶段中计算出的信息,我们能够确定下一条指令的地址。 对于 calljmp(无条件转移)来说,下一条指令的地址是指令中的常数字 valC,而对于其他指令来说就是 valP。 通过预测 PC 的下一个值,在大多数情况下,我们能达到每个时钟周期发射一条新指令的目的。 对于大多数指令来说,我们的预测是完全可靠的。 对条件转移来说,我们既可以预测选择了分支,那么新的 PC 值应为 valC,也可以预测没有选择分支,那么新 PC 值应为 valP。

猜测分支方向并根据猜测开始取指的技术称为分支预测。 我们的设计只使用了简单的策略,即总是预测选择了条件分支,因而预测 PC 的新值为 valC。

其他的分支预测策略
我们的设计使用总是选择(always taken)分支的预测策略。 研究表明这个策略的成功率大约为 60%。 相反,从不选择(never taken,NT)策略的成功率大约为 40%。 稍微复杂一点的是反向选择、正向不选择(backward taken,forward not-taken,BTFNT)的策略, 当分支地址比下一条地址低时就预测选择分支,而分支地址比较高时,就预测不选择分支。 这种改进源自一个事实,即循环时由向后分支结束的,而循环通常会执行多次。 前向分支用于条件操作,而这种选择的可能性较小。
分支预测错误会极大地降低程序的性能,因此就促使我们在可能的时候,要使用条件数据传送而不是条件控制转移。

同条件转移不同,ret 指令的新 PC 值可能的返回值几乎是无限的,因为返回地址位于栈顶的字,其内容可以是任意的。 在设计中,我们不会试图对返回地址做任何预测。 只是简单地暂停处理新指令,直到 ret 指令通过写回阶段。

使用栈的返回地址预测
对大多数程序来说,预测返回值很容易,因为过程调用和返回是成对出现的。 大多数函数调用,会返回到调用后的那条指令。 高性能处理器运用了这个属性,在取值单元中放入一个硬件栈,保存过程调用指令产生的返回地址。 每次执行过程调用指令时,都将其返回地址压入栈中。 当取出一个返回指令时,就从这个栈中弹出顶部的值,作为预测的返回值。 同分支预测一样,在预测错误时必须提供一个恢复机制,因为还是有调用和返回不匹配的时候。 通常,这种预测很可靠。这个硬件栈堆程序员来说是不可见的。

PIPE- 的取指阶段,如 图 41 底部所示,负责预测 PC 的下一个值,以及为取指选择实际的 PC。 标号为 “Predict PC” 的块会在 PC 增加器计算出的 valP 和取出的指令中得到的 valC 中进行选择。 这个值存放在流水线寄存器 F 中,作为程序计数器的预测值。 标号为 “Select PC” 的块类似于 SEQ+ 的 PC 选择阶段中标号为 “PC” 的块(见 图 40)。 它从三个值中选择一个作为指令存储器的地址:预测的 PC,对于到达流水线寄存器 M 的不选择分支的指令来说是 valP 的值(存储在寄存器 M_valA 中),或是当 ret 指令到达流水线寄存器 W(存储在 W_valM)时返回地址的值。

流水线冒险

PIPE- 结构是创建一个流水线的 Y86 处理器的好开端。 不过,将流水线技术引入一个带反馈的系统,当相邻指令间存在相关时会导致出现问题。

这些相关有两种形式:

  • 数据相关,下一条指令会用到这一条指令计算出的结果
  • 控制相关,一条指令要确定下一条指令的位置,例如在执行跳转、调用或返回指令时。

这些相关可能会导致流水线产生计算错误,称为冒险(hazard)。 同相关一样,冒险也可以分为两类:数据冒险和控制冒险。 这里关注的是数据冒险。 我们会将控制冒险作为整个流水线控制的一部分加以讨论。

图 43 描述的是 PIPE- 处理器处理 prog1 指令序列的情况。 假设这个例子以及后面的例子中,程序寄存器初始值都为 0。

流水线图下面是周期 6 中写回活动和周期 7 中译码阶段活动的扩展说明。 在周期 7 开始以后,两条 irmovl 都已经通过写回阶段,所以寄存器文件保存着更新过的 %edx 和 %eax 的值。 因此,当 addl 指令在周期 7 经过译码阶段时,它可以读到源操作数的正确值。 在此示例中,两条 irmovl 指令和 addl 指令之间的数据相关没有造成数据冒险。

图 43 prog1 的流水线化的执行,没有特殊的流水线控制

prog1 通过流水线并得到正确的结果,因为 3 条 nop 指令在有数据相关的指令之间创造了一些延迟。 让我们看看如果去掉这些 nop 指令会发生些什么。 图 44 描述的是 prog2 程序的流水线流程,在两条产生寄存器 %edx 和 %eax 值的 irmovl 指令和以这两个寄存器作为操作数的 addl 指令之间有两条 nop 指令。 此时,关键步骤发生在周期 6,此时 addl 指令从寄存器文件中读取它的操作数。 第一个 irmovl 指令已经通过了写回阶段,因此程序寄存器 %edx 已经在寄存器文件中更新了。 在该周期内,第一个 irmovl 指令处于写回阶段,因此程序寄存器 %eax 的写要到周期 7 开始,时钟上升时,才会发生。 结果,会读出 %eax 的错误值(回想我们假设所有的寄存器的初始值为 0),因为对寄存器的写还未发生。 很明显,我们必须改进流水线让它能够正确处理这样的冒险。

图 44 prog2 的流水线化的执行,没有特殊的流水线控制

图 45 是当 irmovl 指令和 addl 指令之间只有一条 nop 指令,即为程序 prog3 时,发生的情况。 现在我们必须检查周期 5 内流水线的行为,此时 addl 指令通过译码阶段。 不幸的是,对寄存器 %edx 的写仍处于写回阶段,而对寄存器 %eax 的写还处于访存阶段。 因此,addl 指令会得到两个错误的操作数。

图 45 prog3 的流水线化的执行,没有特殊的流水线控制

图 46 是当去掉 irmovl 指令和 addl 指令间的所有 nop 指令,即为程序 prog4 时,发生的情况。 现在我们必须检查周期 4 内流水线的行为,此时 addl 指令通过译码阶段。 不幸的是,对寄存器 %edx 的写仍处于访存阶段,而执行阶段正在计算寄存器 %eax 的新值。 因此,addl 指令得两个操作数都是不正确的。

图 46 prog4 的流水线化的执行,没有特殊的流水线控制

列举数据冒险的类型

当一条指令更新后面指令会读到的那些程序状态时,就有可能出现冒险。 对于 Y86 来说,程序状态包括程序寄存器、程序计数器、存储器、条件吗寄存器和状态寄存器。 让我们来看看在提出的设计中每类状态出现冒险的可能性。

  • 程序寄存器:我们已经认识这种冒险了。出现这种冒险是因为寄存器文件的读写是在不同的阶段进行的,导致不同指令之间可能出现不希望的相互作用。
  • 程序计数器:更新和读取程序计数器之间的冲突导致了控制冒险。 当我们的取值阶段逻辑在取下一条指令之前,正确预测了程序计数器的新值时,就不会产生冒险。 预测错误的分支和 ret 指令需要特殊的处理。
  • 存储器:对数据存储器的读和写都发生在访存阶段。 在一条读存储器的指令到达这个阶段之前,前面所有要写存储器的指令都已经完成这个阶段。 另外,在访存阶段写数据的指令和取指阶段中读指令之间也有冲突,因为指令和数据存储器访问的是同一个地址空间。 只有包含自我修改代码的程序才会发生这种情况,在这样的程序中,指令写存储器的一部分,过后会从中取出指令。 有些系统有复杂的机制检测和避免这种冒险,而有些系统知识简单地强制要求不应该使用自我修改代码。 为了简便,假设程序不能修改自身,因此,我们不需要采取特殊的措施,根据在程序执行过程中对数据存储器的修改来修改指令存储器。
  • 条件码寄存器:在执行阶段中,整数操作会写这些寄存器。 条件传送指令会在执行阶段以及条件转移会在访存阶段读这些寄存器。 在条件传送或转移到达执行阶段之前,前面所有的整数操作都已经完成这个阶段,所以不会发生冒险。
  • 状态寄存器:指令流经流水线的时候,会影响程序状态。 我们采用流水线中的每条指令都与一个状态码相关联的机制,使得当异常发生时,处理器能够有条理地停止。

这些分析表明我们只需要处理寄存器数据冒险、控制冒险,以及确保能够正确处理异常。 当设计一个复杂系统时,这样的分类分析是很重要的。 这样做可以确认出系统实现中可能的困难,还可以指导生成用于检查系统正确性的测试程序。

用暂停来避免数据冒险

暂停(stalling)是避免冒险的一种常用技术,暂停时,处理器会停止流水线中一条或多条指令,直到冒险条件不再满足。 让一条指令停顿在译码阶段,直到产生它的源操作数的指令通过了写回阶段,这样我们的处理器就能避免数据冒险。 图 47(prog2)和图 48(prog4)画出了暂停的效果。 当指令 addl 处于译码阶段时,流水线控制逻辑发现执行、访存或写回阶段中至少有一条指令会更新寄存器 %edx 或 %eax。 处理器不会让 addl 指令带着不正确的结果通过这个阶段,而是会暂停指令,将它阻塞在译码阶段,时间为一个周期(对 prog2 来说)或者三个周期(对 prog4 来说)。 对所有这三个程序来说,addl 指令最终都会在周期 7 中得到两个源操作数的正确值,然后继续沿着流水线进行下去。

图 47 prog2 使用暂停的流水线化的执行

图 48 prog4 使用暂停的流水线化的执行

addl 指令阻塞在译码阶段时,我们还必须将紧跟其后的 halt 指令阻塞在取指阶段。 通过将程序计数器保持不变就能做到这一点,这样一来,会不断地对 halt 指令进行取指,直到暂停结束。

暂停技术就是让一组指令阻塞在它们所处的阶段,而允许其他指令继续通过流水线。 那么本该正确处理 addl 指令的阶段中,我们该做些什么呢? 我们使用的处理方法是:每次要把一条指令阻塞在译码阶段,就在执行阶段插入一个气泡。 气泡就像一个自动产生的 nop 指令——它不会改变寄存器、存储器、条件码或程序状态。

虽然实现这一机制相当容易,但是得到的性能并不好。 一条指令更新一个寄存器,紧跟其后的指令就使用被更新的寄存器,像这样的情况不胜枚举。 这样会导致流水线暂停长达三个周期,严重降低了整体的吞吐量。

用转发避免数据冒险

我们 PIPE- 的设计是在译码阶段从寄存器文件中读入源操作数,但是对这些源寄存器的写有可能要在写回阶段才能进行。 与其暂停直到写完成,不如简单地将要写的值传到流水线寄存器 E 作为源操作数。 图 49 用 prog2 周期 6 的流水线扩展描述来说明这一策略。 译码阶段逻辑发现,寄存器 %eax 是操作数 valB 的源寄存器,而在写端口 E 上还有一个对 %eax 的未进行的写。 它只要简单地将提供到端口 E 的数据字(信号 W_valE)作为操作数 valB 的值,就能避免暂停。 这种将结果值直接从一个流水线阶段传到较早阶段的技术成为数据转发(data forwarding,或简称转发,有时称为旁路(bypassing))。 它使 prog2 的指令能通过流水线而不需要任何暂停。 数据转发需要在基本的硬件结构中增加一些额外的数据连接和控制逻辑。

图 49 prog2 使用转发的流水线化的执行。在周期 6 中,译码阶段逻辑发现有在写回阶段中对寄存器 %eax 未进行的写。它用这个值,而不是从寄存器文件中读出的值,作为源操作数 valB

如图 50 所示,当访存阶段中有对寄存器未进行的写时,也可以使用数据转发,以避免程序 prog3 中的暂停。 在周期 5 中,译码阶段逻辑发现,在写回阶段中端口 E 上有寄存器 %edx 未进行的写,以及在访存阶段中会在端口 E 上对寄存器 %eax 未进行的写。 它不会暂停直到这些写真正发生,而是用写回阶段中的值(信号 W_valE)作为操作数 valA,用访存阶段中的值(信号 M_valE)作为操作数 valB。

图 50 prog3 使用转发的流水线化的执行。在周期 5 中,译码阶段逻辑发现有在写回阶段中对寄存器 %edx 未进行的写,以及在访存阶段中对寄存器 %eax 未进行的写。它用这些值,而不是从寄存器文件中读出的值,作为 valA 和 valB 的值

为了充分利用数据转发技术,我们还可以将新计算出来的值从执行阶段传到译码阶段,以避免程序 prog4 所需要的暂停,如图 51 所示。 周期 4 中,译码阶段逻辑发现在访存阶段中有对寄存器 %edx 未进行的写,而且在执行阶段中 ALU 正在计算的值稍后也会写入寄存器 %eax。 它可以将访存阶段中的值(信号 M_valE)作为操作数 valA,也可以将 ALU 的输出(信号 e_valE)作为操作数 valB。 注意,ALU 的输出不会导致任何时序问题。 译码阶段只要在时钟周期结束之前产生信号 valA 和 valB,在时钟上升开始下一个周期时,流水线寄存器 E 就能装载来自译码阶段的值。 而在此之前 ALU 的输出已经是合法的了。

图 51 prog4 使用转发的流水线化的执行。在周期 4 中,译码阶段逻辑发现有在访存阶段中对寄存器 %edx 未进行的写,还发现在执行阶段中正在计算寄存器 %eax 的新值。它用这些值,而不是从寄存器文件中读出的值,作为 valA 和 valB 的值

程序 prog2~prog4 中描述的转发技术使用的是将 ALU 产生的以及其目标为写端口 E 的值进行转发,其实也可以转发从存储器中读出的以及其目标为写端口的 M 的值。 从访存阶段,我们可以转发刚刚从数据存储器中读出的值(信号 m_valM)。 从写回阶段,我们可以转发对端口 M 未进行的写(信号 W_valM)。 这样一共就有 5 个不同的转发源(e_valE、m_valM、M_valE、w_valM 和 W_valE),以及两个不同的转发目的(valA 和 valB)。

图 49~图 51 的扩展图还表明译码阶段逻辑能够确定使用来自寄存器文件的值,还是用转发过来的值。 与每个要写回寄存器文件的值相关的是目的寄存器 ID。 逻辑会将这些 ID 与源寄存器 ID srcA 和 srcB 相比较,以此来检测是否需要转发。 可能有多个目的寄存器 ID 与一个源 ID 相等。 要解决这样的情况,我们必须在各个转发源中建立优先级关系。

图 52 是 PIPE 的结构,它是 PIPE- 的扩展,能通过转发处理数据冒险。 将这幅图与 PIPE- 的结构(图 41)相比,我们可以看到来自 5 个转发源的值反馈到译码阶段中两个标号为 “Sel+Fwd A” 和 “Fwd B” 的块。 标号为 “Sel+Fwd A” 的块是 PIPE- 中标号为 “Select A” 的块的功能与转发逻辑的结合。 它允许流水线寄存器 E 的 valA 为已增量的程序计数器值 valP,从寄存器文件 A 端口读出的值,或者某个转发过来的值。 标号为 “Fwd B” 的块实现的是源操作数 valB 的转发逻辑。

图 52 流水线化的最终实现——PIPE 的硬件结构

加载/使用数据冒险

有一类数据冒险不能单纯用转发来解决,因为存储器读在流水线发生的比较晚。 图 53 举例说明了 加载/使用冒险(load/used hazard),其中一条指令(位于地址 0x18 的 mrmovl)从存储器中读出寄存器 %eax 的值,而下一条指令(位于地址 0x01e 的 addl)需要该值作为源操作数。 图的下部是周期 7 和 8 的扩展说明,在此假设所有的程序寄存器都初始化为 0。 addl 指令在周期 7 中需要该寄存器的值,但是 mrmovl 指令直到周期 8 才产生这个值。

图 53 加载/使用数据冒险的示例。addl 指令在周期 7 译码阶段中需要寄存器 %eax 的值。前面的 mrmovl 指令在周期 8 访存阶段中读出这个寄存器的新值,这对于 addl 指令来说太迟了

如图 54 所示,我们可以将暂停和转发结合起来,避免加载/使用数据冒险。 这需要修改控制逻辑,但是可以使用现有的旁路路径。 当 mrmovl 指令通过执行阶段时,流水线控制逻辑发现译码阶段中的指令(addl)需要从存储器中读出的结果。 它会将译码阶段中的指令暂停一个周期,导致执行阶段中插入一个气泡。 如周期 8 的扩展说明所示,从存储器中读出的值可以从访存阶段转发到译码阶段中的 addl 指令。 寄存器 %ebx 的值也可以从写回阶段转发到访存阶段。 就像流水线图,从周期 7 中标号为 “D” 的方框到周期 8 中标号为 “E” 的方框的箭头表明的那样,插入的气泡代替了正常情况下本来应该继续通过流水线的 addl 指令。

图 54 用暂停来处理加载/使用冒险

这种用暂停来处理加载/使用冒险的方法称为加载互锁(load interlock)。 加载互锁和转发技术结合起来足以处理所有可能类型的数据冒险。 因为只有加载互锁降低流水线的吞吐量,我们几乎可以实现每个时钟周期发射一条新指令的吞吐量目标。

异常处理

处理器中很多事情都会导致异常控制流,此时,程序执行的正常流程被破坏掉。 异常可以由程序执行从内部产生,也可以由某个外部信号从外部产生。

我们的指令集体系结构包括三种不同的内部产生的异常:

  • halt 指令
  • 有非法指令和功能码组合的指令
  • 取值或数据读写试图访问一个非法地址

一个更完整的处理器设计应该也能处理外部异常,例如当处理器收到一个网络接口收到新包的信号,或是一个用户点击鼠标按钮的信号。

我们把导致异常的指令称为异常指令。 在使用非法指令地址的情况中,没有实际的异常指令,但是想象在非法地址处有一种 “虚拟指令” 会有所帮助。 在简化的 ISA 模型中,我们希望当处理器遇到异常时,会停止,设置适当的状态码,如 图 5 所示。 看上去应该是到异常指令之前的所有指令都已经完成,而其后的指令都不应该对程序员可见的状态产生任何影响。 在一个更完整的设计中,处理器会继续调用异常处理程序(exception handler),这是操作系统的一部分,但是实现异常处理程序的这部分超出了本文的范围。

在一个流水线化的系统中,异常处理包括一些细节问题。 首先,可能同时有多条指令会引起异常。 例如,在一个流水线操作的周期内,取指阶段中有 halt 指令,而数据存储器会报告访存阶段的指令数据地址越界。 我们必须确定处理器应该向操作系统报告哪个异常。 基本原则是:由流水线中最深的指令引起的异常,优先级最高。 在上面的那个例子中,应该报告访存阶段中指令的地址越界。 就机器语言而言,访存阶段中的指令本来应该在取指阶段中的指令开始之前就结束的,所以,只应该向操作系统报告这个异常。

第二个细节问题是,当首先取出一条指令,开始执行时,导致了一个异常,而后来由于分支预测错误,取消了该指令。 下面就是一个这样程序示例的目标代码:

0x000: 6300         |   xorl %eax,%eax
0x002: 740e000000   |   jne Target          # Not taken
0x007: 30f001000000 |   irmovl $1, %eax     # Fall through
0x00d: 00           |   halt
0x00e:              | Target:
0x00e: ff           |   .byte 0xFF          # Invalid instruction code

在这个程序中,流水线会预测选择分支,因此它会取出并以一个值为 0xFF 的字节作为指令(由汇编代码中 .byte 命令产生的)。 译码阶段会因此发现一个非法指令异常。 稍后,流水线会发现不应该选择分支,因此根本不应该取出位于地址 0x00e 的指令。 流水线控制逻辑会取消该指令,但是我们想要避免出现异常。

第三个细节问题的产生是因为流水线化的处理器会在不同的阶段更新系统状态的不同部分。 有可能会出现这样的情况:一条指令导致了一个异常,它后面的指令在异常指令完成之前改变了部分状态。 比如说,考虑下面的代码序列,其中假设不允许用户访问大于 0xc0000000 的地址(与 32 位 Linux 版本的情况一样):

1   irmovl $1,%eax
2   xorl %esp,%esp  # Set stack pointer to 0 and CC to 100
3   pushl %eax      # Attempt to write to 0xfffffffc
4   addl %eax,%eax  # (Should not be executed) Would set CC to 000

pushl 指令导致一个地址异常,因为减小栈指针会导致它绕回到 0xfffffffc。 在访存阶段发现这个异常。 在同一周期中,addl 指令处于执行阶段,而它会将条件码设置成新的值。 这就会违反异常指令之后的所有指令都不能影响系统状态的要求。

一般地,通过在流水线结构中加入异常处理逻辑,我们既能够从各个异常中做出正确的选择,也能够避免出现由于分支预测错误取出的指令造成的异常。 这就是为什么我们会在每个流水线寄存器中包括一个状态码 Stat(见 图 41图 52 )。 如果一条指令在其处理中于某个阶段产生了一个异常,这个状态字段被设置成异常的种类。 异常状态和改指令的其他信息一起沿着流水线传播,直到它到达写回阶段。 在此,流水线控制逻辑发现出现了异常,并停止执行。

为了避免异常指令之后的指令更新任何程序员可见的状态,当处于访存或写回阶段中的指令导致异常时,流水线控制逻辑必须禁止更新条件码寄存器或是数据存储器。 在上面的示例程序中,控制逻辑会发现访存阶段的 pushl 导致了异常,因此应该禁止 addl 指令更新条件码寄存器。

让我们来看看这种处理异常的方法是怎样解决刚才提到的那些细节问题的。 当流水线中有一个或多个阶段出现异常时,信息只是简单地存放在流水线寄存器的状态字段中。 异常事件不会对流水线中的指令流有任何影响,除了会禁止流水线中后面的指令更新程序员可见的状态(条件码寄存器和存储器),直到异常指令到达最后的流水线阶段。 因为指令到达写回阶段的顺序与它们在非流水线化的处理器中执行的顺序相同,所以我们可以保证第一条遇到异常的指令会第一个到达写回阶段,此时程序执行会停止,流水线寄存器 W 中的状态码会被记录为程序状态。 如果取出了某条指令,过后又取消了,那么所有关于这条指令的异常状态信息也都会被取消。 所有导致异常的指令后面的指令都不能改变程序员可见的状态。 携带指令的异常状态以及所有其他信息通过流水线的简单原则是处理异常的简单而可靠的机制。

PIPE 各阶段的实现

本节将浏览各个逻辑块的设计而将流水线控制逻辑的设计放到下一节介绍。 许多逻辑块与 SEQ 和 SEQ+ 中相应的部件完全相同,除了我们必须从来自不同流水线寄存器(用大写的流水线寄存器的名字作为前缀)或来自各个阶段计算(用小写的阶段名字的第一个字母作为前缀)的信号中选择适当的值。

作为一个示例,比较 SEQ 中产生 srcA 信号逻辑的 HCL 代码与 PIPE 中相应的代码:

    # Code from SEQ
int srcA = [
        icode in { IRRMOVL, IRMMOVL, IOPL, IPUSHL } : rA;
        icode in { IPOPL, IRET } : RESP;
        1 : RNONE; # Don’t need register
];

    # Code from PIPE
int d_srcA = [
        D_icode in { IRRMOVL, IRMMOVL, IOPL, IPUSHL } : D_rA;
        D_icode in { IPOPL, IRET } : RESP;
        1 : RNONE; # Don’t need register
];

它们的不同之处只在于 PIPE 信号都加上了前缀:“D_” 表示源值,以表明信号来自流水线寄存器 D, 而 “d_” 表示结果值,以表明它是在译码阶段中产生的。 为了避免重复,我们在此就不列出那些与 SEQ 中代码只有名字前缀不同的块的 HCL 代码。

1. PC 选择和取指阶段

图 55 提供了 PIPE 取指阶段逻辑的详细描述。

图 55 PIPE 的 PC 选择和取指逻辑

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

int 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;
];

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

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

同 SEQ 不一样,我们必须将指令状态的计算分成两个部分。 在取指阶段,可以测试由于指令地址越界引起的存储器错误,还可以发现非法指令或 halt 指令。 必须延迟到访存阶段才能发现非法数据地址。

## Determine status code for fetched instruction
int f_stat = [
      imem_error : SADR;
      !instr_valid: SINS;
      icode == IHALT : SHLT;
      1 : SAOK;
];
2. 译码和写回阶段

图 56 是 PIPE 的译码和写回逻辑的详细说明。 标号为 “dstE”、“dstM”、“srcA” 和 “srcB” 的块与它们在 SEQ 实现中的相应部件非常类似。 提供给写端口的寄存器 ID 来自于写回阶段(信号 W_dstE 和 W_dstM),而不是来自于译码阶段。 这是因为我们系统进行写的目的寄存器是由写回阶段中的指令指定的。

图 56 PIPE 的译码和写回阶段逻辑

这个阶段的复杂性主要是跟转发逻辑有关。 标号为 “Sel+Fwd A” 的块扮演两个角色。 它为后面的阶段将 valP 信号合并到 valA 信号,这样可以减少流水线寄存器中状态的数量。 它还实现了源操作数 valA 的转发逻辑。

合并信号 valA 和 valP 的依据是,只有 call 和跳转指令在后面的阶段中需要 valP 的值,而这些指令不需要从寄存器文件 A 端口读出值。

用转发避免数据冒险 提到有 5 个不同的转发源,每个都有一个数据字和一个目的寄存器 ID:

数据字 寄存器 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 描述:

int 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
];

流水线寄存器 E 的 valB 新值的 HCL 描述为:

int d_valB = [
    d_srcB == e_dstE : e_valE;           # Forward valE from execute
    d_srcB == M_dstM : m_valM;           # Forward valM from memory
    d_srcB == M_dstE : M_valE;           # Forward valE from memory
    d_srcB == W_dstM : W_valM;           # Forward valM from write back
    d_srcB == W_dstE : W_valE;           # Forward valE from write back
    1 : d_rvalB;                         # Use value read from register file
];

上述 HCL 代码中赋予这五个转发源的优先级是非常重要的。 这种优先级由 HCL 代码中检测五个目的寄存器 ID 的顺序来确定。 如果选择了其他任何顺序,对某些程序来说,流水线就会出错。

图 57 转发优先级的说明

图 57 给出了一个程序示例,要求对执行和访存阶段中的转发源设置正确的优先级。 在这个程序中,前两条指令写寄存器 %edx,而第三条指令用这个寄存器作为它的源操作数。 当指令 rrmov1 在周期4到达译码阶段时,转发逻辑必须在两个都以该源寄存器为目的的值中选择一个。 它应该选择哪一个呢?为了设定优先级,我们必须考虑当一次执行一条指令时,机器语言程序的行为。 第一条 irmov1 指令会将寄存器 %edx 设为 10,第二条 irmov1 指令会将之设为 3,然后 rrmov1 指令会从 %edx 中读出 3。 为了模拟这种行为,流水线化的实现应该总是给处于最早流水线阶段的转发源以较高的优先级,因为它保持着程序序列中设置该寄存器的最近的指令。 因此,上述 HCL,代码中的逻辑首先会检测执行阶段的转发源,然后是访存阶段,最后才是写回阶段。

只有指令 popl %esp 会关心在访存或写回阶段两个源之间的转发优先级,因为只有这条指令会同时写两个不同的值到相同的寄存器。

写回阶段的一小部分是保持不变的。 如 图 52 所示,整个处理器的状态 Stat 是一个块根据流水线寄存器 W 中的状态值计算出来的。 由于流水线寄存器 W 保存着最近完成的指令的状态,很自然地要用这个值来表示整个处理器的状态。 唯一要考虑的特殊情况是当写回阶段有气泡时。 这是正常操作的一部分,因此对于这种情况,我们也希望状态码是 AOK:

int Stat = [
        W_stat == SBUB : SAOK;
        1 : W_stat;
];
3. 执行阶段

图 58 是 PIPE 执行阶段的逻辑。 这些硬件单元和逻辑块同 SEQ 中的相同,使用的信号做适当的重命名。 我们可以看到信号 e_valE 和 e_dstE 作为转发源,指向译码阶段。 一个区别是标号为 “Set CC” 的逻辑以信号 m_stat 和 W_stat 作为输入,这个逻辑决定了是否要更新条件码。 这些信号用来检查一条导致异常的命令正在通过后面的流水线阶段的情况,因此,任何对条件码的更新都会被禁止。

图 58 PIPE 的执行阶段逻辑

4. 访存阶段

图 59 是 PIPE 的访存阶段逻辑。 将这个逻辑与 SEQ 的访存阶段(图 30)相比较,我们看到,正如前面提到的那样,PIPE 中没有 SEQ 中标号为 “Data” 的块。 这个块是用来在数据源 valP(对 call 指令来说)和 valA 中进行选择的,然后这个选择现在由译码阶段中标号为 “Sel+Fwd A” 的块来执行。 这个阶段的其他块都和 SEQ 相应的部件相同,采用的信号做适当的重命名。 在图中,你还可以看到许多流水线寄存器中的值,同时 M 和 W 还作为转发和流水线控制逻辑的一部分,提供给电路中其他部分。

图 59 PIPE 的访存阶段逻辑

流水线控制逻辑

现在准备创建流水线控制逻辑,以完成我们的 PIPE 设计。 这个逻辑必须处理以下 4 中控制情况,这些情况是其他机制(例如数据转发和分支预测)不能处理的:

  • 处理 ret:流水线必须暂停直到 ret 指令到达写回阶段
  • 加载/使用冒险:在一条从存储器中读出一个值的指令和一条使用该值的指令之间,流水线必须暂停一个周期
  • 预测错误的分支:在分支逻辑下发现不该选择分支之前,分支目标处的几条指令已经进入流水线了。必须从流水线中去掉这些指令。
  • 异常:当一条指令导致异常,我们想要禁止后面的指令更新程序员可见的状态,并且在异常指令到达写回阶段时,停止执行
1. 特殊控制情况所期望的处理

对于 ret 指令,考虑下面的示例程序。

0x000:      irmovl Stack,%esp   # Initialize stack pointer
0x006:      call Proc           # procedure call
0x00b:      irmovl $10,%edx     # return point
0x011:      halt
0x020:  .pos 0x20
0x020:  Proc:                   # Proc:
0x020:      ret                 # return immediately
0x021:      rrmovl %edx,%ebx    # not executed
0x030:  .pos 0x30
0x030:  Stack:                  # Stack: Stack pointer

图 60 给出了我们希望流水线如何处理 ret 指令。 与前面的流水线图不同的是,指令列出的顺序与它们在程序中出现顺序并不相同,因为这个程序含有一个控制流,指令并不是按线性顺序执行的。

图 60 ret 指令处理的简化视图

如图所示,在周期 3 取出 ret 指令,并沿着流水线前进,在周期 7 进入写回阶段。 在经过译码、执行和访存阶段时,流水线不能做任何有用的活动。 取而代之的,我们只能在流水线中插入 3 个气泡。 一旦 ret 指令到达写回阶段,PC 选择逻辑就会将程序计数器设为返回地址,然后取值阶段就会取出位于返回点(地址 0x00b)处的 irmovl 指令。

图 61 是示例程序中 ret 指令的实际处理过程。 在此可以看到,在流水线取指阶段没有办法插入气泡。 每个周期,取指阶段从指令存储器中读出一条指令。 根据 PC 选择和取指阶段 可以知道,对 ret 指令来说,PC 的新值被预测成 valP,也就是下一条指令的地址。 取指阶段会暂停 3 个时钟周期,导致取出 rrmovl 指令,但是在译码阶段就被替换成了气泡。

图 61 ret 指令处理的实际处理过程

加载/使用冒险 一节,我们已经描述了加载/使用冒险所期望的流水线操作,如 图 54 所示。 只有 mrmovlpopl 指令会从存储器中读数据。 当着两条指令中任一条处于执行阶段,且需要该目的寄存器的指令正处于译码阶段时,我们要将第二条指令阻塞在译码阶段,并在下一个周期往执行阶段插入一个气泡。 此后,转发逻辑会解决这个数据冒险。 可以将流水线寄存器 D 保持为固定状态,从而将一个指令阻塞在译码阶段。 与此同时,还必须将流水线寄存器 F 保持为固定状态,这样,就会第二次取出下一条指令。 总之,实现这个流水线流需要发现冒险的情况,保持流水线寄存器 F 和 D 固定不变,并且在执行阶段插入气泡。

要处理预测错误的分支,让我们来考虑下面这个用汇编代码表示的程序,左边是各个指令的地址以供参考:

0x000:    xorl %eax,%eax
0x002:    jne target        # Not taken
0x007:    irmovl $1, %eax   # Fall through
0x00d:    halt
0x00e: target:
0x00e:    irmovl $2, %edx   # Target
0x014:    irmovl $3, %ebx   # Target+1
0x01a:    halt

图 62 表明如何处理这些指令。 同前面一样,指令会按照它们进入流水线的顺序列出,而不是按照它们出现在程序中的顺序。

图 62 处理预测错误的分支指令

因为预测跳转指令会选择分支,所以周期 3 会取出位于跳转目标处的指令,而周期 4 会取出该指令后的那条指令。 在周期 4,分支逻辑发现不应该选择分支之前,已经取出了两条指令,它们不应该继续执行下去了。 幸运的是,这两条指令都没有导致程序员可见的状态发生改变。 只有指令到的执行阶段才会发现哪种情况,在执行阶段中,指令会改变条件码。 我们只要在下一个周期往译码和执行阶段中插入气泡,并同时取出跳转指令后面的指令,这样就能取消(有时也称为指令排除(instruction squashing))那两条预测错误的指令。 这样一来,两条预测错误的指令就会从流水线中消失。 正如 图 65 讨论的那样,在流水线控制逻辑中加入对基本的时钟寄存器设计所做的简单扩展,就能使我们向流水线寄存器中插入气泡。

对于导致异常的指令,我们必须使流水线化的实现符合期望的ISA行为,也就是在前面所有的指令结束前,后面的指令不能影响程序的状态。

要达到这些效果比较麻烦的因素有:

  • 在程序执行的两个不同阶段(取指和访存)会发现异常
  • 在三个不同阶段(执行、访存和写回)会更新程序状态

在我们的阶段设计中,每个流水线寄存器中会包含一个状态码 stat,随着每条指令经过流水线阶段,它会记录指令的状态。 当异常发生时,我们将这个信息作为指令状态的一部分记录下来,并且继续取指、译码和执行指令,就好像什么都没有出错似的。 当异常指令到达访存阶段时,我们会采取措施防止后面的指令修改程序员可见的状态:

  • 禁止执行阶段中的指令设置条件码
  • 向存储器阶段中插入气泡,以禁止向数据存储器中写人
  • 当写回阶段中有异常指令时,暂停写回阶段,从而暂停了流水线

图 63 中的流水线图说明了我们的流水线控制如何处理导致异常的指令后面跟着一条会改变条件码的指令的情况。 在周期 6,pushl 指令到达访存阶段,产生一个存储器错误。 在同一个周期,执行阶段中的 addl 指令产生新的条件码的值。 当访存或者写回阶段中有异常指令时(通过检査信号 m_stat 和 W_stat,然后将信号 set_cc 设置为 0),禁止设置条件码。 在图 63 的例子中,我们还可以看到既向访存阶段插人了气泡,也在写回阶段暂停了异常指令 —— pushl 指令在写回阶段保持暂停,后面的指令都没有通过执行阶段。

图 63 处理存储器引用非法异常

对状态信号流水线化,控制条件码的设置,以及控制流水线阶段 —— 将这些结合起来,我们实现了异常的期望的行为: 异常指令之前的指令都完成了,而后面的指令对程序员可见的状态都没有影响。

2. 发现特殊控制条件

图 64 总结了需要特殊流水线控制的条件。 它给出的 HCL 表达式描述了在哪些条件下会出现这三种特殊情况。 一些简单的组合逻辑块实现了这些表达式,为了在时钟上升开始下一个周期时控制流水线寄存器的活动,这些块必须在时钟周期结束之前产生结果。 在一个时钟周期内,流水线寄存器 D、E 和 M 分别保持着处于译码执行和访存阶段中的指令的状态。 在到达时钟周期末尾时,信号 d_srcA 和 d_srcB 会被设置为译码阶段中指令的源操作数的寄存器 D。 当 ret 指令通过流水线时,要想发现它,只要检查译码、执行和访存阶段中指令的指令码。 发现加载/使用冒险要检查执行阶段中的指令类型(mrmovlpopl),并把它的目的寄存器与译码阶段中指令的源寄存器相比较。 当跳转指令在行阶段时,流水线控制逻辑应该能发现预测错误的分支,这样当指令进入访存阶段时,它就能置从错误预测中恢复所需要的条件。 当跳转指令处于执行阶段时,信号 e_Cnd 指明是否要选择分支。 通过检查访存和写回阶段中的指令状态值,就能发现异常指令。 对于访存阶段,我们使用在这个阶段中计算出来的信号 m_stat,而不是使用流水线寄存器的 M_stat。 这个内部信号包含着可能的数据存储器地址错误。

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

图 64 流水线控制逻辑的检查条件

3. 流水线控制机制

图 65 是一些低级机制,它们使得流水线控制逻辑能将指令阻塞在流水线寄存器中,或是往流水线中插入一个气泡。 这些机制包括对 存储器和时钟 描述的基本时钟寄存器的小扩展。

图 65 附加的流水线寄存器操作

一个流水线寄存器复位配置的0、1模式由流水线寄存器中字段的集合决定。 例如,要往流水线寄存器 D 中插入一个气泡,我们要将 icode 字段设置为常数值 INOP( 图 26)。 要往流水线寄存器E中插人一个气泡,我们要将 icode 字段设为 INOP,并将 dstE、dstM、srcA 和 srcB 字段设为常数 RNONE。 确定复位配置是硬件设计师在设计流水线寄存器时的任务之一。 在此我们不讨论细节,我们会将气泡和暂停都设为 1 看成是出错。

图 66 中的表给出了各个流水线寄存器在三种特殊情况下应该采取的行动。 对每种情况的处理都是流水线寄存器正常、暂停和气泡操作的某个组合。

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

图 66 流水线控制逻辑的动作

4. 控制条件的组合

到目前为止,在我们对待特殊流水线控制条件的讨论中,假设任意一个时钟周期内,最多只能出现一个特殊情况。 在设计系统时,一个常见的缺陷是不能处理同时出现多个特殊情况的情形。 现在来分析这种可能性。我们不需要担心多个程序异常的组合情况,因为已经很小心地设计了异常处理机制,它能够考虑流水线其他指令的情况。 图 67 是导致其他三种特殊控制条件的流水线状态。 图中所示的是译码、执行和访存阶段的块。 加载/使用冒险要求执行阶段中的指令将一个值从存储器读到寄存器中,同时译码阶段中的指令要以该寄存器作为源操作数。 预测错误分支要求执行阶段中的指令是一个跳转指令。 对 ret 来说有三种可能的情况——指令可以处在译码、执行或访存阶段。 当 ret 指令通过流水线时,前面的流水线阶段都是气泡。

图 67 特殊控制条件的流水线状态

从图中我们可以看出,大多数控制条件是互斥的。 例如,不可能同时既有 加载/使用冒险又有预测错误的分支,因为加载/使用冒险要求执行阶段中是加载指令(mrmovlpopl),而预测错误的分支要求执行阶段中是一条跳转指令。 类似地,第二个和第三个 ret 组合也不可能与加载/使用冒险或预测错误的分支同时出现。 只有用头标明的两种组合可能同时出现。

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

合并组合 A 条件的控制动作(见 图 66),得到以下流水线控制动作(假设气泡或暂停会覆盖正常的情况):

条件流水线寄存器
FDEMW
处理 ret暂停气泡正常正常正常
预测错误的分支正常正常气泡正常正常
组合暂停气泡气泡正常正常

也就是说,组合A的处理与预测错误的分支相似,只不过在取指阶段是暂停。 幸运的是在下一个周期,PC 选择逻辑会选择跳转后面那条指令的地址,而不是预测的程序计数器值,所以流水线寄存器 F 发生了什么是没有关系的。 因此我们得出结论,流水线能正确处理这种组合情况。

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

合并组合 B 条件的控制动作(见 图 66),得到以下流水线控制动作:

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

如果同时触发两组动作,控制逻辑会试图暂停 ret 指令来避免加载/使用冒险,同时又会因为 ret 指令而往译码阶段中插入一个气泡。 显然,我们不希望流水线同时执行这两组动作。而是希望它只采取针对加载/使用冒险的动作。 处理 ret 指令的动作应该推迟一个周期。

这些分析表明组合 B 需要特殊处理。 实际上,PIPE 控制逻辑原来的实现并没有正确处理这种组合情况。 即使设计已经通过了许多模拟测试,它还是有细节问题,只有通过刚才那样的分析才能发现。 当执行一个含有组合B的程序时,控制逻辑会将流水线寄存器 D 的气泡和暂停信号都置为 1。 这个例子表明了系统分析的重要性。只运行正常的程序是很难发现这个问题的。 如果没有发现这个问题,流水线就不能忠实地实现 ISA 的行为。

5. 控制逻辑实现

图 68 是流水线控制逻辑的整体结构。 根据来自流水线寄存器和流水线阶段的信号,控制逻辑产生流水线寄存器的暂停和气泡控制信号,同时决定是否要更新条件码寄存器。 我们可以将 图 64 的发现条件和 图 66 的动作结合起来,产生各个流水线控制信号的 HCL 描述。

图 68 PIPE 流水线控制逻辑

这个逻辑覆盖了通过流水线的正常指令流,以处理特殊条件,例如过程返回、预测错误的分支、加载/使用冒险和程序异常。

遇到加载/使用冒险或 ret 指令,流水线寄存器 F 必须暂停:

bool F_stall =
        # Conditions for a load/use hazard
        E_icode in { IMRMOVL, IPOPL } &&
        E_dstM in { d_srcA, d_srcB } ||
        # Stalling at fetch while ret passes through pipeline
        IRET in { D_icode, E_icode, M_icode };

遇到加载/使用冒险,流水线寄存器 D 必须暂停:

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

遇到预测错误的分支或 ret 指令,流水线寄存器 D 必须设置为气泡。 然而,正如前面一节的分析,当遇到加载/使用冒险和 ret 指令组合时,不应该插入气泡:

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 { IMRMOVL, IPOPL }
          && E_dstM in { d_srcA, d_srcB })
        && IRET in { D_icode, E_icode, M_icode };

图 66 可以看出,由于加载/使用冒险,或者分支预测错误,流水线寄存器 E 必须设置成气泡:

bool E_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 { IMRMOVL, IPOPL }
          && E_dstM in { d_srcA, d_srcB });

set_cc 信号需要检查正在执行的指令的代码,还要检查流水线中更后面阶段中的异常。

bool set_cc =
        E_icode == IOPL &&
        !m_stat in { SADR, SINS, SHLT } && !W_stat in { SADR, SINS, SHLT };

在下一个周期向访存阶段插入气泡需要检查当前周期中访存或者写回阶段中是否有异常。

# Start injecting bubbles as soon as exception passes through memory stage
bool M_bubble = m_stat in { SADR, SINS, SHLT } || W_stat in { SADR, SINS, SHLT };

对于暂停写回阶段,只用检查这个阶段中的指令的状态。 如果当访存阶段中有异常指令时我们也暂停了,那么这条指令就不能进入写会阶段:

bool W_stall = W_stat in { SADR, SINS, SHLT };

现在我们就讲完了所有的特殊流水线控制信号的值。 在 PIPE 的完整 HCL 代码中,所有其他的流水线控制信号都设为 0。

性能分析

我们可以看到,所有需要流水线控制逻辑进行特殊处理的条件,都会导致流水线不能够实现每个时钟周期发射一条新指令的目标。 我们可以通过确定往流水线中插入气泡的频率,来衡量这种效率的损失,因为插人气泡会引发未使用的流水线周期。 一条返回指令会产生三个气泡,一个加载/使用冒险会产生一个,而一个预测错误的分支会产生两个。 我们可以通过计算 PIPE 执行条指令所需要的平均时钟周期数的估计值,来量化这些处罚对整体性能的影响,这种衡量方法称为 CPI(Cycles Per Instruction,每指令周期数)。 这种衡量值是流水线平均吞吐量的倒数,不过时间单位是时钟周期,而不是微微秒。 这是一个设计体系结构效率的很有用的衡量标准。

如果我们忽略异常带来的性能损失(异常的定义表明它是很少出现的),另一种思考 CPI 的方法是,假设我们在处理器上运行某个基准程序,并观察执行阶段的运行。 每个周期,执行阶段要么会处理一条指令,然后这条指令继续通过剩下的阶段,直到完成;要么会处理一个由于三种特殊情况之一而插入的气泡。 如果这个阶段一共处理了 Ci 条指令和 Cb 个气泡,那么处理器总共需要大约 Ci+Cb 个时钟周期来执行C条指令。 我们说“大约”是因为忽略了启动指令通过流水线的周期。 于是,可以用如下方法来计算这个基准程序的CPI:

CPI = Ci+Cb Ci =1.0+ Cb Ci

也就是说,CPI 等于 1.0 加上一个处罚项 Cb/Ci,这个项表明执行一条指令平均要插入多少个气泡。 因为只有三种指令类型会导致插人气泡,我们可以将这个处罚项分解成三个部分:

CPI = 1.0+ lp+ mp+ rp

这里,lp(load penalty,加载处罚)是由于加载/使用冒险造成暂停时插入气泡的平均数,mp(mispredicted branch penalty,预测错误分支处罚)是由于预测错误取消指令时插人气泡的平均数,而 rp(retur penalty,返回处罚) 是由于 ret 指令造成暂停时插入气泡的平均数。 每种处罚都是由该种原因引起的插人气泡的总数(Cb 的一部分)除以执行指令的总数(Ci)。

未完成的工作

我们已经创建了 PIPE 流水线化的微处理器结构,设计了控制逻辑块,并实现了处理普通流水线不足以处理的特殊情况的流水线控制逻辑。 然而,PIPE 还是缺乏一些实际微处理器设计中所必需的关键特性

1.多周期指令

Y86 指令集中的所有指令都包括一些简单的操作,例如数字加法。 这些操作可以在执行阶段中一个周期内处理完。在一个更完整的指令集中,我们还将实现一些需要更为复杂操作的指令,例如,整数乘法和除法,以及浮点运算。 在一个像 PIPE 这样性能中等的处理器中,这些操作的典型执行时间从浮点加法的 3 或 4 个周期到整数除法的 32 个周期。 为了实现这些指令,我们既需要额外的硬件来执行这些计算,还需要一种机制来协调这些指令的处理与流水线其他部分之间的关系。

实现多周期指令的一种简单方法就是简单地扩展执行阶段逻辑的功能,添加一些整数和浮点算术运算单元。 一条指令在执行阶段中逗留它所需要的多个时钟周期,会导致取指和译码阶段暂停。 这种方法实现起来很简单,但是得到的性能并不是太好。 通过采用独立于主流水线的特殊硬件功能单元来处理较为复杂的操作,可以得到更好的性能。 通常,有一个功能单元来执行整数乘法和除法,还有一个来执行浮点操作。 当一条指令进人译码阶段时,它可以被发射到特殊单元。 在这个特殊单元执行该操作时,流水线会继续处理其他指令。 通常,浮点单元本身也是流水线化的,因此多条指令可以在主流水线和各个单元中并发执行。

不同单元的操作必须同步,以避免出错。 比如说,如果在不同单元执行的各个指令之间有数据相关,控制逻辑可能需要暂停系统的某个部分,直到由系统其他某个部分处理的操作的结果完成。 经常使用各种形式的转发,将结果从系统的一个部分传递到其他部分,这和前面 PIPE 各个阶段之间的转发一样。 虽然与 PIPE 相比,整个设计变得更复杂,但还是可以使用暂停、转发,以及流水线控制等同样的技术,使整体行为与顺序的 ISA 模型相匹配。

2.与存储系统的接口

在 PIPE 的描述中,我们假设取指单元和数据存储器都可以在一个时钟周期内读或是写存储器中任意的位置。 我们还忽略了由自我修改代码造成的可能冒险,在自我修改代码中,一条指令对一个存储区域进行写,而后面又从这个区域中读取指令。 进一步说,是以存储器位置的虚拟地址来引用它们的,这要求在执行实际的读或写操作之前,要将虚拟地址翻译成物理地址。 显然要在一个时钟周期内完成所有这些处理是不现实的。 更糟糕的是,要访问的存储器的值可能位于磁盘上,这会需要上百万个时钟周期才能把数据读入到处理器存储器中。

处理器的存储系统是由多种硬件存储器和管理虚拟存储器的操作系统软件共同组成的。 存储系统被组织成一个层次结构,较快但是较小的存储器保持着存储器的一个子集,而较慢但是较大的存储器作为它的后备。 最靠近处理器的一层是高速缓存(cache)存储器,它提供对最常使用的存储器位置的快速访问。 一个典型的处理器有两个第一层高速缓存——一个用于读指令,一个用于读和写数据。 另一种类型的高速缓存存储器,称为高速缓存翻译后备缓冲器(Translation Look-aside Buffer) 或 TLB,它提供了从虚拟地址到物理地址的快速翻译。 将 TLB 和高速缓存结合起来使用,在大多数时候,确实可能在一个时钟周期内读指令并读或是写数据。 因此,我们的处理器对访问存储器的简化看法实际上是很合理的。

虽然高速缓存中保存最常引用的存储器位置,但是有时候还会出现高速缓存不命中(miss)也就是有些引用的位置不在高速缓存中。 在最好的情况下,可以从较高层的高速缓存或处理器的主存中找到不命中的数据,这需要3~20个时钟周期。 同时,流水线会简单地暂停,将指令保持在取指或访存阶段,直到高速缓存能够执行读或写操作。 至于流水线设计,通过添加更多的暂停条件到流水线控制逻辑,就能实现这个功能。 高速缓存不命中以及随之而来的与流水线的同步都完全是由硬件来处理的,这样能使所需的时间尽可能地缩短到很少数量的时钟周期。

在有些情况,被引用的存储器位置实际上是存储在磁盘存储器上的。 此时,硬件会产生一个缺页(page fault)异常信号。 同其他异常一样,这个异常会导致处理器调用操作系统的异常处理程序代码。 然后这段代码会发起一个从磁盘到主存的传送操作。 一旦完成,操作系统会返回到原来的程序,而导致缺页的指令会被重新执行。 这次,存储器引用将成功,虽然可能会导致高速缓存不命中。 让硬件调用操作系统例程,然后操作系统例程又会将控制返回给硬件,这就使得硬件和系统软件在处理缺页时能协同工作。 因为访问磁盘需要数百万个时钟周期,OS 缺页中断处理程序执行的处理所需的几百个时钟周期对性能的影响可以忽略不计。

从处理器的角度来看,将用暂停来处理短时间的高速缓存不命中和用异常处理来处理长时间的缺页结合起来,能够顾及到存储器访问时由于存储器层次结构引起的所有不可预测性。