4. Verilog基础语法(下):组合逻辑与时序逻辑、状态机基础、参数化设计、testbench编写

好,咱们接着聊。上一节我们把Verilog的基本语法骨架搭起来了,这一节要往里填“肉”了。说白了,就是让你写的代码真正能干活——能算数、能记住状态、能灵活复用、还能自己验证对不对。

我个人觉得,这四块内容是FPGA设计的“四梁八柱”。你把这四个东西玩明白了,写个电机控制逻辑基本就手到擒来了。咱们一个一个来。

4.1 组合逻辑 vs 时序逻辑:一个“立刻算”,一个“等下算”

先问个问题:你按下电机启动按钮,LED是立刻亮,还是等一个时钟周期才亮?

这就是组合逻辑和时序逻辑最直观的区别。

  • 组合逻辑:输出只取决于当前输入。输入一变,输出立马变。没有记忆能力。
  • 时序逻辑:输出不仅看当前输入,还看之前的状态。说白了,它有“记忆”,靠时钟沿来更新。

我在项目中遇到过一位同事,把组合逻辑的敏感列表写漏了,结果仿真和实测对不上,查了两天才找到原因。嗯,这里要注意。

4.1.1 组合逻辑的两种写法

第一种,用 assign 连续赋值。简单粗暴,适合做数据通路。

// 组合逻辑:电机电流过流指示
assign over_current_flag = (motor_current > 10.0) ? 1'b1 : 1'b0;

第二种,用 always @(*) 过程块。注意,敏感列表用 * 表示所有输入信号,这样不会漏。

// 组合逻辑:编码器AB相解码
always @(*) begin
    case ({encoder_a, encoder_b})
        2'b00 : direction = 2'b00; // 停止
        2'b01 : direction = 2'b01; // 正转
        2'b10 : direction = 2'b10; // 反转
        2'b11 : direction = 2'b00; // 无效
        default: direction = 2'b00;
    endcase
end
避坑指南: 我曾经在组合逻辑里不小心产生了latch(锁存器)。原因是 case 语句没写全,或者 if 没有 else。记住:组合逻辑里,所有分支都要覆盖,否则综合出来就是latch,时序会乱套。

4.1.2 时序逻辑:靠时钟吃饭

时序逻辑的核心是 always @(posedge clk or negedge rst_n)。你想想看,电机控制里的PWM生成、速度积分、位置累加,哪个离得开时钟?

// 时序逻辑:电机转速累加器(每来一个脉冲,计数值+1)
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        speed_counter <= 32'd0;
    else if (pulse_in)
        speed_counter <= speed_counter + 1'b1;
    else
        speed_counter <= speed_counter; // 保持
end

注意,时序逻辑里赋值用 <=(非阻塞赋值),组合逻辑用 =(阻塞赋值)。这个区别我刚开始也搞混过,后来养成习惯:看到 posedge clk 就条件反射用 <=

4.2 状态机基础:让逻辑“有章可循”

电机控制里,状态机太常用了。比如电机启动→加速→匀速→减速→停止,这就是一个典型的状态机。

我个人习惯用三段式状态机。为什么?因为代码清晰,综合出来性能好,调试也方便。

4.2.1 三段式状态机模板

// 第一段:状态转移(时序逻辑)
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        current_state <= IDLE;
    else
        current_state <= next_state;
end

// 第二段:次态逻辑(组合逻辑)
always @(*) begin
    case (current_state)
        IDLE:   if (start)      next_state = RUN;
                else            next_state = IDLE;
        RUN:    if (stop)       next_state = STOP;
                else            next_state = RUN;
        STOP:                   next_state = IDLE;
        default:                next_state = IDLE;
    endcase
end

// 第三段:输出逻辑(组合逻辑或时序逻辑均可)
always @(*) begin
    case (current_state)
        IDLE:   motor_en = 1'b0;
        RUN:    motor_en = 1'b1;
        STOP:   motor_en = 1'b0;
        default: motor_en = 1'b0;
    endcase
end
小技巧: 状态编码用独热码(one-hot)还是格雷码?我的经验是:状态少于8个用独热码,综合出来速度快;状态多的话用格雷码,省寄存器。电机控制一般状态不多,独热码够用了。

4.3 参数化设计:写一次,用多次

你想想看,如果每个PWM模块都要手动改位宽、改周期,那不得累死?参数化设计就是干这个的。

parameter 定义常量,用 localparam 定义局部常量。调用模块时用 #(参数列表) 传参。

// 参数化PWM模块
module pwm_generator #(
    parameter   CNT_WIDTH = 16,      // 计数器位宽
    parameter   PWM_PERIOD = 1000    // PWM周期
)(
    input   wire                clk,
    input   wire                rst_n,
    input   wire [CNT_WIDTH-1:0] duty_cycle,  // 占空比
    output  reg                 pwm_out
);

    reg [CNT_WIDTH-1:0] counter;

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            counter <= 0;
        else if (counter >= PWM_PERIOD - 1)
            counter <= 0;
        else
            counter <= counter + 1'b1;
    end

    always @(*) begin
        if (counter < duty_cycle)
            pwm_out = 1'b1;
        else
            pwm_out = 1'b0;
    end

endmodule

调用的时候,可以这样:

pwm_generator #(
    .CNT_WIDTH(12),
    .PWM_PERIOD(4095)
) u_pwm_motor (
    .clk(clk),
    .rst_n(rst_n),
    .duty_cycle(12'd2048),  // 50%占空比
    .pwm_out(motor_pwm)
);

我在项目中用过这个技巧,一个PWM模块配了6个不同的参数,分别控制6路电机,代码量省了80%。

4.4 Testbench编写:自己写的代码,自己先测

说实话,很多初学者不爱写testbench,觉得麻烦。但我跟你说,不写testbench直接上板,那叫“盲调”,出了问题哭都来不及。

Testbench说白了就是给设计模块“喂激励,看反应”。

4.4.1 一个简单的testbench模板

`timescale 1ns / 1ps

module tb_pwm_generator;

    // 信号声明
    reg                 clk;
    reg                 rst_n;
    reg  [15:0]         duty_cycle;
    wire                pwm_out;

    // 实例化待测模块
    pwm_generator #(
        .CNT_WIDTH(16),
        .PWM_PERIOD(1000)
    ) uut (
        .clk(clk),
        .rst_n(rst_n),
        .duty_cycle(duty_cycle),
        .pwm_out(pwm_out)
    );

    // 时钟生成:50MHz -> 20ns周期
    initial begin
        clk = 0;
        forever #10 clk = ~clk;
    end

    // 激励序列
    initial begin
        // 复位
        rst_n = 0;
        duty_cycle = 16'd0;
        #100;
        rst_n = 1;

        // 测试50%占空比
        duty_cycle = 16'd500;
        #2000;

        // 测试75%占空比
        duty_cycle = 16'd750;
        #2000;

        // 测试0%占空比
        duty_cycle = 16'd0;
        #2000;

        // 结束仿真
        $finish;
    end

    // 监控输出
    initial begin
        $monitor("Time=%0t, duty=%d, pwm_out=%b", $time, duty_cycle, pwm_out);
    end

endmodule
关键点:
  • `timescale 定义时间单位和精度,别写错了
  • initial 块用来生成时钟和激励
  • $monitor$display 用来打印调试信息
  • $finish 结束仿真,否则会一直跑下去

4.5 本章知识体系总览

下面这张图,是我自己总结的本章知识结构。你可以把它当成一张“地图”,学完回头看看,心里就有数了。

Verilog基础语法(下) 组合逻辑 assign 连续赋值 always @(*) 过程块 阻塞赋值 = ⚠ 避免latch 时序逻辑 always @(posedge clk) 非阻塞赋值 <= 寄存器/计数器 有记忆能力 状态机基础 三段式写法 状态转移+次态+输出 独热码/格雷码 电机控制常用 参数化+TB parameter 参数化 #() 模块传参 testbench 激励 仿真验证 核心思想:组合逻辑算得快,时序逻辑记得住,状态机管流程,参数化省力气,testbench保质量 —— 五者结合,才能写出靠谱的电机控制逻辑

好了,这一节的内容就到这儿。组合逻辑和时序逻辑是基本功,状态机是组织代码的骨架,参数化设计让你事半功倍,testbench则是你最后的防线。把这四样东西练熟了,后面写电机控制逻辑就会顺手很多。