FPGA实现PWM脉宽调制:从Verilog代码到Vivado仿真与开发板验证
2026/9/16 18:20:38 网站建设 项目流程

简介:面向FPGA数字系统设计初学者与高年级研究生的PWM脉宽调制实践资料,基于Vivado 2019.2平台,采用Verilog语言完成,也包含部分VHDL源文件,内容丰富扎实。资源围绕PWM生成与调节这一核心,完整呈现了工程创建、代码编写、功能仿真、逻辑综合、实现与下载验证的典型流程,适合课程设计、毕业设计及教研演示等场景。压缩包约99.61MB,共1012个文件,其中Vivado工程文件(xpr、v/vhd)、仿真数据库(sdb/vdb/wdb)与日志文件占比较大,另有tcl脚本、批处理命令、综合实现报告(dcp/rpt)和操作录像(avi)等,便于对照工程结构和仿真过程逐步理解FPGA设计方法。已有803人学习,资料内附操作录像,可跟随视频逐项操作,快速掌握PWM模块设计技巧;使用前注意工程路径不能包含中文,推荐在Vivado2019.2及以上版本中打开运行。通过全套源码、脚本与文档,使用者既能获得可直接复用的PWM控制器,也能深入体会FPGA从RTL设计到仿真的完整思路,提升数字电路调试与验证能力。

1. 为什么在 FPGA 上做 PWM:一个比定时器更灵活的选择

做电机驱动或者呼吸灯的时候,单片机上的 PWM 定时器经常不够用:通道数量固定、占空比更新时机不稳定、想加故障保护还得绕回中断里判断。转到 FPGA 上之后,情况完全不同。一个计数器加一个比较器就能生成一路 PWM,占空比可以在每个周期安全更新,故障信号直接接到输出逻辑上,做到硬实时关断。我用 Verilog 在 Vivado 2019.2 工程里把这条路完整走通,这里按实际开发顺序讲讲怎么定参数、怎么写代码、怎么仿真,以及最后落到开发板上你要注意什么。刚学完数字逻辑或者做嵌入式想切 FPGA 的工程师,都能按这套方法直接跑通第一版。

如果你只是想要一个可用的 IP,Xilinx 的 AXI Timer 也能输出 PWM,但理由很简单:手写 RTL 可控性高,资源开销几乎只有几个 LUT 和触发器,频率和占空比完全自己说了算。这篇文章的示例工程基于 50MHz 时钟,PWM 频率可以按参数调整,核心代码不到 50 行,非常适合作为第一个自己动手的 FPGA 基础模块。

2. PWM 脉宽调制的核心原理与参数设计(含 Verilog 计数器模型)

2.1 周期、占空比与分辨率:FPGA 版 PWM 的三大要素

PWM 波的本质是一个周期性数字信号,用高电平时间占整个周期的比例来表示模拟量。实现 PWM 的几种方式里,计数比较法最适合 FPGA:计数器每个时钟周期加一,当计数值小于阈值时输出高电平,达到或超过阈值后输出低电平,计数器计满一个周期归零,这个输出就是 PWM。

对于 FPGA 实现,需要确定三个量:PWM 频率、占空比步进和系统时钟频率。PWM 频率由目标对象决定,比如 LED 调光一般用几百 Hz 到几千 Hz,直流电机常用 15kHz 以上避免可闻噪声,航模舵机则要求 50Hz,而且占空比直接对应舵机角度。占空比步进决定能把输出调校多细,步进越小,分辨率越高,但计数器位宽也越大,随之而来的比较器和触发器的资源消耗也越高。

三个参数的关系是:周期计数值PERIOD = CLK_FREQ / PWM_FREQ,计数器位宽N = clog2(PERIOD)。以 50MHz 时钟为基准,可以列出下面这张常用对照表:

时钟频率PWM 频率周期计数值计数器位宽占空比分辨率
50MHz1kHz5000016 bit0.002%
50MHz15kHz333312 bit0.03%
50MHz50Hz100000020 bit0.0001%

这里容易踩的一个坑是:直接把占空比寄存器做成百分比形式,例如 16 位duty表示 0 到 65535,映射到 0% 到 100%,然后与计数值比较。这种做法没问题,但要注意PERIOD可能不是 2 的整数次幂。15kHz 时PERIOD = 3333,如果拿 16 位占空比直接比较,结果会超过计数值范围,行为不可控。我一般先把duty乘以PERIOD再右移DUTY_WIDTH位,把百分比映射到实际的比较阈值。

2.2 计数比较法:用 Verilog 表达 PWM 的最小实现

先看一个最简化的计数比较模型,不处理占空比更新时机,只说明核心逻辑:

module pwm_simple #( parameter PERIOD = 50000, parameter WIDTH = 16 )( input wire clk, input wire rst_n, input wire [WIDTH-1:0] duty, output reg pwm_out ); reg [WIDTH-1:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; pwm_out <= 1'b0; end else begin if (cnt == PERIOD - 1) cnt <= 0; else cnt <= cnt + 1'b1; pwm_out <= (cnt < duty) ? 1'b1 : 1'b0; end end endmodule

这段代码里,cnt从 0 到PERIOD-1循环,每个时钟周期比较一次。当cnt小于duty时输出高电平,否则输出低电平,这样一次循环就是完整的一个 PWM 周期。

问题在于duty如果是一个持续变化的输入信号,可能在计数器运行到中间时改变比较值,导致输出出现一段比正常宽很多或窄很多的脉冲。比如 duty 从 100 变成 40000,在改变的时钟沿上,计数器可能刚好处于 30000,输出直接拉高并一直保持到周期结束,这一周期的占空比完全失真。解决办法是引入同步装载机制:在计数器回零的那个时钟边沿,才把新的duty值写入内部比较寄存器,确保每个 PWM 周期都使用同一个阈值。

2.3 参数化设计:预先计算时钟频率与步进值

实际工程中,周期和位宽都应该写成参数,这样换板子或者换负载时不用改逻辑。我用 Vivado 2019.2 时习惯在模块头定义CLK_FREQPWM_FREQDUTY_WIDTH三个参数,然后在内部用localparam算出周期和位宽。

计算示例:目标 1kHz、占空比步进 0.1%,50MHz 时钟。周期PERIOD = 50000,分辨率是 1/50000,远高于 0.1% 的需求。占空比寄存器用 16 位,如果直接映射,需要一次 16 位乘 16 位的乘法,综合工具会映射到 DSP48 上。如果位宽超过 32 位,DSP 可能不够用,这时候就要考虑把占空比设计成实际阈值而不是百分比。

还有一点要注意:$clog2是 Verilog-2001 系统函数,Vivado 综合器和 XSim 都支持。如果你用localparam CNT_WIDTH = $clog2(PERIOD)产生编译错误,八成是文件的语法版本设置不对,或者工程里混入了旧 IP。PWM 频率不要选得和时钟太接近,至少需要低 100 倍以上,否则计数器位数太少,占空比每跳动一位都会引起明显的输出抖动。

3. 在 Vivado 2019.2 中编写 Verilog PWM 模块并完成仿真

3.1 完整 RTL 代码与注释说明

下面这个模块集成了参数化周期、占空比百分比映射、同步装载、使能控制和故障保护,可以直接用于工程:

module pwm_fpga #( parameter CLK_FREQ = 50_000_000, parameter PWM_FREQ = 1_000, parameter DUTY_WIDTH = 16 )( input wire clk, input wire rst_n, input wire [DUTY_WIDTH-1:0] duty, // 0 ~ 2^DUTY_WIDTH-1 映射到 0% ~ 100% input wire pwm_enable, // 使能,拉低则无输出 input wire fault, // 故障信号,高电平关断输出 output reg pwm_out, output wire pwm_period_irq // 每一帧的边界脉冲,可接 ILA 或外部示波器触发 ); localparam PERIOD = CLK_FREQ / PWM_FREQ; localparam CNT_WIDTH = $clog2(PERIOD); reg [CNT_WIDTH-1:0] cnt; reg [CNT_WIDTH:0] duty_f; // 当前周期比较阈值,允许等于 PERIOD wire [DUTY_WIDTH+CNT_WIDTH-1:0] duty_mult; assign duty_mult = duty * PERIOD[CNT_WIDTH-1:0]; assign pwm_period_irq = (cnt == PERIOD - 1); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; duty_f <= 0; pwm_out <= 1'b0; end else begin if (pwm_period_irq) begin cnt <= 0; duty_f <= duty_mult >> DUTY_WIDTH; // 周期边界同步更新阈值 end else begin cnt <= cnt + 1'b1; end if (!pwm_enable || fault) pwm_out <= 1'b0; else pwm_out <= (cnt < duty_f) ? 1'b1 : 1'b0; end end endmodule

代码中的duty_mult是组合逻辑乘法,duty_fpwm_period_irq出现的那一拍被更新为当前duty映射后的比较值。cntduty_f同拍更新,所以下一个立即生效的 PWM 周期会完整地使用新阈值,不会出现半周期毛刺。pwm_period_irq不仅用于同步装载,也可以作为调试信号。

fault输入直接走进输出逻辑,只要拉高一个时钟周期,pwm_out立刻为低,不需要等到周期结束。这里有个细节:如果故障信号来自外部、与clk没有同步,需要先做两级触发器同步,防止亚稳态传到组合逻辑里。

3.2 编写 testbench 并分析波形

仿真用 Vivado 自带的 XSim 就可以。新建仿真文件后,把下面的 testbench 设为 top:

`timescale 1ns / 1ps module tb_pwm_fpga; reg clk; reg rst_n; reg [15:0] duty; reg pwm_enable; reg fault; wire pwm_out; wire pwm_period_irq; pwm_fpga #( .CLK_FREQ (50_000_000), .PWM_FREQ (1_000), .DUTY_WIDTH(16) ) uut ( .clk(clk), .rst_n(rst_n), .duty(duty), .pwm_enable(pwm_enable), .fault(fault), .pwm_out(pwm_out), .pwm_period_irq(pwm_period_irq) ); initial clk = 0; always #10 clk = ~clk; // 20ns 周期 => 50MHz initial begin rst_n = 0; duty = 0; pwm_enable = 0; fault = 0; repeat (10) @(posedge clk); rst_n = 1; pwm_enable = 1; #20us; duty = 16'd32768; // 50% 占空比 #2ms; duty = 16'd16384; // 25% 占空比 #2ms; fault = 1; // 强制关断 #500us; fault = 0; #1ms; $finish; end endmodule

仿真流程是:在 Vivado 左侧 Flow Navigator 里选 Run Simulation - Run Behavioral Simulation,确认tb_pwm_fpga是仿真顶层。打开pwm_outpwm_period_irq波形,用鼠标标记测量高电平时间。频率 1kHz 时,周期是 1ms,50% 占空比对应高电平 500us,25% 对应 250us。

如果想自动化检查占空比,可以在 testbench 里统计每个周期的高电平时钟数:

integer high_cnt; always @(posedge clk) begin if (pwm_out) high_cnt = high_cnt + 1; if (pwm_period_irq) begin if (high_cnt != 16'd32768) $display("period high cnt error: %0d", high_cnt); high_cnt = 0; end end

注意这个统计逻辑本身要和pwm_period_irq对齐,否则第一个周期的计数值会少一段。最好在测试时先跑一段空载延时,等占空比稳定后再开始统计。

3.3 参数调优与仿真波形对照

调参时主要看应用场景。不同负载对 PWM 频率和占空比范围的要求完全不同:

应用PWM 频率典型占空比范围说明
LED 呼吸灯1kHz0%-100%频率太低会看到闪烁,超过几 kHz 人眼无法分辨
舵机50Hz5%-10%(1ms-2ms 脉宽)周期固定 20ms,占空比范围很窄
直流电机15-20kHz0%-100%避开可闻噪声,需要驱动芯片和续流二极管
加热器1-10Hz0%-100%热惯性大,低频即可,还能减少继电器损耗

修改模块参数时,只需要改实例化里的CLK_FREQPWM_FREQ。例如舵机控制可以设为CLK_FREQ50MHz、PWM_FREQ50Hz,此时PERIOD = 1000000CNT_WIDTH = 20,占空比 5% 对应duty = 3277,但舵机真正关心的是脉宽 1ms,对应duty = 50000除以 20 位最大值再乘 65536。如果直接把 1ms 换算成占空比,高频小误差会放大成角度误差,这时更合适的做法是把duty直接定义为比较阈值,而不是百分比。

4. 从 RTL 到开发板:Vivado 2019.2 工程搭建、约束与下载

4.1 新建工程与器件选择的关键步骤

Vivado 2019.2 的工程创建有两种方式。我习惯用 Tcl 命令,尤其是录制操作视频时要反复删除重建工程,命令行比 GUI 稳定得多:

create_project pwm_fpga ./pwm_fpga -part xc7a35tcsg324-1 add_files -norecurse ./rtl/pwm_fpga.v add_files -fileset sim_1 -norecurse ./sim/tb_pwm_fpga.v update_compile_order -fileset sources_1

器件型号要严格对应开发板。Artix-7 XC7A35T 有 CSG324 和 CPG236 两种封装,选错引脚约束全部无效。如果用的是国产板卡,型号通常在底板或原理图第一页能找到。工程创建后,在 Sources 面板里右键pwm_fpga.v,选择 Set as Top;仿真文件也要确认 Set as Top,否则仿真时可能跑到综合顶层上。

4.2 引脚约束与时钟约束写法

约束文件负责把 RTL 端口映射到真实引脚,并告诉工具时钟周期。一个对应 50MHz 系统时钟和单路 PWM 输出的 XDC 如下:

# 系统时钟引脚,50MHz 晶振,实际引脚要看你的原理图 set_property PACKAGE_PIN D12 [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk] # PWM 输出,建议先接 LED 或示波器,不要直接接电机 set_property PACKAGE_PIN R1 [get_ports pwm_out] set_property IOSTANDARD LVCMOS33 [get_ports pwm_out] # 创建 50MHz 时钟约束 create_clock -period 20.000 -name sys_clk [get_ports clk]

引脚编号必须对照开发板原理图修改。有些板卡的 LED 阳极已经串联电阻,输出高电平点亮;有些则用高电平关闭。如果发现占空比和预期相反,先检查外部电路,再考虑是否要反转输出。IOSTANDARD也要匹配,有的核心板是 1.8V 供电,不能直接用 LVCMOS33。

4.3 综合、实现与比特流生成的注意事项

在 Run Synthesis 之前,先在 Messages 面板里确认没有语法错误。综合成功后进入 Run Implementation,最后 Generate Bitstream。Vivado 2019.2 的默认流会按顺序执行,也可以在 Design Runs 里选 Launch Runs 同时完成。

生成比特流后,打开 Hardware Manager,连接 JTAG 线,点击 Program Device 选择.bit文件。下载后如果 LED 亮度没有变化,先用手触摸芯片和引脚区域看是否过热,再用万用表测引脚电压。实际测量时,FPGA 输出是 3.3V 方波,示波器能看到明显的梯形边沿,这很正常。如果 SW 上连拨码开关、按键都没接,复位信号rst_n必须接一个独立按键,或者在上电后通过时钟分频产生自动复位。我这里是为了演示方便,实际产品里建议用上电延时复位。

录操作视频时,我一般同时打开工程、仿真波形和 Hardware Manager,先从仿真开始跑一小段,再下载到板子用示波器对照。这样观看者能直观看到 RTL 仿真结果和真实波形之间的差异,也方便排查是不是引脚约束写错。

5. 让 PWM 更实用:故障保护、死区控制与逻辑分析仪验证

5.1 用逻辑分析仪抓取真实波形

仿真正确不等于板子上一定正确。FPGA 引脚上的输出延迟、板载 RC 滤波、示波器探头电容都会让波形变形。最简单的验证方法是把pwm_out接逻辑分析仪,采样率至少设为 PWM 频率的 20 倍。例如 1kHz 的 PWM 用 20MHz 采样,这样每个周期有 20000 个采样点,占空比测量误差可以控制在 0.005% 左右。用 Saleae Logic 或者普通国产逻辑分析仪,直接看高电平宽度,和duty_f >> 16 * PERIOD的理论值对比。

如果不想外接仪器,可以在 Vivado 中例化 ILA 核。把clk接到 ILA 的时钟,采集pwm_outduty_fcnt三个信号。这样下载后直接在 Hardware Manager 窗口里能看到实时波形,最大优势是能同时看到内部比较阈值和计数器行为,定位问题比示波器快得多。不过 ILA 会把观察窗口深度的变量存到 BRAM 里,如果你的设计 BRAM 占用率高,可以把采样深度从 1024 改成 512,只抓几个周期足够。

5.2 增加故障保护与死区时间

真正的电机驱动项目里,光有 PWM 还不够。故障保护已经在代码fault支路体现,但实际工程需要把故障信号同步到时钟域。常见做法是先用两级触发器打两拍,再接入模块。如果故障信号来自过流比较器,通常希望故障锁存,直到软件清除后才恢复输出。那就要在模块外部再加一个状态寄存器,不能直接让fault自动清除。

死区时间用于 H 桥驱动,防止同桥臂上下管同时导通。FPGA 实现死区不需要写 while 延时,直接用一个移位寄存器产生延迟即可:

reg [7:0] dly_shift; always @(posedge clk) begin dly_shift <= {dly_shift[6:0], pwm_out}; end wire pwm_delayed = dly_shift[7]; // 延迟 8 个时钟周期

这段代码把原 PWM 延迟了 8 拍,如果时钟是 50MHz,死区时间就是 160ns。实际死区大小要看 MOSFET 的关断延时,一般在几百纳秒到几微秒。你需要在原信号和延迟信号上做组合逻辑生成互补输出,并且保证死区期间两个输出都为低电平。注意死区移位寄存器的位宽不要超过一个 PWM 周期对应的时钟数,否则输出会丢失整个周期。

5.3 高分辨率 PWM 的进阶思路

如果项目要求 16 位甚至更高分辨率,但时钟频率不允许计数器计到 65535,普通计数比较法就不够用了。常见思路是混合移位调制:粗调部分还是计数器,细调部分在周期末尾用 CARRY8 进位链做延迟插入,实现远小于一个时钟周期的脉宽调整。这种 DPWM 结构能提高等效分辨率,但延迟链受温度和电压影响,需要在线校准。另一种是 delta-sigma 调制,让输出在新的周期里以高频率抖动,用平均电平表示高精度占空比,代价是瞬时纹波增大。这两种方案都要先保证占空比在周期边界更新、故障关闭时不产生毛刺,再去处理增量精度。这也是我不建议一上来就深挖高分辨率 PWM 的原因。

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

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

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

立即咨询