用 Vivado 2018.3 打造一个真正能用的 PWM 信号发生器
最近带学生做 FPGA 实验,发现很多人写完代码、生成比特流下载到板子上,示波器一接——没波形。问起来都说:“我照着例程写的啊?” 结果一看,要么时钟没约束,要么引脚根本没绑对,更有甚者连复位逻辑都没处理好。
今天我们就以Vivado 2018.3为平台,从零开始完整走一遍:如何在 Xilinx 7 系列 FPGA 上实现一个可调频率、可调占空比、稳定可靠的 PWM 模块,并确保它能在真实硬件上跑得起来。
这不是一份“理论正确但实测翻车”的文档,而是一份基于多年调试经验总结出的实战手册。我们不堆术语,只讲你真正需要知道的事。
为什么是 PWM?它到底解决了什么问题?
在嵌入式控制领域,我们经常要解决一个问题:怎么用数字的方式,精准控制模拟的效果?
比如:
- 让 LED 明暗渐变;
- 控制电机转速;
- 调节电源输出电压。
传统做法是用 DAC 输出模拟电压,成本高、抗干扰差。而 PWM 提供了一种优雅的替代方案——通过快速开关来等效出连续电压。
它的核心思想很简单:
如果一个信号在一个周期内高电平的时间越长,那么它的平均电压就越高。
这个“高电平时间占比”,就是所谓的占空比(Duty Cycle)。而整个波形重复的速度,就是频率(Frequency)。
举个例子:
- 使用 3.3V 供电;
- 生成 1kHz 的 PWM 信号;
- 占空比设为 60%;
那这个信号的等效直流电压就是3.3V × 60% = 1.98V。
更妙的是,在 FPGA 中实现 PWM 几乎不消耗额外硬件资源,只需要几个寄存器和比较器就够了。
为什么选 Vivado 2018.3?
虽然现在最新版 Vivado 已经更新到 2023.x,但很多工业项目仍在使用Vivado 2018.3,原因很现实:
- 官方长期支持 Zynq-7000 和 Artix-7 等主流器件;
- 对老开发板兼容性最好(如 Basys3、Nexys4 DDR、ZedBoard);
- 编译稳定性强,不像新版偶尔出现 IP 加载失败的问题;
- 很多企业已有成熟流程固化在此版本。
所以如果你正在参与实际产品开发或课程设计,掌握 2018.3 的使用是非常实用的技能。
PWM 是怎么在 FPGA 里“造”出来的?
最常见也最高效的实现方式是:计数器 + 比较器结构。
基本原理一句话说清:
我们让一个计数器每拍加一,当它小于某个设定值时输出高电平,否则输出低电平。计数满后自动归零,周而复始,就形成了固定频率的 PWM 波。
假设系统时钟是 100MHz,我们要产生 1kHz 的 PWM 信号:
- 一个周期需要计数 $ \frac{100\,000\,000}{1\,000} = 100\,000 $ 次;
- 所以用一个至少 17 位的计数器($2^{17} = 131072 > 100000$);
- 当前值小于
duty_cycle_reg时,pwm_out = 1; - 达到最大值后回 0,重新开始。
这样,只要改变duty_cycle_reg的值,就能动态调节占空比。
关键参数怎么算?
| 参数 | 含义 | 示例 |
|---|---|---|
CLK_FREQ | 输入时钟频率 | 100_000_000 Hz |
PWM_FREQ | 目标 PWM 频率 | 1_000 Hz |
COUNTER_WIDTH | 计数器位宽 | $\lceil \log_2(100M / 1k) \rceil = 17$ |
公式如下:
$$
COUNTER_WIDTH = \left\lceil \log_2\left(\frac{CLK_FREQ}{PWM_FREQ}\right) \right\rceil
$$
⚠️ 注意:不要手动计算后硬编码!建议将COUNTER_WIDTH设为参数,方便移植到不同平台。
Verilog 实现:不只是“能编译”,更要“能工作”
下面这段代码,是你能在大多数教程里看到的标准实现。但它有几个隐藏陷阱,稍不留神就会导致波形抖动甚至锁死。
module pwm_generator #( parameter COUNTER_WIDTH = 17 )( input clk, input rst_n, input [COUNTER_WIDTH-1:0] duty_cycle, output reg pwm_out ); reg [COUNTER_WIDTH-1:0] counter; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin counter <= 0; pwm_out <= 0; end else begin counter <= counter + 1'b1; if (counter == {COUNTER_WIDTH{1'b1}}) counter <= 0; pwm_out <= (counter < duty_cycle) ? 1'b1 : 1'b0; end end endmodule看起来没问题?其实有三个关键点必须注意:
🔥 问题 1:duty_cycle更新是异步的 → 可能产生毛刺!
如果外部模块直接修改duty_cycle寄存器,而没有同步机制,会导致比较结果在单个时钟周期内突变,从而引起输出跳变不稳定。
✅解决方案:加入双缓冲机制,或者确保duty_cycle来自同步写入的寄存器文件。
🔥 问题 2:pwm_out是组合逻辑输出 → 存在亚稳态风险!
当前设计中,pwm_out直接受(counter < duty_cycle)控制,属于纯组合逻辑。这意味着每当counter变化时,输出都会立刻响应,容易受到布线延迟影响,尤其是在高速路径上。
✅改进方法:将输出改为寄存器打一拍:
reg pwm_next; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin counter <= 0; pwm_next <= 0; pwm_out <= 0; end else begin counter <= counter + 1; if (counter == {COUNTER_WIDTH{1'b1}}) counter <= 0; pwm_next <= (counter < duty_cycle); pwm_out <= pwm_next; // 寄存一次,消除毛刺 end end虽然延迟了一个周期,但换来的是干净稳定的边沿,值得!
🔥 问题 3:全‘1’判断虽简洁,但可读性差且不易扩展
{COUNTER_WIDTH{1'b1}}这种写法虽然省事,但在综合时可能无法被工具识别为“自然回滚”,影响优化。
✅ 更清晰的做法是定义最大值常量:
localparam MAX_COUNT = (1 << COUNTER_WIDTH) - 1; ... if (counter == MAX_COUNT) counter <= 0;不仅语义明确,还能避免因位宽变化导致的溢出错误。
在 Vivado 2018.3 中一步步搭建工程
别急着仿真,先确保你的工程结构是对的。
第一步:创建新工程
打开 Vivado 2018.3,选择Create Project→ 设置工程名(不要含中文!)→ 选择 RTL Project → Skip添加源文件(后面手动加)。
目标器件选你手上的开发板型号,例如:
- Basys3:
xc7a35tcpg236-1 - Nexys4 DDR:
xc7a100tcsg324-1 - ZedBoard:
xc7z020clg484-1
第二步:添加设计源文件
右键Design Sources→ Add Sources → Add or create design modules → 创建或导入pwm_generator.v。
第三步:写测试平台(testbench),提前发现问题
别跳过仿真!这是排查逻辑错误最快的方式。
module tb_pwm; parameter COUNTER_WIDTH = 17; reg clk; reg rst_n; reg [COUNTER_WIDTH-1:0] duty_cycle; wire pwm_out; // 实例化待测模块 pwm_generator #(.COUNTER_WIDTH(COUNTER_WIDTH)) uut ( .clk(clk), .rst_n(rst_n), .duty_cycle(duty_cycle), .pwm_out(pwm_out) ); // 生成 100MHz 时钟 initial begin clk = 0; forever #5 clk = ~clk; // 10ns 周期 = 100MHz end // 复位与激励 initial begin rst_n = 0; duty_cycle = 0; #20 rst_n = 1; // 测试不同占空比 duty_cycle = 17'd50000; // ~50% #1000000; duty_cycle = 17'd80000; // ~80% #1000000 $finish; end endmodule运行仿真(Run Simulation → Run Behavioral Simulation),观察波形是否符合预期:
- 周期 ≈ 1ms(对应 1kHz)
- 初始低电平持续约 500μs,后变为 200μs
- 边沿整齐,无抖动
✅ 成功后再进入下一阶段。
引脚约束与时钟设置:决定你能不能“看到波形”
太多人卡在这一步却不知道原因。
管脚分配(XDC 文件)
在Constraints下新建 XDC 文件,写入:
# PWM 输出引脚(以 Basys3 的 JA[1] 为例) set_property PACKAGE_PIN U10 [get_ports pwm_out] set_property IOSTANDARD LVCMOS33 [get_ports pwm_out] # 输入时钟(板载 100MHz 晶振) set_property PACKAGE_PIN W5 [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk]📌 必须查开发板原理图确认 PIN 名称!不同板子不一样。
时钟约束(SDC 格式)
在 XDC 中添加:
create_clock -period 10.000 -name sys_clk_pin -waveform {0.000 5.000} [get_ports clk]这告诉 Vivado:“我的输入时钟是 100MHz”,否则时序分析会按默认 1GHz 处理,导致布局布线失败或实际频率不准。
下载验证:用示波器说话
生成比特流 → 连接 JTAG 下载器 → Open Hardware Manager → Program Device。
接上示波器探头到指定引脚(比如 U10),你应该能看到:
- 清晰的方波;
- 频率接近 1kHz;
- 占空比随
duty_cycle改变而变化。
如果看不到信号,请按以下顺序排查:
| 检查项 | 方法 |
|---|---|
| 是否绑定了物理引脚? | 查 XDC 文件中的PACKAGE_PIN |
| 时钟是否锁定? | 若用了 PLL/MMCM,检查 LOCKED 信号 |
| 复位是否释放? | 确保rst_n接了上拉电阻或由按键控制 |
| 电源是否正常? | 测 FPGA 核心电压(通常 1.0V 或 1.2V) |
高级技巧与常见坑点
✅ 技巧 1:多路 PWM 共享计数器,节省资源
如果你要做 RGB LED 调光或三相逆变器,可以共享同一个计数器,只为每个通道保留独立的duty_cycle和比较器:
genvar i; generate for (i = 0; i < NUM_CHANNELS; i = i + 1) begin : gen_pwm assign pwm_out[i] = (counter < duty_cycle[i]); end endgenerate大幅减少 FF 和 LUT 消耗。
✅ 技巧 2:加入使能控制端口
增加一个enable输入,允许动态启停 PWM:
input enable, ... pwm_next <= enable ? (counter < duty_cycle) : 1'b0;适用于节能模式或故障保护。
❌ 坑点提醒:千万不要用阻塞赋值混在时序逻辑里!
尤其是初学者容易写出:
pwm_out = (counter < duty_cycle) ? 1 : 0; // 错!应使用 <=这可能导致综合失败或行为异常。
总结:什么样的 PWM 才算是“可用”的?
回顾一下,一个真正能在项目中使用的 PWM 模块应该满足:
- ✅ 参数化设计,适配多种频率需求;
- ✅ 输出经过寄存器同步,消除毛刺;
- ✅ 支持动态调节占空比且无跳变;
- ✅ 有时钟约束和正确引脚绑定;
- ✅ 经过仿真验证 + 硬件实测确认。
掌握了这套方法,你就不再只是“会写代码”,而是具备了完整的 FPGA 数字控制系统开发能力。
下一步,你可以尝试:
- 把 PWM 封装成 AXI-Lite 外设,由 MicroBlaze 动态控制;
- 实现 SPWM(正弦脉宽调制)用于逆变器;
- 结合 ADC 采样做闭环调光或调速。
这些高级应用,都建立在今天这个“小小 PWM”的基础之上。
如果你也在用 Vivado 2018.3 做控制类项目,欢迎留言交流踩过的坑。毕竟,最好的学习,永远来自实战。