第 6 章 指令解码
书内第 176–194 页;PDF 第 188–206 页。原书疑点见校注。
- 6.1 指令缓存
- 6.2 一般情况
- 6.3 特殊情况
- 6.3.1 分支指令的处理
- 6.3.2 乘累加/乘法指令的处理
- 6.3.3 前/后变址指令的处理
- 6.3.4 LDM/STM 指令的处理
- 6.3.5 条件执行指令的处理
在流水线中,指令解码 (decode) 阶段的任务是将指令中携带的信息提取出来,处理器使用这些信息控制后续的流水线来执行这条指令。指令集的复杂程度直接决定了这部分任务的工作量,对于 CISC 指令集,例如 x86 来说,指令的长度是不固定的,解码阶段首先需要分辨指令的边界,这样才能够找到有效的指令,而且 x86 指令的寻址方式也很复杂,这也增加了解码的难度。而对于 RISC 指令集来说,指令的长度是固定的,例如 MIPS 指令和 ARM 指令的长度都是 32 位,这样很容易将指令分辨出来,而且 RISC 处理器的寻址方式相对也是比较简单的,这些因素都导致 RISC 处理器的解码难度要远低于 CISC 处理器,因此 RISC 处理器在成本和功耗方面天生就比 CISC 处理器占据优势。影响处理器解码复杂度的还有一个因素,那就是每周期可以解码的指令个数,由于每条指令都需要一个完整的解码电路,所以对于一个每周期可以解码 $n$ 条指令的超标量处理器来说,就需要 $n$ 个解码电路,这样当然也就增加了解码电路的复杂度,总结来看,一个 RISC 处理器的解码部分可以用图 6.1 来表示。

图 6.1 在 RISC 处理器中的解码电路
在超标量处理器中,即使是 RISC 指令集,仍存在一些比较 “另类”的指令,这些指令不能使用一般指令的处理方式,需要特殊处理,例如有些指令的目的寄存器个数多于一个 (MIPS 中的乘累加指令,ARM 中的乘累加指令和 LDM/STM 指令等 ),这会影响寄存器重命名 (register renaming) 的过程;再例如,有些 RISC 指令集支持每条指令都可以条件执行,例如 ARM 指令集,这样的指令在解码时需要进行特殊处理等,因为这些 “另类”的指令使用的频率并不高,没有必要让后面的流水线增加硬件来对它们进行处理,这需要在解码阶段将它们转换为普通的指令,后面的流水线按照一般指令的处理方式来执行它们,这些内容将在本章进行讲解。
6.1 指令缓存
在前文说过,为了减少 I-Cache 缺失带来的影响,处理器可以在取指令阶段从 I-Cache 中取出的指令个数多于每周期可以解码的指令个数,例如在 MIPS 74kf 处理器中,每周期可以取出四条指令,但是每周期只可以解码两条指令,这就需要在取指令阶段和解码阶段之间加入一个缓存,用来将 I-Cache 取出的所有指令保存起来,这个缓存称为指令缓存 (instruction buffer),加入这个缓存之后,取指令阶段最终会输出两个主要的部分给这个缓存 ( 假设每周期可以从 I-Cache 中取出四条指令 )。
(1) 四条指令,当然这四条指令未必全部都是有效的;
(2) 有效指令的个数,正常情况下,每周期可以从 I-Cache 中取出四条有效指令,但是当取指令的地址落在 Cache line 的最后三个字,或者指令组 (fetch group) 中存在被预测跳转的分支指令时,会导致取指令阶段无法向指令缓存中写入四条指令,此时使用这个信号来告诉指令缓存,哪些指令是有效的。
指令缓存本质上是一个 FIFO, 它能够将指令按照程序中指定的顺序存储起来,这样指令在解码的时候,仍旧可以按照程序中指定的顺序进行解码,便于找到指令间存在的相关性。指令缓存是超标量处理器中必需的部件,其原因有如下两个。
(1) 在很多超标量处理器中,每周期可以取指令的个数大于每周期可以解码指令的个数,这样即使在 I-Cache 缺失的时候,指令缓存中也仍然有存有一些余量的指令,如果 I-Cache 的缺失可以很快地解决,那么基本上就不会引起流水线的暂停,这样就可以增加处理器的性能。
(2) 在超标量处理器中,即使每周期取指令的个数等于每周期解码指令的个数,例如都是四条指令,在流水线的解码阶段却有一些特殊的指令要处理,这会导致取指令阶段得到的所有指令没有办法在解码阶段全部都得到处理。举例来说,不管是在 MIPS 还是 ARM 处理器中,由于乘累加指令有两个目的寄存器,为了减少对寄存器重命名阶段的影响,都会将其拆分成两条普通的指令,每条指令都只有一个目的寄存器,因此,一旦在取指令阶段得到的指令包括乘累加指令时,如果不进行处理,会导致解码之后的指令个数多于四条,而后续的流水线都是按照每周期处理四条指令来设计的,不可能因为这种不常出现的情况而增加后续流水线的处理能力,这样会占用更多的硅片面积,因此就需要在解码阶段对乘累加类型的指令进行处理,例如可以使乘累加指令后续的指令放在下一个周期进行解码,这样就会导致取指令阶段送过来的指令不能够全部被解码,因此指令缓存就是必需的了。
其实,在超标量处理器中还有很多这样的限制,这些限制是一种折中 (tradeoff), 它的思路都是不为很少出现的情况增加硬件的复杂度。由于这些限制的存在,在每周期内,取指令阶段送出的指令不一定能够在解码阶段全部被解码,因此就需要一个地方来存储这些不能够被解码的指令,这个地方就是指令缓存,使用它,可以使解码部分的设计更加灵活,而且,即使发生 I-Cache 缺失,指令缓存中的 “存粮”也不至于使后续的流水线马上停止,因此,可以将指令缓存看作是一个 “储备仓库”。
由于指令缓存在每周期内可以写入多条指令,也可以读出多条指令,因此它是一个多端口的 FIFO, 但是在实际的设计中并不会使用真正的多端口 SRAM 来实现这样的 FIFO, 而是使用交叠 (interleaving) 的方式,使用多个单端口的 SRAM 来实现这个功能,从而避免了使用多端口 SRAM 所带来的硬件和速度上的限制,这样的设计在前文中已经涉及到,此处不再赘述。
6.2 一般情况
MIPS 指令集包括 I-Type、R-Type 和 J-Type 三种类型的指令,一般的指令包括两个源寄存器 (Rs 和 Rt) 和一个目的寄存器 (Rd); ARM 指令集包括 DP format、DT format 和 BR format 三种类型的指令,一般的指令包括三个源寄存器 (Rn、Rs 和 Rm) 和一个目的寄存器 (Rd),其实对于 ARM 指令集来说,情况远不止如此,很多的特殊情况都需考虑,有如下几种。
(1) 对于条件执行的指令来说,还包括第四个源寄存器,那就是 CPSR 寄存器,所有条件执行的指令都需要读取 CPSR 寄存器的值。
(2) 所有改变状态寄存器的指令,还包括第二个目的寄存器,即 CPSR 寄存器,这条指令在执行完成后,除了要将结果写到正常的目的寄存器之外,还需要将结果的执行状态写到 CPSR 寄存器中。
(3) 对于前 /后变址的 load/store 指令,也包括第二个目的寄存器,即用来作为地址的寄存器,当指令执行完成后,除了将正常的数据写到目的寄存器之外,还需要将地址寄存器的内容也进行更新。
(4) 还有很另类的 LDM/STM 指令,包括了个数不确定的目的寄存器和源寄存器。
在上面的特殊情况中,前两条所体现出来的特殊性在于 CPSR 寄存器,它可以作为源寄存器,也可以作为目的寄存器,在超标量实现时,如果将 CPSR 寄存器作为一个普通的寄存器来对待,那么此时 ARM 的每条指令就包括四个源寄存器和两个目的寄存器,这对寄存器重命名 (register renaming) 过程中的映射表 (mapping table) 的影响是非常大的,本来映射表的端口个数就已经很多了,面积和速度都很难进行优化,如果再加入额外的端口,那么情况就会更加糟糕;而且,CPSR 寄存器的宽度只有四位 (因为条件执行只考虑 N、C、Z、V 这四位就可以 ),而普通的通用寄存器是 32 位的,如果将 CPSR 寄存器和通用寄存器统一对待,会造成很多寄存器不能够得到有效的利用,32 位的寄存器只装入了 4 位的数据。考虑到这些限制,一般都是将 CPSR 寄存器单独处理,为 CPSR 寄存器设置一个独立的物理寄存器堆 (Physical Register File),用来存放重命名之后的 CPSR 寄存器,并使用一个独立的映射表 (mapping table) 来进行管理,也就是为 CPSR 寄存器单独使用一套寄存器重命名的流程,这样就可以根据 CPSR 寄存器的特点来定制重命名的过程,例如每个物理寄存器都是 4 位的,而且考虑到条件执行的指令只是少部分,所以寄存器堆的容量可以很小,例如只使用 16 个物理寄存器就足够了,这样的处理方式不会增加通用寄存器的映射表的端口个数,因此不会对处理器的速度产生影响。
对于第 (3) 和 (4) 条所体现出来的特性,需要借鉴现代 x86 处理器的设计思路,将这些复杂的指令拆分为多条普通的 RISC 指令,这样就可以利用一般的硬件对这些特殊的指令进行处理,在超标量处理器中,一条指令所携带的源寄存器和目的寄存器的个数直接决定了寄存器重命名电路在实现上的难易,例如映射表的端口个数,指令间相关性检查电路的复杂度等,因此 RISC 指令集中的这些复杂指令成为了超标量处理器中比较难以处理的另类,需要使用更多的硬件来对它们进行处理。
一般情况下,每周期都会从指令缓存 (instruction buffer) 中读取多条指令,例如处理器每周期可以解码四条指令,那么此时就会从指令缓存中读取四条指令,并对每条指令都进行解码,每条指令的解码电路是一样的,都可以对任何指令进行解码,这个过程如图 6.2 所示。

图 6.2 每周期从指令缓存中读取四条指令进行解码
图 6.2 表示了每周期解码四条指令的示意图,由于 RISC 指令的长度固定,所以 RISC 指令是很容易被解码的,也很容易从指令缓存读取四条指令,但是需要注意的是,从指令缓存中读取的四条指令未必是四字对齐的,这需要将这四条指令按照程序中原始的顺序排列好,送给解码电路进行解码,这是采用交叠 (interleaving) 结构的多端口存储器所必须考虑的问题。
由于 RISC 指令比较规整,很容易找出指令中的操作码 (opcode) 和操作数 (operand),在解码阶段产生的流水线控制信号也会比较少,因此 RISC 处理器的解码过程一般都可以在一个周期内完成,流水线后续阶段的处理也不会很复杂,这些原因都使 RISC 处理器的频率比较高,这也是 RISC 所秉承的目的,即通过简化的指令集来简化硬件设计的复杂度,从而获得更高的性能。早期的工作站很多都使用 RISC 处理器,后来由于种种原因,RISC 处理器大多数都退到了嵌入式领域,只有 IBM 仍然在自己的服务器产品中使用 RISC 指令集的 POWER 系列处理器,但是随着 ARM 处理器的崛起,相信 RISC 处理器在高性能处理器领域会占据一席之地。
总体来看,在一般情况下,RISC 处理器在解码阶段完成任务可以概括为三个 what, 它们分别解释如下。
(1) What type, 例如指令是算术指令,访问存储器的指令还是分支指令等;
(2) What operation, 例如当指令是算术指令时进行何种算术运算,当指令是分支指令时它的条件是什么样的,当指令是访问存储器的指令时是 load 指令还是 store 指令等;
(3) What resource, 例如对于算术指令来说,源寄存器和目的寄存器是哪些,指令中是否有立即数等。
当然,在解码阶段可能还需要得到一些其他的信息,这需要根据具体的指令集进行分析。
6.3 特殊情况
如果指令集中的所有指令都是上述简单的 RISC 指令,那么解码阶段的任务就比较容易了,但是事实并非如此,即使在 RISC 指令集中,也存在一些 “不和谐” 的指令,这些指令不能够按照一般的方法进行处理,例如 ARM 指令集中的 LDM/STM 指令,需要多个周期才能够完成,而且它们的目的寄存器 / 源寄存器有多个,如果在超标量处理器中对它们按照普通指令对待的话,就会使映射表 (mapping table),发射队列 (Issue Queue) 和重排序缓存 (ROB) 等部件面临数量众多的端口需求,大大增加了硬件的消耗,并且降低了速度,因此在超标量处理器中不会直接处理这种复杂的 LDM/STM 指令,而是将它转化为多条简单的指令,每条简单指令就是一条普通的 load/store 指令,这样就可以按照一般的方式进行处理。其实在 Intel 的很多处理器中也是采用了这种方法,在处理器的解码阶段,将复杂的 x86 指令转化为多条简单的 RISC 指令 (Intel 将其称为 uops),然后再进行处理,表 6.1 给出了在 Intel 的 P6 架构中25,将复杂指令进行转换的一个例子。
表 6.1 在 Intel 的 P6 架构中,将 CISC 指令转换为 RISC 指令的一个例子
| CISC 指令 | 对应的 RISC 指令 | 指令解释 |
|---|---|---|
ADD EAX, EBX |
① ADD EAX, EBX |
两个寄存器 EAX 和 EBX 相加,结果放到寄存器 EAX 中 |
ADD EAX, [EBX] |
① Load tmp = [EBX]② ADD EAX, tmp |
一个寄存器 EAX 和一个 memory 中的数据 [EBX] 相加,结果放到寄存器 EAX 中 |
ADD [EAX], EBX |
① Load tmp = [EAX]② ADD tmp, EBX③ STA EAX④ STD tmp |
一个 memory 中的数据 [EBX] 和一个寄存器 EAX 相加,结果再写回 memory 中 |
在 x86 指令集中,是允许指令中的一个操作数来自于存储器的,这也是 CISC 指令的一个特点,而且,为了便于访问存储器的指令之间进行相关性的检查 (memory disambiguation),会将 store 指令拆分为 STA 和 STD 两条指令,STA 指令用来计算地址,而 STD 指令用来找到数据,这是 P6 架构对 store 指令进行处理的方法。正是由于 x86 指令集的这种复杂性,因此很难直接用流水线进行处理,这需要在解码阶段对所有的 x86 指令进行拆分,导致 x86 处理器的解码部分要远比 RISC 处理器复杂。而且,很多复杂的 x86 指令按照上述的拆分方式,可以拆分出很多条的 RISC 指令,这样就大大地增加了解码电路设计的复杂度,所以 Intel 在此处仍旧采用了折中的方式,对 x86 指令的拆分进行了限制,对于可以拆分成多于四条 uops 的 x86 指令,不使用表 6.1 所示的指令拆分方法,而是使用 ucode-ROM 来存储这些复杂 x86 指令对应的 uops, 这种方式比直接使用解码电路会慢一些,但是由于这些复杂的 x86 指令使用的频率并不是很高,所以并不会带来性能的明显下降。
不管对于 CISC 指令集还是 RISC 指令集,使用比较复杂的指令可以获得更高的代码密度和更低的 I-Cache 缺失率,但是在超标量处理器中这些复杂的指令就引入了额外的很多麻烦,需要使用更多的硬件资源才可以对它们进行处理。最初在设计这些复杂指令的时候并没有考虑到超标量的实现,例如 ARM 指令集是为了普通的标量 (scalar) 流水线而设计的,而 x86 指令集则诞生在编译器 (compiler) 很不发达的年代,需要硬件来承担更多的责任,随着时代的变迁,在指令集诞生的年代遇到的问题,到现在早已经不成问题,不管是 ARM 指令集还是 x86 指令集,面对现在的高性能应用领域,代码密度这个宝刀在现代的硅工艺面前早已经锈迹斑斑,失去了曾经的魅力,但是在向前兼容这个包袱的重压下,即使采用超标量处理器来实现 ARM 指令集或者 x86 指令集,这些复杂的指令仍旧需要被实现,否则之前的所有软件将无法在新的处理器上运行,虽然设计一个新的指令集并不是很困难,但是它意味着要抛弃之前所有的积蓄,这需要壮士断腕的勇气,Intel 曾经设计出了一个不同于 x86 指令集的新指令集 IA-64, 但是遭遇惨败,好在 Intel 这样的大公司不只有一个手腕,因此即使断掉一个,仍旧能够活下来,但是留下的伤疤却是会疼的,因此当 Intel 决定进军移动市场的时候,即使知道 x86 指令集这个复杂的东西并不适合低功耗的移动领域,但是 Intel 仍旧选择了在 ATOM 处理器中实现 x86 指令集,或许做出这个决定也让 Intel 犹豫了很久,但是没有人再敢承担断一只手腕的风险了。
随着移动领域飞速发展而崛起的 ARM, 它的指令集中也有一些 “不和谐”的东西,例如 LDM/STM 这样的复杂指令、 每条指令都可以条件执行、 在运算之前可以对操作数进行移位的特性等。早期的 ARM 处理器只是面向性能要求不高的低功耗领域,这些复杂指令在那个时候曾经发挥了积极的作用,即可以减少底层编程人员的工作量,并且获得更高的代码密度。当移动领域的快速发展 (例如高性能的智能手机 ) 对处理器的性能要求越来越高时,ARM 处理器也逐步采用了超标量的方式来实现,而 ARM 指令集中的这些复杂指令则需要耗费更多的硅片资源来实现,导致了功耗的增大,这已经和 ARM 最初低功耗的愿景渐行渐远了。当然,相比于 x86 指令集的处理器,ARM 处理器仍然是低功耗的,这种优势其实是 RISC 处理器天生的,但是在 RISC 阵营中,MIPS 有着比 ARM 更为天生的低功耗特性,因为 MIPS 指令集继承了 RISC 的更多优点,没有那么多的复杂指令,更容易实现低功耗,早期的 MIPS 处理器应用在了网络领域等性能要求比较高的地方,但是这些领域到了现代,已经被移动领域 (如智能手机 ) 远远地甩在了后面,站错队的 MIPS 处理器只能选择重新站队,但是市场留下来的未必是最优的,x86 指令集就是一个鲜活的例子,命途多舛的 MIPS 能否取得成功,还需要时间来证明。
ARM 也并不是没有意识到它的指令集的短板,在新的 64 位的 ARMv8 指令集中,LDM/STM 这样的复杂指令已经被砍掉了,而且通用寄存器的个数也变为了 32 个,也不再有指令内嵌的移位运算,条件执行也被限制在了少数的指令上,从这些改变看起来,ARM 已经完全地回归了 RISC 阵营,新的 ARMv8 指令集已经完全和其他 64 位的 RISC 指令集类似了。当然,这种改变是有益处的,可以更有效率地在硅片上进行实现。但是 ARM 并没有抛弃向前兼容这个紧箍咒,仍旧在 ARMv8 中提供了一个兼容以前 32 位特性的模式,在这个模式下,ARMv8 指令集就又回到了以前的 32 位指令集,因此,除非针对 ARMv8 指令集编写新的程序,否则之前老的程序运行在 ARMv8 指令集的处理器上是不会带来任何好处的。
下面讲述如何在超标量处理器中处理这些 RISC 指令集中的复杂指令,遵循的原则和 Intel 的 P6 架构是一样的,将复杂的指令拆解成普通的 RISC 指令,这部分内容在 RISC 处理器的解码电路中是比较复杂的,对解码电路的延迟也产生了很大的负面影响。
6.3.1 分支指令的处理
在之前讲过,采用 Checkpoint 的方式对分支预测失败的处理器进行状态恢复时,为了减少分支编号分配电路的复杂度,需要限制每周期进行解码的分支指令个数,例如最多只能有一条分支指令。但是,每周期从指令缓存中读取的指令中,有可能存在多条分支指令,这就需要在解码阶段处理这个情况,有很多方法都可以用来解决这个问题,这里给出一个比较简单的方法,即遇到分支指令时,就不在本周期对这条分支指令后面的指令进行解码,而是将它们放到下个周期,这个功能只需要通过改变指令缓存的读指针就可以实现,这个过程如图 6.3 所示。

图 6.3 通过指令缓存的读指针,控制每周期可以解码的分支指令的个数
在图 6.3 中,当前周期从指令缓存中读取的四条指令依次是 ADD、BR、SUB 和 BR, 按照上述的处理方式,当前周期只会对 ADD 和 BR 指令进行解码,指令缓存的读指针会增加 2; 下个周期会对 SUB 和 BR 指令进行解码,指令缓存的读指针也会增加 2, 通过这种方式,每周期最多可以对一条分支指令进行解码,降低了分支指令编号分配电路的复杂度。虽然这种方法会降低一些性能,但是比较容易实现,因此是一种折中的方法。
使用 Checkpoint 的方法对分支预测失败时的处理器进行状态恢复,还需要在解码阶段完成分支指令的编号分配工作,这部分内容在之前已经讲过了,此处不再赘述。
当然,如果采用基于 ROB 的方法对分支预测失败时的处理器进行状态恢复,就不再需要进行编号的分配工作了,也就不需要在解码阶段限制分支指令的个数,因此本节讲述的内容并不是超标量处理器所必需的。
在解码阶段还有一个重要的任务,就是对分支预测是否正确进行初步的检查,越早地发现错误的分支预测,引起的惩罚 (penalty) 也就越小,而一些直接跳转类型 (PC-relative) 的分支指令在解码阶段就可以计算出目标地址 ($PC+\mathrm{offset}$), 因此可以在解码阶段对这些分支指令的目标地址是否预测正确进行检查,如果发现了预测失败,那么可以马上从正确的地址开始取指令。
当然,分支指令在解码阶段是无法得到实际方向的 (除了 jump 指令,但是因为它总是跳转的,一般不会预测错误 ),因此在解码阶段也就无法对分支预测的方向进行检查,这需要等到后续的流水线阶段才可以完成。
6.3.2 乘累加/乘法指令的处理
一般来说,越是简单的指令,越容易在超标量处理器中实现,MIPS 指令集相比于其他商用的指令集,算是最简洁的一种了。当然,这也不能够说 MIPS 没有复杂的指令,乘法和乘累加指令就是一种特殊的指令,它的特殊之处在于指令中包括两个目的寄存器 (Hi 寄存器和 Lo 寄存器 ),这两个寄存器并不属于通用寄存器。在超标量处理器中,需要对每条指令都进行寄存器重命名 (register renaming),大致的过程是将指令的源寄存器变为对应的物理寄存器,并为指令的目的寄存器分配一个物理寄存器。由于大多数指令都只有一个目的寄存器,而这种乘法和乘累加指令却有两个目的寄存器,这种非常规的情况就为重命名电路带来了麻烦。不仅如此,如果把这条乘累加 /乘法指令直接放到 ROB 中,则 ROB 需要能够存放两个目的寄存器,这种非常规的情况也增加了 ROB 的面积,而且由于大部分指令都不是乘累加类型的指令,所以 ROB 中增加的部分绝大部分时间都是没有被使用的,这样就造成了资源的浪费。
还需要注意的是,普通的寄存器重命名是针对通用寄存器进行的,而这种乘累加 /乘法指令的目的寄存器 Hi 和 Lo 并不属于通用寄存器,这也需要进行处理,概括来讲,可以采用下面的两步来解决这个问题。
(1) 将寄存器 Hi 和 Lo 分配为 MIPS 处理器的第 33 和第 34 个通用寄存器,当然在指令集中是看不到这个过程的,这种分配只是在处理器内部进行,寄存器重命名过程中的映射表 (mapping table) 也相应地需要支持 34 个通用寄存器 (实际上是 33 个,因为 MIPS 中的 R0 寄存器的值恒为 0)。
(2) 将乘法 / 乘累加指令拆成两条指令,不过,乘法和乘累加指令的情况是不一样的,乘法指令的操作是 ${\mathrm{Hi},\mathrm{Lo}}=Rs\times Rt$, 可以拆解成如下的两条指令。
Hi = Rs × Rt;
Lo = Rs × Rt;
这两条指令经过重命名并写到 ROB 之中时,会占用 ROB 的两个表项 (entry),但是实际上,这两条指令只使用一个乘法器就足够了,只要这个乘法操作在流水线的执行阶段计算完毕,这两条指令就同时都完成了。
而对于乘累加类型的指令,由于其完成的操作是 ${\mathrm{Hi},\mathrm{Lo}}={\mathrm{Hi},\mathrm{Lo}}+Rs\times Rt$, 需要读取四个源寄存器 (Rs、Rt、Hi 和 Lo), 同时目的寄存器也有 Hi 和 Lo 两个,正好是两倍于普通指令 (一条普通指令有两个源寄存器,一个目的寄存器 ), 则可以将乘累加指令拆分成两条如下所示的普通指令。
Lo = {Hi, Lo};
Hi = Rs × Rt;
这两条指令初看起来,似乎不符合运算的逻辑,其实在处理器内部,被拆分的乘累加指令并不是单独去完成运算的,而是在流水线的执行阶段,仍旧以一个完整的乘累加指令来完成运算,因此指令的拆分只是更有利于进行寄存器重命名,以及便于在 ROB 中的存放,每条被拆分的指令并不是单独进行运算,从这个角度来看,被拆分的指令只需要包括两个源寄存器和一个目的寄存器就可以了。
当然,要实现上述的功能,还需要对发射队列 (Issue Queue) 也做特殊处理,将乘法指令和乘累加指令使用一个运算单元 (Function Unit,FU),这个 FU 的发射队列和其他的有所不同,它包括四个源操作数、两个目的寄存器。在解码阶段被拆分的指令,经过寄存器重命名之后,就要写到 ROB 和发射队列中 (这个阶段就是 Dispatch),此时写到 ROB 中仍旧是以两条指令的方式写入,占据 ROB 中两个连续的空间;而写发射队列的时候,则将两条指令进行了融合,这两条指令在发射队列中又变为了一条完整的乘累加或乘法指令,这样就能够保证 FU 在执行的时候,能够执行一个完整的乘累加或者乘法指令。
在 4-way 的超标量处理器中,每周期可以对四条指令进行解码和寄存器重命名,但是,如果在一个周期内解码的四条指令中包括乘法或乘累加指令的话,会导致解码出的指令多于四条,那么仍旧会给后面的寄存器重命名带来麻烦。最坏情况下,如果一个周期内解码的四条指令都是乘累加类型的指令,那么会解码出八条指令,但是这种情况是很少见的,寄存器重命名不可能设计成每周期可以对八条指令进行操作,这样很不划算,为极少出现的情况而浪费大量的硬件面积,这个问题可以采用下面的两种方法来解决。
方法一:在解码阶段和寄存器重命名阶段之间加入一个缓存,用它来替代流水线寄存器,暂存解码阶段产生的指令信息,这样即使在解码阶段得到了八条指令也没关系,只需要将它们写入到这个缓存就可以了,在寄存器重命名阶段,每周期从这个缓存中读取指令进行重命名即可。但是,由于指令经过解码之后会得到很多的信息,例如流水线的控制信号等,导致这个缓存需要的位宽会很大,在一定程度上增加了硬件的面积。
方法二:限制每周期可以解码的指令个数,一旦在解码阶段发现乘累加指令,例如 MADD 指令,那么只有 MADD1 指令及其之前的指令可以进行解码,MADD2 及其之后的指令需要等到下个周期才可以进行解码 (MADD1 和 MADD2 表示 MADD 指令被拆分成的两条指令 ),这样就可以保证,不管 MADD 指令处于四条指令的哪个位置,最后进行解码的指令个数总是不大于四条的,这个过程可以用图 6.4 来表示。

图 6.4 指令拆分
在图 6.4 所示的例子中,解码阶段遇到第一条 MADD 指令时,在第一个周期 (cycle1),只有 ADD 和 MADD1 指令进行解码;在第二个周期 (cycle2),MADD2 指令会占用原来 MADD 指令的位置,继续进行解码,同时在这个周期又遇到了一条新的乘累加类型的指令 MSUB( 乘累减指令 ),因此在第二个周期 (cycle2),只有 MADD2、SUB 和 MSUB1 指令进行解码;在不考虑后续指令的情况下,在第三个周期 (cycle3), 只有 MSUB2 指令进行解码。总结起来就是,如果要进行解码的指令中包括 $n$ 条乘累加类型的指令,那么最后需要 $n+1$ 个周期才可以完成整个解码的过程,这样的做法肯定会降低处理器的性能,但是考虑到乘累加类型的指令使用的频率并不高,而且这种方法易于实现,所以是一种可以接受的折中方法。
6.3.3 前/后变址指令的处理
在前文讲过,ARM 指令集中还有一种前 /后变址 (pre-index/post-index) 的寻址方式,能够在一条指令中完成两个任务,这种指令在普通的流水线处理器中是容易实现的,但是在超标量实现时,会带来额外的麻烦,例如对于 ARM 指令集中的一条 load 指令。
LDR R2, [R1, #4]! ; Load R2 from MEM[R1 + 4], then R1 = R1 + 4
上面的一条指令执行了两个操作,即从存储器中将地址 [R1+4] 中的数据放到寄存器 R2 中,并且把用作地址的寄存器 R1 更新为 R1+4 的值。此时的目的寄存器不只是存放结果数据的寄存器 R2, 用来存放地址的寄存器 R1 也是目的寄存器,需要在 load 指令执行完的时候改变寄存器 R1 的值,这样就相当于 load 指令有两个目的寄存器,而两个目的寄存器会给寄存器重命名以及后续的过程 ( 例如唤醒 ) 带来麻烦,因此在超标量处理器实现时,仍旧会在处理器内部将这个复杂的 load 指令拆成两条普通的指令。
(1) 一条普通的 load 指令 “LDR R2, [R1, #4]”, 用来实现 load 数据的功能;
(2) 一条加法指令 “R1 = R1 + 4”, 用来改变地址寄存器。
这个拆分的过程是在解码阶段完成的,对于一个 4-way 的超标量处理器来说,经过拆分之后,会导致在解码阶段得到的指令个数多于四条,其实这个问题的处理方式和 6.2.2 节对乘累加指令的处理方式是一样的,可以将解码之后的指令存储于缓存中,也可以对解码阶段的指令进行限制。在超标量处理器内部,不会对这些特殊的情况使用额外的硬件,这样是得不偿失的,因此在 ARM 指令集中,即使这种前 /后变址的寻址方式在一条指令内完成了两个任务,在超标量处理器内部使用这种拆分指令的处理方法后,其实和 MIPS 指令集的执行效率是一样的,因此 MIPS 指令集的这种简单的 load/store 指令,在超标量处理器中的执行效率是很高的。但是 ARM 这种实现方式也有一个优势,即由于 ARM 的一条指令需要 MIPS 的两条指令才能够完成,所以实现同样的功能,ARM 处理器占用更少的指令存储器空间,这会使 ARM 处理器的I-Cache 缺失率低于 MIPS 处理器,这是一个优势;但是 ARM 需要在处理器内部使用硬件将指令进行拆分,这需要占用更多的硅片面积,也导致了功耗的增大,因此在超标量处理器的世界中,没有办法笼统地说哪种指令集更好一些,其实 ARM 的这种指令集更带有 CISC 的风格,在一条指令中做了很多事情,将复杂度从软件转移到了处理器硬件上面。而 MIPS 是一个坚定的 RISC 主义者,从上面指令集的对比可以很明显地感受到这一点,但是只从这一点来说,在超标量处理器的世界中,MIPS 处理器可以比 ARM 处理器使用更少的硅片面积和更低的功耗。不过,决定一个处理器面积和功耗的因素还有很多,例如微架构 (microarchitecture)、EDA 工具和工艺等,并不能只从一方面来得出孰优孰劣的结论,但是从市场的追捧程度来看,ARM 处理器是占据绝对优势的。
6.3.4 LDM/STM 指令的处理
在 ARM 的指令集中,还有一种很特殊的指令——LDM/STM 指令,它的指令格式如下。
LDM/STM <Rn>{!}, <register_list>
STM 指令可以将多个寄存器的内容保存在存储器中的一片连续空间内,LDM 指令可以将存储器中一片连续地址空间上的数据加载到多个寄存器中,很显然,这是一种带有 CISC 风格的指令,在超标量处理器中,很难按照常规的方式直接进行处理,因此仍旧需要将它们拆成多条普通的 load/store 指令。举例来说,有这样一条 LDM 指令。
LDM R5!, {R0~R3}
这条指令将存储器中四个地址的数据 memory[R5],memory[R5+4]、memory[R5+8] 和 memory[R5+12] 读取到寄存器 R0、R1、R2 和 R3 中,并将作为地址的寄存器 R5 更新为新的值 R5+12, 因此这条指令相当于完成了五个任务,这条指令在超标量处理器的解码阶段会被拆成四条普通的 load 指令和一条普通的加法指令,如图 6.5 所示。

图 6.5 在超标量处理器中,对 LDM/STM 这样的指令也需要进行拆分
经过 decode 阶段拆分之后的指令:
LDR R0,#0[R5]
LDR R1,#4[R5]
LDR R2,#8[R5]
LDR R3,#C[R5]
ADD R5,R5,12
经过 register renaming 之后的指令:
LDR P8,#0[P6]
LDR P9,#4[P6]
LDR P10,#8[P6]
LDR P11,#C[P6]
ADD P12,P6,12
在超标量处理器中经过如图 6.5 所示的指令拆分,LDM 指令仍旧需要多个周期才可以完成,而且这种拆分的过程需要消耗一些逻辑电路,并对处理器的周期时间产生了负面的影响。图 6.5 中 LDM 指令中的 {R0~R3} 在 ARM 指令集中称为寄存器列表 (register_list, 简称为 reg_list),使用指令编码中的 Bit[15:0] 来表示,这 16 位的信号正好对应着 ARM 指令集中定义的 16 个通用寄存器,在对 LDM/STM 指令进行拆分时,例如每周期可以拆分出两条 load/store 指令,则需要从 16 位的寄存器列表的最低位开始找出两个 1, 它们对应的两个通用寄存器会应用到被拆分的 load/store 指令中,为什么需要从寄存器列表的最低位开始,顺序进行查找呢?这是因为在 LDM/STM 指令中,寄存器按照编号从小到大的顺序和存储器中一片连续的地址空间对应,因此必须按照顺序找到寄存器列表中的寄存器,才可以正确的和存储器的地址相对应。那么,如何才能从 16 位的寄存器列表 (reg_list) 的最低位开始,找到两个 1 呢?最容易想到的方法就是首先遍历这个 16 位的信号,找到第一个 1, 这最少需要 16 级的门延迟,然后将寄存器列表中刚刚被找到的这个信号清零,再次遍历这个 16 位的寄存器列表,此时找到的第一个 1, 就对应着原来的寄存器列表中的第二个 1 了。当然,这又会引入最少 16 级的门延迟,对于周期时间很短的超标量处理器来说,这种做法显然是不切实际的,需要一种更优化的方法来实现这个过程,既然直接遍历 16 位的信号会带来比较大的延迟,那么就可以考虑将这个 16 位的信号进行分解,逐级地进行解决,这种思路如图 6.6 所示。
对于每周期可以将 LDM/STM 指令拆分出两条 load/store 指令的设计来说,不管是找到寄存器列表 (reg_list) 中的第一个 1 还是第二个 1, 都可以使用图 6.6 的这种思路,下面分别进行说明。

图 6.6 将 16 位的寄存器列表进行拆分,逐级进行处理
1. 找到寄存器列表中的第一个 1
因为将 16 位的寄存器列表 (reg_list) 拆分成了四个部分,每个部分都是四位的,首先需要对这四位的信号进行处理,这就是图 6.6 中第一级处理电路完成的任务了,每个处理电路完成的任务都是一样的。以 reg_list[3:0] 为例,要从这个四位的信号中找到第一个 1 所在的位置,这个位置可以用一个两位的信号 A0[1:0] 来表示,很容易看出,此时的对应关系如下。
当第一个 1 位于 reg_list[0] 时,A0[1:0]=00;
当第一个 1 位于 reg_list[1] 时,A0[1:0]=01;
当第一个 1 位于 reg_list[2] 时,A0[1:0]=10;
当第一个 1 位于 reg_list[3] 时,A0[1:0]=11。
上面的功能抽象出来,就是要从四位的信号中得到一个两位信号的结果,要实现上述的这种功能,可以有两种方法,第一种是直接使用 RTL 代码来描述出上面的这种关系,然后依靠综合工具来将其转换成合适的电路,在 RTL 代码中可以列举四位信号的各种取值情况,并给出每种情况时的输出值,如下所示。
casez(reg_list[3:0])
4'b???1: A0[1:0] = 00; //第一个1在bit0
4'b??10: A0[1:0] = 01; //第一个1在bit1
4'b?100: A0[1:0] = 10; //第一个1在bit2
4'b1000: A0[1:0] = 11; //第一个1在bit3
default: A0[1:0] = xx; //不存在第一个1
endcase
当然,仅有上述的功能是不完备的,因为它无法表达出 “reg_list[3:0] 中不存在第一个 1” 这种情况,此时还需要使用另外一个信号,来表示 reg_list[3:0] 中是否存在第一个 1, 这个信号用 A0_valid 来表示,只要 reg_list[3:0] 中的某一位是 1, 那么 A0_valid 就会有效,这可以用下面的 RTL 代码来表示。
assign A0_valid = (|reg_list[3:0]);
综合工具会对上述的 RTL 代码进行分析,并根据给定的约束,例如面积和速度,将其转换为最合适的电路,对于一般的设计来说,这种方法足够了,它的可读性和可维护性都比较好,那么,还有没有其他的方法可以实现这个功能呢?当然是有的,对于这种输入是四位信号、输出是两位信号的设计来说,其实可以用卡诺图来直接得到对应的电路,它的设计如图 6.7 所示。
![图 6.7 使用卡诺图找出寄存器列表 reg_list[3:0] 中第一个 1 的位置](../images/Ch06/figure-6.7.png)
图 6.7 使用卡诺图找出寄存器列表 reg_list[3:0] 中第一个 1 的位置
// (a)
A0[0] = (reg_list[1:0] == 2'b10) | (reg_list[3:2] == 2'b10 & ~reg_list[0]);
// (b)
A0[1] = (reg_list[1:0] == 2'b00);
在图 6.7 中,根据 reg_list[3:0] 各种取值时 A0[1:0] 的值来画出卡诺图,然后对其进行化简,可以直接得到 A0[1:0] 的表达式,这样得到的结果是最精简的,减少了对综合工具的依赖。不过需要注意的是,当 reg_list[3:0]=4'b0000 时,不存在第一个 1,此时会使 A0_valid 这个信号为 0,而并不关心 A0[1:0] 的值是多少,因此在上述的卡诺图中,reg_list[3:0]=4'b0000 对应的内容是 $x$,这样便于对卡诺图进行化简。
上述的两种方法(使用 RTL 描述和使用卡诺图)都可以从 reg_list[3:0] 中找到第一个 1 所在的位置。同理,对于 reg_list[7:4]、reg_list[11:8]、reg_list[15:12] 都可以采用这两种方法,分别找出第一个 1 所在的位置,分别用 A1[1:0]、A2[1:0] 和 A3[1:0] 来表示,相应的,是否存在第一个 1 的标志信号分别用 A1_valid、A2_valid 和 A3_valid 来表示,这就是第一级处理电路完成的全部任务了。
在第二级处理电路中,会对 reg_list[15:8] 和 reg_list[7:0] 这两个信号进行分析,分别找出第一个 1 所在的位置,这可以使用三位的信号 B0[2:0] 和 B1[2:0] 来表示,这个处理电路的输入信号来自于第一级处理电路得到的信号。以 reg_list[7:0] 为例,它的输入信号是 A0[1:0]、A0_valid、A1[1:0] 和 A1_valid,需要根据这四个信号,找出 reg_list[7:0] 中第一个 1 所在的位置,用 B0[2:0] 来表示,这个功能该如何进行实现呢?
由于需要从低位开始,找出 reg_list[7:0] 中第一个 1 所在的位置,所以,当 reg_list[3:0] 中存在 1 时,就需要使用它的结果,否则就会使用 reg_list[7:4] 所产生的结果,这个功能可以使用下述的 RTL 代码来表示。
assign B0[2:0] = A0_valid ? {1'b0, A0[1:0]} : {1'b1, A1[1:0]};
当然,仅有 B0[2:0] 这个信号是不完备的,还需要一个信号来表示 reg_list[7:0] 中是否存在第一个 1,这个信号称为 B0_valid,只要 reg_list[7:4] 和 reg_list[3:0] 中的任意一个存在 1,那么这个信号就会为 1,这可以用下述的 RTL 代码来描述。
assign B0_valid = A0_valid | A1_valid;
使用上述的 RTL 代码,就可以找出 reg_list[7:0] 中第一个 1 所在的位置,对于 reg_list[15:8] 也可以采用这种方法,找出第一个 1 所在的位置,以及是否存在 1,这可以用 B1[2:0] 和 B1_valid 来表示,这就是第二级处理电路完成的工作了。
第三级处理电路会对 reg_list[15:0] 进行分析,它的工作过程和第二级处理电路是类似的,通过对输入信号(B0[2:0]、B1[2:0]、B0_valid 和 B1_valid)进行分析,找出第一个 1 所在的位置,以及是否存在第一个 1,这可以用 C0[3:0] 和 C0_valid 来表示,由于需要从最低位开始查找,找出第一个 1 所在的位置,因此当 reg_list[7:0] 中存在 1 时,使用它的结果,否则就是使用 reg_list[15:8] 的结果,这可以使用下面的 RTL 代码来描述。
assign C0[3:0] = B0_valid ? {1'b0, B0[2:0]} : {1'b1, B1[2:0]};
当然,还需要一个信号来表示 reg_list[15:0] 中是否存在第一个 1,这个信号称为 C0_valid,只要 reg_list[15:8] 和 reg_list[7:0] 中的任意一个存在 1,那么这个信号就会为 1,这可以用下面的 RTL 代码来表示。
assign C0_valid = B0_valid | B1_valid;
到目前为止,通过三级处理电路,就找到了寄存器列表(reg_list[15:0])中的第一个 1 所在的位置(C0_valid 和 C0[3:0]),这种方法可以继续扩展,对寄存器列表中的第二个 1 进行查找,这就是下面的内容。
2. 找到寄存器列表中的第二个 1
相对来说,要从寄存器列表中找到第一个 1 所在的位置,不管是用 RTL 代码来描述,还是用卡诺图来实现,都是比较容易的。但是,要找到第二个 1 所在的位置,似乎不是一件很直接的事情,它需要和查找第一个 1 的电路并行工作,这样才能够使处理器获得比较小的周期时间,那么,该如何实现这个功能呢?很显然,直接对 16 位的寄存器列表进行查找是不可能的,这样会引入过大的延迟,此处仍旧需要使用上面讲述的方法,将 16 位的寄存器列表进行拆分,然后逐级地得到结果,这样可以缩短所需要的时间,并且降低设计的复杂度。
和上面一样,仍旧采用三级处理电路的设计方法,第一级处理器电路对 reg_list[15:12]、reg_list[11:8]、reg_list[7:4] 和 reg_list[3:0] 分别处理,以 reg_list[3:0] 为例,要在这个四位的信号中找到第二个 1 所在的位置,可以使用 RTL 来列举 reg_list[3:0] 的各种取值情况,然后给出在每种情况下,第二个 1 所在的位置,用信号 D0[1:0] 来表示,并且还需要使用一个信号 D0_valid 来表示在 reg_list[3:0] 中是否存在第二个 1,这段 RTL 代码如下所示。
case (reg_list[3:0])
4'b0000: begin
D0[1:0] = 2'bxx;
D0_valid = 1'b0;
end
4'b0001: begin
D0[1:0] = 2'bxx;
D0_valid = 1'b0;
end
4'b0010: begin
D0[1:0] = 2'bxx;
D0_valid = 1'b0;
end
4'b0011: begin
D0[1:0] = 2'b01;
D0_valid = 1'b1;
end
...
4'b1100: begin
D0[1:0] = 2'b11
D0_valid = 1'b1;
end
4'b1101: begin
D0[1:0] = 2'b10;
D0_valid = 1'b1
end
4'b1110: begin
D0[1:0] = 2'b10;
D0_valid = 1'b1;
end
4'b1111: begin
D0[1:0] = 2'b01;
D0_valid = 1'b1;
end
default: begin
D0[1:0] = 2'bxx;
D0_valid = 1'b0;
end
endcase
综合工具会根据给定的约束,将上述行为级的 RTL 代码变为合适的电路,这种方法的可读性和可移植性都比较好,当然对于综合工具的依赖性也是比较强的 (事实上,这种方法正是现代的数字电路设计的一个缩影 )。其实,对于这种输入和输出有着固定对应关系的设计,可以直接使用卡诺图来得到所需要的电路,如图 6.8 所示。
![图 6.8 使用卡诺图来找到 reg_list[3:0] 中第二个 1 的位置](../images/Ch06/figure-6.8.png)
图 6.8 使用卡诺图来找到 reg_list[3:0] 中第二个 1 的位置
// (a)
D0[0] = (reg_list[1:0] == 2'b00) | (reg_list[1:0] == 2'b11) | ~reg_list[2];
// (b)
D0[1] = ~reg_list[1] | ~reg_list[0];
D0[1] = ~(reg_list[1:0] == 2'b11);
// (c)
D0_valid = (reg_list[1:0] == 2'b11) | (reg_list[3:2] == 2'b11)
| ~(reg_list[1:0] == 2'b00 | reg_list[3:2] == 2'b00);
在图 6.8 中,仍旧是根据 reg_list[3:0] 各种取值时 D0[1:0] 的值来画出卡诺图,然后对其进行化简,可以直接得到 D0[1:0] 的表达式,这样得到的结果是最精简的,减少了对综合工具的依赖。不过需要注意的是,当 reg_list[3:0] 中不存在第二个 1 时,此时会使 D0_valid 这个信号为 0, 而并不关心 D0[1:0] 的值是多少,因此在图 6.8 中,将此时 D0[1:0] 的值写成了 $x$,这样便于对卡诺图进行化简。
上述的两种方法 (使用 RTL 描述和使用卡诺图 ) 都可以从 reg_list[3:0] 中找到第二个 1 所在的位置,同理,对于 reg_list[7:4]、reg_list[11:8]、reg_list[15:12] 都可以采用这两种方法,分别找出第二个 1 所在的位置,分别用 D1[1:0]、D2[1:0] 和 D3[1:0] 来表示,相应的,是否存在第二个 1的标志信号分别用 D1_valid、D2_valid 和 D3_valid 来表示,这就是第一级处理电路完成的全部任务了。
在第二级处理电路中,会对 reg_list[15:8] 和 reg_list[7:0] 这两个信号进行分析,分别找出第二个 1 所在的位置,这可以使用三位的信号 E0[2:0] 和 E1[2:0] 来表示,这个处理电路的输入信号来自于第一级处理电路得到的信号,以 reg_list[7:0] 为例,它的输入信号是 D0[1:0]、D0_valid、D1[1:0] 和 D1_valid, 需要根据这四个信号,找出 reg_list[7:0] 中的第二个 1所在的位置,用 E0[2:0] 来表示,并且使用信号 E0_valid 来表示 reg_list[7:0] 中是否存在第二个 1, 相比于之前查找第一个 1 所在的位置,这个功能实现起来要稍微复杂一些,需要考虑下面三种情况。
情况 1: 当 reg_list[3:0] 中存在第二个 1 时,直接使用它的结果 ( 即 D0[1:0]) 就可以了;
情况 2: 当 reg_list[3:0] 中不存在第二个 1, 但是存在一个 1 时,reg_list[7:4] 中的第一个 1, 就是 reg_list[7:0] 中的第二个 1 了,也就是说,需要使用 reg_list[7:4] 中的第一个 1 所在的位置作为结果 ( 即 A1[1:0]),如果在 reg_list[7:4] 中不存在 1 的话,这也没有关系,因为信号 E0_valid 会给出这个结果;
情况 3: 当 reg_list[3:0] 中不存在 1 时,reg_list[7:4] 中的第二个 1 所在的位置就是需要的结果 ( 即 D1[1:0]),如果在 reg_list[7:4] 中不存在第二个 1 的话,这也没有关系,因为信号 E0_valid 会给出这个结果。
上面的三种情况,用 RTL 代码描述如下。
assign E0[2:0] = D0_valid ? {1'b0, D0[1:0]} : (A0_valid ? {1'b1, A1[1:0]} : {1'b1, D1[1:0]});
assign E0_valid = D0_valid | D1_valid | (A0_valid & A1_valid);
上面的第二个式子表达的含义是当 reg_list[3:0] 或 reg_list[7:4] 中存在第二个 1, 或者 reg_list[7:4] 和 reg_list[3:0] 中各存在一个 1 时,reg_list[7:0] 中必然存在第二个 1, 也就是 E0_valid 这个信号会为 1。通过上述的两个表达式,可以找到 reg_list[7:0] 中第二个 1 所在的位置,当然,用同样的方法也可以找到 reg_list[15:8] 中第二个 1 所在的位置,用信号 E1[1:0] 表示,并且用信号 E1_valid 来表示 reg_list[15:8] 中是否存在第二个 1, 这就是第二级处理电路完成的工作了。
第三级处理电路会对 reg_list[15:0] 进行分析,它的工作过程和第二级处理电路是类似的,通过对输入信号 (E0[2:0]、E1[2:0]、E0_valid 和 E1_valid) 进行分析,找出第二个 1 所在的位置,以及是否存在第二个 1, 这可以用 F0[3:0] 和 F0_valid 来表示,相比于找出第一个 1 所在的位置,这部分功能要更复杂一些,需要考虑下面三种情况。
情况 1:当 reg_list[7:0] 中存在第二个 1 时,直接使用它的结果 ( 即 E0[2:0]) 就可以了;
情况 2: 当 reg_list[7:0] 中不存在第二个 1, 但是存在第一个 1 时,此时的 reg_list[15:8] 中的第一个 1 所在的位置,就是 reg_list[15:0] 中第二个 1所在的位置了,也就是说,需要使用 reg_list[15:8] 中第一个 1 所在的位置 ( 即 B1[2:0]) 作为结果,如果在 reg_list[15:8] 中不存在 1 的话,这也没有关系,因为信号 F0_valid 会给出这个结果;
情况 3:当 reg_list[7:0] 中不存在 1 时,reg_list[15:8] 中的第二个 1 所在的位置就是需要的结果 ( 即 E1[2:0]), 如果在 reg_list[15:0] 中不存在第二个 1 的话,这也没有关系,因为信号 F0_valid 会给出这个结果。
上面的三种情况,用 RTL 代码描述如下。
assign F0[3:0] = E0_valid ? {1'b0, E0[2:0]};
(B0_valid ? {1'b1, B1[2:0]} : {1'b1, E1[2:0]});
assign F0_valid = E0_valid | E1_valid | (B0_valid & B1_valid);
上面的第二个式子表达的含义是当 reg_list[7:0] 或 reg_list[15:8] 中存在第二个 1, 或者 reg_list[7:0] 和 reg_list[15:8] 中各存在一个 1 时,reg_list[15:0] 中必然存在第二个 1,也就是 F0_valid 这个信号会为 1。通过上述的两个表达式,可以找到 reg_list[15:0] 中的第二个 1 所在的位置,这就是第三级处理电路完成的工作了。
通过上面介绍的方法,可以同时找到寄存器列表中的第一个 1 和第二个 1 所在的位置,避免了将它们串行进行处理,使处理器的速度不至于降低,当需要找出寄存器列表中的第三个 1 和第四个 1 时,仍旧可以使用这种方法,只不过复杂度会更高一些。
在 ARMv7 以及更高版本的指令集体系中,对 LDM 指令还有一个限制,那就是 LDM 指令中的地址寄存器 (Rn) 最好不能够存在于寄存器列表 (register_list) 中12,举例来说,不推荐把指令写成下面的样子。
LDM R2, {R1 - R4}
按照 LDM 指令设计的本意,是将存储器中一片连续地址上的数据放到多个寄存器中,但是在上面的这条指令中,当地址 [R2+4] 上的数据放到寄存器 R2 中之后,用来作为地址的寄存器 R2 就发生了变化,此时就会从这片新的存储器地址开始取数据,这种情况会让人费解,因此在 ARMv7 及其更高的版本指令集体系中,并不推荐这种写法,在 ARM Cortex A9 处理器中,这种地址寄存器 (Rn) 存在于寄存器列表中的 LDM 指令将不会被执行。
从上面的描述可以看到,越是复杂的指令集,在超标量实现时越会遇到各种的困难,在 RISC 处理器中,相比于 MIPS 指令集,ARM 指令集的复杂性使 ARM 处理器需要耗费更多的硬件资源来处理它们,而另一个极端就是 x86 指令集了,它属于 CISC 指令集,这个复杂的指令集在解码阶段需要额外消耗很多硬件资源来处理,Intel 的 P6 架构就是在解码阶段将复杂 x86 指令拆分为简单的 RISC 指令来实现较高的性能,由于 x86 指令集的复杂性,这种拆分工作并不是很容易,相应的需要消耗不菲的硬件资源,这也是 x86 处理器难以获得低功耗的原因之一。
6.3.5 条件执行指令的处理
在很多 RISC 处理器中,除了分支类型的指令,其他类型的指令也都支持条件执行,典型的例子就 ARM 处理器,每条指令的编码中,Bit[31:28] 用作了条件码 (condition code),用来判断当前 CPSR 寄存器中的值是否是想要的值,从而决定这条指令是否执行。ARM 处理器中,条件执行的本质就是将 CPSR 寄存器也作为一个源 / 目的寄存器来对待,当一条指令的执行需要改变状态寄存器 CPSR 时,CPSR 寄存器作为一个目的寄存器,而当一条指令需要条件执行时,状态寄存器 CPSR 就作为源寄存器,这个过程如图 6.9 所示。

图 6.9 条件执行
subs r1, r1, #0 ; 判断r1和0是否相等
addeq r2, r3, #2 ; 如果相等,执行r2=r3+2
subne r2, r3, #2 ; 如果不相等,执行r2=r3-2
总体来看,每条指令都可以条件执行的这个特征,相当于在 ARM 处理器中又额外增加了一个源寄存器和一个目的寄存器:状态寄存器 CPSR。在普通流水线的处理器中,这不会引起什么问题,因为指令都是按照程序中指定的顺序 (in-order) 来更新 CPSR 寄存器的内容,条件执行的指令读取当前的 CPSR 寄存器的值,肯定就会得到自己想要的值,但是到了超标量处理器中,问题就随之而来了,由于指令都是乱序 (out-of-order) 执行的,条件执行的指令读取到的 CPSR 寄存器的内容未必就是自己需要的,如图 6.10 所示。
图 6.10 中的例子在超标量处理器中执行的时候,如果不加以处理,那么很多情况下都会产生错误,举例来说。
情况 1: inst4 提前到 inst2 之前执行完毕并改写了 CPSR 寄存器的内容,则当 inst2 和 inst3 指令读取 CPSR 寄存器的时候,就会错误地使用 inst4 指令所产生的状态了;
情况 2:inst6 提前到 inst4 之前开始执行,此时 inst6 读取的 CPSR 寄存器的内容来自于 inst1 指令的结果,而不是程序中规定的 inst4 指令的结果。

图 6.10 超标量处理器中的条件执行
Inst1: subs r1, r1, #0 ; 判断r1和0是否相等
inst2: addeq r2, r3, #2 ; 如果相等,执行r2=r3+2
inst3: subne r2, r3, #2 ; 如果不相等,执行r2=r3-2
inst4: subs r4, r4, #1 ; 判断r4和1是否相等
inst5: addeq r5, r6, #3 ; 如果相等,执行r5=r6+3
inst6: subne r5, r7, #3 ; 如果不相等,执行r5=r7-3
从图 6.10 所示的例子可以看出,在超标量的 ARM 处理器中,需要对条件执行的情况 (也就是对 CPSR 寄存器 ) 加以处理,虽然在指令集中看起来只有一个 CPSR 寄存器,但是通过图 6.10 所示很明显地可以看出来,inst2,inst3 使用的 CPSR 寄存器不同于 inst5,inst6 使用的 CPSR 寄存器,因此当一条指令要改写 CPSR 寄存器时 (如图 6.10 中的 inst1 和 inst4),使用一个新的 CPSR 寄存器来保存这条指令的状态,并给这个 CPSR 寄存器赋予一个新的名字,与之对应的条件执行指令 (如图 6.10 中的 inst2 和 inst3) 都会使用这个新的 CPSR 寄存器作为它们的一个源寄存器,这样在图 6.10 中 inst1 和 inst4 分别会使用一个 CPSR 寄存器,也就是需要两个 CPSR 寄存器,上述的这个过程就是对 CPSR 的寄存器重命名,通过这个方法来消除超标量 ARM 处理器中的条件执行指令所带来的问题,关于寄存器重命名的详细介绍参见第 7 章。
关于条件执行,事情还没有结束,仅仅对 CPSR 寄存器进行寄存器重命名,仍旧不能够完全解决条件执行指令在超标量处理器中所带来的问题,还需要一些额外的处理方法,这些内容将在第 9 章进行详细介绍。
本站所有文章转发 CSDN 将按侵权追究法律责任,其它情况随意。