3. 硬件描述语言基础

各位同学,今天我们来啃一块硬骨头——硬件描述语言的基础。说实话,很多初学者觉得Verilog或者VHDL就是写代码,跟C语言差不多。嗯,这个想法很危险。我见过太多人把FPGA当单片机写,结果综合出来的电路完全不是那么回事。

硬件描述语言,说白了就是「用文本画电路图」。你写的每一行代码,最终都会变成实实在在的门电路、触发器和连线。这个思维转变,是入门的第一道坎。

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

每个模块都有明确的输入输出接口。内部怎么实现,外部不用管。这就是封装的思想。我个人习惯把功能相关的信号放在同一个模块里,比如一个UART模块,就把波特率发生器、接收器、发送器全包进去。

小技巧:模块的端口命名要有规律。我习惯用「前缀_信号名」的方式,比如uart_txd、uart_rxd。这样在顶层连线时,一眼就能看出信号属于哪个模块。

3.2 数据类型——wire和reg,别搞混了

Verilog里最基础的数据类型就两个:wire和reg。但很多新手在这里栽跟头。

wire——说白了就是一根导线。它不存储值,只是把驱动端的值传递出去。assign语句赋值的目标必须是wire。

reg——寄存器?不一定。在always块里赋值的变量,必须声明为reg。但综合出来可能是寄存器,也可能是组合逻辑。这个要看你怎么写。

举个例子:

wire a, b, c;
assign c = a & b;  // c是wire,组合逻辑

reg d, e;
always @(*) begin
    d = a | b;      // d是reg,但综合成组合逻辑
end

always @(posedge clk) begin
    e <= a & b;    // e是reg,综合成触发器
end

我曾经在项目中遇到一个同事,把所有信号都定义成reg。结果综合报告里一堆警告,说某些reg被优化掉了。为什么?因为那些信号实际上只是连线,不需要存储。

类型 赋值方式 综合结果 典型用途
wire assign 组合逻辑 模块间连线、组合逻辑输出
reg always块内赋值 组合逻辑或时序逻辑 状态机、计数器、数据通路

3.3 运算符——不只是加减乘除

Verilog的运算符跟C语言很像,但有些细节完全不同。你想想看,C语言里a = b + c,就是算个数。但在Verilog里,这代表一个加法器硬件。

常用的运算符:

  • 算术运算符:+、-、*、/、%。注意,乘除法综合出来很耗资源。我一般用移位代替乘除,除非精度要求很高。
  • 逻辑运算符:&&、||、!。用于条件判断,结果只有0或1。
  • 位运算符:&、|、^、~。按位操作,这个在硬件里用得最多。
  • 关系运算符:>、<、==、!=。比较大小,综合出来是比较器。
  • 移位运算符:<<、>>。左移一位相当于乘2,右移一位相当于除2。硬件实现几乎不花资源。
注意:不要滥用除法运算符。我曾经接手过一个项目,里面用了大量的除法,结果芯片面积超标30%。后来全部改成移位和查表,面积降下来了,时序也好了。

3.4 assign与always块——两种描述方式

assign和always,是Verilog里最重要的两种描述方式。它们各有各的用途。

assign——连续赋值。只要右边表达式有变化,左边立即更新。适合描述简单的组合逻辑。

assign sum = a + b;
assign flag = (data == 8'hFF) ? 1'b1 : 1'b0;

always——过程赋值。可以描述组合逻辑,也可以描述时序逻辑。关键看敏感列表怎么写。

// 组合逻辑:敏感列表用 @(*)
always @(*) begin
    if (sel)
        out = a;
    else
        out = b;
end

// 时序逻辑:敏感列表用 @(posedge clk)
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        q <= 1'b0;
    else
        q <= d;
end

这里有个坑:组合逻辑用阻塞赋值(=),时序逻辑用非阻塞赋值(<=)。为什么?因为非阻塞赋值能避免竞争冒险。我刚开始学的时候也不理解,直到有一次仿真结果跟实际电路对不上,查了两天才发现是赋值方式用错了。

3.5 组合逻辑与时序逻辑——本质区别

这两个概念,是数字电路的核心。搞清楚了,FPGA设计就入门了。

组合逻辑:输出只取决于当前输入。没有记忆功能。比如与门、或门、加法器、多路选择器。

时序逻辑:输出不仅取决于当前输入,还取决于之前的状态。有记忆功能。比如触发器、计数器、状态机。

用一张图来理解:

组合逻辑 输出 = f(当前输入) 时序逻辑 输出 = f(当前输入, 历史状态) 需要时钟驱动 反馈(记忆)

在实际项目中,组合逻辑和时序逻辑往往是混在一起的。比如一个计数器:

module counter_example (
    input  wire       clk,
    input  wire       rst_n,
    input  wire       inc,
    output reg  [3:0] count,
    output wire       overflow
);

    // 时序逻辑:计数器
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            count <= 4'd0;
        else if (inc)
            count <= count + 1'b1;
    end

    // 组合逻辑:溢出判断
    assign overflow = (count == 4'd15) & inc;

endmodule

你看,count是时序逻辑,由时钟驱动。overflow是组合逻辑,由count和inc实时计算。这就是典型的混合设计。

核心要点:

  • 组合逻辑用assign或always @(*),用阻塞赋值(=)
  • 时序逻辑用always @(posedge clk),用非阻塞赋值(<=)
  • 不要把组合逻辑和时序逻辑混在一个always块里
  • 复位信号要放在敏感列表里,异步复位

嗯,到这里,硬件描述语言的基础就讲完了。这些东西看起来简单,但真正用好需要大量实践。我建议你每学一个知识点,就写一个小模块验证一下。比如写个4位加法器,再写个8位计数器,仿真看看波形对不对。

记住,FPGA设计不是写软件,是在画电路。你的每一行代码,最终都会变成硅片上的晶体管。带着这个思维去学习,进步会快很多。

我的经验:刚开始学的时候,可以先用原理图方式理解电路,再用Verilog描述出来。等熟练了,就可以直接写代码了。这个过程,就像学写字——先描红,再临摹,最后自己写。

可可精品资料库