Loading header...

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.

flowchart TD classDef wave fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a classDef avg fill:#dcfce7,stroke:#16a34a,color:#14532d classDef label fill:#f1f5f9,stroke:#475569,color:#1e293b subgraph DT100 ["100% Duty Cycle — full brightness"] W1["▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ 5V\nAverage = 5.0 V"]:::avg end subgraph DT75 ["75% Duty Cycle — 3/4 brightness"] W2["▓▓▓▓▓▓▓▓▓▓▓▓░░░░\n5V on for 75%, 0V for 25%\nAverage = 3.75 V"]:::wave end subgraph DT50 ["50% Duty Cycle — half brightness"] W3["▓▓▓▓▓▓▓▓░░░░░░░░\n5V on for 50%, 0V for 50%\nAverage = 2.5 V"]:::wave end subgraph DT25 ["25% Duty Cycle — dim"] W4["▓▓▓▓░░░░░░░░░░░░\n5V on for 25%, 0V for 75%\nAverage = 1.25 V"]:::wave end subgraph DT0 ["0% Duty Cycle — off"] W5["░░░░░░░░░░░░░░░░ 0V\nAverage = 0 V"]:::avg end FORMULA["Duty Cycle = (t_on / T_period) × 100%\nAverage Voltage = Duty Cycle × V_supply"]:::label

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.

flowchart TD classDef reg fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a classDef hw fill:#dcfce7,stroke:#16a34a,color:#14532d classDef out fill:#ffedd5,stroke:#ea580c,color:#9a3412 classDef cpu fill:#f3e8ff,stroke:#9333ea,color:#581c87 CPU["CPU\n(free to do other work)"]:::cpu subgraph TIMER0 ["Timer 0 — 8-bit hardware counter"] direction TB TCNT0["TCNT0\n(8-bit counter register)\nCounts 0 → 255 → 0 → ...\ndriven by prescaled clock"]:::reg OCR0A["OCR0A\n(Output Compare Register A)\nYou write your duty cycle here\n0 = 0%, 128 = 50%, 255 = 100%"]:::reg TCCR0A["TCCR0A + TCCR0B\n(Timer Control Registers)\nSelect mode (Fast PWM)\nSelect prescaler\nSelect output pin behaviour"]:::reg end COMPARE["Hardware comparator\n(combinational logic)\nEach clock: TCNT0 vs OCR0A"]:::hw OC0A_PIN["OC0A pin (PD6, Arduino pin 6)\nGoes HIGH when TCNT0 = 0\nGoes LOW when TCNT0 = OCR0A\n→ automatic PWM, no CPU needed"]:::out CPU -->|"writes OCR0A once"| OCR0A TIMER0 --> COMPARE COMPARE --> OC0A_PIN

Fast PWM — Register Settings

flowchart LR classDef reg fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a classDef bit fill:#fee2e2,stroke:#dc2626,color:#7f1d1d classDef explain fill:#f1f5f9,stroke:#475569,color:#1e293b subgraph TCCR0A_REG ["TCCR0A — bits COM0A1:COM0A0:WGM01:WGM00"] B7["COM0A1=1"]:::bit B6["COM0A0=0"]:::bit B1["WGM01=1"]:::bit B0["WGM00=1"]:::bit E1["COM0A1:0 = 10\n→ Non-inverting Fast PWM\n(pin HIGH at BOTTOM, LOW at compare match)"]:::explain E2["WGM01:00 = 11\n→ Fast PWM mode\n(count 0→255, then reset)"]:::explain end subgraph TCCR0B_REG ["TCCR0B — bits CS02:CS01:CS00"] B2["CS02=0\nCS01=1\nCS00=1"]:::bit E3["CS = 011\n→ Prescaler = 64\n→ PWM frequency = 16MHz / 64 / 256\n= 976 Hz ≈ 1 kHz"]:::explain end

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.

flowchart TD classDef init fill:#e0e7ff,stroke:#4338ca,color:#312e81 classDef loop fill:#dcfce7,stroke:#16a34a,color:#14532d classDef btn fill:#f3e8ff,stroke:#9333ea,color:#581c87 classDef pwm fill:#ffedd5,stroke:#ea580c,color:#9a3412 classDef db fill:#fee2e2,stroke:#dc2626,color:#7f1d1d START["Reset"]:::init INIT_PWM["Configure Timer0 Fast PWM\nTCCR0A = 0b10000011\nTCCR0B = 0b00000011\nDDRD |= PD6 as output"]:::init INIT_BTN["Configure PD2, PD3 as inputs\nEnable internal pull-ups\n(PORTD |= (1< INIT_PWM --> INIT_BTN --> INIT_BRIGHT INIT_BRIGHT --> LOOP LOOP --> BTN_UP BTN_UP -->|"not pressed"| BTN_DN BTN_UP -->|"pressed"| INC_PWM --> DEBOUNCE --> WAIT_RELEASE --> LOOP BTN_DN -->|"not pressed"| LOOP BTN_DN -->|"pressed"| DEC_PWM --> DEBOUNCE --> WAIT_RELEASE --> LOOP

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.

sequenceDiagram participant Physical as Physical Button participant Pin as MCU Pin Voltage participant Firmware as Firmware (no debounce) participant Debounced as Firmware (with debounce) Physical->>Pin: Press — contact closes Note over Pin: 5V → 0V → 5V → 0V → 5V → 0V (bounce, 15ms) Pin->>Firmware: Detects: press! Pin->>Firmware: Detects: release! Pin->>Firmware: Detects: press! Pin->>Firmware: Detects: release! Note over Firmware: Counted 3 presses — wrong! Pin->>Debounced: Detects: press! Note over Debounced: delay_20ms() — ignore bounces Pin->>Debounced: Pin is stable LOW after 20ms Note over Debounced: Counted 1 press — correct

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:

flowchart LR classDef probe fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a classDef measure fill:#dcfce7,stroke:#16a34a,color:#14532d P1["Period T\n= 1 / f\n= 1 / 976 Hz\n≈ 1.025 ms"]:::probe P2["t_on (pulse width)\n= OCR0A / 256 × T\nAt OCR0A=128:\n= 0.5 × 1.025 ms\n≈ 512 µs"]:::measure P3["t_off\n= T − t_on\n≈ 513 µs"]:::measure P4["Duty cycle\n= t_on / T × 100%\n= 128/256 × 100%\n= 50%"]:::measure P1 --> P2 --> P3 --> P4

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.


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

Next Exercise: Exercise 3 — UART: Talk to Your MCU →

Mind Map

mindmap root((PWM LED Dimmer)) Core idea Digital pin switches fast Average power sets brightness Timer hardware runs PWM Formulas Duty equals ton over T Vavg equals duty times Vs fPWM equals FCPU over prescaler times 256 16 MHz over 64 over 256 equals 976 Hz Registers TCCR0A sets Fast PWM TCCR0B sets prescaler OCR0A sets duty DDRD enables PD6 output Buttons Internal pull-up Press reads zero Debounce about 25 ms Wait for release Checks OCR0A zero is off OCR0A 128 about half OCR0A 255 full Scope period about 1.025 ms Mistakes Direct motor drive Floating button input Duty wraparound Wrong OC pin