
做CPU设计这件事很多人一开始都会纠结到底该看《计算机组成与设计》那本厚书还是直接拉个开源项目硬啃我的经验是理论看再多不如亲手在FPGA上跑通一个能执行指令的处理器来得实在。这篇文章记录的就是这样一个完整过程用Xilinx FPGA从零搭建一个RISC-V单周期CPU配套的Verilog代码可以直接复用尤其适合正在学计算机组成原理、或者想入门CPU设计却不知道怎么下手的同学。在做这个项目之前我自己也踩过不少坑。最开始想直接上手五级流水线结果被数据冒险和分支预测搞得焦头烂额连仿真波形都调不明白。后来老老实实退回单周期架构把指令取指、译码、执行、访存、写回这条链路彻底吃透再回头看流水线思路瞬间清晰了很多。所以这篇文章的核心思路很明确先用单周期把RISC-V指令集的执行链路跑通把每条指令在硬件上是怎么走完一个时钟周期的搞明白后面再往高性能方向走就有了底气。文章会从架构设计、模块拆分、代码实现、仿真验证到上板调试把我实际操作的完整流程和中间踩过的坑都摊开来讲。所有代码都是我在Vivado里实测通过的不是那种从网上复制下来就跑不了的半成品。如果你手里正好有块Xilinx的开发板比如Artix-7系列的Nexys A7或者Spartan-7系列的Basys 3跟着这篇文章走一遍你也能拥有一个属于自己的CPU。1. 整体设计思路为什么第一步选RISC-V和单周期架构很多初学者第一次接触CPU设计最容易犯的错误就是一上来就追求高性能。看到别人做乱序执行、多核处理器自己也跟着搞结果写了上千行代码仿真全红根本不知道问题出在哪里。我自己就经历过这种阶段所以这个项目里我用了一个更务实的思路先把单周期架构吃透用最少的指令集跑通一个最小的计算机系统。1.1 指令集选型RISC-V到底赢在哪里选择RISC-V而不是ARM或者x86不是因为ARM不好而是因为RISC-V开源免费的特性让学习者可以毫无负担地研究指令集的每一个细节。ARM的指令集文档虽然公开但你要用它的IP核授权费就是个门槛更别提把整个指令集的执行逻辑完全打开给你研究。x86就更不用说了微码那层东西复杂到连Intel自己都说不清楚。RISC-V的文档写得极其清晰特别是“指令集规范”这份文档把一个指令从编码格式到执行语义讲得明明白白。这个项目用的是RV32I基础整数指令集总共也就40多条指令每条指令都是固定32位长度解码逻辑非常简单。对比一下MIPS指令集RISC-V的设计明显更现代——它没有延迟槽这种让流水线实现变得极其痛苦的机制寄存器数量、指令编码格式也都做了优化。还有一个很现实的优势RISC-V的生态已经相当成熟。Vivado里有现成的RISC-V软核可以调用GCC有RISC-V版本连学校里的计算机组成原理实验课都在用RISC-V做教学。这意味着你写完CPU之后可以直接用GCC编译一段C代码然后把二进制文件加载到你的CPU上运行这种“我写的CPU跑起了真实编译的程序”带来的成就感是仿真波形图完全比不了的。1.2 架构选型单周期CPU的价值被严重低估了单周期CPU顾名思义就是每条指令都在一个时钟周期内完成。每条指令的CPI每指令周期数恒等于1这听起来好像很浪费——毕竟不是每条指令都需要ALU运算、访存和写回但所有指令都得把最长的关键路径走一遍。但换个角度想单周期架构恰恰把“指令在硬件里到底是怎么流转的”这件事以最直观的方式呈现在你眼前。举个具体的例子一条加法指令add x1, x2, x3在单周期CPU里是这样流转的程序计数器PC先把地址送到指令存储器拿到32位的机器码然后译码模块解析出opcode确定这是一条R型指令把寄存器堆里x2和x3的值读出来这两个值马上进入ALU加法器在组合逻辑里算好结果紧接着结果被写回寄存器堆的x1。这一整条链路在一个时钟上升沿到来之前全部完成下一个时钟沿一到所有状态统一更新。这种“所有事情在一个节拍里搞定”的设计让数据通路变得无比清晰。对比一下如果我一开始就做五级流水线取指、译码、执行、访存、写回五个阶段并行工作虽然吞吐率上去了但随之而来的结构冒险、数据冒险、控制冒险任何一个都够初学者折腾一个星期。特别是数据冒险写后读冲突的处理方式有转发、停顿、编译器调度好几种每种都有不同的适用场景。这些当然很重要但它们是流水线引入的“新问题”而不是CPU设计的“核心问题”。如果连单周期的数据通路都画不清楚直接上流水线就是给自己挖坑。1.3 Xilinx平台选型Vivado和开发板的实操感受我手里这块板子是Xilinx Artix-7系列的XC7A35T用的开发环境是Vivado 2019.1。选择Xilinx主要是看中两件事第一Artix-7这个系列的片子性价比很高学生党也买得起性能却足够跑一个完整的RISC-V处理器第二Vivado的仿真工具虽然称不上好用但胜在稳定而且网上关于Xilinx FPGA的资料多到你看不完遇到问题搜索一下基本都能解决。Vivado的IP核也是个好东西像Block Memory Generator这个IP核直接用来做指令存储器和数据存储器非常方便不用自己手写BRAM控制逻辑。我认识一些朋友用Altera的Quartus也能做类似的事情但我个人用Xilinx用得顺手这个项目的所有代码和约束文件也都是基于Xilinx开发的。如果你手头的板子是其他厂商的代码本身几乎不需要改动只要把存储器相关的IP核换一下就差不多了。2. 指令集与数据通路动手写代码前必须想清楚的两件事写Verilog代码之前我建议你先花半天时间做两件事第一把RISC-V的指令格式彻底搞清楚第二把数据通路图画在纸上。这两个准备工作做扎实了后面写代码就是按图索骥的事。如果跳过了这两步直接开写大概率是写了一堆代码却不知道自己在干什么仿真报错了也不知道从哪里开始查。2.1 RV32I指令格式剖析R型、I型、S型、B型、U型、J型RV32I的指令编码格式一共有六种R型寄存器-寄存器运算、I型立即数运算和加载、S型存储、B型条件分支、U型长立即数、J型跳转。这六种格式的指令长度都是32位但每个字段的位置和含义不同。理解它们的核心诀窍只有一个把指令编码想象成一个拼图不同格式的指令就是把不同的拼图块放进对应的位置。以R型指令add x1, x2, x3为例它的二进制编码为0000000_00011_00010_000_00001_0110011。我来拆解一下最低7位[6:0]是opcode操作码R型指令固定为0110011[11:7]是rd目标寄存器这里对应x1[14:12]是funct3用来区分同opcode下的不同操作add的funct3是000[19:15]是rs1源寄存器1对应x2[24:20]是rs2源寄存器2对应x3最高位[31:25]是funct7add的funct7全是0。如果你看到funct7为0100000同样是R型和000的funct3但指令就变成了sub x1, x2, x3。I型指令则有些不同它的立即数是12位的位于[31:20]所以编译器可以直接对寄存器进行操作。addi x1, x2, 5这条指令就是x1 x2 5立即数5放在12位的字段里可以表示-2048到2047的范围。这里有个特别容易踩的坑RISC-V的立即数符号扩展不是简单的0扩展。B型指令的立即数由[31:25]和[11:7]两个字段拼起来为了实现12位立即数覆盖整个指令宽度编码时把立即数的奇数位和偶数位拆开了。所以如果是第一次照着说明书写译码模块一定要把立即数扩展逻辑单独写成一个函数加上充分的注释不然过两个星期自己都看不懂了。2.2 数据通路手绘图每一个信号走向都必须心里有数我画了三版数据通路图第一版是在白纸上用铅笔画的手稿。为什么要画图因为Verilog是并行的硬件描述语言写代码的时候如果不清楚每个模块之间的连接关系你会发现自己连wire都命名混乱。数据通路图的本质是建立起信号流向的全局观PC从哪里来送到哪里去指令寄存器的输出连接到谁ALU的两个输入分别是谁写回的数据总线从哪里选通。一个最小可运行的单周期RISC-V CPU数据通路需要包含以下关键路径取指路径PC → 指令存储器 → 当前指令。这条路径的延迟决定了系统最高时钟频率。通用路径寄存器堆读取 → ALU运算 → 写回。这是最多功能的路径承载了所有R型和I型算术逻辑指令。访存路径ALU的结果作为地址 → 数据存储器 → 写回。加载和存储指令专用涉及一个关键的“写回数据来源选择”MUX。分支路径寄存器比较的结果 → 加法器计算目标地址 → 选择PC的下一个值。这条路径是关键路径的长尾时序收敛最容易出问题的地方。我把每一根线的信号名和位宽都标在图上32位PC总线、32位指令总线、5位寄存器地址、3位funct3控制信号等等。这些信号在后面的Verilog代码里会用同样的命名让代码和图纸一一对应。这一步做扎实了写代码时可以省下至少一半的调试时间。2.3 控制信号的产生一条指令的“指挥中心”怎么工作控制模块是整个CPU的指挥中心它的输入是当前指令的opcode、funct3、funct7输出是各类控制信号。对比Xilinx自己的MicroBlaze软核的结构你会发现它也有一个控制信号生成模块但我们的RV32I单周期CPU控制模块要简单得多因为指令集精简控制逻辑也就十几行case语句。对照前面讲的六种指令格式我需要生成的控制信号有这些reg_write寄存器堆写使能R型、I型、U型、J型指令有效S型存储指令和B型分支指令无效。alu_src选择ALU第二个操作数来自寄存器值还是立即数。R型选寄存器I型、S型、加载指令选立即数。mem_read和mem_write数据存储器的读写使能加载指令开读存储指令开写其他指令两者都关。mem_to_reg写回数据选择寄存器计算结果还是存储器读出的数据加载指令选择存储器数据。alu_op两比特控制信号送到ALU控制模块让它根据funct3和funct7生成最终的ALU运算选择信号。branch分支指令有效信号B型指令置高ALU输出零标志位后和它做与运算来决定是否跳转。jal和jalr跳转指令特殊控制需要把PC4或者寄存器值作为跳转地址。写完控制模块的case语句后有个非常好的验证方法把手边的每一条指令穷举一遍人工模拟一遍每条控制信号的取值。比如lw x1, 0(x2)这条加载指令控制信号应该是reg_write1、alu_src1、mem_read1、mem_write0、mem_to_reg1。如果case语句里少了某个分支仿真的时候很容易通过覆盖率工具暴露出来。但对于初学者来说更现实的方法是写一个简单的测试平台把所有指令类型都跑一遍观察波形里关键控制信号的变化是否符合预期。3. Verilog代码实现与模块详解代码部分是整个项目的重头戏。我按照模块化的思路把CPU拆成了几个相对独立的文件这样每个模块的错误都比较好定位。当然如果你用的是Vivado你也可以把所有模块写在一个文件里但说实话我不建议这样做。等你后面想在这个CPU基础上加流水线、加中断、加MMU的时候模块分离的优势就会体现出来——你可以只替换某一个模块而不动其他模块的代码。3.1 程序计数器PC模块一切指令流转的起点PC模块是整个处理器的发动机。每个时钟上升沿它把下一个指令地址锁存到输出端口。模块本身很简单但它的输入next_pc来自哪里是整个数据通路设计里最需要想清楚的地方。module pc( input clk, input rst_n, input [31:0] next_pc, output reg [31:0] pc_out ); always (posedge clk or negedge rst_n) begin if (!rst_n) pc_out 32h0000_0000; else pc_out next_pc; end endmodulenext_pc的选择逻辑是这样的默认情况下取PC4这是顺序执行的情况当分支指令的条件满足时next_pc PC 符号扩展的立即数这是一个相对跳转jal指令则是next_pc PC 相对偏移同时把PC4存到链接寄存器jalr指令是next_pc 寄存器值 立即数注意这一条是绝对跳转。这段代码里有一个地方我要专门强调复位信号rst_n是低电平有效。很多初学者在这个地方容易搞混导致上板后按了复位键系统反而不复位。我自己的习惯是定义端口时把活性写进名字比如rst_n就是低有效wr_en就是高有效写使能这样后代码写多了更不容易出错。3.2 指令存储器与数据存储器的实现方案对比指令存储器的实现我对比过两种方案第一种是直接例化Xilinx的Block Memory Generator IP核生成一个单端口ROM初始化文件用.coe格式第二种是手写reg [31:0] mem [0:4095]数组通过$readmemh在初始化时加载指令。前者适合代码量大、需要实际占用BRAM的情况后者在仿真和调试初期更方便因为$readmemh支持运行时重新加载不用每次修改程序都重新生成IP核。我在实际项目中用了折中方案仿真阶段用手写的存储器数组综合时切换到IP核版本。两者的接口完全一致只是内部实现不同这得益于Xilinx Vitis和Vivado在设计流程上的强项——可综合的代码和仿真用的testbench相互独立可以灵活切换。数据存储器的设计稍微复杂一点因为它需要支持按字节读写。RISC-V的lb、lbu、sb指令要求能够按字节访问这意味着写使能信号有4个比特位分别对应一个32位字中每个字节的写使能。module data_mem( input clk, input [31:0] addr, input [31:0] wdata, input [3:0] we, input re, output [31:0] rdata ); reg [7:0] mem [0:4095]; wire [31:2] word_addr addr[31:2]; always (posedge clk) begin if (we[0]) mem[{word_addr, 2b00}] wdata[7:0]; if (we[1]) mem[{word_addr, 2b01}] wdata[15:8]; if (we[2]) mem[{word_addr, 2b10}] wdata[23:16]; if (we[3]) mem[{word_addr, 2b11}] wdata[31:24]; end assign rdata {mem[{word_addr, 2b11}], mem[{word_addr, 2b10}], mem[{word_addr, 2b01}], mem[{word_addr, 2b00}]}; endmodule如果直接访问字节字地址word_addr需要左移两位再传输。有些初学者在这里会把地址索引写错导致加载出来永远是错的数据。建议先在仿真波形里仔细检查地址线的每一位确保addr[1:0]传递的字节偏移信息没有被丢掉。3.3 寄存器堆模块RISC-V的32个通用寄存器RISC-V架构定义了32个32位的通用寄存器x0寄存器是硬连线的零寄存器写进去的任何值都会被忽略读出永远是0。这个特性在硬件上实现起来很简单但它在指令集设计层面有大用处编译器可以用x0作为占位寄存器丢掉那些不需要的结果不用真正执行一条“空操作”指令。寄存器堆的写端口是同步的读端口是组合逻辑的。这意味着写使能拉高的时钟上升沿到来之前如果读地址刚好等于写地址读到的可能是旧值而不是新值。在单周期CPU里因为写回结果在一个时钟周期内就会反馈给后面指令的读操作所以这个结构天然避免了数据冒险。但如果你以后做流水线这里就必须加上转发逻辑不然就读到旧数据了。module regfile( input clk, input reg_write, input [4:0] rs1, rs2, rd, input [31:0] wdata, output [31:0] rdata1, rdata2 ); reg [31:0] regs [0:31]; assign regs[0] 32h0; always (posedge clk) begin if (reg_write rd ! 5h0) regs[rd] wdata; end assign rdata1 regs[rs1]; assign rdata2 regs[rs2]; endmoduleregs[0]被assign语句强制为0这样即使有代码不小心往x0里写数据也不会改变它的值。这个写法的成本很低但可以防止一些莫名其妙的问题。另一个细节是rd ! 5h0的判断虽然硬连线已经让x0写不进东西了但判断条件能减少无谓的寄存器堆写操作功耗更接近真实处理器的做法。3.4 ALU模块与ALU控制子模块一切计算的中枢ALU是处理器里最纯粹的组合逻辑模块它的输入是两个32位操作数和一个4位的运算选择信号输出是32位的计算结果和一个零标志位。我用的ALU控制信号编码比较直接0000加法、0001减法、0010按位与、0011按位或、0100按位异或、0101逻辑左移、0110逻辑右移、0111算术右移、1000小于则置位set less than。零标志位zero是实现分支指令的关键。beq指令判断两个寄存器是否相等其实就是ALU做一次减法然后看零标志位是否为1。如果相等相减结果为零zero拉高跳转条件成立。module alu( input [31:0] a, b, input [3:0] alu_ctrl, output reg [31:0] result, output zero ); assign zero (result 32h0); always (*) begin case (alu_ctrl) 4b0000: result a b; 4b0001: result a - b; 4b0010: result a b; 4b0011: result a | b; 4b0100: result a ^ b; 4b0101: result a b[4:0]; 4b0110: result a b[4:0]; 4b0111: result $signed(a) b[4:0]; 4b1000: result ($signed(a) $signed(b)) ? 32h1 : 32h0; default: result 32h0; endcase end endmodule细节上移位指令的操作数只取b的低5位这是RISC-V规范规定的因为32位寄存器最多只能移31位再多就没有意义。算术右移用的是$signed(a) b[4:0]这里必须把a先声明为signed再移位否则Verilog会把它当作逻辑右移结果就错了。slt指令实现的是带符号比较如果要实现sltu无符号比较把$signed($unsigned(a))改成$unsigned(a)就行。这些细节的区分是RISC-V指令集里funct7字段干的事情funct7的最高位区分了add/sub和slt/sltu做ALU控制子模块的时候需要非常小心地去译码。3.5 顶层数据通路把零散的模块连接成一个真正的CPU顶层模块rvcpu_single是所有模块的“总装车间”。在这个模块里我用wire声明了所有内部信号然后把PC、指令存储器、寄存器堆、ALU、数据存储器、控制模块逐一例化并连接起来。这里有个关键点例化顺序没有要求硬件电路是并行连接的但wire命名必须统一我习惯用信号名_源模块_目的模块的命名方式比如pc_out_imem表示PC的输出连接到指令存储器。顶层模块里最复杂的部分是写回数据的选择MUX、分支地址的计算和PC的更新逻辑。分支的计算并不是直接加指令里给的立即数因为RISC-V的B型指令立即数实际上是字节偏移但PC是按字对齐的。所以在Verilog里需要把立即数左移1位后再做地址加法。这个左移在指令编码阶段就已经定好了指令里的立即数是偏移量的1/2但稍不留神就会忘记在硬件里再移一次导致分支跳转总是错位。wire [31:0] pc_plus4 pc_out 32h4; wire [31:0] branch_target pc_out {{19{imm[31]}}, imm[30:0], 1b0}; wire [31:0] jump_target (rs1_data {{20{imm[31]}}, imm[31:20]}) ~32h1;这三个地址计算的核心都是立即数的符号扩展。imm是译码模块根据指令格式生成的32位立即数{{19{imm[31]}}, imm[30:0], 1b0}是把它左移一位并符号扩展最后一位补0。jump_target需要把算计出来的地址最低位清零保证跳转目标是字对齐的这是jalr指令的规范要求。4. 完整Verilog代码与工程搭建指南这一节我来直接给出可以在Vivado里跑通的完整代码。工程搭建我就不啰嗦了新建工程、添加源文件的步骤在网上一搜一大把我重点讲两个容易出错的地方存储器的初始化和约束文件。4.1 存储器初始化与程序烧录方式指令存储器需要有初始内容才能执行我在项目里用了两种方式第一种是$readmemh。这种方式最简单但只能用于仿真。$readmemh(program.hex, mem)这条语句会把一个十六进制文本文件按行读入存储器数组。文件的格式是每行一个32位的十六进制数比如00000013就是一个什么都不做的addi x0, x0, 0NOP指令。第二种是.coe文件配合Block Memory Generator IP核。综合后下载到FPGA时需要这种方式因为FPGA里没有磁盘可以读文件只能把初始化数据当成二进制比特流的一部分写进BRAM。.coe文件的格式是固定开头memory_initialization_radix16;和memory_initialization_vector后面跟逗号分隔的十六进制数据。在开发初期我强烈建议先用$readmemh配合testbench仿真调通指令逻辑等指令都正确了再切换到.coe上板调试。如果你一上来就在Vivado里捣鼓IP核的.coe格式出了错误很难排查是存储器的初始化问题还是CPU逻辑本身的问题。4.2 Xilinx FPGA约束文件的核心要点如果你用的是Xilinx官方开发板比如Nexys A7或者Basys 3约束文件可以直接从官网下载。如果你用的是国产开发板或者自己画的板子就要根据原理图写约束。我以一个外部按键作为复位、一个外部时钟为100MHz为例给出核心约束set_property PACKAGE_PIN E3 [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk] set_property PACKAGE_PIN C12 [get_ports rst_n] set_property IOSTANDARD LVCMOS33 [get_ports rst_n] create_clock -period 10.000 -name clk [get_ports clk]create_clock这一条必须写上不然Vivado的时序分析器不知道时钟周期是多少综合后也看不到时序报告。对于100MHz的输入时钟单周期CPU完全可以跑得稳因为关键路径一般不会超过10ns。如果你发现时序违例最简单的处理方式就是把时钟频率降下来比如改成50MHz把输入时钟用PLL分频。4.3 顶层模块完整代码可直接综合和仿真我把自己在项目中使用的顶层模块完整贴出来去掉了一些和专业无关的调试功能保留了最核心的CPU逻辑。这个顶层模块可以综合也可以仿真接口非常简单。module riscv_single( input wire clk, input wire rst_n, output wire [31:0] pc_debug, output wire [31:0] inst_debug, output wire [31:0] alu_result_debug ); // 内部信号声明 wire [31:0] pc_curr; wire [31:0] pc_next; wire [31:0] inst; wire [31:0] imm; wire [31:0] reg_rdata1, reg_rdata2; wire [31:0] alu_in2; wire [31:0] alu_result; wire alu_zero; wire [31:0] mem_rdata; wire [31:0] reg_wdata; // 控制信号 wire reg_write, alu_src, mem_read, mem_write, mem_to_reg, branch; wire [1:0] alu_op; // 分支与跳转相关 wire branch_sel branch alu_zero; wire [31:0] pc_plus4 pc_curr 32d4; wire [31:0] branch_target pc_curr {imm[30:0], 1b0}; wire [31:0] jalr_target (reg_rdata1 imm) ~32h1; assign pc_next branch_sel ? branch_target : (jal_ctrl ? (jalr_ctrl ? jalr_target : branch_target) : pc_plus4); // 模块例化 pc u_pc( .clk(clk), .rst_n(rst_n), .next_pc(pc_next), .pc_out(pc_curr) ); inst_mem u_imem( .addr(pc_curr), .inst(inst) ); regfile u_regfile( .clk(clk), .reg_write(reg_write), .rs1(inst[19:15]), .rs2(inst[24:20]), .rd(inst[11:7]), .wdata(reg_wdata), .rdata1(reg_rdata1), .rdata2(reg_rdata2) ); control u_control( .opcode(inst[6:0]), .funct3(inst[14:12]), .funct7(inst[31:25]), .reg_write(reg_write), .alu_src(alu_src), .mem_read(mem_read), .mem_write(mem_write), .mem_to_reg(mem_to_reg), .branch(branch), .alu_op(alu_op), .jal_ctrl(jal_ctrl), .jalr_ctrl(jalr_ctrl) ); alu_ctrl u_alu_ctrl( .alu_op(alu_op), .funct3(inst[14:12]), .funct7(inst[31:25]), .alu_ctrl(alu_ctrl) ); alu u_alu( .a(reg_rdata1), .b(alu_src ? imm : reg_rdata2), .alu_ctrl(alu_ctrl), .result(alu_result), .zero(alu_zero) ); data_mem u_dmem( .clk(clk), .addr(alu_result), .wdata(reg_rdata2), .we(mem_write ? 4b1111 : 4b0000), .re(mem_read), .rdata(mem_rdata) ); assign reg_wdata mem_to_reg ? mem_rdata : alu_result; // 调试信号输出 assign pc_debug pc_curr; assign inst_debug inst; assign alu_result_debug alu_result; endmodule顶层模块里能看到一个有趣的设计PC更新逻辑里的跳转优先级我在实际设计时不使用jal和jalr指令专注于加减法和存储访问指令的验证所以jal_ctrl和jalr_ctrl可以直接做简化——当branch_sel有效时跳转否则顺序执行。你如果要把分支和跳转都做全需要仔细理解每个跳转指令的优先级关系特别是jal和jalr如何与branch共存。4.4 控制模块和ALU控制模块的详细实现控制模块是整个CPU里最典型的“查表逻辑”module control( input [6:0] opcode, input [2:0] funct3, input [6:0] funct7, output reg reg_write, output reg alu_src, output reg mem_read, output reg mem_write, output reg mem_to_reg, output reg branch, output reg jal_ctrl, output reg jalr_ctrl, output reg [1:0] alu_op ); always (*) begin // 默认值所有控制信号置0 {reg_write, alu_src, mem_read, mem_write, mem_to_reg, branch, jal_ctrl, jalr_ctrl} 0; alu_op 2b00; case (opcode) 7b0110011: begin // R型 reg_write 1; alu_op 2b10; end 7b0010011: begin // I型算术 reg_write 1; alu_src 1; alu_op 2b11; end 7b0000011: begin // 加载指令 lb/lw reg_write 1; alu_src 1; mem_read 1; mem_to_reg 1; alu_op 2b00; // 地址计算用加法 end 7b0100011: begin // 存储指令 sb/sw alu_src 1; mem_write 1; alu_op 2b00; end 7b1100011: begin // 分支指令 branch 1; alu_op 2b01; end 7b1101111: begin // jal reg_write 1; jal_ctrl 1; end 7b1100111: begin // jalr reg_write 1; jalr_ctrl 1; alu_op 2b00; alu_src 1; end default: begin reg_write 0; end endcase end endmodule控制信号里没有pc_write、if_id_write这些流水线才有的信号因为单周期里每个周期所有模块都在工作不存在“停顿”的概念。这是单周期最大的好处控制逻辑简单直接非常适合学习理解每条指令的控制需求。5. 仿真与上板调试我自己踩过的最深的几个坑写完代码不等于做完项目。从“代码写完”到“FPGA开发板上程序跑起来”中间隔着仿真、综合、实现、下载几个大关卡。每一步都可能翻车。这节我专门分享一下最常见的几个问题以及我自己怎么排查的。5.1 仿真阶段testbench别只写happy path写testbench时很多人喜欢只测加法指令看到x1寄存器等于预期值就觉得万事大吉。这种测试方式会漏掉大量边界情况。我在仿真阶段做了一个小工具把RISC-V指令集的每一条指令都写进测试程序然后用汇编器生成机器码再用$display打印每条指令执行完毕后的寄存器值和GCC编译的RISC-V模拟器输出做对比。如果你像我一样用Vivado的仿真器注意它的$display输出在Tcl控制台里默认可能会被淹没在大量信号名打印里。建议写一个测试任务只在每个关键指令执行完成后打印一行结果。下面是一段我自己用的testbench核心片段initial begin rst_n 0; #100; rst_n 1; #500; // 检查x1是否等于0x12345678 if (cpu_top.regs[1] 32h12345678) $display(Test ADD: PASS); else $display(Test ADD: FAIL, x10x%08x, cpu_top.regs[1]); end这里直接引用了顶层模块内部的寄存器堆信号。如果数据通路是加密的IP核或者层级很深这种跨层引用的方式会变得很脆弱但在自研CPU里这是最高效的调试手段没有任何封装阻隔。5.2 仿真通过但上板失败大概率是复位和时钟问题仿真一切正常波形也完美下载到FPGA板上却一团糟这是最常见的情况。我遇到过的问题有三类第一类是复位问题。仿真里直接#100; rst_n0;然后拉高但真实板子上的复位按键是机械按键按下和释放的过程有抖动。如果复位信号在时钟上升沿附近跳变会导致寄存器进入亚稳态。这个问题最直接的解决方法是加一个简单的RC滤波或使用Xilinx的复位IP核我一般会写一个简单的按键消抖模块把机械抖动滤掉后再给CPU复位信号。第二类是时钟问题。开发板上的100MHz时钟经过PLL分频后如果引脚约束没写对CPU可能完全跑不起来。建议先用Xilinx的Clocking Wizard IP核对板载时钟分频并把锁定信号locked作为复位释放条件。这样时钟稳定之前系统不会启动效果非常稳。第三类是调试观测问题。CPU运行太快寄存器值变化根本来不及用示波器观察也难以判断对错。我的做法是拉出几个调试信号管脚比如把PC的低几位、ALU结果的低8位接到LED上一步步检查运行状态。assign led[7:0] alu_result_debug[7:0];5.3 常见问题速查代码报错、仿真异常和逻辑Bug的排查思路问题现象可能原因排查方法仿真波形正确但上板无输出复位键抖动检查复位信号和时钟上升沿的相对关系加按键消抖模块分支跳转永远不执行立即数扩展错误B型指令立即数是字节偏移需要左移1位比较两个寄存器是否相等要用减法加载指令读到固定值0寄存器堆x0被写检查底层寄存器堆是否强制assign了regs[0]0检查写使能信号编码ALU的SLT结果总为0有符号比较写成了无符号检查$signed是否在整个表达式中生效必要时用wire声明signed wire [31:0] a_s; assign a_s a;综合报错“signal not constant”循环变量在generate块中被误当常数使用检查是否不小心在for循环里使用了非局部参数变量时序不收敛频率上不去ALU与分支判断关键路径过长降低时钟频率或者把分支计算挪到PC4之后做流水线寄存器但那样就要考虑冒险了上板后LED乱跳和设计不符合没有做异步复位同步释放处理所有使用rst_n的always块统一用同一个复位信号推荐加两级寄存器做复位同步有个细节很值得聊一下仿真通过但上板失败的问题很多都出在复位和时钟域上而不是逻辑错误。真实世界里所有信号都有延迟时钟沿到来时数据不一定满足建立时间和保持时间的要求。仿真器默认没有把这些问题暴露出来所以必须以“仿真只是辅助、时序才是关键”的心态做上板调试。6. 项目扩展从单周期CPU到真正能跑程序的处理器单周期CPU跑通之后你手里已经有一套完整的RISC-V最小系统。接下来怎么进阶方向有好几个但我的经验是别急着一步跨到超标量或乱序执行先按下面的顺序来。6.1 加一个串口把指令从电脑发到FPGA第一件值得做的事情是加一个UART串口模块。RISC-V的程序现在还是靠testbench里的$readmemh硬编码进去的加上UART之后就可以通过电脑端软件把程序二进制文件发送到FPGA的存储器里然后再启动CPU执行。这才有“下载程序”的感觉。UART模块本身不是什么难点网上有现成的Verilog实现重点是你要理解它的波特率怎么算。以115200波特率为例如果系统时钟是100MHz那么每发送一位需要等待100000000/115200约868个时钟周期代码里用一个计数器每计数868次翻转一次输出就行。串口调试是我认为性价比最高的扩展因为以后你调试任何FPGA工程都用得上。6.2 把程序从汇编提升到GCC用C语言写你的CPU程序RV32I的GCC工具链现在很好获取Linux下装一个riscv64-unknown-elf-gcc就行。写一个C文件然后编译出机器码再用工具转成hex文件加载进CPU存储器这一整套流程我已经跑通了。这里最大的坑是编译器生成的标准启动代码crt0会用到很多伪指令和系统调用如果你的CPU不支持程序会立刻跑飞。我的解决办法是不要用默认的链接脚本而是自己写一个极简的启动文件只做三件事——设置栈指针、把bss段清零、跳转到main函数。链接脚本指定代码段起始地址为0x00000000数据段放在0x00001000以后。这个极简环境搭建完以后你就能用C语言写测试程序了比手写汇编舒服太多。6.3 往流水线方向走复盘单周期设计的好处做完单周期CPU再回头学流水线会容易很多。关键的理解体现在当你看到五级流水线的冒险处理时能迅速定位到是“取指还是译码阶段出问题”。在单周期里所有指令都假设在一个时钟周期执行完毕但流水线需要把一条指令拆成多个时钟周期执行这里的核心冲突点你都提前经历过比如ALU的结果写回寄存器的时序、分支跳转的地址计算时刻、访存和写回的优先级。等到你做流水线的时候会发现这些概念在新架构下的解决办法反而是“更容易”的——因为你已经不需要在组合逻辑里同时完成所有事而是交给流水线寄存器去分割任务。如果你要做一部能在真实设备上跑起来的完整处理器下一步一定是流水线。但放心有了单周期的数据通路和控制信号设计基础你在流水线里只需关心一个问题两个相邻指令之间对同一寄存器或者存储单元访问时如何避免冲突——这是完全另外一码事了。7. 实操中的个人体会做这个项目的过程中我踩过的最大的坑同时也是最值得分享的经验我觉得是“上层视角”和“底层执行”之间必须反复横跳。写Verilog的时候一定要想着RISC-V指令集规范里的每条指令在硬件上是怎么流动的看波形图的时候要能反过来定位是哪条指令、哪个模块产生了问题。这种双向联动的能力不亲手做完一个CPU是练不出来的。第二个体会是工具链的熟练程度决定了项目推进的效率。Vivado的编译速度不算快如果把所有时间都放在调编译错误上很容易消耗掉耐心。我的习惯是尽量在仿真层面解决问题等仿真覆盖全面了再上板上板后就只用观察灯、数码管这类直观反馈来验证。能用Tcl脚本做的操作尽量写成脚本不要每次都鼠标点来点去。第三个体会是关于代码风格的。CPU设计里到处都是并行信号如果没有一套清晰的命名规范代码量过300行就会开始混乱。我自己的习惯所有模块命名用下划线分隔小写字母所有内部信号用源_信号名_目的的方式所有顶层端口加注释说明位宽和方向。这套规范在复杂项目里能救命的尤其当你写完了CPU准备做流水线、加入中断控制器和总线接口之后。最后再说一个小技巧如果你有条件把数据通路图真的画成一幅大挂在座位前面或者打印出来贴在桌上。写代码的时候扫一眼图纸比翻几页文档效率高得多。数据通路图是整个项目的灵魂它不只是一张工程图更是你对CPU理解程度的可视化体现。