第三讲:硬件描述语言基础(Verilog)——模块结构、数据类型、运算符、assign语句

各位同学,今天咱们来啃Verilog的硬骨头。说实话,很多初学者觉得Verilog就是写代码,跟C语言差不多。我当年也这么想,结果第一次写出来的模块,综合出来一堆莫名其妙的锁存器……嗯,那都是血泪史。

Verilog不是软件语言,它是描述硬件的。你写的每一行代码,最终都会变成实实在在的门电路、触发器和连线。这个观念必须从一开始就刻在脑子里。

一、模块结构——Verilog的基本单元

模块(module)是Verilog最基本的封装单位。你可以把它想象成一个芯片:有输入引脚、输出引脚,内部有逻辑功能。

module counter (
    input  wire       clk,      // 时钟
    input  wire       rst_n,    // 复位,低有效
    input  wire       en,       // 使能
    output reg  [3:0] count     // 4位计数器输出
);

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

endmodule

模块的基本结构就三部分:端口声明、内部信号定义、功能描述。端口声明现在推荐用ANSI风格,也就是把端口类型和位宽直接写在括号里。我早期用过老式的非ANSI风格,代码长了之后端口列表和声明对不上,查错查到崩溃。

核心要点:每个模块对应一个硬件功能块。顶层模块就是你的整个设计,子模块就是各个功能单元。

二、数据类型——wire和reg的恩怨情仇

Verilog里最常用的数据类型就两个:wire和reg。很多新手搞不清它们的区别,我刚开始也迷糊过。

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

reg:它不代表寄存器!这是个天大的误解。reg只是表示一个变量,可以在always块或initial块中被赋值。它综合出来可能是寄存器,也可能是组合逻辑,取决于你怎么写。

特性 wire reg
默认值 高阻态z 不定态x
赋值方式 assign语句 always/initial块
综合结果 组合逻辑 组合或时序逻辑
端口类型 input/output默认wire output可声明为reg

我的习惯:组合逻辑的输出用wire,时序逻辑的输出用reg。这样一目了然,别人看你的代码就知道哪些是纯组合,哪些有时序。

还有一点要注意:位宽声明。比如 [7:0] data,表示8位数据,data[7]是最高位。我见过有人写成 [0:7],虽然语法没错,但跟主流习惯反着来,团队协作时容易出问题。

三、运算符——从算术到位运算

Verilog的运算符跟C语言很像,但有些细节完全不同。你想想看,C语言里a = b + c,就是加法。但在Verilog里,这个加法会综合成一个加法器,占用实实在在的逻辑资源。

算术运算符:+、-、*、/、%。注意乘法和除法,综合出来的面积很大。我做过一个项目,用了三个乘法器,结果芯片面积超标,最后不得不改成移位加法的实现方式。

位运算符:&、|、^、~。这些是数字电路设计的核心。与门、或门、异或门、非门,都是最基础的门电路。

逻辑运算符:&&、||、!。注意区分位运算和逻辑运算。位运算是对每一位操作,逻辑运算只返回0或1。

关系运算符:>、<、>=、<=、==、!=。综合出来是比较器。

移位运算符:<<、>>。左移一位相当于乘2,右移一位相当于除2。这是做乘除法时常用的优化手段。

我曾经踩过的坑:用==比较两个多位数时,如果其中一位是x或z,结果就是x。在仿真里可能没问题,但综合出来的硬件永远不会出现x。所以仿真时看到x,一定要追查源头。

四、assign语句——连续赋值的精髓

assign语句是Verilog里描述组合逻辑最直接的方式。它的意思是:等号右边的表达式一旦发生变化,立即计算并赋值给左边。

// 一个简单的半加器
module half_adder (
    input  wire a, b,
    output wire sum, cout
);

    assign sum  = a ^ b;    // 异或,得到和
    assign cout = a & b;    // 与,得到进位

endmodule

assign语句有几个特点:

  • 左边必须是wire类型,不能是reg
  • 多个assign语句是并行执行的,不分先后顺序
  • 每个wire只能被一个assign驱动,否则会多驱动冲突

为什么会这样?因为硬件本身就是并行的。你想想看,两个与门同时工作,谁先谁后?没有先后,它们同时输出结果。

assign语句还可以用条件运算符:

assign max = (a > b) ? a : b;  // 二选一多路器

这个写法综合出来就是一个多路选择器。条件运算符可以嵌套,但嵌套太多层代码可读性会变差。我一般嵌套不超过两层,再多就用always块加case语句。

实战建议:简单的组合逻辑用assign,复杂的用always块。assign写出来简洁明了,但逻辑太复杂时,always块配合case或if-else更容易维护。

五、综合实战——用assign实现一个4位加法器

咱们把今天学的知识串起来,写一个完整的模块:

module adder_4bit (
    input  wire [3:0] a, b,
    input  wire       cin,      // 进位输入
    output wire [3:0] sum,      // 和
    output wire       cout      // 进位输出
);

    wire [4:0] temp;

    assign temp = a + b + cin;
    assign sum  = temp[3:0];
    assign cout = temp[4];

endmodule

这里用了一个5位的临时变量temp,把进位位也保留下来。然后分别取低4位作为和,第5位作为进位输出。简洁高效,综合出来就是一组加法器。

嗯,到这里今天的核心内容就讲完了。总结一下:模块是骨架,wire和reg是血肉,运算符是功能,assign是连接方式。把这四样东西搞明白,你就能写出大部分组合逻辑电路了。

下一讲我们会深入always块,那是时序逻辑的主战场。到时候我会讲讲怎么避免写出锁存器——这可是新手最容易犯的错误之一。