PWM 信号生成与呼吸灯:占空比控制、按键调光与舵机扩展(附完整 Verilog 实操)

本文是《FPGA入门到实战》专栏第22篇。上一篇完成了六位数码管秒表,本篇继续做一个更直观的板级项目:使用 PWM 控制 LED 亮度,实现三角波呼吸灯、按键调光,并把同一设计方法扩展到舵机控制。文中的 Verilog 均采用时钟使能方式,不在逻辑内部制造新的时钟。


1. PWM 为什么能控制 LED 亮度

PWM(Pulse Width Modulation,脉宽调制)的核心是:在固定周期内改变高电平所占的时间比例。

设一个 PWM 周期为 T,高电平持续时间为 Ton,占空比为:

text 复制代码
D = Ton / T × 100%

LED 实际上仍在快速地亮、灭切换。只要 PWM 频率足够高,人眼就不会看到单次闪烁,而会把一个周期内的平均光功率感知为亮度:

  • 占空比为 0%:LED 熄灭;
  • 占空比为 25%:亮度较低;
  • 占空比为 50%:中等亮度;
  • 占空比为 100%:持续点亮。

PWM 不仅能控制 LED,还常用于直流电机调速、加热功率控制、蜂鸣器音量控制和舵机脉宽控制。


2. PWM 频率与占空比精度

2.1 用计数器生成 PWM

最直接的方法是使用一个自由运行计数器 pwm_cnt,再把它与占空比设定值 duty 比较:

text 复制代码
pwm_cnt < duty  -> 输出高电平
pwm_cnt >= duty -> 输出低电平

假设计数器宽度为 N,一个 PWM 周期包含 2^N 个系统时钟,占空比分辨率为:

text 复制代码
1 / 2^N

PWM 频率为:

text 复制代码
f_pwm = f_clk / 2^N

以 50 MHz 时钟为例:

计数位宽 PWM 周期计数 PWM 频率 占空比最小步进
8 bit 256 195.3125 kHz 0.390625%
10 bit 1024 48.828 kHz 0.097656%
12 bit 4096 12.207 kHz 0.024414%
16 bit 65536 762.94 Hz 0.001526%

位宽越大,占空比越细,但 PWM 频率越低。LED 调光通常不需要极端精度,12 bit 是一个很实用的折中:频率高于人眼闪烁感知范围,亮度步进也足够细腻。

2.2 为什么 duty 要多一位

如果 pwm_cnt 是 12 bit,它只能表示 0~4095。若 duty 也只有 12 bit,则无法表示完整的 4096 个高电平计数,也就无法得到真正的 100% 占空比。

因此本文把 duty 定义为 PWM_WIDTH+1 bit:

  • duty = 0:0%;
  • duty = 2048:50%;
  • duty = 4096:100%。

这是 PWM 设计中很容易忽略的边界问题。


3. 通用可调占空比 PWM 模块

下面先实现一个可复用的 PWM 基础模块。

verilog 复制代码
module pwm_generator #(
  parameter PWM_WIDTH = 12
) (
  input  wire                 clk    ,  // 系统时钟
  input  wire                 rstn   ,  // 低有效复位
  input  wire [PWM_WIDTH:0]   duty   ,  // 占空比计数值
  output wire                 pwm_out   // PWM 输出
);

  localparam PWM_STEPS = 1 << PWM_WIDTH;

  reg [PWM_WIDTH-1:0] pwm_cnt;

  always @(posedge clk or negedge rstn)
    begin
      if (rstn == 1'b0)
        begin
          pwm_cnt <= 'd0;
        end
      else
        begin
          pwm_cnt <= pwm_cnt + 1'b1;
        end
    end

  assign pwm_out = (duty >= PWM_STEPS) ? 1'b1 :
                   (pwm_cnt < duty[PWM_WIDTH-1:0]);

endmodule

这里对满量程单独判断,使 duty = PWM_STEPS 时输出保持高电平。其他情况下,比较器直接决定本周期的高电平宽度。

注意:localparam PWM_STEPS = 1 << PWM_WIDTH 在常用位宽下可以正常工作。若把 PWM_WIDTH 扩展到 31 以上,应改用更宽的常量类型,避免左移结果超出整数宽度。


4. 呼吸灯的亮度包络

PWM 只负责"把一个亮度值变成脉冲",呼吸效果还需要一个缓慢变化的 duty

最常见的包络有两种:

  1. 三角波:占空比等步长上升,到顶后等步长下降;
  2. 正弦波:使用 ROM 查表,让亮度变化在两端更平滑。

三角波逻辑简单、资源少,非常适合入门实操。正弦查表视觉效果更柔和,适合后续优化。

4.1 三角波更新速度

系统时钟不能每拍都改变 duty,否则一次呼吸只需要几千个时钟,肉眼根本看不到渐变。因此先产生一个低频更新使能 update_en

例如:

text 复制代码
系统时钟          50 MHz
亮度更新频率      1000 Hz
亮度步进          8
PWM 满量程         4096
单向渐变时间      4096 / 8 / 1000 = 0.512 s
完整呼吸周期      约 1.024 s

4.2 三角波呼吸灯完整代码

verilog 复制代码
module breathing_led #(
  parameter CLK_FREQ  = 50_000_000,
  parameter UPDATE_HZ = 1_000,
  parameter PWM_WIDTH = 12,
  parameter STEP      = 8
) (
  input  wire clk ,  // 系统时钟
  input  wire rstn,  // 低有效复位
  output wire led    // LED PWM 输出
);

  localparam UPDATE_DIV = CLK_FREQ / UPDATE_HZ;
  localparam PWM_STEPS  = 1 << PWM_WIDTH;
  localparam UPDATE_CNT_WIDTH = $clog2(UPDATE_DIV);

  reg [UPDATE_CNT_WIDTH-1:0] update_cnt;
  reg                        update_en;
  reg [PWM_WIDTH:0]          duty;
  reg                        direction;

  always @(posedge clk or negedge rstn)
    begin
      if (rstn == 1'b0)
        begin
          update_cnt <= 'd0;
          update_en  <= 1'b0;
        end
      else if (update_cnt == UPDATE_DIV - 1)
        begin
          update_cnt <= 'd0;
          update_en  <= 1'b1;
        end
      else
        begin
          update_cnt <= update_cnt + 1'b1;
          update_en  <= 1'b0;
        end
    end

  always @(posedge clk or negedge rstn)
    begin
      if (rstn == 1'b0)
        begin
          duty      <= 'd0;
          direction <= 1'b1;
        end
      else if (update_en == 1'b1)
        begin
          if (direction == 1'b1)
            begin
              if (duty >= PWM_STEPS - STEP)
                begin
                  duty      <= PWM_STEPS;
                  direction <= 1'b0;
                end
              else
                begin
                  duty <= duty + STEP;
                end
            end
          else
            begin
              if (duty <= STEP)
                begin
                  duty      <= 'd0;
                  direction <= 1'b1;
                end
              else
                begin
                  duty <= duty - STEP;
                end
            end
        end
    end

  pwm_generator #(
    .PWM_WIDTH(PWM_WIDTH)
  ) u_pwm_generator (
    .clk    (clk ),
    .rstn   (rstn),
    .duty   (duty),
    .pwm_out(led )
  );

endmodule

update_en 只是一个单周期使能脉冲,所有寄存器仍由同一个 clk 驱动。与直接把分频结果当作新时钟相比,这种写法更容易约束,也避免在 FPGA 内部制造额外时钟域。


5. 正弦波查表法

人眼对亮度的感知不是线性的。三角波在低亮度区域可能显得变化较快,在最高点和最低点又会突然反向。若希望效果更柔和,可以用相位累加器寻址正弦 ROM。

下面给出 16 点半波亮度表,先从暗到亮,再从亮到暗循环:

verilog 复制代码
module sine_breath_lut (
  input  wire       clk      ,  // 系统时钟
  input  wire       rstn     ,  // 低有效复位
  input  wire       update_en,  // 查表更新使能
  output reg [11:0] duty        // 12 bit 亮度值
);

  reg [4:0] phase;

  always @(posedge clk or negedge rstn)
    begin
      if (rstn == 1'b0)
        begin
          phase <= 5'd0;
        end
      else if (update_en == 1'b1)
        begin
          phase <= phase + 1'b1;
        end
    end

  always @(*)
    begin
      case (phase)
        5'd0,  5'd31: duty = 12'd0;
        5'd1,  5'd30: duty = 12'd39;
        5'd2,  5'd29: duty = 12'd156;
        5'd3,  5'd28: duty = 12'd347;
        5'd4,  5'd27: duty = 12'd605;
        5'd5,  5'd26: duty = 12'd921;
        5'd6,  5'd25: duty = 12'd1285;
        5'd7,  5'd24: duty = 12'd1684;
        5'd8,  5'd23: duty = 12'd2100;
        5'd9,  5'd22: duty = 12'd2516;
        5'd10, 5'd21: duty = 12'd2915;
        5'd11, 5'd20: duty = 12'd3279;
        5'd12, 5'd19: duty = 12'd3595;
        5'd13, 5'd18: duty = 12'd3853;
        5'd14, 5'd17: duty = 12'd4044;
        5'd15, 5'd16: duty = 12'd4095;
        default:      duty = 12'd0;
      endcase
    end

endmodule

实际工程可以把表扩展到 64、128 或 256 点,并使用 Block RAM 存储。表越密,相邻亮度跳变越小;但呼吸效果主要由更新频率和亮度曲线决定,不必盲目追求大表。


6. 按键控制亮度

下面实现两个按键:

  • key_up_pulse:每按一次增加亮度;
  • key_down_pulse:每按一次降低亮度。

为了让模块职责清晰,输入使用已经完成消抖和边沿检测的单周期脉冲。按键同步、消抖方法可复用前文的按键教程。

verilog 复制代码
module pwm_key_ctrl #(
  parameter PWM_WIDTH = 12,
  parameter DUTY_STEP = 256
) (
  input  wire               clk           ,  // 系统时钟
  input  wire               rstn          ,  // 低有效复位
  input  wire               key_up_pulse  ,  // 增亮脉冲
  input  wire               key_down_pulse,  // 减亮脉冲
  output wire               led              // LED PWM 输出
);

  localparam PWM_STEPS = 1 << PWM_WIDTH;

  reg [PWM_WIDTH:0] duty;

  always @(posedge clk or negedge rstn)
    begin
      if (rstn == 1'b0)
        begin
          duty <= PWM_STEPS / 2;
        end
      else if (key_up_pulse == 1'b1)
        begin
          if (duty >= PWM_STEPS - DUTY_STEP)
            begin
              duty <= PWM_STEPS;
            end
          else
            begin
              duty <= duty + DUTY_STEP;
            end
        end
      else if (key_down_pulse == 1'b1)
        begin
          if (duty <= DUTY_STEP)
            begin
              duty <= 'd0;
            end
          else
            begin
              duty <= duty - DUTY_STEP;
            end
        end
    end

  pwm_generator #(
    .PWM_WIDTH(PWM_WIDTH)
  ) u_pwm_generator (
    .clk    (clk ),
    .rstn   (rstn),
    .duty   (duty),
    .pwm_out(led )
  );

endmodule

上、下边界都采用饱和处理,不允许无符号数越界回绕。例如亮度已经为 0 时继续减亮,结果仍保持 0,而不是回绕成最大值。


7. 板级顶层连接

如果开发板 LED 为低电平点亮,需要在顶层对 PWM 极性取反;如果高电平点亮则直接连接。

verilog 复制代码
module pwm_led_top (
  input  wire clk   ,  // 50 MHz 系统时钟
  input  wire rstn  ,  // 低有效复位
  output wire led_n    // 低电平点亮 LED
);

  wire led_pwm;

  breathing_led #(
    .CLK_FREQ (50_000_000),
    .UPDATE_HZ(1_000     ),
    .PWM_WIDTH(12        ),
    .STEP     (8         )
  ) u_breathing_led (
    .clk (clk    ),
    .rstn(rstn   ),
    .led (led_pwm)
  );

  assign led_n = ~led_pwm;

endmodule

XDC 约束中的引脚名必须与顶层端口一致。上板后若 LED 一直反向显示,优先检查原理图中的 LED 有效电平,而不是立即修改 PWM 内核。


8. 自检 Testbench

仿真时把参数缩小,加快一个完整呼吸周期的验证:

  • CLK_FREQ = 2560
  • UPDATE_HZ = 10
  • PWM_WIDTH = 8
  • STEP = 64

这样每 256 个时钟更新一次亮度,4 次更新到达满量程,再经过 4 次更新回到 0。

verilog 复制代码
`timescale 1ns/1ps

module tb_breathing_led;

  reg  clk;
  reg  rstn;
  wire led;

  integer timeout_cnt;
  reg     seen_full;
  reg     seen_fall;
  reg [8:0] last_duty;

  breathing_led #(
    .CLK_FREQ (2560),
    .UPDATE_HZ(10  ),
    .PWM_WIDTH(8   ),
    .STEP     (64  )
  ) dut (
    .clk (clk ),
    .rstn(rstn),
    .led (led )
  );

  initial
    begin
      clk = 1'b0;
      forever #10 clk = ~clk;
    end

  initial
    begin
      rstn        = 1'b0;
      timeout_cnt = 0;
      seen_full   = 1'b0;
      seen_fall   = 1'b0;
      last_duty   = 9'd0;

      repeat (5) @(posedge clk);
      rstn = 1'b1;

      while ((seen_fall == 1'b0) && (timeout_cnt < 5000))
        begin
          @(posedge clk);
          timeout_cnt = timeout_cnt + 1;

          if (dut.duty == 9'd256)
            begin
              seen_full = 1'b1;
            end

          if ((seen_full == 1'b1) && (dut.duty < last_duty))
            begin
              seen_fall = 1'b1;
            end

          last_duty = dut.duty;
        end

      if (seen_full == 1'b0)
        begin
          $display("FAIL: duty never reaches full scale");
          $finish;
        end

      if (seen_fall == 1'b0)
        begin
          $display("FAIL: duty never starts falling");
          $finish;
        end

      $display("PASS: duty reaches full scale and reverses");
      $finish;
    end

endmodule

仿真波形中应同时观察:

  1. update_en 是否每 256 个时钟出现一次;
  2. duty 是否按 0、64、128、192、256 递增;
  3. 到达 256 后 direction 是否翻转;
  4. duty 是否按 192、128、64、0 递减;
  5. led 在不同亮度阶段的高电平宽度是否变化。

9. 扩展:PWM 驱动舵机

常见模拟舵机也接收周期脉冲,但它与 LED PWM 的含义不同:

  • 周期通常约为 20 ms,也就是 50 Hz;
  • 高电平宽度通常在 0.5~2.5 ms 范围;
  • 脉宽决定角度,而不是周期平均功率。

以 50 MHz 时钟为例:

text 复制代码
20 ms 周期计数 = 50_000_000 × 0.020 = 1_000_000
0.5 ms 高电平  = 50_000_000 × 0.0005 = 25_000
1.5 ms 高电平  = 75_000
2.5 ms 高电平  = 125_000

因此舵机更适合使用"固定周期计数 + 可调高电平计数"的形式:

verilog 复制代码
module servo_pwm #(
  parameter CLK_FREQ = 50_000_000
) (
  input  wire        clk        ,  // 系统时钟
  input  wire        rstn       ,  // 低有效复位
  input  wire [17:0] pulse_count,  // 高电平时钟数
  output wire        servo_out     // 舵机控制输出
);

  localparam PERIOD_COUNT = CLK_FREQ / 50;

  reg [19:0] period_cnt;

  always @(posedge clk or negedge rstn)
    begin
      if (rstn == 1'b0)
        begin
          period_cnt <= 20'd0;
        end
      else if (period_cnt == PERIOD_COUNT - 1)
        begin
          period_cnt <= 20'd0;
        end
      else
        begin
          period_cnt <= period_cnt + 1'b1;
        end
    end

  assign servo_out = (period_cnt < pulse_count) ? 1'b1 : 1'b0;

endmodule

不同型号舵机允许的脉宽范围可能不同。上板前应查看数据手册,从中间位置开始测试,再逐步扩大范围,避免机械结构撞到限位。


10. 常见问题排查

10.1 LED 肉眼可见闪烁

原因通常是 PWM 频率过低。提高系统时钟或减小 PWM_WIDTH,让 PWM 频率至少达到几百赫兹,推荐大于 1 kHz。

10.2 呼吸速度太快或太慢

呼吸速度由 UPDATE_HZSTEPPWM_STEPS 共同决定:

text 复制代码
完整周期约为 2 × PWM_STEPS / STEP / UPDATE_HZ

增大 STEPUPDATE_HZ 会加快呼吸,减小它们会放慢呼吸。

10.3 最亮时仍有短暂熄灭

这通常是没有单独处理 100% 占空比。确认 duty 比计数器多一位,并在满量程时直接输出高电平。

10.4 亮度接近 0 时跳变明显

三角波占空比虽然线性,但人眼感知并不线性。可以使用正弦表或 Gamma 校正表,把更多采样点分配到低亮度区域。

10.5 按键按一次变化多级

说明输入仍含机械抖动,或者没有把按键电平转换为单周期脉冲。先做双触发同步、计数消抖和边沿检测,再连接 pwm_key_ctrl

10.6 舵机抖动

检查 20 ms 周期是否稳定、脉宽是否每帧保持一致、电源是否足够。舵机应使用独立且电流充足的电源,并与 FPGA 共地,不能直接由 FPGA IO 供电。


11. 工程扩展方向

在当前工程上可以继续实现:

  1. 使用 UART 命令实时设置亮度;
  2. 使用多个 PWM 通道驱动 RGB LED,实现颜色渐变;
  3. 使用 Gamma 查表改善低亮度区域的视觉线性;
  4. 增加模式按键,在常亮、呼吸和手动调光之间切换;
  5. duty 接入 AXI-Lite 寄存器,由处理器控制;
  6. 增加互补 PWM 和死区控制,为后续电机驱动做准备。

12. 本篇总结

本篇完成了 PWM 呼吸灯与按键调光的完整设计:

  • 理解 PWM 频率、计数位宽和占空比精度的关系;
  • 用多一位的 duty 正确覆盖 0%~100% 边界;
  • 实现可复用的 pwm_generator
  • 使用低频时钟使能生成三角波呼吸包络;
  • 了解正弦 ROM 查表与视觉亮度优化方法;
  • 实现带饱和边界的按键亮度调节;
  • 用自检 Testbench 验证满量程和方向翻转;
  • 将 PWM 思路扩展到 50 Hz 舵机脉宽控制。

下一篇将进入简易频率计项目,学习闸门计数、跨时钟域采样、测频误差分析,并把结果送到数码管显示。

相关推荐
zlinear数据采集卡5 小时前
ZLinear DABM-D223 通信协议与软件开发全解析:从 USB CDC 到 DDS 波形输出
arm开发·嵌入式硬件·fpga开发·开源
LCMICRO-133108477469 小时前
国产长芯微LD3092完全pin-pin替代LT3092,是一款可编程两端电流源
arm开发·单片机·嵌入式硬件·fpga开发·硬件工程·恒流源·lt3092
nuoxin11410 小时前
BL-M8812CU3 无线 WiFi 模块-富利威
人工智能·嵌入式硬件·fpga开发·硬件工程·dsp开发
xiaojiaohuazi13 小时前
基于Zynq UltraScale+ MPSoC的PL DDR4直写多NVMe条带阵列(RAID 0)与exFAT高速采集存储系统
fpga开发
ALINX技术博客13 小时前
【黑金云课堂】FPGA技术教程Vitis开发:AXI总线
fpga开发·vitis
星夜离尘561 天前
UART 串口通信:从帧格式到收发回显(附完整 Verilog 实操)
fpga开发
仲南音1 天前
Xilinx FPGA—— DDR4读写
fpga开发
思尔芯S2C1 天前
思尔芯RCF RTL自动分割工具,助力大规模AI/HPC芯片原型验证
人工智能·fpga开发·内存模型·自动编译·ddr5·prototyping·原型验证
Terasic友晶科技2 天前
答疑解惑|Agilex 3 FPGA QSPI时钟设置注意事项
fpga开发·de23-lite·terasic·qspi flash