MIPS
MIPS
汇编语言
为了阅读和理解机器码(CPU所直接处理的是一条条二进制机器码指令),人们发明了汇编语言(低级语言,稍比机器语言高级)——本质上是一种==助记符==(用一些易于理解的符号代表特定含义的机器码,基本上贴着机器码描述,用标签(Label)来替代地址)。
注意:经常会用到的概念:比特(bit)、位、字节(byte)、半字节、字。
一比特就是一位,一字节是八位,半字节是四位,一个字的大小在不同的计算机架构不同,其中32位CPU中一个字为4个字节,16位CPU中一个字为2个字节,即一个字是能一次处理的最大位数。
寄存器
介绍
寄存器是 CPU 的组成部分之一,是一种高速存储器(甚至是 CPU 可以使用的最高速的一种存储器),可以用来暂存指令,数据,地址等。由于寄存器的成本较高,一般的 CPU 中只有数量很有限的寄存器可供使用。
在 MIPS 体系结构中,CPU 对数据的操作均是基于寄存器的。内存中的数据需要先使用读取(load)类指令保存到寄存器才可使用。操作完成的数据也不能直接保存到内存中,需要使用装载(store)类指令保存至内存中。
MIPS 中的 32 个通用寄存器
所谓通用寄存器,代表它没有明确规定的用途,程序员可以随意对他们赋值、取值,同时它们的值也可以直接参与到各种指令之中。然而,虽然冠有通用的头衔,程序员们还是会以一定的规则来使用它们,这是为了便于程序员之间的交流,同时也是为编译器等工具定下了一定的标准。
这 32 个通用寄存器有两种命名方式,一种是按序号命名 $0~$31
(注意前面有个美元的符号),一种就是按功能命名,如下表:
registers | Name | usage |
---|---|---|
$0 | $zero | 常量0(可以用来赋值:add $s0, $0, $a0 ) |
$1 | $at | 保留给汇编器使用的临时变量 |
$2-$3 | $v0-$v1 | 函数调用返回值 |
$4-$7 | $a0-$a3 | 函数调用参数 |
$8-$15 | $t0-$t7 | 临时变量(由调用者保存,否则易丢失) |
$16-$23 | $s0-$s7 | 需要保存的变量(被调用者保存) |
$24-$25 | $t8-$t9 | 临时变量(由调用者保存,否则易丢失) |
$26-$27 | $k0-$k1 | 留给操作系统使用 |
$28 | $gp | 全局指针 |
$29 | $sp | 堆栈指针 |
$30 | $fp | 帧指针 |
$31 | $ra | 返回地址 |
特别说明:
$0
一般不能用于赋值,但这不代表你对它赋值就是错的。在实际中,你可以对$0
进行赋值,但它的值会始终保持为 0。也就是说对$0
赋值并不违反语法,但没有==实际效果==。$1
保留给汇编器,一般不使用此寄存器(使用该寄存器可能会导致难以发现的 bug,具体原因请在编写 MIPS 汇编时注意扩展指令是如何翻译成普通指令的)对s寄存器来说,被调用者需要保证寄存器中的值在调用前后不能发生改变——对应在实际操作中,就是若想要编写一个子函数,则在该子函数中使用的所有s寄存器都必须要在函数的开头入栈,在函数结尾出栈,来确保其值在函数被调用前后不发生变化。
对于t寄存器来说则刚好相反,编写的子函数中使用到t寄存器的地方无需做任何保存,可随意使用——因为维护t寄存器是上层函数的任务
上述两条虽可不遵守,但是一种默认的约定,因此在编写涉及到子函数时,要确保一个函数在被调用前后,其中所用的s寄存器的值并不会发生改变。
一个调用者(即父函数)并不能预知其将要调用的子函数(即被调用者)会使用到哪些 t 寄存器,但可能在调用时并不想失去自己正在使用的某个 t 寄存器中的数据。
在这种情况下,为了维持 t 寄存器中的数据,调用者有两种选择:一是将所有 t 寄存器中的数据移至 s 寄存器,函数调用结束之后再移回来;二是将自己希望保留的 t 寄存器压入栈中,函数调用结束之后再弹回来。
第一种方法看似简单,但实际上引入了很多潜在的问题,比如:s 寄存器用完了怎么办?怎么确保子函数一定不会破坏 s 寄存器中的数据?在自动生成汇编代码(如编译)的过程中,怎样确定哪些 s 寄存器是可以用来保存 t 寄存器中的数据的?
因此,采用第二种方法,是一个更优雅,也更规范的做法。在第二种方法里,不再需要去考虑寄存器之间如何倒腾,只需要借助 sp 指针,不停地用栈去存取自己需要的数据就可以了。这减少了程序员的心智负担,规范了函数调用的过程,也方便了编译器的实现。
总而言之,调用者维护 t 寄存器,被调用者维护 s 寄存器的意义,就在于让代码更易于模块化。在这种约定下,调用者不需要去考虑被调用者的具体细节,被调用者也不需要去考虑自己被调用的方式。这使得 mips 代码可以以函数为单位进行模块化开发。
三个特殊寄存器
- PC(Program Counter):它用于存储当前 CPU 正在执行的指令在内存中的地址。需要注意的是,这个寄存器的值不能用常规的指令进行取值和赋值,但这并不意味着不能做到,只是麻烦一些。
- HI:这个寄存器用于乘除法。它被用来存放每次乘法结果的高 32 位,也被用来存放除法结果的余数。
- LO:HI 的孪生兄弟。它被用来存放每次乘法结果的低 32 位,也被用来存放除法结果的商。
CP0 寄存器
当我们的 CPU 设计推进到比较深入的阶段时,我们就需要对异常和中断进行处理,届时我们就会使用到 CP0 寄存器。
CP0 是一个系统控制协处理器,而 CP0 寄存器则是该协处理器工作时需要用到的一些寄存器。在我们的实验中,只会用到其中的 4 个寄存器:SR、Cause、EPC 和 PRId。
简单介绍:
- SR:用于系统控制,决定是否允许异常和中断
- Cause:记录异常和中断的类型
- EPC:保存异常或中断发生时的 PC 值,也就是发送异常或中断时 CPU 正在执行的那条指令的地址。当处理完成之后,CPU 会根据这个地址返回到正常程序中继续往下执行。
- PRId:处理器 ID,用于实现个性的寄存器。
MIPS汇编指令
初识指令
指令,即是由处理器指令集架构(Instruction Set Architecture,可以理解为计算机体系结构中对程序相关的部分所做的定义)定义的处理器的独立操作,这个操作一般是运算、存储、读取等。一个指令在 CPU 中真正的存在形式是高低电平,也可以理解为由 01 序列组成的机器码。但因为机器码人类难以阅读和理解,所以指令一般由汇编语言来表示,也就是我们俗称的汇编指令。从这个角度上来说,汇编指令只是指令的一种表示形式而已,其实质是一样的。
一条指令的功能较为单一,一般不具有复杂的逻辑。例如「将某两个寄存器的值相加并存入另一个寄存器」,或者是「如果某个寄存器的值满足某个条件则跳转至某条指令」。
一小段汇编程序:
1 |
|
其中 tag 标记的 addi $t0, $0, 100
就是一条指令,它的含义为“将 $0
寄存器的值加上 100,并将结果存入 $t0
寄存器”。
需要注意的是,虽然其主要由指令构成,但汇编语言并非全部由指令组成。上面的代码中,第 5-16、18-19 行为严格意义上的指令,其余有标签、伪指令等。
指令格式
指令一般由一个指令名作为开头,后跟该指令的操作数,中间由空格或逗号隔开。指令的操作数的个数一般为 ==0-3== 个,每一个指令都有其固定操作数个数。一般来说,指令的格式如下:
指令名 操作数 1, 操作数 2, 操作数 3
不过,也有如下的指令格式,一般用于存取类指令:
指令名 操作数 1, 操作数 3(操作数 2)
所谓操作数,即指令操作所作用的实体,可以是寄存器、立即数或标签,每个指令都有其固定的对操作数形式的要求。而标签最终会由汇编器转换为立即数。
所谓立即数,即==在指令中设定好的常数==,==可以直接参与运算==,一般长度为 16 位二进制。
而标签,用于使程序更简单清晰。标签用于表示一个地址,以供指令来引用。一般用于表示一个数据存取的地址(类似于数组名)、或者一个程序跳转的地址(类似于函数名,或者 C 语言中 goto
的跳转目标)。在 MIPS 汇编中(以及其他大部分汇编语言中),标签用如下的方式写出:
name:
其中的「name」代表这个标签的名称,可以自行取名。
常见的指令格式样例:
1 |
|
当然,前面说过,可以使用标签来代替某个地址,因此也可以以如下方式书写:
1 |
|
这里的 loop
就是一个标签,其所代表的是一段代码的起始地址。在进行汇编时,汇编器会自动把标签转换成我们所需要的立即数,这样就不用我们自己去计算这些地址偏移量,简化了编程难度。
注意:在 MARS 中,跳转指令只能使用标签来进行跳转,不能使用立即数!!!
机器码指令
作为一种机器能够理解的机器语言,机器码是CPU最基本的一种操作,一种原子操作,不可被打断。
精简指令集(RISC,Reduced Instruction Set Computing):所有指令长度相同,包括MIPS和手机上的ARM……
复杂指令集(CISC,Complex Instruction Set Computing):指令数目多,长度并不一定相同,包括PC段的x86架构。
一段汇编语言可转换为机器码。
如:
1 |
|
转换结果(16进制):
1 |
|
机器码指令格式
R型指令
R 型指令的操作数最多,一般用于运算指令。例如 add
、sub
、sll
等。其格式如下(左侧为高位,右侧为低位):
op | rs | rt | rd | shamt | func |
---|---|---|---|---|---|
6bits | 5bits | 5bits | 5bits | 5bits | 6bits |
R型指令的op等于0,代表R型指令。func与op组合起来决定该条指令的操作(操作符)
rs(Source Register)指定存放第一个操作数的寄存器,rt(Target Register)为存放第二个,rd存放计算结果(Destination Register)
I型指令
16位的立即数。一般用于addi
、subi
、ori
等与立即数相运算的指令(这里需要注意:在写汇编语言的时候,需要使用负号来标记负数,而不要和机器码一样认为首位的 1 就代表负数),或 beq
、bgtz
等比较跳转指令,因为它们要让两个寄存器的值相比并让 PC 偏移 offset 这么多,刚好利用了全部的字段。还有存取指令,例如 sw
、lw
,它们在使用时需要对地址指定一个偏移值,也会用到立即数字段。
op | rs | rt | offset or immediate |
---|---|---|---|
6bits | 5bits | 5bits | 16bits |
rs:存储器操作数的基地址,rt:寄存器操作数的地址
对于跳转指令,op:分支指令操作码;rs和rt为要比较的两个寄存器(rs更靠近op);immediate满足相对寻址的位移量(即转向指令与当前PC间的指令数),由于指令4个字节,则:PC(跳转后) = (PC(跳转前) + 4) + (immediate * 4)
J型指令
常见的为j
和jar
,它们需要直接跳转至某个地址,而非用当前的PC值+偏移量计算出新的地址,需要位数较多。
op | address |
---|---|
6bits | 26bits |
目标地址:26位,按如下方式计算转向地址:
字对齐,26位扩展成28位(低两位补0),最高4位:直接取PC的高4位
New PC = { PC(原地址)[31..28], target address, 00 }
注意:
严格来说,并非所有的指令都严格遵守上面三种格式,有的如 eret
、syscall
指令一样没有操作数;有的如 jalr
指令一样某些字段被固定为某个值。不过,就大部分指令而言,都可按上面三种格式进行解释,某些字段被固定也可以按照格式来识别为 R、I、J 中的一种,因此这三种格式要着重理解。
解读
op:也称 opcode、操作码,用于标识指令的功能。CPU 需要通过这个字段来识别这是一条什么指令。不过,由于 op 只有 6 位,不足以表示所有的 MIPS 指令,因此在 R 型指令中,有 func 字段来辅助它的功能。
func: 用于辅助 op 来识别指令。
rs、rt、rd: 通用寄存器的代号,并不特指某一寄存器。范围是
$0~$31
,用机器码表示就是 00000~11111。shamt:移位值,用于移位指令。
offset:地址偏移量。
immediate:立即数。
address:跳转目标地址,用于跳转指令 。
结合《MIPS-C 指令集》(下简称手册)读懂指令
- **+**:手册中的所有
+
一律将其左右两侧的操作数视为无符号数,再进行相加。 - **-**:手册中的所有
-
一律将其左右两侧的操作数视为无符号数,再进行相减。 - 下标:变量的下标代表变量中所取用的位,temp
31即代表 temptemp 变量在二进制表示下的第 31 位。特别的,下标中的..
可理解为至,temp31..0与 temp[31:0] 是等价的。 - 上标:某一位二进制数(即 00 或 11 )的上标代表该数字重复的位数,0^5^ 即代表 5′b00000。
- **||**:该符号代表拼接操作。temp1∣∣temp2与 {temp1,temp2}等价。
- sign_extend() :代表符号扩展,其扩展结果的正负会与被扩展数相同,一般用于有符号数。其扩展方法是,将被扩展数不足的部分用被扩展数的符号位补全。即若被扩展数为正数,则将其高位用 0 补全至相应位数;若为负数,则在高位补 1 至相应位数。
- zero_extend() :代表无符号扩展,即倘若被扩展数位数不足,则将其高位补 0 至相应位数。
- GPR :即 General Purpose Register (通用寄存器),其后的中括号内为寄存器编号,
GPR[rs]
即可表示编号为 rs 的寄存器。 - memory :表示内存,当其后跟随中括号时,表示存储在以括号中数值为起始地址的 4 字节内存中的数据。以
memory[Addr]
为例,其表示以 Addr 为首地址的 4 字节内存中存储的数据。
MIPS-C指令集
1.ADD:符号加(R型指令)
格式:add rd, rs, rt
描述:GPR[rd] <—GPR[rs]+GPR[rt]
示例:add $s1, $s2, $s3
不溢出的话,add同addu等价
2.ADDI:符号加立即数(I)
格式:addi rt, rs, immediate
示例:addi $s1, $s2, $s3
不考虑溢出,则addi与addiu等价
3.ADDIU:无符号加立即数(I)
格式:addiu rt, rs, immediate
“无符号”是误导,本意是不考虑溢出
4.ADDU:无符号加(R)
不考虑溢出
5.AND:与(R)
and $s1, $s2, $s3:$s1<—$s2 AND $s3
6.ANDI:与立即数(I)
格式 andi rt, rs, immediate
7.BEQ:相等时转移(I)
格式:beq rs, rt, offset
描述:if (GPR[rs] == GPR[rt]) then 转移
操作:
1 |
|
示例:beq $s1, $s2, -2
8.BGEZ:大于等于0时转移(类I)
转移操作同BEQ(传入两个操作数即可)
9.BGTZ:大于0时转移(类I)
转移操作同BEQ(传入两个操作数即可)
10.BLEZ:小于等于0时转移(类I)
转移操作同BEQ(传入两个操作数即可)
11.BLTZ:小于0时转移(类I)
转移操作同BEQ(传入两个操作数即可)
12.BNE:不等于时转移(I)
转移操作同BEQ(传入三个操作数)
13.BREAK:断点
示例:break
描述:产生断点异常
14.DIV:符号除(类R)
格式:div rs, rt
描述:(HI, LO) <- GPR[rs] / GPR[rt]
商存放在LO寄存器,余数存放在HI寄存器
若除数为0则HI/LO结果不可预测
15.DIVU:无符号除
操作:LO<- (0||GPR[rs]) div (0||GPR[rt])
HI<- (0||GPR[rs]) mod (0||GPR[rt])
作为无符号除法,所以先对其0扩展1位后再进行计算
16.ERET:异常返回
格式:eret
描述:eret 将保存在 CP0 的 EPC 寄存器中的现场(被中断指令的下一条地址)写入 PC,从而实现从中断、异常或指令执行错误的处理程序中返回。
操作:PC<-CP0[epc]
当程序被硬件中断、执行 sc 指令、指令执行异常(如除 0)时,PC 将被保存在 EPC 中。
【注意】如果是硬件中断和 SC,EPC 中保存的 PC+4;如果是指令执行异常(如除零、 异常等),则保存 PC。
17.J:跳转(J)
格式:j target
描述:j 指令是 PC 相关的转移指令。当把 4GB 划分为 16 个 256MB 区域,j 指令可以在当前 PC 所在的 256MB 区域内任意跳转。
操作:PC <- PC31..28||instr_index||0^2^
如果需要跳转范围超出了当前 PC 所在的 256MB 区域内时,可以使用 JR 指令。
26位地址码表示的是64M,但j指令立即数有低位补00的扩展(即应是表示64M条指令,不是64MB),因此应该是表示64M*4=265MB代码大小。
18.JAL:跳转并链接(J)
描述:jal 指令是函数指令,PC 转向被调用函数,同时将当前 PC+4 保存在 GPR[31]也就是$ra中。当把 4GB 划分为 16 个 256MB 区域,jal 指令可以在当前 PC 所在的 256MB 区域内任意跳转。
操作:PC<-PC31..28||instr_index||0^2^
GPR[31]<-PC + 4
jal 与 jr 配套使用。jal 用于调用函数(指令转到被调函数处,也就是标签标记处,并且将当前地址值+4存在GPR[31]也就是$ra处),函数中碰到jr后返回指令到GPR[rs] jr指令后跟着的寄存器表示的地址处,通常jr 用于函数返回。当所调用的函数地址超出了当前 PC 所在的 256MB 区域内时,可以使用 jalr指令。
19.JALR:跳转并链接(类R)
格式:jalr rd, rs
描述:jalr指令是函数指令,PC转向被调用函数(函数入口地址保存在GPR[rs]中),同时将当前 PC+4 保存在 GPR[rd]中。
20.JR:跳转至寄存器
格式:jr rs
操作:PC <- GPR[rs]
jr 与 jal/jalr 配套使用。jal/jalr 用于调用函数,jr 用于函数返回.
jr指令跳转到的是寄存器中的值,有32位,但是没有低位扩展,所以有2^32^=2^2^ * 2^10^ * 2^10^ * 2^10^B=4GB
21.LB:加载字节(I)
格式:lb rt,offset(base)
描述:GPR[rt] <- memory[GPR[base]+offset]
操作:Addr <- GPR[base] + sign_ext(offset)
memword <- memory[Addr] (这步要先确保Addr是4的整数倍先对Addr进行字对齐,即最低两位置0,再计算memword)
byte <- Addr1..0 (byte的计算仍按原Addr)
GPR[rt] <- sign_ext(memword7+8byte..8byte)
示例:lb $v1,3($s0)
22.LBU:加载无符号字符(I)
操作同LB略有不同:
Addr <- GPR[base] + sign_ext(offset)
memword <- memory[Addr]
byte <- Addr1..0
GPR[rt] <- zero_ext(memword7+8byte..8byte)
最后一步操作中扩展的是0而不是符号位
23.LH:加载半字(类I)
操作同LB略有不同:
Addr <- GPR[base] + sign_ext(offset)
memword <- memory[Addr]
byte <- Addr1
GPR[rt] <- sign_ext(memword15+16byte.16byte)
半字一般为两个字节,16位
约束:Addr 必须是 2 的倍数(即 Addr0 必须为 0),否则产生地址错误异常
24.LHU:加载无符号半字(I)
操作同LH略有不同:
Addr <- GPR[base] + sign_ext(offset)
memword <- memory[Addr]
byte <- Addr1
GPR[rt] <- zero_ext(memword15+16byte.16byte)
25.LUI:立即数加载至高位(I)
格式:lui rt,immediate
操作:GPR[rt] <- immediate||0^16^
26.LW:加载字(I)
操作与LB不同:
Addr<-GPR[base]+sign_ext(offset)
GPR[rt]<-memory[Addr]
约束:Addr必须是4的倍数,否则会产生地址错误异常
27.MFC0:读CP0寄存器(类R)
操作:GPR[rt] <- CP0[rd]
28.MFHI:读HI寄存器
操作:GPR[rt] <- HI
当乘法/除法计算完毕后,需要用 mfhi 读取相应的结果。
29.MFLO:读LO寄存器
用法同MFHI
30.MTC0:写CP0寄存器(类R)
同MFC0操作相反。
31.MTHI:写HI寄存器
与MFHI操作相反
mthi/mtlo 只在进行中断响应是需要使用。此时与 mfhi/mflo 配套使用确保被中断程序的乘除法运算在中断响应结束后能够得到正确结果。
32.MTLO:写LO寄存器
操作同MFLO相反
33.MULT:符号乘
格式:mult rs,rt
描述:(HI, LO)<- GPR[rs]×GPR[rt],乘积低32位存放在LO寄存器,高32位存放在HI寄存器。所有操作数均为有符号数。
操作:
prod <- GPR[rs]× GPR[rt]
HI <- prod63..32
LO <- prod31..0
34.MULTU:无符号乘
所有操作数为无符号数,操作同MULT略有不同:
prod <- (0||GPR[rs])×(0||GPR[rt])
HI <- prod63..32
LO <- prod31..0
35.NOR:或非(R)
操作:nor $s1, $s2, $s3:$s1<—$s2 nor $s3
36.OR:或(R)
操作:or $s1, $s2, $s3:$s1<—$s2 or $s3
37.ORI:或立即数(I)
ori rt, rs, immediate:
GPR[rt] <- GPR[rs] OR zero_extend(immediate)
38.SB:存储字节(类I)
格式: sb rt, offset(base)
描述:memory[GPR[base]+offset] <- GPR[rt]
操作:Addr <- GPR[base] + sign_extend(offset)
byte <- Addr1..0
memory[Addr]7+8byte..8byte <- GPR[rt]7:0
约束:Addr必须是4的倍数,否则会产生地址错误异常,要先进行字对齐。
39.SH:存储半字节(类I)
操作同SB略有不同:
Addr <- GPR[base] + sign_extend(offset)
byte <- Addr1
memory[Addr]15+16byte..16byte <- GPR[rt]15:0
约束:Addr必须是2的倍数,否则会产生地址错误异常,否则要进行字对齐。这里的Addr要对齐存入时的完整一个字的地址,是对(其所在的字的地址)【表示】的字的【部分字节】的操作,例如memory[Addr]31…16应该是对齐存入时完整字地址,如果Addr=0x1002,存入时的首地址是0x1000,则若是小端存储,写入的应该是0x1003和0x1002;若是大端存储,写入的应该是0x1000和0x1001。因为大小端只涉及内存的解释问题,这包括了读取的解释(怎么把这几个字节组成起来)和写入的解释(怎么把几个字节分布开来),本质上是寄存器值=>(大小端映射)=>内存的多个字节数据=>(大小端映射)=>寄存器值。
40.SLL:逻辑左移(类R)
格式:sll rd, rt, s
描述:GPR[rd] <- GPR[rt] << s
操作: GPR[rd] <- GPR[rt] (31-s)..0||0^s^
sll $0, $0, 0 对应的指令码是 0x0000_0000,也被认为是 NOP(空操作指令)。 该指令有时被用于空循环,有时被编译器用于与体系结构相关的编译优化。
41.SLLV:逻辑可变左移(R)
格式:sllv rd, rt, rs
描述: GPR[rd] <- GPR[rt] << GPR[rs]
操作:s <- GPR[rs]4..0
GPR[rd] <- GPR[rt] (31-s)..0||0^s^
GPR[rs]的位31至位5被忽略
42.SLT:小于(最低位)置1(有符号)(R)
格式:slt rd,rs,rt (rs:00000)
描述:GPR[rd] <- (GPR[rs] < GPR[rt])
操作: GPR[rd] <- (GPR[rs] < GPR[rt]) ? 0^31^||1 : 0^32^
43.SLTI:小于立即数置1(有符号)(I)
操作同SLT略有不同:
GPR[rd] <- (GPR[rs] < sign_extend(immediate)) ? 0^31^||1 : 0^32^
44.SLTIU:小于立即数置1(无符号)(I)
操作同SLTI略有不同:
GPR[rd] <- (GPR[rs] < 0||sign_extend(immediate)) ? 0^31^||1 : 0^32^
45.SLTU:小于置1(无符号)(R)
操作同SLT略有不同:
GPR[rd] <- (0||GPR[rs] < 0||GPR[rt]) ? 0^31^||1 : 0^32^
46.SRA:算术右移(类R)
格式:sra rd, rt, s
操作:GPR[rd] <- GPR[rt]31^s^ ||GPR[rt]31..s
47.SRAV:算术可变右移(R)
格式:srav rd, rt, rs
操作:s <- GPR[rs]4..0
GPR[rd] <- GPR[rt]31^s^ ||GPR[rt]31..s
GPR[rs]的位31至5位被忽略
48.SRL:逻辑右移(类R)
操作同SRA略有不同:
GPR[rd] <- 0^s^ ||GPR[rt]31..s
49.SRLV:逻辑可变右移(R)
操作同SRAV略有不同:
s <- GPR[rs]4..0
GPR[rd] <- 0^s^ ||GPR[rt]31..s
50.SUB:符号减(R)
格式:sub rd,rs,rt
操作:
temp <- (GPR[rs]31||GPR[rs]) - (GPR[rt]31||GPR[rt])
if temp32 ≠ temp31 then
SignalException(IntegerOverflow)
else GPR[rd] ← temp31..0
endif
temp32 ≠ temp31 代表计算结果溢出。
如果不考虑溢出,则 sub 与 subu 等价。
51.SUBU:无符号减(R)
GPR[rd] <- GPR[rs] - GPR[rt]
不考虑减法溢出。例如:0x0000_0000 - 0xFFFF_FFFF = 0x0000_0001, 即结果为非负值。
52.SW:存储字(I)
格式:sw rt, offset(base)
描述:memory[GPR[base]+offset] <- GPR[rt]
操作:Addr <- GPR[base] + sign_ext(offset)
memory[Addr] <- GPR[rt]
Addr必须为4的倍数,否则产生地址错误异常
53.SYSCALL:系统调用
描述:产生系统调用异常
操作:SignalException(systemcall)
一般都是为$a0和$v0寄存器赋值
$v0中存储的值 | syscall指令执行时的操作 |
---|---|
1 | 输出存储在$a0整型数 |
2 | 输出存储在$f12的单精度浮点数 |
3 | 输出存储在$f12的双精度浮点数 |
4 | (前提:把要输出的字符串在内存中的首地址赋给$a0 寄存器)输出$a0指向的字符串 |
5 | 读入整型数至$v0存储器中 |
6 | 读入float型浮点数至$f0存储器中 |
7 | 读入double型浮点数至$f0存储器中 |
8 | 读入指向字符串的指针至$a0中,$a1存储的值代表读入字符的最大个数,不过考虑到字符结尾的”\0”能够读取的字符的最大数量应该是$a1 寄存器所存储的值-1 |
9 | |
10 | 程序结束 |
11 | 输出$a0中存储的字符(存储的是ASCII码) |
12 | 读入字符至$v0存储器 |
13 | |
14 | |
15 | |
16 | |
17 |
54.XOR:异或(R)
操作:GPR[rd] <—GPR[rs] XOR GPR[rt]
55.XORI:异或立即数(I)
操作同XOR略有不同:
GPR[rd] <—GPR[rs] XOR zero_extend(immediate)
扩展指令
扩展指令功能主要是简化程序。汇编器将一些常用、但标准指令集不提供的功能封装为一条指令;或者改变现有指令的操作数的形式或个数,使其以新的形式出现。需要注意的是,它们只是形式上是一条新指令,而实际上,在汇编器将其汇编之后,还是使用标准指令来实现的。
最常用到的一条扩展指令是li指令,它用来为某个寄存器赋值,比如 li $a0,100
就是将 100 赋给 $a0
寄存器。汇编器在翻译这条扩展指令时会根据需要,将它翻译成不同的基本指令或基本指令的组合。如下,第一条 li
指令后面的立即数不多于 16 位,因此只被翻译成了一条 addiu
;第二条 li
指令后面的立即数多于 16 位,因此被翻译成了 lui+ori 的组合。
另一条常用的扩展指令是 la
指令,这条指令与 li
指令非常类似,都是为寄存器赋值,只不过是使用标签来为寄存器赋值。经过了前面的学习,大家应该已经知道标签本质上对应一个 32 位地址,但 li
指令并不能直接使用标签来为寄存器赋值,必须要使用 la
。比如 la $t0, fibs
这条指令就是把 fibs 这个标签的地址存入$t0
中。
上面的例子就是利用现有基础指令组合出新的扩展指令。当然,我们也可以对现有指令进行简化或转写,例如前面提到过的 j 0xc0a
,我们可以将其立即数改为一个标签,这样汇编器会在进行汇编时,会将标签所代表的立即数计算出来,形成基本指令的形式,例如下图:
除了像li
这种标准指令集中不存在的指令是扩展指令,一些标准指令集中的指令也可能以扩展指令的形式出现。后者与标准指令不同之处大都在于参数的类型、参数的个数(下例)或**参数的位数(例:ori $a0,$a1,0x12345678)
**。
例如: ori $a0, 4
这就是一条扩展指令,汇编器会把它翻译为:ori $a0, $a0, 4
move $a0, $s1
指令也是一条扩展指令,是将$s1赋值给$a0
sge $t1, $t2, $t3
:$t1
== ($t2
>= $t3
) ? 1 : 0
伪指令
伪指令(Directives)是用来指导汇编器如何处理程序的语句,有点类似于其他语言中的预处理命令。伪指令不是指令,它并不会被编译为机器码,但它却能影响其他指令的汇编结果。常用的伪指令有以下几个:
- .data:用于预先存储数据的伪指令的开始标志。
- .text:程序代码指令开始的标志。
- .word:以字为单位存储数据。
- .asciiz:以字节为单位存储字符串。
- .space:申请若干个字节的未初始化的内存空间。
- .ascii: 功能同
.asciiz
- **.byte:**将
[label]: .byte x,y,z,h…
中的x,y,z,h按8位一个字节存储以4个数字一个字的方式存储在内存地址中
注意
1.使用伪指令初始化数据时,.word
、.asciiz
等伪指令存储的数据是从.data
声明的首地址开始,按照伪指令声明顺序紧密有序的存储。
2..ascii
与.asciiz
区别是后者会在字符串后补’\0’。这将会导致前者在输出字符串时由于没有后置”\0”,可能会一直输出内存地址上连续的都由.ascii定义的字符串,因为没到”\0”,输出不会停止。(每个内存地址上初始值就为”\0”).
3..asciiz
和.ascii
存放字符串的时候在内存地址上体现的是一个字符一个地址数,即一个字节,例如
1 |
|
str从0x00001000开始存,又由于小端存储(四个字符为一个字的存,在字中采用小端存储),0x00001000存入l,0x00001001:l,…0x00001008:\0(.asciiz
末尾添”\0”),0x00001009:d,0x0000100a:l,0x0000100b:r.
因此num
从0x0000100c开始存,由于小端存储,.byte
将0x04(8位)存入0x0000100c,0x03:0x0000100d,0x02:0x0000100e,0x01:0x0000100f,0x08:0x00001010,0x07:0x00001011,0x06:0x00001012,0x05:0x00001013。若为num: .byte 1,2,3,4,5,6
,则0x00001010和0x00001011均为0即未填入,而其他都没变。
MIPS汇编程序解析
第一个汇编程序
输出”Hello World”
文本代码:
1 |
|
第5、6、9行是扩展指令,在执行中分别转换成addi $4, $0, 4
、addiu $2, $0, 4
、addiu $2, $0, 10
。
第 7 行和第 10 行的 syscall
,会根据当前的 $v0
寄存器的值,进行相应的操作,比如执行第 7 行 syscall
的时候,$v0=4
,此时就会输出 $a0
寄存器指向的字符串,执行第 10 行 syscall
的时候,$v0=10
,此时程序结束运行。有关 syscall
的说明,可以按快捷键 F1,在 MIPS -> Syscalls 页面中查到相应的用法。
这个 MIPS 汇编程序在运行的时候,先把 str 的地址 (0x0) 赋值给 $a0
,然后令 $v0=4
,执行syscall
,输出 $a0
寄存器指向的字符串 ”Hello World”,直到遇到 ’\0’ 为止,最后令 $v0=10
,执行 syscall
,结束程序。
简单循环例子
输入n,输出1+……+n的值
代码:
1 |
|
1 |
|
在MIPS中使用数组
要求:输入整数n([2,10]),接下来n行,每行一个整数,输出时先输出The numbers are:
,将n个整数按输入顺序输出,两个数字间有空格。
代码:
1 |
|
在MIPS中使用二维数组
要求输入两个数m和n([1,8]),代表一个m行n列的矩阵,此后的m*n行每行一个整数,输入矩阵的元素,最后输出该矩阵,每两个数字中一个空格,最后有回车。
代码:
1 |
|
求Fibonacci数的程序
1 |
|
变量声明与定义
1).data
格式:.data [address]
说明:
- 定义程序的数据段,初始地址为 address,若无 address 参数,初始地址为设置的默认地址。
- 需要用伪指令声明的程序变量需要紧跟着该指令。比如该程序中的
fibs
,size
,space
,head
这四个变量。
2).text
格式:.text [address]
说明:
- 定义程序的代码段,初始地址为 address,若无 address 参数,初始地址为设置的默认地址。
- 该指令后面就是程序代码。
- 在 MARS 中如果前面没有使用
.data
伪指令,可以不使用.text
直接编写程序代码,代码将放置在前面设置的代码段默认地址中,但如果前面使用了.data
伪指令,务必在代码段开始前使用.text
进行标注。
3).space
格式: [name]: .space [n]
说明:
申请 n 个字节未初始化的内存空间,类似于其他语言中的数组声明。
这段数据的初始地址保存在标签
name
中。name 的地址是由
.data
段的初始地址加上前面所申请的数据大小计算得出的。由于前面申请的空间大小不定,有可能会出现后来申请的空间没有字对齐的情况,从而在使用sw
,lw
一类指令时出现错误,所以在申请空间时尽可能让 n 为 4 的倍数,防止在数据存取过程中出现问题。
4) .word
格式:[name]: .word [data1].[data2] …
说明:
在内存数据段中以字为单位连续存储数据 data1, data2,… (也就是将 datax 写入对应的 1 个字的空间,注意 .word 和 .space 的区别,以及.word和.byte之间的区别)
这段数据的初始地址保存在标签 name 中。计算方式与上面相同。
5).asciiz
格式:[name]: . asciiz “[content]”
说明:
- 以字节为单位存储字符串,末尾以 NULL 结尾。
- 这个字符串在内存数据区的初始地址保存在标签 name 中。
- 注意
.asciiz
与.ascii
这两条伪指令的区别。前者在字符串末尾加”\0”,后者不加。 .asciiz
由于是按字节存储,可能会导致之后分配的空间首地址无法字对齐的情况发生,请大家自行思考解决方法(感觉可以采用将字符串拆分成若干个符合要求的字符串最后输出时拼接而成即可。
宏的使用
主要是为了简化程序,避免过多的复制粘贴使代码冗长,复用性差,通过宏的使用提高复用性。
不带参数
定义方式如下:
1 |
|
例如:定义一个结束程序的宏:
1 |
|
带参数
定义方式如下:
1 |
|
例如:去一个存储在内存地址中的第%i行,第%j列的矩阵的数(设矩阵最大列数为8,则需要通过计算(行数 * 最大列数 + 列数) * 4来在内存地址上取值,因此可以通过宏来封装计算过程,便于复用:
1 |
|
.eqv
用法:.eqv EQV_NAME string
类似于C语言中的#define
宏定义,汇编器会将EQV_NAME的地方替换成string,可以用来定义一些常量。
或者用来取别名,方便阅读
分配寄存器
代码详见求Fibonacci数的程序的代码段的8-14行
1.la
指令用于获取标签所指向的地址,相当于获取数组第一个存储空间的指针,如:la $t0, fibs
就是将fibs这个标签对应的地址0x00000000存入了$t0
寄存器,方便后续向里面存入Fibonacci数,注意:内存地址一位存一个字节,而存入的Fibonacci数作为int
型,需要4个字节,存入后一个数需要向高地址偏移4个地址。
2.la $t5, size
和lw $t5, 0($t5)
两行使用$t5
寄存器保存所需要计算的Fibonacci数的总个数,先用前者将size
的地址载入$t5
中,再用后者从内存中的$t5
所保存的地址(初始时偏移量为0)开始读取一个字即12写入$t5
中。
3.li
指令可以直接往寄存器中写入一个32位的立即数(扩展后的)。而sw $t2, 0($t0)
和sw $t2, 4($t0)
是将前两个初始Fibonacci数存入数组中,也就是$t0
寄存器中保存地址对应的空间。
循环运算
代码:
1 |
|
前两行从fibs数组中取前两个Fibonacci数F(n-1)和F(n-2),再根据第三行得到F(n)得到新的Fibonacci数,再通过第四行存入数组中,接下来两行用于修改变量,将$t0
移动4个偏移量,换到下一个地址,$t1
自减1,代表要求的Fibonacci数减1.第8行的跳转指令,是大于0便跳转,意味着Fibonacci数没求完,循环就不会结束,第十一行的jal print
表示跳转到print
处开始输出,并将一行的地址保存在$ra
寄存器中,方便在函数中jr $ra
直接返回到原函数使用处的下一条指令,完成对于函数的调用与返回。
MIPS汇编程序设计
条件语句
代码:(if/else语句)
1 |
|
if/else if/else语句:
可以用类似if/else的语句继续编写一个判断加转移块。
switch语句:
1 |
|
可以通过类似的地址偏移的方式实现switch选择
循环语句
for循环:
1 |
|
2、3行初始化,6行判断i<n
是否为真,跳转指令,9行作为需在循环体中执行的代码,10行为自增变量,11行闭环循环。
while循环:
1 |
|
do-while循环无非是在while循环的基础上在循环体外先做一次循环体内的内容罢了。
main函数的定义
1 |
|
子过程调用关键
跳转指令:jal、jalr、jr(return)
为了便于协作与理解,需要满足一定的协议
栈的理解与使用
前提:
1.过程自身满足栈的结构
2.过程调用子过程时需满足栈的结构
3.子过程执行前后需移动栈指针$sp
(分配与释放栈)
在MIPS中一般使用从高地址向低地址延申使用栈,即优先使用高地址,栈是一段内存,其中划分出来的每一个小段称为栈帧,一个栈帧供一个过程使用,最开始,父过程栈帧在较高地址,调用子过程后,子过程栈帧在较低地址区域,子子过程的栈帧在更低地址区域。
对于一个栈帧,大概由以下几个部分由高地址到低地址构成:
临时变量(高地址)(t寄存器以及因寄存器不足而需要保存的变量) |
---|
返回地址(保存$ra 的地方) |
需要保存的寄存器($s0 -$s7 若该过程需要使用这些寄存器,那便需要在该过程开始时将其保存在这里,在该过程结束时将其移回寄存器,注意:只需要保存使用的寄存器,不使用的无需保存) |
其他参数(无对应寄存器,只能存在栈空间中) |
参数0-3(低地址)(指的是该过程调用其他过程,即其子过程时传给其子过程的参数,由对应的寄存器$a0 -$a3 ,之后其子过程可以自行决定是否使用这段栈空间,需要注意的是这四个字只是声明而无需初始化,即这四个字的值可以不定) |
栈的使用方法
1.计算好栈帧大小
2.栈指针始终指向栈顶
3.过程开始时分配栈空间(假设此时的栈帧大小为32字节,则需要执行addiu $sp, $sp, -32
指令)
4.过程结束时回收栈空间(同上假设,则需要执行addiu $sp, $sp, 32
指令)
5.以栈指针为基地址进行栈的存取(sw $t0, 24($sp)
以假设为例:栈空间的分布大致如下:
+16 | main.$t0 |
---|---|
+12 | main.$a3 |
+8 | main.$a2 |
+4 | main.$a1 |
main.$sp | main.$a0 |
+28 | sort.$t2 |
+24 | sort.$t1 |
+20 | sort.$t0 |
+16 | sort.$ra |
+12 | sort.$a3 |
+8 | sort.$a2 |
+4 | sort.$a1 |
sort.$sp(低地址) | sort.$a0 |
在这里main作为一种叶子函数,不需要返回值和需要保存的寄存器。
示例:通过选择排序法将数组排序,下为部分代码:
1 |
|
函数调用
函数特性:
- 函数是一个代码块,可以由指定语句调用,并且在执行完毕后返回调用语句。
- 函数通过传参,可以实现代码的复用。
- 函数只能通过返回值等有限手段对函数外造成影响。
- 函数里依然可以嵌套调用函数。
代码复用:
为了复用代码,必须让一些特定寄存器作为”接收器“,对于不同的参数,都采用同一组寄存器来存储值,即形参寄存器$a0, $a1, $a2, $a3
,同理:对返回值也需要指定特定的寄存器。
例如:
1 |
|
上述C语言代码可以通过下列汇编代码实现:
1 |
|
$a0, $a1, $a2, $a3
只是一种约定,当传递的参数超过4个时,一般将多出的参数存入内存中,在MIPS中使用内存的主要方法是利用栈,通过控制栈指针寄存器$sp
完成对内存的访问。
特别注意:实际上栈中未被分配出去内存区域的只有地址低于栈指针寄存器$sp
那部分栈,即以栈指针寄存器$sp
在自减前的地址为起始位置的子空间已被分配出去了,因此在需要向栈空间存储数据前,需先将栈指针寄存器$sp
自减相应大小,再进行存储操作。
避免对外界造成影响:
错误代码:
1 |
|
需要保证函数不会对外部造成影响,方法即为应用栈(作用是保存和恢复函数使用的寄存器,函数应计算返回值,但不应产生其他负面影响)
同样也有两种使用栈的位置:
调用函数前,调用者保存
1 |
|
被调用者保存(在sum
里出入栈)
1 |
|
嵌套函数调用
由于只有一个$ra
寄存器,因此在嵌套函数调用时,&ra
会被修改可能会导致死循环的状况出现。将下列C语言代码转换为汇编语言时,若没有充分考虑对于$ra
寄存器值得存储,将会出现两块指令来回跳转的死循环情况,因此,一旦一个函数不是叶子函数(不会调用其他函数的函数),就需要保存和恢复$ra
(通过栈实现),
1 |
|
递归函数调用
递归函数的本质是一个在函数体内调用自身的嵌套函数。
例:阶乘程序:
1 |
|
汇编版本:
1 |
|
对每个函数,入口和出口都是十分固定的,即入栈、函数过程、出栈、返回这四个基本流程)。
哈密顿回路思路
实现满足下面功能的汇编程序:
输入一个具有 个顶点的无向图 ,判断 是否有哈密尔顿回路。(哈密顿回路问题,建议使用递归解决)
输入格式
第一行是一个整数 ,代表 有 个顶点,第二行是一个整数 ,代表 有 条边,接下来的 行,每行具有一个整数,设每个奇数行的数为 ,它下一行的数 ,序号为 , 的两个顶点间具有一条边,两个整数之间以回车隔开(点的标号从 1 开始)
输出格式
输出一个整数,若为 0 则代表 不具有哈密尔顿回路,若为 1 则代表 具有哈密尔顿回路。
约定
1、
2、
3、请勿使用 .globl main
4、最大运行指令条数限制为 100000
5、请使用 syscall
结束程序:
1 |
|
输入样例
1 |
|
输出样例
1 |
|
输入样例
1 |
|
输出样例
1 |
|
提交要求
- 请勿使用
.globl main
。 - 不考虑延迟槽。
- 只需要提交 .asm 文件。
- 程序的初始地址设置(Mars->Settings->Memory Configuration)为Compact,Data at Address 0。
提示
哈密顿回路的定义:设 G=(V,E)是一个图,若G中一个回路通过且仅通过每一个顶点一次,则称这个回路为哈密顿回路。
例如,下图中红色的回路就是一个哈密顿回路
我们希望大家通过这道题目来练习使用汇编语言设计递归程序,因此建议大家使用递归的方式解决,以下是一个参考思路:
采用dfs
(深度优先搜索),从一个点开始,搜索与之相邻且未经过的点,直到找到一条哈密顿回路。
参考C语言代码
1 |
|
大致思路就是根据C语言版本改成汇编语言:
注意:
1.勤使用宏定义,提高复用性
2.递归函数在设计时要关注什么在递归调用过程中不能改变,而什么可以,不能改变的变量就压入栈中并在调用结束后按顺序依次出栈,像在本题中递归方法中的第二个循环的i在再次递归调用时是不能改变的,即在新的递归函数被调用前其需要入栈以保存,得调用结束后再重新赋值,ps:递归函数中第一个循环和第二个循环中的i不要用同一个寄存器去存储,容易导致混乱。
3.假如递归传入参数是当前参数的简单变形,在原递归层中参数不能改变(即需要通过入栈的方式将其保存起来),那么传入下一个递归层的参数可以通过一个可以改变的变量在调用下一次递归前都赋一次值,并在递归函数的开头以move
指令的方式赋给相应的参数,进而实现递归的正确调用,需要注意的是第一次调用就传入初始参数,相应的可变变量也要赋成相应的值。
1 |
|
高精度阶乘
题面:
1 |
|
由于int型整型数溢出问题,本题不可采用一位一位相乘的方法,因此用C写出如下朴素的利用int型数组将两个数的相乘转化为大数的每一位同小数相乘再进行进位处理:
1 |
|
得到以上代码的汇编版本后却没有通过,个人认为是在进位处理时,后面的进位加到前面导致前面的溢出:
因此考虑令数组的每一位储存大数的更多位数,即:提高进制到1000
得到如下汇编代码:
1 |
|
注意前导零的处理,循环的书写一定要规范。
MARS小工具
MARS的Tool菜单里有个叫做Instruction Counter的工具,点击Connect to MIPS后运行程序可以看见程序跑了多少天实际的指令,可以作为相应题目指令限制的参考。
MIPS程序的内存视图
Text:程序代码段,最大代码长度:256M-4M
Static data:全局变量,$gp寄存器初始地址+-偏移量寻址本段内存
Dynamic data:堆
Stack:栈,自动存储区
MIPS程序的编译、链接和加载
编译
1.转换汇编指令
2.生成符号分配表
3.为全局变量、指令分配内存地址
4.转换成机器指令,保存到目标文件
例:将如下代码转换为汇编代码
1 |
|
寄存器$4($a0)=f, $5($a1)=g
操作系统会把从main返回的地址提前送$ra
程序区从0x00400000开始
静态数据区从0x10000000开始
全局指针$28($gp)=0x10008000
符号分配表
链接
1.链接其他目标代码文件和库函数文件(多个目标代码文件,链接时需要重新分配指令地址和数据地址)
2.生成一个完整的可执行代码文件
加载
1.可执行代码加载到内存(指令加载到代码段,数据加载到数据段)
2.设置PC、SP、GP
几种典型ISA简介
x86指令架构
8086/8088CPU结构
CISC指令集:数千条指令;变长指令格式;分段式存储管理;物理地址扩展
ARM架构
移动端、嵌入式系统和物联网标准
ARM指令集、Thumb指令
RISC-V架构
完全开源,继承MIPS
RISC与CISC
CISC(复杂指令系统计算机)
设置更多、更复杂指令实现更多的功能,减少运行程序所需的指令数,依靠硬件提升运行速度,实例:x86
SISC(精简指令系统计算机)
减少指令种类、简化指令功能,降低单个指令的执行周期数(CPI)来提高MIPS(单位时间执行百万条指令数),提升运行速度,实例:MIPS、ARM、RISC-V
两者相互学习借鉴。
MIPS处理器设计
CPU功能与组成
功能
取数、存数、传送、运算
组成
数据通路:指令执行过程中,指令数据流经过的部件和路径的总称,指令的执行部件。
设计步骤
MIPS模型机
单周期通路时序控制:
所有操作(组合逻辑)在单个时钟周期内完成;
信号在时钟周期内从状态单元输出经组合逻辑再回到状态单元输入;
状态单元时钟信号上跳沿同步
单周期数据通路设计
取指令(取出PC后维持一个周期后将PC+4重新写入PC寄存器)、读寄存器(根据PC读取的指令、读取相应寄存器操作数)、ALU运算、数据存区、写寄存器。