第三章:硬件描述语言基础(Verilog)——模块结构、端口定义、数据类型、assign与always块

各位同学,欢迎来到第三章。从这一章开始,我们正式进入Verilog的世界。

说实话,很多初学者一上来就被Verilog的语法吓住了。觉得它像C语言,又不完全是。我当年刚接触时也犯过迷糊——明明写的是“硬件描述”,怎么看起来像在写软件?

嗯,这里要给大家打个预防针:Verilog不是软件编程语言,它是用来描述硬件电路的。你写的每一行代码,最终都会映射成真实的逻辑门、触发器和连线。带着这个认知去学,事半功倍。

核心认知:Verilog代码 = 电路结构 + 行为描述。不是“执行”,而是“存在”。

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

一个Verilog设计,说白了就是一堆模块(module)拼起来的。模块就像芯片里的“黑盒子”,有输入、有输出,内部干点啥事。

来看一个最简单的模块结构:

module led_controller(
    input  wire clk,      // 时钟输入
    input  wire rst_n,    // 复位输入(低有效)
    output reg  led_out   // LED输出
);

// 内部逻辑写在这里

endmodule

我个人习惯把端口声明直接写在括号里,这样一眼就能看到模块的“长相”。当然你也可以把端口声明写在模块内部,两种方式都行,看团队规范。

模块的命名我建议用下划线分隔的小写字母,比如 uart_rxspi_master。别用驼峰命名,也别用中文拼音——我在项目中见过有人用 dianji_kongzhi,虽然能看懂,但不太专业。

3.2 端口定义——模块的“手脚”

端口就是模块与外界通信的通道。Verilog里有三种端口方向:

方向 关键字 说明
输入 input 信号从外部流入模块内部
输出 output 信号从模块内部流出到外部
双向 inout 信号可进可出(如I2C数据线)

这里有个坑:inout端口必须声明为wire类型,不能是reg。为什么?因为双向端口需要三态驱动,而reg类型在综合时会被实现为寄存器,没法直接挂到总线上。我曾经在一个I2C控制器项目里踩过这个坑,仿真怎么都对,一综合就报错,查了半天才发现是inout用了reg。

注意:inout端口在FPGA中通常用IOB(输入输出块)实现,需要配合三态门使用。初学者建议先避开inout,用input和output就够了。

3.3 数据类型——wire和reg

这是新手最容易搞混的地方。我简单总结一下:

  • wire:相当于一根导线。它不存储值,只是把驱动端的值传递出去。用在assign语句、模块端口连接、连续赋值等场景。
  • reg:相当于一个存储单元。它可以在always块中被赋值,并且会保持上次的值直到被重新赋值。

你想想看,如果电路里有一根线,它本身没有记忆能力,那就是wire。如果是一个D触发器,它能记住上一个时钟周期的值,那就是reg。

但注意:reg不一定会被综合成寄存器。如果你在always块里写组合逻辑,reg综合出来可能只是一堆门电路。我刚开始学的时候也以为reg就是寄存器,后来发现不是这么回事——reg只是语法上的变量类型,综合成什么取决于你怎么用它

小技巧:在always @(*)中赋值的变量,用reg声明;在assign中赋值的变量,用wire声明。这样写代码,综合工具不会报错,别人也容易看懂。

3.4 assign语句——连续赋值

assign语句用来描述组合逻辑。它的意思是“只要右边变了,左边立刻跟着变”。

举个例子:

wire a, b, c;
assign c = a & b;   // c = a 与 b

这段代码综合出来就是一个与门。a和b是输入,c是输出。只要a或b发生变化,c会立即更新——没有延迟,没有时钟控制。

assign的右边可以是任何表达式:与、或、非、异或、移位、拼接等等。但注意:assign左边必须是wire类型,不能是reg。

我在项目中常用assign来做简单的信号处理,比如:

// 按键消抖后的上升沿检测
assign key_rise = key_debounced & ~key_debounced_dly;

这种写法简洁明了,综合出来也就几个门电路,效率很高。

3.5 always块——时序与组合逻辑的“心脏”

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

3.5.1 组合逻辑的always块

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

这里的 @(*) 表示“敏感列表包含所有输入信号”。只要a、b、sel中任何一个发生变化,这个块就会被触发执行。综合出来就是一个多路选择器。

写组合逻辑的always块时,有个铁律:所有输入信号都要出现在敏感列表里。如果你漏写了某个信号,综合工具会生成一个锁存器(latch),而不是你想要的组合逻辑。我见过不少新手因为这个bug调了一整天。

避坑指南:组合逻辑的always块中,每个分支都必须给所有输出赋值。否则综合工具会推断出锁存器。我曾经在一个状态机解码器里漏了一个分支,结果综合出来的电路多了8个锁存器,功耗和面积都超标了。

3.5.2 时序逻辑的always块

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

这段代码描述了一个8位计数器,在时钟上升沿递增,复位时清零。注意这里用的是 非阻塞赋值 <=,而不是阻塞赋值 =

为什么时序逻辑要用非阻塞赋值?因为非阻塞赋值能模拟寄存器的行为:所有赋值在时钟沿同时发生,不会互相影响。如果用阻塞赋值,仿真结果可能不对,综合出来的电路也可能有竞争冒险。

我个人的习惯是:组合逻辑用阻塞赋值,时序逻辑用非阻塞赋值。这个规则我用了十几年,从来没出过问题。

3.6 知识体系总览

下面这张图总结了本章的核心知识点,以及它们之间的关系:

Verilog基础:模块结构与核心语法 module / endmodule 端口定义 input / output / inout 数据类型 wire / reg assign语句 连续赋值(组合逻辑) always块 时序/组合逻辑 核心要点 • wire用于assign和端口连接,reg用于always块赋值 • 组合逻辑用阻塞赋值(=),时序逻辑用非阻塞赋值(<=) • 避免在组合逻辑中生成锁存器(latch)

3.7 本章小结

这一章我们讲了Verilog最基础、最核心的几个概念:

  • 模块结构:module/endmodule是设计的骨架
  • 端口定义:input/output/inout决定了信号的流向
  • 数据类型:wire和reg各有各的用法,别搞混
  • assign语句:描述组合逻辑的简洁方式
  • always块:既能描述组合逻辑,也能描述时序逻辑

这些内容看起来简单,但它们是后续所有复杂设计的基础。我建议你打开开发环境,把每个例子都敲一遍,仿真看看波形。光看不练,永远学不会Verilog。

嗯,这一章就到这里。下一章我们会深入讨论运算符和表达式,到时候会用到今天讲的知识点。


公众号:蓝海资料掘金营,微信deep3321