MIPS 内存访问与分支跳转:指令背后的陷阱

好,我们继续往下走。上一章我们把 MIPS 的寄存器体系和基本算术指令过了一遍。这一章要聊的,是真正让程序“活起来”的东西——内存访问和流程控制。说白了,没有这些,你的程序就是一条直线跑到底,啥也干不了。

我个人觉得,MIPS 的内存和分支指令其实比 x86 要清爽很多。但清爽不代表简单,尤其是那个延迟槽,当年我第一次在 CTF 里碰到它,差点把键盘砸了。嗯,咱们一个一个来。

内存访问指令:lw / sw / lb / sb

MIPS 是加载-存储架构。什么意思?就是只有 lw、sw 这些指令能碰内存,算术指令只能在寄存器里玩。这一点和 x86 那种“指令可以直接操作内存”的风格完全不同。

指令格式速览

指令 全称 功能 示例
lw Load Word 从内存加载 4 字节到寄存器 lw $t0, 0($sp)
sw Store Word 将寄存器 4 字节存入内存 sw $t0, 4($sp)
lb Load Byte 从内存加载 1 字节(符号扩展) lb $t0, 0($a0)
sb Store Byte 将寄存器最低 1 字节存入内存 sb $t0, 0($a0)

这里有个细节:lb 是有符号扩展的。如果你加载的字节最高位是 1,它会自动把高 24 位填成 1。想要无符号加载?用 lbu。我在逆向一个嵌入式设备固件时,就因为这个符号扩展问题,调试了整整一个下午——一个本该是正数的字节,被扩展成了负数,整个逻辑全乱了。

核心记忆点:MIPS 的内存访问必须对齐。lw/sw 要求地址是 4 的倍数,否则触发异常。CTF 里经常有人故意构造非对齐访问来搞事情。

寻址模式

MIPS 的内存寻址其实就一种:基址+偏移。格式是 指令 寄存器, 偏移(基址寄存器)。比如:

lw $t0, 8($sp)    # 从 sp+8 的位置加载 4 字节到 t0
sw $s0, -4($fp)   # 将 s0 存入 fp-4 的位置

偏移量是 16 位有符号整数,范围 -32768 到 32767。够用吗?大部分情况够。但如果你要访问一个全局数组,偏移量可能不够,那就得先算地址放到寄存器里,再用 $zero 做基址。

小技巧:在 CTF 的 ROP 里,我经常用 lw $ra, 0($sp) 配合 jr $ra 来劫持控制流。这个组合在 MIPS 的 gadget 里非常常见。

分支指令:beq / bne

分支指令让程序有了“选择”。MIPS 的分支指令不多,但够用。

  • beq rs, rt, label — 如果 rs == rt,跳转到 label
  • bne rs, rt, label — 如果 rs != rt,跳转到 label

注意,MIPS 没有直接的 blt(小于则跳转)或 bgt。那怎么比较大小?用 slt(set on less than)配合 bne 或 beq。

slt $t0, $s0, $s1   # 如果 s0 < s1,t0 = 1;否则 t0 = 0
bne $t0, $zero, label  # 如果 t0 != 0,说明 s0 < s1,跳转

你想想看,这其实就是把比较和分支拆成了两步。虽然多了一条指令,但硬件实现简单了很多。MIPS 的设计哲学就是“硬件越简单越好,复杂的事情交给编译器”。

跳转指令:j / jal / jr

跳转指令用于无条件改变程序流程。这里有三兄弟:

指令 功能 目标地址 保存返回地址?
j label 直接跳转 26 位伪直接地址 否
jal label 跳转并链接 26 位伪直接地址 是,存入 $ra
jr $rs 寄存器跳转 寄存器中的 32 位地址 否

jal 是函数调用的关键。它会把 PC+8(注意不是 +4)存入 $ra,然后跳转到目标地址。为什么是 +8?因为延迟槽的存在。我们马上讲。

jr $ra 则是函数返回的标准写法。在 CTF 的 ROP 里,jr $ra 几乎是所有 gadget 的结尾。

注意:j 指令的 26 位地址是字地址,实际跳转时左移 2 位,再与 PC 高 4 位拼接。这意味着它只能跳转到当前 256MB 范围内。如果你想跳转到任意地址,必须用 jr。

延迟槽:MIPS 最“坑”的设计

好,重头戏来了。延迟槽(Delay Slot)是 MIPS 架构最著名的特性,也是初学者最容易翻车的地方。

为什么会这样?因为 MIPS 是经典的五级流水线架构。当 CPU 执行到一条分支或跳转指令时,下一条指令其实已经进入了流水线。如果直接清空流水线,会浪费一个周期。MIPS 的设计者想了个办法:让分支/跳转指令后面的那条指令无论如何都会被执行,这条指令的位置就叫延迟槽。

看个例子:

    addi $t0, $zero, 1
    j    target
    addi $t1, $zero, 2    # 这是延迟槽指令,会被执行!
target:
    addi $t2, $zero, 3

执行完 j target 后,addi $t1, $zero, 2 会先执行,然后才跳到 target。所以最终 $t1 的值是 2,$t2 的值是 3。

我曾经在逆向一个 MIPS 路由器的固件时,看到一个函数开头是这样的:

func:
    j    real_start
    nop

那个 nop 就是用来填延迟槽的。编译器如果找不到有用的指令来填,就会塞一个 nop。这在反汇编代码里非常常见。

CTF 考点:延迟槽里的指令如果触发了异常,PC 会指向分支/跳转指令,而不是延迟槽指令本身。这个特性在构造 ROP 链时经常被利用——你可以让延迟槽指令做一件“坏事”,然后异常处理帮你擦屁股。

延迟槽对调试的影响

用 GDB 调试 MIPS 程序时,如果你在分支指令上设断点,单步执行会直接跳到延迟槽指令,而不是目标地址。我第一次遇到时一脸懵:”怎么跳到这里来了?“

记住:延迟槽指令是分支/跳转指令的“影子”。你永远不能单独跳过它。

知识结构图:MIPS 内存与分支指令体系

下面这张图把本章的核心知识点串起来了。你可以看到内存访问、分支、跳转和延迟槽之间的关系。

MIPS 内存与分支指令体系 MIPS 内存与分支指令体系 内存访问指令 lw / sw lb / sb 分支指令 beq / bne slt + beq/bne 跳转指令 j / jal jr 延迟槽(Delay Slot) 分支/跳转指令后的指令无论如何都会被执行 调试与 CTF 要点 • 延迟槽指令在分支/跳转之前执行 • 异常时 PC 指向分支/跳转指令 • jr $ra 是 ROP 链核心 • 非对齐访问触发异常

避坑指南:我踩过的那些坑

讲几个我实际遇到过的坑,你们以后碰到了心里有数。

  • 延迟槽里的分支指令:MIPS 不允许在延迟槽里放另一条分支或跳转指令。虽然有些汇编器会报错,但有些不会——结果就是 CPU 行为未定义。我在一个 CTF 题目里见过出题人故意这么搞,让反汇编器解析出错。
  • jal 的返回地址:jal 保存的是 PC+8,不是 PC+4。如果你在延迟槽里放了一条 jal,返回地址会指向哪里?嗯,这个问题留给你自己思考。
  • 延迟槽和异常:如果延迟槽指令触发了异常(比如除零),CPU 会报告异常发生在分支/跳转指令上,而不是延迟槽指令。这意味着异常处理程序拿到的 EPC 是分支指令的地址。这个特性在构造某些漏洞利用时非常有用。

调试建议:用 GDB 调试 MIPS 程序时,记得用 set architecture mips 设置架构。然后在分支指令上设断点,用 stepi 单步执行——你会看到先执行延迟槽,再跳转。亲眼看到这个过程,比背一百遍理论都管用。