Exercise 2 — PWM LED Dimmer
In the previous exercise you drove an LED fully on or fully off. But microcontrollers can simulate analog output using a technique called Pulse Width Modulation (PWM). It is how motor speed controllers, audio amplifiers, servo drivers, and LED dimmers all work.
Prerequisites — What to Buy
If you completed Exercise 1 you already have the Arduino Uno, breadboard, jumper wires, and USB cable. The new items for this exercise cost less than ₹30 total.
Components list
| # | Component | Qty | Approx. cost | How to identify / what to ask for |
|---|---|---|---|---|
| 1 | Arduino Uno R3 | 1 | ₹350–₹500 | Carry over from Exercise 1. |
| 2 | LED — any colour | 2 | ₹1–₹3 each | Any 5 mm LED. Red, green, or yellow are easiest to see. Has two legs — the longer leg is + (anode). If you want to experiment with brightness differences, buy red and blue (blue appears brighter at same current). |
| 3 | 220 Ω resistor | 2 | ₹1–₹2 each | Colour bands: red – red – brown – gold. Carry over from Exercise 1. |
| 4 | Tactile push button (momentary) | 2 | ₹2–₹5 each | Small square buttons, 4 legs, fit directly into breadboard. Also called "tact switch" or "12×12 push button". Press = ON, release = OFF (momentary action). Do not buy toggle switches — those stay on/off permanently. |
| 5 | 10 kΩ resistors | 2 | ₹1–₹2 each | Colour bands: brown – black – orange – gold. Used as pull-up resistors for the buttons. (You can skip these if you use AVR's internal pull-ups in software.) |
| 6 | Breadboard + jumper wires + USB cable | — | — | Carry over from Exercise 1. |
Total new components cost: ₹10–₹25
Tools you need
Same as Exercise 1 — Arduino IDE or AVR-GCC, and your PC.
[!TIP] Extension challenge hardware (optional): If you want to dim a high-power load (a motor or a 12 V LED strip) instead of a small LED, you will need a logic-level N-channel MOSFET such as the 2N7000 (₹5–₹10) or IRLZ44N (₹20–₹40). For now, a regular 5 mm LED is enough to understand PWM.
Where to buy
Any electronics component shop carries all of these. Ask for:
- "5 mm LED" (specify colour)
- "4-pin tact switch" or "12×12 push button"
- "10 kilohm resistor" (they may write it as 10K)
Online: Robu.in, Evelta.com, or Amazon India — search "tactile push button 4pin" and "5mm LED assorted".
The Core Idea — PWM
The MCU cannot output 2.5V on a digital pin. It can only output 0V or 5V. But if you switch between 0V and 5V fast enough — faster than the eye can see, faster than a motor can respond — the average voltage follows the ratio of on-time to off-time.
At 1000 Hz (1ms period), each cycle is 1ms. 50% duty = 0.5ms on, 0.5ms off. The LED flickers at 1000 Hz — invisible to the eye — but appears half as bright. An electric motor at 50% duty cycle spins at roughly half speed.
AVR Timer Hardware
You could generate PWM in software (set pin high, delay, set low, delay, repeat) — but that wastes all CPU time in delay loops. Instead, the AVR has hardware timers that generate PWM automatically, completely in the background, without interrupting the CPU.
Fast PWM — Register Settings
In C (for reference):
// Enable Fast PWM on OC0A (PD6, Arduino pin 6)
TCCR0A = (1 << COM0A1) | (1 << WGM01) | (1 << WGM00);
TCCR0B = (1 << CS01) | (1 << CS00); // prescaler = 64
DDRD |= (1 << PD6); // PD6 as output
OCR0A = 128; // 50% duty cycle
// From this point the PWM runs completely in hardware
// CPU can do anything else
The Program — Brightness Control with Buttons
Two buttons: one increases brightness (duty cycle +10), one decreases it. The PWM keeps running in hardware between button presses.
Button Debouncing — A Real Hardware Problem
When a mechanical button is pressed, the metal contacts bounce 5–50 times in the first 5–20ms before settling. Without debouncing, one physical press registers as 5–20 presses in firmware.
The debounce delay is always after detecting the edge, before acting on it. 20ms is a safe value for most tactile buttons.
Building the Program — Snippet by Snippet
Snippet 1 — Register Definitions
All UART and Timer registers are in extended I/O space (above 0x5F), so we use sts/lds not out/in. Name them with .equ so the code is readable.
; ── Timer 0 registers (I/O space — use out/in) ──────────
.equ TCCR0A, 0x24
.equ TCCR0B, 0x25
.equ OCR0A, 0x27 ; duty cycle register
; ── GPIO (I/O space) ────────────────────────────────────
.equ DDRD, 0x0A
.equ PORTD, 0x0B
.equ PIND, 0x09 ; read pin state with IN, not PORTD
; Bit positions
.equ LED_PIN, 6 ; PD6 = OC0A = Arduino pin 6
.equ BTN_UP, 2 ; PD2 = increase brightness
.equ BTN_DN, 3 ; PD3 = decrease brightness
.equ STEP, 25 ; brightness step per button press (≈10%)
Notice PIND — to read a pin you read from PIND (Port D Input), not PORTD. PORTD is the output latch. Writing to it drives the pin; reading from it gives you what you last wrote, not what is on the wire.
Snippet 2 — Timer0 Fast PWM Configuration
Three registers configure the timer. Write them once at startup — the hardware does the PWM forever after, with zero CPU involvement.
; TCCR0A = COM0A1:1, COM0A0:0, WGM01:1, WGM00:1 → 0b10000011 = 0x83
; COM0A1:0 = 10 → non-inverting: pin HIGH at BOTTOM, LOW at OCR0A match
; WGM01:00 = 11 → Fast PWM mode (count 0→255, reset)
LDI R16, 0x83
OUT TCCR0A, R16
; TCCR0B = CS02:0, CS01:1, CS00:1 → 0b00000011 = 0x03
; CS = 011 → prescaler = 64
; PWM frequency = 16MHz / 64 / 256 = 976 Hz ≈ 1 kHz
LDI R16, 0x03
OUT TCCR0B, R16
; PD6 must be an output — timer cannot drive an input pin
SBI DDRD, LED_PIN
The prescaler divides the 16 MHz clock by 64 before feeding the timer. The timer then counts 0→255 (256 steps) before resetting. So PWM frequency = 16,000,000 / 64 / 256 = 976 Hz. At this frequency the LED appears perfectly smooth to the eye.
Snippet 3 — Button Pin Configuration (Internal Pull-ups)
Buttons wire one side to the MCU pin and the other to GND. When not pressed, the pin must be pulled HIGH — otherwise it floats and reads random values. The AVR has built-in pull-up resistors (~50 kΩ) that you enable in software: set the PORTD bit of an input pin to 1.
; PD2 and PD3 stay as inputs (DDRD bits 2,3 = 0, which is the reset default)
; Enable internal pull-ups: write 1 to the PORTD bits while in input mode
IN R16, PORTD
ORI R16, (1 << BTN_UP) | (1 << BTN_DN)
OUT PORTD, R16
; Start at 50% brightness (OCR0A = 128)
LDI R17, 128 ; R17 = current brightness
OUT OCR0A, R17
Button logic is now active-low: pin reads 0 when pressed (GND pulls it low), reads 1 when not pressed (pull-up holds it high). This is the standard wiring used in virtually every embedded system.
Snippet 4 — Reading a Button and Debouncing
Mechanical contacts bounce for up to 20ms. We detect the falling edge (1→0), wait 20ms, confirm it is still 0, act, then wait for release (0→1).
; ── Check BTN_UP ────────────────────────────────────────
IN R18, PIND
SBRC R18, BTN_UP ; skip if bit BTN_UP is 0 (pressed)
RJMP check_dn ; not pressed — skip to next button
CALL delay_20ms ; wait for bounce to settle
IN R18, PIND
SBRC R18, BTN_UP ; confirm still pressed (not a glitch)
RJMP check_dn
; ── Button confirmed: increase brightness ───────────
MOV R16, R17
SUBI R16, (256 - STEP) ; add STEP by subtracting (256-STEP) mod 256
BRCS saturate_max ; if carry set, result wrapped past 255
MOV R17, R16
RJMP update_ocr
saturate_max:
LDI R17, 255
update_ocr:
OUT OCR0A, R17
; ── Wait for BTN_UP release ─────────────────────────────
wait_up_release:
IN R18, PIND
SBRS R18, BTN_UP ; skip if bit is 1 (released)
RJMP wait_up_release
SBRC = Skip if Bit in Register is Clear. SBRS = Skip if Bit is Set. These two instructions are the workhorses of bit testing in AVR assembly. BRCS = Branch if Carry Set — we use carry as an overflow flag for the saturating add.
Snippet 5 — Debounce Delay
Same nested-loop technique as Exercise 1, tuned to about 25ms at 16 MHz. That is intentionally conservative for ordinary tactile switches, which commonly bounce for 5-20ms.
; delay_20ms: busy-wait about 25 ms at 16 MHz
; 2 × 256 × 256 × about 3 cycles = about 393k cycles = 24.6 ms
delay_20ms:
LDI R20, 2
d20_outer:
LDI R19, 0
d20_middle:
LDI R18, 0
d20_inner:
DEC R18
BRNE d20_inner
DEC R19
BRNE d20_middle
DEC R20
BRNE d20_outer
RET
Complete Program
; ═══════════════════════════════════════════════════════
; pwm_dimmer.S — PWM LED Dimmer
; ATmega328P @ 16 MHz
; LED on PD6 (OC0A, Arduino pin 6)
; BTN_UP on PD2, BTN_DN on PD3 (active-low, internal pull-up)
; Brightness adjusts in steps of 25 (≈10% per press)
; ═══════════════════════════════════════════════════════
; ── Register addresses ──────────────────────────────────
.equ TCCR0A, 0x24
.equ TCCR0B, 0x25
.equ OCR0A, 0x27
.equ DDRD, 0x0A
.equ PORTD, 0x0B
.equ PIND, 0x09
.equ LED_PIN, 6
.equ BTN_UP, 2
.equ BTN_DN, 3
.equ STEP, 25
; ── Reset vector ────────────────────────────────────────
.org 0x0000
RJMP main
.org 0x0034
main:
LDI R16, HIGH(RAMEND)
OUT SPH, R16
LDI R16, LOW(RAMEND)
OUT SPL, R16
; Timer0 Fast PWM on OC0A (PD6)
LDI R16, 0x83 ; COM0A1=1, WGM01=1, WGM00=1
OUT TCCR0A, R16
LDI R16, 0x03 ; prescaler = 64 → ~976 Hz PWM
OUT TCCR0B, R16
SBI DDRD, LED_PIN
; Buttons: input with internal pull-up
IN R16, PORTD
ORI R16, (1<<BTN_UP)|(1<<BTN_DN)
OUT PORTD, R16
LDI R17, 128 ; R17 = brightness (0–255), start at 50%
OUT OCR0A, R17
; ── Main loop ───────────────────────────────────────────
main_loop:
; ── Check BTN_UP (increase brightness) ─────────────
IN R18, PIND
SBRC R18, BTN_UP
RJMP check_dn
CALL delay_20ms
IN R18, PIND
SBRC R18, BTN_UP
RJMP check_dn
MOV R16, R17
SUBI R16, (256 - STEP)
BRCS sat_max
MOV R17, R16
RJMP apply_up
sat_max:
LDI R17, 255
apply_up:
OUT OCR0A, R17
wait_up:
IN R18, PIND
SBRS R18, BTN_UP
RJMP wait_up
; ── Check BTN_DN (decrease brightness) ─────────────
check_dn:
IN R18, PIND
SBRC R18, BTN_DN
RJMP main_loop
CALL delay_20ms
IN R18, PIND
SBRC R18, BTN_DN
RJMP main_loop
MOV R16, R17
SUBI R16, STEP
BRCS sat_min ; borrow set = went below 0
MOV R17, R16
RJMP apply_dn
sat_min:
LDI R17, 0
apply_dn:
OUT OCR0A, R17
wait_dn:
IN R18, PIND
SBRS R18, BTN_DN
RJMP wait_dn
RJMP main_loop
; ── 20ms debounce delay ─────────────────────────────────
delay_20ms:
LDI R20, 2
d20_o:
LDI R19, 0
d20_m:
LDI R18, 0
d20_i:
DEC R18
BRNE d20_i
DEC R19
BRNE d20_m
DEC R20
BRNE d20_o
RET
Measuring PWM on an Oscilloscope
If you have access to an oscilloscope (or a logic analyser), connect a probe to PD6 and observe the waveform. You will see:
The oscilloscope is the most important tool in embedded debugging. A digital multimeter averages voltage — it will show ~2.5V for a 50% PWM signal, which looks like analog output. Only the oscilloscope reveals the switching waveform.
Extension Challenges
Challenge A — Smooth Fade Effect
Instead of button steps, make the LED slowly fade in and out in a loop (breathing effect). Use a sine-wave lookup table (64 entries, 0–255) and increment through it with a small delay between steps.
Challenge B — Three LEDs, RGB Mixing
Connect R, G, B LEDs to OC0A (PD6), OC0B (PD5), and OC2A (PB3). Each has an independent PWM channel. Write a program that cycles through the colour wheel by adjusting three duty cycles.
Challenge C — Motor Speed Control
Replace the LED with a small DC motor driven through an N-channel MOSFET (2N7000 or similar). The gate connects to your PWM pin. Motor speed follows duty cycle. Add a freewheeling diode (1N4001) across the motor terminals to suppress back-EMF.
Never connect a motor directly to an MCU pin. Motors draw hundreds of mA — far beyond the 40mA maximum of an AVR I/O pin. Always use a transistor or MOSFET as a driver stage.
Verification Checklist
Before you call the dimmer working, verify these observable results:
- With
OCR0A = 0, the LED is off. - With
OCR0A = 128, the LED is visibly dimmer than full brightness and a multimeter reads roughly half the supply average on PD6. - With
OCR0A = 255, the LED is nearly full brightness. - The UP button increases brightness one step per press, not many steps from contact bounce.
- The DOWN button decreases brightness and saturates at zero instead of wrapping to full brightness.
- On a scope or logic analyzer, the PWM period is about
1 / 976 Hz = 1.025 ms.
Common failure symptoms:
- LED never lights: PD6 is not configured as output, the LED polarity is reversed, or the resistor is not in series.
- Buttons act backwards: active-low pull-up logic was handled as active-high.
- One press jumps many levels: debounce or release-wait logic is missing.
- PWM pin stays static: COM0A1 or WGM bits are not set correctly.
Explained Solution
Timer0 runs in Fast PWM mode, so the CPU only writes the duty cycle into OCR0A; the timer hardware toggles OC0A on PD6 automatically. Non-inverting mode makes the pulse high from counter bottom until the compare match. With a 16 MHz clock, prescaler 64, and 8-bit overflow, the PWM frequency is 16 MHz / (64 x 256) = 976 Hz. The two buttons use internal pull-ups, so a pressed button reads as 0. Each confirmed press changes R17 by 25 counts, saturates at 0 or 255, writes OCR0A, and waits for release so one physical press produces one firmware action.
Summary
PWM uses time averaging to control power with a digital output. On the ATmega328P, Timer0 can generate a stable PWM waveform on OC0A while the CPU handles buttons or other work. The key design checks are duty-cycle limits, correct active-low button handling, adequate debounce, and never driving loads that exceed the MCU pin rating.
Further Reading
- Microchip ATmega328P Datasheet - Timer/Counter0 Fast PWM and I/O electrical limits.
- AVR Libc FAQ - toolchain and register-access guidance.
- Arduino Uno Rev3 Documentation - OC0A and board pin mapping.
Next Exercise: Exercise 3 — UART: Talk to Your MCU →