简介:这份资源面向学习计算机组成原理、嵌入式开发及MIPS指令集的学生与工程师,聚焦汇编语言与机器码之间的双向转换。包内提供可运行的汇编/反汇编工具及其Java源码,用户既能输入MIPS汇编语句得到对应32位机器码,也能将已有机器码还原为可读汇编,便于理解指令字段、寄存器分配与控制流标签的处理方式。资源共5个文件,包含1个jar可执行程序、1个java源码、2张png界面或示例截图以及1份txt使用说明,压缩包约81KB,体积轻巧,适合随取随用。目前已有351人学习下载。借助该工具,读者可直观验证add等指令的编码结果,观察反汇编输出,配合说明文档快速上手,是调试与优化MIPS代码、加深底层计算理解的实用辅助材料。
1. MIPS 汇编与机器码互转:从 Asm.zip 这个标题能拆出什么
你手里可能有一个叫 Asm.zip 的压缩包,也可能只是看到这个标题,想知道 MIPS 汇编、机器码、反汇编、汇编器这几个词到底怎么串起来。先说结论:这个标题指向的是一套围绕 MIPS 指令集的编解码工具链——把人类可读的汇编指令翻译成 32 位机器码,或者反过来把二进制机器码还原成汇编。它解决的核心问题是:当你面对一段裸二进制、一段 .text 段、或者一个没有源码的固件时,怎么快速看懂它、改它、重新生成它。
适合谁看?三类人:一是做嵌入式或系统结构课程设计的学生,手头有 MIPS 模拟器或 FPGA 上的多周期 CPU,需要往指令存储器里填机器码;二是做逆向或固件分析的人,拿到一段 MIPS 二进制需要反汇编定位逻辑;三是自己写汇编器或反汇编器练手的人,想搞清楚指令格式、立即数拼接、跳转地址计算这些细节。下面按“先搞懂编码规则 → 再动手做汇编器 → 再做反汇编器 → 避坑 → 进阶验证”的顺序讲,每一步都给可复现的命令和代码。
2. MIPS 指令编码规则:32 位里每一位在干什么
2.1 三种指令格式与操作码分布
MIPS 是典型的三操作数、定长 32 位指令集。常见格式分三种:R 型(寄存器-寄存器运算)、I 型(立即数、访存、分支)、J 型(跳转)。不管哪种,最高 6 位都是 opcode(操作码),最低 6 位在 R 型里是 funct(功能码),两者组合决定具体指令。
R 型布局:opcode(6) | rs(5) | rt(5) | rd(5) | shamt(5) | funct(6)。比如add $t0, $t1, $t2,opcode 为 0,funct 为 0x20。I 型布局:opcode(6) | rs(5) | rt(5) | immediate(16)。比如addi $t0, $t1, 100,opcode 为 0x08,立即数 100 放在低 16 位。J 型布局:opcode(6) | address(26),比如j 0x00400000,opcode 为 0x02,低 26 位是目标地址右移 2 位后的值。
理解这三种格式是后面所有代码的基础。你不需要背下所有 opcode,但要知道怎么查表、怎么把字段拼成一个 32 位整数。常见做法是维护一张指令表,每条指令记录格式、opcode、funct(R 型才有)。我一般会把表写成 Python 字典,方便后续汇编器和反汇编器共用。
2.2 立即数与跳转地址的编码细节
I 型立即数分两种:逻辑运算(andi、ori、xori)做零扩展,算术和访存(addi、lw、sw)做符号扩展。这意味着addi $t0, $t1, -1的低 16 位是 0xFFFF,解码时要按有符号数还原成 -1。分支指令(beq、bne)的 16 位立即数不是绝对地址,而是相对于下一条指令的偏移量,单位是指令字(4 字节)。计算方式是:目标地址 = (PC + 4) + (offset << 2)。很多新手在这里翻车,把 offset 直接当字节数用,结果跳转飞到别处。
J 型跳转更绕:26 位地址字段不是完整地址,而是目标地址的低 28 位右移 2 位。实际跳转时,取当前 PC+4 的高 4 位作为高 4 位,拼接address << 2作为低 28 位。也就是说,J 型只能在同一个 256MB 区域里跳。写汇编器时,如果目标地址超出这个范围,必须报错或改用jr配合lui/ori加载完整地址。
注意:MIPS 有分支延迟槽。分支指令后面那条指令无论分支是否跳转都会先执行。写反汇编器时,如果只按线性地址逐条翻译,遇到分支会把延迟槽指令也翻出来,但逻辑上它属于分支的一部分。做控制流分析时要特别处理。
2.3 用 Python 手工编码一条 addi 指令
先不写完整汇编器,手工拼一条addi $t0, $t1, 100的机器码,验证你对字段的理解。寄存器编号:$zero=0, $at=1, $v0=2, $v1=3, $a0=4…$t0=8, $t1=9, $t2=10…$s0=16…$ra=31。addi 的 opcode 是 0x08,rs=$t1=9,rt=$t0=8,立即数 100。
# 手工编码 addi $t0, $t1, 100 opcode = 0x08 rs = 9 # $t1 rt = 8 # $t0 imm = 100 # 按 I 型格式拼接:opcode(6) | rs(5) | rt(5) | imm(16) machine = (opcode << 26) | (rs << 21) | (rt << 16) | (imm & 0xFFFF) print(f"0x{machine:08X}") # 输出 0x21280064逻辑说明:opcode << 26把 6 位操作码放到最高 6 位;rs << 21把 5 位源寄存器放到第 21-25 位;rt << 16放到第 16-20 位;imm & 0xFFFF保证立即数只占低 16 位,负数也能正确截断。参数说明:如果你把 imm 改成 -1,输出会是0x2128FFFF,解码时按符号扩展还原成 -1。这条手工编码可以当作后面汇编器的单元测试用例。
3. 写一个最小 MIPS 汇编器:从文本到机器码
3.1 指令表设计与解析流程
汇编器的输入是汇编文本,输出是机器码列表(通常再转成十六进制或二进制文件)。最小可用版本只需要支持十几条常用指令:add、sub、and、or、slt、addi、andi、ori、lw、sw、beq、bne、j、jr、lui。流程分四步:第一遍扫描收集标签地址;第二遍逐行解析指令、查表、编码;最后输出。
指令表用字典存,键是助记符,值包含格式、opcode、funct。寄存器表把$t0映射到 8。解析时先去掉注释(#之后的内容),再按逗号或空格切分操作数。标签定义形如loop:,第一遍记录它对应的指令索引乘以 4 得到字节地址。
# 最小指令表(部分) INSTR_TABLE = { 'add': {'fmt': 'R', 'op': 0x00, 'funct': 0x20}, 'sub': {'fmt': 'R', 'op': 0x00, 'funct': 0x22}, 'and': {'fmt': 'R', 'op': 0x00, 'funct': 0x24}, 'or': {'fmt': 'R', 'op': 0x00, 'funct': 0x25}, 'slt': {'fmt': 'R', 'op': 0x00, 'funct': 0x2A}, 'addi': {'fmt': 'I', 'op': 0x08}, 'andi': {'fmt': 'I', 'op': 0x0C}, 'ori': {'fmt': 'I', 'op': 0x0D}, 'lw': {'fmt': 'I', 'op': 0x23}, 'sw': {'fmt': 'I', 'op': 0x2B}, 'beq': {'fmt': 'I', 'op': 0x04}, 'bne': {'fmt': 'I', 'op': 0x05}, 'j': {'fmt': 'J', 'op': 0x02}, 'jr': {'fmt': 'R', 'op': 0x00, 'funct': 0x08}, 'lui': {'fmt': 'I', 'op': 0x0F}, } REG = {'$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,'$k0':26,'$k1':27,'$gp':28,'$sp':29,'$fp':30,'$ra':31}参数说明:fmt决定用哪种编码函数;op是操作码;funct只有 R 型需要。寄存器表覆盖了标准 O32 ABI 的命名。如果你用的模拟器或教材用不同命名(比如$r8),在 REG 里加映射即可。
3.2 两遍扫描与标签地址回填
第一遍扫描时,维护一个pc计数器,每遇到一条指令就加 4。遇到label:就记录labels[label] = pc。注意标签可以单独占一行,也可以和指令同行。第二遍扫描时,对分支和跳转指令,把标签替换成计算出的偏移或地址。
分支偏移计算:offset = (labels[target] - (pc + 4)) // 4。跳转地址计算:addr = labels[target] >> 2,取低 26 位。如果标签不存在,报错并给出行号。这一步是汇编器最容易出 bug 的地方,建议每编码一条就打印中间结果,和手工计算对照。
def assemble(lines): labels = {} pc = 0 # 第一遍:收集标签 for line in lines: line = line.split('#')[0].strip() if not line: continue if ':' in line: label, rest = line.split(':', 1) labels[label.strip()] = pc line = rest.strip() if not line: continue pc += 4 # 第二遍:编码 result = [] pc = 0 for line in lines: line = line.split('#')[0].strip() if not line: continue if ':' in line: line = line.split(':', 1)[1].strip() if not line: continue parts = line.replace(',', ' ').split() mnemonic = parts[0] ops = parts[1:] info = INSTR_TABLE[mnemonic] if info['fmt'] == 'R': rd, rs, rt = REG[ops[0]], REG[ops[1]], REG[ops[2]] code = (info['op'] << 26) | (rs << 21) | (rt << 16) | (rd << 11) | info['funct'] elif info['fmt'] == 'I': rt, rs = REG[ops[0]], REG[ops[1]] if ops[2] in labels: imm = (labels[ops[2]] - (pc + 4)) // 4 else: imm = int(ops[2], 0) code = (info['op'] << 26) | (rs << 21) | (rt << 16) | (imm & 0xFFFF) elif info['fmt'] == 'J': addr = labels[ops[0]] >> 2 if ops[0] in labels else int(ops[0], 0) >> 2 code = (info['op'] << 26) | (addr & 0x03FFFFFF) result.append(code) pc += 4 return result逻辑说明:第一遍只关心标签和 pc,不生成代码;第二遍才真正编码。分支指令的立即数用标签地址减pc+4再除 4,得到相对偏移。跳转指令取标签地址右移 2 位。参数说明:int(ops[2], 0)支持十进制和十六进制立即数(如0x64)。如果你的汇编器要支持lw $t0, 0($sp)这种内存操作数,需要在解析时把0($sp)拆成立即数和基址寄存器。
3.3 输出格式与验证方法
汇编器输出可以是纯十六进制文本、二进制文件或 Verilog 的$readmemh格式。做 CPU 课程设计时,最常见的是生成.hex文件,每行一个 32 位十六进制数,供指令存储器初始化。验证方法:写一段已知汇编,手工算出机器码,和汇编器输出对比。比如addi $t0, $zero, 1应该是0x20080001,add $t0, $t1, $t2应该是0x012A4020。
# 输出 $readmemh 格式 codes = assemble(open('test.asm').readlines()) with open('inst.hex', 'w') as f: for c in codes: f.write(f"{c:08X}\n")参数说明:$readmemh要求每行一个十六进制数,位宽和存储器一致。如果你的模拟器要求小端字节序的二进制文件,可以用struct.pack('<I', c)逐条写入。验证时至少覆盖 R 型、I 型算术、分支、跳转各一条,确保字段拼接和符号扩展都正确。
4. 写一个最小 MIPS 反汇编器:从机器码还原汇编
4.1 解码表与字段提取
反汇编器是汇编器的逆过程:输入 32 位整数,输出汇编文本。核心是根据 opcode 和 funct 查表,确定指令格式,再提取各字段。R 型先看 opcode 是否为 0,是则用 funct 区分具体指令;I 型和 J 型直接用 opcode 区分。解码表可以复用汇编器的表,反向建一个(op, funct) -> mnemonic的映射。
字段提取用位运算:op = (code >> 26) & 0x3F,rs = (code >> 21) & 0x1F,rt = (code >> 16) & 0x1F,rd = (code >> 11) & 0x1F,shamt = (code >> 6) & 0x1F,funct = code & 0x3F。I 型立即数imm = code & 0xFFFF,需要判断是否符号扩展。J 型地址addr = code & 0x03FFFFFF。
def decode(code, pc=0): op = (code >> 26) & 0x3F rs = (code >> 21) & 0x1F rt = (code >> 16) & 0x1F rd = (code >> 11) & 0x1F funct = code & 0x3F imm = code & 0xFFFF if op == 0: # R 型,查 funct name = R_FUNCT.get(funct, f"unknown_r_{funct:02X}") return f"{name} {REG_NAME[rd]}, {REG_NAME[rs]}, {REG_NAME[rt]}" elif op in I_TABLE: name = I_TABLE[op] # 符号扩展 simm = imm - 0x10000 if imm & 0x8000 else imm return f"{name} {REG_NAME[rt]}, {REG_NAME[rs]}, {simm}" elif op == 0x02: target = ((pc + 4) & 0xF0000000) | ((code & 0x03FFFFFF) << 2) return f"j 0x{target:08X}" else: return f".word 0x{code:08X}"逻辑说明:R 型用 funct 查表,输出三个寄存器操作数。I 型输出 rt、rs、立即数,立即数按符号扩展还原。J 型计算完整目标地址,取当前 PC+4 的高 4 位拼接。参数说明:pc是当前指令地址,反汇编分支和跳转时需要它来计算目标。如果 opcode 不认识,输出.word保留原始数据,方便后续人工分析。
4.2 分支与跳转目标还原
反汇编分支指令时,不能只输出偏移量,最好同时输出目标地址或标签。计算方式:target = pc + 4 + (simm << 2)。如果目标地址在已知范围内,可以生成label_0x00400010:这样的标签,并在指令里引用。跳转指令按前面说的方式还原完整地址。这一步对逆向分析很关键,因为裸偏移量很难直接看出控制流。
# 分支目标还原示例 if name in ('beq', 'bne'): target = pc + 4 + (simm << 2) return f"{name} {REG_NAME[rs]}, {REG_NAME[rt]}, 0x{target:08X}"参数说明:simm是符号扩展后的 16 位偏移,左移 2 位变成字节偏移。pc是当前指令地址。如果你的反汇编器要生成可重新汇编的文本,可以把目标地址替换成标签名,但需要先扫描一遍所有分支目标,建立地址到标签的映射。
4.3 处理数据段与混合二进制
真实二进制里不只有指令,还有数据。反汇编器通常只反汇编.text段,数据段按.word、.byte或.ascii输出。如果你拿到的是裸二进制,没有段信息,常见做法是先用objdump或readelf看段表,或者按经验判断:一段连续看起来像指令的字节,反汇编后如果大量出现unknown或.word,可能进入了数据区。
# 用 objdump 辅助定位 .text 段(如果有 ELF 头) mips-linux-gnu-objdump -d -j .text firmware.elf参数说明:-d反汇编,-j .text只处理代码段。如果你没有交叉工具链,可以用 Python 读 ELF 头,找到.text的偏移和大小,再喂给自写反汇编器。这一步的坑是字节序:MIPS 可以是大端也可以是小端,读 32 位整数时要用struct.unpack('>I')或'<I',搞反了反汇编出来全是乱码。
5. 避坑与排查:MIPS 编解码里最容易翻车的 5 个点
5.1 现象:分支跳转飞到错误地址 → 原因:偏移单位搞错 → 解决:统一按指令字计算
血泪经验:很多人第一次写分支编码时,把标签地址直接减去当前 PC,忘了减 4,也忘了除 4。MIPS 分支偏移是相对于延迟槽指令(PC+4)的指令字偏移。正确公式是(target - (pc + 4)) // 4。反汇编时对应pc + 4 + (offset << 2)。建议在代码里把这两个公式写成函数,单元测试覆盖正负偏移。
5.2 现象:addi 负数变成大正数 → 原因:立即数没做符号扩展 → 解决:解码时判断 bit15
addi $t0, $t1, -1编码后低 16 位是 0xFFFF。如果反汇编时直接输出 65535,逻辑就错了。正确做法:imm = code & 0xFFFF; if imm & 0x8000: imm -= 0x10000。逻辑运算 andi、ori、xori 不做符号扩展,直接输出无符号数。这两类指令要分开处理,不能一刀切。
5.3 现象:j 指令跳转后地址不对 → 原因:高 4 位没拼接 → 解决:用 PC+4 的高 4 位
J 型指令只存 26 位地址,实际跳转地址是(PC+4)[31:28] | (addr << 2)。如果你直接输出addr << 2,高 4 位就是 0,跳转到 0x00000000 附近,而不是当前区域。反汇编时要用当前 PC 计算高 4 位。汇编时如果目标地址和当前 PC 不在同一个 256MB 区域,必须报错。
5.4 现象:lw/sw 的偏移量解析失败 → 原因:内存操作数格式没处理 → 解决:正则拆出立即数和基址
lw $t0, 8($sp)这种操作数不能简单按逗号切分。常见做法是用正则r'(-?\d+)\((\$\w+)\)'提取偏移和基址寄存器。如果偏移是标签,还要查标签表。写汇编器时,建议先把操作数规范化,再进入编码函数。反汇编时输出offset(base)格式,方便重新汇编。
5.5 现象:反汇编输出乱码 → 原因:字节序或起始地址错误 → 解决:确认大小端和加载地址
MIPS 工具链常见的是大端(mips-linux-gnu)和小端(mipsel-linux-gnu)两种。读二进制时用错字节序,32 位整数会完全颠倒。另外,裸二进制通常有加载地址(比如 0x00400000 或 0x80000000),反汇编时 PC 要从这个地址开始算,否则分支和跳转目标全错。先用file或readelf -h确认字节序和入口地址,再开始反汇编。
6. 进阶验证:用模拟器跑通汇编与反汇编闭环
6.1 用 MARS 或 QtSpim 验证机器码
写完汇编器后,最可靠的验证是拿生成的机器码到 MARS 或 QtSpim 里跑。MARS 支持直接粘贴十六进制机器码,也支持加载.hex文件。步骤:在 MARS 里选File -> Open,加载你的.hex,然后单步执行,观察寄存器变化是否符合预期。如果 MARS 报“未知指令”,多半是 opcode 或 funct 拼错了。QtSpim 类似,但更严格,对延迟槽的处理更接近真实硬件。
# 用 MARS 命令行模式跑(假设 mars.jar 在当前目录) java -jar mars.jar nc inst.hex参数说明:nc表示不弹窗,直接运行。如果你的模拟器不支持命令行,就手工粘贴机器码。验证时至少跑一个循环:用addi初始化计数器,beq判断退出,j跳回循环头。如果循环能正常退出,说明分支和跳转编码都对了。
6.2 汇编→反汇编→再汇编的闭环测试
更严格的验证是闭环:写一段汇编 A,汇编成机器码 M,反汇编 M 得到汇编 B,再汇编 B 得到机器码 M2,比较 M 和 M2 是否完全一致。如果一致,说明编解码是对称的。这个测试能抓出很多边界问题,比如符号扩展、分支偏移、跳转地址拼接。我一般会写一个脚本自动跑十几条代表性指令,包括正负立即数、前后向分支、跨区域跳转。
# 闭环测试伪代码 asm_a = """ addi $t0, $zero, -5 addi $t1, $zero, 10 loop: add $t2, $t0, $t1 addi $t0, $t0, 1 bne $t0, $zero, loop j 0x00400000 """ m1 = assemble(asm_a.splitlines()) asm_b = "\n".join(disassemble(m1, base=0x00400000)) m2 = assemble(asm_b.splitlines()) assert m1 == m2, "闭环失败"参数说明:base是加载地址,反汇编和再汇编时必须一致。如果闭环失败,逐条对比机器码,定位是哪条指令的哪个字段不一致。常见原因是反汇编输出的立即数格式和汇编器解析格式不匹配,比如十六进制和十进制混用。
6.3 一个具体技巧:用查表法加速反汇编
如果你要反汇编大段二进制,逐条查字典没问题,但想更快可以用数组查表。建一个 64 项的 opcode 表和一个 64 项的 funct 表,直接索引,比字典哈希快。对于嵌入式模拟器或在线反汇编工具,这个优化有意义。另外,把寄存器名预存成列表,用编号直接索引,避免每次格式化字符串。
REG_NAME = ['$zero','$at','$v0','$v1','$a0','$a1','$a2','$a3', '$t0','$t1','$t2','$t3','$t4','$t5','$t6','$t7', '$s0','$s1','$s2','$s3','$s4','$s5','$s6','$s7', '$t8','$t9','$k0','$k1','$gp','$sp','$fp','$ra']这个列表按编号顺序排列,REG_NAME[rs]直接得到寄存器名。注意 $zero 到 $ra 的编号是标准 O32,如果你用的教材或模拟器编号不同,改这个列表即可。我习惯在项目开始时就把寄存器表、指令表、格式判断函数定好,后面所有代码都复用,减少不一致导致的玄学 bug。
最后说个习惯:每次改完汇编器或反汇编器,先跑闭环测试,再跑模拟器验证。不要只靠肉眼检查机器码,人眼对十六进制不敏感,一个位错就可能让整个程序跑飞。希望帮到你。
本文还有配套的精品资源,点击获取