3. 硬件描述语言基础

各位同学,今天我们聊聊Verilog。说实话,很多初学者觉得Verilog就是另一种编程语言,写起来跟C差不多。嗯,这个想法很危险。我在项目里见过太多人把Verilog当C写,结果综合出来的电路完全不是那么回事。今天我们就把它掰扯清楚。

3.1 Verilog HDL基本语法——别把它当软件写

Verilog的核心思想是描述硬件,不是执行指令。你写的每一行代码,最终都会变成实实在在的门电路、触发器和连线。这一点,请务必刻在脑子里。

3.1.1 模块结构

一个Verilog设计的基本单元是module。我习惯把它想象成一个芯片的封装——有输入引脚、输出引脚,内部有逻辑。

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

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

endmodule

你看,input和output定义了端口方向。wire和reg是数据类型。这里有个坑:wire用于组合逻辑连线,reg用于存储变量。但注意,在always块里赋值的变量必须声明为reg,哪怕它综合出来只是个连线。

⚠ 我曾经踩过的坑: 把组合逻辑的中间变量声明成wire,然后在always块里赋值,编译直接报错。记住:always块里只能用reg。

3.1.2 数据类型与运算符

Verilog的数据类型其实不多,常用的就几种:

类型 说明 我常用的场景
wire 线网类型,用于组合逻辑 模块间连线、assign赋值
reg 寄存器类型,用于时序逻辑 always块内的变量
integer 32位有符号整数 仿真中的循环变量
parameter 常量定义 定义位宽、计数值等

运算符方面,==和===的区别你搞清楚了吗?===会比较x和z状态,而==不会。仿真时,如果信号是x态,用==比较会返回x,条件判断可能出错。我个人习惯在状态机里用===,避免仿真陷阱。

3.1.3 阻塞赋值与非阻塞赋值

这是新手最容易翻车的地方。说白了:

  • 阻塞赋值 =:顺序执行,前面的赋值会阻塞后面的。用于组合逻辑。
  • 非阻塞赋值 <=:并行执行,所有赋值同时发生。用于时序逻辑。

为什么会这样?因为硬件里,多个触发器是在同一个时钟沿同时更新的。你用=写时序逻辑,仿真结果和实际电路可能完全不一样。

我的铁律: 写时序逻辑永远用<=,写组合逻辑用=。别问为什么,先记住,以后自然懂。

3.2 模块化设计——把大问题拆成小模块

你想想看,一个复杂的Zynq系统,如果所有逻辑都写在一个模块里,那调试起来得多痛苦?模块化设计就是把功能拆解,每个模块负责一件事。

3.2.1 模块划分原则

我在项目中总结了几条经验:

  • 高内聚低耦合:每个模块内部功能紧密相关,模块间接口尽量简单。
  • 单一职责:一个模块只做一件事。比如,计数器模块只计数,不要在里面混入串口逻辑。
  • 接口标准化:尽量用统一的握手信号(valid/ready)或AXI总线接口。

3.2.2 模块实例化

写好了模块,怎么用?实例化。就像你买了一个芯片,把它焊到板子上,连好线。

// 顶层模块
module top (
    input  wire clk,
    input  wire rst_n,
    output wire [7:0] led
);

    wire [7:0] count;

    // 实例化计数器模块
    counter u_counter (
        .clk   (clk),
        .rst_n (rst_n),
        .count (count)
    );

    // 把计数值直接连到LED
    assign led = count;

endmodule

注意实例化时的端口连接方式。我推荐用.端口名(连线名)这种显式连接,虽然代码长一点,但可读性强,不容易出错。用位置连接?嗯,我年轻时用过,后来改代码时把自己坑惨了。

3.2.3 参数化设计

用parameter让模块更灵活。比如计数器模块,位宽可以配置:

module counter #(
    parameter WIDTH = 8
) (
    input  wire             clk,
    input  wire             rst_n,
    output reg  [WIDTH-1:0] count
);
    // 逻辑同上,但位宽由参数决定
endmodule

// 实例化时指定参数
counter #(.WIDTH(16)) u_counter (...);

这样,同一个模块可以复用在不同位宽的场景。我在做多通道数据采集时,就用参数化模块生成了8个不同位宽的计数器,省了不少事。

3.3 仿真与综合——先仿真,后综合,别偷懒

写完了代码,下一步就是验证。我见过太多人直接上板调试,结果花的时间比写代码还多。仿真不花钱,上板烧芯片可是要花钱的。

3.3.1 仿真基础

仿真就是给设计加激励,看输出对不对。常用的仿真工具有Vivado Simulator、ModelSim、VCS等。写一个简单的testbench:

`timescale 1ns / 1ps

module tb_counter;

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

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

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

    // 生成复位
    initial begin
        rst_n = 0;
        #20;
        rst_n = 1;
    end

    // 监控输出
    initial begin
        #200;
        $display("Test completed");
        $finish;
    end

endmodule

仿真时,我会先看波形,确认每个信号的变化是否符合预期。特别是复位释放后的第一个时钟沿,计数器是否从0开始?边界条件是否处理正确?

💡 我的习惯: 仿真时加一些$display打印关键信息,但别太多,否则输出刷屏反而看不清。重点打印状态跳转、数据有效标志等。

3.3.2 综合——把代码变成电路

综合是把Verilog代码映射到目标器件的逻辑单元(LUT、FF、BRAM等)。Vivado里跑综合时,我会关注几个指标:

  • 资源利用率:LUT、FF、BRAM用了多少?别超了。
  • 时序报告:WNS(最差负时序裕量)是不是正的?如果是负的,说明你的设计跑不到目标频率。
  • 警告信息:Vivado的警告很多,但有些是致命的。比如"inferred latch"——这通常意味着组合逻辑里漏写了else分支。

⚠ 我曾经犯过的错: 有一次综合后看到"inferred latch"警告,没当回事。结果上板后,某个信号在特定条件下保持不定态,查了两天才发现是组合逻辑的case语句没写default。从那以后,我写组合逻辑一定补全所有分支。

3.3.3 仿真与综合的区别

仿真通过不代表综合没问题。为什么?因为仿真器是软件,它不关心你的代码能不能综合成硬件。比如:

  • initial块里的#delay——仿真能用,但综合会忽略或报错。
  • for循环——仿真没问题,但综合时如果循环次数不是常数,会报错。
  • integer变量——仿真灵活,但综合成硬件会占用大量资源。

所以,我的流程是:先写代码,跑仿真验证功能,再综合检查资源和时序。仿真和综合是两条腿走路,缺一不可。

3.4 本章小结

Verilog基础就这些。说白了,语法是工具,模块化是方法,仿真和综合是验证手段。你写代码时,脑子里要时刻想着:这行代码会综合成什么电路?是LUT还是触发器?连线对不对?

嗯,今天就到这里。记住,多写多练,仿真跑通了再上板。别像我当年那样,代码写完直接烧芯片,结果冒烟了才后悔。


可可精品资料库