Verilog三种赋值方式:assign、always @(*)与always @(posedge clk)的硬件本质

发布时间:2026/9/7 8:31:08
Verilog三种赋值方式:assign、always @(*)与always @(posedge clk)的硬件本质 在 FPGA 开发群里每隔几天就会出现一个似曾相识的问题同一段逻辑为什么有人用assign写有人用always写还有人非要在always后面加一个(posedge clk)更让新手困惑的是这三种写法有时候仿真波形看起来一模一样有时候又完全不能互相替换。甚至有人把三者混着用最后仿真结果正确上板却一团糟。这篇文章想把这件事一次讲透。我的判断是assign、always (*)、always (posedge clk)不只是“三种赋值写法”而是 Verilog 三种建模方式——数据流建模、行为级组合逻辑建模、时序逻辑建模——的入口。一个人能不能写好 Verilog很大程度上就看他是否真的理解这三个入口背后的硬件结构。读完本文你会掌握以下内容三种赋值方式分别会被综合成什么硬件结构什么场景必须用assign什么场景必须用always组合逻辑为什么推荐使用阻塞赋值时序逻辑为什么必须使用非阻塞赋值always (*)为什么比老式always (a or b)更安全如何用开源工具 iverilog 或者 Vivado、Quartus 验证一套完整示例工程。1. 组合逻辑与时序逻辑的本质区别要理解三种赋值写法第一步不是背语法而是先理解硬件。Verilog 所有的赋值规则本质上都是为硬件结构服务的。你写的每一行代码综合工具都会把它映射成真实的门电路、触发器和连线。1.1 组合逻辑输出只由当前输入决定组合逻辑的特点是输出是当前输入的直接函数没有任何记忆能力。输入一变输出马上跟着变。例如一个与门assign y a b;只要a或b变化y就会立即被重新计算。组合逻辑对应的硬件结构是门电路网络没有存储单元也没有时钟概念。1.2 时序逻辑输出还取决于历史状态时序逻辑与组合逻辑最大的区别是它有“记忆”。时序逻辑的输出不仅取决于当前输入还取决于之前的状态。这个“状态”保存在触发器中而触发器靠时钟边沿来更新。所以时序逻辑必须在时钟边沿例如posedge clk触发下改变状态。没有时钟边沿数据就停在触发器里不动。1.3 为什么这个区别决定了赋值写法直接说结论连续赋值assign只能描述组合逻辑因为它的本质是一根带逻辑运算的“导线”always (*)描述的也是组合逻辑但它可以用if/case这种行为级语句适合描述复杂控制逻辑always (posedge clk)描述的是时序逻辑综合出来是寄存器/触发器它只能在时钟边沿更新。把组合逻辑写成(posedge clk)会把本来应该是纯门电路的结构变成寄存器时序上会慢一个周期把时序逻辑写成(*)则根本保存不了状态。理解这个对应关系是写 Verilog 的第一道分水岭。2. 连续赋值 assign数据流建模的核心2.1 assign 的语法与规则连续赋值语句的基本形式如下assign 左侧信号 右侧表达式;其中左侧必须是线网类型也就是wire不能是reg。原因是assign本质上是在描述“一根被驱动起来的线”它没有存储能力信号值始终由右侧表达式的当前值实时决定。这一条规则在实际工程中经常让新手报错。最常见的报错信息是Error: reg type is not valid in continuous assignment意思就是你把reg类型的信号放到了assign左侧。解决办法是把左侧声明成wire。2.2 assign 的典型场景assign最适合描述逻辑表达式清晰、结构简单的组合逻辑。例如一个全加器// 文件路径rtl/full_adder.v module full_adder ( input wire a, input wire b, input wire cin, output wire sum, output wire cout ); assign sum a ^ b ^ cin; assign cout (a b) | (a cin) | (b cin); endmodule多个assign语句之间的执行是并发的。也就是说上面两个assign没有先后顺序它们的硬件本质是两个独立门电路同时工作谁先谁后并不影响结果。再看一个稍微复杂一点的场景用assign对总线进行位拼接和选择。// 文件路径rtl/bus_ops.v module bus_ops ( input wire [7:0] data_in, output wire [7:0] data_out ); assign data_out {data_in[0], data_in[1], data_in[2], data_in[3], data_in[4], data_in[5], data_in[6], data_in[7]}; endmodule这是把 8 位总线倒序输出相当于一根经过交叉连接的线束。这类操作用连续赋值写最直观换成always反而显得啰嗦。2.3 assign 的局限assign不能直接使用if/else和case。如果逻辑分支比较复杂只能用三目运算符? :来“挤”进表达式里。例如一个二选一多路选择器// 文件路径rtl/mux2.v module mux2 ( input wire sel, input wire [3:0] a, input wire [3:0] b, output wire [3:0] y ); assign y sel ? a : b; endmodule三目运算符可以嵌套但嵌套一多代码可读性就会迅速下降。比如四选一如果强行用三目运算符写会变成这样assign y (sel 2b00) ? a : (sel 2b01) ? b : (sel 2b10) ? c : d;这种写法能工作但已经不太好维护了。当分支超过两三个时更推荐用always (*)配case语句来表达。2.4 assign 的注意事项使用assign时有几个细节值得注意。第一同一个wire只能被一个assign驱动。如果两个assign分别给同一个信号赋值会出现多重驱动问题仿真波形可能变成 X 或 Z这在真实项目中是灾难。第二assign右侧表达式的仿真延迟可以写成assign #5 y a b;但这种带延迟的写法主要用于仿真建模综合时延迟会被忽略。第三assign适合组合逻辑不要用它去描述需要存储的逻辑。如果确实需要寄存器就用always (posedge clk)。小结一下assign是数据流建模主力适合表达式简单、逻辑可读性好的组合逻辑。遇到分支多、控制流明显的逻辑切换到always (*)。3. 过程块 always (*)行为级组合逻辑3.1 为什么需要 always (*)assign虽然简单但它的表达能力有限。真实项目中组合逻辑经常伴随着优先级判断、多条件分支、译码表这些逻辑用if/case描述比用三目运算符嵌套清晰得多。always过程块允许在begin...end中使用if、else、case、for等语句。当敏感列表写成(*)时表示“块内所有被读取的信号发生变化时自动执行一次块内逻辑”。下面是一个 4 输入优先级编码器// 文件路径rtl/prio_encoder.v module prio_encoder ( input wire [3:0] req, output reg [1:0] code ); always (*) begin if (req[3]) code 2d3; else if (req[2]) code 2d2; else if (req[1]) code 2d1; else code 2d0; end endmodule这里用if...else表达优先级非常自然如果用assign写就得嵌套三目运算符可读性差很多。3.2 always (*) 左侧信号必须是 reg这里有一个经典误区。很多初学者以为always块里的信号声明成reg就意味着综合出一个寄存器。这是错误的。在 Verilog 中reg只是“过程块中赋值的变量”的类型标记它不是寄存器的代名词。always (*)描述的是组合逻辑综合出来是纯组合门电路不会产生触发器。真正的触发器来自always (posedge clk)。换句话说reg描述的是“赋值方式”不是“硬件存储能力”。这一点必须刻在脑子里。3.3 组合逻辑中的阻塞赋值在always (*)中组合逻辑一般使用阻塞赋值符号code 2d3;阻塞赋值的含义是语句按先后顺序执行上一条赋值完成之后下一条才开始。因为组合逻辑本身是“实时计算”的没有时钟节拍所以用阻塞赋值来描述是合适的。对比之下时序逻辑使用非阻塞赋值。为什么必须这样第 4 部分会重点解释。3.4 避免锁存器分支必须完整always (*)组合逻辑最需要警惕的是锁存器latch的推断。如果代码中存在某个输入组合下某个变量没有被赋值综合工具为了让电路保持原值就会推断出一个锁存器。锁存器是电平敏感的存储单元在 FPGA 设计中容易引起时序问题和毛刺应尽量避免。看一个反面案例// 反例缺少 else存在锁存器风险 module latch_bad ( input wire en, input wire [3:0] d, output reg [3:0] q ); always (*) begin if (en) q d; end endmodule当en为 0 时q没有被赋值于是综合工具会推断出锁存器来保存上一次的值。正确的写法是补上else分支// 正确补全 else输出始终有确定值 module latch_good ( input wire en, input wire [3:0] d, output reg [3:0] q ); always (*) begin if (en) q d; else q 4b0; end endmodule同样case语句也必须有default分支或者在case之前先给输出赋默认值。常见的稳妥写法是“先默认赋值再覆盖”always (*) begin dout 1b0; // 默认值 case (sel) 2b00: dout a; 2b01: dout b; 2b10: dout c; default: dout d; endcase end这种写法保证所有分支都被覆盖从根源上避免锁存器。3.5 always (*) 与老式敏感列表在早期代码里经常能看到always (a or b or sel)这种写法。它的问题在于如果逻辑块内新增了一个输入信号却忘了把它加进敏感列表仿真时信号变化触发不了块内逻辑波形就会和真实硬件不一致。(*)让工具自动识别块内所有被读取的信号从根本上消除了“敏感列表不完整”这一类问题。现在的工程中应该默认使用(*)除非有特殊需求不建议写老式列表。小结always (*)是行为级组合逻辑的标准写法适合复杂分支和译码逻辑务必保证分支完整并使用阻塞赋值。4. always (posedge clk)时序逻辑的标准写法4.1 基本结构时序逻辑中的触发器在 Verilog 中通常这样写always (posedge clk or negedge rst_n) begin if (!rst_n) q 1b0; else q d; end这里有两个边沿posedge clk是时钟上升沿negedge rst_n是异步复位的下降沿。这段代码综合出来是一个带异步低电平复位的 D 触发器。这个模板就是搜索热词中经常出现的posedge clk or negedge rstn模式。它是 FPGA 工程中最常见、也最推荐的时序逻辑写法。4.2 同步复位与异步复位时序逻辑的复位风格是工程里绕不开的选型题。异步复位复位信号不依赖时钟只要rst_n拉低寄存器立刻清零。always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 4b0; else cnt cnt 1b1; end同步复位复位信号只在时钟上升沿到来时才生效。always (posedge clk) begin if (!rst_n) cnt 4b0; else cnt cnt 1b1; end两者各有适用场景。异步复位在 FPGA 中由于有专用的复位资源使用很普遍但需要注意复位释放时的亚稳态问题同步复位时序上更干净但需要保证复位信号持续足够长的时间。工程上一个稳妥做法是用异步复位并在模块内部做“异步复位、同步释放”这样既保证复位立即生效又消除释放时的亚稳态。这个话题可以单独写一篇本文先记住有这两种风格即可。4.3 为什么时序逻辑必须用非阻塞赋值这是 Verilog 学习者必须理解的核心问题。非阻塞赋值用它的行为是在always块内部所有语句的右侧表达式在同一时刻采样然后在时钟边沿结束后统一更新左侧变量。举个例子一个两级移位寄存器always (posedge clk or negedge rst_n) begin if (!rst_n) begin reg1 1b0; reg2 1b0; end else begin reg1 d; reg2 reg1; end end由于使用了非阻塞赋值reg1和reg2在同一时钟边沿采样各自的右侧值更新时reg2拿到的是更新前的reg1。这样reg2就正确实现了“延迟一拍”的功能。如果这里改用阻塞赋值// 反例错误地在时序逻辑中使用阻塞赋值 always (posedge clk) begin reg1 d; reg2 reg1; // 拿到的是更新后的 reg1 end由于阻塞赋值是顺序执行的reg2会在同一个时钟边沿拿到reg1更新后的值等价于两级同时换数据完全失去了移位寄存器的意义。这种错误在仿真波形上极难排查因为波形看起来“好像有数据流动”但时序完全不对。所以行业里的铁律是组合逻辑用阻塞赋值时序逻辑用非阻塞赋值两者不要混用。4.4 时序逻辑典型场景计数器是最典型的时序逻辑// 文件路径rtl/counter.v module counter ( input wire clk, input wire rst_n, input wire en, output reg [3:0] cnt ); always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 4b0; else if (en) cnt cnt 1b1; end endmodule寄存器组、移位寄存器、状态机状态寄存器、FIFO 读写指针本质上都是同一个模板在时钟边沿到来时根据条件更新一组reg变量。小结always (posedge clk)是时序逻辑的标准写法必须使用非阻塞赋值注意复位风格统一。5. 三种写法的横向对比为了更直观把三者放在一张表里对比对比维度assignalways (*)always (posedge clk)建模层次数据流建模行为级组合逻辑行为级时序逻辑左侧信号类型wireregreg赋值符号连续赋值 阻塞赋值 非阻塞赋值 综合出的硬件纯组合门电路纯组合门电路触发器/寄存器是否依赖时钟否否是是否支持 if/case不支持支持支持适合场景加法器、译码器、总线运算多路选择器、优先级逻辑、状态译码计数器、寄存器、状态机常见误区左侧误用 reg分支不完整产生锁存器误用阻塞赋值这张表读懂了后面写代码时就不会再纠结“这个信号该用assign还是always”。先判断要实现的硬件是组合还是时序再决定用哪类写法。6. 完整工程示例计数器驱动的七段数码管译码下面用一个可综合、可仿真的完整模块把三种写法串起来。场景是一个 0~9 循环计数器每个时钟上升沿自动加一计数值通过组合逻辑译码成七段数码管段码当计数到 9 时产生一个tick脉冲。6.1 顶层模块设计文件路径rtl/counter_decoder.v// 文件路径rtl/counter_decoder.v module counter_decoder ( input wire clk, input wire rst_n, input wire en, output wire [6:0] seg, output wire tick ); // 1. 时序逻辑0 ~ 9 循环计数 reg [3:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 4d0; else if (en) begin if (cnt 4d9) cnt 4d0; else cnt cnt 1b1; end end // 2. 组合逻辑always (*) 译码 reg [6:0] seg_r; always (*) begin case (cnt) 4d0: seg_r 7b0111111; 4d1: seg_r 7b0000110; 4d2: seg_r 7b1011011; 4d3: seg_r 7b1001111; 4d4: seg_r 7b1100110; 4d5: seg_r 7b1101101; 4d6: seg_r 7b1111101; 4d7: seg_r 7b0000111; 4d8: seg_r 7b1111111; 4d9: seg_r 7b1101111; default: seg_r 7b0000000; endcase end // 3. 组合逻辑assign 输出 assign seg seg_r; assign tick (cnt 4d9) ? 1b1 : 1b0; endmodule这段代码里cnt是时序逻辑用always (posedge clk or negedge rst_n)和非阻塞赋值综合为 4 位寄存器seg_r是组合逻辑用always (*)和阻塞赋值综合为纯组合译码电路seg和tick用连续赋值assign本质是把内部信号连接到输出端口。从语法上看reg类型的cnt和seg_r都在always块里赋值但硬件上一个产生触发器、一个不产生触发器。这就是前面强调过的“reg 不等于寄存器”。6.2 为什么加 default 分支上面always (*)的case语句有default分支。虽然cnt理论上是 0~9但综合工具无法证明cnt永远不会出现 10~15 这几个状态。没有default时工具为了在未定义状态下保持输出有可能会推断出锁存器或者产生未定义行为。补上default是组合逻辑的安全习惯。7. 仿真验证与 Testbench7.1 Testbench 代码文件路径sim/tb_counter_decoder.v// 文件路径sim/tb_counter_decoder.v timescale 1ns/1ps module tb_counter_decoder; reg clk; reg rst_n; reg en; wire [6:0] seg; wire tick; counter_decoder dut ( .clk (clk), .rst_n(rst_n), .en (en), .seg (seg), .tick (tick) ); // 生成 50MHz 时钟 initial begin clk 1b0; forever #10 clk ~clk; end // 复位与使能控制 initial begin rst_n 1b0; en 1b0; #25; rst_n 1b1; en 1b1; #220; $finish; end // 打印关键信号 initial begin $monitor(time%0t cnt%0d seg%b tick%b, $time, dut.cnt, seg, tick); end endmodule7.2 使用 iverilog 运行仿真如果本机安装了开源仿真工具 Icarus Verilog可以用以下命令编译和运行iverilog -o sim.vvp rtl/counter_decoder.v sim/tb_counter_decoder.v vvp sim.vvp预期输出大致如下time25 cnt0 seg0111111 tick0 time45 cnt1 seg0000110 tick0 time65 cnt2 seg1011011 tick0 ... time205 cnt9 seg1101111 tick1 time225 cnt0 seg0111111 tick07.3 如何判断仿真正确判断标准有两条第一cnt从 0 递增到 9并在cnt等于 9 的下一个时钟上升沿回到 0 第二tick在cnt等于 9 的整个周期内为高对应的是组合逻辑实时输出。如果cnt一直为 X优先检查复位是否正常、时钟是否生成如果tick一直为 0检查比较条件与信号位宽是否匹配。在 Vivado 或 Quartus 中同样可以通过添加仿真源文件、运行行为仿真并查看波形窗口来完成验证原理一致。8. 常见问题与排查思路下面汇总初学者最常遇到的几类问题问题现象可能原因排查方式解决方案编译报错 reg 不能用于 assign 左侧assign 左侧声明成了 reg查看编译错误行号将左侧改为 wire输出一直为 Z 或 X信号没有被任何语句驱动检查是否有遗漏的驱动语句为信号补上驱动或默认值组合逻辑综合后出现锁存器if/case 分支不完整查看综合报告中的 latch 警告补全 else/default或先赋默认值时序逻辑功能完全错乱always (posedge clk) 中误用阻塞赋值检查块内赋值符号改成非阻塞赋值 仿真的组合逻辑不更新使用了老式敏感列表且漏信号检查 always 的敏感列表改为 (*)计数器不复位复位信号命名或极性不一致检查复位连线和时序模板统一使用低有效复位 rst_n多个 assign 驱动同一信号驱动源重复搜索该信号的赋值语句保证单一驱动其中“组合逻辑出现锁存器”和“时序逻辑误用阻塞赋值”是两类最隐蔽的问题。前者靠综合报告中的 latch 警告可以发现后者往往要等到上板验证才会暴露所以编码阶段就要养成正确习惯。9. 工程实践建议最后把实际工程中沉淀下来的几条建议列出来供大家在项目里直接采用。9.1 编码习惯assign左侧一律用wirealways块内赋值变量一律用reg组合逻辑统一使用always (*) 阻塞赋值时序逻辑统一使用always (posedge clk or negedge rst_n) 非阻塞赋值不要在同一个always块里混用时序和组合逻辑case语句永远写defaultif语句永远写else。9.2 命名规范低有效复位统一命名为rst_n高有效复位使用rst时序逻辑变量用带含义的名字组合逻辑中间变量要能看出是“临时计算值”跨时钟域信号标注同步级数避免多人协作时产生误解。9.3 设计思路先画硬件结构再写代码。先想清楚这块逻辑是组合还是时序需要几个触发器时钟频率是多少组合逻辑尽量简单复杂的组合路径会拉低时钟频率必要时插入寄存器做流水线复位策略全工程统一不要有的模块异步复位、有的模块同步复位每个模块保持单一职责一个always块做一个功能方便约束和调试。9.4 验证意识写完代码先查综合报告的 latch 警告仿真尽量覆盖边界条件比如计数器最大值、使能为 0、复位释放瞬间上板调试前在 SignalTap 或 ILA 中观察关键reg信号确认时序逻辑行为。10. 总结与下一步现在可以回到开头的问题了assign、always (*)、always (posedge clk)之间不是“随便用哪个都行”而是不同的硬件意图。assign描述的是数据流组合逻辑always (*)描述的是行为级组合逻辑always (posedge clk)描述的是时序逻辑。选哪一种取决于你想要的是门电路还是触发器。下一步建议做两个练习巩固。第一把本文的 counter_decoder 工程用 Vivado 或 Quartus 完整跑一遍综合查看 RTL 视图确认cnt变成触发器、seg_r变成组合逻辑。第二尝试自己写一个 4 位右移寄存器分别用非阻塞赋值和阻塞赋值仿真对比观察两种波形差异。这个练习能让你直观理解和在时序逻辑中的区别。还可以继续深入的方向包括阻塞与非阻塞赋值在仿真器中的事件调度机制、状态机的三段式写法、跨时钟域处理中的两级同步寄存器设计。这些话题都建立在今天这套基础上。收藏本文动手把示例跑通一遍比看十遍语法讲解都有用。如果在仿真中遇到波形现象和本文描述不一致欢迎在评论区列出你的代码和波形截图一起讨论。