这次我们直接切入 Verilog 里最容易被绕晕的三个知识点:连续赋值assign、过程块always @(*)组合逻辑,以及always @(posedge clk)时序逻辑。很多初学者能看懂单个例子,但一旦把三种写法混在一个模块里,就会遇到“阻塞赋值和非阻塞赋值到底怎么选”“为什么组合逻辑综合出来一个 latch”“为什么同一个信号在多个 always 里赋值会报错”这类问题。这篇文章不会只讲语法,会把三种赋值方式放到同一个对比框架里,明确告诉你在什么场景用哪一种、仿真行为和综合行为分别是什么、出了问题从哪几个方向排查。
先说结论:assign适合描述“数据流”级别的组合逻辑,always @(*)适合描述带if/case分支的“过程”组合逻辑,always @(posedge clk)是描述寄存器、计数器和状态机时绕不开的时序逻辑。三种写法都能综合成真实电路,但它们对信号类型的要求、赋值符号的选择、敏感列表的写法和综合出的硬件结构完全不同。如果你能把这三个写法的边界分清楚,Verilog 编码里很大一部分警告和仿真错误都可以提前避免。
下面会从核心概念速览开始,然后分别拆解assign、always @(*)、always @(posedge clk)的语法规则和代码实例,最后给出 Testbench 仿真、常见问题排摸和工程规范建议。整个过程会用到可以在本地运行的小例子,你可以直接复制到 Icarus Verilog 或 Verilator 中跑一遍,对照波形检查自己的理解。
1. 核心概念速览
| 特性 | assign连续赋值 | always @(*)组合逻辑 | always @(posedge clk)时序逻辑 |
|---|---|---|---|
| 关键字 | assign | always @(*) | always @(posedge clk) |
| 左侧信号类型 | wire/ 其他net类型 | 必须为reg类型变量 | 必须为reg类型变量 |
| 常用赋值符号 | = | =(阻塞赋值) | <=(非阻塞赋值) |
| 触发条件 | 右侧表达式任意信号变化 | 敏感列表中任意信号变化 | 时钟上升沿(或复位边沿) |
| 典型硬件结果 | 组合逻辑门、数据流连线 | 组合逻辑门、优先编码器、译码器 | 触发器、寄存器、计数器、状态机 |
| 常见错误 | 左侧用了reg或存在多驱动 | 条件分支不完整导致 latch | 使用阻塞赋值导致仿真竞争 |
| 可综合性 | 可综合 | 可综合 | 可综合 |
这张表是全文的最小记忆单位。后续所有代码和排错思路,都是围绕表中这几个差异展开的。如果你已经能理解“always块里赋值给reg,但reg不一定是寄存器”这句话,那么组合逻辑和时序逻辑的区别你已经掌握了一大半。
2. 适用场景与使用边界
assign连续赋值的核心是“一旦右侧变化,左侧立刻更新”。它适合描述一组输入到一组输出的纯组合映射,例如数据选择器、译码器、加法器的进位输出、总线信号的拼接和截取。这类逻辑用表达式就能写清楚,不涉及顺序判断,也没有中间变量。
always @(*)过程块适合描述需要分支判断的组合逻辑。比如多路选择器用case、优先编码器用if...else if、仲裁器用多级优先级判断。由于always块内部可以声明局部变量、使用for循环、临时变量,它比assign更容易表达“先比较,再选择”的过程化逻辑。
always @(posedge clk)时序逻辑则用于描述需要记忆状态的硬件:计数器、状态机、移位寄存器、同步 FIFO 的读写指针、流水线寄存器等。它的本质是“在时钟边沿采样输入,输出保持到下一个时钟边沿”。使用时必须注意复位方式、非阻塞赋值和敏感列表完整性。
边界也很明确:同一个信号不能同时被assign和always驱动,也不能被两个always块同时驱动。组合逻辑块中不要使用非阻塞赋值,时序逻辑块中尽量不要使用阻塞赋值。只描述纯组合逻辑时,assign和always @(*)在综合结果上可能等价,但在代码可读性和维护性上各有侧重。
3. 环境准备与前置条件
本文所有代码都可以在本地仿真环境里运行,不依赖特定 FPGA 板卡。如果你还没有 Verilog 仿真环境,推荐安装 Icarus Verilog(iverilog)配合 GTKWave 查看波形,这是一套轻量、免费、适合学习的最小组合。
在 Ubuntu/Debian 上,可以执行:
sudo apt update sudo apt install iverilog gtkwaveWindows 环境可以通过 Icarus Verilog 官方安装包安装,也可以在 WSL 中执行同样的命令。macOS 下可以通过 Homebrew 安装:
brew install icarus-verilog brew install --cask gtkwave安装完成后,在终端运行:
iverilog -V如果能看到iverilog版本信息,说明环境可用。Vivado、Quartus、ModelSim/Questa 也可以运行后面的代码,但建议先在小仿真环境里把 RTL 行为验证清楚,再放到工程里综合实现,这样排错成本最低。
目录结构建议按下面的方式组织:
verilog_assign_always/ ├── rtl/ │ ├── half_adder.v │ ├── mux4_1.v │ └── counter.v └── sim/ ├── tb_half_adder.v ├── tb_mux4_1.v └── tb_counter.v这样做的目的是把设计文件和测试文件分开,编译时使用明确路径,避免大量文件堆在一起后分不清驱动关系。后面所有仿真命令都会基于这种目录结构写。
4. 连续赋值assign组合逻辑
4.1 基本语法与规则
assign的基本语法如下:
assign 左侧信号 = 右侧表达式;左侧信号必须是wire或output wire类型的网络变量,右侧可以是wire、reg、常量、拼接或函数调用结果。assign是并行语句,不放在initial或always里,写在模块体内任意位置都能生效。
它的行为可以理解为“持续连线”:右侧任意变量发生变化,左侧立即重新计算。例如:
assign y = (a & b) | c;只要a、b、c任一变化,y就会被更新。这种特性非常适合表达组合逻辑的数据流关系。
4.2 用assign实现半加器
下面是一个最经典的半加器例子:
module half_adder( input wire a, input wire b, output wire sum, output wire carry ); assign sum = a ^ b; assign carry = a & b; endmodule这里sum和carry都是wire类型,使用assign直接实现异或和与操作。综合后得到的就是一个异或门和一个与门,没有任何存储元件。
也可以用一条assign同时输出多个信号,例如使用拼接:
assign {carry, sum} = a + b;这种写法在算术逻辑中更紧凑,但要保证左侧位宽和右侧位宽一致,否则会产生符号扩展或截断问题。
4.3assign的常见错误
第一个常见错误是左侧使用了reg。assign只能驱动网络类型,不能直接对reg做连续赋值。如果确实需要在一个模块里既做组合逻辑又做输出,可以让output端口是wire,然后通过assign驱动。
第二个常见错误是多个驱动源。同一个wire在模块内被两个assign赋值,或者一个assign加一个always同时赋值,都会造成多驱动。综合工具会报错或产生不确定的连接关系。正确的做法是让每个信号只被一条语句驱动。
第三个常见错误是忽略位宽。assign右侧表达式宽度和左侧不一致时,Verilog 会按左侧宽度自动截断或扩展,这种隐式行为容易掩盖 bug。建议在写高位宽总线时显式使用[N-1:0]定义,并通过仿真断言检查数值范围。
4.4 什么时候优先用assign
如果一个逻辑可以用一条表达式描述清楚,并且没有分支判断,优先用assign。典型场景包括:地址译码、低位宽算术运算、总线拼接、常量偏移、状态转移条件中的组合标志位。assign的优点是结构清晰、并行语义直观、不容易产生 latch。
但如果逻辑里有大量if和case,强行用条件表达式嵌套会变得很难读,这时就应该改用always @(*)。
5. 过程块always @(*)组合逻辑
5.1 基本语法与敏感列表
always过程块可以描述组合逻辑,也可以描述时序逻辑,差别主要体现在敏感列表和赋值符号上。组合逻辑的标准写法是:
always @(*) begin // 组合逻辑处理 end@(*)在 Verilog-2001 中表示自动敏感列表,编译器会自动把块内读取的所有信号加入敏感列表。它比手写always @(a or b or sel)更安全,因为手写容易漏信号,漏掉后仿真行为会与实际硬件不一致。
块内被赋值的信号必须声明为reg类型,这里的reg只是过程赋值的载体,并不代表综合后是寄存器。always @(*)内必须使用阻塞赋值=,这样变量更新才是“立即生效”,符合组合逻辑的数据流语义。
5.2 用always @(*)实现 4 选 1 多路选择器
下面是一个完整例子:
module mux4_1( input wire [1:0] sel, input wire [3:0] in, output reg y ); always @(*) begin case (sel) 2'd0: y = in[0]; 2'd1: y = in[1]; 2'd2: y = in[2]; 2'd3: y = in[3]; default: y = 1'bx; endcase end endmodule这里y声明为output reg,因为在always块内赋值。case语句完整覆盖了sel的四种状态,所以不会产生 latch。如果不写default,在sel为2'bxx或2'bzz时,综合工具会认为某些输入组合下y不需要保持旧值,从而可能推断出锁存器。
5.3 条件不完整导致 latch
always @(*)里最典型的坑是:某个分支下没有给输出赋值。例如:
always @(*) begin if (enable) begin y = a; end end当enable = 0时,y没有任何语句赋值,硬件为了保持原值,只能引入锁存器。大多数设计里 latch 不是想要的结果,综合报告会出现Latch inferred警告。
解决方法是给输出赋默认值:
always @(*) begin y = 1'b0; if (enable) begin y = a; end end也可以写完整的else分支。对于case,则补default。这条规则不仅适用于多路选择器,也适用于状态机的组合输出、读写控制信号生成等所有组合逻辑。
5.4assign和always @(*)的区别
从综合结果看,同样一段逻辑用assign和always @(*)写,可能综合出相同或接近的电路。区别在于表达方式:assign是显式表达式,适合简单映射;always @(*)是过程描述,适合分支判断和需要临时变量的逻辑。
有一点要特别注意:两个方式不能同时驱动同一个信号。比如一个模块里既写assign y = a & b;,又在always @(*)里给y赋值,那么y会被多个驱动源驱动,仿真和综合都会出问题。
6. 时序逻辑always @(posedge clk)
6.1 基本语法与复位方式
时序逻辑的标准模板是:
always @(posedge clk) begin // 同步时序逻辑 end这个写法表示:只有时钟clk的上升沿到达时,块内语句才执行。输出在时钟边沿采样输入,并在下一个时钟边沿前保持不变,综合后对应的是触发器。
如果使用异步复位,通常写作:
always @(posedge clk or negedge rst_n) begin if (!rst_n) begin q <= 1'b0; end else begin q <= d; end end敏感列表里同时包含时钟上升沿和复位下降沿。复位信号rst_n低电平有效时,无论时钟是否到来,复位都能立即将q清零。同步复位则写作:
always @(posedge clk) begin if (!rst_n) begin q <= 1'b0; end else begin q <= d; end end同步复位的复位动作只在时钟沿发生,不容易受复位毛刺影响。实际工程里建议全模块统一使用同一种复位方式,不要在一个模块里混用同步复位和异步复位,否则时序约束和复位验证都会变复杂。
6.2 为什么时序逻辑必须用非阻塞赋值
时序逻辑块内部推荐使用非阻塞赋值<=。非阻塞赋值的语义是:右侧表达式在进入 always 块时先采样旧值,左侧更新在时间步结束时统一完成。用这个模板写出的行为符合真实寄存器“边沿采样,随后更新”的特性。
反过来,如果在时序逻辑里用阻塞赋值=,同一时刻多个 always 块之间的信号依赖会变得非常敏感,仿真时很容易出现竞态。例如一个模块里有两个 always 块,一个把a赋给b,另一个把b赋给c,使用=时,模拟器对块执行顺序的差异会导致c的值不确定。
所以工程规范是:组合逻辑用=,时序逻辑用<=,同一个 always 块内不要混用两种赋值方式。
6.3 8 位计数器示例
下面用一个带异步复位的 8 位计数器演示时序逻辑:
module counter( input wire clk, input wire rst_n, output reg [7:0] cnt ); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 8'd0; end else begin cnt <= cnt + 1'b1; end end endmodule这个计数器每来一个时钟上升沿就加一。复位信号rst_n为低电平时,cnt异步清零。使用<=的好处是,所有寄存器在当前时钟沿先读取旧值,再统一更新,因此cnt <= cnt + 1'b1不会出现“一步加两次”的问题。
6.4 移位寄存器与状态机思维
时序逻辑还可以描述移位寄存器:
module shift_reg( input wire clk, input wire rst_n, input wire din, output reg [3:0] q ); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin q <= 4'b0; end else begin q <= {q[2:0], din}; end end endmodule这个例子展示了“并行赋值”的时序行为:q[3]拿到q[2]的旧值,q[2]拿到q[1]的旧值,q[0]拿到din。因为所有右侧都在时钟沿前采样旧值,所以不会出现竞争。
写状态机时也是一样的逻辑:状态寄存器和输出寄存器都用<=,状态转移条件用组合逻辑生成,最终在时钟沿统一更新。只要记住“时序块只描述存储,组合块只描述判断”,状态机的代码结构就不会乱。
7. 三种赋值方式对比与选择
7.1 选择指南
| 需求 | 推荐写法 | 示例 |
|---|---|---|
| 简单逻辑表达式 | assign | assign y = a & b; |
| 带条件判断的组合逻辑 | always @(*)+= | case、if...else |
| 寄存器、计数器、状态机 | always @(posedge clk)+<= | 计数器、移位寄存器 |
如果是纯组合逻辑,可以用assign,也可以用always @(*)。选择依据是代码可读性。能一行写完的用assign;需要多个分支、优先级判断或临时变量的,用always @(*)。
如果是时序逻辑,不要用assign,也不要用always @(*)描述存储行为。时序逻辑必须出现在以时钟边沿为敏感事件的always块里,并且使用非阻塞赋值。
7.2 阻塞赋值与非阻塞赋值的核心区别
阻塞赋值=在赋值语句执行时立即更新左侧变量,后面的语句会立刻看到新值。非阻塞赋值<=则是先把右侧表达式求值保存,等当前时间步结束时统一更新左侧变量,后面的语句在执行时仍然看到旧值。
这个区别在建模时非常关键。组合逻辑要求信号在某个时间点立即反映输入变化,所以用=;寄存器逻辑要求在时钟沿统一采样,所以用<=。如果你发现仿真波形中的寄存器输出总是比预期晚一拍,或者出现不确定的 X 态,优先检查是否在时序块里误用了=。
7.3always块不要混用赋值类型
一个常见但不规范的做法是在同一个always块里先做组合逻辑又做时序逻辑,同时混用=和<=。这样的代码仿真也许能跑通,但综合后的结果很难预测,debug 成本很高。
推荐的做法是:把组合逻辑和时序逻辑拆成两个块。组合部分负责生成中间信号,时序部分只负责采样和寄存。这样每个always块的语义单一,后续加约束、查波形、写断言都会轻松很多。
8. Testbench 仿真与效果验证
8.1 仿真环境基本流程
使用 Icarus Verilog 的运行流程是:先编译所有设计文件和 testbench 文件,生成仿真可执行文件,然后运行仿真,最后用 GTKWave 查看 VCD 波形。
以计数器counter.v为例,先写一个 testbench:
`timescale 1ns/1ps module tb_counter; reg clk; reg rst_n; wire [7:0] cnt; counter u_counter( .clk (clk), .rst_n (rst_n), .cnt (cnt) ); initial begin clk = 1'b0; rst_n = 1'b0; #20 rst_n = 1'b1; #500 $finish; end always #10 clk = ~clk; initial begin $dumpfile("tb_counter.vcd"); $dumpvars(0, tb_counter); end initial begin $monitor("time=%0t rst_n=%b cnt=%0d", $time, rst_n, cnt); end endmodule编译和仿真命令:
iverilog -o sim_counter rtl/counter.v sim/tb_counter.v vvp sim_counter执行后会在终端看到每隔 10ns 打印一次的计数器值,如果cnt从 0 开始按 1 递增,说明时序逻辑行为正确。GTKWave 打开 VCD 文件:
gtkwave tb_counter.vcd在波形窗口里可以检查复位释放后cnt是否在每个时钟上升沿加一,这是判断时序逻辑是否正确的直接方式。
8.2 组合逻辑 testbench 示例
对于半加器,可以写一个自动对比的 testbench:
`timescale 1ns/1ps module tb_half_adder; reg a, b; wire sum, carry; half_adder u_half_adder( .a(a), .b(b), .sum(sum), .carry(carry) ); initial begin $monitor("a=%b b=%b sum=%b carry=%b", a, b, sum, carry); a = 0; b = 0; #10; a = 0; b = 1; #10; a = 1; b = 0; #10; a = 1; b = 1; #10; $finish; end endmodule如果仿真结果符合真值表,说明assign写出的组合逻辑没有问题。同样的方法也适用于always @(*)的多路选择器,只需要把sel和in的值周期性切换,并通过$display或assert检查输出。
8.3 仿真中容易忽略的点
仿真通过只代表功能行为正确,不代表综合后时序一定满足要求。组合逻辑在仿真中看起来是瞬时更新的,但实际电路存在门延迟;时序逻辑在仿真中忽略时钟偏移和建立保持时间,但真实 FPGA 上必须做时序约束。
所以在功能仿真之外,还需要在 FPGA 工程里跑综合实现和时序收敛。仿真用于验证逻辑,综合用于验证可制造性和性能,两者不能互相替代。
9. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
仿真波形出现X | 信号未初始化、多驱动、复位未拉低 | 检查initial是否给reg赋初值,检查是否多个语句驱动同一信号 | 统一初始化,确保复位信号先有效,消除多驱动 |
综合报告出现Latch inferred | always @(*)中 if/case 分支不完整 | 查看 warning 中给出的信号名 | 补else、default,或提前给输出赋默认值 |
| 时序块中寄存器输出晚一拍 | =和<=混用,或对组合信号路径有误解 | 检查 testbench 中采样时刻 | 时序块统一使用<=,组合块统一使用= |
| 同一个信号多处赋值,编译报错 | assign与always同时驱动一个信号 | 搜索该信号在模块内所有出现位置 | 确认只保留一个驱动源 |
assign左侧使用reg导致语法错误 | assign只能驱动wire | 查看报错行号 | 将reg改为wire,或改用always |
| 敏感列表手写漏了信号,仿真不更新 | 使用了always @(a or b)但遗漏sel | 对比实际逻辑与敏感列表 | 改用always @(*)自动生成敏感列表 |
| 仿真时间一直没有前进 | testbench 缺少循环/延时控制 | 检查是否有always和#延时 | 在 testbench 中加入固定时钟或使用$finish |
| 计数器综合后 bit 扩展异常 | 位宽不匹配或加法溢出 | 检查cnt + 1'b1的位宽 | 显式定义localparam WIDTH并统一位宽 |
这些都是在学习assign、always和过程块时最常踩的坑。看到警告不要急着忽略,Latch inferred、multi-driver、incomplete sensitivity这三类信息是保证代码质量的关键信号。
10. 最佳实践与代码规范
第一,每个always块只描述一种功能。组合逻辑用always @(*)配合阻塞赋值,时序逻辑用always @(posedge clk)配合非阻塞赋值,不要让一个块同时承担两种语义。
第二,组合逻辑中尽量补全条件。无论是if还是case,都要覆盖所有分支,否则综合工具大概率推断出 latch。最简单的做法是在always块最前面给所有输出赋一个默认值,再写具体分支逻辑。
第三,敏感列表尽量使用@(*)。手写敏感列表容易漏信号,漏一个信号就会导致仿真和综合的行为不一致。使用@(*)后,编译器会自动维护敏感列表,降低人为错误。
第四,命名要有区分度。建议对组合逻辑信号和时序逻辑信号做不同后缀,例如组合信号用_comb,寄存器输出用_q或_r。这样在长代码里一眼就能看出信号性质,综合报告里的 warning 也更容易定位。
第五,复位方式要统一。同步复位和异步复位各有适用场景,但一个模块内不要混用。异步复位必须写进敏感列表,复位释放还要考虑恢复时间;同步复位逻辑上更简单,适合大多数内部寄存器复位场景。
第六,仿真环境保留一组最小可运行文件。把counter.v、mux4_1.v和对应 testbench 放在固定目录,这样每次学习新知识点时,都能用同一套环境快速验证语法修改是否影响功能。
第七,做综合前先跑 lint 或语法检查。Vivado、Quartus、Verilator 都能给出大量 warning,优先看latch inferred、width mismatch、multi-driver这几类,先把它们清零再进入布局布线,后面遇到时序问题时排除范围会小很多。
第八,代码中不要写不可综合的仿真语法用于 RTL,比如#10、$display、$monitor、initial。这些只允许出现在 testbench 里。RTL 中一旦出现延时,综合工具通常会忽略或产生不一致结果。
第九,涉及总线时统一位宽。assign {carry, sum} = a + b;这种写法要确认两侧位宽一致。高位宽不要隐式依赖 Verilog 自动扩展,建议通过localparam定义位宽,并在仿真 testbench 中检查边界值。
第十,状态机和计数器这类时序逻辑,尽量使用参数化的位宽和状态编码。用localparam定义状态名,用parameter定义计数器宽度,后续修改时只改一处,代码维护成本会明显降低。
11. 总结与下一步
assign、always @(*)和always @(posedge clk)三者的边界其实很清晰:assign描述连续组合逻辑,always @(*)描述过程组合逻辑,always @(posedge clk)描述时序逻辑。assign左侧用wire,always块内左侧用reg;组合逻辑用阻塞赋值=,时序逻辑用非阻塞赋值<=;同一信号只能有一个驱动源。
建议你先不用急着写复杂状态机,而是把半加器、4 选 1 多路选择器和 8 位计数器这三个例子全部在iverilog里跑一遍,打开 VCD 波形观察赋值时刻。重点看三件事:assign是否在输入变化后立即更新输出、always @(*)在条件分支补全后是否不再产生 latch、计数器在异步复位释放后是否每个时钟沿加一。
下一步可以在这个基础上继续做三件事:第一,给计数器增加使能信号和清零信号,体会条件分支在时序逻辑中的写法;第二,把多路选择器升级为参数化优先编码器,对比assign和always @(*)的代码可读性;第三,尝试写一个简单的状态机,用always @(posedge clk)保存状态,用always @(*)生成输出,这样你会对组合逻辑和时序逻辑的配合有更完整的理解。