Appearance
实践:手写一段汇编并在模拟器/真机跑通
概念
前五篇把"零件"讲齐了:
| 篇 | 给的零件 |
|---|---|
01-isa.md | 指令格式、寄存器编号、R/I/J 三类 |
02-addressing.md | 寻址方式、lw/sw、la/li |
03-arith.md | 算术、逻辑、分支、跳转 |
04-stack.md | 调用约定、栈帧、$ra 与 $sp |
05-syscall.md | syscall、服务号、中断异常 |
但"会写零件"和"能跑通一个程序"是两件事。 这一篇要回答的是:
一个 50 行的
.asm文件,怎么从"能读懂"变成"真跑起来、并且确认跑对了"?
"跑通"包含三件事,缺一不可:
| 环节 | 要确认什么 | 手段 |
|---|---|---|
| ① 能汇编 | 语法、标签、伪指令是否被接受 | 模拟器/汇编器报错行号 |
| ② 能运行 | 控制流走对了、内存没写飞 | 单步 + 看寄存器 + 看内存 |
| ③ 结果对 | 不是"没报错"而是"值就是该有的值" | 手算对账 / 脚本对账 |
⚠️ 第三条最容易被跳过。 一个把 $t1 和 $t2 写反的循环,一样能"跑完不报错",只是答案是错的。 "程序没崩"和"程序算对了"之间隔着一整套对账工序。
环境矩阵:
| 环境 | 形态 | 本站能否用 |
|---|---|---|
| MARS | Java 写的 MIPS 模拟器,带图形界面与调试器 | 本机未安装 |
| QtSpim | 另一款 MIPS 模拟器,syscall 约定与 MARS 不同 | 本机未安装 |
| Linux/MIPS 真机 | 交叉编译出 MIPS 可执行文件,跑在 MIPS 内核上 | 无此机器 |
| 交叉编译 + QEMU 用户态 | mips-linux-gnu-gcc + qemu-mips | 本机无工具链 |
| 本站线上 | 只发静态页,不提供代码运行(硬约束) | 不可运行 |
所以本篇的定位要说清楚:
它是一份"操作手册 + 对账方法",不是一篇"点一下就跑"的教程。你在自己机器上装 MARS 就能按第一到第五节走完;没有环境时,第六节给出等价手段——手算 + 自写 MIPS 子集解释器,把指令流逐条"走"一遍。
为什么本站坚持讲 MIPS32 而不是 x86:因为 MIPS 的模拟器只有几百 KB、指令集干净(32 条核心指令)、寄存器编号固定——手算和脚本建模都做得动。x86-64 光是"同一条 mov 有多少种编码"就够写一本书,只在本篇末尾作对照。
原理
一、最小骨架:一个 MIPS 汇编程序长的样子
MARS 接受的文件(hello.asm):
asm
# hello.asm —— 最小可运行程序
.data # ① 数据段:声明变量
msg: .asciiz "hello, mips\n" # 以 0 结尾的字符串
.text # ② 代码段:写指令
.globl main # ③ 声明 main 是全局符号(模拟器从这里开始)
main: # ④ 标签:main 的入口地址
li $v0, 4 # ⑤ 服务号 4 = 打印字符串
la $a0, msg # 参数放 $a0(la 取的是"地址")
syscall # ⑥ 交给内核/模拟器执行
li $v0, 10 # ⑦ 服务号 10 = 退出
syscall七个要素,一个都不能少:
| # | 要素 | 少了会怎样 |
|---|---|---|
| ① | .data | 数据没地方放——引用 msg 会报"符号未定义" |
| ② | .text | 指令被当成数据(某些模拟器会直接报错) |
| ③ | .globl main | 模拟器不知道该从哪开始(MARS 会自己找 main 标签,但交叉编译真机必需) |
| ④ | 标签 main: | 同上;标签必须以 : 结尾,且顶格写 |
| ⑤ | li 服务号 | syscall 不知道你要干什么($v0 是垃圾值 → 行为随机) |
| ⑥ | syscall | 只是"准备好参数",什么都不会发生 |
| ⑦ | 退出序列 | 程序跑完 .text 末尾后踏空**(MARS 会报 "attempt to execute non-instruction")** |
最后一条是初学者最常踩的坑:汇编里没有"函数返回就结束"这回事——
main也不是特殊函数,它就是一段被摆在0x00400000的普通代码。跑完必须显式告诉模拟器"到此为止"(MARS:$v0=10的syscall)。
二、数据段:四种声明与"对齐"这件事
| 伪指令 | 大小 | 举例 | 说明 |
|---|---|---|---|
.word | 4 字节 | a: .word 3,1,4,1 | 可以一次声明多个,形成数组 |
.byte | 1 字节 | b: .byte 0x41 | 一字节,读要用 lb |
.asciiz | n+1 字节 | s: .asciiz "abc" | 末尾自动补 \0(4 字节) |
.ascii | n 字节 | s2: .ascii "abc" | 不补 \0(3 字节) |
.space n | n 字节 | buf: .space 16 | 留一块未初始化的空间(缓冲区的标准写法) |
.align n | — | .align 2 | 把地址对齐到 2^n 字节(2 = 4 字节对齐) |
下面这段数据段的地址分配值得逐字节算一遍(假设 .data 从 0x10010000 开始):
asm
.data
x: .byte 0x41 # 1 字节
y: .word 0x00000007 # 需要 4 字节对齐!
s: .asciiz "abc" # 4 字节(a b c \0)
buf: .space 4 # 4 字节| 标签 | 起址 | 占用 | 说明 |
|---|---|---|---|
x | 0x10010000 | 1 字节 | 放 0x41,占 ...00 |
| (填充) | 0x10010001 | 3 字节 | .word 要求 4 对齐 → 自动跳过 ...01~...03 |
y | 0x10010004 | 4 字节 | ...04~...07 |
s | 0x10010008 | 4 字节 | 61 62 63 00 |
buf | 0x1001000c | 4 字节 | ...0c~...0f |
两个字要分清:
.align是"你自己写的对齐要求",而.word/.asciiz自带对齐(汇编器会自动插入填充字节)。所以"我数着应该是 1 字节,怎么下一个变量跑到 +4 去了"——不是错,是对齐在起作用。反过来,
.byte之间不会被填充:连着写b1: .byte 1/b2: .byte 2,两个地址差 1。
三、伪指令与真指令:哪些是你以为的、哪些是真的
列在 01-isa.md 里的 R/I/J 三类格式,只覆盖"真指令"。日常写的这些其实都是汇编器展开出来的:
| 伪指令 | 真实展开 | 代价 |
|---|---|---|
li $t0, 0x12345678 | lui $t0, 0x1234 + ori $t0, $t0, 0x5678 | 2 条 |
li $t0, 5 | addiu $t0, $zero, 5 | 1 条 |
la $t0, msg | lui + ori(拼出 32 位地址) | 2 条 |
move $t0, $t1 | addu $t0, $zero, $t1 | 1 条 |
bge $t0, $t1, L | slt $at, $t0, $t1 + beq $at, $zero, L | 2 条 |
blt / bgt / ble | 同理(都要借 $at) | 2 条 |
mul $t0, $t1, $t2 | mult + mflo | 2 条 |
三条必须记住的推论:
la取的是"地址",lw才是"内容"。la $a0, msg之后$a0里是0x10010000这样的地址;要读那个字必须再lw $t0, 0($a0)。 打印字符串的服务号 4 要的就是地址,不是内容——这就是la而非lw的原因。$at($1)是汇编器的"临时寄存器",专门给伪指令用。所以"$t0~$t9随便用"不等于"寄存器都归你"——$at不要手写。li装大常数是两条指令——所以"li很便宜"是错觉;循环体内反复li同一常量,是明显可省的开销。
四、MARS 的界面与六个调试动作
MARS 主窗口四个面板:
| 面板 | 看什么 |
|---|---|
| Source(源码) | 左侧行号 + 断点标记(红点);高亮行 = 下一条要执行的指令 |
| Registers(寄存器) | 32 个通用寄存器 + PC + HI/LO,实时刷新 |
| Data/Text(内存) | 可切到 .data / .text / 用户栈,按字或按字节查看 |
| Run I/O(运行输出) | 服务号 1/4 的输出、服务号 5/8 的输入都在这里 |
六个动作(记快捷键就够):
| 动作 | 快捷键 | 用途 |
|---|---|---|
| Assemble | F3 | 汇编;有错在下方 Messages 面板给行号 |
| Run | F5 | 一路跑到底 |
| Step | F7 | 单步执行一条指令(最常用) |
| Set breakpoint | F9 | 在光标行加/去断点(红点) |
| Backstep | F8 | 回退一步(MARS 独有,很值钱) |
| Pause / Stop | F5 / F11 | 暂停 / 停止 |
单步调试的正确姿势(不是"随便按 F7 看数字跳",而是带着假设走):
① 先在纸上写出"这一步之后
$t0应该是几";② 按 F7;③ 对照寄存器面板:一致就继续,不一致就停下来查——错误就发生在这条指令上。这叫"带预测的单步"。 不带预测地单步,你只是在看数字变,看不出对错。
五、跑不通时的六类症状 → 根因 → 定位法
| 症状(报错或现象) | 根因 | 定位法 |
|---|---|---|
symbol "xxx" not found | 标签拼写;或变量没在 .data 里声明;或漏了 : | 看拼写;.data 段是否写了这个标签;标签行有没有冒号 |
attempt to execute non-instruction at 0x... | 跑到 .text 末尾没退出(漏了 $v0=10 的 syscall) | 在最后一条指令后补退出序列 |
address out of range 0x... | la 写成了 lw(拿内容当地址);或 sw 到没分配的地址;或偏移量算错 | 在报错前一步看目标寄存器:地址应该在 0x1001_xxxx(数据段)或 0x7fff_xxxx(栈) |
invalid program counter value | jr $ra 但 $ra 没被正确设置(漏 jal,或 $ra 被覆盖) | 看 $ra:非叶子函数应先 sw $ra, ...($sp) |
| 结果"像是对的"但差一点 | $t 跨调用没保存;循环边界差 1(bge vs bgt);$sp 帧大小与压栈顺序不匹配 | 逐层打印/手算栈内容;把"最后一次迭代"单独走一遍 |
| 输入卡住不继续 | 服务号 8(读字符串)要缓冲区地址和长度;服务号 5 需要一个整数 | 看 $a0(缓冲地址)、$a1(长度)是否都设了 |
其中"结果差一点"最危险——因为它不报错。这类错误只能靠对账抓,见第六节和示例 2/3。
六、没有模拟器时怎么验:手算 + 自写子集解释器
两步走:
第一步:手算。 把程序当成"寄存器表 + 内存表",一条一条往下推。手算能覆盖到的规模很小(几十条指令),但它能暴露"我到底以为程序在干什么"。
手算模板(本篇全篇沿用):
| 约定 | 取值 |
|---|---|
.data 基址 | 0x10010000 |
.text 基址 | 0x00400000 |
$sp 初值 | 0x7ffffffc(MARS 默认栈顶附近;此处取 4 字节对齐的典型值) |
| 帧大小口径 | 取 8 的倍数(本站口径,见 04-stack.md)——这样每层 $sp 的位移恒定,便于对账 |
$zero | 恒 0,写它无效 |
说明:真机 Linux/MIPS 进程启动时
$sp是 8 字节对齐的;MARS 的默认栈指针按 4 字节给。本篇的手算统一"从0x7ffffffc出发、每帧减 8 的倍数",只是为了账好算——结论(哪一层存了什么、$ra有没有被覆盖)与对齐细节无关。
第二步:写一个 MIPS 子集解释器。 用 Python 把"寄存器数组 + 内存字典 + 逐条解释"实现出来(约 60 行),然后把汇编指令流喂进去,让机器替你走那几百条它应该走的指令。 它可以走完一个 8 次迭代的循环、可以模拟递归 5 层——手算做不到的事它能做,而且每一步都能打印出来跟手算比对。
这就是本篇要建立的"对账"工序:
手写汇编 ──► 手算前 3~5 步(想清楚自己在干什么)
──► 子集解释器跑全程(拿到"应该是什么")
──► 逐项比对(对不上就回到那一条指令)⚠️ 本机没有 MARS、没有 QtSpim、没有 gcc、没有 mips-linux-gnu-*,所以本篇的汇编块全部只逐行人工审查**,Python 块用真实解释器跑过(把汇编的等价语义跑通,就是对那套汇编逻辑最直接的验算)。**
示例
例 1:最小可运行程序——按骨架逐行填
目标:打印一行字,然后干净退出。
asm
# hello.asm —— MARS 直接可跑
.data
msg: .asciiz "hello, mips\n" # 10 个字符 + 1 个 '\0' = 11 字节
.text
.globl main
main:
li $v0, 4 # 服务号 4 = 打印字符串
la $a0, msg # $a0 = 字符串地址(不是内容!)
syscall
li $v0, 10 # 服务号 10 = 退出
syscall逐行说明(对照第一节的七要素):
| 行 | 内容 | 作用 | 易错 |
|---|---|---|---|
| 1 | .data | 进入数据段 | 写成 .date 会报"unknown directive" |
| 2 | msg: .asciiz "hello, mips\n" | 声明 11 字节 | \n 是转义换行,占 1 字节;.asciiz 末尾补 \0 |
| 4 | .text | 进入代码段 | — |
| 5 | .globl main | 导出 main 符号 | MARS 可以省(它自己找 main),交叉编译真机必需 |
| 6 | main: | 标签,记录地址 | 顶格写、带冒号 |
| 7–9 | li / la / syscall | 准备服务号与参数,然后敲门 | la 与 lw 混用(见例 5 第 1 条) |
| 10–11 | 退出 | $v0=10 后 syscall | 漏掉 → 踏空报错 |
内存分布手算(.data 基址 0x10010000):
| 标签 | 起址 | 内容(十六进制) | 长度 |
|---|---|---|---|
msg | 0x10010000 | 68 65 6c 6c 6f 2c 20 6d 69 70 73 0a 00 | 13 字节 |
等一下——
"hello, mips\n"是 11 个字符吗? 数一遍:h e l l o , 空格 m i p s是 11 个,再加\n是 12 个,再加\0是 13 个。.asciiz的长度必须逐字符数,这是"差一字节"类错误的来源。
预期输出(Run I/O 面板):
hello, mips例 2:数组求和——循环 + 手算轨迹表
任务:把 [3, 1, 4, 1, 5, 9, 2, 6] 求和并求最大值。这个数组刻意用了圆周率前八位数字,方便记。
asm
# sum.asm —— 数组求和与求最大值
.data
arr: .word 3, 1, 4, 1, 5, 9, 2, 6 # 8 个字 = 32 字节
n: .word 8
msg1: .asciiz "sum = "
msg2: .asciiz "\nmax = "
.text
.globl main
main:
li $t0, 0 # $t0 = i = 0
lw $t1, n # $t1 = n = 8
li $t2, 0 # $t2 = sum = 0
li $t3, 0x10010000 # $t3 = arr 的基址(la 的展开)
li $t4, 0 # $t4 = max = 0
loop:
bge $t0, $t1, done # i >= n → 结束
sll $t5, $t0, 2 # $t5 = i * 4
add $t5, $t5, $t3 # $t5 = arr + i*4
lw $t6, 0($t5) # $t6 = arr[i]
add $t2, $t2, $t6 # sum += arr[i]
slt $t7, $t4, $t6 # $t7 = (max < arr[i])
beq $t7, $zero, skip # 不大于则跳过更新
move $t4, $t6 # max = arr[i]
skip:
addi $t0, $t0, 1 # i++
j loop
done:
li $v0, 4
la $a0, msg1
syscall
move $a0, $t2 # 打印 sum
li $v0, 1
syscall
li $v0, 4
la $a0, msg2
syscall
move $a0, $t4 # 打印 max
li $v0, 1
syscall
li $v0, 10
syscall手算轨迹表(逐次迭代,这是本篇最该学会的一张表):
| 迭代 | i | i*4 | arr+i*4 | arr[i] | sum | max 更新? |
|---|---|---|---|---|---|---|
| 1 | 0 | 0 | 0x10010000 | 3 | 3 | 是(0<3)→ 3 |
| 2 | 1 | 4 | 0x10010004 | 1 | 4 | 否 |
| 3 | 2 | 8 | 0x10010008 | 4 | 8 | 是 → 4 |
| 4 | 3 | 12 | 0x1001000c | 1 | 9 | 否 |
| 5 | 4 | 16 | 0x10010010 | 5 | 14 | 是 → 5 |
| 6 | 5 | 20 | 0x10010014 | 9 | 23 | 是 → 9 |
| 7 | 6 | 24 | 0x10010018 | 2 | 25 | 否 |
| 8 | 7 | 28 | 0x1001001c | 6 | 31 | 否 |
校验两条:
- 求和:
3+1+4+1+5+9+2+6 = 31✔ - 最大值:数组里的最大值是 9(第 6 个元素),出现在
0x10010014✔ - 循环次数:
i从 0 到 7 共 8 次;每次循环体 9 条(bge/sll/add/lw/add/slt/beq/addi/j),最大值被更新时多一条move(本数组里更新 4 次) → 循环部分 8 × 9 + 4 = 76 条;加前后铺垫与末尾的退出检查,整个程序共 87 条(与例 4 解释器报出的"指令数 = 87"一致)
预期输出:
sum = 31
max = 9⚠️ 两个最容易写错的地方: ①
sll $t5, $t0, 2是"左移 2 位"= ×4——.word每个占 4 字节,忘了乘 4 就会读到错误的地址(且不报错); ②slt $t7, $t4, $t6的参数顺序:slt d, s, t表示d = (s < t)——写成slt $t7, $t6, $t4就成了"arr[i]小于max",逻辑正好反过来,答案会变成数组的第一个元素。
例 3:递归阶乘——栈帧逐层手算
任务:算 5!,走一条完整的递归+栈展开路径。这是 04-stack.md 的"上机版"。
asm
# fact.asm —— 递归阶乘,返回 n!
.data
msg: .asciiz "5! = "
.text
.globl main
main:
li $a0, 5 # 参数 n = 5
jal fact # 调用;$ra 被自动设为下一条指令地址
move $t0, $v0 # 保住结果
li $v0, 4
la $a0, msg
syscall
move $a0, $t0
li $v0, 1
syscall
li $v0, 10
syscall
fact: # int fact(int n)
addi $sp, $sp, -8 # 开 8 字节栈帧
sw $ra, 4($sp) # 保存返回地址(非叶子函数必做)
sw $a0, 0($sp) # 保存 n(因为下面要改 $a0)
li $t0, 1
ble $a0, $t0, base # n <= 1 → 返回 1
addi $a0, $a0, -1 # n - 1
jal fact # 递归
lw $a0, 0($sp) # 恢复 n
mul $v0, $a0, $v0 # n * fact(n-1)
j epi
base:
li $v0, 1 # 0! = 1! = 1
epi:
lw $ra, 4($sp) # 恢复返回地址
addi $sp, $sp, 8 # 收栈帧
jr $ra # 返回栈帧布局(8 字节,与 04-stack.md 口径一致):
| 偏移 | 存什么 |
|---|---|
0($sp) | $a0(本层的 n) |
4($sp) | $ra(返回地址) |
逐层手算($sp 初值取 0x7ffffffc):
| 层 | 进入时 $sp | 本层 $a0 | 栈内 0($sp) | 栈内 4($sp) | 备注 |
|---|---|---|---|---|---|
| main | 0x7ffffffc | — | — | — | jal fact 把 $ra 设为 main 内的返回点 |
| fact(5) | 0x7ffffff4 | 5 | 5 | main 的返回点 | 5>1,递归 |
| fact(4) | 0x7fffffec | 4 | 4 | fact 内的返回点 | 同上 |
| fact(3) | 0x7fffffe4 | 3 | 3 | fact 内的返回点 | 同上 |
| fact(2) | 0x7fffffdc | 2 | 2 | fact 内的返回点 | 同上 |
| fact(1) | 0x7fffffd4 | 1 | 1 | fact 内的返回点 | 1≤1 → 走 base,$v0 = 1 |
地址核对:0x7ffffffc − 5 × 8 = 0x7ffffffc − 0x28 = 0x7fffffd4 ✔ 栈用量 = 5 层 × 8 字节 = 40 字节(最深时 $sp = 0x7fffffd4)。
回程(逐层乘回来):
| 层 | 本层 $a0 | 递归拿到的 $v0 | 本层算出 n × 下层 | 返回给谁 |
|---|---|---|---|---|
| fact(1) | 1 | —(base) | 1 | fact(2) |
| fact(2) | 2 | 1 | 2 | fact(3) |
| fact(3) | 3 | 2 | 6 | fact(4) |
| fact(4) | 4 | 6 | 24 | fact(5) |
| fact(5) | 5 | 24 | 120 | main |
预期输出:
5! = 120⚠️ 递归三件事,错一件就崩: ① 非叶子函数必须
sw $ra——jal fact会覆盖$ra,不保存就回不到上一层的正确地址(症状:invalid program counter value或无限循环)。 ②$a0也要保存或先算再改——本例在递归前把$a0存进了栈,递归回来后lw恢复(这就是"参数在$a0里、而$a0是 caller-saved"的直接后果)。 ③ble也是伪指令(展开成slt+beq),所以 base 分支实际用掉 2 条指令。
例 4:用 Python 写一个 MIPS 子集解释器来"对账"
任务:没有模拟器时,自己造一个。 下面这个解释器实现了例 2、例 3 用到的全部指令;跑出来的结果必须与手算表逐项一致。
python
# mips_sim.py —— MIPS32 子集解释器(寄存器表 + 内存字典 + 逐条解释)
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
REGNO = {"zero": 0, "at": 1, "v0": 2, "v1": 3, "a0": 4, "a1": 5, "a2": 6, "a3": 7,
"t0": 8, "t1": 9, "t2": 10, "t3": 11, "t4": 12, "t5": 13, "t6": 14, "t7": 15,
"s0": 16, "s1": 17, "s2": 18, "s3": 19, "s4": 20, "s5": 21, "s6": 22, "s7": 23,
"t8": 24, "t9": 25, "sp": 29, "ra": 31}
class Sim:
def __init__(self, prog, clab, mem=None, stack_top=0x7ffffffc, strings=None):
self.prog = prog
self.clab = clab
self.reg = [0] * 32
self.reg[REGNO["sp"]] = stack_top
self.mem = dict(mem or {})
self.strings = dict(strings or {})
self.pc = 0
self.out = []
self.trace = []
self.steps = 0
def g(self, n):
return self.reg[REGNO[n]]
def s(self, n, v):
if REGNO[n] != 0:
self.reg[REGNO[n]] = v & 0xffffffff
def step(self):
"""执行一条指令;返回 False 表示程序结束(供单步/快照用)"""
if not (0 <= self.pc < len(self.prog)):
return False
self.steps += 1
if self.steps > 200000:
raise RuntimeError("指令数超限,疑似死循环")
op = self.prog[self.pc][0]
args = self.prog[self.pc][1:]
nxt = self.pc + 1
if op == "li":
self.s(args[0], args[1])
elif op == "move":
self.s(args[0], self.g(args[1]))
elif op == "add":
self.s(args[0], self.g(args[1]) + self.g(args[2]))
elif op == "addi":
self.s(args[0], self.g(args[1]) + args[2])
elif op == "sub":
self.s(args[0], self.g(args[1]) - self.g(args[2]))
elif op == "mul":
self.s(args[0], self.g(args[1]) * self.g(args[2]))
elif op == "sll":
self.s(args[0], self.g(args[1]) << args[2])
elif op == "slt":
self.s(args[0], 1 if self.g(args[1]) < self.g(args[2]) else 0)
elif op == "sltu":
a = self.g(args[1]) & 0xffffffff
b = self.g(args[2]) & 0xffffffff
self.s(args[0], 1 if a < b else 0)
elif op == "lw":
self.s(args[0], self.mem.get(self.g(args[2]) + args[1], 0))
elif op == "sw":
self.mem[self.g(args[2]) + args[1]] = self.g(args[0]) & 0xffffffff
elif op == "bge":
if self.g(args[0]) >= self.g(args[1]):
nxt = self.clab[args[2]]
elif op == "ble":
if self.g(args[0]) <= self.g(args[1]):
nxt = self.clab[args[2]]
elif op == "beq":
if self.g(args[0]) == self.g(args[1]):
nxt = self.clab[args[2]]
elif op == "bne":
if self.g(args[0]) != self.g(args[1]):
nxt = self.clab[args[2]]
elif op == "j":
nxt = self.clab[args[0]]
elif op == "jal":
self.s("ra", nxt)
nxt = self.clab[args[0]]
elif op == "jr":
nxt = self.g(args[0])
elif op == "syscall":
v0 = self.g("v0")
if v0 == 1:
self.out.append(str(self.g("a0")))
elif v0 == 4:
self.out.append(self.strings.get(self.g("a0"), "?"))
elif v0 == 10:
self.pc = len(self.prog)
return False
else:
raise RuntimeError("未实现的服务号 %d" % v0)
else:
raise RuntimeError("未实现的指令 %s" % op)
self.pc = nxt
return True
def run(self):
while self.step():
pass
return self.out
print("=== ① 数组求和:用解释器跑例 2 的指令流 ===")
DATA = 0x10010000
ARR = [3, 1, 4, 1, 5, 9, 2, 6]
mem0 = {DATA + 4 * i: v for i, v in enumerate(ARR)}
SUM_PROG = [
("li", "t0", 0), ("li", "t1", len(ARR)), ("li", "t2", 0), ("li", "t3", DATA), ("li", "t4", 0),
("bge", "t0", "t1", "done"),
("sll", "t5", "t0", 2), ("add", "t5", "t5", "t3"), ("lw", "t6", 0, "t5"),
("add", "t2", "t2", "t6"), ("slt", "t7", "t4", "t6"),
("beq", "t7", "zero", "skip"),
("move", "t4", "t6"),
("addi", "t0", "t0", 1), ("j", "loop"),
("move", "a0", "t2"), ("li", "v0", 1), ("syscall",),
("li", "v0", 10), ("syscall",),
]
CLAB = {"loop": 5, "skip": 13, "done": 15}
sim = Sim(SUM_PROG, CLAB, mem0,
strings={DATA + 0x40: "sum = ", DATA + 0x50: "\nmax = "})
sim.pc = 0
res = sim.run()
print(" 解释器输出:" + "".join(res))
print(" sum = %d,max = %d,指令数 = %d" % (sim.g("t2"), sim.g("t4"), sim.steps))
print(" 手算对账:sum 应 31 ✔ / max 应 9 ✔ / 循环 8 次 ✔")
print()
print("=== ② 逐次迭代快照(解释器重跑一遍,每次循环后记账) ===")
sim2 = Sim(SUM_PROG, CLAB, mem0)
snap = []
while sim2.pc != 15: # 15 = done("move a0,t2")
if sim2.pc == 13: # 13 = "addi t0,t0,1",一次迭代到此结束
i = sim2.g("t0")
snap.append((i, ARR[i], sim2.g("t2"), sim2.g("t4")))
if not sim2.step():
break
print(" " + pad("迭代", 6) + pad("i", 4) + pad("arr[i]", 8) + pad("sum", 6) + "max")
for k, (i, v, s_, mx) in enumerate(snap, 1):
print(" " + pad(str(k), 6) + pad(str(i), 4) + pad(str(v), 8) + pad(str(s_), 6) + str(mx))
print(" ★ 快照点选在 “addi t0,t0,1” 之前:此时 sum 已加完 arr[i]、max 已更新、i 还是本轮的值")
print(" 对照例 2 的手算轨迹表:sum 3/4/8/9/14/23/25/31,max 3/3/4/4/5/9/9/9")
print()
print("=== ③ 递归阶乘:显式栈模拟 fact 的栈帧 ===")
SP0 = 0x7ffffffc
def fact_sim(n):
sp = SP0
mem = {}
frames = []
ra_up = 0x00400030 # main 里 jal 之后的地址
k = n
while True: # ① 下行:逐层压栈
sp -= 8
mem[sp] = k # 0($sp) = n
mem[sp + 4] = ra_up # 4($sp) = $ra
frames.append((k, sp))
if k <= 1:
break
ra_up = 0x00400108 # fact 内部 jal 之后的地址(各层相同)
k -= 1
print(" " + pad("层", 8) + pad("n", 4) + pad("$sp", 14) + pad("0($sp)", 8) + "4($sp)")
for kk, s_ in frames:
print(" " + pad("fact(%d)" % kk, 8) + pad(str(kk), 4)
+ pad("0x%08x" % s_, 14) + pad(str(mem[s_]), 8) + "0x%08x" % mem[s_ + 4])
acc = 1
print(" 回程:", end="")
for kk, s_ in reversed(frames):
acc *= kk
print(" %d×→%d" % (kk, acc), end="")
print()
print(" 栈用量 = %d 层 × 8 字节 = %d 字节;最深 $sp = 0x%08x"
% (len(frames), len(frames) * 8, frames[-1][1]))
print(" $sp 校验:0x7ffffffc - %d = 0x%08x" % (len(frames) * 8, SP0 - len(frames) * 8))
return acc
got = fact_sim(5)
print(" fact(5) = %d(手算 120 ✔,4! = 24,3! = 6)" % got)预期输出:
=== ① 数组求和:用解释器跑例 2 的指令流 ===
解释器输出:31
sum = 31,max = 9,指令数 = 87
手算对账:sum 应 31 ✔ / max 应 9 ✔ / 循环 8 次 ✔
=== ② 逐次迭代快照(解释器重跑一遍,每次循环后记账) ===
迭代 i arr[i] sum max
1 0 3 3 3
2 1 1 4 3
3 2 4 8 4
4 3 1 9 4
5 4 5 14 5
6 5 9 23 9
7 6 2 25 9
8 7 6 31 9
★ 快照点选在 “addi t0,t0,1” 之前:此时 sum 已加完 arr[i]、max 已更新、i 还是本轮的值
对照例 2 的手算轨迹表:sum 3/4/8/9/14/23/25/31,max 3/3/4/4/5/9/9/9
=== ③ 递归阶乘:显式栈模拟 fact 的栈帧 ===
层 n $sp 0($sp) 4($sp)
fact(5) 5 0x7ffffff4 5 0x00400030
fact(4) 4 0x7fffffec 4 0x00400108
fact(3) 3 0x7fffffe4 3 0x00400108
fact(2) 2 0x7fffffdc 2 0x00400108
fact(1) 1 0x7fffffd4 1 0x00400108
回程: 1×→1 2×→2 3×→6 4×→24 5×→120
栈用量 = 5 层 × 8 字节 = 40 字节;最深 $sp = 0x7fffffd4
$sp 校验:0x7ffffffc - 40 = 0x7fffffd4
fact(5) = 120(手算 120 ✔,4! = 24,3! = 6)例 5:五类常见错误的复现与修复
这张表是"照着写就少调两小时"的清单。
| # | 错误写法 | 症状 | 修复 |
|---|---|---|---|
| 1 | lw $a0, msg(想打印字符串) | la 与 lw 混用:lw 把 msg 当"地址常量",实际读的是 0x10010000 处的内容(0x006c6568)当地址 → address out of range | 要地址用 la,要内容用 lw;打印字符串的 $a0 必须是地址 |
| 2 | 循环体末尾漏 j loop | 只迭代 1 次就往下走("跑完不报错,答案是错的") | 循环必须有回跳;用"预期迭代次数"对账 |
| 3 | sll $t5, $t0, 2 写成 sll $t5, $t0, 1 | 每次只前进 2 字节——lw 要求 4 字节对齐,MARS 会报 load address not aligned | 数组下标换算固定"×4",因为 .word 是 4 字节 |
| 4 | $ra 未保存就 jal(叶子函数写惯了) | 回程跳到错误的地址(invalid program counter value) | 非叶子函数入口先 sw $ra, 4($sp),出口 lw $ra, 4($sp) |
| 5 | $sp 帧大小取 4、却压了 $ra 和 $a0 两个字 | 0($sp) 与 4($sp) 互相覆盖(读回的 n 是错的) | 帧大小 ≥ 本层要保存的字节数,并取 8 的倍数(本站口径) |
第 2 条和第 5 条属于"不报错的错"——必须靠例 2 的轨迹表、例 4 的解释器来抓。 这就是为什么"对账"要和"调试"并列为两项独立工序。
考点
考点
1. 最小骨架七要素(默写)
.data → 变量声明 → .text → .globl main → main: → li $v0, 服务号 + 参数 + syscall → 退出序列($v0=10 + syscall)。
漏掉退出序列 → attempt to execute non-instruction,这是最高频的首次报错。
2. 伪指令与真指令(表要能背)
| 伪指令 | 展开 | 条数 |
|---|---|---|
li $t, 大常数 | lui + ori | 2 |
la $t, label | lui + ori | 2 |
move | addu $d, $zero, $s | 1 |
bge / blt / bgt / ble | slt $at, ... + beq/bne | 2 |
mul | mult + mflo | 2 |
$at($1)归汇编器用,不要手写;la取地址、lw取内容——打印字符串用la。
3. 数据段与对齐
.word4 字节、.byte1 字节、.asciiz含末尾\0、.space n留空;.word/.asciiz自带对齐,汇编器会自动插填充字节——"我数着是 +1,怎么跑到 +4"是正常的;.byte之间不填充。
4. 调试法:带预测的单步
① 先写"这一步之后某寄存器应该是几" → ② 单步 → ③ 对照。 不带预测地单步等于没调。
5. 六类症状 → 根因(背成一问一答)
| 症状 | 先想到 |
|---|---|
symbol not found | 拼写 / 没声明 / 漏冒号 |
execute non-instruction | 漏退出序列 |
address out of range | la/lw 混用、偏移算错、没分配的地址 |
invalid program counter | $ra 没保存或被覆盖 |
| 结果差一点但不报错 | $t 跨调用、边界差 1、帧大小不匹配 |
| 输入卡住 | 服务号 8 的 $a0/$a1 没设 |
6. 没有模拟器时的对账工序(本站的实际情况)
手算前 3~5 步 → 自写子集解释器跑全程 → 逐项比对。 本篇例 4 的解释器就是模板:寄存器 32 个数组 + 内存字典 + 指令分派 + syscall 只实现 1/4/10。
7. 本节五个高频易错点
.asciiz的长度要逐字符数("hello, mips\n"是 13 字节,不是 11);- 数组下标换算必须 ×4(
.word的字节宽度); slt d, s, t是d = (s < t)——参数顺序反了逻辑就反了,且不报错;bge/ble是伪指令,展开后 2 条——数指令条数别漏;- 帧大小取 8 的倍数(本站口径),且必须 ≥ 本层要保存的字节数。
小结
- "跑通"= 能汇编 + 能运行 + 结果对,第三条最容易被跳过,而"没崩"不等于"算对了"。
- 最小骨架七要素:
.data/ 变量 /.text/.globl main/main:/ 服务号序列 / 退出序列。 la取地址、lw取内容;li/la/move/bge/mul都是伪指令——li装大常数是 2 条,$at归汇编器。- 数据段要记对齐:
.word4 字节、.asciiz补\0、汇编器自动插填充。 - 调试=带预测的单步;六类症状各有固定根因,其中"结果差一点但不报错"最危险。
- 对账工序:手算 + 自写 MIPS 子集解释器(例 4 的 60 行模板)——本机没有 MARS,这套工序就是本站在无模拟器环境下验证汇编的替代手段。
- 三个手算锚点:数组求和 8 次迭代 →
sum = 31、max = 9;递归fact(5)→ 5 层栈帧 = 40 字节、最深$sp = 0x7fffffd4、结果 120。
回到主线:到这里,lang 的第一部分(汇编语言,01–06)就完整了。
前五篇给零件:指令格式、寻址、运算与分支、栈帧与调用约定、系统调用。这一篇把零件装成三台能跑的小机器——一个打印程序、一个循环程序、一个递归程序,并给出了"在没有模拟器的机器上怎么确认它们跑对了"的方法。
这三类程序恰好覆盖了汇编的全部控制流形态:顺序(例 1)、循环(例 2)、递归(例 3)。 会写这三种,就已经能读懂任何一段编译器吐出来的汇编——这正是下一篇要马上做的事。
从下一篇起进入 lang 的第二部分:C 语言。 你会看到编译器把你写的 arr[i]、struct、*p、malloc()翻译成本篇这些指令——下标为什么会变成 sll + add?结构体成员的偏移是谁算的?函数调用为什么正好长成例 3 那个样子? 答案全在寄存器、栈帧与寻址方式里。
下一篇:数据类型与位级表示
评论(0)
当前浏览器不允许本地存储,评论无法保存。
还没有评论,来说两句。