资讯动态

单周期CPU设计实战:从零在Vivado跑通一条指令

发布时间:2026/10/17 11:10:26 来源:尧图企业网站定制
简介这是一份面向计算机组成原理学习者与FPGA入门者的单周期CPU设计工程包基于Verilog硬件描述语言编写配合Xilinx Vivado完成综合、仿真与下载适合课程设计、实验教学及处理器原理自学的实践场景。压缩包共107个文件约1.31MB包含12个.v源码文件承载CPU核心逻辑另有xpr工程文件、xdc约束、tcl脚本、bat批处理以及log、jou调试日志和dcp综合结果等覆盖从源码到实现下载的完整流程。设计围绕取指、译码、执行、写回四个阶段展开涉及指令寄存器、指令解码器、ALU、通用寄存器、控制单元、内存接口与程序计数器等关键模块可帮助读者理解指令在单周期内的流转方式与时序约束的优化思路。目前已有3633人学习下载借助工程文件与调试日志读者能够快速复现仿真波形、定位综合报错并对照修改指令解码逻辑从而掌握FPGA开发的基本技能。1. 单周期CPU设计从零在Vivado里跑通一条指令的完整路径很多人第一次接触 Verilog 和 Vivado都是从点灯开始的但真正让人对数字逻辑开窍的是亲手搭一颗能执行指令的 CPU。这份单周期 CPU 设计资源就是干这件事的它用 Verilog 描述了一个取指、译码、执行、访存、写回全部在一个时钟周期内完成的处理器核配套 Vivado 工程可以直接综合、仿真、上板。适合正在学计算机组成原理、想搞懂一条 add 指令到底怎么在硬件里跑起来的学生也适合想拿它当模板做二次开发、加指令或改数据通路的开发者。单周期结构简单、路径清晰是理解流水线之前绕不开的一站把它吃透后面看多周期和流水线就不会一头雾水。2. 架构拆解单周期数据通路里每个模块在干什么单周期 CPU 的核心思想是一条指令一个周期听起来简单但它逼着你把整条数据通路想清楚——因为所有模块必须在同一个时钟沿内串起来跑完。下面按数据流方向把模块拆开讲。2.1 顶层模块与数据通路总览顶层模块负责把 PC、指令存储器、寄存器堆、ALU、数据存储器、控制器这些部件用线连起来。常见做法是顶层只做例化和连线不写逻辑逻辑都下沉到子模块里这样综合出来的层次清晰也方便单独仿真某个模块。// 顶层只做例化和连线保持干净 module cpu_top( input wire clk, input wire rst_n, output wire [31:0] pc_out, // 方便仿真观察当前PC output wire [31:0] alu_result // 方便观察ALU输出 ); wire [31:0] pc, pc_next, instr; wire [31:0] rd1, rd2, alu_y, mem_rdata, wb_data; wire [31:0] imm; wire zero; wire [3:0] alu_op; wire reg_we, mem_we, mem_re, alu_src, wb_sel; // PC 与取指 pc_reg u_pc(.clk(clk), .rst_n(rst_n), .pc_next(pc_next), .pc(pc)); imem u_imem(.addr(pc), .instr(instr)); // 译码 控制器 ctrl u_ctrl(.instr(instr), .zero(zero), .alu_op(alu_op), .reg_we(reg_we), .mem_we(mem_we), .mem_re(mem_re), .alu_src(alu_src), .wb_sel(wb_sel)); regfile u_rf(.clk(clk), .we(reg_we), .ra1(instr[19:15]), .ra2(instr[24:20]), .wa(instr[11:7]), .wd(wb_data), .rd1(rd1), .rd2(rd2)); imm_gen u_imm(.instr(instr), .imm(imm)); // 执行 assign alu_y alu_src ? imm : rd2; alu u_alu(.a(rd1), .b(alu_y), .op(alu_op), .y(alu_result), .zero(zero)); // 访存 写回 dmem u_dmem(.clk(clk), .we(mem_we), .re(mem_re), .addr(alu_result), .wdata(rd2), .rdata(mem_rdata)); assign wb_data wb_sel ? mem_rdata : alu_result; assign pc_next pc 32d4; // 单周期顺序执行暂不处理跳转 assign pc_out pc; endmodule这段代码里几个关键点pc_next直接等于pc4说明这是最简版本还没接分支跳转alu_src决定 ALU 的第二个操作数来自寄存器还是立即数wb_sel决定写回的是 ALU 结果还是访存读出的数据。参数上clk是时钟rst_n低电平复位两个 output 纯粹是为了仿真时能在波形里直接看到 PC 和 ALU 结果实际综合时可以去掉。2.2 控制器与 ALU 的译码逻辑控制器是整个 CPU 的大脑它根据指令的 opcode 和 funct 字段产生一堆控制信号。单周期里控制器是纯组合逻辑输入指令、输出所有控制线没有状态机这也是它比多周期好懂的地方。module ctrl( input wire [31:0] instr, input wire zero, output reg [3:0] alu_op, output reg reg_we, mem_we, mem_re, alu_src, wb_sel ); wire [6:0] opcode instr[6:0]; wire [2:0] funct3 instr[14:12]; wire [6:0] funct7 instr[31:25]; always (*) begin // 默认值避免综合出锁存器 alu_op 4d0; reg_we 0; mem_we 0; mem_re 0; alu_src 0; wb_sel 0; case(opcode) 7b0110011: begin // R型add/sub/and/or reg_we 1b1; alu_src 1b0; alu_op {funct7[5], funct3}; // 用funct7区分add/sub end 7b0010011: begin // I型addi reg_we 1b1; alu_src 1b1; alu_op 4b0000; end 7b0000011: begin // load reg_we 1b1; alu_src 1b1; mem_re 1b1; wb_sel 1b1; alu_op 4b0000; end 7b0100011: begin // store alu_src 1b1; mem_we 1b1; alu_op 4b0000; end default: ; endcase end endmodule逻辑说明always (*)里第一件事是给所有输出赋默认值这是写组合逻辑的血泪经验——不赋默认值综合工具会推断出锁存器latch波形上会出现莫名其妙的保持行为。alu_op用{funct7[5], funct3}拼出来是因为 R 型指令里 add 和 sub 的 funct3 相同只能靠 funct7 的最高位区分。参数上zero目前没用到等加分支指令时才会接进来判断是否跳转。2.3 寄存器堆与立即数生成寄存器堆是 CPU 里唯一带状态的存储部件除了 PC 和内存写口在时钟上升沿生效读口是组合输出。立即数生成模块负责把不同指令格式里的散落比特拼成完整立即数。module regfile( input wire clk, input wire we, input wire [4:0] ra1, ra2, wa, input wire [31:0] wd, output wire [31:0] rd1, rd2 ); reg [31:0] regs [0:31]; integer i; initial begin for (i 0; i 32; i i 1) regs[i] 32d0; end assign rd1 (ra1 5d0) ? 32d0 : regs[ra1]; // x0恒为0 assign rd2 (ra2 5d0) ? 32d0 : regs[ra2]; always (posedge clk) begin if (we wa ! 5d0) regs[wa] wd; // 不写x0 end endmodule这里有两个容易翻车的点一是x0寄存器必须恒为 0读的时候要特判写的时候要禁止二是initial块在 FPGA 上能综合成上电初值但换到 ASIC 流程就不行了所以正式项目里通常靠复位信号清零。参数ra1/ra2是读地址wa是写地址we是写使能。3. Vivado 工程搭建从新建到综合上板的完整流程代码写完了不代表能跑Vivado 的工程配置和约束文件才是让设计真正落到板子上的关键。这一章按实际操作顺序走一遍。3.1 新建工程与添加源文件打开 Vivado选 Create Project一路 Next 到添加源文件那一步把上面几个.v文件都加进去顶层设为cpu_top。器件型号按你手上的板子选比如 Artix-7 系列就选对应的xc7a...型号。选错型号综合能过但上板会出问题这一步别图快。添加完源文件后Vivado 会自动做一次语法检查如果cpu_top里例化的模块名和文件名对不上这里就会报错。常见做法是模块名和文件名保持一致省得自己给自己挖坑。3.2 约束文件与时钟配置约束文件.xdc告诉工具引脚怎么映射、时钟周期是多少。最小可用的约束长这样# 时钟约束假设板载50MHz晶振 create_clock -period 20.000 -name sys_clk [get_ports clk] # 复位引脚 set_property PACKAGE_PIN P20 [get_ports rst_n] set_property IOSTANDARD LVCMOS33 [get_ports rst_n] # 时钟引脚 set_property PACKAGE_PIN P17 [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk]create_clock里的-period 20.000单位是纳秒对应 50MHz。单周期 CPU 的关键路径通常很长取指到写回全串在一起50MHz 能不能跑满要看综合后的时序报告。如果时序不收敛先把周期放宽到 40ns 试试跑通了再慢慢收紧。引脚号P20、P17是示例实际要查你板子的原理图填错了轻则没反应重则烧引脚。这一步是新手最容易翻车的地方。3.3 仿真验证写一个能跑的 testbench上板之前先用仿真确认逻辑对比直接烧板子调试快得多。testbench 里给时钟和复位然后观察 PC 和寄存器变化。module tb_cpu; reg clk, rst_n; wire [31:0] pc_out, alu_result; cpu_top u_cpu(.clk(clk), .rst_n(rst_n), .pc_out(pc_out), .alu_result(alu_result)); // 50MHz 时钟 initial clk 0; always #10 clk ~clk; initial begin rst_n 0; #100 rst_n 1; // 释放复位 #2000 $finish; // 跑够时间后结束 end // 打印PC方便看指令流 always (posedge clk) begin if (rst_n) $display(time%0t pc%h alu%h, $time, pc_out, alu_result); end endmodule#10 clk ~clk产生周期 20ns 的时钟和约束里的 50MHz 对应。$display每个时钟沿打印一次 PC 和 ALU 结果跑完仿真在 Tcl Console 里就能看到指令一条条执行过去。如果 PC 一直停在 0 或者乱跳多半是复位没接对或者 PC 模块的时序写错了。3.4 综合、实现与上板仿真过了就可以跑综合Synthesis和实现Implementation。综合看的是逻辑能不能映射到 FPGA 资源实现看的是布局布线后时序能不能收敛。两个都过了生成比特流Generate Bitstream用 Hardware Manager 下载到板子。上板后如果没反应先检查时钟引脚约束对不对再看复位是不是一直拉着。单周期 CPU 没有输出显示的话可以加几个 LED 接到 PC 的低位看灯有没有在变这是最朴素的调试手段。4. 避坑与排查单周期 CPU 调试中最容易踩的五个坑单周期 CPU 逻辑不复杂但坑都藏在细节里。下面五条是我自己踩过、也见过别人反复踩的。现象仿真里 PC 一直不动。原因复位信号没释放或者pc_reg里的复位逻辑写成了同步复位但时钟没接对。 解决在 testbench 里确认rst_n在某个时刻确实拉高了波形里看rst_n和clk的关系。现象综合报 latch inferred 警告。原因组合逻辑always块里有分支没赋默认值工具推断出锁存器。 解决在always (*)开头给所有输出赋默认值这是硬规矩别偷懒。现象寄存器写进去了但读出来是旧值。原因写口和读口在同一个时钟沿竞争读的是写之前的值。 解决单周期里读口是组合的写口是时序的正常情况读到的就是当前值。如果不对检查we使能是不是晚了一拍。现象上板后时序不收敛实现报 setup violation。原因单周期关键路径太长50MHz 跑不动。 解决先把时钟周期放宽确认功能对了再逐步收紧或者把 ALU 里的复杂运算拆成两级。现象load 指令读出来的数据不对。原因wb_sel选错了写回的是 ALU 结果而不是内存数据或者mem_re没拉高。 解决在波形里看wb_sel和mem_rdata确认访存路径通了。提示调试单周期 CPU波形比打印好用。把 PC、指令、ALU 结果、写回数据都加到波形窗口一条指令一条指令对着看比猜快得多。5. 二次开发加指令、改数据通路和验证方法把基础版本跑通之后真正有意思的是改它。单周期 CPU 因为结构简单加一条指令的改动路径非常清晰适合拿来练手。5.1 加一条分支指令的完整改动以加beq相等则跳转为例需要动三个地方控制器加译码、PC 模块加跳转逻辑、顶层把zero接进控制器。// 控制器里增加分支判断 7b1100011: begin // beq alu_op 4b0001; // 减法用zero判断相等 // reg_we 保持0分支不写寄存器 end // 顶层PC逻辑改成带分支 wire take_branch (opcode 7b1100011) zero; assign pc_next take_branch ? (pc imm) : (pc 32d4);改动虽小但要注意imm的位宽和符号扩展分支偏移是相对当前 PC 的算错了会跳到莫名其妙的地方。改完先仿真构造两条beq前后跳的指令看 PC 是不是按预期跳。5.2 从单周期到多周期的改造思路单周期最大的问题是关键路径长、时钟上不去。想改成多周期核心是把一条指令拆成取指、译码、执行、访存、写回几个状态用一个状态机控制每个周期做什么。改造时数据通路基本不变主要加的是状态寄存器和多路选择器——比如 PC 要加一个保持选项寄存器堆的写使能要延后到写回状态。我一般会先把状态机画出来标清楚每个状态激活哪些控制信号再动代码。直接改代码很容易漏掉某个状态下的默认值又掉进 latch 的坑里。5.3 验证方法用指令序列覆盖每条路径改完之后怎么确认没改坏我的习惯是写一段覆盖所有指令类型的汇编序列手工算出每条指令执行后的寄存器值然后在 testbench 里跑完对比。比如先addi x1, x0, 5再addi x2, x0, 3然后add x3, x1, x2期望x38。这种小序列比随机测试靠谱出问题能直接定位到哪条指令。从那以后我每次改数据通路都强制走一遍仿真波形 指令序列对比这两步不看到波形里 PC 和寄存器按预期变化绝不上板。希望这份单周期 CPU 的设计和调试思路能帮你少走几个弯路早点看到自己写的处理器跑起来。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑