尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Verilog单周期CPU设计实战:数据通路、控制信号与仿真验证

发布时间:2026/9/9 0:55:27

资讯中心
01
ARTICLE

Verilog单周期CPU设计实战:数据通路、控制信号与仿真验证

Verilog单周期CPU设计实战:数据通路、控制信号与仿真验证
简介面向计算机组成原理与数字逻辑课程学习者这套Verilog单周期处理器源码提供可直接运行的完整设计参照解决实验课中单周期处理器数据通路搭建与仿真验证难的问题。文件包内有两个压缩包一个为完整工程一个为可直接导入的函数库二者选其一即可使用便于在不同开发环境下快速复用。整个压缩包共85个文件以.v硬件描述源码为主覆盖算术逻辑单元、程序计数器、寄存器堆、指令存储器、数据存储器、扩展器及多路选择器等核心模块并包含Vivado工程配置、仿真波形设置和说明文档整体仅3.03MB结构紧凑、便于迁移学习。源码采用模块化设计层次清晰可对照仿真波形逐周期检查取指、译码、执行与写回过程也有助于理解各控制信号如何协调各部件完成数据通路操作。目前已有11496人浏览学习适合课程实验、竞赛备赛或自主项目参考。 数字IC设计这块学Verilog的人早晚都要撞上同一个项目单周期CPU。我在做这个项目之前以为无非就是把ALU、寄存器堆、指令存储器拼在一起但真到自己动手写代码、跑仿真、调板子的时候才发现里面的坑比想象中多得多。这篇博文就是配合我开源的那份Verilog单周期CPU源码写的配套说明把整个设计思路、模块划分、控制信号怎么定、仿真怎么验、有哪些容易翻车的地方一次讲清楚适合刚学完Verilog语法、想拿一个完整项目练手的人也适合正在准备数字IC相关面试、需要把CPU数据通路彻底吃透的人。1. 单周期CPU到底在解决什么先捋清设计目标单周期CPU顾名思义就是一条指令从取指、译码、执行到写回全部在一个时钟周期内完成。听起来简单但“一个周期做完所有事”这句话展开之后涉及到的是一整条完整的数据通路和一套严格的控制逻辑。我当初做这个项目目标定得很明确不追求性能不搞超标量也不上流水线就是把一条指令的生命周期完整地走通。用Verilog写一个支持基础整数指令的CPU核能够执行加法、减法、逻辑运算、访存、分支跳转这些最常见的操作并且能在仿真环境里跑通一段真实的汇编程序。这个项目做完之后整个计算机组成原理里最抽象的那部分——指令是怎么一步步变成控制信号的寄存器之间是怎么协作的——一下子就落地了。单周期设计最大的好处是时序简单。没有流水线就不存在数据冒险、控制冒险这些让人头大的问题所有信号在同一个周期内稳定下来即可。对入门阶段来说单周期能让你把注意力完全集中在“指令到底是怎么被执行的”这件事本身而不是被各种转发逻辑和流水线寄存器拖住。它最大的代价是时钟频率上不去因为周期长度被最慢的那条指令通常是访存指令锁死了这不是现在要操心的事情。做单周期CPU恰恰就是为了先把正确性做出来把指令集、数据通路、控制逻辑这套骨架搭扎实。这个基础打牢之后再去碰流水线、Cache、多周期调度这些进阶话题才会有一个清晰的坐标系。2. 指令集裁剪做成什么样才算版写CPU之前第一步不是打开代码编辑器而是先把指令集定下来。做单周期CPU不可能把所有指令都支持一遍也没这个必要。我给源码定的指令集是MIPS的一个精简子集规整且非常适合用来理解CPU的运行机制。2.1 指令格式R型、I型、J型MIPS指令集的规整性在于所有指令统一32位并且分为三类格式R型寄存器型用于寄存器与寄存器之间的运算比如add、sub、and、or、slt。I型立即数型包含一个立即数字段用于lw、sw、addi、beq这类操作。J型跳转型直接包含一个26位的跳转目标地址用于j指令。这种格式划分特别适合数字逻辑实现因为控制信号的生成很大程度上可以直接从opcode字段散射出来。学过数电就会知道译码逻辑本质上就是一个巨大的真值表指令格式越规整真值表就越容易整理。2.2 本项目的指令清单我的源码里一共实现了11条指令覆盖了运算、访存、分支跳转三大类类别指令opcodefunct说明R型add000000100000加法R型sub000000100010减法R型and000000100100逻辑与R型or000000100101逻辑或R型slt000000101010小于则置1I型addi001000-加立即数I型lw100011-读内存I型sw101011-写内存I型beq000100-相等则跳转J型j000010-无条件跳转指令集到这个规模已经能写出很漂亮的测试程序了比如循环累加、数组遍历、斐波那契数列、简单排序全都没问题。同时又不会因为指令太多导致控制逻辑变得繁琐。这里有个心得不要一上来就想把所有指令都塞进去第一次做CPU先做一个小而完整的版本跑通再去扩展指令数量。小版本跑通之后加一条新指令的成本其实很低但如果你一开始就带着一大堆指令去写排查问题的时候根本不知道是哪条指令的条件写得不对。3. 数据通路搭建从取指到写回的一整条链数据通路是CPU的骨架。写代码的时候建议从上往下梳理PC先指向哪里、指令存储器返回什么、寄存器堆给出什么数据、ALU怎么运算、结果写到哪去、PC下一拍怎么变化。这条链路理顺了代码写起来就是按图索骥。3.1 PC与指令存储器PC程序计数器是整个CPU的起点它保存当前正在执行的指令地址。在每个时钟上升沿PC根据控制信号决定自己的下一值正常执行就加4因为每条指令占4字节地址按字节算遇到分支且条件成立就跳转到目标地址遇到j指令就直接跳转。PC的核心代码长这样always (posedge clk or posedge rst) begin if (rst) pc 32h0000_0000; else if (branch_taken) pc pc_next_branch; else if (jump_en) pc pc_next_jump; else pc pc 4; end指令存储器Instruction Memory设计成组合逻辑读取。这里有一个重要的工程决策指令存储器和数据存储器虽然都是存储器但在单周期CPU里指令存储器用组合读、数据存储器用同步写。组合读的意思是只要地址输入有效输出立刻就有效不需要等时钟。这是因为取指必须在周期一开始就读出指令没有时间等一拍时钟。指令存储器本质是一个ROM模型实际使用时会被综合成Block RAM或者其他只读存储但仿真模型里可以直接用initial块初始化。3.2 寄存器堆寄存器堆是CPU里读写最频繁的部件。MIPS架构有32个通用寄存器每个32位其中寄存器0固定为0硬件上任何写操作写入寄存器0都应该被忽略。寄存器堆需要两个读端口和一个写端口因为像add rd, rs, rt这样的指令需要同时读取两个源寄存器。Verilog里实现多端口寄存器堆非常直接always (*) begin if (rst) begin read_data1 0; read_data2 0; end else begin read_data1 regfile[read_addr1] | (|read_addr1 ? 0 : 0); read_data2 (read_addr2 0) ? 32h0 : regfile[read_addr2]; end end注意上面这个写法里我加了寄存器0的判断这是新手最容易漏掉的点。即使在不知道寄存器0约定的人看来这个细节最多让你的程序在某些边界场景下算错但在真实场景里任何程序都会频繁依赖寄存器0恒为0的特性从第一条指令就在用了。写端口在写使能有效时于时钟上升沿写入always (posedge clk) begin if (RegWrite write_addr ! 0) regfile[write_addr] write_data; end模拟读写冲突是寄存器堆设计的一个细节。如果同一条指令在同一周期既有写使能又有读请求且读地址等于写地址这时的行为取决于具体硬件。大多数教学级CPU采用“写优先”策略即读到的数据是写入后的值。为了规避这个边界讨论我的源码里让写操作在下降沿写入读操作是组合逻辑这样在读同周期写入时组合逻辑可以在电平阶段读取到更新值时序上非常稳。3.3 ALU与立即数扩展ALU是整个CPU的算力中心。输入两个32位操作数通过ALUOp控制信号选择执行加法、减法、与、或、小于比较输出32位结果和零标志位。零标志位Zero是专门为beq准备的。beq的本质是“两个数相等”在硬件上等价于“两个数相减看结果是否为0”。所以beq不需要ALU新增任何操作直接用减法结果产生零标志位就行。立即数扩展是很多人容易忽略的模块。lw和sw指令的16位立即数是带符号的因为地址偏移量可以是负的。所以扩展方式必须是符号扩展高位补符号位而不是零扩展高位补0。这两者对于正数没区别但一旦偏移量为负区别就会立刻浮现出来——你算出来的地址差了65536个字节程序直接跑到完全不对的地址上去。3.4 数据存储器与回写选择数据存储器Data Memory用来执行lw和sw。读操作是组合逻辑地址有效后数据立刻出来写操作是同步的在时钟上升沿按字节写使能写入。回写选择是整个数据通路最后的“岔路口”。运算结果可能来自ALU也可能来自数据存储器lw读出的数据还可能是下一个PCj指令需要把返回地址写入寄存器虽然我们的指令集里没实现jal但接口保留了这个选项。这些来源通过MemtoReg信号控制的多路选择器MUX选出一个最终的回写数据送到寄存器堆的写端口。数据通路这部分写完之后整个CPU的框架其实已经出来了。你可以把PC、指令存储器、寄存器堆、ALU、数据存储器想象成一条流水线一样的工作链条PC给出地址、指令存储器给出指令、控制单元解读指令、寄存器堆提供数据、ALU完成计算、数据存储器完成访存、最后写回寄存器堆同时反向控制PC的下一步走向。这个循环跑顺了CPU的“生命节拍”就起来了。4. 控制信号真值表CPU的指挥调度中心如果说数据通路是CPU的骨架那么控制单元就是CPU的神经中枢。控制单元输入指令中的opcode和funct字段输出一整组控制信号指挥数据通路里的每个部件该干什么。4.1 主控真值表我实现的11条指令对应的主控信号如下指令RegDstALUSrcMemtoRegRegWriteMemReadMemWriteBranchJumpALUOpadd1001000010sub1001000010and1001000010or1001000010slt1001000010addi0101000000lw0111100000swx1x0010000beqx0x0001001jxxx00001xx这张表是整个CPU的灵魂。我在写代码之前先花了一整个晚上把这11条指令的真值表全部手推了一遍确认没有任何冲突和歧义才开始动手写code。建议你也这么做因为在纸上花的时间将来一定会在仿真调试时加倍省回来。4.2 ALU控制逻辑ALUOp是一个两位信号由主控单元产生但它还不能直接决定ALU执行什么运算因为R型指令的add和sub在主控单元看来都是ALUOp为10真正区分它们的是指令中的funct字段。所以还需要一个ALU控制子模块将ALUOp和funct组合译码成最终的ALU操作控制信号。这个二级译码的逻辑是这样的当ALUOp为00时ALU做加法addi和lw均为ALU加运算当ALUOp为01时ALU做减法beq比较当ALUOp为10时根据funct字段判断100000做加法、100010做减法、100100做与、100101做或、101010做小于比较。ALUSrc信号是这张真值表里的一个关键细节。它决定ALU的第二个输入是寄存器堆读出的数据还是立即数扩展后的数据。对于add这种R型指令第二个操作数来自寄存器对于addi、lw、sw第二个操作数是立即数。sw指令虽然不需要回写寄存器但它的地址计算依然需要ALUSrc为1把立即数偏移送到ALU去计算访存地址。这些细节在真值表里都能一目了然地体现出来。5. 仿真验证不是“没报错”就完事代码写完之后真正的考验才开始。CPU的验证比普通模块要复杂因为你不能只验证一个模块的功能而是要验证整条指令流水线的行为是否正确。我的方法是写一份简单的汇编测试程序手工翻译成机器码然后灌进指令存储器的初始化文件里用ModelSim跑完整仿真逐条观察关键信号的变化。5.1 先设计一个能“暴露问题”的测试程序测试程序的设计直接影响验证效果。我的第一个测试程序很简单但每一步都在验证不同的指令# 测试程序 addi $1, $0, 5 # $1 5 addi $2, $0, 3 # $2 3 add $3, $1, $2 # $3 8 sub $4, $1, $2 # $4 2 sw $3, 0($0) # 把8写入mem[0] lw $5, 0($0) # $5 8 beq $3, $5, next # 相等跳转 addi $6, $0, 1 # 如果没跳转$6会被写1 next: addi $6, $0, 2 # $6应该等于2这个程序的每一行都有明确的目的。addi和add验证算术逻辑sub验证减法sw和lw验证访存通路beq验证分支而且beq设计成“必然跳转”用来测试跳转逻辑生效。最后那个addi $6用来验证beq是否真正跳过了一条指令——如果仿真结束$6等于2说明跳转正常如果$6等于1说明beq根本没跳。5.2 手工翻译机器码测试程序写好后关键步骤是把汇编指令翻译成机器码。有过Verilog基础的人都知道指令存储器的初始化文件里只能是32位的二进制数。翻译的时候强烈建议自己动手算一遍不要直接用汇编器因为手算的过程能让你对指令格式和字段布局有更深的理解。以addi $1, $0, 5为例opcode 001000addirs 00000$0rt 00001$1立即数 0000 0000 0000 01015拼接起来就是001000_00000_00001_0000000000000101下划线仅为方便阅读。转换成16进制是0x20010005。其余指令同理全部转换完后写进inst_init.txt文件里。5.3 仿真过程中的关键观测点仿真跑起来之后不要只看最终结果对不对要盯着关键信号逐拍看。我分享三个最值得观察的信号点PC的变化每拍PC加4是基础行为但遇到beq和j时要确认PC真的跳转到了正确位置。跳转目标的计算是否出错了1拍或者目标地址偏了一拍通过观察PC波形能立刻看出来。寄存器写入时机RegWrite信号的时序很关键。写入必须发生在周期后半段的稳定窗口里如果写使能过早或过晚寄存器堆可能写错值。存储器的读写地址lw和sw的地址计算是否等于预期值特别是符号扩展后的负偏移地址。这个用波形一看就能确认。我的测试里还特意把寄存器堆的32个寄存器全部拉出来在波形里观察。这样某一个寄存器里的值不对扫一眼波形就能定位到是哪一条指令写坏了省去一层层追溯的麻烦。6. 我调单周期CPU时踩过的坑最后分享一些在调试过程中真正踩过的坑。这些坑每一个都花了我不少时间才定位到写出来希望能帮你少走弯路。6.1 波形里的X态最凶残的调试噩梦第一次跑仿真的时候整个波形里全是红色的X态未知值。我当时第一反应是代码写错了但检查语法又完全没问题最后发现是复位逻辑的问题——仿真一开始PC没有被初始化成0而是高阻态Z后续所有信号都以这个Z为基础计算结果全是X。解决方法是保证所有状态元件都有明确的复位逻辑。PC要复位成0寄存器堆里的所有寄存器也要复位成0数据存储器如果不需要初始化也要确保没有被读取到未初始化的地址。从此之后我写任何模块都会先检查一条规则是否有任何电平触发路径上存在未初始化的寄存器或者存储器单元。6.2 beq跳转地址差了一条指令beq的设计是所有测试里最容易出错的。我最初出现的bug是beq的跳转目标算对了但PC会在跳转指令后的下一拍没有跳转反而多执行了一条指令。原因在于我的分支目标计算逻辑写错了偏移量来源。MIPS的beq偏移量是以指令数为单位相对PC4来算的我当时把偏移量按字节直接算结果目标地址偏了4个字节一条指令。这个问题最坑的地方在于程序小的时候表现不明显一旦代码段长一点就很容易跳飞到别的地方。排查这个问题的思路是打印出beq执行时的PC、立即数扩展值、目标地址三者一对比就能发现偏移量的问题。6.3 存储器写法错误导致仿真和综合结果不一致仿真用的数据存储器读者经常会写成一个always块里既有组合逻辑又有时序逻辑导致仿真的时候看起来正常综合却综合出一堆奇怪的Latch。正确的做法是读写分离读地址到读数据走组合逻辑路径写使能到写数据走时钟路径。我在源码里把读写分成了两个always块就是防止这个问题。如果你用的是Vivado综合后的Latch报告里出现意外的Latch那多半就是存储器或某个MUX的写法出了问题。另一个细节是写使能信号千万不要在always块里做无default的case生成这也是Latch的温床。6.4 一个非常实用的调试技巧调试CPU的时候最怕的是“问题看着像随机出现的”。后来我养成了一个习惯在测试程序的关键指令后面手动把那条指令执行后的关键寄存器值打印成仿真日志。Verilog里有现成的$display用法很直接// 仿真监控脚本片段 always (posedge clk) begin if (pc 32h0000_000c) // 某条指令的地址 $display(PC%h, R[3]%d, R[4]%d, pc, regfile[3], regfile[4]); end这个技巧比纯看波形高效太多。波形是二维的你要自己去对照时间和信号值而打印日志直接把关键结论以文本形式输出一眼就能看出哪一步的计算结果不符合预期。我在调整个CPU的过程中有一半的bug是靠这样一条条打印日志定位出来的。这个方法对以后调流水线CPU同样适用建议尽早养出这个习惯。整个项目做下来我最深的感受是单周期CPU虽小但五脏俱全。它把Verilog里最核心的时序逻辑、组合逻辑、状态机、存储器控制这些内容全部融合在了一起同时还让你对计算机体系结构的理解发生质变——原来那条跑在计算机里的指令最底层是这样的运作方式。源码里还额外附了更新的验证环境和几个进阶测试用例跑通这份源码之后你可以试着扩充指令、修改数据通路甚至把它改成多周期或者加一个简单的5级流水线都是完全不同的体验。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。