尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于Vivado和Verilog的FPGA PWM脉宽调制模块设计与实现

基于Vivado和Verilog的FPGA PWM脉宽调制模块设计与实现 简介面向FPGA初学者的PWM脉宽调制教学资源基于vivado2019.2平台使用Verilog语言完成工程实现适用于本硕博及科研人员的教研与自主学习。PWM脉宽调制是电机控制、LED调光、电源变换等领域的常用技术项目从代码编写到仿真验证提供了完整链路可帮助理解占空比与周期调节的FPGA实现方式。工程路径需为英文建议采用2019.2或更高版本Vivado打开配套操作录像能降低上手门槛。整包共1012个文件压缩包约99.61MB包含Vivado工程文件xpr、prj、Verilog/VHDL源码、仿真波形与日志、Tcl脚本与批处理命令以及AVI操作录屏等目录结构清晰便于按需查阅与复现。已有803人学习内容覆盖综合实现与常见排错环节并提供多类辅助脚本与中间文件适合对照视频逐项练习也可作为课程设计、毕业设计或研究生实验的参考模板。1. 为什么用 FPGA 做 PWM 而不是单片机定时器做电机驱动、LED 调光、开关电源第一反应往往是单片机定时器。但当你需要同时输出 8 路、频率 20kHz 以上、且希望占空比更新与 PWM 周期严格同步时定时器中断方案就暴露出两个硬伤中断响应延迟带来的相位抖动以及多路输出之间的时序漂移。FPGA 做 PWM 的核心优势在于它把计数—比较—翻转这条链路全部放进硬件逻辑周期确定、无 jitter、多路天然同步。这篇博客基于 Vivado 2019.2 平台用 Verilog 完整实现一个参数可配的 PWM 脉宽调制模块并给出仿真和上板验证的具体操作路径。工程文件和操作录像已打包适合本硕博做 FPGA 课程设计或工业控制方向教研学习路径要求英文Vivado 版本 2019.2 以上。2. PWM 生成原理与计数器/比较器结构选型2.1 PWM 三要素频率、占空比、相位PWM 波形本质上是周期性方波描述它只需要三个参数输出频率周期倒数、占空比高电平时间占比、相位波形起点位置。FPGA 实现方案里最常见的是自由运行计数器 比较器结构即计数器从 0 累加到周期上限后归零同时把计数值和占空比阈值做比较计数值小于阈值输出高电平否则输出低电平。// 核心比较逻辑伪代码 always (posedge clk) begin if (counter duty) pwm_out 1b1; else pwm_out 1b0; end这段逻辑虽然只有三行但决定了整个 PWM 模块的精度与资源消耗。计数器位宽 N 决定占空比分辨率输出频率由系统时钟频率和分频系数共同决定。假设系统时钟 50MHz分频系数为 1不分频N 取 12 位则计数器上限为 4095PWM 输出频率约为 12.2kHz占空比最小步进为 1/4096约 0.024%这个精度足以覆盖绝大多数电机调速和电源控制场景。2.2 为什么选计数器比较器而不是状态机或定时器有同学会问用状态机翻转电平行不行理论上可以但状态机在频率切换和占空比动态更新时需要额外维护状态转移条件代码复杂度随路数线性上升。而计数比较结构天然支持多路并行——每一路只需要一个比较器共享同一个计数器占空比更新写入寄存器后下一个周期立即生效不需要打断当前周期的输出。另一个问题是相位对齐。电机驱动里经常需要多路 PWM 相位完全一致如果用独立状态机或独立计数器做同步要加额外的同步信号共享计数器从硬件结构上就保证了这一点。这也是 FPGA 在电机控制领域优于 MCU 定时器阵列的关键原因之一。提示计数上限用到 2^N-1 还是 2^N会影响输出频率和占空比精度的取舍。取 2^N-1 时高电平时间范围是 0 到 2^N-1 个时钟周期逻辑简单取 2^N 时输出频率更接近理论值但需要额外处理溢出回绕。本设计采用 2^N-1 方案工程上足够。2.3 分频与分辨率的设计权衡系统时钟往往很高直接让计数器跑 50MHz、12 位输出频率只有 12kHz 左右达不到一些场景要求比如开关电源要几百 kHz。这时候需要预分频器把计数器的输入时钟降下来分频系数越大输出频率越低但占空比分辨率保持不变——因为分频只影响计数节奏不影响计数器位宽。这里有两种设计取向第一种是高频低分辨率分频系数小输出频率高但位宽受限占空比粗糙。第二种是低频高分辨率分频系数大输出频率低位宽可以开很大占空比精细。实际工程里先确定输出频率 F再确定占空比精度要求 P比如 0.1%反推计数器位宽 N 和分频系数 D。公式如下计数器位宽 N ≥ ceil(log2(1/P))分频系数 D F_clk / (F_pwm × 2^N)比如系统时钟 50MHz要输出 20kHz PWM、精度做到 1/1024则 N 取 10D 50e6 / (20e3 × 1024) ≈ 2.44向上取整为 3实际输出频率约 16.28kHz。这意味着精度和频率之间存在不可避免的折中设计时必须先明确优先级。3. Verilog 代码实现与 Vivado 2019.2 工程搭建3.1 模块划分分频器、PWM 核心、顶层例化一个可复用的 PWM 模块应该拆成三个部分时钟分频模块、PWM 波形生成模块、顶层对接模块。分频模块和 PWM 核心模块保持独立便于单测顶层只做例化和参数连接。这样也方便后面扩展多路输出——例化多个 pwm_gen 即可。先写时钟分频模块使用计数器实现任意整数分频支持奇数和偶数分频。这里不用 vivado 自带的 clocking wizard因为那种方式生成的是优化的全局时钟网络用于几百 kHz 的 PWM 有点浪费普通分频逻辑就够了。// clk_div.v module clk_div #( parameter DIV 4 // 分频系数即每 DIV 个系统时钟产生 1 个分频时钟 )( input wire clk, input wire rst_n, output reg clk_out ); reg [$clog2(DIV)-1:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 0; clk_out 1b0; end else if (cnt (DIV/2 - 1)) begin cnt 0; clk_out ~clk_out; // 周期翻转得到 50% 占空比分频时钟 end else begin cnt cnt 1b1; end end endmodule这里参数 DIV 用 $clog2 自动计算计数器位宽避免手动指定位数。DIV 必须是偶数因为代码里 DIV/2 作为翻转点如果应用场景需要奇数分频要改成双计数器方案后面实战部分会提到。分频时钟的占空比固定为 50%不影响最终 PWM 输出——PWM 波形的占空比由比较器决定分频时钟只负责节奏。3.2 PWM 生成模块占空比寄存器更新与时序说明PWM 核心模块是设计的重点包含周期计数、占空比比较、以及占空比寄存器的同步更新逻辑。占空比输入用duty信号位宽通过参数N定义。为了安全这里加一个shutdown信号拉高时输出强制为低用于故障保护场景。// pwm_gen.v module pwm_gen #( parameter N 12 // 计数器位宽占空比分辨率为 2^N )( input wire clk, // 经过分频后的时钟 input wire rst_n, input wire [N-1:0] duty, // 占空比阈值0 ~ 2^N-1 input wire shutdown, // 故障保护输入高电平关闭输出 output reg pwm_out ); reg [N-1:0] cnt; // 周期计数器计数范围 0 ~ 2^N-1 always (posedge clk or negedge rst_n) begin if (!rst_n) cnt {N{1b0}}; else if (cnt {N{1b1}}) cnt {N{1b0}}; // 计数到上限后归零形成周期 else cnt cnt 1b1; end // 比较输出计数值小于 duty 时输出高电平 always (posedge clk or negedge rst_n) begin if (!rst_n) pwm_out 1b0; else if (shutdown) pwm_out 1b0; // 故障时强制拉低优先级最高 else if (cnt duty) pwm_out 1b1; else pwm_out 1b0; end endmodulecnt duty而不是cnt duty这一点很关键。duty 0 时输出恒为低duty 2^N-1 时输出恒为高正好对应 0% 和接近 100% 的占空比。如果改用duty 0 时会出现一个时钟周期的高电平毛刺。shutdown信号的优先级高于占空比比较这样在外部电路过流或过压时能立即关断输出不需要等待计数器周期结束。3.3 顶层模块例化与参数传递方式顶层模块把分频器和 PWM 核心连起来同时把占空比阈值暴露成外部输入方便在仿真或上板时直接赋值。参数用 defparam 或 #() 例化传递这里用 #() 更清晰。// pwm_top.v module pwm_top #( parameter CLK_DIV 4, // 分频系数需要偶数 parameter PWM_BITS 12 // 占空比分辨率位宽 )( input wire clk_50m, input wire rst_n, input wire [PWM_BITS-1:0] duty_in, input wire shutdown_in, output wire pwm_out ); wire clk_div_out; // 例化分频模块产生 PWM 计数器时钟 clk_div #(.DIV(CLK_DIV)) u_clk_div ( .clk (clk_50m), .rst_n (rst_n), .clk_out(clk_div_out) ); // 例化 PWM 生成模块 pwm_gen #(.N(PWM_BITS)) u_pwm_gen ( .clk (clk_div_out), .rst_n (rst_n), .duty (duty_in), .shutdown(shutdown_in), .pwm_out (pwm_out) ); endmodule两个参数带上默认值后顶层既可以按默认参数运行也可以通过 Vivado 的 Elaborate 界面重新指定参数值不用改代码。在 Vivado 2019.2 里修改参数位置在 Sources 窗口右键点击pwm_top选 Set as Top然后在下的 Properties 面板里可以看到 HDL Parameters。实际使用时CLK_DIV和PWM_BITS要在仿真前想清楚因为改参数会改变输出频率和精度重新综合后时序结果也会变。3.4 XDC 管脚约束要点Vivado 是约束驱动的开发流程不写 XDC 文件综合能过但 Implementation 会报管脚未分配的错误。针对本设计的输入输出约束文件里至少要声明三个内容时钟管脚、复位管脚、PWM 输出管脚。下面给出一份基于 Digilent Basys3Artix-7 XC7A35T风格的约束如果你用的开发板不同管脚号需要换成板卡原理图上的对应编号。# pwm_top.xdc set_property PACKAGE_PIN W5 [get_ports clk_50m] set_property IOSTANDARD LVCMOS33 [get_ports clk_50m] set_property PACKAGE_PIN U7 [get_ports rst_n] set_property IOSTANDARD LVCMOS33 [get_ports rst_n] set_property PACKAGE_PIN V17 [get_ports pwm_out] set_property IOSTANDARD LVCMOS33 [get_ports pwm_out]还有一个坑是时钟约束。有些工程只写了管脚约束没写 create_clockVivado 会给出时序警告如果系统时钟频率低、组合逻辑简单这个警告可以忽略但严谨的做法是在 XDC 中加一行主时钟约束create_clock -period 20.0 [get_ports clk_50m]20ns 对应 50MHz。约束文件里同时有 log 信息会显示create_clock是否被正确识别。本工程路径中不能出现中文这一点 Windows 下特别容易踩坑建议整个 Vivado 工程目录直接命名为英文不要出现基于“工程”这样的中文字符否则会有个别老版本 IP 在综合阶段报路径异常的问题。4. 基于 Vivado 2019.2 的仿真验证与操作录像对照4.1 Testbench 设计与激励时序仿真用行为级 testbench 验证 PWM 波形占空比先给一个固定值跑若干个周期后动态改占空比观察输出是否随之变化。testbench 里要例化顶层模块生成时钟并通过$readmemb或直接给寄存器赋值的方式改变duty_in。// tb_pwm_top.v timescale 1ns / 1ps module tb_pwm_top; reg clk_50m 0; reg rst_n 0; reg [11:0] duty_in 12d1024; // 初始占空比约 25% reg shutdown_in 0; wire pwm_out; // 50MHz 时钟生成周期 20ns always #10 clk_50m ~clk_50m; // 例化被测模块使用默认 CLK_DIV4, PWM_BITS12 pwm_top #( .CLK_DIV(4), .PWM_BITS(12) ) uut ( .clk_50m (clk_50m), .rst_n (rst_n), .duty_in (duty_in), .shutdown_in(shutdown_in), .pwm_out (pwm_out) ); initial begin rst_n 0; #100; rst_n 1; // 释放复位 #20000; // 跑一段观察 25% 占空比输出 duty_in 12d3072; // 改成约 75% 占空比 #20000; shutdown_in 1; // 测试故障保护输出应强制拉低 #1000; shutdown_in 0; #5000; $finish; end endmoduletestbench 中CLK_DIV4意味着分频后 PWM 计数时钟为 12.5MHz12 位计数器周期为 4096 个计数时钟输出频率约 3.05kHz。仿真里看波形直接数高电平占一个完整周期的比例即可不需要精确测量。占空比从 1024 改到 3072 这个过程重点观察输出波形是否无毛刺切换以及切换点在计数器回绕处还是任意位置——本设计是任意位置切换都安全因为输出只有电平翻转不涉及组合逻辑竞争。4.2 Vivado 2019.2 仿真操作流程与录像对照操作录像里我用的流程是打开工程后在 Sources——Simulation Sources 下添加 testbench 文件确认tb_pwm_top被设为 simulation top左侧 Flow Navigator 点击 Run Simulation选择 Run Behavioral Simulation。这里有一个细节第一次仿真时 Vivado 会编译所有相关源文件耗时几十秒到几分钟不等编译日志里出现Behavioral Simulation completed successfully表示编译通过。波形窗口打开后的关键操作是点击 Restart 按钮重置仿真时间然后点击 Run All 跑完整个 testbench 的时长。本 testbench 总共约 46000ns可以手动输入运行时间长度。如果某个信号的位宽太大导致波形显示为总线值不方便看占空比可以右键信号选择 Radix——Unsigned Decimal把计数器的数值显示出来。仿真结果最终应看到 pwm_out 在 0~20480ns 期间高电平占比明显低20480~40480ns 期间高电平占比明显高shutdown_in 拉高后 pwm_out 立即变 0。4.3 仿真波形解读与常见错误排除仿真最常见的三类问题。第一类是 pwm_out 一直为高或一直为低先检查rst_n信号是否在 100ns 后正确拉高再检查duty_in是不是为 0 或满量程。第二类是波形完全没有输出大概率是 testbench 里顶层参数不匹配比如例化时写了#(.CLK_DIV(3))而分频模块要求偶数分频这种情况 clk_div_out 不翻转。第三类是仿真速度极慢跑到几毫秒几乎没有进展这是因为 testbench 里forever或循环次数写多了Vivado 的仿真器是事件驱动的对于长周期大位宽计数器仿真时间步长会非常大。常见排错手段是追加$display语句打印关键寄存器值。比如在 PWM 模块内部加如下打印always (posedge clk) begin if (cnt {N{1b1}}) $display(pwm cycle done, duty%0d, pwm_out%b, duty, pwm_out); end这样每次 PWM 周期结束都会在 Tcl Console 打印一行信息不用盯着波形就能判断占空比更新是否生效。从 Vivado 2019.2 开始仿真器对$display的处理效率有优化不会像老版本那样拖慢仿真。日志中如果出现pwm cycle done但占空比未变化说明 duty_in 根本没连接到有效值回查端口映射。5. 进阶实战死区插入、故障保护与呼吸灯调速5.1 死区时间插入实现互补 PWM 输出电机控制和 H 桥驱动场景下上桥臂和下桥臂的 PWM 必须互补输出并且之间要插入一个死区时间防止上下管同时导通造成直通短路。在 Verilog 里实现死区最直接的方式是延迟生成互补信号把原 pwm_out 取反后用移位寄存器打拍延迟若干周期得到带死区的互补输出。// dead_band.v module dead_band #( parameter DEAD_CYCLE 8 // 死区时间 DEAD_CYCLE 个分频时钟周期 )( input wire clk, input wire rst_n, input wire pwm_in, output wire pwm_high, // 上桥臂 output wire pwm_low // 下桥臂 ); reg [DEAD_CYCLE-1:0] delay_chain; // 用移位寄存器将输入信号延迟 DEAD_CYCLE 个时钟周期 always (posedge clk or negedge rst_n) begin if (!rst_n) delay_chain {DEAD_CYCLE{1b0}}; else delay_chain {delay_chain[DEAD_CYCLE-2:0], pwm_in}; end // 上桥臂用原始信号下桥臂用延迟后的信号取反 assign pwm_high pwm_in; assign pwm_low ~delay_chain[DEAD_CYCLE-1]; endmodule这里有个值得注意的问题pwm_in 经过移位寄存器会有 DEAD_CYCLE 个时钟周期的组合逻辑延迟上桥臂pwm_high pwm_in是组合输出下桥臂pwm_low也来自寄存器会导致两个输出的时间基准不一致。严谨的做法是把pwm_high也经过同样的移位寄存器打拍对齐后再输出只是对齐后的信号用于上桥臂取反后的信号用于下桥臂这样死区才对称。5.2 故障保护信号优先级与硬件联调前面 PWM 核心模块里的 shutdown 只是提供了一个保护接口工程上故障来源通常有多路过流信号、过压信号、外部急停信号需要做一个故障汇总模块把多路输入合并成一个 shutdown 信号输出。同时故障触发后必须锁存状态不能干扰消失后自动恢复需要手动复位键解除。用锁存器实现故障保持// fault_latch.v module fault_latch ( input wire clk, input wire rst_n, input wire fault_in, // 任意故障输入 input wire clear, // 故障复位由外部按键或上位机给出 output reg fault_latched ); always (posedge clk or negedge rst_n) begin if (!rst_n) fault_latched 1b0; else if (fault_in) fault_latched 1b1; // 捕获故障并锁存 else if (clear) fault_latched 1b0; // 手动清除 end endmodule这种锁存逻辑在工业控制里叫故障自锁是安全规范里的常见要求。故障发生后 PWM 输出被关断即使故障信号瞬时消失系统也保持关断状态直到操作员确认排除故障后按下复位按钮。如果做成故障消失自动恢复电机可能在故障未被排除时再次启动造成安全事故。5.3 呼吸灯实验验证输出动态变化把 PWM 模块接到一个 LED 上让占空比随时间缓慢递增再递减就能看到 LED呼吸效果。这个实验不需要示波器肉眼即可判断 PWM 输出是否正确工作是上板验证的最简单手段。占空比递增用计数器实现每计数到一定次数就让 duty 加 1到达上限后切换方向递减。// breathing_light 驱动逻辑 reg [8:0] duty_cnt; // 占空比当前值9 位精度 reg dir; // 1 递增0 递减 reg [19:0] step_cnt; // 分频计数器控制占空比变化速度 always (posedge clk_div_out) begin if (!rst_n) begin duty_cnt 0; dir 1b1; step_cnt 0; end else if (step_cnt 20d100000) begin step_cnt 0; if (dir) duty_cnt duty_cnt 1b1; else duty_cnt duty_cnt - 1b1; // 到达边界时反转方向 if (duty_cnt 9d511 dir) dir 1b0; else if (duty_cnt 9d0 !dir) dir 1b1; end else begin step_cnt step_cnt 1b1; end endstep_cnt 的上限决定呼吸周期20 位计数器在 12.5MHz 分频时钟下意味着每 8ms 改变一次占空比从 0 到 511 共需要约 4 秒呼吸效果比较舒适。把 duty_cnt 直接连到 pwm_gen 的 duty 输入即可在硬件上看到 LED 亮度平滑变化。这个实验验证了占空比动态更新路径是否畅通也顺带检验了计数器位宽切换时有没有毛刺。实际应用时可以把 duty 来源从内部计数器换成任意外部接口比如串口指令或 ADC 采样值就扩展成了可在线调速的 LED 调光器或直流电机驱动。整个工程的限制项主要是分频模块要求偶数分频如果换成任意分频需要改写 clk_div 为奇偶分频统一架构这个改动大约增加 10 行逻辑建议在掌握基础版本后再做增强。本文还有配套的精品资源点击获取
返回列表