4. Verilog基础(一):模块化设计、数据类型(wire/reg)、always块与assign语句

各位同学,今天我们正式进入Verilog的世界。说实话,很多初学者一上来就被各种语法细节搞晕了。我当年也是这样,总觉得这语言怎么这么别扭,又像C又不像C。但做了十几年FPGA设计后,我越来越觉得,Verilog其实很纯粹——它就是在描述硬件,不是在写软件。

这一节我们重点讲三个核心概念:模块化设计wire和reg的区别、以及always块和assign语句。这三样东西,你搞明白了,Verilog就算入门了。

4.1 模块化设计——把大问题拆成小零件

FPGA设计跟盖房子很像。你不会直接去砌一整面墙,而是先做砖头、做门窗、做梁柱,最后再组装起来。模块化设计就是这个道理。

每个Verilog模块,就是一个功能单元。它有输入、有输出,内部实现具体的逻辑。模块之间通过端口连接,互相配合完成整个系统的功能。

我个人的习惯是:一个模块只干一件事。比如计数器模块只管计数,FIFO模块只管缓存数据,SPI控制器只管收发。这样设计的好处是——调试的时候,你只需要盯着一个小模块看,问题很快就能定位。

来看一个最简单的模块例子:

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

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

endmodule

这个模块叫counter,输入是时钟和复位,输出是一个8位的计数器值。内部就是一个简单的always块,每个时钟上升沿加1。

小提示:模块名最好用有意义的英文单词,别用a、b、c这种。我见过有人用module m1、m2、m3,三个月后他自己都看不懂了。

4.2 wire和reg——别再傻傻分不清了

这个问题,几乎每个初学者都会问:wire和reg到底有什么区别?

说白了,wire是连线,reg是存储单元。但这句话太抽象,我换个说法:

  • wire:它本身不保存值,只是把某个信号的值传递到另一个地方。你可以把它想象成一根导线。
  • reg:它能保存值,在时钟沿到来之前,它的值不会变。你可以把它想象成一个小盒子,里面存着数据。

但这里有个坑,我当年就踩过:reg不一定被综合成寄存器。什么意思呢?如果你在always块里写组合逻辑,reg综合出来就是连线。只有当时钟沿触发的always块,reg才会变成真正的寄存器。

来看个对比:

特性 wire reg
默认值 高阻态z 不定态x
赋值方式 assign语句 always块内赋值
能否存储值 不能 能(但要看上下文)
综合结果 连线 可能是连线,也可能是寄存器
注意:我曾经在项目中遇到一个bug,一个信号在always块里赋值,但忘记声明成reg,结果综合报错。后来养成习惯:assign语句左边用wire,always块里赋值用reg,再也没出过这种低级错误。

4.3 assign语句——连续赋值,简单粗暴

assign语句,说白了就是把右边的表达式结果,实时地赋给左边的wire。它没有时钟概念,只要右边变了,左边立刻跟着变。

举个例子:

module adder (
    input  wire [3:0] a,
    input  wire [3:0] b,
    output wire [4:0] sum
);

assign sum = a + b;

endmodule

这个模块就是一个加法器。a和b变了,sum立刻跟着变。没有时钟,没有复位,就是纯组合逻辑。

assign语句适合做什么?

  • 简单的逻辑运算(与、或、非、加法等)
  • 数据选择(比如用条件表达式)
  • 信号拼接和位宽转换

但要注意,assign不能用于reg类型的赋值。你想想看,reg需要保存值,而assign是连续赋值,这两者天生矛盾。

4.4 always块——时序逻辑的核心

always块是Verilog里最灵活、也最容易出错的语法结构。它有两种主要用法:

第一种:组合逻辑

always @(*) begin
    if (sel)
        out = a;
    else
        out = b;
end

这里的@(*)表示敏感列表包含所有输入信号。只要a、b、sel任何一个变了,always块就会重新执行。注意,这里用的是阻塞赋值=

第二种:时序逻辑

always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        q <= 1'b0;
    else
        q <= d;
end

这里用的是非阻塞赋值<=。为什么?因为时序逻辑里,所有赋值应该同时发生,而不是顺序执行。非阻塞赋值正好满足这个要求。

核心原则:
  • 组合逻辑用阻塞赋值 =
  • 时序逻辑用非阻塞赋值 <=
  • 千万别混用,否则仿真和综合结果可能不一致

嗯,这里我要多说一句。我见过很多新手在同一个always块里既用=又用<=,结果仿真跑得好好的,综合出来就是错的。为什么?因为综合工具对混合赋值的处理方式跟仿真器不一样。所以我的建议是:一个always块里只用一种赋值方式

4.5 实战小例子——边沿检测器

光说不练假把式。我们来看一个实际项目中经常用到的电路:边沿检测器。它能检测信号的上升沿或下降沿。

module edge_detector (
    input  wire       clk,
    input  wire       rst_n,
    input  wire       sig_in,
    output reg        pos_edge,
    output reg        neg_edge
);

reg sig_dly;

always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        sig_dly <= 1'b0;
        pos_edge <= 1'b0;
        neg_edge <= 1'b0;
    end
    else begin
        sig_dly <= sig_in;
        pos_edge <= sig_in & ~sig_dly;
        neg_edge <= ~sig_in & sig_dly;
    end
end

endmodule

这个电路的核心思想是:把输入信号延迟一拍,然后用当前值和延迟值做比较。如果当前是1、延迟是0,那就是上升沿;反过来就是下降沿。

我在项目中用过这个电路无数次。比如SPI接口的片选信号检测、按键消抖后的边沿提取、数据有效信号的同步等等。别看它简单,但非常实用。

4.6 避坑指南——我踩过的那些雷

最后,分享几个我亲身经历过的坑,希望能帮大家少走弯路:

  • 敏感列表不完整:写组合逻辑时,always@后面忘了加*,结果漏掉某个信号,仿真和综合结果不一致。后来我统一用always @(*),再也没出过问题。
  • reg忘记赋初值:FPGA上电后,reg的初始值是不定态x。如果不加复位,仿真时就会出现一堆红色波浪线。我的习惯是:每个reg都在复位时赋一个确定值
  • wire和reg混用:在assign语句左边用reg,或者在always块里给wire赋值。这种错误综合工具会报错,但仿真器可能不报。所以一定要养成好习惯。

好了,这一节的内容就到这里。下一节我们会深入讲Verilog的运算符和常用结构,包括阻塞与非阻塞赋值的底层原理。到时候我会用一个实际项目中的例子来演示,保证让你印象深刻。