简介:一套完整的16位单周期CPU设计工程包,面向计算机体系结构课程与Verilog硬件描述入门实践,聚焦十六位数据宽度,演示在单个时钟周期内完成取指、译码、执行与写回的完整流程,适合正在做CPU课程设计或希望理解处理器内部工作机制的学生与开发者。压缩包仅826KB,包含140个文件,以Verilog源码、C辅助程序及ISE工程文件为主,同时带有综合、仿真生成文件和报告日志,目录组织清楚,可覆盖从编写、仿真到综合验证的环节。已有604人学习下载。实践内容涵盖指令寄存器、指令解码器、数据路径、控制单元、程序计数器、寄存器堆、算术逻辑单元及内存接口等关键模块,并配有测试向量与仿真模型,可逐模块对照设计、单独验证。通过研读源码与工程文件,读者能直观看到各模块如何协同运作,掌握单周期CPU的构建思路与验证方法,为后续学习多周期处理、流水线或片上系统设计奠定基础。
1. 16位单周期CPU:一条指令一个时钟周期,真的能跑起来吗
第一次听“16位单周期CPU”这个说法的人,多半会以为是那种只能在课件里转圈的教学玩具。其实不是。所谓单周期,指的是处理器每执行一条指令只占一个时钟周期,数据通路里的所有部件(取指、译码、执行、写回)在同一次时钟沿内协同完成动作;而16位指的是指令字宽、数据总线宽以及寄存器宽度都统一成16位。这个设计虽然走的是教科书里的经典路子,但它着实能跑在真实开发板上,代替单片机做一点简单的计算、点亮数码管、控制外设。适合正在学计算机组成原理和Verilog的人,也适合想做课程设计却不知道怎么收尾的从业者。这篇文章不讲天书般的体系结构理论,只讲清楚:指令集怎么定、数据通路怎么搭、控制器怎么写、仿真怎么验、上板怎么调,以及最让人头疼的时序和复位问题到底坑在哪。
2. 指令集与存储位宽:先定游戏规则,再动手写Verilog
2.1 指令集的取舍:R型、I型与跳转指令怎么圈定
设计CPU的第一步不是写代码,而是把指令集定下来。16位单周期CPU不像商用处理器那样追求兼容性,指令集完全由你说了算。我一般会建议先从三类指令入手:计算类(R型)、立即数类(I型)、跳转类(J型)。计算类包括加法、减法、与、或、异或、比较,最多六条;立即数类包括加载立即数到寄存器、立即数加法;跳转类包括无条件跳转和条件相等跳转。这一套下来大约十条指令,足以跑通冒泡排序、乘法循环等基础程序,又不至于让状态控制复杂到没法在单周期内收敛。
指令集一旦扩展,控制信号的数量和数据通路的宽度就会跟着膨胀。比如加上移位指令,就需要增加桶形移位器或者循环移位逻辑,这对16位数据通路来说是个不小的面积开销。加上乘除法指令则更麻烦,单周期内完成16位乘法的组合逻辑路径延迟会非常长,导致最大时钟频率掉到几十兆赫以下。因此初版设计应该克制,把乘除法留到后续版本再说。
2.2 指令编码与位域划分:opcode 与功能码的分配逻辑
指令编码是定下每个bit的含义。16位指令信号的通用划分方式是:高4位作为操作码(opcode),中间或低位再根据指令类型复用。R型指令的低6位可以作为功能码(funct),用于区分加法、减法、与、或等不同操作;I型指令的低12位直接作为立即数;J型指令的低12位则作为跳转偏移。
| 指令类型 | 高位位域 | 中位位域 | 低位位域 |
|---|---|---|---|
| R型 | opcode[15:12] | rs[11:8], rt[7:4] | funct[3:0] |
| I型 | opcode[15:12] | rt[11:8] | imm[7:0] |
| J型 | opcode[15:12] | addr[11:0] | 无 |
opcode只占4位,意味着最多能支持16条大指令类别,完全够用。funct占4位,R型内部能区分16种操作,如果只用了6种,将来扩展也不愁。而立即数在这里被设计成8位有符号数,后续在做符号扩展(sign-extend)时要特别注意——这是个经典的坑点,后面会单独讲。指令编码一旦确定,就不要轻易改变,因为汇编器和测试程序都要跟着变,修改编码的成本比想象中高得多。
2.3 16位存储宽度的搭配:指令ROM与数据RAM的安排
16位CPU通常配合16位宽度的指令存储器和数据存储器。指令存储器用只读形式实现,在FPGA里一般直接例化只读的IP核或用一个case语句生成;数据存储器则是可读写的随机存储器。指令计数器的更新以及取指操作,都建立在这些存储器的读延迟足够短的前提下。
对于入门设计,我建议数据存储器深度选择256字(即2^8个16位存储单元),指令存储器深度选择1024字。这个容量能跑下百行左右的汇编程序,又不占用太多FPGA内部块存储器资源。关键是单周期设计要求读存储器操作在一个周期内完成,因此应当使用组合逻辑读或同步读但时钟周期足够宽裕的方式,不能引入读延迟大于一个周期的随机存储器模型——这会直接破坏数据通路的时序。
3. 数据通路搭建:拿Verilog把每条指令的路由铺出来
3.1 取指与PC更新:程序计数器在单周期里的自增方式
程序计数器(PC)是数据通路的起点。单周期设计里,PC在每个时钟上升沿更新为“当前指令地址 + 1”,如果当前指令是跳转且条件成立,则更新为“当前指令地址 + 1 + 偏移量”。这里务必注意:PC值必须在时钟沿变化,不能在组合逻辑中直接回环,否则会产生两个周期内的不稳定状态。
module pc ( input wire clk, input wire rst, input wire branch_taken, input wire [15:0] branch_addr, output reg [15:0] pc_out ); always @(posedge clk) begin if (rst) pc_out <= 16'b0; else if (branch_taken) pc_out <= branch_addr; else pc_out <= pc_out + 16'b1; end endmodule这段代码里,branch_taken信号由控制器根据比较结果和跳转指令类别共同生成,branch_addr则是由当前PC和指令中的偏移量在组合逻辑里预先计算出来的目标地址。PC模块本身不含计算逻辑,只做寄存和更新。参数就是16位计数器的位宽以及复位初始值,如果你想让第一条指令从0x10开始执行,把复位值改掉即可。注意这里用的是非阻塞赋值(<=),保证所有寄存器在时钟沿统一更新,不会出现进程间竞争。
3.2 寄存器堆与ALU:16位运算器的具体实现
寄存器堆是CPU内部最核心的存储部件。16位CPU一般设计8个通用寄存器(r0~r7),每个寄存器16位宽。这里有个设计约定:r0恒为0,写入r0的操作不生效,这样能为汇编程序员提供常量来源,简化指令设计。寄存器堆的读端口应该是组合逻辑——一个周期的前半部分读寄存器,后半部分ALU完成运算,最终在时钟沿写入目标寄存器。
module register_file ( input wire clk, input wire [2:0] rs_addr, input wire [2:0] rt_addr, input wire [2:0] wr_addr, input wire [15:0] wr_data, input wire reg_write_en, output wire [15:0] rs_data, output wire [15:0] rt_data ); reg [15:0] regs [0:7]; assign rs_data = regs[rs_addr]; assign rt_data = regs[rt_addr]; always @(posedge clk) begin if (reg_write_en && wr_addr != 3'b000) regs[wr_addr] <= wr_data; end endmoduleread端口用了连续赋值,所以是纯组合逻辑;写入则发生在时钟上升沿。wr_addr != 3'b000 这一句就是r0恒为零的约定。初始化时可以把regs数组在仿真里通过for循环置零,上板时由复位信号统一清零。需要留意的点是:如果在一个时钟内既要读某个寄存器、又要写它,单周期模型里的读操作读到的应该是旧值,因为新值只在时钟沿写入,而读端口在整个周期内输出的是沿前的旧值——这一点和流水线处理器截然不同。
ALU负责具体运算。这里给出一个最小ALU的实现,支持加、减、与、或、异或、跳转相等比较六种操作。
module alu16 ( input wire [15:0] src_a, input wire [15:0] src_b, input wire [3:0] alu_op, output reg [15:0] result, output reg zero_flag ); always @(*) begin case (alu_op) 4'b0001: result = src_a + src_b; 4'b0010: result = src_a - src_b; 4'b0011: result = src_a & src_b; 4'b0100: result = src_a | src_b; 4'b0101: result = src_a ^ src_b; 4'b0110: result = (src_a == src_b) ? 16'b1 : 16'b0; default: result = 16'b0; endcase zero_flag = (result == 16'b0); end endmodule这里的alu_op就是控制器根据指令译码出来的操作选择线。zero_flag用于支持分支判断,比如“相等跳转”指令就可以直接检查zero_flag是否为1。注意减法用的是补码运算,Verilog里的减号自动完成按位取反加一,不在ALU内部显式表达借位逻辑;但如果你想要带进位标志的减法,就得额外拆出进位与借位输出,这就超出最小集的范围了。
3.3 数据存储器与读写时序:单端口RAM的控制信号配合
数据存储器通常使用单端口随机存储器,意味着读和写占用同一个端口。单周期模型里,读操作在一个周期内完成,写操作则在时钟沿写入。问题来了:如果指令同时要读存储器和写寄存器(比如加载类指令),存储器读出的数据会贯穿半个周期,最终被寄存器堆在下个时钟沿捕获;而存储器的写入使能则必须在周期内有效。因此控制信号配合的关键是:读使能可以常开,写使能必须精确限制在有写意图的指令周期内。
module data_mem ( input wire clk, input wire [7:0] addr, input wire [15:0] wr_data, input wire mem_write_en, output wire [15:0] rd_data ); reg [15:0] mem [0:255]; assign rd_data = mem[addr]; always @(posedge clk) begin if (mem_write_en) mem[addr] <= wr_data; end endmodule这里采用读组合、写同步的模型,或者说读地址直接穿透到输出。上板时这套代码如果放在真实随机存储器IP里,通常需要将组合读转换为同步读,即添加一级输出寄存器。我建议初学阶段先在仿真里用这种组合读模型来验证指令逻辑,等整套程序跑通后再换成真正的存储IP并接受一拍读延迟。这就是为什么单周期CPU看起来简单、实际联调时容易在存储器读延迟上翻车的原因。
4. 控制器解码与单周期时序:所有控制线在周期里如何协同
4.1 控制信号的生成:opcode到每根控制线的映射表
控制器是CPU的大脑,它的本质是根据当前指令的opcode,在组合逻辑中生成一组控制信号:寄存器写使能、ALU操作码、存储器写使能、分支判断使能、写回数据源选择等。不要用时钟沿去采样再输出这些控制线——那样会让CPU变成双周期行为,这是单周期设计里新手最容易犯的错误。
module controller ( input wire [3:0] opcode, output reg reg_write_en, output reg mem_write_en, output reg branch_en, output reg [3:0] alu_op, output reg [1:0] wb_sel ); always @(*) begin {reg_write_en, mem_write_en, branch_en, alu_op, wb_sel} = 11'b0; case (opcode) 4'b0001: begin // ADD reg_write_en = 1; alu_op = 4'b0001; wb_sel = 2'b00; end 4'b0010: begin // SUB reg_write_en = 1; alu_op = 4'b0010; wb_sel = 2'b00; end 4'b0011: begin // AND reg_write_en = 1; alu_op = 4'b0011; wb_sel = 2'b00; end 4'b0100: begin // OR reg_write_en = 1; alu_op = 4'b0100; wb_sel = 2'b00; end 4'b0101: begin // XOR reg_write_en = 1; alu_op = 4'b0101; wb_sel = 2'b00; end 4'b0110: begin // ADDI reg_write_en = 1; alu_op = 4'b0001; wb_sel = 2'b00; end 4'b0111: begin // LI reg_write_en = 1; alu_op = 4'b0001; wb_sel = 2'b10; end 4'b1000: begin // BEQ branch_en = 1; alu_op = 4'b0110; end 4'b1001: begin // J branch_en = 1; end 4'b1010: begin // LW reg_write_en = 1; alu_op = 4'b0001; mem_write_en = 0; wb_sel = 2'b01; end 4'b1011: begin // SW mem_write_en = 1; alu_op = 4'b0001; end default: begin end endcase end endmodulewb_sel信号统一决定了写回寄存器堆的数据来源:2’b00表示ALU运算结果,2’b01表示从数据存储器读取的值,2’b10表示立即数本身。这里值得说的是,LI(加载立即数)指令的立即数也要经过符号扩展,但目的寄存器的写回数据不是ALU的输出,而是扩展后的立即数。控制信号全部用阻塞赋值在同一个always块里生成,没有时序问题,这是组合逻辑控制器的正确写法。
4.2 单周期时序剖析:数据建立时间、路径延迟与时钟周期的关系
单周期CPU的时钟周期必须大于“最长的组合逻辑路径延迟”加上“寄存器建立时间”,否则就会采样到不稳定的值。这条最长路径通常发生在:从PC输出 → 指令存储器读出 → 控制器译码 → 寄存器堆读出 → ALU运算 → 写回数据选择 → 寄存器堆写入。任何一个环节延迟超了,整体频率就得降下来。
这就是为什么把所有控制信号揉在一个组合逻辑大块里会严重拖慢时序。我建议把控制器分成两组:一组根据opcode直接生成控制线的核心逻辑,另一组根据指令位域计算跳转目标地址的加法逻辑。跳转地址计算可以和ALU运算并行,路径上两者不重叠。实测下来,同样的代码风格,单一大case块的控制器比拆分方案慢了大约15%,这在FPGA布局布线后体现得尤为明显。
4.3 顶层模块集成:把PC、寄存器堆、ALU、存储器、控制器拼起来
顶层模块的作用是把零散的部件连成一个完整的回路。这里的关键连线包括:PC输出的地址作为指令ROM的地址,指令ROM读出的数据被拆分为opcode、rs、rt、imm等字段;控制器接收字段生成控制线;寄存器堆根据rs和rt读出两个操作数;ALU计算后结果送往寄存器堆写数据端;同时PC更新逻辑根据branch_en和zero_flag决定下一周期地址。
module top_cpu ( input wire clk, input wire rst, output wire [15:0] debug_pc, output wire [15:0] debug_inst, output wire [15:0] debug_reg0 ); wire [15:0] pc_w; wire [15:0] inst_w; wire [15:0] rs_data_w, rt_data_w; wire [15:0] alu_out_w; wire [15:0] mem_rd_w; wire [15:0] write_back_w; wire reg_write_en_w, mem_write_en_w, branch_en_w; wire zero_flag_w; wire [3:0] alu_op_w; wire [1:0] wb_sel_w; wire [15:0] branch_addr_w; wire branch_taken_w = branch_en_w & (zero_flag_w | inst_w[15:12] == 4'b1001); pc pc_unit (.clk(clk), .rst(rst), .branch_taken(branch_taken_w), .branch_addr(branch_addr_w), .pc_out(pc_w)); instr_rom rom_unit (.addr(pc_w[9:0]), .inst(inst_w)); controller ctrl_unit (.opcode(inst_w[15:12]), .reg_write_en(reg_write_en_w), .mem_write_en(mem_write_en_w), .branch_en(branch_en_w), .alu_op(alu_op_w), .wb_sel(wb_sel_w)); register_file rf_unit (.clk(clk), .rs_addr(inst_w[11:8]), .rt_addr(inst_w[7:4]), .wr_addr(inst_w[7:4]), .wr_data(write_back_w), .reg_write_en(reg_write_en_w), .rs_data(rs_data_w), .rt_data(rt_data_w)); alu16 alu_unit (.src_a(rs_data_w), .src_b(rt_data_w), .alu_op(alu_op_w), .result(alu_out_w), .zero_flag(zero_flag_w)); data_mem mem_unit (.clk(clk), .addr(alu_out_w[7:0]), .wr_data(rt_data_w), .mem_write_en(mem_write_en_w), .rd_data(mem_rd_w)); sign_extend se_unit (.imm8(inst_w[7:0]), .imm16(ext_imm_w)); assign branch_addr_w = pc_w + ext_imm_w; always @(*) begin case (wb_sel_w) 2'b00: write_back_w = alu_out_w; 2'b01: write_back_w = mem_rd_w; 2'b10: write_back_w = ext_imm_w; default: write_back_w = alu_out_w; endcase end assign debug_pc = pc_w; assign debug_inst = inst_w; assign debug_reg0 = rs_data_w; endmodule特别留意寄存器堆的写地址:在R型指令中,目标寄存器通常是rt_addr字段;在I型指令中则是rt_addr字段;而在LI指令中,目标寄存器也取自rt_addr。所以顶层统一用inst_w[7:4]作为写地址即可。跳转目标地址的计算方式是pc_w + ext_imm_w,不要直接把指令里的立即数当绝对地址用——这是初学者最容易计算错的地方。
5. 仿真与上板避坑:波形对了不代表电路就一定对
5.1 always块敏感列表不完整导致生成了锁存器
现象:仿真跑出来的功能正确,但综合报告里出现大量D锁存器警告,而且上板后芯片资源占用率异常高、时序约束不通过。原因:在组合逻辑always块内,控制信号的赋值没有覆盖所有条件分支,某个输入组合下落到了一个未被赋值的路径,硬件综合时系统默认保持原值,从而推演出锁存器。
解决:给控制器、ALU这类纯组合逻辑块的每个分支末尾加上default赋值,或者在always块开头给所有输出信号赋一个默认初值,比如4’b0000。养成这个习惯以后,组合逻辑综合警告基本绝迹。
5.2 立即数符号扩展的符号位写反了,负数运算全盘翻车
现象:执行“立即数加法”指令时,正数一切正常,但立即数为负数(比如0x80,即-128)时的运算结果与预期不符,甚至影响了后续跳转判断。原因:符号扩展模块把imm[7]当作无符号高位补零,0x80被扩展成0x0080而不是0xFF80,导致计算全部偏移128。
解决:写一个独立的符号扩展模块并做定向测试,验证-1(0xFF)扩展到0xFFFF,-128(0x80)扩展到0xFF80。我习惯于把符号扩展模块的测试放在最前面跑,确保这个地基组件没问题再往下联调,否则后期排错成本极高。
5.3 分支跳转地址计算偏了一条,程序陷入死循环
现象:BEQ指令条件成立时,程序没有跳到目标指令,而是停在了下下一条指令,或者跳到了错位的地方。原因:PC在取指阶段已经自动加1,此时PC寄存器里的值是当前指令地址+1;而计算跳转目标时,有些人的习惯是用当前指令的原始地址加偏移,从而多加了1。
解决:跳转目标地址使用当前PC值(已经处于加1后的状态)加上符号扩展的偏移量,不要额外减1。把这条规则写在代码注释里,让后续维护的人一目了然。我在第一次写BEQ时也踩了这个坑,整整调试了一个晚上。
5.4 仿真通过但上板后程序不运行,复位信号是罪魁祸首
现象:仿真testbench里一切正常,加载到开发板后CPU就是不动,数码管无任何输出。原因:开发板上的复位按键默认是高电平有效,但设计里用的是低电平复位,或者复位信号没有做同步处理,导致上电瞬间PC被复位到了不确定值,之后再也收不到有效的复位沿。
解决:要么在顶层把复位信号反转一次,要么在设计内部做同步复位:always @(posedge clk) begin if (rst) pc_out <= 16’b0; else ... end。关键是PCB上的物理信号和内部逻辑的极性必须匹配,仿真时只是你自己控制testbench里的rst初始值,掩盖了极性不一致的问题。
5.5 testbench里初始化时序写错,让调试者白白加班
现象:某个内部的中间信号在波形里永远是不定态X,查遍所有模块都没找到问题,最后发现是testbench里对指令ROM内存的初始化发生在仿真时间0时刻之前,而某个地址的数据没有初始化,默认成了不定态。
解决:在testbench的initial块里,用readmemh或for循环给所有存储器地址赋初值,并确保在复位拉高之前完成。另外在仿真时第一个操作应是复位,至少保持2个时钟周期的复位时间,然后再释放。不要一上来就连续跑指令,否则复位期间产生的毛刺会污染后面所有波形。
6. 给单周期CPU加上栈与调用指令:把裸机变成可跑子程序的小系统
单周期CPU跑汇编循环固然能验证正确性,但要说真正“有用”,还得加上子程序调用和返回能力。这里给的思路是定义两条新指令:CALL(调用)和RET(返回),并配备一个栈指针寄存器(SP,指向数据存储器的高地址区)。CALL指令需要将返回地址(即当前PC+1)压入栈中,然后跳到目标地址;RET指令则从栈顶弹出地址到PC。这要求在数据通路里增加一个专用的读写口,并且让PC的更新逻辑支持来自栈的跳转源。做法是在寄存器堆之外单独加一个栈指针寄存器,SP初始指向0xFF(栈顶为空),CALL执行时先写数据存储器(SP地址减一),RET执行时先读数据存储器再恢复SP。
动手跑一个递归加法或阶乘程序来验证。你会惊讶于这样一个简单的16位单周期CPU,加上栈和调用指令之后,编写程序的体验和裸写跳转标签完全不一样:子程序能封装、数据能局部化、程序结构从一坨线性代码变成了模块化代码。这也是把它从“课程设计作业”推向“可以演示的小系统”最关键的一步。在此之上还可以加中断控制,但那就需要多周期与状态机来保存上下文了,单周期已经无力承载。我个人建议做到栈调用为止,它花费的代码不到200行,却能把整个CPU设计能力提升一个档次。做这个改造时我也翻了一次车,就发生在压栈写入数据信号与PC更新的竞争——最后通过把写入提前半个周期解决,这个思路供你参考,动手时注意观察波形里的写使能时序就好。希望这篇笔记能帮你把16位单周期CPU从纸面设计一路推到板上跑起来,少走些弯路。
本文还有配套的精品资源,点击获取