最近在做一个MIPS32处理器的项目第一步就是和指令集打交道。说实话最开始拿到MIPS32的资料满眼都是各种指令格式、操作码、功能码很容易看懵。但真正动手“手撸”一遍之后才发现指令集其实就是一张契约CPU和编译器、汇编器之间约定的二进制接口。你用汇编写一条addiuCPU能执行它模拟器也能执行它靠的就是这张契约。这篇博文就记录我在实现MIPS32过程中对指令集本身的理解和实操过程中的笔记。内容不算高深但足够基础适合正在做CPU设计、编译器后端、或者纯粹想搞懂“一条指令从文本变成CPU动作”的读者。我尽量把最核心的编码规则、格式细节和一堆容易踩的坑都写清楚。1. 为什么先啃指令集这是整颗CPU的“设计合同”做CPU设计至少有两种常见的路线一种是从需求出发先想清楚要跑什么程序再反推指令集另一种是直接选定一个现成指令集比如MIPS32、RISC-V然后照着指令集手册来实现微架构。我这次用的是第二种选择MIPS32的原因比较实际它指令长度固定为32位三种格式规整变长编码的复杂程度低非常适合第一颗亲手写出来的CPU。指令集在整个设计流程里地位类似于软件开发的接口文档。硬件模块比如ALU、寄存器堆、存储器接口只认二进制的操作码编译器只负责把高级语言翻译成汇编汇编器负责把汇编翻译成机器码。三方共同依赖的就是这张编码表。你如果在编码规则上理解错了比如把rs和rt搞反轻则结果算错重则取指乱套、CPU跑飞。MIPS32指令集最大的特点就是“规整”所有指令都是32位操作码opcode永远占据最高6位指令[31:26]剩下的26位再根据指令类型切分。这个固定长度的设计带来一个明显好处取指阶段不需要根据指令长度来动态计算下一条指令地址下一条永远在PC4不考虑延迟槽和跳转的情况下。对于流水线设计而言这省掉大量判断逻辑。但规整并不意味着简单真正让初学者头疼的是opcode只占高6位最多只能区分64种主指令后来指令越来越多MIPS引入了funct字段低6位做二次区分这也就是R型指令里opcode全是0的原因。所以你在译码阶段不能只看opcode还要看低6位的funct才能决定到底执行add还是sub。这套“先大类、再细分”的译码思路贯穿了整个CPU设计的译码模块。2. 三大指令格式拆解R型、I型、J型2.1 R型指令格式寄存器到寄存器的算术逻辑R型是MIPS里最符合直觉的格式因为它所有运算都在寄存器里完成不需要立即数也不需要访存。它的32位划分如下[31:26] opcode固定为0[25:21] rs第一个源操作数寄存器编号[20:16] rt第二个源操作数寄存器编号[15:11] rd目标寄存器编号[10:6] shamt移位量只有移位指令才使用[5:0] funct功能码决定具体执行什么操作举个例子add rd, rs, rt这条指令如果我要让寄存器$1和$2相加结果放入$3那么机器码是opcode(6) 000000 rs(5) 00001 rt(5) 00010 rd(5) 00011 shamt(5) 00000 funct(6) 100000拼起来就是0x00221820。我验证的时候一开始总是忍不住人工去拼这一串01后来发现直接用十六进制倒推更快00221820拆成6位一组就是000000 00001 00010 00011 00000 100000和上面的字段完全对应。熟悉这种十六进制和二进制之间的快速换算对调试CPU非常有用。R型指令里需要注意一点shamt字段只有移位指令才有效。比如sll逻辑左移指令它的shamt字段用于指定移动位数rs字段是空的固定为0。如果理解成所有R型指令都能用shamt那就错了。硬件实现时是否把shamt接入ALU完全取决于这条指令是不是移位指令。2.2 I型指令格式立即数运算、访存与分支I型指令是使用频率最高、隐藏细节也最多的格式。它的32位划分如下[31:26] opcode指令主操作码[25:21] rs基址寄存器或源操作数寄存器[20:16] rt目标寄存器或条件寄存器[15:0] immediate16位立即数I型指令覆盖了三大类功能立即数算术/逻辑运算、装载/存储访存指令、分支指令。同样是16位立即数在不同指令里的语义却完全不同这是最容易出错的地方。算术逻辑类的I型指令比如addiu rt, rs, immediate立即数是有符号扩展的。也就是说如果immediate的最高位是1那么它扩展成32位后高位全部是1。而逻辑类指令比如ori rt, rs, immediate立即数是零扩展的高位全部补0。这个差别在硬件实现上就是两条不同的数据通路一个做符号扩展一个做零扩展。我设计时曾经把addiu误写成零扩展结果用负数立即数做加法时结果完全不对。访存类的I型指令比如lw和sw它们的立即数表示偏移量rt是数据寄存器rs是基址寄存器。地址计算方式是(rs) sign_extend(immediate)最终访问的地址 寄存器内容加上符号扩展后的16位偏移。注意这里偏移量也是符号扩展的因为偏移量可以为负数用于访问基址之前的地址。分支类的I型指令比如beq和bne其中的立即数不是绝对地址也不表示字节偏移它是一个以指令为单位的相对偏移。实际分支目标地址的计算公式是分支目标地址 (PC 4) sign_extend(immediate 2)这里有两个关键细节第一是PC 4因为MIPS是流水线设计分支指令在执行时PC已经指向下下条指令了第二是立即数要先左移2位再扩展因为指令是4字节对齐的用指令数而不是字节数来表示偏移可以用更少的位数表达更远的跳转范围。2.3 J型指令格式段内跳转J型指令只有两种j跳转和jal跳转并链接用于无条件跳转。它的格式是[31:26] opcode[25:0] target26位目标地址J型指令的跳转目标地址计算比较特殊它是把PC4的高4位拼上26位target再补两个0跳转目标地址 {(PC4)[31:28], target, 2b00}也就是说J型指令只能在当前256MB对齐的区域内跳转因为高4位不变。跨到另一个256MB区域就得借助寄存器跳转比如jr。这个限制在小型处理器里通常无所谓毕竟程序一般很小但理解了它你就明白为什么MIPS还保留jr指令。三种格式的对比我整理如下类型高6位中段低6位典型指令R型opcode0rs, rt, rd, shamtfunctadd、sub、and、jrI型opcoders, rt, immediate-addiu、lw、beqJ型opcodetarget-j、jal2.4 设计译码器时的字段提取技巧在硬件译码器里我一般先用一段组合逻辑把rs、rt、rd、funct、immediate、target等字段统一提取出来再让它们走各自的通路。这样做的好处是译码逻辑只做一次字段切分后面的每个模块复用这些提取结果不用每个模块都做一遍位运算。例如wire [25:21] rs_field instr[25:21]; wire [20:16] rt_field instr[20:16]; wire [15:11] rd_field instr[15:11]; wire [5:0] funct_field instr[5:0]; wire [15:0] imm16 instr[15:0]; wire [25:0] target instr[25:0];字段提取本身不难真正要注意的是命名。有些人习惯把指令的第[20:16]位叫rt但如果某条指令比如lw的[20:16]位其实存放的是目标寄存器一旦命名固化下来代码读起来就会很别扭。我的习惯是按硬件功能命名把[20:16]统一叫rt_field不按指令语义改名。这样在写lw的逻辑时看到rt_field被当作写入目标会比较直观。3. 核心指令分类与逐条解析3.1 初定最小指令子集先跑通冒泡排序我非常不建议一上来就把整个MIPS32指令集的所有指令都实现一遍。很多指令是给编译器优化用的对第一版处理器来说需求很低。我第一版只选了约20条指令就足以运行经典教材里的冒泡排序。这个“最小可运行子集”是根据编译器的常见输出统计出来的。我的最小子集包含addiu、addu、subu、and、or、xor、nor、sll、srl、jr、jal、beq、bne、lui、lw、sw。这套组合覆盖了算术、逻辑、移位、跳转、分支、访存六大类功能能跑完排序算法也能写简单的控制流。初始最小子集不需要乘法除法因为第一版排序程序可以完全避开乘除。每条指令在指令表中的编码我建议用一个大列表维护而不是分散记忆。列一张表左边是汇编助记符中间是opcode/funct右边是类型和格式。比如指令opcodefunct类型16位字段语义addiu001001-I符号扩展立即数addu000000100001R无subu000000100011R无ori001101-I零扩展立即数lui001111-I立即数存入高16位beq000100-I分支偏移以指令为单位lw100011-I符号扩展偏移量sw101011-I符号扩展偏移量j000010-J段内跳转目标jal000011-J段内跳转并保存返回地址这张表是我做译码器的“地基”。每加一条新指令我都会先在这张表里找到它的确切编码再在硬件里加相应的控制信号。3.2 算术逻辑指令细节算术指令里addu和addiu是最常用的。注意它们的区别addu是R型、不做溢出检测add在溢出时会产生异常。日常写程序编译器一般都用addu或addiu因为无符号算术不考虑溢出标志硬件上省去异常逻辑。我在实现第一版时只做了addu/subu没做add/sub等后续功能稳定了才考虑异常处理。逻辑指令里值得说的是nor。它有32位双输入输出是~(a | b)可以用它配合立即数常数来构造not操作。但更实用的是MIPS里常配合lui和ori来构造32位立即数lui rt, imm把16位立即数写入rt的高16位低16位清零然后再用ori rt, rt, imm_low把另一个16位立即数写入rt的低16位高16位保持不变这样两步就能装载任意32位常数。这算是一个很经典的MIPS编程技巧在写启动代码时一定会用到。3.3 访存指令的字节对齐陷阱lw和sw的一条要求是地址必须4字节对齐。也就是说字节地址的低2位必须为0。MIPS的规范里如果访问未对齐的地址一般会产生地址异常。但很多小型的教学CPU根本不捕获这个异常只是静默地取错数据这其实是隐患。我建议至少用断言在仿真阶段检查这一点。访存指令还有一个经常被忽略的细节它的目标寄存器在指令字的[20:16]位而不是R型指令里的[15:11]位。这就意味着寄存器堆的写地址端口在不同指令类型下要选择不同字段。硬件上需要一个简单的多路选择器// 写地址选择I型访存或立即数指令用rtR型用rd wire [4:0] regfile_wa i_type_inst ? instr[20:16] : instr[15:11];如果你漏了这一步lw指令写入的目标寄存器就会错位。我调试的时候花了一整个晚上排查“为什么 lw 之后寄存器总是多出乱七八糟的值”最后定位到就是这个mux写错了。3.4 分支跳转指令延迟槽是MIPS绕不过去的关键MIPS的分支指令beq/bne有两个非常反直觉的硬件特性一是比较条件后只产生目标地址不立即改变PC二是存在分支延迟槽。分支延迟槽的意思是在分支指令之后的那一条指令无论分支是否跳转都一定会被执行。这是MIPS为了简化流水线设计而采用的一种方案早期的MIPS处理器不预测分支而是让分支指令后面的那条指令总是进入流水线从而避免清空流水线。用汇编写跳转程序时如果你忘记了延迟槽结果会离谱。我举一个经典例子beq $t0, $t1, target nop这里需要显式加一条nop来占据延迟槽。如果你直接在beq后面放一条真正要执行的有效指令那么这条指令无论跳不跳都会执行很可能造成非预期的副作用。在硬件实现上延迟槽意味着取指阶段遇到分支指令时下一条指令PC4仍然要被取出来执行。我在写取指逻辑时专门给分支指令加了一个“延迟槽有效”标记只有满足分支条件的那个周期才会把下一条PC替换成目标地址。这个逻辑不难但很容易在状态机里忙中出错。建议在仿真代码里专门设计几个带nop和不带nop的测试用例对比分支跳转前后的寄存器变化。3.5 函数调用的核心jal与jrjal指令在做函数调用时有两个动作把返回地址写入$ra寄存器编号31然后跳转到目标地址。返回地址的值是PC4注意是PC4而不是PC8这一点和分支目标计算公式里的基准是一致的。jr $ra则负责返回它把寄存器里的值作为跳转目标地址。这里要注意jr的跳转目标不需要再左移2位因为它本身就是一个完整的32位寄存器值。我在实现jr时就是直接把read_data1接到PC的mux上不需要任何地址计算。函数调用还有个小技巧MIPS的函数调用约定里$ra是调用者保存的。如果函数内部还要调用其他函数先要把$ra压栈返回前再恢复。这套约定虽然不是ISA强制的但如果你遵从它写出来的汇编代码会被编译器兼容。4. 实操从汇编到机器码的完整流程4.1 第一步写出测试汇编程序我第一版CPU的测试程序是经典的数组求和数组放在内存某个基址上循环累加。为了验证分支、跳转和访存我故意在循环里用两个寄存器保存循环计数器和累加和。下面是我实际用的汇编代码片段# 假设 $s0 存放数组首地址$s1 存放数组长度 addiu $t0, $zero, 0 # 累加和 0 addiu $t1, $zero, 0 # 循环计数 0 loop: lw $t2, 0($s0) # 取数组元素 addu $t0, $t0, $t2 # 累加 addiu $s0, $s0, 4 # 指针加4 addiu $t1, $t1, 1 # 计数加1 slt $t3, $t1, $s1 # 计数 长度比较产生1 bne $t3, $zero, loop # 没到末尾就继续 nop # 延迟槽顺带一提这里用了slt指令小于则置1用来实现有符号比较。我在第一版时为了省事直接复用减法器来算差值再用符号位判断省去了专门设计比较器的功夫。4.2 第二步手工汇编成机器码做完汇编接下来就是手工把每条指令翻译成 machine code。这个过程虽然繁琐但对于理解指令格式来说是绕不开的。我拿其中一条addiu $t0, $zero, 0举例addiu的opcode是001001rs是$zero即寄存器0编号00000rt是$t0编号01000立即数是0。高6位001001rs00000rt01000immediate0000000000000000整条指令32位是00100100000010000000000000000000换算成十六进制就是0x21080000。你可以在仿真波形里用这一句去对照生成的机器码如果取指阶段读到的指令不是这个数那你的汇编器或者加载逻辑就有问题。最开始我不用手动汇编直接拜托工具生成。但有一次工具链配置失误把大小端搞混了内存里数据全是反的最后靠人工挑出几条指令比对才发现。4.3 第三步用小模拟器验证机器码在自己写硬件之前我强烈建议先用市面上开源的MIPS模拟器把汇编程序跑一遍确认逻辑结果正确再进入硬件设计。这样可以隔离两类问题一类是程序本身的算法bug一类是硬件行为bug。如果程序在模拟器里能跑通在RTL仿真里跑不过那问题大概率出在硬件逻辑而不是脑子里想象的程序执行流程。我用模拟器跑上述程序时特意开启了“显示机器码”和“显示流水线”的模式。这样既能核对每一条指令的编码又能查看分支延迟槽是否按预期执行。我发现很多隐性问题在纯文本调试器下很难看到但在流水线/周期视图里一眼就能看出问题。4.4 第四步在硬件仿真里执行并比对在RTL仿真里我把编译好的机器码文件按字节或者按字加载到存储器模型中。为了方便调试每一次写PC和数据写回我都会在测试平台里打印PC 0x00400000, instr 0x21080000, write reg 8, value 0有了这个打印配合模拟器的寄存器变化基本能快速定位是哪条指令的哪个行为不匹配。分支目标地址算错是高频问题我建议在打印里把PC、分支目标、分支是否成立全打出来。曾经有一次beq的方向搞反了程序一跳到循环外就卡死最后就是靠这份打印发现目标地址高4位拼接错了。5. 常见问题与排查技巧实录5.1 立即数扩展方式混用addiu用符号扩展ori用零扩展这是MIPS新手最容易混淆的地方。我见过不少调试案例最后发现就是因为ALU输入端的扩展模块写错了逻辑类指令得到错误的高位数据导致关键位被意外置1。排查方法很简单在alu输入处加一组信号观察分别看符号扩展和零扩展之后的结果。如果扩展后的值和你手算的预期不一致立刻就能定位到扩展模块。5.2 lw/sw 偏移量计算错误访存指令偏移量的符号扩展很容易被忽略。有一个测试场景是基址寄存器指向数组中间用负偏移访问前面的元素。如果硬件用零扩展负偏移就变成一个很大的正数访问到的内存位置完全错乱。我建议在测试程序里专门加一条lw $t0, -4($s0)这样的指令验证负偏移量。如果能正确读到上一个元素说明符号扩展逻辑没问题。5.3 分支目标地址少算了4这是一个非常经典的bug分支目标算成了PC (imm 2)但正确公式是(PC 4) (imm 2)。为什么会错因为很多初学流水线的人容易忘记MIPS的PC在分支判定时已经不是当前指令的地址了而是下一条或者下下条地址。验证方法用两条相邻的beq指令通过调整imm来跳转到不同位置然后检查取指地址是否和手工算出来的一致。手工计算时一定要带上4。5.4 延迟槽里放了一条不该执行的指令这是最隐蔽的软件层面坑。如果你从CPU设计的代码库里把汇编代码复制出来没有手动在分支和跳转后加nop那么在模拟器里跑结果是对的但在真实CPU上跑可能会错因为真实硬件存在延迟槽。这个bug极难排查因为它只影响少数几条指令。我的经验是在所有分支和跳转指令后面统一手动加一条nop直到你完全熟悉延迟槽的规律。另外你也可以用编译器自带的指令调度器来自动填充延迟槽但这需要后端支持前期的裸编程就老老实实写nop最安全。5.5 jr 与 jal 的返回地址寄存器记错jal写入的返回地址寄存器是$ra31号而不是任意指定寄存器。有些CPU设计里采用通用寄存器堆的写地址来存储但如果你在写jal时把目标寄存器选成了其他编号调用关系会完全错乱。检测方式在测试程序里连续两层函数调用内层函数返回后外层能继续执行。如果第二次函数返回跑飞先检查jal写入的寄存器编号。6. 给“手撸”实践者的几点心得做完第一版指令集实现后最明显的感受是指令集是CPU设计里最不需要“创造”的部分但却是最容易出现低级错误的地方。你不需要发明什么技巧只需要严谨地对照那张编码表把每个位放对位置。我后来整理自己的设计笔记时形成了一套习惯每实现一条指令就完成三个动作——一是查编码表确认字段二是在模拟器里跑通包含这条指令的测试程序三是在RTL仿真里对比每一条指令执行前后的寄存器变化。三个动作都完成才算这条指令真的“通关”。如果让我给一个刚刚开始手撸CPU的人建议我会说指令集不要贪多20条指令够用就跑得动很多程序了但20条指令里必须覆盖访存、分支、跳转、延迟槽、立即数符号/零扩展这五个难点。把这五个难点都吃透再往后扩展乘除法、异常处理、缓存一致性时你会发现自己已经养成了很好的调试习惯。
阅读完成 · 觉得有帮助?