3. Verilog基础语法回顾:模块化设计、always块、assign语句、reg与wire类型、参数化设计

各位同学,欢迎来到我们串口实战课程的第一章。在正式开始撸串口代码之前,我觉得有必要把Verilog里最核心的几个语法点再过一遍。别嫌我啰嗦,这些基础打不牢,后面写状态机、调时序的时候,你肯定会回来翻笔记的。

我个人习惯是:先理解硬件思维,再写代码。Verilog不是C语言,你写的每一行代码,最终都会变成实实在在的门电路和触发器。所以,咱们今天聊的每一个语法,我都会告诉你它对应什么样的硬件。

3.1 模块化设计:把大工程拆成小积木

模块化设计,说白了就是“分而治之”。一个复杂的串口收发器,你不可能在一个module里写完所有逻辑——那样调试起来会让人崩溃的。

我刚开始做项目时,就吃过这个亏。一个UART模块写了800行,结果仿真波形一出来,发送和接收互相干扰,找bug找了整整两天。后来我把发送、接收、波特率生成拆成三个独立模块,问题一下子就清晰了。

一个典型的Verilog模块长这样:

module uart_tx (
    input  wire       clk,      // 系统时钟
    input  wire       rst_n,    // 异步复位,低有效
    input  wire       tx_start, // 发送启动信号
    input  wire [7:0] tx_data,  // 待发送的8位数据
    output reg        tx_busy,  // 发送忙标志
    output reg        txd       // 串行输出
);

    // 内部逻辑写在这里

endmodule

看到没?每个模块都有明确的输入输出接口。就像搭积木一样,你只需要关心这个模块的接口定义,内部实现可以慢慢打磨。

我的小建议:每个模块只做一件事,并且做好。比如串口发送模块,就只管把并行数据转成串行输出,别把波特率生成也塞进来。这样后期复用起来特别方便。

3.2 always块:时序逻辑与组合逻辑的“分水岭”

always块是Verilog里最常用的过程块。但很多新手容易搞混:什么时候用always @(posedge clk),什么时候用always @(*)?

我给大家一个简单的判断标准:

  • 要生成寄存器(触发器) → 用 always @(posedge clk or negedge rst_n)
  • 只是组合逻辑(纯门电路) → 用 always @(*)

举个例子,串口接收模块里,我们需要一个计数器来记录每一位的时间:

// 时序逻辑:生成寄存器
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        bit_cnt <= 4'd0;
    else if (rx_enable)
        bit_cnt <= bit_cnt + 1'b1;
    else
        bit_cnt <= 4'd0;
end

// 组合逻辑:生成下一个状态
always @(*) begin
    if (bit_cnt == 4'd8)
        rx_done = 1'b1;
    else
        rx_done = 1'b0;
end

注意看,时序逻辑里我用的是非阻塞赋值 <=,组合逻辑里用的是阻塞赋值 =。这个区别很重要,我曾经见过有人混用导致仿真结果和实际硬件不一致,查了三天才发现是赋值方式的问题。

避坑指南:我曾经在同一个always块里同时写时序和组合逻辑,结果综合出来的电路多了一堆不必要的锁存器。记住:时序逻辑用非阻塞赋值,组合逻辑用阻塞赋值,别混在一个块里。

3.3 assign语句:连续赋值的“直通车”

assign语句,说白了就是“输入一变,输出立刻变”。它描述的是组合逻辑,没有时钟参与。

比如串口发送模块里,我们需要把波特率时钟分频出来:

// 波特率时钟使能信号
assign baud_en = (baud_cnt == BAUD_DIV - 1) ? 1'b1 : 1'b0;

这个assign语句,综合出来就是一个比较器加一个选择器。你想想看,它没有时钟沿触发,只要baud_cnt变化,baud_en立刻跟着变。

我个人习惯是:简单的组合逻辑用assign,复杂的用always @(*)。比如三目运算符能搞定的,就别写always块了,代码更简洁。

3.4 reg与wire类型:搞懂“存储”与“连线”

很多初学者被reg和wire搞得晕头转向。我给大家一个最直观的理解:

类型 硬件本质 赋值方式 典型用途
wire 一根导线 assign 或 模块端口连接 组合逻辑输出、模块间连线
reg 一个存储单元 always 块内赋值 寄存器、计数器、状态机

但这里有个容易误解的地方:reg不一定综合成寄存器。如果你在always @(*)里给reg赋值,它综合出来还是组合逻辑,只是写法上需要用reg类型。

举个例子:

wire       a, b;      // 输入信号,用wire
reg        c;         // 组合逻辑输出,但必须声明为reg

always @(*) begin
    c = a & b;        // 实际综合出来就是一个与门
end

嗯,这里要注意:端口声明时,input只能是wire,output可以是wire或reg。我一般习惯把模块内部的寄存器都声明为reg,连线都声明为wire,这样一目了然。

3.5 参数化设计:让代码“活”起来

参数化设计,说白了就是用参数代替常量。这样你改一个参数,整个模块的行为就变了,不用手动改代码里的每一个数字。

比如我们的串口波特率生成模块:

module baud_gen #(
    parameter CLK_FREQ = 50_000_000,  // 系统时钟频率 50MHz
    parameter BAUD_RATE = 115200      // 目标波特率
) (
    input  wire       clk,
    input  wire       rst_n,
    output reg        baud_clk
);

    // 计算分频系数
    localparam DIV_CNT = CLK_FREQ / BAUD_RATE / 16;

    reg [15:0] cnt;

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            cnt <= 16'd0;
        else if (cnt < DIV_CNT - 1)
            cnt <= cnt + 1'b1;
        else begin
            cnt <= 16'd0;
            baud_clk <= ~baud_clk;
        end
    end

endmodule

看到没?#(parameter CLK_FREQ = 50_000_000, BAUD_RATE = 115200) 就是参数列表。以后换一个板子,时钟频率变成100MHz,我只需要在实例化时重新传参:

baud_gen #(
    .CLK_FREQ(100_000_000),
    .BAUD_RATE(9600)
) u_baud_gen (
    .clk(clk),
    .rst_n(rst_n),
    .baud_clk(baud_clk)
);

这样代码的复用性一下子就上来了。我在项目中经常用参数化设计来适配不同速率的传感器,省了不少事。

核心要点总结:
  • 模块化设计:一个模块只做一件事,接口清晰
  • always块:时序逻辑用posedge,组合逻辑用@(*),赋值方式别搞混
  • assign语句:连续赋值,适合简单组合逻辑
  • reg与wire:reg不一定综合成寄存器,看你怎么用
  • 参数化设计:用parameter代替常量,提高代码复用性

好了,这一章的基础语法就回顾到这里。这些内容虽然基础,但却是后面所有实战代码的基石。你想想看,如果连reg和wire都分不清,写出来的串口收发器能稳定工作吗?

下一章,我们就要开始搭建串口发送模块了。到时候你会发现,今天讲的这些语法,每一个都会用到。


可可精品资料库