Chapter 4. Processor Architecture¶
约 3684 个字 31 行代码 预计阅读时间 19 分钟
Quote
指令系统是计算机软件和硬件的交互接口,程序员根据指令系统设计软件,处理器设计人员根据指令系统来实现硬件。
从Y86-64讲起¶
由于x86-64
指令系统较为复杂,为方便学习和理解,CSAPP原书中参照x86-64
的指令系统,自定义了一个相对简单的指令系统——Y86-64
,该指令系统定义包括:
- Programmer-Visible State 程序员可见状态
- Y86-64 Instructions 指令集
- Instruction Encoding 指令集的编码
- Y86-64 Exceptions 编程规范以及异常事件处理
Programmmer-Visible State¶
程序员可见,这里的 Programmer 既可以是用汇编代码写程序的人,也可以是产生机器级代码的编译器;可见状态是指每条指令都会去读取或者修改处理器的某些部分,例如内存、寄存器、条件码、程序计数器以及程序状态等
在Y86-64
指令系统中,定义了15个64位的程序寄存器,相对于x86-64
少了一个寄存器%r15
,主要是为了降低指令编码的复杂度
除此之外,Y86-64
的指令系统还简化了条件码寄存器,仅保留了零标志(ZF)、符号标志(SF)和溢出标志(OF)
复习一下:程序计数器PC
用来存放当前正在执行指令的地址
Y86-64 Instructions¶
类比x86
,Y86
对其指令集做了一定程度的简化,例如将movq
指令按照源操作数和目的操作数的类型分为了四类:
Source | Destinination | |
---|---|---|
rrmovq | Register | Register |
Irmovq | Immediate | Register |
rmmovq | Register | Memory |
mrmovq | Memory | Register |
这样设计的目的主要是为了降低处理器实现的复杂度
Instruction Encoding¶
接下来我们对上述数据传送指令进行编码,每条指令的第一个字节表示指令的类型。这个字节分为两部分,每一部分占四个比特位:高四位表示指令代码,低四位表示指令功能
对于上述定义的数据传送指令,不同的指令代码表示不同的指令,指令的功能部分均为 0
在 Y86
系统中定义了15个寄存器,虽然每个寄存器有着不同名字,但是还需要为每一个寄存器指定一个编号,用十六进制数 0~0xE
来表示。如果指令中某个寄存器字段的值为0xF
,表示此处没有寄存器操作数。
在Y86-64
指令系统中,定义了四条整数操作指令,不同于x86
,它们只能对寄存器数据进行操作。由于这四条指令属于同一个类型,所以指令代码是一样的,不同的是功能部分:
综上所述,Y86-64
的指令集及其编码的定义介绍完毕
根据上述的编码规则,可以将Y86-64
的汇编代码翻译成二进制表示:
例如,对于指令 rmmovq %rsp, 0x123456789abcd(%rdx)
rmmovq
→0x40
%rsp|%rdx
→0x42
0x123456789abcd
→0xcdab896745230100
- 通过在前面添加 0 来补齐八个字节
x86-64
采用小端法存储,所以需要对偏移量进行字节反序操作
最终得到长度为十个字节的二进制指令:
接下来通过一个例子看一下C程序翻译成Y86-64
汇编代码:
那么其可以在Y86-64
下运行的汇编代码及机器码如下:
Y86-64 Exceptions¶
Value | Name | Meaning | Explanation |
---|---|---|---|
1 | AOK | Normal operation | 程序正常执行 |
2 | HLT | halt instruction encountered | 处理器执行 halt 命令 |
3 | ADR | Invalid address encountered | 程序试图从非法地址读取数据或向非法地址写入数据 |
4 | INS | Invalid instruction encountered | 程序遇到非法指令 |
数字电路与处理器设计¶
对于寄存器堆 (Register File) ,其抽象建模如下:
使用 Verilog 对该寄存器文件进行描述,代码如下:
寄存器堆具体构造如下,详情请自行学习浙江大学《数字逻辑设计》,讲的内容还是相当优质的。
详情可以了解相关章节笔记: CH 7. Memory Basics - Nimisora's Notes
Y86-64 的顺序实现¶
第四章的主要内容是设计一个Y86-64
的处理器,用它来执行上面生成的二进制指令
通常,处理器执行一条指令的操作可以被组织成六个基本阶段:
- 取指阶段 Fetch
- 指令的长度并不是固定的,需要在取指阶段进行判断
- 取指阶段会根据指令代码判断指令是否含有寄存器指示符以及是否含有常数,从而计算出当前指令的指令长度
- 译码阶段 Decode
- 译码阶段即从寄存器堆中读取数据
- 执行阶段 Execute
- 执行算数逻辑运算
- 计算内存引用的有效地址
push
&pop
- 访存阶段 Memory
- 既可以从内存中读取数据,也可以将数据写入内存
- 写回阶段 Write Back
- 与译码阶段相反,将数据写入寄存器堆中
- 更新PC Update
- 将PC设置成下一条指令的地址
并不是所有指令执行都要经过上述六个阶段
例如,处理器处理一个减法指令的具体过程如下:
- Fetch: 减法指令的两个操作数都为寄存器,因此可以算出当前指令的长度为 2 bytes
- Decode: 根据指令,从寄存器堆中读取寄存器数据
- Execute: ALU 根据读取的数据执行算数逻辑运算,并根据结果设置条件码寄存器
CC
- Memory: 由于该指令不需要读写内存,因此访存阶段没有任何操作
- Write Back: 写回阶段将 ALU 的运算结果写回到寄存器
%rbx
- Update: 最后对程序计数器
PC
进行更新
Y86-64 处理器硬件结构¶
该节内容为介绍处理器执行指令各个阶段的硬件实现
- Fetch:取指阶段以程序计数器(PC)的值作为起始地址,每次从指令内存中取十个字节
- 取十个字节,是因为在取指操作前无法判断指令的长度
- 根据指令代码 icode 判断是否合法、指令长度等
- Decode:在
Y86-64
处理器中,寄存器堆有两个读端口srcA
和srcB
,支持同时进行两个读操作- 其中要读取的寄存器的 ID 值需要根据指令代码 icode 以及寄存器指示值
rA
和rB
来判断 - 为什么需要 icode ?因为部分指令只要一个寄存器ID,如
push
需要栈顶指针寄存器%rsp
- 其中要读取的寄存器的 ID 值需要根据指令代码 icode 以及寄存器指示值
- Execute:执行阶段的核心部件为算数逻辑单元 ALU
- ALU 根据指令功能 ifun 来判断堆输入的操作数进行何种运算
- 每次运行时,ALU 都会产生三个与条件码相关的信号——零、符号、溢出
- 图中
setCC
模块会根据指令代码 icode 来控制是否要更新条件码寄存器 cond
硬件单元用来产生cnd
信号,对于跳转指令,如果cnd=1
,那么执行跳转;cnd=0
,则不执行跳转
- Memory:如图所示,根据图中的各个模块进行内存的读写
- 访存阶段的最后操作会根据图中的信号计算状态码
Stat
- 访存阶段的最后操作会根据图中的信号计算状态码
- Write Back:写入阶段即将数据写入寄存器堆中,两个写端口分别为
M
和E
,对应的地址输入为dstE
和dstM
- 如果执行的是条件传送指令
cmov
,写入操作还需要判断执行阶段计算出的cnd
信号,若不满足条件,则将目的寄存器设置为0xF
来禁止写入寄存器文件
- 如果执行的是条件传送指令
- Update:最后更新 PC 的值,分为三种情况
- 如果当前执行
call
,那么新的 PC 就等于call
的常数字段 - 如果当前执行
ret
,指令ret
在访存阶段会从内存(栈)中读出返回地址,这个返回地址就是新的 PC 值 - 如果当前执行跳转指令
jxx
,那么当cnd
信号为 1 的时候,新的 PC 就等于常数字段;当不满足跳转条件时,跳转指令和其它指令一样,新的 PC 就等于当前 PC 的值加上当前指令的长度
- 如果当前执行
以上顺序结构存在一个问题:指令执行速度太慢,难以在一个时钟周期完成所有操作
流水线¶
基本原理与设计¶
对于上述时钟驱动的流水线寄存器,仅在时钟上升沿加载输入
假设一条指令执行需要经过三部分组合逻辑电路,且每部分组合逻辑电路和流水线寄存器的延迟如下,那么可以设置一个时钟周期为 120ps ,且一条指令执行需要 3 个时钟周期
第一条指令经过组合逻辑电路A后腾出了空位给第二条指令,由此可以构造出一个简单的流水线,其吞吐量相比原来的吞吐量提高 2.67 倍
上述实现是一个理想的流水线系统,实际上会有一些因素影响流水线的效率。
- 对于硬件设计者来讲,将一个整体的设计划分为多个延迟都相等的子阶段是一个严峻的挑战,那么时钟的周期就受最慢阶段的延迟来限制
- 另外,如果把一个指令的执行过程划分为更多的子阶段,虽然理论上系统的吞吐量提升了,但是过深的流水线也会导致系统性能的下降
以下是之前讲解过的 Y86-64
的硬件实现,其以读取 PC 为开始,以向更新 PC 为结束,为 顺序结构 。对于当前的顺序结构,所有阶段的操作都要在一个时钟周期内完成
为了将 Y86-64
指令系统改造为流水线结构,首先将 PC 的更新移动至所有阶段的最前面,对于更新 PC 所需要的各个数据,当它们产生后直接传入 PC 前的寄存器内,用来保存 PC 的预测值(如何预测地址,后面章节会有讲解)。
除此之外,还要将每个子阶段前设置一个寄存器用来保存状态(图中 F
,D
,E
,M
,W
)
冒险¶
由于指令与指令之间可能存在数据相关或是控制相关,导致流水线产生了错误的计算结果,这种情况即称之为 冒险 (或者冲突)
数据冒险¶
对于一个指令,需要前面指令修改过后的寄存器,这种情况称为数据相关。避免数据冒险,首先想到的是暂停该指令的执行。
上图,addq
的两个操作数需要先由前两个指令设置,其译码阶段需要推迟到上面两个指令写入阶段结束后。暂停之后,通过插入气泡(bubble)来代替暂停指令的指令,气泡不会改变寄存器、内存、条件码以及程序状态
虽然使用暂停技术可以解决数据冒险,但是基于这种机制实现的流水线性能并不高,这是因为程序中 数据相关 的情况非常多,频繁暂停指令执行会严重降低流水线的吞吐量。
数据转发
另一种解决方法是,在前置数据进入寄存器堆前,就将其传输到addq
的译码阶段进行运算,即 数据转发
数据转发的实现需要在基本的硬件结构中增加一些额外的数据连接和控制逻辑,这使得流水线可以不暂停就能处理大多数情况的数据冒险
控制冒险¶
在流水线的设计中,我们期望每个时钟周期都能完成一条指令的执行,为此,流水线在每个时钟周期都要取到一条指令,因此,每一次取指操作后,必须马上确定下一条指令的地址。
但是当遇到返回指令 ret
时,流水线需要等到访存阶段结束后才能从栈中取得下一条指令的地址;当遇到分支指令 jxx
时,流水线需要等到执行阶段后才能决定是否执行跳转操作,此即控制冒险
对于解决方法,同样可以采取暂停处理新指令,并插入 Bubble 的方法来避免。
分支指令 jxx
有其独特的处理方式,可以预先假定一种策略,预设分支的结果总是跳转或是不跳转,即 分支预测 ,分支预测的准确性对程序的性能有非常大的影响。接下来举一个遇到分支指令,总是跳转执行的例子:
当跳转指令 jne
运行到执行阶段,根据执行结果发现不应该执行跳转,要立即终止 target
中两条 irmovq
指令的运行,方法是将它们剩余的运行阶段作为 Bubble 插入
综上所述,流水线实现过程中出现的冒险,都可以通过暂停和插入气泡来实现。暂停和插入气泡的操作可以通过流水线寄存器的输入 stall
和 bubble
来控制: