4.4 指令集
计算机在寻宝过程中在内存中找到的“纸条”叫作指令。本节将介绍这些指令所包含的内容。
4.4.1 指令
为了了解在CPU中可以找到什么样的指令,以及如何为它们选择位型,我们假设计算机中的指令为16位宽。
我们把指令分成四个字段:操作码以及两个操作数的地址和结果地址,如图4-13所示。
图4-13 三地址指令分布
将指令分成四段看起来是个好主意,但效果不太好。因为我们只有4个位的地址空间可以存放每个操作数或结果。当只有16个地址位时,要寻址较大的内存有点困难。当然也可以使指令更大,但即使使用64位宽的指令,我们也只有20位的地址,而20位的地址只能达到1兆字节的内存。现代计算机有千兆字节的内存。
另一种方法是借鉴图3-23中的DRAM寻址技巧。可以增加一个地址扩展寄存器,并用一条单独的指令加载高阶地址位。英特尔使用这项技术使其32位计算机可以访问超过4 GiB的内存。英特尔称这项技术为物理地址扩展(Physical Address Extension, PAE)。当然,加载这个寄存器需要额外的时间。如果我们使用这种方法创建的边界两边的内存,则需要大量的寄存器加载。
不过,三地址格式不能正常工作还有一个更重要的原因:三地址格式依赖于某种神奇的、不存在的内存形式才能让三个不同的位置同时被寻址。图4-14中的三个内存块都是相同的存储设备,三地址格式不代表它包含三个地址总线和三个数据总线。
图4-14 行不通的计算机架构
要想使它工作,可以让一个寄存器保存操作数A的内容,让另一个寄存器保存操作数B的内容。硬件需要执行以下操作:
1. 使用程序计数器中的地址从内存中加载指令。
2. 使用指令中操作数A部分的地址将操作数A加载到寄存器中。
3. 使用指令中操作数B部分的地址将操作数B加载到寄存器中。
4. 使用指令结果部分的地址将结果存储在内存中。
寄存器是很复杂的硬件,如果每一个步骤都需要一个时钟周期,那么就需要4个周期来完成这项工作。我们应该从一次只能访问一个内存位置这一事实出发,并据此设计指令集。如果一次只处理一件事,就会有更多的地址位可用。
我们可以通过在寄存器街道再建造一个寄存器来做到上面的那一点。我们将这个寄存器称为累加器(Accumulator),或者简称A寄存器,它将保存来自ALU的结果。我们将在一个内存位置和累加器之间执行运算,而不是在两个内存位置之间执行运算。当然,我们必须添加一个存储指令,将累加器的内容存储在某个内存位置,因此其指令分布如图4-15所示。
图4-15 单地址指令分布
以上的思路让我们得到了更多的地址位,但完成这件事情需要更多的指令。之前只需一条指令:
C=A+B
但现在需要三条指令:
累加器=A
累加器=累加器+B
C=累加器
你可能会注意到,一条指令变成了上面的三条指令,有效地使指令变大,且自相矛盾。对于这个简单的例子来说,的确是这样的,但一般情况下并不会这样。假设我们需要计算:
D=A+B+C
只使用一条指令无法计算出结果,即使这条指令可以访问三个地址。现在我们需要四个地址,我们必须这样做:
中间体=A+B
D=中间体+C
如果坚持使用12位地址,则需要40位指令来处理三个地址和操作码。我们需要两条这样的指令,总共80位来计算D。使用单地址版本的指令需要4条指令,总共64位。
累加器=A
累加器=累加器+B
累加器=累加器+C
D=累加器
4.4.2 寻址方式
使用累加器可以得到12个地址位,虽然能够寻址4 096个字节比只能寻址16个字节好得多,但4 096个字节仍然不够。这种寻址内存的方式称为直接寻址,意味着地址就是指令中给定的地址。
我们可以使用间接寻址来寻址更多的内存。使用间接寻址可以从指令中包含的内存位置获取地址,而不是直接从指令本身获取地址。例如,假设内存位置12包含值4321,而内存位置4321包含值345。如果使用直接寻址,会从内存位置12加载,得到4321,而间接寻址将得到345,即位置4321包含的内容。
这两种寻址方法对于处理内存来说都很好,但有时候我们只需要得到常量。例如,如果要数到10,我们就需要使用某种方法来加载这个数字。我们可以用另一种寻址方式来实现,称为立即数寻址。在立即数寻址中,地址只是作为一个数字被处理。因此,前面的示例若以立即数寻址方式加载位置12将得到12。这些寻址方式对比如图4-16所示。
图4-16 寻址方式
显然,直接寻址比立即数寻址慢,因为它需要多次内存访问。间接寻址速度更慢,因为它需要再多一次内存访问。
4.4.3 条件码指令
我们的CPU还缺少一些东西,比如处理条件码的指令。前面提到这些条件码是通过加法、减法和比较形成的。但是我们需要一些方法将它们设置为已知的值并查看这些值。要实现这点,可以添加一个cca
指令将条件码寄存器的内容复制到累加器,再添加一个acc
指令将累加器的内容复制到条件码寄存器。
4.4.4 分支
现在我们有了可以做各种事情的指令,但我们只能从头到尾执行一系列的指令,这没多大用处。我们希望的是程序可以作出决定并选择部分代码执行。程序可以接收某些指令让我们改变程序计数器的值。这些指令称为分支指令,它们使程序计数器加载一个新地址。就其本身而言,分支指令并不比仅执行一系列指令更有用。但是分支指令也并不总分支,它们会查看条件码,并且只在满足条件时才分支。不满足条件时,程序计数器正常递增,然后执行分支指令之后的指令。分支指令需要一些位来保存条件,如表4-2所示。
表4-2 分支指令的条件
有时我们需要明确地更改程序计数器的内容。有两个特殊的指令可以实现这一点:pca
以及apc
。pca
可以将当前的程序计数器值复制到累加器,apc
可以将累加器的内容复制到程序计数器。
4.4.5 最终指令集
我们将所有这些特性集成到指令集中,如图4-17所示。
图4-17 最终指令分布
我们有三种寻址方式,这意味着需要2个位来实现寻址方式的选择。未使用的第4个组合用于不涉及内存的操作。
寻址方式和操作码解码成指令,如表4-3所示。注意,分支条件合并到了操作码中。寻址方式3的操作码用于只涉及累加器的操作。完整实现的一个副作用是操作码与表4-1中的ALU不完全匹配。这并不罕见,需要一些额外的逻辑。
表4-3 寻址方式和操作码
“左移”和“右移”指令使用一些未使用的位作为要移位的位置数的计数,如图4-18所示。
图4-18 移位指令分布
现在我们可以通过编写一个程序来指导计算机做一些事情,这个程序就是执行某项任务的指令的列表。我们来计算从0到200的斐波那契(意大利数学家,1175—1250)数。斐波那契数相当有意思,一朵花上花瓣的数目就是斐波那契数。前两个斐波那契数是0和1,把它们加起来就得到了下一个斐波那契数。不断地在前一个斐波那契数的基础上加上一个新的斐波那契数,即可得到斐波那契数序列,即0,1,1,2,3,5,8,13,…。该过程如图4-19所示。
图4-19 计算斐波那契数序列的程序流程图
表4-4的短程序可实现这个过程。指令列按图4-17的分布方式划分为字段。描述中涉及的地址是十进制数字。
表4-4 计算斐波那契数序列的机器语言程序