Verilog 入门第2讲:wire/reg 数据类型与阻塞、非阻塞赋值
第一次打开 Verilog 代码大概谁都会愣一下为什么有的信号声明成wire有的声明成reg为什么一条赋值用的是另一条却变成了这两个问题正好就是 Verilog 入门实战第 2 讲要解决的核心——数据类型和赋值语句。别小看这些语法细节它们决定了你写的代码在硬件上到底长成什么样是一根单纯的连线还是一个触发器或者说一片组合逻辑。搞清楚它们后面写计数器、状态机、存储器接口才不会走弯路。这篇内容适合刚学完硬件描述语言基础语法、准备开始写第一个可综合模块的同学也适合那些从 C 语言转过来、总忍不住把 Verilog 当成“顺序执行的软件”来写的朋友。我会从硬件思维讲起把wire和reg的底层逻辑拆开再带着你走一遍组合逻辑加法器和带寄存器输出的加法器用仿真波形直接看阻塞赋值与非阻塞赋值的差别。这些例子都很小但都是真实工程里每天都在用的结构。1. 先把思维从“软件执行”切换到“硬件并行”1.1 为什么不能用 C 语言的脑子写 Verilog学过 C 语言的同学都知道代码是一条一条从上往下执行的。前一条语句给变量赋了值后一条语句马上就能读到新值。这种顺序执行的思维惯性是学 Verilog 时最先要吃掉的绊脚石。Verilog 描述的是一堆硬件模块在物理空间里同时工作。实际上一个 module 内部的assign语句、always块、initial块在仿真一开始就是并行存在的文本上谁写在前面谁写在后面完全不影响它们的执行顺序。我举个生活中的例子C 语言像一个人按清单办事干完第一件事再干第二件事Verilog 像一个车间里多个工人同时开工每个人只负责自己那条线。这种并行特性意味着你在看 Verilog 代码时不能用“上一行是谁、下一行该执行什么”的思路去推导结果而要想“这个信号由哪个模块或者哪个块来驱动那个信号什么时候会被采样更新”。想通了这一点数据类型和赋值语句才有讨论的基础。1.2 数据类型在说“物理形态”这件事wire和reg这两个关键词经常被初学者当成“变量名前面的修饰词”但实际上它们描述的是信号在硬件里对应的物理形态。wire本质是物理连线。它的值不是自己“存”的而是由驱动源不断决定的。只要驱动源的电平变了wire的值立刻跟着变。reg本质是过程块里的存储型变量。它用于记录值在always或者initial块中被赋值可以保持上次赋的值。关键点来了reg不代表一定综合成寄存器。如果你在always块里用阻塞赋值写了一段纯组合逻辑那个reg类型信号综合之后依然是一根连线。反之如果wire出现在连续赋值assign的左侧它也可能被实现成寄存器输出引脚不对标准综合下assign左侧只能是连线。所以判断信号类型主要看驱动方式而不是名字。做模块设计时我喜欢先画结构图再用文字标清楚每个端口和内部信号是wire还是reg。这个习惯能省掉后面一大半编译报错。2. 数据类型拆解wire、reg 与那些容易被忽略的补充类型2.1 wire 的准确定义与使用边界wire信号描述的是“由连续赋值驱动的连线”它最典型的出现位置就是assign语句左侧。比如module demo_wire ( input wire a, input wire b, output wire y ); assign y a b; endmodule这里y在物理上就是一根与门输出线。只要a或b翻转y在仿真时间上会立即更新。wire型的端口通常不需要显式声明端口列表里写output y之后综合工具默认它就是wire。wire可以被多个assign同时驱动吗从语法上允许例如三态总线场景中用一个assign在使能时驱动数据线另一个assign在非使能时给高阻z。但是无脑让两个assign驱动同一个wire在仿真时很容易出现x状态在综合时也可能演变成总线冲突。工程上要尽量避免这种“多驱动”设计宁可拆信号也不要去挑战。2.2 reg 不是“寄存器”的缩写reg是一个在过程块中被赋值的变量。它最常见的位置是always块里reg [7:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 8d0; else cnt cnt 1b1; end这个cnt被实现成一组触发器是真正意义上的寄存器。但再看这个组合逻辑写法reg y; always (*) begin y a b; endy虽然声明为reg但综合工具会把它优化成一根与门连线不会产生存储单元。所以初学者记住一句话声明reg只是因为你在always块里给它赋值至于硬件是不是寄存器由你写的逻辑本身决定。reg在不被驱动时的初始值在仿真里默认是x这很容易导致波形上出现未知态。所以写可综合代码时一定要给时序逻辑加复位让寄存器上电后先进入确定状态。2.3 参数、常量和时间尺度除了wire和reg还有一些类型在工程里也极为常用只是新手可能不太注意。parameter和localparam用来定义常量。parameter可以被上层模块例化时覆盖比如module counter #( parameter WIDTH 8 ) ( input wire clk, output reg [WIDTH-1:0] cnt );localparam只能在模块内部使用不能被外部修改适合定义状态编码等内部常量。integer是 32 位有符号整型通常只出现在 testbench 和 for 循环里不建议在可综合设计里大规模使用因为综合效率和资源占用都不好控制。还有一个很容易忽略的东西是timescale必须加在 testbench 最前面timescale 1ns/1ps它声明了仿真时间单位和时间精度。缺少这一行#10这样的延时在部分仿真工具里是 10 个时间单位还是 10 纳秒完全取决于工具默认配置很容易造成激励时序错乱。2.4 位宽不匹配少一个比特就可能丢数据位宽不匹配是初学者最容易踩的坑之一。举个例子wire [7:0] data8; wire [3:0] data4; assign data4 data8;赋值时发生位宽截断data8的高 4 位被直接丢掉。仿真时工具一般会提示截断警告但很多人不当回事。如果是在地址计算或者数据总线里出现这种问题轻则功能错误重则总线地址错乱非常难排查。所以写赋值语句之前先想清楚左边的位宽能不能完整装下右边的值需要在模块端口处做好位宽约束必要时用$clog2计算计数器位宽避免“手工估一个位宽”带来的隐患。3. 赋值语句的三条路assign、阻塞赋值与非阻塞赋值3.1 连续赋值 assign给 wire 一个持续的驱动assign语句属于连续赋值只能写在模块级别不能放在always或者initial块里。它描述的是一种持续驱动关系右侧表达式一旦变化左侧wire立刻更新。上面那个与门例子就是最典型的连续赋值。assign也可以做多位拼接赋值我把加法器的进位和结果一起输出assign {cout, sum} a b cin;{cout, sum}是一个拼接操作把 1 位cout和高位的sum拼接成一个多位总线。整个表达式的结果宽度等于左值总宽度这种写法简洁且不容易漏进位。要提醒的是assign左侧只能是wire。如果误把reg写在assign左侧编译工具会直接报reg cannot be driven by assign之类的错误。3.2 阻塞赋值与非阻塞赋值时序逻辑的分水岭过程赋值分为两种阻塞赋值用非阻塞赋值用。它们都只能出现在always或initial块中。阻塞赋值的语义是“立刻生效”。就像 C 语言里的赋值上一条语句刚把a改成新值下一条语句读a读到的就是新值。用它描述组合逻辑时信号像一条流水线一样一个接一个地传播。非阻塞赋值的语义是“先采样后更新”。块内的所有右侧表达式都在进入always块时被采样然后等到块结束再统一把值赋给左侧信号。这模拟的是寄存器在时钟边沿采样的物理行为时钟沿来临时所有触发器同时采样 D 端之后同时更新 Q 端。用一个简单的两级打拍模块来说always (posedge clk or negedge rst_n) begin if (!rst_n) begin q1 1b0; q2 1b0; end else begin q1 din; q2 q1; end end非阻塞风格下q2拿到的是q1在时钟沿之前的旧值所以整体行为是din经过两个时钟周期后从q2输出。如果这里用阻塞赋值always (posedge clk or negedge rst_n) begin if (!rst_n) begin q1 1b0; q2 1b0; end else begin q1 din; q2 q1; end end那么同一个时钟沿内q1先更新成din随后q2立刻拿到q1的新值整体延迟变成了一拍。这不是你想要的“打两拍”效果。更重要的是这种写法在综合后和仿真中的结果可能不一致是时序电路里最危险的一种写法。所以行业里有一条不成文的铁律时序逻辑块里全部用非阻塞赋值组合逻辑块里全部用阻塞赋值不要在同一个always块里混用两种赋值风格。3.3 一个实验看清两种赋值的差别我建议你亲手在仿真工具里跑一遍这个对比module diff_assign ( input wire clk, input wire rst_n, input wire din, output reg mid_ok, output reg out_nb, output reg out_blk ); reg mid_blk; // 非阻塞风格中间变量和输出都非阻塞 always (posedge clk or negedge rst_n) begin if (!rst_n) begin mid_ok 1b0; out_nb 1b0; end else begin mid_ok din; out_nb mid_ok; end end // 阻塞风格为了对比单独写一组 always (posedge clk or negedge rst_n) begin if (!rst_n) begin mid_blk 1b0; out_blk 1b0; end else begin mid_blk din; out_blk mid_blk; end end endmodule在时钟沿到来后out_nb得到的是din延迟两拍的值而out_blk得到的是din延迟一拍的值。打开波形图把三路信号叠在一起看一眼就能理解“统一更新”和“顺序传递”的本质区别。很多书本上写了八百遍的规则真的在波形上看到一次才算真懂。4. 实战两位加法器与带输出寄存器的版本仿真全流程4.1 项目需求与模块划分这一节我们做一个很小的完整项目两位加法器。需求如下输入a[1:0]、b[1:0]和进位输入cin输出sum[1:0]和进位输出cout同时做两个版本纯组合逻辑版本以及结果打一拍后输出的寄存器版本这个设计虽然简单但组合逻辑计算、寄存器采样、异步复位、模块例化这几个核心知识点全都能练到。更重要的是寄存器版本就是流水线的第一级形态后面做复杂处理器或者接口电路本质上都是“组合逻辑计算 → 时钟边沿采样 → 下一级继续处理”这个循环。4.2 设计代码和 testbench先写纯组合逻辑模块module adder_2bit_c ( input wire [1:0] a, input wire [1:0] b, input wire cin, output wire [1:0] sum, output wire cout ); assign {cout, sum} a b cin; endmodule再写带输出寄存器的版本例化上面的组合模块再用always块把结果打一拍module adder_2bit_reg ( input wire clk, input wire rst_n, input wire [1:0] a, input wire [1:0] b, input wire cin, output reg [1:0] sum_reg, output reg cout_reg ); wire [1:0] sum_tmp; wire cout_tmp; adder_2bit_c comb ( .a (a), .b (b), .cin (cin), .sum (sum_tmp), .cout (cout_tmp) ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin sum_reg 2b00; cout_reg 1b0; end else begin sum_reg sum_tmp; cout_reg cout_tmp; end end endmodule注意这里两个模块的端口定义方式组合模块的sum和cout是wire由assign驱动寄存器模块的sum_reg和cout_reg是reg在always块里赋值。这就是类型声明和赋值方式配对的典型示范。testbench 长这样timescale 1ns/1ps module tb_adder; reg clk 1b0; reg rst_n; reg [1:0] a; reg [1:0] b; reg cin; wire [1:0] sum_c; wire cout_c; wire [1:0] sum_r; wire cout_r; adder_2bit_c u_comb ( .a (a), .b (b), .cin (cin), .sum (sum_c), .cout(cout_c) ); adder_2bit_reg u_reg ( .clk (clk), .rst_n (rst_n), .a (a), .b (b), .cin (cin), .sum_reg (sum_r), .cout_reg(cout_r) ); // 生成 10ns 周期时钟 always #5 clk ~clk; initial begin rst_n 1b0; a 2b00; b 2b00; cin 1b0; #20; rst_n 1b1; // 用例 1: 01 10 0 11 a 2b01; b 2b10; cin 1b0; #20; // 用例 2: 10 01 1 00, cout 1 a 2b10; b 2b01; cin 1b1; #20; $finish; end endmodule时钟用always #5 clk ~clk;生成周期是 10ns。激励在initial块里给这也是仿真代码和可综合设计代码的重要区别initial不能综合只用于测试平台。4.3 仿真波形怎么读跑完仿真你会看到组合输出sum_c在激励变化的瞬间就更新而寄存器输出sum_r要等下一个时钟上升沿才跟着变化。我整理了一张表帮你对照理解时间点激励变化组合输出sum_c寄存器输出sum_r0-20ns复位有效000020nsa01, b10, cin01100还没到采样沿25ns时钟上升沿1111采样到刚才的1130nsa10, b01, cin100, cout111保持上一拍35ns时钟上升沿00, cout100, cout_reg1注意一个细节20ns 时组合输出已经是11但寄存器输出仍然是00因为always块只在时钟沿处采样。直到 25ns 的上升沿sum_tmp的值 11 才被锁存进sum_reg。这就是“组合逻辑 寄存器采样”的时序关系属于数字电路里最基础的握手方式。如果你在波形图上同时选中clk、rst_n、sum_c、sum_r这四路信号会非常直观地看到组合输出变化在前、寄存器输出跟随时钟沿更新在后的现象。建议做完这一讲后自己再去改几个测试用例把边界输入也测一遍比如加法溢出时cout的变化。4.4 为什么把加法器拆成“组合 寄存器”两层很多初学者会问直接在一个always块里用sum_reg a b cin;不就行了完全没问题这也是一种常见写法。但我故意拆成两个模块图的是让你看清数据的流动组合逻辑负责计算寄存器负责按时钟节拍锁存结果。拆分的第二个好处是可复用性。adder_2bit_c这个小模块可以被多个地方例化甚至可以继续拼装成 4 位加法器。等后面学到流水线设计时你会发现“组合逻辑级之间插寄存器”是提升系统吞吐量的核心手段。现在养成分层设计的习惯后面自然接得上。5. 新手最容易踩的坑编译报错与仿真行为异常5.1 编译报错的快速排查链路先看一个经典错误reg [1:0] data; assign data a b;编译工具会报reg cannot be driven by assign operators之类的信息。原因很简单assign只能驱动wiredata偏偏声明成了reg。改法有两个方向要么把data改成wire继续用assign要么保留reg把赋值挪到always块里。反向错误也很常见wire y; always (*) begin y a b; end会报procedural assignment to a non-reg y。这时把y改成reg即可。注意y是组合逻辑改成reg后综合出来仍然是一根线不会生成寄存器。我发现一个很有效的排查思路报错信息里出现assign和reg同时出现几乎都是类型与赋值方式不匹配出现procedural assignment和wire同时出现就是 signal 类型选错了。先看报错信号名再确认它被哪条语句驱动80% 的编译错误当场就能定位。5.2 仿真波形不对先查这三个“隐形坑”比编译错误更折磨人的是仿真能跑完但波形和你预期不符。我总结了三个高频原因。第一个是敏感列表不完整。比如always (a) begin c a b; end这里c既依赖a又依赖b敏感列表却只写了a。当b变化而a不变时c不会更新仿真结果自然错。更麻烦的是综合时工具可能推断出锁存器行为完全不符合预期。组合逻辑的always块最稳妥的写法永远是always (*)让工具自动提取敏感列表。第二个是多驱动导致的x态。比如两个always块同时给同一个reg赋值仿真波形上这个信号很可能出现干干巴巴的x。硬件世界里一个信号只能有一个驱动器多个驱动器争夺同一根线属于设计错误。看到波形里出现x优先排查是否有多驱动。第三个是复位不完整。时序逻辑有了异步复位但复位信号本身没在 testbench 里正确拉低拉高寄存器输出初始就是x。处理办法很简单testbench 里先让rst_n保持低电平几个时钟周期再拉高进入正常工作状态。我在前面加法器的 testbench 里就是这么处理的建议你照着这个套路来。5.3 可综合代码的几条训练建议写可综合代码和写 testbench 是两种不同的状态。可综合代码追求能被综合工具映射成实际的触发器和门电路而 testbench 只服务于仿真可以用initial、#延时这些不可综合的语法。我的建议是按照这个顺序来练习先把组合逻辑写好比如多路选择器、译码器、加法器再写带时钟和复位的时序逻辑比如计数器、移位寄存器最后才做状态机和复杂接口。每写一个模块都顺手写一套 testbench在波形图上验证过再进入下一阶段。还有一个训练窍门同一个功能分别用assign、阻塞赋值的always块、非阻塞赋值的always块实现然后对比综合警告。你会更深刻地理解“同样的代码风格差异最终影响的是硬件结构”。我刚开始学 Verilog 时因为分不清阻塞和非阻塞写了一个简单的移位寄存器仿真结果直接乱套。后来我把两种写法的波形叠在一起看才真正理解了非阻塞赋值在时钟边沿“并行更新”的特性。这个过程特别像学骑车光看规则没用必须自己上去骑一次、摔一次肌肉记忆才算建立。建议你也把本文的加法器和打拍模块亲手跑一遍在波形图上看到组合输出与寄存器输出的时序差比背十遍语法规则都管用。下一讲就可以沿着这个基础继续玩计数器或状态机了。