第五讲 · MIPS 汇编编程与函数调用

对应材料:2025 计组复习提纲“MIPS 汇编程序”,并用实验目录中的 MARS 汇编练习核对程序类型。

学汇编不是为了拿助记符替换 C 关键字,而是为了把程序拆成 CPU 真正能执行的微小动作:寄存器之间算一步、主存与寄存器之间搬一步、根据比较结果改一次 PC。

1. 程序与数据在内存中的样子

MIPS 汇编常用 .text 表示代码段,用 .data 表示静态数据段:

.data
msg:  .asciiz "hello"
arr:  .word 3, 5, 8

.text
main:
    # 指令写在这里

汇编里的数据类型首先说明“占多少字节”:

声明大小常见用途
.byte1 B字符、字节数组
.half2 B半字
.word4 B32 位整数、地址
.asciiz字符串长度 + 1末尾自动补 \0

2. 寄存器要按调用约定分工

名称编号约定用途
$zero0常量 0
$at1汇编器临时使用
$v0-$v12–3返回值
$a0-$a34–7前四个参数
$t0-$t98–15, 24–25临时值,调用者若需要应自行保护
$s0-$s716–23长期变量,被调用者使用后要恢复
$sp29栈顶指针
$fp30可选的栈帧基准
$ra31返回地址

这些不是硬件强制的“类型”,而是软件约定。大家遵守同一套约定,分别编译的函数才可以互相调用。

3. 主存访问:地址先算出来,再读或写

统一格式是

op  rt, offset(base)

有效地址为

EA=R[base]+signext⁡(offset).EA=R[base]+\operatorname{signext}(offset).

3.1 字、半字和字节

指令传输量读出后怎样扩展
lw/sw4 B整个字,不需要扩展
lh/sh2 Blh 符号扩展
lhu2 B零扩展
lb/sb1 Blb 符号扩展
lbu1 B零扩展

sb 和 sh 只把寄存器的低 8 位或低 16 位写入主存。lb 读到 0x80 时得到 0xFFFFFF80,lbu 则得到 0x00000080。

3.2 对齐

  • 字地址通常要求是 4 的倍数;
  • 半字地址通常要求是 2 的倍数;
  • 字节可以位于任意地址。

数组 int A[100] 的 A[i] 地址为

addr⁡(A[i])=base⁡(A)+4i.\operatorname{addr}(A[i])=\operatorname{base}(A)+4i.

这就是“基址 + 偏移”与数组、结构体天然契合的原因。

3.3 大端与小端

字节序只决定一个多字节对象的各字节如何摆在连续地址中,不改变寄存器里的数值:

  • 大端:最高有效字节放最低地址;
  • 小端:最低有效字节放最低地址。

做题时先按题目给出的字节序画出地址从低到高的字节,再执行 lb/lbu/lw,不要靠肉眼猜。

4. 算术、逻辑和移位

4.1 加减法

add   $t0, $s0, $s1
addu  $t0, $s0, $s1
addi  $t0, $s0, -12
sub   $t0, $s0, $s1

带 u 的名字并不意味着寄存器突然有了无符号类型;主要差别是是否对有符号溢出触发异常。加法器做的仍然是相同的 32 位补码加法。

乘除法结果可能超过 32 位,因此课件使用 HI/LO:

mult  $s0, $s1       # 64 位乘积写入 HI:LO
mflo  $t0            # 取乘积低 32 位
div   $s0, $s1       # LO=商,HI=余数
mfhi  $t1            # 取余数

4.2 位运算

and   $t0, $s0, $s1
or    $t0, $s0, $s1
xor   $t0, $s0, $s1
nor   $t0, $s0, $s1
andi  $t0, $s0, 0x00FF
ori   $t0, $s0, 0x8000

逻辑立即数采用零扩展,适合掩码操作。

4.3 移位

指令含义
sll逻辑左移,低位补 0
srl逻辑右移,高位补 0
sra算术右移,高位复制符号位
sllv/srlv/srav移位量来自寄存器低 5 位

例如 $t0=0xFFFFFF00:

srl  $s0, $t0, 8     # 0x00FFFFFF
sra  $s1, $t0, 8     # 0xFFFFFFFF

左移 kk 位在不溢出时相当于乘 2k2^k;算术右移负数时与 C 语言整数除法的舍入规则未必完全等价,不能无条件拿它代替除法。

5. 16 位立即数怎样构造 32 位常量

lui 把 16 位立即数放到寄存器高 16 位,低 16 位清零:

lui  $t0, 0xABCD      # $t0 = 0xABCD0000
ori  $t0, $t0, 0x1234 # $t0 = 0xABCD1234

所以 li $t0,0xABCD1234 通常是伪指令,要由汇编器展开成一条或多条真实指令。

6. 分支、比较和循环

6.1 相等分支

beq  $s0, $s1, Equal
bne  $s0, $s1, NotEqual

beq/bne 只有“相等/不等”比较。其他关系通常先用 slt 产生 0/1,再与零寄存器分支:

slt  $t0, $s0, $s1   # $t0 = 1 当且仅当 $s0 < $s1(有符号)
bne  $t0, $zero, Less

sltu 按无符号数比较。寄存器比特没变,变化的是比较器如何解释最高位。

6.2 把 if 翻成控制流

if (a == b) {
    x = x + 1;
} else {
    x = x - 1;
}

假设 a,b,x 分别在 $s0,$s1,$s2:

bne   $s0, $s1, Else
addi  $s2, $s2, 1
j     EndIf
Else:
addi  $s2, $s2, -1
EndIf:

关键不是死记“先跳哪边”,而是先画基本块,再用分支把不该执行的块绕开。

6.3 循环例:字符串复制

课件用 while((*q++=*p++)!='\0') 说明 C 到 MIPS 的翻译:

Loop:
    lbu   $t0, 0($s0)      # 取 *p
    sb    $t0, 0($s1)      # 写 *q
    addiu $s0, $s0, 1      # p++
    addiu $s1, $s1, 1      # q++
    bne   $t0, $zero, Loop

指针每次加 1,因为字符串元素是 1 字节;若是 int 数组,则通常每次加 4。

7. 伪指令:给人写,不给 CPU 执行

常见伪指令:

move $t0, $s0        # 可展开为 addu $t0,$s0,$zero
li   $t0, 100        # 可能是一条 addiu,也可能是 lui+ori
la   $t0, label      # 加载标号地址,展开方式取决于地址与重定位
blt  $s0, $s1, L     # 通常展开为 slt + bne

课件把含伪指令、面向程序员的语言称为 MAL,把硬件真实执行的指令称为 TAL。做机器码题时必须先展开伪指令,否则“一行对应一条机器码”的前提不成立。

8. 函数调用的六件事

一次函数调用要完成:

  1. 调用者放好参数;
  2. jal 保存返回位置并跳到函数;
  3. 被调用者为局部变量、需保护寄存器开栈帧;
  4. 执行函数主体;
  5. 把返回值放入 $v0-$v1,恢复现场;
  6. jr $ra 返回。

8.1 jal 与 jr

jal  func             # 跳转,并把返回位置写入 $ra
...
func:
    ...
    jr   $ra

课程单周期模型没有实现分支延迟槽,因此把 jal 的链接值按 PC+4PC+4 处理;经典带一条延迟槽的 MIPS 流水线会涉及 PC+8PC+8。做题必须服从题目给定的处理器模型,不能把两套约定混在一起。

8.2 栈帧为什么向低地址增长

课件采用 MIPS 常见约定:$sp 指向栈顶,开栈帧时减小 $sp,返回前再加回去。

func:
    addiu $sp, $sp, -8
    sw    $ra, 4($sp)
    sw    $s0, 0($sp)

    # 函数主体

    lw    $s0, 0($sp)
    lw    $ra, 4($sp)
    addiu $sp, $sp, 8
    jr    $ra

“先减再存、先取再加”保证整个调用期间栈帧都属于当前函数。

9. 谁来保护寄存器

  • $s0-$s7:被调用者保存。函数若改过,返回前必须恢复;
  • $t0-$t9:调用者保存。调用者若想跨函数调用保留,就在调用前自行保存;
  • $a0-$a3、$v0-$v1:本来就用于传参和返回,调用后不能假定保持;
  • $ra:叶函数不调用别的函数时可不压栈;非叶函数再次执行 jal 前必须保护旧 $ra。

9.1 非叶函数示例

int sumSquare(int x, int y) {
    return mult(x, x) + y;
}

sumSquare 调用 mult,所以至少要保护返回地址;y 若调用后还要用,也不能留在可能被覆盖的 $a1 或临时寄存器里。可以存入栈,或放进受保护的 $s 寄存器并按约定恢复。

10. 写汇编时按这条路线检查

  1. 先给变量、指针和数组基址分配寄存器;
  2. 把 C 结构拆成基本块和控制流,不逐字符翻译;
  3. 所有主存数据先 load,计算后再 store;
  4. 检查元素大小与地址步长;
  5. 区分符号扩展、零扩展和有符号/无符号比较;
  6. 函数调用前后检查 $ra、$sp 与需要跨调用保存的值;
  7. 最后才考虑减少指令数。

实验目录中的闰年、最大公约数、汉诺塔、回文串与冒泡排序,表面算法不同,底层都只是在反复组合本讲的几类动作。

评论