
简介基于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 周期计数器与占空比分辨率先定参数再写 Verilog2.1 计数器回绕到 2^WIDTHPWM 的分辨率就没误差一个 PWM 周期由若干时钟周期组成。计数器从 0 走到满再回到 0这个回绕周期决定了 PWM 频率占空比则在“计数值小于控制字”的区间输出高电平。选什么回绕值直接影响实现复杂度和占空比误差。最常见的做法是让计数器自然溢出不判断上限也就是用2^WIDTH作为周期。原因有两个硬件位宽溢出就是取模既省比较器又省复位逻辑占空比控制字按同一套位宽取值配出来的百分比在数值上完全线性。reg [PW-1:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) cnt {PW{1b0}}; else cnt cnt 1b1; end wire pwm_tmp (cnt duty) ? 1b1 : 1b0;这段骨架能跑但不是工程版本没有预分频时PWM 频率被系统时钟和位宽锁死duty又是组合输入实时比较时可能在周期中间跳变。后面的章节逐个补上。下表把这套映射关系写清楚指标公式例子CLK50 MHz, PW8, PRESCALE0PWM 频率fclk / ((PRESCALE1) × 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 HzLED 调光从几百 Hz 到几 kHz 都有。这时候需要在主计数器前加一级预分频器。预分频的原理很简单div_cnt从 0 数到PRESCALE就回绕回绕后的div_en以(PRESCALE1)个系统时钟为周期出现一次再拿div_en驱动主计数器。这样 PWM 频率公式变成PWM_FREQ fclk / ((PRESCALE1) × 2^PW)以 50 MHz、PW8 为例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-1pwm_outoutput1PWM 方波输出高电平有效参数默认值作用PW8PWM 计数器和占空比控制字位宽PRESCALE0预分频系数0 表示不分频3.2 带预分频和钳位的完整 Verilog 代码下面是完整模块直接新建pwm_ctrl.v保存即可。代码里注释写的是边界条件不是装饰。timescale 1ns / 1ps // --------------------------------------------------------------- // pwm_ctrl : 占空比可配置的 PWM 脉宽调制方波产生器 // 特性: 预分频 / 自由运行计数器 / duty 同步采样 / 100% 钳位 // 频率: f_clk / ((PRESCALE1) * 2^PW) // 占空比: duty / 2^PW * 100%, duty2^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 ); // 分频计数器的位宽, PRESCALE0 时至少保留 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{1b0}}); // 预分频: 每 (PRESCALE1) 个系统时钟产生一个 div_en 节拍 always (posedge clk or negedge rst_n) begin if (!rst_n) div_cnt {DIV_W{1b0}}; else div_cnt (div_cnt PRESCALE) ? {DIV_W{1b0}} : div_cnt 1b1; end // duty 同步采样: 在 PWM 节拍上更新, 避免周期中间跳变 always (posedge clk or negedge rst_n) begin if (!rst_n) duty_reg {PW{1b0}}; 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{1b0}}; else if (div_en) cnt cnt 1b1; end // 用 next_cnt 做比较, 让回绕沿也能正确进入高电平区间 wire [PW-1:0] cnt_next (cnt {PW{1b1}}) ? {PW{1b0}} : cnt 1b1; // 比较输出: 下一拍 cntduty 则高, 否则低; 满量程钳位到常高 always (posedge clk or negedge rst_n) begin if (!rst_n) pwm_out 1b0; else if (div_en) begin if (duty_reg {PW{1b1}}) pwm_out 1b1; // 100% 占空比 else if (cnt_next duty_reg) pwm_out 1b1; // 导通区间 else pwm_out 1b0; // 关断区间 end end endmodule这段代码有三个地方值得单独说。第一个是DIV_W的写法。PRESCALE0时$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 而不是 assignpwm_out是时序寄存器而不是组合wire。这在 FPGA 上意义很大寄存器输出天然减少组合路径长度pwm_out可以直接接板上引脚或下一级模块不需要再额外打一拍。另一个好处是仿真波形更干净毛刺只会出现在div_en节拍沿中间任何时刻pwm_out都保持不变排查问题时能直接看沿。3.3 三种最容易被 testbench 坑到的边界情况输入/参数边界输出行为注意点duty0pwm_out恒为 0计数器仍正常运行占空比可再次调回duty2^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(duty0x%02X, 实测占空比%0.2f%%, d, 100.0 * high_cnt / total_cnt); end endtask initial begin clk 1b0; rst_n 1b0; duty 8d0; #100; rst_n 1b1; measure_duty(8d64, 4096); // 25% measure_duty(8d128, 4096); // 50% measure_duty(8d192, 4096); // 75% measure_duty(8d255, 4096); // 100%, 走钳位分支 measure_duty(8d0, 4096); // 0% $finish; end initial begin $dumpfile(tb_pwm_ctrl.vcd); $dumpvars(0, tb_pwm_ctrl); end endmodule任务里的n4096不是随便选的。这个 testbench 实例的 PWM 周期是(PRESCALE1) × 2^PW 4 × 256 1024个系统时钟4096 刚好等于 4 个完整 PWM 周期所以统计窗口首尾对齐测量误差非常小。repeat(10)只是等待duty_reg完成一次采样保证统计结果属于新的占空比段。4.2 ModelSim、Vivado、iverilog 三种仿真操作步骤仿真工具命令/操作说明ModelSim / QuestaSimvlib work→vlog pwm_ctrl.v tb_pwm_ctrl.v→vsim work.tb_pwm_ctrlGUI 里执行add wave -hex /tb_pwm_ctrl/*后run 500usVivado xsimxvlog pwm_ctrl.v tb_pwm_ctrl.v→xelab tb_pwm_ctrl -s pwm_sim -debug typical→xsim pwm_sim -gui工程模式也可直接 Set as Top 后 Run Behavioral SimulationIcarus Verilogiverilog -s tb_pwm_ctrl -o pwm_tb.vvp pwm_ctrl.v tb_pwm_ctrl.v→vvp pwm_tb.vvp→gtkwave 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输出大致是duty0x40, 实测占空比25.00% duty0x80, 实测占空比50.00% duty0xC0, 实测占空比75.00% duty0xFF, 实测占空比100.00% duty0x00, 实测占空比0.00%如果在你的环境里 25% 段打出 24.97%不要慌多半是统计窗口没有落在整数个 PWM 周期上。把输出改成等待一个div_en后采样或者把仿真时间拉长到 8192 个时钟整数误差就会消失。波形窗口里主要看cnt、duty_reg和pwm_out三者的对齐关系cnt回绕时duty_reg同时更新pwm_out在回绕沿立即进入高电平这就是正确的周期起始行为。Vivado 工程模式的操作稍微不同新建工程后添加pwm_ctrl.v和tb_pwm_ctrl.v把tb_pwm_ctrl设置为仿真 TopBehavioral 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 脚本里跑。本文还有配套的精品资源点击获取