简介:16位单周期CPU设计是一份面向计算机组成原理、Verilog数字设计与CPU设计入门学习者的工程资源,以16位数据宽度和单时钟周期执行为核心,覆盖指令寄存器、指令解码器、数据路径、控制单元、程序计数器、寄存器堆及ALU等关键模块,帮助读者将单周期处理器“取指—译码—执行—写回”在一个时钟周期内并行完成的理论落实到可综合的硬件描述代码。压缩包共140个文件,其中16个.v源文件构成CPU主体,19个.c文件多为仿真激励或辅助脚本,另有Xilinx工程文件(.xise/.gise)、综合仿真日志(.log/.xrpt)及内存初始化数据(.dat)等,可辅助复现综合流程;整体仅826KB,结构清晰、便于对照查阅。已有601人学习浏览。通过该资源可直观查阅ALU、寄存器堆、控制单元等子模块的Verilog实现,以及引脚约束和综合报告,既能作为课程设计或自学动手的参考模板,也有助于理解单周期CPU中控制信号生成、ALU运算与访存操作的协同过程,适合在FPGA平台上进一步扩展,也为后续多周期或流水线设计打下基础。
1. 项目背景与整体设计思路
1.1 为什么是16位,为什么是单周期
如果你接触过计算机组成原理课程,大概率听说过“单周期CPU”这个词。很多人第一反应是:这玩意太基础了,几十年前的东西,做过有什么意义?但如果你真的从头到尾手写过一版可以跑通的16位单周期CPU,你对“程序到底是怎么在机器里跑起来的”这件事的理解,会和只听课完全不一样。
先解释两个关键选择。16位意味着我们的数据总线、寄存器宽度、ALU位宽全部是16比特。为什么不直接上32位或64位?原因很实在:教学和验证成本。32位指令集意味着大量的低32位数据通路、更宽的多路选择器和译码逻辑,初版设计很容易在布线、信号同步上翻车。16位是一个恰到好处的“折中”——既能处理有实际意义的运算(比如两个千以内的整数相加、循环计数、内存寻址),又不至于让数据通路复杂到失控。另一个经典选择是8位,但8位的ALU和寄存器在写汇编程序时会频繁遇到“数放不下”的问题,体验比较憋屈。
单周期的意思是:每条指令从取指、译码、执行、访存到写回,全部在一个时钟周期内完成。时钟周期必须足够长,以保证最慢的那条指令(通常是访存指令,因为要等存储器响应)也能跑完。所以单周期CPU的CPI(每条指令周期数)恒等于1,看起来很“快”,但实际时钟频率会受限于最慢路径,做不了太高。这不影响我们用它学习原理——单周期结构将“控制信号如何产生、如何驱动数据通路”这件事暴露得最彻底。你想理解多周期和流水线,单周期是绕不开的地基。
1.2 指令集设计:先定指令,再定硬件
很多新手做CPU时容易犯一个顺序错误:上来就画数据通路,然后才想指令集。正确的做法是反过来的——先确定指令集需要支持哪些操作,再倒推需要什么样的硬件资源。
我在做这个16位单周期CPU时,参考了MIPS的经典风格,但砍掉了大量复杂特性,最终保留了12条核心指令。这12条指令覆盖了算术运算、逻辑运算、访存和跳转四大类:
| 类别 | 指令 | 功能描述 | 操作码 |
|---|---|---|---|
| 算术 | ADD | 寄存器加法 | 0001 |
| 算术 | SUB | 寄存器减法 | 0010 |
| 算术 | ADDI | 立即数加法 | 0011 |
| 逻辑 | AND | 按位与 | 0100 |
| 逻辑 | OR | 按位或 | 0101 |
| 逻辑 | XOR | 按位异或 | 0110 |
| 逻辑 | NOT | 按位取反 | 0111 |
| 访存 | LW | 从内存加载 | 1000 |
| 访存 | SW | 存入内存 | 1001 |
| 跳转 | BEQ | 相等跳转 | 1010 |
| 跳转 | JMP | 无条件跳转 | 1011 |
| 控制 | HLT | 停机 | 1100 |
指令编码采用统一的16位定长格式。R型指令(如ADD、SUB、AND、OR、XOR)使用高4位作为操作码,接下来两个4位字段作为源寄存器地址,再接下来4位作为目标寄存器地址,低4位保留不用。I型指令(如ADDI、LW、SW、BEQ)则是高4位操作码、4位寄存器地址、8位立即数。JMP和HLT就比较特殊了,整条16位除操作码外全是地址或保留位。
为什么要统一成16位定长?因为取指阶段非常省事——程序计数器PC每次加1就是下一条指令的地址,不需要根据指令长度进行额外跳转计算。这对单周期CPU尤为友好,省掉了可变长指令带来的额外加法器和分支逻辑。代价是指令表达能力有限,但对我们这个“教学向”的CPU来说完全够用。
2. 数据通路设计与控制信号解析
2.1 数据通路搭积木:从部件到通路
数据通路(Datapath)是CPU的“骨架”,所有数据流都沿着它运动。在设计时,我遵循一个非常朴素的原则:先把所有需要参与的部件列出来,再考虑它们怎么连。
这个CPU数据通路由以下核心部件组成:程序计数器PC(16位寄存器,存下一条指令地址)、指令存储器IM(存放机器码)、寄存器堆RegFile(16个16位寄存器,支持同时读两个、写一个)、ALU(支持加法、减法、与、或、异或、非六种运算)、数据存储器DM(16位地址线、16位数据线)、以及一堆多路选择器和扩展器。
各部件之间的连接逻辑是:PC把当前指令地址送给IM,IM返回对应的16位指令;指令的高4位是操作码,送给控制单元;中间字段被拆解成源寄存器地址rs、目标寄存器地址rd和立即数;寄存器堆根据rs和rd同时输出两个操作数到ALU和DM的写数据端口;ALU计算结果再经过MUX选择,是写回寄存器堆还是作为内存访问地址。
我需要特别提醒一个细节:16位地址最多只能寻址64KB(2^16 = 65536字节),而我们的指令和数据都是16位宽。如果采用统一编址,指令空间和数据空间会挤在一起,容易溢出。我在设计时采用了哈佛结构——指令存储器和数据存储器物理分离,各自有独立的16位地址空间。这样指令可以放2^16条,数据也可以放2^16个字,互不干扰。这比冯·诺依曼结构多一套存储器的访问通道,但在Verilog实现时只是多实例化一个RAM模块的事,非常划算。
2.2 控制信号表:单周期CPU的“灵魂图纸”
数据通路本身是静止的,让它动起来的是控制信号。每条指令对应一组控制信号,分别决定多路选择器的输出、ALU的操作模式、寄存器堆是否写入、内存是否读写。我把控制信号整理如下:
| 信号名 | 作用域 | 功能说明 |
|---|---|---|
| RegWrite | 寄存器堆 | 1时允许写入目标寄存器 |
| ALUSrc | ALU输入MUX | 0选寄存器数据,1选立即数扩展值 |
| ALUOp | ALU | 3位控制信号,选择运算类型 |
| MemWrite | 数据存储器 | 1时写入数据存储器 |
| MemRead | 数据存储器 | 1时读取数据存储器 |
| MemtoReg | 写回MUX | 0将ALU结果写回寄存器,1将内存数据写回 |
| Branch | 分支控制 | 1时若ALU零标志有效则跳转 |
| Jump | 跳转控制 | 1时PC直接载入跳转地址 |
| PCWrite | PC使能 | 0时暂停PC更新(用于HLT停机) |
以ADD指令为例,控制信号组合是:RegWrite=1、ALUSrc=0、ALUOp=001(加法)、MemWrite=0、MemRead=0、MemtoReg=0、Branch=0、Jump=0。你可能会问:每条指令都要去查这样一张表,多麻烦?确实麻烦,但这正是CPU设计中最有趣的部分——你开始理解“硬件描述语言”中的“描述”二字的真正含义:你不是在写代码让机器做事情,而是在描述一台机器应该具有的结构。控制信号表本质上就是一张真值表,通过组合逻辑电路即可实现。
2.3 ALU内部实现与零标志位
ALU是数据通路的心脏。这个16位ALU我采用了非常直接的方式——用行为级描述写了一个always块,根据ALUOp的3位输入选择不同运算。内部操作包括加法、减法、按位与、或、异或、取反。特别要注意的是加减法可能产生进位/借位和溢出,虽然16位溢出在我们低强度测试中触发概率不高,但代码里必须留出溢出处理的逻辑位,方便调试时定位“数据突然变成负数”的诡异问题。
另一个关键输出是零标志Zero。只有一条指令会用到它:BEQ。实现方式极其简单——ALU输出的16位结果与16位零值比较,若相等则Zero置1。这里有一个小坑:SUB指令的减法结果如果是0,Zero同样会拉高,但这只在BEQ指令执行时才会被用到。因为控制信号里BEQ会让ALUOp等于减法的编码,所以“相等比较”其实是通过减法来判断的。这个设计非常巧妙,也极其经典,MIPS和RISC-V也是这么干的。
3. 实操:用Verilog写一个能跑通的CPU
3.1 顶层模块划分:一个模块干一件事
我用Verilog HDL来完成这个CPU,因为相对VHDL来说它更简洁,也因为它在FPGA教学领域的生态更好,Vivado或Quartus都能直接上板调试。整个工程划分为5个模块:顶层CPU模块、PC模块、指令存储器ROM、寄存器堆、ALU、数据存储器RAM。控制单元我直接写在顶层模块中,用组合逻辑实现。
模块划分的原则很简单:一个模块干一件事。PC模块内部只维护一个16位寄存器,输出当前地址,并根据控制信号计算下一地址。寄存器堆模块内部是16个16位寄存器组成的数组,支持同步写入、异步读取(这是单周期CPU的标准操作方式,读不需要等时钟,写需要等时钟上升沿)。ROM和RAM直接用IP核生成,或者用initial数组初始化一段机器码。
3.2 核心代码:控制单元与数据通路连接
我贴一段控制单元的核心代码,完整工程可以去我的仓库看,这里只展示关键部分:
// 控制信号生成:根据操作码输出各控制信号 always @(*) begin // 默认值:不写寄存器、不写内存、不跳转、不暂停 RegWrite = 0; ALUSrc = 0; MemWrite = 0; MemRead = 0; MemtoReg = 0; Branch = 0; Jump = 0; PCWrite = 1; case (opcode) 4'b0001: begin // ADD RegWrite = 1; ALUOp = 3'b001; ALUSrc = 0; end 4'b0011: begin // ADDI RegWrite = 1; ALUOp = 3'b001; ALUSrc = 1; end 4'b1000: begin // LW RegWrite = 1; MemRead = 1; ALUOp = 3'b001; ALUSrc = 1; MemtoReg = 1; end 4'b1001: begin // SW MemWrite = 1; ALUOp = 3'b001; ALUSrc = 1; end 4'b1010: begin // BEQ Branch = 1; ALUOp = 3'b010; // 减法 end 4'b1011: begin // JMP Jump = 1; end 4'b1100: begin // HLT PCWrite = 0; end default: begin // 未识别指令,停机 PCWrite = 0; end endcase end注意一个小细节:在always组合逻辑块中,我习惯先给所有输出赋默认值,再在各分支中覆盖。这样能有效避免产生锁存器(Latch)。很多新手在这里摔跤:漏掉了某个控制信号在某条指令下的赋值,导致综合时多出一个不必要的锁存器,仿真不一定报错,但上板后时序完全不对。
3.3 程序计数器与跳转逻辑的实现
PC模块的逻辑比较精巧。单周期CPU中,PC的更新逻辑如下:默认情况下PC加1;当Branch有效且ALU输出零标志为1时,PC加上偏移量(立即数符号扩展);当Jump有效时,PC直接载入指令地址字段。这三路选择在顶层模块中用一个MUX实现:
// PC下一地址计算 wire [15:0] pc_plus1; wire [15:0] pc_branch; wire [15:0] pc_jump; wire [15:0] pc_next; assign pc_plus1 = pc + 16'd1; assign pc_branch = pc + {8'b00000000, imm_value}; // 无符号偏移 assign pc_jump = {opcode_imm, 4'b0000}; // 简化跳转地址 assign pc_next = Jump ? pc_jump : (Branch && zero_flag) ? pc_branch : pc_plus1;我在这里做了一个简化:跳转目标地址直接取指令中的立即数字段左移4位,这相当于在2^12个16位字空间的范围内跳转,对测试程序来说足够用。实际如果要支持更灵活的跳转范围,可以拼接PC高位和立即数,但那是更高级优化的范畴了,这里不做扩展。
调试时最有用的波形信号是:pc、opcode、reg_write_data、zero_flag。只要留意这四个信号,就能快速定位指令是“取错了”还是“算错了”还是“写回了错误的位置”。
4. 仿真验证与FPGA上板测试
4.1 用Testbench进行指令级仿真
代码写完只完成了一半,真正的重头戏是验证。我在Vivado里写了一个Testbench,初始化ROM中预先写好的测试程序,然后跑行为级仿真。测试程序我设计得很务实:先用ADDI将数据存入R1和R2,执行ADD运算存入R3,再做一次减法、一次内存写入,最后用BEQ跳转回前一条指令形成一个死循环(用于观察跳转是否生效)。
仿真通过的标准很明确:信号波形里每个时钟上升沿,PC的递增符合预期;每个数据写回寄存器堆的值符合手工推演的结果。我通常会先用纸笔把机器码和期望的寄存器值全部算一遍,再用波形逐一对照。你会发现大多数bug都是这样浮出水面的——不是指令集设计错了,而是数据通路上哪个MUX的选择信号接反了,或者立即数符号扩展的位数弄错了。
4.2 上板测试:第一次看到CPU“活过来”
仿真通过后就到了最兴奋的时刻:上板。我用的是Nexys 4 Artix-7开发板。将CPU工程综合、布线、生成比特流下载进FPGA后,把数据存储器的特定地址映射到开发板上的8个LED灯,写一个让寄存器值递增的循环,然后在时钟信号上接一个按钮,每按一次按钮执行一条指令。这样就能肉眼看到LED灯的二进制数值按预期变化——那一刻的成就感,和当年第一次点亮LED完全不在一个量级。
上板遇到的第一个问题是时钟抖动。用按钮直接作为时钟输入时,按下和松开瞬间会产生毛刺,导致一次按键被识别成好几次。解决办法是用开发板自带的100MHz晶振主时钟,配合一个时钟分频模块把频率降到1Hz,再利用按键输入作为“使能信号”而不是时钟源。这个教训很重要:千万不要用机械按键直接驱动时序电路的时钟端口。
4.3 常用工具链和参考资源
如果你也打算动手做,我建议的工具链是:Verilog + Vivado(或免费版Vivado ML),仿真直接用内置的仿真器,不需要额外装ModelSim。参考资料优先级从高到低:官方《Computer Organization and Design》中MIPS单周期章节、任意一本《CPU自制入门》、以及开源的RISC-V CPU项目(比如tinyriscv)作为代码风格参考。
如果你不想用FPGA,也可以先用Logisim或者Digital这类逻辑仿真工具搭积木式的验证设计,画完再翻译成Verilog。我个人的体会是:逻辑仿真工具适合快速验证指令集设计是否正确,但真要理解“硬件描述语言是描述硬件而不是写程序”,还是直接写Verilog上板子来得透彻。
5. 调试经验与常见问题速查
5.1 最常踩的四个坑
回头看整个项目,有几个坑是几乎所有做CPU的人都会踩的。第一个是锁存器意外生成,这个前面已经说过,解决方法是组合逻辑块中所有输出必须有默认赋值。第二个是立即数符号扩展:8位立即数到底该当作无符号还是补码有符号数,取决于指令语义。ADDI里面的立即数我按无符号处理,但如果想让它能减数,就得扩展成16位补码——最好在文档中白纸黑字写明,否则第二天看代码就会犹豫。
第三个坑是寄存器堆的异步读同步写。同步写的意思是只在时钟上升沿写入;异步读则是组合逻辑直接从寄存器数组取值。如果误写成了同步读,同一个周期内无法同时完成“读一个寄存器的值和写另一个寄存器”,流水线还没搭呢就先把关键路径搞坏了。第四个坑是复位信号的控制:上电时PC必须清零,否则从随机地址开始取指,仿真里看到的波形完全是乱的。每个计数器类模块都必须有可用的异步复位。
5.2 问题定位方法论:二分法 + 波形追踪
当你仿真出来的波形和预期不符时,别急着一行行去读代码。我推荐“二分法定位”:先确认PC是否正确递增取指;如果PC对,再确认取到的指令操作码是否正确;如果操作码对,再追踪看控制信号是否符合指令预期;如果控制信号对,问题八成就在数据通路的连接上。就这样一层层往下扒,比盯着代码看效率高很多。
有一个一直以来都很好用的技巧:写一个专门的显示模块,将PC、IR、ALU结果、零标志这4个核心信号在板上通过串口打印到电脑终端,这样不需要每次仿真导波形,直接跑板上程序就能看到执行轨迹。对于调试十六进制级别的问题,这比Vivado的波形界面更直接。
5.3 从单周期到更远的扩展方向
做完单周期CPU并不是终点。如果你想继续深入,我建议按以下顺序迭代:先加中断异常机制,再改为多周期(每条指令2-5个周期),最后尝试5级流水线。多周期和流水线会引入状态机控制、冒险检测、前递旁路等新概念,但这都是建立在单周期基础之上的——数据通路本质上还是那套东西,只是在时间维度上重新切分。
我做这个项目时最大的收获,是理解了“控制信号表”和“数据通路”这张双面镜。所有处理器设计,本质上都是在这两者之间寻找平衡。无论你未来是做嵌入式、做编译器还是做操作系统,对CPU内部结构的这种直观感受,都会在某个关键时刻派上用场。比如排查软件性能问题时,你一看循环体就知道它会产生多少次cache miss、多少次分支预测失败——这种能力不是背几本教材能练出来的,是真刀真枪跑过一遍CPU设计之后沉淀下来的直觉。
本文还有配套的精品资源,点击获取