与时序逻辑)
Verilog 的赋值方式是很多初学者刚接触 FPGA 时最容易绕晕的地方。同样是“把一个值给到另一个信号”为什么有的地方写assign有的地方必须放在always里面为什么组合逻辑和时序逻辑的写法不一样为什么老师总是强调always (posedge clk)要用而组合逻辑里要用这些不是语法喜好而是直接影响仿真结果、综合电路和最终板上行为的关键区别。这篇博客就把连续赋值assign、过程块always (*)组合逻辑、以及always (posedge clk)时序逻辑这三类写法一次性讲清楚。文章会从原理区别开始然后用一个可以完整跑仿真的小型数字电路工程把三种写法的代码、仿真波形和综合注意事项都过一遍。如果你正在学 Verilog、准备校招笔试或者刚用 Vivado / Quartus 做第一个实验这篇文章可以先收藏。读完你会清楚什么时候用 assign什么时候用 always (*)什么时候必须用 always (posedge clk)以及为什么阻塞赋值和非阻塞赋值不能随便混用。1. 核心能力速览先给一张表把三类写法的核心区别放在一起。后面所有代码和验证都围绕这张表展开。写法用途左侧信号类型赋值方式敏感列表综合结果assign连续赋值数据选择、译码、算术运算等纯组合逻辑wire连续驱动值一变就立即更新无自动由右侧信号触发组合逻辑门电路always (*)过程块需要 if/else、case 等描述的组合逻辑reg阻塞赋值(*)自动包含所有输入信号组合逻辑门电路敏感列表不完整或分支不全会产生锁存器always (posedge clk)时序逻辑计数器、移位寄存器、状态机等reg非阻塞赋值时钟上升沿触发触发器Flip-Flop构成的时序电路再补充几个关键结论先记在心里assign只能驱动 wire 类型always过程块内赋值的信号必须是 reg 类型。并不是说 always 里赋值就一定是时序逻辑。always (*)描述的是组合逻辑always (posedge clk)描述的才是时序逻辑。组合逻辑用阻塞赋值时序逻辑用非阻塞赋值。这个约定用错了仿真可能 “碰巧对”但综合结果和真实硬件上很可能出问题。敏感列表不完整是 Verilog 新手写组合逻辑最容易踩的坑。用(*)可以很大程度避免这个问题。时序逻辑的敏感列表也可以是(negedge clk)或(posedge clk or negedge rst_n)但最常用的还是上升沿触发。2. 适用场景与使用边界2.1 什么时候用 assignassign适合描述“输入到输出直接映射”的组合逻辑。典型场景包括多路选择器根据选择信号从多个输入中选一个。译码器 / 编码器把一组信号转换为另一种编码。简单的算术运算加法、减法、位拼接、比较。组合逻辑输出到模块端口模块的输出端口声明为 wire 时直接assign out ...。assign的优点是写法直观、没有敏感列表的负担、综合结果确定。它的缺点是表达复杂分支逻辑时能力较弱例如“当 a 等于 1 且 b 不等于 2 时输出 3否则如果 c 是 4 输出 5”这种逻辑虽然可以用三元运算符嵌套但可读性很差。这时候就该用always (*)。2.2 什么时候用 always (*)always (*)适合描述带有条件分支的组合逻辑多级 if / else 嵌套。多分支 case 语句。根据状态信号计算输出。组合逻辑中对多个输入信号进行较复杂的组合判断。这里有一个重要边界组合逻辑的always块必须保证所有分支都对输出赋值。如果存在某个路径没有给输出赋值工具会推断出锁存器Latch。锁存器在 FPGA 里一般不是我们想要的东西它会导致时序分析困难甚至出现毛刺。2.3 什么时候用 always (posedge clk)时序逻辑用always (posedge clk)描述核心特征是输出在时钟沿更新与时钟同步。典型场景包括计数器。分频器。移位寄存器。状态机状态寄存器。同步复位 / 异步复位的触发器。时序逻辑里最重要的一条准则是使用非阻塞赋值。原因在后面的代码演示里会详细解释。2.4 使用边界与合规提醒本文所有代码都是标准数字逻辑教学内容不涉及版权或隐私问题。实际项目中使用开源 IP、参考代码时需要注意开源协议和版权声明涉及 FPGA 开发板、厂商标识等图片素材时也应遵守相关版权规定。开发和测试建议在本地仿真环境完成避免在未经验证的硬件上直接跑高风险测试。3. 开发环境准备与仿真工具3.1 工具选择跑通本文代码不一定需要昂贵的商业工具。这里给出两套方案方案一免费开源仿真链Icarus Verilogiverilog用于 Verilog 编译和仿真。GTKWave用于查看仿真波形。Visual Studio Code Verilog 插件用于写代码和语法高亮。这套方案适合学习语法、验证逻辑功能零成本跨平台。方案二商业 FPGA 完整链路Xilinx Vivado 或 AMD Vivado 的免费版 WebPACK。Intel Quartus Prime Lite。ModelSim / QuestaSim。商业工具的优势是一套环境完成仿真、综合、实现和下载适合做板级验证。但学习阶段用 iverilog 跑仿真更快建议先学会仿真再进综合工具。3.2 安装与验证命令Ubuntu / Debian 系统安装 iverilog 和 GTKWavesudo apt update sudo apt install iverilog gtkwaveWindows 可以从 iverilog 官网下载安装包安装后把安装目录下的bin文件夹加入系统 PATH。GTKWave 同样有 Windows 安装包。验证安装是否成功iverilog -V gtkwave --version如果能输出版本号环境就准备好了。注意iverilog 版本不同支持的 SystemVerilog 语法有差异本文代码使用标准 Verilog-2001 语法兼容性最好。4. 工程示例一个组合逻辑 时序逻辑的小型电路下面用一个完整的数字电路例子把三类赋值方式全部串起来。设计一个 4 位计数器计数结果经过一个组合逻辑模块再输出到 LED 或仿真波形中。工程文件结构如下fpga_assign_demo/ ├── rtl/ │ ├── mux_assign.v │ ├── mux_always.v │ └── counter.v ├── sim/ │ └── tb_top.v └── Makefile4.1 连续赋值 assign多路选择器先看最简单的连续赋值。用assign实现一个 4 选 1 多路选择器。// rtl/mux_assign.v // 4选1多路选择器使用连续赋值 assign 实现 module mux_assign( input wire [1:0] sel, input wire [3:0] a, input wire [3:0] b, input wire [3:0] c, input wire [3:0] d, output wire [3:0] y ); assign y (sel 2b00) ? a : (sel 2b01) ? b : (sel 2b10) ? c : d; endmodule这个模块的输出y是 wire 类型左侧用assign驱动。只要sel、a、b、c、d任何一个发生变化y就会被重新计算。综合工具会把这段代码映射为两级组合逻辑先比较 sel再通过多路选择器输出。连续赋值在仿真中的特点是右侧表达式任意输入变化左边立刻更新。这个“立刻更新”是在仿真时间步内完成的没有时钟参与。4.2 过程块 always (*) 组合逻辑同一个选择器接下来用always (*)实现相同功能。注意输出信号类型必须声明为 reg因为过程块赋值的信号在语法上要求是变量类型。// rtl/mux_always.v // 4选1多路选择器使用过程块 always (*) 实现 module mux_always( input wire [1:0] sel, input wire [3:0] a, input wire [3:0] b, input wire [3:0] c, input wire [3:0] d, output reg [3:0] y ); always (*) begin case (sel) 2b00: y a; 2b01: y b; 2b10: y c; 2b11: y d; default: y 4b0000; endcase end endmodule这段代码的敏感列表写的是(*)意思是“所有出现在 always 块中的输入信号都会被自动加入敏感列表”。这里sel、a、b、c、d都会被自动追踪。case语句的可读性明显比连续赋值更好尤其在分支多的情况下。但要注意如果 case 分支不完整又没有 default输出就可能在某个分支下保持旧值被综合成锁存器。这里写default: y 4b0000;就是为了补全分支。4.3 时序逻辑 always (posedge clk)计数器现在看时序逻辑。设计一个带同步复位的 4 位计数器在时钟上升沿加一计数到 15 后回绕到 0。// rtl/counter.v // 4位计数器使用时序逻辑 always (posedge clk) module counter( input wire clk, input wire rst_n, output reg [3:0] count ); always (posedge clk) begin if (!rst_n) begin count 4b0000; end else begin count count 1b1; end end endmodule这里有几个典型的时序逻辑特征敏感列表是posedge clk只有时钟上升沿才会触发块内的赋值。只在rst_n为低时复位复位信号不是posedge rst_n所以这是同步复位不是异步复位。内部使用非阻塞赋值。把上面三个模块组合成一个顶层模块把计数器的输出接到多路选择器的数据输入上选择信号由外部输入。// rtl/top_demo.v module top_demo( input wire clk, input wire rst_n, input wire [1:0] sel, output wire [3:0] y ); wire [3:0] count; counter u_counter( .clk (clk), .rst_n (rst_n), .count (count) ); mux_assign u_mux( .sel (sel), .a (count), .b (4b0001), .c (4b0010), .d (4b0011), .y (y) ); endmodule当然实际工程中不需要同时写两个内容相同的多路选择器。这里特意把mux_assign和mux_always分开是为了在仿真时直接对比两种组合逻辑写法的行为。5. 功能测试与效果验证5.1 编写 Testbench写一个 testbench用 iverilog 跑仿真验证以下内容计数器在复位释放后能否从 0 开始递增。多路选择器在 sel 变化后能否正确切换输出。组合逻辑的 assign 输出和 always 输出是否行为一致。// sim/tb_top.v timescale 1ns/1ps module tb_top; reg clk; reg rst_n; reg [1:0] sel; wire [3:0] y_assign; wire [3:0] y_always; // 生成100MHz时钟周期10ns initial begin clk 0; forever #5 clk ~clk; end // 初始化输入信号 initial begin rst_n 0; sel 2b00; #20; rst_n 1; #20; sel 2b00; #100; sel 2b01; #100; sel 2b10; #100; sel 2b11; #100; $finish; end // 例化被测设计 top_demo u_top( .clk (clk), .rst_n (rst_n), .sel (sel), .y (y_assign) ); mux_always u_mux_always( .sel (sel), .a (u_top.u_counter.count), .b (4b0001), .c (4b0010), .d (4b0011), .y (y_always) ); // 记录仿真数据 initial begin $dumpfile(tb_top.vcd); $dumpvars(0, tb_top); end // 监控输出变化 initial begin $monitor(time%0t clk%b rst_n%b sel%b count%d y_assign%d y_always%d, $time, clk, rst_n, sel, u_top.u_counter.count, y_assign, y_always); end endmodule这里注意y_assign数据来自top_demo中例化的mux_assign而u_mux_always直接用mux_always模块例化数据源与top_demo内部计数器的count相同。所以两个输出理论上应该完全一致可以用来对比。5.2 编译与运行仿真在工程根目录执行编译iverilog -o sim/sim.vvp \ rtl/mux_assign.v \ rtl/mux_always.v \ rtl/counter.v \ rtl/top_demo.v \ sim/tb_top.v生成可执行仿真文件后运行cd sim vvp sim.vvp正常运行会输出类似下面的文本time0 clk0 rst_n0 sel00 count0 y_assign0 y_always0 time20 clk1 rst_n1 sel00 count1 y_assign1 y_always1 time25 clk0 rst_n1 sel00 count1 y_assign1 y_always1 time30 clk1 rst_n1 sel00 count2 y_assign2 y_always2 ...看到$monitor输出且没有编译错误就说明基础功能仿真通过。接下来用 GTKWave 查看波形gtkwave tb_top.vcd选择需要观察的信号clk、rst_n、sel、count、y_assign、y_always。5.3 预期结果与判断标准判断仿真是否成功的标准如下检查项预期结果复位期间 count保持 0复位释放后 count每个时钟上升沿加 1sel 00 时 y_assign / y_always等于 counter 的当前计数值sel 01 时输出固定为 1sel 10 时输出固定为 2sel 11 时输出固定为 3y_assign 与 y_always每个时刻完全相同如果y_assign与y_always不一致优先检查两个多路选择器的输入信号是否接相同的数据源再检查case分支是否完整。5.4 失败排查方向如果仿真一开始就报错常见原因如下文件名与模块名不一致Verilog 要求模块名和文件名没有硬性对应关系但 iverilog 是按文件路径读取的需要确保编译命令里写对了路径。timescale缺失或写错testbench 里必须写timescale 1ns/1ps才能正确控制#5延迟。信号类型不匹配例如把 reg 类型输出接到了 wire 上或者反过来。检查输出端口声明。敏感列表漏信号如果always (a or b)漏掉了c仿真结果会对c的变化不敏感综合结果也可能不一致。用always (*)可以避免。6. 综合实现与硬件行为注意仿真通过不代表综合结果一定正确。这里重点讲几个从代码到电路的关键问题。6.1 组合逻辑里的锁存器推断回到mux_always模块。如果把default: y 4b0000;删掉综合工具会怎么做答案是对sel 2b11这个分支如果没有 default仿真时 y 会因为敏感列表里没有变化而不变综合工具就会推断一个锁存器来保存旧值。这在组合逻辑中一般不是设计本意。所以写组合逻辑 case 时两种保险写法任选其一写法一给每个分支赋值。写法二在 case 前先给输出赋默认值。always (*) begin y 4b0000; case (sel) 2b00: y a; 2b01: y b; 2b10: y c; 2b11: y d; endcase end第二种写法更简洁而且不会产生锁存器。它的原理是如果某个分支没有额外赋值就保持默认值但如果分支里在“某个 if 条件不满足”时没有赋值仍然可能产生锁存器。因此if / else 嵌套也要注意补全 else。6.2 阻塞赋值与非阻塞赋值的区别代码里always (*)组合逻辑用的是阻塞赋值always (posedge clk)时序逻辑用的是非阻塞赋值。为什么不能互换阻塞赋值是“执行完立即更新”后面的语句会立刻看到新值。非阻塞赋值是“在当前时间步结束时统一更新”块内后面的语句看到的还是旧值。看一个典型例子交换两个信号的错误写法// 错误组合逻辑里用非阻塞赋值 always (*) begin a b; b a; end非阻塞赋值在块结束时统一生效真实硬件中每个信号只有一个触发器无法同时完成双向交换这种写法综合后行为不可预测仿真也达不到交换效果。再来一个时序逻辑中常见错误// 错误时序逻辑里用阻塞赋值 always (posedge clk) begin count count 1b1; end这段代码综合后其实也能工作因为只有一个赋值语句阻塞与非阻塞差别不大。但一旦有两个以上语句比如先count count 1再用count计算另一个信号就会引入额外的组合逻辑延迟甚至导致仿真前后不一致。所以规范是时序逻辑的 always 块内统一使用组合逻辑的 always 块内统一使用。6.3 异步复位与同步复位上面计数器用的是同步复位即rst_n低电平只在时钟上升沿才能把计数器清零。也可以改成异步复位always (posedge clk or negedge rst_n) begin if (!rst_n) begin count 4b0000; end else begin count count 1b1; end end敏感列表里多了negedge rst_n表示rst_n下降沿也能触发 always 块。两种复位方式各有适用场景。同步复位利于时序分析异步复位不依赖时钟适合上电瞬间复位。实际工程中根据开发板原理图选择但必须保持一致不要混用。6.4 时钟与复位设计建议时序逻辑的质量取决于时钟的质量。实际 FPGA 工程中时钟应来自板载晶振经过 PLL/MMCM 生成的时钟网络不要直接使用逻辑输出作为时钟。复位信号应经过同步处理避免异步复位释放时产生亚稳态。尽量避免在 always 块中同时使用posedge clk和posedge rst_n之外的其他边沿触发信号。需要多时钟域时谨慎设计跨时钟域处理不要直接把快时钟域信号送给慢时钟域逻辑。这些要点在仿真中很难看出来但在综合后的时序报告中会暴露。等到编译综合阶段如果时序约束不过再回到代码检查时钟和复位设计。7. 接口与批量任务仿真驱动的工程化这个主题不涉及 API 服务但可以顺便讲一下如何用脚本把仿真批量跑起来这在实际项目里很有用。7.1 用 Makefile 管理编译仿真工程文件一多手动敲iverilog命令容易漏文件。用 Makefile 管理编译仿真。# Makefile IVERILOG iverilog VVP vvp GTKWAVE gtkwave RTL_SRC \ rtl/mux_assign.v \ rtl/mux_always.v \ rtl/counter.v \ rtl/top_demo.v SIM_SRC sim/tb_top.v VVP_OUT sim/sim.vvp VCD_OUT sim/tb_top.vcd all: compile run compile: $(IVERILOG) -o $(VVP_OUT) $(RTL_SRC) $(SIM_SRC) run: cd sim $(VVP) sim.vvp wave: $(GTKWAVE) $(VCD_OUT) clean: rm -f $(VVP_OUT) $(VCD_OUT) .PHONY: all compile run wave clean在工程根目录执行make就会完成编译和仿真。查看波形make wave7.2 批量仿真不同参数如果需要测试不同位宽、不同初始值的设计可以写一个简单的 shell 脚本修改参数后编译仿真。#!/bin/bash # batch_sim.sh 批量仿真示例 for WIDTH in 4 8 16; do echo Simulate with WIDTH$WIDTH iverilog -D WIDTH$WIDTH -o sim/sim_${WIDTH}.vvp rtl/counter_param.v sim/tb_counter_param.v cd sim vvp sim_${WIDTH}.vvp ; cd .. done这种脚本方式适合在代码修改后跑回归测试。实际工程中还可以把输出 VCD 文件名按参数区分避免互相覆盖。7.3 芯片验证中的“测试用例”思路在数字电路验证中仿真 testbench 相当于软件工程里的单元测试。好的 testbench 应该覆盖正常输入路径例如计数器从 0 计数到 15。边界条件例如选择信号为最大值、复位在计数中间发生。异常输入例如复位信号持续时间非常短观察是否能可靠复位。时序检查例如信号变化是否只在时钟沿附近变化。这个工程目前只用了一个最简 testbench。要做得更严谨可以在 testbench 中增加initial块里的断言比较y_assign和y_always是否一致不一致就报错。always (y_assign or y_always) begin if (y_assign ! y_always) begin $display(ERROR: mismatch at time%0t y_assign%d y_always%d, $time, y_assign, y_always); end end把断言加入 testbench 后仿真只要出现不匹配就会在终端打印错误信息。这是向“自动化验证”迈出的第一步。8. 常见问题与排查方法问题现象可能原因排查方式解决方案编译报错syntax error模块声明方式写错、少了括号或分号检查报错行附近的完整语句用支持 Verilog 语法的编辑器或插件高亮检查assign左侧报错左侧信号声明为 reg检查输出信号声明改成 wire 类型或改用 always 块赋值always 块内输出报错输出声明为 wire过程块不能赋值 wire检查端口声明改成 reg 类型注意组合逻辑和时序逻辑都是 reg仿真波形没有变化敏感列表漏信号检查 always 敏感列表用(*)自动包含所有输入仿真结果与预期不一致阻塞赋值与非阻塞赋值混用检查 always 块内赋值符号组合逻辑用时序逻辑用case 分支不完整产生锁存器case 没有 default综合报告里搜索 Latch补全 default 或在 case 前赋默认值计数器不复位复位信号没有同步到时钟域检查 rst_n 波形根据设计选择同步或异步复位并确认复位极性时序逻辑输出频繁变化敏感列表写了(sel or clk)或组合逻辑错误查看综合网表和波形时序逻辑敏感列表只保留时钟和异步复位iverilog 不识别(*)版本过旧运行iverilog -V查看版本升级到新版或改用(a or b or sel)显式敏感列表综合后出现多余锁存器if 没有 else审查 RTL 源码中 if/else 结构补全 else或提前赋默认值9. 最佳实践与代码规范9.1 命名与模块划分信号命名要一眼能看出功能。时钟信号统一前缀clk或clk_复位信号统一rst_n表示低有效计数信号用count、cnt等。模块名、文件名保持一致端口顺序统一先输入后输出这样例化时可读性更高。9.2 三类赋值方式的使用规范把前面的结论落地成几条可直接执行的规则纯组合逻辑输出优先用assign。逻辑简单、没有分支时最清晰。组合逻辑包含 if/else、case用always (*)。块内使用阻塞赋值所有分支必须覆盖完全避免锁存器。时序逻辑只用always (posedge clk)或带异步复位的写法块内使用非阻塞赋值。同一信号只能由一种赋值方式驱动。不要在多个 always 块里对同一个 reg 赋值也不要同时用 assign 和 always 驱动同一 wire/reg。组合逻辑和时序逻辑尽量分模块。例如功能模块内部计数输出计数结果再由顶层的组合逻辑模块进行译码或选择。9.3 仿真与综合的差异仿真通过只代表功能正确不代表时序收敛。综合之后还需要关注LUT 与 FF 资源占用。关键路径延迟是否满足时钟约束。是否意外产生了锁存器。扇出过大导致布线拥塞。在做完综合实现后打开资源报告和时序报告确认没有几个关键警告。9.4 版本管理RTL 代码建议纳入 Git 管理。iv 文件、vcd 波形文件属于生成物应加入.gitignore。每次修改代码后运行make做回归仿真避免引入新的行为变化。# .gitignore *.vvp *.vcd *.log *.wdb10. 总结与下一步这篇文章从一个最简单的 4 位计数器加多路选择器的小工程出发把 Verilog 里最容易混淆的三类赋值方式全部梳理了一遍连续赋值assign适合直接组合逻辑过程块always (*)适合复杂分支组合逻辑always (posedge clk)是时序逻辑的标准写法。三者分别对应 wire 和 reg、阻塞赋值和非阻塞赋值、组合逻辑和时序逻辑的区别这些概念如果只看理论很容易绕所以建议直接下载 iverilog 和 GTKWave把文中的 testbench 跑一遍亲眼看一看波形中count在每个时钟沿的跳变再看y_assign和y_always如何跟随 sel 变化。最容易踩的坑有三类一是把组合逻辑写成锁存器二是阻塞赋值和非阻塞赋值乱用三是敏感列表不完整导致仿真与综合不一致。如果第一次综合时报出了 latch 警告别着急回到代码检查默认赋值和 case 分支即可。下一步可以做的事把计数器位宽扩展到 8 位或 16 位增加使能信号、进位输出用状态机代替简单的 if/else 实现一个按键消抖或串口发送模块。你会发现所有状态机、计数器、接口时序控制本质上都在这三类赋值方式的框架内打转。基础打牢了复杂设计就只是规模的堆叠。建议把这篇文章里仿真工程的目录结构和代码规范保留下来以后每个新模块都按这个框架建立 RTL、SIM、Makefile 和断言检查。小工程规范化了大工程才不会乱。