我们知道,计算机指令是指挥机器工作的指示和命令,程序就是一系列指令按照顺序排列的集合,执行程序的过程就是计算机的工作过程
从微观上看,我们输入指令的时候,计算机会将指令转换成二进制码存储在存储单元里面,然后在即将执行的时候拿出来
那么计算机是怎么知道我们输入的是什么指令,指令要怎么执行呢?
这就要提到ISA也就是指令集, 指令集就是CPU用来计算和控制计算机系统的一套指令的集合,而每种新型的CPU在设计的时候就规定了一系列和其他硬件电路配合的指令系统
计算机就可以通过指令集,判断这一段二进制码是什么意思,然后通过CPU转换成控制硬件执行的信号,从而完成整个操作,这样一来,指令集其实就是硬件和软件之间的接口(interface),我们不再需要直接和硬件进行交互,而是和具有更高的抽象程度的ISA进行交互,集中注意在指令的编写逻辑,提高工作效率
指令集不仅仅是指令的集合,还包括全部指令的指令格式、寻址方式和数据形式
ISA设计中的一个重要的设计因素就是一条指令中可以使用的操作数的个数,它会影响到字的长度(word size)和CPU的复杂程度
以ADD为例,两个数相加需要两个操作数,结果需要一个操作数,可能还因为指令跳转需要第四个操作数,如果每条指令都要这么多操作数,那么这个指令就会变得很长很长
但是在现实生活中,我们很少用到上面说的这种存有四个操作数地址的指令结构,最常见的是1、2和3个地址,原因有:
按照使用的地址的个数,我们可以将ISA分成几类:
TIPS:寄存器
寄存器是计算机存储结构中最靠近CPU的存储器,它比cache和主存储器更快,但是相应的,寄存器的容量很小,而且寄存器的数量是有限的
寄存器中存储的是指令和数据
简单介绍一下这几种就是:
Stack:我们需要在计算之前要先把第一个操作数 A给PUSH进去,然后直接在下一条指令里面用到第二个操作数(ADD B) ,这个时候Stack的顶端就是A+B,最后我们把它POP出来聚能得到结果
Accumulator:假设我们要计算(A+B)* (C+D),那么我们可以先计算A+B,这个时候我们先将A 加载到累加器里面(LOAD A),然后再将B拿出来进行加法运算(ADD B),这个加法运算是发生在ALU里面的,然后这个时候,ALU里就有了A+B的结果,我们把这个结果放回存储器的E中,然后进行C+D,当ALU中有了C+D之后,我们不是将它存储进存储器,而是拿出E来乘(MUL E),ALU计算完成之后,里面就有了我们要的结果,但是❗️我们还要把它存回存储器(Store X)
剩下的三个原理差不多,可以认为是我们有可以存放不只一个操作数的缓冲空间,这样我们就有了更多选择,可以从Mem中直接拿,可以先拿到Reg里面再进行操作……
接下来再来看看操作数对指令格式的影响
如果我们的指令长度是16bit,OpCode占用4bit,每个地址占用6bit,那么指令就是:
各个部分所占用的长度取决于“数量”:
上面的OpCode一共要使用4bit,其实我们就能看出,这个指令集中一共有2^4个指令类型
每个地址要有6bit,所以我们推断要么寄存器有2^6个存储单元,要么Mem有2^6个存储单元
如果ISA只支持寄存器取数,一共有2^m个指令,寄存器存储单元有2^k个,每个指令需要x个操作数
那么这条指令的长度就是 (m + k * x) bit
如果要求Mem取数,就要看Mem有多少个存储单元
我们下面主要以MIPS以参考对象来进行解释和学习
MIPS是寄存器-寄存器型的架构,一共有32个寄存器,每个寄存器的大小是32bit,而且MIPS要求:所有的操作数必须从寄存器中拿,如果操作数在Mem里面,那么就要先从Mem拿到寄存器里面,然后再进行操作
a. MIPS中有一些特殊的寄存器:
b. 寄存器的操作顺序:
我们拿 add R5,R5,R6来举例,这个指令的意思其实是将R5 + R6的数值存储到R5中,也就是说,在operation之后的第一个寄存器是目的寄存器,第二、三个寄存器是操作数所在的寄存器
首先我们要知道,从微观上来说,存储器是一个很大的一位数组,数组中的每个元素的location占用1B也就是8 bits,地址从0开始编号
a. 存储器 vs 寄存器
存储器 | 寄存器 | |
存储单元大小 | 1 Byte = 8 bits | 32 bits |
存储单元数量 | 对于32位架构来说是2^32-1 对于64位结构来说是2^64-1 |
32 |
性能 | 容量大,存取速度慢 |
容量小,存取速度快 |
b. 字节地址和字地址
首先我们要明确的是,这两种地址是从不同角度对存储器的存储单元做出的解释。出现字地址的原因是存储器的存储单元大小固定是1 Byte,但是我们在将数据从存储器拿进寄存器的时候是4 Byte也就是1 word为单位拿,为了方便起见,引入字地址
字节地址将存储单元的单位设置成为字节Byte,字地址将存储器的存储单元的单位设置为4 Byte = 1 word
但是如果要引入字地址,就一定要注意对齐(word-aligned)
我们从word的角度去看memory的时候,要注意怎么存取,我们只能去找4的整数倍,也就是地址的最后两位是00的地址,否则,如果我们去找7,9号地址,我们将memory的存储单元看成字就没有意义了
在MIPS中,所有的指令默认是顺序执行的,并且一次执行只执行一条指令,只有在上一条指令执行完成之后我们才会去执行下一条指令
MIPS的指令集架构是建立在存储程序型的计算机上面的,这种计算机的特点是:
1⃣️程序和数据都存储在memory里面,不分彼此
2⃣️当我们需要执行指令的时候,需要从memory里面拿
3⃣️当我们需要数据执行计算命令的时候,需要从memory里面拿
数值计算相关的执行流程
- 在执行语句之前,PC中早就存储了即将执行的这条语句的地址A(注意不是语句本身,而是地址),然后根据A,相应的指令I会从memory中被拿到,然后放进IR寄存器(Instruction register)里面
- control unit会对I进行翻译和解码,然后给ALU和寄存器们发送控制信号,告知他们要完成的操作,然后ALU就会对寄存器中的数据进行计算,然后将结果放回寄存器
- 最后control unit会更新PC,然后准备执行下一条指令
从memory中拿取数值相关的执行流程(LW R6,R5)
- 和数值计算相关的执行流程相比,只有ALU部分有所不同
- ALU会计算memory的地址,这个地址会存储在存储器地址寄存器(Memory Address Register),然后根据这个地址拿到对应的数据之后,数据会被存储在存储数据寄存器(Memory Data Register)中
- 然后数据就可以被放进寄存器中
将数据存储进memory的相关执行流程(SW R6,R5)
- 和数值计算相关的执行流程相比,只有ALU部分有所不同
- 首先ALU先将R5中存储的地址放进Memory Address Register中
- 然后再从寄存器R6中拿到数据,放进Memory Data Register中之后,将数据写进memory中
在这个执行流程中,有几个小细节需要拓展出去:
第一个是PC的更新
由于判断语句的存在,我们可能有的时候并不想让程序顺序执行,而是跳转到想要执行的语句
1⃣️ bne R1, R2, Label
当R1和R2寄存器中的数值不相等的时候,跳转到Label这个地址对应的指令,否则继续顺序执行
2⃣️beq R1,R2,Label
当R1和R2寄存器中的数值相等的时候,跳转到Label这个地址对应的指令,否则继续顺序执行
3⃣️j Label
有点类似于goto语句,无论前面是什么语句,这条语句直接让执行流跳转到Label这个地址所在的语句
比较典型的应用是在if..else语句中和loop里面
if...else语句
在这个类型的语句中,最容易忘记的是jump指令
我们假设有代码
if(i == j)
h = i + j;
else
h = i - j;
寄存器:R1(i)、R2(j)、R3(h)
那么就有:
bne R1 , R2 , ELSE
add R3 , R1 , R2
j NEXT
ELSE: sub R3 , R1 , R2
NEXT:........
loop
在这个语句中,其实包含初始化的格式和其他的一些汇编语言模式,但是最容易忘记的还是jump和递增语句
我们假设有代码:
for(int i = 0;i < 5;i ++)
{
b = b + i;
}
寄存器: R5(i) , R6(b)
那么有MIPS的汇编代码:
addi R5, R0, 0
addi R1, R0, 5
Loop: beq R1, R5, Exit
add R6, R6, R5
addi R5, R5, 1
j Loop
Exit: .........
在执行流程中我们能看到,指令在进入ALU执行步骤之前需要在control unit进行识别,那么control unit的识别机制又是怎么样的?
1⃣️R型指令
R型指令主要是用于算数运算和逻辑运算
R型 | Opcode(6 bits) | rs(5 bits) | rt(5 bits) |
rd(5 bits) | shamt(5 bits) | funct(6 bits) |
Opcode + Funct: 用来描述语句的类型,在R型指令里面,Opcode全部都是0000 00,这是因为MIPS要用这种设置来将R型指令和其他两种指令区分开来,但是因为R型指令里面还有很多子指令,i.e. add, sub, 所以需要funct这一段来指明这是哪种子指令
rs(source register): 第一个源寄存器
rt(source register):第二个源寄存器
上面两个寄存器都是用来提供数据的
rd(destination register): 这个寄存器是用来存放结果的
shamt(shift amount): 这个field是用来存放偏移量的,一般用在位移指令中
2⃣️I型指令
I型指令通常是Load/Store指令、分支跳转语句和立即数语句(带有常数的语句:addi R1, R1,1)
I型 | Opcode(6 bits) | rs(5 bits) | rt(5 bits) | address/immediate(16 bits) |
由于其他的fields已经在前面介绍过了,所以我么不在这里再次重复,我们来看这个address/immediate
如果存放立即数
首先我们可以根据这个field占用的bit数目看出,这个能存放在指令中的立即数的大小应该是有限的,也就是(-2^15 ~ 2^15-1)
但是既然涉及到了位数,我们也要思考一个问题:这个16位的立即数N是要最终放进一个32位的寄存器里面的,这个时候我们怎么把这个数字N放进去呢?
这就引出了Sign Extension(符号拓展),这个概念就是说,我们没有的16位一律用N最左边的位补齐,但是符号拓展这个概念仅仅在算数运算中存在,在逻辑运算中仅仅只用0拓展
N | 0000 | 0000 | 1100 | 0110 | ||||
寄存器 | 0000 | 0000 | 0000 | 0000 | 0000 | 0000 | 1100 | 0110 |
N | 1000 | 0011 | 1001 | 0000 | ||||
寄存器 | 1111 | 1111 | 1111 | 1111 | 1000 | 0011 | 1001 | 0000 |
可是如果这个数的大小超过了这个范围怎么办?
这个时候就要用其他的指令去先存储这个数,lui + ori
lui可以理解为将高16位放进寄存器
ori可以理解为将剩下的低16位放进寄存器
如果存放供跳转语句使用的地址
这个时候又会带来新的问题:
我们的地址是32bit但是我们这里只有16位能够存放这个地址,这该怎么办?
其实这个时候,我们可以利用PC里面的地址
如果我们现在执行到语句S,S告诉我们要跳转,根据locality我们其实能够推断:我们想要跳转去的指令应该在S的附近,那么我们就没有必要非得去存储一个地址,而是存储一个“距离”,这里称为偏移量offset
同时,为了能够让这个偏移量尽可能大一点,能过跳到尽可能远的指令去,我们可以存4的倍数,这是因为我们采用word address的视角,由于对齐的需要,我们的等差必须是4的倍数,既然如此,我们只用记录到底是4的多少倍就可以了
但是这个offset怎么找到呢?
首先先把公式给出来(这里使用word address视角)
PC + 4 + (offset << 2) = Next Instruction Address
这个+4看起来有点怪异, 个人理解是因为我们在跳转之前,首先要把S执行完成,而一旦S执行完成,根据我们执行流程里说的,control unit会自动将PC+ 4,找到下一个顺序执行的语句,所以我们其实是在这个步骤结束之后进行的跳转,所以需要在PC+4的基础上加offset * 4
根据下面的图来看,我们应该可以推断这个offset其实是掐头去尾之后8和24中间的statement的个数
TIPS:
我们看到,在这里面我们使用了左移运算
因为左移运算其实就相当于✖️2的倍数
右移运算相当于除以2的倍数
所以虽然MIPS中有乘法的Operation,但是还是推荐使用位移
3⃣️J型指令
J型指令的最典型就是jump command
J型 | Opcode(6 bits) | target address(26 bits) |
一看到address就会想到和I型指令一样的问题:32位的地址塞不进26位去
那么我们还要用偏移量的方法吗?可是已经26位了,离32位只有6位,如果算上4的倍数的说法,其实我们已经能够到26 + 2 = 28位了,用偏移量会不会太麻烦?能不能直接用这28位地址来找指令呢?
是可以的。
这个时候我们还是要用到PC里面的地址。
因为locality的原因,地址的前端更改的可能性非常小,大部分的改动都发生在后面,所以我们如果能用这28位表示更改的部分,然后和PC中的地址拼起来,就解决了
这个地方是不用进行+4处理的,个人认为是因为这个类型的语句只执行跳转功能,而没有判断功能,所以不用进行(?)
指令的翻译还涉及到一个内容就是大端和小端的问题
这个问题是由于寄存器向memory中存储引出的,我们可以从左向右读取寄存器,但是因为word address一个单元有4 Byte,我们该从左边开始放还是从右边?
小端:寄存器数值低位地址放在memory的低位地址,这样一来,由于对齐,寻址的时候会优先看到数值的低位
大端:寄存器数值低位地址放在memory的高位地址,这样一来,由于对齐,寻址的时候会优先看到数值的高位
低到高 | a | a+1 | a+2 | a+3 |
小端 | 0D | 0C | 0B | 0A |
大端 | 0A | 0B | 0C | 0D |
我们知道,在指令中有很多需要用到地址和地址里面的指令和操作数,所以我们如何找到它们也是指令集的一个关键,我们可以在寄存器里面找,也可以在memory里面找
1⃣️寄存器寻址
当操作数载寄存器中的时候,给出寄存器的地址去找里面的操作数
2⃣️立即数寻址
不需要访问memory或者是寄存器就可以拿到数据
但是这个数据要先放进另一个寄存器里面,这个时候可能会用到sign extension
3⃣️偏移量寻址
在基地址的基础上从寄存器中拿到一个偏移量,相加之后得到操作数的地址,然后从Mem中根据这个地址拿到操作数
4⃣️绝对寻址/直接寻址
直接给出操作数的地址,然从Mem中拿到操作数,这个其实就是在jump里面放地址的操作
5⃣️memory间接寻址
先给出一个Memory地址A,然后A中存储着操作数O的地址B,然后我们再去Memory的B地址去拿操作数