指令被编码为由一个或多个字节序列组成的二进制格式,一个处理器支持的指令
和指令的字节级编码
称为它的指令集体系结构(Instruction-Set Architecture,ISA),如LoongArch ISA指令addu12i.w
的字节级编码(字节序列)是00000000001010010
。
Y86-64指令集是本书作者自己定义的一个简单指令集,与X86-64 相比,Y86-64 指令集的数据类型、指令和寻址方式都要少一些。定义一个指令集体系结构包括定义各种状态单元、指令集和它们的编码、一组编程规范和异常事件处理。
Y86-64包括有以下可见的状态单元:
15
个程序寄存器。每个程序寄存器存储一个 64
位的字。寄存器%rsp
被入栈、出栈、调用和返回指令作为栈指针。3
个1
位的条件码,它们保存着最近的算术或逻辑指令所造成影响的有关信息。Stat
,表明程序执行的总体状态。它会指示是正常运行,还是出现了某种异常,例如当一条指令试图去读非法的内存地址时。以下是作者对Y86-64指令的定义,只包括8
字节整数操作,寻址方式比较少,操作也少。图中左边是指令的汇编码表示,右边是字节编码:
movq
指令分成了4
个不同的指令:irmovq
、rrmovq
、mrmov
、rmmovq
,分别显式的指明源和目的的格式。源可以是立即数(i)、寄存器(r)或内存(m),指令名字的第一个字母就表明了源的类型。目的可以是寄存器(r)或内存(m),指令名字的第二个字母指明了目的的类型。4
个整数操作指令,见下图OPq
指令,分别是addq
、subq
、andq
和xorq
,只对寄存器数据进行操作,这些指令会设置3
个条件码ZF
、SF
和OF
(零、符号和溢出)。7
个跳转指令是jmp
、jle
、jl
、je
、jne
、jge
和jg
。6
个条件传送指令:cmovle
、cmovl
、cmove
、cmovne
、cmovge
和cmovg
。call
将call的下条指令地址值压到栈顶,然后将PC值设置为call后面跟的目的地址;ret
将PC的设置为当前栈顶存放的值。pushq
和popq
指令实现了入栈和出栈。halt
指令停止指令的执行,执行halt
指令会导致处理器停止,并将状态码设置为HLT
。如1.2中的图4.2所给指令字节级编码,每条指令需要 1 ~ 10
个字节不等,且第一个字节表明指令的类型,这个字节分为两个部分,每部分 4 位:高 4 位是code
部分(值为 0 ~ 0xB
),低 4 位是function
部分(function
值只有在一组相关指令共用一个code
时才有用),如下图所示:
15
个程序寄存器中每个都有一个范围在0~0xE
之间的寄存器标识符(registerID),程序寄存器保存在CPU的寄存器文件中,这个寄存器文件就是一个小的、以寄存器ID作为地址的随机访问存储器,在编码过程中,当需要指明不应访问任何寄存器时,就用ID值0xF
来表示。
指令集的一个重要性质就是字节编码必须有唯一的解释,任意一个字节序列要么是一个确定且唯一的指令序列的编码,要么就不是一个合法的字节序列。Y86-64就具有这个性质,因为每条指令的第一个字节有唯一的code
和function
组合,给定这个字节,就可以决定所有其他附加字节的长度和含义。这个性质保证了处理器可以无二义性地执行目标代码程序,即使代码嵌入在程序的其他字节中,只要从序列的第一个字节开始处理,我们仍然可以很容易确定指令序列。反过来说,如果不知道一段代码序列的起始位置,我们就不能确定如何将字节序列划分成单独的指令,所以确定的字节序列 => 起始位置 + 指令的字节编码
。
Y86-64状态码可以取以下值,1
表示执行正常,2
表示执行一条halt指令,3
遇到非法读写,4
表示遇到非法指令代码,其中2、3、4
则为异常状态码。Y86-64的状态码为异常时,程序会停止(没有异常处理),一般完整的指令集定义,都会有异常处理程序。
逻辑门类比于C语言的逻辑运算,而不是按位与、或、非。逻辑门总是活动的(active),一旦一个门的输入发生变化,在很短的时间内,输出也会相应地变化。
将很多的逻辑门组合成一个网,就能构建计算块(computational block)
,称为组合电路(combinational circuits)
。如何构建这些网有几个限制:
下图中的两个组合电路,第一个是异或,第二个是多路复用器(通常称为MUX
)。
异或和多路复用器对应的HCL表达式分别如下:
bool eq = (a && b) ||(!a && !b);
bool out = (s && a) ||(!s && b);
HCL 表达式很清楚地表明了组合逻辑电路和 C语言中逻辑表达式的对应之处,它们都是用布尔操作来对输入进行计算的函数,但是两者表达式计算有以下区别:
0
表示 FALSE
,其他任何值都表示 TRUE
,而逻辑门只对位置 0
和 1
进行操作AND
或 OR
操作的结果只用对第一个参数求值就能确定,那么就不会对第二个参数求值了,而逻辑组合没有部分求值这条规则,逻辑门只是简单地响应输入的变化。通过将逻辑门组合成大的网,可以构造出能计算更加复杂函数的组合电路,通常,我们设计能对数据字(word)
进行操作的电路。下面组合电路是由64个2.2中的位相等(图4-10)组合电路构成,当且仅当 A 的每一位都和 B 的相应位相等时,输出才为 1,对应的HCL表达式为bool Eq = (A == B);
。
下图字级多路复用器用HCL描述为word out = [s: A; 1: B;];
,第二个选择表达式就是 1
,表明如果前面没有情况被选中,那就选择这种情况。
选择表达式可以是任意的布尔表达式,可以有任意多的情况。这就使得情况表达式能描述带复杂选择标准的、多路输入信号的块。图4-14电路根据控制信号 s1
和 s0
,从 4
个输入字 A、B、C
和 D
中选择一个,将控制信号看作一个两位的二进制数。我们可以用 HCL 来表示这个电路,用布尔表达式描述控制位模式的不同组合:
word out = [
!s1 && !s0: A; #00
!s1: B; #01,这里的分支是“!s1 && s0”
!s0: C; #10,这里的分支是“s1 && !s0”
1: D; #11,这里的分支是“s1 && s0”
];
在处理器设计中,很多时候都需要将一个信号与许多可能匹配的信号做比较,以此来检测正在处理的某个指令代码是否属于某一类指令代码。举个例子,假设想从一个2
位信号 code
中选择高位
和低位
来产生四路复用器的控制信号 s1
和 s0
,对应的HCL表达式:
bool s1 = code == 2 || code == 3;
bool s0 = code == 1 || code == 3;
将其写成集合关系表述就是:
bool s1 = code in {2, 3};
bool s0 = code in {1, 3};
组合电路从本质上讲,不存储任何信息,相反,它们只是简单地响应输入信号,产生等于输入的某个函数的输出。为了产生时序电路(能够存储各种操作之间的信息),也就是有状态并且在这个状态上进行计算的系统,我们必须引入按位存储信息的设备。存储设备都是由同一个时钟控制的,时钟是一个周期性信号,决定什么时候要把新值加载到设备中。考虑两类存储器设备:
r0、r1...)
作为地址,CPU根据地址获取寄存器中的值。在 IA32 或 y86-64 处理器中,寄存器文件有15 个程序寄存器(%rax ~ %r14)。在MIPS中,寄存器文件有32个通用寄存器。这里需要区分一下针对于组合电路所说的硬件寄存器和针对于机器级编程的程序寄存器:
下图是硬件寄存器的工作方式,大多数时候,寄存器都保持在稳定状态(用 x
表示),产生的输出等于它的当前状态。当新的信号沿着寄存器前面的组合逻辑电路传播,这时,产生了一个新的寄存器输入(用 y
表示),但是只要时钟是低电位的,寄存器的输出就仍然保持不变。当时钟变成高电位时候,输入信号就加载到寄存器中,成为下一个状态 y
,直到下一个时钟上升沿,这个状态就一直是寄存器的新输出。每当每个时钟到达上升沿时,值才会从寄存器的输入传送到输出。
下图是寄存器文件的工作方式,寄存器文件不是组合电路,因为它有内部存储。寄存器文件有两个读端口(A
和 B
),还有一个写端口(W
),这样一个多端口随机访问存储器允许同时进行多个读和写操作。两个读端口有地址输入 srcA
和 srcB
和对应的数据输出 valA
和 valB
,写端口有地址输入 dstW
,以及数据输入 valW
。例如,读取$r3
中的值时,将 src A
设为 3
,在一段延迟之后,程序寄存器 %rbx
中存放的值就会出现在输出 valA 上。向寄存器文件写入值是由时钟信号控制的,控制方式类似于将值加载到时钟寄存器,每次时钟上升时,输入 valW
上的值会被写入输入 dstW
上的寄存器 ID
指示的程序寄存器,当 dstW
设为特殊的 ID
值 0xF
时,不会写任何程序寄存器。
描述一个称为 SEQ(“sequential” 顺序的)的处理器。
通常,处理一条指令包括很多操作,将它们组织成某个特殊的阶段序列,所有的指令都遵循统一的序列,即使某条指令在某个阶段不执行,也要遵循这个阶段序列,阶段序列如下:
取指(fetch)
:取指阶段从内存读取指令字节,地址为 PC
的值。从指令中抽取出指令指示符字节的两个四位部分,称为 icode
(指令代码)和 ifun
(指令功能)。然后根据icode
,它可能会继续取出一个寄存器指示符字节,指明一个或两个寄存器操作数指示符 rA
和 rB
,它也可能取出一个四字节常数字 valC
。最后按顺序方式计算当前指令的下一条指令的地址 valP
,也就是说,valP
等于 PC
的值加上已取出指令的长度。icode + ifun
可以用一个字节表示,如果是x86-64,肯定不止一个字节。很多RISC指令集是32
位定长的,所以取指方式同这里的也会不一样,不过原理相同。译码(decode)
:译码阶段从寄存器文件读入最多两个操作数,得到值 valA
和/或 valB
。通常,它读入指令 rA
和 rB
字段指明的寄存器,不过有些指令是读寄存器 %rsp
的。执行(execute)
:在执行阶段,算术/逻辑单元(ALU)要么执行指令指明的操作(根据 ifun
的值),计算内存引用的有效地址,要么增加或减少栈指针。得到的值我们称为 valE
。在此,也可能设置条件码。对一条条件传送指令来说,这个阶段会检验条件码和传送条件(由 ifun
给出),如果条件成立,则更新木雕寄存器。同样,对一条跳转指令来说,这个阶段会决定是不是应该选择分支。访存( memory)
:访存阶段可以将数据写入内存,或者从内存读出数据。读出的值为 valM
。写回(write back)
:写回阶段最多可以写两个结果到寄存器文件。更新 PC(PC update)
:将 PC
设置成下一条指令的地址。以某个代码块中的pushq rA
指令为例,说明各个阶段的具体工作,pushq rA
表示将栈顶指针减8
(%rsp = %rsp - 8
),并将rA
中的值存放在栈顶(%rsp
),其指令的定义如下:
0x02a: a02f pushq $rdx
实现所有 y86-64 所需要的计算可以被组织成 6 个基本阶段:取指、译码、执行、访存、写回 和 更新 PC,下图给出一个能执行这些计算的硬件结构的抽象表示,其中:
valP
,即增加了的 PC。A
和 B
,从这两个端口同时读寄存器值 valA
和 valB
。0
,将一个输入传递到输出。Cnd
。M
用来写从数据内存中读出的值。valP
,下一条指令的地址;valC
是调用指令或跳转指令指定的目标地址;valM
是从内存读取的返回地址。一个时钟变化会引发一个经过组合逻辑的流,来执行整个指令。组合逻辑电路与时序电路不同,不存储任何信息,只是简单地响应输入信号,产生符合组合逻辑的输出,如果输入信号再次更新,则输出也会跟着变化,并不会将先前的输出存下来,时序电路则有专门的存储设备将输出保存下来。
SEQ 的时序实现包括组合逻辑和两种存储设备:时钟寄存器(PC
和 条件码寄存器
),随机访问存储器(存储器文件
,指令内存
和 数据内存
);组合逻辑不需要任何时序或控制——只要输入变化了,值就通过逻辑门网络传播。可以将读随机访问存储器看成是组合逻辑一样的操作,即当输入某个合法地址值,输出则是地址中存放的值。
有以下四个硬件单元的时序要进行明确的控制——PC、条件码寄存器、数据内存 和 寄存器文件。这些单元通过一个时钟信号来控制,它触发将新值装载到寄存器以及将值写到随机访问存储器。每个时钟周期,PC 都会装载新的指令地址;只有在执行整数运算指令时,才会装载条件码寄存器;只有在执行 rmmovq
、 pushq
或 call
指令时,才会写数据内存;寄存器文件的两个写端口允许每个时钟周期更新两个程序寄存器,不过我们可以用特殊的寄存器 ID 0xF
作为端口地址,来表明此端口不应该执行写操作。
要控制处理器中活动的时序,只需要寄存器和内存的时钟控制,且所有的状态的更新都只在时钟上升开始下一个周期时同时发生。保持这样的等价性要遵循一个组织计算原则:从不回读,处理器从来不需要为了完成一条指令的执行而去读由该指令更新了的状态
。看3.1图片中的push
指令,如果我们对 pushq
指令的实现是先将 %rsp = %rsp - 8
,然后再将更新后的 %rsp
值作为写操作的地址,这样就同组织计算原则相违背,因为指令从寄存器文件中读取了指令本身更新的栈指针。所以应该这样设计,3.1图片的执行阶段,将%rsp - 8
产生的栈指针,作为信号valE
,然后利用这个信号作为访存时的地址,最后在时钟上升时,将valE信号写入寄存器%rsp
中。
以下是汇编代码,SEQ硬件处理其中的3、4行指令:
跟踪SEQ的两个执行周期,每个周期开始时,状态单元(寄存器和内存)是根据前一条执行设置的,信号传播通过组合逻辑,创建出新的状态单元的值,在下一个周期开始时(时钟上升),这些值会被加载到状态单元。如下图中,当周期3开始时,上升前状态单元中的值是周期1组合逻辑创建,上升后状态单元被修改该为周期2组合逻辑创建;当周期3结束时,状态单元保持不变,周期四开始时(上升后),状态单元时周期3组合逻辑创建。
本节设计实现 SEQ 所需要的控制逻辑块的 HCL 描述中使用的名称和含义如下:
1)取指阶段
取指阶段主要工作是指令内存硬件单元,以 PC 作为第一个字节(字节0)的地址,从指令内存中一次读出 10
个字节(Y86-64 最长的指令占10
个字节,见1.2中的插图)。第一个字节被解释成指令字节(标为 Split
单元),分为两个 4
位的数,标号为 icode
和 ifun
;或者使之等于从内存读出的值;或者当指令地址不合法时(由信号 imem_error
指明),使这些值对应于 nop
指令。根据 icode
的值,我们可以计算三个1
位的信号(用虚线表示):
instr_valid
: 这个字节是否是一个合法的 Y86-64 指令。need_regids
:这个 Y86-64 指令是否为一条带有寄存器指示值字节的指令。need_valC
:这个 Y86-64 指令是否包括一个常数字。instr_valid
和 imem_error
在访存阶段被用来产生状态码。
从指令内存中读出的剩下 9
个字节是寄存器指示符字节和常数字的组合编码。标号为 Align 的硬件单元会处理这些字节,将它们放入寄存器字段和常数字中。当被计算出的信号 need_regids
为 1
时,字节 1
被分开装入寄存器指示符 rA
和 rB
中,否则,这两个字段会被设为 0xF
(NONE),表明这条指令没有指明寄存器。任何只有一个寄存器操作数的指令,寄存器指示值字节的另一个字段都设为 0xF
,所以可以将信号 rA
和 rB
看成,要么放着我们想要访问的寄存器,要么表明不需要访问任何寄存器。Align单元还会根据信号 need_regids
的值来产生常数字 valC
,没有寄存器时 valC
是 1 ~ 8
字节,有寄存器是 2 ~ 9
字节。
PC 增加器硬件单元根据当前的 PC 以及两个信号 need_regids
和 need_valC
的值,产生信号 valP
。对于 PC 值p
、need_regids
值 r
以及 need_valC
值 i
,增加器产生值 valP = p + 1 + r + 8i
(仔细推敲这个公式,理解取值时每次从内存中读取10
个字节,下一次读取从哪个位置开始)。need_regids
的 HCL 描述只是确定了 icode
的值是否为一条带有寄存器指示值字节的指令:
2)译码和写回阶段
译码阶段会从寄存器中读取最多两个操作数,写回阶段会将值写入到寄存器中,所以将这两个阶段放一起说明。寄存器文件有四个端口,它支持同时进行两个读(在端口 A
和 B
上)和两个写(在端口 E
和 M
上)。每个端口都有一个地址连接和一个数据连接,地址连接是一个寄存器 ID,而数据连接是一组 64
根线路,既可以作为寄存器文件的输出字(对读端口来说),也可以作为它的输入字(对写端口来说)。两个读端口的地址输入位 srcA
和 srcB
,而两个写端口的地址输入位 dstE
和 dstM
,如果某个地址端口上的值为特殊标识符 0xF
,则表明不需要访问寄存器。
根据指令代码 icode
以及寄存器指示值 rA
和 rB
,可能还有执行阶段计算出的 Cnd
条件信号,可以确定出写端口dstE
、dstM
和读端口srcA
、srcB
的寄存器 ID。在译码阶段会从读端口中读出的值为信号valA
和valB
,写回阶段会将信号valM
和valE
的值写入到寄存器。部分信号的HCL描述如下:
3)执行阶段
执行阶段包括算术/逻辑单元(ALU),这个单元根据 ALUfun
信号的设置,对输入 ALU A
和 ALU B
执行 ADD
、SUBTRACT
、AND
或 EXCLUSIVE-OR
运算,最终的输出则是valE
。
列出操作数 ALU B
在前面,后面是 ALU A
,这样是为了保证 subq
指令是 valB
减去 valA
。可以看到,根据指令的类型, ALU A
的值可以是 valA
、valC
,或者 -8
或 +8
。因此 ALU A
的HCL描述为:
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
];
ALU可为整数运算指令执行操作,也可作为加法器执行操作,对于OPq
(整数运算)指令,使用ifun
字段中的编码执行操作,所以ALU控制信号 ALU fun
的HCL描述为:
执行阶段还包括条件码寄存器,每次运行时,ALU 都会产生三个与条件码相关的信号——零、符号 和 溢出。不过,我们只希望在执行 OPq
指令时才设置条件码,因此产生了一个信号 set_cc
来控制是否该更新条件码寄存器:
标号为 cond 的硬件单元会根据条件码和功能码来确定是否进行条件分支或者条件数据传送( 1.3 中的图4.3),它产生信号 Cnd
,用于设置条件传送的 dstE
(图4.28
),也用在条件分支的下一个 PC 逻辑中。对于其他指令,取决于指令的功能码和条件码的设置,Cnd
信号可以被设置为 1
或者 0
,但是控制逻辑会忽略它。条件传送指令(简称cmovXX
)的指令代码为IRRMOVQ
,如图4.28
所示,可以用执行阶段产生的Cnd
信号实现这些指令。
4)访存阶段
访存阶段的任务就是读或者写程序数据。如下图,两个控制块产生内存地址Mem addr
和内存输入数据Mem data
(为写操作)的值,另外两个块产生表明应该执行读操作(Mem read
)还是写操作(Mem write
)的控制信号。
从图中可以看出,当执行读操作时,数据内存产生值 valM
,内存读写的地址总是 valE
或 valA
,这个地址Mem addr
用 HCL 描述就是:
也可以从图中看到,内存写的数据总是valA
或valP
,所以Mem data
的HCL表示为:
mem addr =[
# Value from register
icode in { IRRMOVQ, IPUSHQ } : valA;
# Return PC
icode == ICALL : valP;
# Default: Don't write anything
];
计算Stat
字段需要从几个阶段收集状态信息,访存阶段最后的功能是根据取值阶段产生的 icode
、imem_error
、instr_valid
值以及数据内存产生的 dmem_error
信号,从指令执行的结果来计算状态码 Stat
,用HCL表示为:
## Determine instruction status
word Stat = [
imem_error || dmem_error : SADR;
!instr_valid: SINS;
icode == IHALT : SHLT;
1 : SAOK;
];
5)更新PC阶段
SEQ 中最后一个阶段会产生 PC 的新值,依据指令的类型和是否选择分支,新的 PC 可能是 valC
、valM
或 valP
。
对于 PC 值p
、need_regids
值 r
以及 need_valC
值 i
,增加器产生值 valP = p + 1 + r + 8i
,用 HCL 来描述这个选择就是:
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;
];
现在我们已经浏览了 y86-64 处理器的一个完整的设计。可以看到,通过将执行每条不同指令所需的步骤组织成一个统一的流程,就可以用很少量的各种硬件单元以及一个时钟来控制计算的顺序,从而实现整个处理器。不过这样一来,控制逻辑就必须要在这些单元之间路由信号,并根据指令类型和分支条件产生适当的控制信号。
SEQ 唯一的为题就是它太慢了。时钟必须非常慢,以使信号能在一个周期内传播所有的阶段。让我们来看看处理一条 ret
指令的例子,在时钟周期起始时,从更新过的 PC 开始,要从指令内存中读出指令,从寄存器文件中读出栈指针,ALU 将栈指针加 8
,为了得到 PC 的下一个值,还要从内存中读出返回地址。所以这一切都必须在这个周期结束之前完成。
这种实现方法不能充分利用硬件单元,因为每个单元只在整个时钟周期的一部分时间内才被使用。我们会看到引入流水线能获得更好的性能。