计算机组成原理CPU课程实验:从指令集设计到流水线优化的完整实现指南 简介这份资源是东南大学计算机组成原理课程实验设计的完整交付物面向正在学习CPU设计、需要完成课程大作业或准备体系结构相关实验的本科生与自学者。内容围绕一个单地址指令格式的简易CPU展开涵盖控制单元、内部寄存器、ALU与指令集四大部分主存按256×16Bits组织指令字16比特、操作码与地址段各8比特并涉及直接寻址与立即数寻址两种方式。压缩包共734个文件约56.18MB以VHDL与Verilog源码、coe/mif存储器初始化文件、xdc约束、tcl与bat脚本、仿真日志及Word版实验报告为主另含大量工程中间文件便于复现综合、仿真与验证流程。已有332人学习。读者可据此获得可运行的CPU源码、完整实验报告、指令集验证程序与排错参考适合对照课程要求逐步搭建并调试自己的处理器设计。1. 从零到一计算机组成原理 CPU 课程实验到底在做什么如果你正在修计算机组成原理这门课或者正准备带学生做 CPU 设计实验大概率会遇到一个很现实的问题课本上讲的是单周期、多周期、流水线这些概念但真正让你用 Verilog 或者 Logisim 从零搭出一个能跑指令的 CPU中间隔着的不是一点点距离。这个标题指向的就是一套完整的 CPU 课程实验设计源码加上配套实验报告覆盖从指令集设计、数据通路搭建、控制器实现到仿真验证的全流程。它解决的核心痛点是你不需要从空白文件开始猜每一步该怎么做而是有一套可参照的实现路径和验证方法。适合的人群很明确——正在做组成原理课程设计的学生、需要快速搭建实验框架的助教以及想通过动手理解 CPU 内部运作的开发者。接下来我会按实际动手顺序把每个阶段的关键决策和容易翻车的地方讲清楚。2. 指令集与数据通路先定规矩再动手连线2.1 指令格式怎么选才不给自己挖坑做 CPU 实验的第一步不是画数据通路而是定指令集。很多同学一上来就想着“我要支持尽可能多的指令”结果做到控制器阶段发现译码逻辑复杂到无法调试。我的建议是先确定一个最小可用指令集通常 8 到 12 条指令就够覆盖课程要求。常见做法是参考 MIPS 的 R 型、I 型、J 型三类格式因为它的字段划分清晰译码时不容易出错。具体来说R 型指令用于寄存器之间的算术逻辑运算格式为 opcode(6) rs(5) rt(5) rd(5) shamt(5) funct(6)。I 型用于立即数运算和访存格式为 opcode(6) rs(5) rt(5) immediate(16)。J 型用于跳转格式为 opcode(6) address(26)。这样划分的好处是opcode 字段统一在最高 6 位译码器可以先根据 opcode 判断指令大类再根据 funct 字段细分 R 型指令逻辑层次分明。下面是一个用 Verilog 定义的指令译码模块片段展示了如何从 32 位指令中提取各字段并生成控制信号// 指令译码模块从32位指令中提取字段并生成控制信号 module decoder ( input [31:0] instr, // 完整的32位指令 output reg [5:0] opcode, // 操作码最高6位 output reg [4:0] rs, // 源寄存器1 output reg [4:0] rt, // 源寄存器2 output reg [4:0] rd, // 目的寄存器R型 output reg [4:0] shamt, // 移位量 output reg [5:0] funct, // 功能码R型 output reg [15:0] imm, // 立即数I型 output reg [25:0] jump_addr // 跳转地址J型 ); always (*) begin opcode instr[31:26]; rs instr[25:21]; rt instr[20:16]; rd instr[15:11]; shamt instr[10:6]; funct instr[5:0]; imm instr[15:0]; jump_addr instr[25:0]; end endmodule这段代码的逻辑很直接把所有可能的字段都拆出来后续控制器根据 opcode 和 funct 来决定用哪些字段。参数说明方面rs 和 rt 是源寄存器索引rd 是目的寄存器索引imm 需要根据指令类型决定是否做符号扩展。注意I 型指令的立即数在参与运算前必须做符号扩展否则负数运算会出错这是新手最容易忽略的点。2.2 数据通路搭建单周期还是多周期指令集定好之后接下来要决定数据通路的组织方式。单周期 CPU 的优点是控制逻辑简单每条指令在一个时钟周期内完成但时钟周期必须按最慢的指令来设导致整体频率上不去。多周期 CPU 把一条指令拆成多个步骤每个步骤一个时钟周期时钟频率可以提高但控制器状态机复杂度上升。对于课程实验来说如果课时有限、验证重点在功能正确性我一般会建议先做单周期版本跑通之后再考虑扩展成多周期或流水线。单周期数据通路的核心组件包括程序计数器、指令存储器、寄存器堆、算术逻辑单元、数据存储器、以及若干多路选择器和控制信号。下面是一个简化的单周期数据通路顶层模块框架// 单周期CPU顶层模块连接各功能部件 module cpu_single_cycle ( input clk, input rst, output [31:0] pc_out, // 当前PC值用于调试 output [31:0] alu_result // ALU结果用于调试 ); // 内部信号声明 wire [31:0] pc, pc_next, instr; wire [31:0] rs_data, rt_data, alu_out; wire [31:0] imm_ext, mem_data; wire [4:0] write_reg; wire [31:0] write_data; wire reg_write, mem_read, mem_write, alu_src, mem_to_reg, branch; // 程序计数器 pc_reg u_pc ( .clk(clk), .rst(rst), .pc_next(pc_next), .pc(pc) ); // 指令存储器 imem u_imem ( .addr(pc), .instr(instr) ); // 控制器 controller u_ctrl ( .opcode(instr[31:26]), .funct(instr[5:0]), .reg_write(reg_write), .mem_read(mem_read), .mem_write(mem_write), .alu_src(alu_src), .mem_to_reg(mem_to_reg), .branch(branch) ); // 寄存器堆 regfile u_regfile ( .clk(clk), .rs(instr[25:21]), .rt(instr[20:16]), .rd(write_reg), .write_data(write_data), .reg_write(reg_write), .rs_data(rs_data), .rt_data(rt_data) ); // 立即数扩展 assign imm_ext {{16{instr[15]}}, instr[15:0]}; // ALU alu u_alu ( .a(rs_data), .b(alu_src ? imm_ext : rt_data), .alu_op(alu_ctrl), .result(alu_out) ); // 数据存储器 dmem u_dmem ( .clk(clk), .addr(alu_out), .write_data(rt_data), .mem_read(mem_read), .mem_write(mem_write), .read_data(mem_data) ); // 写回数据选择 assign write_data mem_to_reg ? mem_data : alu_out; assign write_reg instr[15:11]; // R型指令的rd字段 // 下一条PC计算 assign pc_next branch ? (pc 4 (imm_ext 2)) : (pc 4); assign pc_out pc; assign alu_result alu_out; endmodule这段顶层代码把各个模块连接起来关键点在于写回数据的选择由 mem_to_reg 控制ALU 的第二个操作数由 alu_src 决定是来自寄存器还是立即数下一条 PC 由 branch 信号决定是否跳转。参数方面imm_ext 做了符号扩展后左移两位这是因为 MIPS 的分支偏移是以字为单位的。注意如果你用的是自己定义的指令格式分支偏移的计算方式可能不同一定要和指令集定义保持一致。2.3 控制器设计状态机还是硬布线控制器是 CPU 的大脑负责根据指令产生所有控制信号。单周期 CPU 通常用硬布线逻辑因为每条指令只在一个周期内完成控制信号可以直接由 opcode 和 funct 译码得到。多周期 CPU 则需要一个有限状态机每个状态对应指令执行的一个步骤。硬布线控制器的 Verilog 实现通常是一个大的 case 语句根据 opcode 和 funct 输出控制信号。下面是一个简化的控制器片段// 硬布线控制器根据opcode和funct生成控制信号 module controller ( input [5:0] opcode, input [5:0] funct, output reg reg_write, output reg mem_read, output reg mem_write, output reg alu_src, output reg mem_to_reg, output reg branch, output reg [3:0] alu_ctrl ); // 操作码定义根据实际指令集调整 localparam OP_RTYPE 6b000000; localparam OP_LW 6b100011; localparam OP_SW 6b101011; localparam OP_BEQ 6b000100; localparam OP_ADDI 6b001000; always (*) begin // 默认值防止锁存器推断 reg_write 0; mem_read 0; mem_write 0; alu_src 0; mem_to_reg 0; branch 0; alu_ctrl 4b0000; case (opcode) OP_RTYPE: begin reg_write 1; alu_src 0; mem_to_reg 0; // 根据funct进一步区分add/sub/and/or case (funct) 6b100000: alu_ctrl 4b0010; // add 6b100010: alu_ctrl 4b0110; // sub 6b100100: alu_ctrl 4b0000; // and 6b100101: alu_ctrl 4b0001; // or default: alu_ctrl 4b0000; endcase end OP_LW: begin reg_write 1; mem_read 1; alu_src 1; mem_to_reg 1; alu_ctrl 4b0010; // add end OP_SW: begin mem_write 1; alu_src 1; alu_ctrl 4b0010; // add end OP_BEQ: begin branch 1; alu_ctrl 4b0110; // sub用减法判断是否相等 end OP_ADDI: begin reg_write 1; alu_src 1; alu_ctrl 4b0010; // add end default: ; endcase end endmodule这段代码的核心逻辑是先给所有控制信号赋默认值避免综合出锁存器然后根据 opcode 分支R 型指令再根据 funct 细分 ALU 操作。参数说明方面alu_ctrl 的编码需要和 ALU 模块内部的 case 语句对应比如 4b0010 表示加法4b0110 表示减法。注意BEQ 指令用减法来判断两个寄存器是否相等如果结果为 0 则跳转这个逻辑要在 ALU 的 zero 输出中体现。3. 仿真验证与上板调试怎么确认你的 CPU 真的在跑3.1 用测试程序验证指令执行代码写完之后最关键的一步是验证。很多同学写完 CPU 直接上板结果发现数码管显示乱码回头查仿真又发现波形对不上。我的习惯是先在仿真环境里跑一段简单的测试程序确认每条指令的执行结果都正确再考虑上板。测试程序通常用汇编写然后手动翻译成机器码存入指令存储器。下面是一个测试程序的示例覆盖了算术运算、访存和分支# 测试程序验证add/sub/lw/sw/beq指令 # 假设寄存器$0恒为0$1-$3用于测试 addi $1, $0, 10 # $1 10 addi $2, $0, 20 # $2 20 add $3, $1, $2 # $3 30 sub $4, $2, $1 # $4 10 sw $3, 0($0) # 将$3的值存入地址0 lw $5, 0($0) # 从地址0加载到$5$5应为30 beq $3, $5, label # 如果$3$5跳转到label addi $6, $0, 99 # 不应执行 label: addi $7, $0, 1 # $7 1对应的机器码需要根据你的指令格式手动编码。比如 addi $1, $0, 10 的 opcode 是 001000rs 是 00000rt 是 00001立即数是 0000000000001010拼接起来就是 00100000000000010000000000001010即 0x2001000A。把所有指令编码后写入指令存储器的初始化文件就可以在仿真中观察波形了。仿真时重点看几个信号PC 是否按预期递增或跳转ALU 结果是否正确寄存器堆的写使能和写地址是否对应数据存储器的读写是否在正确时机发生。如果发现某条指令结果不对先检查控制信号再检查数据通路中的多路选择器。3.2 上板调试数码管和LED怎么用仿真通过之后就可以上板了。FPGA 开发板上的数码管和 LED 是最好的调试工具。我一般会把 PC 值、ALU 结果、寄存器堆的某几个寄存器值接到数码管上用拨码开关选择显示哪个信号。这样即使没有逻辑分析仪也能通过观察数码管的变化判断 CPU 是否在正常运行。上板时常见的现象是下载后数码管显示全 0 或者乱码。这时候先检查时钟信号是否正常复位是否有效。如果时钟和复位都没问题再检查指令存储器的初始化文件是否正确加载。有些开发板的综合工具不会自动加载 .mif 或 .coe 文件需要手动在综合选项中指定。另一个常见问题是时序不满足。单周期 CPU 的组合逻辑路径可能很长如果时钟频率设得太高会出现建立时间违例。解决办法是降低时钟频率或者把长路径拆成多周期。在 Quartus 或 Vivado 的时序报告中可以看到具体的违例路径根据报告优化关键路径。3.3 实验报告怎么写才不像流水账实验报告不是代码的堆砌而是要体现你的设计思路和验证过程。我一般会按这个结构写指令集设计说明、数据通路框图、控制器状态转移表、仿真波形分析、上板测试结果、遇到的问题和解决方案。其中仿真波形分析要截取关键指令的执行波形标注每个周期各信号的值说明为什么这样设计。上板测试结果要附上数码管显示的照片或视频截图证明 CPU 确实在运行。报告中最容易忽略的是“遇到的问题和解决方案”部分。这部分恰恰是最有价值的因为它体现了你的调试能力。比如“BEQ 指令跳转地址计算错误原因是立即数左移两位后没有加上 PC4”这种具体的踩坑记录比泛泛而谈的“调试过程”有用得多。4. 避坑指南那些年我们踩过的血泪坑4.1 寄存器堆写回时序冲突现象仿真时发现某些指令写回的数据不对或者寄存器值偶尔跳变。原因寄存器堆的写操作和读操作在同一个时钟沿触发如果写回地址和读地址相同读出的数据可能是旧值也可能是新值取决于综合工具的实现。解决在寄存器堆模块中写操作在时钟上升沿完成读操作用组合逻辑并且在写回阶段优先转发新值。或者简单一点在测试程序中避免连续两条指令读写同一个寄存器。4.2 立即数符号扩展遗漏现象负数运算结果错误比如 addi $1, $0, -1 之后 $1 的值是 65535 而不是 -1。原因I 型指令的立即数是 16 位参与 32 位运算前必须做符号扩展否则高位补 0 导致数值错误。解决在数据通路中加一个符号扩展模块把 16 位立即数扩展成 32 位最高位复制 16 次。这个模块很简单但漏掉就会导致所有涉及负数的指令出错。4.3 分支指令的延迟槽处理现象BEQ 指令跳转后跳转目标地址的下一条指令执行了两次或者跳转目标不对。原因MIPS 的分支指令有延迟槽即分支指令的下一条指令总是被执行。如果你的 CPU 没有实现延迟槽但测试程序是按有延迟槽写的就会出错。解决要么在 CPU 中实现延迟槽要么在写测试程序时避免在分支指令后放有效指令。对于课程实验我一般建议不实现延迟槽简化设计但要在报告中说明这一点。4.4 存储器读写冲突现象LW 指令读出的数据是旧值或者 SW 指令写入后立即 LW 读不到新值。原因数据存储器的读写时序不对或者读写使能信号在同一个周期内同时有效。解决确保 mem_read 和 mem_write 不会同时为 1LW 指令在时钟上升沿读出数据SW 指令在时钟上升沿写入数据。如果仿真时发现读出的数据滞后一个周期检查存储器的读延迟设置。4.5 综合工具优化掉关键逻辑现象仿真通过但上板后功能不对或者综合报告显示某些模块被优化掉了。原因综合工具可能会把未使用的信号或模块优化掉比如调试用的 PC 输出如果没有连接到顶层端口就会被综合掉。解决在综合选项中关闭“优化未使用信号”或者把调试信号引出到顶层端口。另外确保所有模块的端口都正确连接没有悬空的输入。5. 进阶技巧从能跑到跑得快的优化路径5.1 流水线改造把单周期拆成五级单周期 CPU 跑通之后如果想进一步提升性能可以尝试流水线改造。经典的五级流水线包括取指、译码、执行、访存、写回。每一级之间加流水线寄存器时钟周期可以缩短到原来的一半甚至更短。但流水线会引入数据冒险和控制冒险需要加转发和停顿逻辑。数据冒险的解决方法是转发如果当前指令需要的数据是上一条指令刚算出来的但还没写回寄存器堆可以直接从 ALU 输出转发到当前指令的 ALU 输入。控制冒险的解决方法是分支预测或延迟槽。对于课程实验我建议先实现转发分支用停顿处理这样复杂度可控。下面是一个转发单元的简化逻辑// 转发单元检测数据冒险并选择转发数据 module forwarding_unit ( input [4:0] ex_rs, ex_rt, // 执行级源寄存器 input [4:0] mem_rd, wb_rd, // 访存级和写回级的目的寄存器 input mem_reg_write, wb_reg_write, output reg [1:0] forward_a, forward_b // 00:不转发 01:从WB转发 10:从MEM转发 ); always (*) begin // 默认不转发 forward_a 2b00; forward_b 2b00; // 执行级源寄存器rs与访存级目的寄存器相同 if (mem_reg_write (mem_rd ! 0) (mem_rd ex_rs)) forward_a 2b10; // 执行级源寄存器rs与写回级目的寄存器相同 else if (wb_reg_write (wb_rd ! 0) (wb_rd ex_rs)) forward_a 2b01; // 对rt同理 if (mem_reg_write (mem_rd ! 0) (mem_rd ex_rt)) forward_b 2b10; else if (wb_reg_write (wb_rd ! 0) (wb_rd ex_rt)) forward_b 2b01; end endmodule这段代码的逻辑是如果执行级的源寄存器和访存级或写回级的目的寄存器相同就产生转发选择信号。参数说明forward_a 和 forward_b 分别控制 ALU 两个输入的多路选择器00 表示用寄存器堆读出的值01 表示用写回级的数据10 表示用访存级的数据。注意寄存器 $0 恒为 0不需要转发所以判断条件里加了 mem_rd ! 0 和 wb_rd ! 0。5.2 用波形对比法定位流水线冒险流水线改造后如果发现结果不对最快的定位方法是波形对比法。具体做法是先跑单周期版本的测试程序记录每个周期各信号的值再跑流水线版本对比同一段程序在两个版本中的执行差异。差异出现的位置就是冒险发生的位置。我一般会在测试平台中加一个自动对比逻辑把单周期和流水线的寄存器堆最终值打印出来如果不一致就报错。这样不需要手动看波形仿真跑完直接看结果就行。5.3 资源占用与频率的权衡上板之后如果发现资源占用太高或者频率上不去可以考虑几个优化方向一是把指令存储器和数据存储器合并成一个双端口存储器减少 BRAM 使用量二是把组合逻辑路径拆成两级流水提高时钟频率三是用分布式 RAM 替代块 RAM适合小容量存储。这些优化需要根据具体开发板的资源情况来定没有万能方案。我自己在做课程实验时最深的教训是不要一开始就追求大而全的指令集和流水线先把单周期的几条核心指令跑通把仿真和上板流程走顺再逐步扩展。很多同学卡在“想一步到位”结果连最基本的 add 指令都没验证通过就急着加流水线最后 debug 到崩溃。先跑通再优化这个顺序不能反。希望帮到你。本文还有配套的精品资源点击获取