ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

从Verilog到CPU:基于ETH Zurich课程的完整数字设计实践指南

从Verilog到CPU:基于ETH Zurich课程的完整数字设计实践指南 在实际硬件开发、嵌入式系统设计或计算机体系结构学习过程中很多人会遇到一个核心矛盾理论教材讲得头头是道但面对一个真实的数字电路或CPU设计项目时却不知道如何从零开始将晶体管、逻辑门、Verilog代码、仿真、综合直到最终在FPGA上运行这一整套链路打通。苏黎世联邦理工学院ETH Zurich的“数字设计与计算机体系结构”课程以其深度和系统性闻名被许多从业者称为“神课”。其2026年的重制版本更是聚焦于从底层到高层的全链路实践。本文旨在为硬件描述语言初学者、计算机体系结构爱好者、以及希望深入理解CPU/GPU内部工作机制的工程师提供一个基于该课程核心思想的实践指南。我们将不局限于观看视频而是通过一个具体的、可操作的路径从理解数字逻辑基础开始使用Verilog编写关键模块进行功能仿真最终在FPGA开发板上实现一个简化的CPU核心。这个过程将串联起关键词中的Digital Design、Verilog、CPU、缓存等核心概念并解释GPU计算、缓存原理等相关扩展知识。你将能获得一套可复现的方法论用于构建自己的数字系统原型。1. 理解从晶体管到CPU的设计层次与工具链在动手写第一行Verilog代码之前必须建立清晰的层次化设计观念。现代计算机系统是一个复杂的层次结构学习时需要自底向上但设计时往往自顶向下。1.1 设计抽象层次数字系统的设计通常分为多个层次每一层都对下一层进行抽象系统级描述整个计算机系统的功能如CPU、内存、外设如何交互。算法/架构级用高级语言如C描述组件的行为。对于CPU设计这就是指令集架构ISA的定义。寄存器传输级RTL这是用硬件描述语言如Verilog、VHDL工作的核心层级。它描述数据如何在寄存器之间流动以及每个时钟周期发生的逻辑操作。你的Verilog代码主要描述这一层。逻辑门级由基本逻辑门与、或、非等和触发器构成的电路。综合工具会将RTL描述转换到这一层。晶体管级用MOSFET等晶体管实现逻辑门。这是物理实现的起点。物理级涉及芯片的布局、布线和制造。我们的实践将聚焦在RTL级用Verilog描述一个CPU的寄存器传输行为然后依赖工具链综合器将其转换为更低层的网表。1.2 核心工具链介绍一个完整的数字设计流程需要一系列工具协同工作文本编辑器/IDE如VS Code用于编写Verilog代码。需要安装相关插件如Verilog-HDL/SystemVerilog实现语法高亮和代码跳转。仿真器如ModelSim、Icarus Verilogiverilog配合GTKWave。用于验证RTL代码的逻辑功能是否正确无需实际硬件。这是排查逻辑错误的主要阶段。综合工具如Xilinx Vivado用于Xilinx FPGA、Intel Quartus用于Intel FPGA中的综合引擎或开源工具Yosys。它将RTL代码转换为目标工艺FPGA或ASIC的基本逻辑单元网表。实现工具通常集成在FPGA厂商的IDE中如Vivado、Quartus。负责将综合后的网表进行布局布线映射到具体的FPGA芯片资源上并生成最终的比特流文件。编程/调试工具将比特流文件下载到FPGA开发板并通过ILA集成逻辑分析仪等工具进行片上调试。对于初学者可以先用Icarus Verilog GTKWave进行仿真验证再使用Vivado或Quartus进行FPGA综合与实现。2. 环境准备与第一个Verilog模块我们选择Verilog作为实践语言因为它广泛用于工业界和学术界。以下环境配置以Windows/Linux/macOS通用性较高的方式为例。2.1 安装仿真工具链Icarus Verilog GTKWaveWindows访问 Icarus Verilog官方发布页 下载安装包。GTKWave可从其 官网 下载。安装后确保将可执行文件路径如C:\iverilog\bin和C:\gtkwave\bin添加到系统环境变量PATH中。Linux (Ubuntu/Debian)使用包管理器安装。sudo apt-get update sudo apt-get install iverilog gtkwavemacOS使用Homebrew安装。brew install icarus-verilog gtkwave安装完成后在终端中运行iverilog -v和gtkwave --version验证安装。2.2 编写与仿真一个简单的组合逻辑模块我们从最基础的与门开始建立“编写-编译-仿真-查看波形”的完整流程。项目结构first_circuit/ ├── and_gate.v // Verilog源文件 ├── testbench.v // 测试平台文件 └── run_sim.sh // 仿真脚本可选1. 设计文件and_gate.v// 模块定义模块名、端口列表 module and_gate ( input wire a, // 输入端口a input wire b, // 输入端口b output wire y // 输出端口y ); // 连续赋值语句描述逻辑功能y a b assign y a b; endmodule2. 测试平台文件testbench.v测试平台Testbench是一个特殊的Verilog模块用于实例化待测设计并施加测试激励。timescale 1ns / 1ps // 定义时间单位/精度 module tb_and_gate; // 声明连接到待测模块的变量 reg a, b; wire y; // 实例化待测模块Unit Under Test, UUT and_gate uut ( .a(a), .b(b), .y(y) ); // 初始化过程块生成测试激励 initial begin // 生成波形文件供GTKWave查看 $dumpfile(tb_and_gate.vcd); $dumpvars(0, tb_and_gate); // 0表示转储所有层次的信号 // 测试用例 a 0; b 0; #10; // 等待10个时间单位 a 0; b 1; #10; a 1; b 0; #10; a 1; b 1; #10; // 结束仿真 $finish; end endmodule3. 运行仿真在项目目录下打开终端执行以下命令# 1. 编译设计文件和测试平台 iverilog -o sim_output and_gate.v testbench.v # 2. 运行仿真这会生成波形文件 tb_and_gate.vcd vvp sim_output # 3. 使用GTKWave打开波形文件查看结果 gtkwave tb_and_gate.vcd在GTKWave中将左侧的信号a, b, y拖到波形视图区即可看到模拟的时序波形。你可以验证在a和b的不同输入组合下输出y是否符合与门的真值表。注意仿真Simulation和综合Synthesis是两回事。仿真用于验证逻辑正确性可以包含不可综合的语句如$dumpfile。综合是将代码转换为实际电路必须使用可综合的Verilog子集。3. 构建一个简化的单周期CPU核心现在我们利用RTL设计方法构建一个极度简化的CPU它能够执行几条基本的指令。这将串联起指令集、控制器、数据通路等核心概念。3.1 定义指令集架构ISA我们设计一个名为“SimpleCPU”的简化RISC架构字长32位。寄存器文件32个32位通用寄存器x0-x31其中x0恒为0。指令格式采用类似RISC-V的固定32位长度。我们只实现三种类型R-type寄存器-寄存器操作如ADD加法。I-type立即数操作如ADDI加立即数、LW加载字。B-type条件分支如BEQ相等时分支。内存统一的指令/数据内存哈佛结构更优但为简化先使用统一内存。我们实现以下5条指令ADD rd, rs1, rs2rd rs1 rs2ADDI rd, rs1, immrd rs1 imm(imm为12位有符号立即数)LW rd, offset(rs1)rd Memory[rs1 offset]SW rs2, offset(rs1)Memory[rs1 offset] rs2BEQ rs1, rs2, offset 若rs1 rs2则PC PC offset3.2 顶层模块与数据通路设计CPU的核心是数据通路Datapath和控制单元Control Unit。数据通路是执行指令的硬件路径控制单元根据指令产生控制信号指挥数据通路上的多路选择器、寄存器写入等操作。项目结构simple_cpu/ ├── rtl/ │ ├── simple_cpu_top.v │ ├── pc_reg.v │ ├── instruction_memory.v │ ├── register_file.v │ ├── alu.v │ ├── control_unit.v │ └── data_memory.v ├── sim/ │ └── tb_simple_cpu.v └── scripts/ └── run_simulation.tcl1. 程序计数器pc_reg.vmodule pc_reg ( 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) begin pc 32h0000_0000; // 复位时PC指向0地址 end else begin pc next_pc; // 每个时钟上升沿更新PC end end endmodule2. 算术逻辑单元alu.v部分module alu ( input wire [31:0] operand_a, input wire [31:0] operand_b, input wire [ 2:0] alu_op, // 操作码如000ADD, 001SUB output reg [31:0] alu_result, output wire zero // 结果是否为0用于BEQ判断 ); assign zero (alu_result 32b0); always (*) begin case (alu_op) 3b000: alu_result operand_a operand_b; // ADD 3b001: alu_result operand_a - operand_b; // SUB 3b010: alu_result operand_a operand_b; // AND 3b011: alu_result operand_a | operand_b; // OR 3b100: alu_result (operand_a operand_b) ? 32b1 : 32b0; // SLT default: alu_result 32b0; endcase end endmodule3. 控制单元control_unit.v部分控制单元是指令的“解码器”根据指令的操作码opcode和功能码funct3/funct7产生所有控制信号。module control_unit ( input wire [6:0] opcode, // 指令[6:0] output reg reg_write, // 是否写寄存器 output reg mem_to_reg, // 数据来源内存还是ALU结果 output reg mem_write, // 是否写内存 output reg alu_src, // ALU操作数B来源寄存器还是立即数 output reg [2:0] alu_op, // ALU操作类型 output reg branch // 是否为分支指令 ); always (*) begin // 默认值 {reg_write, mem_to_reg, mem_write, alu_src, alu_op, branch} 8b0; case (opcode) 7b0110011: begin // R-type (ADD, SUB...) reg_write 1b1; alu_src 1b0; // 操作数来自寄存器 alu_op 3b000; // 具体操作由funct3进一步决定此处简化 end 7b0010011: begin // I-type (ADDI) reg_write 1b1; alu_src 1b1; // 操作数B来自立即数 alu_op 3b000; end 7b0000011: begin // I-type (LW) reg_write 1b1; mem_to_reg 1b1; // 结果来自内存 alu_src 1b1; alu_op 3b000; // 计算地址 end 7b0100011: begin // S-type (SW) mem_write 1b1; alu_src 1b1; alu_op 3b000; // 计算地址 end 7b1100011: begin // B-type (BEQ) branch 1b1; alu_op 3b001; // 使用SUB操作进行比较 end default: begin // 处理非法指令或NOP end endcase end endmodule4. 顶层模块simple_cpu_top.v顶层模块将各个子模块像搭积木一样连接起来形成完整的数据通路。这包括PC寄存器、指令内存、寄存器文件、立即数生成器、ALU、控制单元、数据内存以及多个多路选择器。module simple_cpu_top ( input wire clk, input wire rst_n ); // 内部信号声明省略部分 wire [31:0] pc, next_pc, instruction; wire [31:0] read_data1, read_data2, write_data; wire [31:0] imm_ext, alu_result, mem_read_data; wire pc_src, reg_write, mem_to_reg, mem_write, alu_src, branch, zero; wire [2:0] alu_ctrl; wire [31:0] alu_operand_b; wire [31:0] branch_target; // 模块实例化 pc_reg u_pc_reg (.clk(clk), .rst_n(rst_n), .next_pc(next_pc), .pc(pc)); instruction_memory u_imem (.addr(pc), .instruction(instruction)); register_file u_reg_file (.clk(clk), .we(reg_write), ...); control_unit u_ctrl (.opcode(instruction[6:0]), ...); alu u_alu (.operand_a(read_data1), .operand_b(alu_operand_b), .alu_op(alu_ctrl), .alu_result(alu_result), .zero(zero)); data_memory u_dmem (.clk(clk), .we(mem_write), .addr(alu_result), .write_data(read_data2), .read_data(mem_read_data)); // 多路选择器用条件运算符实现 assign alu_operand_b alu_src ? imm_ext : read_data2; assign write_data mem_to_reg ? mem_read_data : alu_result; assign branch_target pc (imm_ext 1); // 分支偏移计算简化 assign pc_src branch zero; assign next_pc pc_src ? branch_target : (pc 4); // 下一条PC endmodule3.3 编写测试程序与仿真我们需要编写一个测试平台并将一段简单的机器码程序加载到指令内存中。这段程序可以是一段计算斐波那契数列或数组求和的代码。测试平台tb_simple_cpu.vmodule tb_simple_cpu; reg clk; reg rst_n; simple_cpu_top uut (.clk(clk), .rst_n(rst_n)); // 生成时钟信号周期10个时间单位 always #5 clk ~clk; initial begin $dumpfile(simple_cpu.vcd); $dumpvars(0, tb_simple_cpu); // 初始化 clk 0; rst_n 0; #20; // 保持复位一段时间 rst_n 1; // 释放复位 // 运行足够多的时钟周期 #500; // 可以在这里添加断言检查最终寄存器x3的值是否为预期结果 // if (uut.u_reg_file.regs[3] ! 32d预期值) $error(Test failed!); $finish; end // 预加载指令内存需要在instruction_memory模块中实现初始化 // 通常通过$readmemh系统任务从文件加载 initial begin // 假设有一个汇编程序编译成的机器码文件 program.mem // $readmemh(program.mem, uut.u_imem.mem); end endmodule汇编程序示例伪代码需手动或通过工具编译为机器码ADDI x1, x0, 5 # x1 5 ADDI x2, x0, 1 # x2 1 LOOP: ADD x3, x1, x2 # x3 x1 x2 ADDI x1, x1, -1 # x1 x1 - 1 BNE x1, x0, LOOP # if x1 ! 0, jump to LOOP你需要一个汇编器或手动查表将上述汇编转换为对应的32位二进制机器码并保存到program.mem文件中每行一个32位十六进制数。然后在测试平台的initial块中使用$readmemh加载。运行仿真观察波形中PC的变化、指令的执行、寄存器的写入以及内存的读写验证CPU是否按预期工作。4. 引入缓存Cache概念与简单模型现代CPU性能的关键之一在于缓存。我们可以为上面的SimpleCPU添加一个极其简化的缓存模型来理解其工作原理。4.1 缓存基本原理缓存是位于CPU核心和主存之间的小容量高速存储器用于存放最近可能被访问的数据副本。其核心思想是时间局部性和空间局部性。时间局部性刚被访问的数据很可能再次被访问。空间局部性访问某个地址后其邻近地址很可能被访问。缓存的基本单位是缓存行一次从主存加载一个缓存行如64字节的数据。我们实现一个简化的直接映射缓存。4.2 实现一个简化的直接映射缓存模块module simple_dcache #( parameter DATA_WIDTH 32, parameter CACHE_SIZE 256, // 缓存大小单位字节 parameter LINE_SIZE 16 // 行大小单位字节 )( input wire clk, input wire rst_n, // CPU侧接口 input wire cpu_req, // 请求有效 input wire cpu_wr, // 1写0读 input wire [31:0] cpu_addr, input wire [DATA_WIDTH-1:0] cpu_wdata, output reg [DATA_WIDTH-1:0] cpu_rdata, output reg cpu_ready, // 请求完成 // 内存侧接口模拟慢速内存 output reg mem_req, output reg mem_wr, output reg [31:0] mem_addr, output reg [DATA_WIDTH-1:0] mem_wdata, input wire [DATA_WIDTH-1:0] mem_rdata, input wire mem_ready ); // 缓存参数计算 localparam LINE_WORDS LINE_SIZE / (DATA_WIDTH/8); // 每行有多少个字 localparam NUM_LINES CACHE_SIZE / LINE_SIZE; localparam INDEX_WIDTH $clog2(NUM_LINES); localparam OFFSET_WIDTH $clog2(LINE_SIZE); // 缓存存储体标签(Tag)、数据(Data)、有效位(Valid) reg [31-INDEX_WIDTH-OFFSET_WIDTH:0] tag_array [0:NUM_LINES-1]; reg [DATA_WIDTH-1:0] data_array [0:NUM_LINES-1][0:LINE_WORDS-1]; reg valid_array [0:NUM_LINES-1]; // 地址分解 wire [INDEX_WIDTH-1:0] index; wire [OFFSET_WIDTH-1:0] offset; wire [31-INDEX_WIDTH-OFFSET_WIDTH:0] tag; assign {tag, index, offset} cpu_addr; // 状态机 typedef enum logic [1:0] { IDLE, COMPARE, MEM_READ, MEM_WRITEBACK } state_t; state_t current_state, next_state; // 状态寄存器 always (posedge clk or negedge rst_n) begin if (!rst_n) current_state IDLE; else current_state next_state; end // 下一状态逻辑和输出逻辑简化版仅示意读命中流程 always (*) begin next_state current_state; cpu_ready 1b0; mem_req 1b0; // ... 其他默认值 case (current_state) IDLE: begin if (cpu_req) begin if (valid_array[index] tag_array[index] tag) begin // 缓存命中 cpu_rdata data_array[index][offset]; cpu_ready 1b1; next_state IDLE; end else begin // 缓存未命中进入内存访问状态 next_state MEM_READ; end end end MEM_READ: begin mem_req 1b1; mem_wr 1b0; mem_addr {cpu_addr[31:OFFSET_WIDTH], {OFFSET_WIDTH{1b0}}}; // 对齐到行首 if (mem_ready) begin // 从内存读取一整行数据到缓存此处简化只读一个字 data_array[index][offset] mem_rdata; tag_array[index] tag; valid_array[index] 1b1; cpu_rdata mem_rdata; cpu_ready 1b1; next_state IDLE; end end // ... 其他状态如写分配、写回等 endcase end endmodule这个模块可以集成到simple_cpu_top中位于CPU核心和数据内存之间。当CPU发起访存请求时先查询缓存。命中则快速返回数据未命中则访问主存并将数据载入缓存。通过仿真你可以对比添加缓存前后执行一段循环访问数组的程序所需的时钟周期数直观感受缓存对性能的影响。5. 综合到FPGA与常见问题排查将RTL代码转换为能在FPGA上运行的比特流是学习的最后一步也会遇到最多实际问题。5.1 使用Vivado进行综合与实现创建项目打开Vivado创建新项目选择目标FPGA型号例如Basys 3开发板对应的是xc7a35tcpg236-1。添加源文件将rtl/目录下的所有.v文件添加到项目中。添加约束文件创建XDC约束文件定义时钟引脚、复位引脚、可能的调试端口如LED用于显示状态等。# 示例Basys 3 100MHz时钟和复位按钮 set_property PACKAGE_PIN W5 [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk] create_clock -add -name sys_clk_pin -period 10.00 -waveform {0 5} [get_ports clk] set_property PACKAGE_PIN U18 [get_ports rst_n] set_property IOSTANDARD LVCMOS33 [get_ports rst_n]综合运行综合Synthesis。此步骤将RTL转换为逻辑网表。实现运行实现Implementation包括布局布线Place Route。生成比特流生成.bit文件。下载连接开发板下载比特流。5.2 常见问题与排查路径在从仿真到上板的整个流程中你会遇到各种问题。下表列出了常见问题及其排查思路问题阶段现象/错误信息可能原因检查与解决思路仿真波形全为X不定态1. 寄存器未初始化。2. 组合逻辑环路。3. 多驱动源。1. 检查所有reg型变量在复位时是否有确定值。2. 检查always (*)块中是否对同一变量既读又写非阻塞赋值可能形成环路。3. 检查是否有两个assign或always块驱动同一根线网。仿真行为与预期不符1. 控制信号生成错误。2. 立即数符号扩展错误。3. 内存地址对齐问题。1. 仔细对照指令格式检查控制单元解码逻辑。2. 确认I-type和B-type指令的立即数符号扩展是否正确。3. 确保LW/SW地址是字对齐的低2位为0。综合综合警告[Synth 8-*]1. 未连接的端口。2. 锁存器推断。3. 多时钟驱动。1. 检查模块实例化时是否所有端口都已连接。2. 在always块中如果条件分支不全会综合出锁存器。确保所有条件下输出都有赋值或赋默认值。3. 检查是否在同一模块中混用了不同时钟域的信号。综合时序违例关键路径延迟过长。1. 查看时序报告找到关键路径。2. 考虑插入流水线寄存器打破长组合逻辑链。3. 优化代码结构减少路径上的逻辑级数。实现布局布线失败1. 资源不足。2. 约束过紧。3. 设计中有不可布线的结构。1. 查看资源利用率报告LUT、FF、BRAM等。简化设计或更换更大容量的FPGA。2. 放松时钟约束或I/O延迟约束。3. 检查是否使用了器件不支持的原语或特性。上板程序无反应/LED不亮1. 时钟未连接或频率错误。2. 复位信号极性错误或毛刺。3. I/O约束错误。1. 用示波器或ILA测量时钟引脚是否有信号。2. 确认复位按钮是低有效还是高有效在代码中是否匹配。可添加复位去抖电路。3. 核对约束文件中引脚编号和电平标准是否与开发板原理图一致。上板行为随机/不稳定1. 跨时钟域问题。2. 亚稳态。3. 电源噪声。1. 对跨时钟域信号使用同步器两级触发器。2. 检查是否在异步复位释放时违反了恢复/移除时间。3. 确保电源稳定必要时在代码中增加上电初始化延时。5.3 使用ILA进行片上调试当代码在FPGA上行为异常时仿真正确的代码也可能出问题。Vivado的集成逻辑分析仪ILA是强大的调试工具。标记调试网络在RTL代码中对需要观察的信号添加(* mark_debug “true” *)属性。(* mark_debug true *) wire [31:0] debug_pc; assign debug_pc u_pc_reg.pc;在Vivado中设置ILA IP综合后在“Netlist”窗口中可以看到标记的信号。将其拖入ILA IP核的调试端口。重新综合与实现生成新的比特流并下载。触发与捕获在Hardware Manager中设置触发条件如当debug_pc 32’h0000_0010然后运行捕获。你可以像看仿真波形一样查看FPGA运行时的真实信号。6. 扩展方向与最佳实践完成基础CPU实现后你可以沿着多个方向深化理解和实践6.1 架构扩展流水线将单周期CPU改为5级流水线取指、译码、执行、访存、写回这是提升CPU频率的关键。需要处理数据冒险通过前推或停顿和控制冒险通过分支预测或延迟槽。缓存优化实现组相联或全相联缓存研究替换算法LRU、随机。添加写缓冲和写回策略。总线与外设通过AXI或Wishbone总线连接UART、GPIO、定时器等外设实现一个简单的SoC。协处理器添加一个简单的硬件乘法/除法单元或者实现一个浮点运算单元。6.2 工具与流程优化自动化脚本使用Tcl或Python脚本自动化整个仿真、综合、实现流程。形式验证学习使用形式化工具如JasperGold验证某些关键属性作为仿真的补充。高级综合尝试使用高层次综合HLS工具如Xilinx Vitis HLS用C/C描述算法并生成RTL与手写RTL进行对比。6.3 代码与设计最佳实践可综合性始终区分可综合代码和仿真代码。initial、$display、#delay等不可综合仅用于测试。同步设计对几乎所有时序逻辑使用单一的全局时钟和同步复位。避免使用门控时钟和异步电路除非有特殊需求且完全理解其风险。清晰命名使用有意义的信号和模块名。对于低有效信号使用_n后缀如rst_n。常量使用PARAMETER或localparam定义。模块化与层次化将功能独立的单元封装成模块并通过清晰的接口连接。顶层模块只做例化不包含复杂逻辑。参数化设计使用parameter使模块可配置如数据位宽、缓存大小、地址宽度等提高代码复用性。注释与文档为每个模块编写头注释说明功能、端口、参数。为复杂的状态机或算法绘制流程图或波形图。版本控制使用Git管理代码特别是当项目变得复杂时。从晶体管物理原理到现代CPU的复杂架构这条学习路径漫长但回报丰厚。通过这个从Verilog模块、CPU核心、缓存模型到FPGA实现的完整实践你获得的不再是孤立的知识点而是一套理解、设计和调试数字系统的工程化思维框架。下一步可以尝试将你的SimpleCPU移植到其他FPGA平台或者参考RISC-V官方规范实现一个更完整的RV32IMC核心那将是通向专业芯片设计领域的坚实一步。
返回列表