2. Verilog基础语法(上):模块结构、端口定义、数据类型、赋值语句、阻塞与非阻塞赋值

各位同学,欢迎来到实战课的第二讲。今天咱们聊聊Verilog最基础的东西——模块结构、端口、数据类型,还有那个让无数新手栽跟头的阻塞与非阻塞赋值。

说实话,这些语法看起来简单,但我在项目里见过太多因为基础不牢导致的bug。比如有人把wire和reg搞混,仿真怎么跑都对,一上板子就废。还有人阻塞赋值用顺手了,结果多级触发器全串一块儿了。嗯,咱们今天就把这些坑一个个填平。

核心要点:Verilog不是C语言,它是硬件描述语言。你写的每一行代码,最终都会变成实实在在的电路。脑子里要时刻想着「这行代码会综合成什么门电路」。

2.1 模块结构——Verilog的基本单元

一个Verilog设计,说白了就是一堆模块互相连接。每个模块就像一块芯片,有输入引脚、输出引脚,内部实现具体功能。

模块的基本骨架长这样:

module 模块名 (
    端口列表
);
    // 端口声明
    // 内部信号声明
    // 功能描述(assign、always等)
endmodule

我个人习惯把端口列表直接写在括号里,这样一眼就能看清模块的对外接口。比如一个简单的D触发器:

module d_flip_flop (
    input  wire clk,    // 时钟
    input  wire rst_n,  // 复位,低有效
    input  wire d,      // 数据输入
    output reg  q       // 数据输出
);
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            q <= 1'b0;
        else
            q <= d;
    end
endmodule

你想想看,这个模块描述的就是一个真实的D触发器芯片。时钟上升沿来的时候,把d的值锁存到q上。复位信号拉低,q就清零。

2.2 端口定义——模块的对外接口

端口有三种方向:input、output、inout(双向)。我建议新手先别碰inout,那玩意儿涉及三态门,容易出问题。

端口的数据类型有两种:wire和reg。这里有个容易混淆的地方——

  • input端口:必须是wire类型。因为输入信号来自外部,你不能在模块内部给它赋值。
  • output端口:可以是wire或reg。如果是在always块里赋值,就用reg;如果是assign连续赋值,就用wire。
  • inout端口:必须是wire类型。

我曾经见过一个新手把output定义成wire,然后在always块里赋值,编译直接报错。嗯,这个坑我当年也踩过。

2.3 数据类型——wire和reg

这两个数据类型是Verilog的基石。说白了:

  • wire:导线。它本身不存储值,只是把驱动端的值传递出去。用assign赋值。
  • reg:寄存器。它可以在always块里被反复赋值,会保持上一次的值。但注意,reg不一定会综合成寄存器!

为什么说reg不一定是寄存器?我举个例子:

reg [3:0] counter;
always @(posedge clk) begin
    counter <= counter + 1'b1;
end

这个counter会综合成4个触发器,因为它在时钟边沿更新值。但下面这个:

reg [3:0] result;
always @(*) begin
    result = a + b;
end

这个result虽然声明为reg,但综合出来只是一堆组合逻辑,没有触发器。所以记住:reg只是语法上的变量类型,不代表硬件上的寄存器。

我的经验:在always @(posedge clk)里用reg,在assign语句里用wire。这样不容易出错。如果你用SystemVerilog,可以用logic代替wire和reg,省心不少。

2.4 赋值语句——assign和always

Verilog里赋值就两种方式:连续赋值(assign)和过程赋值(always)。

2.4.1 assign——连续赋值

assign描述的是组合逻辑。只要右边信号变化,左边立即更新。比如:

assign y = a & b;  // 与门
assign sum = a ^ b; // 异或门

这就像在电路板上直接焊了一根导线,输入变了,输出马上跟着变。没有延迟,没有时钟。

2.4.2 always——过程赋值

always块可以描述组合逻辑,也可以描述时序逻辑。区别在于敏感列表:

  • 组合逻辑:always @(*) 或 always @(a, b, c) —— 所有输入信号都要列在敏感列表里
  • 时序逻辑:always @(posedge clk) 或 always @(posedge clk or negedge rst_n)

我建议组合逻辑用assign,时序逻辑用always。这样代码结构清晰,别人一看就知道你的设计意图。

避坑指南:千万不要在多个always块里对同一个变量赋值!综合工具会报多驱动错误。我曾经在一个项目里,两个always块都试图给同一个reg赋值,仿真没问题,综合报了一堆error。查了半天才发现。

2.5 阻塞与非阻塞赋值——新手最容易翻车的地方

好,重点来了。阻塞赋值(=)和非阻塞赋值(<=)的区别,是Verilog里最重要的概念之一。搞不懂这个,你写的时序逻辑大概率是错的。

2.5.1 阻塞赋值(=)

阻塞赋值是「立即生效」的。执行完当前赋值语句,变量立刻更新,后面的语句才能执行。这就像C语言里的赋值。

always @(posedge clk) begin
    a = b;
    c = a;  // 此时a已经更新为b的值,所以c等于b
end

这段代码综合出来是什么?只有一级触发器!因为a和c在同一个时钟周期里被连续赋值,最终c直接等于b。a和c之间没有延迟。

2.5.2 非阻塞赋值(<=)

非阻塞赋值是「并行生效」的。所有赋值语句同时计算右值,然后在时钟边沿统一更新左值。

always @(posedge clk) begin
    a <= b;
    c <= a;  // 这里用的是a的旧值,不是b
end

这段代码综合出来是两级触发器。第一个时钟沿,a变成b,c变成a的旧值。第二个时钟沿,c才变成b。这就是移位寄存器的行为。

2.5.3 黄金法则

我总结了三条铁律,你记住就不会出错:

  1. 时序逻辑(always @(posedge clk))用非阻塞赋值(<=)
  2. 组合逻辑(always @(*) 或 assign)用阻塞赋值(=)
  3. 同一个always块里,不要混用阻塞和非阻塞

为什么?因为时序逻辑需要模拟触发器的行为——输入在时钟沿之前采样,之后更新。非阻塞赋值正好符合这个特性。而组合逻辑需要立即响应输入变化,阻塞赋值更合适。

真实案例:我曾经接手一个项目,前同事在always @(posedge clk)里用了阻塞赋值做流水线。仿真跑得挺好,但上板子后数据总是不对。我一看代码,好家伙,三级流水线全串成一级了。改成非阻塞赋值,问题立刻解决。

2.6 本章知识体系

下面这张图总结了本章的核心内容,你可以对照着复习:

Verilog基础语法(上)知识体系 模块结构 module / endmodule 端口定义 input / output / inout 数据类型 wire / reg 赋值语句 assign(连续赋值) always(过程赋值) 阻塞 vs 非阻塞赋值 阻塞赋值(=) 非阻塞赋值(<=) 黄金法则 时序逻辑用 <= | 组合逻辑用 = | 不要混用

2.7 小结

今天的内容就这些。模块结构是骨架,端口是接口,wire和reg是血肉,assign和always是行为描述,阻塞与非阻塞赋值是灵魂。把这几个概念吃透,后面的课程你就不会觉得吃力。

嗯,我建议你打开Vivado或Quartus,照着今天的代码敲一遍,看看综合出来的RTL视图。亲眼看到代码变成电路,比死记硬背强一百倍。

课后练习:写一个4位加法器,用assign实现组合逻辑。再写一个4位计数器,用always实现时序逻辑。对比两者的RTL视图,看看有什么区别。

可可精品资料库