资讯动态

MIPS汇编器从零实现:指令文本到32位机器码的完整转换流程

发布时间:2026/10/13 22:26:39 来源:尧图企业网站定制
简介MIPSAssembler 是一套用 Python 编写的 MIPS 汇编器工具面向计算机系统、汇编语言课程学习者及嵌入式入门开发者。它能够将 MIPS 架构下的汇编指令转换为可执行的二进制机器码帮助使用者脱离仿真器直接观察指令编码过程。压缩包共 4 个文件包括汇编器源码、示例 MIPS 代码、输出结果文本及说明文档整体大小仅 5KB轻量易用。目前已有 194 人浏览学习。结合示例代码读者可以逐步理解 MIPS 指令集、寄存器约定、地址计算、数据段与代码段划分以及分支跳转和函数调用等核心知识点通过修改和运行汇编源文件还能验证不同指令的机器码输出。借助附带的汇编示例与输出文本读者能直观对照源码与机器码体会汇编到二进制的映射关系是辅助课程实验、考前复习或自学底层编程的实用工具。1. 认识MIPSAssembler从指令文本到机器码的那一步写 CPU 课程设计或者自己写模拟器时很多人卡在同一个环节汇编代码写好了却要把每条指令手算成 32 位机器码再逐行填进 ROM。手算一两次还行指令一多、标签一多就开始翻车。MIPSAssembler 就是解决这件事的工具它读取 MIPS 汇编语言经过注释清理、词法切分、符号表收集、指令编码最终输出对应的 32 位机器码让模拟器、测试台或者你正在设计的 CPU 直接使用。这篇文章按照「指令格式 → 工程流水线 → 编码实现 → 踩坑 → 验证」的顺序把一个最小但能用的 MIPS 汇编器完整拆开讲透。适合做 CPU 实验、写模拟器、或者单纯想把汇编器原理弄明白的开发者。2. 为什么先定指令集MIPS 的三种编码格式与伪指令MIPS 汇编器的输入是文本输出是 32 位机器码两者之间只能靠一套固定规则对应起来这套规则就是指令集编码。动手写代码前先把编码表放在手边比边写边查要高效得多。一般情况下汇编器核心需要维护三张表R 型功能码表、I 型 opcode 表、J 型 opcode 表。后面所有编码函数都只查表遇到表中不存在的助记符统一报“未知指令”。2.1 从R型/I型/J型说起指令编码的三个固定模板MIPS 指令长度固定 32 位所有指令按最高 6 位 opcode 分成三类格式。R 型面向寄存器运算布局为 6 位 opcode、5 位 rs、5 位 rt、5 位 rd、5 位 shamt、6 位 funct刚好 32 位。add、sub、and、or、slt 等算术逻辑指令都用这种格式opcode 恒为 0具体行为由 funct 字段区分。I 型面向立即数和访存布局为 6 位 opcode、5 位 rs、5 位 rt、16 位立即数addiu、ori、lw、sw、beq、bne 都走这个模板。J 型只有 6 位 opcode 和 26 位地址j 和 jal 用它。这三个模板直接决定了代码结构R 型需要一张“助记符 → funct”映射表I 型需要区分 opcode并且对 16 位立即数做边界处理J 型只需要把 26 位字地址左移两位后拼接进指令。这里要注意J 型里的 26 位保存的是“字地址”真正的指令地址等于它左移两位因为 MIPS 指令按 4 字节对齐低两位恒为 0省出来可以多存两位地址。格式字段布局典型指令编码要点R 型opcode(6) rs(5) rt(5) rd(5) shamt(5) funct(6)add / sub / sll / jrfunct 决定真实指令I 型opcode(6) rs(5) rt(5) imm(16)addiu / lw / beq / oriimm 需要按有符号或无符号截断J 型opcode(6) addr(26)j / jaladdr 左移 2 位得到真实目标地址我在做这个汇编器时先把这三张表写成字典然后再写任何编码逻辑。这样后面遇到不认识的助记符直接查表失败并报错而不是散落在一堆 if-else 里。实际编码时还有一个容易被忽略的点R 型里 sll、srl 的操作数顺序和 add、sub 不一样前者是sll $rd, $rt, shamt后者是add $rd, $rs, $rt。两种顺序如果混在一个编码函数里最容易产生隐蔽错误。2.2 伪指令与寄存器别名汇编器替你做了哪些事MIPS 汇编语言里大量指令并不是真实机器指令而是伪指令。伪指令没有直接编码汇编器负责把它们展开成一条或多条真实指令。典型的如 movemove $t0, $t1本身不存在真实做法是add $t0, $t1, $zero利用 $zero 恒为 0 完成数据搬移。li 的展开方式根据立即数大小分两种情况16 位有符号范围内用addiu $rt, $zero, imm超出范围用 lui 加载高 16 位、再用 ori 拼接低 16 位。la 加载标签地址也按高、低半字拆成两步。寄存器别名是另一个容易被忽略的点。MIPS 汇编器允许程序员写 $t0、$s1、$ra但机器码里只有数字编号。汇编器必须维护一张别名表并且在解析操作数的第一步就做转换。$t0 到 $t7 对应寄存器 8 到 15$t8、$t9 对应 24、25$s0 到 $s7 对应 16 到 23$zero 对应 0$v0 对应 2$a0 到 $a3 对应 4 到 7$sp 对应 29$ra 对应 31。这张表最好放在一个独立的解析函数里让所有编码函数共用。伪指令展开结果说明move $rd, $rsadd $rd, $rs, $zero走 R 型编码li $rt, 小立即数addiu $rt, $zero, immimm 须在 -32768~32767 内li $rt, 大立即数lui $rt, high; ori $rt, $rt, lowhigh / low 各 16 位la $rt, labellui $rt, high; addiu $rt, $rt, low需要 label 的绝对地址bgt $rs, $rt, labelslt $at, $rt, $rs; bne $at, $zero, label用 $at 暂存比较结果我一般会把伪指令展开放在真正的编码之前也就是在第二趟扫描开始前先对指令列表做一遍整体展开这样后面编码函数见到的全是真实指令分支逻辑大幅减少。只处理 move、li、la 三个伪指令已经足够支撑大部分教学实验bgt 这类可以等基础跑通后再补。2.3 最小指令子集怎么圈定先支持哪些指令设计阶段的最后一个核心问题是第一版到底支持多少指令。我见过不少开发者一上来就想覆盖全部 MIPS 指令集结果写了一千多行最后连冒泡排序都没跑通。更可靠的做法是先圈一个能写完整排序和递归调用的最小集R 型选 add、sub、and、or、slt、sll、srl、jrI 型选 addiu、ori、andi、lw、sw、beq、bneJ 型选 j、jal伪指令选 move、li、la。加起来不到二十条但覆盖了三种格式、访存、分支、跳转、运算。为什么不一开始支持所有指令因为每条指令至少对应一次编码测试指令越多调试空间越大。先把这不到二十条跑通、和教学模拟器的输出完全一致再往指令表里加 slti、bgtz、mul 等扩展指令。这样每加一条的边际成本都很低如果编码出错也能快速定位是新加的指令问题还是原有框架的问题。这也是我认为做汇编器项目最省力的路径先小步跑通闭环再逐步扩展而不是一次性覆盖整个指令集。3. 搭一个能跑的最小汇编器从汇编文本到机器码的两趟扫描3.1 总体架构词法、符号表、两趟扫描汇编器整体分四步读取文本并去掉注释把每一行切成助记符和操作数 token建立标签符号表按地址编码输出机器码。其中“两趟扫描”是个关键决策新手很容易踩坑。第一趟遍历所有行识别标签并记录地址第二趟才真正编码机器码。为什么必须两趟因为分支和跳转指令经常引用后面的标签单趟扫描遇到 beq 时根本不知道目标标签的地址只能先跳过全部扫完再回来补本质上等于再扫一遍。更具体地说第一趟只需要做两件事一是解析出每条指令所在地址二是把形如loop:的标签写进符号表。MIPS 真实指令都是 4 字节地址计算很简单从文本段基地址开始每遇到一条指令地址加 4。我一般把文本段和数据段分开处理文本段从基地址连续存放指令数据段另行管理 .word、.byte、.asciiz 等数据。如果目标实验只要指令 ROM数据段可以先不编码但汇编器架构里要预留接口。下面是一个最小两趟扫描的骨架。3.2 第一趟收集标签地址第一趟的目标是收集标签地址同时把清洗后的指令行连同地址存下来供第二趟使用。下面的代码是一个可直接运行的 pass_one 实现为了便于理解这里假定文本段基地址可配置并先只处理指令段。BASE_ADDR 0x00400000 # MIPS文本段常见基地址课程实验可改为0 def strip_comment(line): return line.split(#)[0].strip() def pass_one(lines): symbols {} raw_items [] address BASE_ADDR for raw in lines: line strip_comment(raw) if not line: continue # 处理标签一行里可以有 “label: 指令”也可以只有 “label:” if : in line: label_part, rest line.split(:, 1) label label_part.strip() if label: if label in symbols: raise ValueError(duplicate label: %s % label) symbols[label] address line rest.strip() if not line: continue raw_items.append((address, line)) address 4 return symbols, raw_items逻辑说明输出有两份一份是符号表 dict一份是 raw_items 列表每个元素包含当前指令的绝对地址和去掉注释后的原文。标签地址在这里记录为当前指令的地址也就是标签所在位置的机器码地址。用split(:, 1)而不是split(:)是为了只拆第一个冒号避免误伤后面可能的字符串内容。这里有一个刻意简化伪指令在地址计算时也占 4 字节如果之后被展开成两条真实指令第二趟里地址就会错位这个小坑我会在下一节说明。参数说明BASE_ADDR 是关键常量。校内 CPU 实验的 ROM 通常从 0 地址加载这时设为 0如果目标是按 MIPS 约定运行 ELF 或二进制就设为 0x00400000。它的值同时影响符号表和机器码里的地址前缀改动后所有测试都要重新比对。第一趟还应检查重复标签和标签名与助记符冲突越早做这件事排错成本越低。3.3 第二趟编码指令与数据段第二趟拿到符号表和 raw_items 后逐条判断这段文本是伪指令、真实指令还是数据定义。为了把伪指令展开和指令编码解耦我会先做一次伪指令整体展开得到新的指令列表然后再编码。这个顺序可以让编码函数保持简单不用为伪指令单独写分支。展开时要特别注意展开后的每条指令都要占据独立的地址槽原地址必须按展开数量递增否则后一条指令会覆盖前一条。def pass_two(raw_items, symbols): machine_codes [] for addr, line in raw_items: parts [p.strip() for p in line.replace(,, ).split()] if not parts: continue mnemonic parts[0] args parts[1:] # 伪指令整体展开后再编码 expanded expand_pseudo(mnemonic, args) pc addr for sub_mnemonic, sub_args in expanded: code encode_instruction(sub_mnemonic, sub_args, symbols, pc) machine_codes.append((pc, code)) pc 4 return machine_codes逻辑说明expand_pseudo 对 move、li、la 等返回一个或多个真实指令的列表。比如 li $t0, 70000 会返回两条指令lui $t0, high和ori $t0, $t0, lowpc 从原地址开始每条指令编码完加 4这样两条真实指令的地址就是连续的。encode_instruction 内部再根据助记符查 R_FUNCT、I_OPCODE、J_OPCODE 三张表决定用哪种编码函数。参数说明encode_instruction 里的第四个参数 pc 是当前指令的实际地址分支指令编码时必须依赖它计算偏移量。数据段处理也在这个阶段完成.word 把整数按 4 字节写入.asciiz 把字符串编码成字节并在末尾补 0。数据段的标签也要进符号表否则 la $t0, arr 会找不到目标。完整的汇编器通常会把数据单独输出到另一个文件或者直接接在指令段后面这取决于目标环境的要求。4. MIPSAssembler核心编码实现寄存器编号、立即数与分支偏移4.1 寄存器编号与R型指令先解决“寄存器名到编号”编码的第一步是把 $t0 这种文本变成数字编号这个函数足够简单却决定了后面所有编码函数的入参形式。把它独立出来统一放到一个模块里所有编码函数都调用它比在不同函数里重复写解析要好维护得多。下面是我常用的一个寄存器解析函数。def reg_num(name): if name $zero: return 0 if name $v0: return 2 if name $sp: return 29 if name $ra: return 31 if name.startswith($a) and name[2:].isdigit(): return 4 int(name[2:]) # $a0-$a3 - 4-7 if name.startswith($t) and name[2:].isdigit(): n int(name[2:]) return 8 n if n 7 else 24 (n - 8) # $t0-$t7 - 8-15, $t8-$t9 - 24-25 if name.startswith($s) and name[2:].isdigit(): return 16 int(name[2:]) # $s0-$s7 - 16-23 if name.startswith($k) and name[2:].isdigit(): return 26 int(name[2:]) # $k0-$k1 - 26-27 raise ValueError(unknown register: %s % name)注意 $t0-$t7 映射到 8 到 15$t8 和 $t9 映射到 24 和 25这是 MIPS 约定里最容易记错的一段很多人把 $t8 写成 16编码出来整条指令错位。解析顺序也有讲究固定名 $zero、$v0、$sp、$ra 要放在前面带数字前缀的名字放在后面这样既保证固定名不被误匹配也能支持像 $0、$1 这样的绝对编号写法。if 语句的判断顺序一旦反了$t8 会被错误匹配进 $t 前缀分支的 8816直接踩坑。接下来是 R 型编码。R 型指令结构固定直接按位填入即可。这里特别处理 sll、srl 和 jr 三种特殊传参方式避免和普通三操作数指令混在一起。R_FUNCT { add: 0x20, sub: 0x22, and: 0x24, or: 0x25, slt: 0x2a, sll: 0x00, srl: 0x02, jr: 0x08 } def encode_r(mnemonic, args): if mnemonic in (sll, srl): rd, rt, shamt args rs 0 elif mnemonic jr: rs reg_num(args[0]) rt rd shamt 0 else: rd, rs, rt args shamt 0 op 0 func R_FUNCT[mnemonic] return (op 26) | (reg_num(rs) 21) | (reg_num(rt) 16) | \ (reg_num(rd) 11) | (int(shamt) 6) | func逻辑说明普通 R 型指令是add $rd, $rs, $rt所以第 25 行参数直接按 rd、rs、rt 解析。sll 语法是sll $rd, $rt, shamt源寄存器在中间这个顺序容易写反。jr 只有一个源寄存器rs 字段填目标寄存器其余字段全部清零。shamt 是 5 位无符号量范围 0 到 31编码前最好单独检查一次超出后直接报错而不是让位运算悄悄截断。参数说明所有寄存器名统一通过 reg_num 转换。这里用 Python 的位或构造 32 位整数最终输出的 int 数值可以直接按 32 位十六进制打印。funct 字段只有 6 位R_FUNCT 里的值都在 0x00~0x3f 之间不会溢出。编码函数不直接处理伪指令伪指令在第 3 章的 pass_two 阶段已经展开成了真实指令。4.2 I型指令与立即数符号扩展负数不能直接拼接I 型指令编码看起来比 R 型简单难点全在立即数上。addiu 的立即数是有符号 16 位范围 -32768 到 32767lw 和 sw 的偏移也是按有符号处理的经常会写负数。Python 与 C 语言不一样整数没有固定位宽-1 在 Python 里直接参与位运算时不会自动截断成 16 位的 0xffff而会带着前面的符号位污染 opcode 区域。所以必须在写字段前统一做一次掩码处理。def slice_imm(value): # 允许带符号负数和无符号正数统一截断到低16位 if value -32768 or value 65535: raise ValueError(immediate out of 16-bit range: %d % value) return value 0xffff def encode_i(opcode, rs, rt, imm): return (opcode 26) | (reg_num(rs) 21) | (reg_num(rt) 16) | slice_imm(imm)逻辑说明slice_imm 的输入范围放宽到 -32768 到 65535覆盖有符号负数和无符号 16 位数两种情况。它只做低 16 位截断代表硬件的真实位宽行为无论这条指令的立即数在语义上是有符号还是无符号最终写进指令的都是同一份 16 位二进制。lw 的 opcode 是 0x23sw 是 0x2bbeq 是 0x04bne 是 0x05这些值都从 I_OPCODE 表里查不在编码函数里写死。参数说明lw 和 sw 的操作数常常写成lw $t0, 8($sp)这种带括号的形式。解析时需要先把括号内的寄存器名拆出来再解析括号外的偏移量。我第一次写的时候直接用字符串替换去处理括号结果遇到-4($sp)这种负偏移时整个切分逻辑全乱套。后来改成先用正则把imm($reg)拆成两个 token再分别传给 encode_i问题才稳定下来。建议在做词法切分时就把括号记法统一处理成独立的操作数而不是等编码时临时补救。4.3 分支跳转与标签解析偏移量到底为什么要除以4分支指令是汇编器里最容易出错的地方核心问题在偏移量的计算基准。MIPS 的分支偏移量 imm 字段存的是“从下一条指令开始数目标指令相隔几条指令”也就是说如果当前指令地址是 0x00400000下一条是 0x00400004目标是 0x00400010那偏移量就是 (0x00400010 - 0x00400004) / 4 3。这个“当前地址 4”的基准就是 MIPS 流水线里 PC 在取指阶段先加 4 的结果。def encode_branch(target_label, rs, rt, current_addr, symbols): if target_label not in symbols: raise KeyError(unknown label: %s % target_label) offset (symbols[target_label] - current_addr - 4) // 4 if offset -32768 or offset 32767: raise ValueError(branch offset out of range: %d % offset) return encode_i(0x04, rs, rt, offset) # 0x04 是 beq 的 opcode逻辑说明分母的 4 是最容易漏掉的点把字节差值直接写进 imm会导致跳转到 4 倍远的位置。符号表里存的目标地址是绝对地址current_addr 是当前分支指令地址两者相减后先减 4 再除以 4得到的就是指令条数差。理想情况下这个除法应该整除如果出现余数说明符号表地址和 current_addr 不是按 4 字节对齐这是更早的地址计算出了问题要立即停下来排查。参数说明bne 只是 opcode 不同编码逻辑完全一样所以 encode_branch 接收 opcode 参数或者拆成两个包装函数都可以。j 和 jal 走 J 型编码不需要计算相对偏移直接取目标地址的低 26 位左移两位即可。理论上有 4GB 地址空间时J 型指令只能覆盖当前 PC 所在 256MB 区域但校内实验程序通常很小这个限制很少触发。如果触发汇编器要给出明确提示而不是静默截断目标地址。4.4 伪指令展开与整体拼装一个可运行的main入口把上面的函数拼起来还需要一个入口。我习惯把整个汇编流程封装成一个 assemble 函数输入汇编文本输出机器码列表。这样方便被测试脚本反复调用也方便后面做差分验证。展开伪指令时li 会根据立即数大小决定返回一条还是两条真实指令la 会从符号表查出标签地址后再拆高、低 16 位。def expand_pseudo(mnemonic, args): if mnemonic move: return [(add, [args[0], args[1], $zero])] if mnemonic li: imm int(args[1]) if -32768 imm 32767: return [(addiu, [args[0], $zero, str(imm)])] high (imm 16) 0xffff low imm 0xffff return [(lui, [args[0], str(high)]), (ori, [args[0], args[0], str(low)])] if mnemonic la: label args[1] value symbols.get(label) # 需要从外部传入符号表 high (value 16) 0xffff low value 0xffff return [(lui, [args[0], str(high)]), (addiu, [args[0], args[0], str(low)])] return [(mnemonic, args)]逻辑说明move 展开成一条 addli 是小立即数时展开成 addiu大立即数时展开成 lui ori。注意大立即数场景里如果 imm 是负数比如 -1它的二进制是 0xffffffff高 16 位 0xffff低 16 位 0xffff展开成 lui ori 也能得到正确结果。这里的 high 和 low 都是无符号 16 位值直接转成字符串传给后面的 encode_i不会再做符号扩展避免过程推理错误。参数说明la 依赖符号表所以 expand_pseudo 需要能访问 symbols可以在组装函数里把它作为参数传入。伪指令展开后assemble 主流程会遍历展开结果逐条调用 encode_instruction并把 pc 按 4 递增。这一节看起来是收尾其实是整个汇编器里最容易产生隐蔽 bug 的地方因为展开后地址重排一旦出错后续所有指令地址都会错位。我在早期版本里就吃过这上面的亏li 展开成两条指令后忘了把 pc 加 8导致后面所有机器码都占到了别人的地址上。5. MIPS汇编器避坑从段地址到延迟槽的7个细节写汇编器最大的感受就是它不怎么崩溃但会在模拟器里跑出各种莫名其妙的结果。下面这 7 个细节几乎每个人都会踩我按“现象 → 原因 → 解决”三步写方便你对照排错。这些问题覆盖了解析顺序、立即数边界、地址计算、流水线语义和数据对齐属于 MIPS 汇编器里最典型的坑。5.1 标签与寄存器的解析顺序先查寄存器还是先查符号表现象代码里明明定义了标签 loop汇编器却报 unknown label或者把标签名当成非法立即数处理。原因token 解析顺序没有设定解析器先尝试 int() 转换再查寄存器别名表最后才查符号表一旦输入是纯字母字符串前面的分支全部误判最终把标签名扔掉。解决所有操作数按固定顺序解析先查寄存器别名表再查符号表最后才尝试 int()。这个顺序定死以后任何标识符都不会因为落在不同的解析分支而产生二义性。5.2 立即数范围检查不要等到编码完才发现溢出现象addiu $t0, $zero, 70000 编译时不报错机器码输出也正常但模拟器里算出来的值完全不对。原因70000 超过了 16 位有符号立即数的上限 32767高位被直接截断数值凭空丢失。解决在编码函数入口统一做范围检查有符号指令检查 -32768~32767无符号指令检查 0~65535超界就抛出带行号的异常。这个检查应该放在 slice_imm 之前而不是让截断逻辑静默处理一切输入否则错误会被拖到模拟阶段才暴露。5.3 分支偏移的“基址4”陷阱差 4 字节就全都偏了现象beq 后面紧跟一条指令预期跳转到这条紧邻指令结果总跳到前一条或者上上条。原因分支偏移量的基准是 PC4不是当前指令地址。很多人写成“目标地址减当前地址”少减了 4 字节再除以 4 后整体偏出一条指令。解决统一用公式(target_addr - current_addr - 4) // 4计算偏移量。我还会在写完偏移后加一个反向断言offset * 4 current_addr 4 target_addr这一行能拦住绝大多数手滑成本极低。5.4 延迟槽处理模拟器不说你不用管真实CPU上就得管现象同一份汇编代码在课程用的教学模拟器里运行正确拿到真实流水线实验板上却多执行了一条本不该执行的分支后的指令。原因MIPS 硬件设计里分支指令后紧接的那条指令始终会被执行这就是分支延迟槽。部分教学模拟器默认关闭延迟槽所以表现不出这条语义差异。解决先确认目标平台是否复用延迟槽。如果复用汇编器要在每条分支后面自动插入 nop或者提示程序员在分支后手动写 nop如果模拟器已关闭延迟槽就不必处理。5.5 符号扩展的坑负数立即数编码后为什么会变成 0xffffffff现象手算 lw $t0, -4($sp) 的机器码怎么对都对不上模拟器里的表现也像寄存器被写入了错误数据。原因lw 偏移是 16 位有符号数-4 要按补码存成 0xfffc。Python 里 -4 参与位运算时不会自动截断成 16 位而是以 32 位负数的形式存在跟 opcode 位移后拼接时直接污染高位。解决所有立即数在写入字段前统一做imm 0xffff把负数变成无符号 16 位补码。验证阶段再把 16 位按符号位扩展回来与原输入比较确认没有丢失信息。5.6 数据段对齐.byte 和 .word 交替时地址错乱现象先用 .byte 定义字符再用 .word 定义数组la 取数组首地址后读出来的前几个字全部错位。原因数据段没有强制对齐。MIPS 的 .word 要求 4 字节对齐.half 要求 2 字节对齐如果前面 .byte 占了两个字节.word 就落到了非对齐地址上有些模拟器直接读出错误数据。解决汇编器在遇到 .word 前检查当前偏移不是 4 的倍数就自动补零或报错也可以要求程序员用 .align 2 显式对齐汇编器只做检查。自动补零更友好但会改变数据布局需要同时调整符号表里的后续地址。5.7 大小写、逗号与注释预处理不干净会拖垮整个词法分析现象同一份代码里混用了 ADD 和 add编译出来有的指令缺失某行注释末尾多了个分号结果被当成非法字符。原因MIPS 惯例区分大小写但练习代码经常大小写混写不同编辑器里注释符号也可能是 # 或 ;单按 # 处理就会出现额外 token。解决在预处理里把 # 和 ; 都当作注释起始符助记符统一转成小写再查表操作数统一按逗号和空白切分。这里还要警惕字符串字面量里的 #所以不能用简单的行内 split而要用能感知引号的分词函数先处理一遍。6. 验证MIPSAssembler用差分测试和手写机器码确认正确性在把汇编器用于 CPU 实验之前得先证明它输出的机器码是对的。我习惯做三层验证难度递增覆盖也递增。第一层是手算黄金样例挑三条有代表性的指令一条 R 型、一条 I 型、一条分支指令人工按位算出 32 位机器码写进测试用例。每次改动编码器之后先跑一遍这批样例能抓住绝大多数回归问题。def golden_test(): cases [ (add $t0, $s1, $s2, 0x02324020), # 手算 R 型样例 (addiu $t0, $zero, 100, 0x24080064), # 手算 I 型样例 (beq $t0, $t1, label\nlabel:, 0x11090001), # 分支偏移为1 ] for asm_text, expect in cases: got assemble(asm_text) assert got[0][1] expect, \ %s %08x, expect %08x % (asm_text, got[0][1], expect)第二层是参照汇编器差分测试。找一款课程里常用的 MIPS 教学模拟器把同一份汇编代码放进去汇编导出机器码自己的汇编器输出同一份按行 diff。这一层可以一口气覆盖几十条指令包括伪指令展开后的编码。第三层是随机差分测试写脚本生成合法指令序列喂给两个汇编器逐条对比。这个测试的价值在于覆盖手写样例没考虑到的边界比如立即数取 -32768、32767标签紧贴分支指令的上下边界。随机生成指令时要用预定义模板而不是完全随机拼 token否则生成的文本多半语法非法测不到编码逻辑。分支偏移连续翻车是我做这个项目时最深的教训后来养成一个习惯每改完编码逻辑先跑黄金样例再跑随机差分最后才动新功能。汇编器是那种“错一个位就全错”的项目靠人工排查十六进制效率极低把验证自动化才是真正省时间的做法。上面的三层测试架子搭好以后我后面加指令就再没慌过。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑