第 5 章 指令集体系
书内第 145–175 页;PDF 第 157–187 页。原书疑点见校注。
- 5.1 复杂指令集和精简指令集
- 5.2 精简指令集概述
- 5.2.1 MIPS 指令集
- 5.2.2 ARM 指令集
- 5.3 Load/Store 指令
- 5.3.1 Load 指令
- 5.3.2 Store 指令
- 5.4 计算指令
- 5.4.1 加减法
- 5.4.2 移位指令
- 5.4.3 逻辑指令
- 5.4.4 乘法指令
- 5.4.5 乘累加指令
- 5.4.6 特殊计算指令
- 5.5 分支指令
- 5.6 杂项指令
- 5.7 异常
指令集体系 (Instruction Set Architecture,ISA) 是规定处理器的外在行为的一系列内容的统称,它包括基本数据类型 (data types)、指令 (instructions)、寄存器 (registers), 寻址模式 (addressing modes)、 存储体系 (memory architecture)、 中断 (interrupt)、 异常 (exception) 以及外部 I/O(external I/O) 等内容23,在指令集体系中规定了处理器的行为,它是处理器使用的语言。正如两个中国人使用汉语交谈的时候可以懂得对方表达的意思一样,使用同样指令集体系的不同处理器也可以 “懂得彼此”, 在这种情况下,一个软件可以运行在任何支持同一个指令集体系的不同处理器上面。指令集体系是软件人员和处理器设计师之间的桥梁,软件人员可以不必关心处理器的硬件实现细节,只需要根据指令集体系就可以开发软件,而处理器设计人员则需要设计出符合指令集体系的处理器。对于同一个指令集体系来说,有很多的实现方式,可以做得很简单但是速度很慢,也可以做得很复杂但是速度很快。对一个指令集体系的硬件实现方式称为微架构 (microarchitecture),不同的设计师,不同的市场需求都会导致不同的微架构,例如 Intel 的 P6 微架构24 (代表作品是 Pentium Ⅲ 处理器 ) 和 AMD 的 K7 微架构 ( 代表作品是 Athlon 处理器 ),两者都是基于 x86 指令集体系而设计的,但是它们在内部结构上并不相同,在性能表现上也是不同的,这可以用图 5.1 来表示。

图 5.1 指令集体系、 微架构和软件之间的关系
其实,本书主要关注的是 RISC 指令集,而图 5.1 给出的两家公司 Intel 和 AMD, 它们的处理器产品都是基于 CISC 指令集的 (起码在本书出版的时候是这样 )。CISC 指令集有着悠久的历史,但是它的包袱过重,因此非常复杂,不是本书关注的重点,不过需要提一句的是,图 5.1 给出的 Intel P6 微架构和 AMD K7 微架构是非常有名气的。P6 微架构是 Intel 历史上最经典的架构,在 P6 微架构上诞生了 Pentium Pro、Pentium Ⅱ、Pentium Ⅲ 和 Pentium M 等经典的处理器,甚至于 Intel 现在的 Core 和 Core2 架构也是传承自 P6 架构,它为 Intel 处理器的成功立下了汗马功劳。而 K7 微架构是 AMD 历史上最成功的处理器架构,它的很多设计思想正是来自于大名鼎鼎的 Alpha 21264 处理器。当年 Alpha 21264 处理器的架构设计师 Dirk Meyer 带领一个设计小组跳槽来到 AMD, 领导 AMD 第 7 代处理器 (也就是 K7) 的设计工作,于是 K7 微架构也就顺理成章地继承了 Alpha 21264 处理器的很多优良的基因,由于 Alpha 指令集是 RISC 指令集,所以 K7 微架构在处理器内部首先将 CISC 指令集转化为 RISC 指令集,然后按照 RISC 处理器的处理方式来执行这些内部的 RISC 指令,这样就避免了 CISC 指令集难以被流水线实现的问题。使用 K7 微架构的 Athlon 处理器在发布的时候,性能超过了当时不可一世的 Intel Pentium Ⅲ处理器,一直到现在,AMD 的处理器都可以看到 K7 的影子,这就像 Intel 现在主流处理器的架构都来自于经典的 P6 微架构一样。而 Dirk Meyer, 他曾经一度是 AMD 公司的 CEO。
5.1 复杂指令集和精简指令集
指令集从本质上可以分为复杂指令集 (Complex Instruction Set Computer,CISC) 和精简指令集 (Reduced Instruction Set Computer,RISC) 两种,复杂指令集的特点是能够在一条指令内完成很多事情,在计算机发展的早期,编译器技术并不发达,很多程序都需要使用汇编语言 ( 甚至是机器语言 ) 来编写,为了方便程序员编写汇编程序,处理器设计师设计出了越来越复杂的指令,这些指令可以使编程人员的工作得到简化。在当时看来,硬件设计应该比编译器设计更容易一些,而且,在那个年代,内存的容量很有限,内存中的每一个字节都是宝贵的,于是业界就更倾向于使用高度编码、多操作数和长度不等的指令,能够使一条指令尽量做很多事情,并且减少内存的占用。同时,寄存器是一种更昂贵的东西,当时的处理器中无法放入数量比较多的通用寄存器,而且,随着通用寄存器个数的增多,会使指令当中需要更多的位数来对其进行编码,这样也会导致指令占用更多的位数,也就占用了更多的内存 (这些原因在现在看起来似乎是很滑稽的,但是在那个年代却是真实存在的 ),这些原因都是导致处理器设计师会让一条指令中完成尽可能多的任务的原因,例如一条 CISC 指令 “ADD [EAX],EBX”, 可以完成从存储器中取数据,然后和寄存器中的数据进行运算,并将运算的结果写回存储器这样一系列的操作。复杂指令集的这种设计方式在当时看起来是顺理成章的,只有在 RISC 的概念提出来之后,这种复杂的指令集才被人们称为 CISC, 当前统治桌面 PC 领域的 x86 指令集就是 CISC 指令集。
经过 IBM 等公司的研究发现,尽管复杂指令集的很多特性让代码编写更加便捷,但是这些复杂特性的指令需要好几个周期才能够执行完,而且大部分复杂的指令都没有被程序使用 (80%的指令只在 20%的时间被使用 ),同时复杂指令集中通用寄存器的个数太少,导致处理器需要经常访问存储器,而随着处理器和存储器之间速度代沟的加大,经常访问存储器会导致处理器执行效率降低。要克服这些缺点,就需要降低处理器设计的复杂度,以让出更多的硅片面积来放置寄存器,这就产生了精简指令集。它只包括程序中经常使用的指令,这样可以大大减少处理器的硅片面积,而且便于使用流水线来实现,使处理器的执行速度和功耗都得以降低,而那些复杂的操作则通过子程序的方式来实现。精简指令集使用了数量丰富的通用寄存器,所有的操作都是在通用寄存器之间完成的,要和存储器进行交互,就需要使用专门访问存储器的 load/store 指令,它们负责在寄存器和存储器之间交换数据。于是,在复杂指令集中的指令 “ADD [EAX],EBX”, 在精简指令集中需要三条指令来实现,一条 load 指令从地址 [EAX] 中将数据取出来,放到寄存器中;一条 ADD 指令将两个寄存器中的数据相加,并保存到寄存器中;一条 store 指令将存储于寄存器中的加法运算结果写回到存储器中。RISC 指令的长度一般是等长的,这样大大简化了处理器中解码电路的设计,也便于流水线的实现,但是相比复杂指令集,精简指令集需要更多的指令来实现同样的功能,导致其占用更多的程序存储器,虽然现在的存储器很廉价,但是这会导致 Cache 缺失率的上升,在一定程度上使 RISC 处理器的执行效率有所降低。当前比较流行的精简指令集有 ARM、MIPS 和 PowerPC 等。
复杂指令集 (CISC) 和精简指令集 (RISC) 孰优孰劣并没有定论,但是这两者正在互相的学习,现在的精简指令集也可以达到上百条指令,而且执行周期也是不固定的;而复杂指令集,例如 x86 指令集,也在处理器内部将大部分指令转化为了 RISC 指令来执行,比较典型的就是 Intel 的 P6 微架构。本书将重点关注精简指令集处理器的设计,所以本章会对精简指令集做详细的介绍,最经典而且容易理解的精简指令集是 MIPS, 但是不可否认的是,目前在商业上最成功的精简指令集却是 ARM, 它和 MIPS 有很多共同的地方,也有一些明显的区别,本章以讲述 MIPS 指令集为主,同时也会涉及到 ARM 指令集的一些内容。
5.2 精简指令集概述
5.2.1 MIPS 指令集14
要想了解精简指令集,MIPS 是最合适不过的教材了,不同于 ARM,MIPS 是一个坚定的 RISC 主义者,MIPS 指令集是最简单纯粹的精简指令集,很容易被理解,在 MIPS 指令集中有下述三种基本的指令,如图 5.2 所示。

图 5.2 MIPS 的三种基本指令
MIPS 指令的长度都是 32 位 ( 暂不考虑 16 位的 MIPS16e 指令,这些指令在处理器内部会被还原成普通的 32 位指令 ), 它会被分为不同的区域,如图 5.2 所示。其中 op 称为操作码,用来给出指令的类型,在 MIPS 中,所有的指令可以划分为三种基本的类型 I-Type、J-Type 和 R-Type, I-Type 格式的指令可以直接使用 16 位的立即数,即图 5.2 中的 immediate,rs 用作源寄存器、rt 用作目的寄存器;J-Type 格式的指令则使用了 26 位的立即数,即图 5.2 中的 target, 它一般用于跳转类型的指令;而 R-Type 类型的指令对寄存器进行操作,rs 和 rt 用作源寄存器,rd 用作目的寄存器,由于 R-Type 类型中包括了数量众多的指令,所以需要使用 funct 对指令进行进一步的区分,而 sa 则专门用于移位指令。MIPS 指令集中的所有指令都属于这三种类型,它们使用 op 进行区分,op 位于指令中的 Bit[31:26],它定义的操作如表 5.1 所示。
表 5.1 MIPS 指令集中,op 区域定义的操作
整理注:横向为
Bit[28:26],纵向为Bit[31:29]。列头位置按原表保留,见表后校注。
| 000 | 001 | 010 | 011 | 100 | 101 | 110 | 111 | |
|---|---|---|---|---|---|---|---|---|
| 000 | Special | RegImm | J | JAL | BEQ | BNE | BLEZ | BGTZ |
| 001 | ADDI | ADDIU | SLTI | SLTIU | ANDI | ORI | XORI | LUI |
| 010 | COP0 | |||||||
| 011 | Special2 | |||||||
| 100 | LB | LH | LW | LBU | LHU | |||
| 101 | SB | SH | SW | CACHE | ||||
| 110 | ||||||||
| 111 |
通过表 5.1 所示,可以看出 MIPS 指令集的编码规律如下。
(1) op==000_xxx 时,表示分支和跳转指令 (000000、000001 除外 ),这些指令都是直接跳转类型的 (PC-relative);
(2) op==001_xxx 时,表示含有立即数的算术操作指令;
(3) op==100_xxx 时,表示访问存储器的 load 类型的指令;
(4) op==101_xxx 时,表示访问存储器的 store 类型的指令;
(5) op==000_000(Special)、000_001(RegImm)、011_100(Special2) 和 010_000(COP0) 这四个值时,有着特殊的含义,需要进一步解码才能够知道操作的类型。
在 Special 区域 ( 即 op==000_000 时 ),需要使用指令当中的 Bit[5:0]( 也就是 funct) 才可以进一步区分指令的类型,这部分定义的指令如表 5.2 所示。
表 5.2 当“op=Special”时,funct 区域定义的指令
| Bit[5:3] \ Bit[2:0] | 000 | 001 | 010 | 011 | 100 | 101 | 110 | 111 |
|---|---|---|---|---|---|---|---|---|
| 000 | SLL | SRL | SRA | SLLV | SRLV | SRAV | ||
| 001 | JR | JALR | MOVZ | MOVN | SYSCALL | BREAK | SYNC | |
| 010 | MFHI | MTHI | MFLO | MTLO | ||||
| 011 | MULT | MULTU | ||||||
| 100 | ADD | ADDU | SUB | SUBU | AND | OR | XOR | NOR |
| 101 | SLT | SLTU | ||||||
| 110 | TGE | TGEU | TLT | TLTU | TEQ | TNE | ||
| 111 |
从表 5.2 可以看出,Special 区域定义了大多数以寄存器为操作数的指令,这些指令将在后面进行详细介绍。
在 Special2 区域 ( 即 op==011_100 时 ),也是需要使用指令当中的 Bit[5:0]( 也就是 funct) 才可以进一步区分指令的类型,这部分定义的指令如表 5.3 所示。
表 5.3 当“op=Special2”时,funct 区域定义的指令
| Bit[5:3] \ Bit[2:0] | 000 | 001 | 010 | 011 | 100 | 101 | 110 | 111 |
|---|---|---|---|---|---|---|---|---|
| 000 | MADD | MADDU | MUL | MSUB | MSUBU | |||
| 001 | ||||||||
| 010 | ||||||||
| 011 | ||||||||
| 100 | CLZ | CLO | ||||||
| 101 | ||||||||
| 110 | ||||||||
| 111 |
从表 5.3 可以看出,Special2 区域定义的指令已经少了很多,主要是乘累加相关的指令,以及两条特殊的指令 CLZ(Count Leading Zero) 和 CLO(Count Leading One), 其中 CLZ 指令用来找出一个指定寄存器的数据中,从高位开始连续的 0 的个数,而 CLO 指令则用来找出从高位开始连续的 1 的个数。
在 RegImm 区域 ( 即 op==000_001 时 ),对分支指令进行了扩展,可以使分支指令更加灵活,而且顾名思义,RegImm 这个名字表示了这些操作是和立即数有关的,因为指令中的 Bit[5:0] 属于立即数的一部分,因此不能够使用这部分对指令进行编码,而是使用了 Bit[20:16] 来对指令进行编码,Bit[20:16] 本来用来表示 rt,而 rt 在 RegImm 类型的指令中没有用到,所以可以使用这部分对指令进行编码,这部分定义的指令如表 5.4 所示。
表 5.4 当“op=RegImm”时,rt 区域定义的指令
| Bit[20:19] \ Bit[18:16] | 000 | 001 | 010 | 011 | 100 | 101 | 110 | 111 |
|---|---|---|---|---|---|---|---|---|
| 00 | BLTZ | BGEZ | ||||||
| 01 | TGEI | TGEIU | TLTI | TLTIU | TEQI | TNEI | ||
| 10 | BLTZAL | BGEZAL | ||||||
| 11 |
需要注意的是,在 MIPS 指令集中支持分支指令的延迟槽 (branch delay slot),这是早期的 MIPS 处理器对流水线中位于分支指令之后的指令进行处理的方法。延迟槽 (delay slot) 位于分支指令的后面,通常由编译器来将一条比较独立的、不依赖于分支指令的指令放到延迟槽中,这条指令一般来自于分支指令的前面,不管分支指令是否跳转,它都会执行,这样即使分支指令发生了跳转,也不需要将延迟槽中的指令从流水线中抹掉 (如果不使用延迟槽,这条指令在分支指令发生跳转时,应该从流水线中抹掉 )。这种方法对于早期流水线很短的普通处理器是比较有效率的,但是在深流水线的超标量处理器中,延迟槽中包含的指令个数也随之增多,已经没有办法找到那么多的不相关指令放到延迟槽中了,所以只能向延迟槽中填充空指令 (NOP 指令 ),这样并不会增加处理器的执行效率。现代的超标量处理器主要依靠精确的分支预测技术来处理分支指令,延迟槽在这种处理器中已经失去了存在的意义。
在决定指令类型的 op 区域,还有一个 COP0, 它是用来定义一些访问协处理器 (Coprocessor) 的特殊的指令,只不过 COP0 定义的所有指令都不能够使用 rs 寄存器,所以使用 rs 对指令进行编码,之所以不使用指令中的 Bit[5:0] 进行编码,是因为这部分区域有着特殊的用途 ( 例如选择哪个协处理器,或者选择协处理器中的哪个寄存器等 ),rs 位于指令中的 Bit[25:21], 它定义的指令如表 5.5 所示。
表 5.5 当“op=COP0”时,rs 区域定义的指令
| Bit[25:24] \ Bit[23:21] | 000 | 001 | 010 | 011 | 100 | 101 | 110 | 111 |
|---|---|---|---|---|---|---|---|---|
| 00 | MFCO | MTCO | ||||||
| 01 | ||||||||
| 10 | ||||||||
| 11 |
这部分定义了两条指令:MTCO 和 MFCO, 用来在协处理器的寄存器和处理器的寄存器之间传递数据,处理器无法直接操作协处理器中的寄存器,因为在指令的编码中已经没有办法对协处理器中的寄存器进行直接的编码,要对协处理器中的寄存器进行操作,只能够将它们先放到处理器内部的通用寄存器中,然后才能够对其进行操作,最后将操作完的数据再写回协处理器中对应的寄存器就可以了。处理器在指令中能够直接操作的寄存器只有在指令集里面定义的 32 个通用寄存器。
上面就是 MIPS 指令集所支持的基本指令,当然,它并不是全部,这其中并没有包括一些特殊的指令,例如调试相关的指令、数字信号处理相关的指令等。通过对上述的这些基本的指令进行组合,就可以完成任何任务了。不过有些时候存在效率不高的问题,这就需要增加特殊的指令来处理,例如,要对图像数据进行处理,但是图像数据一般都是 8 位的,直接使用上述普通的指令对其进行加减乘除的处理,虽然没问题,但是 32 位的数据只是使用了 8 位,造成了性能的浪费,因此就产生了单指令多数据 (SIMD) 类型的扩展指令,能够在一个周期内完成四个 8 位数据的运算,通过这些特殊的指令,可以增加处理器对特定类型任务的处理能力,关于这些特殊指令的详细介绍,可以参见 MIPS 指令集的手册14。
5.2.2 ARM 指令集
ARM 指令集从本质上来讲,和 MIPS 指令集是类似的,它的指令都是 32 位的长度 (不考虑 16 位的 thumb 指令,这些指令在处理器内部会被转换为标准的 32 位指令 ), 采用了 load/store 结构,处理器只能对寄存器进行操作,而且指令的类型和 MIPS 指令相比,也是大同小异。当然,由于这两者设计理念的不同,还是有一些区别的,ARM 指令集或多或少地借鉴了复杂指令集的一些特点,在一条指令中尽量做了很多的任务,这是有别于 MIPS 指令的一个显著特点。ARM 指令集概括来讲,也可以分为图 5.3 所示的三种类型 DP format (Data Processing)、DT format(Data Transfer) 和 BR format(Branch),但是相比 MIPS 指令集,ARM 指令集的编码要更复杂和凌乱一些,图 5.3 中每个部分的含义如下。

图 5.3 ARM 指令集的三种格式
(1) Cond: condition, 由于 ARM 指令集中,每条指令都可以条件执行,这部分就用来判断指令执行的条件是否成立;
(2) F: instruction format, 用来区分指令的类型,如 DP 类型、DT 类型或者 BR 类型等;
(3) I: immediate, 如果这一位是 0, 则指令中的第二个操作数 (operand2) 是寄存器,否则第二个操作数 (operand2) 是立即数;
(4) Opcode: 指令的基本操作类型;
(5) S: set condition code, 当一条指令的这一位被置 1, 表示该指令的操作会影响状态寄存器 (CPSR ) 的值,通常将一条指令后面附加 S 来表示这个功能;
(6) Rn: 指令中的第一个操作数,来自于寄存器;
(7) Operand2: 指令中的第二个操作数,它有可能来自于寄存器,也有可能是立即数;
(8) Rd: 目的寄存器,存放指令运算的结果。
在 ARM 指令集定义的三种基本指令类型中,DP 类型的指令主要用来对数据进行处理,这里需要重点说明一下的是第二个操作数 (Operand2)。当第二个操作数是立即数时,并不是简单地将指令中 12 位的 Operand2 都用来表示一个 12 位的立即数,ARM 认为 12 位的立即数表示的范围太小,为了扩大立即数的表示范围,将指令中 12 位的 Operand2 分为了两部分,如图 5.4 所示。

图 5.4 ARM 指令中的立即数
在图 5.4 中包括 rotate_imm 和 immed_8 两部分,通过将 8 位的数据 (immed_8) 循环右移偶数位 ($\mathrm{rotate_imm}\times2$),可以得到一个 32 位的立即数 (immediate),可以用下面的式子来表示。
\[\mathrm{immediate}=\mathrm{immed\_8}\text{ 循环右移 }(\mathrm{rotate\_imm}\times2)\]通过上面的式子可以看出,按照这种构造数据的方式,一个 8 位的数只能循环右移 0、 2、4、…30 位,这样得到的 32 位立即数显然不能够涵盖任意一个 32 位立即数,因此在 ARM 中,很多 32 位的立即数都是不合法的,不能够在指令中被直接编码,只有少部分 32 位立即数才是合法的,合法的 32 位立即数可以用图 5.5 来表示。

图 5.5 在 ARM 中,合法的 32 位立即数应该具有的条件
只有满足如图 5.5 所示条件的 32 位立即数,才可以使用 “immed_8≫(rotate_imm×2)” 的方式来得到,举例来说,立即数 0x10c 和 0xff0 都是合法的,它们用二进制表示如下。
① 0x10c:0000_0000_0000_0000_0000_0001_0000_1100(通过将 8'b01_0000_11 循环右移 30 位来得到,这个立即数在指令中编码为 1111_01000011);
② 0xff0:0000_0000_0000_0000_0000_1111_1111_0000(通过将 8'b1111_1111 循环右移 28 位来得到,这个立即数在指令中编码为 1110_11111111)。
相反,立即数 0x1010_0000 和 0x1020_0000 都是违法的,它们可以表示如下。
① 0x1010_0000:0001_0000_0001_0000_0000_0000_0000_0000(有效数据大于 8 位,并且不能通过移动偶数位获得);
② 0x1020_0000:0001_0000_0010_0000_0000_0000_0000_0000(不能通过移动偶数位获得)。
但是有时候会出现这种情况:一个立即数可以使用很多方式来获得,例如 0x3f0 这个立即数,可以通过下面两种方式得到。
方式一:将立即数 0x3f 循环右移 28 位来得到 0x3f0;
方式二:将立即数 0xfc 循环右移 30 位来得到 0x3f0。
面对这种情况,ARM 有如下规则。
(1) 当立即数的值是 0x00~0xFF 时,由于使用 8 位可以直接表示,因此不需要使用移位的方式,此时 rotate_imm=0;
(2) 其他情况下,选择使 rotate_imm 的数值最小的编码方式。
根据上面的两条规则可以看出,0x3f0 是通过方式一获得的,因为方式一循环移位的位数比较小。
很显然,ARM 规定的这种构造 32 位立即数的方法,只会保证很少部分的立即数是有效的,只有这些有效的立即数才可以直接在指令中进行编码。在 ARM 中,如果想要使用任意一个 32 位立即数,那么上面的这种方法显然是不够的。事实上,程序员完全没必要关心什么样的立即数是合法的,只需要在汇编程序中采用下述伪指令就可以了。
LDR Rd, =<immed_32> //immed_32 是一个任意的 32 位立即数
如果这个 32 位的立即数是一个合法的立即数,那么可以直接在指令中进行编码,这条伪指令就可以变为一条普通的 MOV( 或者 MVN) 指令,将 32 位立即数直接放到一个通用寄存器中,举例来说,对于伪指令 “LDR r1, =0xff00ffff”, 编译器会将其变为 “MVN r1, #0x00ff0000”。可以看出,0x00ff0000 是一个有效的立即数,可以通过将立即数 0xff 循环右移 16 位来获得,因此可以直接在指令中进行编码。
如果编译器发现 LDR 伪指令中的 32 位立即数是不合法的,就需要将这个 32 位的立即数放到文字池 (literal pool) 中,然后使用一条 PC 相关的 load 指令来获得这个立即数,这个过程可以用图 5.6 来表示。
PC 相关的 load 指令使用这条指令的 PC 值作为基址,偏移量 (offset) 也是在指令中直接以立即数的形式给出,这和 MIPS 指令集中的直接跳转类型 (PC-relative) 的分支指令是一样的。当然,由于流水线的原因,实际上这种类型的 load 指令在计算地址的时候,使用的PC 值已经是 $PC+8$ 了。在 ARM 的程序中,文字池 (literal pool) 是指在程序存储器中位于程序区后面,用来存放常数的一段空间,如果在伪指令 “LDR Rd, =<immed_32>”中,32 位的立即数不是合法的,那么编译器就会使用一条 PC 相关的 load 指令来替代这条伪指令,同时编译器会将这个 32 位立即数放到文字池中,然后计算出这个立即数的地址相对于当前指令 PC 值的偏移量 ( 由于流水线的原因,实际计算的是相对于 $PC+8$ 的偏移值 ),这条 PC 相关的 load 指令就可以通过这种方式从程序存储器的文字池中将 32 位的立即数取出来了。

图 5.6 ARM 使用 PC 相关的 load 指令来获得 32 位的立即数
在 ARM 中,由于 PC 寄存器是指令集中定义的一个通用寄存器,所以可以直接在指令中使用,但是相比较之下,MIPS 指令集中,PC 寄存器并不是一个通用寄存器,没有办法在指令中直接使用,所以 MIPS 采用了完全不同的方法来处理 32 位立即数。在 MIPS 中,立即数是 16 位的,而且这 16 位的立即数直接被指令使用,不存在是否合法的问题,考虑到两个 16 位的立即数就可以拼成一个 32 位的立即数,所以 MIPS 使用 lui 和 addiu( 或 ori) 两条指令来完成将一个 32 位立即数放到寄存器的过程。其中 lui 指令可以将 16 位的立即数放到寄存器的高 16 位,同时将寄存器的低 16 位清零;而 addiu 指令将 16 位的立即数和寄存器的低 16 位相加,于是通过这两条指令就可以将一个 32 位立即数放到寄存器中了。举例来说,将 32 位的立即数 #5918a681 放到寄存器 r1 中,可以用下面两条指令实现。
lui r1, #5918 ; r1 = #59180000
addiu r1, #a681 ; r1 = #5918a681
为了便于程序的编写,MIPS 使用了伪指令 li 来加载一个立即数到寄存器中,根据立即数的不同情况,会采用不同的指令来替代伪指令 li, 下面给出了几个例子。
li R5, 0x330000 →lui R5, 0x33 //将 0x33 放到寄存器 R5 的高 16 位
li R3, -8 →addiu R3, R0, -8 //R0 寄存器中存储 0 值
li R2, 0x1234 →ori R2, R0, 0x1234
li R2, 0x123456 →lui R2, 0x12 //高位值 0x12 放到 R2 的高 16 位
→ori R2, R2, 0x3456 //低位值 0x3456 放到 R2 的低 16 位
从上面的描述可以看出,ARM 和 MIPS 采用了完全不同的方法来处理 32 位立即数,对于 ARM 指令集来说,大部分的 32 位立即数都是不合法的,因此都需要访问程序存储器。在超标量处理器中,访问存储器需要经过 TLB 和 Cache 等一系列的部件,任何的缺失 (miss) 都会造成执行效率的降低。而 MIPS 的处理方式则更简洁高效,直接使用普通的两条指令来获得 32 位立即数,因此在超标量处理器中可以获得高效的执行,这一点是 ARM 无法比拟的。
在寻址模式方面,由于 MIPS 和 ARM 都是 RISC 处理器,所以没有本质的区别,一个操作数可以以立即数的形式存在于指令中,也可以存在于处理器内的通用寄存器中,还可以存在于存储器中,存在于指令中的立即数可以直接被处理器使用,因此这种寻址方式效率最高,但是由于立即数可以表示的范围有限,所以只有少部分数据可以使用立即数表示。处理器当中的寄存器可以存储任意一个数据,而且寄存器的访问速度很快,所以这种寻址方式的执行效率也是很高的,但是在处理器内部,由于指令编码长度、硅片的面积和速度等限制,寄存器的个数一般是有限的,所以寄存器中只能保存少部分经常使用的数据。在存储器中可以存储任意的数据,它的容量很大,但是访问速度一般比较慢,这种寻址方式虽然效率不是很高,但却是应用最广泛的,为了加快这种寻址方式的速度,一般都会在处理器中使用 Cache, 这样可以对大部分存储器的访问进行提速,为了不失一般性,图 5.7 概括表示了 RISC 处理器中使用的寻址方式2。

图 5.7 RISC 处理器中使用的寻址方式
在图 5.7 中,特别需要注意的是图 (e) 寻址方式,这种方式将 PC 和指令中的立即数 (Address) 进行了拼接,而不是进行加法操作,这种寻址方式是 MIPS 特有的,关于它的详细介绍将在后文展开。
下面根据指令的类型,对指令进行分类的介绍。
5.3 Load/Store 指令
5.3.1 Load 指令
在 MIPS 指令集中,基本的 Load 指令包括 LB、LBU、LH、LHU 和 LW 五条指令,它们的含义如表 5.6 所示。
表 5.6 MIPS 指令集中的 load 指令
| Instruction | Description | Function |
|---|---|---|
| LB | Load Byte | Rt = sign_extend((byte)Mem[Rs+offset]) |
| LBU | Unsigned Load Byte | Rt = zero_extend((byte)Mem[Rs+offset]) |
| LH | Load Halfword | Rt = sign_extend((half)Mem[Rs+offset]) |
| LHU | Unsigned Load Halfword | Rt = zero_extend((half)Mem[Rs+offset]) |
| LW | Load Word | Rt = Mem[Rs+offset] |
在表 5.6 中,各指令解释如下。
(1) LB 指令用来从存储器中读取一个字节的数据,将其符号扩展为 32 位,然后将其放到处理器内部的通用寄存器中,这条指令用来处理有符号数;
(2) LBU 指令用来从存储器中读取一个字节的数据,将其无符号扩展为 32 位 ( 即高位扩展 0),然后将其放到处理器内部的通用寄存器中,这条指令用来处理无符号数;
(3) LH 指令用来从存储器中读取半个字,将其符号扩展为 32 位,然后将其放到处理器内部的通用寄存器中,这条指令用来处理有符号数;
(4) LHU 指令用来从存储器中读取半个字,将其无符号扩展为 32 位 ( 即高位扩展 0),然后将其放到处理器内部的通用寄存器中,这条指令用来处理无符号数;
(5) LW 指令用来从存储器中读取一个字,然后将其放到处理器内部的通用寄存器中,因为一个字的长度已经是 32 位了,因此不需要对高位进行扩展,也就不存在 LWU 这样的指令了。
在 MIPS 中,所有的 load 指令使用的存储器地址来自于基址和偏移量的加和 (Rs+offset),其中 Rs 是一个通用寄存器的值,offset 是来自于指令中 16 位的立即数。
5.3.2 Store 指令
在 MIPS 指令集中,基本的 Store 指令包括 SB、SH 和 SW 三条指令,它们的含义如表 5.7 所示。
表 5.7 MIPS 指令集中的 store 指令
| Instruction | Description | Function |
|---|---|---|
| SB | Store Byte | (byte)Mem[Rs+offset] = Rt |
| SH | Store Halfword | (half)Mem[Rs+offset] = Rt |
| SW | Store Word | Mem[Rs+offset] = Rt |
在表 5.7 中,各指令解释如下。
(1) SB 指令用来将 32 位通用寄存器的低 8 位放到存储器中;
(2) SH 指令用来将 32 位通用寄存器的低 16 位放到存储器中;
(3) SW 指令用来将整个 32 位通用寄存器放到存储器中。
可以看到,并没有 SBU(store byte unsigned) 和 SHU(store halfword unsigned) 这样的指令,因为对于向存储器中写数据来说,只要将寄存器指定的内容放到存储器指定的位置就可以了 (存储器的最小单位是字节 ),不需要理会它是否是有符号数。
在 RISC 处理器中,load/store 指令在使用的时候需要注意大小端的问题,从一般意义上来讲,小端格式 (little endian) 更符合正常思维一些,大小端所引起的问题可以用图 5.8 来表示。

图 5.8 大小端格式
小端格式 (little endian) 将一个数据的低位字节放在存储器的低位地址,而大端格式 (big endian) 则将一个数据的低位字节放到存储器的高位地址。从图 5.8 中可以看到,当一个数据 0x12345678 以小端格式存储时,它的低位字节 0x78 将放到存储器的最低位,而以大端格式存储时,它的最高字节 0x12 将放到存储器的最低地址,这样在执行 LB 指令,从这个指定的存储器地址读取一个字节的数据时,就会得到不同的值了。
在 MIPS 指令集中,load/store 指令完成的任务比较简单,反观 ARM 中的 load/store 指令,则进行了更多的任务,这些功能主要借鉴了 CISC 和 DSP 指令集中的一些设计思路,和 MIPS 指令集比起来,主要有两大方面不同。
(1) 支持前 /后变址 (pre-indexed/post-indexed) 的寻址方式,这种寻址方式概括起来完成了两个任务。
任务一:执行普通的 load/store 操作;
任务二:改变 load/store 指令中地址寄存器的值。
也就是说,使用这种前 /后变址的寻址方式,一条 load/store 指令在执行完成后,可以自动将存放地址的寄存器进行自加 / 自减,通过这种方式,就可以对一片连续的地址空间进行操作,而在 MIPS 中,还需要使用加减法指令来控制地址寄存器的自加 / 自减,显然 ARM 指令集采用的前 /后变址的 load/store 指令会更有效率,尤其是对于某些数字信号处理 (DSP) 的运算,需要运算的数据和系数都存放在存储器中,使用这种 load 指令可以方便地将它们取出来,然后进行乘累加运算,而使用 MIPS 指令集来实现这些功能,就需要更多的步骤了,既然这样,为什么 MIPS 不采用这种寻址方式呢?原因大概有二:一是这种前 /后变址的寻址方式已经不符合 RISC 当初的理念了,在 RISC 的观点中,更多的事情丢给软件来处理,这样可以降低硬件设计的复杂度,从而获得更高的硬件性能,而 MIPS 作为坚定的 RISC 主义者,很显然不会采用这种方式;二是在 32 位的 MIPS 指令集的编码中,对于 load/store 类型的指令,已经没有空间再进行这种 “前/后变址”功能的编码了。
这种前 /后变址的寻址方式,在短流水线的普通处理器中是容易实现的,在性能上也占据了优势,但是在超标量处理器中,这种寻址方式会带来额外的麻烦,这部分内容将在第 10 章进行详细的说明。
(2) 多寄存器传送指令 LDM/STM,能够在一条指令中,将存储器中一片连续地址的数据放到多个寄存器中,或者将多个寄存器的内容放到存储器内一片连续的地址空间,同时还能够改变指令中地址寄存器的内容,这是一个非常 CISC 风格的指令,如果直接编写汇编程序,这种类型的指令简化了程序员的任务,例如可以使用一条 STM 指令完成压栈的工作。一条 LDM/STM 指令完成的工作,在 MIPS 处理器中可能需要很多条指令才可以完成,但是实际上在 ARM 处理器中,LDM/STM 这样的指令也是需要消耗多个周期才能够完成的,需要的周期数取决于要传送的寄存器的个数。因此从执行的效率方面来说,使用 LDM/STM 指令并不能占到太多的优势,但是却能够给程序员造成一种假象:在一条指令中完成了如此多的任务,而且还能够节省指令存储空间。虽然在现代处理器中,存储器已经很廉价了,但是节省程序存储空间也就意味着更低的 I-Cache 的缺失率 (miss rate),这样也就提高了程序执行的效率。在短流水线的普通处理器中,LDM/STM 指令实现起来也不会带来太多的硬件消耗,但是到了超标量处理器中,LDM/STM 指令由于含有多个目的寄存器和源寄存器,很难直接进行处理,需要采取一些特殊的措施,这部分内容将在第 10 章进行介绍。
5.4 计算指令
MIPS 指令集中的计算指令的类型包括加减法、逻辑、 移位和乘法,处理器使用这些指令完成运算的任务,这些指令如表 5.8 所示。
表 5.8 MIPS 指令集中的计算指令
| 类型 | Instruction | Description | Function |
|---|---|---|---|
| 加减法 | ADD | Integer Add | Rd = Rs + Rt |
| 加减法 | ADDI | Integer Add Immediate | Rd = Rs + sign_extend(imm) |
| 加减法 | ADDU | Unsigned Integer Add | Rd = Rs + Rt |
| 加减法 | ADDIU | Unsigned Integer Add Immediate | Rd = Rs + zero_extend(imm) |
| 加减法 | SUB | Integer Subtract | Rd = Rs - Rt |
| 加减法 | SUBU | Unsigned Integer Subtract | Rd = Rs - Rt |
| 逻辑 | AND | Logical AND | Rd = Rs & Rt |
| 逻辑 | ANDI | Logical AND Immediate | Rd = Rs & zero_extend(imm) |
| 逻辑 | OR | Logical OR | Rd = Rs \| Rt |
| 逻辑 | ORI | Logical OR Immediate | Rd = Rs \| zero_extend(imm) |
| 逻辑 | XOR | Exclusive OR | Rd = Rs ^ Rt |
| 逻辑 | XORI | Exclusive OR Immediate | Rd = Rs ^ zero_extend(imm) |
| 逻辑 | NOR | Logical NOT OR | Rd = ~(Rs\|Rt) |
| 逻辑 | LUI | Load Upper Immediate | Rt = {imm, 16'b0000} |
| 移位 | SLL | Shift Left Logical | Rd = Rt << sa |
| 移位 | SLLV | Shift Left Logical Variable | Rd = Rt << Rs[4:0] |
| 移位 | SRL | Shift Right Logical | (uns)Rd = Rt >> sa |
| 移位 | SRLV | Shift Right Logical Variable | (uns)Rd = Rt >> Rs[4:0] |
| 移位 | SRA | Shift Right Arithmetic | (int)Rd = Rt >> sa |
| 移位 | SRAV | Shift Right Arithmetic Variable | (int)Rd = Rt >> Rs[4:0] |
| 特殊 | CLO | Counting Leading Ones in word | Rd = NumLeadingOnes(Rs) |
| 特殊 | CLZ | Counting Leading Zeros in word | Rd = NumLeadingZeros(Rs) |
| 乘法 | MUL | Multiply with Register Write | Rd = Rs×Rt |
| 乘法 | MULT | Integer Multiply | {Hi, Lo} = (int)Rs×(int)Rt |
| 乘法 | MULTU | Unsigned Multiply | {Hi, Lo} = (uns)Rs×(uns)Rt |
| 乘累加 | MADD | Multiply-Add | {Hi, Lo} = {Hi, Lo} + (int)Rs×(int)Rt |
| 乘累加 | MADDU | Multiply-Add Unsigned | {Hi, Lo} = {Hi, Lo} + (uns)Rs×(uns)Rt |
| 乘累加 | MSUB | Multiply-Subtract | {Hi, Lo} = {Hi, Lo} - (int)Rs×(int)Rt |
| 乘累加 | MSUBU | Multiply-Subtract Unsigned | {Hi, Lo} = {Hi, Lo} - (uns)Rs×(uns)Rt |
5.4.1 加减法
在 MIPS 中,加法指令分为有符号加法 (ADD) 和无符号加法 (ADDU),ADD 和 ADDU 其实进行的运算是一样的,都是将两个 32 位的数相加,但是当加法发生溢出 (overflow) 时,即两个 32 位的数相加的结果超过了 32 位,ADD 指令会产生一个异常 (exception),此时计算的结果不会写到目的寄存器中;而 ADDU 指令则不会关注这个溢出,也不会产生异常,仍旧会将结果写到目的寄存器中。当不需要考虑溢出时,就要采用 ADDU 指令,如计算地址等操作,而当需要考虑溢出时,就要采用 ADD 指令,此时会产生异常,MIPS 处理器在异常处理程序 (exception handler) 中对这个异常进行处理。流水线处理器中如果发生异常,在这条发生异常的指令之后进入到流水线的指令都应该从流水线中抹掉,这些指令不应该更改处理器的状态,流水线会从异常处理程序对应的入口地址开始取新指令来执行,在短流水线的普通处理器中,这种操作不会引起太大的性能损失,但是对于流水线很深的超标量处理器,异常的处理需要等到产生异常的指令变为流水线中最旧的指令 (也就是退休的时候 ),然后需要将整个流水线中的指令都抹掉,并且还要对处理器的状态进行恢复,例如要恢复寄存器重命名阶段的映射表 (mapping table),这些操作浪费的周期数会更多,因此,在超标量处理器中使用异常的方式对加法运算的溢出进行处理,效率会很低,这也算是 MIPS 指令集到了超标量处理器中的一个缺点。
减法操作中的 SUB 和 SUBU 指令也是类似的,而且,处理器内部其实并没有减法器,而是使用加法器来实现的,因为在二进制补码的运算中,有这样的公式。
\[A-B=A+(\sim B)+1\]也就是说,要执行减法运算,只需要将减数取反,然后使用加法器就可以完成了,如果产生了溢出,SUB 指令会产生异常,而 SUBU 指令则不会产生异常。
相比较之下,ARM 对于加减运算指令产生溢出时的处理更有效率一些,ARM 处理器在进行加减运算,例如执行 ADD 指令时,可以选择将结果的状态 (例如结果是否溢出,是否为零等信息 ) 保存到状态寄存器中,在 ARM 中这个状态寄存器称为 CPSR,后面的指令可以直接利用这个状态寄存器的值,例如进行带进位的加法,或者根据状态寄存器 CPSR 的值来决定自身是否执行等,ARM 指令集的一个显著的特点就是每条指令都可以条件执行。
当然,ARM 指令这样做也是有代价的,由于每条指令都可以条件执行,则在每条指令中都需要包含 4 位的条件码 (condition code),这就使指令中可以用来寻址寄存器的编码空间变小了,所以 ARM 当中的通用寄存器只有 16 个,而 MIPS 中有 32 个。通用寄存器个数比较多时,处理器就可以减少访问存储器的次数,这样也就增加了程序执行的效率。
还需要注意的是,MIPS 中没有带进位的加法这种类型的指令,带进位的加法指令一般用在使用 32 位加法模拟 64 位加法的时候使用,在 32 位的 MIPS 处理器中,要想实现 64 位的加法,就需要配合溢出的异常来实现了,这样效率肯定是很低的,例如要实现下面的运算。
\[\{r2,r1\}=\{r2,r1\}+\{r4,r3\}\]在 MIPS 处理器中,需要先使用指令来计算 r1 = r1 + r3, 如果产生了溢出,则在对应的异常处理程序 (exception handler) 中进行处理,此时需要进行 r2 = r2 + r4 + 1 的运算,并且还需要使用其他的指令才可以完成这个 64 位的加法,当然,如果 r1 = r1 + r3 的运算没有产生溢出,那么只需要再进行 r2 = r2 + r4 的运算就可以了。
在 ARM 指令集中则直接实现了带进位的加法 (ADD with Carry,ADDC) 这样的指令,可以很方便地实现更高位数的加法运算,例如上面的加法运算只需要使用下面的两条指令就可以实现。
ADDS R1, R1, R3 ; Add the least significant words
ADCS R2, R2, R4 ; Add the most significant words with carry
很显然,ARM 的这种方式会有更高的执行效率,而 MIPS 指令集则秉承了 RISC 的理念,把更多的事情尽量丢给软件来处理,而且 MIPS 指令集很早就实现了 64 位,根本就没有必要使用上述的方法来模拟 64 位的加法运算,而反观 ARM 指令集,直到最近的 ARMv8 指令集才实现了 64 位,当然,这是和产品的定位有关的。MIPS 处理器定位高性能的应用场合,例如工作站,所以需要实现 64 位的指令集来提高性能;而 ARM 指令集一直应用于对功耗敏感、 对性能要求相对不高的嵌入式领域,所以 ARM 迟迟没有推出 64 位指令集,在 64 位运算方面,ARM 是一个迟到的来者。
5.4.2 移位指令
MIPS 的移位指令中,带 V 和不带 V 的指令 (如 SLL 和 SLLV) 的区别是不带 V 的指令 (如 SLL 指令 ) 移位的位数由指令当中的 sa( 即 Bit[10:6]) 直接给出,而带 V 的指令 (如 SLLV 指令 ) 移位的位数由寄存器 Rs 的低 5 位来决定。也就相当于,不带 V 的移位指令,一个操作数是立即数,而带 V 的移位指令,两个操作数都是通用寄存器。
左移的指令会在低位部分补 0, 而对于右移的操作,则分为逻辑右移和算术右移两种类型,对于逻辑右移,高位空出的部分用 0 来填充;而算术右移,高位空出的部分用原来数据的符号位来填充,因此,对有符号数进行右移,就需要使用算术右移,而对于无符号数右移就使用逻辑右移。
在 ARM 中没有专门的移位指令,这是因为 ARM 中大部分运算指令都可以将操作数在运算之前进行移位操作,这也是 ARM 指令集的一个特点,即将移位操作和运算操作集成到了一条指令当中,如图 5.9 所示。

图 5.9 ARM 指令集将移位操作和算术操作集成在了一起
在一条指令中做更多的事情,这就是 ARM 指令集的特点,从这个角度来看,ARM 指令集确实是一个有着 CISC 风格的 RISC 指令集。
5.4.3 逻辑指令
MIPS 中的逻辑指令主要完成与、或、非、异或等操作,这部分指令相对比较简单,在 ARM 中也有类似功能的指令,逻辑运算指令配合立即数,可以完成很多任务,例如下列两种。
(1) 位屏蔽功能,有选择地屏蔽掉一个寄存器中的某些位,例如 “ANDI R2, R1, 0x8000_0000” 只是将 R1 寄存器的最高位写到了 R2 寄存器中,R1 寄存器中的其他位将被忽略;而 “ORI R2, R1, 0x0000_FFFF” 只考虑了 R1 寄存器的高 16 位,低 16 位将被忽略;这种位屏蔽指令在配置寄存器的时候会经常使用。
(2) 计算余数的功能,正常情况下,余数是除法的一个结果,但是很多处理器中并没有硬件除法指令,即使有,消耗的周期数也是很多的,要计算两个数相除的余数,在某些特定的情况下,可以使用逻辑指令来完成。例如,要计算 R1/32 的余数,可以使用“ANDI R2, R1, 0x0000_001F” 指令来实现,并将得到的余数放到寄存器 R2 中。当然,要使用这种方法,是有限制的,它要求除数必需要是 2 的整数次幂,例如上例中使用的 $32=2^5$,事实上,由于现代的处理器建立的基础就是二进制运算,所以在处理器中经常能够找到符合这种规律的数据。
5.4.4 乘法指令
MIPS 中的乘法指令可以将两个 32 位的数据进行相乘,并根据乘法指令的类型对乘法结果进行选取。MUL 指令将两个 32 位源寄存器相乘,并将乘法结果的低 32 位放到目的寄存器中,当乘法的结果大于 32 位时,肯定会导致乘法的结果不能够全部放到寄存器的情况,需要在编程时加以注意。
MULT 指令将两个 32 位的源寄存器相乘,并将乘法结果的低 32 位放到特殊寄存器 Lo 中,高 32 位放到特殊寄存器 Hi 中,这样就能够完整地保存一个乘法的结果了。寄存器 Hi 和 Lo 是在 MIPS 处理器中专门存放乘法结果的寄存器,但是指令并不能直接使用 Hi 寄存器和 Lo 寄存器进行运算,因为在指令中已经没有空间对这两个寄存器进行编码,需要将 Hi 寄存器和 Lo 寄存器的内容先放到通用寄存器中,然后才可以进行运算,这就需要使用 MFHI(Move From Hi) 和 MFLO(Move From Lo) 这两条指令,将 Hi/Lo 寄存器的内容放到通用寄存器中,然后才能够进行计算,并且将计算完的结果通过 MTHI(Move To Hi) 和 MTLO(Move To Lo) 指令放回到 Hi/Lo 寄存器中。MULT 指令将进行相乘的两个源操作数都按照有符号数看待,此时进行的乘法是有符号数的乘法。
MULTU 指令的运算过程和 MULT 指令是一样的,最后也将乘法结果保存到 Hi/Lo 寄存器中,但是,MULTU 指令进行的是无符号数的乘法运算,无符号数乘法和有符号数的乘法有什么区别呢?下面通过一个例子来说明。
例如,计算 $1001\times0011$( 为了简化,假设数据是 4 位的 )。
当使用 MULT 指令进行运算时,表示两个操作数都是有符号数,此时相当于是 $(-7)\times3=-21$(11101011); 当使用 MULTU 指令进行运算时,表示两个操作数是无符号数,此时相当于是 $9\times3=27$(00011011)。可见,对于同样的两个寄存器使用 MULT 和 MULTU 指令,得到的结果是不一样的。
在 MIPS 指令集中,乘法的结果保存在特殊寄存器 Hi 和 Lo 中,指令不能够直接操作这两个寄存器,但是,这两个寄存器在超标量处理器内部被认为是 32 个通用寄存器之外的第 33 和第 34 个通用寄存器,对它们也可以进行正常的寄存器重命名 (register renaming)。在 MIPS 处理器中,要对乘法的结果进行操作,显然需要多费一番周折:先将 Hi 寄存器和 Lo 寄存器读取到通用寄存器中,然后进行运算,再将结果写回到 Hi 寄存器和 Lo 寄存器中,这样的执行效率显然不会很高。
在 ARM 指令集中,可以直接在指令中指定两个通用寄存器来存储乘法的结果,这样就可以直接对乘法的结果进行其他的运算,而不需要像 MIPS 那样,先将数据从 Hi 寄存器和 Lo 寄存器放到通用寄存器再进行计算,在这一点上,ARM 指令集的优势非常明显。其实,如果仔细观察 MIPS 指令集中,MULT 和 MULTU 这两条指令的编码,如图 5.10 所示。

图 5.10 MIPS 指令集中,乘法指令的编码
通过图 5.10 会发现,其实这两条指令中的 Bit[15:6] 部分都没有实际的意义,也就是没有被使用,这 10 位其实正好可以用来放置两个目的寄存器,这样就可以直接将乘法的 64 位结果放到两个通用寄存器中,后续的指令直接可以对这两个寄存器进行运算,也就不需要先将数据从 Hi 寄存器和 Lo 寄存器加载到通用寄存器,进行运算,然后还需要将处理完成的结果写回到 Hi 寄存器和 Lo 寄存器的过程了,因此,完全可以将 MULT 和 MULTU 这两条指令做成如下的编码,如图 5.11 所示。

图 5.11 更改之后的乘法指令
这样就可以直接在指令中指定两个通用寄存器 RdHi 和 RdLo 作为乘法运算的目的寄存器,存放乘法的结果,RdHi 寄存器存放乘法结果的高 32 位,RdLo 寄存器存放乘法结果的低 32 位。既然 RdHi 和 RdLo 都是通用寄存器,因此后续的指令可以直接使用它们,这样可以提高一些执行效率。
5.4.5 乘累加指令
乘累加运算是数字信号处理中最基本的运算,在 MIPS 指令集中有四条基本的乘累加指令 MADD、MADDU、MSUB 和 MSUBU。乘累加指令 MADD 对两个操作数进行有符号的乘法,并将乘法的结果自动与 Hi/Lo 寄存器中的数据相加,然后再将相加之后的结果写到 Hi/Lo 寄存器中,这个过程可以用下面的式子来表示。
\[\{\mathrm{Hi},\mathrm{Lo}\}=\{\mathrm{Hi},\mathrm{Lo}\}+(\mathrm{GPR}[rs]\times\mathrm{GPR}[rt])\]MADDU 也是乘累加指令,它的运算过程和 MADD 指令是一样的,只是将参与乘法运算的数据认为是无符号数,进行的乘法也就是无符号数的乘法,而对于累加运算,不管是有符号数还是无符号数,运算的过程都是一样的。这里需要说明的是,对于处理器内部来说,参与乘法和加法运算的所有数据都是以二进制补码的形式表示的,在当代计算机的世界里,二进制补码是唯一的数据表达方式。
乘累减指令 MSUB 也是将两个源操作数进行有符号乘法运算,然后从 {Hi,Lo} 寄存器中减去乘法的结果,从本质上来讲,这也是一种乘累加运算,可以用下面的式子来表示。
\[\{\mathrm{Hi},\mathrm{Lo}\}=\{\mathrm{Hi},\mathrm{Lo}\}-(\mathrm{GPR}[rs]\times\mathrm{GPR}[rt])\]乘累减指令 MSUBU 完成的运算和 MSUB 基本上是一样的,只是参与乘法运算的两个数都被认为是无符号数,进行的乘法也是无符号数的乘法。
从上面的描述可以看出,MIPS 中的乘累加类型的指令和乘法指令一样,都需要将结果保存到 Hi 寄存器和 Lo 寄存器中,这些指令的编码如下,如图 5.12 所示。

图 5.12 MIPS 指令集中,乘累加指令的编码
通过观察可以发现,指令当中的 Bit[15:6] 并没有使用,这和乘法指令的情况是一样的,可以按照 ARM 指令集的方式,在指令中指定两个通用寄存器保存运算的结果,于是可以将指令编码改为如图 5.13 所示的方式。

图 5.13 更改之后的乘累加指令
使用这种编码方式后,可以直接在指令中指定两个通用寄存器作为目的寄存器:RdHi 和 RdLo, 将乘法的结果和这两个寄存器进行累加,完成 ${\mathrm{RdHi},\mathrm{RdLo}}={\mathrm{RdHi},\mathrm{RdLo}}+Rs\times Rt$ 的运算。不过对于乘累加指令来说,很多情况下都是完成下面的运算。
\[C=a_1\times b_1+a_2\times b_2+\cdots+a_n\times b_n\]这种运算是数字信号处理中经常使用的,对于这种运算,使用 MIPS 中的乘累加指令也可以获得很高的执行效率,因为每次乘法的结果都会自动累加到 {Hi,Lo} 寄存器中,只有最后一次乘累加运算完成后才有可能会使用 {Hi,Lo} 寄存器的值进行其他的运算,因此在累加次数比较多的乘累加运算中,MIPS 和 ARM 指令集的乘累加指令都有很高的效率。
5.4.6 特殊计算指令
MIPS 中有两条特殊的计算指令,即 CLZ 和 CLO,CLZ 指令用来计算一个通用寄存器中,从最高位开始连续的 0 的个数,例如寄存器中的数据为 0x007010A2, 则执行 CLZ 指令后,结果为 9, 即从最高位开始连续的 0 的个数是 9 个。要实现这个功能并不是很直接,需要使用一些硬件才能够实现,这在后文会进行介绍。
CLO 指令的实现和 CLZ 指令在本质上是一样的,只需要将寄存器的内容取反,就可以使用 CLZ 指令的硬件来实现 CLO 指令的功能了。
在 ARM 中也有 CLZ 指令,实现的功能和 MIPS 是一样的。
5.5 分支指令
所有能改变程序中指令执行顺序的指令称为分支指令,MIPS 中的分支指令包括两种:(1) 无条件执行,在 MIPS 中称为 Jump 指令,这些指令总是执行的;(2) 有条件执行,在 MIPS 中称为分支指令,这些指令只有在满足特定条件时才会执行,这等同于 ARM 中的条件执行指令,今后为了方便,把这两种类型的指令统称为分支指令。总结来看,MIPS 中的分支指令包括的内容如表 5.9 所示。
表 5.9 MIPS 中的分支指令
| 类型 | Instruction | Description | Function |
|---|---|---|---|
| Jump | J | Unconditional Jump | PC={PC[31:28],instr[25:0],2'b00} |
| Jump | JR | Jump Register | PC={Rs[31:2],2'b00} |
| Jump | JAL | Jump and Link | GPR[31]=PC+4PC={PC[31:28],instr[25:0],2'b00} |
| Jump | JALR | Jump and Link Register | GPR[31]=PC+4PC={Rs[31:2],2'b00} |
| Branch | BEQ | Branch on Equal | If Rs==RtPC=PC+sign_extend({imm,2'b00}) |
| Branch | BNE | Branch on Not Equal | If Rs!=RtPC=PC+sign_extend({imm,2'b00}) |
| Branch | BLEZ | Branch on Less Than or Equal to Zero | If Rs[31]\|\|(Rs==0)PC=PC+sign_extend({imm,2'b00}) |
| Branch | BLTZ | Branch on Less Than Zero | If Rs[31]PC=PC+sign_extend({imm,2'b00}) |
| Branch | BGTZ | Branch on Great Than Zero | If !Rs[31]&& (Rs!=0)PC=PC+sign_extend({imm,2'b00}) |
| Branch | BGEZ | Branch on Greater Than or Equal to Zero | If !Rs[31]PC=PC+sign_extend({imm,2'b00}) |
| Branch | BLTZAL | Branch on Less Than Zero And Link | GPR[31]=PC+4If Rs[31]PC=PC+sign_extend({imm,2'b00}) |
| Branch | BGEZAL | Branch on Greater Than or Equal to Zero And Link | GPR[31]=PC+4If !Rs[31]PC=PC+sign_extend({imm,2'b00}) |
所有以 B 开头的指令表示有条件执行的分支指令,它们包括 BEQ、BNE、BLEZ、BLTZ、 BGTZ、BGEZ、BLTZAL 和 BGEZAL, 这些指令中含有 16 位的立即数,分支指令的 PC 值和立即数进行相加而计算出新的目标地址,由于处理器中的指令是字对齐的,也就是任何 PC 值的最低 2 位都是 00, 所以分支指令的目标地址的最低 2 位也应该是 00, 这要求指令中包括的立即数的最低 2 位是 00, 但是如果是这样,这 2 位 00 就在指令中没有起到真正的意义,因为它总是 00, 故这样做是比较浪费的,因此在 MIPS 的编码中,立即数中最低的两位 0 并不会写在指令中,当进行目标地址的计算时,可以将这 16 位的立即数左移 2 位 ( 即相当于在立即数的最低部分添加 2'b00, 使其扩展到 18 位 ),这样就可以使指令当中的立即数能够有效地利用起来,此时 PC 能够跳转的范围就可以用 18 位来表示。考虑到在程序中,既可以向前跳转,也可以向后跳转,所以这 18 位的立即数其实是一个有符号数,能够跳转的范围是±128KB, 在 MIPS 的指令集中,这个跳转范围是相对于分支指令后面延迟槽 (delay slot) 中的指令而言的。由于这个跳转是和当前的分支指令有关系的,所以也称这种指令为相对于 PC 的 (PC-relative) 的分支指令。
所有以 B 开头的分支指令都需要判断条件,只有条件成立的时候才会真正执行分支指令,如果条件不成立,就直接忽略这条分支指令,就好像这条分支指令不存在一样,这个条件是直接嵌入在分支指令当中的,分支指令首先需要对条件进行判断,然后才能够决定分支指令是否执行,这一点和 ARM 指令是完全不同的,ARM 中的分支指令是判断 CPSR 寄存器中的状态是否满足要求,举例来说,ARM 和 MIPS 中都有 BEQ 这条指令,MIPS 的使用方式如下。
BEQ r1, r2, offset
这条指令在执行的时候,首先判断 r1 是否等于 r2, 如果相等,则跳转到地址 $PC+4+\mathrm{offset}$ 的地方,否则就继续顺序地执行。
而在 ARM 中这条指令的使用方式如下。
BEQ LABEL1
这条指令在执行的时候,直接读取 CPSR 寄存器的内容,判断 Zero 标志位 Z 是否有效,如果有效,表示上一条指令满足相等的条件 ( 例如上一条指令是比较指令 ), 则跳转到 LABEL1 的地方执行指令,否则就继续顺序地执行。可见,同样是 BEQ 指令,在两个处理器中的差别是很大的。
在 MIPS 中,分支类型指令的跳转范围只有±128KB, 如果想要获得更大的跳转范围,可以使用 J 和 JAL 指令,这两条指令中包括 26 位的立即数 (在指令使用时,会将其左移 2 位而变为 28 位 ),但是,在 MIPS 指令集中,这个立即数并不是像 PC-relative 类型的分支指令那样,直接和 PC 相加,而是将这 28 位的立即数直接和当前分支指令 PC 的高 4 位 ( 即 PC[31:28])“拼接”成一个新的地址 (其实应该是和延迟槽中指令的 PC 值进行拼接,此处为了方便说明,就直接使用了分支指令的 PC 值 ),这样在当前分支指令所在的 256MB 的范围之内 ($2^{28}=256\mathrm{MB}$, 由 PC[31:28] 指定哪个 256MB),使用 J 和 JAL 指令可以任意跳转到这 256MB 中的任何地方。这种分支的方式在 MIPS 中称为 PC-region, 这和刚才讲过的 PC-relative 类型是不一样的,并不是将指令当中的立即数和 PC 值进行相加,而是直接将这个立即数和 PC 值进行拼接而形成新的地址。
相比于 PC-relative 的分支方式,这种 PC-region 的方式有什么好处呢?如果一个程序全部位于 256MB 对齐的范围之内,则使用这种 PC-region 的分支指令,可以直接跳转到这个程序的任意一个地方;而如果对 J 和 JAL 指令采取 PC-relative 的处理方式,那么这 28 位的立即数就需要作为有符号数,和分支指令的 PC 值进行相加,那么此时跳转的范围就变为了±128MB, 这个范围就不能够覆盖到 256MB 的任意一个地方了,如图 5.14 所示。
由图 5.14 可以看出,如果程序位于 0x6000_0000~0x6FFF_FFFF 这个对齐的 256MB 范围之内,那么使用 PC-region 的方式可以直接访问这个区域内的任意一个地方,而使用 PC-relative 的方式则不能够访问到整个对齐的 256MB 区域,因此,如果将一个程序的全部内容放到这样对齐的 256MB 范围之内,使用 J 和 JAL 指令就可以访问到程序内的任意地方了。
在 MIPS 中,如果 256MB 的跳转范围还是不能够满足要求,那么此时就需要使用 JALR 指令了,这条指令直接使用一个 32 位通用寄存器的值作为跳转的目标地址,这样就可以跳转到整个 4GB 的任意地方。之前讲述的所有分支指令 ( 包括 jump 指令 ), 其目标地址都是由指令中的立即数给出的,这样指令经过解码,其实就可以计算出目标地址,这也就是直接跳转类型 (PC-relative, 也称为 direct) 的分支指令;而 JALR 这条指令的目标地址是由通用寄存器给出的,需要读取寄存器才可以得到这个目标地址,称这种为间接跳转类型 (absolute, 也称为 indirect) 的分支指令。对于直接跳转类型的分支指令来说,由于它的目标地址已经固定在指令中了 (因为立即数在指令中是固定的 ),所以对这种类型的分支指令进行目标地址的预测就比较容易;而反观间接跳转类型的分支指令,由于通用寄存器的内容是经常变化的,所以这种分支指令的目标地址也是经常变化的,给目标地址的预测带来了一定的困难。

图 5.14 使用 PC_relative 的方式,并不能访问到对齐的 256MB 区域中的任意地方
在 MIPS 指令集中,所有带 “AL” 的指令 (包括 JAL、JALR、BLTZAL 和 BGEZAL) 都用来实现子程序调用的 CALL 指令的功能 (MIPS 中没有 CALL 指令,其实很多 RISC 处理器中也没有 ),这样的指令将当前分支指令的下一条指令的地址 (如果存在延迟槽,则保存下下条指令的地址 ) 保存到最后一个通用寄存器中 ( 即 R31),然后根据条件是否成立来决定是否跳转到目标地址,这和 CALL 指令的功能是一样的。这里需要注意的是,不管分支指令的条件是否成立,都会将它的 $PC+4$( 如果存在延迟槽,就是 $PC+8$) 的内容保存到 R31 中,这样,当需要对当前分支指令的地址做计算时,就可以使用 BTLZAL 或者 BGEZAL 指令,只需要将条件弄成不成立,那么就不会进行跳转,但是可以将当前分支指令的 $PC+4$( 或 $PC+8$) 放到 R31 中,此时就可以对这个 PC 值进行运算了。由于 MIPS 中的 PC 寄存器不属于通用寄存器的范围,所以无法直接通过指令读取 PC 寄存器的内容 ( 这一点和 ARM 是不一样的,在 ARM 中 PC 寄存器是一个通用寄存器 ),只能通过这种方式来获取 PC 的值。
在 MIPS 指令集中,所有带 “AL” 的指令,默认都是将返回地址 ( 即 $PC+4$, 如果存在延迟槽,就是 $PC+8$) 保存到 R31 中,但是 JALR 指令是个例外,这条指令可以自行指定存放返回地址的寄存器,但是实际使用时,很少放到其他寄存器中,编译器都是默认将返回地址放到 R31 中。这样做对于超标量处理器是有好处的,因为将返回地址都放到 R31 中,那么在子程序中一旦执行 “JR $31” 这条指令,就相当于执行了一条 Return 指令 (在 MIPS 中没有 CALL 指令和 Return 指令 ),这样就为分支预测电路中对 CALL/Return 指令的预测提供了可能,只要在取指令阶段预测到带 “AL” 的指令,就认为是 CALL 指令,此时将这条指令的下一条指令的地址 (如果存在延迟槽,则需要使用下下条指令的地址 ) 保存到返回地址堆栈中 (RAS);同理,在取指令阶段预测到这条指令是 “JR $31”, 就认为是 Return 指令,此时就从返回地址堆栈 (RAS) 中读出最新写入的值作为返回地址,这样就实现了对 CALL/Return 指令的分支预测。如果 JALR 指令的返回地址可以保存到任意的通用寄存器中,那么在子程序返回时,由于 JR 指令中存放返回地址的通用寄存器是不确定的,此时就无法知道哪条 JR 指令是 Return 指令,也就无法准确地对 CALL/Return 指令进行分支预测,因此,要对 MIPS 处理器中的 CALL/Return 指令进行准确的分支预测,就需要将任何带 “AL” 的指令的返回地址保存到默认的 R31 中,同时使用 “JR $31” 作为 Return 指令。在 JR 指令的其他用途中,例如作为一般的跳转指令,不要使用 R31 作为目标地址,否则会被分支预测器错误地认为是一条 Return 指令而进行分支预测,导致 CALL/Return 分支预测准确度的降低。
对于 MIPS 指令集来说,还需要注意的是,在子程序中,由于可能会改变 R31 寄存器的值,所以在进入子程序的时候就要将 R31 的内容进行手动的压栈保存,这样即使在子程序中改变了这个寄存器,只要在子程序结束的时候将 R31 的内容出栈就可以恢复它的值了,也就可以正常地从子程序中返回。这对于子程序的嵌套也是同理的,因为在子程序中如果再次调用子程序,那么新执行的调用子程序的 CALL 指令 (例如 JAL 指令 ) 就会将 R31 的内容写为新的返回地址,此时就会将原来子程序的返回地址覆盖了,因此进入子程序的时候,手动保存 R31 的内容是必需的。既然是一个必需的过程,其实在进入子程序的时候可以用硬件来自动完成这个任务的,但是考虑到向前兼容的问题,MIPS 并没有这样做,这就是向前兼容所带来的包袱。
在 MIPS 的指令集中,除了分支类型的指令,其他指令都不支持条件执行,它们是肯定会执行的,因此要实现if … else … 这样的功能,只能使用分支指令来实现,举例来说,有下面的一段 C 代码。
if (a == 0)
b = c + 2;
else
b = c - 2;
假设 $a$ 值放在寄存器 r1 中,$b$ 在寄存器 r2 中,$c$ 在寄存器 r3 中,寄存器 r0 中的值为 0,则用 MIPS 的指令集来实现上述 C 代码的话,如下所示。
bne r1, r0, EXC_SUB ; 判断 a(即 r1)的值是否不等于 0
add r2, r3, #2 ; 如果 a=0,执行 b=c+2
j END
EXC_SUB:
sub r2, r3, #2 ; 如果 a!=0,执行 b=c-2
END: ...
因为在 MIPS 中,非分支类型的指令都不是条件执行的,所以需要使用一条分支指令来产生一个条件,根据这个条件来决定要执行哪些指令,而在超标量处理器中,分支指令是需要进行分支预测的,这就需要承担分支预测失败时的惩罚 (penalty),造成处理器执行效率的降低。而 ARM 指令集则不同,在 ARM 指令集中,任何指令都可以条件执行,于是上面的C 代码如果使用 ARM 指令集来实现,则可以写成如下指令。
subs r1, r1, 0 ; 判断 a 和 0 是否相等
addeq r2, r3, 2 ; 如果相等执行 b=c+2
subne r2, r3, 2 ; 如果不相等,执行 b=c-2
在 ARM 指令集中,每次执行完一条指令,都可以选择是否将这条指令结果的状态写到状态寄存器 CPSR 中,在 CPSR 中记录了指令的结果是否为 0、正值还是负值、是否发生了溢出 (overflow) 等信息,后面的指令可以根据 CPSR 寄存器的状态来决定是否执行,使用这种类型的条件执行之后,只需要三条指令就可以实现上述 C 代码的功能,而且由于没有分支指令,也就不存在分支预测失败时引起性能下降的问题,从分支预测的角度来考虑,对于超标量处理器来说,ARM 指令集执行这种程序效率是更高的。
但是这种优势不是绝对的,当一个分支块 (branch block) 变得很大时,需要条件执行的指令的个数会变得很多,此时这种方法的优势就变成了劣势,而且还会给寄存器重命名的过程带来额外的麻烦,这部分内容将在后文进行详细的介绍。
不仅如此,每条指令想要条件执行还需要其他的代价,在每条指令中,都需要包括一个对条件进行编码的条件码 (condition code), 它用来对这条指令使用何种条件进行编码,在 ARM 指令集中这个条件码占据了指令的 Bit[31:28], 如图 5.15 所示。

图 5.15 在 ARM 指令集中,条件码的位置
这 4 位的条件码对应着 16 种不同的条件,关于这些条件的详细内容参见 ARM 的技术手册12。举例来说,上例中的 subs 指令将两个数进行相减,如果结果为 0,CPSR 寄存器中的Z 标志位就会被置 1,后续的 addeq 指令中的条件码部分编码为 0000, 表示只有此时 CPSR 寄存器中的 Z==0 时,这条指令才可以执行;同理,subne 指令中的条件码部分编码为 0001, 表示只有 CPSR 寄存器中的 Z!=0 时,这条指令才执行,这样通过条件码部分,就可以对每条指令设置执行的条件了。
但是,不管这条指令是否需要条件执行,每条指令都会有 4 位的条件码,所以指令中可以用来对通用寄存器进行编码的资源就少了,因此 ARM 中通用寄存器只有 16 个,使用 4 位来编码,而 MIPS 指令集中没有这个条件码部分,可以使用 5 位数对通用寄存器进行编码,因此 MIPS 中通用寄存器有 32 个。通用寄存器个数的增多,可以减少程序访问存储器的次数,降低了 Cache 和 TLB 的缺失率,也就增加了处理器的性能,因此并不是说每条指令都采用条件执行就绝对是有优势的。而且,在程序执行的过程中,大部分指令其实都是绝对会执行的,也就是说,大部分指令中的条件码都是 1110( 表示总是执行 ), 只有少部分需要条件执行的指令才会有其他的编码,这样综合看起来,其实对于大部分指令来说,条件码这个部分是没有起作用的,这样其实并不符合 RISC 的风格。在 RISC 中,只有最常使用的情况才会用硬件实现,所以对于纯粹的 RISC 处理器,如 MIPS, 是不会对这少部分的情况而专门使用硬件的,从这个观点来说,MIPS 是一个纯粹的 RISC,而 ARM 则不是。
相比 MIPS 指令集,ARM 指令集是比较复杂的,相应的指令编码也比较凌乱,不太适合作为教科书的材料进行讲述,而 MIPS 的指令集则是非常简洁高效的,其指令集的编码也是非常有规律,因此作为这个世界上最 “干净”的指令集,它受到了世界上绝大多数教科书的青睐,大部分教科书都是基于 MIPS 指令集来讲述处理器和计算机原理,本书亦是如此。
5.6 杂项指令
在 MIPS 中,除了上述的指令之外,还有一些其他的指令,例如访问协处理器的指令、产生软件中断的指令,以及调试相关的指令等,这些指令在实际中也有重要的用处,它们的内容如表 5.10 所示。
表 5.10 MIPS 指令集中的杂项指令
| 类型 | Instruction | Description | Function |
|---|---|---|---|
| Move 指令 | MOVZ | Move Conditional on Zero | If GPR[Rt]==0 thenGPR[Rd] = GPR[Rs] |
| Move 指令 | MOVN | Move Conditional on Not Zero | If GPR[Rt]!=0 thenGPR[Rd] = GPR[Rs] |
| Move 指令 | MFHI | Move From Hi | GPR[Rd] = Hi |
| Move 指令 | MFLO | Move From Lo | GPR[Rd] = Lo |
| Move 指令 | MTHI | Move To HI | Hi = GPR[Rs] |
| Move 指令 | MTLO | Move To Lo | Lo = GPR[Rs] |
| Move 指令 | MFC0 | Move From Coprocessor0 | GPR[Rt] = CPR[0,Rd,sel] |
| Move 指令 | MTC0 | Move To Coprocessor0 | CPR[0,Rd,sel] = GPR[Rt] |
| Trap | TEQ | Trap if Equal | If (int)GPR[Rs] == (int)GPR[Rt]TrapException |
| Trap | TEQI | Trap if Equal Immediate | If (int)GPR[Rs] == sign_extend(imm)TrapException |
| Trap | TNE | Trap if Not Equal | If (int)GPR[Rs] != (int)GPR[Rt]TrapException |
| Trap | TNEI | Trap if Not Equal Immediate | If (int)GPR[Rs] != sign_extend(imm)TrapException |
| Trap | TGE | Trap if Greater Than or Equal | If (int)GPR[Rs] >= (int)GPR[Rt]TrapException |
| Trap | TGEI | Trap if Greater Than or Equal Immediate | If (int)GPR[Rs] >= sign_extend(imm)TrapException |
| Trap | TGEU | Trap if Greater Than or Equal Unsigned | If (uns)GPR[Rs] >= (uns)GPR[Rt]TrapException |
| Trap | TGEIU | Trap if Greater Than or Equal Immediate Unsigned | If (uns)GPR[Rs] >= zero_extend(imm)TrapException |
| Trap | TLT | Trap is Less Than | If (int)GPR[Rs] < (int)GPR[Rt]TrapException |
| Trap | TLTI | Trap is Less Than Immediate | If (int)GPR[Rs] < sign_extend(imm)TrapException |
| Trap | TLTU | Trap is Less Than Unsigned | If (uns)GPR[Rs] < (uns)GPR[Rt]TrapException |
| Trap | TLTIU | Trap is Less Than Immediate Unsigned | If (uns)GPR[Rs] < zero_extend(imm)TrapException |
| System Call and Breakpoint | SYSCALL | System Call | SystemCall Exception |
| System Call and Breakpoint | ERET | Return From Exception |
在 MIPS 处理器中,用来控制处理器执行情况的所有控制寄存器都放在第一个协处理器中,这个协处理器的编号是 0, 也称为协处理器 0(Coprocessor0),但是处理器无法直接通过指令来操纵协处理器 0 中的寄存器,因为在指令中已经没有空间对其进行编码了,如果想要对协处理器 0 中的寄存器进行修改,就需要先将其读取到通用寄存器中,然后才能够对其进行处理,处理完之后再写回到协处理器 0 中。这个过程其实和访问存储器是类似的,处理器并不能够直接对存储器中的数据进行运算,只能够将存储器中的数据先读到通用寄存器中,然后才能够进行处理,最后将处理完的数据再写回到存储器中就可以了。这个过程是 RISC 处理器的一个特征,不管是 ARM 还是 MIPS 都采用了这种方法,如果一个处理器能够直接将存储器中的数据作为操作数,那么这个处理器肯定就不能称为 RISC 处理器了。
在 MIPS 指令集中,使用 MFC0 指令来将协处理器 0 中的某个寄存器的内容读取到通用寄存器中,使用 MTC0 指令将某个通用寄存器的内容写到协处理器 0 中指定的寄存器中,正因为访问协处理器和访问存储器的过程是类似的,所以在处理器内部,它们的处理也是类似的。而且,由于向协处理器 0 中写数据的这个过程是不可逆的 (类似于 store 指令 ),所以在 MTC0 这条指令在超标量流水线中退休 (retire) 之前,它的内容会被暂存到一个缓存中,只有这条指令退休而离开了流水线,才允许它真正地将数据写到协处理器 0 中。这里需要注意一个问题,由于协处理器 0 中的寄存器控制着处理器执行的状态,当 MTC0 指令改变协处理器 0 中的某个寄存器的内容时,后续的指令应该在新的处理器状态下执行 (例如将处理器从内核模式改为用户模式 ), 但是对于超标量处理器来说,指令是乱序 (out-of-order) 执行的,也就是说,MTC0 指令后面的指令可能会先于它而执行了,这些指令此时的执行结果就是不正确的,需要等到 MTC0 指令执行完毕,改变处理器的状态之后,它后面的指令才允许执行,这就需要在 MTC0 指令后面使用隔离 (barrier) 指令,一条隔离指令可以保证它之前的指令都执行完毕,并且改变了处理器的状态之后,才允许后面的指令执行。在超标量处理器中,隔离指令到达流水线的最后阶段,并且退休 (retire) 的时候 ( 此时隔离指令之前的所有指令都已经顺利地离开了流水线 ),会引发处理器将流水线中所有的指令都抹掉,然后重新从隔离指令后面的地址开始取指令。通过在 MTC0 指令后面使用隔离指令,就能够保证 MTC0 指令后面的所有指令都使用新的处理器状态,在 MIPS 指令集中,隔离指令是 SYNC, 在 ARM 指令集的隔离指令是 DMB、DSB 和 ISB。
MIPS 中的 SYSCALL 指令用来产生异常 (exception), 当这条指令被执行的时候,会马上无条件地产生一个异常,处理器会跳转到对应的异常处理器程序中 (exception handler),这条指令的编码如图 5.16 所示。

图 5.16 MIPS 指令集中,SYSCALL 指令的编码
在 SYSCALL 指令的编码中,包括了 20 位的 code 部分,这部分可以被软件用来传递参数,在异常处理程序中,需要使用 load 指令将这条 SYSCALL 指令从程序存储器中读出来,才可以知道 code 部分的内容。在超标量方式的 MIPS 处理器中,SYSCALL 指令只有退休 (retire),顺利地离开流水线的时候,才会产生异常,这和普通异常的产生方式是一样的。当处理器运行操作系统的时候,例如 Linux, 普通的用户程序只能够访问规定的用户态空间,它不能访问操作系统所使用的内核空间,也不能够直接访问底层的很多硬件,例如协处理 0。当用户程序需要在特权模式做一些事情时,例如加载某些页 (page) 到物理内存 (physical memory) 中,那么就需要使用 SYSCALL 这条指令,当这条指令执行时,处理器就会由用户模式转为内核模式,其实,SYSCALL 指令对应的异常处理程序就是属于操作系统的一部分,此时可以访问任何资源,当执行完规定的任务,需要返回到用户程序的时候,就在异常处理器程序中使用 ERET 指令,这条指令使程序返回到 SYSCALL 指令之后的那条指令,同时还会使处理器退出内核模式,转到用户模式中。
ERET 指令还有一个功能,它会保证在异常处理程序中的指令都执行完成后 (在超标量处理器中,也就是保证这些指令都退休而离开了流水线 ), 才会执行返回操作,这相当于实现了隔离指令的功能,即 “ERET 指令 = 隔离指令 + Return 指令”。
MIPS 中的 Trap 类型指令其实也是用指令来产生异常,只是它附带了条件,只有条件满足时才会产生异常,例如 TEQ 指令,只有当指令中指定的两个寄存器 rs 和 rt 相等的时候,这条指令才会产生一个异常。也就是说,SYSCALL 指令可以无条件地产生异常,而 trap 指令是有条件地产生异常 (这等同于分支指令和跳转指令的关系 ),它们只有这一点是不同的,其他的处理过程都是一样的。
5.7 异常
处理器在执行的过程中,除了分支类型的指令之外,很多其他的情况也可以打断程序的执行,这些情况统称为异常 (exception),不同的指令集体系中,异常包括的内容也不尽相同,可以概括地将异常分为下面几种。
(1) 处理器的外部事件引起的异常,这种类型的异常其实更多时候被称做中断 (interrupt),因为发生在处理器的外部,中断本质上和处理器中执行的指令没有必然的关系,处理器在执行的任何阶段都有可能接收到中断,因此也称做是异步的异常,在本书的后文中,对这种类型的异常统称为中断。
(2) 虚拟地址 (virtual address) 到物理地址 (physical address) 的转换引起的异常,例如当这个转换关系不存在于 TLB 中时,就会产生 TLB 缺失的异常,而这个转换关系如果在页表中也不存在,那么就发生了 Page Fault 的异常,又或者某个程序访问了一个受保护的页,那么也会产生一个访问权限错误的异常,当然,如果处理器没有实现虚拟存储器,那么这些异常也就不存在了。
(3) 指令自身引起的错误,例如未定义的指令,用户态下的非法指令、整数运算时的溢出,访问存储器的地址未对齐等。很多处理器还支持数据的完整性检查,例如处理器对 L2 Cache 送来的数据进行奇偶校验或 ECC 校验,如果发现校验失败,也会产生异常。
(4) 指令自身产生的异常,例如 MIPS 指令集中的 SYSCALL 和 Trap 两种指令,执行这些指令就会产生异常,其中 SYSCALL 指令是无条件产生异常,而 Trap 指令则是有条件地产生异常,普通的程序可以通过这种类型的指令来调用操作系统的某些任务,一个处理器要支持操作系统,这种类型的指令是必须有的。
对于一个特定的处理器来说,所有类型异常的处理过程是一样的,从处理器外部看起来,产生异常的指令之前的所有指令都已经完成了,而这条产生异常的指令及其之后的所有指令都不允许完成。处理器会跳转到对应的异常处理程序 (exception handler) 的入口地址,开始执行这个异常处理程序,当其执行完成后,会返回到刚才发生异常的地方,重新开始将这条指令取到流水线中,就好像这个异常没有发生过一样,这种方式也称做精确的异常 (precise exception), 因为总能够找到哪条指令发生了异常,这个过程如图 5.17 所示。

图 5.17 在程序中对异常的处理过程
对于大部分类型的异常来说,从其对应的异常处理程序返回的时候,都需要重新执行这条产生异常的指令,举例来说,对于一条 load 指令,如果在执行的时候发生了 Page Fault 类型的异常,那么在对应的异常处理程序中,会从硬盘 (或者闪存 ) 中找到这个缺失的页,并将其放到物理内存中,然后从异常处理程序返回的时候,会再次执行这条 load 指令,就不会发生 Page Fault 了。对于大部分发生异常的指令来说,都需要重新被执行一遍,但是这不是绝对的,有些类型的异常不能这样做,典型的例子就是 SYSCALL/Trap 指令产生的异常,当从这种异常处理程序返回的时候,如果再次执行产生异常的 SYSCALL/Trap 指令,那么就会再次产生异常,这就相当于有了一个死循环,因此异常的返回地址是要根据不同类型的异常进行区别对待的。
在异常发生时,为了能够顺利地从异常处理程序中返回,还需要在发生异常时,将返回地址保存起来,这个返回地址就是当前发生异常指令的 PC 值 ( 或者下一条指令的 PC 值,根据异常的类型来决定 ), 至于将这个返回地址保存到什么地方,不同的指令集有不同的实现方法。对于 RISC 处理器来说,一般都是将它保存到一个专用的寄存器中,例如在 MIPS 中使用 EPC 寄存器来保存异常发生时的 PC 值;而对于 CISC 指令集来说,通常都是使用堆栈来保存这个 PC 值,堆栈实际上就是位于存储器中的一段空间,因为 CISC 指令集诞生的那个年代,寄存器很昂贵,所以更多地使用了存储器来完成一些任务,例如在 x86 指令集中就将异常发生时的 PC 值保存到堆栈当中。
异常发生时,还需要考虑通用寄存器的处理,因为在异常处理程序中可能会更改通用寄存器的内容,所以在异常处理程序开始的时候,需要对涉及到的寄存器进行保存,不管是 RISC 处理器还是 CISC 处理器,都将通用寄存器的内容保存到堆栈中 (也就是保存到存储器中 )。不过对于 RISC 处理器来说,要访问存储器,只能使用 load/store 指令,所以将寄存器保存到堆栈的这个过程,只能使用 store 指令,堆栈的指针使用一个通用寄存器来模拟,例如 MIPS 指令集中使用了 R29 作为堆栈指针,如果要将通用寄存器 R20 的内容保存到堆栈中,就要使用 “SW R20, 0[R29]” 这样的指令,R29 作为堆栈指针,只在异常处理程序中被使用,其他的程序不允许使用,但是,这只是一种约定而已,实际上在 MIPS 处理器中,R29 就是一个普通的通用寄存器而已,和其他的通用寄存器没有任何区别,需要使用软件来管理堆栈指针的增减。
而在 CISC 指令集中,则设置了专门操作堆栈的 PUSH/POP 指令,并使用专用的寄存器作为堆栈指针,使用 PUSH/POP 指令,不需要软件对堆栈指针进行管理,硬件会自动将其增加或减少,x86 处理器就采用了这种方式。其实在 ARM 指令集中,也采用了 PUSH/POP 指令和专用的堆栈指针寄存器,它的处理方式和 x86 处理器是类似的,因此从这个角度来看,ARM 指令集更偏向于 CISC 的风格。
在一个流水线的处理器中,流水线的各个阶段都有可能发生异常,如图 5.18 所示为一个五级流水线中各种异常的示意图。

图 5.18 在经典的五级流水线中,各个阶段可能发生的异常
图 5.18 中,在流水线的各个阶段,可能产生的异常有如下几种。
(1) 取指令 (Fetch) 阶段:取指令时发生 I-TLB 缺失甚至 Page Fault, 或者取指令的地址不存在,又或者到受保护的区域取指令;
(2) 解码 (Decode) 阶段:遇到未定义的指令;
(3) 执行 (Execute) 阶段:算术运算发生溢出,或者除 0 运算;
(4) 访问存储器 (Memory) 阶段:访问数据存储器时发生 D-TLB 缺失甚至 Page Fault,或者访问数据存储器的地址是不存在的,又或者非对齐访问等。
如果在流水线中,这些异常同时发生了,处理器应该如何处理?对于异常的处理要遵循程序中的原始顺序,为了满足这个条件,可以使处理器在流水线的最后一个阶段才对异常进行统一的处理,在流水线的其他阶段产生的异常,都需要随着指令在流水线中流动,直到流水线的最后阶段才进行处理,这样就能够保证对异常的处理按照程序中指定的顺序进行,这个过程如图 5.19 所示。

图 5.19 在流水线中,对异常的处理统一放到 Memory 阶段
在图 5.19 所示的流水线中,在流水线的访问存储器阶段 (Memory) 之后,就不会再产生异常了,因此可以在访问存储器的这个阶段对异常进行统一处理,称这个阶段是异常的处理点 (Commit Point)。根据精确异常的定义,发生异常的指令以及之后的所有指令都不能够完成,因此在访问存储器的阶段对异常进行处理的时候,需要将这条异常指令之后进入到流水线的所有指令都抹掉,同时这条产生异常的指令也不能够完成,因此这条指令也不允许进入到下一个写回 (Write Back) 的阶段。为了将产生异常的指令的 PC 值保存起来,每条指令的 PC 值都会随着流水线流动,这样在访问存储器的阶段,处理异常的时候,就可以将这条指令对应的 PC 值保存到 EPC(Exception PC) 寄存器中,EPC 寄存器是 MIPS 处理器中专门用来保存发生异常指令的 PC 值的寄存器,而异常的类型会被记录在另外一个专门的寄存器中,这个寄存器称为 Cause 寄存器,供异常处理程序查询使用。
由图 5.19 可以知道,对异常的处理需要抹掉流水线中的部分指令,这样就会造成处理器执行效率的降低,这和分支预测失败的处理是一样的,如图 5.20 所示。
在图 5.20 中,当 ADD 指令在执行阶段 (EX) 发现了溢出的异常时,这个异常会被记录下来,并随着流水线到达异常的处理阶段 (Commit Point, 即访问存储器的 Memory 阶段 ),然后将流水线中的相关指令进行抹掉,这会导致 ADD 指令后面的三条指令都从流水线中被抹掉,流水线会重新从异常处理程序中开始取指令来执行。上述的过程和分支预测失败时,对流水线的处理过程是类似的,因此分支预测失败和异常这两种情况,都会由于抹掉流水线中的部分指令而降低处理器的性能,对分支预测来说,可以通过更精确的分支预测算法而减少它出现的频率,而对于异常则没有很好的预测方法,好在异常发生的频率很低,因此对处理器性能的影响也是很小的。
在超标量处理器中,由于乱序执行 (out-of-order) 的特性,对异常的处理会复杂一些,详细的处理方法在后文中会进行介绍。

图 5.20 在流水线中,对异常的处理需要清空流水线
本站所有文章转发 CSDN 将按侵权追究法律责任,其它情况随意。