初学 Verilog 时,大部分人学到的第一句可综合语句不是assign,就是某个always块。等到真正开始写一个模块,问题就来了:同样是把一个值给到输出,为什么有时用assign,有时又要放进always里?同样是always,为什么有的敏感列表写@(*),有的却写@(posedge clk)?写错之后,仿真还能过,综合却报出一堆 latch 警告,或者上板后波形完全不对。
这篇文章想把这件事一次讲清楚。核心判断先放在这里:assign、always @(*)、always @(posedge clk)分别对应 Verilog 中三种典型的硬件建模方式——数据流建模、过程组合逻辑建模、过程时序逻辑建模。它们的区别表面上是“赋值的位置不同”,本质上是你想把逻辑映射成什么样的物理电路:是纯组合逻辑的 wire,是带有优先级和分支的组合逻辑网络,还是带有寄存器和时钟边沿的时序电路。如果这三个概念没分清,后面写状态机、写总线接口、写跨时钟域逻辑都会踩坑。
读完这篇文章,你应该能判断一个信号该用哪种方式赋值,能看懂别人模块里三种写法的用意,也能在编译报错、仿真出现 latch 警告、波形乱跳时快速定位问题方向。下面先从底层概念讲起。
1. 为什么 Verilog 新手总在“赋值方式”上翻车
很多初学者是从软件语言转过来的。在 C 语言里,赋值就是“把右边的值算出来,放到左边的变量里”,运行顺序确定,变量可以反复赋值,一切很自然。到了 Verilog,同一个想法立刻会撞上两个硬件事实:第一,Verilog 描述的是电路,不是指令序列;第二,电路里的信号一旦生成,就一直在根据输入变化,没有“执行到这一行才赋值”的概念。
于是你会看到下面这种代码,语法完全合法,行为却让新手摸不着头脑:
module weird( input wire a, input wire b, output reg y ); always @(*) begin if (a) begin y = b; end end endmodule这段代码的问题不是组合逻辑写错,而是if (a)为假时没有给y赋值。软件思维会觉得“不写就保持原值,这很合理”,但综合工具会告诉你“latch inferred”,也就是综合出一个锁存器。硬件里,组合逻辑的输出不能“保持原值”,除非你显式给出else分支或者默认赋值。这种思维差异,是翻车的第一大来源。
第二个翻车点更隐蔽:在always @(posedge clk)里用了阻塞赋值=而不是非阻塞赋值<=。初学者在书上读到“时序逻辑要用非阻塞赋值”时,往往只是当作语法规则背下来,不理解为什么。直到仿真时发现两个寄存器之间有一拍错位,或者用阻塞赋值写了一个移位寄存器,仿真结果和实际电路对不上,才意识到这个概念的重要性。
第三个翻车点,是分不清assign和always @(*)是否“等效”。表面看,两者都能描述组合逻辑,很多简单场景确实可以互相替换。可一旦设计里有优先级分支、多输入 case,或者多个驱动源,两者的可读性和综合结果是不同的。还有一点新手常忽略:assign只能驱动wire,always块内赋值的目标必须是reg类型。这个语法限制让很多人一开始误以为reg就代表寄存器,但硬件上的reg在组合逻辑always块里综合出来其实只是一根线。
这三个翻车点归结起来,都源于同一个问题:没有把“赋值方式”和“目标电路结构”绑定起来看。本文后面的章节,就用这个视角来拆解三种写法。
2. 三种赋值的核心概念与底层区别
2.1 连续赋值 assign:数据流建模
assign称为连续赋值语句,它描述的是一个持续有效的逻辑关系。只要右边表达式的任何一个输入发生变化,左边的wire就会被重新驱动。它的物理含义就是一根用逻辑门连接起来的线网,没有存储能力,没有时钟概念,任何时刻都“直通”。
assign y = a & b;y必须是wire类型,a、b可以是wire或reg。从综合结果看,assign不会生成寄存器,只会生成 LUT 或逻辑门组合。它适合描述加法器、多路选择器、译码器这类纯粹由输入决定输出的逻辑,也适合在模块间做信号拼接和位选。
2.2 过程块 always:行为建模
always是过程赋值语句。它不是“一直执行”的意思,而是“满足条件时执行块内逻辑”。always块内的目标信号类型必须是reg,但这并不代表一定会综合成寄存器。到底综合成什么,取决于敏感列表写的是什么。
- 敏感列表为
@(*)或@(a or b)时,综合工具认为这是一个组合逻辑块。 - 敏感列表为
@(posedge clk)或@(negedge clk)时,综合工具认为这是一个时序逻辑块,通常会生成触发器。
always块的出现,是为了解决assign不适合描述带分支条件、带优先级、带状态保持的逻辑。举个例子,一个 4 输入的优先编码器,用assign可以写但阅读困难;用always @(*)配casez或if-else,结构和真值表一一对应,非常直观。
2.3 组合逻辑与时序逻辑的本质差异
组合逻辑的输出只依赖当前输入,比如y = a & b,电路里没有记忆单元。时序逻辑的输出依赖当前输入和之前的状态,比如计数器、移位寄存器,电路里必须有触发器或锁存器,并且一般由时钟边沿驱动。
在写法上的对应关系是:
| 电路类型 | 物理结构 | 常用声明 | 常用赋值方式 |
|---|---|---|---|
| 组合逻辑 | 逻辑门/LUT | wire,或 reg(always 内) | assign 或 always @(*) 内阻塞赋值 |
| 时序逻辑 | 触发器/寄存器 | reg | always @(posedge clk) 内非阻塞赋值 |
| 锁存器 | Latch | reg | always @(*) 内分支不完整,设计应避免 |
这里要特别强调一点:很多教材说“在 always 块里赋值的信号要声明为 reg”,于是初学者就以为reg型信号一定会综合成寄存器。这个理解是错的。always @(*)块里的reg,综合结果只是普通组合逻辑;真正让信号变成寄存器的是@(posedge clk),而不是reg关键字。反过来,wire也不是一定不能存储,只是不能作为过程块的赋值目标而已。
3. 连续赋值 assign 详解与示例
3.1 assign 的语法与规则
assign的完整语法可以带延迟、带驱动强度,但实际工程中我们绝大多数时候只用最简单形式:
assign <wire信号> = <表达式>;几点关键规则:
- 左边必须是
wire或wire类型的端口,不能是reg。 - 右边可以包含
wire、reg、常量、函数调用、运算符表达式。 assign在模块内是并行执行的,多个assign的先后顺序不影响逻辑。- 同一个 wire 不能被多个
assign重复驱动,否则会出现多驱动冲突,仿真表现为X,综合会报错。
3.2 一个多路选择器示例
下面用assign实现一个 2 选 1 多路选择器,这是数据流建模的典型场景:
// 文件路径:src/mux2_1.v module mux2_1( input wire a, input wire b, input wire sel, output wire y ); assign y = sel ? b : a; endmodule这段代码在综合后会变成用 LUT 实现的组合逻辑。sel为 1 时选b,为 0 时选a。从工程角度看,这种一行就能描述完的逻辑,用assign是最合适的:简洁、无歧义、不会有 latch 风险。
又比如一个简单的 3-8 译码器,也可以继续用assign,但表达式会变长。此时可以用组合逻辑always的结构更清晰,这就是下一节的重点。
3.3 什么时候该用 assign
我判断是否使用assign的经验原则有三个:
- 逻辑简单,表达式一两个小时能写完;
- 没有记忆需求,也没有复杂分支,尤其是没有“条件不满足时保持原值”的需求;
- 信号本身就是数据通路,比如模块输出的拼接、位宽转换、顶层信号的直连。
如果逻辑开始出现多层if-else嵌套、复杂的case状态判断,或者需要在多个分支中对一个信号赋值,就应该考虑切换到always @(*)。
4. always @(*) 组合逻辑详解
4.1 敏感列表和 @(*) 的含义
always块中的@(...)表示敏感列表,即“哪些信号变化时需要触发块内逻辑”。最完整的写法是把右边出现过的所有输入都列出来,比如always @(a or b or sel)。但人工列敏感列表很容易漏,漏掉后会带来一个很隐蔽的问题:仿真时信号变化不会立即触发块执行,导致仿真结果和综合结果不一致。
Verilog 提供了@(*),表示自动推导敏感列表,等价于“块内所有被读取的输入信号”。这不是偷懒,而是为了保证仿真和综合行为一致,所以工程中组合逻辑的always块都应该写@(*),而不是手动罗列信号。
4.2 组合逻辑 always 的赋值风格
组合逻辑的always块内部,推荐使用阻塞赋值=。所谓阻塞赋值,可以理解为“立即生效”:赋值语句执行后,变量马上被更新,下一条语句读取到的是新值。这符合组合逻辑“通过各级门电路一级一级传播”的思路。
来看完整的优先编码器示例:
// 文件路径:src/priority_encoder.v module priority_encoder( input wire [3:0] din, output reg [1:0] dout, output reg valid ); always @(*) begin // 先给默认值,避免else分支缺失 valid = 1'b0; dout = 2'b00; if (din[3]) begin valid = 1'b1; dout = 2'b11; end else if (din[2]) begin valid = 1'b1; dout = 2'b10; end else if (din[1]) begin valid = 1'b1; dout = 2'b01; end else if (din[0]) begin valid = 1'b1; dout = 2'b00; end end endmodule这段代码里,最值得学习的一点是先给默认值再写分支。valid和dout在一进入 always 块时就赋了一个默认值,后面即使所有if条件都不满足,输出也有确定的赋值,综合工具就不会推断出 latch。
4.3 latch 陷阱与避免方法
在组合逻辑 always 块中出现 latch 推断,是初学者最常见的警告之一。触发条件有三个:
if缺少else;case缺少default;- 在多个分支赋值时,信号没有在所有路径都被赋值。
出现 latch 后,电路会产生一个电平敏感的存储单元,时序分析难度增大,还容易上板后出现功能异常。避免方法很简单,但需要养成习惯:
- 每个
always @(*)块,对块内要赋值的信号,在开头先给默认赋值; case语句一定要写default;- 不要试图用组合逻辑 always 写出“保持原值”的效果,那是时序逻辑或者 latch 的行为。
4.4 assign 与 always @(*) 如何选择
从电路功能看,两者都能描述组合逻辑,常可以互换。但从代码可读性和维护性看,规则并不复杂:
- 简单数据通路、一行表达式,用
assign; - 有
if-else、case分支,有多路条件赋值,用always @(*); - 需要编码风格统一,团队内部可以约定“组合逻辑一律使用 always @(*)”或“简单逻辑用 assign”,关键是一致性。
5. always @(posedge clk) 时序逻辑详解
5.1 为什么需要时钟边沿
时序逻辑的核心特征是“在时钟边沿采样输入,更新输出”。这里的posedge clk表示“时钟上升沿”是触发条件。硬件上对应的是触发器:每个时钟上升沿到来时,把 D 端的数据锁存到 Q 端,其余时间输出保持不变。
posedge clk的 always 块写法如下:
always @(posedge clk) begin // 时序逻辑赋值 end还可以加入复位信号,比如常见的异步复位写法:
always @(posedge clk or negedge rst_n) begin if (!rst_n) begin // 复位 end else begin // 正常逻辑 end endnegedge rst_n表示复位信号下降沿也会触发该 always 块,这是异步复位的特点:复位不等待时钟,立刻生效。后面会详细说明同步复位和异步复位的区别。
5.2 非阻塞赋值 <= 的工程意义
时序逻辑 always 块中,必须使用非阻塞赋值<=。非阻塞赋值的含义是:先计算右侧表达式的值并暂存,直到整个 always 块执行结束后,统一更新左侧变量。这种“先读后写、块末统一更新”的机制,避免了多个触发器在同一个时钟沿赋值时的仿真竞争问题。
对比一下阻塞赋值写时序逻辑的问题。假设要写一个两级移位寄存器:
// 错误示例:时序逻辑中使用阻塞赋值 always @(posedge clk) begin q1 = d; q2 = q1; end仿真时,q1 = d立即执行,然后q2 = q1时读到的已经是新q1,所以q2拿到的是当前时钟沿的输入d。综合工具综合出来的电路也会据此优化,行为与仿真一致;但真实硬件中,触发器是同时采样、同时输出的,q2应该拿到的是上一个周期锁存下来的q1,而不是当前周期的新值。这就造成了仿真、综合、硬件行为三者不一致。改成<=之后,右侧使用的都是进入 always 块前的旧值,行为才符合真实电路。
虽然阻塞赋值写时序逻辑在某些工具下也能综合出正确电路,但 Verilog 社区的铁律就是:
时序逻辑用非阻塞赋值,组合逻辑用阻塞赋值。这条规则不要打破。
5.3 计数器实例:同步复位与异步复位
下面实现一个带异步复位的 8 位计数器:
// 文件路径:src/counter8.v module counter8( input wire clk, input wire rst_n, input wire enable, output reg [7:0] count ); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin count <= 8'b0; end else if (enable) begin count <= count + 1'b1; end else begin count <= count; end end endmodule这里count <= count + 1'b1右侧的count是当前时钟沿到来之前的旧值,所以计数器每个时钟周期递增 1,不会出现累加跳变问题。if (!rst_n)是复位分支,由于敏感列表里有negedge rst_n,复位信号有效时会立刻触发 always 块,将count清零,而不需要等待下一个时钟沿。
如果改成同步复位,代码是:
always @(posedge clk) begin if (!rst_n) begin count <= 8'b0; end else if (enable) begin count <= count + 1'b1; end end同步复位的含义是:复位信号即使有效,也要等到时钟上升沿才能完成复位。两者在时序约束、复位释放时间上的要求不同。工程里两种复位风格都存在,但无论用哪种,建议整个项目统一,不要在同一个模块里混用。
5.4 时序逻辑中的默认赋值问题
时序逻辑中的“不满足条件保持原值”是正常行为,因为触发器本身就有存储能力。所以在时序逻辑 always 块里,if分支不完整是允许的,它与组合逻辑的处理方式不同。但为了代码清晰,如果分支较多,还是建议写else分支或者默认赋值,避免设计意图不明确。
不过需要特别注意:时序逻辑中不能出现对同一个信号在多个 always 块里赋值,也不要在同一个 always 块里对某些信号用=、某些用<=,混合使用会让综合工具产生不可预期的行为。
6. 完整示例:计数与校验模块打包
前面分别讲了三种赋值方式,现在把它们组合到一个完整模块中,展示一套真实的协作关系。这个模块的功能是:每 4 个时钟周期产生一个高电平脉冲,并统计高电平的数量,最后输出一个奇偶校验标志。
// 文件路径:src/top_example.v module top_example( input wire clk, input wire rst_n, output wire [3:0] counter_value, output wire pulse, output wire parity_odd ); // 内部信号 reg [3:0] cnt; reg [1:0] pulse_cnt; wire cnt_lsb; // 时序逻辑:4 分频计数 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 4'b0; end else begin cnt <= cnt + 1'b1; end end // 组合逻辑:检测 cnt 的最低位 assign cnt_lsb = cnt[0]; // 组合逻辑:每 4 个周期产生一个脉冲 always @(*) begin if (cnt[1:0] == 2'b11) begin pulse = 1'b1; end else begin pulse = 1'b0; end end // 时序逻辑:统计脉冲个数 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin pulse_cnt <= 2'b0; end else if (pulse) begin pulse_cnt <= pulse_cnt + 1'b1; end end // 连续赋值:输出计数器值,并计算奇偶校验 assign counter_value = cnt; assign parity_odd = ^counter_value; endmodule这个模块展示了三种赋值的配合方式:
cnt、pulse_cnt是时序逻辑信号,用always @(posedge clk or negedge rst_n)和非阻塞赋值。pulse是组合逻辑输出,用always @(*)和阻塞赋值,且else分支完整。counter_value、parity_odd是纯组合数据通路,用assign简单直连或做运算。
这里有个细节值得学习:assign cnt_lsb = cnt[0]虽然只是取位,但说明了从寄存器到后续组合逻辑的信号传递关系。真实工程中,模块内部的信号往往就是这样在时序逻辑、组合逻辑、连续赋值之间周转的。
7. 仿真验证与结果检查方法
写完模块后,必须通过仿真验证功能。下面是配套的 testbench,覆盖复位、计数、脉冲输出、奇偶校验等场景:
// 文件路径:sim/tb_top_example.v `timescale 1ns / 1ps module tb_top_example; reg clk; reg rst_n; wire [3:0] counter_value; wire pulse; wire parity_odd; top_example u_top_example( .clk (clk), .rst_n (rst_n), .counter_value(counter_value), .pulse (pulse), .parity_odd (parity_odd) ); initial begin clk = 0; forever #10 clk = ~clk; // 50MHz 时钟 end initial begin rst_n = 0; #25; rst_n = 1; #200; $finish; end initial begin $monitor("time=%0t rst_n=%b counter=%d pulse=%b parity_odd=%b", $time, rst_n, counter_value, pulse, parity_odd); end endmodule仿真运行命令(以常见的 Icarus Verilog 为例):
iverilog -o tb_top_example.vvp src/top_example.v sim/tb_top_example.v vvp tb_top_example.vvp预期结果为:
- 复位期间
counter_value保持 0,pulse为 0; - 复位释放后,
counter_value从 0 开始每个时钟周期加 1; - 当
cnt[1:0] == 2'b11时,pulse输出高电平,即每 4 个周期出现一次; - 每次
pulse为高时,pulse_cnt递增; parity_odd的值由counter_value各位异或得到。
判断仿真成功的标准,是在打印日志中看到复位后计数器从 0 稳步递增,脉冲周期性出现。如果pulse一直为 0,优先检查cnt[1:0]是否走到了2'b11;如果计数器跳变异常,检查是否在posedge clk的 always 块中误用了阻塞赋值。
需要提醒的是,以上文档中的仿真命令与模块代码是配套的,实际项目可能使用 Vivado、Questa、VCS 等工具,但验证思路完全相同。综合工具(如 Vivado Synthesis)也会对代码做语法和可综合性检查,如果出现latch inferred、multiple drivers等 warning,就需要回到对应的 always 块里检查。
8. 常见问题与排查方法
下面是工程中经常遇到的几种问题现象和排查方法,整理成表格,方便实际开发时快速对照。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 综合报告提示 latch inferred | 组合逻辑 always 块 if 缺 else,或 case 缺 default | 定位对应 always 块,检查每个信号在所有分支是否有赋值 | 在块开头给所有输出信号加默认赋值,或补全 else/default |
| 编译报错“register is illegal in left hand side of continuous assignment” | 对 reg 类型信号使用了 assign | 查看报错行,确认目标信号类型 | 将目标信号改为 wire,或将赋值移入 always 块 |
| 仿真波形中出现 X 状态 | 存在多驱动,或信号未初始化 | 检查是否有多个 assign/always 对同一信号赋值 | 保证每个信号只有一个驱动源 |
| 时序逻辑输出乱跳,仿真结果和预期相差一拍 | 在 always @(posedge clk) 中用了阻塞赋值 = | 检查时序 always 块内赋值符号 | 时序逻辑统一改用非阻塞赋值 <= |
| 组合逻辑仿真结果和综合后行为不一致 | 敏感列表漏写了输入信号 | 检查 always @(a or b) 中是否包含所有输入 | 统一使用 always @(*) |
| 模块输出为高阻 Z 或一直为 0 | 输出信号没有被驱动,或赋值的条件永远不成立 | 检查端口连接和波形中的信号值 | 确认模块实例化时端口连接正确,内部赋值路径完整 |
| 时钟复位信号不稳定,仿真有毛刺 | testbench 中时钟或复位信号被多个 initial 块驱动 | 检查 tb 中 clk、rst_n 是否有多次赋值 | 时钟用独立 initial 循环产生,复位用单一 initial 时序赋值 |
在这张表里,最需要重视的是前四行。它们覆盖了绝大多数初学者在前三个月会遇到的编译和综合问题。排查顺序建议是:先看编译错误行号,再看综合 warning 中提到哪个信号,最后回到对应的assign或always块,用“这个信号应该由什么电路产生”的思路去检查。
9. 工程实践:到底该怎么选择赋值方式
学完语法之后,真正决定代码质量的是工程实践习惯。这里直接给出可执行的建议,建议当成团队规范来用。
9.1 按逻辑类型区分赋值方式
- 纯组合逻辑,能一行写清楚,用
assign。比如位拼接、比较、简单算术。 - 纯组合逻辑,有分支、有优先级、有 case,用
always @(*),内部用阻塞赋值=,并且每个分支路径都写全。 - 时序逻辑,用
always @(posedge clk),内部统一用非阻塞赋值<=。 - 需要异步复位,在敏感列表中加入
or negedge rst_n。 - 不要在一个模块里把三种方式混得无法区分。模块边界清晰时,每个模块内部至少有明确的逻辑分区。
9.2 复位策略统一
同一份代码里,尽量只采用一种复位策略。如果顶层用异步复位,则整个模块树的时序逻辑都建议用异步复位;如果某些模块用同步复位,应在命名和注释中明确标注,避免后续维护者混淆。复位信号建议统一命名为rst_n或rst,并固定有效电平。比如rst_n是低电平有效,则判断时用!rst_n;如果使用高电平有效复位rst,则判断时用rst。
9.3 减少多驱动和组合逻辑环路
初学者最常见的隐藏问题,是用assign给一个已经被always块驱动的信号又赋值,或者在一个 always 块里读取另一个 always 块刚要更新的信号。硬件代码中,每个信号只允许一个驱动源。建议在命名上区分信号类型:输出端口用dout前缀、内部寄存器用r_前缀、内部线网用w_前缀,能显著降低多驱动问题的引入概率。
9.4 用 lint 工具和综合报告做检查
不要只依赖仿真。仿真通过只代表行为符合预期,不代表能综合成干净电路。综合报告中的Number of latches、Number of flip-flops、WARNING: multiple drivers都是重要质量指标。如果报告中出现数量异常,直接定位对应 always 块。大型工程还可以使用 SpyGlass 等 lint 工具自动检查代码规范,把这三种赋值方式的使用规范做成规则,提前拦截问题。
9.5 写可读性更强的组合逻辑
组合逻辑 always 块内部,默认赋值放在最前面,分支放在后面。这一条看似简单,却能避免在复杂的状态机输入逻辑中引入 latch。下面的写法是推荐模板:
always @(*) begin valid = 1'b0; next_state = curr_state; case (curr_state) IDLE: begin if (start) begin next_state = RUN; valid = 1'b1; end end RUN: begin next_state = DONE; valid = 1'b1; end default: begin next_state = IDLE; end endcase end这个模板先给valid和next_state默认值,再在 case 中覆写。即使IDLE在start为假时什么都不做,next_state也已经有了默认值curr_state,不会产生 latch,这种写法是状态机输入逻辑的标准姿势。
10. 后续进阶方向
本文讲的是三种赋值方式的基础分水岭。真正进阶时,建议按下面的顺序继续深入:
- 学习阻塞赋值与非阻塞赋值的完整语义,尤其是仿真事件队列中两者执行时的差别。
- 学习如何编写可综合的 case、casez、
priority case、unique case,理解这些关键字对综合工具和仿真工具的影响。 - 学习跨时钟域处理,理解为什么跨时钟域信号不能简单用
always @(posedge clk)去赋值,而是需要两级同步器或异步 FIFO。 - 学习状态机的标准三段式写法,这会进一步巩固组合逻辑 always 与时序逻辑 always 的分工。
- 学会读综合报告、时序报告,把这篇文中提到的“三种赋值方式”和真实电路结构对应起来。
在工程中,一个模块绝不可能只用某一种赋值方式。计数器、状态机、数据通路、控制逻辑,都是这三种写法按场景组合出来的。想明白“这段电路最终是组合逻辑还是时序逻辑,需不需要记忆保持”,再决定用哪种赋值方式,比死记语法规则更重要。理解了这一点,再看别人的代码,表面是赋值语法,背后其实是电路结构。