3、MIPS汇编基础:常用指令、过程调用与栈帧、延迟槽

好,咱们进入第三章。说实话,MIPS 的指令集在 RISC 家族里算是相当清爽的。我当年第一次接触它的时候,感觉就像拿到了一把瑞士军刀——功能明确,没有花里胡哨的东西。但清爽归清爽,坑也不少。尤其是延迟槽,我第一次逆向的时候就被它坑了一把,后面我会细说。

这一章,咱们就聚焦三个核心模块:常用指令过程调用与栈帧、以及那个让人又爱又恨的延迟槽。掌握了这些,你就能看懂大部分 MIPS 固件的汇编代码了。

3.1 常用指令:加减、访存、跳转

MIPS 的指令格式非常规整。每条指令都是 32 位,操作码、寄存器、立即数,各就各位。我个人习惯把常用指令分成三类:算术运算、内存访问、控制流。

3.1.1 算术运算:add 与 sub

先看加法。MIPS 里加法指令有好几个变种,但最常用的是 addaddu。区别在哪?add 会检查溢出,addu 不会。在固件逆向里,我几乎没见过有人用 add 检查溢出,基本都是 addu。为什么?因为嵌入式系统追求效率,溢出检查会多一条指令,没必要。

# 加法示例
add $t0, $t1, $t2    # $t0 = $t1 + $t2,溢出会触发异常
addu $t0, $t1, $t2   # $t0 = $t1 + $t2,不检查溢出
addiu $t0, $t1, 100  # $t0 = $t1 + 100,立即数加法,不检查溢出

减法就简单了,subsubu。用法跟加法对称。

sub $t0, $t1, $t2    # $t0 = $t1 - $t2
subu $t0, $t1, $t2   # 同上,不检查溢出

我在项目中遇到过一个问题:某款路由器固件里,一个加法指令用了 add 而不是 addu,结果某个特殊输入导致溢出,直接触发了异常,系统重启。嗯,从那以后我逆向时都会留意这个细节。

3.1.2 内存访问:lw 与 sw

MIPS 是加载-存储架构。什么意思?就是只有 lwsw 这类指令能访问内存,算术运算只能在寄存器之间进行。这一点跟 x86 很不一样,你想想看,x86 可以直接在内存上做加法,但 MIPS 不行。

指令含义示例
lw从内存加载一个字(32位)到寄存器lw $t0, 0($sp)
sw将寄存器的值存储到内存sw $t0, 4($sp)
lb加载一个字节(8位)lb $t0, 0($a0)
sb存储一个字节sb $t0, 0($a1)

注意,MIPS 要求内存访问必须对齐。比如 lw 的地址必须是 4 的倍数。如果不对齐,会触发异常。我曾经逆向过一个固件,里面有一段代码故意用非对齐访问来检测环境——嗯,算是一种反调试技巧吧。

3.1.3 控制流:beq 与 j

分支和跳转,是控制流的灵魂。

  • beq:相等则跳转。格式是 beq $rs, $rt, offset。如果 $rs == $rt,就跳转到 PC + 4 + offset 的位置。
  • bne:不相等则跳转。用法跟 beq 一样,条件相反。
  • j:无条件跳转。格式是 j target,直接跳到目标地址。
  • jal:跳转并链接。用于函数调用,会把返回地址保存到 $ra 寄存器。
beq $t0, $t1, label   # 如果 $t0 == $t1,跳转到 label
bne $t0, $zero, loop  # 如果 $t0 != 0,跳转到 loop
j main                # 无条件跳转到 main
jal func              # 调用 func,返回地址存到 $ra

这里有个细节:beqbne 的跳转范围是有限的(±128KB 左右),而 j 可以跳到 256MB 范围内的任意地址。在固件逆向中,如果你看到 j 指令,通常意味着跳转目标比较远。

3.2 过程调用与栈帧

函数调用,说白了就是怎么跳过去、怎么回来、怎么传参。MIPS 有一套约定,叫调用约定(Calling Convention)。虽然不同编译器可能略有差异,但大体相同。

3.2.1 参数传递

MIPS 用 $a0$a3 传递前四个参数。如果参数超过四个,就用栈来传递。返回值放在 $v0$v1 里。

# 调用 func(a, b, c, d)
move $a0, $s0        # 第一个参数
move $a1, $s1        # 第二个参数
move $a2, $s2        # 第三个参数
move $a3, $s3        # 第四个参数
jal func             # 调用函数

3.2.2 栈帧结构

每个函数都有自己的栈帧。栈帧里存什么?局部变量、保存的寄存器、返回地址。我画了一张图,帮你理解栈帧的结构。

MIPS 栈帧结构 高地址 调用者栈帧 参数区域(保存 $a0-$a3) 返回地址($ra) 保存的寄存器($s0-$s7) 局部变量 低地址 $sp 指向这里 栈增长方向

你看,栈是从高地址向低地址增长的。每次函数调用,$sp 会减去一个值,为当前函数分配栈帧。函数返回时,$sp 再加回来。

3.2.3 典型函数入口和出口

一个典型的 MIPS 函数,开头和结尾长这样:

# 函数入口
func:
    addiu $sp, $sp, -32    # 分配 32 字节栈帧
    sw $ra, 28($sp)        # 保存返回地址
    sw $s0, 24($sp)        # 保存 $s0
    sw $s1, 20($sp)        # 保存 $s1
    # ... 函数体 ...

# 函数出口
    lw $ra, 28($sp)        # 恢复返回地址
    lw $s0, 24($sp)        # 恢复 $s0
    lw $s1, 20($sp)        # 恢复 $s1
    addiu $sp, $sp, 32     # 释放栈帧
    jr $ra                 # 返回调用者

注意看,jr $ra 是返回指令。它跳转到 $ra 里保存的地址。如果函数没有调用其他函数(叶子函数),可以不保存 $ra,直接 jr $ra 返回。

小技巧: 逆向时,看到函数开头有 addiu $sp, $sp, -N,就知道这个函数分配了 N 字节的栈帧。然后看它保存了哪些寄存器,就能推断出函数里用了哪些局部变量和临时寄存器。

3.3 延迟槽:MIPS 的独特设计

好,终于到了延迟槽。这是 MIPS 最反直觉的设计之一。我第一次逆向时,看到分支指令后面的那条指令明明在分支条件成立时不该执行,可它偏偏执行了——我当时以为 IDA 反汇编错了。后来查了资料才明白,这是延迟槽。

3.3.1 什么是延迟槽?

MIPS 是流水线架构。当 CPU 执行到分支指令时,下一条指令已经进入流水线了。为了不浪费这个时钟周期,MIPS 规定:分支指令后面的那条指令,无论分支是否跳转,都会被执行。这条指令的位置,就叫延迟槽。

beq $t0, $zero, target   # 分支指令
nop                       # 延迟槽,总会执行
target:
    addiu $t1, $t1, 1     # 跳转目标

上面这个例子,nop 在延迟槽里,它总会执行。但很多时候,编译器会优化,把一条有用的指令塞进延迟槽。

3.3.2 延迟槽的优化

编译器会尽量把不依赖分支结果的指令放到延迟槽里。比如:

# 优化前
addiu $t0, $t0, 1
beq $t0, $zero, target
nop

# 优化后
beq $t0, $zero, target
addiu $t0, $t0, 1    # 延迟槽,提前执行加法

你看,优化后的代码把 addiu 放进了延迟槽。这样既利用了延迟槽,又少了一条 nop,效率更高。

3.3.3 逆向时如何应对延迟槽?

嗯,这里要注意。逆向时,你看到分支指令,一定要看它后面的那条指令。那条指令是延迟槽,它总会执行。如果你忽略它,可能会误解程序的逻辑。

避坑指南: 我曾经逆向过一个 MIPS 固件,里面有一段代码看起来像是死循环——分支指令跳转到自己。但仔细一看,延迟槽里有一条 addiu $sp, $sp, -32。原来这不是死循环,而是一个巧妙的栈帧分配技巧。如果我没注意到延迟槽,就会完全理解错。

另外,MIPS 的 jr $ra 也有延迟槽。也就是说,在 jr $ra 后面的那条指令,会在函数返回后执行。这听起来很诡异,但确实如此。编译器通常会在这条延迟槽里放一条 nop,或者放一条恢复 $sp 的指令。

3.4 小结

这一章我们聊了 MIPS 的常用指令、过程调用与栈帧、以及延迟槽。说实话,这些内容看起来简单,但实际逆向时,细节决定成败。尤其是延迟槽,我建议你多花点时间理解它。你可以在 IDA 里打开一个 MIPS 固件,单步跟踪几条分支指令,看看延迟槽里的指令是怎么执行的——实践出真知。

好,这一章就到这里。记住,MIPS 的指令集虽然简单,但它的设计哲学很独特。理解了这些基础,后面的章节我们就能深入分析更复杂的固件了。