ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Verilog核心解析:assign、always组合逻辑与时序逻辑对比

Verilog核心解析:assign、always组合逻辑与时序逻辑对比 这次我们直接切入 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)时序逻辑关键字assignalways (*)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 Verilogiverilog配合 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) 2d0: y in[0]; 2d1: y in[1]; 2d2: y in[2]; 2d3: y in[3]; default: y 1bx; endcase end endmodule这里y声明为output reg因为在always块内赋值。case语句完整覆盖了sel的四种状态所以不会产生 latch。如果不写default在sel为2bxx或2bzz时综合工具会认为某些输入组合下y不需要保持旧值从而可能推断出锁存器。5.3 条件不完整导致 latchalways (*)里最典型的坑是某个分支下没有给输出赋值。例如always (*) begin if (enable) begin y a; end end当enable 0时y没有任何语句赋值硬件为了保持原值只能引入锁存器。大多数设计里 latch 不是想要的结果综合报告会出现Latch inferred警告。解决方法是给输出赋默认值always (*) begin y 1b0; 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 1b0; end else begin q d; end end敏感列表里同时包含时钟上升沿和复位下降沿。复位信号rst_n低电平有效时无论时钟是否到来复位都能立即将q清零。同步复位则写作always (posedge clk) begin if (!rst_n) begin q 1b0; 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 8d0; end else begin cnt cnt 1b1; end end endmodule这个计数器每来一个时钟上升沿就加一。复位信号rst_n为低电平时cnt异步清零。使用的好处是所有寄存器在当前时钟沿先读取旧值再统一更新因此cnt cnt 1b1不会出现“一步加两次”的问题。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 4b0; 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 选择指南需求推荐写法示例简单逻辑表达式assignassign 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为例先写一个 testbenchtimescale 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 1b0; rst_n 1b0; #20 rst_n 1b1; #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 示例对于半加器可以写一个自动对比的 testbenchtimescale 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 inferredalways (*)中 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 1b1的位宽显式定义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左侧用wirealways块内左侧用reg组合逻辑用阻塞赋值时序逻辑用非阻塞赋值同一信号只能有一个驱动源。建议你先不用急着写复杂状态机而是把半加器、4 选 1 多路选择器和 8 位计数器这三个例子全部在iverilog里跑一遍打开 VCD 波形观察赋值时刻。重点看三件事assign是否在输入变化后立即更新输出、always (*)在条件分支补全后是否不再产生 latch、计数器在异步复位释放后是否每个时钟沿加一。下一步可以在这个基础上继续做三件事第一给计数器增加使能信号和清零信号体会条件分支在时序逻辑中的写法第二把多路选择器升级为参数化优先编码器对比assign和always (*)的代码可读性第三尝试写一个简单的状态机用always (posedge clk)保存状态用always (*)生成输出这样你会对组合逻辑和时序逻辑的配合有更完整的理解。
返回列表