Project 1: LED Controller and Input Lab

Build a GPIO lab board that drives LED patterns, reads two buttons with debouncing, and reports deterministic state changes over USB serial.

Quick Reference

Attribute Value
Difficulty Level 1: Beginner
Time Estimate 1-2 weeks (or a focused weekend)
Main Programming Language C (Pico SDK)
Alternative Programming Languages MicroPython
Coolness Level Level 1: Fundamentals
Business Potential 2. The “Embedded Basics” Portfolio
Prerequisites Basic C, breadboarding, GPIO basics
Key Topics GPIO, pull-ups, debouncing, timers, interrupts, state machines

1. Learning Objectives

By completing this project, you will:

  1. Configure GPIO pins for stable input/output using pull-ups and proper drive settings.
  2. Implement debounced button events using non-blocking timing logic.
  3. Build a small finite state machine for LED patterns and speed control.
  4. Compare polling vs interrupt-driven input handling and measure latency.
  5. Produce deterministic logs that make firmware behavior observable and testable.

2. All Theory Needed (Per-Concept Breakdown)

2.1 GPIO Input/Output and Pull-Ups

Fundamentals

GPIO pins are multi-function pads that can be configured as input, output, or alternate peripheral functions. As inputs, pins are high-impedance and will float if you do not provide a default electrical state. A pull-up (or pull-down) resistor defines a known idle level by weakly biasing the pin to VCC (or GND). This prevents random transitions caused by noise. As outputs, GPIO pins can drive current into LEDs (source) or sink current from LEDs (sink), and the drive strength and slew rate affect signal integrity. Understanding GPIO configuration is fundamental because every external device you attach is ultimately a GPIO operation plus timing.

Deep Dive into the concept

On the RP2040, GPIOs are controlled through the IO_BANK0 and SIO blocks. You choose a function (GPIO vs UART/I2C/SPI) using the IO_BANK mux, then configure direction and output enable in the SIO registers. This separation matters: if the function mux is wrong, your pin will not respond, even if direction is correct. Pull-ups and pull-downs are controlled in the pad control registers. These are weak (tens of kilo-ohms) and are not substitutes for proper external resistors when you need precise analog behavior, but they are perfect for button inputs. Output drive is not infinite; each pin has a maximum current, and the total for the package is limited. That is why LED circuits need resistors and why you should avoid driving multiple LEDs directly in parallel from a single pin. GPIO timing also matters: reading a pin is a snapshot of that moment in time. If the input bounces or is metastable, you can read a different value on each sample. That is why you combine GPIO knowledge with debouncing and timers. In practice, you will configure pin function, pad control (pull-up, drive, hysteresis), direction, and then read or write. If you see inverted behavior, you likely wired the button as active-low, which is common with pull-ups. That is not a bug, but you must reflect it in software.

How this fits on projects

GPIO setup is the first gate in §3.2 requirements and §5.2 project structure. It also underpins event logic in §5.10 Phase 1.

Definitions & key terms

  • GPIO -> general-purpose digital input/output pin
  • Pull-up -> resistor that biases an input to logic high when idle
  • High-impedance -> input state that does not drive voltage
  • Drive strength -> maximum current a pin can source or sink
  • Active-low -> logic is inverted (pressed = 0)

Mental model diagram (ASCII)

VCC
 |
[Pull-up]
 |
GPIO -----> MCU input buffer ----> software reads 1/0
 |
[Button]
 |
GND

How it works (step-by-step)

  1. Configure pin mux to GPIO function.
  2. Enable pull-up (or pull-down) in pad control.
  3. Set direction: input for buttons, output for LEDs.
  4. Read input; interpret with active-low or active-high convention.
  5. Write outputs to drive LED patterns.

Minimal concrete example

// LED on GPIO 16, button on GPIO 14 (active-low)
gpio_init(16);
gpio_set_dir(16, GPIO_OUT);

gpio_init(14);
gpio_set_dir(14, GPIO_IN);
gpio_pull_up(14);

bool pressed = (gpio_get(14) == 0);
gpio_put(16, pressed);

Common misconceptions

  • “Pull-ups are optional” -> floating inputs read random values.
  • “Any pin can source unlimited current” -> exceeds GPIO limits and damages pins.

Check-your-understanding questions

  1. Why does an input float if you do not enable a pull-up or pull-down?
  2. What is the difference between configuring a pin function and setting direction?
  3. Why are buttons often wired as active-low?

Check-your-understanding answers

  1. The input buffer is high-impedance, so noise determines the voltage.
  2. Function mux selects which hardware block controls the pin; direction selects input vs output.
  3. Pull-ups are easy and stable; pressing connects to ground for a clean transition.

Real-world applications

  • Buttons, switches, relays, LED indicators, chip-select lines for SPI peripherals.

Where you’ll apply it

References

  • RP2040 Datasheet: GPIO and Pad Control sections
  • “Making Embedded Systems” by Elecia White, Ch. 5

Key insights

GPIO correctness is 80% wiring and configuration, 20% code logic.

Summary

Pull-ups define stable inputs, correct muxing gives control, and direction chooses input vs output.

Homework/Exercises to practice the concept

  1. Wire the same button as active-high and active-low; compare software changes.
  2. Measure LED current with different resistor values and observe brightness.

Solutions to the homework/exercises

  1. Active-high requires a pull-down and press = 1; active-low requires pull-up and press = 0.
  2. Higher resistance reduces current and brightness; too low risks overcurrent.

2.2 Debouncing and Event Qualification

Fundamentals

Mechanical switches do not transition cleanly. When contacts close, they bounce for a few milliseconds, creating multiple rapid transitions. If firmware treats each transition as a press, a single button action becomes multiple events. Debouncing is the process of filtering the raw input into a single clean event. It can be done in hardware (RC filter, Schmitt trigger) or in software (timing window, state machine). In small embedded systems, software debouncing is common because it is flexible and cheap, but it requires careful timing so it does not block other tasks.

Deep Dive into the concept

Debouncing is essentially a time-domain filter. You sample the input, detect a transition, then ignore further transitions for a fixed window (e.g., 10-20 ms) until the contact settles. A better approach is to require the input to remain stable for a threshold duration before declaring a state change. This avoids false triggers if the bounce window is longer than expected. On the RP2040, you can implement debouncing with a monotonic timer (time_us_64) or with a periodic tick. The key is that the debounce logic must not block other tasks; you should store a timestamp and check it in your main loop or in a timer callback. For interrupt-driven inputs, the interrupt may fire on every bounce edge, so you must either disable the interrupt temporarily or ignore edges within the debounce window. This is the difference between edge-driven input and event-driven behavior: you use time to transform noisy edges into meaningful events. For user interfaces, latency matters; a 20 ms debounce delay feels instant but prevents double-triggering. Too long and the UI feels sluggish; too short and bounce leaks through. You can test bounce behavior by logging timestamps and observing the cluster of edges.

How this fits on projects

Debouncing is core to §3.2 requirements and §5.4 Concepts You Must Understand. It defines event correctness in §5.10 Phase 2.

Definitions & key terms

  • Bounce -> rapid mechanical transitions when a switch changes state
  • Debounce window -> time interval during which edges are ignored
  • Stable state -> input remains consistent for a threshold duration

Mental model diagram (ASCII)

Raw button:  _|‾|_|‾|_|‾‾‾
Filtered:    _______|‾‾‾‾
               <20ms>

How it works (step-by-step)

  1. Detect a candidate transition (pressed or released).
  2. Record a timestamp and mark “debouncing” state.
  3. Ignore further transitions until the debounce time elapses.
  4. Confirm the new state and emit a single event.

Minimal concrete example

if (!debouncing && pressed_edge) {
    debouncing = true;
    debounce_until = make_timeout_time_ms(20);
}
if (debouncing && time_reached(debounce_until)) {
    debouncing = false;
    if (gpio_get(btn) == 0) emit_press_event();
}

Common misconceptions

  • “I can just delay(20)” -> blocking delays stall the whole system.
  • “Bounce is always 1-2 ms” -> some switches bounce longer.

Check-your-understanding questions

  1. Why is blocking delay-based debounce harmful in embedded systems?
  2. What is the trade-off between debounce time and UI responsiveness?
  3. How does debounce differ for edge-triggered interrupts vs polling?

Check-your-understanding answers

  1. It prevents other tasks from running and increases latency.
  2. Longer debounce reduces false presses but feels sluggish.
  3. Interrupts need edge filtering or temporary disable to avoid storms.

Real-world applications

  • Keyboards, industrial buttons, rotary encoders, limit switches.

Where you’ll apply it

References

  • “Making Embedded Systems” by Elecia White, Ch. 6

Key insights

Debouncing turns noisy physics into a single reliable software event.

Summary

Use time-based filtering to collapse bounce into a stable press/release.

Homework/Exercises to practice the concept

  1. Log button edge timestamps for 1 minute and estimate typical bounce time.
  2. Implement a stable-state debounce that requires 3 consecutive reads.

Solutions to the homework/exercises

  1. You will likely see clusters of edges within 5-15 ms.
  2. Only accept a transition after 3 identical samples across the debounce interval.

2.3 Timer-Based Scheduling (Non-Blocking Timing)

Fundamentals

Embedded systems must handle multiple tasks without an operating system. If you call a blocking delay, you stop all work, including button reads and serial output. Timer-based scheduling uses timestamps to decide when to perform an action, allowing the main loop to stay responsive. Instead of “sleep for 200 ms,” you set a next-deadline time and check it each iteration. This produces deterministic behavior and ensures inputs are not missed.

Deep Dive into the concept

A non-blocking scheduler is a small state machine. You store a next_time value (absolute time) for each task and compare it to the current time. When the current time passes, you run the task and schedule the next deadline. On RP2040, you can use absolute_time_t or 64-bit microsecond counters. This approach scales: you can manage LED patterns, debounce logic, and serial reporting as independent periodic tasks. The important detail is monotonic time; you must avoid overflow issues by using unsigned arithmetic or library helpers that handle wraparound. Timer-based scheduling also allows you to measure latency. If your loop takes too long, deadlines will slip, and you can log missed periods. That diagnostic is far more useful than a frozen system caused by delay calls. You can implement periodic timers using hardware alarms or by polling time in the main loop. Hardware alarms are more precise but require interrupt handlers. Polling is simpler and sufficient for this project. The key invariant is: every task must be able to complete quickly, and you should never block inside the main loop.

How this fits on projects

This is the core of §3.2 Functional Requirements and §5.10 Phase 1 scheduling.

Definitions & key terms

  • Deadline -> absolute time at which a task should run
  • Non-blocking -> no operation halts the whole loop
  • Monotonic timer -> time source that only moves forward

Mental model diagram (ASCII)

Loop: read inputs -> check timers -> update state -> output -> repeat

How it works (step-by-step)

  1. Initialize a next-deadline for each periodic task.
  2. In the main loop, check current time vs deadline.
  3. If due, execute task and set next deadline.
  4. Continue without blocking.

Minimal concrete example

absolute_time_t next_blink = make_timeout_time_ms(200);
if (absolute_time_diff_us(get_absolute_time(), next_blink) <= 0) {
    toggle_led();
    next_blink = make_timeout_time_ms(200);
}

Common misconceptions

  • “Polling timers wastes CPU” -> the CPU is idle anyway without an RTOS.
  • “Delays are simpler” -> they hide latency bugs and missed events.

Check-your-understanding questions

  1. Why does non-blocking timing improve responsiveness?
  2. What happens if your loop runs slower than the task period?
  3. How do you avoid timer overflow bugs?

Check-your-understanding answers

  1. Inputs and outputs are checked continuously instead of being paused.
  2. Deadlines slip and you can log missed periods or skip updates.
  3. Use unsigned arithmetic or helper functions with wraparound handling.

Real-world applications

  • LED animations, sensor polling, periodic telemetry, watchdog feeding.

Where you’ll apply it

References

  • RP2040 SDK time functions
  • “Making Embedded Systems” by Elecia White, Ch. 10

Key insights

Timer-based scheduling is the simplest way to create deterministic firmware without an RTOS.

Summary

Use deadlines, not delays, to keep firmware responsive and predictable.

Homework/Exercises to practice the concept

  1. Create two timers: one at 100 ms and one at 700 ms. Log both.
  2. Intentionally add a 30 ms busy loop and observe timer slip.

Solutions to the homework/exercises

  1. The 100 ms task runs 10x per second, the 700 ms task ~1.4x per second.
  2. Deadlines shift; you will see drift or missed cycles.

3. Project Specification

3.1 What You Will Build

A GPIO lab controller with 3 LEDs and 2 buttons. Button A cycles through LED patterns; Button B toggles speed. Firmware reports every state change over USB serial and never blocks.

3.2 Functional Requirements

  1. GPIO configuration: configure 3 LED outputs and 2 button inputs with pull-ups.
  2. Pattern state machine: implement at least 3 patterns (chase, blink, solid).
  3. Debounced inputs: filter button events to one press per physical action.
  4. Non-blocking timing: schedule pattern updates without sleep loops.
  5. Observability: log state transitions and timing via USB serial.

3.3 Non-Functional Requirements

  • Performance: LED updates remain stable at 20-500 ms periods.
  • Reliability: no missed or duplicated button events for 100 presses.
  • Usability: serial log clearly shows current pattern and speed.

3.4 Example Usage / Output

[BOOT] LED lab starting...
[GPIO] LED pins: 16,17,18
[GPIO] Button pins: 14,15 (pull-up)
[STATE] pattern=CHASE speed=200ms
[EVENT] button1 pressed -> pattern=BLINK
[EVENT] button2 pressed -> speed=50ms

3.5 Data Formats / Schemas / Protocols

  • Serial log line: [TAG] key=value key=value
  • State representation:
    • pattern: enum {CHASE, BLINK, SOLID}
    • speed_ms: uint32

3.6 Edge Cases

  • Button held down (no repeat events unless long-press enabled).
  • Simultaneous button presses.
  • Speed toggled during pattern update.
  • UART disconnected (logs should not block execution).

3.7 Real World Outcome

You will have a physical control panel with visible patterns and a serial console that mirrors state transitions.

3.7.1 How to Run (Copy/Paste)

mkdir build && cd build
cmake ..
make -j4
picotool load -f led_lab.uf2
minicom -b 115200 -o -D /dev/ttyACM0

3.7.2 Golden Path Demo (Deterministic)

  1. Power on with both buttons released.
  2. Press Button A once -> pattern changes from CHASE to BLINK.
  3. Press Button B once -> speed changes from 200 ms to 50 ms.
  4. Press Button A again -> pattern changes to SOLID.

3.7.3 Failure Demo (Bad Input)

  • Scenario: Button line floating (pull-up disabled).
  • Expected result: log shows rapid random events; firmware prints [WARN] input unstable after 10 spurious edges.

3.7.4 If CLI: exact terminal transcript

$ minicom -b 115200 -o -D /dev/ttyACM0
[BOOT] LED lab starting...
[STATE] pattern=CHASE speed=200ms
[EVENT] button1 pressed -> pattern=BLINK
[EVENT] button2 pressed -> speed=50ms
[STATE] pattern=BLINK speed=50ms

4. Solution Architecture

4.1 High-Level Design

Buttons -> Debounce -> Event Queue -> State Machine -> LED Driver -> GPIO
                                  -> Logger -> USB Serial

4.2 Key Components

| Component | Responsibility | Key Decisions | |———–|—————-|—————| | GPIO Init | Configure pins and pulls | Use active-low buttons for stability | | Debouncer | Filter raw input edges | Fixed 20 ms window vs stable-state | | Pattern FSM | Manage LED patterns | Simple enum + per-pattern function | | Timer Scheduler | Non-blocking timing | Polling loop with deadlines | | Logger | Serial observability | Non-blocking writes, drop if busy |

4.3 Data Structures (No Full Code)

typedef enum { PAT_CHASE, PAT_BLINK, PAT_SOLID } pattern_t;

typedef struct {
    pattern_t pattern;
    uint32_t speed_ms;
    bool button1_pressed;
    bool button2_pressed;
    absolute_time_t next_tick;
} led_state_t;

4.4 Algorithm Overview

Key Algorithm: Debounced Event Loop

  1. Sample button pins.
  2. Update debounce timers; emit events on stable transitions.
  3. If timer expired, update LED pattern state.
  4. Log any state change.

Complexity Analysis:

  • Time: O(1) per loop iteration
  • Space: O(1)

5. Implementation Guide

5.1 Development Environment Setup

# Pico SDK setup assumed
export PICO_SDK_PATH=/path/to/pico-sdk
mkdir build && cd build
cmake ..
make -j4

5.2 Project Structure

led-lab/
├── src/
│   ├── main.c
│   ├── gpio_init.c
│   ├── debounce.c
│   ├── patterns.c
│   └── logger.c
├── include/
│   ├── debounce.h
│   └── patterns.h
├── CMakeLists.txt
└── README.md

5.3 The Core Question You’re Answering

“How do you turn raw GPIO inputs into reliable, deterministic behavior?”

5.4 Concepts You Must Understand First

Stop and research these before coding:

  1. GPIO input/output and pull-ups
  2. Debouncing strategies (time window vs stable-state)
  3. Non-blocking timing with monotonic clocks

5.5 Questions to Guide Your Design

  1. How will you represent patterns and speed in a state machine?
  2. What constitutes a “press” vs “hold” event?
  3. What log line format will make debugging easiest?

5.6 Thinking Exercise

Trace a bouncing waveform and decide where you emit the event in your debounce logic.

5.7 The Interview Questions They’ll Ask

  1. Why do buttons need debouncing?
  2. What is the difference between polling and interrupts?
  3. How do you avoid blocking delays in embedded systems?

5.8 Hints in Layers

Hint 1: Start with one LED and one button. Hint 2: Add a debounce window using timestamps. Hint 3: Add a second button and a simple enum for patterns. Hint 4: Add non-blocking logging; never print inside an ISR.

5.9 Books That Will Help

| Topic | Book | Chapter | |——-|——|———| | GPIO and debounce | “Making Embedded Systems” | Ch. 5-6 | | Non-blocking timing | “Making Embedded Systems” | Ch. 10 | | Firmware structure | “Code Complete” | Ch. 5 |

5.10 Implementation Phases

Phase 1: Foundation (2-4 hours)

  • Configure GPIO and blink one LED.
  • Verify button reads with pull-up. Checkpoint: LED toggles and button prints raw state.

Phase 2: Core Functionality (4-6 hours)

  • Implement debounce and pattern FSM.
  • Add non-blocking timer scheduler. Checkpoint: Patterns change on button presses without missed events.

Phase 3: Polish & Diagnostics (2-4 hours)

  • Add serial logging, speed control, and edge-case handling. Checkpoint: Logs are deterministic and UI feels responsive.

5.11 Key Implementation Decisions

| Decision | Options | Recommendation | Rationale | |———-|———|—————-|———–| | Debounce method | Fixed window vs stable-state | Stable-state | More robust to varied switches | | Input handling | Polling vs interrupts | Polling | Simpler and sufficient for this scale | | Logging | Blocking vs drop-on-busy | Drop-on-busy | Avoids timing jitter |


6. Testing Strategy

6.1 Test Categories

| Category | Purpose | Examples | |———-|———|———-| | Unit Tests | Validate debounce logic | Simulated bounce input sequences | | Integration Tests | GPIO + timing combined | Button press while pattern updates | | Edge Case Tests | Stress odd states | Simultaneous button presses |

6.2 Critical Test Cases

  1. Single press: One physical press yields exactly one event.
  2. Bounce storm: 10 edges in 5 ms still yields one event.
  3. Speed toggle: Speed changes do not reset the pattern state.

6.3 Test Data

input: 1 0 1 0 1 0 0 0 (bounce) -> expected: single press event

7. Common Pitfalls & Debugging

7.1 Frequent Mistakes

| Pitfall | Symptom | Solution | |———|———|———-| | Floating input | Random events | Enable pull-up and check wiring | | Blocking delays | Missed presses | Replace delays with timers | | Logging in ISR | Jitter or lockups | Set flags in ISR and log in main loop |

7.2 Debugging Strategies

  • Toggle a spare GPIO in code to measure loop timing with a scope.
  • Log timestamps for button events to verify debounce timing.

7.3 Performance Traps

  • Excessive serial prints can stall the loop; throttle logs if needed.

8. Extensions & Challenges

8.1 Beginner Extensions

  • Add a long-press event to switch into a settings mode.
  • Add an LED brightness control using PWM.

8.2 Intermediate Extensions

  • Add a small OLED display for pattern status.
  • Add a rotary encoder as an input device.

8.3 Advanced Extensions

  • Implement a cooperative task scheduler with multiple tasks.
  • Add a configuration mode stored in flash.

9. Real-World Connections

9.1 Industry Applications

  • Industrial panels: button-driven control surfaces with status LEDs.
  • Consumer devices: UI feedback and debounce logic in appliances.
  • Pico SDK examples: GPIO, button, timer demos.
  • QMK firmware: large-scale debounce and key scanning strategies.

9.3 Interview Relevance

  • GPIO configuration, debouncing, and cooperative scheduling are common embedded interview topics.

10. Resources

10.1 Essential Reading

  • “Making Embedded Systems” by Elecia White (Ch. 5-6, 10)
  • RP2040 Datasheet (GPIO + SIO + IO_BANK0)

10.2 Video Resources

  • Raspberry Pi Pico official getting started videos

10.3 Tools & Documentation

  • Pico SDK documentation
  • picotool and minicom usage guides

11. Self-Assessment Checklist

11.1 Understanding

  • I can explain why pull-ups prevent floating inputs.
  • I can describe at least two debounce strategies.
  • I can explain why blocking delays are harmful.

11.2 Implementation

  • All functional requirements are met.
  • Button presses are stable and deterministic.
  • Logs are clear and non-blocking.

11.3 Growth

  • I documented at least three bugs and their fixes.
  • I can explain this project in a job interview.

12. Submission / Completion Criteria

Minimum Viable Completion:

  • LED patterns run and buttons switch pattern/speed.
  • Debouncing prevents double triggers.
  • Serial log shows state transitions.

Full Completion:

  • All minimum criteria plus:
  • Stable timing across 3 different patterns.
  • Clean project structure with modules.

Excellence (Going Above & Beyond):

  • Add long-press behavior and configuration storage.
  • Provide a short demo video or oscilloscope capture of timing.

13. Additional Content Rules

13.1 Determinism

Use fixed debounce window (e.g., 20 ms) and fixed pattern timing for tests. Log timestamps in milliseconds to make behavior reproducible.

13.2 Outcome Completeness

  • Success demo provided in §3.7.2.
  • Failure demo provided in §3.7.3.
  • CLI exit codes: Host scripts return 0 on success, 2 on USB open failure, 3 on malformed serial input.

13.3 Cross-Linking

Concept links and references appear in §2.x “Where you’ll apply it” and §10.4.

13.4 No Placeholder Text

All sections are fully specified for this project.