简介:北航计算机学院计算机组成原理课程设计完整资料包,涵盖P0至P8各阶段项目要求,覆盖Logisim部件与状态机、Verilog HDL实现、汇编语言、单周期CPU与流水线CPU设计等核心内容,适合计算机专业本科生、课程学习者及准备相关实验的考生对照练习。包内1541个文件,以733个v源码文件为核心,辅以sdb电路文件、asm汇编代码、didat仿真数据、c与obj编译结果等,可完整还原从电路搭建到CPU实现的各环节;另有docx/pdf说明文档与工程配置文件,便于查阅和复现。资源包33.25MB,目录按P0-P8分模块组织,浏览学习人数已达287人。针对课下测试与课上测试两道场景,资料梳理了CRC校验电路、ALU、矩阵乘法、排序、回文判断、指令扩展与流水线工程化等关键任务,可作为赛题方案、设计思路和调试参考。
1. 计算机组成原理课程设计:北航这门课到底在逼你做什么
第一次打开北航计算机学院的计算机组成原理课程设计文档时,我以为只是把前几周实验的运算器、存储器拼一拼,结果发现期末要交的是一颗能从外部取指令、译码、执行、访存、写回的最小处理器。这门课设计不是考背诵,它要求你设计一个支持指定指令集的 CPU 模型,通常至少涵盖算术运算、逻辑运算、取数存数和转移控制四类指令;设计环境有的班允许 Logisim,有的班直接上 Verilog,验收方式从仿真波形到板级下载都有。它最大的价值是把计算机组成原理这门课里背过的真值表、数据通路图变成一张你能对着它讲清楚每个信号为什么这么连的电路。适合的人是正在赶课设的本科生,以及想借此搞明白处理器为什么能跑起来的入门者。
2. 课设前的架构决策:指令集选型和数据通路风格直接决定你的工作量
2.1 指令集选型:MIPS 子集和 RISC-V 怎么选
课程设计最忌讳把题干要求的指令先放着,上来就画电路。你先得把“支持哪些指令”落在纸面上,才能反推出哪些引脚、哪些控制信号是必须的。以北航课设最常见的参考来看,指令风格偏向 MIPS,因为它的编码格式和教材章节的对应关系最顺:算术指令对应运算器,访存指令对应存储器,分支指令对应 PC 更新和比较逻辑。唐朔飞教材里的数据通路,几乎就是照着 MIPS 单周期处理器画的,所以选 MIPS 子集能少走很多弯路。
RISC-V 近年热度很高,理论上也更现代,但课程设计的坑在于参考资料散、指令编码格式和教材对不上。除非指导老师明确允许并展开讲 RISC-V,否则我不建议在课设里用 RISC-V 从头造轮子。一个够用的 MIPS 子集,我一般保留下面这 8 条指令:
- add、sub、or 三条 R 型算术逻辑指令
- ori 一条带立即数逻辑指令,用来初始化寄存器
- lw 和 sw 两条访存指令
- beq 和 j 两条转移指令
这 8 条能组合出算术运算、数组遍历、循环和函数调用场景,覆盖课设验收绰绰有余。指令再多,比如加上 mult、div,ALU 和控制逻辑的复杂度翻倍,但得分不会翻倍。
指令编码最好用脚本生成,纯手算二进制很容易把 rs、rt、rd 字段写串。下面这段 Python 把 R 型和 I 型的字段拼成 32 位整数,打印成固定宽度十六进制,生成的机器码可以直接喂给仿真器和 .mem 文件:
def encode_r(rs, rt, rd, func, shamt=0): # R 型字段: op(6) rs(5) rt(5) rd(5) shamt(5) func(6) return ((0 & 0x3F) << 26) | ((rs & 0x1F) << 21) | ((rt & 0x1F) << 16) \ | ((rd & 0x1F) << 11) | ((shamt & 0x1F) << 6) | (func & 0x3F) def encode_i(op, rs, rt, imm16): # I 型字段: op(6) rs(5) rt(5) imm16(16) return ((op & 0x3F) << 26) | ((rs & 0x1F) << 21) | ((rt & 0x1F) << 16) \ | (imm16 & 0xFFFF) # add $8, $9, $10 : func 字段为 0x20 print("0x%08X" % encode_r(9, 10, 8, 0x20)) # ori $8, $9, 0xFF : op 字段为 0x0D print("0x%08X" % encode_i(0x0D, 9, 8, 0x00FF))逻辑说明:encode_r 里 op 固定为 0,这是 MIPS 约定 R 型指令的高 6 位全零。rs、rt、rd 分别对应两个源操作数和写回目标寄存器,调换位置整条指令的语义就变了。encode_i 里 imm16 直接截断到 16 位,符号扩展要在 CPU 内部完成而不是在编码器里做,否则 beq 的偏移量会算错。func 字段是重点,add 是 0x20、sub 是 0x22、or 是 0x25,写错一位,ALU 就会执行完全不同的操作。
2.2 单周期还是流水线:按验收标准反推数据通路结构
北航课设的常见验收标准是“指令功能全部正确,能跑通给定程序”,而不是“时钟频率尽量高”。据此,单周期是最稳的选择:一条指令一拍完成,控制信号全靠译码器组合输出,不需要考虑数据冒险和转发;你甚至不需要单独的 Forwarding 逻辑,调试成本低一个量级。缺点是用最长路径决定整个时钟周期,CPI 不好看,但对课程设计这是优点,因为你有大量时间花在功能验证上而不是频率优化上。
流水线作为加分项可以后置。我见过不少同学一开始就想做五级流水,结果在数据冒险上耗掉两周,最后只能减配交付。更合适的路径是先做出完全正确的单周期 CPU,再插入流水段寄存器,把流水线作为第二阶段的进阶目标。如果你在复习考研,这条单周期数据通路和备考时刷王道计算机组成原理掌握的知识点是一致的,两者可以并行理解。
| 结构 | 单条指令周期 | 主要难度 | 调试点 | 推荐场景 |
|---|---|---|---|---|
| 单周期 | 固定一个时钟周期 | 较低 | 单拍波形 | 默认选择 |
| 多周期 | 依指令而定 | 控制状态机 | 状态跳转 | 想练有限状态机 |
| 五级流水 | 理想 1 CPI | 冒险与旁路 | 时序深度 | 加分项,后期做 |
2.3 工具链选型:Logisim 和 Verilog 各自适合哪类人
Logisim 的最大优点是可以直接上手做实验,连线即所得,仿真时能看到每一位信号在电路里的走向;当堂验收时逻辑非常直观,适合画数字电路熟练的同学。它的缺点是总线一多就乱,寄存器堆和存储器用图形方式连接时要格外小心,改一处控制逻辑往往要拖一大片线。
Verilog 的优点是可综合、方便做自动化回归,并且后续上板路线顺,适合在头歌这类实验平台上已经写过 Verilog 代码的同学。缺点是抽象度高,信号之间靠命名关联,你容易写出生理上能通过语法检查但时序上完全不合理的代码。我的选择建议是:如果只在仿真环境验收、画图能力强,选 Logisim;如果还想把作品留在简历上或者有上板机会,就选 Verilog。
工具选择不影响架构,但会决定你调试信号的粒度。Logisim 适合单步走,Verilog 适合同时打印几十个信号的波形。后续章节的代码以 Verilog 为主,但每一模块在 Logisim 里的对应位置我都会说明。
3. 把 CPU 拆成可测试单元:ALU、寄存器组与译码逻辑的手写实现
刚开始做课程设计时,最容易犯的错误是把整个 CPU 画成一张超级大图,然后对着图发愁。正确做法是把处理器拆成 ALU、寄存器组、指令存储器、数据存储器、控制器五个单元,每个单元单独验证,最后再对接。这样出问题时你能定位到具体模块,而不是在一整片电路里盲目试错。
3.1 ALU:算术逻辑单元的最小可测实现
ALU 是第一个要实现的模块。北航实验课第一周就是运算器,课程设计里的 ALU 只是把它扩成 32 位。常见设计需要支持加、减、与、或、异或,至少这五个。控制信号 ctrl 一般给 4 位,留出两位扩展位给后续逻辑。重点不是真值表背得多熟,而是确认两个操作数来自寄存器组还是立即数扩展,这一步由数据通路层的 ALUSrc 信号决定,不归 ALU 自身管理。
下面是最小 Verilog 实现,带 default 分支,避免综合时生成 latch:
module alu_32 ( input [31:0] a, b, input [3:0] ctrl, output reg [31:0] result, output zero ); parameter ADD = 4'b0010, SUB = 4'b0110; parameter AND = 4'b0000, OR = 4'b0001, XOR = 4'b0011; always @(*) begin case (ctrl) ADD: result = a + b; SUB: result = a - b; AND: result = a & b; OR: result = a | b; XOR: result = a ^ b; default: result = 32'b0; endcase end assign zero = (result == 32'b0); endmodule逻辑说明:这是一个纯组合逻辑模块,always @(*)表示输出只随输入变化,不依赖时钟沿。zero信号专门给 beq 用,控制器后续看到 zero 为 1 就拉起 Branch 信号。参数值 ADD、SUB 不要随意改,建议和数据通路里其他模块共用一份宏定义,否则后期管理很容易出现功能和位宽对不上的情况。
3.2 寄存器组与指令译码:从机器码到控制信号
寄存器组是 CPU 里状态量最多的模块:32 个 32 位寄存器,两个读口和一个写口。读口是异步的,只要给出地址就输出;写口是同步的,只在时钟上升沿并且 RegWrite 使能时写入。寄存器$0必须硬连线为 0,这是 RISC 指令集的基本约定,如果忽略它,某条算术指令的结果会莫名多出初始值。
指令译码是连接指令位域和控制信号的桥梁。以 lw、sw、beq 三条 I 型指令为例,控制逻辑至少需要产出五个信号:RegDst、ALUSrc、MemtoReg、RegWrite、MemWrite。用 Verilog 写控制真值表时,我习惯把每条指令显式列全,而不是用一个大判断式隐式表达,否则漏一条指令很难查:
module controller ( input [5:0] op, // 指令高 6 位 op 字段 output reg reg_write, mem_write, branch, jump, output reg alu_src, mem_to_reg, reg_dst ); parameter LW = 6'b100011; parameter SW = 6'b101011; parameter BEQ = 6'b000100; parameter J = 6'b000010; always @(*) begin // 位序: {reg_write, mem_write, branch, jump, alu_src, mem_to_reg, reg_dst} {reg_write, mem_write, branch, jump, alu_src, mem_to_reg, reg_dst} = 7'b0000000; case (op) LW: {reg_write, mem_write, branch, jump, alu_src, mem_to_reg, reg_dst} = 7'b1000111; SW: {reg_write, mem_write, branch, jump, alu_src, mem_to_reg, reg_dst} = 7'b0100100; BEQ: {reg_write, mem_write, branch, jump, alu_src, mem_to_reg, reg_dst} = 7'b0010000; J: {reg_write, mem_write, branch, jump, alu_src, mem_to_reg, reg_dst} = 7'b0001000; endcase end endmodule这段代码里最需要注意位序:集合赋值按声明顺序从高到低排列。比如 lw 要写回寄存器、需要把访存数据送到寄存器组输入端,所以 reg_write=1、mem_to_reg=1、reg_dst=1;同时访存地址来自寄存器加上立即数,因此 alu_src=1。beq 不需要写回任何目标寄存器,所以 reg_write、mem_to_reg、reg_dst 全是 0,只有 branch 和 alu_src 保持指定值。
3.3 模块测试:别等数据通路搭完才验证
单独对 ALU 做模块测试,是课设里最划算的几分钟。Verilog 下用 testbench,Logisim 下用探针加常量输入,方法一致:每组输入配一个期望输出。前几周在头歌这类平台上做计算机组成原理实验时也是同一套流程,拆一个模块验证一个模块。
这是最简 ALU testbench,输出用$display打印,便于和手算结果对账:
reg [31:0] a, b; reg [3:0] f; wire [31:0] y; alu_32 uut (.a(a), .b(b), .ctrl(f), .result(y), .zero(q)); initial begin a = 32'd15; b = 32'd7; f = 4'b0010; #10; $display("add: %0d", y); f = 4'b0110; #10; $display("sub: %0d", y); f = 4'b0001; #10; $display("or : %0d", y); $finish; end这里的#10只是仿真延时,不是时序设计里的周期约束,但记得给足,否则显示的是同一仿真时间片下的旧值。测试寄存器组时更要注意:写口的结果只在时钟上升沿后可见,如果只在 initial 里连续赋值,看到的是组合逻辑输出而不是寄存器真实状态,所以测试寄存器组要用 repeat 循环打时钟。
提示:模块测试请保留成文件,不要跑完就删。答辩时被问到“你怎么验证 ALU 正确”时,把 testbench 调出来展示,比口头解释有说服力得多。
4. 数据通路搭接与联调:从单条指令跑到完整测试程序
4.1 数据通路总图与多路开关信号对齐
把 ALU、寄存器组、两块存储器和控制器对接起来之前,先画一张数据通路总图。图上要有 PC、指令存储器、寄存器组、ALU、数据存储器、控制器的位置,并标出每个多路器的选择信号名。北航课设的评审往往要看报告,缺这张图会很吃亏,更重要的是画图的过程能逼你把每根连线都想清楚。
连接顺序我按教材习惯:取指 → 译码 → 执行 → 访存 → 写回。最容易乱的是三个 MUX:
- RegDst:选择把 rt 还是 rd 写入寄存器组,R 型指令必须选 rd。
- ALUSrc:选择寄存器值还是立即数扩展值送入 ALU 的 B 端。
- MemtoReg:选择访存读出数据还是 ALU 计算结果写回寄存器组。
Verilog 里可以用条件运算符实现这三个 MUX,但要注意它们不是同一层级的信号。RegDst 由指令 op 字段决定,ALUSrc 由指令类型决定,MemtoReg 只有 lw 需要置 1。三个信号如果共用一套判断逻辑,后续扩展指令时会互相污染。
4.2 时钟、复位和写使能:三条最容易出错的信号线
第一次跑波形时你会看到两个奇怪现象:寄存器安安静静吞掉了本该写入的数据,或者数据在半路上消失。这些问题通常不是逻辑错误,而是信号采样时机不对。
第一,寄存器组的写操作必须发生在时钟上升沿,并且要有 RegWrite 门控。有些仿真器默认数据变化就写入,RegWrite 没拉高也把数据灌进了寄存器,这是仿真器行为与真实硬件不一致的典型陷阱;等你上板时发现寄存器值总是不对,再回头查这个问题会非常痛苦。
第二,复位信号最好用同步复位。也就是说 rst 为高时,在下一个时钟上升沿把 PC 清零、寄存器组恢复初值。异步复位在仿真里常表现正常,但板级会有毛刺,下载后开机状态不可控。
第三,PC 更新逻辑不要用组合逻辑自增。应该把 PC 声明为reg [31:0],在 always 块里同步更新:pc <= pc + 4或pc <= jump_target。组合自增会让当前指令执行和下一周期取指并行变化,单周期还好,一旦转向流水线,整条逻辑就废了。
4.3 跑一个能证明全指令集的测试程序
当数据通路所有 MUX 对齐后,用一段完整程序做回归验证。我推荐“从数据存储器中找最大值”这个小程序:它同时覆盖 R 型指令、ori 立即数初始化、lw 访存、sw 写回、beq 分支和 j 无条件跳转,比单独跑单条指令可靠得多。
Verilog 仿真时,先用脚本生成 .mem 文件,在 testbench 里用$readmemh加载进指令存储器和数据存储器,然后复位跑若干周期,打印关键寄存器和 PC:
// tb_top.v 最小验证段 cpu_top cpu ( .clk(clk), .rst(rst), .pc(pc_wire), .inst(inst_wire), .r8(reg8_wire) // 把寄存器组 8 号寄存器引到顶层方便观察 ); initial begin $readmemh("prog.mem", cpu.imem.mem); // 加载指令 $readmemh("data.mem", cpu.dmem.mem); // 加载数据 clk = 0; rst = 1; #10 rst = 0; repeat (200) begin #5 clk = 1; #5 clk = 0; end $display("R8 = %0d, PC = %0d", reg8_wire, pc_wire); if (reg8_wire == 32'd42) $display("TEST PASS"); else $display("TEST FAIL"); $finish; end逻辑说明:prog.mem 每行一个 32 位十六进制机器码,起始地址从 0 开始;cpu.imem.mem 和 cpu.dmem.mem 是具体例化名,换成自己工程的层次路径即可。repeat(200) 是防死循环保险,如果程序有 bug 导致 PC 卡住,200 个周期后也会自动停止并打印错误寄存器值。测试程序不要只覆盖三条指令,至少要保证每条指令都执行一次,并且出现“访存后立即使用该数据”的相关场景。
5. 课设避坑记录:北航 CPU 设计最常见的五个翻车点
过了设计和实现阶段,接下来就是没人能逃过的查错阶段。下面五条是我在带课设和答辩时反复见到的翻车点,按照“现象、原因、解决”整理,属于血泪经验。
5.1 现象:模块测试全通过,整机跑必现错误
这是最打击人的情况:ALU、寄存器组、数据存储器单测都对,一接成整机就从第三条指令开始错。原因通常不在模块本身,而在多路器的选择信号没有按指令类型切换。比如 lw 指令要求写回访存数据,如果 MemtoReg 在控制真值表里恒为默认值,写回的就会是 ALU 计算结果,差之毫厘。如果你是软件方向来补课,很容易下意识默认“寄存器里存的数就是原来那个数”,绕开了写回路径这个黑匣子,自然找不到错。
解决:先用单步时钟,把出错指令的所有控制信号打印出来,和 3.2 节的真值表逐位对比,不要上来就怀疑 ALU。
5.2 现象:要写入的数据总是晚一个周期才落进寄存器
这个现象几乎每个做课设的人都会遇到一次。表现是你期望第 5 个时钟周期末寄存器组里出现新值,结果第 6 个周期初才看到。原因多半是 RegWrite 的使能时间没对准,或者 testbench 里对写口的激励写在了时钟下降沿,而寄存器组只在上升沿采样,数据等于白送一个周期。
解决:调整时钟边沿与写使能做正交,保证“指令在第 i 拍执行完,结果在第 i 拍上升沿写入,在第 i+1 拍可以被读到”。观察波形时重点看写使能信号相对于时钟边沿建立得够不够早,不要只看结果最终是否出现。
5.3 现象:仿真波形正常,下载到板子后直接哑火
仿真通过代表逻辑正确,不代表板级时序正确。最常见的诱因是时钟过快。开发板默认晶振常在 50 MHz 到 100 MHz 量级,而课程设计里的 CPU 没有做静态时序收敛,存储器 IP 和引脚走线跟不上。
解决:把时钟分频到 1 MHz 到 10 MHz 量级,再用同步复位把 PC 和关键状态清零。分频常用的做法是在顶层模块写一个计数器,对板载时钟按数万分频后输出到 CPU 的 clk 端口。不要追求运行频率,课设评审不会因为你跑 100 MHz 加分,只会因为你跑不起来扣分。
5.4 现象:某条指令执行时整条总线数据变成 X
X 在 Verilog 里表示未驱动或发生冲突。你会在波形里看到数据总线上出现了几个未知位,而且每次仿真出现的位置还一样。原因基本逃不出两处:一是控制器里 case 没有 default,非法控制组合输出高阻态;二是 MUX 的条件判断没写完整,两个分支同时向同一根线赋值。
解决:给控制器的 case 补 default 分支,让所有信号在非法 op 下归零;给每一个多路器补 else 分支。这个行为能让仿真波形干净,更是可综合代码的基本要求。总线上出现 X 时,顺着波形往上找哪一级输出在高阻,通常就是问题模块。
5.5 现象:分支跳转总是跑到错误地址
beq 指令的跳转目标计算有两个边界:立即数是否做了符号扩展,以及目标地址到底是PC+4+sign_ext(imm)*4还是PC+sign_ext(imm)*4。MIPS 约定分支基于 PC+4,如果你的 ALU 在计算跳转地址时拿错了基址,表现就是每条分支固定偏移错一段距离,而且条件成立和不成立的路径偏差还不一样。
解决:单独构造两条 beq 测试,一条条件成立、一条条件不成立,分别打印跳转前后的 PC。差多少、差在正负方向,都能告诉你是不是符号扩展写成了无符号扩展。我遇到过一位师弟在这个地方卡了一整天,最后发现是编码器里把负数 immed 按无符号截断,汇编层就得改。
6. 验收前别急着交:用一套测试向量证明你的 CPU 是对的
靠代码里的$display打印一个寄存器值就算通过,这是最危险的验收方式。更稳的流程是把测试向量组织成一张表,每个用例对应一组指令、一个期望结果,跑完之后程序自动比对。
| 用例 | 覆盖点 | 检查内容 |
|---|---|---|
| 单条 R 型指令 | add、sub、or | 目标寄存器结果 |
| ori 立即数初始化 | 符号扩展与位宽 | 初始值正确 |
| 连续 lw、sw | 访存读写 | 内存值与寄存器一致 |
| beq 成立与不成立 | PC 跳转两个方向 | 目标地址或顺序地址 |
| j 跳转 | 绝对地址跳转 | PC 落到目标 |
| 数组最大值程序 | 全部指令组合 | 结果寄存器符合预期 |
这套表不需要额外工具,把 5 个用例写成一个 testbench 驱动,每个用例跑固定周期数并打印结果就行。我自己的习惯是:在写代码之前先手算出每个用例的期望值,放进测试脚本里;仿真跑出不一致就停下来改,而不是在波形窗口里靠肉眼找问题。
这里有一条真实教训:有一版 CPU 所有测试都通过,我提交前临时加了一条加载立即数指令,结果得意到忘了重新回归前面的用例。答辩时讲师随手给了个负数偏移的 beq,直接暴露了符号扩展的 backlog。后来我把所有用例做成了可重复执行的回归脚本,每次改完立刻跑一遍,再也没在验收时翻车。
希望这个方向能帮你少走几步弯路。
本文还有配套的精品资源,点击获取