3、MIPS汇编基础:常用指令、过程调用与栈帧、延迟槽
好,咱们进入第三章。说实话,MIPS 的指令集在 RISC 家族里算是相当清爽的。我当年第一次接触它的时候,感觉就像拿到了一把瑞士军刀——功能明确,没有花里胡哨的东西。但清爽归清爽,坑也不少。尤其是延迟槽,我第一次逆向的时候就被它坑了一把,后面我会细说。
这一章,咱们就聚焦三个核心模块:常用指令、过程调用与栈帧、以及那个让人又爱又恨的延迟槽。掌握了这些,你就能看懂大部分 MIPS 固件的汇编代码了。
3.1 常用指令:加减、访存、跳转
MIPS 的指令格式非常规整。每条指令都是 32 位,操作码、寄存器、立即数,各就各位。我个人习惯把常用指令分成三类:算术运算、内存访问、控制流。
3.1.1 算术运算:add 与 sub
先看加法。MIPS 里加法指令有好几个变种,但最常用的是 add 和 addu。区别在哪?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,立即数加法,不检查溢出
减法就简单了,sub 和 subu。用法跟加法对称。
sub $t0, $t1, $t2 # $t0 = $t1 - $t2
subu $t0, $t1, $t2 # 同上,不检查溢出
我在项目中遇到过一个问题:某款路由器固件里,一个加法指令用了 add 而不是 addu,结果某个特殊输入导致溢出,直接触发了异常,系统重启。嗯,从那以后我逆向时都会留意这个细节。
3.1.2 内存访问:lw 与 sw
MIPS 是加载-存储架构。什么意思?就是只有 lw 和 sw 这类指令能访问内存,算术运算只能在寄存器之间进行。这一点跟 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
这里有个细节:beq 和 bne 的跳转范围是有限的(±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 栈帧结构
每个函数都有自己的栈帧。栈帧里存什么?局部变量、保存的寄存器、返回地址。我画了一张图,帮你理解栈帧的结构。
你看,栈是从高地址向低地址增长的。每次函数调用,$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 逆向时如何应对延迟槽?
嗯,这里要注意。逆向时,你看到分支指令,一定要看它后面的那条指令。那条指令是延迟槽,它总会执行。如果你忽略它,可能会误解程序的逻辑。
addiu $sp, $sp, -32。原来这不是死循环,而是一个巧妙的栈帧分配技巧。如果我没注意到延迟槽,就会完全理解错。
另外,MIPS 的 jr $ra 也有延迟槽。也就是说,在 jr $ra 后面的那条指令,会在函数返回后执行。这听起来很诡异,但确实如此。编译器通常会在这条延迟槽里放一条 nop,或者放一条恢复 $sp 的指令。
3.4 小结
这一章我们聊了 MIPS 的常用指令、过程调用与栈帧、以及延迟槽。说实话,这些内容看起来简单,但实际逆向时,细节决定成败。尤其是延迟槽,我建议你多花点时间理解它。你可以在 IDA 里打开一个 MIPS 固件,单步跟踪几条分支指令,看看延迟槽里的指令是怎么执行的——实践出真知。
好,这一章就到这里。记住,MIPS 的指令集虽然简单,但它的设计哲学很独特。理解了这些基础,后面的章节我们就能深入分析更复杂的固件了。