
简介基于现场可编程门阵列的脉宽调制工程资料面向本硕博及教研人群以Vivado2019.2为平台通过Verilog语言实现并配套操作录像适合从零学习脉宽调制算法的现场可编程门阵列编程与仿真验证。压缩包共1012个文件约99.61MB主要包含Vivado工程文件、Verilog源码、仿真与波形文件、运行脚本以及操作录像等目录结构完整便于按需查阅。目前已有803人学习使用。通过该资源可获得可直接打开的完整工程、源码与仿真脚本并能参照操作视频逐步完成综合、实现和仿真快速掌握脉宽调制信号生成的实现思路与排错方法。资源内还包含综合与仿真产生的中间文件如综合网表、仿真波形日志等便于比对每一步操作结果。注意需使用Vivado2019.2及以上版本且工程路径不能包含中文以免工具报错。1. 在 vivado2019.2 里用 Verilog 做 FPGA PWM先想清楚再写代码做 PWM 并不需要调用 Xilinx 的 IP 核FPGA 里也没有专门的 PWM 外设。本质上就是一个自由运行的计数器和一个比较器计数器在 0 到某个最大值之间循环比较器把计数值和你设定的阈值作比较输出就是 PWM 波。很多人在 vivado2019.2 里卡住不是卡在 Verilog 语法而是卡在 PWM 频率、占空比和计数器周期三者之间的换算以及仿真通过之后上板波形不正常的现象处理。下面按这个顺序展开先讲计数比较结构和占空比换算再在 vivado2019.2 里建工程跑仿真然后写管脚约束上板验证最后补上在线可调、故障保护这些工程上离不开的环节。适合正在学 Verilog、想用 FPGA 做调光、电机调速或开关电源输出控制的读者。2. Verilog 实现 PWM 脉宽调制的计数器与比较器写法2.1 先定频率和分辨率PERIOD、duty 和占空比怎么换算假设输入时钟 100MHz想得到 50kHz 的 PWM。一个 PWM 周期占用的时钟周期数是 100MHz ÷ 50kHz 2000计数器从 0 数到 1999共 2000 拍后回 0。代码里的周期上限就是 PERIOD 1999即 CLK_FREQ / PWM_FREQ - 1。占空比换算关系是占空比 duty / (PERIOD 1)反过来 duty 占空比 × (PERIOD 1)。例如 80% 占空比duty 0.8 × 2000 1600。如果上层传来的 duty 恰好等于 PERIOD1也就是 2000由于 cnt 永远到不了 2000cnt duty 恒成立输出是 100% 高电平不会报错。一个容易踩的坑是分辨率位宽。如果直接用 8 位 duty0~255去配一个 PERIOD1999 的 PWM那 duty 加到 160 只代表 8% 的占空比而不是 62.5%。想让调节线性、每一份增量对应固定占空比变化最合理的做法是让 duty 位宽跟着 PERIOD 走DUTY_WIDTH $clog2(PERIOD 1)。PERIOD1999 时取 11 位能表示 0~2047覆盖 0~102.3% 的占空比范围。如果反过来要固定 256 档分辨率则令 PERIOD 255此时 PWM 频率 100MHz ÷ 256 390.625kHz。也就是说在 FPGA 里频率和分辨率是同一个变量的两种表达把计数器周期做短分辨率就低频率就高把周期做长分辨率就高频率就低。遇到“我要 8 位分辨率又要正好 50kHz”的需求必须换更高时钟或接受没法同时满足。2.2 可综合的 PWM 核心代码计数器、比较器和参数表下面是一个不带任何 IP、可直接综合到 vivado2019.2 工程的 Verilog 模块。两个 always 块分别负责计数和输出输出用了寄存器比纯 assign 组合输出晚一拍但波形边沿更干净不容易在驱动功率器件时出现毛刺。module pwm_gen #( parameter CLK_FREQ 100_000_000, // 输入时钟频率单位 Hz parameter PWM_FREQ 50_000, // 目标 PWM 频率 parameter DUTY_WIDTH 11 // duty 位宽取 $clog2(PERIOD1) )( input wire clk, input wire rst_n, input wire [DUTY_WIDTH-1:0] duty, output reg pwm_out ); localparam PERIOD CLK_FREQ / PWM_FREQ - 1; // 计数器周期 localparam CNT_WIDTH $clog2(PERIOD 1); reg [CNT_WIDTH-1:0] cnt; // 自由运行的周期计数器 always (posedge clk or negedge rst_n) begin if (!rst_n) cnt d0; else if (cnt PERIOD[CNT_WIDTH-1:0]) cnt d0; else cnt cnt 1b1; end // 比较器cnt 小于 duty 输出高电平 always (posedge clk or negedge rst_n) begin if (!rst_n) pwm_out 1b0; else if (duty {DUTY_WIDTH{1b0}}) pwm_out 1b0; // 占空比为 0 else if (duty {DUTY_WIDTH{1b1}}) pwm_out 1b1; // 上限钳位保证输出恒高 else pwm_out (cnt duty); end endmodule参数说明CLK_FREQ 是系统时钟实际频率PWM_FREQ 是想要的输出频率DUTY_WIDTH 决定了 duty 的最大可设范围。三个参数不要只改其中一个PERIOD、CNT_WIDTH 都是由参数直接推导出来的局部参数localparam改动 CLK_FREQ 或 PWM_FREQ 后会自动重新计算。代码里处理了两个容易出错的边界。一是 duty0 时输出恒低避免比较器在 0 和 1 之间出现短促毛刺二是 duty 大于等于 DUTY_WIDTH 全 1即 2^DUTY_WIDTH-1时输出恒高。如果不写这两条duty 一旦超过 PERIODcnt 永远小于 duty输出虽然也会恒高但那种恒高是“比较器凑巧成立”的结果代码语义不清晰。另外注意 cnt 的比较对象写的是 PERIOD[CNT_WIDTH-1:0]这是为了匹配位宽避免综合时出现无符号数与参数位宽不匹配的警告。2.3 用 testbench 验证占空比可调仿真不能只验证一个占空比至少要覆盖复位、低压和高压三种情况。下面的 testbench 里先让 duty0确认复位后输出为低再把 duty 切到 30% 和 80%观察 pwm_out 的高电平脉宽是否按预期变化。timescale 1ns / 1ps module tb_pwm_gen; localparam CLK_FREQ 100_000_000; localparam PWM_FREQ 50_000; localparam DIV CLK_FREQ / PWM_FREQ; // 2000 localparam DUTY_WIDTH $clog2(DIV); // 11 reg clk; reg rst_n; reg [DUTY_WIDTH-1:0] duty; wire pwm_out; pwm_gen #( .CLK_FREQ (CLK_FREQ), .PWM_FREQ (PWM_FREQ), .DUTY_WIDTH(DUTY_WIDTH) ) uut ( .clk (clk), .rst_n (rst_n), .duty (duty), .pwm_out (pwm_out) ); initial begin clk 0; forever #5 clk ~clk; // 100MHz end initial begin rst_n 0; duty 0; #20; rst_n 1; #1000; duty DIV * 3 / 10; // 30% #2000_000; // 2ms等待足够多的 PWM 周期 duty DIV * 8 / 10; // 80% #2000_000; $finish; end endmoduleDIV * 3 / 10 在 Verilog 里是整数乘法除法仿真时会先算乘法再除法不会产生额外硬件只用于测试。这里的 DUTY_WIDTH 用 $clog2(DIV) 自动计算和 2.2 里模块参数一致避免手写位宽时漏改。仿真需要跑满至少 2ms 才看得到多个完整的 50kHz PWM 周期这也是后面在 vivado2019.2 里 run 10ms 的原因时间太短看不到占空比切换效果。3. vivado2019.2 里建工程、跑仿真、看 PWM 波形的完整路径3.1 新建 RTL 工程器件选择和文件组织在 vivado2019.2 里依次走 File → Project → New选择 RTL Project源文件先不添加或稍后添加都可以。器件型号按你手头 FPGA 板卡选比如常见 Artix-7 的 xc7a35tcsg324-1或者 Zynq 的 xc7z010clg400-1型号填错会导致后面综合和管脚约束不匹配。工程路径建议纯英文不要带中文和空格否则 xsim 偶尔会报一些难查的文件系统错误。提示vivado2019.2 里 XDC 约束文件里同一个端口只能写一次 PACKAGE_PIN重复设置时不会直接报错但会以最后一条为准排查引脚异常时先检查是不是有重复约束行。刚打开工程时先把 2.2 的 pwm_gen.v 加入 Design Sources把 2.3 的 tb_pwm_gen.v 加入 Simulation Sources。文件里的 module 名和文件名可以不一致但保持一致会省很多麻烦。在 Sources 面板里选中 tb_pwm_gen右键 Set as Top这一步决定仿真入口容易漏。设计源文件和仿真源文件分离的另一个好处是综合时只处理 pwm_gen.vtb 不会参与布局布线也不会浪费资源。很多人把 testbench 和 RTL 写在一个文件里综合时忘记移除虽然功能上不影响但会给后期维护带来困惑。3.2 用 vivado2019.2 自带 xsim 跑行为仿真不装 Modelsim网上搜 Verilog 仿真教程常常会看到 Modelsim但 vivado2019.2 自带 xsim在同一工程里就能完成编译和波形查看没必要再装一套仿真器还能避开版本匹配问题。走 Flow Navigator → Run Simulation → Run Behavioral SimulationVivado 会启动 xsim 窗口并自动加载所有顶层信号。第一次打开只会跑 1us对 50kHz 的 PWM 来说连一个完整波形都看不到。在 xsim 下方的 Tcl 面板输入 restart再输入 run 10ms就能看到完整的 PWM 序列。波形中 pwm_out 会显示为密集方波把光标放到 10ms 时间轴上后逐级放大能看到高电平宽度随 duty 变化。若没显示 cnt 信号在 Tcl 面板敲 add_wave / 后会列出当前作用域的全部信号手动加入即可。下面这张表是这套仿真流程的关键操作对照照着敲比到处找菜单快操作菜单/命令说明运行行为仿真Flow Navigator → Simulation → Run Behavioral Simulation启动 xsim重置波形Tcl 输入 restart清空波形和仿真时间运行时长Tcl 输入 run 10ms10ms 足够观察多个 PWM 周期添加信号Tcl 输入 add_wave /显示顶层作用域全部信号退出仿真菜单 Simulation → Close Simulation回到主界面如果直接在波形里看到 duty 出现 X 状态大多数情况是 testbench 的 duty 没有在 initial 中赋值或者分配了但复位期间一直为高阻。testbench 中所有 reg 型信号都应该有初始化值这是 xsim 比一些老式仿真器更严格的地方。3.3 仿真常见报错和现象排查仿真失败和跑出来的结果不对是两种需要分开处理的情况。报错通常在 xsim 启动阶段出现结果不对则要看波形而不是重新编译。报错方面最常见的两类都是工程环境引起的工程路径里带中文或空格会报 [Vivado 12-1345] 或 ELF 相关错误重建纯英文路径的工程就能解决而 testbench 里引用了未例化的模块会报 Unable to bind parameter 之类的错多半是 Set as Top 时选错了对象或者例化时 module 名拼写和文件名不一致。结果不对的现象也很好辨认。波形里 rst_n 一直为 0输出恒低是 testbench 的 initial 里复位释放语句写错或没执行pwm_out 是一条稳定直线且在 duty 变化前后没有任何反应通常是 Simulator 运行时间太短只跑了 1us连第一个 PWM 周期都没走完。还有一类隐蔽情况duty 位宽比 cnt 位宽窄比如用 8 位 duty 去和 11 位 cnt 比较仿真波形虽然正常但 duty 到 255 就封顶永远跑不出 100% 占空比这是设计层面的问题仿真工具不会报错。4. 把 PWM 输出到 FPGA 引脚vivado2019.2 管脚约束与上板实测4.1 写 XDC 约束PACKAGE_PIN 和 IOSTANDARD仿真只证明逻辑正确上板前必须给每个顶层端口分配物理引脚。在 vivado2019.2 中约束文件是 XDC后缀和 UCF 不同语法也不同。常规做法是 Add Sources 时先不添加工程建好后在 Sources → Constraints 里新建 XDC写好后保存重新综合实现时会自动加载。XDC 的基本写法是两行set_property PACKAGE_PIN PIN_NAME [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk]相同的格式分别写 rst_n 和 pwm_out。PIN_NAME 必须换成你自己板卡上的实际引脚编号可以从板卡原理图或厂商提供的 master XDC 里找常见 Artix-7 板卡的系统时钟是 100MHz复位脚和 LED 引脚各板不同千万别照抄别的工程。IOSTANDARD 要跟引脚所在 bank 的电压匹配3.3V bank 用 LVCMOS332.5V bank 用 LVCMOS25选错会在生成 bitstream 阶段报 DRC 错误。写完约束后 Generate Bitstream成功后再用 Open Hardware Manager → Open Target → Program Device 下载。下载时确认 USB 线连的是板上的 JTAG 口而不是 UART 口Vivado 识别不到设备时先在 Hardware Manager 里点 Refresh再检查系统设备管理器里有没有 Xilinx USB Cable 驱动。4.2 用 LED 或 RC 低通验证 PWM 波形50kHz 的 PWM 接 LED人眼看不到闪烁只会看到某个固定亮度的灯这正是 PWM 调光的基本现象。要验证占空比确实在变最简单的方法是不改 PWM 频率直接让 duty 周期性递增递减比如每 1ms 增加一档LED 亮度就会以约 1Hz 的速率循环变化形成呼吸灯效果。呼吸灯只需在已有模块外加一个自动递增的 duty每几个 PWM 周期让 duty 加 1加到 PERIOD 后再减回来就能形成三角波亮度。想定量验证占空比最直观的是加一阶 RC 低通。比如 10kΩ 电阻串 1uF 电容截止频率约为 16Hz远低于 50kHzPWM 高频分量被滤掉输出端用万用表直流档能测到近似平均电压。电压值应等于 duty 与周期之比乘上 IO 口电平例如 3.3V、50% 占空比时测到约 1.65V。这个方法不需要示波器是实验室里验证 PWM 电路最常用的手段。用操作视频核对整条链路时我一般录三个镜头仿真波形中 duty 从 30% 切到 80% 的瞬间、上板后万用表读数随 duty 变化的过程、以及拨码或按键触发 duty 变化时 LED 亮度的响应。回放时只要这三段对得上PWM 功能就算可靠交付了。如果 PWM 载波频率低于 100Hz视频里会看到原始闪烁和亮度循环混在一起看起来像接触不良录制时要把载波频率维持在 1kHz 以上。4.3 上板后没有输出的排查顺序上板后 pwm_out 没输出先不要怀疑代码按这个顺序查硬件管理器是否报告 Program Done没有则检查 bit 文件有没有生成成功以及 JTAG 链路有的话用万用表量 pwm_out 引脚对地电压恒为 0V 再查约束恒为 3.3V 再查 duty 是否被拨码全拉高。一个常见反例是拨码开关负逻辑接法开关闭合时输入为 0duty 被读成 0输出自然恒低不是代码问题。现象优先检查原因下载成功但全部引脚无输出Generate Bitstream 是否重新执行改完约束忘了重新布线pwm_out 恒为低duty 的输入源是否有效拨码负逻辑闭合时读 0pwm_out 恒为高XDC 的 IOSTANDARD引脚 bank 电压和电平标准不匹配亮度固定不变化duty 是否在变化、变化周期是否太快固定占空比下灯只会显示固定亮度最后一条特别提醒很多第一次做 PWM 的开发者把 50kHz 当成呼吸灯频率上板发现灯不闪就以为是故障其实固定占空比下 50kHz 的灯就该是固定亮度。想看到亮度循环应该让 duty 缓慢扫描而不是去降 PWM_FREQPWM_FREQ 低于 100Hz 反而会出现原始闪烁干扰判断。5. 让占空比在线可调拨码、按键消抖和故障保护5.1 用拨码开关和按键改变 duty拨码开关是最直接的占空比输入方式硬件上把 8 位拨码的一端接到 FPGA IO另一端接地或接 VCCduty 读入后给到 pwm_gen 即可。拨码没有抖动问题但建议在输入路径上多加两级寄存器做同步避免采集到亚稳态。按键改变占空比的场景则需要消抖常见的做法是连续计数 10ms计数期间电平稳定才接受按键值。100MHz 时钟下 10ms 对应 1_000_000 个时钟周期20 位计数器。reg [19:0] deb_cnt; reg key_value, key_ok; always (posedge clk or negedge rst_n) begin if (!rst_n) begin deb_cnt 20d0; key_ok 1b0; key_value 1b1; // 假设按键按下为低 end else if (key_in ! key_value) begin deb_cnt 20d0; // 检测到跳变重新计时 key_value key_in; end else if (deb_cnt 1_000_000) begin key_ok 1b1; // 稳定 10ms认为按下有效 end else begin deb_cnt deb_cnt 1b1; key_ok 1b0; end end这个消抖逻辑把电平跳变和稳定计数的判断分开处理key_in 与 key_value 不一致说明抖动还没结束立刻清空计数器只要 10ms 内没有新跳变就认为按键稳定key_ok 拉高一个周期。按键松开时 key_in 又跳回高电平计数器再次清零所以连按两次不会产生误触发。对应到 duty 上可以在 key_ok 有效时给 duty 加一个步进比如 100超出上限后回 0。这种写法比直接用延时语句阻塞仿真更接近可综合风格直接放进 vivado2019.2 也能布局布线。5.2 工程上的最后一块故障保护PWM 输出接功率器件时不能只依赖比较器输出。电机驱动的 H 桥场景里一旦上管和下管同时导通会直接短路烧毁桥臂这是 PWM 应用中最重要的安全问题也是最容易被初学者忽略的。常见做法是独立引入一个故障引脚 fault_n低有效它的优先级必须高于任何 duty 设置只要有故障就强制把 pwm_out 拉低。在原来的比较器输出基础上扩展只需要加一个输入和一个条件always (posedge clk or negedge rst_n) begin if (!rst_n) pwm_out 1b0; else if (!fault_n) pwm_out 1b0; // 故障时强制关断 else if (duty {DUTY_WIDTH{1b0}}) pwm_out 1b0; else if (duty {DUTY_WIDTH{1b1}}) pwm_out 1b1; else pwm_out (cnt duty); end故障引脚必须放在比较器之前判断这样不管 duty 和 cnt 处于什么状态fault_n 拉低的那一拍输出就归零不需要等待计数器周期结束。实际操作中fault_n 一般来自过流比较器或温度保护信号需要在示波器上确认它的建立时间避免故障发生瞬间输出还有半个周期的高电平残留。死区时间不属于 FPGA 的算术部分它取决于功率管的开通和关断延时先读手册拿到 t_on 和 t_off再在 RTL 里用计数器补一段低电平时间初始值预留 20% 余量然后在示波器上确认上下管导通沿之间没有直通尖峰。死区太大输出波形会畸变太小桥臂会冒烟这个值不是从算法推出来的是用示波器量出来的。本文还有配套的精品资源点击获取