多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

亲手写CPU:从Verilog到FPGA的计算机组成原理课设全攻略

亲手写CPU:从Verilog到FPGA的计算机组成原理课设全攻略 简介北航计算机学院计算机组成原理课程设计各阶段项目资料包面向正在做计组课设、需要从基础部件到 CPU 实现完整参考的本科生。内容按 P0 至 P8 组织涵盖 Logisim 与 Verilog 两套部件及状态机设计汇编语言下的矩阵乘法、排序与回文串判断以及后续单周期、流水线 CPU 的构建过程课下测试PW与课上测试PT均有对应文件整体结构与课程节奏一致便于按阶段复盘。资源共 1541 个文件压缩包大小约 33.25MB以 Verilog 源码v、Logisim 工程sdb/circ、C 程序c、汇编代码asm和项目配置文件为主可直观查看各模块的电路连接、指令实现与测试逻辑。目前已有 287 人学习下载资料中保留了不同项目的源码与工程备份可当作调试排错时的对照样例也能帮助快速梳理状态机设计、汇编算法、CPU 流水线等关键知识点的自主实现思路。1. 为什么每个学计算机的人都得亲手写一颗 CPU北航计组课程设计到底在做什么真正证明你学懂计算机组成原理的往往不是期末卷面分而是你亲手写出来的那颗 CPU 能不能在没有操作系统、没有标准库的“裸环境”里把一小段机器码老老实实跑对。北航计算机学院的计算机组成原理课程设计就是这个判断标准的落地实践用半个学期左右的时间从指令集设计开始到数据通路、控制器、存储器的 Verilog 实现再到 FPGA 板级验证完整走一遍 CPU 从图纸到硅片的思考路径。它能解决一个很具体的问题——汇编或 C 语言编译器输出的机器码进入硬件之后每个时钟周期到底发生了什么。适合正在上计组理论课、想用硬核方式检验自己的学生也适合拿到类似课程设计题目但不知从何下手的 FPGA 新人。2. 课程设计的第一道选择题用仿真软件还是真实 FPGA2.1 两条路线Logisim 的“所见即所得”与 Verilog 的“文本即硬件”最常见的分岔路出现在开工第一天到底用 Logisim 一类图形化工具搭电路还是直接用 Verilog 写硬件描述。Logisim 的优势非常直观把门电路、寄存器、ALU 一个个拖到画布上连线鼠标点几下就能看到波形教学演示效果极好。但它有个致命短板一旦设计规模超过单周期 CPU连线数量会膨胀到难以维护。移动一个模块后面几十根线跟着乱掉想改一条数据通路得在半张 A3 纸大小的画布上找半天。我见过不少同学把大量时间耗在“拉线拉直”上而不是思考指令怎么走通这是典型的用鼠标证明自己懂硬件。Verilog 的体验则完全相反一行assign alu_b ...代替十几根物理连线模块之间的接口用端口列表就能看清楚。更重要的是Verilog 这套东西往工业方向延伸性极好FPGA 综合、ASIC 前端设计、仿真验证用的都是同一套思路。北航这门课设计里真实 FPGA 平台是更常见的选择Verilog 既是完成课设的手段也是提前接触工程流程的机会。如果你所在的学校没有强制指定工具我建议直接走 Verilog 路线后面综合、上板、调试的每一步都能复用同一份代码。2.2 工具链选型iverilog GTKWave 起步Vivado 收尾仿真与综合是两个不同阶段需要的工具也不一样。仿真阶段我用的是 iverilog 搭配 GTKWave两个都是开源免费工具命令行驱动适合在本地反复跑回归测试。iverilog 对 Verilog-2001 的支持很完善课程设计用到的基础语法基本不会踩坑。综合阶段再用 Vivado 或 Quartus把同一份 RTL 代码烧到板子上。这样安排的好处是仿真环境轻量改完代码几秒钟就能跑一轮综合工具启动慢、工程文件大只留到最后做板上验证。如果你用的是 Windows可以直接装 iverilog 的官方安装包把 bin 目录加进 PATH 就能用。Linux 下更简单apt install iverilog gtkwave一行搞定。我一般会把整个工程按模块拆开每个.v文件单独存放顶层用cpu_top.v统一例化。推荐的最小工程目录结构大概是这样的cpu_top.v顶层模块例化 PC、寄存器堆、ALU、存储器alu.v算数逻辑单元纯组合逻辑regfile.v寄存器堆异步读、同步写control.v主控制器根据 opcode 生成控制信号imem.v/dmem.v指令存储器与数据存储器cpu_tb.v测试激励产生时钟与复位信号导出波形Makefile编译与仿真脚本2.3 第一个最小工程从源码到波形的完整命令环境搭好之后先在最小工程上跑通一个“什么都不干的 CPU”——顶层只有一个 PC 寄存器和一个测试模块看时钟波形能否正常翻转。这一步的意义是验证工具链没问题避免后面写了 500 行代码才发现仿真环境本身有毛病。最小工程的 Makefile 长这样SIM iverilog TARGET cpu_tb.vvp SRC cpu_top.v alu.v regfile.v control.v imem.v dmem.v cpu_tb.v all: $(TARGET) vvp $(TARGET) $(TARGET): $(SRC) $(SIM) -o $(TARGET) $(SRC) wave: all gtkwave cpu_tb.vcd clean: rm -f $(TARGET) cpu_tb.vcdiverilog命令把多个源码文件编译成一个 vvp 仿真目标vvp负责执行仿真并生成 VCD 波形文件最后用gtkwave打开波形。-o参数指定输出文件名不写的话默认叫a.out我习惯统一命名为cpu_tb.vvp。wave目标把仿真和查看波形串在一起clean用于清理编译产物。这一步跑通后你再往里加真实逻辑遇到问题至少知道锅不在工具链。提示testbench 里记得写$dumpfile(cpu_tb.vcd)和$dumpvars(0, cpu_tb)否则 GTKWave 打开一片空白。这是新手最容易漏的一行。3. 从指令集到数据通路课程设计的灵魂不在地上而在纸上3.1 指令集选型MIPS 子集是安全牌RISC-V 是加分项许多同学拿到题目后第一反应是打开 Vivado 新建工程这其实是本末倒置。CPU 设计的起点是指令集指令集没定数据通路无从谈起。课程设计的主流选择是 MIPS 的子集原因很现实MIPS 指令编码极其规整所有指令都是 32 位定长opcode 固定占 6 位寄存器号固定占 5 位这样的规则让控制器的编写变得机械而可靠。唐朔飞那本教材把 MIPS 数据通路图拆得很细适合对着抄白中英的实验指导书则偏向具体实验步骤做课设时两者可以互相补充。王道这类复习资料适合考研刷题但到课设阶段真正救你的是教材里的通路图。一个能通过验收的最小指令集通常包含 7 条指令R 型的add、sub、and、or、sltI 型的addi、lw、sw、beq再加上一条j无条件跳转。这个子集覆盖了算术运算、逻辑运算、访存、分支、跳转全部五类基本行为足够写一个数组求和或斐波那契程序来验证 CPU 正确性。RISC-V 是另一个可选方向指令编码更简洁但参考资料的丰富程度暂时还比不上 MIPS。如果学校没有硬性指定MIPS 子集是投入产出比最高的方案。3.2 数据通路与控制信号一张表解决 70% 的代码确定了指令集下一步是画数据通路图。你会发现所有指令的执行路径高度相似取指→读寄存器→ALU 计算→访存→写回。区别只在于某些阶段对特定指令是“空操作”。把这些差异抽象成控制信号就得到课程设计里最重要的一张表。指令opcodeRegDstALUSrcMemReadMemWriteMemToRegBranchJumpRegWriteALUOpR 型01000000110addi80100000100lw350110100100sw43x101x00000beq4x000x10001j2xx00x010xx这张表里的每个信号都有明确含义RegDst决定写回寄存器的地址来自 rd 还是 rtALUSrc决定 ALU 的第二操作数是寄存器还是立即数MemToReg决定写回数据来自 ALU 结果还是存储器ALUOp则告诉 ALU 控制模块当前指令需要哪种运算。填表的方法很简单拿一条指令沿着数据通路从头走到尾哪些模块被真正用到就把对应信号置 1用不到的置 0。比如sw指令不写寄存器所以RegWrite是 0它也不需要把数据写回寄存器堆所以MemToReg是无关项 x。3.3 控制信号真值表的推导方法纸上谈兵比仿真快十倍很多同学喜欢直接在代码里改控制信号然后跑仿真看波形对不对这其实非常低效。仿真一轮几十秒改一次代码可能引入新的错误来回几趟半天就没了。正确做法是先拿一张纸把上表完整推一遍确认每一条指令的每个控制信号都合理再打开编辑器写代码。纸上推演的过程本质上就是把 408 真题里那道经典的“某条指令在数据通路各阶段发生了什么”反过来做——真题是给你通路求信号课设是给你指令设计通路。推荐一个具体的推导顺序。先把所有指令按类型分组R 型一组I 型访存一组I 型分支一组跳转一组。对每一组画出它专属的那条通路路径比如lw的路径是指令存储器→寄存器堆读 rs 和 rt→ALU 用 rs 加立即数→数据存储器读→写回 rt。路径画完逐个信号打勾打叉最后汇成一张完整的控制表。这个过程可能花掉一整个晚上但它能帮你节省至少两天的仿真排错时间。你后面写control.v时实际上只是在把这张表翻译成 case 语句。注意ALUOp 是两位信号含义必须自己定义清楚。我习惯用 00 表示加法、01 表示减法、10 表示由 funct 字段决定这样beq和add能共用 ALU 的加/减逻辑控制器代码也能少写几行。4. 用 Verilog 把数据通路变成能仿真的 CPU三段核心代码4.1 ALU 与顶层模块组合逻辑的写法与 zero 信号理论和方案都定好了这才轮到写代码。以 MIPS 单周期 CPU 为例实现顺序建议从内到外先写 ALU再写寄存器堆再写控制器最后用顶层模块把它们串起来。ALU 本身没有任何时序逻辑是一个纯粹的组合逻辑块输入端是 32 位的 a 和 b外加 3 位控制信号输出端是 32 位结果和 1 位 zero 标志。module alu( input [31:0] a, input [31:0] b, input [2:0] aluctrl, output reg [31:0] result, output zero ); assign zero (result 32b0); always (*) begin case (aluctrl) 3b000: result a b; 3b001: result a - b; 3b010: result a b; 3b011: result a | b; 3b100: result (a b) ? 32b1 : 32b0; default: result 32b0; endcase end endmodule课上讲过的组间串行进位与超前进位在这里被抽象成了行为级的加法真正需要关注的是zero信号的产生方式——它由 result 全零组合判断得到beq指令就是靠它决定是否跳转。注意a b这条分支按无符号数比较处理若要支持有符号比较需要额外判断符号位课程设计阶段一般用无符号就够了。default分支兜底置零避免综合时产生不必要的锁存器。4.2 控制器把真值表翻译成 case 语句控制器是整个 CPU 里最“无聊”但也最容易出错的模块。它的输入只有 6 位 opcode输出是第 3 章那张控制表的全部信号。一个常见的坏习惯是在每个 case 分支里只赋需要改变的信号其他信号保持上一个值这样综合工具会认为你有隐式锁存。正确做法是在 always 块开头把所有输出全部置零再按指令逐个置位。module control( input [5:0] opcode, output reg regdst, output reg alusrc, output reg memread, output reg memwrite, output reg memtoreg, output reg branch, output reg jump, output reg regwrite, output reg [1:0] aluop ); always (*) begin regdst 0; alusrc 0; memread 0; memwrite 0; memtoreg 0; branch 0; jump 0; regwrite 0; aluop 2b00; case (opcode) 6b000000: begin regdst 1; regwrite 1; aluop 2b10; end 6b001000: begin alusrc 1; regwrite 1; aluop 2b00; end 6b100011: begin alusrc 1; memread 1; memtoreg 1; regwrite 1; aluop 2b00; end 6b101011: begin alusrc 1; memwrite 1; aluop 2b00; end 6b000100: begin branch 1; aluop 2b01; end 6b000010: begin jump 1; end default: ; endcase end endmoduleALUOp2b10的指令还需要一个 ALU 控制子模块根据 funct 字段把 opcode 翻译成具体的aluctrl。这个子模块代码量更少本质上也是一个 case把add、sub、and、or、slt的 funct 值映射到 ALU 的 3 位控制信号上即可。注意控制器是纯组合逻辑所以 always 块里全部用阻塞赋值这一点和第 4 节寄存器堆的处理方式形成鲜明对比。4.3 寄存器堆与存储器异步读、同步写的边界寄存器堆是 CPU 里最容易踩坑的模块。它的读操作必须是组合逻辑——只要给出地址数据立刻出现在输出端而写操作必须在时钟上升沿触发。这种“异步读、同步写”的模式是单周期 CPU 能在一个周期内完成读寄存器→计算→写回的关键。如果你把读也做成时序逻辑beq的分支判断就会晚一个周期整条流水线的节奏全乱。module regfile( input clk, input we, input [4:0] raddr1, input [4:0] raddr2, input [4:0] waddr, input [31:0] wdata, output [31:0] rdata1, output [31:0] rdata2 ); reg [31:0] regs [0:31]; integer i; initial begin for (i 0; i 32; i i 1) regs[i] 32b0; end assign rdata1 regs[raddr1]; assign rdata2 regs[raddr2]; always (posedge clk) begin if (we) regs[waddr] wdata; end endmoduleassign语句完成异步读always (posedge clk)完成同步写。initial块把 32 个寄存器全部清零这一步不能省——仿真器里的 reg 变量默认是高阻态 X不清零的话所有运算结果都是 X波形乱七八糟。数据存储器的写法和寄存器堆类似唯一的区别是要处理字节地址到字地址的转换。MIPS 的lw/sw指令地址是字节地址而存储器用字索引所以索引部分写addr[31:2]才能正确访问。5. 避坑 / 排查课程设计里最容易翻车的五个细节5.1 寄存器堆写不进去仿真波形全是 X现象写使能we已经拉高wdata数据也对但下一次读出来的寄存器值还是 X 或 0。 原因八成是寄存器堆没有初始化。Verilog 仿真器里reg型变量默认是 X如果你没有在 initial 块里清零任何读操作都会把 X 传播到后续所有计算。 解决在regfile模块里加一个initial循环清零见 4.3 节代码。板上验证时则要把regs放到复位信号控制的 always 块里确保开发板一上电就进入确定状态。5.2 数据存储器地址错位读出来的数永远不对现象sw $t2, 0($zero)明明写入了地址 0执行lw $t3, 0($zero)读出来的却不是同一个数。 原因MIPS 的访存地址是字节地址而memory[0]表示第 0 个字。如果你直接用addr作为数组下标等价于访问了第 0 字节而不是第 0 个字数据错位四个字节。 解决数据存储器模块里统一用addr[31:2]做索引把字节地址除以 4 得到字地址。这个坑几乎每个做 MIPS 的人都会踩一次写代码前先在纸上确认地址位宽关系。5.3 beq 后面多执行了一条指令现象分支条件成立时PC 没有立刻跳到目标地址反而先执行了分支指令的下一条。 原因你的 PC 更新逻辑和教材上的 MIPS 经典实现不一致。教材里 MIPS 有延迟槽分支指令后面的那条指令无论如何都会执行而课程设计如果按简化单周期实现PC 必须在本周期就用branch和zero的组合结果完成跳转。 解决先搞清楚题目要求的是“带延迟槽”还是“无延迟槽”的 MIPS。如果无延迟槽PC 的写使能逻辑里要加(branch zero)判断并且跳转地址用 ALU 计算出来的结果。仿真时用一条beq加上下一条nop观察 PC 波形一眼就能看出问题。5.4 仿真全绿、板上全黑时序问题现象iverilog 仿真跑完所有测试都正确但烧进 FPGA 后 LED 不亮、数码管乱跳。 原因仿真器默认是理想时序真实 FPGA 上有时钟偏斜、建立时间、复位抖动。常见触发因素包括开发板晶振频率太高直接当 CPU 时钟、复位按键没有消抖、计数器分频位数不够导致分频系数错误。 解决板上时钟先用一个计数器分频到几 MHz 级别或者直接调用 FPGA 厂商的 PLL 核复位信号要通过按键模块做简单消抖如果怀疑时序问题先用逻辑分析仪或集成逻辑分析核抓内部信号别靠肉眼猜。5.5 同样的代码综合结果和仿真不一致现象仿真里功能完全正确但下载到板上以后行为诡异有时数据错、有时偶发复位。 原因在时钟驱动的 always 块里混用了阻塞赋值。时钟沿触发的逻辑必须用非阻塞赋值否则多个触发器同时更新时会出现仿真和综合语义不一致严重时产生亚稳态。 解决遵守一条铁律——always (posedge clk)块里一律用always (*)组合逻辑块里一律用。这条规则没有任何例外别为了省事把混进时序块里。6. 验证方法论让 CPU 在仿真里把程序跑对的最后一百米6.1 最小 testbench 的写法与波形检查点代码写完之后验证的优先级比功能扩展更高。我的习惯是先写一个最简单的 testbench只产生时钟和复位信号让 CPU 跑一条addi指令然后在 GTKWave 里逐个检查 PC、指令、寄存器堆输出、ALU 结果这几处波形。最小 testbench 长这样module cpu_tb; reg clk 0; always #5 clk ~clk; cpu_top u_cpu( .clk(clk), .rst(rst) ); initial begin rst 1; #12 rst 0; $dumpfile(cpu_tb.vcd); $dumpvars(0, cpu_tb); #1000 $finish; end endmodule#5表示时钟周期为 10 个时间单位rst 拉高 12 个时间单位后释放保证 CPU 在稳定时钟下开始工作。$dumpvars(0, cpu_tb)把整个 testbench 层级下的所有信号都导出到 VCD 文件。检查波形时看三个关键点PC 是否按 4 递增、寄存器堆的写地址和写数据是否对得上、ALU 的 zero 信号在beq执行时是否正确。6.2 用一条汇编程序做系统性回归单指令测试通过后写一段真正有逻辑的汇编程序让它跑一个数组求和然后比较结果。我常用的一段测试代码addi $t0, $zero, 5 # t0 5 addi $t1, $zero, 7 # t1 7 add $t2, $t0, $t1 # t2 12 sw $t2, 0($zero) # mem[0] 12 lw $t3, 0($zero) # t3 mem[0] 12 beq $t2, $t3, 8 # 相等则跳过下一条 addi $t4, $zero, 1 # 不该执行 addi $t5, $zero, 99 # 跳转到这里把这段汇编手工翻译成机器码以$readmemh的方式加载到指令存储器里仿真结束后检查t2、t3、t5的值。这个测试覆盖了算术、访存、分支三类核心行为比单独测每一条指令更能暴露数据通路的边界问题。如果这段程序能在大约 40 个时钟周期内跑完且结果全对单周期 CPU 的核心功能就算过了关。6.3 进一步验证写回数据检查与板上输出仿真验证只能证明行为正确板上验证才是课程设计的最终验收。常见做法是把 CPU 算出的结果映射到 8 个 LED 或者数码管上用拨码开关输入数据。我踩过的一个教训是板上输出显示的是 HEX 值看惯了二进制的人极容易把十六进制误读成十进制浪费一晚上查一个根本不存在的 bug。如果你的板子有 UART建议挂一个最简单的发送模块把寄存器的值用串口打印出来验证效率比数 LED 高一个量级。时间允许的话下一步就是把单周期改成流水线。流水线的核心不是多写几个寄存器而是处理数据冒险和分支冒险写 forwarding 逻辑解决 ALU 数据前递写 stall 逻辑解决 load-use 冲突最后把分支判断提前到译码阶段。这个过程会逼迫你重新审视每一份控制信号我对 CPU 的理解就是在那一次重构里真正上了一个台阶。最后说点个人习惯我每次改完代码都会先跑一遍回归再碰 FPGA这样哪一层出了问题回退起来不慌。希望帮到你。本文还有配套的精品资源点击获取
返回列表