
搞 FPGA 的人大概都经历过这种场景打开 Quartus 或 Vivado改一行代码等综合跑完然后发现只是少了个分号再改再等一上午就在进度条和报错窗口之间耗掉了。后来我把日常 RTL 编写和功能仿真挪到了 VS Code 上用 Icarus Verilog也就是 iverilog做仿真验证厂商 IDE 只负责最后一步综合、布局布线和上板。这套流程让改代码到看到仿真结果的反馈周期从分钟级缩到了秒级代码补全、语法高亮、格式化这些体验也比厂商自带编辑器舒服得多。这篇文章就把完整链路交代清楚VS Code 怎么搭建 Verilog 开发环境、iverilog 的常用命令和仿真流程、Tasks 和 Makefile 怎么组织工程最后用一个数码管动态扫描的例子把整个流程串起来。想入门 FPGA、或者正被大 IDE 拖着走的人都可以直接照着这套流程走一遍先学会用轻量工具验证逻辑再回到厂商工具做综合布线学习曲线会平滑很多。1. 这套轻量开发流程解决什么问题1.1 厂商 IDE 的编辑器体验实在太老了不是说 Quartus 和 Vivado 不好综合、布线、时序分析这些东西它们确实不可替代。但作为日常写代码的编辑器它们实在有点跟不上节奏。工程一大的时候打开工程、索引文件、编译几个环节都有明显等待感。代码补全有但不智能格式化基本靠手想快速跳转到模块定义得靠鼠标慢慢翻换一个主题、配一个快捷键都要去翻软件自己的配置项。VS Code 在这一块是另一个维度的存在。装上 Verilog-HDL/SystemVerilog 这类插件之后模块名、信号名、端口列表的自动补全、悬停提示、跳转定义都能用。配合 linter 在写代码的同时标出语法问题很多低级错误在保存前就被消灭了。这个体验一旦用回去基本就回不去了。但这里有一个认知要提前建立VS Code 只是个编辑器iverilog 只是个仿真器它们都不负责把 Verilog 变成比特流。你能在 VS Code 里舒舒服服地写 RTL能在终端里快速跑出仿真波形但最终上板还是要 Quartus、Vivado、钻石或者盘古这些厂商工具走一遍综合布局布线。这套轻量流程替代的是编码 功能验证环节不是整个 FPGA 开发流程。1.2 iverilog 在验证流程里的定位iverilog 是一个开源的 Verilog 仿真器全称 Icarus Verilog。它支持 Verilog-2001 和大部分常用的 SystemVerilog 语法配合 GTKWave 查看波形足以覆盖教学实验、算法验证、模块级功能仿真这些场景。很多人会问直接用厂商工具里带的 ModelSim 或 Vivado Simulator 不就行了为什么要额外折腾一套关键在于启动速度和自动化程度。iverilog 是命令行工具跑一次编译 仿真就是两条命令的事接口简单稳定适合嵌入 VS Code 的 Tasks、写进 Makefile甚至接到 CI 流程里做回归测试。你不需要打开一个 GUI、点一堆 Next、等它加载完才能在 console 里执行一条 do 文件。从学习路径看用 iverilog 学 Verilog 也是一个好选择。语法错误信息直接打在终端里没有厂商 IP 和库文件的干扰能更快建立对语言本身的直觉。等你需要仿真 DDR、高速 SerDes、MIPI 这类依赖厂商原语和 IP 的场景时再切到厂商仿真器也不迟这两者不是二选一的关系而是可以共存的。1.3 谁最适合用这套组合如果你是这几类人这套流程会很对味FPGA 入门学习者。手头有一块开发板但还没有形成系统的仿真习惯可以先在 VS Code 里把 Verilog 语法和 testbench 写法练熟再上板验证。做图像处理算法验证的人。很多算法模块先用 iverilog 仿真验证功能逻辑对了再进厂商工具做综合迭代效率高不少。被厂商 IDE 启动速度折磨的日常开发者。主用厂商 IDE 做后端但把代码编辑和快速仿真放到 VS Code。有工程化习惯的人。想在终端里用命令行完成编译仿真配合 Makefile、脚本甚至持续集成做自动化验证。这套组合不适合的场景也要说清楚涉及厂商专有 IP、原语、高速接口比如 LVDS、MIPI、PCIe、或者需要精确到器件资源时序的仿真还是要回到厂商工具链。iverilog 能跑的是功能仿真不是时序仿真这个边界从一开始建立起来后面少走很多弯路。2. 环境准备安装、插件和第一行命令2.1 三个基础组件的安装先装 iverilog。Windows 用户直接从 GitHub 上 Icarus Verilog 项目的 release 页面下载安装包装完它会顺便把 GTKWave 和 vvp 一起装好环境变量也会配好。Linux 用户一条命令搞定sudo apt install iverilog gtkwavemacOS 用户用 Homebrewbrew install icarus-verilog装完在终端里验证一下iverilog -V能看到版本号输出就说明没问题。这里有个细节Windows 装完如果终端里找不到命令先重开一个终端窗口环境变量没有刷新还不行就手动把安装目录一般类似C:\iverilog\bin加到 PATH 里。VS Code 那边需要装三个插件按重要性排序Verilog-HDL/SystemVerilog语法高亮、自动补全、格式化、lint 配置都在它里面。Makefile Tools后面用 Makefile 管理编译这个插件能提供任务识别和错误跳转。Surfer一个直接在 VS Code 里查看 VCD/FST 波形的插件比反复打开 GTKWave 方便一点适合快速确认结果。插件清单和作用可以看下面这张表插件作用替代方案Verilog-HDL/SystemVerilog语法高亮、代码补全、lintTerosHDLMakefile Tools识别 Makefile 目标、错误定位手动在终端跑命令Surfer在 VS Code 内打开 VCD 波形GTKWave独立窗口2.2 配置 lint 和格式化代码补全才完整装完插件只是第一步要让 lint 在实际写代码时即时报错需要告诉插件用 iverilog 做检查工具。在 VS Code 的设置里搜verilog.linting.linter把它改成 iverilator 的几个选项之外直接选 iverilog。再看verilog.linting.iverilog.arguments建议填上-g2012 -Wall这样插件每次保存文件时都会用 iverilog 的语法检查模式跑一遍有错误和警告会直接在代码下方标红标黄。注意它只检查语法层面不做完整编译所以有些跨模块的信号连接错误它不一定能抓到但那也比什么都没有强很多。格式化方面如果插件自带的格式化效果不满意可以装 verible-verilog 这个工具它由 Google 出品格式化规则可配置对 SystemVerilog 的覆盖更全面。在 VS Code 设置里把默认格式化程序指定为 Verible然后快捷键ShiftAltF就能全文格式化。团队协作时统一格式化风格代码 review 会省很多事。2.3 跑一条最简单的心跳灯验证到这一步先别急着写大工程用最基础的例子确认整个链路是通的。新建一个blink.vmodule blink ( input wire clk, input wire rst_n, output reg led ); reg [25:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 26d0; led 1b0; end else if (cnt 26d49_999_999) begin cnt 26d0; led ~led; end else begin cnt cnt 1b1; end end endmodule再写一个tb_blink.vtimescale 1ns/1ps module tb_blink; reg clk 0; reg rst_n 0; wire led; blink u_blink ( .clk (clk), .rst_n (rst_n), .led (led) ); always #10 clk ~clk; initial begin $dumpfile(blink.vcd); $dumpvars(0, tb_blink); #20 rst_n 1; #5000; $finish; end endmodule然后在终端里执行iverilog -g2012 -o blink.vvp tb_blink.v blink.v vvp blink.vvp如果看到命令正常退出、没有报错再用 Surfer 打开生成的blink.vcd能看到 clk 波形和 led 翻转整套环境就算通了。注意这里我把 testbench 文件放在被测试文件前面还加了-g2012这两个小细节后面专门解释。3. iverilog 编译与仿真的核心命令一次说透3.1 顶层模块的选择逻辑很多刚接触 iverilog 的人会碰到一个困惑为什么明明所有模块都写在文件列表里了仿真结果却不对原因在于顶层模块的选择。iverilog 默认把命令行列出的第一个模块作为仿真顶层。有两种方法控制顶层一是把 testbench 模块写在文件列表最前面二是用-s参数显式指定。我推荐用第二种显式指定更不容易出错iverilog -g2012 -s tb_blink -o blink.vvp tb_blink.v blink.v其中-s tb_blink告诉 iverilog 顶层是测试平台模块 tb_blink不管它在文件列表里的哪个位置。好处是文件顺序不再重要你可以按blink.v tb_blink.v的顺序排也可以按目录扫描出一堆文件来编译顶层始终是明确的。3.2 常用参数和宏定义iverilog 的常用参数并不复杂我整理了一个在实践中用得非常频繁的清单参数作用示例-o 文件名指定编译输出文件-o sim.vvp-s 模块名指定仿真顶层模块-s tb_top-g2012启用 SystemVerilog 语法支持 interface、logic 等-Wall打开所有警告发现未初始化等潜在问题-I 路径添加 include 搜索目录-I ./include-D宏名定义宏等价于代码里 define-D SIMULATION-p参数值覆盖模块参数-pWIDTH8-l 文件名输出编译日志-l compile.log实际项目里最常见的组合是这样iverilog -g2012 -Wall -s tb_core -o sim.vvp -I ./rtl -I ./tb \ ./rtl/core.v ./rtl/alu.v ./tb/tb_core.v写 Makefile 或脚本时建议把文件列表用通配符或者 find 命令收集起来不要一个个手敲工程文件一多手敲列表很容易漏。比如RTL_SRC $(wildcard rtl/*.v) TB_SRC $(wildcard tb/*.v)3.3 仿真运行与波形输出编译出来的.vvp文件需要 vvp 来运行vvp sim.vvp跑完后能不能看到波形取决于 testbench 里有没有做两件事声明波形文件名、声明要 dump 哪些信号。第一件事是$dumpfile(sim.vcd)。这里建议波形文件名和编译输出名保持一致比如编译用-o sim.vvp波形就叫sim.vcd养成习惯后项目多了也好管理。第二件事是$dumpvars(0, tb_top)。第一个参数 0 表示 dump 整个层次结构里的所有信号第二个参数写测试平台的顶层模块名这样仿真器就会把 testbench 下面的所有模块信号都记录到 VCD 文件里。也有只 dump 某个模块的写法$dumpvars(1, tb_top.u_dut);第一个参数改成 1只 dump 这一层及其下面的信号再往上层的不记录。波形文件太大、打开卡顿的时候这个用法能帮你精准缩减数据量。3.4 $display、$monitor、$stop 的调试用法除了看波形命令行调试也很有用。$display是打印一行信息适合单次查看$monitor是持续监视只要参数列表里的任何一个信号发生变化就自动打印一行适合盯状态机跳转和数据流变化。一个典型的带自动检查的 testbench 片段initial begin $dumpfile(tb_alu.vcd); $dumpvars(0, tb_alu); $display( ALU test start ); // 测试加法 a 8h01; b 8h02; op 3b000; #10; if (y ! 8h03) $display(ERROR: ADD a01 b02 expected 03, got %02h, y); else $display(PASS: ADD); #10; $finish; end这里用$display(ERROR ...)而不是一报错就停是为了跑回归时能看到所有失败用例。如果希望遇到问题立刻停下来单步分析在出错分支里加上$stop;它会挂起仿真保留当前状态方便你观察周围信号。4. VS Code 里把工程串起来Tasks 和 Makefile 双管齐下4.1 用 Makefile 管理 RTL 文件列表命令行直接敲命令适合一两个文件的小例子工程一复杂文件十几二十个的时候靠大脑记住文件列表不现实。这时就该上 Makefile。这里给一个适合小型 FPGA 仿真工程的模板TB tb_seg_scan RTL_SRC $(wildcard rtl/*.v) TB_SRC tb/$(TB).v VVPOUT sim.vvp VCDOUT sim.vcd IVERILOG iverilog VVP vvp .PHONY: all all: run .PHONY: compile compile: $(IVERILOG) -g2012 -Wall -s $(TB) -o $(VVPOUT) $(RTL_SRC) $(TB_SRC) .PHONY: run run: compile $(VVP) $(VVPOUT) .PHONY: wave wave: gtkwave $(VCDOUT) .PHONY: clean clean: rm -f $(VVPOUT) $(VCDOUT)几点说明顶层 testbench 模块名通过变量TB控制用-s指定不依赖文件顺序wave目标用 GTKWave 打开波形clean清理中间文件。把所有 RTL 文件和 testbench 分开放在rtl/和tb/目录下是 FPGA 工程一个非常值得养成的习惯。综合脚本、仿真脚本都能用清晰的通配规则找到文件不会跟文档、脚本混在一起。4.2 配置 VS Code Tasks一个 CtrlShiftB 搞定编译VS Code 的 Tasks 可以理解成把终端命令集成到快捷键和菜单里。在工程根目录建.vscode/tasks.json{ version: 2.0.0, tasks: [ { label: iverilog: compile, type: shell, command: iverilog, args: [ -g2012, -Wall, -s, tb_seg_scan, -o, sim.vvp, rtl/seg_scan.v, tb/tb_seg_scan.v ], group: build, problemMatcher: [] }, { label: iverilog: run, type: shell, command: vvp, args: [sim.vvp], dependsOn: iverilog: compile, group: test, problemMatcher: [] } ] }配完后按CtrlShiftB或者CmdShiftB会看到编译任务运行它会调用 iverilog 编译。iverilog: run任务通过dependsOn依赖编译任务会先编译再仿真。顺带说一句problemMatcher配置得好编译错误能直接跳转到对应文件行非常香。不过 iverilog 的错误格式比较简单写个简单的正则匹配器也不难对效率追求高的人可以研究一下需求不大的人可以先留空。4.3 和厂商工具的分工要明确用 VS Code 加 iverilog 做前仿真时一定要注意它与厂商工具之间的边界。iverilog 功能仿真的结果证明的是逻辑功能正确不包含任何布线延迟和器件时序信息。更准确地说一个成熟流程是这样的在 VS Code 里写 RTL 和 testbench用 iverilog 快速迭代功能验证。逻辑确认没问题后在厂商工程里添加同样的 RTL 文件写约束文件SDC 或 QSF跑综合和布局布线。布局布线后用厂商工具做时序仿真如果有必要或者直接上板用逻辑分析仪验证。功能仿真的 testbench 可以留档后续修改代码后回回归一遍防止改出回归问题。我在实际项目中功能仿真和厂商综合之间经常来回切换先在 iverilog 里快速试探一个算法的可行性比如图像滤波模块跑通了再进 Vivado 走完整流程。这样综合工具的资源虽然有限但每一分钟都花在真正需要的时候。4.4 让 VS Code 里的代码更好维护格式化、注释和命名FPGA 工程代码维护性和软件一样重要。在 VS Code 里可以充分利用编辑器能力养成好习惯注释模块端口时写清楚方向、位宽和含义配合插件的悬停提示其他人接手时读代码效率会高很多。信号命名建议统一风格。时钟用clk、复位用rst_n低有效加_n后缀、使能用_en、计数器用cnt_前缀状态机状态用大写字母枚举。这些约定用习惯后代码自己会说话。每个文件头部统一写文件说明、模块名、作者和修改日期。听起来很老套但在工程里被队友问这个模块是干嘛的时你会发现这条很救命。5. 一个能跑通的例子数码管动态扫描仿真5.1 动态显示的基本原理用 FPGA 驱动数码管最常见的是共阴极数码管段码和位选各有讲究。动态扫描的原理一句话就能说清楚利用人眼的视觉暂留轮流点亮各个数码管。每次只点亮一位让对应的位选信号选通同时送出这一位要显示的段码。把四位都轮流扫一遍时间足够短人眼看到的就是四位数同时亮的稳定画面。这里足够短是关键。一般来说整个扫描周期要控制在 16ms 以内刷新频率 60Hz 以上否则会看到明显的闪烁。四位循环每位点亮时间就是 4ms 左右扫描频率 250Hz完全满足无闪烁要求。复位信号处理上教程里普遍用低有效复位rst_n这个约定在 FPGA 工程里非常常见涉及上电初始化时也更容易处理。下面代码沿用这个约定。5.2 RTL 设计分频、扫描和段码译码这里给出一段可直接用 iverilog 仿真的数码管动态扫描代码。先说明一点为了仿真演示直观我用分频产生的扫描时钟作为切换信号。在真实项目中更推荐使用全局时钟加时钟使能的做法避免产生额外的时钟域后面单独展开讲。timescale 1ns/1ps module seg_scan ( input wire clk, // 系统时钟50MHz input wire rst_n, // 复位低有效 input wire [15:0] data, // 四位 BCD 数据每 4bit 一位 output reg [3:0] seg_sel, // 位选高有效 output reg [7:0] seg_data // 段码输出共阴极 ); // 段码译码函数 function [7:0] seg_decode; input [3:0] bcd; begin case (bcd) 4h0: seg_decode 8h3F; 4h1: seg_decode 8h06; 4h2: seg_decode 8h5B; 4h3: seg_decode 8h4F; 4h4: seg_decode 8h66; 4h5: seg_decode 8h6D; 4h6: seg_decode 8h7D; 4h7: seg_decode 8h07; 4h8: seg_decode 8h7F; 4h9: seg_decode 8h6F; default: seg_decode 8h00; endcase end endfunction // 分频计数50MHz 计到 49999 产生约 1kHz 脉冲 reg [15:0] cnt; reg scan_clk; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 16d0; scan_clk 1b0; end else if (cnt 16d49_999) begin cnt 16d0; scan_clk ~scan_clk; end else begin cnt cnt 1b1; end end // 扫描位置0/1/2/3 循环 reg [1:0] pos; always (posedge scan_clk or negedge rst_n) begin if (!rst_n) pos 2d0; else pos pos 2d1; end // 组合逻辑选择当前位的位选和段码 always (*) begin case (pos) 2d0: begin seg_sel 4b0001; seg_data seg_decode(data[3:0]); end 2d1: begin seg_sel 4b0010; seg_data seg_decode(data[7:4]); end 2d2: begin seg_sel 4b0100; seg_data seg_decode(data[11:8]); end 2d3: begin seg_sel 4b1000; seg_data seg_decode(data[15:12]); end default: begin seg_sel 4b0000; seg_data 8h00; end endcase end endmodule这里用 function 来写段码译码好处是整个组合逻辑集中在一个函数里case 分支清晰。注意函数定义放在 module 内部、使用位置之前这是告诫自己避免某些仿真器对前向引用敏感的做法。代码里有两个小细节值得一提。一是计数比较值写成16d49_999下划线只是方便阅读Verilog 语法是支持的二是scan_clk在真实的综合工程里不建议作为 always 块的时钟信号因为内部产生的时钟容易在布局布线后形成时钟偏斜也影响时序约束。更稳的写法是保持全局clk做时钟用计数到达目标值时产生一个使能信号在使能有效的那一拍更新 pos。仿真环境下影响不大上板前要重构。5.3 testbench 的设计关键点写 testbench 不是一拍脑袋就能写好最关键的是把时间尺度、波形 dump、初始化顺序整清楚。下面是不带自检查、适合看波形的版本timescale 1ns/1ps module tb_seg_scan; reg clk 0; reg rst_n 0; reg [15:0] data 16h1234; wire [3:0] seg_sel; wire [7:0] seg_data; seg_scan u_dut ( .clk (clk), .rst_n (rst_n), .data (data), .seg_sel (seg_sel), .seg_data (seg_data) ); // 50MHz 时钟周期 20ns always #10 clk ~clk; initial begin $dumpfile(seg_scan.vcd); $dumpvars(0, tb_seg_scan); #20 rst_n 1; // 释放复位 #200_000; // 仿真 200us覆盖几十个扫描周期 $finish; end endmodule注意#200_000这个仿真时间。如果时间太短比如只有几微秒可能看不到完整的四位数扫描循环如果太长VCD 文件会膨胀得很厉害。怎么看够不够可以在仿真结束时打开波形确认 seg_sel 每一位都被选通到了。如果想要自动验证只要增加一个检查逻辑当seg_sel 4b0001时seg_data应该等于 data 第 0 位 BCD 数 4 对应的段码 0x66。用 $display 自动比对产生详尽报告。这一段代码可以放在 initial 里用轮询的方式写也可以放到 always 块里做边沿检测初学者先不用搞太复杂。5.4 仿真结果怎么读在 VS Code 里打开 Surfer 插件加载seg_scan.vcd你会看到这样的现象scan_clk是分频后的方波周期约为 1ms如果按真实 50MHz 计时pos在 0/1/2/3 之间循环seg_sel相应地在 4b0001、4b0010、4b0100、4b1000 之间跳变。同时seg_data会对应在data[3:0]到data[15:12]的段码之间切换。由于我们data固定为 0x1234最直观的读法是确认第 0 位个位的段码是 0x66数字 4第 1 位是 0x5B数字 3以此类推。如果你修改 data 的值波形会立即体现出来这个例子很适合拿来做仿真入门练习。很多初学者看仿真波形会觉得信号太多不知道看哪些。这里有一个通用的思路先看驱动时钟和复位再看控制逻辑信号这里是 pos最后看输出信号。控制逻辑的时序对了输出不对就是组合逻辑或译码函数的问题控制逻辑本身就乱了那就要回到分频计数那里查起。按这个顺序看波形定位问题的速度会快很多。6. 常见坑与排查链路从编译不过到波形诡异6.1 编译报错的高频原因先列几个我用 iverilog 编译 Verilog 时最常见的报错场景。一是文件顺序导致顶层不对。症状是仿真的行为跟预期完全对不上可能什么波形都没有。解决方式就是前面说的加-s指定顶层模块别依赖文件顺序。二是 SystemVerilog 语法用不了。比如写了logic、always_ff、interface 等关键字报错说语法不认识。解决办法是编译参数加-g2012。如果还是报错说明该特性 iverilog 支持得不好需要改写为传统 Verilog 写法。三是指针或位选越界。Verilog 里访问一个[7:0]信号的 bit 10编译阶段很多情况不报错但仿真行为会变得非常难查。这类问题建议多用位宽匹配的中间变量、多查看编译警告。四是模块端口连接时名字写错。比如顶层模块端口叫rst_n例化时写成了.rstn(rst_n)iverilog 可能会把它当成一个不存在的端口报错或者在某些宽松模式下直接忽略并继续。养成例化后对照顶层声明检查一遍的习惯能省不少时间。6.2 波形全 X 或没有波形的排查链路这是仿真里最让人抓狂的问题编译通过了vvp也跑了VCD 文件也生成了但打开波形一看全是 X或者压根没有波形输出。我总结了一条排查链路按顺序走第一步确认复位时序。复位信号在一开始必须处于有效状态然后在一定时间后释放。如果 testbench 里根本没有给复位信号赋初值它会一直处于 X 状态所有寄存器也永远停在 X。上面例子里的写法reg rst_n 0;就是保证初值。第二步确认时钟确实在翻转。在 testbench 里always #10 clk ~clk;之前必须先给 clk 赋初值 0或者声明时初始化。否则第一次赋值是 X 取反还是 X时钟永远不会产生。第三步确认$finish之前给了足够的仿真时间。有些模块需要上电后的多个周期才完成初始化如果仿真时间不够就退出波形上只有启动瞬间的一小段看不出任何行为。第四步检查$dumpvars的层次参数。如果你写的是$dumpvars(1, tb_top)那只有这一层和它的子层信号会被记录testbench 自身的局部变量不会记录。想无脑看全部信号用$dumpvars(0, tb_top)。第五步如果是大型工程确认文件编译顺序和模块例化之间没有遗漏。用-Wall编译时如果出现 warning: module ... is defined but never used 之类的提示说明顶层选错了或者某些模块没有被真正例化。6.3 把时钟使能写法当作进阶目标前面提过用分频时钟做触发很容易让初学者理解但不适合真实工程。更接近工程实践的写法是用时钟使能。简单说就是全局只有一个时钟到某个计数周期到达时产生一个高电平脉冲模块只在脉冲有效时更新状态。reg [3:0] pos; reg tick; // 计数到 49999 时 tick 拉高一拍 always (posedge clk or negedge rst_n) begin if (!rst_n) tick 1b0; else if (cnt 16d49_999) tick 1b1; else tick 1b0; end // 只在 tick 有效时切换 always (posedge clk or negedge rst_n) begin if (!rst_n) pos 2d0; else if (tick) pos pos 2d1; end这样整个设计只有一个时钟域综合和时序约束都不会出问题。在 iverilog 里仿真结果和分频时钟版本也是一样的但代码离可综合的工程更近了一步。我的建议是学习和实验阶段随便写理解了原理之后尽快过渡到时钟使能的写法这样在 FPGA 上遇到的诡异问题会少很多。6.4 从仿真通过到上板正常还差几步最后聊一个容易被新人忽略的问题仿真完全正确但烧到 FPGA 板上之后灯不亮、显示不对、状态机乱跳。这种情况多半不是逻辑功能错了而是约束没写好。最常见的是引脚约束。Verilog 里output reg led只是个抽象端口它默认不会绑定到 FPGA 芯片的物理引脚。厂商工具需要一份引脚约束文件Vivado 是 XDCQuartus 是 QSF告诉工具led 这个端口接到芯片的第几号引脚。其次是时钟约束。如果你扳子上的外部时钟是 50MHz但约束里写成 100MHz布局布线工具得出的时序分析结果就没有意义。时序问题往往在高速设计中才会暴露但约束本身从一开始就应该写对。再者仿真里的 reset 和板子上的真实复位来源不一样。有的板子复位按键是高有效有的是低有效有的板子上电后需要几十毫秒时钟才稳定。功能仿真阶段testbench 里任意控制复位时序很方便上板时就要根据实际硬件改复位逻辑。我的习惯是每个工程在动手写 RTL 之前先确定目标板卡的时钟频率、复位极性、核心引脚分配然后在工程文档里写清楚。等要上板时这些信息可以直接变成约束文件不用临时翻手册。这套 VS Code 加 iverilog 的轻量开发流程我自己用了很长时间最大的感受是工具链变轻之后反复实验的意愿会明显增加。改一个模块参数、换一种状态机写法、调整一下组合逻辑跑一次仿真也就是两三秒的事这种低成本试错对学习算法和熟悉 Verilog 风格都非常有帮助。你可以先跑通这篇文章里的数码管例子然后试着把它改成温控风扇的占空比控制模块或者加一个串口发送模块做收发回环都是很好的下一步练习。