3、Verilog基础(一):模块与端口、数据类型(wire/reg)、连续赋值(assign)、过程块(always)

各位同学,欢迎来到Verilog基础的第一讲。说实话,每次带新人时,我都会从这一章开始讲起。因为不管你以后是做验证还是做设计,这些概念就像你吃饭用的筷子一样,天天都要打交道。

我个人习惯把Verilog看作是一种「描述硬件行为的语言」。它不像C语言那样从上往下执行,而是描述一堆电路同时在工作。嗯,这个思维转变很重要,咱们慢慢来。

3.1 模块与端口:芯片的「黑盒子」

模块(module)是什么?说白了,就是你的设计单元。你可以把它想象成一个黑盒子——外面的人只关心它有什么输入、输出,不关心里面怎么实现的。

举个例子,一个最简单的与门模块:

module and_gate (
    input  wire a,      // 输入端口
    input  wire b,      // 输入端口
    output wire y       // 输出端口
);
    assign y = a & b;
endmodule

这里有几个关键点:

  • module/endmodule:模块的起止标志,就像C语言的{}一样
  • 端口列表:写在括号里,告诉外界这个模块有哪些接口
  • 端口方向:input、output、inout(双向端口,用得少)
我的小建议:写端口时,我习惯把input和output分开写,并且按信号功能分组。比如时钟复位放一起,数据信号放一起。这样别人看你的代码,一眼就能知道这个模块是干什么的。

端口类型还有一点要注意:端口默认是wire类型。如果你在端口声明时没写wire或reg,那它默认就是wire。我曾经见过新人因为没搞清楚这个,debug了一整天。

3.2 数据类型:wire vs reg

这是新手最容易搞混的地方。我当年刚学的时候也绕了好久,后来发现其实没那么复杂。

3.2.1 wire(线网类型)

wire代表的是物理连线。它本身不存储值,只是把驱动端的值传递到接收端。你可以把它想象成一根导线——导线本身能「存」电压吗?不能,它只是传递。

wire的特点:

  • 只能被连续赋值(assign)或模块端口驱动
  • 不能在always块中被赋值
  • 如果没有驱动源,默认是高阻态(z)
wire w1;
assign w1 = a & b;  // 正确:连续赋值驱动wire

wire w2;
always @(*) begin
    w2 = a & b;      // 错误!wire不能在always中被赋值
end

3.2.2 reg(寄存器类型)

reg代表的是存储单元。它可以在过程块(always)中被赋值,并且会保持这个值直到下一次被赋值。

这里有个坑:reg不一定综合成寄存器。我刚开始做项目时,以为reg就一定是触发器,结果综合出来发现是个组合逻辑。为什么会这样?因为reg只是语法上的变量类型,综合工具会根据你的代码风格来决定是寄存器还是锁存器。

reg r1;
always @(*) begin
    r1 = a & b;      // 正确:组合逻辑,综合成与门
end

reg r2;
always @(posedge clk) begin
    r2 <= a & b;     // 正确:时序逻辑,综合成D触发器
end
核心区别一句话:wire是物理连线,reg是变量。wire用assign赋值,reg用always赋值。记住这个就够了。

3.2.3 什么时候用wire,什么时候用reg?

我总结了一个简单规则:

  • 模块的output端口:如果是在assign中赋值,用wire;如果在always中赋值,用reg
  • 模块的input端口:永远是wire
  • 内部信号:如果被assign驱动,用wire;如果被always驱动,用reg
场景 推荐类型 说明
模块输入端口 wire 只能被外部驱动
assign驱动的输出 wire 连续赋值驱动
always驱动的输出 reg 过程块赋值
内部连线 wire 模块间连接
内部寄存器 reg 存储状态

3.3 连续赋值(assign)

连续赋值,说白了就是「只要输入变了,输出立刻跟着变」。它描述的是组合逻辑,没有延迟,没有时钟。

assign y = a & b;           // 与门
assign sum = a + b;         // 加法器
assign sel = (cnt > 10) ? 1'b1 : 1'b0;  // 条件赋值

assign的左边必须是wire类型,右边可以是任何表达式。我常用的assign场景:

  • 简单的组合逻辑(与或非、加法、比较)
  • 多路选择器(用条件运算符)
  • 模块间的信号连接
避坑指南:我曾经犯过一个错误——在assign中用了太复杂的表达式。比如一个assign里写了七八个嵌套的条件运算符。结果综合出来的面积大得吓人,而且时序也跑不过。后来我学乖了:复杂的组合逻辑,拆成多个assign,或者放到always块里写。

3.4 过程块(always)

always块是Verilog中最灵活、也最容易出错的语法结构。它描述的是「在特定事件发生时,执行一段代码」。

3.4.1 always的触发方式

always块的触发方式由敏感列表决定:

// 组合逻辑:所有输入变化时触发
always @(*) begin
    // 组合逻辑代码
end

// 时序逻辑:时钟上升沿触发
always @(posedge clk) begin
    // 时序逻辑代码
end

// 时序逻辑:时钟上升沿+异步复位
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        q <= 1'b0;
    else
        q <= d;
end

这里有个重要原则:组合逻辑用阻塞赋值(=),时序逻辑用非阻塞赋值(<=)。这不是随便说的,而是有深刻原因的。

3.4.2 阻塞赋值 vs 非阻塞赋值

我当年理解这个花了不少时间。简单来说:

  • 阻塞赋值(=):先算完右边的值,立刻赋给左边,然后才执行下一条语句。就像流水线上的工人,做完一个再做下一个。
  • 非阻塞赋值(<=):先算好所有右边的值,然后统一赋给左边。就像大家同时举手,老师数完人数后一起放下。
// 阻塞赋值:顺序执行
always @(*) begin
    a = b;
    c = a;  // c得到的是b的新值
end

// 非阻塞赋值:并行执行
always @(posedge clk) begin
    a <= b;
    c <= a;  // c得到的是a的旧值
end
黄金法则:写组合逻辑用阻塞赋值,写时序逻辑用非阻塞赋值。这个规则我用了十年,从来没出过问题。

3.4.3 常见的always写法

我总结了三种最常用的always模板:

// 模板1:组合逻辑
always @(*) begin
    // 用阻塞赋值
    // 必须覆盖所有分支,否则会生成锁存器
end

// 模板2:同步时序逻辑
always @(posedge clk) begin
    // 用非阻塞赋值
    // 同步复位或不复位
end

// 模板3:异步复位时序逻辑
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        // 复位逻辑
    end else begin
        // 正常逻辑
    end
end
我的经验:写always块时,我习惯先想清楚「这个块要综合成什么电路」。如果是组合逻辑,就用@(*)和阻塞赋值;如果是触发器,就用@(posedge clk)和非阻塞赋值。想清楚了再动笔,代码质量会高很多。

3.5 本章小结

这一章的内容,说白了就是Verilog的「语法骨架」:

  • 模块:你的设计单元,用端口和外界通信
  • wire/reg:wire是连线,reg是变量,别搞混
  • assign:描述组合逻辑,输入变了输出立刻变
  • always:描述时序或组合逻辑,注意敏感列表和赋值方式

嗯,这些概念看起来简单,但真正用好需要大量练习。下一章我们会讲运算符和表达式,到时候你会看到这些基础概念是怎么组合起来构建复杂电路的。

记住:Verilog不是编程语言,是硬件描述语言。你写的每一行代码,最终都会变成实实在在的电路。带着这个思维去学习,你会少走很多弯路。