3. 硬件描述语言基础(Verilog):模块化设计、组合逻辑与时序逻辑、always块与assign语句、仿真与测试平台

说实话,Verilog 这门语言,刚接触时觉得它跟 C 语言长得挺像。但写了两周你就会发现——完全不是一回事。C 是顺序执行的,Verilog 是并发的。这个思维转变,是新手最容易卡住的地方。

我个人习惯把 Verilog 看作「描述电路的语言」,而不是「写程序的工具」。你写的每一行代码,最终都会变成实实在在的门电路、触发器和连线。脑子里要有这个画面,代码才不会跑偏。

3.1 模块化设计:把大电路拆成小积木

一个复杂的硬件系统,没人能一口气写完。模块化设计就是把它拆成多个小模块,每个模块负责一个功能。就像搭积木,每个积木简单,组合起来就强大。

一个 Verilog 模块的基本结构长这样:

module counter (
    input  wire       clk,
    input  wire       rst_n,
    input  wire       en,
    output reg  [7:0] count
);

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            count <= 8'd0;
        else if (en)
            count <= count + 1'b1;
    end

endmodule

每个模块都有三要素:端口定义、内部逻辑、实例化。端口就是模块的「手脚」,用来跟外界通信。内部逻辑是模块的「大脑」,决定它做什么。

我在项目中遇到过一个问题:一个同事把所有功能写在一个大模块里,足足 2000 行。后来要改一个功能,找了半天才找到位置,还改出了 bug。从那以后,我坚持每个模块不超过 300 行。超过就拆。

小技巧:模块的端口尽量用 wire 和 reg 明确声明。别偷懒用默认类型,后期维护时你会感谢自己的。

3.2 组合逻辑与时序逻辑:两个世界的分界线

这是 Verilog 里最核心的概念,没有之一。搞混了,你的电路就会「跑飞」。

组合逻辑:输出只取决于当前输入。没有记忆功能。比如一个加法器,输入 1+1,输出就是 2。输入变了,输出立刻变。

时序逻辑:输出不仅取决于当前输入,还取决于之前的状态。它有记忆功能。比如计数器,它记得自己数到哪了。

你想想看,为什么要有这两种逻辑?因为现实世界需要「记忆」。CPU 要记住指令,状态机要记住当前状态,这些都靠时序逻辑。

记住这个原则:
  • 组合逻辑用 assign 或 always @(*)
  • 时序逻辑用 always @(posedge clk)
  • 千万别混用!

我曾经犯过一个低级错误:在时序逻辑的 always 块里用了阻塞赋值 =,结果仿真波形完全不对。查了两天才发现是赋值方式的问题。嗯,这里要注意——时序逻辑用非阻塞赋值 <=,组合逻辑用阻塞赋值 =。这是铁律。

3.3 always块与assign语句:两种描述方式

说白了,Verilog 就两种描述电路的方式:assign 和 always。

assign 语句:用来描述组合逻辑。它连续赋值,只要右边变了,左边立刻更新。

assign sum = a + b;
assign sel = (addr > 8'hFF) ? 1'b1 : 1'b0;

always 块:既可以描述组合逻辑,也可以描述时序逻辑。关键看敏感列表怎么写。

// 组合逻辑
always @(*) begin
    if (sel)
        out = a;
    else
        out = b;
end

// 时序逻辑
always @(posedge clk) begin
    if (rst_n)
        q <= 0;
    else
        q <= d;
end

我个人习惯:简单的组合逻辑用 assign,复杂的用 always @(*)。时序逻辑一律用 always @(posedge clk)。这样代码读起来一目了然。

避坑指南:在 always @(*) 里,如果某个分支没写全,综合时会生成锁存器(latch)。这玩意通常不是你想要的。我曾经因为一个 if 少了 else,导致芯片功耗超标。所以,组合逻辑的 always 块,一定要把所有条件写全。

3.4 仿真与测试平台:不仿真的代码都是耍流氓

写完了代码,怎么知道它对不对?靠仿真。仿真就是给电路输入各种激励,看它输出是否符合预期。

测试平台(testbench)是仿真的核心。它没有端口,内部实例化被测试模块,然后产生激励信号。

module tb_counter;

    reg        clk;
    reg        rst_n;
    reg        en;
    wire [7:0] count;

    // 实例化被测试模块
    counter u_counter (
        .clk  (clk),
        .rst_n(rst_n),
        .en   (en),
        .count(count)
    );

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

    // 产生激励
    initial begin
        rst_n = 0;
        en    = 0;
        #20;
        rst_n = 1;
        #10;
        en = 1;
        #100;
        en = 0;
        #50;
        $finish;
    end

    // 监控输出
    initial begin
        $monitor("time=%0t, count=%d", $time, count);
    end

endmodule

你看,测试平台里用了 initial 块来产生激励。这是仿真特有的结构,不能综合成硬件。它只在仿真时存在。

我建议你养成一个习惯:写模块之前,先写测试计划。列出要测哪些功能、边界条件、异常情况。然后根据测试计划写 testbench。这样不会漏测。

经验之谈:仿真时多用 $monitor 和 $display 打印关键信号。波形图虽然直观,但有时候看打印信息更快定位问题。我调试时经常两者结合着用。

3.5 本章知识体系

下面这张图总结了本章的核心逻辑。你看一眼,就能把知识点串起来。

Verilog 硬件描述语言基础 — 知识体系 模块化设计 端口定义 · 实例化 · 层次化结构 组合逻辑 assign · always @(*) · 阻塞赋值 时序逻辑 always @(posedge clk) · 非阻塞赋值 仿真与测试平台 testbench · 激励生成 · 波形验证 核心原则:组合逻辑无记忆,时序逻辑有记忆 仿真验证是硬件设计的最后一道防线

这张图把本章内容串起来了。模块化是骨架,组合逻辑和时序逻辑是血肉,仿真测试是检验手段。三者缺一不可。

最后说一句:Verilog 不难,难的是建立「硬件思维」。多写、多仿、多查波形,慢慢就上手了。别怕犯错,我到现在偶尔还会翻车呢。

可可精品资料库