简介:基于现场可编程门阵列的脉宽调制工程资料,面向本硕博及教研人群,以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 恰好等于 PERIOD+1,也就是 2000,由于 cnt 永远到不了 2000,cnt < duty 恒成立,输出是 100% 高电平,不会报错。
一个容易踩的坑是分辨率位宽。如果直接用 8 位 duty(0~255)去配一个 PERIOD=1999 的 PWM,那 duty 加到 160 只代表 8% 的占空比,而不是 62.5%。想让调节线性、每一份增量对应固定占空比变化,最合理的做法是让 duty 位宽跟着 PERIOD 走:DUTY_WIDTH = $clog2(PERIOD + 1)。PERIOD=1999 时取 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(PERIOD+1) )( 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 + 1'b1; end // 比较器:cnt 小于 duty 输出高电平 always @(posedge clk or negedge rst_n) begin if (!rst_n) pwm_out <= 1'b0; else if (duty == {DUTY_WIDTH{1'b0}}) pwm_out <= 1'b0; // 占空比为 0 else if (duty >= {DUTY_WIDTH{1'b1}}) pwm_out <= 1'b1; // 上限钳位,保证输出恒高 else pwm_out <= (cnt < duty); end endmodule参数说明:CLK_FREQ 是系统时钟实际频率,PWM_FREQ 是想要的输出频率,DUTY_WIDTH 决定了 duty 的最大可设范围。三个参数不要只改其中一个,PERIOD、CNT_WIDTH 都是由参数直接推导出来的局部参数(localparam),改动 CLK_FREQ 或 PWM_FREQ 后会自动重新计算。
代码里处理了两个容易出错的边界。一是 duty=0 时输出恒低,避免比较器在 0 和 1 之间出现短促毛刺;二是 duty 大于等于 DUTY_WIDTH 全 1(即 2^DUTY_WIDTH-1)时输出恒高。如果不写这两条,duty 一旦超过 PERIOD,cnt 永远小于 duty,输出虽然也会恒高,但那种恒高是“比较器凑巧成立”的结果,代码语义不清晰。另外注意 cnt 的比较对象写的是 PERIOD[CNT_WIDTH-1:0],这是为了匹配位宽,避免综合时出现无符号数与参数位宽不匹配的警告。
2.3 用 testbench 验证占空比可调
仿真不能只验证一个占空比,至少要覆盖复位、低压和高压三种情况。下面的 testbench 里先让 duty=0,确认复位后输出为低,再把 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.v,tb 不会参与布局布线,也不会浪费资源。很多人把 testbench 和 RTL 写在一个文件里,综合时忘记移除,虽然功能上不影响,但会给后期维护带来困惑。
3.2 用 vivado2019.2 自带 xsim 跑行为仿真,不装 Modelsim
网上搜 Verilog 仿真教程常常会看到 Modelsim,但 vivado2019.2 自带 xsim,在同一工程里就能完成编译和波形查看,没必要再装一套仿真器,还能避开版本匹配问题。走 Flow Navigator → Run Simulation → Run Behavioral Simulation,Vivado 会启动 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 10ms | 10ms 足够观察多个 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 用 LVCMOS33,2.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,远低于 50kHz,PWM 高频分量被滤掉,输出端用万用表直流档能测到近似平均电压。电压值应等于 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 是否被拨码全拉高。一个常见反例是拨码开关负逻辑接法:开关闭合时输入为 0,duty 被读成 0,输出自然恒低,不是代码问题。
| 现象 | 优先检查 | 原因 |
|---|---|---|
| 下载成功但全部引脚无输出 | Generate Bitstream 是否重新执行 | 改完约束忘了重新布线 |
| pwm_out 恒为低 | duty 的输入源是否有效 | 拨码负逻辑闭合时读 0 |
| pwm_out 恒为高 | XDC 的 IOSTANDARD | 引脚 bank 电压和电平标准不匹配 |
| 亮度固定不变化 | duty 是否在变化、变化周期是否太快 | 固定占空比下灯只会显示固定亮度 |
最后一条特别提醒:很多第一次做 PWM 的开发者把 50kHz 当成呼吸灯频率,上板发现灯不闪就以为是故障,其实固定占空比下 50kHz 的灯就该是固定亮度。想看到亮度循环,应该让 duty 缓慢扫描,而不是去降 PWM_FREQ;PWM_FREQ 低于 100Hz 反而会出现原始闪烁,干扰判断。
5. 让占空比在线可调:拨码、按键消抖和故障保护
5.1 用拨码开关和按键改变 duty
拨码开关是最直接的占空比输入方式,硬件上把 8 位拨码的一端接到 FPGA IO,另一端接地或接 VCC,duty 读入后给到 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 <= 20'd0; key_ok <= 1'b0; key_value <= 1'b1; // 假设按键按下为低 end else if (key_in != key_value) begin deb_cnt <= 20'd0; // 检测到跳变,重新计时 key_value <= key_in; end else if (deb_cnt >= 1_000_000) begin key_ok <= 1'b1; // 稳定 10ms,认为按下有效 end else begin deb_cnt <= deb_cnt + 1'b1; key_ok <= 1'b0; 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 <= 1'b0; else if (!fault_n) pwm_out <= 1'b0; // 故障时强制关断 else if (duty == {DUTY_WIDTH{1'b0}}) pwm_out <= 1'b0; else if (duty >= {DUTY_WIDTH{1'b1}}) pwm_out <= 1'b1; else pwm_out <= (cnt < duty); end故障引脚必须放在比较器之前判断,这样不管 duty 和 cnt 处于什么状态,fault_n 拉低的那一拍输出就归零,不需要等待计数器周期结束。实际操作中,fault_n 一般来自过流比较器或温度保护信号,需要在示波器上确认它的建立时间,避免故障发生瞬间输出还有半个周期的高电平残留。死区时间不属于 FPGA 的算术部分,它取决于功率管的开通和关断延时,先读手册拿到 t_on 和 t_off,再在 RTL 里用计数器补一段低电平时间,初始值预留 20% 余量,然后在示波器上确认上下管导通沿之间没有直通尖峰。死区太大输出波形会畸变,太小桥臂会冒烟,这个值不是从算法推出来的,是用示波器量出来的。
本文还有配套的精品资源,点击获取