图形渲染ISA指令集分析
图形渲染ISA指令集分析
1 ISA定义
就像任何语言都有有限的单词一样,处理器可以支持的基本指令/基本命令的数量也必须是有限的,这组指令通常称为指令集(instruction set),基本指令的一些示例是加法、减法、乘法、逻辑或和逻辑非。请注意,每条指令需要处理一组变量和常量,最后将结果保存在变量中,这些变量不是程序员定义的变量,是计算机内的内部位置。我们将指令集架构定义为:
指令集架构(instruction set architecture,ISA)是处理器支持的所有指令的语义,包括指令本身及其操作数的语义,以及与外围设备的接口。
指令集架构是软件感知硬件的方式,我们可以将其视为硬件输出到外部世界的基本功能列表。Intel和AMD CPU使用x86指令集,IBM处理器使用PowerPC R指令集,HP处理器使用PA-RISC指令集,ARM处理器使用ARMR指令集(或其变体,如Thumb-1和Thumb-2)。因此,不可能在基于ARM的系统上运行为Intel系统编译的二进制文件,因为指令集不兼容,但在大多数情况下,可以重用C/C++程序。要在特定架构上运行C/C++程序,我们需要为该特定架构购买一个编译器,然后适当地编译C/C++程序。
2 基础指令
基本计算机具有16位指令寄存器 (IR),可以表示内存引用或寄存器引用或输入输出指令。一种简单的指令格式可以是如下形式:
基础指令可分为以下几类:
- 内存引用
这些指令将内存地址称为操作数,另一个操作数总是累加器。下图为直接和间接寻址指定12位地址、3位操作码(111除外)和1位寻址模式。
示例:IR寄存器内容是0001XXXXXXXXXXXX,即ADD指令取指译码后发现是ADD操作的内存引用指令,因此:
DR ← M[AR]
AC ← AC + DR, SC ← 0
- 寄存器引用
这些指令对寄存器而不是内存地址执行操作。下图的IR(14 – 12) 为 111(将其与内存引用区分开),IR(15) 为 0(将其与输入/输出指令区分开),其余12位指定寄存器操作。
示例:IR寄存器内容是0111001000000000,即CMA在取指和解码周期后发现它是补码累加器的寄存器引用指令,因此:
AC ← ~AC
- 输入/输出
这些指令用于计算机和外部环境之间的通信。下图的IR(14 – 12) 为 111(将其与内存引用区分开来),IR(15) 为 1(将其与寄存器引用指令区分开),其余 12 位指定 I/O 操作。
示例:IR寄存器内容是1111100000000000,即INP经过取指和解码循环后发现它是用于输入字符的输入/输出指令。因此,来自外围设备的INPUT字符。
包含在16位IR寄存器中的指令集是:
- 算术、逻辑和移位指令(与、加、补、左循环、右循环等)。
- 将信息移入和移出内存(存储累加器,加载累加器)。
- 带有状态条件的程序控制指令(分支、跳过)。
- 输入输出指令(输入字符、输出字符)。
指令具体的描述如下表:
符号 |
16进制码 |
描述 |
AND |
0xxx、8xxx |
与任意字到AC |
ADD |
1xxx、9xxx |
累加任意字到AC |
LDA |
2xxx、Axxx |
加载内存字到AC |
STA |
3xxx、Bxxx |
存储AC字到内存 |
BUN |
4xxx、Cxxx |
无条件分支 |
BSA |
5xxx、Dxxx |
分支并保存返回地址 |
ISZ |
6xxx、Exxx |
如果为0,则递增并跳过 |
CLA |
7800 |
清理AC |
CLE |
7400 |
清除E(溢出位) |
CMA |
7200 |
补充AC |
CME |
7100 |
补充E |
CIR |
7080 |
右循环AC和E |
CIL |
7040 |
左循环AC和E |
INC |
7020 |
递增AC |
SPA |
7010 |
如果AC>0,跳过下一条指令 |
SNA |
7008 |
如果AC<0,跳过下一条指令 |
SZA |
7004 |
如果AC=0,跳过下一条指令 |
SZE |
7002 |
如果E=0,跳过下一条指令 |
HLT |
7001 |
停止计算机 |
INP |
F800 |
输入字符到AC |
OUT |
F400 |
输出字符到AC |
SKI |
F200 |
跳过输入标志 |
SKO |
F100 |
跳过输出标志 |
ION |
F080 |
中断开启 |
IOF |
F040 |
中断关闭 |
3 指令集设计准则
现在让我们开始为处理器设计指令集的艰难过程,可以将指令集视为软件和硬件之间的法律合同,双方都需要履行各自的合同。软件部分需要确保用户编写的所有程序都能成功有效地转译成基本指令,同样,硬件需要确保指令集中的所有指令都是有效实现的。双方都需要做出合理的假设,ISA需要具有一些必要的特性和一些有效性所需的特性。
- 完整。ISA应能够实现所有用户程序,是绝对必要的要求,我们希望ISA能够代表用户为其编写的所有程序。例如,如果我们有一个ISA,只有一条ADD指令,那么我们将无法减去两个数字。为了实现循环,ISA应该有一些方法来一遍遍地重新执行同一段代码。如果没有这种对和while循环的支持,C程序中的循环将无法工作。
请注意,对于通用处理器,我们正在查看所有可能的程序。然而,许多用于嵌入式设备的处理器功能有限,例如执行字符串处理的简单处理器不需要支持模拟点数(带小数点的数字)。我们需要注意的是,不同的处理器被设计用于做不同的事情,因此它们的ISA可能不同。然而,底线是任何ISA都应该是完整的,因为它应该能够用机器代码表达用户打算为其编写的所有程序。
- 简明。指令集的有限大小,最好不要有太多的指示。实现一条指令需要相当多的硬件,执行大量指令将不必要地增加处理器中晶体管的数量并增加其复杂性。因此,大多数指令集都有64到1000条指令。例如,MIPS指令集包含64条指令,而截至2012年,Intel x86指令集大约有1000条指令。请注意,对于ISA中的指令数量,1000条被认为是相当大的数字。
- 通用。指令应捕获通用案例,程序中的大多数常见指令都是简单的算术指令,如加法、减法、乘法、除法。最常见的逻辑指令是逻辑和、或、异或、和非。因此,为这些常见操作中的每一个指定一条指令是有意义的。
很少使用的计算的指令不是一个好主意。例如,实现计算sin−1(x)的指令可能没有意义,可以提供使用现有的数学技术(如泰勒级数展开)实现的专用库函数来计算sin−1(x)。由于大多数程序很少使用此函数,因此如果此函数执行时间相对较长,它们不会受到不利影响。
- 简单。指令应该尽量简单。假设有很多添加数字序列的程序,为了设计专门针对此类程序定制的处理器,我们有几个关于add指令的选项。我们可以实现一条将两个数字相加的指令,也可以实现一个可以获取操作数列表并生成列表和的指令。这里的复杂性显然存在差异,不能说哪种实现更快。前一种方法要求编译器生成更多指令,但是,每个添加操作都执行得很快。后一种方法生成的指令数量更少,但是,每条指令执行的时间更长。前一种类型的ISA称为精简指令集(Reduced Instruction Set),后一种ISA称为复杂指令集(Complex Instruction Set)。
精简指令集计算机(reduced instruction set computer,RISC)实现具有简单规则结构的简单指令,指令的数量通常很小(64到128)。示例:ARM、IBM PowerPC、HP PA-RISC。
复杂指令集计算机(complex instruction set computer,CISC)实现高度不规则的复杂指令,采用多个操作数,并实现复杂功能。其次,指令的数量很大(通常为500+)。示例:Intel x86、VAX。
直到90年代末,RISC与CISC的争论一直是一个非常有争议的问题。然而,从那时起,设计师、程序员和处理器供应商一直倾向于RISC设计风格,共识似乎是采用少量相对简单的、具有规则结构和格式的指令。值得注意的是,这一点仍有争议,因为CISC指令有时更适合某些类型的应用。现代处理器通常使用混合方法,其中既有简单的指令,也有一些复杂的指令。然而,在底层,CISC指令被转译成RISC指令。因此,我们认为行业稍微偏向RISC指令,认为有简单的指示是一种可取的特性。
ISA需要完整、简洁、通用和简单,且必须完整,而其余属性是可取的(但附有争议)。
4 图灵机和指令完整性
如何验证ISA的完整性?这是一个非常有趣、困难且理论上深刻的问题。确定给定ISA对于给定程序集是否完整的问题是一个相当困难的问题,一般情况要有趣得多。我们需要回答这个问题:给定ISA,它能代表所有可能的程序吗?
假设有一个ISA,其中包含基本的加法和乘法指令,我们能用这个ISA运行所有可能的程序吗?答案是否定的,因为我们不能用现有的基本指令减去两个数字。如果我们将减法指令添加到指令库中,我们可以计算一个数的平方根吗?即使我们可以,是否可以保证我们可以进行所有类型的计算?要回答这些令人烦恼的问题,我们需要首先设计一台通用机器。
通用机器(universal machine)是可以执行任何程序的机器。
它是一台可以执行所有程序的机器,可以把这台机器的每一个基本动作都当作一条指令。通用机器的一组动作就是它的ISA,而这个ISA是完整的。当说ISA是完整的时,相当于说可以专门基于给定的ISA构建通用机器,可以通过解决通用机器的设计问题来解决ISA的完整性问题。它们是双重问题,就通用机器而言,推理更容易。
20世纪初,计算机科学家开始思考通用机器的设计,他们想知道什么是可计算的,什么不是,以及不同类别机器的能力。其次,能够计算所有可能程序结果的理论机器的形式是什么?计算机科学的这些基本结果构成了当今现代计算机体系结构的基础。
阿兰·图灵(Alan Turing)是第一个提出一种极其简单和强大的通用机器的人,这台机器恰如其分地以他的名字命名,被称为图灵机器(Turing machine)。这只是一个理论实体,通常用作数学推理工具,可以创建图灵机的硬件实现,然而极为困难,并且需要不成比例的资源。尽管如此,图灵机构成了当今计算机的基础,而现代ISA是从图灵机的基本动作中派生出来的。因此,非常有必要研究它的设计。
下图显示了图灵机的一般结构,它包含一个内部磁带,磁带是一个单元阵列,每个单元格可以包含有限字母表中的符号,有一个特殊符号$用作特殊标记,一个专用的磁带头指向磁带中的一个单元。在一组状态中,有一小块存储器可以保存当前状态,该存储元件称为状态寄存器。
图灵机的操作非常简单。在每一步中,磁带头从状态寄存器中读取当前单元中的符号及其当前状态,并查找一个表,该表包含每个符号和状态组合的操作集,这个专用表称为转换函数表或动作表。这个表中的每个条目都说明了三件事——是否将磁带头向左或向右移动一步、下一个状态及应写入当前单元格的符号。因此,在每一步中,磁带头都可以覆盖单元格的值,改变状态寄存器中的状态,并移动到新单元格。唯一的限制是新单元格必须位于当前单元格的最左边或最右边。形式上,它的格式为:
(state, symbol)→({L,R}, new_state, new_symbol)
其中L代表左边,R代表右边。
示例:设计一个图灵机来判断字符串的形式是否为aaa...abb...bb。答案:让我们定义两个状态(Sa,Sb)和两个特殊状态——exit和error。如果状态等于退出或错误,则计算停止。图灵机可以开始从右向左扫描输入,开始于状态(Sb)。动作表如下:
Sb,b)→(L,Sb,b)(Sb,a)→(L,Sa,a)(Sb,$)→(L, error ,$)(Sa,b)→(L, error ,b)(Sa,a)→(L,Sa,a)(Sa,$)→(L, exit,$)
以上只是图灵机的简单应用案例,但实际场景中,复杂程度远远不止于此。我们可以立即得出结论,为即使是简单的问题设计图灵机也是不可能的。因为动作表会包含很多状态,并且很快就会超出大小,但基线是可以用这个简单的设备解决复杂的问题。事实上,这台机器可以解决各种问题,如天气建模、金融计算和微分方程的求解!
Church-Turing论文捕捉到了这一观察结果,该论文说,任何物理计算设备都可以计算的所有函数都可以由图灵机计算。用外行的话说,任何可以在人类已知的任何计算机上用确定性算法计算的程序,也可以用图灵机计算。
这篇论文在过去的半个世纪里一直坚定不移。到目前为止,研究人员还无法找到比图灵机器更强大的机器,意味着没有程序可以由图灵机之外的另一种机器计算。有一些程序可能需要很长时间才能在图灵机上进行计算,但它们也会占用所有其他计算机上的无限时间。我们可以用所有可能的方式扩展图灵机,可以考虑多个磁带、多个磁带头或每个磁带中的多个磁道。可以看出,这些机器中的每一个都像一个简单的图灵机一样强大。
上面描述的图灵机不是通用机器,因为它包含一个动作表,该动作表特定于机器正在计算的函数。一个真正的通用机器将具有相同的动作表、符号以及每个功能的相同状态集。如果我们能设计一个能模拟另一个图灵机的图灵机,我们就能制造一个通用图灵机——通用且不会特定于正在计算的函数。
让被模拟的图灵机被称为M,通用图灵机则被称为U。让我们首先为M的动作表创建一个通用格式,并将其保存在U磁带上的指定位置,每个动作都需要5个参数——旧状态、旧符号、方向(左或右)、新状态、新符号。我们可以使用一组常见的基本符号,可以是10位十进制数字(0-9),如果一个函数需要更多的符号,那么我们可以考虑将一个符号包含在一组由特殊分隔符划分的连续单元中。让这样的符号称为模拟符号。同样,模拟动作表中的状态也可以编码为十进制数。对于方向,我们可以使用0表示左侧,1表示右侧。因此,单个动作表条目可能看起来像(@1334@34@0@1335@10@),其中“@”是分隔符,该条目表示,如果遇到符号34,我们将从状态1334移动到1335。我们向左移动(0),并写一个值10。因此,我们找到了一种对用于计算某个函数的图灵机的动作表、符号集和状态进行编码的方法。
类似地,我们可以指定磁带的一个区域来包含M的状态寄存器,称之为模拟状态寄存器。让M的磁带在U的磁带中有一个专用的空间,我们把这个空间称为工作区(work area)。这种组织如下图所示。
通用图灵机的布局。
磁带因此分为三部分,第一部分包含模拟动作表,第二部分包含模拟状态寄存器,最后一部分包含包含一组模拟符号的工作区。通用图灵机(U)有一个非常简单的动作表和一组状态,其思想是在模拟动作表中查找与模拟状态寄存器中的值和磁带头下的模拟符号相匹配的正确条目。然后,通用图灵机需要通过移动到新的模拟状态来执行相应的动作,并在需要时覆盖工作区中的模拟符号。为了做每一个基本动作,U需要做几十次磁带头运动。然而,结论是我们可以构造一个通用的图灵机。
可以构造一个通用的图灵机,它可以模拟任何其他的图灵机器。
自20世纪50年代以来,研究人员设计了更多类型的具有自己的状态和规则集的假想机器,这些机器中的每一台都已被证明至多与图灵机一样强大。所有机器和计算系统都有一个通用名称,它们都像图灵机一样具有表达力和功能。这种系统可以说是图灵完整的(Turing complete)。因此,任何通用机器和ISA都是图灵完整的。
任何等同于图灵机的计算系统都被称为图灵机。
因此,如果ISA是图灵完整的,我们需要证明ISA是完整的或通用的。
现在考虑一个更适合实际实现的通用图灵机的变体(下图),让它具有以下特性。请注意,这样的机器已经被证明是图灵完整的。
一种改进的通用图灵机
1、磁带为半无限(semi-infinite,仅在一个方向上延伸至无限)。
2、模拟状态是指向模拟动作表中的条目的指针。
3、每个状态的模拟动作表中有一个唯一的条目。在查找模拟动作表时,我们不关心磁带头下的符号。
4、一个动作指示磁带头访问工作区中的一组位置,并根据它们的值使用简单的算术函数计算一个新值。它将此新值写入工作区中的新位置。
5、默认的下一个状态是动作表中的后续状态。
6、如果磁带上某个位置的符号小于某个值,动作也可以任意改变状态,意味着模拟磁带头将开始从模拟动作表中的新区域提取动作。
这台图灵机建议采用以下形式的机器组织。有大量指令(动作表),这个指令数组通常被称为程序。有一个状态寄存器,用于维护指向数组中当前指令的指针,称为程序计数器,可以更改程序计数器以指向新指令。有一个大的工作区,可以存储、检索和修改符号,此工作区也称为数据区。指令表(程序)和工作区(数据)保存在我们改进的图灵机的磁带上。在实际的机器中,有限磁带可被看作内存。存储器是一个大的存储单元阵列,其中存储单元包含一个基本符号。内存的一部分包含程序,另一部分包含数据。
此外,每条指令都可以读取内存中的一组位置,计算它们上的一个小算术函数,并将结果写回内存,还可以根据内存中的值跳转到任何其他指令。有一个专用单元来计算这些算术函数,写入内存,并跳转到其他指令,被称为CPU(中央处理单元)。下图显示了该机器的概念组织。
基本指令处理器。
上面我们已经捕获了图灵机的所有方面:状态转换、磁带头的移动、重写符号以及基于磁带头下符号的决策。这种机器与冯·诺依曼机器非常相似,后者构成了当今计算机的基础。
现在,让我们尝试为改进的图灵机设计一个ISA,有可能有一个只包含一条指令的完整ISA,考虑一个与改进的图灵机兼容并且已经被证明是图灵完备的指令。
sbn a, b, c
sbn表示减法,如果为负数则分支,此指令从a中减去b(a和b是存储器位置),将结果保存在a中。如果a<0,则跳转到指令表中位置c处的指令,否则,控制转移到下一条指令。例如,我们可以使用此指令将保存在位置a和b中的两个数字相加。请注意,退出是程序末尾的一个特殊位置。
1: sbn temp, b, 2
2: sbn a, temp, exit
这里假设内存位置temp已经包含值0。第一条指令将−b保存在temp中,不管结果的值如何,它都跳到下一条指令。请注意,标识符(数字:)是指令的序列号。在第二条指令中,计算a=a+b=a−(−b)。因此,成功地相加了两个数字,现在可以使用这段基本代码将数字从1加到10。我们假设变量计数器初始化为9,索引初始化为10,一初始化为1,和初始化为0。
1: sbn temp, temp, 2 // temp = 0
2: sbn temp, index, 3 // temp = -1 * index
3: sbn sum, temp, 4 // sum += index
4: sbn index, one, 5 // index -= 1
5: sbn counter, one, exit // loop is finished, exit
6: sbn temp, temp, 7 // temp = 0
7: sbn temp, one, 1 // (0 - 1 < 0), hence goto 1
我们观察到,这个小的操作序列运行for循环。退出条件在第5行,循环返回发生在第7行。在每一次迭代中,它都计算−sum+=index。
有许多类似的单指令ISA已经被证明是完整的,例如,如果小于等于,则进行减法和分支,如果借用(borrow),则进行反向减法和跳过,以及具有通用内存移动操作的计算机。
用一条指令编写一个程序是非常困难的,而且程序往往很长。没有理由吝啬指令的数量,通过考虑大量的指令,可以使复杂程序的实现变得更加轻松。让我们尝试将基本的sbn指令分解为几个指令:
- 算术指令。可以有一组算术指令,如加法、减法、乘法和除法。
- 移动指令。可以有移动指令,在不同的内存位置移动值,允许将常量值加载到内存位置。
- 分支指令。需要根据计算结果或存储在内存中的值来改变程序计数器以指向新指令的分支指令。
记住这些基本原则,我们可以设计许多不同类型的完整ISA。需要注意的是,我们只需要三种类型的指令:算术(数据处理)、移动(数据传输)和分支(控制)。
在任何指令集中,至少需要三种类型的指令:
1、需要算术指令来执行加法、减法、乘法和除法等运算。大多数指令集也有这类专门的指令来执行逻辑运算,如逻辑OR和NOT。
2、需要数据传输指令,可以在内存位置之间传输值,并可以将常量加载到内存位置。
3、需要能够根据指令操作数的值在程序中的不同点开始执行指令的分支指令。
寄存器机(register machine)是指包含无限数量的命名存储位置,这些存储位置称为寄存器。寄存器可以随机访问,所有指令都使用寄存器名作为操作数。CPU访问寄存器,获取操作数,然后处理它们。还存在混合机器,它们可以增加存储空间带有寄存器的标准Von Neumann机器。寄存器是可以保存符号的存储位置。
存储器通常是非常大的结构,在现代处理器中,整个内存可以包含数十亿个存储位置,这种大小的内存的任何实际实现在实践中都相当缓慢。硬件中有一个一般的经验法则,大则慢,小则快。因此,为了实现快速操作,每个处理器都有一组可以快速访问的寄存器,寄存器的数量通常在8到64之间。算术和分支操作中的大多数操作数都存在于这些寄存器中,由于程序倾向于在任何时间点重复使用一小组变量,因此使用寄存器可以节省许多内存访问。然而,有时需要将内存位置引入寄存器或将寄存器中的值写回内存位置。在这些情况下,我们使用专用的加载和存储指令,在内存和寄存器之间传输值。大多数程序都有大多数纯寄存器指令,加载和存储指令的数量通常约为已执行指令总数的三分之一。
假设我们要将数字的3次方加到存储位置b和c中,并将结果保存在存储位置a中。带有寄存器的机器需要以下指令,假设r1、r2和r3是寄存器的名称,没有使用任何特定的(通用的、概念性的)ISA。
1: r1 = mem[b] // load b
2: r2 = mem[c] // load c
3: r3 = r1 * r1 // compute b^2
4: r4 = r1 * r3 // compute b^3
5: r5 = r2 * r2 // compute c^2
6: r6 = r2 * r5 // compute c^3
7: r7 = r4 + r6 // compute b^3 + c^3
4: mem[a] = r7 // save the result
mem是表示内存的数组,需要首先将值加载到寄存器中,然后执行算术计算,然后将结果保存回内存。上面的代码通过使用寄存器来节省内存访问,如果增加计算的复杂性,将节省更多的内存访问,因此,使用寄存器的执行速度会更快。最终的处理器组织如下图所示。
很明显,安排计算在堆栈上工作是不可取的,将有许多冗余负载和存储。尽管如此,对于打算计算长数学表达式的机器,以及程序大小是一个问题的机器,通常会选择堆栈。很少有基于堆栈的机器的实际实现,如Burroughs Large Systems、UCSD Pascal和HP 3000(经典)。Java语言在编译过程中假设一台基于堆栈的机器,由于基于堆栈的机器很简单,Java程序实际上可以在任何硬件平台上运行。当我们运行编译后的Java程序时,Java虚拟机(JVM)会动态地将Java程序转换为另一个可以在带有寄存器的机器上运行的程序。
基于累加器的机器使用一个寄存器,称为累加器(accumulator)。每条指令都将单个内存位置作为输入操作数,例如,加法运算将累加器中的值与存储器地址中的值相加,然后将结果存储回累加器。早期无法容纳寄存器的机器曾经有累加器,累加器能够减少内存访问的次数并加速程序。
累加器的某些方面已经渗透到英特尔x86处理器组中,这些处理器是2012年台式机和笔记本电脑最常用的处理器。对于大数的乘法和除法,这些处理器使用寄存器eax作为累加器。对于其他通用指令,任何寄存器都可以指定为累加器。
5 常见ISA
目前市面上流行的指令集包含ARM指令集和x86指令集。ARM是高级RISC机器(Advanced RISC Machines),是一家总部位于英国剑桥的标志性公司,截至2012年,包括苹果iPhone和iPad在内的大约90%的移动设备都运行在基于ARM的处理器上。同样,截至2012年超过90%的台式机和笔记本电脑运行在基于Intel或AMD的x86处理器上。ARM是RISC指令集,x86是CISC指令集。
还有许多其他为各种处理器量身定制的指令集,移动计算机的另一个流行指令集是MIPS指令集,基于MIPS的处理器也用于汽车和工业电子中的各种处理器。
对于大型服务器,通常使用IBM(PowerPC)、Sun(如今的Oracle,UltraSparc)或HP(PA-RISC)处理器。每个处理器系列都有自己的指令集,这些指令集通常是RISC指令集,大多数ISA共享简单的指令,如加法、减法、乘法、移位和加载/存储指令。除了这个简单的集合,他们使用了大量更专业的指令。在ISA中选择正确的指令集取决于处理器的目标市场、工作负载的性质以及许多设计时间限制,下表显示了流行的指令集列表。
ISA |
类型 |
年份 |
厂商 |
位数 |
字节顺序 |
寄存器数 |
VAX |
CISC |
1977 |
DEC |
32 |
little |
16 |
SPARC |
RISC |
1986 |
Sun |
32 |
bi |
32 |
SPARC |
RISC |
1993 |
Sun |
64 |
bi |
32 |
PowerPC |
RISC |
1992 |
Apple, IBM, Motorola |
32 |
bi |
32 |
PowerPC |
RISC |
2002 |
Apple, IBM |
64 |
bi |
32 |
PA-RISC |
RISC |
1986 |
HP |
32 |
big |
32 |
PA-RISC |
RISC |
1996 |
HP |
64 |
big |
32 |
m68000 |
CISC |
1979 |
Motorola |
16 |
big |
16 |
m68000 |
CISC |
1979 |
Motorola |
32 |
big |
16 |
MIPS |
RISC |
1981 |
MIPS |
32 |
bi |
32 |
MIPS |
RISC |
1999 |
MIPS |
64 |
bi |
32 |
Alpha |
RISC |
1992 |
DEC |
64 |
bi |
32 |
x86 |
CISC |
1978 |
Intel, AMD |
16 |
little |
8 |
x86 |
CISC |
1985 |
Intel, AMD |
32 |
little |
8 |
x86 |
CISC |
2003 |
Intel, AMD |
64 |
64 little |
16 |
ARM |
RISC |
1985 |
ARM |
32 |
bi (little default) |
16 |
ARM |
RISC |
2011 |
ARM |
64 |
bi (little default) |
31 |
6 指令实现机制
有一小组基本逻辑组件,可以以各种方式组合起来存储二进制数据,并对该数据执行算术和逻辑运算。如果要执行特定的计算,则可以构造专门为该计算设计的逻辑组件的配置。我们可以将以所需配置连接各种组件的过程视为编程的一种形式。生成的“程序”是硬件形式的,称为硬连线程序(hardwired program)。
现在考虑这个替代方案。假设我们构造了算术和逻辑函数的通用配置,这组硬件将根据施加到硬件的控制信号对数据执行各种功能。在定制硬件的原始情况下,系统接受数据并产生结果(下图a)。使用通用硬件,系统接受数据和控制信号并产生结果,因此程序员只需要提供一组新的控制信号,而不是为每个新程序重新布线硬件。
硬件和软件方法。
如何提供控制信号?答案很简单,但很微妙。整个程序实际上是一系列步骤,在每个步骤中,对一些数据执行一些算术或逻辑运算。对于每个步骤,都需要一组新的控制信号。让我们为每一组可能的控制信号提供一个唯一的代码,并在通用硬件中添加一个可以接受代码并生成控制信号的段(上图b)。
编程现在更容易了。我们需要做的是提供一个新的代码序列,而不是为每个新程序重新布线硬件。实际上,每个代码都是一条指令,部分硬件解释每个指令并生成控制信号。为了区分这种新的编程方法,一系列代码或指令被称为软件。
6.1 存储指令
让我们考虑加载指令:ld r1, 12[r2],此处将内存地址计算为r2和数字12的内容之和。ld指令访问此内存地址,获取存储的整数并将其存储在r1中。假设计算的内存地址指向整数的第一个存储字节(即小端表示),所以内存地址包含LSB。详情如下图(a)所示。存储操作则相反,将r1的值存储到存储器地址(r2+12)中,如下图(b)所示。
6.2 函数
回顾一下实现一个简单函数的基本要求。假设地址为A的指令调用函数foo,在执行函数foo之后,需要立即返回A处指令之后的指令,该指令的地址为A+4(如果我们假设A处的指令长度为4字节)。这个过程被称为从函数返回,地址(a+4)被称为返回地址。
返回地址(Return address)是进程在执行函数后需要分支到的指令的地址。
因此,实现函数有两个基本方面:1、调用或调用函数的过程;2、涉及从函数返回。
函数本质上是一块汇编代码,调用一个函数本质上是让PC指向这段代码的开头。我们可以将标签与每个函数相关联,标签应该与函数中的第一条指令相关联,调用函数就像分支到函数开头的标签一样简单。然而,这只是故事的一部分,我们还需要实现返回功能。因此,我们不能使用无条件分支指令来实现函数调用。
因此,让我们提出一个专用的函数调用指令,它分支到函数的开头,同时保存函数需要返回的地址(称为返回地址)。让我们考虑下面的C代码,并假设每个C语句对应于一行汇编代码。
a = foo(); /* Line 1 */
c = a + b; /* Line 2 */
在这个小代码片段中,我们使用函数调用指令来调用foo函数,返回地址是第2行中指令的地址。调用指令必须将返回地址保存在专用存储位置,以便以后可以检索。大多数RISC指令集都有一个专用寄存器,称为返回地址寄存器(不妨称为ra),用于保存返回地址,返回地址寄存器由函数调用指令自动填充。当我们需要从函数返回时,我们需要分支返回地址寄存器中包含的地址。
如果foo调用另一个函数会发生什么?在这种情况下,ra中的值将被覆盖。我们稍后将讨论这个问题。现在让我们考虑将参数传递给函数并返回返回值的问题。
假设函数foo调用函数foobar。foo被称为调用者(caller),foobar被称为被调用者(callee)。请注意,调用方与被调用方的关系是不固定的。foo可以调用foobar,foobar也可以在同一个程序中调用foo。根据哪个函数调用另一个函数来决定单个函数调用的调用者和被调用者。
调用者和被调用者都看到相同的寄存器视图。因此,我们可以通过寄存器传递参数,同样也可以通过寄存器来传递返回值。然而,正如我们在下面列举的,在这个简单的想法中有几个问题(假设我们有16个寄存器)。
1、一个函数可以接受16个以上的参数,比我们现有的通用寄存器数量还要多,因此需要添加额外的空间来保存参数。
2、函数可以返回大量数据,例如C中的大型结构。这段数据可能不可能在寄存器中存储。
3、被调用者可能会覆盖调用者将来可能需要的寄存器。
因此,我们观察到,通过寄存器传递参数和返回值只适用于简单的情况,不是一个非常灵活和通用的解决方案。尽管如此,我们的讨论提出了两个要求:
- 空间问题。我们需要额外的空间来发送和返回更多的参数。
- 覆盖问题。我们需要确保被调用方不会覆盖调用方的寄存器。
为了解决这两个问题,需要更深入地了解函数是如何工作的。可以将函数foo想象成一个黑匣子,它接受一系列参数并返回一组值。要执行它的工作,foo可以花费一纳秒、一周甚至一年的时间。foo可以调用其他函数来完成它的工作、将数据发送到I/O设备以及访问内存位置。下图是函数foo的可视化。
总而言之,通用函数处理参数,根据需要从内存和I/O设备读取和写入值,然后返回结果。关于内存和I/O设备,目前我们并不特别关心,有大量可用内存,空间不是主要限制,读写I/O设备通常也与空间限制无关。主要问题是寄存器,因为它们供不应求。
让我们先解决空间问题,可以通过寄存器和内存传输值。为了简单起见,如果我们需要传输少量数据,我们可以使用寄存器,否则我们可以通过内存传输它们。类似地,对于返回值,我们可以通过内存传输值。如果我们使用内存传输数据,那么我们不受空间限制。然而,这种方法缺乏灵活性,因为调用者和被调用者之间必须就要使用的内存位置达成严格的协议。请注意,我们不能使用一组固定的内存位置,因为被调用方可以递归调用自己。
void foobar()
{
...
foobar();
...
}
精明的读者可能会认为,被调用方可以从内存中读取参数并将其转移到内存中的其他临时区域,然后调用其他函数。然而,这种方法既不优雅,也不十分有效。稍后将研究更优雅的解决方案。
因此可以得出结论,我们已经部分解决了空间问题。如果需要在调用者和被调用者之间传输一些值,或者反之亦然,可以使用寄存器。但是,如果参数/返回值不在可用寄存器集中,那么需要通过内存传输它们。对于通过内存传输数据,我们需要一个优雅的解决方案,它不需要调用者和被调用者之间就用于传输数据的内存位置达成严格的协议。
将寄存器保存在内存中并随后恢复的概念称为寄存器溢出(register spilling)。
要解决覆盖问题,有两种解决方案:1、调用者可以将所需的寄存器集保存在内存中的专用位置,可以在被调用方完成后检索其寄存器集,并将控制权返回给调用方。2、让被调用方保存和恢复它需要的寄存器。这两种方法都如下图所示。这种将寄存器值保存在内存中,然后再检索的方法称为溢出。
调用方保存和被调用方保存的寄存器。
我们又遇到了同样的问题,即调用者和被调用者都需要就需要使用的内存位置达成严格的协议。现在让我们一起努力解决这两个问题。
我们简化了向函数传递参数和从函数传递参数,以及使用内存中的专用位置保存/恢复寄存器的过程。然而,该解决方案被发现是灵活的,对于大型现实世界程序来说,实现起来可能相当复杂。为了简化这个想法,让我们在函数调用中定义一个模式。
典型的C或Java程序从主函数开始。然后,该函数调用其他函数,这些函数可能反过来调用其他函数。最后,当主函数退出时,执行终止。每个函数定义一组局部变量,并对这些变量和函数参数执行计算,它还可以调用其他函数。最后,函数返回一个值,很少返回一组值(C中的结构)。请注意,函数终止后,不再需要局部变量和参数。因此,如果其中一些变量或参数保存在内存中,我们需要回收空间。其次,如果函数溢出了寄存器,那么这些内存位置也需要在它退出后释放。最后,如果被调用方调用另一个函数,则需要将返回地址寄存器的值保存在内存中,还需要在函数退出后释放此位置。
最好将所有这些信息连续保存在一个内存区域中,被称为函数的激活块(activation block),下图显示了激活块的内存映射。
激活块包含参数、返回地址、寄存器溢出区(对于调用方保存和被调用方保存的方案)和局部变量。一旦函数终止,就可以完全摆脱激活块。一个函数如果想要返回一些值,那么可以使用寄存器这样做,但是它如果想要返回一个大的结构,那么就可以将其写入调用方的激活块中,调用方可以在其激活块中提供一个可以写入该数据的位置。后面有可能更优雅地做到这一点,在解释如何做到这一点之前,需要了解如何在内存中安排激活块。
我们可以有一个存储区域,其中所有的激活块都存储在相邻的区域中。考虑一个例子,假设函数foo调用函数foobar,foobar又调用foobarbar。下图显示了4个内存状态:(a)在调用foobar之前,(b)在调用foobarbar之前,(c)在调用foobarbar之后,(d)在foobarar返回之后。
在这个内存区域中有一个后进先出的行为,最后调用的函数是要完成的第一个函数,这种后进先出的结构传统上被称为计算机科学中的堆栈(stack),因此专用于保存激活块的存储区域称为堆栈。传统上,堆栈被认为是向下增长的(向更小的内存地址增长),意味着主功能的激活块从非常高的位置开始,新的激活块被添加到现有激活块的正下方(朝向较低的地址)。堆栈的顶部实际上是堆栈中最小的地址,而堆栈的底部是最大的地址。堆栈的顶部表示当前正在执行的函数的激活块,堆栈的底部表示初始主函数。
堆栈(stack)是保存程序中所有激活块的内存区域,一般情况是向下增长的。在调用函数之前,我们需要将其激活块推送到堆栈中,当函数完成执行时,需要将其激活块弹出到堆栈中。
堆栈指针寄存器(stack pointer register)保存指向堆栈顶部的指针。
大多数架构将指向堆栈顶部的指针保存在一个称为堆栈指针的专用寄存器中,常被称为sp。请注意,对于许多架构,堆栈是纯软件结构。对于他们来说,硬件不知道堆栈。但对于某些架构(如x86),硬件知道堆栈并使用它来推送返回地址或其他寄存器的值。即使在这种情况下,硬件也不知道每个激活块的内容,结构由程序集程序员或编译器决定。在所有情况下,编译器都需要显式添加汇编指令来管理堆栈。
为被调用方创建新的激活块涉及以下步骤。
1、将堆栈指针减小激活块的大小。
2、复制参数的值。
3、如果需要,通过写入相应的内存位置来初始化任何局部变量。
4、如果需要,溢出任何寄存器(存储到激活块)。
从函数返回时,必须销毁激活块,可以通过将激活块的大小添加到堆栈指针来完成。
通过使用堆栈,我们解决了所有问题。调用方和被调用方不能覆盖彼此的局部变量,局部变量保存在激活块中,两个激活块不重叠。除了变量之外,还可以通过在激活块中显式插入保存寄存器的指令来阻止被调用方重写调用方的寄存器。实现这一点有两种方法:调用者保存的方案和被调用方保存的方案。其次,无需就将用于传递参数的内存区域达成明确协议,堆栈可以用于此目的,调用者可以简单地将参数推送到堆栈上,这些参数将被推送到被调用方的激活块中,被调用方可以轻松使用它们。同样,当从函数,被调用方可以通过堆栈传递返回值,需要先通过减少堆栈指针来销毁其激活块,然后才能将返回值推送到堆栈上。调用方将知道被调用方的语义,因此在被调用方返回后,可以假定其激活块已被被调用方有效地放大,返回值占用了额外的空间。
ARM使用B/BL/BX/BLX等语句调用函数和返回函数,而x86使用call等指令调用函数,此外,x86和ARM都可使用ret指令返回地址。下面是ARM的函数调用示例代码:
.globl main
.extern abs
.extern printf
.text
output_str:
.ascii "The answer is %d\n\0"
@ returns abs(z)+x+y
@ r0 = x, r1 = y, r2 = z
.align 4
do_something:
push {r4, lr}
add r4, r0, r1
mov r0, r2
bl abs ; 调用abs
add r0, r4, r0
pop {r4, pc}
main:
push {ip, lr}
mov r0, #1
mov r1, #3
mov r2, #-4
bl do_something ; 调用do_something
mov r1, r0
ldr r0, =output_str
bl printf
mov r0, #0
pop {ip, pc}
有趣的指令是pushpop和bl,只需获取提供的寄存器列表并将其推到堆栈上,或者将其弹出并放入提供的寄存器中。bl只不过是带链接的分支,分支后的下一条指令的地址被加载到链接寄存器lr中。
一旦我们正在调用的例程被执行,lr就可以被复制回pc,将使CPU能够在bl指令之后从代码中继续。在do_someting中,我们将链接寄存器推送到堆栈,这样就可以再次将其弹出返回,即使对abs的调用将覆盖链接寄存器的原始内容。程序存储r4,因为Arm过程调用标准规定在函数调用之间必须保留r4-r11(下图),并且被调用的函数负责该保留,意味着do_someting需要将r0+r1的结果保存在一个不会被abs破坏的寄存器中,并且我们还必须保存用于保存该结果的任何寄存器的内容。当然,在这种特殊情况下,我们可以只使用r3,但是需要考虑的。我们推送并弹出寄存器,尽管我们不必保留它,因为过程调用标准要求堆栈64位对齐。这在使用堆栈操作时提供了性能优势,因为它们可以利用CPU内的64位数据路径。
我们可以直接压入高地址的值,毕竟如果abs需要注册,那么这就是它保存值的方式。推送r4而不是我们知道需要的值有一个小的性能问题,但最有力的论点可能是,在函数的开始和结束时只推送/弹出所需的任何寄存器,就可以减少错误发生的可能性,提高代码的可读性。此外,“main”函数还压入和弹出lr的内容,因为虽然主代码可能是我的代码中要执行的第一件事,但它不是加载程序时要执行的第二件事。编译器将在调用main之前插入对一些基本设置函数的调用,并在退出时进行一些最终清理调用。
现在让我们尝试将每条指令编码为32位值。假设有0、1、2和3地址格式的指令,其次,有些指令采用即时值,因此需要将32位划分为多个字段。假设有21条指令,则需要5位来编码指令类型,常规指令中的每个指令的代码如下表所示。我们可以使用32位字段中最重要的位来指定指令类型,指令的代码也称为操作码(opcode)。
指令 |
二进制码 |
add |
00000 |
sub |
00001 |
mul |
00010 |
div |
00011 |
mod |
00100 |
cmp |
00101 |
and |
00110 |
or |
00111 |
not |
01000 |
mov |
01001 |
lsl |
01010 |
lsr |
01011 |
asr |
01100 |
nop |
01101 |
ld |
01110 |
st |
01111 |
beq |
10000 |
bgt |
10001 |
b |
10010 |
call |
10011 |
ret |
10100 |
现在,让我们尝试从0地址指令开始对每种类型的指令进行编码。
我们拥有的两条0地址指令是ret和nop。操作码由五个最重要的位指定,在这种情况下,ret等于10100,b等于10010(参见上表)。它们的编码如下图所示,我们只需要在MSB位置指定5位操作码,其余27位不需要。
编码ret指令。
我们拥有的1地址指令是call、b、beq和bgt,它们将标签作为参数。在编码指令时,我们需要指定标签的地址作为参数,标签的地址与它所指向的指令的地址相同。如果标签后的行为空,那么我们需要考虑下一条包含指令的汇编语句。
这四条指令的操作码需要5位,剩余的27位可用于地址。请注意,内存地址是32位长,不能用27位覆盖地址空间,但可以进行两个关键的优化。首先,可以假设PC相对寻址,可以假设27位指定了相对于当前PC的偏移量(正负)。现代程序中的分支语句是因为for/while循环或if语句而生成的,对于这些构造,分支目标通常在几百条指令的范围内。如果有27位来指定偏移量,并且假设它是2的补码,那么任何方向(正或负)的最大偏移量都是226,对于几乎所有的程序来说已足够。
还有另一个重要的观察。一条指令需要4个字节。如果假设所有指令都与4字节边界对齐,那么指令的所有起始内存地址都将是4的倍数,因此地址的至少两个有符号二进制数字将是00,没有理由在试图指定它们时浪费比特,可以假设27位指定包含指令的内存字(以4字节内存字为单位)地址的偏移量。通过这种优化,从PC的字节偏移量变为29位,即使是最大的程序,这个数字也应该足够。以防万一有极端的例子,其中分支目标距离超过228个字节,那么汇编程序需要将分支链接起来,这样一个分支将调用另一个分支,以此类推。这些指令的编码如下图所示。
1地址指令的编码(分支格式)。
请注意,1地址指令格式禁止使用0地址格式中未使用的位,可以将ret指令的0地址格式视为1地址格式的特例,1地址格式称为分支格式。以这种格式命名字段,将格式的操作码部分称为op,将偏移量称为offset。操作字段包含位置28-32的位,偏移字段包含位置1-27的位。
接下来考虑3地址指令:add、sub、mul、div、mod和或、lsl、lsr和asr。
考虑一个通用的3地址指令,它有一个目标寄存器、一个输入源寄存器和一个可以是寄存器或立即数的第二个源操作数。如果第二个源操作数是寄存器或立即数,需要将一位输入和输出。将其称为I位,并在指令中的操作码之后指定它。如果I=1,则第二个源操作数是立即数,如果I=0,则第二个源操作数是寄存器。
现在考虑将第二个源操作数作为寄存器(I=0)的3地址寄存器的情况。因为有16个寄存器,所以需要4位来唯一地指定每个寄存器。寄存器ri可以编码为i的无符号4位二进制等价物。因此,要指定目标寄存器和两个输入源寄存器,需要12位。结构如下图所示,此指令格式称为寄存器格式。像分支格式一样,不妨命名不同的字段:op(操作码,位:28-32)、I(立即数,位:27)、rd(目的寄存器,位:23-26)、rs1(源寄存器1,位:19-22)和rs2(源寄存器2,位:15-18)。
假设第二个源操作数是立即数,那么需要将I设置为1,接下来计算指定立即数所剩的位数。现在已经为操作码投入了5位,为I位投入了1位,为目标寄存器投入了4位,为第一个源寄存器投入了四位,总共花费了14位。因此,在32位中,剩下18位,可以使用它们来指定立即数。
建议将18位分为两部分:2位(修改器)+16位(立即数的常数部分),两个修改位可以取三个值:00(默认值)、01(“u”)和10(“h”)。当使用默认修改器时,剩余的16位用于指定16位2的补码数。对于u和h修改器,假设立即字段中的16位常量是无符号数。假设立即字段为18位长,具有修改部分和常量部分,处理器根据修改器将立即数内部扩展为32位值。
此编码如下图所示,可将此指令格式称为立即数格式。像分支格式一样,不妨命名不同的字段:op(操作码,位:28-32)、I(立即数,位:27)、rd(目标寄存器,位:23-26)、rs1(源寄存器1,位:19-22)和imm(立即数:1-18)。
用类似的方式,可以用下图所示的方式编码cmp、not和mov指令:
而加载指令的实现如下图:
7 ARM指令编码
ARM有四种类型的指令:数据处理(加/减/乘/比较)、加载/存储、分支和其他,需要2位来表示这些信息,这些位决定了指令的类型。下图显示了ARM中指令的通用格式。
对于数据处理指令,类型字段等于00,其余26位需要包含指令类型、特殊条件和寄存器。下图显示了数据处理指令的格式。
第26位称为I(立即数)位,类似于前面所述的I位。如果将其设置为1,则第二个操作数是立即数,否则是寄存器。由于ARM有16条数据处理指令,需要4位来表示它们,该信息保存在第22-25位。第21位保存S位,如果打开,则指令将设置CPSR。
其余20位保存输入和输出操作数。由于ARM有16个寄存器,需要4位来编码一个寄存器。第17-20位保存第一个输入操作数(rs)的标识符,要求是一个寄存器。第13-16位保存目标寄存器(rd)的标识符。
位1-12用于保存立即数或移位器操作数,下面看看如何最好地利用这12位。
ARM支持32位立即数,然而实际上只有12位来编码它们。不可能对所有232232个可能的值进行编码,需要从中选择一个有意义的子集,想法是使用12位对32位值的子集进行编码,硬件预计将解码这12位,并在处理指令时将其扩展到32位。
现在,12位是一个相当不灵活的值,既不是1字节,也不是2字节。有必要想出一个非常巧妙的解决方案,想法是将12位分为两部分:4位常量(rot)和8位有效载荷(payload),参见下图。
假设12位中编码的实际数字为n,有:
n=payload ror (2⋅rot)
其中ror是右旋操作。通过将有效载荷右旋2倍于rot字段中的值,获得实际数字n。现在试着理解这样做的逻辑。
数字n是32位值。一个天真的解决方案是使用12位来指定n的最小符号位,高阶位可以是0。然而,程序员倾向于以字节为单位访问数据和内存,因此1.5个字节对我们毫无用处。更好的解决方案是使用1字节的有效载荷,并将其放置在32位字段中的任何位置,其余4位用于此目的,它们可以对0到15之间的数字进行编码。ARM处理器将该值加倍,以考虑0到30之间的所有偶数,将有效载荷向右旋转该量。这样做的好处是可以对更广泛的数字集进行编码,对于所有这些数字,有8位对应于有效载荷,其余24位均为零。rot位仅确定32位字段中的哪8位被有效载荷占用。
同样地,通过合理地思考,可以得到以下的位移指令格式图:
此外,加载、存储指令格式如下:
而分支指令如下:
ARM Endian支持使用E-Bit加载/存储字:
8 x86指令编码
x86是真正的CISC指令集,其编码过程更为规律,几乎所有的指令都遵循标准格式。其次,x86中的操作码通常有多种模式和前缀。先看看编码机器指令的广泛结构,下图显示了二进制编码指令的结构。
x86二进制指令格式。
x86指令格式细节。
第一组1-4字节用于编码指令的前缀,rep前缀就是其中一个例子,还有许多其他类型的前缀可以在第一组1-4字节中编码。
接下来的1-3个字节用于对操作码进行编码,整个x86 ISA有数百条指令,操作码还编码操作数的格式。例如,加法指令可以将其第一个操作数作为内存操作数,也可以将其第二个操作数用作内存操作数。此信息也是操作码的一部分。
接下来的两个字节是可选的。第一个字节被称为ModR/M字节,用于指定源寄存器和目标寄存器的地址,第二个字节被称作SIB(标度索引基)字节,该字节记录基本缩放索引和基本缩放索引偏移寻址模式的参数,存储器地址可以可选地具有32位的位移(在本书中也称为偏移量)。因此,我们可以选择在一条指令中多4个字节来记录位移值。最后,一些x86指令接受立即数作为操作数,立即数也可以大到32位,因此,最后一个字段(也是可选的)用于指定立即数操作数。
ModR/M字节有三个字段,如下图所示:
SIB字节的结构如下图所示:
x86数字数据格式如下:
x86 EFLAGS寄存器:
x86控制寄存器:
MMX寄存器到浮点寄存器的映射: