4. Verilog/VHDL基础回顾:模块化设计思想、常用语法、仿真时间尺度与可综合代码

各位同学,咱们今天聊点实在的。做验证,说白了就是跟代码打交道。你连设计代码都看不懂,那验证也就无从谈起。这一章,我带大家快速过一遍Verilog和VHDL里那些最核心、最常用的东西。别怕,咱们不搞教科书式的罗列,我挑我这些年踩过的坑、用顺手的招儿,讲给你听。

4.1 模块化设计思想:别把鸡蛋放在一个篮子里

模块化,听起来高大上,其实道理很简单。就是把一个复杂的大功能,拆成几个独立的小功能块。每个块干好自己的事,块与块之间通过接口(端口)通信。

我刚开始做项目时,就吃过亏。一个顶层文件写了上千行,所有逻辑揉在一起。后来要改一个计数器,结果把状态机也带崩了。从那以后,我养成了一个习惯:一个模块只干一件事,而且要把这件事干好。

模块化设计的核心原则:

  • 高内聚:模块内部的功能要紧密相关。
  • 低耦合:模块之间的依赖要尽可能少。
  • 接口清晰:端口定义要明确,信号名要见名知意。

举个例子,一个简单的UART接收模块,我会拆成这样:

// 顶层模块:只负责例化子模块和连线
module uart_rx_top (
    input  wire       clk,
    input  wire       rst_n,
    input  wire       rx,
    output wire [7:0] data_out,
    output wire       data_valid
);

    wire baud_tick;
    wire rx_sync;

    // 同步模块:消除亚稳态
    sync_edge u_sync (
        .clk    (clk),
        .rst_n  (rst_n),
        .din    (rx),
        .dout   (rx_sync)
    );

    // 波特率发生器:产生采样时钟
    baud_gen u_baud (
        .clk        (clk),
        .rst_n      (rst_n),
        .baud_tick  (baud_tick)
    );

    // 接收核心:采样并解析数据
    uart_rx_core u_core (
        .clk        (clk),
        .rst_n      (rst_n),
        .baud_tick  (baud_tick),
        .rx         (rx_sync),
        .data_out   (data_out),
        .data_valid (data_valid)
    );

endmodule

你看,顶层模块就像一张电路图,只负责把各个芯片(子模块)连起来。每个子模块的代码都很短,容易读,也容易测。这就是模块化的好处。

4.2 常用语法:always、assign、initial

这三个关键字,是Verilog的基石。我敢说,你写的代码里,90%都离不开它们。咱们一个一个看。

4.2.1 assign:组合逻辑的“连线”

assign 用来描述组合逻辑。说白了,就是一根线,输入变了,输出立刻跟着变。没有时钟,没有延迟(除了门延迟)。

// 一个简单的与门
assign and_out = a & b;

// 一个多路选择器
assign mux_out = sel ? data1 : data0;

注意:assign 的左边必须是 wire 类型。我见过不少新手把 reg 放在左边,编译直接报错。

4.2.2 always:时序逻辑的“心脏”

always 块是时序逻辑的核心。它描述的是“在某个事件触发时,做什么事”。最常用的触发事件是时钟上升沿和复位下降沿。

// 一个带异步复位的D触发器
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        q <= 1'b0;
    else
        q <= d;
end

这里有个关键点:组合逻辑用阻塞赋值(=),时序逻辑用非阻塞赋值(<=)。这是硬性规定,也是新手最容易犯的错。我曾经在一个项目里,因为把时序逻辑里的 <= 写成了 =,导致仿真波形完全不对,排查了整整一天。从那以后,我写代码时都会默念一遍:时序用非阻塞,组合用阻塞。

避坑指南:

我曾经在同一个 always 块里混用了阻塞和非阻塞赋值,结果综合出来的电路和仿真结果完全不一样。记住:同一个 always 块里,要么全用阻塞,要么全用非阻塞,千万别混着来。

4.2.3 initial:仿真的“启动器”

initial 块只在仿真开始时执行一次。它主要用于测试平台(testbench)中,用来初始化信号、生成激励。

// 一个简单的时钟生成器
initial begin
    clk = 0;
    forever #5 clk = ~clk;  // 每5个时间单位翻转一次
end

// 一个复位序列
initial begin
    rst_n = 0;
    #100;
    rst_n = 1;
    #200;
    rst_n = 0;
    #50;
    rst_n = 1;
end

注意:initial 块是不可综合的。你只能在仿真代码里用它,设计代码里千万别写。

4.3 仿真时间尺度(`timescale)

`timescale 是Verilog里的一个编译器指令,用来定义仿真时间单位。它的格式是:

`timescale 时间单位 / 时间精度

比如:

`timescale 1ns / 1ps

意思是:仿真时间单位是1纳秒,时间精度是1皮秒。也就是说,你写 #10,就代表延迟10纳秒。而精度决定了仿真器能分辨的最小时间间隔。

我的习惯:

我个人习惯在测试平台文件的开头加上 `timescale 1ns / 1ps。这样既保证了时间单位够用,又保证了精度足够高。如果精度设得太粗(比如 1ns / 1ns),你写 #0.5 会被四舍五入成 #1,仿真结果可能就不对了。

另外,`timescale 的作用域是文件级的。如果一个工程里有多个文件,每个文件都定义了不同的 `timescale,那仿真器会以最后一个生效的为准。为了避免混乱,我建议:所有文件统一使用同一个 `timescale。

4.4 可综合与不可综合代码的区别

这个问题,我每次面试新人都会问。说白了,可综合的代码,是能变成实际门电路的;不可综合的代码,只能用于仿真。

为什么会有不可综合的代码?因为有些语法是专门为仿真设计的,比如文件读写、延时控制、初始块等。这些功能在真实芯片里根本不存在。

类别 可综合 不可综合
赋值方式 assign、always @(posedge clk) initial、force、release
延时控制 无(或门延迟) #10、#delay
循环 for(固定次数)、generate while、forever
系统函数 无 $display、$monitor、$readmemh、$fopen
数据类型 wire、reg、integer real、time、event

你想想看,如果你在RTL代码里写了个 #10,综合器会怎么处理?它根本不知道“10纳秒”是什么东西。所以,设计代码里,不能出现任何延时控制。

一个简单的判断标准:

如果你写的代码,描述的是“在某个时钟沿做什么事”,那大概率是可综合的。如果你写的代码,描述的是“等一段时间再做什么事”,那大概率是不可综合的。

嗯,这里要注意,有些语法虽然可综合,但综合出来的电路可能不是你想要的。比如 for 循环,如果循环次数不是常数,综合器会展开成一个巨大的组合逻辑,面积和延迟都可能爆炸。所以,能用状态机解决的问题,就别用循环。

4.5 知识体系总览

为了让你更直观地理解这一章的内容,我画了一张图。它把模块化设计、常用语法、时间尺度和可综合性的关系串了起来。

Verilog/VHDL 基础回顾知识体系 Verilog/VHDL 基础 模块化设计 常用语法 仿真时间尺度 可综合 vs 不可综合 高内聚 低耦合 assign always initial 时间单位 / 时间精度 可综合:RTL代码 不可综合:TB代码 核心思想:设计代码(可综合)只描述电路结构,测试代码(不可综合)只描述仿真行为。 两者严格分离,才能保证综合结果与仿真结果一致。

好了,这一章的内容就这些。模块化设计是骨架,常用语法是血肉,时间尺度是时钟,可综合性是红线。把这四点吃透了,后面的验证环境搭建,你就能游刃有余。