☰
手写MIPS汇编器:指令编码、伪指令展开与两遍扫描实战
2026/10/12 4:22:42 网站建设 项目流程

简介:一份基于 Python 实现的 MIPS 汇编器,面向计算机系统底层学习者、汇编语言初学者及嵌入式开发入门者,解决将 MIPS 指令集架构下的汇编代码转换为二进制机器码的问题,帮助理解 RISC 架构和汇编与机器码的对应关系。压缩包共 4 个文件,大小仅 5KB,包含 Python 实现脚本、MIPS 汇编源文件(.asm)、转换输出文本(.txt)和 README 说明文档,涵盖输入样例、运行结果与使用说明,结构紧凑且可直接运行。已有 194 人学习。借助这个工具,读者可以结合 add、sub、beq 等典型指令,以及 32 个通用寄存器、.data/.text 段划分、伪指令和函数调用约定等关键知识点进行实践,在命令行中完成从汇编到二进制的转换并观察结果,适合作为课程设计、实验教学或自学 MIPS 编程的辅助工具。资源体积虽小,但完整演示了汇编器的工作流程,能帮助初学者快速建立对底层系统、编译过程和计算机组成原理的直观认识,为后续学习操作系统、编译原理等高级主题奠定基础。

1. MIPSAssembler 解决的核心痛点:手工编码效率太低,伪指令终于有了解释器

如果你第一次在某个 CPU 模拟器上写 MIPS 汇编语言的汇编器,最大的挫败感往往不是语法,而是你刚写完一段看起来毫无问题的代码,汇编结果却跟手抄的机器码完全对不上。MIPSAssembler 这类工具干的事,就是把add $t0, $t1, $t2这种文本变成0x012A4020这样的 32 位编码,同时负责标签地址计算、立即数符号扩展、伪指令展开等琐碎但绝不能错的细节。

这个方向适合谁?一是正在做计算机组成原理课设、需要把汇编程序手工转成机器码的从业者或学生;二是在自制指令集模拟器、五级流水线处理器项目里找一套可复用汇编器的开发者;三是想理解编译前端里“词法分析 + 符号表 + 代码生成”这一段最小工作量的工程人员。本文后面会按“编码规则 → 汇编流程 → 踩坑记录 → 验证方法”的顺序,把 MIPSAssembler 从零到能用的关键路径讲清楚,你可以直接照着复现,也能拿来当自己项目的最小骨架。

2. 先读懂 MIPS 指令编码:R/I/J 型格式与伪指令展开规则

2.1 R 型指令编码:opcode、rs、rt、rd 与 shamt 的比特位分配

MIPS 指令集有一个和 x86 完全不同的特点:所有指令长度固定 32 位,CPU 不需要像解码复杂指令那样逐字节猜长度。这个固定长度的设计,让汇编器每条指令的编码规则都可以抽象成一张表。R 型指令是其中最简单的一类,典型代表是算术运算和逻辑运算。

R 型指令的 32 位布局是:[31:26]是 opcode,[25:21]是源寄存器 rs,[20:16]是源寄存器 rt,[15:11]是目的寄存器 rd,[10:6]是移位量 shamt,[5:0]是功能码 funct。像add、sub、and、or、sll这一类指令,opcode 全部是 0,真正区分操作的是 funct 字段。这意味着汇编器在识别指令时,只需要查一个指令表,就能从“汇编助记符 + 操作数类型”跳到“opcode + funct”的组合。

我一般会在代码里先放一个寄存器编码映射表,把 32 个通用寄存器的名字转成 5 位编号。这一步看似多余,但能省掉后面大量 if-else。

REG_MAP = { '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 } def encode_r(opcode, rs, rt, rd, shamt, funct): # opcode 虽然对 R 型来说恒为 0,但保留参数能让 encode_r 同时兼容其他类型 return ((opcode & 0x3F) << 26) | \ ((rs & 0x1F) << 21) | \ ((rt & 0x1F) << 16) | \ ((rd & 0x1F) << 11) | \ ((shamt & 0x1F) << 6) | \ (funct & 0x3F)

这里把每个字段都做了掩码处理后移位,最终用按位或拼成一个 32 位整数。参数说明:opcode占 6 位,所以& 0x3F只保留低 6 位;rs/rt/rd/shamt各占 5 位,用& 0x1F截断;funct也是 6 位。移位时注意rs左移 21 位后落在[25:21]区间,funct不需要移位,直接落在低 6 位。

如果要用这条函数编码add $t0, $t1, $t2,那么 rs 对应$t1是 9,rt 对应$t2是 10,rd 对应$t0是 8,funct 是 0x20。最终拼出来的结果是0x012A4020。你在纸上拆一下:000000 01001 01010 01000 00000 100000,正好是 R 型加法的标准格式。理解这个拆位过程,后面调汇编器时才不会被一个错误的 hex 值带偏。

2.2 I 型和 J 型指令:立即数符号扩展和跳转偏移量的真实计算方式

R 型之外,I 型指令是使用频率更高的类型。它的布局是[31:26]opcode,[25:21]rs,[20:16]rt,[15:0]16 位立即数。典型指令有addi、addiu、lw、sw、beq、bne。这些指令的立即数分为两类:算术立即数被当作有符号数做符号扩展,而lw/sw里的偏移量同样要符号扩展到 32 位后与基地址寄存器相加。

这里最容易踩坑的是分支指令的偏移计算。MIPS 里beq $t0, $t1, label的机器码公式是:

offset = (label_addr - (branch_pc + 4)) / 4

branch_pc + 4是分支指令的下一条指令地址,也就是延迟槽的地址。CPU 执行分支时会先用branch_pc + 4取出下一条指令执行,再用这个偏移量算出真正的跳转目标。偏移量需要除以 4,因为 MIPS 指令按 4 字节对齐,16 位字段里存的是“指令条数”而不是字节数。

我在汇编器里会专门写一个encode_branch函数,把标签地址解析后的绝对地址换算成相对偏移:

def encode_i(opcode, rs, rt, imm): # imm 只占 16 位,这里先不做范围检查,后面章节专门处理越界 return ((opcode & 0x3F) << 26) | \ ((rs & 0x1F) << 21) | \ ((rt & 0x1F) << 16) | \ (imm & 0xFFFF) def encode_branch(opcode, rs, rt, label_addr, branch_pc): # 关键:以 branch_pc + 4 为基准,偏移量按指令条数计算 offset = (label_addr - (branch_pc + 4)) >> 2 return encode_i(opcode, rs, rt, offset)

encode_branch里减去的branch_pc + 4,是所有 MIPS 汇编器都必须遵守的规则。如果你在交叉验证时发现自己的汇编结果比参考结果多了 4 个字节,多半就是这里出了问题。

J 型指令只有j和jal两种,格式是[31:26]opcode 加[25:0]26 位目标地址字段。这个 26 位字段不直接存地址,而是存“目标地址右移 2 位”后的值。CPU 实际跳转地址是((pc + 4) & 0xF0000000) | (target_index << 2),也就是保留当前指令 4KB 对齐后的高 4 位,再拼接上 26 位索引左移 2 位的低 28 位。编码时要注意目标地址的高 4 位必须和当前指令一致,否则跳转会落到别的内存区域。

2.3 伪指令展开逻辑:li、la、mul、blt 如何变成一条或多条真指令

MIPS 汇编器不是简单做“助记符到机器码”的翻译,它还要处理伪指令。伪指令是汇编器提供的便捷语法,CPU 本身不认识它们。比如li $t0, 0x12345678这条指令,在标准 MIPS 指令集里根本没有对应的 opcode,汇编器必须把它展开成两条真指令才能让 CPU 执行。

li的大立即数常规展开方式是:

li $t0, 0x12345678

等价于:

lui $t0, 0x1234 ori $t0, $t0, 0x5678

lui把高 16 位加载到寄存器的高 16 位,同时把低 16 位置零,接着ori把低 16 位填充进去。这样一条伪指令就变成了两条真指令,指令地址计数器也要相应加 8 而不是加 4。

其他常见伪指令展开规则我整理在下面这张表里,做指令表匹配时可以直接参考:

伪指令展开结果展开后指令数
la $t0, labellui $t0, label_hi+addiu $t0, $t0, label_lo2
li $t0, imm(16 位内)addiu $t0, $zero, imm或ori $t0, $zero, imm1
li $t0, imm(32 位)lui $t0, high16+ori $t0, $t0, low162
mul $t0, $t1, $t2mult $t1, $t2+mflo $t02
blt $t0, $t1, labelslt $at, $t0, $t1+bne $at, $zero, label2
bge $t0, $t1, labelslt $at, $t0, $t1+beq $at, $zero, label2
nopsll $zero, $zero, 01

上面blt和bge展开时用到了$at寄存器。$at是汇编器保留的临时寄存器,用户在写汇编时不建议直接使用它,因为伪指令展开随时可能覆盖它的值。mul展开成mult加mflo,也是经典 MIPS 体系下的标准做法,如果你的目标是 MIPS32 的某个具体实现,需要先确认目标 CPU 是否提供了真正的mul指令,避免展开规则和硬件不匹配。

3. 搭建 MIPSAssembler 的完整流程:两遍扫描、符号表与二进制输出

3.1 词法分析与指令表匹配:每条指令的模板就是一部字典

汇编器的第一段输入处理,是把.asm文件里的原始文本拆成可识别的 token。MIPS 汇编语句有比较强的结构:每行要么是标签定义,要么是指令,要么是伪操作,比如.data、.text、.asciiz。词法分析要做的,是把行首的标签、行尾的注释、操作数和逗号分隔符都识别出来。

我常用的做法是先把分号注释全部剥掉,再按行划分语句。MIPS 的注释通常以#开头,可以出现在行中间,所以剥离注释要放在分词之前。接下来把每一行用逗号、空格、制表符拆成若干个 token,然后判断第一个 token 末尾有没有冒号,有冒号就说明这是一个标签定义。

指令匹配阶段,我习惯维护一个指令模板字典,而不是写一整片 if-else。字典的 key 是助记符,value 是一个解析函数,这个函数负责把操作数列表转成编码字段:

def parse_add(args): # args 形如 ['$t0', '$t1', '$t2'] rd = REG_MAP[args[0].lstrip('$')] rs = REG_MAP[args[1].lstrip('$')] rt = REG_MAP[args[2].lstrip('$')] return encode_r(0, rs, rt, rd, 0, 0x20) INSTR_TABLE = { 'add': parse_add, 'addi': parse_addi, 'lw': parse_lw, 'sw': parse_sw, 'beq': parse_beq, 'j': parse_j, # 伪指令也要登记到同一张表里 'li': parse_li, 'la': parse_la, 'blt': parse_blt, }

这样设计的好处是,每条指令的解析逻辑被收敛进一个函数,后续要支持新指令只需要往 INSTR_TABLE 里加一项。参数说明:REG_MAP[args[0].lstrip('$')]里的lstrip('$')是为了去掉寄存器名的$前缀,MIPS 汇编器对寄存器大小写不敏感,这里统一转小写后查表会更稳。

模板字典里,伪指令和真指令需要分开标记。真指令在编码阶段直接产生一条 32 位指令,伪指令产生的则是一个长度大于 1 的指令列表。这一步做不好,会在后面第一遍扫描时造成地址漂移,第 4 章会专门讲这个坑。

3.2 第一遍扫描:收集标签地址并计算指令长度

汇编器和解释器的核心区别在于:汇编器不能一边解析一边生成机器码,因为指令里可能引用一个还没有定义到的标签。比如代码中先出现beq $t0, $zero, end,然后过 10 行才出现end: add $t1, $t2, $t3。只有等把所有标签的位置都算清楚,才能回头填beq的偏移量。所以 MIPSAssembler 必须做两遍扫描。

第一遍扫描的任务有两个:一是给每一条指令分配地址,二是把标签名和对应地址记录到符号表里。这里的地址不是虚拟地址,而是从程序起始地址开始逐步累加得到的。

def first_pass(lines): symbols = {} next_sym = None # 当前最近的全局标签,用于局部符号作用域 pc = text_addr # text_addr 通常是 0x00400000 for line in lines: tokens = tokenize(line) if not tokens: continue if tokens[0].endswith(':'): label = tokens[0][:-1] symbols[label] = pc next_sym = label tokens = tokens[1:] if not tokens: continue op = tokens[0] length = instruction_length(op, tokens[1:]) pc += length return symbols

instruction_length是关键函数。对真指令返回 4,对伪指令返回展开后的指令条数乘以 4。比如li $t0, 0x12345678返回 8,blt返回 8,nop返回 4。这个长度函数必须和第二遍扫描里的展开逻辑完全一致,否则第一遍算出来的标签地址就是错的。

我在第一遍扫描里还会额外记录每个标签的下一级符号前缀。MIPS 的局部标签机制允许同一个源码里出现多个重名的loop,但它们的实际符号名会被加上最近一个全局标签作为前缀。第一遍扫描时,每遇到一个新的全局标签,就把current_scope更新成这个标签名,后面遇到的局部标签重命名成前一个全局标签 + 局部标签,这样既满足地址计算,也避免了符号表冲突。

3.3 第二遍扫描:生成机器码、检测越界并输出 hex 文件

第二遍扫描是在符号表建立完成之后执行的,这时所有标签都有了确定地址。第二遍的目的不是再算一遍地址,而是把每条指令的操作数里的符号引用替换成实际值,然后拼出机器码。遍历的方式和第一遍基本一样,但要额外处理一个状态:记录当前指令的地址,用来计算分支偏移量和跳转地址。

def second_pass(lines, symbols): output = [] pc = text_addr for line in lines: tokens = tokenize(line) if not tokens: continue if tokens[0].endswith(':'): tokens = tokens[1:] if not tokens: continue op = tokens[0] if op in PSEUDO_TABLE: # 伪指令展开成多条真指令,每条都有独立地址 for real_op, args in PSEUDO_TABLE[op].expand(tokens[1:]): inst = assemble_one(pc, real_op, args, symbols) output.append((pc, inst)) pc += 4 else: inst = assemble_one(pc, op, tokens[1:], symbols) output.append((pc, inst)) pc += 4 return output

assemble_one函数内部会把beq的标签解析成偏移量,把j的标签解析成 26 位索引,把lw/sw的常量表达式计算成 16 位偏移。同时它负责越界检测:分支偏移超过-32768到32767的范围、跳转目标的高 4 位和当前 PC 不一致、立即数超过 16 位能表示的范围等,都要在这一步报错。

输出格式我一般选两种:一种是每行输出地址: 机器码的文本文件,方便在模拟器里逐条加载;另一种是直接用 Python 的array模块写成二进制.bin文件,供指令 RAM 初始化使用。文本格式更适合调试,二进制格式更适合接入仿真环境。

输出之前还有一步容易漏掉:检查.align伪操作和段地址边界。MIPS 要求大部分指令按 4 字节对齐,如果上一个.asciiz数据段的长度不是 4 的倍数,后续指令地址就会错位。这个问题在课设里不常见,但一旦遇到,查起来会非常费眼神。

4. MIPSAssembler 的避坑实战:偏移量、延迟槽与符号表冲突的 5 个教训

4.1 分支跳转偏移量算错:现象是跳去了错误地址,原因是忘 PC+4

现象:在一个无延迟槽的模拟器上跑beq,程序跳去了目标标签之前的一条指令,单步仿真的每一处跳转都偏差一个指令长度。

原因:这是刚写汇编器时最容易犯的错误。很多初版实现会直接用label_addr - branch_pc计算偏移量,再除以 4,得到的结果比正确值大 1。MIPS 硬件约定分支偏移量的基准地址是branch_pc + 4,也就是分支指令的延迟槽地址,不是分支指令本身地址。无延迟槽的模拟器也沿用了这一套公式,所以不能按直觉去减branch_pc。

解决:统一在分支编码函数里使用label_addr - (branch_pc + 4)作为分子。为了不再犯,我把这个公式写成了注释贴在函数头部,同时写了一个针对分支指令的测试用例:beq $zero, $zero, next,nop,next: nop,如果next地址不是branch_pc + 8,就说明偏移量算错了。

4.2 伪指令展开后地址漂移:现象是标签对不上,原因是第一遍长度估计不全

现象:一段程序汇编后,符号表里label的值和它在反汇编输出里的位置对不上,连带着所有引用这个标签的分支指令通通跳歪。

原因:第一遍扫描和第二遍扫描对伪指令的长度计算不一致。我最初为了省事,在第一遍扫描里把所有li都当成单条指令,地址只加 4;第二遍扫描却把它展开成了lui + ori两条指令,地址加了 8。结果第二遍扫描真正写入的机器码从第三个标签开始就和符号表错位。这类问题相当隐蔽,因为汇编器不会报任何错误。

解决:抽出一个instruction_length(op, args)函数,让第一遍和第二遍都调用它。第一遍用它累加地址,第二遍用它计算展开后占用的字数。同时把伪指令的展开规则做成一张固定表,绝对不要在第一遍和第二遍之间玩“优化”读取长度。如果哪天改了展开规则,务必同步改长度函数,并重新跑全部测试。

4.3 重名标签与符号覆盖:现象是链接时报多重定义,原因是作用域没做隔离

现象:在有两个函数func_a和func_b的汇编代码里,两者各有一个loop局部标签,汇编器不报错,但loop被后定义的func_b里的标签覆盖,导致func_a的循环跳转异常。

原因:第一版符号表只有一个dict,键是标签名,值是对应地址。连续扫描时,后写的symbols['loop'] = addr自然会覆盖先写的。汇编器如果不做作用域管理,只能发现不了这类错误。

解决:把局部标签重命名为当前全局标签 + 分割符 + 局部标签名。比如func_a下的loop实际存成func_a$loop。这样字典的键不会冲突。遇到同名全局标签则要主动抛错,不能静默覆盖。实现时我在第一遍扫描里维护一个current_global_label变量,每当扫到一个标签定义时,先判断它是全局标签还是局部标签,再决定是否加前缀。

4.4 立即数超出 16 位:现象是汇编器静默截断,原因是没有做范围检查

现象:addiu $t0, $t1, 0x12345汇编通过,但在模拟器里$t0的结果是一只一个想象不到的值。把机器码反推回源代码时,发现立即数被截成了0x2345。

原因:I 型指令的立即数字段只有 16 位,encode_i函数里imm & 0xFFFF这一步会把这个数字的低 16 位直接留下,高 16 位悄无声息地丢弃。汇编器如果不检查范围,用户写错大立即数时得不到任何提醒。

解决:在编码前做范围检查。对于addiu、ori这类算术逻辑立即数,检查是否在-32768到65535范围,超出就报错;对于beq、bne分支偏移量,检查它除以 4 后在 16 位有符号范围内。另外,对于大的li,汇编器应该自动选择lui + ori展开,而不是直接截断。我的规则是:li的立即数不超过 16 位就展开成一条,否则展开成lui + ori两条。

4.5 数据段和文本段地址混淆:现象是 lw 加载到奇怪的值,原因是地址空间布局没算对

现象:.data段里定义了一个数组,紧跟在.text段之后,汇编器报错或者lw读到的数据和预期不符。

原因:一些简化实现把数据段和代码段放在同一个线性地址空间里,从0x00400000连续往下排。这样标签地址的计算虽然简单,但指令缓存和数据缓存访问同一块内存区域,仿真时容易互相干扰。更重要的是,标准 MIPS 程序布局里.text起始地址通常是0x00400000,.data起始地址通常是0x10010000,两者之间有明确的区域划分。

解决:在汇编器初始化时分别定义TEXT_BASE = 0x00400000和DATA_BASE = 0x10010000。第一遍扫描时,遇到.text伪操作就把当前地址切片切换到TEXT_BASE,遇到.data就切换到DATA_BASE。符号表记录标签时,标签会关联到各自所在段的基础地址。两段之间距离过近时还要考虑.align对齐。这样一个风险是,数据段里出现分支跳转到代码段的错误会被延迟到仿真阶段才发现,所以我在地址切换时额外加了一个警告:如果beq的目标落在了.data地址区间,直接报错。

5. 把 MIPSAssembler 用到极致:多文件符号解析与差分验证技巧

5.1 多文件符号解析:global 与 extern 的可见性控制

单文件汇编器把标签全部当作模块内符号,已经能覆盖大部分课设需求。一旦程序拆成多个.s文件,就需要引入global和extern这一类符号可见性概念。我的做法是给符号表每一项增加一个exported标志:默认情况下,所有标签只在本文件可见;只有标注了.globl的标签才会被写入导出符号表。extern进来的符号则单独放在外部符号表里,不在本文件内定义,汇编时如果发现定义重复就报错。

多文件汇编后输出的不是纯机器码,而是一个带重定位信息的对象文件。每条引用外部标签的指令,都要记录下指令地址、指令类型和外部符号名。后续由链接器把这些外部符号解析成具体地址,再回填到指令的立即数字段。这一步实现起来比单文件复杂,但如果你已经完成了符号表和两遍扫描,重定位表无非是另一个追加的结构。

5.2 差分验证:用自写汇编器和参考汇编器对拍

写汇编器最怕的,不是报错,而是不报错却生成了错误机器码。靠人工逐条对指令非常枯燥,也容易漏掉边界情况。我比较推荐做差分验证:准备一组覆盖所有指令和伪指令的测试汇编文件,分别用自写 MIPSAssembler 和一个可信的参考汇编器编译,然后逐字节比较输出。

比较逻辑不需要太复杂,一个 Python 脚本就能做:

def compare_outputs(source_file, self_bin, ref_bin): self_data = open(self_bin, 'rb').read() ref_data = open(ref_bin, 'rb').read() length = min(len(self_data), len(ref_data)) for i in range(length): if self_data[i] != ref_data[i]: print(f'offset 0x{i:04X}: self=0x{self_data[i]:02X}, ref=0x{ref_data[i]:02X}') return False return True

差分测试的意义不在于第一步就能通过,而在于它能用最小成本暴露出编码规则上的细节差异。我第一次跑对拍时,十个测试里七八个失败,基本都是分支偏移计算和伪指令长度的问题。把每次差异对应到源码里的某一行,再修正汇编器,比对着反汇编结果逐行猜效率高得多。

5.3 用一段自举代码检验汇编器是否已经可用

差分验证能证明你的 MIPSAssembler 和参考工具等价,但如果你想验证的不只是编码结果,而是整套编译链路是否真实可用,我建议额外写一段自举程序来闭环测试。所谓自举,指的是用你自己的汇编器编译一段能输出结果的汇编代码,放到目标模拟器里运行,再检查输出。

我常用来闭环的测试程序是这段:计算从 1 加到 10 的累加和,把结果写到约定地址,然后通过系统调用把值打印出来。代码里强制混用了真指令、伪指令、分支跳转和数据段,能覆盖掉前面踩坑的大多数点。汇编器把这个程序编译成.bin,模拟器加载后如果能看到累加结果 55,整个链路才算真的跑通。

这段验证需要确认的事情有三件:第一,li加载 10 和累加和的立即数展开正确;第二,循环底部的分支跳转偏移量计算正确;第三,数据段里保存结果的内存地址没有覆盖到代码段。等到这段自举代码跑出预期结果,MIPSAssembler 才算是真正可用的工具,而不是一个只能编译单条指令的玩具。

我个人的习惯是,每次改动汇编器内部结构,比如修改指令表实现、调整伪指令展开规则、新增一个新的寄存器名称写法,第一件事就是把自举程序和差分测试重新跑一遍。两个测试都通过再继续写新功能,否则宁可停下来排查,也不带着隐患后发一个“看似能跑”的版本。做汇编器这一类贴近硬件工具链的东西,最靠谱的教训就是:不要相信“看起来对”,要让校验程序替你再一次交付出厂测试。希望这篇文章能帮你在 MIPSAssembler 这条路上少走几步弯路,把精力真正留给指令集本身那些有趣的设计细节上。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询