第3章:硬件描述语言入门(Verilog)

好,咱们开始聊Verilog。说实话,很多初学者一听到「硬件描述语言」这六个字就头大,觉得这玩意儿跟C语言差不多。嗯,我当年也这么想过,结果第一次写出来的代码,综合器直接给我报了一堆警告——说白了,Verilog不是写软件,是在画电路。

我个人习惯把Verilog当成「用文字画电路图」的工具。你写的每一行代码,最终都会变成实实在在的门电路、触发器和连线。这个思维转变,是入门最关键的一步。

3.1 Verilog基本语法

先看一个最简单的例子。我刚开始带新人时,总让他们先写一个与门:

module and_gate (
    input  wire a,
    input  wire b,
    output wire y
);
    assign y = a & b;
endmodule

看到没?moduleendmodule就像一对括号,把整个电路包起来。inputoutput声明端口方向,wire表示连线型变量。这里有个坑——我曾经见过有人把wirereg搞混,结果仿真死活不对。

注意:wire是连线,reg是寄存器。在always块里赋值必须用reg,但综合出来不一定是寄存器——这个反直觉的点,我当年也栽过跟头。

常用的数据类型就两种:

  • wire:组合逻辑连线,用assign赋值
  • reg:在always块里赋值,可综合成寄存器或组合逻辑

数值表示也很简单:4'b1010表示4位二进制数1010,8'hFF表示8位十六进制数FF。我个人习惯用二进制写测试向量,用十六进制写地址——这样一眼就能看出位宽。

3.2 模块化设计

做项目时,我从来不会把所有逻辑塞进一个模块。你想想看,一个几千行的Verilog文件,出bug了你找都找不到。模块化设计,说白了就是「分而治之」。

举个例子,一个计数器模块:

module counter #(
    parameter WIDTH = 8
)(
    input  wire             clk,
    input  wire             rst_n,
    input  wire             en,
    output reg [WIDTH-1:0]  count
);
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            count <= 0;
        else if (en)
            count <= count + 1;
    end
endmodule

这里用了parameter定义位宽,调用时可以直接改:

counter #(.WIDTH(16)) u_counter (
    .clk   (clk),
    .rst_n (rst_n),
    .en    (en),
    .count (count)
);
我的习惯:每个模块只做一件事。比如计数器就只管计数,不要在里面混入使能逻辑。这样后期维护时,你只需要改一个地方。

模块化设计还有个好处——复用。我在一个项目中写过一个I2C控制器,后来三个项目都直接拿来用,改改参数就行。省下来的时间,喝杯咖啡不香吗?

3.3 组合逻辑与时序逻辑

这是Verilog里最容易混淆的地方。我见过太多人把组合逻辑和时序逻辑写成一锅粥。

组合逻辑:输出只取决于当前输入。用assign或者always @(*)实现。

// 组合逻辑:多路选择器
assign y = sel ? a : b;

// 或者用always块
always @(*) begin
    if (sel)
        y = a;
    else
        y = b;
end

时序逻辑:输出还取决于之前的状态。用always @(posedge clk)实现。

// 时序逻辑:D触发器
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        q <= 0;
    else
        q <= d;
end

这里有个关键点:组合逻辑用=(阻塞赋值),时序逻辑用<=(非阻塞赋值)。为什么?因为非阻塞赋值能模拟寄存器的并行更新行为。我曾经在面试时问过这个问题,十个人里有八个答不上来。

避坑指南:我曾经在一个项目中,把时序逻辑里的赋值写成了阻塞赋值。结果仿真看起来没问题,但综合后的电路时序全乱了。从那以后,我养成了一个习惯——always块里只要有时钟沿,一律用非阻塞赋值。

3.4 仿真与测试平台搭建

写完了代码,不仿真就敢流片?我反正不敢。测试平台(testbench)就是用来验证你的设计对不对的。

一个最简单的testbench:

`timescale 1ns / 1ps

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

    // 实例化被测试模块
    counter #(.WIDTH(8)) uut (
        .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
        $dumpfile("wave.vcd");
        $dumpvars(0, tb_counter);
    end
endmodule

你看,testbench里没有inputoutput,因为它是一个独立的仿真环境。时钟用forever生成,激励用initial块控制。我个人习惯在仿真开始时先复位一段时间,等所有信号稳定了再开始操作。

仿真技巧:不要只仿真正常情况。我每次都会加几个边界条件——比如复位释放瞬间使能信号跳变、时钟频率突然变化等。这些才是真正容易出bug的地方。

常用的仿真工具有ModelSim、Vivado Simulator、VCS等。我个人推荐初学者先用开源的Icarus Verilog + GTKWave,免费且够用。等做项目了再上商业工具。

最后说一句:仿真通过不代表硬件没问题。我遇到过仿真完美但上板后死机的案例——原因是仿真时没考虑门延迟和布线延迟。所以,仿真只是第一步,后仿和时序分析才是真正见真章的地方。

嗯,这一章的内容就到这儿。下一章咱们聊聊如何用Verilog写一个真正的项目——从需求分析到上板验证,走一遍完整的流程。