Skip to content

Stopwatch with LED Indicator — A Build Roadmap

A standalone assignment that pulls together Experiment 5 (GPIO interrupts), Experiment 6 (GPTM timing), and Experiment 7 (character LCD) into one build: a stopwatch with start/stop/lap control, its elapsed time shown on an LCD instead of a serial terminal, with an LED that shows at a glance whether it's running.

Target device: TM4C123GH6PM.

Introduction

What Is This Build?

A hardware timer counts in fixed increments — here, hundredths of a second — for as long as the stopwatch is "running." Two buttons control it, each handled by a GPIO interrupt rather than being polled in the main loop: one toggles running/paused, the other records a lap while running or resets the clock while paused. An LED blinks while the stopwatch is running and goes dark while paused, so the state is readable without even looking at the display. The elapsed time and most recent lap are shown as text on a 16×2 LCD.

None of these pieces are new on their own — the timer pattern is the same one from Experiment 6, the LCD driver is the same one from Experiment 7. What's new is making all of them coexist correctly: the timer has to keep perfectly accurate time whether or not anyone touches a button, a button's interrupt handler has to mean something different depending on whether the clock is currently running or paused, and the display has to stay readable without needing every single tick.

Hardware Setup

SignalPinDirection
Pause / Resume button (onboard SW1)PF4Input, pull-up
Lap / Reset button (onboard SW2)PF0Input, pull-up
Running indicator (onboard LED)PF1Output
LCD (RS/E/D4–D7)PB0, PB2, PB4–PB7Output

No breadboard buttons needed — this reuses the LaunchPad's own SW1/SW2. PF0 is the one pin on this board that ships commit-protected: unlock it the same way Experiment 5 did, GPIOF->LOCK = 0x4C4F434B; then GPIOF->CR = 0x01;, before touching its DIR/DEN/PUR. Wire the LCD exactly as in Experiment 7 (4-bit mode, RW tied to ground for write-only).

Behavior

InputAction
Pause/Resume buttonToggles running/paused
Lap button, while runningRecords the current elapsed time as a lap, clock keeps going
Lap button, while pausedResets both the elapsed time and the lap count to zero

The onboard LED blinks at roughly 1 Hz while running, and stays off while paused — a runner glancing at the board should be able to tell which state it's in without reading the display at all.

Build Steps

You get two files to fill in — ledstopwatch.c/ledstopwatch.h hold the timer, button, and display logic, main.c just calls into them (plus the LCD driver from Experiment 7, reused as-is). Every function is stubbed out with // TODO comments and builds cleanly as-is; no logic is implemented yet.

c
#include "TM4C123.h"
#include "lcd.h"
#include "ledstopwatch.h"

int main(void)
{
    LCD_Init();
    Stopwatch_GPIO_Init();
    Timer0_Init();
    GPIOF_ButtonInterrupt_Init();
    SysTick_Config(SystemCoreClock / 1000);   // ~1 ms tick

    LCD_Clear();
    LCD_SetCursor(0, 0);
    LCD_Print("Time:");

    while (1) {
        Stopwatch_Update();
        Stopwatch_Display();
    }
}
c
#include "ledstopwatch.h"
#include "lcd.h"

volatile uint32_t global_ms  = 0;
volatile uint32_t elapsed_cs = 0;
volatile uint8_t  running    = 0;

volatile uint8_t  pause_requested = 0;
volatile uint8_t  lap_requested   = 0;

uint32_t laps[MAX_LAPS];
uint8_t  lap_count = 0;

void Stopwatch_GPIO_Init(void)
{
    // TODO: enable the clock for GPIOF
    // TODO: unlock PF0 -- GPIOF->LOCK = 0x4C4F434B, then GPIOF->CR |= 0x01
    // TODO: PF0, PF4 as inputs -- DIR cleared, DEN, PUR (pull-up)
    // TODO: PF1 as output -- DIR, DEN
}

void SysTick_Handler(void)
{
    // TODO: increment global_ms
}

void Timer0_Init(void)
{
    // TODO: enable the Timer0 clock
    // TODO: CFG = 0x0 (32-bit mode), TAMR = periodic mode
    // TODO: TAILR = TICK_RELOAD - 1
    // TODO: clear + enable the timeout interrupt, enable NVIC IRQ for TIMER0A
}

void TIMER0A_Handler(void)
{
    // TODO: clear the timeout interrupt
    // TODO: if running, advance elapsed_cs by one
    // TODO: if running, toggle RUN_LED every BLINK_TICKS ticks; hold it off while paused
}

void GPIOF_ButtonInterrupt_Init(void)
{
    // TODO -- Step 3: configure PF0 and PF4 for a falling-edge interrupt + NVIC (GPIOF = IRQ 30)
}

void GPIOF_Handler(void)
{
    // TODO -- Step 3: for whichever of PF0/PF4 triggered, clear its interrupt
    //         flag, and -- if at least DEBOUNCE_MS has passed since that
    //         button's own last accepted press -- set pause_requested (PF4)
    //         or lap_requested (PF0)
}

void Stopwatch_Update(void)
{
    // TODO -- Step 3: if pause_requested, clear it and toggle `running`
    // TODO -- Step 3: if lap_requested, clear it, and -- while running,
    //         record elapsed_cs into laps[] (if there's room) and bump
    //         lap_count; while paused, reset elapsed_cs and lap_count to
    //         0 instead
}

void FormatTime(uint32_t cs, char *out)
{
    // TODO -- Step 4: write "MM:SS.cc" into out, null-terminated --
    //         integer math only, no sprintf, no floats
}

void Stopwatch_Display(void)
{
    // TODO -- Step 4: roughly 10 times a second, format elapsed_cs and
    //         write it to a fixed spot on the LCD's first line -- not on
    //         every call, and not by clearing the whole screen
}
h
#ifndef LEDSTOPWATCH_H
#define LEDSTOPWATCH_H

#include "TM4C123.h"

// ---- Buttons: onboard SW1/SW2 (interrupt-driven) ----
#define BTN_PAUSE    0x10                  // PF4 (SW1) -- pause/resume
#define BTN_LAP      0x01                  // PF0 (SW2) -- lap while running, reset while paused -- commit-protected, see below

// ---- Running indicator ----
#define RUN_LED      0x02                  // PF1

#define SYS_CLOCK    50000000UL            // 50 MHz system clock
#define TICK_HZ      100UL                  // Timer0A fires 100x/sec
#define TICK_RELOAD  (SYS_CLOCK / TICK_HZ)  // -> 10 ms per tick = 1 centisecond
#define BLINK_TICKS  50UL                   // toggle RUN_LED every 50 ticks (0.5 s) while running

#define MAX_LAPS     8
#define DEBOUNCE_MS  25

extern volatile uint32_t global_ms;       // free-running millisecond counter (SysTick) -- independent of `running`
extern volatile uint32_t elapsed_cs;      // centiseconds elapsed, advances only while running (Timer0A)
extern volatile uint8_t  running;         // 0 = paused, 1 = running

extern volatile uint8_t  pause_requested; // set by GPIOF_Handler, cleared once serviced
extern volatile uint8_t  lap_requested;   // set by GPIOF_Handler, cleared once serviced

extern uint32_t laps[MAX_LAPS];           // recorded lap times, in centiseconds
extern uint8_t  lap_count;                // how many laps recorded so far

void Stopwatch_GPIO_Init(void);           // Step 1 -- clocks + DIR/DEN/PUR for PF0/PF1/PF4 (unlock PF0 first)
void SysTick_Handler(void);               // Step 2 -- advance global_ms

void Timer0_Init(void);                   // Step 2 -- TIMER0A, 32-bit periodic mode, TICK_RELOAD, NVIC IRQ 19
void TIMER0A_Handler(void);               // Step 2 -- clear the interrupt, advance elapsed_cs, blink RUN_LED while running

void GPIOF_ButtonInterrupt_Init(void);    // Step 3 -- edge interrupt config for PF0/PF4 + NVIC (GPIOF = IRQ 30)
void GPIOF_Handler(void);                 // Step 3 -- debounce (using global_ms), set pause_requested / lap_requested

void Stopwatch_Update(void);              // Step 3 -- call every main-loop iteration, acts on the request flags

void FormatTime(uint32_t cs, char *out);  // Step 4 -- cs -> "MM:SS.cc" -- integer math only, no sprintf/float
void Stopwatch_Display(void);             // Step 4 -- call every main-loop iteration, updates the LCD at a sane rate

#endif // LEDSTOPWATCH_H
c
#include "lcd.h"

#define CYCLES_PER_US   (SystemCoreClock / 1000000u)



//====================[ SysTick Delay Functions ]====================
void SysTick_Init(void)
{
    SysTick->CTRL = 0;
    SysTick->LOAD = CYCLES_PER_US - 1;  // 1us delay at 50MHz
    SysTick->VAL = 0;
    SysTick->CTRL = 0x5;     // Enable with system clock
}

void delay_us(int us)
{
    SysTick->LOAD = (CYCLES_PER_US * us) - 1;
    SysTick->VAL = 0;
    SysTick->CTRL = 0x5; // Enable with system clock
    while ((SysTick->CTRL & 0x10000) == 0);
    SysTick->CTRL = 0;
}

void delay_ms(int ms)
{
    while (ms--)
        delay_us(1000);
}

//====================[ LCD Helper Functions ]====================
void LCD_EnablePulse(void)
{
    delay_us(1);
    GPIOB->DATA |= EN;
    delay_us(1);
    GPIOB->DATA &= ~EN;
    delay_us(1);
}

void LCD_SendNibble(unsigned char nibble)
{
    // Send nibble to PB4-PB7
    GPIOB->DATA = (GPIOB->DATA & ~DATA_MASK) | ((nibble << 4) & DATA_MASK);
    LCD_EnablePulse();
}

//====================[ LCD Initialization ]====================
void LCD_Init(void)
{
    // Enable clock to PORTB
    SYSCTL->RCGCGPIO |= (1 << 1);
    while ((SYSCTL->PRGPIO & (1 << 1)) == 0)
        ;

    // Configure PB0 (RS), PB1 (RW), PB2 (EN), PB4-PB7 (data) as output
    GPIOB->DIR |= RS | RW | EN | DATA_MASK;
    GPIOB->DEN |= RS | RW | EN | DATA_MASK;
    GPIOB->DATA &= ~(RS | RW | EN | DATA_MASK);  // Clear all

    SysTick_Init();

    delay_ms(50);  // Wait for LCD to power up

    // Initialization sequence (8-bit interface mode to start)
    LCD_SendNibble(0x03);
    delay_ms(5);

    LCD_SendNibble(0x03);
    delay_us(150);

    LCD_SendNibble(0x03);
    delay_us(150);

    LCD_SendNibble(0x02);  // Set 4-bit mode
    delay_us(150);

    // Now in 4-bit mode: use full commands
    LCD_Command(0x28); // Function set: 4-bit, 2 lines, 5x8 dots
    LCD_Command(0x0C); // Display ON, Cursor OFF
    LCD_Command(0x06); // Entry mode: increment cursor
    LCD_Command(0x01); // Clear display
    delay_ms(2);
}

//====================[ LCD Command/Data API ]====================
void LCD_Command(unsigned char command)
{
    GPIOB->DATA &= ~RS; // RS = 0 for command
    delay_us(1);
    LCD_SendNibble(command >> 4);     // Upper nibble
    LCD_SendNibble(command & 0x0F);   // Lower nibble
    delay_ms(2);
}

void LCD_Data(unsigned char data)
{
    GPIOB->DATA |= RS; // RS = 1 for data
    delay_us(1);
    LCD_SendNibble(data >> 4);
    LCD_SendNibble(data & 0x0F);
    delay_ms(1);
}

void LCD_Clear(void)
{
    LCD_Command(0x01);
    delay_ms(2);
}

void LCD_SetCursor(unsigned char row, unsigned char col)
{
    unsigned char address = (row == 0) ? 0x80 + col : 0xC0 + col;
    LCD_Command(address);
    delay_ms(1);
}

void LCD_Print(char *str)
{
    while (*str)
    {
        LCD_Data(*str++);
    }
}
h
#ifndef LCD_H
#define LCD_H

#include "TM4C123.h"

// LCD pin definitions (connected to PORTB)
#define RS         (1 << 0)  // PB0
#define RW         (1 << 1)  // PB1
#define EN         (1 << 2)  // PB2
#define DATA_MASK  0xF0      // PB4-PB7

// Function prototypes
void LCD_Init(void);
void LCD_Command(unsigned char cmd);
void LCD_Data(unsigned char data);
void LCD_Clear(void);
void LCD_SetCursor(unsigned char row, unsigned char col);
void LCD_Print(char *str);
void delay_us(int us);
void delay_ms(int ms);

#endif

Fill it in through the four steps below, in order — don't start Step N+1 until Step N's check actually passes.

Step 1: Configure the Ports

Fill in Stopwatch_GPIO_Init: clock for GPIOF, the PF0 unlock sequence, DIR/DEN/PUR for PF0 and PF4 (inputs), and DIR/DEN for PF1 (output).

c
void Stopwatch_GPIO_Init(void)
{
    // TODO: enable the clock for GPIOF
    // TODO: unlock PF0 -- GPIOF->LOCK = 0x4C4F434B, then GPIOF->CR |= 0x01
    // TODO: PF0, PF4 as inputs -- DIR cleared, DEN, PUR (pull-up)
    // TODO: PF1 as output -- DIR, DEN
}

Verify on the board/debugger: breakpoint after Stopwatch_GPIO_Init() returns; watch GPIOF->DIR/DEN/PUR and confirm they match the comments. Hand-edit GPIOF->DATA bit 1 in the Watch window and confirm the LED lights; hold SW1 and SW2 in turn and confirm the matching GPIOF->DATA bit reads LOW while held.

Step 2: Bring Up Both Timers

Fill in SysTick_Handler (advance global_ms), Timer0_Init (Timer0A, 32-bit periodic mode, TICK_RELOAD for a 100 Hz tick), and TIMER0A_Handler (clear the interrupt, advance elapsed_cs and blink the LED while running).

c
void SysTick_Handler(void)
{
    // TODO: increment global_ms
}

void Timer0_Init(void)
{
    // TODO: enable the Timer0 clock
    // TODO: CFG = 0x0 (32-bit mode), TAMR = periodic mode
    // TODO: TAILR = TICK_RELOAD - 1
    // TODO: clear + enable the timeout interrupt, enable NVIC IRQ for TIMER0A
}

void TIMER0A_Handler(void)
{
    // TODO: clear the timeout interrupt
    // TODO: if running, advance elapsed_cs by one
    // TODO: if running, toggle RUN_LED every BLINK_TICKS ticks; hold it off while paused
}

global_ms and elapsed_cs look similar but serve different purposes: global_ms always advances, running or not, and Step 3's button interrupts lean on that. elapsed_cs is the actual stopwatch reading — it advances only while running, which is exactly why it isn't suitable for anything that also needs to work while paused.

Verify in isolation, before Step 3 wires in the buttons: confirm global_ms climbs steadily at roughly 1000/sec regardless of anything else. Then temporarily force running = 1; right after Timer0_Init() in main.c, watch elapsed_cs in the Watch window, and confirm it climbs by exactly 100 per second (time it against a clock) while the LED blinks at roughly 1 Hz. Delete the forced line once this checks out.

Step 3: Handle the Buttons via Interrupt

Fill in GPIOF_ButtonInterrupt_Init (falling-edge interrupt on PF0 and PF4 + NVIC), GPIOF_Handler (debounce each button independently using global_ms, then set a request flag), and Stopwatch_Update (act on the request flags per the Behavior table above).

c
void GPIOF_ButtonInterrupt_Init(void)
{
    // TODO -- Step 3: configure PF0 and PF4 for a falling-edge interrupt + NVIC (GPIOF = IRQ 30)
}

void GPIOF_Handler(void)
{
    // TODO -- Step 3: for whichever of PF0/PF4 triggered, clear its interrupt
    //         flag, and -- if at least DEBOUNCE_MS has passed since that
    //         button's own last accepted press -- set pause_requested (PF4)
    //         or lap_requested (PF0)
}

void Stopwatch_Update(void)
{
    // TODO -- Step 3: if pause_requested, clear it and toggle `running`
    // TODO -- Step 3: if lap_requested, clear it, and -- while running,
    //         record elapsed_cs into laps[] (if there's room) and bump
    //         lap_count; while paused, reset elapsed_cs and lap_count to
    //         0 instead
}

Something to think about: GPIOF_Handler fires the instant a button is pressed, but Stopwatch_Update only runs on the next pass through the main loop. Why does that gap matter here, and why is it safer for the handler to just set a flag than to record the lap or toggle running directly from inside the interrupt?

Verify: press Pause/Resume and confirm pause_requested sets immediately, then running flips once Stopwatch_Update runs, with elapsed_cs visibly freezing and resuming right at the press (not a tick later) and the LED starting/stopping its blink to match. While running, press Lap a few times and confirm laps[]/lap_count fill in correctly without disturbing elapsed_cs. Pause, then press Lap again, and confirm elapsed_cs and lap_count both reset to 0. Confirm GPIOF->MIS clears immediately after each press and the program never hangs on one.

Step 4: Display at a Sane Rate

Fill in FormatTime (integer math only, no sprintf/floats) and Stopwatch_Display (refresh the LCD roughly 10 times a second, not on every tick).

c
void FormatTime(uint32_t cs, char *out)
{
    // TODO -- Step 4: write "MM:SS.cc" into out, null-terminated --
    //         integer math only, no sprintf, no floats
}

void Stopwatch_Display(void)
{
    // TODO -- Step 4: roughly 10 times a second, format elapsed_cs and
    //         write it to a fixed spot on the LCD's first line -- not on
    //         every call, and not by clearing the whole screen
}

Don't clear and reprint the whole screen every update

Calling LCD_Clear() on every refresh will visibly flash the display. Move the cursor straight to the numeric field with LCD_SetCursor and overwrite just that part of the line — same idea as recording a lap on line 2 without disturbing line 1.

Verify: confirm the time on the LCD updates smoothly, without flashing, and stays in sync with elapsed_cs in the Watch window. Record a lap and confirm it shows up immediately on the second line rather than waiting for the next scheduled refresh.

Extras (Optional)

Lap overflow

Decide — and justify — what happens on a 9th lap when laps[] only holds MAX_LAPS (8).

Long-press reset

Add a second way to reset — holding Lap for 2+ seconds while paused — so the two behaviors (record vs. reset) don't rely purely on running/paused state.

Make the LED blink faster each time a lap is recorded, as a rough visual count of how many laps have been taken this run.

Verification Checklist

  1. Ports — buttons and LED are correctly wired and configured (Step 1).
  2. Timersglobal_ms climbs steadily regardless of running; elapsed_cs advances at a steady 100/sec while forced on, and the LED blinks at roughly 1 Hz (Step 2).
  3. Buttons — each button's interrupt reliably sets its request flag exactly once per press, with no hangs; Pause/Resume toggles running immediately, Lap records correctly while running and resets correctly while paused (Step 3).
  4. Display — the LCD updates smoothly at a fixed cursor position with no flashing, and a recorded lap shows up immediately (Step 4).
  5. If you did the Extras — confirm the behavior you chose works as intended.