哈工大13015计算机系统原理大作业:单周期RISC-V处理器实现全攻略

哈工大13015计算机系统原理大作业:单周期RISC-V处理器实现全攻略 如果要我选一门“看着不难、上手致命”的课哈工大的13015计算机系统原理绝对排得进前三。这门课的理论部分讲数据表示、运算器、存储层次、指令系统和CPU设计听起来都是计算机组成原理里的老面孔可真正把工作量拉满的是大作业——它要求你亲手实现一个能真正执行程序的处理器。网上流传的13015计算机系统原理笔记重点很多但我的体会是背十遍笔记不如亲手把一条指令从取指到写回的完整路径跑通一遍。这篇博文把我完成大作业的完整思路、关键代码、测试方案和踩坑记录整理出来给正在为这门课头疼的同学一个可以直接参考的路线图。尤其是那些刚接触Verilog、对“数据通路加控制信号”没什么感觉的人按这个顺序走至少能少熬三个通宵。1. 大作业选题的魄力活为什么我选了单周期RISC-V1.1 13015这门课真正想考你什么13015计算机系统原理这门课理论上覆盖的很广从补码运算、IEEE 754浮点格式到Cache映射、虚拟内存再到指令流水线和中断异常。期中期末的考题往往盯着“会不会算”但大作业的评分逻辑完全不一样它盯的是“能不能搭”。大作业本质上是把整门课的知识点压缩到一个设计里你要自己定指令集自己画数据通路自己写控制信号自己验证指令能不能跑。很多人挂在这一关不是因为某一章没学好而是因为从头到尾没有一个完整的系统观。补码算得再熟不知道ALU的减法其实是一组加法器和取反逻辑在干活Cache映射公式背得再顺写存储器模块时还是会把读使能和写使能搞混。所以我的建议是做这个作业之前先把“取指—译码—执行—访存—写回”这一条主线当成树干所有模块都是长在树干上的枝杈。只要主线通细节都好补主线不通局部模块做得再精致也白搭。1.2 单周期、流水线还是自定义指令集我选的是单周期RISC-V没有上五级流水线。当时也纠结过流水线听起来更高级汇报的时候拿出来更唬人。但冷静算了一笔账流水线意味着要处理数据冒险、控制冒险、转发通路、停顿逻辑这套东西的调试难度是单周期的两到三倍。对于一门课的作业来说投入产出比并不划算。下表是我当时对比的几个方案方案代码量估算验证难度翻车风险适合人群单周期RISC-VRV32I子集500~700行Verilog低单条指令周期内完成语义低只要控制信号别配错大多数人五级流水线RISC-V1200行以上Verilog高冒险处理容易出隐藏bug高一个转发条件写错就全盘崩想冲高难度的人自定义精简指令集取决于精度中但评分时可能需要额外解释中资料少遇到问题难查有特殊设计需求的人基于Logisim画电路图形化无代码低但连线繁琐中图大了容易漏连线对Verilog不熟的人指令集我选了RISC-V而不是MIPS原因很简单RISC-V的指令格式更规整生态资料多而且这门课现在也把RISC-V作为主要参考。不过要提醒一句RISC-V的B型分支指令立即数是“散装”的拼接顺序特别容易踩坑后面我会专门说。1.3 指令子集裁剪原则很多同学一上来就想实现完整RV32I其实完全没必要。我的原则是能覆盖核心功能就行宁可少不可错。我最终实现的大概有25条指令算术逻辑类ADD、SUB、AND、OR、XOR、SLL、SRL、SRA、SLT、SLTU立即数类ADDI、ANDI、ORI、XORI、SLTI、SLLI、SRLI、SRAI访存类LW、SW、LB、SB分支跳转类BEQ、BNE、BLT、JAL、JALR高立即数类LUI、AUIPC这组指令足够写出递归、循环、数组访问、函数调用这些典型程序对所有核心数据通路和控制信号都做了覆盖。那些冷门指令比如FENCE、ECALL、CSR一个都不用碰它们只会往你的控制器里塞进去一堆无用逻辑。2. 数据通路与控制信号骨架画对再动手2.1 单周期数据通路的连接关系写代码之前我花了整整一个晚上在纸上把这幅图画了出来。别小看这一步它决定了后面所有代码的组织方式。核心连接关系是这样的PC送指令存储器得到32位指令指令拆成opcode、rs1、rs2、rd、funct3、funct7、immrs1和rs2地址送寄存器堆读出的两个操作数传给ALUALU的计算结果可能直接用于写回也可能作为存储器地址数据存储器的读数据经过写回选择器后回到寄存器堆的写数据端口下一个PC要么是PC4要么是分支目标地址要么是JALR计算出的寄存器加立即数这段关系里最容易忽略的是PC的多路选择。JAL会把PC4存回rd同时跳转到目标地址JALR用的是rs1加上立即数而且跳转结果的bit0必须清零。这两条指令如果不仔细处理写出的程序只要一遇到函数调用就会疯掉。2.2 立即数扩展B型指令的“散装立即数”这个坑我身边至少三个人踩过。RISC-V里I型、S型、B型、U型指令的立即数位段位置不一样。I型很规整就是把指令高12位扩展成32位但B型的立即数分散在指令的不同位置标准顺序是imm[12]、imm[10:5]、imm[4:1]、imm[11]拼完之后还要在后面补一个0最后做符号扩展。我当时写B型立即数扩展的Verilog就写错过顺序导致分支指令总是跳到一个奇怪地址。代码长这样wire [31:0] imm_b; assign imm_b {{19{instr[31]}}, instr[31], instr[7], instr[30:25], instr[11:8], 1b0};注意这里最前面的符号扩展位数是19因为有效立即数是13位12位原值加末尾1位的0加上符号扩展后总长32位。如果你在拼接时漏掉instr[7]或者补零位置不对仿真时PC就会直接飞出代码段。2.3 控制信号真值表一条条指令对出来的控制信号是整个CPU的神经中枢。我实现了一套做法先用表格列出每条指令的RegWrite、ALUSrc、MemWrite、MemRead、MemtoReg、Branch、Jump、ALUSrc2、ALU_CTRL然后直接把这个表变成case语句。核心控制信号定义如下指令类型RegWriteALUSrcMemWriteMemReadMemtoRegBranchJumpALU_CTRLR型1000000funct生成ADDI1100000加LW1101100加SW0110x00加BEQ0000x10减JAL1x002x1x这里有个细节要注意SW不需要写寄存器所以RegWrite等于0MemtoReg无所谓但不能让综合工具推断出不确定的锁存器。我在代码里统一给这些“无所谓”的信号赋了确定值而不是给x。3. 核心模块实现寄存器堆、ALU、控制器和存储器3.1 寄存器堆同一周期“先读后写”的约定寄存器堆是整个CPU里最容易被想当然的模块。一开始我写成了同步读结果ALU一直拿到的是上一周期的寄存器值怎么调都不对。单周期CPU里寄存器堆的读必须是组合逻辑写才是时序逻辑。也就是说只要rs1地址一变rs1_data就得跟着变等在一个时钟边沿上把rd_data写进去。我当时实现的代码核心是always (*) begin rs1_data (rs1_addr 5b0) ? 32h0 : regs[rs1_addr]; rs2_data (rs2_addr 5b0) ? 32h0 : regs[rs2_addr]; end always (posedge clk) begin if (reg_write rd_addr ! 5b0) regs[rd_addr] rd_data; end就是这两段代码完美处理了x0寄存器永远为0的问题也天然避免了写端口和读端口同时操作时的竞争。没有做内部的转发因为在单周期设计里当前指令写回的结果本来就要到下一个时钟周期才能被下一条指令使用不存在流水线那种RAW冒险不需要额外处理。3.2 ALU的符号位陷阱无符号比较要非常小心ALU看似简单就是加减与或非但一旦涉及SLTU和SRA事情就变了。SLTU要求把两个操作数当成无符号数比较Verilog里默认的比较运算会按有符号数处理所以必须用$unsigned把操作数包起来。右移也有讲究逻辑右移在左边填0算术右移要填符号位。这个区别如果不处理用SRA指令做带符号除法或取整时会得到完全错误的结果。我当时是这么写右移的4b0101: result a shamt; // SRL 4b0110: result $signed(a) shamt; // SRA减法和SLT我也统一由ALU控制信号来区分没有像某些设计那样把减法拆到控制器里。控制信号分得越细控制器越复杂反而降低可读性。3.3 控制器localparam配合case比真值表硬编码好维护控制器有两种写法一种是像课本那样画出真值表然后用一个巨大的assign语句把每个控制信号算出来另一种是用localparam给指令命名然后写一个always块用case判断opcode和funct。我用的是第二种。好处是调试时非常直观仿真里看到当前指令的助记符一眼就知道跑到哪里了。而且case语句的default分支可以给所有控制信号赋默认值杜绝锁存器。大概结构是这样localparam OP_RTYPE 7b0110011; localparam OP_ITYPE 7b0010011; localparam OP_LW 7b0000011; localparam OP_SW 7b0100011; localparam OP_BRANCH 7b1100011; localparam OP_JAL 7b1101111; always (*) begin // 先给默认值 reg_write 1b0; alu_src 1b0; // ... case (opcode) OP_RTYPE: begin reg_write 1b1; case ({funct7, funct3}) 10b0000000_000: alu_ctrl ALU_ADD; // ... endcase end // ... default: begin end endcase end我用这个方式分别生成R型、I型、访存、分支和跳转的控制信号。注意R型指令的funct7和funct3两个字段要拼成10位一起判断拼法错了就会把ADD识别成SUB那种bug特别隐蔽。3.4 存储器初始化用hex文件给CPU喂程序指令存储器和数据存储器最好分开建模。指令存储器用$readmemh在initial块里加载程序数据存储器则从地址0开始由测试程序自己往里写。初始化代码reg [31:0] imem [0:4095]; initial $readmemh(test_program.hex, imem); // 指令读取是纯组合逻辑 wire [31:0] instruction imem[pc[13:2]];这里要注意PC的低两位因为字节寻址被丢弃。RISC-V要求指令对齐到4字节边界pc[1:0]恒为0取指令时地址右移两位。很多刚开始写的人会把imem的下标写成pc本身导致读出来的指令错位得离谱。数据存储器我设成同步写、组合读always (posedge clk) begin if (mem_write) dmem[addr[13:2]] write_data; end assign read_data mem_read ? dmem[addr[13:2]] : 32h0;这个做法保证了一点在同一周期里执行SW时不会立刻影响同一个周期里其他读操作的结果避免了很多奇怪的仿真问题。4. 给CPU上强度测试程序的覆盖与检查点4.1 一段能测遍核心指令的汇编程序写测试程序是整个大作业里最有价值的环节之一。只测一条加法指令没有任何说服力我写了一段计算斐波那契数列的汇编外加一组访存和跳转测试让CPU跑一遍就知道所有核心指令是否正常。# test.asm —— 计算 fib(10) 并写入内存 addi x5, x0, 0 # 循环计数 n 0 addi x6, x0, 10 # 最大值 n 10 addi x7, x0, 0 # fib(0) 0 addi x8, x0, 1 # fib(1) 1 loop: beq x5, x6, done # 若 n 10 则结束 add x9, x7, x8 # x9 x7 x8 addi x7, x8, 0 # x7 x8 addi x8, x9, 0 # x8 x9 addi x5, x5, 1 # n n 1 jal x0, loop # 跳回 loop done: addi x28, x7, 0 # 把结果放到 x28 sw x28, 4(x0) # 存入内存地址 4 lw x29, 4(x0) # 读回 x29 addi x30, x29, 7 # 再做一个算术运算 sb x30, 8(x0) # 字节写入 lb x31, 8(x0) # 字节读回这段代码同时测了R型、I型、B型分支、JAL跳转、SW/LW访存、SB/LB字节访问。每个寄存器最后的值都可以作为检查点。我记得自己第一次跑通时看到x28等于55长舒了一口气。4.2 自检检查点与$display配合仿真最怕的是程序跑完但你不知道结果对不对。我在testbench里写了一个自检过程先让CPU跑固定数量的时钟周期然后检查x28、x29、x30、x31的期望值任何一个不对就把错误打出来。initial begin // 复位 10ns reset 1b1; #10 reset 1b0; // 跑 500 个周期足够完成上面的程序 repeat (500) (posedge clk); if (tb_cpu.regs[28] ! 32d55) $display(FAIL: x28 %0d, expected 55, tb_cpu.regs[28]); else $display(PASS: x28 %0d, tb_cpu.regs[28]); // 其他检查点同理 $finish; end有一个小技巧值得分享我把寄存器堆、PC、当前指令等内部信号全部引到了testbench的顶层这样一旦出错不需要探针一根根去查内部层次直接在testbench里用层次化引用就能看到。Verilog是支持tb_cpu.regs[28]这种层级访问的排查效率高很多。4.3 波形调试的实用技巧$display能告诉你结果对不对但很难告诉你到底哪一步错了。这时候要看波形。我的经验是不要一上来就开满所有信号看面不如看点。第一步只看PC和instruction确认指令一条条正常取出来。如果PC在某个周期突然跳到一个奇怪地址基本就是分支立即数或JALR处理有问题。第二步再拉出rs1、rs2、alu_result、mem_read_data看数据通路上每一级的传播是否符合预期。第三步才是查控制信号比如某条指令本该写寄存器但reg_write一直是0那就是控制器真值表配错了。用Vivado自带的行为仿真就够不需要额外工具。关键是养成“分步定位”的习惯而不是望着波形发呆。5. 从“完全跑不动”到验收通过踩坑实录5.1 第一轮仿真全红先砍到最小内核我第一次跑仿真的时候PC压根不动波形里一条指令都执行不了。排查了很久最终发现是指令存储器的地址位宽和实际的数组下标对不上。我把PC直接当成了数组下标而PC递增步长是4所以第一次读的是地址0没错但第二次就读了地址4而数组到底有没有初始化到下标4完全取决于hex文件编译器生成的起始地址。处理办法是先把测试程序砍到最简只执行一条ADDI然后不断重复。让CPU先能把一条指令完整走完再把指令数量一点点加回去。这种“最小可运行内核”的思路在整个大作业阶段帮我省了无数时间。5.2 分支立即数符号扩展的命名混乱有一晚调分支跳转发现BEQ总是跳到低地址而不是目标的高地址。后来仔细对比RISC-V指令编码发现自己把立即数低位和高位的拼接顺序搞反了应该放在高位的imm[12]被拼到了低位导致符号扩展后变成了一个负数。从那以后我养成了一个习惯每写一个立即数扩展模块先用三两条已知指令做单元测试。比如构造一条BEQ x0, x0, -4手动算出目标地址然后看波形里PC是否跳到PC-4。单元测试过了再放到整个CPU里跑避免混在一起无法定位。5.3 数据存储器写使能的“晚到一步”还有一个隐蔽问题出在数据存储器的写使能上。我最初把MemWrite直接连到dmem的写使能但dmem写在时钟上升沿而MemWrite是组合逻辑生成的理论上在时钟沿到来时已经稳定。问题是RISC-V的SW指令会把寄存器里的数据写到内存如果这个数据本身来自刚才ALU的输出而ALU输出的变化和控制器信号的生成存在一点点延迟仿真时就会看到写入的值不是预期值。解决方法是把存储器写使能设计成同步信号只在时钟边沿采样写数据也用寄存器打一拍。这样虽然牺牲了一点时序上的优雅但工程上非常可靠。课程作业不追求极致性能稳定通过才是第一位的。5.4 验收前的最后检查清单到交作业之前我给所有信号过了一遍检查清单下面这几项是我强烈建议你也检查的x0寄存器是否永远为0任何写x0的操作都不该改掉它。JAL是否把PC4正确写回rd而不是PC或PC指令。JALR跳转地址的最低位是否被清0。分支指令是否用减法比较然后使用zero信号。SW执行时寄存器堆的RegWrite一定是0不要多写。所有组合逻辑的case是否都有default避免锁存器。复位信号是否能在一个周期内把PC清0。我最后交上去的版本仿真一遍过检查点全部PASS。回头看最难的不是写代码而是把整个系统在脑子里串起来。大作业做完之后我才真正理解课本上那些“控制信号”“数据通路”的概念不是用来背的是拿来用的。最后再分享一个小细节如果你时间还充裕可以在验收前给自己加一个“坏程序”测试故意写一段访问非法内存地址或者死循环的程序看CPU是优雅地跑出预期行为还是直接挂死。这个测试不会加分但能让你对自己的设计有更完整的把握。至少我在那次测试里又发现了一个只有在这种极端情况下才会暴露的控制信号边界问题。