内容基于中国大学MOOC的2023考研计算机组成原理课程所做的笔记。

感谢LY,他帮我做了一部分笔记。由于听的时间不一样,第四章前的内容看起来可能稍显啰嗦,后面会记得简略一些。

 

西电的计算机组织与体系结构课讲法和王道考研的课不太一样,要应付校内考试建议还是跟着老师学比较好。以下是20年西电计科院车向泉老师这门课的录播下载链接(请勿将录像上传到B站等网站!!):

链接:https://pan.baidu.com/s/1bFs3ajhy8ZcbHopS9izGsw

提取码:fdez

期中考试占20分,一般只考前两章内容,期末考试占60分,一般前两章内容不考,考察内容一般考前的复习课都会讲清楚,请务必认真听复习课。

 

其他各章节的链接如下:

计算机组成原理笔记(王道考研) 第一章:计算机系统概述

计算机组成原理笔记(王道考研) 第二章:数据的表示和运算1

计算机组成原理笔记(王道考研) 第二章:数据的表示和运算2

计算机组成原理笔记(王道考研) 第三章:存储系统

计算机组成原理笔记(王道考研) 第四章:指令系统

计算机组成原理笔记(王道考研) 第五章:中央处理器

计算机组成原理笔记(王道考研) 第六章:总线

计算机组成原理笔记(王道考研) 第七章:输入输出系统

其他各科笔记汇总

指令系统

通过之前的学习知道现代计算机的结构大致上可以分为这样的几个版块。通过第二章的学习已经知道了运算器如何实现加减乘除,移位运算这些操作。而通过第三章的学习又知道了数据如何存放在各种各样的存储器里面,然后CPU又是如何从存储器当中取走数据的。从这一章开始要学习CPU的控制器大致有什么样的功能,还有各种功能的基本原理。之前说过控制器有两个很重要的功能,第一个功能是解析各种各样的指令,然后根据不同的指令指挥其他部件协调地工作,所以这一章会重点探讨控制器需要支持的指令应该怎么设计。下一章再来具体地探讨控制器是如何控制和协调各种部件,让它们配合地工作的

image-20210123160052289

第一章介绍了计算机的工作原理,我们用高级语言写的一段代码被编译后可以形成一系列与之对等的机器指令,除了指令之外还会有一些数据同时被放到主存里。程序执行的过程其实就是CPU执行这些一条一条的机器指令。可以看到任何一条指令都会由操作码和地址码两部分组成。操作码指明了此时需要做什么,地址码指明了这个操作的运算对象,不过有的指令不需要地址码(如停机)。因此可以看到即便是对于同一台计算机,这台计算机所支持的指令类别也是千差万别的。各种各样的指令应该如何设计,这就是这一章要探讨的问题

在下一小节中会着重探讨指令的格式

指令格式

image-20210303171137853
指令的定义
image-20210304161807820

需要注意的是不同体系结构的计算机所能支持的指令集是不一样的。大家应该都听说过x86,ARM这两个名词,Intel系列的CPU就是x86架构的,现在使用的智能手机基本都是ARM架构。这两种架构所能支持的指令类型和指令集是不一样的,所以这才导致了在个人电脑PC上运行的各种各样的软件不能直接运行在手机上面,因为电脑里面运行的软件其实是基于x86架构的指令实现的,而手机里所运行的那些软件是基于ARM架构所支持的另一种指令集实现的

2020年苹果开发者大会上苹果公司宣布说接下来苹果电脑会逐渐抛弃Intel的芯片,转而使用它们自己设计的基于ARM架构的CPU。这样的转换就意味着在未来苹果电脑上很有可能会支持苹果手机上的应用

CPU所能支持的指令集应该怎么设计,这个问题会在计算机体系结构那门课里重点探讨。计算机组成原理这门课里只会简要地了解关于指令设计所需要考虑的一些问题

指令格式
image-20210304163532843
指令 - 按地址码数目分类
image-20210304164551318
零地址指令
image-20210304164326692
一地址指令
image-20210304164410374

O P ( A 1 ) → A 1 OP(A_1)\to A_1 OP(A1)A1:CPU首先会从 A 1 A_1 A1所指向的主存单元当中取出相应的数据,对这个数据执行OP,也就是这个操作符所指明的相对应操作,得到运算结果之后再把运算结果放回 A 1 A_1 A1所指向的主存单元。执行这样的指令需要进行3次访存,第一次访存是从主存当中取出这条一地址指令,然后第二次访存是根据 A 1 A_1 A1所指的内容去读出 A 1 A_1 A1这个地址所对应的主存单元,而第三次访存就是得到了结果之后把运算结果再写回 A 1 A_1 A1

( A C C ) O P ( A 1 ) → A C C (ACC)OP(A_1)\to ACC (ACC)OP(A1)ACC:其中一个操作数会由地址码显式地指明,而另一个操作数会隐含在ACC当中。这一类指令所需要做的事情就是把ACC里面存放的数据还有 A 1 A_1 A1所指明的地址当中存放的数据进行相应的操作运算,然后再把运算的结果存回ACC当中。完成这样的指令只需要2次访存,第一次是从主存当中取出指令,第二次访存是根据 A 1 A_1 A1所指向的地址从主存当中读出操作数。由于最后是把运算结果存回ACC当中,所以不需要访存

 

后面几小节中就不再对指令含义进行具体说明了

二、三地址指令
image-20210304164438153

三地址指令和二地址指令非常类似,二地址指令当中会把最终的运算结果默认存回 A 1 A_1 A1所指向的地址,而三地址指令当中会显式地给出最终的运算结果要存放到哪个位置

四地址指令
image-20210304164527122
指令 - 按指令长度分类
image-20210304164733049
指令 - 按操作码长度分类
image-20210304165238851
指令 — 按操作类型分类
image-20210304165306091

本质上4一类的指令实现的就是程序执行流的改变,正常来说程序是一条一条指令顺序执行的,但是写程序时难免会遇到if-else或者某些函数调用的情况,在这种情况下程序的执行流就不是顺序的,有可能会发生跳转。执行转移类的指令最终都会导致PC的值发生改变,因为PC指明下一条指令的存放地址,所以要改变程序的执行流本质上就是改变PC的值

算术逻辑操作类和移位操作类可以被称为运算类

 

下一小节会基于定长指令字结构和可变长操作码这两个条件来设计一个指令系统

扩展操作码指令格式

image-20210304165459138

下面来看一下如何设计这种指令格式的指令集

扩展操作码

假设每个指令字长为16位,每个地址码占4位。那么如果要设计一些三地址指令,就意味着3个地址码总共会占12位,由于指令总长是16位,只会剩余4位用来表示三地址指令的操作码。4位操作码最多可以表示 2 4 2^4 24=16种状态,也就是说三地址指令最多可以设置16条。但是如果还想设计二地址,一地址,零地址指令,那么必须保留4个操作码全为1这种状态码留作扩展操作码来使用,也就是说最多只能有15条三地址指令,操作码的范围应该是0000$\sim$1110,这么做是因为还需要添加一些二地址指令

所有的二地址指令开头的4位一定是全1,这样就可以理解为什么三地址指令当中要保留一个全1的状态。事实上CPU在取得一条指令时一定是直接读入16位。根据开头的这几位是否为全1,CPU就可以判断这是一条三地址指令还是二地址指令。如果开头的4位是全1而后面的这4位不是全1那这一定是一条二地址指令

这里也会发现只设置了15条二地址指令,后面的这4位全1的这种状态会留下来作为扩展。对于所有的一地址指令来说,前边的8位都会是全1。同样地这种设计是为了能够让CPU判断这是一条几地址的指令

同样地一地址指令也只能取15条,最后这4位全1的状态会用作拓展为零地址指令,这样当前面的12位都是1时就说明这是一条零地址指令。零地址指令就可以取后面的4位为全0一直到全1总共16种状态,因为不需要再往后拓展了

当然这里只是给出了其中一种扩展操作码的设计方法,还可以有其他的设计方法,等下再补充

image-20210304174203283

这里需要强调的东西见下图

image-20210304174243027

二地址指令的操作码是前边的8位,这是一个比较长的操作码,相比之下三地址指令的操作码只有4位,所以三地址指令的操作码是更短的操作码。对于二地址指令前边的4个bit都是全1,也就意味着三地址指令的这4个操作码也不能是全1的状态,否则就不满足上面的第一个条件

 

现在再基于刚才那个条件来设计另外一种扩展操作码,同样地指令字长固定为16位,然后每一个地址码规定需要4位。现在要求设计出具有15条三地址指令、12条二地址指令、62条一地址指令、32条零地址指令的一个扩展操作码

对于三地址指令需要保留后面的12位用来表示三个地址,那么总共16位的指令就只会剩下开头的4位用来表示操作码。由于需要表示15条三地址指令,这个操作码的范围就应该是0000$\sim$1110,总共有15种状态,然后会留下4个全1作为扩展操作码

接下来二地址指令只需要有12条。首先对于二地址指令,它开头的4个bit一定是全1。接下来由于只需要保留2个地址,每个地址是4位,因此只能用中间的这4位来表示12条二地址指令。那可以取0000$\sim 1011 ,翻译成十进制就是 0 1011,翻译成十进制就是0 1011,翻译成十进制就是0\sim$11这12种状态来分别对应12条二地址指令的操作码

此时大于1011的数还剩下1100、1101、1110、1111,可以发现剩余这几种状态最高的这两位都是全1,所以接下来开头的6位都是全1就超出了二地址指令的范围。接下来要设计一地址指令,一地址只需要保留最后的4位来表示地址,因此对于一地址指令可以用这里剩下的6个bit来表示不同的一地址指令。总共需要有62条一地址指令,因此只需要用这6个bit来分别表示0$\sim 61 ,用这 62 种状态来表示一地址指令,对应的二进制是 000000 61,用这62种状态来表示一地址指令,对应的二进制是000000 61,用这62种状态来表示一地址指令,对应的二进制是000000\sim$111101,这就是0到61的表示范围

在下表中给出了 A 1 A_1 A1 A 2 A_2 A2这两个部分可以取得的两个合法的范围,其实和刚刚说的是同一个意思。只不过刚刚这种描述是描述了这整整6位所能表示的范围,而在这个表当中以4位为一组来分别描述可以取得的范围

可以看到需要62条一地址指令,根据刚才的分析可以知道如果前面的这11位全都是1的话就超出了一地址指令的范围。最后需要32条零地址指令,那么现在剩下的这5个bit刚好可以表示0到31这个数字范围,所以零地址指令的范围就是后边这5个bit分别是全0一直到全1这32种状态

这是另一种扩展操作码的设计方式,需要根据每一种地址指令要有多少条来设计出合理的扩展操作码

 

接下来看一下对于这种设计方式来说CPU是如何解析一条指令的,首先CPU读入一条指令一定是读入了16个bit,然后接下来CPU会首先判断这16bit当中的前4位,如果前4个bit不是全1的状态,那么就说明这是一条三地址指令,那接下来CPU会根据这4位的指示,按照三地址指令的规则去执行这条指令

另一种情况,如果CPU检测到开始的这4位是4个全1,那么接下来CPU会检查后面的两位是不是全1。如果不是全1的话就意味着这是一条二地址指令,那么CPU就可以根据前8位来判断出这是一条什么样的二地址指令

如果CPU检测到前面的6位都是1,那么它还会继续检测后面跟着的这5位是不是也是全1。如果这5位不是全1就意味着这是一条一地址指令,那CPU就可以根据前面的12个bit来判断这是一条什么样的一地址指令,然后根据这个指令进行相应的操作

最后一种情况,如果CPU发现前面的这11位都是全1就可以确定这是一条零地址指令,那么就根据这一整串操作码的信息来判断这条零地址指令需要做什么,然后CPU执行相应的操作

这种方式保证了操作码更短的指令不可能是操作码更长的指令的前缀,这样CPU在解析这些指令时就不会出现歧义

 

接下来看一下计算的问题。对于扩展操作码如果地址长度是 n n n位(如这个例子中地址的长度是4位),那么如果上一层留出 m m m种状态,下一层就可以扩展出 m × 2 n m\times 2^n m×2n种状态。下面解释一下这是什么意思

首先三地址指令需要用开头的4bit表示操作码,4bit可以表示16种状态,但是会留出一种状态也就是4个全1作为下一层的扩展。所以下一层的二地址指令可以用4个全1再加上4个bit的信息来表示这是哪一种指令,4个bit总共可以表示16种状态,而上一层留下的扩展状态是1种,所以这一层总共可以表示的二地址指令就有 1 × 2 4 1\times 2^4 1×24=16种可能性,然而只取其中的12种表示二地址指令,也就是留下了16-12=4种状态作为下一层的扩展。因此下一层的一地址指令可以有 4 × 2 4 4\times 2^4 4×24=64种状态,只取其中的62种状态来表示一地址指令,留下2种状态作为下一层的扩展。所以下一层的零地址指令就可以有 2 × 2 4 2\times 2^4 2×24=32种状态

image-20210305085005889
指令操作码
image-20210304174312833

指令寻址

image-20210305185345125

这里的1理解为1个指令字长,实际加的值会因指令长度、编址方式而不同。后面会对这一点做具体说明

经过之前几个小节的练习,我们已经知道了指令在计算机内部是如何表示的。在逻辑上一条指令可以被分为操作码和地址码两大部分,其中地址码有可能会有多个。用高级语言写的程序最终一定会被转换为用机器语言表示的一条条指令,而这些指令序列在主存当中被顺序地存放。之前说过一个计算机当中有可能各条指令的长度不一样,那么计算机在执行这些指令序列时如何确定下一条指令的存放地址?这就是这一小节要探讨的所谓指令寻址的问题。CPU可以通过顺序寻址或者跳跃寻址这两种方式来确定下一条指令在主存当中的存放位置

第一章就提到过CPU内部有一个很重要的寄存器叫程序计数器PC(program counter),用PC来表明接下来应该执行哪一条指令。刚开始PC会被指向这个程序的第一条指令,接下来CPU取得这条指令之后会让PC的值自动加1也就是指向下一条应该执行的指令。用这样的方式可以把这些指令一条一条地往后顺序执行。所以正常情况下CPU确定下一条指令存放地址的方式很简单,只需要让PC的值加1,因为正常情况下这些指令都是顺序执行的

现在我们要注意这样的一些细节

在第一章的例子中给主存的这些存储单元编址时是按存储字,即按字编址的。每一个存储字可以存放2个字节的数据,所有这些指令也刚好都是占2个字节。所以这个例子比较特殊的是只需要简单地让PC不断地加1就可以找到下一条指令存放的存储字。如果主存按字节编址就意味着每一条指令会占2个地址,这样每次让PC简单地加1就不行了,需要加2,这是第一种值得考虑的情况。还有第二种情况,如果这个系统当中采用变长指令字结构,那寻找下一条指令的存储地址又会变得更复杂,这样PC的值简单地加1肯定就不行了

总之对于指令寻址这个问题,并不是让PC的值简单地加1就可以。不过有一点是可以确定的,PC始终都是指向下一条应该执行的指令的存放地址

image-20210305202312480
指令寻址

一条一条的机器指令在内存当中被顺序地存放。下图的每一行都是16bit(2B),假设每一行刚好就是一个存储字,然后在这个系统当中采用定长指令字结构,并且指令字长=存储字长=16bit=2B。另外还规定这个主存是按字编址的。这样两条指令之间的地址刚好就相差1,也就是刚才所说的最简单的情况,这样CPU每取走1条指令之后只需要简单地让PC的值加1指向下一条指令就可以

image-20210305202809591

这里用二进制机器语言表示指令,可以把它换成比较好看的汇编语言的形式。每一条汇编语言和机器语言是对应的,同样有操作码和地址码,每一条指令占一个存储字

接下来模拟一下这个程序的执行过程,由于第一章有过说明,直接看下图即可,此处详细讲解略过不记

注意PC指向的是下一条应该被执行的指令所存放的地址,并不是当前执行的这条指令的地址。当正在执行的指令结束之后,CPU才会根据PC所指向的位置去取下一条指令同时执行这条指令,另外也让PC的值继续加1指向下一条指令。总之就是每取走一条指令都让PC加1,同时CPU执行当前这条指令

image-20210305203858644 image-20210305203913253 image-20210305203927463 image-20210305203955874

接下来把设定的这几个条件改一下,其他不变,主存按字节编址。这意味着每一条指令会占2个地址,如第一条指令的前8个bit对应字节地址是0,后8个bit对应字节地址是1。这样CPU每取一条指令之后应该让PC的值加2

总之如果主存的编址方式发生改变,那对PC的处理也会相应地发生改变

image-20210305204828778

到目前为止看的是定长指令字结构,也就是每一条指令的长度都是确定不变的,接下来再看一下如果这个系统采用变长指令字结构,同时也是按照字节编址,那这种情况下PC的值又应该怎样变化呢?

下图中具有相同颜色的存储字就表示这几个存储字共同组成了一条指令,这种情况下不同指令的指令字长是不一样的,不再是固定的2B

来分析一下这些指令的执行过程,刚开始PC的值肯定是指向第一条指令的存储地址0,由于CPU无法确定当前指向的这条指令到底占几个存储字,CPU可以首先读入第一个字的内容。由于操作码被包含在了第一个字里面,所以CPU可以根据这里面隐含的操作码来判断出这条指令到底是几地址的指令,就可以确定这条指令总共占多少个字节。接下来CPU还会读入后面的字节,这样就得到了完整的指令。在取指令阶段结束之后CPU会把PC的值加上n,这里的n指的是刚才取出的这条指令的总字节数(因为是按字节编址的,所以需要加上总字节数)。这样的操作就导致PC指向了下一条指令的存放地址

这就是采用变长指令字结构,按字节编址的处理方式。由于无法预先判断当前要执行的这条指令的指令字长到底是多长,因此CPU都会先读入一个字,然后接下来根据指令的类型,有可能还要进行多次访存读入一条指令后续那几个字

image-20210305212436197

目前为止介绍的指令寻址方式都可以统一归为顺序寻址,因为到目前为止介绍的例子都是一条一条往后顺序地执行各个指令。接下来再来介绍第二种指令寻址方式跳跃寻址,可以根据转移指令指出接下来应该执行的指令在什么位置。还是结合刚才使用的例子继续往下分析,事先的规定见下几张图

前面的三条指令只是进行简单的算术运算和取一个数的操作,并不会改变整个程序的执行流。直接看下图即可,此处详细讲解略过不记

image-20210305213817999 image-20210305213832017 image-20210305213846866 image-20210305213900439

对于3这个地址所存放的指令就不一样了,当CPU执行到3地址所存放的这条指令时,首先CPU取出这条指令之后,同样会让PC的值加1,也就是指向4这条指令。但是CPU执行这条指令的过程中会发现这条指令是一条无条件转换指令JMP(jump,跳转的意思)。JMP后面跟着的地址7意味着下一条应该执行的指令存放在哪个位置(有点类似于C语言里的goto语句),所以执行这条指令的效果就是让PC的值强行变更为7,执行了这条指令之后CPU就会根据PC所指向的位置去取出下一条应该执行的指令

因此JMP这条指令改变了程序的执行流,通过这种转移指令让CPU跳跃着找到了下一条指令的存放位置

image-20210305213913534 image-20210305214134479

所以前边每一次PC加1的这种指令寻址方式就是顺序寻址。而通过转移类指令改变了程序的执行流的这种指令寻址方式就是跳跃寻址

image-20210305214909194

除了无条件转移指令JMP,还有函数调用指令CALL也会改变程序的执行流

 

数据寻址

image-20210305215301425

这里给出的是指令执行期间需要访存多少次,排除了取指令所需要的那一次访存操作

需要重点关注的是如何根据形式地址得到最终的有效地址,不同的寻址方式对形式地址A的解读方式也不一样

指令寻址与数据寻址

之前说过一条指令逻辑上由一个操作码和若干个地址码组成。上一小节的例子当CPU执行到JMP这条指令时,它会知道接下来需要把PC的值改为7,这也就意味着接下来要执行的指令存放在主存地址为7的这个地方,那对于这个例子来说JMP这条指令的地址码所指向的就是一个真实的地址。这个例子给的这个程序这段代码是从主存地址为0的这个单元开始往后存储的,刚好JMP指令想要跳转到的那条指令的地址就是7,所以把PC的值直接改为7并不会导致这个程序运行错误

但是同一时刻计算机主存里面可能存在很多正在并发运行的程序,这也就意味着无法保证当前要运行的这个程序一定可以从地址为0的这个地方开始存储。所以假设要运行的这段程序是从主存地址为100的地方开始存储的,那么JMP这条指令的地址码的含义会出现错误。如果依然按照之前的解释方式来解读地址码的真实含义,那CPU执行到这条指令时就意味着执行了103这条指令之后,接下来需要跳转到地址为7的地方运行这个地方所存储的指令,这显然是错误的,因为这个程序都是从100往后存储的,地址为7的那个地方所存储的指令应该从属于其他程序

 

那对于这个例子要如何解读这个地址码7的含义?可以这么来看,这段程序代码是从地址单元100这个地方开始存储的,这是这个程序的起始存放地址。所以这个地方的7可以解读为基于这个起始地址往后的一个偏移量,也就是这个地址码的含义应该是100再加上7来往后偏移7,用这样的方式解读这个地址码就可以得到期待的运行顺序

所以不能简单粗暴地认为一条指令所包含的地址码指向的就是一个真实的地址。有的情况下需要改变对这个地址码的解读方式

 

再看一个例子,现在把这条JMP指令的地址码改为3,当CPU执行这条指令之后依然期待接下来执行的是107这条指令,那这里的3应该怎么解读?当前CPU正在执行的是103这条指令,上一小节说过CPU每取出一条指令之后都会让PC的值自动加1,这也就意味着当CPU执行这条指令时,PC应该指向104这个位置。所以对这个地方的地址码3正确的解读方式是从PC所指向的地址往后偏移3个单位,104+3=107。在这个例子当中对地址码的解读方式又发生了变化

下图中间的解读方式是基于程序的起始地址往后偏移多少个地址,而右边这种解读方式是基于PC往后偏移多少个位置。通过接下来几个小节的学习会知道计算机常用的数据寻址方式会有哪些

image-20210305224045182

现在新的问题出现了,给出的指令由操作码和地址码这两部分组成,但是刚才说过数据的寻址方式也就是地址码的解释方式可能会有很多种,那如何区分一条指令的地址码应该用什么方式解读?

通常来说可以在地址码的前面加上几个bit位用来标识地址码应该采用什么样的寻址方式。这里会学习10种寻址方式,所以只需要用4个bit来标识就可以。因此在原有的指令基础上再增加上几个bit位来表示数据寻址的方式,根据加上的中间几个bit的数值可以确定用什么样的方式解读形式地址来得到最终的真实地址

EA表示有效地址,A表示形式地址

这是对于一地址指令,如果是多地址指令,由于其中会包含多个形式地址,对各个形式地址的解读方式可能会不一样,因此每一个形式地址的前边都会配上一个寻址特征

在接下来的讲解中要认识10种数据寻址的方式,也就是10种形式地址的解读方式,只需要基于一地址指令来讲解和分析,二地址指令是一样的原理

为了讲解方便,接下来默认讲解的计算机系统当中指令字长=机器字长=存储字长,并且假设最终想要找到的操作数是3

image-20210306090951228 image-20210306091014524 image-20210306091033463
直接寻址

直接见下图即可,此处详细讲解略过不记

image-20210306091129361

中间有4个bit用来表示接下来形式地址应该采用直接寻址的方式解读

这个数据读出之后是放到寄存器当中,所以写入寄存器不算访存

间接寻址

指令里面给出的形式地址A指向了某一个主存单元,在这个主存单元当中存储了最终要取得的操作数的真正的地址

image-20210306091207511 image-20210306091230669

两次间接寻址中,指令里面的形式地址A指明了第一个地址信息所存放的主存单元。如果这个主存单元里面保存的第一个比特位是1,就意味着还要继续往下寻址,以这个存储单元里边存储的数据作为地址去查找下一个存储单元,接下来这个存储单元的刚开始这一位是0,就意味着这个存储单元所保存的地址就是最终的操作数的有效地址,所以再根据这个地址信息找到最终的操作数

A表示这个地址,(A)表示的是这个地址所指向的主存单元里面的数据

对于第二个优点的理解涉及汇编语言,这里就不展开了

寄存器寻址

这里给出的地址码并不是指向了某一个主存单元而是指向了某一个寄存器的编号。根据寄存器编号直接去寄存器里面找数据

image-20210306091252454

CPU内部会有很多通用寄存器,它们都有自己的编号。由于寄存器数量不可能太多,所以编号数目也不会特别大,用很短的几个bit就可以表示所有寄存器的编号,整个指令的字长就会变得比较短

寄存器间接寻址

直接见下图即可,此处详细讲解略过不记

image-20210306091315575
隐含寻址

结合第一章的例子来理解,有的指令显式地给出的地址只指明了其中一个操作数存放在什么位置,另一个操作数会默认隐含在ACC中

image-20210306091338402
立即寻址

想要的操作数会直接被显式地写在指令当中,形式地址A就是操作数本身而不是操作数的地址

image-20210306091406667

这里寻址特征部分写了一个"#“,大家在指令当中看到”#"时就意味着后面跟的这段形式地址并不是地址而是立即数

很多汇编语言里面如果一个指令后面跟了一个"#“,然后后面又紧跟了一个数字(如LOAD #985),则后面跟的这串数字是一个立即数而不是一个地址。也正是因为汇编语言里面经常用这样的方式来表示一个立即数,所以我们在计算机组成原理这门课里也会用一个”#"来特别地标识这是立即寻址方式

数据寻址2(偏移寻址)

image-20210306091509897

上一小节介绍了6种数据寻址的方式,这一小节会介绍剩下3种,这3种寻址方式都可以归为偏移寻址

上个小节刚开始时举过这样的3个例子,最左边的例子采用直接寻址的方式直接访问这条指令所指向的位置。而中间的例子由于这段程序的起始存放地址是100,所以对JMP这条指令进行解析时,对于7这个地址码就应该理解为从起始地址开始往后偏移7个单位。而最右边的例子可以把形式地址3理解为从当前PC所指向的地址往后偏移3个单位

右边这两个例子都有一个特征,就是以某个特定的地址作为起点,然后加上形式地址所表示的“偏移量”得到最终的有效地址

image-20210307140322760

这一小节会学习3种寻址方式相对寻址、基址寻址、变址寻址。这三种寻址方式都可以被归为偏移寻址,因为它们的共同特点是以某一个特定的地址作为起点然后偏移形式地址A这么多的单位,上图右边的两个例子就分别是基址寻址和相对寻址,而变址寻址会以变址寄存器IX这个寄存器里存放的地址作为起点再偏移A这么多的偏移量。这3种偏移寻址的区别在于它们选取的偏移起点不一样

image-20210307142944833
基址寻址
image-20210307143555638

基址寄存器会指向当前这个程序的起始存放地址,要得到最终的有效地址只需要用基址寄存器里存放的地址再加上形式地址A也就是偏移量。上图画了一个ALU算术逻辑单元,也就是说把BR和A这两个数据送给ALU进行加法逻辑运算就可以得到最终的有效地址EA

有的计算机内部不会专门地设置一个基址寄存器而是使用某一个通用寄存器来代替前者的功能。比如说总共有n个通用寄存器,编号分别为0 ∼ \sim n-1,这条指令当中指明了要采用基址寻址,另外用几个bit位指明基地址存放在哪一个寄存器当中

目前为止我们已经大致了解了基址寻址的硬件实现原理,接下来再探讨为何基址寻址有存在的必要

基址寻址的作用

还是第一章的例子,下图中这条指令的数据寻址方式采用直接寻址,也就是直接去访问5号地址,用这种方式处理可以得到期待的结果

image-20210307150516075

但是现在假设这段程序是从内存地址100这个地方开始存放的,解析刚才提到的第一条指令的地址码就不应该采用直接寻址而应该采用基址寻址,基址寄存器BR会指向这个程序在内存当中的起始存放地址也就是100这个地方。所以基址寻址的意义在于可以便于程序的“浮动”,就是说这个程序可以从内存当中任何一个地址开始往后存放,当这个程序在主存当中的位置发生改变,操作系统只需要修改BR这个寄存器的内容,永远让BR指向当前这个程序的起始地址,这样这段程序就不用更改,保持原来这个地址码就可以

有了基址寻址之后就可以很方便地实现多道程序并发运行,因为主存里面可能会同时存在多个程序的数据,每一个程序的起始存放位置都不一样

image-20210307155029437

每个程序运行之前CPU的BR的值都会被修改为当前运行程序的起始存放地址,而这个信息通常存放在进程控制块PCB当中

基址寻址的特点是

image-20210307155752489

也就是普通程序员不可以操作BR里面的值,因为我们写的应用程序到底被放到内存的哪个位置是由操作系统来负责管理的,我们决定不了。而BR指向了应用程序的起始存放地址,因此BR的值显然应该由操作系统负责管理

普通程序员可以用汇编语言直接读写某个通用寄存器的内容。但是如果某一个通用寄存器被指定为基址寄存器,那么接下来这个通用寄存器里面的值不可以被随意修改,其中的内容由操作系统负责管理

变址寻址

除了寄存器的名称,看上去和基址寻址几乎一模一样。直接见下图即可,此处详细讲解略过不记

image-20210307160956386
变址寻址的作用

下面通过一个例子来了解变址寻址的作用

假设现在要实现如下代码的功能,即把数组的 a [ 0 ] ∼ a [ 9 ] a[0]\sim a[9] a[0]a[9]元素进行加和存到变量sum里面。要实现这个循环里面的10次加法操作可以用10条加法指令来实现,然后最后再把ACC里面累加的结果放回sum变量里面。这里能够感受到对于这种循环类的操作,如果按照之前知道的老办法,那么每一次循环都需要对应一条指令,这就会导致编程很不方便和灵活。比如万一未来要实现 a [ 0 ] ∼ a [ 19 ] a[0]\sim a[19] a[0]a[19]相加,按照这种方法还需要继续在最后的指令的后面再增加一些加法指令。因此就需要引入变址寻址来实现循环操作

image-20210307163720557

地址码前面打“#”说明当前这条指令采用立即寻址

这里省略了用来指明这条指令寻址方式的几个bit

引入变址寻址之后,刚才这段程序可以改造成下图的样子,用6条指令就可以实现。此处详细讲解略过不记,只针对下图的内容写了一些批注

  1. 中间的加法指令采用变址寻址,变址寻址就意味着有效地址等于变址寄存器IX里的地址加上形式地址A,这条指令的形式地址A指向了7这个位置,也就是数组第一个元素的存放地址。现在变址寄存器IX的值是0,故现在执行这条加法指令导致的结果就是ACC里的内容0加上(IX)+A=0+7=7这个单元里的数据(即a[0]的值),然后把相加的结果放回ACC寄存器当中。对照高级代码就是完成了第一轮的循环
  2. IX的值和立即数10进行比较在硬件层面实现的逻辑暂时还没有细讲,这个问题先留下,在这个小节的最后再补充。这里只需要知道进行10和IX的比较本质上就是硬件会进行10-(IX)的操作,接下来这个条件跳转指令会判断刚才的计算结果是否大于0,如果大于0就跳转回2这个地址,即把PC的值改为2
image-20210307165452911

变址寻址的特点是

image-20210307172301408
基址 & 变址复合寻址

接下来看一种复合的寻址方式,即基址寻址和变址寻址的结合。刚才这个例子当中默认了这个程序是从主存地址为0这个地方开始往后存放的,因此当IX等于2时刚好可以访问到 a [ 2 ] a[2] a[2]这个单元,因为基址7加上变址2刚好等于9

 

现在假设这段程序从内存地址为100的地方开始存放,也就是说第一条指令存放在100, a [ 2 ] a[2] a[2]这个数组元素就应该存放在109这个地址。这时执行这条加法指令就不能只用变址寻址,还需要结合基址寻址来得到最终的有效地址

要采用基址寻址就需要有基址寄存器,它指向了当前这个程序的起始地址100。之前已经知道了基址寻址和变址寻址的有效地址的计算方式,现在把这两种寻址方式结合。先进行基址寻址,把当前这条指令当中给出的形式地址A加上地址BR的内容(A=7,(BR)=100)得到107,107已经指向了 a [ 0 ] a[0] a[0]这个元素。接下来在基址的基础上再进行一次变址寻址,也就是再加上变址寄存器IX里的内容((IX)=2),得到最终的有效地址109

因此在实际应用当中往往需要多种寻址方式复合着使用的,大家可以把每一种寻址方式理解为是一种函数,因为每一种寻址方式都是要把形式地址A映射为一个有效的真实地址EA,只不过不同寻址方式的映射规则不一样。多种寻址方式的复合其实就是相当于把不同映射的规则结合起来,类似于数学里的复合函数

image-20210307172335919
相对寻址
image-20210307215337246

由于地址A用补码表示,因此当A为负时就相当于是在PC的基础上减掉一个正数,故这种寻址从PC所指的地址往前偏移或者往后偏移都是可以的,因为A可正可负

相对寻址的作用

接下来还是通过一个例子理解相对寻址有什么作用

先基于上一小节的例子,如果现在想要挪动for循环的位置,站在汇编语言程序员的视角就相当于要把for循环主体的这几句指令挪一下位置

image-20210307220744723

假设挪到下图的位置,显然不能再按照直接寻址的方式解析跳转指令。为了解决这个问题引入相对寻址。相对寻址是在PC所指的地址的基础上进行偏移,目前CPU正在执行的是跳转指令,那么CPU取出这条指令之后,PC的值会自动指向下一条指令也就是M+4这个地址。现在为了让这个循环能够正常地工作,我们所期待的结果是让PC的值从当前PC所指的地址往回偏移4个地址,因为M+4-4就可以回到M。所以把这条跳转指令的地址码改为-4,这个地址码是用补码表示的

image-20210307221728118

现在这条指令采用相对寻址,因此这条指令真正要跳转到的地址就应该是(PC)+A=M。由于跳转指令本质上是在修改PC的值,所以会把经过相对寻址得到的有效地址值赋给PC,让PC重新指回M这条语句,这样就可以保证for循环正常进行

并且之后无论for循环的这几句指令被放到程序的哪一个位置,只要采用这种相对寻址的方式,那么这条跳转指令都一定可以让程序的执行流重新跳转回加法这个地方。也就是之后无论这段汇编代码被放到什么位置都不需要再修改跳转指令的地址码,让它永远保持-4并且采用相对寻址就可以得到正确的结果

image-20210307223057245

这段程序整体变长之后数组元素 a [ 0 ] a[0] a[0]可能就不再存放在7那个地址而是存放到后面的某一个位置,这是不是意味着得修改上面这条加法指令的地址参数?显然如果每一次挪动代码都需要修改地址参数,对于程序员来说也是一件很麻烦的事。现实当中如何处理这个问题?

通常可以进行分段,把一个进程的数据分为程序段只保存指令代码和数据段专门存放数据。进行分段之后我们想要访问的那些变量,数组元素在段里的相对位置就可以保持固定不变,也就是说之前写好的这条加法指令给出的形式地址不需要改变

相对寻址的特点是

image-20210307223811658

之前说过采用基址寻址也可以方便程序的浮动,在基址寻址当中所说的程序的浮动指的是整段程序在内存里的浮动,而相对寻址的程序浮动指的是一段代码在程序内部的浮动

硬件如何实现数的“比较”

直接见下图即可,此处详细讲解略过不记,只针对下图的内容写了一些批注

image-20210307224641473
  1. 转移指程序执行流的转移
  2. 上图中站在高级语言的视角就相当于a>b这个条件满足时程序执行流会被转移到else所包含的这一系列语句当中。站在硬件层面我们执行这条if语句相当于硬件帮我们做了一个a-b的操作,如果a>b,这个减法操作会导致ZF=0,SF=0,硬件电路会以这两个bit位作为输入信号然后通过电路的处理把PC的值指向else所对应的第一条语句,这样就完成了一个条件转移指令
  3. je = jump when equal,jg = jump when greater

数据寻址3(堆栈寻址)

image-20210306091551390

对于堆栈寻址,当要进行出栈操作时,当前SP所指向的地址就是栈顶元素,也就是想要访问的那个操作数的实际有效地址。而入栈时要先改变SP的值,之后SP所指向的地址才是最终要写入的有效地址,所以堆栈寻址入栈和出栈时有效地址的确定方式是不太一样的。当然SP的值加1还是减1这些事情是由硬件自动帮我们完成的,所以也可以说采用堆栈寻址时有效地址就是SP所指向的地址

 

注意上表写的是指令执行期间,取指令还需要访存

堆栈寻址

CPU内部会有专门的堆栈指针SP,存在专门的寄存器当中,即SP这个寄存器指向栈顶元素。所以如果一条指令采用堆栈寻址,那么就不需要我们显式地给出操作数的存放地址,它的存放地址隐含在SP这个寄存器当中

系统的堆栈可以有两种实现方式,一种是设置一组专门的寄存器,每一个寄存器存放一个堆栈元素,另一种是在主存当中划出一片区域作为堆栈

 

先来看用一组寄存器实现的堆栈是如何操作的。假设像下图一样用4个寄存器实现堆栈,最上面的 R 0 R_0 R0寄存器是栈顶,最下面是栈底,另外系统当中会有一个专门的寄存器SP指明当前栈顶元素所存放的位置

假设此时整个堆栈已经存满了,栈顶元素就是 R 0 R_0 R0里存放的元素,所以SP应该指向 R 0 R_0 R0。由于只有4个寄存器,所以SP只需要用2个bit就可以表示所有寄存器

image-20210310102153239

假设现在想要用堆栈里的两个栈顶元素完成一次加法操作,加数和被加数会先被放到ACC和X这两个寄存器当中,然后通过ALU的计算把结果输出到另一个寄存器里面。为了书写方便,记当前栈顶单元为 M s p M_{sp} Msp,就是SP所指向的这个存储单元。现在要把栈顶和次栈顶的元素相加,可以用一条汇编语言指令POP弹出栈顶的元素,这个元素会被存放到ACC寄存器当中导致ACC的值变为0001,所以这里也需要指明弹出的元素存放的位置。现在由于栈顶元素弹出,所以需要让SP的值加1指向次栈顶的元素,这相当于在逻辑上把栈顶元素给删除了

image-20210310104602849

参与加法运算的第二个操作数是1001,同样用POP指令把栈顶元素放到X寄存器当中,这导致X的值变为1001。接下来同样地弹出一个元素后让SP的值加1指向下一个元素,相当于在逻辑上删除了上面的两个元素

image-20210310105527616

现在加法运算所需要的两个数已经准备好了,接下来执行一条加法指令,加法的结果放到寄存器Y里面。这就会导致ALU开始工作,然后把相加的结果输出到Y这个寄存器当中。最后要把这次加法的运算结果压回栈顶,压栈操作会用到汇编语言的PUSH指令,把Y寄存器里的数据压回栈顶,这个操作首先要把SP的值减1让它指向1号寄存器

image-20210310105903813

然后再把Y的值放到当前SP所指向的寄存器当中,将里面的数据覆盖为1010。这样就用原本这个栈里的两个栈顶元素相加得到了一个结果并且又重新压回栈中

 

这里执行的PUSH和POP这两个指令就使用了堆栈寻址,这两个指令要访问的操作数所存放的位置被隐含在SP当中。当要弹出一个栈顶元素时就是弹出此时SP所指向的元素,出栈之后SP必须加1。而对于入栈需要让SP的值减1,然后再把想要存的元素存到SP所指向的这个地址。这就是堆栈寻址,要操作的数会隐含地用SP这个寄存器指明

刚才这个例子规定栈顶处于地址更小的这个方向,还有的题目可能栈顶是在地址更大的方向。大家要能写出出栈和入栈操作背后所需要进行的一些处理,特别是SP栈顶指针的变化

image-20210310110523755

目前为止介绍的这个例子是专门用几个寄存器来实现堆栈,这种堆栈称为硬堆栈,还有一种堆栈的实现方式是软堆栈,会从主存当中划分出一片区域作为堆栈。对于刚才的POP和PUSH这两个操作,如果采用软堆栈则弹出一个栈顶元素和压入一个栈顶元素都一定需要一次访存。而如果采用硬堆栈,由于这些栈元素都存放在寄存器当中,因此无论是对栈的压入还是弹出都不需要进行访存

显然采用寄存器实现的硬堆栈速度更快,但是成本高。在实际系统当中通常使用软堆栈实现

image-20210310111644975

程序运行过程中和函数调用还有局部变量相关的信息都会被保存在这个程序所对应的软堆栈当中。所以有了软堆栈和堆栈寻址才可以实现函数的调用,至于函数调用时需要往堆栈里面压入或弹出哪些信息见数据结构相关内容

 

高级语言与机器级代码之间的对应

image-20220914151027854
高级语言 —> 汇编语言 —> 机器语言
image-20220914151212259

C语言当中的一句代码很有可能对应好几条汇编语言指令,而汇编语言指令和机器语言指令之间一一对应

x86汇编语言指令基础
image-20220914152152432
以mov指令为例
image-20220914171653698

蓝色 —— 寄存器

紫色 —— 立即数

绿色 —— 内存地址

红色 —— 指明读写内存地址时要读多少个字节

中括号里的内容是主存地址,地址前面通常要指明此次要读写多少比特

x86里的寄存器
image-20220914171901338 image-20220914171835002 image-20220914171937327
更多例子
image-20220914172016875

 

常用的x86汇编指令

image-20220914172201026
常见的算数运算指令
image-20220914172238139

在进行除法运算之前需要把被除数进行位扩展,更高的32位存放在edx,更低的32位存放在eax

关于王道书的解释
image-20220914172552426

在x86汇编语言指令当中不允许两个操作数同时来自于主存

常见的逻辑运算指令
image-20220914172703680
其他指令
image-20220914172735236

 

AT&T格式 v.s. Intel格式

image-20220914174005518

 

image-20220914174037621 image-20220914174105591

如果给出一个指令后面没有加b、w、l,默认此次读写长度为32bit

基址 + 变址 × \times ×比例因子找到想要访问某一个的数组元素,而一个数组元素里面可能包含很多个变量,用加上偏移量的方式来指明要访问哪几个字节

 

选择语句机器级表示

程序中的选择语句(分支结构)
image-20220914184709987

 

image-20220914184734985 image-20220914184807367
无条件转移指令 —— jmp
image-20220914184909964
条件转移指令 —— jxxx
image-20220914184946330
示例:选择语句的机器级表示
image-20220914185022450
历年真题
image-20220914185042412 image-20220914185112683

f1指的是这个函数的起始地址,也就是它的第一条指令所存储的地址

扩展:cmp指令的底层原理
image-20220914185218336

 

循环语句机器级表示

用条件转移指令实现循环
image-20220914185326891 image-20220914185354820
用loop指令实现循环
image-20220914185425861

不可以用其他的寄存器代替ecx

 

CISC 和 RISC

通过之前的学习我们已经知道了在一个系统当中指令的格式应该怎么设计。另外介绍了指令寻址和数据寻址,前者探讨的是如何找到下一条应该执行的指令的地址,后者探讨的是如何解释指令里边所包含的形式地址。通过前面的学习我们可以感受到指令系统的设计其实是很灵活的,这一小节要学习的CISC和RISC就是指令系统的两种设计方向

image-20210310114504064
CISC 和 RISC

直接见下图即可,此处只针对下两图的内容写了一些批注,详细讲解略过不记

image-20210307230148310
  1. CISC:复杂指令集的计算机系统/RISC:精简指令集的计算机系统
  2. 复杂指令集的设计方式通常会用于笔记本、台式机或者一些商业服务器,因为这些地方可能对功能的复杂性要求更高一些
  3. “存储程序”这个概念之前提到过,我们可以给定一串基本的指令,把它提前存储在某个地方。把复杂指令的实现拆解为由基本指令完成的操作,这就是微程序的概念。CPU对外提供的某些复杂的指令功能在内部是由某些更简单基本的功能组合实现的

 

image-20210307230124319
  1. 由于RISC里面的这些指令都很简单,都是一些基本的指令,因此所有的这些指令执行时间都差不多。这个特性可以很方便地实现“并行”和“流水线”技术,这两个术语的具体含义会在下一章当中进行学习

  2. 由于CISC当中指令的功能很丰富,因此各指令的指令字长是不固定的,可能有很长的指令,也可能有很短的指令。而RISC当中由于各个指令的功能都比较单一,因此可以比较方便的设计出定长指令字结构。之前说过一个指令系统当中如果每一条指令的长度都是相等的,那么对指令的解析速度也可以得到提升

  3. Load:读某个主存单元到某个寄存器当中/Store:从某个寄存器当中往主存里写一个数据

    由两种指令系统在可访问指令方面的差异可以知道第一章中举例的指令系统一定是CSIC,因为该例子中乘法指令也可以访存。也正是由于这个特性,CSIC当中只需要比较少的寄存器,而RISC当中需要有比较多的寄存器,这点从第一章中举例的乘法指令也可以体会的到,CISC里的乘法指令会直接把需要相乘的数取到某一个寄存器然后紧接着就完成乘法操作,不会过多地占用寄存器。而对于RSIC无论要实现的是加法、乘法还是除法中的哪一种运算都一定需要先用一条指令把数据从主存读入到某一个寄存器,然后再用一条乘法指令对两个寄存器的值进行相乘。由此可以看到如果采用RISC,作为程序员也许会长期地占用很多个寄存器

  4. 之前说过CSIC有点类似于有很多库函数的C语言,你当然可以通过一句代码调用一个库函数就完成一个复杂的功能,但是如果这个库函数实现的不是特别好,执行效率不是很高,你是不能修改库函数的。而RSIC有点类似于没有库函数的C语言,当我们要实现某个复杂功能时不可以直接调用某个库函数,但是由于我们使用的是C语言最基本的语法,因此完全可以自己用合理的方式来优化程序代码让我们的代码执行起来更高效。这两种指令系统在目标代码方面的区别原理都是和C语言类似的

  5. 在第五章会进一步探讨微程序控制,现在只需要知道组合逻辑控制效率更高

  6. 由于CSIC当中各种指令的执行时间相差比较大,所以指令流水线的实现会比较困难。而RSIC当中由于所有的指令几乎都可以在一个周期内完成,因此可以很方便地实现指令流水线。而实现指令流水线之后可以让CPU整体的效率实现质的飞跃,因此RSIC当中指令流水线都是必须实现的

什么叫指令流水线/控制方式,什么叫微程序控制/组合逻辑控制?这些术语背后有什么含义?这是下一章要探讨的问题,这里只需做一个简化的了解

 

Logo

汇聚原天河团队并行计算工程师、中科院计算所专家以及头部AI名企HPC专家,助力解决“卡脖子”问题

更多推荐