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

各位同学,欢迎来到第三章。从这一章开始,我们就要真正动手写代码了。

Verilog 这门语言,说白了就是用来描述数字电路的。你写出来的每一行代码,最终都会变成实实在在的门电路、触发器和连线。我刚开始学的时候,总觉得它跟 C 语言差不多,后来被现实狠狠教育了一顿——Verilog 是硬件,不是软件。这个观念一定要先立住。

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

一个 Verilog 设计,本质上就是一堆模块(module)的集合。模块就像是一个黑盒子,有输入、有输出,内部封装了具体的逻辑功能。

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

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

// 这里写内部逻辑

endmodule

结构其实很清晰:

  • module 关键字后面跟模块名,我习惯用下划线命名法,比如 uart_rx、spi_master。
  • 端口列表 定义模块对外的接口,包括方向(input/output/inout)和数据类型。
  • 内部逻辑 写在 module 和 endmodule 之间。
  • endmodule 收尾,别忘了这个。
小技巧: 我写模块时,习惯把端口声明和内部逻辑之间空一行,再加个注释分隔。代码一多,这样找起来快很多。

3.2 数据类型:wire 和 reg

这是新手最容易搞混的地方。我见过不少同学把 wire 和 reg 用反,仿真能过,综合却报错。

wire 型:说白了就是一根导线。它不存储值,只是把驱动端的信号传递出去。比如 assign 语句的左边,或者模块端口连接,都用 wire。

reg 型:它代表一个寄存器,可以在 always 块中被赋值。注意,reg 不一定会综合成寄存器——如果你在组合逻辑的 always 块里用 reg,它综合出来还是导线。

我给大家整理了一个对照表:

特性 wire reg
默认值 高阻态 z 不定态 x
赋值方式 assign 连续赋值 always 块内赋值
综合结果 组合逻辑(导线) 组合逻辑或时序逻辑
端口类型 input/output/inout output 常用
注意: 我曾经在项目中犯过一个低级错误——把 always 块里赋值的信号声明成 wire,结果综合工具直接报错。记住一条铁律:assign 左边用 wire,always 块里赋值用 reg。

3.3 运算符:不只是加减乘除

Verilog 的运算符跟 C 语言很像,但有几个地方要特别小心。

算术运算符:+、-、*、/、%。这里要注意,乘法和除法在 FPGA 里很耗资源。我一般能用移位实现的,绝不用乘除。比如乘以 2 写成 << 1,除以 2 写成 >> 1。

位运算符:&、|、^、~。这些是逐位操作的,跟逻辑运算符 &&、||、! 要区分开。举个例子:

wire [3:0] a = 4'b1010;
wire [3:0] b = 4'b1100;

wire [3:0] c = a & b;   // 结果是 4'b1000
wire       d = a && b;  // 结果是 1'b1(非零即真)

拼接运算符:{ }。这个非常实用,我经常用它来组合信号。比如:

wire [7:0] byte_data = {high_nibble, low_nibble};

三目运算符:? :。写组合逻辑时特别好用,比 if-else 更简洁。

assign max = (a > b) ? a : b;

3.4 assign 语句:连续赋值

assign 语句描述的是组合逻辑。它的特点是:只要右边表达式发生变化,左边立即更新。这跟实际电路里的导线行为完全一致。

来看一个加法器的例子:

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

assign sum = a + b;

endmodule

嗯,这里要注意:assign 的左边必须是 wire 类型,不能是 reg。而且一个 wire 只能被一个 assign 驱动,不能重复赋值。

核心要点: assign 描述的是「一直连接」的关系,不是「什么时候赋值」。你想想看,导线什么时候需要赋值?它本来就是连着的。

3.5 always 块:时序逻辑的核心

always 块是 Verilog 里最灵活、也最容易出问题的地方。它既可以描述组合逻辑,也可以描述时序逻辑,关键看敏感列表怎么写。

时序逻辑的标准写法:

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

这里有几个关键点:

  • 敏感列表:posedge 表示上升沿触发,negedge 表示下降沿触发。我习惯把时钟和复位都写进去。
  • 非阻塞赋值 <=:时序逻辑里一定要用这个。它表示「在时钟沿到来时,同时计算并更新」。如果用 =(阻塞赋值),仿真结果会完全不一样。
  • 复位逻辑:异步复位时,一定要把复位信号写进敏感列表。我曾经漏写过一次,仿真没问题,上板后复位死活不生效,查了两天才发现。

组合逻辑的 always 块:

always @(*) begin
    case (sel)
        2'b00: out = a;
        2'b01: out = b;
        2'b10: out = c;
        default: out = 4'b0;
    endcase
end

这里用 @(*) 表示敏感列表自动推导,所有输入信号变化都会触发。组合逻辑里一定要用阻塞赋值 =,而且要注意覆盖所有分支,否则会综合出锁存器。

避坑指南: 我曾经在组合逻辑的 case 语句里漏写了 default,结果综合出来一个锁存器,功能完全不对。从那以后,我写组合逻辑 always 块,一定会检查每个分支是否都有赋值,或者加 default 兜底。

3.6 实战:一个简单的分频器

把今天学的知识串起来,写一个 8 分频器:

module clk_divider(
    input  wire       clk_in,
    input  wire       rst_n,
    output reg        clk_out
);

reg [2:0] cnt;

always @(posedge clk_in or negedge rst_n) begin
    if (!rst_n) begin
        cnt <= 3'd0;
        clk_out <= 1'b0;
    end else begin
        if (cnt == 3'd7) begin
            cnt <= 3'd0;
            clk_out <= ~clk_out;
        end else begin
            cnt <= cnt + 1'b1;
        end
    end
end

endmodule

这个例子用到了:模块结构、reg 数据类型、算术运算符、always 时序块、非阻塞赋值。你想想看,是不是每个知识点都用上了?

好了,第三章的内容就到这里。下一章我们会深入讨论组合逻辑和时序逻辑的区别,以及如何写出可综合的代码。记住,写 Verilog 的时候,脑子里要想着电路,而不是想着软件。这个习惯越早养成越好。