哈工大13015计算机系统原理大作业:单周期RISC-V处理器实现全攻略
2026/9/10 17:31:08 网站建设 项目流程

如果要我选一门“看着不难、上手致命”的课,哈工大的13015计算机系统原理绝对排得进前三。这门课的理论部分讲数据表示、运算器、存储层次、指令系统和CPU设计,听起来都是计算机组成原理里的老面孔,可真正把工作量拉满的是大作业——它要求你亲手实现一个能真正执行程序的处理器。网上流传的13015计算机系统原理笔记重点很多,但我的体会是,背十遍笔记不如亲手把一条指令从取指到写回的完整路径跑通一遍。

这篇博文把我完成大作业的完整思路、关键代码、测试方案和踩坑记录整理出来,给正在为这门课头疼的同学一个可以直接参考的路线图。尤其是那些刚接触Verilog、对“数据通路加控制信号”没什么感觉的人,按这个顺序走,至少能少熬三个通宵。

1. 大作业选题的魄力活:为什么我选了单周期RISC-V

1.1 13015这门课真正想考你什么

13015计算机系统原理这门课,理论上覆盖的很广:从补码运算、IEEE 754浮点格式,到Cache映射、虚拟内存,再到指令流水线和中断异常。期中期末的考题往往盯着“会不会算”,但大作业的评分逻辑完全不一样,它盯的是“能不能搭”。

大作业本质上是把整门课的知识点压缩到一个设计里:你要自己定指令集,自己画数据通路,自己写控制信号,自己验证指令能不能跑。很多人挂在这一关,不是因为某一章没学好,而是因为从头到尾没有一个完整的系统观。补码算得再熟,不知道ALU的减法其实是一组加法器和取反逻辑在干活;Cache映射公式背得再顺,写存储器模块时还是会把读使能和写使能搞混。

所以我的建议是,做这个作业之前,先把“取指—译码—执行—访存—写回”这一条主线当成树干,所有模块都是长在树干上的枝杈。只要主线通,细节都好补;主线不通,局部模块做得再精致也白搭。

1.2 单周期、流水线还是自定义指令集

我选的是单周期RISC-V,没有上五级流水线。当时也纠结过:流水线听起来更高级,汇报的时候拿出来更唬人。但冷静算了一笔账,流水线意味着要处理数据冒险、控制冒险、转发通路、停顿逻辑,这套东西的调试难度是单周期的两到三倍。对于一门课的作业来说,投入产出比并不划算。

下表是我当时对比的几个方案:

方案代码量估算验证难度翻车风险适合人群
单周期RISC-V(RV32I子集)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、imm
  • rs1和rs2地址送寄存器堆,读出的两个操作数传给ALU
  • ALU的计算结果可能直接用于写回,也可能作为存储器地址
  • 数据存储器的读数据经过写回选择器后,回到寄存器堆的写数据端口
  • 下一个PC要么是PC+4,要么是分支目标地址,要么是JALR计算出的寄存器加立即数

这段关系里,最容易忽略的是PC的多路选择。JAL会把PC+4存回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], 1'b0};

注意这里最前面的符号扩展位数是19,因为有效立即数是13位(12位原值加末尾1位的0),加上符号扩展后总长32位。如果你在拼接时漏掉instr[7]或者补零位置不对,仿真时PC就会直接飞出代码段。

2.3 控制信号真值表:一条条指令对出来的

控制信号是整个CPU的神经中枢。我实现了一套做法:先用表格列出每条指令的RegWrite、ALUSrc、MemWrite、MemRead、MemtoReg、Branch、Jump、ALUSrc2、ALU_CTRL,然后直接把这个表变成case语句。

核心控制信号定义如下:

指令类型RegWriteALUSrcMemWriteMemReadMemtoRegBranchJumpALU_CTRL
R型1000000funct生成
ADDI1100000
LW1101100
SW0110x00
BEQ0000x10
JAL1x002x1x

这里有个细节要注意:SW不需要写寄存器,所以RegWrite等于0,MemtoReg无所谓,但不能让综合工具推断出不确定的锁存器。我在代码里统一给这些“无所谓”的信号赋了确定值,而不是给x。

3. 核心模块实现:寄存器堆、ALU、控制器和存储器

3.1 寄存器堆:同一周期“先读后写”的约定

寄存器堆是整个CPU里最容易被想当然的模块。一开始我写成了同步读,结果ALU一直拿到的是上一周期的寄存器值,怎么调都不对。单周期CPU里,寄存器堆的读必须是组合逻辑,写才是时序逻辑。也就是说,只要rs1地址一变,rs1_data就得跟着变,等在一个时钟边沿上把rd_data写进去。

我当时实现的代码核心是:

always @(*) begin rs1_data = (rs1_addr == 5'b0) ? 32'h0 : regs[rs1_addr]; rs2_data = (rs2_addr == 5'b0) ? 32'h0 : regs[rs2_addr]; end always @(posedge clk) begin if (reg_write && rd_addr != 5'b0) regs[rd_addr] <= rd_data; end

就是这两段代码,完美处理了x0寄存器永远为0的问题,也天然避免了写端口和读端口同时操作时的竞争。没有做内部的转发,因为在单周期设计里,当前指令写回的结果本来就要到下一个时钟周期才能被下一条指令使用,不存在流水线那种RAW冒险,不需要额外处理。

3.2 ALU的符号位陷阱:无符号比较要非常小心

ALU看似简单,就是加减与或非,但一旦涉及SLTU和SRA,事情就变了。SLTU要求把两个操作数当成无符号数比较,Verilog里默认的比较运算会按有符号数处理,所以必须用$unsigned把操作数包起来。

右移也有讲究:逻辑右移在左边填0,算术右移要填符号位。这个区别如果不处理,用SRA指令做带符号除法或取整时会得到完全错误的结果。我当时是这么写右移的:

4'b0101: result = a >> shamt; // SRL 4'b0110: result = $signed(a) >>> shamt; // SRA

减法和SLT我也统一由ALU控制信号来区分,没有像某些设计那样把减法拆到控制器里。控制信号分得越细,控制器越复杂,反而降低可读性。

3.3 控制器:localparam配合case,比真值表硬编码好维护

控制器有两种写法,一种是像课本那样画出真值表,然后用一个巨大的assign语句把每个控制信号算出来;另一种是用localparam给指令命名,然后写一个always块用case判断opcode和funct。

我用的是第二种。好处是调试时非常直观:仿真里看到当前指令的助记符,一眼就知道跑到哪里了。而且case语句的default分支可以给所有控制信号赋默认值,杜绝锁存器。

大概结构是这样:

localparam OP_RTYPE = 7'b0110011; localparam OP_ITYPE = 7'b0010011; localparam OP_LW = 7'b0000011; localparam OP_SW = 7'b0100011; localparam OP_BRANCH= 7'b1100011; localparam OP_JAL = 7'b1101111; always @(*) begin // 先给默认值 reg_write = 1'b0; alu_src = 1'b0; // ... case (opcode) OP_RTYPE: begin reg_write = 1'b1; case ({funct7, funct3}) 10'b0000000_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]] : 32'h0;

这个做法保证了一点:在同一周期里执行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 = 1'b1; #10 reset = 1'b0; // 跑 500 个周期,足够完成上面的程序 repeat (500) @(posedge clk); if (tb_cpu.regs[28] !== 32'd55) $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 验收前的最后检查清单

到交作业之前,我给所有信号过了一遍检查清单,下面这几项是我强烈建议你也检查的:

  1. x0寄存器是否永远为0,任何写x0的操作都不该改掉它。
  2. JAL是否把PC+4正确写回rd,而不是PC或PC+指令。
  3. JALR跳转地址的最低位是否被清0。
  4. 分支指令是否用减法比较,然后使用zero信号。
  5. SW执行时寄存器堆的RegWrite一定是0,不要多写。
  6. 所有组合逻辑的case是否都有default,避免锁存器。
  7. 复位信号是否能在一个周期内把PC清0。

我最后交上去的版本,仿真一遍过,检查点全部PASS。回头看,最难的不是写代码,而是把整个系统在脑子里串起来。大作业做完之后,我才真正理解课本上那些“控制信号”“数据通路”的概念,不是用来背的,是拿来用的。

最后再分享一个小细节:如果你时间还充裕,可以在验收前给自己加一个“坏程序”测试,故意写一段访问非法内存地址或者死循环的程序,看CPU是优雅地跑出预期行为,还是直接挂死。这个测试不会加分,但能让你对自己的设计有更完整的把握。至少我在那次测试里,又发现了一个只有在这种极端情况下才会暴露的控制信号边界问题。

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

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

立即咨询