Verilog 基础与仿真验证

系统讲解 Verilog 硬件描述语言的核心语义与仿真方法:模块与端口、wire 与 reg 的本质区别、阻塞与非阻塞赋值的时序含义、always 块的三种建模范式、参数化与 generate,并给出 testbench 编写、Icarus Verilog 与 Verilator 仿真流程、波形调试与综合友好代码风格的完整实践。

引言

Verilog 最容易被误解的地方在于:它的语法看起来像 C,但语义完全是另一回事。always 块不是函数,reg 不是变量,<= 不是赋值而是「计划在时钟边沿赋值」。用 C 的思维写 Verilog,得到的不是程序,而是一堆在综合时爆炸的逻辑门,或者在仿真时永远跑不对的时序。

本文的目标是建立正确的硬件心智模型:每一行 Verilog 都在描述一条真实的电路连接或一个真实的存储元件。学会这个模型之后,wire 和 reg 的区别、= 和 <= 的区别就不再是需要背诵的规则,而是自然推论。

内容上,本文从模块与端口开始,依次讲数据类型、赋值语义、always 块建模、参数化设计,然后转入仿真侧:testbench 结构、开源仿真工具链、波形调试,最后给出综合友好代码风格清单。这是一篇偏「语言与工具」的文章,微架构层面的设计方法见 有限状态机与时序设计 。

目录

  1. 模块与端口:硬件的基本封装单元
  2. 数据类型:wire 与 reg 的本质
  3. 阻塞与非阻塞赋值
  4. always 块的三种建模范式
  5. 组合逻辑的正确写法
  6. 时序逻辑的正确写法
  7. 参数化与 generate
  8. 位宽、运算符与常见陷阱
  9. testbench 结构与时序控制
  10. 仿真工具链:iverilog 与 Verilator
  11. 波形调试与断言初探
  12. 综合友好代码风格

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

三个要点:

  1. 复位分支要覆盖块内所有信号。漏掉一个,它上电后就是不确定的 X 值。
  2. 不要在时序块里堆长组合表达式。q <= a + b + c + d; 会被综合成一长串组合逻辑挂在触发器前,成为关键路径。
  3. 一个信号只在一个 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. 综合友好代码风格

同一段功能,不同写法综合出的面积和时序可能差数倍。以下清单是长期实践总结:

  1. 避免 #delay 与 initial 赋初值:delay 不可综合;FPGA 可用 initial 做寄存器初值,但 ASIC 必须靠复位。
  2. 不用 while/for 做无限循环:for 在 RTL 里是「展开成 N 份硬件」,循环次数必须是编译期常量。
  3. 慎用乘除法:* 可映射到 DSP;/ 和 % 除数是常量时才能展开,变量除法会生成巨大组合逻辑。
  4. 时钟只走时钟资源:不要把时钟接进 LUT 或作为数据使用。
  5. 避免组合逻辑输出直接驱动异步控制:毛刺会导致误触发。
  6. casez/casex 要谨慎:casex 把 X 也当通配,仿真可能掩盖 bug。
  7. 一行一个赋值、显式位宽:可读性直接影响验证和维护成本。
// 综合友好:同步读 → 推断 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:可复用硬编码:简单直接
testbenchVerilog:简单场景够用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 时序约束与收敛 ,理解为什么仿真通过的代码仍然可能因为时序不满足而在板上失效。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「芯片与体系结构」更多文章

  1. 可测性设计与测试
  2. 低功耗数字设计
  3. RISC-V 向量扩展 RVV