3、RTL设计与仿真:编写Verilog代码、行为仿真、波形查看与调试

好了,咱们进入正题。这一章是FPGA设计的核心环节——写代码、跑仿真、看波形、找问题。说白了,就是把你脑子里的逻辑,变成机器能理解的语言,再验证它对不对。

我刚开始学的时候,总觉得仿真可有可无,代码写完直接上板子跑多痛快。结果呢?板子亮了,功能不对,找bug找到怀疑人生。后来我学乖了——仿真做得好,调试时间能省下一大半。

3.1 编写Verilog代码——从模块开始

Verilog的基本单元是模块(module)。一个模块就像一个小黑盒,有输入、有输出,里面装着你要实现的逻辑。

先看一个最简单的例子——D触发器:

module d_flip_flop (
    input  wire       clk,    // 时钟
    input  wire       rst_n,  // 异步复位,低有效
    input  wire       d,      // 数据输入
    output reg        q       // 数据输出
);

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            q <= 1'b0;
        else
            q <= d;
    end

endmodule

这里有几个关键点:

  • 端口声明:input/output,wire/reg要分清。我见过有人把input声明成reg,仿真报错半天找不到原因。
  • 时序逻辑:用always @(posedge clk or negedge rst_n)描述。敏感列表里时钟沿和复位沿都要写全。
  • 阻塞赋值(=) vs 非阻塞赋值(<=):时序逻辑用非阻塞,组合逻辑用阻塞。这是铁律,别问为什么,记住就行。
个人习惯:我写模块时,喜欢把输入信号全部声明为wire,输出信号如果是寄存器类型就声明为reg,组合逻辑输出也用wire。这样结构清晰,不容易混。

3.2 行为仿真——让代码动起来

代码写完了,怎么知道它对不对?写个testbench,给它喂激励,看它怎么反应。

Testbench也是一个module,但它没有输入输出端口。它的任务就是:

  1. 实例化被测试模块(DUT)
  2. 生成时钟和复位信号
  3. 施加测试激励
  4. 检查输出结果

来看刚才那个D触发器的testbench:

`timescale 1ns / 1ps

module tb_d_flip_flop;

    reg  clk;
    reg  rst_n;
    reg  d;
    wire q;

    // 实例化DUT
    d_flip_flop u_dut (
        .clk  (clk),
        .rst_n(rst_n),
        .d    (d),
        .q    (q)
    );

    // 生成时钟:周期10ns
    initial begin
        clk = 0;
        forever #5 clk = ~clk;
    end

    // 施加激励
    initial begin
        // 初始化
        rst_n = 0;
        d     = 0;
        #20;
        // 释放复位
        rst_n = 1;
        #10;
        // 改变数据
        d = 1;
        #10;
        d = 0;
        #10;
        d = 1;
        #20;
        // 结束仿真
        $finish;
    end

    // 监控输出
    initial begin
        $monitor("Time=%0t, clk=%b, rst_n=%b, d=%b, q=%b", $time, clk, rst_n, d, q);
    end

endmodule

这里要注意:

  • `timescale定义了时间单位和精度。1ns/1ps表示时间单位1ns,精度1ps。
  • initial块里的语句是顺序执行的,#5表示延迟5个时间单位。
  • $monitor会在信号变化时自动打印,调试利器。
我曾经踩过的坑:写testbench时忘记加$finish,仿真跑起来没完没了,电脑风扇呼呼转。后来养成习惯,每个testbench最后都加上$finish或设置仿真时间上限。

3.3 波形查看——用眼睛找bug

光看打印信息不够直观,波形才是王道。Vivado自带的仿真器(xsim)可以生成波形文件(.wcfg),用Vivado的波形窗口打开。

操作步骤:

  1. 在Vivado的Flow Navigator中点击Run Simulation → Run Behavioral Simulation
  2. 仿真启动后,在Scope窗口找到你要看的信号
  3. 右键信号,选择Add to Wave Window
  4. 点击Run All跑完仿真,波形就出来了

波形窗口里,你可以:

  • 放大/缩小:用鼠标滚轮或工具栏的+/-按钮
  • 测量时间:按住Ctrl键,在波形上拖拽,会显示时间差
  • 添加标记:右键波形,选择Set Marker,方便定位关键时间点
  • 改变显示进制:右键信号,选择Radix,可以切换二进制、十进制、十六进制
看波形的技巧:别从头看到尾,先找到关键事件点(比如复位释放、数据变化),然后看这些点前后的信号是否符合预期。我一般会先看时钟和复位,确认它们正常,再看数据路径。

3.4 调试——找到问题根源

波形出来了,功能不对,怎么办?别慌,按步骤来:

  1. 确认时钟和复位:时钟有没有起振?复位有没有释放?这是最基础的问题。
  2. 检查输入信号:testbench给的激励对不对?有没有毛刺?
  3. 跟踪关键信号:从输入到输出,一级一级看。比如计数器没计数,先看时钟,再看使能信号,再看复位。
  4. 对比预期值:用手算一遍,看波形上的值对不对得上。

举个例子,假设你写了一个计数器,预期是每个时钟周期加1,但波形显示它一直为0。你会怎么查?

  • 先看时钟——有波形,没问题
  • 再看复位——复位已经释放了,没问题
  • 再看使能信号——嗯?使能一直为0?
  • 找到问题了:testbench里忘记把使能拉高了

你看,一步步排查,问题很快就找到了。

我的调试习惯:在代码里加一些调试用的内部信号,比如状态机的当前状态、计数器的中间值。把这些信号也加到波形里,能帮你更快定位问题。但记得仿真完要删掉,别留到综合里。

3.5 知识结构总览

下面这张图总结了RTL设计与仿真的核心流程,你可以对照着看:

RTL设计与仿真流程 编写Verilog代码 编写Testbench 运行行为仿真 查看波形 调试与修改 发现问题,返回修改 核心原则:仿真做得越充分,上板调试的时间越短。不要跳过仿真直接上板!

3.6 常见问题与避坑指南

问题 现象 解决方法
仿真结果全是X 波形显示红色或未知态 检查复位是否释放、时钟是否起振、信号是否初始化
仿真结果全是Z 波形显示高阻态 检查端口是否漏接、wire类型是否未驱动
仿真跑不完 仿真一直运行不停止 检查是否有死循环、是否漏了$finish
波形与预期不符 输出值不对 逐级检查内部信号,确认逻辑是否正确
我曾经犯过的错:有一次仿真结果全是X,我查了半天,最后发现是testbench里时钟生成写成了forever #5 clk = clk,忘记取反了。时钟一直为0,所有时序逻辑都处于未初始化状态。这种低级错误,写代码时多看一眼就能避免。

好了,这一章的内容就这些。记住:写代码是基础,仿真才是关键。多花时间在仿真上,后面会省很多事。去试试吧,把刚才的D触发器代码和testbench跑起来,看看波形是不是符合预期。