SysTick Timer on Cortex-M: Millisecond Ticks, Delays and Timeouts

Every Cortex-M3, M4 and M7 core contains a small timer called SysTick, and nearly all M0 and M0+ chips include it as well. Because it is part of the core rather than a vendor peripheral, the same code configures it on an NXP LPC1768, a TI TM4C123 or any other Cortex-M device. Operating systems use it as their scheduler tick, and bare-metal programs use it for a millisecond time base. This tutorial explains its four registers, the reload calculation, and builds blocking delays, non-blocking timers and timeouts on top of it.

How SysTick Works

SysTick is a 24-bit down-counter. When enabled, it decrements once per clock. When it reaches zero, it reloads from the reload register on the next clock, sets the COUNTFLAG bit, and if enabled raises the SysTick exception (exception number 15). Because reloading takes one clock, the period is the reload value plus one.

Period (s)  = (RELOAD + 1) / f_clock
RELOAD      = f_clock x Period - 1

f_clock = 100 MHz, Period = 1 ms:
RELOAD = 100000000 x 0.001 - 1 = 99999

Maximum RELOAD = 0xFFFFFF = 16777215
Longest period at 100 MHz = 16777216 / 100000000 = 167.8 ms
Register (CMSIS name)AddressFields
SYST_CSR (SysTick->CTRL)0xE000E010Bit 0 ENABLE, bit 1 TICKINT, bit 2 CLKSOURCE (1 = CPU clock), bit 16 COUNTFLAG
SYST_RVR (SysTick->LOAD)0xE000E01424-bit reload value
SYST_CVR (SysTick->VAL)0xE000E018Current value; any write clears it to 0 and clears COUNTFLAG
SYST_CALIB (SysTick->CALIB)0xE000E01COptional calibration value for a 10 ms tick, vendor-defined

With CLKSOURCE = 1 the counter runs from the processor clock. With CLKSOURCE = 0 it uses an implementation-defined reference clock, which differs between vendors, so most code uses the processor clock. COUNTFLAG reads as 1 if the counter has reached zero since the last read, and reading CTRL clears it.

Setting Up a 1 ms Tick

CMSIS provides SysTick_Config(ticks), which writes ticks - 1 to LOAD, sets the SysTick exception to the lowest priority, clears VAL and enables the counter with interrupt and processor clock. It returns 1 if the value does not fit in 24 bits. The same setup written directly with registers looks like this:

Direct register setup equivalent to SysTick_Config(SystemCoreClock / 1000)
#include "LPC17xx.h"   /* any CMSIS device header works */

void systick_init_1ms(void)
{
    SysTick->CTRL = 0;                              /* stop while configuring */
    SysTick->LOAD = (SystemCoreClock / 1000u) - 1u; /* 1 ms period */
    SysTick->VAL  = 0;                              /* clear current value */
    NVIC_SetPriority(SysTick_IRQn, (1u << __NVIC_PRIO_BITS) - 1u);
    SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk      /* processor clock */
                  | SysTick_CTRL_TICKINT_Msk        /* raise exception */
                  | SysTick_CTRL_ENABLE_Msk;        /* start */
}

Complete Example: Delays and Non-Blocking Timers

The program below runs on an LPC1768 at 100 MHz with LEDs on P1.18 and P1.20 (LED1 and LED2 on the mbed board). LED1 blinks every 500 ms using a non-blocking timer, LED2 flashes briefly every 2 seconds, and a timeout function shows how to wait for a condition without hanging forever.

main.c: SysTick millisecond counter with blocking, non-blocking and timeout helpers
#include "LPC17xx.h"
#include <stdbool.h>

#define LED1 (1u << 18)
#define LED2 (1u << 20)

static volatile uint32_t ms_ticks = 0;

void SysTick_Handler(void)
{
    ms_ticks++;                     /* wraps after about 49.7 days */
}

uint32_t millis(void)
{
    return ms_ticks;                /* 32-bit read is atomic on Cortex-M */
}

/* Blocking delay */
void delay_ms(uint32_t ms)
{
    uint32_t start = millis();
    while ((millis() - start) < ms)
        __WFI();                    /* sleep until the next tick */
}

/* True once 'interval' ms have passed since *last; then restarts */
bool every_ms(uint32_t *last, uint32_t interval)
{
    uint32_t now = millis();
    if ((now - *last) >= interval) {
        *last += interval;          /* keeps a steady rate, no drift */
        return true;
    }
    return false;
}

/* Busy-wait microsecond delay by watching SysTick->VAL (us < 1000) */
void delay_us(uint32_t us)
{
    uint32_t load  = SysTick->LOAD + 1u;
    uint32_t ticks = us * (SystemCoreClock / 1000000u);
    uint32_t prev  = SysTick->VAL;
    uint32_t elapsed = 0;

    while (elapsed < ticks) {
        uint32_t now = SysTick->VAL;
        elapsed += (prev >= now) ? (prev - now) : (prev + load - now);
        prev = now;
    }
}

/* Wait for a pin to go high, give up after timeout_ms */
bool wait_pin_high(volatile uint32_t *port_pin, uint32_t mask, uint32_t timeout_ms)
{
    uint32_t start = millis();
    while (!(*port_pin & mask)) {
        if ((millis() - start) >= timeout_ms)
            return false;           /* timed out */
    }
    return true;
}

int main(void)
{
    uint32_t t_led1 = 0, t_led2 = 0;

    SystemCoreClockUpdate();
    if (SysTick_Config(SystemCoreClock / 1000u) != 0u)
        while (1);                  /* reload value too large */

    LPC_GPIO1->FIODIR |= LED1 | LED2;

    while (1) {
        if (every_ms(&t_led1, 500)) {
            if (LPC_GPIO1->FIOPIN & LED1) LPC_GPIO1->FIOCLR = LED1;
            else                          LPC_GPIO1->FIOSET = LED1;
        }
        if (every_ms(&t_led2, 2000)) {
            LPC_GPIO1->FIOSET = LED2;
            delay_us(900);          /* brief, faint flash */
            LPC_GPIO1->FIOCLR = LED2;
        }
    }
}

Why the Subtraction Trick Works

Comparing millis() >= start + ms fails when the counter wraps from 0xFFFFFFFF to 0. Computing millis() - start with unsigned 32-bit arithmetic always gives the correct elapsed time, even across the wrap, as long as the interval is shorter than about 49 days. Use this form in every time comparison.

The every_ms function adds the interval to the saved time instead of setting it to the current time. If the main loop is late by a few milliseconds once, the next event comes slightly early, so the long-term rate stays exact. This is how you make a clock or data logger that does not drift.

delay_us reads the down-counter directly and accumulates elapsed counts, handling the reload when VAL jumps back to the top. It must be used only for short waits, and the result is slightly longer than requested because of loop overhead and interrupts.

Polling SysTick Without Interrupts

In a bootloader or very early startup code you may not want any interrupts at all. In that case leave TICKINT cleared and poll COUNTFLAG instead. Set LOAD for 1 ms, write 0 to VAL, enable the counter with only CLKSOURCE and ENABLE, and then loop ms times, each time waiting until SysTick->CTRL & SysTick_CTRL_COUNTFLAG_Msk becomes non-zero. Because reading CTRL clears COUNTFLAG, each pass waits for exactly one more reload. This gives accurate delays with no handler, but the CPU cannot do anything else while waiting.

Common Mistakes

  • Using SystemCoreClock before it is updated; the tick ends up a different length than intended.
  • Requesting a period longer than the 24-bit limit, for example 1 second at 100 MHz; SysTick_Config returns 1 and nothing runs.
  • Calling delay_ms inside an interrupt with a priority higher than SysTick; ms_ticks never increments and the delay never ends.
  • Doing heavy work inside SysTick_Handler; keep it short and set flags for the main loop instead.
  • Using SysTick for your own delays while also running an RTOS that owns it; use the RTOS delay functions instead.

Summary

SysTick is a 24-bit down-counter built into the Cortex-M core, controlled by CTRL, LOAD, VAL and CALIB at 0xE000E010. Load it with clock frequency times period minus one, or call SysTick_Config. Increment a volatile counter in SysTick_Handler, compare times with unsigned subtraction, and build blocking delays, non-blocking periodic tasks and timeouts from that single counter.