引言
Verilog 最容易被误解的地方在于:它的语法看起来像 C,但语义完全是另一回事。always 块不是函数,reg 不是变量,<= 不是赋值而是「计划在时钟边沿赋值」。用 C 的思维写 Verilog,得到的不是程序,而是一堆在综合时爆炸的逻辑门,或者在仿真时永远跑不对的时序。
本文的目标是建立正确的硬件心智模型:每一行 Verilog 都在描述一条真实的电路连接或一个真实的存储元件。学会这个模型之后,wire 和 reg 的区别、= 和 <= 的区别就不再是需要背诵的规则,而是自然推论。
内容上,本文从模块与端口开始,依次讲数据类型、赋值语义、always 块建模、参数化设计,然后转入仿真侧:testbench 结构、开源仿真工具链、波形调试,最后给出综合友好代码风格清单。这是一篇偏「语言与工具」的文章,微架构层面的设计方法见 有限状态机与时序设计 。
目录
- 模块与端口:硬件的基本封装单元
- 数据类型:wire 与 reg 的本质
- 阻塞与非阻塞赋值
- always 块的三种建模范式
- 组合逻辑的正确写法
- 时序逻辑的正确写法
- 参数化与 generate
- 位宽、运算符与常见陷阱
- testbench 结构与时序控制
- 仿真工具链:iverilog 与 Verilator
- 波形调试与断言初探
- 综合友好代码风格
1. 模块与端口:硬件的基本封装单元
module 是 Verilog 的基本封装单位,对应硬件上的一块电路。端口是它与外界的连接点,每个端口都有方向(input/output/inout)和位宽。
module counter #(
parameter WIDTH = 8,
parameter MAX = 255
) (
input wire clk,
input wire rst_n,
input wire en,
output reg [WIDTH-1:0] cnt,
output wire overflow
);
assign overflow = (cnt == MAX);
always @(posedge clk or negedge rst_n) begin
if (!rst_n) cnt <= {WIDTH{1'b0}};
else if (en) cnt <= (cnt == MAX) ? {WIDTH{1'b0}} : cnt + 1'b1;
end
endmodule
三种端口声明风格的演进值得知道:Verilog-1995 要求方向、类型、位宽分开写,Verilog-2001 引入 ANSI 风格(如上),SystemVerilog 进一步用 logic 统一 wire/reg。新代码一律用 ANSI 风格。
实例化模块永远用命名端口连接:位置连接一旦端口顺序变化就会静默错连,是极难排查的 bug 源。
counter #(.WIDTH(16), .MAX(65535)) u_cnt (
.clk(clk), .rst_n(rst_n), .en(tick),
.cnt(count_out), .overflow(ovf)
);
2. 数据类型:wire 与 reg 的本质
这是 Verilog 新手最困惑的一点。wire 和 reg 的区别不在于「是否存储」,而在于「被谁驱动」:
wire表示网络(net),必须被连续驱动(assign或模块输出端口)。它不能保存值,只能反映当前驱动源的电平。reg在 Verilog 里只是一个语法标签,表示「可以被过程赋值」。它不一定是寄存器——在always @(*)里赋值的reg综合出来是纯组合逻辑。
wire [7:0] sum; // 必须用 assign 驱动
assign sum = a + b;
reg [7:0] y; // 组合逻辑,不是触发器
always @(*) if (sel) y = a; else y = b;
reg [7:0] q; // 这里才是真正的触发器
always @(posedge clk) q <= d;
SystemVerilog 引入 logic 类型,可同时被连续赋值和过程赋值(但不能被两者同时驱动),推荐新代码使用。真正的存储语义由上下文决定:只有「在边沿触发的 always 块里被赋值」才推断出触发器。
3. 阻塞与非阻塞赋值
这是 Verilog 语义中最关键、也最容易出错的一对概念。
- 阻塞赋值
=:像 C 一样立即求值并生效,后面的语句能看到新值。用于组合逻辑。 - 非阻塞赋值
<=:所有右侧在时钟边沿同时求值,左侧在时间步结束时统一更新。用于时序逻辑。
// 两个触发器交换值:正确的非阻塞写法
always @(posedge clk) begin
a <= b;
b <= a; // 读到的是旧的 a,实现真正的交换
end
// 错误示范:用阻塞赋值,两次赋值退化成 b 的值传到 a
always @(posedge clk) begin
a = b; // a 立即变成 b
b = a; // 此时 a 已是 b,等价于 b = b
end
非阻塞赋值之所以能正确建模移位寄存器,是因为它把所有更新推迟到时间步末尾,模拟了「所有触发器同时采样」的物理现实。记住这条黄金规则:时序逻辑用 <=,组合逻辑用 =,同一个信号不要在两个块里赋值。遵守它,90% 的仿真/综合不一致问题都会消失。
4. always 块的三种建模范式
always 块在 RTL 层只有三种合法形态,识别它们就掌握了 Verilog 的骨架:
| 范式 | 敏感列表 | 赋值符号 | 综合结果 |
|---|---|---|---|
| 组合逻辑 | @(*) | = | 纯组合网络 |
| 时序逻辑 | @(posedge clk) | <= | 触发器 |
| 时序+异步复位 | @(posedge clk or negedge rst_n) | <= | 带异步复位的触发器 |
任何「混用」都是错误信号:在 @(posedge clk) 里用 =,或者在 @(*) 里用 <=,都可能让仿真与综合结果不一致。SystemVerilog 用 always_comb / always_ff / always_latch 把意图显式化,工具会主动检查——推荐新代码采用。
一个必须避开的陷阱:敏感列表不完整。用 @(a) 但块里还读了 b,仿真时 b 变化不会触发块,结果与综合(组合逻辑)不一致。这就是为什么 @(*) 和 always_comb 是唯一正确的选择。
5. 组合逻辑的正确写法
组合逻辑块必须满足两条:敏感列表完整、所有输出在所有分支都被赋值。违反第二条就会推断出锁存器。
// 反例:else 缺失,推断出锁存器
always @(*) begin
if (sel) y = a; // sel=0 时 y 保持不变 → 需要存储 → latch
end
// 正例:先赋默认值,消除 latch
always @(*) begin
y = 1'b0;
if (sel) y = a;
end
对于 case 语句,务必加 default 分支。综合工具对缺 default 的 case 会推断锁存器并给出 warning;即使用户认为「所有情况都覆盖了」,也要显式写出 default,因为 X 态或非法编码会落入未定义分支。SystemVerilog 的 unique case 还能让仿真器在分支不互斥或未覆盖时直接报错。
always_comb begin
unique case (op)
2'b00: result = a + b;
2'b01: result = a - b;
2'b10: result = a & b;
default: result = a | b;
endcase
end
组合逻辑的另一个实践要点是优先用 assign 表达简单逻辑:assign y = (a & b) | c; 比一个 always 块更清晰,也更难写错。
6. 时序逻辑的正确写法
时序逻辑块的模板非常固定,记住它就能写对绝大多数触发器:
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
q1 <= 1'b0; // 复位分支要覆盖块内所有信号
q2 <= 8'd0;
end else begin
q1 <= d1;
q2 <= d2;
end
end
三个要点:
- 复位分支要覆盖块内所有信号。漏掉一个,它上电后就是不确定的 X 值。
- 不要在时序块里堆长组合表达式。
q <= a + b + c + d;会被综合成一长串组合逻辑挂在触发器前,成为关键路径。 - 一个信号只在一个 always 块里赋值。多处驱动会综合出多驱动冲突,仿真可能侥幸通过,上板必错。
把长组合路径切开,就是流水线的基本手法:
always @(posedge clk) begin
mult_stage <= a * b; // 第 1 级:乘法(映射到 DSP)
acc_stage <= acc_stage + mult_stage; // 第 2 级:累加
end
7. 参数化与 generate
可复用模块靠 parameter 和 generate 实现。parameter 在编译期确定,localparam 是内部常量(不可被覆盖),generate 在展开期复制结构。
module fifo #(parameter DEPTH = 16, parameter WIDTH = 8) (
input wire clk, rst_n, wr_en, rd_en,
input wire [WIDTH-1:0] din,
output reg full,
output wire [WIDTH-1:0] dout
);
localparam AW = $clog2(DEPTH); // 地址位宽自动推导
reg [WIDTH-1:0] mem [0:DEPTH-1];
reg [AW:0] wptr, rptr; // 多一位用于判满
assign dout = mem[rptr[AW-1:0]];
assign empty = (wptr == rptr);
assign full = (wptr[AW] != rptr[AW]) && (wptr[AW-1:0] == rptr[AW-1:0]);
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
wptr <= '0; rptr <= '0;
end else begin
if (wr_en && !full) begin mem[wptr[AW-1:0]] <= din; wptr <= wptr + 1'b1; end
if (rd_en && !empty) rptr <= rptr + 1'b1;
end
end
endmodule
这里用「多一位指针」判断满:wptr 比 rptr 多一圈时最高位不同、低位相同即为满,比维护额外计数器更省资源,也是 FIFO 的标准实现手法。
generate 用于按参数展开重复结构,是写脉动阵列、加法器树的基础:
genvar i;
generate
for (i = 0; i < N; i = i + 1) begin : gen_mul
wire [2*W-1:0] prod = a[i] * b[i];
assign stage0[i] = prod;
end
endgenerate
注意 $clog2、'0(全零)这类 SystemVerilog 语法在 Icarus 的 -g2012 模式下才支持,写可移植代码时要留意工具的版本开关。
8. 位宽、运算符与常见陷阱
位宽不匹配是 Verilog 最隐蔽的 bug 来源,因为它是静默截断的。
reg [7:0] a = 8'hFF, b = 8'h01;
reg [7:0] c;
reg [8:0] d;
always @(*) begin
c = a + b; // 8'hFF + 8'h01 = 9'h100,赋给 8 位 c → 截断为 8'h00
d = a + b; // 9 位 d 正确得到 9'h100
end
要点:
- 算术表达式的位宽由最宽的操作数决定,但赋值给更窄的目标时会截断。
- 有符号与无符号混用:
reg signed与无符号混算时,无符号会把有符号「拉平」,比较结果反直觉。要么全有符号,要么全无符号。 =与==混淆:if (a = b)是常见笔误,Verilog 不报错但语义完全错误。x与z:仿真中x表示未知、z表示高阻。综合工具对x的处理是实现相关的,不要依赖x做设计。
运算符速查:位运算 & | ~ ^ ~^,逻辑运算 && || !(返回 1 位),归约运算 &a |a ^a(单目,逐位归约成 1 位),移位 << >> <<< >>>(后两者为算术移位),拼接 {a, b} 与重复 {4{a}},条件 sel ? a : b。
9. testbench 结构与时序控制
testbench 是不可综合的仿真代码,它不描述硬件,而是描述「怎么激励硬件、怎么检查结果」。它的写法与 RTL 完全不同,可以用 initial、#delay、$display、$finish 这些综合工具不支持的构造。
`timescale 1ns/1ps
module tb_counter;
reg clk = 0, rst_n = 0, en = 0;
wire [7:0] cnt;
counter #(.WIDTH(8), .MAX(255)) dut (
.clk(clk), .rst_n(rst_n), .en(en), .cnt(cnt), .overflow()
);
always #5 clk = ~clk; // 周期 10ns(100MHz)
initial begin
$dumpfile("tb_counter.vcd");
$dumpvars(0, tb_counter);
rst_n = 0;
repeat (3) @(posedge clk); // 保持复位 3 拍
rst_n = 1;
en = 1;
repeat (10) @(posedge clk);
if (cnt !== 8'd10) $fatal(1, "expected 10, got %0d", cnt);
en = 0;
repeat (5) @(posedge clk);
if (cnt !== 8'd10) $fatal(1, "counter must hold when en=0");
$display("PASS");
$finish;
end
endmodule
几个实践要点:
- 用
!==而非!=:!==把 X/Z 也当作确定值比较,能捕获未初始化信号。 repeat (n) @(posedge clk)是等待 n 个时钟的标准写法,比裸#100更鲁棒(时钟周期变了也不用改)。$fatal让仿真在失败时以非零状态退出,是 CI 集成的关键。$dumpvars输出 VCD 波形,配合 GTKWave 查看。
10. 仿真工具链:iverilog 与 Verilator
两个最常用的开源仿真器定位不同:
| 工具 | 类型 | 特点 | 适合场景 |
|---|---|---|---|
| Icarus Verilog | 事件驱动解释器 | 支持 Verilog-2005 大部分 + 部分 SV,编译快 | 日常功能仿真、教学 |
| Verilator | 编译型(转 C++) | 极快(10~100 倍),支持 SV 子集,要求严格 | 大规模验证、CI、与 C++ 联仿 |
# Icarus:-g2012 开启 SystemVerilog 子集,-Wall 打开全部警告
iverilog -g2012 -Wall -o tb_counter.vvp tb_counter.v counter.v
vvp tb_counter.vvp # 运行,生成 VCD
gtkwave tb_counter.vcd & # 看波形
# Verilator:转 C++ 模型,编译成可执行文件
verilator --cc --exe --build -Wall -Wno-fatal \
--trace --top-module tb_counter tb_counter.sv counter.sv
./obj_dir/Vtb_counter
Verilator 的严格性既是优点也是门槛:它要求代码是「可综合风格」的,不支持 #delay 精确时序、不支持 initial 中的复杂时序。为了用它做验证,testbench 通常要写成 C++ 或 SystemVerilog 的受限子集。这个取舍在 SystemVerilog 验证与 testbench
里会展开。
11. 波形调试与断言初探
波形是硬件调试的「printf」。GTKWave 或 Surfer 能加载 VCD/FST,展开信号层次、加标记、测时间差。调试时的常用技巧:
- 先看时钟和复位:90% 的「不工作」是因为时钟没起来或复位没释放。
- 用分组管理信号:把相关信号拖进一个 group,缩放查看变化。
- 加 marker 测间隔:验证握手信号之间的周期数是否符合预期。
- 关注 X 值:波形上的红色 X 表示未初始化或冲突驱动,是 bug 的强信号。
除了波形,断言是更主动的检查手段。SystemVerilog 的并发断言(SVA)能描述跨周期的时序性质,例如「请求后 1~3 拍内必须有响应」:
property p_ack_timely;
@(posedge clk) disable iff (!rst_n)
req |-> ##[1:3] ack;
endproperty
a_ack_timely: assert property (p_ack_timely)
else $error("ack not within 1-3 cycles");
12. 综合友好代码风格
同一段功能,不同写法综合出的面积和时序可能差数倍。以下清单是长期实践总结:
- 避免
#delay与initial赋初值:delay 不可综合;FPGA 可用initial做寄存器初值,但 ASIC 必须靠复位。 - 不用
while/for做无限循环:for在 RTL 里是「展开成 N 份硬件」,循环次数必须是编译期常量。 - 慎用乘除法:
*可映射到 DSP;/和%除数是常量时才能展开,变量除法会生成巨大组合逻辑。 - 时钟只走时钟资源:不要把时钟接进 LUT 或作为数据使用。
- 避免组合逻辑输出直接驱动异步控制:毛刺会导致误触发。
casez/casex要谨慎:casex把 X 也当通配,仿真可能掩盖 bug。- 一行一个赋值、显式位宽:可读性直接影响验证和维护成本。
// 综合友好:同步读 → 推断 Block RAM
always @(posedge clk) begin
if (we) mem[addr] <= din;
dout <= mem[raddr]; // 异步读 assign dout = mem[raddr] 会用 LUT 堆成分布式 RAM
end
权衡取舍
| 决策点 | 选项 A | 选项 B |
|---|---|---|
| 仿真器 | Icarus:上手快、语法宽容 | Verilator:快 10~100 倍、严格 |
| 赋值风格 | 非阻塞 <=:时序语义正确 | 阻塞 =:组合逻辑清晰 |
| 复位 | 异步复位:上电可靠、FPGA 免费 | 同步复位:时序友好、易做 STA |
| 编码风格 | 显式默认值:无 latch | 紧凑 if/else:代码短但有风险 |
| 参数化 | parameter + generate:可复用 | 硬编码:简单直接 |
| testbench | Verilog:简单场景够用 | SystemVerilog/UVM:复杂验证必需 |
选择的核心依据是项目规模:几百行的模块用 Icarus + Verilog testbench 完全够;上万行、多接口的 SoC 验证必须上 SystemVerilog + UVM,否则覆盖率无法收敛。
常见坑清单
- 敏感列表不完整:
@(a)但读了b,仿真与综合不一致,务必用@(*)或always_comb。 - 锁存器意外推断:
if缺else、case缺default,综合工具报 warning 但设计可能「能用」,必须清零这类 warning。 - 阻塞/非阻塞混用:时序块里用
=导致仿真通过、综合出不同电路。 - 位宽静默截断:
c = a + b目标位宽不足,高位丢失且不报错,关键算术务必加宽一位。 - 有符号无符号混算:比较和移位结果反直觉,统一符号性。
- 多驱动同一信号:两个 always 块赋同一个
reg,综合报 multiple driver。 =与==混淆:if (a = b)是赋值不是比较,Verilog 不报错但逻辑完全错。- 用
#delay写 RTL:仿真能跑,综合直接忽略或报错,delay 只能出现在 testbench。 initial赋初值依赖综合器:FPGA 可行、ASIC 不可行,跨平台代码要靠复位初始化。- 模块实例化用位置连接:端口顺序一变就静默错连,永远用
.name(sig)命名连接。 - 忘了
timescale:testbench 的#10语义随默认时间单位变化,务必显式声明。
小结
Verilog 的学习曲线陡峭,根源在于它要求你同时理解「语言语义」和「它描述的硬件」。抓住三条主线就能建立正确模型:wire/reg 的区别是驱动方式而非存储能力、非阻塞赋值模拟了触发器的同时采样、always 块只有三种合法范式。把这三条内化之后,读任何 Verilog 代码都能迅速在脑中还原出电路结构。
工具侧,建议先用 Icarus Verilog 快速迭代,写小模块 + testbench + 波形,建立「改代码 → 看波形」的反馈闭环;等设计规模上来了再引入 Verilator 提升仿真速度,或转向 SystemVerilog 做结构化验证。
下一步建议阅读 有限状态机与时序设计 ,把组合逻辑和触发器组合成真正的控制系统;之后再进入 FPGA 时序约束与收敛 ,理解为什么仿真通过的代码仍然可能因为时序不满足而在板上失效。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。