FPGA-Based Breathing LED Using PWM Control
A breathing LED produces a gradual brightening and dimming effect by continuously varying its brightness. In digital hardware, this is accomplished through Pulse Width Modulation (PWM) where the duty cycle is swept from minimum to maximum and back. An FPGA implementation typically splits the design into a PWM generator and a brightness controller that adjusts the duty cycle over time.
PWM Signal Generation
A PWM generator can be built with a free-running counter and a compare register. The counter defines the PWM period. While counting, the output is driven low if the count value lies below the compare threshold, and high otherwise. The duty cycle changes when the compare value is updated. The following diagram illustrates this mechanism:
Hardware Setup
Four LEDs (LED0–LED3) are connected with their cathodes to the collectors of S8050 NPN transistors. The anodes are tied to 3.3V, and the transistor bases are driven by FPGA I/O pins. The FPGA's 1.5V logic level is insufficient to drive the LEDs directly, so the transistors serve as current amplifiers. A high output from the FPGA turns on the transistor, lighting the LED; a low output turns it off. Only LED0 is used in this experiment.
Architecture
The design contains two main blocks: a PWM generator and a breath controller. The breath controller computes a time-varying duty cycle and feeds it to the PWM generaotr, which produces the actual waveform that drives the LED.
RTL Implementation
PWM Generator Module
This module accepts a period value and a duty cycle input, and outputs a PWM waveform. The period is constrained to be at least 2 to guarantee proper operation. The duty cycle is translated into a comparison threshold. A cycle counter runs from 0 up to period_max - 1, wrapping to zero. While the counter is below the threshold, the output is logic 0; otherwise it is logic 1.
`timescale 1ns / 1ns
module pwm_gen
(
input wire clk,
input wire rst_n,
input wire [31:0] period_max,
input wire [31:0] duty_val,
output reg pwm_out
);
wire [31:0] safe_period = (period_max < 2) ? 2 : period_max;
wire [31:0] compare_th = (duty_val < safe_period) ? (safe_period - duty_val) : 0;
reg [31:0] tick_cnt;
always @(posedge clk) begin
if (!rst_n)
tick_cnt <= 0;
else if (tick_cnt < safe_period - 1)
tick_cnt <= tick_cnt + 1;
else
tick_cnt <= 0;
end
always @(posedge clk) begin
if (!rst_n)
pwm_out <= 1'b0;
else
pwm_out <= (tick_cnt < compare_th) ? 1'b0 : 1'b1;
end
endmodule
Breath Controller Module
The breath controller adjusts the duty cycle incrementally. It uses three counters:
- A period counter that paces the duty cycle updates.
- A step counter that tracks the number of brightness steps applied in one direction.
- A direction flag (
inc_not_dec) that toggles when the duty cycle reaches its maximum or minimum.
The brightness range is divided into BRIGHT_LEVELS discrete steps. At each update interval, the duty cycle is increased or decreased by STEP_SIZE. When step_cnt reaches BRIGHT_LEVELS - 1, the direction is reversed.
`timescale 1ns / 1ns
module breath_ctrl #(
parameter PWM_PERIOD = 100_000,
parameter BREATH_CYCLE = 100_000_000,
parameter BRIGHT_LEVELS = 100
) (
input wire clk,
input wire rst_n,
output reg [31:0] duty_cycle
);
localparam STEP_SIZE = PWM_PERIOD / BRIGHT_LEVELS;
localparam TICK_LIMIT = BREATH_CYCLE / BRIGHT_LEVELS;
reg [31:0] pace_cnt;
reg [31:0] step_cnt;
reg inc_not_dec; // 1: increase, 0: decrease
// Pacing counter
always @(posedge clk) begin
if (!rst_n)
pace_cnt <= 0;
else if (pace_cnt < TICK_LIMIT - 1)
pace_cnt <= pace_cnt + 1;
else
pace_cnt <= 0;
end
// Duty cycle update
always @(posedge clk) begin
if (!rst_n)
duty_cycle <= 0;
else if (pace_cnt == TICK_LIMIT - 1) begin
if (inc_not_dec && (PWM_PERIOD - duty_cycle) >= STEP_SIZE)
duty_cycle <= duty_cycle + STEP_SIZE;
else if (!inc_not_dec && (duty_cycle >= STEP_SIZE))
duty_cycle <= duty_cycle - STEP_SIZE;
end
end
// Step counter
always @(posedge clk) begin
if (!rst_n)
step_cnt <= 0;
else if (pace_cnt == TICK_LIMIT - 1) begin
if (step_cnt < BRIGHT_LEVELS - 1)
step_cnt <= step_cnt + 1;
else
step_cnt <= 0;
end
end
// Direction control
always @(posedge clk) begin
if (!rst_n)
inc_not_dec <= 1'b1;
else if (pace_cnt == TICK_LIMIT - 1) begin
if (step_cnt == BRIGHT_LEVELS - 1)
inc_not_dec <= ~inc_not_dec;
end
end
endmodule
Top-Level Integration
The top module instantiates the breath controller and the PWM generator, wiring the dynamic duty cycle value to the PWM module. A parameterizable period, breath cycle length, and number of brightness levels allow easy tuning.
`timescale 1ns / 1ns
module top_breathing_led #(
parameter PWM_PERIOD = 100_000,
parameter BREATH_CYCLE = 100_000_000,
parameter BRIGHT_LEVELS = 100
) (
input wire clk,
input wire rst_n,
output wire led
);
wire [31:0] duty_cycle;
breath_ctrl #(
.PWM_PERIOD (PWM_PERIOD),
.BREATH_CYCLE (BREATH_CYCLE),
.BRIGHT_LEVELS (BRIGHT_LEVELS)
) u_ctrl (
.clk (clk),
.rst_n (rst_n),
.duty_cycle (duty_cycle)
);
pwm_gen u_pwm (
.clk (clk),
.rst_n (rst_n),
.period_max (PWM_PERIOD),
.duty_val (duty_cycle),
.pwm_out (led)
);
endmodule
Verification
PWM Generator Testbench
A simple testbench toggles the duty cycle through several values while monitoring the PWM output. A 10 ns clock is used.
`timescale 1ns / 1ns
module tb_pwm_gen;
reg clk;
reg rst_n;
reg [31:0] duty_val;
wire pwm_wire;
pwm_gen dut (
.clk (clk),
.rst_n (rst_n),
.period_max(32'd10),
.duty_val (duty_val),
.pwm_out (pwm_wire)
);
always #10 clk = ~clk;
initial begin
clk = 0;
rst_n = 0;
duty_val = 0;
#200 rst_n = 1;
repeat (10) begin
#1000 duty_val = duty_val + 1;
end
$finish;
end
endmodule
Breath Controller Testbench
The breath controller is verified by observing the duty cycle waveform. The parameters are scaled down for simulation speed.
`timescale 1ns / 1ns
module tb_breath_ctrl;
reg clk;
reg rst_n;
wire [31:0] duty_cycle;
breath_ctrl #(
.PWM_PERIOD (100),
.BREATH_CYCLE (10_000),
.BRIGHT_LEVELS(10)
) dut (
.clk (clk),
.rst_n (rst_n),
.duty_cycle(duty_cycle)
);
always #10 clk = ~clk;
initial begin
clk = 0;
rst_n = 0;
#200 rst_n = 1;
#200_000 $finish;
end
endmodule
Full System Testbench
The top-level testbench exercises the integrated design with scaled-down parameters.
`timescale 1ns / 1ns
module tb_top;
reg clk;
reg rst_n;
wire led;
top_breathing_led #(
.PWM_PERIOD (10),
.BREATH_CYCLE (1000),
.BRIGHT_LEVELS(10)
) dut (
.clk (clk),
.rst_n (rst_n),
.led (led)
);
always #10 clk = ~clk;
initial begin
clk = 0;
rst_n = 0;
#200 rst_n = 1;
#50_000 $finish;
end
endmodule
Constraints
The design is targeted at a device with a 50 MHz input clock. Pin assignments for clock, reset, and LED0 are given in the XDC file:
# Clock constraint: 50 MHz
create_clock -period 20.000 -name sys_clk [get_ports clk]
# I/O placement
set_property -dict {PACKAGE_PIN R4 IOSTANDARD LVCMOS15} [get_ports clk]
set_property -dict {PACKAGE_PIN U7 IOSTANDARD LVCMOS15} [get_ports rst_n]
set_property -dict {PACKAGE_PIN V9 IOSTANDARD LVCMOS15} [get_ports led]