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
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
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 本章知识体系总览
下面这张图,是我自己总结的本章知识结构。你可以把它当成一张“地图”,学完回头看看,心里就有数了。
好了,这一节的内容就到这儿。组合逻辑和时序逻辑是基本功,状态机是组织代码的骨架,参数化设计让你事半功倍,testbench则是你最后的防线。把这四样东西练熟了,后面写电机控制逻辑就会顺手很多。