1.指令和指令格式
1.1.指令的概念及构成
指令(机器指令):指示计算机执行某种操作的命令,是计算机运行的最小功能单位。
指令集(指令系统):一台计算机的所有指令的集合。
注意:一台计算机只能执行自己指令系统中的指令,不能执行其他系统的指令。例如 X86 和 ARM。
一条指令就是机器语言的一个语句,它是一组有意义的二进制代码。指令通常包括操作码字段和地址码字段两个部分:
操作码:指明了该指令是一个什么类型的指令,也即它具有怎样的功能。它是识别指令,了解指令功能及操作数地址的关键信息。比如:操作码可以指出该操作是 “算数加” 还是 “算数减” 运算,是 “程序转移” 还是 “返回操作” 等等。
地址码:给出了被操作的信息(指令或数据)的地址。 比如:“参与运算的一个和多个操作数所在的地址、运算结果的保存地址、程序的转移地址、被调用的子程序的入口地址” 等等 。
1.2.指令字长、机器字长和存储字长
- 指令字长:一条指令的总长度(可能会变),也即一条指令中所包含的二进制代码的位数,它取决于操作码的长度、操作数地址码的长度和操作数地址的个数。
- 机器字长:CPU 进行一次运算所能处理的二进制数据的位数(通常和 ALU 直接相关)。
- 存储字长:一个存储单元中的二进制代码位数(通常和 MDR 位数相同)。
注意:指令字长与机器字长没有固定的关系,它可以等于机器字长,也可以大于或小于。通常,把指令字长等于机器字长的指令称为单字长指令,相应地还有半字长指令、双字长指令。指令字长会影响取指令所需时间,例如,当机器字长 = 存储字长 = 16bit,则取一条双字长指令就需要两次访存操作。
1.3.定长指令字和变长指令字
定长指令字结构:在一个指令系统中,如果所有指令的长度都是相等的,则称为定长指令字结构。具有定长指令字结构的指令其执行速度快,控制简单。
变长指令字结构:各种指令的长度随指令功能而不同。由于主存一般是按字节编址的,所以指令字长多为字节的整数倍。
2.指令的分类
2.1.按操作数地址码的数目不同进行分类
2.1.1.零地址指令
零地址指令:它只给出了操作码 OP,没有给出显式地址,有以下两种情况:
- 该指令本身就不需要操作数:比如空操作、停机、关中断等等。
- 零地址的运算类指令它仅仅会用于堆栈计算机中:通常参与运算的两个操作隐含地从栈顶和次栈顶弹出,送到运算器进行运算,运算结果再隐含地压入堆栈。
对于第二种情况,最为经典的例子就是后缀表达式的计算,扫描后缀表达式时遇到操作数就会压栈,遇到运算符就会取操作数再运算,结果仍会压入栈中 。
2.1.2.一地址指令
一地址指令:一地址指令有两种常见的形态,需要根据操作的含义确定究竟属于哪一种:
- 只有目的操作数:根据地址 $A_{1} $读取操作数,进行 $OP $操作后,结果存回原地址,也即 $OP(A_{1}) $-> $A_{1} $。比如常见的 “加 1、减 1、求反、求补” 等操作。完成一条指令需要3次访存:取指→读A1→写A1。
- 需要两个操作数,但其中一个操作数隐含在某个寄存器中,比如 ACC:也即 $ (ACC)OP(A_{1})->A_{1} $,其中 $A_{1} $指某个主存地址, $ (A_{1}) $ 表示地址中的内容,可以类比 C 语言中的指针理解。完成一条指令需要2次访存:取指→读A1。
2.1.3.二、三地址指令
二地址指令:二地址指令往往就是常见的算数和逻辑运算,它们需要使用两个操作数,也即目的操作数和。地址中会给出目的操作数和源操作数的地址,其中目的操作数地址还可用于保存本次运算的结果,也即$(A_{1})OP(A_{2}) $-> $A_{1} $。完成一条指令需要访存4次,取指→读A1→读A2→写A1。
三地址指令:相比二地址指令,三地址指令需要新的地址来存储运算结果,也即 $ (A_{1})OP(A_{2}) $-> $A_{3} $ 。完成一条指令需要访存4次,取指→读A1→读A2→写A3。
2.1.4.四地址指令
四地址指令:相比三地址指令,多了一个用于指明下一条指令地址的功能。这样就实现了指令的跳转功能(如果没有跳转,指令将会正常 + 1),也即$ (A_{1})OP(A_{2})- > A_{3} $, \(A_{4}= 下一条指令的地址\)。完成一条指令需要访存4次,取指→读A1→读A2→写A3。
2.2.按操作码长度分类
定长操作码:在指令字的最高位部分分配固定的若干位(定长)表示操作码。指令系统中所有指令的操作码长度都相同,有 $n $ 位就有 $2^{n} $条指令。定长操作码的控制器译码电路设计简单,但是灵活较差。
- 优:定长操作码对于简化计算机硬件设计,提高指令译码和识别速度很有利。
- 缺:指令数量增加时会占用更多固定位,留给表示操作数地址的位数受限。
扩展操作码(不定长操作码):全部指令的操作码字段的位数不固定,且分散地放在指令字的不同位置上。最常见的变长操作码方法是扩展操作码,使操作码的长度随地址码的减少而增加,不同地址数的指令可以具有不同长度的操作码,从而在满足需要的前提下,有效地缩短指令字长。指令系统中各指令的操作码长度可变。可变长操作码控制器译码电路设计复杂,但灵活性很高。
- 优:在指令字长有限的前提下仍保持比较丰富的指令种类。
- 缺:增加了指令译码和分析的难度,使控制器的设计复杂化。
2.3.按操作类型分类
数据传送(进行主存与CPU之间的数据传送)
LOAD
作用:把存储器中的数据放到寄存器。STORE
作用:把寄存器中的数据放到存储器。
算数逻辑操作(运算类)
- 算数:加、减、乘、除、增 1、减 1、求补、浮点运算、十进制运算等。
- 逻辑:与、或、非、异或、位操作、位测试、位清除、位求反。
移位操作(运算类)
- 算数移位、逻辑移位、循环移位。
转移操作(程序控制类:改变程序执行的顺序)
- 无条件转移
JMP
- 条件转移(
JZ
: 结果为 0;JP
:结果溢出;JC
:结果有进位)。 - 调用和返回
CALL
及RETURN。
- 陷阱(
Trap
)与陷阱指令。
输入和输出操作(输入输出类(I/0):进行CPU和I/0设备之间的数据传送)
- CPU 寄存器与 IP 端口之间的数据传送(端口即 IO 接口中的寄存器)。
3.扩展操作码指令格式
3.1.重述几个概念
指令格式:有两部分构成:
- 操作码。
- 地址码。
根据指令字长是否固定对指令分类:
- 定长指令字结构:指令系统中所有指令的长度都相等。
- 变长指令字结构:指令系统中各种指令的长度不固定。
根据操作码字段长度是否固定对指令分类:
- 定长操作码:指令系统中所有指令的操作码长度都固定且相同。
- 变长操作码:指令系统中各指令的操作码长度不固定。
所以这里会有以下四种类型:
- 定长指令字定长操作码。
- 定长指令字变长操作码:本节扩展操作码基于此类进行,也就是说不同地址数的指令会使用不同长度的操作码。
- 变长指令字定长操作码。
- 变长指令字变长操作码。
3.2.为什么要扩展操作码
如下图,假设指令字长为 16 位,其中前 4 位为操作码字段 $ OP $,另外有 3 个 4 位的地址字段 $A_{1} $、 $A_{2} $和 $ A_{3} $。
如果 4 位操作码全部用于三地址指令,由于 $2^{4}=16 $,所以该结构仅能表示 16 条指令。但这种方式是不合理的,因为所能表示的指令数是在是太少了。所以我们要做适当的处理,使其虽然不能涵盖全部的三地址指令,但是我能在牺牲有限条三地址指令的情况下向下扩展出更多的二地址、一地址、零地址指令,这样一来,所能表示的指令数目将会大大增大。
3.3.扩展操作码方法
3.3.1.最常用方法
- 以上图为例。
扩展二地址指令:
- 三地址指令操作码范围为
0000
~1111。
- 将
1111
留作扩展码,也即1111
开头的指令不再代表三地址指令,此时三地址指令变更为 15 条,操作码范围为0000
~1110。
- 于是,二地址指令操作码将会以
1111
开头。 - 实际上,CPU 在取得一条指令时,一定是直接读入 16
位,所以只需要根据所读入的是否为
1111
即可判断它是三地址还是二地址指令。
扩展一地址指令:
- 此时二地址指令操作码范围为
1111
0000
~1111
1111。
- 将
1111
1111
留作扩展码,也即1111
1111
开头的指令不再代表二地址指令,此时二地址指令变更为 15 条,操作码范围1111
0000
~1111
1110。
- 于是,一地址指令操作码将会以
1111
1111
开头。
扩展零地址指令:
此时二地址指令操作码范围为
1111
1111
0000
~1111
1111
1111
。将
1111
1111
1111
留作扩展码,也即1111
1111
1111
开头的指令不再代表一地址指令,此时一地址指令变更为 15 条,操作码范围为1111
1111
0000
~1111
1111
1110。
于是,零地址指令操作码将会以
1111
1111
1111
开头。由于零地址指令不需要再向后扩展,所以是 16 条,范围为
1111
1111
1111
0000
~1111
1111
1111
1111。
总之,在整个扩展过程中,操作码的位数会随着地址码位数的减少而增加。相比于之前的 16 条三地址指令。经过扩展,仅损失了一个三地址指令,却增加了 15 条二地址指令、15 条一地址指令和 16 条零地址指令,这是很划算的。
3.3.2.其他方法
上面展示的是扩展操作码比较常用的一种方法,还有很多种设计方案。不过,不论使用哪种方法,在设计时一定要注意以下几点:
- 不允许短码是长码的前缀,也即短操作码不能与长操作码的前面部分相同:这一点,类似于哈夫曼树的前缀编码,比如
0011
和0011
0000
,如果这样设计就会产生歧义。 - 各指令操作码不能重复。
通常情况下,对于使用频率较高的指令,分配较短的操作码;对使用频率较低的指令则分配较长的操作码,从而尽可能减少指令译码和分析的时间。
- 这一点其实可通过哈夫曼树看出。
3.4.经典例题
设地址长度为 $n $,上一层留出 \(m\)种状态,则下一层可以扩展出 $ m×2^{n} $种状态。
4.指令寻址
指令寻址:下一条欲执行指令的地址会由程序计数器 $PC $ 给出,分为顺序寻址和跳跃寻址。
数据寻址:执行一条指令时,将地址码当作形式地址码,需要对其按照一定规则进行解释翻译,才能得到真实的地址。当按不同方式进行解释时,就会形成不同的数据寻址方式,共有十种。
4.1.什么是指令寻址
- 程序运行实则就是指令执行,指令可以顺序执行也可以跳跃执行,这就涉及到指令寻址的问题了。
指令寻址:我们编写的程序最终会被翻译等价的机器指令,指令和数据无差别地存放在主存当中。CPU 中有一个很重要的寄存器——程序计数器 PC(Program-Counter),它指明了下一条指令的存放地址,CPU 在执行完一条指令后会让程序计数器自动 + 1。
- 注意:这里的 “+1” 不是简单的 + 1,下面会在顺序寻址中说明。
指令寻址有两类:
顺序寻址。
跳跃寻址 。
4.1.两类指令寻址
4.1.2.顺序寻址
顺序寻址:顺序寻址可以简单的理解为:(PC)+“1”->PC,但是这里的 “1” 要理解为一个指令字长,要视具体的指令长度、编址方式的不同而定。
只有在系统采用定长指令字结构,并且指令字长 = 存储字长 = 16bit=2B,且主存按字编址时,PC 才能简单的 + 1。
其余条件不变,如果主存按字节编址,即每一条指令会占两个地址,此时 PC 要 + 2 。
如果采用变长指令字结构同时按字节编址,此时不同指令的字长是不一样的。由于 CPU 无法确定当前指向的指令占多少存储字,此时 CPU 可以先读入一个字,操作码一定会包含在其中,因此可以通过操作码来判断这是一个几地址的指令,就可以确定这条指令具体占的字节数 n,接着 PC+n 即可。
下图中相同颜色表示一条指令。
注意:在这种方式下,由于 CPU 无法预先知道总字节数,所以可能会进行多次访存,每次读入一个字,对于一条指令可能要读多次才能读干净 。
4.1.2.跳跃寻址
跳跃寻址:所谓跳跃,是指下一条指令的地址码不再由程序计数器给出,而由本条指令给出下一条指令地址的计算方式。跳跃寻址通过转移类指令实现。
- 无条件转移
JMP。
- 条件转移(
JZ
: 结果为 0;JP
:结果溢出;JC
:结果有进位)。 - 调用和返回
CALL
及RETURN。
- 陷阱(
Trap
)与陷阱指令。
如下,CPU
正常执行指令,在遇到无条件转移指令JMP
时会把 PC
中的内容强制改为 7,意味着下次执行要从 7
的位置开始执行,这有点类似goto
语句。
5.数据寻址
指令寻址:下一条欲执行指令的地址会由程序计数器 $PC $ 给出,分为顺序寻址和跳跃寻址。
数据寻址:执行一条指令时,将地址码当作形式地址码,需要对其按照一定规则进行解释翻译,才能得到真实的地址。当按不同方式进行解释时,就会形成不同的数据寻址方式,共有十种。
5.1.什么是数据寻址
5.1.1.理解什么是数据寻址
数据寻址:借用上一节文章中最后一个例子,该程序是从主存地址为
0 的单元开始向后存储的,执行到JMP
指令后会把 PC 改为
7,所以接下来会直接跳转到 7 这个地方,因此这里的 7
就是真实的地址,没有歧义。
但在实际情况中,几乎不可能保证当前运行的程序恰好就从主存位置为
0 的地方开始存储。比如下面,该程序是从主存位置为 100
的单元向后存储的。在这种情况下,如果JMP
依旧采用之前的方式去理解,那么在
103 执行完毕之后,它仍然会跳转到 7 这个位置。
这显然是不合理的,因为 7 的位置是一个未知区域,甚至有可能是别的程序正在使用的区域,这就属于非法越界了。因此这里的 7 则可以解释为基于程序开始位置 100 的偏移量,也即 107 。
当然还有其它解释方式,例如下图可以解释为:JMP 执行完成之后 PC 会自动 + 1,然后从 PC 所指向的位置向后偏移 3 个单位开始执行。
5.1.2.数据寻址分类
可以发现,不同的解读方式下地址码会有不同的含义,也就会产生不同的寻址方式。所以,我们会在地址码的前边新加入寻址方式位(寻址特征)来标识该指令的地址会采用何种方式来解释(或寻址)。主要会有以下十种寻址方式。
5.1.3.数据寻址指令格式
共有十种寻址方式,所以寻址方式位(寻址特征)需要 4 个比特位。总之,根据寻址方式位(寻址特征)可以确定形式地址$ (A)$采用怎样的方式解读,得到相应的真实地址(称为有效地址 $ (EA) $)。
- 注意:n 地址指令需要 n 个寻址特征。
在下面的介绍中,假设指令字长 = 机器字长 = 存储字长,且操作数为 3。
5.2.第一类数据寻址(将形式地址按照某种规则解释)
5.2.1.直接寻址
直接寻址:指令字中的形式地址 A 就是操作数的真实地址,即 EA=A。
访存次数:取指令的 1 次 + 执行指令的 1 次 =2 次(暂不考虑存结果)。
优点: 简单,指令执行阶段仅访问一次主存,不需要专门计算操作数的地址。
缺点: A 的位数决定了该指令操作数的寻址范围,且操作数的地址不易修改 。
上图是一个取数指令。
5.2.2.间接寻址
间接寻址:指令的地址字段给出的形式地址不是操作数的真正地址,而是操作数的有效地址所在存储单元的地址,也就是操作数地址的地址,即 EA=(A)。
- 访存次数:取指令的 1 次 + 执行指令的 2 次 =3 次(暂不考虑存结果)。
- 优点:可以扩大寻址范围(有效地址 EA 的位数大于形式地址 A 的位数);便于编写程序(间接寻址方式可以很方便地完成子程序返回)。
- 缺点::指令在执行阶段要多次访存(一次间接寻址需要两次访存,多次寻址需要根据存储字的最高位确定几次访存)。
下图一次间接寻址:
下图两次间接寻址 :
5.2.3.寄存器寻址
寄存器寻址:在指令字中直接给出操作数所在的寄存器编号,即 EA=Rs,所其操作数就在由 Rs 所指的寄存器内存放。
- 访存次数:取指令 1 次 + 执行指令 0 次 =1 次(暂不考虑存结果)。
- 优点:指令在执行阶段不访问主存,只访问寄存器;指令字段且执行速度快,支持向量 / 矩阵运算。
- 缺点:寄存器价格昂贵,个数有限。
5.2.4.寄存器间接寻址
寄存器间接寻址:寄存器 Ri 中给出的不是一个操作数,而是操作数所在主存单元的地址,即EA=(R)。
访存次数: 取指令 1 次 + 执行指令 1 次 =2 次(暂不考虑存结果) 。 特点: 与一般间接寻址方式相比速度更快,但指令的执行阶段需要访问主存。
5.2.5.隐含寻址
隐含寻址:不是明显地给出操作数的地址,而是在指令中隐含着操作数的地址。
- 优点:有利于缩短指令字长。
- 缺点:需要增加存储操作数和隐含地址的硬件。
5.2.6.立即寻址
立即寻址:形式地址 A
就是操作数本身,又称为立即数,一般采用补码形式。它的寻址特征为#
,#
表示立即寻址特征。
- 访存次数: 取指令 1 次 + 执行指令 0 次 =1 次(暂不考虑存结果)。
- 优点: 指令执行阶段不需要访问主存,指令执行时间最短。
- 缺点: A 的位数限制了立即数的范围 。
5.2.7.堆栈寻址
堆栈是存储器(或专用寄存器组)中一块特定的按“后进先出(LIFO)"原则管理的存储区,该存储区中被读/写单元的地址是用一个特定的寄存器给出的,该寄存器称为堆栈指针(SP)。
堆栈寻址:是指操作数存放在堆栈中,隐含使用堆栈指针 (\(SP\)) 作为操作数地址。其中,堆栈是存储器(或专用寄存器组)中一块特定的按 “先进后出” 原则管理的存储区,该存储区中被读 / 写单元的地址由一个特定的寄存器给出的,也就是我们上面说到的堆栈指针(\(SP\))。
如下,记栈顶单元为 $ M_{sp} $,那么完成一次加法运算的过程为:
- 首先是
POP ACC
,也就是将栈顶单元弹出并将其内容送入ACC
,也即 \((M_{sp})->ACC\),同时栈顶指针向下移动,也即(SP)+1=SP
,指向次栈顶元素。 - 接着是
POP X
,将操作数放到X
寄存器中,重复。 - 然后进行加法运算
ADD Y
,结果保存在变量Y
中,也即(ACC)+(X)->Y
。 - 接着进行压栈,结果压回栈顶
PUSH Y
,栈顶指针向上移动,结果送入此时的栈顶,也即(SP-1)->SP
和$ (Y)->(M_{sp})$。
堆栈可以分为硬堆栈和软堆栈两种(上面的例子属于硬堆栈)。
- 硬堆栈:又称为寄存器堆栈,其成本较高,不适合做大容量的堆栈。
- 软堆栈(最常用):是指从主存中划分一段区域来做堆栈。
6.第二类数据寻址(将形式地址视为偏移量)
主要介绍以下三种数据寻址方式,它们都是将形式地址视为 “偏移量”。**
- 基址寻址:以程序的起始存放地址作为起点,即
EA=(BR)+A
。 - 变址寻址:程序员自己决定从哪里作为起点,即
EA=(IX)+A
。 - 相对寻址:以程序计数器
PC
所指地址作为起点,即EA=(PC)+A
。
6.1.基址寻址
6.1.1.基本概念
基址寻址:将 CPU 中基址寄存器 BR
中的内容加上指令格式中的形式地址
A,从而形成操作数的有效地址,也即EA=(BR)+A
。
如下,采用基址寻址,指令中会包含一个形式地址 A,BR 会指向当前程序存放的起始位置 。
需要注意,有的计算机内部不会专门设计一个基址寄存器,而会使用通用寄存器代替基址寄存器。如果采用通用寄存器,除了要给明寻址特征外,指令中还要多出几位 ( $R_{0} $) 用于说明要将哪个通用寄存器作为基址寄存器使用。
6.1.2.基址寻址的作用
基址寻址作用:基址寻址利于程序浮动,程序存储位置可以更改,但指令内容不需要修改,只需要更改基址寄存器,让其始终指向程序的起始地址,这==有利于多道程序并发运行==;另外,采用基址寻址可以扩大寻址范围(因为基址寄存器的位数大于形式地址 A 的位数)。
例如下面有这样一段 C 语言段程序:
int a=2,b=3,c=1,y=0;
void main()
{
y=a*b*c;
}
翻译为等价的机器指令
下图中该程序从 主存 “100”
处开始存储,第一个指令是一个取数指令,目的是为了把 “105”
处的变量a=2
取到
ACC
中,此时该地址码就会被解释为偏移量,因此真实地址
= 基址寄存器 + 地址码。
6.2.变址寻址
6.2.1.基本概念
变址寻址:有效地址 EA 等于形式地址 $A $与变址寄存器 IX 中的内容相加之和,也即 $ EA=(IX)+A $。其中 IX 可以是专用的,也可以将通用寄存器用作变址寄存器 。
可以看出变址寄存器和基址寄存器非常相像,那么他们的区别又在哪里呢?
基址寄存器是面向操作系统的,其内容由操作系统或管理程序确定,主要用于解决程序逻辑空间与存储器物理空间无关性。在程序执行过程中,基址寄存器内容不变(作为基地址)、形式地址可变(作为偏移量);另外,采用通用寄存器作为基址寄存器时,可由用户决定哪个寄存器作为基址寄存器,但是其内容仍然由操作系统确定。
变址寄存器是面向用户的,在程序的执行过程中,变址寄存器中的内容可以由用户改变,其中 IX 作为偏移量,形式地址 A 不变,作为基地址,这一点和基址寄存器恰好相反。
6.2.2.变址寄存器的作用
变址寻址作用:变址寻址特别适合编写循环程序,例如在下面所述的数组处理过程中,可将形式地址 A 设置为基地址,这里就是数组的首地址,而变址寄存器 $ IX $的内容为偏移量,也就是循环变量。
依旧采用基址寻址,如下有一段 C
语言程序,其作用是对a[0]~a[9]
进行求和,涉及到了循环语句。
int main()
{
int sum=0;
for(int i=0;i<10;i++)
{
sum+=a[i];
}
return sum;
}
翻译为等价的机器指令:
- 第 0 条指令是立即寻址,将 0 取到 ACC 中。
- 第 1 条第 10 条均为加法指令,从地址码指示位置取得操作数然后和 ACC 中内容相加并送入 ACC 中。
- 第 11 条是一个取数指令,将累加结果放回到 sum 中。
可以发现对于基址寻址,它在处理循环时显得不那么合适,一旦数组的内容成百上千,难道还要继续这样操作吗?答案显然不是这样的,这时候就要用到变址寻址了。
- 第 0 条指令是取数指令,取到
ACC
中。 - 第 1 条指令是取数指令,取到
IX
中。 - 第 2 条指令采用变址寻址,也即
EA=(IX)+A
。这条指令的 A 指向了 “7”,也就是数组的起始位置,此时执行这条指令的结果就是把ACC
中的内容(目前为 0)加上$ IX$偏移 7 后所指内存单元中的内容(也即a[0]
),然后再放到ACC
中,完成第一轮循环。 - 第 3 条指令是 IX+1,对应于循环变量
i++。
- 第 4 条指令是 IX 与 10 做比较,来决定是执行第 5 条指令还是第 6 条指令。如果 IX<10 参见第 5 条指令,如果 IX>=10 参见第 6 条指令。
- 第 5 条指令是条件跳转,程序会跳转至 “2”,进行第 2 轮循环,然后依次类推。
- 第 6 条指令是存数指令,此时结束循环。
6.2.3.复合寻址(基址 + 变址)
实际上,基址和变址这两种寻址方式通常会配合使用,上面变址的例子中仅仅展示了当程序从主存 “0” 位置开始存储的情形,当然是没有问题的,但是一旦改变程序的起始位置,那么仅仅依靠变址寻址就不行了。
基本规则如下:
- 基址寻址:
EA=(BR)+A
。 - 变址寻址:
EA=(IX)+A
。 - 先基址再变址:
EA=(IX)+((BR)+A)
。
6.3.相对寻址
6.3.1.基本概念
相对寻址:把程序计数器 PC 的内容加上指令格式中的形式地址 A
而形成操作数的有效地址,也即 EA=(PC)+A
,其中 A 是相对于 PC
所指地址的偏移量,可正可负,使用补码表示。
前面说过,当前指令执行完毕之后,PC 会自动 +“1”(注意这里的 “1” 仅仅表示下一步的意思,并不是实际 + 1,这要视具体情况而定,具体细节读者可查看上一节) 。
6.3.2.相对寻址作用
相对寻址的作用:相对寻址中,操作数地址不是固定不变的,可以随 PC 值的变化而变化,并且与指令地址之间总是相差一个固定值,因此便于程序的浮动(注意区分基址寻址的浮动,这里的浮动是指==一段代码在程序内部的浮动==),相对寻址==广泛应用在转移指令中==。
还是这样一段加和程序:
int main()
{
int sum=0;
for(int i=0;i<10;i++)
{
sum+=a[i];
}
return sum;
}
循环部分对应的指令如下:
在编写程序时我们常常有跳转的需求,比如上面的那一段程序可能循环未结束,我们需要马上执行它下面的一段程序,那么在这种情况下如果进行跳转,会出现很大的问题 。
为了解决这样的问题,引入相对寻址。在上图中,执行完 “M+3” 处的指令后会自动跳转至 "M+4",如果想要跳回至 “M” 处,那么就要将 “M+3” 中的地址码改为 - 4 即可 。
自此,这段程序就似乎自我形成了一个封闭的体系,无论你把程序放到什么位置,只要他们的相对位置不变,总能跳转到正确的位置。
6.4.数据寻址总结
7.高级语言、汇编语言、机器语言
使用高级语言编写的源程程序会经过以下两步转变为与之对应的机器语言:
- 编译: 高级语言经编译程序 编译后 转变为汇编语言,一条高级语言语句可能对应多条汇编语言语句。
- 汇编: 汇编语言经汇编程序 汇编后 转变为机器语言,一条汇编语言语句对应一条机器语言语句。
7.1.汇编程序简单入门
如下为一段 C 语言程序,输出 “Hello World”:
#include <stdio.h>
int main(int argc, char* argv[])
{
printf("Hello, world!\n");
return 0;
}
使用 gcc 生成汇编程序。
.file "hello.c"
.intel_syntax noprefix
.section .rodata
.LC0:
.string "Hello, world!"
.text
.globl main
.type main, @function
main:
push rbp
mov rbp, rsp
sub rsp, 16
mov DWORD PTR [rbp-4], edi
mov QWORD PTR [rbp-16], rsi
mov edi, OFFSET FLAT:.LC0
call puts
mov eax, 0
leave
ret
.size main, .-main
.ident "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.9)"
.section .note.GNU-stack,"",@progbits
一个汇编程序由如下 4 个基本组件构成:
指令(instruction):是 CPU 执行的实际操作。
伪指令(directive):告诉汇编工具生成特定数据,并将指令或数据放在指定的节。
标号(label):在汇编工具中在汇编工具中引用指令或数据的符号名称。
注释(comment):在汇编程序中,可以使用
COMMENT
伪指令来定义注释。```c COMMENT [regexp] ; 在此处输入注释内容
**例如**: | 类型 | 示例 | 含义 | | ------ | --------------------- | ------------------------------- | | 指令 | mov eax,0 | 给 eax 赋值为 0 | | 伪指令 | .section .text | 将以下代码放入.text 节 | | 伪指令 | .string "foobar" | 定义包含"foobar”的 ASCII 字符串 | | 伪指令 | .long 0x12345678 | 定义一个双字 0x12345678 | | 标号 | foo: .string "foobar" | 使用符号定义“foobar”"字符串 | | 注释 | #这是注释 | 可读注释 | 注意:上面的示例是伪指令和标号的示例,它们并不是实际的汇编代码。在实际的汇编代码中,指令和伪指令的顺序是固定的,并且伪指令和标号后面的字符串必须是唯一的。 7.2.什么是 x86 架构 ------------ **x86 架构:是微处理器执行的计算机语言指令集,指一个 intel 通用计算机系列的标准编号缩写,也标识一套通用的计算机指令集合。1978 年 6 月 8 日,Intel 发布了新款 16 位微处理器 “8086”,也同时开创了一个新时代——x86 架构。** ![image-20230415213903625](https://raw.githubusercontent.com/wushuai2000/PicGo/main/img/202304152139726.png) 7.3.x86 指令结构 ---------- ### 7.3.1.x86 的汇编层表示 **x86 的汇编层表示:x86 指令通常表示为助记符 目标地址,源地址。** * **助记符**:是人类可读的机器指令表示。 * **源地址和目标地址**:指令的操作数。 **如汇编指令`mov rbx,rax`就是将寄存器`rax`的值赋给`rbx。`** ![image-20230415214114111](https://raw.githubusercontent.com/wushuai2000/PicGo/main/img/202304152141207.png) ### 7.3.2.x86 指令的机器级结构 **x86 指令的机器级结构:由以下部分组成**: * **前缀**(可选):前缀**可以修改指令的行为**,如让一条指令重复执行多次或访问不同的内存段。 * **操作码**:是指令对其进行**操作的数据**。 * **寻址模式字节**(可选):包含有关指令操作数类型的元数据,SIB(scale/index/base)字节和偏移(displacement)用来表示内存操作数,立即数字段(immediate)包含立即操作数(常量数值),**标识特定的寻址方式。** ![](https://raw.githubusercontent.com/wushuai2000/PicGo/main/img/202304152141551.png) ### 7.3.3.x86 操作数来源 **x86 中操作数有三个来源**: * 寄存器。 * 内存。 * 操作数。 #### 7.3.3.1.寄存器操作数 **x86 寄存器操作数:x86 下只需关注如下 8 个寄存器,每个寄存器以`E`开头,表示`Extended`,长度为 32bit。分为三类**: * **以`X`结尾**:通用寄存器。 * **以`I`结尾**:变址寄存器。 * **以`P`结尾**:堆栈寄存器。 ![image-20230415214508206](https://raw.githubusercontent.com/wushuai2000/PicGo/main/img/202304152145304.png) **对于上面的四个通用寄存器,也可以只使用低 16bit 或低 8bit。** ![image-20230415214540807](https://raw.githubusercontent.com/wushuai2000/PicGo/main/img/202304152145906.png) ![image-20230415214552671](https://raw.githubusercontent.com/wushuai2000/PicGo/main/img/202304152148376.png) #### 7.3.3.2.内存操作数 **内存操作数:在 x86 中,可以用`[base+index*scale+displacement]`指定内存操作数**。 * `base`和`index`:是 64 位寄存器。 * `scale`:1、2、4 或 8 的整数值。 * `displacement`(偏移):是 32 位常量或符号。 **例如`mov eax, dword ptr[af966h]`就表示将内存地址 af996h 所指的 32bit 值复制到寄存器`eax`中**。 * `dword ptr`:双字(32bit)。 * `word ptr`:单字(16bit)。 * `byte ptr`:字节(8bit)。 #### 7.3.3.1.立即数 **立即数:立即数就是指令中硬编码的常量整数操作数,如指令`add rax,42`,其中 42 就是一个立即数。** 7.4.x86 指令例子 ---------- * `mov eax, ebx`:将寄存器`ebx`的值复制到寄存器`eax`中。 * `mov eax, 5`:将立即数 5 复制到寄存器`eax`中。 * `mov eax, dword ptr[af996h]`:将内存地址 af996h 所指的 32bit 值复制到寄存器`eax`中。 * `mov byte prt[af996h], 5`:将立即数 5 复制到内存地址 af996h 所指的一字节中。 * `mov eax, dword ptr[ebx]`:将`ebx`所指主存地址的 32bit 复制到`eax`寄存器中。 * `mov dword ptr[ebx], eax`:将`eax`的内容复制到`ebx`所指主存地址的 32bit 中。 * `mov eax byte ptr[ebx]`:将`ebx`所指的主存地址的 8bit 复制到`eax`中。 * `mov eax, [ebx]`: 若未指明主存读写长度,默认 32 bit。 * `mov [af996h], eax`:将`eax`的内容复制到 af996h 所指的地址(未指明长度默认 32 bit)。 * `mov eax, dword ptr[ebx+8]`:将`ebx+8`所指主存地址的 32bit 复制到`eax`寄存器中。 7.5.AT&T 格式和 intel 格式对比 --------------------- ![image-20230415220645899](https://raw.githubusercontent.com/wushuai2000/PicGo/main/img/202304152206018.png) **表示 x86 机器指令的语法格式主要有两种,其中 intel 格式考试最为常见**: * **AT&T 格式**(Linux):显式地在每个**寄存器名称**的前面加上 % 符号,每个**常量**前面加上 $ 符号;源操作数在目的操作数**前面**。 * **Intel 格式**(Windows):相对简洁、不加符号;源操作数在目的操作数**后面。** **区别一:操作数位置**。 * **AT&T 格式源操作数在左,目的操作数在右面**:`op s, d。` * **intel 格式目的操作数在左,源操作数在右面**:`op d, s。` **区别二:寄存器表示。** * **AT&T 格式寄存器名之前必须加`%`**:`mov %ebx, %eax。` * **intel 格式直接写寄存器名即可**: `mov eax, ebx。` **区别三:立即数表示**。 * **AT&T 格式立即数之前必须加`$`**:`mov $985, %eax。` * **intel 格式直接写立即数即可**: `mov eax, 985。` **区别四:主存地址表示。** * **AT&T 格式使用中括号`()`表示主存地址**:`mov $eax, (af996h)。` * **intel 格式使用中括号`[]`表示主存地址**: `mov [af996], eax。` **区别五:读写长度表示。** * **AT&T 格式使用`b`、`w`、`l`分别代表`byte`、`word`、`dword`,写到指令的后面**:`movb $5, (af997h)`、`movw $5, (af997h)`、`movl $5, (af997h)`。 * **intel 格式在主存地址前面使用`byte`、`word`、`dword`说明**:`mov byte ptr [af996h], 5`、`mov word ptr [af996h], 5`、`mov dword ptr [af996h], 5`。 **区别六:主存地址偏移量表示** * **AT&T 格式使用`偏移量(基址)`或`偏移量(基址,变址,比例因子)`表示**:`movl -8(%ebx), %eax`、`mov eax,[ebx+ecx*32+4]`。 * **intel 格式使用`[基址+偏移量]`或`[基址+变址*比例因子+偏移量]`表示**: `mov eax, [ebx-8]`、`mov eax, [ebx+ecx*32+4]`。 # 8.常用的x86汇编指令、选择和循环语句的机器级表示 ![image-20230415220429441](https://raw.githubusercontent.com/wushuai2000/PicGo/main/img/202304152204539.png) 8.1.常见的算数运算指令 ----------- **注意**: * 除法中`s`作除数,被除数会被**提前放置**到`edx`和`eax`当中。 * `edx:eax`:在进行除法运算之前,需要把被除数进行**位扩展**为 64bit,所以需要两个寄存器。 <table><thead><tr><th>中文名</th><th>英文名</th><th>汇编指令</th><th>功能</th></tr></thead><tbody><tr><td>加</td><td><code>add</code></td><td><code>add d,s</code></td><td>计算<code>d+s</code>,结果存入<code>d</code></td></tr><tr><td>减</td><td><code>subtract</code></td><td><code>sub d,s</code></td><td>计算<code>d-s</code>,结果存入<code>d</code></td></tr><tr><td>乘</td><td><code>multiply</code></td><td><code>mul d,s</code> 和<code>imul d, s</code></td><td>无符号数<code>d*s</code>和有符号数<code>d*s</code>,结果存入<code>d</code></td></tr><tr><td>除</td><td><code>divide</code></td><td><code>div d,s</code>和<code>idiv d,s</code></td><td>无符号数和有符号除法<code>edx:eax/s</code> ,其中商存入<code>eax</code>,余数存入<code>edx</code></td></tr><tr><td>取负数</td><td><code>negative</code></td><td><code>neg d</code></td><td>将<code>d</code>取负数,结果存入<code>d</code></td></tr><tr><td>自增</td><td><code>increase</code></td><td><code>inc d</code></td><td>也即<code>d++</code>,结果存入<code>d</code></td></tr><tr><td>自减</td><td><code>decrease</code></td><td><code>dec d</code></td><td>也即<code>d--</code>,结果存入<code>d</code></td></tr></tbody></table> **举例**: * `sub eax, 10`:计算`eax-10`并存入`eax`。 * `add byte ptr [var], 10`:`var`所指内存地址一字节值与 10 相加,结果存入`var`所指内存地址处。 8.2.常见的逻辑运算指令 ----------- <table><thead><tr><th>中文名</th><th>英文名</th><th>汇编指令</th><th>功能</th></tr></thead><tbody><tr><td>与</td><td><code>and</code></td><td><code>and d,s</code></td><td>将<code>d</code>和<code>s</code>逐位相与,结果放回<code>d</code></td></tr><tr><td>或</td><td><code>or</code></td><td><code>or d,s</code></td><td>将<code>d</code>和<code>s</code>逐位相或,结果放回<code>d</code></td></tr><tr><td>非</td><td><code>not</code></td><td><code>not d</code></td><td>将<code>d</code>逐位取反,结果放回<code>d</code></td></tr><tr><td>异或</td><td><code>exclusive or</code></td><td><code>xor d,s</code></td><td>将<code>d</code>和<code>s</code>逐位异或,结果放回<code>d</code></td></tr><tr><td>左移</td><td><code>shift left</code></td><td><code>shl d,s</code></td><td>将<code>d</code>逻辑左移<code>s</code>位,结果放回<code>d</code></td></tr><tr><td>右移</td><td><code>shift rightft</code></td><td><code>shr d,s</code></td><td>将<code>d</code>逻辑右移<code>s</code>位,结果放回<code>d</code></td></tr></tbody></table> 8.3.其他类型指令 -------- ### 8.3.1.数据传送类指令 * `mov`:将第二个操作数 (寄存器的内容、内存中的内容或常数值) **复制**到第一个操作数 (寄存器或内存)。但不能用于直接从内存复制到内存。 * `push`:将**操作数压入内存的栈**,常用于函数调用。ESP 是栈顶,压栈前先将 ESP 值减 4 (栈增长方向与内存地址增长方向相反),然后将操作数压入 ESP 指示的地址。 * `pop`:与 push 指令相反,pop 指令执行的是**出栈工作**,出栈前先将 ESP 指示的地址中的内容出栈,然后将 ESP 值加 4。 ### 8.3.2.控制流指令 **指令指针寄存器 IP(相当于 ARM 型 CPU 中的程序计数器 PC):x86 处理器维持着一个指示当前执行指令的指令指针 (IP), 当一条指令执行后,此指针自动指向下一条指令。IP 寄存器不能直接操作,但可以用控制流指令更新。通常用标签 (label) 指示程序中的指令地址,在 x86 汇编代码中,可在任何指令前加入标签。** * `jmp`:**无条件转移指令**,控制 IP 转移到 **label 所指示的地址** (从 label 中取出指令执行)。 * `jcondition`:**条件转移指令**,依据 CPU 状态字中的一系列条件状态转移。CPU 状态字中包括指示最后一个算术运算结果是否为 0,运算结果是否为负数等。 * `comp/test`:`cmp`指令用于**比较两个操作数的值**,`test` 指令**对两个操作数进行逐位与运算**,这两类指今都不保存操作结果,仅根据运算结果**设置 CPU 状态字中的条件码。** * `call/ret`:**无条件转移指令**,分别用于实现子程序 (过程、函数等) 的调用及返回。 **对于无条件转移指令`call/ret`,其过程调用的执行步骤如下,假设$P$ 调用 $Q$** * $P$ 将入口参数 (实参) 放在 $Q$ 能访问到的地方。 * $P$ 将返回地址存到特定的地方,然后将控制转移到 $Q$。 * $Q$ 保存$P$ 的现场 (通用寄存器的内容),并为自己的非静态局部变量分配空间 * 执行过程 $Q$。 * $Q$ 恢复$P$ 的现场,将返回结果放到$P$ 能访问到的地方,并释放局部变量所占空间。 * $Q$ 取出返回地址,将控制转移到$P$。 #### 8.3.2.1.无条件转移指令 jmp ![image-20230415220911290](https://raw.githubusercontent.com/wushuai2000/PicGo/main/img/202304152209403.png) **对于`jmp`指令,有如下四种使用方法**: * `jmp 128`:地址由常数给出。 * `jmp eax`:地址可以来自于寄存器。 * `jmp[999]`:地址可以来自于主存。 * `jmp NEXT`:地址可以由 “标号” 给出,类似于 C 语言中的 goto 语句。 ```c mov eax, 7 mov ebx, 6 jmp NEXT mov ecx, ebx NEXT: mov ecx, eax
8.3.2.2.条件转移指令 jcondition
jmp
指令是很不灵活的,无法实现一些复杂条件转移操作,所以需要借助条件转移指令jcondition
来完成,同时要借助cmp
a, b
(比较a
和b
的大小)。
je <地址>
:若a == b
则跳转(ZF == 1)。jne <地址>
:若a != b
则跳转(ZF == 0)。jg <地址>
:若a > b
则跳转(ZF == 0 && SF == OF)。jge <地址>
:若a >= b
则跳转( SF == OF)。jl <地址>
:若a < b
则跳转( SF != OF)。jle <地址>
:若a <= b
则跳转( SF != OF || ZF == 1)。
cmp eax,ebx # 比较寄存器eax和ebx里的面的值
jg NEXT #若eax>ebx则跳转至NEXT位置处
8.4.选择语句的机器级表示
如下是 C 语言中的if-else
语句:
if (a > b){
c = a;
} else{
c = b;
}
对应机器级表示如下:
mov eax, 7 # 变量a=7,存入eax中
mov ebx, 6 # 变量b=6,存入ebx中
cmp eax, ebx # 比较a和b
jg NEXT # 若a>b,跳转至NEXT处
mov ecx, ebx # 使用ecx存入变量c,使c=b
jmp END
NEXT:
mov ecx, eax # 使用ecx存入变量c,使c=a
END:
8.5.循环语句的机器级表示
8.5.1.使用条件转移指令实现循环
使用条件转移指令实现循环由以下 4 部分构成:
- 循环前的初始化。
- 是否直接跳过循环。
- 循环主体。
- 是否继续循环。
如下代码是 C 语言中的for
循环,用于求解 1+2+…+100。
int result = 0;
for(int i = 1; i <= 100; i++){
result += i;
}
对应机器级表示如下:
mov eax,0 # 使用eax保存result,初始值为0
mov edx,1 # 使用edx保存i,初始值为1
cmp edx,100 # 比较i和100的大小
jg L2 # 如果i>100,跳转至L2处
L1: # 循环主体
add eax,edx # result += i
inc edx # i++
cmp edx,100 # 比较i和100的大小
jle L1 # 如果i<=100,跳转至L1处
l2: # 结束循环
8.5.2.使用 loop 指令实现循环
从理论上讲,能用 loop 指令实现的功能也一定能用条件转移指令实现。loop 指令的存在目的是为了使代码更加清晰、简洁,让其余分支语句区别更明显。
如下代码是 C 语言中的for
循环,固定循环 500 次:
for(int i=500; i > 0; i--){
do something;
}
对应机器级表示如下,其中:
loop Looptop
:等价于dec ecx
、cmp ecx, 0
、jne Looptop
。
mov ecx, 500 # ecx是循环变量
Looptop: # 循环开始
...
do something
...
loop Looptop # ecx--,若ecx != 0,跳转至Looptop
8.6.cmp指令的底层原理
9.CISC和RISC简单了解
指令系统的设计如今朝着两个截然不同的方向发展:
一是增强原有指令的功能,设置为更复杂的新指令实现软件功能的硬化,这类机器称为复杂指令系统计算机(CISC),典型的如 ×86 架构的计算机;二是减少指令种类和简化指令功能,提高指令的执行速度,这类机器称为精简指令系统计算机(RISC),典型的有 ARM,MIPS 架构的计算机。
9.1.复杂指令系统计算机(CISC)
随着 VLSI 技术的发展,硬件成本不断降低,软件成本不断上升,促使人们在系统中增加更多、更复杂的指令,以适应不同的应用领域,这样就构成了 CISC。其主要特点如下:
- 指令系统复杂庞大,指令数目一般为 200 条以上。
- 指令的长度不固定,指令格式多,寻址方式多。
- 可以访存的指令不受限制。
- 各种指令使用的频度相差很大。
- 各种指令执行时间相差很大,大多数指令需要多个时钟周期才能完成。
- 控制器大多数采用微程序编程。有的指令非常复杂,以至于无法采用硬连线控制。
- 难以用优化编译生成高效的目标代码途径。
如此庞大的指令系统,对指令的设计提出了极高的要求,胭研制周期变得很长。后来人们发现一味地追求指令系统的复杂和完备程度不是提高计算机性能的唯一途径。对传统 CISC 指令系统测试表明,各种指令的使用频率相差悬殊,从这一事实出发,人们开始了指令系统合理性的研究,于是 RISC 随之诞生。
9.2.精简指令系统计算机(RISC)
RISC 的中心思想是要求指令系统简化,尽量使用寄存器 - 寄存器操作指令,指令格式力求一致。其主要特点如下:
- 选取使用频率最高的一些简单指令,复杂指令的功能由简单指令组合实现。
- 指令长度固定,指令格式种类少,寻址方式种类少。
- 只有 Load/Store(取数 / 存数) 指令访存,其余指令的操作都在寄存器之间进行。
- CPU 中通用寄存器的数量相当多。
- RISC 一定采用指令流水线技术,大部分指令在一个时钟周期内完成。
- 以硬布线控制为主,少用或者不用微程序控制。
- 特别重视编译优化工作,以减少程序执行时间。
值得注意的是,从指令系统的兼容性来看,CISC 大多能够实现软件兼容,即高高档机包含了低档机的全部指令,并可以进行扩充。但 RISC 简化了指令系统,指令条数少,格式也不同于老机器,因此大多数 RISC 机不能与老机器兼容。由于 RISC 具有更强的实用性,因此应该是未来处理器发展的方向。但事实上,当今时代 Intex 几乎一统江湖,且早期很多软件都是根据 CISC 设计的,单纯的 RISC 将无法兼容。此外,现代 CISC 结构的 CPU 已经融合了很多 RISC 的成分,其性能差距已经越来越小。CISC 可以提供更多的功能,这一点是程序设计所需要的。
9.3.CISC 和 RISC 比较
和 CISC 相比,RISC 的优点主要集中在以下几个方面:
- RISC 更能充分利用 VLSI 芯片的面积。CISC 的控制器大多采用微程序控制,其控制存储器在 CPU 芯片内所占面积会达到 50% 以上,而 RISC 控制器采用组合逻辑控制,其硬布线逻辑只占 CPU 芯片面积的 10% 左右。
- RISC 更能提高运行速度。RISC 的指令数、寻址方式和指令格式种类少,又设有多个通用寄存器,采用流水线技术,所以运算速度更快,大多数指令在一个时钟周期内完成。
- RISC 便于设计,可以降低成本,提高可靠性。RISC 指令系统简单,因此机器设计周期短,其逻辑简单。
- RISC 有利于程序优化。RISC 指令类型少,寻址方式少,使编译程序容易选择更有效的指令和寻址方式,并适当地调整指令顺序,使得代码执行更加高效化。
RISC 和 CISC 具体区别如下 :