2_MIPS32指令集与编程

MIPS 32 指令集与编程

我们以经典的嵌入式处理器 --- MIPS 4kc 系列 为参照, 来讲解 MIPS 指令集与编程. 最后来讲解编程实例.

指令分类 (主要部分, 不包括浮点)

算术 (Arithmetic) 指令 (部分)

它有一系列算术指令, 但是太多了, 我们不可能一个个讲, 就给大家讲讲一些典型的.

举例

我们从最简单的加法指令开始

指令 格式 指令功能 其他
ADD ADD rd, rs, rt rd ← rs + rt 执行 32 位整数加法 ; 如果补码运算溢出则产生异常
ADDI ADDI rt, rs, immediate rt ← rs + immediate 16 位带符号立即数扩展后执行加法 ; 如果补码运算溢出则产生异常
ADDU ADDU rd, rs, rt rd ← rs + rt 不产生异常

以前讲过无符号数和带符号数, 运算的时候, 在硬件上是没有差别的.

但是 ADD 指令, 它把这个操作数当作补码运算, 当作带符号数运算的时候, 如果产生溢出, 它就会抛出一个异常. 这个有点意思, 一般很少说加法指令抛出个异常.

注意, ADDU 这个后面的 U 可能有点误导别人的地方, 貌似是 Unsigned 的加法, ADD 没有 U 貌似是一个 signed 的加法. 但是, 实际上不是这个意思. 这个 U 的意思是不产生异常.

不管是 32 位整型加法, 算法逻辑上是一样的, 它无非就是说 ADD 可能会产生异常, ADDU 不会产生异常, 它们就这个区别.

所以说, 一个很有意思的现象是, 大量的 MIPS 编译器, 编译出的指令基本上不用 ADD 这个指令. 因为比如你编译个 C 语言, 你加法溢出就溢出了, 没人会考虑到加法运算会抛出个异常出来. 所以编译器不用 ADD 这个指令, 用 ADDU 或者 ADDIU 就完了.

大家可以想想 ADDIU 这个指令里面的 IU 是什么.


指令 格式 指令功能 其它
CLO CLO rd, rs rd ← rs 前导 1 的个数 X86 指令集中有类似的 BSF (Bit Scan Forward), BSR 指令
CLZ CLZ rd, rs rd ← rs 前导 0 的个数 X86 指令集中有类似的 BSF (Bit Scan Forward), BSR 指令

这几个指令有点意思, 计算前导的 0 和 1, 相当于把 rs 这个 32 bit 的一个整型, 把它从高位比特往下捋, 看看它连着有多少前导 1, 相当于捋到第一个 0 为止, 如果你第一个 0 之前有 3 个 1 的话, 这个就是 3. 前导 0 也是一个意思.

实际上这种指令, 在有些 dsq 处理器里面有这个用处. x86 里面实际上也有这样的指令, 也就是说 Bit Scan 这种指令, 但是我们没有讲.

包括 lib_c 库里面也有相应的函数 :

-ffs, ffsl, ffsll - find first bit set in a word

#include <strings.h>
int ffs(int i);

#include <string.h>
int ffsl(long int i);
int ffsll(long long int i);

但是它捋的顺序好像是从低位往上捋, 是反过来的.


再往下就是乘法指令

指令 格式 指令功能 其它
MUL MUL rd, rs, rt rd ← rs * rt 32 位整数相乘, 结果只保留低 32 位 ; Hi/Lo 寄存器无定义
MULT MULT rs, rt (HI, LO) ← rs * rt 32 位带符号整数相乘, 结果存于 Hi/Lo 寄存器
MULTU MULTU rs, rt (HI, LO) ← rs * rt 32 位无符号整数相乘, 结果存于 Hi/Lo 寄存器
DIV DIV rs, rt (HI, LO) ← rs / rt 32 位带符号数相除, 结果存于 Hi/Lo 寄存器, 不会产生算术异常 (即便除以 0)
DIVU DIVU rs, rt (HI, LO) ← rs / rt 32 位无符号数相除, 结果存于 Hi/Lo 寄存器, 不会产生算术异常 (即便除以 0)

乘法 MUL 指令挺有意思, 我们可以在 x86 里面找到它对应的这条指令. 它是两个 32 位整数相乘, 结果只保留 32 位, 放到 32 位目标寄存器里面去. 那么如果你 32 位整型相乘, 目标只取 32 位, 带符号和不带符号就是一样的了.

MULT 是 32 位整数相乘, 结果存于 Hi/Lo 寄存器, 也就是说我要保存 64 位的结果. 那这个时候带符号和不带符号的运算, 结果就不一样了, 这个必须区分了. MULT 实际上是 32 位带符号的整数相乘, 结果存于 Hi/Lo 寄存器. MULTU 就是 32 位无符号数相乘, 结果存于 Hi/Lo 寄存器.

相应除法就是 DIVDIVU 了. 主要是它们不产生异常, 除以 0 也不产生异常, 加法都可能产生异常, 除法除以 0 都不产生异常, 很有意思, 但它就是这么设计的.


还有一系列的乘加指令

指令 格式 指令功能 其它
MADD MADD rs, rt (HI, LO) ← (HI, LO) + (rs * rt) 32 位带符号整数相乘再相加
MADDU MADDU rs, rt (HI, LO) ← (HI, LO) + (rs * rt) 32 位不带符号整数相乘再相加
MSUB MSUB rs, rt (HI, LO) ← (HI, LO) - (rs * rt) 32 位带符号整数相乘再相减
MSUBU MSUBU rs, rt (HI, LO) ← (HI, LO) - (rs * rt) 32 位不带符号整数相乘再相减
- - - - -
SLT SLT rd, rs, rt rd ← (rs < rt) 比较两个带符号 32 位整数, 比较的结果 (1 或者 0) 存入 rd 寄存器
SLTI SLTI rd, rs, immediate rd ← (rs < immediate) 比较两个带符号 32 位整数, 比较的结果 (1 或者 0) 存入 rd 寄存器
SLTIU SLTIU rd, rs, immediate rd ← (rs < immediate) 比较两个不带符号 32 位整数, 比较的结果 (1 或者 0) 存入 rd 寄存器
SLTU SLTU rd, rs, rt rd ← (rs < rt) 比较两个不带符号 32 位整数, 比较的结果 (1 或者 0) 存入 rd 寄存器

乘加相当于累乘加, 就是你乘完之后再做个加法, 再存到 Hi/Lo. 这个运算挺有用的, 因为我们会有大量作乘法操作的时候, 乘和加连续计算.

MADDU 相当于也是乘加指令, 是 32 位不带符号的整型相加.

MSUB 就是乘减了, 带符号数的乘减.

MSUBU 就是不带符号数的乘减.

SLT 指令叫 set less than. 具体见表格. 我们想想看, CISC 与 x86 相比, x86 有条件码, 但是咱们这个 MIPS 里没有, 所以说有些时候, 你要条件跳转, 就用这种指令, 先比大小, 结果存到通用寄存器里面去, 再用通用寄存器里的值, 来决定我分支是跳转还是不跳转.

STLI 就是和一个立即数相比较, 比较的时候, 到底是带符号还是不带符号, 就看指令后缀里面有没有 U.

分支 (Branch) 和跳转 (Jump) 指令 (部分)

条件跳转与无条件跳转

指令 说明
BEQ Branch on Equal
BGEZ Branch on Greater Than or Equal to Zero
BGEZAL Branch on Greater Than or Equal to Zero and Link
BGTZ Branch on Greater Than Zero
BLEZ Branch on Less Than or Equal to Zero
BLTZ Branch on Less Than Zero
BLTZAL Branch on Less Than Zero and Link
BNE Branch on Less Than Zero and Link
J Jump
JAL Jump and Link
JALR Jump and Link Register
JR Jump Register

关于这个 link, 我们说过 JAL 指令, 相当于 call 指令, 它会把返回地址保存在 31 号寄存器. 这个 link 实际上就是保存返回地址. 在满足条件判断的情况下, 我相当于做了一个 call 操作.

这个 JALR, 我 link 寄存器, 默认是 32 位寄存器, 返回地址默认保存在 31 号寄存器. 我 link 的寄存器可以换一个; JR 就是 jump register, 就相当于我的跳转目标地址, 放到一个寄存器里面.

指令 格式 指令功能 其它
BEQ BEQ rs, rt, offset if rs = rt then branch target_offset ← sign_extend(offset 00) ; if(rs = rt) then PC ← PC + target_offset offset 的宽度是 16 位
BGEZ BGEZ rs, offset if rs >= 0 then branch
BGEZAL BGEZAL rs, offset if rs >= 0 then procedure_call ..... GPR[31] ← PC + 4

PC 就是代表当前指令在内存中的地址

BEQ 最简单, 但注意这个 offset 是怎么算出来的, 指令字里面 offset 是 16 位长度, 但我们说过 MIPS 32 里所有指令字长都是一个 word, 就是一个 32 位的 word, 相当于 4 byte. 那这样的话, offset 我写的时候就是说, 我跳转目标地址, 如果把它完全写出来的话, 低两位肯定永远是 0 了, 因为它要 4 字节对齐.

所以在这种情况下, 我可以把它低两位的 0 不体现在指令里面, 这让我省了两位空间. 但是我计算的时候把它低两位填 0, 然后再做一个符号的扩展, 也就是说我可以往前跳, 也可以往后跳. 扩展完了呢, 看看条件是否满足, 如果 rs = rt 的话, 那我的跳转地址 PC + target_offset, 就加一下. 出来一个新的 PC 地址跳过去, 但是这时候 PC 指的是什么呢, 指的是下一条指令的地址. PC 指的是 delay slot 这个指令的地址.

BGEZ offset 的计算和上面也是一样的.

BGEZAL 也是类似, 我处理完之后一个 and link, 做个 link 操作, link 操作就是我把你这个返回地址放到 31 号寄存器里面去. 返回地址注意是 PC + 4, PC 又是 delay slot 的地址. 我们说过, 你跳转指令, 之后的 branch delay slot 这条指令是永远要一块执行的. 所以如果你是个函数调用的话, 是个返回地址的话, 你只能返回到再往下的一条地址, 所以相当于 PC + 4.

PC 就是代表当前指令在内存中的地址

指令 格式 指令功能 其它
J J target PC ← PC(高四位) || (target || 00) (target 26 位) 在当前的 256MB 对齐的空间内跳转
JAL JAL target GPR[31] ← PC + 4 ; PC ← PC(高四位) || (target || 00) 在当前的 256MB 对齐空间内执行过程调用
JALR JALR rs (rd = 31 implied) JALR rd, rs rd ← PC + 4 ; PC ← rs 执行过程调用, 过程入口地址位于 rs 内
JR JR rs PC ← rs 跳转至 rs 指定的地址

无条件跳转最简单的就是 jump target, 就是我要跳到一个目标地址, 但是要无条件跳过去. 那怎么弄呢, 一样的 target 是 26 位, 刚才说的 offset 是 16 位, target 可以是 26 位因为它指令字简单, 还是后面先填 0, 因为 target 跳过去应该是地址 4 字节对齐, 所以后面可以填 2 个 0, 然后再把 PC 的高四位取出来, 加上原来的 26 位, 2 + 4 + 26 = 32, 凑成一个 32 位的 地址, 这就是新的 PC 地址, 跳到这个地方去.

JAL 也是类似, 唯一的区别就是把 PC + 4 返回地址放到 31 号寄存器里面去, 返回地址是什么已经说过了, 你要考虑 branch delay slot.

JALR 也是类似的, 就是在默认的情况下呢, 它有两种方式. JALR rs 就是说过程调用的地址位于 rs 寄存器里面, 相当于我直接把 32 位地址放到寄存器里面跳过去, 完了我就把你的返回地址放到 rd 里面去. rd 如果要特别指定的话, 指定哪个寄存器就是哪个寄存器 ; 如果不指定的话, 就是默认的 31 号寄存器.

JR 就简单了, 就是直接跳到 rs 这寄存器里面所指定的地址.

指令控制 (Instruction Control) 指令

NOP (伪指令) : No Operation (SLL, r0, r0, 0) 伪指令

它不是一条真正的机器指令, 它就相当于做了一个逻辑移位, 把 r0 寄存器左移, 移了 0 位, 结果还是放在 r0 寄存器. 我们说过 r0 全是 0, 你可以去写入数据, 但结果它还是为 0.

所以这条指令它汇编出来之后, 机器码正好是全 0

装载 (Load)、存储 (Store) 指令 (部分)

我们说过有 Load Byte, Load Byte Unsigned, 我们说过它可以支持 Load 一个字、半字或者字节, 因为它的目标寄存器是 32 位的. 如果你要 Load 一个字或者半字的话呢, 那么你得把这个字或者半字扩充到一个 32 位寄存器里头. 那么就要两种扩展方式, 一个带符号扩展, 一个不带符号扩展, 所以它分成 LB 或者 LBU, LH 或者 LHU.

LLSC 是两个比较特殊的指令, 对于共享数据的原子操作的保证, 它里面用于这种用途, 我们等会会简单讲解.

还有一种就是 SWLSWR, 就是说对于不对齐的这个数据, 进行 Load/Store 操作, 但这里我们就不说了, 因为这些操作太复杂了, MIPS 也不建议用, 我们现在就不说这个事.


我们现在就主要看一般的 Load Store 指令.

指令 格式 指令功能 其它
LW LW rt, offset(base) 从内存中读取一个字存入目的寄存器 rt ← memory[base + offset] (offset 是 16 位带符号整数), 地址必须 4 字节对齐, 否则产生异常
LB LB rt, offset(base) 从内存中读取一个字节, 符号扩展后存入目的寄存器 rt ← sigh_extend (memory[base + offset]) (offset 是 16 位带符号整数)
LBU LBU rt, offset(base) 无符号扩展, 其它同上 ...
SW SW rt, offset(base) 从源寄存器读取字存入内存 ...
SB SB rt, offset(base) 从源寄存器读取低 8 位存入内存 ...

首先是 LW, LW 就是从内存当中读取一个字, 存入目标寄存器, 目标寄存器就是这里面的 rt, base 是个寄存器, 基址寄存器的值加上 offset, offset 是一个 16 位的带符号整型. 注意你算出来的地址必须 4 字节对齐, 否则产生异常. 如果你没有其它的措施的话, 程序就会关闭.

LB 就是从内存中读取一个字节, 符号扩展后存入 32 位目的寄存器, 那么它当然没有对齐的说法, 因为你是一个字节访问.

LBULB 一样, 无非就是说, 一个我把存入的数当成一个不带符号数处理, 另一个就是当成带符号数处理.

SW 就是从源寄存器读取字存入内存, 存进去, 它实际上相当于一个 load. SWLW 是一对, 当 LW 的时候地址也必须是 4 对齐.

SBLB 也是一对同等意思

LL/SC 指令

这个指令如果你们以后讲操作系统做这个多线程互斥的话, 会用到这个东西. 这里简单讲讲.

  • 在多线程程序中, 为了实现对共享变量的互斥访问, 一般需要一个 TestAndSet 的原子操作

    因为大家是共享内存的, 所以某线程可能无法同时访问某个变量. 但是我要保证这个原文操作的原子性, 所以一般需要一个 test and set 的原子操作. 比方说我要累加, 两个线程同时去累加一个数, 那先把它取出来, 用 load 操作取出来. 然后再线程加一, 再把它 store 出去. 另外一个线程也是同时在做这个操作.

    大家注意, 首先 load, 之后你做个加 modification, 再写回去, 这个至少需要 3 条指令来完成, 在这种情况下, 如果两个线程同时做这样的操作, 可能相互之间有干扰, 结果可能是错误的, 错误的根源在于你这个共享变量没有对多线程的访问进行保护, 所以一般来说需要 test and set 操作, 看看是不是有别人在访问它, 如果有别人我就等下一次, 防止这种冲突.

    • 这种原子操作需要专门的硬件支持才能完成
  • 在 MIPS 当中, 是通过特殊的 LoaD/Store 指令 " LL (Load Linked, 链接加载) 以及 SC (Store Conditional, 条件存储) 这一指令对完成的.

当使用 LL 指令从内存中读取一个字之后, 处理器会记住 LL 指令的这次操作, 同时 LL 指令读取的地址也会保存在处理器的寄存器中.

接下来的 SC 指令, 会检查上次 LL 指令执行后的操作是否是原子操作 (即不存在其它对这个地址的操作), 同时确保这个过程中没有中断异常之类的东西.

如果这些条件都满足, 是原子操作, 那么这个新的指令 v0 (下面的示例) 的值将会被更新到内存中, 同时 v0 的值也会变成 1, 表示操作成功 ;

反之, 如果不是原子操作 (即存在其它对这个地址的访问冲突), 则 v0 的值不会被更新到内存中, 且 v0 的值也会变为 0, 表示操作失败.

atomic_inc:
  ll    v0, 0(a0) # a0 has pointer to 'mycount'
  addu  v0, 1
  sc    v0, 0(a0)
  beq   v0, zero, atomic_inc  # retry if sc fails
  nop
  jr    rs
  nop

ll 把 v0 里面这个地址, 我们要保护这个地址, 把这个数取出来放到 v0 里面去, v0 加 1, 我要确保前面这个 load 这个加 1, 这个 store 是个原子型的操作, 不能被别人打断, 然后把 sc 存下去.

如果 sc 存成功了, 也就是说明我这个操作, 没有别的线程切换进来, 把它进行任何的干扰的话, 我这个操作就算成功, 成功了就是 v0 的数存到 a0 这个地址里面去, v0 这个值被修改成 1. 如果失败的话, v0 这个值就变成 0, 同时这个值也没有存下去. 这样的话, 我后面就可以判断了.

如果 v0 它是 zero 的话, 就说明我刚才这个 sc 存储失败了, 失败了我就再来一次, 说明有人在同时访问这个东西, 所以我再来一次, 这样就给它解决了问题. 就是多个线程同时对共享地址进行操作的话, 可能会引起一些相互之间冲突的问题.

逻辑指令

逻辑 (Logical) 指令 8 条

指令 全称
AND And
ANDI And Immediate
LUI Load Upper Immediate
NOR Not Or
OR Or
ORI Or Immediate
XOR Exclusive Or
XORI Exclusive Or Immediate

转移 (Move) 指令 6 条

指令 全称
MFHI Move From HI Register
MFLO Move From LO Register
MOVN Move Conditional on Not Zero
MOVZ Move Conditional on Zero
MTHI Move To HI Register
MTLP Move To LO Register

移位 (Shift) 指令 6 条

指令 全称
SLL Shift Word Left Logical
SLLV Shift Word Logical Variable
SRA Shift Word Right Arithmetic
SRAV Shift Word Right Arithmetic Variable
SRL Shift Word Right Logical
SRLV Shift Word Right Logical Variable

条件移动 Conditional Move, 这个指令在 x86 里面有, MIPS 里面也有. 就是移位操作, 我们简单讲讲看, 就是下面这个样子.

指令 格式 指令功能 其它
AND AND rd, rs, rt 针对 32 位寄存器执行逻辑与操作 rd ← rs AND rt
ANDI ANDI rt, rs, immediate 针对 32 位寄存器与立即数 (0 扩展后) 执行逻辑与操作 rt ← rs AND zero_extend(immediate)
LUI LUI rt, immediate 将 16 位立即数装入目的寄存器的高 16 位 (低 16 位清 0) rt ← immediate 00...00 (16
MOVZ MOVZ rd, rs, rt 条件移动 if rt = 0 then rd ← rs
SLL SLL rd, rt, sa 左移操作 rd ← rt << sa sa 是一个 5 位立即数 (无符号)
SLLV SLLV rd, rt, rs 左移操作 寄存器 rs 的低 5 位表示左移的位数

AND 这个简单, 就是三操作数指令, 针对 32 位寄存器执行逻辑操作, 结果放在 rd.

ANDI 也是类似, 无非是把其中一个操作数换成一个立即数, 但是它需要扩展, 因为它是做这种按位操作, 所以它要做一个 0 扩展.

LUI 相当于将 16 位立即数装入目的寄存器的高 16 位, 同时将低 16 位清零. 这个蛮重要的, 因为我们以前讲过 MIPS 32 位, 它固定字长, 简洁漂亮, 但是有个不好的地方, 就是常数项的长度受限了. 有些情况下, 比如你要做一个比较大的运算, 这个数字 16 位可能不够用, 那得把它装到一个 32 位寄存器里面去. 怎么装呢, 直接给它拆分呗. 我就把它的这个所有的立即数, 首先取这个数, 把原来的这个数给它分成两半, 把它高 16 位通过 LUI 指令装到 rt 里面去, 然后再想办法把低 16 位给它拼起来. 这样我就可以把一个比较大的数装入到一个 32 位寄存器里面去.

MOVZ, 就是条件移动, 如果 rt (rt 是个条件) 等于 0, 那么 rs 里面这个数就放到 rd 里面去. 这个和 x86 里面讲的条件跳转指令是一样的, 就是用它的这个方式可以减少条件判断指令, 来提升流水线运行的效率.

SLL 就是左移操作, rt 寄存器左移 sa, sa 是一个 5 位立即数, 因为你移位嘛, 32 位宽度, 5 位立即数够用了, 结果放到 rd 里面去, 那么 sa 也可以被替换成是一个原寄存器 rs, rs 的低 5 位表示左移的位数, 因为它 5 位就够了.

当然还有右移操作, 右移操作就得区分是不是带符号位了, 这个是算术右移还是逻辑右移, 算术右移就是带符号位, 逻辑右移就是用 0 填充.

陷阱指令

指令 全称
BREAK Breakpoint
SYSCALL System Call
TEQ Trap if Equal
TEQI Trap if Equal Immediate
TGE Trap if Greater or Equal
TGEI Trao if Greater or Equal Immediate
TGEIU Trap if Greater or Equal Immediate Unsigned
TGEU Trap if Greater or Equal Unsigned
TLT Trap if Less Than
TLTI Trap if Less Than Immediate
TLTIU Trap if Less Than Immediate Unsigned
TLTU Trap if Less Than Unsigned
TNE Trap if Not Equal
TNEI Trap if Not Equal Immediate

关键的是 syscall 这个指令, syscall 相当于 x86 里面的 int next 80, 相当于就是说你的用户层的程序, 如果要调用操作系统的一些功能, 你得通过 syscall 这条指令进行.

分支 (Branch Likely) 指令 (不建议使用)

指令 全称
BEQL Branch on Equal Likely
BGEZALL Branch on Greater Than or Equal to Zero and Link Likely
BGEZL Branch on Greater Than or Equal to Zero Likely
BGTZL Branch on Greater Than or Equal to Zero Likely
BLEZL Branch on Less Than or Equal to Zero Likely
BLTZALL Branch on Less Than Zero and Link Likely
BLTZL Branch on Less Than Zero Likely
BNEL Branch on Not Equal Likely

EJTAG 指令(调试用)

指令 全称
DERET Debug Exception Return
SDBBP Software Debug Breakpoint

特权 (Privileged) 指令

特权指令和在内核态才能使用的指令

指令 全称
CACHE Perform Cache Operation
ERET Exception Return
MFC0 Move from Coprocessor 0
MTC0 Move to Coprocessor 0
TLBP Probe TLB for Matching Entry
TLBR Read Indexed TLB Entry
TLBWI Write Indexed TLB Entry
TLBWR Write Random TLB Entry
WAIT Enter Standing Mode

立即数相关的转换

我们一再提到 MIPS 指令集 32 位宽度, 定长的宽度, 实际上是限制了立即数的它能够直接支持的立即数的大小. 这样你的汇编编程难度会提高.

比方说刚才的简单例子, 我要去算一个数, 立即数, 这个立即数比较大, 所以一看存不下. 但刚才那些 ADD 什么呢, IU 指令, 一看就只能放 16 位立即数, 那我这个数比较大怎么办呢. MIPS 汇编器会做一些预处理, 帮助你降低这个难度.

举几个例子

addu $2, $4, 64addiu $2, $4, 64 比方说我写条指令 addu 64 加上 4 号寄存器, 结果放到 2 号寄存器, 这严格来说应该叫作 addiu 指令, 因为有一个立即数在里头, 但这个没关系, 它也识别这种指令, 所以你严格来说, 硬件上面 addu 这个指令不是这个写法, 但它也能识别, 自动把你转化成 addiu 这个指令.


addu $4, 0x12345li at, 0x12345 addu $4, $4, at 再往下可能更有代表性了, 你看本来三个操作数的, 我可以只用两个操作数, 那么当然这个操作数的做法的语义是非常明确的, 我就把立即数加到 4 号寄存器里面去, 结果肯定是放到 4 号寄存器里面去了. 明显的一个事, 就是说首先它能自动地把你这个加法指令扩展成一个三操作数.

另外一个是什么呢, 你看 0x12345, 这个数明摆着一看就是 16 位放不下, 16 位放不下怎么办, 它引用了一条指令 load immediate, 装载立即数指令, 就把 0x12345 放到 AT 寄存器里面去, 然后 AT 寄存器加上 4 号寄存器, 结果放到 4 号寄存器里面去.

首先要注意 li 指令也是一条伪指令, 是 MIPS 支持的, 后面会解释 li 指令干了啥. 那这里面主要是, 它能够自动地把你的两操作数指令转换为三操作数. 另外就是这个 AT, 因为 0x12345 不能一次性地去做运算, 所以汇编器会把你给出的这条指令拆成两条, 或者更多条的指令, 来完成相关的操作.

那么在这个拆的过程中, 它需要一些寄存器, 用来存储一些中间结果, 比方说 AT 寄存器. 之前将寄存器的时候提到过, AT 寄存器是保留给汇编器使用的, 实际上就是这个道理. 因为你看在这里面, 它自动地把这条指令拆分成多条指令, 多条指令当中需要一个中间寄存器, 这个寄存器它就选 AT 来用. 但是要注意, 我们手写汇编的时候不能去用 AT 寄存器, 因为有可能一条指令被它拆分后, AT 寄存器的值被改掉了, 那这个时候你要再去拿 AT 作为一个重要寄存器来使用的话, 你可能会发现一些莫名其妙的结果, 实际上是因为汇编器保留 AT 做其他事情去了. 所以这个时候我们可以反过来推推 AT 寄存器是保留给汇编器使用的, 你手写汇编的时候不要去动它.

再讲讲这个 li, li 是个很好的例子, 刚才说过 0x12345 立即数不够放了, li 这条指令, 汇编器会根据你不同的操作数, 把它转换为不同的真正的汇编指令, 一条或者多条.

比方说

li  $3, -5        → addiu $3, $0, -5
li  $4, 0x8000    → ori   $4, $0, 0x8000
li  $5, 0x120000  → lui   $5, 0x12
li  $6, 0x12345   → lui   $6, 0x1
                    ori   $6, $6, 0x2345

比方说 li $3, -5 这个指令, 就把 -5 这个值放到 $3 号里面去, 那这就很简单了, -5 加上寄存器 0, 放到 3 里面去

然后是 $4, 0x8000, 这个也可以用一个 addiu 这种类似的加法, 它有很多种可能性, 它也可以用或操作

li $5, 0x120000 这个比较有意思, 把它转换为一条 lui 指令 0x12. 为什么呢, 因为你正好发现它的低 16 位是全零, 正好可以用 lui 把 0x12 这个立即数放到目标寄存器的高 16 位, 然后低 16 位填 0.

那么回过来我们这个 0x12345 这个例子, 这个数看来 16 位是放不下了, 要给它放到一个目标寄存器应该怎么做呢. 这样的话就是拆分成两条指令了, 首先是 lui 0x1, 我先把高的 16 位给它填上, 然后再去填它的低 16 位, 我这个做个或操作, 因为你相当于 0 号寄存器的低 16 位全零了, 所以做个或操作是正好, 这个非常幸运, 汇编器帮我们解决掉了. 所幸汇编器帮我们完成了这么一个工作, 不然你想想看你要手动这么去拆分, 挺麻烦的.

不过这有副作用, 副作用就是 6 号寄存器被汇编器保留使用, 你手动写汇编的时候不能用这个 AT 寄存器.


接下来还有些例子

lw  $2, ($3)      → lw    $2, 0($3)
lw  $2, 8 + 4($3) → lw    $2, 12($3)
lw  $2, addr      → lui   at, %hi(addr)
                    lw    $2, %lo(addr)(at)
sw  $2, addr($3)  → lui   at, %hi(addr)
                    addu  at, at, $3
                    sw    $2, %lo(addr)(at)

比方说你写 load 指令的时候, 应该是 offset 加上一个基址寄存器, 但是你 offset 可以不写, 它自动帮你填 0. 你也可以写成 8 + 4, 汇编器自动给你加成 12.

还有, 我直接给出一个 address, 我这个 addr 把它作为地址去访存, 把这个值直接放到 2 号寄存器里面去, 这个汇编器就会分析一下这个 addr.

汇编器会先把 addr 的高 16 位放到 at 寄存器里面去, 然后把 at 寄存器作为基址, 把它的低 16 位, 因为 addr 是一个立即数嘛, 把低 16 位作 offset, 这样正好回避了上面说的 addr 可能大于 16 位能表示的范围, 这个拆分的问题, 给它回避掉了.

sw 也是类似, 比方说这个 addr, 我把 addr 高 16 位放到 at 寄存器里面去, 然后 at 寄存器加上原来这个基址寄存器, 然后相当于把 at 变成了一个新的基址寄存器, 然后 offset 就取原来 addr 的低 16 位, 也就是这么多方法就是为了回避上面立即数 16 位放不下的这个问题.

不过, 说了这么多, 我们手写汇编的时候其实根本不需要操心这个问题, 汇编器会帮我们完成这个事情.

指令编码

当我们只写程序的话, 指令编码看不看得到就没有关系, 但如果你要去设计处理器的话就要看指令编码了, 因为你要进行指令解码.

MIPS 32 的指令类型非常简单, 就分成 3 类 I J R

I 就是带立即数的, load store 里面用, 属于这一种但不仅限于

J 就 jump 跳转指令, 就跳转指令这一条, 这也是第二类

第三类 Register, 相当于三操作数的, 寄存器的加加减减这种, 包括一些移位的在 R 的类型里面

一共就三种, 看起来非常漂亮和工整, 长度都是 32 位, 而且它们的 op, 就是具体的功能码都是非常固定的, rs rt 的位置都是很固定的位置, 所以解码的时候就比较简单了.

op 段编码*

往下就看看不同指令的 op 的编码的值是什么, 可以去对应一下

比方说 31 位到 29 位, 28 位到 26 位, 对起来查查看是什么指令, 是什么位置, 它们编码是什么样子

当然我们写程序的时候不考虑这个, 但是你设计处理器, 做译码电路的时候, 这个就有用了

Special 2 操作码对应的 Function 段编码*

这是一些相关的译码

这些个东西, 我们看看就好

CP0 中的 rs 为 CO 时 Function 段编码*

编程实例

这里有个问题哈, 就是我们要写 MIPS 程序, 问题是你上哪里去找 MIPS 的机器, MIPS 的机器其实很少, 所以我们需要一个模拟器

  • 目录

  • MIPS 32 指令集

  • 编程实例

    • 使用 "SPIM" MIPS 系统模拟器

这个模拟器是开源的, 但它并没有完全实现 MIPS 在 IT 架构里面的功能, 不过对于一些简单的用户层的程序以及一些简单的异常向量处理, 它还是可以搞定的

SPIM 模拟器下载

这个模拟器长得很像一个调试器

模拟器左侧是当前寄存器的值, 右侧是当前正在跑的程序代码, 你可以在左上角 load file 加载一个 MIPS 的汇编代码

它加载的代码分为左右两侧, 实际上右侧是你手写出来的东西, 那么左侧是就是汇编器转换成真实的 MIPS 的代码.

然后你可以按 F10 进行单步跟踪

你可以在上面切换成显示数据, 就是显示你当前程序数据段里面的地址, 包括你程序的栈, 都是可以显示出来的.

你可以对这个模拟器做一些设置, 建议把 branch delay slot 这个选项打开, 这个知识我们前面讲过, 具体就不说了.

MIPS 汇编指示 (Directives)

现在该怎么写一个 MIPS 程序呢

实际上和 x86 差不多

  • 段说明 .text, .radata, .data, .bss

比如段说明和 x86 几乎是一样的

.rdata
  msg:
  .asciiz "Hello world\n"
.data
  table:
  .word 1
  .word 2
  .word 3
.text
  func:
  sub sp, 64
  ...
.bss
  .comm dbgflag, 4  # global common variable, 4 bytes
  .lcomm array. 300 # local common variable, 100 bytes

数据类型定义

再往下就是一些数据类型的定义

.byte, .half, .word, .dword, .float, .double, .ascii, .asciiz

.byte 3             # 1 byte 3
.half 1, 2, 3       # 3 half-words : 1 2 3
.word 5 : 3, 6, 7   # 5 words : 5 5 5 6 7

.float  1.4142175   # 1 single-precision value
.double 110, 3.1415 # 2 double-precision values

.ascii  "Hello
.byte 3             # 1 byte 3
.half 1, 2, 3       # 3 half-words : 1 2 3
.word 5 : 3, 6, 7   # 5 words : 5 5 5 6 7
.float  1.4142175   # 1 single-precision value
.double 110, 3.1415 # 2 double-precision values
.ascii  "Hello\0"
.asciiz "Hello"
" .asciiz "Hello"
.align  4 # 16-byte boundary (2^4) 对齐
var:
  .word 0

.align 的那个 4 就是要求后续地址的这个数据, 要跟地址的 2 的 4 次方对齐

struc:
.word 3
.space  120 # 120-byte 的空间
.word -1

这里面可以定义的有 struct, 注意这个标号 stuc 表示的其实是一个起始地址, 然后 .space 就是说可以表示多少个字节的空间, .word -1 就是我这里面写了一个 32 位字, 32 位字值为 -1, 就存在这个地方

各类标识的属性

.data
.globl  status  # global variable
  status: .word 0
.text
.globs  set_status  # global function
set_status:
  subu  sp, 24
  ......

.extern index, 4
.extern array, 100
lw  $3, index # load a 4-byte(1-word) external
lw  $2, array($3) # load part of a 100-byte external

A global symbol. This directive enables the assembler to store the datum in a portion of the data segment that is efficiently access via register $gp.

你如果要引用其它模块所定义的一些全局变量的话, 可以用 .extern, 这点和 C 语言是一样的.

那么这些全局变量和这些符号有些什么用呢, 就是可以让汇编器把这些数据存放在一个比较集中的区域, 这个区域可以通过全局寄存器 gp 所指定的地址周围.

我们说过, load store 指令, 它里面 offset 这个值取值范围有限, 是 16 位, 所以有的时候这个全局变量可能够不着, 就是说你必须进入寄存器加上你这 offset 要指到它的地方, 才能把它访问出来, 所以在这种情况下, 我就保留 gp 寄存器, 把它先指到一个位置, 然后把你相关的这些全局变量放到 gp 寄存器所指定地址的周围, 使得你这个 offset 的这个值不用太大, 16 位就够了, 然后这就可以访问你所有的全局变量.

编程示例

示例一 : 分别计算整数数组中正数、负数的和

.data
array:
  .word -4, 5, 8, -1
msg1:
  .asciiz "\n The sum of the positive values = "
  .asciiz "\n The sum of the negative values = "  # 字符串默认以 0 结尾, 所以 .ascii 后面加了个 z
.globl  main
.text
main:
  li  $v0, 4  # system call code for print_str
  la  $a0, msg1 # load address of msg1. into $a0
  syscall # print the string
  La  $a0, array  # Initialize address Parameter
  li  $a1, 4  # Initialize length Parameter
  jal sum # Call sum
  nop # delay slot
  move  $a0, $v0  # move value to be printed to $a0
  li  $v0, 1  # system call code for print_int
  syscall # print sum of Pos:
  li  $v0, 4  # system call code for print_str
  la  $a0, msg2 # load address of msg2. into $a0
  syscall
  li  $v0, 1  # system call code for print_int
  move  $a0, $v1  # move value to be printed to $a0
  syscall # print sum of neg

  li  $v0, 10 # terminate program run and return control to system
  syscall

首先把两句话打印出来, 把文字说明打印出来. 然后我要调用一个 sum, 调用 sum 是什么意思呢, 就是说我先初始化, 之前说过 MIPS 里面调用, 我这边还是遵循 C 语言的规范, 默认通过寄存器传递前 4 个参数, 这样我就告诉 sum 函数, sum 函数所要访问的数组的起始地址放在 0 号寄存器里面去, sum 要访问的数组的长度是 4 放到 1 号寄存器里面去, 我就跳过去, 跳过去之后要注意, jal 指令是跳转指令, 它有 delay slot, 要插入一条无关指令, 这里直接插条 nop 完事.

jalnop 是一块执行的, jal 的返回地址是 move 指令, 然后 sum 函数怎么去求和一会再说, 我们看看 sum 返回之后的结果放在 a0 里面. move 指令是条伪指令, 当然可以有很多种转化, 我们回头再看, 它把这个结果放在 v0 里面去, 然后就是 sum 这个过程返回之后, 它默认计算的结果, 默认 return 的结果是放在 v0 的, 我们把结果值放到 a0 里面去.

然后 li $v0, 1, syscall, 这几个的目的就是为了我通过一个系统调用, 把我所计算出来的和, 给它打印出来.

这里面有个问题, SPIM 模拟器实际上是没法跑操作系统的, 因为它有些功能不全, 包括虚存方面实现的不全, sp0 上面. 那么它怎么去实现系统调用呢. 所谓的系统调用是用户程序去请求操作系统去完成一个任务. SPIM 模拟器里面虚拟了一些系统调用, 就是说你可以利用系统调用这个形式, 进行写程序, 呢模拟器看到这个系统调用的话, 它会把相关的参数通过 v0 a0 传到的参数给它读出来, 它是通过 v0 来指定你所要调用的功能是哪个功能, 你是 print 一个 string 还是 print 一个 int. 然后如果你是 print string 的话, 就看下 a0, a0 里面就告诉你我要的这个 print string 这个地址层在 a0 里面; 如果是 int 的话, 就在 a0 里面放你所要 print 出来的 int 类型的值. 相当于 SPIM 模拟器它自己做了一套简单的系统调用, 你只要遵循它这个规范, 就可以对外进行输出, 或者进行输入等一系列功能.

后面就是类似的把另外一些打印出来, 就不说了

我们来看一下 sum 函数

sum:
  li  $v0, 0
  li  $v1, 0  # Initialize v0 and v1 to zero
loop:
  blez  $a1, retzz  # If(a1 <= 0) Branch to Return
  nop
  addi  $a1, $a1, -1  # Decrement loop count
  lw  $t0, 0($a0) # Get a value from the array
  addi  $a0, $a0, 4 # Increment array pointer to next
  bltz  $t0, negg # If value is negative Branch to neg
  nop
  add $v0, $v0, $t0 # Add to the positive sum
  b loop  # Branch around the next two instructions
  nop
negg:
  add $v1, $v1, $t0 # Add to the negative sum
  b loop  # Branch to loop
  nop
retzz:
  jr  $ra # Return

这个函数功能本身非常简单, 无非就是看一下 a1, a1 如果小于等于 0 的话就 return. ai 就指的是数组的长度, 如果你小于等于 0 的话就没必要继续执行了. 然后就计算一下循环的个数了, 因为 a1 里面存放的就是你数组的长度, 然后我们在 a0 里面存放的就是你数组的起始地址, 然后我们把里面当前的数取出来放到 t0 里面, 然后 a0+4, 相当于每一次都是整数嘛, 取一位, 指针就往下挪个 4 嘛. 然后比较下 t0 是正的还是负的, 如果是负的, 就做一个负数的累加; 正的话, 就做一个正的累加. 但是我结果放到不同的地方, 一个放到 v0, 一个放到 v1, 因为 MIPS32 里面两个寄存器是用于保存返回值的, 一个是 v0, 一个是 v1. 所以我把正的结果放到 v0 里面, 负的结果就放在 v1 里面. 然后循环, 循环次数返回之后就 return, 就是 jr, 因为 ra 寄存器存放的是返回地址, jr 就是 jump register, 就跳到 ra 里面指定的地址. return 回去之后, 我们这边 move v1 相当于负数的值放到 v1 里头, 把它打出来, 刚才那个是 v0, 把这个值打出来就可以了.

SPIM 模拟的系统调用

具体它模拟的系统调用就这些, 简单看看就可以了

示例二 : 阶乘 (delay slot 关闭)

最后一个例子是阶乘的运算, 这里面给大家一个小小的提示是, SPIM 模拟器里面, 它可以把 delay slot 这个功能给关掉.刚才讲累加的时候, delay slot 是打开的, 所以那个 jal 指令后面要有个 nop 填入那个 delay slot. 但是 SPIM 可以关掉它, 也就是 jal 指令后面不用加无关指令.

我们这里讲的例子是把它关掉的, 但是建议以后写程序的时候还是把它打开, 体现一下 MIPS 的特点.

阶乘是用过一个递归调用来完成的

.text
.globl  main
main:
  subu  $sp, $sp, 32  # stack frame is 32 bytes long
  sw  $ra, 20($sp)    # save return address
  sw  $fp, 16($sp)    # save old frame pointer
  addiu $fp, $sp, 28  # set up frame pointer

  li  $a0, 10         # put argument (10) in $a0
  jal fact            # call factorial function

  mov $a0, $v0
  li  $v0, 1          # print the result
  syscall

  lw  $ra, 20($sp)    # restore return address
  lw  $fp, 16($sp)    # restore frame pointer
  addiu $sp, $sp, 32  # pop stace frame
  li  $v0, 10         # terminate program run and return control to system
  syscall

首先开辟一个栈空间, 然后把返回地址存起来, MIPS 下 C 到汇编的编译, 它的规范和 x86 几乎是一样的, 大家都是 gcc 做的, 差不多, 在这种情况下 ra 是 31 号寄存器, 但是 31 号寄存器只有一个, 但是你要做嵌套过程调用并且还是递归调用的话, 你要先把返回值存到栈里面去. 还有 fp 就相当于 x86 里面的 ebp, 这个也要存一下, 因为每一个过程都要用到它, 就是 sp 在栈顶, fp 在栈底, 两头一掐, 中间的栈帧就给你来用, 栈的长度是 32 也就是这么来的.

sp 加 28 是 fp, sp 在栈底, 加 28, 这个 28 byte 栈帧就是你的了.

再往下我们就要做阶乘了, 做 10 的阶乘, 通过 a0 传参, 然后 jal 放过去, 递归的过程等会再说, 我们先看看过程调用返回之后, 阶乘算完之后怎么做的. 阶乘完成之后, 把这个结果 v0 放到 a0 里面去, 然后 print the result, 因为它是个 int 值, 把它打出来, 就用 SPIM 的系统调用把它打出来.

然后再 restore return address, 因为你要返回了, ra 存在里面的, ra 通过反复的系统调用被改掉了, 所以这里要把它返回, fp 也要返回, 因为 fp 是个栈帧的基址, 大家都在用, 把它返回是一个好习惯. 然后再是恢复 sp, 加 32.

最后也是通过一个虚拟的系统调用结束当前的代码.


现在我们看看具体的阶乘操作

.text
fact:
  subu  $sp, $sp, 32  # stack frame is 32 bytes long
  sw  $ra, 20($sp)  # save return address
  sw  $fp, 16($sp)  # save frame pointer
  addiu $fp, $sp, 28  # set up frame pointer

  load  $v0, 0($fp) # load n
  bgtz  $v0, $L2  # branch if n > 0
  li  $v0, 1  # return 1
  jr  $L1 # jump to code to return
$L2:
  lw  $v1, 0($fp) # load n
  subu  $v0, $v1, 1 # compute n - 1
  move  $a0, $v0  # move value to $a0

  jal fact  # call factorial function
  lw  $v1, 0($fp) # load n
  mul $v0, $v0, $v1 # compute fact(n-1) * n

$L1:
  lw  $ra, 20($sp)  # restore $ra
  lw  $fp, 16($sp)  # restore $fp
  addiu $sp, $sp, 32  # pop stack
  jr  $ra # return to caller

前面指令就是保存栈帧

然后就把阶乘的这个数 n 给 load 起来, 看看是不是大于 0, 如果不是大于 0 的话就返回 1, 也就相当于等于 0, 0 的阶乘是 1 嘛, 然后就 return 回去了. 如果是 L2 的话, 我就把 load n compute n - 1, 然后因为我要进行递归调用, 我只要 n - 1 传参, 放到 a0 寄存器里面去.

然后再往下进行递归调用, 递归调用结果完成之后, 它的返回结果放到 v0 里面, 然后我要再完成 n-1 的这个阶乘的结果, 乘上当前的这个 n, 完了最终再返回, 函数返回, 相当于解递归出来了, 就返回.

那么它的整个 stack 的运行过程也是从高地址往下, 这个是跟 x86 里面一样的, 不细说了.