第一次打开 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 <= 8'd0; else cnt <= cnt + 1'b1; 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 <= 1'b0; q2 <= 1'b0; 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 = 1'b0; q2 = 1'b0; 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 <= 1'b0; out_nb <= 1'b0; 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 = 1'b0; out_blk = 1'b0; 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 <= 2'b00; cout_reg <= 1'b0; 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 = 1'b0; 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 = 1'b0; a = 2'b00; b = 2'b00; cin = 1'b0; #20; rst_n = 1'b1; // 用例 1: 01 + 10 + 0 = 11 a = 2'b01; b = 2'b10; cin = 1'b0; #20; // 用例 2: 10 + 01 + 1 = 00, cout = 1 a = 2'b10; b = 2'b01; cin = 1'b1; #20; $finish; end endmodule时钟用always #5 clk = ~clk;生成,周期是 10ns。激励在initial块里给,这也是仿真代码和可综合设计代码的重要区别:initial不能综合,只用于测试平台。
4.3 仿真波形怎么读
跑完仿真,你会看到组合输出sum_c在激励变化的瞬间就更新,而寄存器输出sum_r要等下一个时钟上升沿才跟着变化。我整理了一张表帮你对照理解:
| 时间点 | 激励变化 | 组合输出sum_c | 寄存器输出sum_r |
|---|---|---|---|
| 0-20ns | 复位有效 | 00 | 00 |
| 20ns | a=01, b=10, cin=0 | 11 | 00(还没到采样沿) |
| 25ns | 时钟上升沿 | 11 | 11(采样到刚才的11) |
| 30ns | a=10, b=01, cin=1 | 00, cout=1 | 11(保持上一拍) |
| 35ns | 时钟上升沿 | 00, cout=1 | 00, cout_reg=1 |
注意一个细节: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只能驱动wire,data偏偏声明成了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 时,因为分不清阻塞和非阻塞,写了一个简单的移位寄存器,仿真结果直接乱套。后来我把两种写法的波形叠在一起看,才真正理解了非阻塞赋值在时钟边沿“并行更新”的特性。这个过程特别像学骑车,光看规则没用,必须自己上去骑一次、摔一次,肌肉记忆才算建立。建议你也把本文的加法器和打拍模块亲手跑一遍,在波形图上看到组合输出与寄存器输出的时序差,比背十遍语法规则都管用。下一讲就可以沿着这个基础继续玩计数器或状态机了。