CSAPP 第四章 处理器体系结构

通过设计一个 Y86-64 体系结构来理解处理器

一个处理器支持的指令和指令的字节集编码被称为它的指令集体系结构(Instruction-Set Architecture,ISA)。我们之前一直学习的处理器的命令就是 ”x86-64“ 的指令集。

不同的处理器“家族”有不同的ISA。同一个家族里也有不同型号的处理器,但是大多保持兼容。

Y86-64指令集体系结构

这并不是一个真实被应用的指令集,这是为了方便学习来根据 x86-64定义的一个精简版指令集体系结构

程序员可见的状态

  • Y86-64程序中的每条指令都会读取或修改处理器状态的某些部分,这称为程序员可见的状态

  • 这里的“程序员”既可以是用汇编代码写程序的人,也可以是产生机器级代码的编译器

在处理器的实现中,只要我们保证机器及程序能够访问程序员可见状态,就不需要完全按照 ISA 暗示的方法来表示和组织这个处理器状态

Y86-64 的状态类似 x86-64 。有 15 个寄存器:%rax,%rcx,%rdx,%rbx,%rsp,%rbp,%rsi,%rdi 以及 %r8-%r14,每个寄存器存储一个 64 位的字。为了简化指令编码,我们减少了一个 %r15 寄存器。

其中,**%rsp 作为栈指针被用于入栈出栈,程序计数器(PC,也就是%rip)存放当前指令执行的地址**。

除此之外还有 3 个 1 位的条件码 ZF、SF 和 OF,用于条件跳转。程序状态的最后一个部分是状态码(Stat),表示程序的总体状态,提示程序正常是否运行或者是出现了某种异常。

其他的概念我们都认为它和 x86-64 相同或更加简化。

Y86-64 指令

如下图就是我们的 Y86-64 指令的简单描述,左边是指令的汇编码表示,右边是字节编码:

数据传送指令

x86-64 的 movq 指令被分为了 4 个不同的指令,irmovq、rrmovq、mrmovq、rmmovq,分别对应了源和目的的种类数:立即数-寄存器,寄存器-寄存器,内存-寄存器,寄存器-内存。Y86-64 与 x86-64 相同,不允许直接从内存地址传送到另一个内存地址,必须通过寄存器传送。

整数操作指令

有四种类型的整数操作(addq,subq,andq,xorq),他们只对寄存器进行操作。除此之外,这些指令还会设置条件码。

跳转指令

有七个跳转指令,一个无条件跳转和六个条件跳转(jle,jl,je,jne,jge,jg),工作原理和 X86-64 相同。

条件传送指令

有六个条件传送指令(cmovXX),与条件跳转类似。

调用函数

call 指令,将返回地址入栈,然后跳到目的地址。ret 指令从调用中返回。

栈操作指令

也与 x86-64 类似,有入栈(push)和出栈(pop)的操作。

指令编码

具体图示如上一小节示例图

每一个指令的第一个字节表示指令的类型,而这一个字节又分为两部分:高 4 位是代码部分,低 4 位是功能部分.其中功能值只有在一组相关指令共用一个代码才会被使用。如下图:

有的指令只有一字节长,有的就需要附加的寄存器指示符字节来指定一个或者两个寄存器,这些寄存器称为 rA 和 rB 。

15 个寄存器每一个都有自己的标识符,范围是 0~0xE。如果寄存器的字段为0xF,就表示此处没有寄存器操作数,只需要一个寄存器的指令比如 pushq 和 popq 指令,也会将其他的寄存器设置为 0xF。

分支指令调用的地址是绝对地址而不是 PC 寻址,因为我们更注重描述的简单性。

指令集的一个重要的性质是字节编码必须有唯一的解释。

Y86-64异常

对于 Y86-64 来讲,程序员可见的状态包括状态码 start ,它描述程序执行的总体状态。而其他的代码则表示发生了某种类型的异常,具体如下表:

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

在Y86-64 中,我们简化处理,让程序遇到异常就停止执行指令而不是单独编写一个异常处理程序。

Y86-64程序

Y86-64 代码与 x86-64 代码类似,但有以下几点不同:

  • add 指令不能直接加一个常数,Opq 指令只能对两个寄存器做运算。因为 Y86-64 的算术指令中不能使用立即数,需要先加载到寄存器中。
  • subq 指令可以直接设置条件码。在 x86-64 架构下,我们需要再多一个 test 命令才能实现设置条件码。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
.pos 0
irmovq stack,%rsp
call main
halt

array:
.quad 0x000d000d000d
.quad 0x00c000c000c0
.quad 0x0b000b000b00
.quad 0xa000a000a000

main:
irmovq array,%rdi
irmovq $4,%rsi
call sum
ret

sum:
irmovq $8,%r8
irmovq $1,%r9
xorq %rax,%rax
andq %rsi,%rsi
jmp test

loop:
mrmovq (%rdi),%r10
addq %r10,%rax
addq %r8,%rdi
subq %r9,%rsi

test:
jne loop
ret

.pos 0x200
stack:

在上面的程序中,以 . 开头的词是汇编器伪指令,他们告诉汇编器台哦正地址,以便产生代码或者插入数据。例如 .pos 0 告诉汇编器应该从地址0 处产生代码。

其他的代码指令和 x86-64含义相同,我们可以推断出这个函数目的实现 WORD sum(WORD *array,WORD length),而该函数会求出 array[0]~array[length] 的和。

一些 Y86-64 指令的详情

需要特别注意 pushq 指令和 popq 指令。

pushq 指令会把栈指针减 8 ,并将一个寄存器值写入内存中,当我们执行 pushq %rsp 时,处理器的行为是不确定的,因为入栈的寄存器会在这条指令中被更改状态,这种情况下就会存在两种不同的结果:

  • 压入 %rsp 的原始值(x86-64采用的约定)
  • 压入减去 8 的 %rsp 的值

我们运行书中的测试程序:

1
2
3
4
5
movq %rsp,%rax
pushq %rsp
popq %rdx
subq %rdx,%rax
ret

它的返回值总是0,也就是说 pushq 指令是先放后减。

同样 popq 也有这样的歧义,树上的测试程序:

1
2
3
4
5
6
movq %rsp,%rdi
pushq $0xabcd
popq %rsp
movq %rsp,%rax
movq %rdi,%rsp
ret

函数的返回值总是 0xabcd,说明 pop %rsp 是先减去了 %rsp 再赋的值。

也就是说,push 和 pop 两个指令在对 %rsp 寄存器本身操作时,都会尽量保证得到的值是原始值。

  • pushq %rsp 一定会压最初的 %rsp。

  • popq %rsp 一定是把那个值正确地给到了 %rsp。

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

这部分应该是模电(还是叫数电?)的知识叭应该

逻辑门

逻辑门是数学电路的基本计算单元,它产生的输出等于他们输入位值的某个布尔函数。

常见的逻辑门就是 and 、or 、not,并且 and 、or 常见为两输入,但是可以扩展到 n 输入的状态,比如三输入的与门用 HCL 表示就为 a&&b&&c。逻辑门总是活动的,一旦一个门的输入发生变化,在很短的时间内输出也会相应变化。

组合电路和HCL 布尔表达式

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

  • 每个逻辑门的输入必须连接到三个选择项之一:一个系统的输入、某个寄存器单元的输入或某个逻辑门的输入

  • 两个或多个逻辑门的输入不能连接在一起

  • 网必须是无环的,也就是说不能形成回路

多路复用器(MUX)根据输入控制信号的值,从一组不同的数据信号中选择一个。

HCL 组合逻辑电路和 C 语言逻辑表达式之间有以下不同:

  • 组合电路会持续地响应输入的变化,C 语言只会在程序运行过程中对应语句被执行到才会进行求值

  • C 语言的逻辑表达式允许参数是任意整数,会自动转化为0 (false) 和非0 (true) ,而逻辑门只对位值 0 和 1 进行操作

  • C 逻辑表达式可能会出现部分求值的特性,如果一个 and 或者 or 运算只对第一个参数求值之后就能确定,那么就不会对第二个参数求值了,而组合逻辑电路没有这种规则。

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

利用逻辑门组合成的网,我们就可以设计出对数据字操作的电路。其中,组合电路分局输入字的每个位,用逻辑门分别计算输出字的每一位。

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

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

表达式包含一系列的情况,每种情况 i 都有一个布尔表达式 select 和一个整数表达式 expr ,分别表示什么时候选择这种情况和会得到什么值。

需要注意的是,情况表达式并不要求各个不同的选项之间互斥,但是并不会出现多个结果,在实际的选择过程中是按顺序进行的,且第一个求值为1的情况会被选中。

eg:设计一个逻辑电路来找一组字 a、b、c 的最小值,就可以这样表达:

1
2
3
4
5
word min=[
a<=b &&a<=c : a;
b<=a &&b<=c : b;
1 : c;
]

其中最后一个选择表达式1,意味如果前面的都没有被选中,那就选择这种情况。这也是比较常用的指定默认情况的方法。

集合关系

集合关系用来将一个信号与许多可能匹配的信号作比较,以此来检测正在处理的某个指令是否属于某一类指令代码。

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

1
iexpr in {iexpr1,iexpr2,...,iexprk}

存储器时钟

组合电路从本质上讲并不存储任何信息,我们想要产生时序电路必须引进按位存储信息的设备。

存储设备都是由同一个时钟控制的,时钟是一个周期性的信号,决定什么时候要把新值加载到设备中。我们考虑以下两种存储器设备:

  • 时钟寄存器(简称寄存器):存储单个位或字,时钟信号控制寄存器加载输入值。
  • 随机访问存储器(简称内存,Random Access Memory):存储多个字,通过地址选择该读或该写哪些字。

需要注意的是硬件中的 ”寄存器“ 和机器级编程中的 ”寄存器“ 并不是完全相同的概念,硬件中的寄存器是一个电子元件,机器编程中寄存器代表 CPU 中可寻址的字,它们存储在寄存器文件中。我们分别称呼这两类寄存器位 ”硬件寄存器“ 和 ”程序寄存器“。

大多时候,寄存器输出会一直保持在当前的寄存器状态上,只有每个时钟到达上升沿时,值才会从寄存器的输入传送到输出。

如上图,每个寄存器文件都有两个读端口和一个写端口。这样的多端口访问机制允许同时进行多个读和写操作。

同样,因为可以同时进行多个读写操作,就不可避免地发生冲突。这时候我们的随机访问寄存器就派上了用场。

这个内存有一个地址输人,一个写的数据输入,以及一个读的数据输出。

其中从内存中读的操作类似于组合逻辑,如果我们在输入 address 上提供一个地址, 并将 write 控制信号设置为 0, 那么在经过一些延迟之后,存储在那个地址上的值会出现在输出 data 上,内存将不会响应输入地址上的数据写入请求。如果地址超出了范围,error 信号会设置为 1,否则就设置为 0。

写内存是由时钟控制的:我们将 address 设置为期望的地址,将 data in 设置为期望的值, write 设置为 1。然后当我们控制时钟时,只要地址是合法的,就会更新内存中指定的位置。

对于读操作来说,如果地址是不合法的,error 信号会被设置为 1。这个信号是由组合逻辑产生的, 因为所需要的边界检查纯粹就是地址输人的函数,不涉及保存任何状态。

Y86-64的顺序实现

首先,我们描述一个称为 SEQ 的处理器。每个时钟周期上,SEQ 执行处理一条完整指令所需的所有步骤。但这需要一个很长的周期,我们需要改进这个缺点,缩短时钟的周期,以实现最终的目的——实现一个高效、流水线化的处理器

将处理组织成阶段

处理一条指令包括很多操作,主要有以下几个阶段:

  • 取指 (fetch)

    取指是指从内存中读取指令操作(不是打错字了),地址为程序计数器的值。从指令中抽取出指令指示符字节的两个四位部分,称为 icode(指令代码)和 ifun(指令功能)。

    它可能取出一个寄存器指示符字节,指明一个或两个寄存器操作数指示符 rA 和 rB 。它还可能取出一个四字节常数字valc。它按顺序方式计算当前指令的下一条指令的地址 valP 。也就是说,valP 等于 PC 的值加上已取出指令的长度。

  • 译码(decode)

    译码阶段从寄存器文件中读入最多两个操作数,得到值 valAvalB。通常,它读入指令rA和rB字段指明的寄存器,不过有些指令是读寄存器 %rsp 的。

  • 执行(execute)

    在执行阶段,算术/逻辑单元(ALU)要么执行指令指明的操作(根据ifun的值),计算内存引用的有效地址,要么增加或减少栈指针。得到的值我们称为 valE 。除此之外我们可以设置条件码来限制传送条件。

  • 访存(memory)

    访存阶段可以将数据写入内存,或者从内存读出数据。读出的值为 valM

  • 写回(write back)

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

  • 更新PC(PC update)

    将PC设置成下一条指令的地址,更新 %rip 寄存器。

处理器无限循环来执行这些阶段,在我们简化的实现中,任何一个阶段异常处理器都会停止。

我们采用通用框架来将指令映射到硬件中,例如下表是对 Opq、rrmovq 和 irmovq 类型的指令所需要的处理:

整数操作都遵循上面的通用模式,在取指阶段我们不需要常数,valP 的值计算为 PC+2 。

需要注意 push 指令的实现,%rsp 的指针是先减去 8 再写入的,具体流程见下图:

SEQ硬件结构

要完成 y86-64 指令的六个基本阶段,需要相应的硬件结构,如下图为硬件结构对应的抽象表示:

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

  • 取指:将 PC 作为地址从对应的内存中读取指令,PC 增加器计算指令长度并将新的 PC 暂时放入值 valP 当中
  • 译码:从寄存器文件中同时读取对应寄存器的值,得到两个操作数
  • 执行:根据指令类型,将算数/逻辑单元用于不同的目的,执行指定运算、改变指针或者计算有效地址等等。这一步可能会更新标志寄存器
  • 访存:对内存进行访问(读/写)
  • 写回:将新的寄存器值更新到寄存器文件中,这里有两个写端口, E 用于接收 ALU 计算的结果、valM 用于接收读取内存的结果。
  • PC 更新:根据预先保存的地址或者是之前计算的指令预计的下一步位置去更新 PC。

SEQ的时序

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

组合逻辑不需要任何时序或控制,只要输人变化了,值就通过逻辑门网络传播。其中读随机访问存储器的操作可以简化地看作是一个立即响应输入地址的组合逻辑操作。

程序计数器、 条件码寄存器、数据内存和寄存器文件这四个硬件需要有明确的时序控制。这些单元通过一个时钟信号来控制,它触发将新值装载到寄存器,以及将值写到随机访问存储器。由于我们遵循从不回读的原则,我们只需要控制内存和寄存器的时钟控制信号。

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

SEQ阶段的实现

我们为不同的常数值赋予了不同的意义,包括指示指令代码、功能码、寄存器值和ALU状态等等。具体如下表:

其中 nop 指令处了将 pc 加 1 不进行任何操作;halt 指令设置处理器状态,导致程序停止运行。

取指阶段

取指阶段包括指令内存硬件单元,以 PC 作为第一个字节(字节 0)的地址,这个单元一次从内存读出 10 个字节。

10字节中的第一个字节被解释成指令字节(Split),分为两个4位的net数。它又被分割得到 icode 和 ifun 。根据 icode 的值,会进一步判断指令是否合法、是否包含寄存器或者常数。

对应的 Instr valid 、Need_regids、Need_valC 用 HCL 表示如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Instr_valid=
icode in{
0xC,0xD,0xE,0xF // 检查没有被赋予特殊意义的常数,也就是检查是否合法
};

Need_regids =
icode in {
IRRMOVQ,IOPQ,IPUSHQ,IPOPQ,
IIRMOVQ,IRMMOVQ,IMRMOVQ
};

Need_valC=
icode in{
IIRMOVQ,IMRMOVQ,IRMMOVQ,
IJXX,ICALL
};

译码和写回阶段

这两个阶段都要访问寄存器文件。

寄存器文件有四个端口,它支持同时进行两个读和两个写。

每个端口都有一个地址连接和一个数据连接,地址连接是一个寄存器 ID,而数据连接是一组 64 根线路,既可以作为寄存器文件的输出字(对读端口来说),也可以作为它的输人字(对写端口来说)。 两个读端口的地址输人为 srcA 和 srcB,而两个写端口的地址输人为 dstE 和 dstM。如果某个地址端口上 的值为特殊标识符 OxF(RNONE),则表明不需要访问 寄存器。

也就是说, srcA 和 srcB 决定我们从哪个寄存器读数据,对应从 valA 和 valB 中读出。同理 destE 和 destM 决定要去写哪个寄存器,对应数据从 valE 和 valM 中写入。

四个端口的 HCL 描述如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
word srcA=[
icode in {IRRMOVQ,IRMMOVQ,IOPQ,IPUSHQ}:rA;
//只有在 rrmovq,rmmovq,OPq,pushq 指令需要读取操作数所包含的寄存器
icode in {IPOPQ, IRET } : RRSP;
//在 popq 和 ret 两个指令执行的时候,我们需要额外读取一个 rsp 寄存器
1 : RNONE;
];

word srcB = [
icode in {IRMMOVQ, IMRMOVQ, IOPQ } : rB;
icode in {IPOPQ, IRET, ICALL, IPUSHQ } : RRSP;
1 : RNONE; # Don't need register
];

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

word dstM = [
icode in {IMRMOVQ,POPQ}:rA;
1 : RNONE;
];

执行

执行阶段包括算数/逻辑单元(ALU),这个单 元根据 alufun 信号的设置,对输人 aluA 和 aluB 执行 ADD、SUBTRACT、 AND 或 EXCLUSIVEOR 运算。这些数据和控制信号是由三个控制块产生的。ALU 的输出就是 valE 信号。

如下图:

执行阶段的第一步就 是每条指令的 ALU 计算。列出的操作数 aluB 在 前面,后面是 aluA,这样是为了保证 subq 指令是 valB 减去 valA 。

我们用如下的 HCL 表达式来描述 aluA 的行为:

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

aluA 的计算方式取决于指令的类型,主要涉及了不同指令的源操作数。具体的计算方式如下:

  • 对于 OPq 指令,aluA 的值等于从寄存器 rA 中读取的 valA
  • 对于 rrmovq 指令,aluA 的值等于 valA,因为这个指令是简单的数据传送,将 valA 赋值给目的寄存器。
  • 对于 irmovq 指令,aluA 的值等于 valC,因为这个指令将立即数(常数)valC 赋值给目的寄存器。
  • 对于 rmmovq mrmovq 指令,aluA 的值等于 valC,因为这些指令需要从指令中提取立即数 valC 并与目的寄存器的值相加。

同理,aluB也是如此,具体的计算方式入下:

  • 对于 rmmovqmrmovqOPqcallpushqretpopq 指令,aluB 的值等于从寄存器rB中读取的valB
  • 对于 rrmovqirmovq 指令,aluB 的值为0,因为这些指令只是数据传送,所以 aluB 为0。

观察 ALU 在执行阶段执行的操作,可以看到它通常作为加法器来使用。不过,对于 oPq 指令,我们希望它使用指令 ifun 字段中编码的操作实现其他运算。

我们有一个信号 ALUfun 来控制 ALU进行什么操作,所以我们这样描述 :

1
2
3
4
5
word alufun = [
icode == IOPQ : ifun;
//只在执行 OPq 指令的时候根据 func 功能位去选择运算种类
1 : ALUADD;
]

当执行 OPq指令时,我们希望设置条件码,对此也有一个信号来控制:

1
bool set_cc = icode in {IOPQ};

访存

访存阶段的任务就是读或者写程序数据。两个控制块产生内存地址和内存输入数据(为写操作)的值。另外两个块产生表明应该执行读操作还是写操作的控制信号。当执行读操作时, 数据内存产生值 valM。

更新 PC

SEQ的最后阶段会更新程序计数器的值,根据指令类型和是否要选择分支,新的PC可能是 valC、valM 或 calP。

流水线的通用原理

SQE 的缺点是要在一个周期内完成所有的操作,必须把时钟周期定的很慢,这样不能充分利用我们的硬件单元。为了解决这个问题,我们引入流水线来或获得更好的性能。

所谓流水线就是我们所熟知的工厂流水线、自动洗车机这种“流水线化”的系统。在流水线系统中,待执行的任务被划分成了若干个独立的部分,而这些部分是同时进行的。就像汽车加工厂拧螺丝的机器一直在拧螺丝,而不是等一辆车制作完成后再拧下一颗螺丝。

计算流水线

下图是一个非流水线化的硬件系统的例子

由一些执行计算的逻辑以及一个保存结果的寄存器组成,时钟信号会在每个特定的时间间隔去保存寄存器。

图中的计算块是用组合逻辑来实现的,意味着信号会穿过一系列逻辑门,在一定时间的延迟之后,输出就成为了输入的某个函数。

下图又是一张流水线化的系统

我们将两个系统对比来看,我们假设连个系统运行同一个程序,运算逻辑需要300ps,加载寄存器需要 20ps,那么非流水线化的指令周期就是 320ps。我们将运算分为3个部分,每个阶段需要100ps。然后在各个阶段之间放上流水线寄存器,这样每条指令都会按照这三步经过这个系统,从头到尾完成一次就需要三个完整的时钟周期。

ps(皮秒,10e-12S) 是时间单位

吞吐量以十亿条指令 /S(GIPS)作为单位,在上面的例子中,这个系统的吞吐量就是 1/(320×10^12)

这样就可以让三个阶段同时在工作,增加效率,但是执行单条指令所需时间增加。例如同样是这个计算逻辑,我们执行三次,非流水线化的程序需要三个完整的周期 960ps ,而流水线化的程序只需要 600ps (只是用于举例的计算,并不是实际过程中实现需要的时间)

流水线操作的详细说明

就像上面的流水线化的程序,把指令分为三个阶段,同一时间可能就会有三条指令经过不同的阶段,比如 240-360。

下面我们跟踪了相应的电路活动

在时刻 240(点 1)时钟上升之前,指令 I1 和I2 已经完成了阶段 B 和 A ,阶段A中计算的指令 I2 的值已经到达第一个流水线寄存器的输入,但是该寄存器的状态和输出还保持为指令Il在阶段A中计算的值。在时钟上升后,这些指令开始传送到阶段 C 和 B,而指令 I3 开始经过阶段 A(点 2 和 3)。就像图中点 3 处的曲线化的波阵面(curved wavefront)表明的那样,信号可能以不同的速率通过各个不同的部分。在时刻 360 之前,结果值到达流水线寄存器的输入(点4)。当时刻 360 时钟上升时,各条指令会前进经过一个流水线阶段。

从这个对流水线操作详细的描述中,我们可以看到减缓时钟不会影响流水线的行为。信号传播到流水线寄存器的输入,但是直到时钟上升时才会改变寄存器的状态。另一方面,如果时钟运行得太快,就会有灾难性的后果。值可能会来不及通过组合逻辑,因此当时钟上升时,寄存器的输人还不是合法的值。

流水线的局限性

在我们理想的流水线化系统中,各部分相互独立,每个部分所需的时间都是相同,但实际情况中会有一些因素来降低流水线的效率。

不一致的划分

每一阶段的时间不是正好相同,就是降低效率的原因之一。比如下方程序被分为三个不同的阶段,但通过这些阶段的延迟从 50ps 到 150ps 不等。通过所有阶段的延迟和仍然为300ps。

不过,运行时钟的速率是由最慢的阶段的延迟限制的。流水线图表明,每个时钟周期,阶段A都会空闲(用白色方框表示)100ps,而阶段C会空闲 50ps 。只有阶段B会一直处于活动状态。我们必须将时钟周期设为150+20=170ps,得到吞吐量为5.88GIPS。另外,由于时钟周期减慢,延迟增加到了 510ps。

需要注意的是,对硬件设计者来说,将指令过程进行等分是很困难的。通常,处理器中的某些硬件单元,如ALU和内存,是不能被划分成多个延迟较小的单元的。这就使得创建一组平衡的阶段非常困难

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

我们在这里划分了 50ps 一个阶段,那么我们所需最小时钟周期为 70ps,比起划分为 100ps 一个阶段,性能提高了 120/70=1.71 倍的效率,虽然我们将划分的阶段时长减小到了二分之一,但是效率确没有提高 2 倍,主要是流水线寄存器产生的延迟。如图这种情况,流水线寄存器的延迟占到了 28.6%。

许多现代的处理器都采用了很深的流水线(15 或者更多),它们把一条指令的执行分成很多简单的步骤,这样一来,每个阶段的延迟就很小。

待反馈的流水线系统

在实际的系统中,我们的每一个指令都不是完全独立的,比如: irmovq $50,%rax; addq %rax,%rbx 这两句代码之间的 rax 就在传递。

如果只是采用最普通的流水线就会发生异常,在第一条指令的执行阶段,可能第二条指令正在译码,需要去等待第一条指令 %rax 的最终结果,直到第一条指令写回才更新 %rax 寄存器,此时第二条指令已经过了执行阶段了。

此时我们引入带反馈的流水线,带需要注意,由于流水线改变了系统的行为,我们必须正确处理反馈的影响。

像上图那样改变系统的行为是不可接受的。我们必须以某种方式来处理指令间的数据和控制相关,以使得到的行为与 ISA 定义的模型相符。

Y86-64 的流水线实现

首先,对顺序的SEQ处理器做一点小的改动,将PC的计算挪到取指阶段。然后,在各个阶段之间加上流水线寄存器。在此基础上做一些修改,就能实现我们的目标——一个高效的、流水线化的实现 Y86-64 ISA 的处理器。

SEQ+:重新安排计算阶段

我们要调整一下 AEQ 中五个阶段的顺序,使更新 PC 阶段在一个时钟周期开始时进行,从而计算当前指令的 PC 值,而不是结束时执行。 调整后的设计称为 SQE+。

我们创建状态寄存器来保存在一条指令执行过程中计算出来的信号,这种改进称为电路重定时(circuit retiming)。重定时改变了一个系统的状态表示,但是并不改变它的逻辑行为。通常用它来平衡一个流水线系统中各个阶段之间的延迟。

SEQ+不会有硬件寄存器来存放 PC,而是根据前一条指令保存下来的一些状态信息动态地计算 PC

插入流水线寄存器

如下图为 SEQ+ 的硬件结构,我们要在此基础上插入流水线寄存器:

我们插入流水线寄存器后得到 PIPE- 架构,其中灰色底色的框框内就是流水线寄存器:

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

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

下面的代码就解释了流水线的步骤。

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

顺序实现 SEQ 和 SEQ+ 在一个时刻只处理一条指令,因此诸如 valc、srcA 和 valE 这样的信号值有唯一的值。但在流水线化的设计中,与各个指令相关联的这些值有多个版本,会随着指令一起流过系统。

例如,在 PIPE- 的详细结构中,有4 个标号为 “Stat” 的白色方框,保存着 4 条不同指令的状态码。我们需要很小心以确保使用的是正确版本的信号。我们采用的命名机制,是在信号名前面加上大写的流水线寄存名字作为前缀,存储在流水线寄存器中的信号就可以被唯一地标识。

在命名系统中,

大写的前缀 “D”、”E”、”M” 和 “W” 指的流水线寄存器,所以 M_stat 指的是流水线寄存器 M的状态码字段。

小写的前缀” f”、”d”、”e”、”m” 和 “w” 指的是流水线阶段,所以 m_stat 指的是在访存阶段中由控制逻辑块产生出的状态信号。

PIPE- 中有一个块在相同表示形式的 SEQ+ 中是没有的,那就是译码阶段中标号为 SelectA 的块。我们可以看出,这个块会从来自流水线寄存器 D 的 valP 或从寄存器文件 A 端口中读出的值中选择一个,作为流水线寄存器 E的值 valA 。

这个块是为了减少要携带给流水线寄存器 E 和 M 的状态数量。在所有的指令中,只有 call 在访存阶段需要 valP 的值(压入下一个PC)。只有跳转指令在执行阶段(当不需要进行跳转时)需要 valP 的值。而这些指令又都不需要从寄存器文件中读出的值。

因此我们合并这两个信号,将它们作为信号 valA 携带穿过流水线,从而可以减少流水线寄存器的状态数量。这样做就消除了 SEQ(图4-23)和SEQ+(图4-40)中标号为 Data 的块,这个块完成的是类似的功能。

预测下一个PC

流水线化设计的目的就是每个时钟周期都发射一条新指令,也就是说每个时钟周期都有一条新指令进入执行阶段并最终完成。要达到这个目的吞吐量必须要是是每个时钟周期一条指令。要做到这一点,我们必须在取出当前指令之后,马上确定下一条指令的位置。

如果取出的指令是条件分支指令,要到指令通过执行阶段之后,我们才能知道是否要选择分支。类似地,如果取出的指令是 ret,要到指令通过访存阶段,才能确定返回地址。

但是除此之外,我们都能在取指阶段结束后马上知道下一跳指令的地址,对于无条件跳转来说,下一条指令的地址是指令中的一个常数 valC,对于其他指令来说就是 valP。对于条件跳转指令来说,如果选择了跳转,那么 PC 新的值应当是 valC,如果选择不跳转,那么 PC 新的值应当是 valP。

流水线冒险

我们需要在流水线中引入反馈系统,因为当相邻指令有关联的时候,前一条指令并不能和后一条指令并行执行。这个关联有两种形式

  • 数据相关:后一条指令需要读取前一条指令执行的结果
  • 控制相关:后一条指令为条件跳转,条件取决于当前语句的执行状态。

这些相关有可能会导致指令执行得到错误的结果,称为冒险。

同相关一样,冒险也可以被分为两部分:数据冒险和控制冒险,我们先考虑数据冒险。

用暂停来避免数据冒险

暂停 (stalling) 是避免冒险的一种常用技术,暂停时,处理器会停止流水线中一条或多条指令,直到冒险条件不再满足。让一条指令停顿在译码阶段,直到产生它的源操作数的指令通过了写回阶段,这样我们的处理器就能避免数据冒险。

用转发来避免数据冒险

PIPE- 的设计是在译码阶段从寄存器文件中读人源操作数,但是对这些源寄存器的写有可能要在写回阶段才能进行。与其暂停直到写完成,不如简单地将要写的值传到流水线寄存器 E 作为源操作数。

这种将结果值直接从一个流水线阶段传到较早阶段的技术称为数据转发(data forwarding,或简称转发,有时称为旁路 bypassing)。数据转发需要在基本的硬件结构中增加一些额外的数据连接和控制逻辑。

加载/使用数据冒险

有一类数据冒险不能单纯用转发来解决,因为内存读在流水线发生的比较晚。

我们可以使用暂停+转发两种思想结合的方式解决冒险,如果发现访存得到的结果需要在下一条指令马上被访问,那么我就暂停一个指令周期等到访存结束之后马上转发给下一条指令,此时下一条指令正在译码阶段。这种方法叫做加载互锁。

避免控制冒险

当处理器无法根据处于取指阶段的当前指令来确定下一条指令的地址时,就会出现控制冒险。我们的流水线化处理器中,控制冒险只会发生在ret 指令和跳转指令。

在出现特殊情况时,暂停和往流水线中插人气泡的技术可以动态调整流水线的流程。


原来已经 20 多天了都没看完这一章,看的头昏脑胀😶‍🌫️

先这样,真的看不动了,大概扫了一下接下来的几个小节内容和之前大差不差且看不懂

最近 sxx 布置的作业好多感觉阳气都被她吸干了,我是懒惰虫😿


CSAPP 第四章 处理器体系结构
https://shmodifier.github.io/2023/10/22/CSAPP-第四章-处理器体系结构/
作者
Modifier
发布于
2023年10月22日
许可协议