ARTICLE DETAIL

资讯详情

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

Verilog assign与always用法详解:组合逻辑与时序逻辑核心区别

Verilog assign与always用法详解:组合逻辑与时序逻辑核心区别 各位做 FPGA 和数字 IC 的朋友应该都有体会Verilog 里面最绕不开、也最容易让初学者纠结的就是assign 连续赋值和always 过程块。很多刚接触硬件描述语言的同学先在软件思维的坑里爬不出来总觉得“代码就是从上往下执行的”结果一写 always 就各种踩雷组合逻辑生出了锁存器、时序逻辑用了阻塞赋值、电平敏感和边沿敏感混为一谈。这篇文章就围绕三个核心点展开连续赋值 assign、过程块 always (*) 组合逻辑、always (posedge clk) 时序逻辑。我会把概念、语法、示例、综合结果、常见错误全部串起来讲清楚新手可以按顺序读有基础的朋友可以直接跳到实战和排错部分。1. 背景与核心概念1.1 什么是连续赋值 assign在 Verilog 中assign是连续赋值语句它的作用是把一个表达式的值不间断地驱动到左边信号上。所谓“连续”指的是右边的表达式一旦发生变化左边的信号会立刻跟着更新不需要等待时钟也不需要经过过程块。assign c a b;这行代码描述的是一个与门。只要 a 或 b 任意一个变化c 就会在仿真时间上立即更新。这个行为相当于在硬件上直接拉了一根线把 a、b 经过与门后的结果接在 c 上。assign 的目标信号必须是 wire 类型不能是 reg 类型。从硬件角度看assign 描述的是组合逻辑它是“没有记忆”的。输出只取决于当前输入不依赖历史状态。这也是理解 assign 最关键的一点。1.2 什么是过程块 alwaysalways是过程块语句的开始标志它后面必须跟一组事件控制条件用来决定这个块什么时候被触发执行。always (敏感事件列表) begin // 过程赋值语句 endalways 的强大之处在于它既能描述组合逻辑也能描述时序逻辑具体取决于敏感列表怎么写、过程赋值用什么符号。always (*)或always (a or b)电平敏感描述组合逻辑。always (posedge clk)边沿敏感描述时序逻辑。always (posedge clk or negedge rst_n)边沿敏感带异步复位。always 和 assign 最大的区别在于always 内部必须使用过程赋值目标信号必须是 reg 类型。但是这个 reg 并不是一定对应硬件寄存器它在组合逻辑 always 中只是语法要求综合后仍然是一根线。1.3 为什么三种写法需要区分很多初学者会问既然 assign 能描述组合逻辑为什么还要用 always 描述组合逻辑既然 always 能描述时序逻辑那什么时候该用哪种这个问题是理解 Verilog 编码风格的核心。简单总结如下简单的组合逻辑比如一个与门、一个加法器、一个多路选择器用 assign 最直观。复杂的组合逻辑比如带 if-else 的译码器、优先级选择逻辑用 always (*) 更清晰因为过程块里可以写 if、case更接近软件思维。时序逻辑比如计数器、移位寄存器、状态机必须用 always (posedge clk) 描述并且内部使用非阻塞赋值。这三者不是随便选择的而是对应不同的硬件结构。理解清楚后写出来的代码可读性高综合后的电路也更符合预期。2. 环境准备与版本说明本文代码可以在任意支持 Verilog 编译仿真的环境中运行。常用的组合有仿真工具VivadoXilinx、QuartusIntel、ModelSim、VCS、Verilator编辑器VS Code Verilog 插件、Vim、GVIM语言标准Verilog-2001主流工具都支持如果你的环境是 Vivado新建工程后可以直接添加 Verilog 文件如果用的是 Icarus Verilogiverilog也可以用命令行编译仿真。以 Ubuntu 环境为例安装 iverilog 和 GTKWave 的命令如下sudo apt-get update sudo apt-get install iverilog gtkwaveWindows 环境可以到官网下载安装包或者在 Vivado 中自带的仿真器完成同样操作。本文示例不依赖具体板卡重点演示语法和仿真结果所以版本差异不会影响代码正确性。你在实际工程中只要注意自己工具的 Verilog 标准支持即可比如 Verilog-2001 和 SystemVerilog 的语法有些差异但 assign 和 always 这块是通用的。3. 核心语法拆解3.1 连续赋值 assign 的细节assign 的基本语法格式如下assign LHS RHS;其中LHS左值必须是 wire 类型。RHS右值可以是 wire、reg、常量、表达式、函数调用等。assign 必须用在模块级别不能写在 initial 或 always 内部。一个常用的场景是数据选择器module mux2_1 ( input wire a, input wire b, input wire sel, output wire y ); assign y sel ? b : a; endmodule这个代码综合出来是一个 2 选 1 多路选择器sel 为 0 时输出 asel 为 1 时输出 b。再看一个稍微复杂的例子利用 assign 实现一个字节的按位拼接和位宽变换module byte_merge ( input wire [3:0] high_nibble, input wire [3:0] low_nibble, output wire [7:0] data_out ); assign data_out {high_nibble, low_nibble}; endmodule这里用到的是位拼接运算符{}把两个 4 位信号拼成 8 位输出。assign 的常见误区把 assign 写在 initial 块里。这是错误的assign 只能写在模块级。对 reg 类型信号 assign。编译会报错可以先把信号类型改成 wire或者改写为 always 过程赋值。在多处对同一个信号 assign。同一个 wire 不能被多次驱动否则会产生多驱动冲突综合时会报错或产生不确定行为。3.2 过程块 always (*) 组合逻辑always (*)表示这个过程块对右值表达式中的所有信号敏感。也就是说只要块内读取的任何一个信号发生变化这个块就会被触发执行。module alu_decode ( input wire [1:0] opcode, output reg [3:0] result ); always (*) begin case (opcode) 2b00: result 4b0001; 2b01: result 4b0010; 2b10: result 4b0100; 2b11: result 4b1000; default: result 4b0000; endcase end endmodule这是一个简单的译码器根据 opcode 的不同输出不同的单热码。因为输出在 always 块内被赋值所以 result 声明为 reg 类型。但注意它依然是组合逻辑综合后不会产生寄存器。组合逻辑 always 内部应使用阻塞赋值。继续看一个带优先级的选择逻辑module priority_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 if (req[0]) code 2d0; else code 2d0; end endmodule如果去掉最后的 else综合工具会认为 req 全为 0 时 code 保持不变从而可能推断出锁存器Latch。在组合逻辑中必须保证所有分支都覆盖到否则会产生锁存器。这是新手最容易踩的坑。3.3 过程块 always (posedge clk) 时序逻辑always (posedge clk)表示在时钟信号的上升沿触发执行是描述时序逻辑的标准写法。module counter ( input wire clk, input wire rst_n, output reg [3:0] count ); always (posedge clk or negedge rst_n) begin if (!rst_n) count 4d0; else count count 1b1; end endmodule这是一个 4 位异步复位计数器每个时钟上升沿 count 加一。这里使用的是非阻塞赋值这是时序逻辑的硬性要求。为什么时序逻辑必须用非阻塞赋值因为非阻塞赋值在时间步的末尾才更新变量多个变量在同一个 always 块中赋值时右侧读取到的是进入 always 块之前的值。这样可以避免多个触发器之间产生竞争冒险确保仿真结果和实际硬件行为一致。举个例子always (posedge clk) begin a b; b a; end硬件上这相当于两个触发器交换数据。a 拿到的是旧 bb 拿到的是旧 a。如果改成阻塞赋值always (posedge clk) begin a b; b a; end仿真时 a 先更新为 b然后 b 再更新为已经变化后的 a结果就完全错了。在硬件上综合出来的电路也会和预期不符。时序逻辑 always 内只能使用非阻塞赋值这是业界规范也是仿真和综合结果正确的前提。4. 完整实战案例下面通过一个完整的实战案例把 assign、always 组合逻辑、always 时序逻辑串起来。这个例子的功能是设计一个 4 位计数器计数到 9 时产生一个脉冲信号同时把当前的计数值译码为 7 段数码管显示码。这个例子麻雀虽小五脏俱全包含了三种核心写法。4.1 创建项目结构建议的目录结构如下counter_seg7/ ├── rtl/ │ └── counter_seg7.v ├── sim/ │ └── tb_counter_seg7.v └── README.md4.2 编写核心代码文件路径rtl/counter_seg7.vmodule counter_seg7 ( input wire clk, input wire rst_n, output wire [3:0] count_out, output wire [6:0] seg_data, output wire pulse ); // 内部计数信号 reg [3:0] count_reg; // 1. 时序逻辑计数器 always (posedge clk or negedge rst_n) begin if (!rst_n) count_reg 4d0; else if (count_reg 4d9) count_reg 4d0; else count_reg count_reg 1b1; end // 2. 组合逻辑译码器使用 always (*) reg [6:0] seg_r; always (*) begin case (count_reg) 4d0: seg_r 7b1000000; // 显示 0 4d1: seg_r 7b1111001; // 显示 1 4d2: seg_r 7b0100100; // 显示 2 4d3: seg_r 7b0110000; // 显示 3 4d4: seg_r 7b0011001; // 显示 4 4d5: seg_r 7b0010010; // 显示 5 4d6: seg_r 7b0000010; // 显示 6 4d7: seg_r 7b1111000; // 显示 7 4d8: seg_r 7b0000000; // 显示 8 4d9: seg_r 7b0010000; // 显示 9 default: seg_r 7b1111111; // 熄灭 endcase end // 3. 连续赋值计数器输出和脉冲信号 assign count_out count_reg; assign seg_data seg_r; assign pulse (count_reg 4d9); endmodule这个模块的核心逻辑拆开看count_reg是时序逻辑在每个时钟上升沿更新。seg_r是组合逻辑用 case 语句把 0~9 译码为共阳极数码管的段码1 表示灭0 表示亮。assign语句把内部寄存器的值引出到端口同时用连续赋值产生一个比较脉冲。注意这里的输出端口都是 wire 类型所以需要用 assign 把内部 reg 信号驱动到端口上。4.3 编写测试文件文件路径sim/tb_counter_seg7.vtimescale 1ns / 1ps module tb_counter_seg7; reg clk; reg rst_n; wire [3:0] count_out; wire [6:0] seg_data; wire pulse; // 生成 10ns 周期的时钟 initial clk 0; always #5 clk ~clk; // 复位信号 initial begin rst_n 0; #20; rst_n 1; end // 实例化被测模块 counter_seg7 uut ( .clk(clk), .rst_n(rst_n), .count_out(count_out), .seg_data(seg_data), .pulse(pulse) ); // 打印输出 initial begin $monitor(time%0t, rst_n%b, count_out%d, seg_data%b, pulse%b, $time, rst_n, count_out, seg_data, pulse); #200; $finish; end endmodule4.4 运行与验证如果你用的是 iverilog可以在 sim 目录下执行iverilog -o tb_counter_seg7.vvp tb_counter_seg7.v ../rtl/counter_seg7.v vvp tb_counter_seg7.vvp预期输出片段大致如下time0, rst_n0, count_out 0, seg_data1000000, pulse0 time20, rst_n1, count_out 0, seg_data1000000, pulse0 time25, rst_n1, count_out 1, seg_data1111001, pulse0 time35, rst_n1, count_out 2, seg_data0100100, pulse0 ... time115, rst_n1, count_out 9, seg_data0010000, pulse1 time125, rst_n1, count_out 0, seg_data1000000, pulse0如果想看到波形可以生成 VCD 文件然后使用 GTKWave 打开vvp tb_counter_seg7.vvp -vcd gtkwave tb_counter_seg7.vcd实际项目中使用 Vivado 的话直接在 Behavioral Simulation 中运行 Testbench 即可流程类似。4.5 结果说明仿真结果验证了三个关键行为计数器在时钟上升沿递增复位后从 0 开始计到 9 后归零。这是典型的时序逻辑行为。译码输出 seg_data 会随着 count_reg 的变化立即变化不依赖时钟边沿这是组合逻辑行为。pulse 信号在计数到 9 时为 1其余时间为 0通过连续赋值即可实现。5. 常见问题与排查思路5.1 组合逻辑总是推断出锁存器错误现象综合后报告显示生成了 Latch或 RTL 视图里出现了一个锁存器图标。常见原因always 组合逻辑中 if 缺少 else。case 语句缺少 default。分支内对某些信号没有全部赋值。示例always (*) begin if (en) y a; // 缺少 elseen0 时 y 保持不变产生锁存器 end解决方案always (*) begin if (en) y a; else y b; // 补全所有分支 endcase 语句类似必须加 default 分支。5.2 时序逻辑使用阻塞赋值导致仿真结果错误错误现象仿真结果与预期不一致比如移位寄存器变成了每一位同时更新。常见原因在always (posedge clk)内部使用了阻塞赋值。解决方案时序逻辑统一使用非阻塞赋值。5.3 assign 与 always 驱动同一个信号错误现象编译报错 multiple drivers或者仿真出现 X 态。常见原因对同一个 reg 或 wire 信号既用 assign 驱动又在 always 内赋值。解决方案一个信号只能有一种驱动方式。如果需要在 assign 中用到某个信号可以让 always 内部生成一个辅助 reg再用 assign 输出到端口。上文实战案例就是这么做的。5.4 always (*) 没有列全敏感信号错误现象仿真结果正确但综合后行为不一致或者仿真中有些输入变化后没有看到输出变化。常见原因使用了旧式写法always (a or b)漏掉了某个输入信号。比如逻辑里读取了 c但敏感列表没有 c。解决方案统一使用always (*)让工具自动推导敏感列表。5.5 多时钟驱动同一个寄存器错误现象综合时报错提示信号被多个时钟驱动。常见原因在多个 always 块中对同一个 reg 赋值。解决方案每个 reg 只能在一个 always 块中赋值。这是 Verilog 的硬性规定也是良好编码风格的基础。5.6 常见问题速查表问题现象常见原因解决思路综合出现 Latchif 缺少 else 或 case 缺少 default补全所有分支时序仿真结果错乱时序逻辑用了阻塞赋值改用非阻塞赋值多驱动报错assign 和 always 驱动同一信号信号驱动方式保持唯一输出没变化敏感列表不完整用always (*)功能仿真对但硬件不对仿真没有初始化寄存器增加 rst_n 复位仿真出现 X 态信号未初始化或多个驱动初始化信号、检查驱动6. 最佳实践与工程建议6.1 关于信号类型的命名与使用建议在模块设计中约定清晰的信号命名规则。比如时钟信号以clk结尾复位信号以rst_n结尾低电平有效用_n后缀寄存器信号用_reg后缀。这样做的好处是看代码时一眼就能分辨信号类型和驱动方式。对于数据类型组合逻辑的 assign 输出用 wire过程块输出用 reg这一点不要混用。但在实际工程中很多团队对数据类型选择有一套固定的模板比如把模块内部所有需要跨 always 块的中间信号统一声明为 wire只在 always 内声明局部 reg这种风格一致性也很重要。6.2 组合逻辑的编码规范组合逻辑 always 内统一使用阻塞赋值。确保所有分支都覆盖。不要在组合逻辑中写入任何带记忆性质的反馈比如count count 1这类语句看似能用实则综合时会产生意想不到的电路。组合逻辑块内不要读取同一个 always 块输出的当前值来生成反馈路径否则容易产生组合环路。组合逻辑推荐用小模块拆分不要把一个几十行的复杂 case 全部塞在一个 always 里可读性会大幅降低。6.3 时序逻辑的编码规范时序逻辑 always 内统一使用非阻塞赋值。每个 always 块只处理一个时钟域。复位方式尽量统一要么全异步复位要么全同步复位不要混用。异步复位一般用negedge rst_n并且复位信号必须出现在敏感列表中。不要在时序逻辑里做组合逻辑运算的复杂嵌套需要复杂组合逻辑时拆成单独的 assign 或 always 组合逻辑块。6.4 跨模块信号传递在真实项目中模块与模块之间的信号通常通过端口传递。建议顶层模块用 wire 连接各子模块。子模块内部需要被端口引出的寄存器信号用 assign 或直接端口声明为 output reg。多比特信号统一使用向量类型比如output reg [7:0] data。6.5 仿真与调试建议调试过程中有一个很实用的方法先确认复位是否有效再确认时钟是否产生最后看敏感列表里的信号是否完整。很多仿真异常都可以从这三个方向排查。另外仿真时尽量加上 initial 初始化并且不要依赖全局信号。自己写 Testbench 时推荐把时钟生成和复位生成单独放在两个 initial 块中逻辑清晰且不易出错。6.6 可综合性原则不是所有 Verilog 语法都能综合成硬件。例如 initial 语句主要用于仿真测试延时#10不可综合。本文讲的 assign 和 always (*) 以及 always (posedge clk) 都是可综合语法可以在实际工程中放心使用。需要提醒的是always (*)综合成组合逻辑、always (posedge clk)综合成寄存器但如果你在时序逻辑块中使用了阻塞赋值大部分综合工具会警告或者报错。这并不是语法不允许而是这种代码风格可能导致仿真和硬件不一致所以在工程实践中要坚决避免。7. 总结与学习路线本文围绕 assign 连续赋值、always (*) 组合逻辑、always (posedge clk) 时序逻辑三个核心主题从概念、语法、示例、仿真到排错进行了完整梳理。关键要点可以概括为三条assign 用于简单的组合逻辑驱动目标信号是 wire随时钟和输入自动更新。always (*) 用于复杂的组合逻辑描述内部用阻塞赋值目标信号是 reg但综合后不一定是寄存器。always (posedge clk) 用于时序逻辑描述内部用非阻塞赋值目标信号是 reg综合后对应真实的触发器。新手学习时容易陷入语法细节的泥潭建议先记住“组合逻辑用时序逻辑用一个信号只在一个 always 块中赋值组合逻辑分支要补全”这三条铁律写代码时逐个对照可以避开 90% 的典型错误。下一步可以继续学习状态机的写法与编码风格一段式、两段式、三段式。跨时钟域同步处理两级触发器打拍。阻塞赋值与非阻塞赋值的深入仿真对比。复位策略与时钟树设计。常见总线接口如 UART、SPI、I2C 的 Verilog 实现。实际项目中优先关注代码风格的一致性和信号命名规范这比堆砌功能更重要。建议从今天开始在自己的 Testbench 和 RTL 代码里有意识地区分 assign 和 always、阻塞赋值和非阻塞赋值写完后综合看一下 RTL 视图确认生成的结构符合预期。如果这篇文章对你有帮助可以先收藏备用。编码过程中遇到问题欢迎在评论区交流我也会继续分享更多 Verilog 实战相关的经验和踩坑记录。
返回列表