首页 / 资讯中心 / 文章详情

FPGA实战:从零搭建RISC-V单周期CPU完整指南

FPGA实战:从零搭建RISC-V单周期CPU完整指南 ★ FEATURED ARTICLE
做CPU这件事听起来像是要发顶会论文才能碰的东西但实际上用一块几百块的Xilinx FPGA开发板配合一段能跑通的Verilog把RISC-V单周期CPU从零搭出来完全可行。这篇文章就是一次完整的过程记录从选板、装Vivado开始到指令集裁剪、数据通路设计再到逐行写代码、仿真、上板验证每个环节都会讲清楚“为什么这么做”和“这个坑在哪”。文章适合这么几类人刚学完Verilog语法但不知道能做什么的初学者正在学计算机组成原理、想亲手验证课本概念的学生想从MCU/软件转硬件、需要一个完整项目的从业者。你可以把它当成一份可照抄的作业也可以当成一本缩小版的CPU设计手册。文末会给出完整的Verilog代码和测试程序照着抄就能跑。1. 先把工具链和板子选对后面才能少踩坑1.1 板子选型Artix-7是最稳的起点我在做这个项目前陆陆续续摸过几块开发板最后固定在Xilinx Artix-7系列上。选择理由其实很简单RISC-V单周期CPU的资源需求非常低逻辑单元几百个LUT就够跑I/O资源也不用多Artix-7这种中低端FPGA属于“杀鸡用牛刀但刚刚好”的配置。跟更高端的Kintex/Virtex相比Artix-7的Vivado WebPACK版本免费支持不用去搞破解或申请特殊License跟更老的Spartan-6相比它的开发环境是Vivado调试手段比ISE时代先进太多。目前市面上常见的Artix-7板子主要是Digilent的Basys 3和Nexys A7或者其他国产核心板。Basys 3用的是XC7A35TNexys A7用的是XC7A100T价格差了一倍左右。做单周期CPU的话Basys 3完全够了没必要多花钱上100T。我手头正好有一块Basys 3下面所有实验都是在这块板上跑的。开发板FPGA芯片LUT资源板载时钟适合场景Basys 3XC7A35T20800100MHzCPU教学、入门实验、小规模逻辑设计Nexys A7XC7A100T63400100MHz需要较多片上资源或做SOPC扩展国产Artix-7核心板各种型号视型号而定50MHz/100MHz需要自己搭底板或做产品原型如果你手里只有一块老旧的Spartan-6板子也不是不能用但需要装ISE 14.7那个环境的仿真和Debug工具确实老态尽显了而且新电脑上ISIM仿真器和USB Cable驱动经常出问题。我的建议是如果条件允许尽量用Vivado支持的Artix-7起步。1.2 Vivado的安装和Cable驱动能劝退一半人很多人在安装Vivado时卡死过原因往往不是网速而是安装选项理解不到位。Vivado现在是ML版在官网注册后可以免费下载Standard版。安装时建议勾选“Vivado HL WebPACK”即可不需要装Vitis和DocNav那两个组件体积大而且当前用不上。完整安装大概要占50GB磁盘空间装到机械硬盘会非常痛苦能放SSD就放SSD。装完之后另一个高频坑是下载器驱动。Windows系统里插入Xilinx Platform Cable USB时经常提示“无法加载设备驱动”这是因为Vivado的Cable驱动不是随软件自动装的。解决办法是安装Vivado时勾选“Install Cable Drivers”如果已经装完软件可以到安装目录下找Vivado/版本号/data/xicom/cable_drivers/nt64里的install_drivers.exe右键管理员运行一次。解决掉这个驱动问题后面Flow才能正常跑。2. 做CPU之前需要给RISC-V指令集做减法2.1 RV32I里到底选哪几条指令RISC-V的RV32I基础指令集有40多条指令。单周期CPU教学项目完全不需要全部实现选一个“最小闭环”就够了能够完成取指、译码、执行、访存、写回全流程同时覆盖R型、I型、S型、SB型、UJ型这几种主要编码格式。我最终选定的指令是这几条R型add、sub、and、or、sltI型ALUaddiI型访存lwS型swSB型beqUJ型jal这9条指令覆盖了数据通路的全部关键环节R型用于寄存器间运算addi用于立即数运算和加载常量lw/sw打通内存通路beq实现条件分支jal实现无条件跳转。用这套指令可以编写一个能够验证CPU功能的测试程序在后续仿真章节会看到。为什么不加更复杂的指令比如lui、auipc、jalr原因很简单每加一条指令控制单元就要多加一个状态分支数据通路可能还要加MUX或改控制信号。第一次做CPU时先把基础架构跑通最重要后面的指令可以像填空一样逐步加。我在实际扩展中发现先学会把控制单元的case写规矩后面加指令就是几行代码的事。2.2 指令编码格式理解它才能顺手写译码器RISC-V指令都是32位定长不同格式的字段位置大致兼容。我当时死记硬背的方式是opcode、rd、funct3、rs1、rs2这几个字段的位置基本固定不同格式之间的差异主要在立即数排列上。格式31-2524-2019-1514-1211-76-0R型funct7rs2rs1funct3rdopcodeI型imm[11:0]rs1funct3rdopcodeS型imm[11:5]rs2rs1funct3imm[4:0]opcodeSB型imm[12] imm[10:5]rs2rs1funct3imm[4:1] imm[11]opcodeUJ型imm[20] imm[10:1] imm[11] imm[19:12]rdopcode这个表非常重要。为什么S型立即数不是简单的高12位和低12位分开因为RISC-V设计时尽量让rs2、rs1字段在各类指令中位置固定译码器可以把公共命令统一走一条线代价就是立即数排列需要额外拼装。一个特别容易被坑的点SB型和UJ型的立即数最低位在编码里已经隐含为0因为RISC-V的指令地址要求2字节对齐实际是4字节对齐所以不需要编码bit 0。如果写立即数扩展时不补这个0分支目标就会算成“半条指令”的位置跳转永远错位。我在第一次做的时候就是这里出了错波形上一看PC跑到了奇数地址才反应过来。2.3 立即数扩展为什么符号扩展这么重要立即数扩展模块做的事很简单把不同指令格式中散落的立即数字段“挖出来”统一扩展成32位有符号数。但“符号扩展”这三个字里藏着CPU设计最基础的命题数据的意义是由指令语义决定的而不是由bit串本身决定的。拿addi举例I型立即数imm[11:0]表示一个有符号数如果最高位是1比如0xFFF就是-1必须扩展成32hFFFFFFFF如果是0x001就是1扩展成32h00000001。如果只是无脑补0那么在计算addi x1, x0, -1时得到的结果会变成4095而不是-1整个程序就崩了。SB型分支立即数也是一样beq跳转是往前还是往后取决于imm[12]这个符号位扩展错了CPU可能在仿真里看起来“每一步都在执行”但所有分支行为都不对。所以写imm_ext模块时我的建议是先对着2.2的表格把每个字段拼好再统一用拼接符{{20{inst[31]}}, ...}做符号扩展不要手写什么三目运算渐进扩展那样容易错。3. 单周期数据通路一条指令的一生3.1 从取指到写回一条指令在CPU里经历了什么单周期CPU的核心思想是一条指令的所有操作在一个时钟周期内完成。前面已经想清楚了”再到寄存器堆写入新值。这些操作全部要在同一个时钟周期内沿着组合逻辑链路做完最终在下一个时钟上升沿把结果打进去。所以单周期CPU的主频不会很高但结构非常清晰。我把数据通路拆成几个环节PC先输出当前指令地址指令存储器根据地址取出一条32位指令。译码部分把指令拆成各字段同时寄存器堆根据rs1、rs2读出两个操作数。控制单元根据opcode、funct3、funct7确定这条指令要做什么输出一系列控制信号。立即数扩展模块拼出32位立即数MUX选择第二个操作数是寄存器值还是立即数。ALU根据控制信号完成加减、逻辑运算、比较等操作。如果是lw/swdata_mem做读或写如果是beq/jalPC计算跳转目标。写回MUX选择ALU结果、内存读数据或PC4送往寄存器堆的写端口。用一句话概括所有指令共用同一条物理通路靠控制单元产生的不同信号来“变道”。3.2 控制信号一览表这是我最希望当时有人能提前给我的一份表。控制单元本质上是“指令到信号的查表器”。我在设计时把控制信号定义为以下这些信号名作用R型addilwswbeqjalreg_write_en寄存器写使能111001mem_write_en内存写使能000100mem_to_reg[1:0]写回来源0ALU1内存2PC4001xx2alu_src_b第二个操作数来源0寄存器1立即数011100branch是不是分支指令000010jump是不是跳转指令000001alu_ctrl[3:0]ALU功能选择按指令addaddaddsubadd看到没有sw虽然不需要写寄存器但它的ALU仍然要工作——它要用加法计算“基址偏移量”得到内存地址。beq的ALU则要做减法来判断两个操作数是否相等。这就是单周期设计的特点每个部件的硬件都在指令只是决定哪些硬件的结果被使用。3.3 为什么写回阶段要加一个PC4的通道一开始我没打算实现jal的返回地址写回因为觉得跳转就行了。但后来在写测试程序时发现如果不能把返回地址保存下来程序就没法实现函数调用。虽然本项目的测试程序只是死循环不需要函数返回但从CPU完整性的角度来说jal把PC4写入rd是RISC-V调用约定的基础。所以我在控制信号里增加了mem_to_reg2的情况让寄存器写回数据MUX多一个来自PC4的输入。加这一路多花了一个时钟周期设计时间换来的是后续扩展函数调用能力时不需要改数据通路。4. Verilog核心模块每行代码都能对上数据通路4.1 PC模块和指令存储器先从时间起点开始PC是整个CPU的“心跳起点”。它保存当前指令地址每个时钟上升沿更新为下一条指令的地址。复位时清零。module pc( input wire clk, input wire rst_n, input wire [31:0] next_pc, output reg [31:0] pc ); always (posedge clk or negedge rst_n) begin if (!rst_n) pc 32h0; else pc next_pc; end endmodule这里我故意用了异步复位rst_n为低时马上清零不等待时钟沿。对于按键复位这种场景比较直观按下按键CPU立即回到0地址。实际工程里异步复位和同步复位各有适用场景关键是整个设计要统一不要在顶层一部分模块用异步复位、另一部分用同步复位。指令存储器在仿真阶段可以用一个简单的reg数组$readmemh实现。$readmemh的作用是把十六进制文件里的机器码按地址加载到数组中非常方便。module inst_mem #( parameter DEPTH 256 )( input wire [31:0] addr, output wire [31:0] inst ); reg [31:0] mem [0:DEPTH-1]; initial begin $readmemh(program.hex, mem); end assign inst mem[addr[9:2]]; endmodule注意这里使用addr[9:2]因为PC是字节地址而mem数组是按字组织的。4字节一条指令地址要右移两位。如果直接用addr[31:0]做索引仿真时很容易越界返回X态这种X态会让整个波形看起来像乱码。DEPTH取256意味着最大支持1KB程序本次测试程序很小绰绰有余。顺便要提一句initial$readmemh只在仿真里有效不能综合。上板前我会把指令存储器改成case语句形式的LUT ROM这个在6.2节再展开。4.2 寄存器堆、ALU和立即数扩展寄存器堆是CPU里读写最频繁的部件。RISC-V定义了32个通用寄存器其中x0恒为0。寄存器堆的读口是组合逻辑写口是时钟上升沿写入这是单周期CPU的典型做法。module regfile( input wire clk, input wire reg_write_en, input wire [4:0] rs1, input wire [4:0] rs2, input wire [4:0] rd, input wire [31:0] write_data, output wire [31:0] read_data1, output wire [31:0] read_data2 ); reg [31:0] regs [0:31]; integer i; initial begin for (i 0; i 32; i i 1) regs[i] 32h0; end assign read_data1 regs[rs1]; assign read_data2 regs[rs2]; always (posedge clk) begin if (reg_write_en (rd ! 5h0)) regs[rd] write_data; end endmodule写端口这里有一个关键判断rd ! 5h0。x0必须恒为0如果允许写入x0将来某些指令把垃圾值写进去所有读取x0的地方都会出问题。很多初学者写寄存器堆时忘了这个判断结果程序里的addi x0, x0, 0把x0搞成了非零值。ALU模块相对简单module alu( input wire signed [31:0] src_a, input wire signed [31:0] src_b, input wire [3:0] alu_ctrl, output reg signed [31:0] alu_result, output wire zero ); always (*) begin case (alu_ctrl) 4b0000: alu_result src_a src_b; 4b0001: alu_result src_a - src_b; 4b0010: alu_result src_a src_b; 4b0011: alu_result src_a | src_b; 4b0100: alu_result (src_a src_b) ? 32h1 : 32h0; default: alu_result 32h0; endcase end assign zero (alu_result 32h0); endmodulezero信号是给beq用的。beq做减法如果结果为0说明两个输入相等zero拉高控制单元据此决定是否跳转。注意我用了wire signed的声明这是为了slt能按有符号数比较否则两个寄存器里的负数会被当成很大的无符号数比较结果完全颠倒。立即数扩展模块是拼装指令格式的核心module imm_ext( input wire [31:0] inst, output reg [31:0] imm_ext_out ); wire [6:0] opcode inst[6:0]; always (*) begin case (opcode) 7b0000011: imm_ext_out {{20{inst[31]}}, inst[31:20]}; // lw 7b0010011: imm_ext_out {{20{inst[31]}}, inst[31:20]}; // addi 7b0100011: imm_ext_out {{20{inst[31]}}, inst[31:25], inst[11:7]}; // sw 7b1100011: imm_ext_out {{20{inst[31]}}, inst[7], inst[30:25], inst[11:8], 1b0}; // beq 7b1101111: imm_ext_out {{12{inst[31]}}, inst[19:12], inst[20], inst[30:21], 1b0}; // jal default: imm_ext_out 32h0; endcase end endmodule这里要特别盯着SB型和UJ型的拼接顺序看跟我2.2节的表格逐位对照。beq的立即数最终要拼成imm[12:0]其中imm[0]永远为0所以拼接末尾直接补1b0jal的立即数有21位同样低位补0。4.3 控制单元整个CPU的“指挥中心”控制单元是我觉得最值得花时间写的模块。它不做什么复杂的计算但每条指令的“性格”都体现在这里。实现方式选择用case(opcode)做第一级分支再用funct3和funct7做第二级区分。module control_unit( input wire [31:0] inst, output reg reg_write_en, output reg mem_write_en, output reg [1:0] mem_to_reg, output reg alu_src_b, output reg branch, output reg jump, output reg [3:0] alu_ctrl ); wire [6:0] opcode inst[6:0]; wire [2:0] funct3 inst[14:12]; wire [6:0] funct7 inst[31:25]; always (*) begin // 默认值所有控制信号归零避免锁存器 reg_write_en 1b0; mem_write_en 1b0; mem_to_reg 2b00; alu_src_b 1b0; branch 1b0; jump 1b0; alu_ctrl 4b0000; case (opcode) 7b0110011: begin // R型指令 reg_write_en 1b1; case ({funct7, funct3}) 10b0000000_000: alu_ctrl 4b0000; // add 10b0100000_000: alu_ctrl 4b0001; // sub 10b0000000_111: alu_ctrl 4b0010; // and 10b0000000_110: alu_ctrl 4b0011; // or 10b0000000_010: alu_ctrl 4b0100; // slt default: alu_ctrl 4b0000; endcase end 7b0010011: begin // addi reg_write_en 1b1; alu_src_b 1b1; alu_ctrl 4b0000; end 7b0000011: begin // lw reg_write_en 1b1; alu_src_b 1b1; mem_to_reg 2b01; alu_ctrl 4b0000; end 7b0100011: begin // sw mem_write_en 1b1; alu_src_b 1b1; alu_ctrl 4b0000; end 7b1100011: begin // beq branch 1b1; alu_ctrl 4b0001; end 7b1101111: begin // jal jump 1b1; reg_write_en 1b1; mem_to_reg 2b10; alu_ctrl 4b0000; end default: begin // 未定义指令保持默认信号 end endcase end endmodule每个case分支开头先归零所有信号这个习惯非常重要。如果漏掉默认赋值组合逻辑在某一输入组合下可能产生锁存器Latch综合时会报警告而且仿真行为可能跟预期不一致。每次新增指令时只要修改这个模块其他模块基本不动。4.4 数据存储器和顶层连线数据存储器跟指令存储器类似区别在于它支持写操作。写入用时钟上升沿触发读取用组合逻辑完成。module data_mem #( parameter DEPTH 256 )( input wire clk, input wire [31:0] addr, input wire [31:0] write_data, input wire mem_write_en, output wire [31:0] read_data ); reg [31:0] mem [0:DEPTH-1]; always (posedge clk) begin if (mem_write_en) mem[addr[9:2]] write_data; end assign read_data mem[addr[9:2]]; endmodule顶层模块把各部分连起来。这段代码是整个项目的“装配图”module riscv_single_cycle_top( input wire clk, input wire rst_n, output wire [31:0] debug_pc, output wire [31:0] debug_inst, output wire [31:0] debug_alu_result, output wire [31:0] debug_rs2_data ); wire [31:0] pc, next_pc, inst, imm_ext_out; wire reg_write_en, mem_write_en, branch, jump; wire [1:0] mem_to_reg; wire alu_src_b; wire [3:0] alu_ctrl; wire [31:0] rs1_data, rs2_data, alu_result, mem_read_data; wire [31:0] src_b_mux, write_back_data; wire [31:0] pc_plus_4 pc 32h4; wire [31:0] branch_target pc imm_ext_out; wire branch_taken branch (alu_result 32h0); assign next_pc jump ? branch_target : (branch_taken ? branch_target : pc_plus_4); assign src_b_mux alu_src_b ? imm_ext_out : rs2_data; assign write_back_data (mem_to_reg 2b01) ? mem_read_data : (mem_to_reg 2b10) ? pc_plus_4 : alu_result; assign debug_pc pc; assign debug_inst inst; assign debug_alu_result alu_result; assign debug_rs2_data rs2_data; pc u_pc ( .clk (clk), .rst_n (rst_n), .next_pc (next_pc), .pc (pc) ); inst_mem u_inst_mem ( .addr (pc), .inst (inst) ); control_unit u_control_unit ( .inst (inst), .reg_write_en (reg_write_en), .mem_write_en (mem_write_en), .mem_to_reg (mem_to_reg), .alu_src_b (alu_src_b), .branch (branch), .jump (jump), .alu_ctrl (alu_ctrl) ); regfile u_regfile ( .clk (clk), .reg_write_en (reg_write_en), .rs1 (inst[19:15]), .rs2 (inst[24:20]), .rd (inst[11:7]), .write_data (write_back_data), .read_data1 (rs1_data), .read_data2 (rs2_data) ); imm_ext u_imm_ext ( .inst (inst), .imm_ext_out (imm_ext_out) ); alu u_alu ( .src_a (rs1_data), .src_b (src_b_mux), .alu_ctrl (alu_ctrl), .alu_result(alu_result), .zero () ); data_mem u_data_mem ( .clk (clk), .addr (alu_result), .write_data (rs2_data), .mem_write_en (mem_write_en), .read_data (mem_read_data) ); endmodule写顶层的时候有个小坑beq跳转里我用alu_result 32h0直接判断相等而没有把ALU的zero端口连出来。这两种写法等价但既然ALU模块里已经算了zero最好还是把zero端口连出来让逻辑更清晰。代码块里我故意留空了zero如果照抄记得改成.zero(zero)并声明wire zero。这种细节正是综合前容易漏掉的地方。5. 仿真验证用一个能跑完所有指令的测试程序5.1 设计一个有“自检”意义的测试程序仿真不是随便跑两条指令看看就行。我给自己的要求是写一个程序把前面选的9条指令全部执行一遍并且每一步的结果都能人工核对。下面是我用的汇编程序addi x1, x0, 5 # x1 5 addi x2, x0, 3 # x2 3 add x3, x1, x2 # x3 8 sub x4, x1, x2 # x4 2 and x5, x1, x2 # x5 1 or x6, x1, x2 # x6 7 slt x7, x1, x2 # x7 0因为53为假 sw x3, 0(x0) # 把x3的值写入内存地址0 lw x8, 0(x0) # 从内存地址0读回x8 8 beq x1, x1, 8 # 相等跳转到0x2C addi x0, x0, 0 # NOP不该被执行到 jal x0, 0 # 死循环程序结束每一条指令的结果都是可预期的。如果仿真波形和这张表不一致说明CPU某个环节有bug。检查顺序可以这样来先看x3是不是8如果不对问题可能在ALU或R型译码再看x8是不是8如果不对问题可能在lw或数据存储器最后看跳转是否落地到0x2C如果不对问题在beq的立即数扩展或PC MUX。对应的program.hex文件内容如下每行对应一条32位指令地址从0开始递增00500093 00300113 002081B3 40208233 0020F2B3 0020E333 0020A3B3 00302023 00002403 00108463 00000013 0000006F如果你想自己验证这些机器码可以用RARS或在线RISC-V模拟器把汇编编译后导出推荐手工算一遍编码再对照模拟器结果这种感觉很直观。5.2 Testbench与波形分析Testbench本身不复杂核心是提供一个时钟和一个复位信号然后把顶层模块实例化仿真时加载hex文件。timescale 1ns / 1ps module tb_riscv; reg clk; reg rst_n; wire [31:0] debug_pc; wire [31:0] debug_inst; wire [31:0] debug_alu_result; wire [31:0] debug_rs2_data; riscv_single_cycle_top dut( .clk(clk), .rst_n(rst_n), .debug_pc(debug_pc), .debug_inst(debug_inst), .debug_alu_result(debug_alu_result), .debug_rs2_data(debug_rs2_data) ); initial begin clk 0; rst_n 0; #100; rst_n 1; #5000; $finish; end always #10 clk ~clk; endmodule这里有一个关键设计复位信号持续100ns后才拉高。为什么因为寄存器堆的initial块是在时间0执行的但PC、寄存器堆需要先保持复位态一段时间确保所有状态稳定。如果在仿真一开始就拉高复位某些模型可能还在X态后面所有信号都会带着X传播。跑完仿真后建议重点观察三个对象PC是否依次变成0x00、0x04、0x08……0x24然后在beq之后跳到0x2C。寄存器堆内部各寄存器的变化时序写信号有效后寄存器的值是否在下一个时钟沿更新。内存地址0处的数据变化当sw执行后mem[0]是不是变为8。如果发现PC在0x00-0x28之间正常但beq没有跳转而是继续执行NOP到0x28再落到0x2C说明beq的branch_taken没有拉高。最常见的两个原因是控制单元的branch信号没置1或者ALU没有做减法导致zero不对。周期序号PC指令预期变化10x00addi x1, x0, 5x1520x04addi x2, x0, 3x2330x08add x3, x1, x2x3840x0Csub x4, x1, x2x4250x10and x5, x1, x2x5160x14or x6, x1, x2x6770x18slt x7, x1, x2x7080x1Csw x3, 0(x0)mem[0]890x20lw x8, 0(x0)x88100x24beq x1, x1, 8PC0x2C110x2Cjal x0, 0PC0x2C5.3 仿真中X态的常见来源我在调试过程中遇到最多的仿真问题就是X态波形上全是红色的x。总结下来就三类第一类是初始化没做。寄存器堆、存储器的initial块没写或者$readmemh的文件路径不对。文件路径要用相对Vivado仿真目录的路径但最稳妥的做法是直接用绝对路径或者把hex文件放在工程根目录下再调整路径。第二类是索引越界。比如指令存储器深度256但PC跳到了超过1KB的地址addr[9:2]索引溢出取出的指令就是X控制单元接收到X后输出X整条流水就崩了。这类问题可以通过约束程序短小来规避也可以用仿真器查看PC跑飞的原因。第三类是case没有default。控制单元的case如果没写default遇到未定义opcode时会保持之前的信号值在组合逻辑里表现为锁存器行为仿真时容易出现X。所以控制单元里我坚持先归零再赋值这是一个非常好的习惯。6. 烧进FPGA约束文件、在线调试与板上注意事项6.1 从仿真到上板指令存储器要做一次“换芯”仿真用的$readmemh不能综合。上板前需要把指令存储器改成可综合的LUT ROM形式。我的做法是保留一个参数化的case语句版本module inst_mem ( input wire [31:0] addr, output reg [31:0] inst ); always (*) begin case (addr[9:2]) 8h00: inst 32h00500093; 8h01: inst 32h00300113; 8h02: inst 32h002081B3; 8h03: inst 32h40208233; 8h04: inst 32h0020F2B3; 8h05: inst 32h0020E333; 8h06: inst 32h0020A3B3; 8h07: inst 32h00302023; 8h08: inst 32h00002403; 8h09: inst 32h00108463; 8h0A: inst 32h00000013; 8h0B: inst 32h0000006F; default: inst 32h00000013; // NOP endcase end endmodule这种写法的缺点是程序固化在逻辑里想换程序要重新综合但好处是省去了Block Memory Generator IP核的配置初学阶段不会被IP核的接口时序绕晕。等以后想做更完整的SoC再换成BRAM加载coe文件也不迟。6.2 引脚约束和上板验证Basys 3板载时钟是100MHz复位按钮我用了中间的那个BTNC。下面是一个最小约束文件示例如果你手头是其他板子需要按原理图修改引脚编号。set_property PACKAGE_PIN W5 [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk] set_property PACKAGE_PIN V17 [get_ports rst_n] set_property IOSTANDARD LVCMOS33 [get_ports rst_n]综合实现后生成bitstream通过Hardware Manager下载到FPGA。按下复位程序从0地址开始执行。想看到结果最直接的办法是把debug_alu_result或debug_rs2_data的低几位接到LED上。因为测试程序的最后x38等程序跑完死循环后LED上应该常亮一个“1000”这样的二进制模式。6.3 用ILA抓内部信号比猜波形痛快多了如果只想在仿真里看波形那ILA不是必须的。但项目上板后很多问题只有在真实时钟下才会暴露。Vivado的ILA集成逻辑分析仪可以让你在板子上实时抓取PC、指令、ALU结果这些内部信号不用把引脚一个个引出来连示波器。在Vivado里用ILA的简单路径是综合后打开Synthesized Design右键需要观察的debug信号选择“Mark Debug”然后重新综合实现生成bitstream。下载后打开Hardware Manager添加ILA探针就能看到信号波形。我上板调试时还真抓到一个仿真里不会出现的问题开发板上的按键复位存在机械抖动按下释放的瞬间rst_n会跳变几次导致PC被多次清零。仿真里不会有这种物理现象。解决方法很粗暴在顶层加一个简单的按键消抖模块或者把复位当成异步复位用抖动造成的偶然复位在演示程序里影响不大但如果你要做更复杂的系统消抖是绕不开的。6.4 综合实现阶段容易忽略的几个警告第一次综合时我看到了很多“信号未使用”的警告比如zero端口没有被连接、某些控制信号不存在于特定指令。这些警告大多数无害但有一条值得认真看Latch inferred。如果综合报告里出现Latch相关警告几乎可以肯定是控制单元或某个组合逻辑模块的case没有把所有分支覆盖全或者没有default默认值需要回头检查代码。还有些时候综合报告里的最高频率显示只有几十MHz不用慌。单周期CPU因为所有事情都在一个周期内完成关键路径是从PC到指令存储器到寄存器堆到ALU再到写回链路过长主频上不去是正常的。我这套设计在Basys 3上跑50MHz都略显紧张但教学演示完全够用。真正追求高性能就得走流水线了那是下一个项目的事。我还试过在综合时把复位极性设错结果板子上所有寄存器都处于复位状态LED全不亮。检查方法很简单看约束里rst_n的电平标准和开发板原理图是不是一致。Basys 3的按钮默认是高电平按下为低所以我的rst_n是低有效。如果你用的是高有效复位需要在顶层或者约束里取反。另外一个实操细节程序跑到死循环后CPU仍然每周期取指令、译码、执行但寄存器堆和内存都不再变化功耗会持续存在。对于Basys 3这种板卡不是问题但如果是电池供电的嵌入式场景就需要考虑时钟门控。这个可以先记着等以后做低功耗设计再深入。这套代码保存下来之后我又在上面扩展过乘法指令和外部中断发现只要当初的结构清晰、控制信号都是独立定义的扩展起来其实很快。单周期CPU虽然是计算机组成原理课上最基础的内容但亲手把它在真实FPGA上跑通带来的收益远超过看一百页教科书。我在写这篇文章时又把当年的仿真波形翻出来看了一遍还是觉得这个项目特别适合作为进入CPU设计世界的“第一块敲门砖”。
阅读完成 · 觉得有帮助?
咨询建站