FPGA实现占空比可配置PWM:预分频与同步采样设计详解
2026/9/17 16:53:17 网站建设 项目流程

简介:基于FPGA的占空比可配置脉宽调制方波产生器设计,为数字逻辑开发学习者提供一套完整的硬件描述语言实现方案。该设计面向初学与进阶人群,解决脉宽调制信号产生、参数调节及仿真验证中的常见问题,占空比可灵活配置,并附带测试平台便于观察波形时序。工程基于Vivado 2019.2环境搭建,包含完整源码、详细注释以及操作步骤录屏,可跟随视频快速创建工程并完成仿真。压缩包内共65个文件,核心包括硬件描述语言源文件、仿真波形文件、工程配置文件与脚本文件,整体大小为2.06MB,轻量易用。截至目前,已有610人学习下载,内容结构清晰,适合课程设计、竞赛准备或自学参考。通过该包可掌握脉宽调制占空比定义、模块化设计方法及测试平台编写技巧,并利用仿真结果对照同名博文,深入理解不同参数下的波形变化与验证流程。

1. 先想清楚 PWM 在 FPGA 里要解决什么,再谈占空比可配置

FPGA 上出方波不难,难的是让方波的高电平时间随时可改、改完不出毛刺。电机调速、舵机角度、Buck 电源反馈环路、LED 呼吸灯,这些场景都需要一个“占空比可配置的 PWM 脉宽调制方波产生器”。Verilog 实现这类电路的标准做法是用一个计数器做时间基准,再用比较器判断输出拉高还是拉低;剩下的事情全是边界:分辨率取多少位、系统时钟太高要不要预分频、占空比控制字从哪里同步进来、testbench 怎么验证 25% 到 100% 的切换。这篇我会直接把一套带预分频、同步采样和 100% 钳位的 PWM 代码写完整,连 testbench 和仿真命令一起给出来,适合正在做 FPGA 数字设计、需要快速把 PWM 外设落在仿真波形里调试的开发者。

2. PWM 周期计数器与占空比分辨率:先定参数再写 Verilog

2.1 计数器回绕到 2^WIDTH,PWM 的分辨率就没误差

一个 PWM 周期由若干时钟周期组成。计数器从 0 走到满,再回到 0,这个回绕周期决定了 PWM 频率;占空比则在“计数值小于控制字”的区间输出高电平。选什么回绕值直接影响实现复杂度和占空比误差。

最常见的做法是让计数器自然溢出不判断上限,也就是用2^WIDTH作为周期。原因有两个:硬件位宽溢出就是取模,既省比较器又省复位逻辑;占空比控制字按同一套位宽取值,配出来的百分比在数值上完全线性。

reg [PW-1:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) cnt <= {PW{1'b0}}; else cnt <= cnt + 1'b1; end wire pwm_tmp = (cnt < duty) ? 1'b1 : 1'b0;

这段骨架能跑,但不是工程版本:没有预分频时,PWM 频率被系统时钟和位宽锁死;duty又是组合输入,实时比较时可能在周期中间跳变。后面的章节逐个补上。

下表把这套映射关系写清楚:

指标公式例子(CLK=50 MHz, PW=8, PRESCALE=0)
PWM 频率fclk / ((PRESCALE+1) × 2^PW)50e6 / 256 ≈ 195.3 kHz
占空比最小步进1 / 2^PW0.39%
控制字 64 对应占空比duty / 2^PW × 100%64 / 256 = 25%
控制字 192 对应占空比duty / 2^PW × 100%192 / 256 = 75%

PW从 8 提到 10,分辨率就从 0.39% 变成 0.097%,代价是主计数器多两级逻辑,以及 PWM 周期变长。多数场景里 8 位已经够用,开环电机调速用 8 位,恒流 LED 驱动建议至少 10 位。

2.2 预分频把高主频拉回电机可用的 PWM 频率

FPGA 系统时钟常见 50 MHz 甚至 200 MHz,直接拿来做 PWM 计数器输出频率会偏高。电机驱动常用的开关频率在 20 kHz 附近,舵机用 50 Hz,LED 调光从几百 Hz 到几 kHz 都有。这时候需要在主计数器前加一级预分频器。

预分频的原理很简单:div_cnt从 0 数到PRESCALE就回绕,回绕后的div_en(PRESCALE+1)个系统时钟为周期出现一次,再拿div_en驱动主计数器。这样 PWM 频率公式变成:

PWM_FREQ = fclk / ((PRESCALE+1) × 2^PW)

以 50 MHz、PW=8 为例:

PRESCALEPWM 频率典型使用场景
0195.3 kHz数字电源纹波测试、快速调光
348.8 kHz中小功率电机驱动、音频淡入淡出
493.9 kHz加热器控制、慢速温控
199976 Hz舵机 50 Hz 附近还需继续加大分频

预分频不改变占空比分辨率,只把 PWM 频率整体往低频方向挪。设计时先按负载确定目标频率,再用公式反推PRESCALE,最后把结果回填到参数表里,这一步能省掉后面大量仿真返工。

2.3 duty 输入不做同步,波形会先“抖”给你看

duty来自状态机、串口、AXI-Lite 寄存器或外部引脚时,它不会恰好在 PWM 计数器的节拍上稳定。如果让duty直接参与比较,输出会在当前 PWM 周期的中间提前翻高拉低,表现的症状是:示波器上看占空比偶尔跳变,电机电流有毛刺,LED 亮度肉眼看不到但频谱测量能发现问题。

工程做法是在 PWM 模块内部加一个duty_reg,只在div_en有效的时钟沿把外部duty采进来。这样当前 PWM 周期内使用的仍是旧占空比,新占空比从下一个周期边界开始生效。对低压电机驱动来说,这种“慢一个周期”的延迟完全无感,却能消除绝大多数组合毛刺。

如果duty来自另一个时钟域,普通打拍不足够,需要先用两级触发器同步,再交给本模块采样。这个内容我会在第 5 章展开,先把主模块的 Verilog 实现完整写出来。

3. 可综合的 Verilog PWM 代码结构:带预分频与极性控制

3.1 端口、参数表:先明确这个 PWM 怎么调

模块边界设计成“参数管编译期、端口管运行期”。PW决定计数器和控制字位宽,PRESCALE决定分频系数,duty作为运行期输入自然就能在 FPGA 上随时改占空比。

端口方向位宽含义
clkinput1系统时钟,建议直接接全局时钟引脚
rst_ninput1异步复位,低有效
dutyinputPW占空比控制字,0 到2^PW-1
pwm_outoutput1PWM 方波输出,高电平有效
参数默认值作用
PW8PWM 计数器和占空比控制字位宽
PRESCALE0预分频系数,0 表示不分频

3.2 带预分频和钳位的完整 Verilog 代码

下面是完整模块,直接新建pwm_ctrl.v保存即可。代码里注释写的是边界条件,不是装饰。

`timescale 1ns / 1ps // --------------------------------------------------------------- // pwm_ctrl : 占空比可配置的 PWM 脉宽调制方波产生器 // 特性: 预分频 / 自由运行计数器 / duty 同步采样 / 100% 钳位 // 频率: f_clk / ((PRESCALE+1) * 2^PW) // 占空比: duty / 2^PW * 100%, duty=2^PW-1 时输出常高 // --------------------------------------------------------------- module pwm_ctrl #( parameter PW = 8, // 分辨率位宽, 计数上限 2^PW parameter PRESCALE = 0 // 预分频系数, 0 为不分频 )( input wire clk, input wire rst_n, input wire [PW-1:0] duty, output reg pwm_out ); // 分频计数器的位宽, PRESCALE=0 时至少保留 1 位 localparam DIV_W = (PRESCALE > 0) ? $clog2(PRESCALE + 1) : 1; reg [DIV_W-1:0] div_cnt; reg [PW-1:0] cnt; reg [PW-1:0] duty_reg; // div_en 有效时, 主计数器前进一步 wire div_en = (div_cnt == {DIV_W{1'b0}}); // 预分频: 每 (PRESCALE+1) 个系统时钟产生一个 div_en 节拍 always @(posedge clk or negedge rst_n) begin if (!rst_n) div_cnt <= {DIV_W{1'b0}}; else div_cnt <= (div_cnt == PRESCALE) ? {DIV_W{1'b0}} : div_cnt + 1'b1; end // duty 同步采样: 在 PWM 节拍上更新, 避免周期中间跳变 always @(posedge clk or negedge rst_n) begin if (!rst_n) duty_reg <= {PW{1'b0}}; else if (div_en) duty_reg <= duty; end // 自由运行计数器: 自然溢出, 周期 = 2^PW 个 div_en 节拍 always @(posedge clk or negedge rst_n) begin if (!rst_n) cnt <= {PW{1'b0}}; else if (div_en) cnt <= cnt + 1'b1; end // 用 next_cnt 做比较, 让回绕沿也能正确进入高电平区间 wire [PW-1:0] cnt_next = (cnt == {PW{1'b1}}) ? {PW{1'b0}} : cnt + 1'b1; // 比较输出: 下一拍 cnt<duty 则高, 否则低; 满量程钳位到常高 always @(posedge clk or negedge rst_n) begin if (!rst_n) pwm_out <= 1'b0; else if (div_en) begin if (duty_reg >= {PW{1'b1}}) pwm_out <= 1'b1; // 100% 占空比 else if (cnt_next < duty_reg) pwm_out <= 1'b1; // 导通区间 else pwm_out <= 1'b0; // 关断区间 end end endmodule

这段代码有三个地方值得单独说。

第一个是DIV_W的写法。PRESCALE=0时,$clog2(1)的结果是 0,直接定义一个reg [-1:0]会在很多仿真器里直接报错。这里用条件表达式把位宽兜底到 1,同时不改变功能。第二个是div_en不是电平使能,而是节拍信号。主计数器只在div_en为高的那个时钟沿动作,我特意把占空比和比较逻辑也放在同一节拍下更新,这样所有状态切换都对齐到同一条时间栅格,不会出现半路跳变。第三个是cnt_next。如果直接比较cnt < duty_reg,计数器回绕沿上cnt还是满量程值,pwm_out会在新周期第一拍误判成低电平,形成周期起始端的窄脉冲毛刺。用cnt_next比较,回绕沿计算结果等于 0,新周期第一拍就能正确输出高。

3.2.1 为什么输出用 reg 而不是 assign

pwm_out是时序寄存器而不是组合wire。这在 FPGA 上意义很大:寄存器输出天然减少组合路径长度,pwm_out可以直接接板上引脚或下一级模块,不需要再额外打一拍。另一个好处是仿真波形更干净,毛刺只会出现在div_en节拍沿,中间任何时刻pwm_out都保持不变,排查问题时能直接看沿。

3.3 三种最容易被 testbench 坑到的边界情况

输入/参数边界输出行为注意点
duty=0pwm_out恒为 0计数器仍正常运行,占空比可再次调回
duty=2^PW-1恒为高,表明显性钳位生效没有钳位时只有 255/256,不到 100%
duty在计数中段变化新值在下一个div_en节拍生效testbench 里要留出至少 2 个 PWM 周期再统计

如果只做呼吸灯这类视觉应用,不处理 100% 钳位问题不大;但做电机调速时,duty拉到最大值却还差一个 tick,电机会发出细微的开关噪声。把这个钳位分支保留,功能完整性更好。边界表也是后面 testbench 的检查清单,五个测量点正好覆盖 0%、25%、50%、75%、100%。

4. testbench 怎么写才完整:激励、统计与 ModelSim 操作步骤

4.1 testbench 的激励任务与统计逻辑

仿真文件要干三件事:生成时钟与复位、切换占空比、统计实际输出的高电平比例。以下 testbench 把“切 duty”和“统计”封进同一个任务,每段测量窗口都是 4096 个系统时钟。

`timescale 1ns / 1ps module tb_pwm_ctrl; reg clk; reg rst_n; reg [7:0] duty; wire pwm_out; pwm_ctrl #( .PW (8), .PRESCALE (3) ) u_pwm ( .clk (clk), .rst_n (rst_n), .duty (duty), .pwm_out (pwm_out) ); integer high_cnt; integer total_cnt; // 50 MHz 时钟, 周期 20ns always #10 clk = ~clk; task measure_duty; input [7:0] d; input [15:0] n; integer i; begin duty = d; // 等 10 个时钟, 确保 duty_reg 赶上新值 repeat(10) @(posedge clk); high_cnt = 0; total_cnt = 0; for (i = 0; i < n; i = i + 1) begin @(posedge clk); total_cnt = total_cnt + 1; if (pwm_out) high_cnt = high_cnt + 1; end $display("duty=0x%02X, 实测占空比=%0.2f%%", d, 100.0 * high_cnt / total_cnt); end endtask initial begin clk = 1'b0; rst_n = 1'b0; duty = 8'd0; #100; rst_n = 1'b1; measure_duty(8'd64, 4096); // 25% measure_duty(8'd128, 4096); // 50% measure_duty(8'd192, 4096); // 75% measure_duty(8'd255, 4096); // 100%, 走钳位分支 measure_duty(8'd0, 4096); // 0% $finish; end initial begin $dumpfile("tb_pwm_ctrl.vcd"); $dumpvars(0, tb_pwm_ctrl); end endmodule

任务里的n=4096不是随便选的。这个 testbench 实例的 PWM 周期是(PRESCALE+1) × 2^PW = 4 × 256 = 1024个系统时钟,4096 刚好等于 4 个完整 PWM 周期,所以统计窗口首尾对齐,测量误差非常小。repeat(10)只是等待duty_reg完成一次采样,保证统计结果属于新的占空比段。

4.2 ModelSim、Vivado、iverilog 三种仿真操作步骤

仿真工具命令/操作说明
ModelSim / QuestaSimvlib workvlog pwm_ctrl.v tb_pwm_ctrl.vvsim work.tb_pwm_ctrlGUI 里执行add wave -hex /tb_pwm_ctrl/*run 500us
Vivado xsimxvlog pwm_ctrl.v tb_pwm_ctrl.vxelab tb_pwm_ctrl -s pwm_sim -debug typicalxsim pwm_sim -gui工程模式也可直接 Set as Top 后 Run Behavioral Simulation
Icarus Verilogiverilog -s tb_pwm_ctrl -o pwm_tb.vvp pwm_ctrl.v tb_pwm_ctrl.vvvp pwm_tb.vvpgtkwave tb_pwm_ctrl.vcd适合命令行批处理回归

ModelSim 的完整交互命令如下:

vlib work vlog +acc pwm_ctrl.v tb_pwm_ctrl.v vsim work.tb_pwm_ctrl add wave -hex /tb_pwm_ctrl/* run 500us

注意+acc选项。用 ModelSim 做行为级仿真时,+acc能保证所有内部信号默认可见,不加时部分工具会把reg信号优化掉,波形窗口里只剩端口信号,调试就失去意义了。

4.3 从打印结果和波形确认占空比配置正确

跑完仿真的$display输出大致是:

duty=0x40, 实测占空比=25.00% duty=0x80, 实测占空比=50.00% duty=0xC0, 实测占空比=75.00% duty=0xFF, 实测占空比=100.00% duty=0x00, 实测占空比=0.00%

如果在你的环境里 25% 段打出 24.97%,不要慌,多半是统计窗口没有落在整数个 PWM 周期上。把输出改成等待一个div_en后采样,或者把仿真时间拉长到 8192 个时钟,整数误差就会消失。波形窗口里主要看cntduty_regpwm_out三者的对齐关系:cnt回绕时duty_reg同时更新,pwm_out在回绕沿立即进入高电平,这就是正确的周期起始行为。

Vivado 工程模式的操作稍微不同:新建工程后添加pwm_ctrl.vtb_pwm_ctrl.v,把tb_pwm_ctrl设置为仿真 Top,Behavioral Simulation 窗口跑完后右键pwm_out选择 Add To Wave Window,再跑500us就能看到完整波形。这套流程在 Xilinx、Altera 和开源工具链上都是通用的。

5. 把占空比配置做成寄存器接口,顺带绕开 PWM 常见坑

5.1 PW 位宽与 duty 寄存器的同步策略

模块只留了duty端口,但实际工程里占空比通常挂在 AXI-Lite、APB 或 SPI 寄存器上。把寄存器写信号和div_en直接拼在一起是不对的,寄存器写脉冲可能落在 PWM 计数器任意相位,新值可能只被部分采用。我的习惯是在 PWM 模块外面放一个duty_q寄存器,寄存器写使能有效时打一拍,再接到duty端口;模块内部已经有duty_reg做节拍采样,这样两级处理下来,任何总线写时序都不会影响 PWM 输出边界。若控制字来自异步域,则duty端口前必须加两级触发器,按MTBF的考虑选择 IOB 内寄存器和专用同步单元,FPGA 实现时不要随手用普通 BRAM 输出替代。

5.2 上板前补一个死区控制,避免 H 桥直通

这个模块输出是单端 PWM。驱动 H 桥或半桥时,需要把pwm_out拆成上下管互补信号,并在两路之间插入死区时间,否则开关切换瞬间会直通。死区不用改主模块,在外部包一层即可:PWM 上升沿立即给上管,下管延迟DEAD_TIME个时钟再关断;下降沿逻辑反过来。经验值是死区设置成 PWM 开关周期的 1% 到 2%,50 MHz 时钟下 8 个时钟就是 160 ns,对小功率 MOS 管已经够用。调试时先给大步长看电流尖峰是否消失,再逐步缩小,不要一开始就压到极限。

最后留一个自检技巧:仿真全部结束后,用$fdisplay把每段统计结果写到文本文件,和期望占空比表做一次 diff。这个方法能把回归测试的成本压到最低,尤其适合后续把duty改成寄存器接口后,验证总线读写是否破坏了 PWM 相位对齐。改动越大,越值得把这条 diff 留在 CI 脚本里跑。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询