armhub.dev
Cortex-M · 24 pages
Cortex-M

SysTick & timing

The universal Cortex-M 24-bit downcounter — how it works, how to use it for an RTOS tick or a delay, and what its limits are.

5 min read · 1,083 wordsupdated · May 12, 2026

Every Cortex-M has a SysTick — a 24-bit downcounter mapped into the Private Peripheral Bus. It's there specifically so OS ports can have a portable tick source that exists on every Cortex-M, regardless of vendor. Even the smallest M0 has one.

SysTick — quick reference
Architectural status
Mandatory on Cortex-M3 and later; optional on Cortex-M0/M0+/M1 but in practice present on every shipping part
Register block
4 registers at 0xE000_E010: SYST_CSR, SYST_RVR, SYST_CVR, SYST_CALIB
Counter width
24 bits, down-counting from RVR to 0, then auto-reload to RVR
Maximum reload
0x00FF_FFFF (16,777,215) — sets the longest single-period a SysTick can produce
Clock sources
Processor clock (CLKSOURCE=1) or external reference (CLKSOURCE=0, typically CPU/8, vendor-specific)
Exception number
15 (SysTick), configurable priority via SHPR3.PRI_15
TICKINT
When set, the counter reaching zero raises the SysTick exception. When clear, the counter still wraps; software polls COUNTFLAG
TrustZone behaviour
On ARMv8-M, SysTick is banked: SysTick_S and SysTick_NS at the same architectural address are independent counters
Calibration
SYST_CALIB.TENMS contains the reload value for a 10 ms tick at the reference clock. NOREF and SKEW bits report hardware limitations

The registers

Four registers at 0xE000_E010:

OffsetNamePurpose
0x00SYST_CSRControl and Status — enable, clksource
0x04SYST_RVRReload Value (the period - 1)
0x08SYST_CVRCurrent Value (counting down)
0x0CSYST_CALIBCalibration — vendor-defined
SYST_CSR · 0xE000_E010
32-bit · reset 0x0000_0000
28
24
20
16
12
8
4
0
b24
b16
b8
b0
BitsNameAccResetDescription
31:17reservedRES—Reserved.
16COUNTFLAGR0Reads 1 if the counter reached 0 since last read. Reading clears it.
15:3reservedRES—Reserved.
2CLKSOURCERW00 = external reference (vendor wires this — often CPU/8); 1 = processor clock.
1TICKINTRW01 = SysTick exception fires when counter reaches 0.
0ENABLERW0Counter enable.
SysTick Control and Status Register.

A 1 ms tick

The standard incantation:

#include "cmsis_gcc.h"
 
void SystemInit_Tick_1ms(void) {
    SysTick->LOAD = (SystemCoreClock / 1000) - 1;   // 1 ms reload
    SysTick->VAL  = 0;                               // clear current
    SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk      // processor clock
                  | SysTick_CTRL_TICKINT_Msk        // enable IRQ
                  | SysTick_CTRL_ENABLE_Msk;        // start counting
 
    NVIC_SetPriority(SysTick_IRQn, 0xF);            // lowest priority
}
 
volatile uint32_t ticks;
 
void SysTick_Handler(void) {
    ticks++;
}

SystemCoreClock is a CMSIS-provided variable set during clock tree initialisation. At 168 MHz, (168000000 / 1000) - 1 = 167999, well below the 24-bit limit of 16777215.

A precise microsecond delay (no interrupt)

For short blocking delays without committing to an ISR:

void delay_us(uint32_t us) {
    uint32_t start = SysTick->VAL;
    uint32_t reload = SysTick->LOAD + 1;
    uint32_t ticks_per_us = SystemCoreClock / 1000000;
    uint32_t total = us * ticks_per_us;
 
    uint32_t elapsed = 0;
    uint32_t prev = start;
    while (elapsed < total) {
        uint32_t now = SysTick->VAL;
        uint32_t delta = (prev >= now) ? (prev - now) : (prev + reload - now);
        elapsed += delta;
        prev = now;
    }
}

The downcounter direction means current value decreases. The wrap handling (prev + reload - now) covers the case where the counter rolls over while we're polling.

For sub-microsecond resolution at high core clocks, the DWT cycle counter is better — see below.

The DWT cycle counter

The Data Watchpoint and Trace unit at 0xE000_1000 includes a 32-bit free-running cycle counter, useful for precise profiling:

void dwt_enable(void) {
    CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;  // enable DWT
    DWT->CYCCNT = 0;
    DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;              // start counting
}
 
uint32_t start = DWT->CYCCNT;
// ... do work ...
uint32_t cycles = DWT->CYCCNT - start;

32-bit wide, increments every CPU cycle. At 168 MHz it wraps every ~25 seconds. For longer measurements, sample the counter periodically and accumulate.

TrustZone considerations

On ARMv8-M, SysTick is duplicated: there's a Secure SysTick and a Non-secure SysTick at the same address range, each visible only from their respective state. RTOSes in both partitions can have independent ticks.

Calibration register — what SYST_CALIB actually tells you

SYST_CALIB is the register most engineers ignore until they need it. It encodes vendor-supplied information about the hardware:

  • TENMS (bits 23:0) — the reload value that yields a 10 ms tick when SysTick is clocked from the reference clock (CLKSOURCE = 0). If your part runs the reference at CPU/8 and you want a 10 ms tick from the processor clock, multiply this by 8.
  • SKEW (bit 30) — set to 1 when the 10 ms calibration is approximate (vendor couldn't trim it to exactly 10 ms).
  • NOREF (bit 31) — set to 1 when there is no external reference clock; you must use the processor clock.
/* Inspect calibration at boot to pick the right tick source */
uint32_t calib = SysTick->CALIB;
if (calib & SysTick_CALIB_NOREF_Msk) {
    /* No reference clock — must use processor clock */
    use_external_ref = false;
} else if (calib & SysTick_CALIB_SKEW_Msk) {
    /* Calibration is approximate; use the SystemCoreClock-derived value */
    reload = (SystemCoreClock / 1000) - 1;
} else {
    /* Vendor-provided 10 ms calibration is exact */
    reload = (calib & SysTick_CALIB_TENMS_Msk) / 10 - 1;  /* 1 ms */
}

Most embedded codebases ignore SYST_CALIB and compute the reload value directly from SystemCoreClock / freq. That's perfectly fine on chips where SystemCoreClock is accurate (which means after every PLL update has been reflected through SystemCoreClockUpdate()). The calibration value is useful mainly when you don't trust the PLL accuracy or you want a portable tick that doesn't depend on the clock-tree code being correct.

Jitter sources — why SysTick is "approximate" in practice

SysTick is architecturally precise: the counter decrements exactly once per clock cycle. The jitter that engineers observe in practice has four causes:

  1. Interrupt-entry latency. When the counter reaches zero, the NVIC pends the SysTick exception, but it doesn't dispatch immediately if a higher-priority handler is running. SysTick is conventionally placed at the lowest priority precisely because every other IRQ can delay it; this is the correct design — you want SysTick to be late rather than have it delay something time-critical — but it does mean the ISR runs late.
  2. Memory wait states. On parts with flash wait states (most STM32 above 30 MHz), the first instruction fetch in the SysTick handler may cost several cycles. Place the handler in RAM with __attribute__((section(".ramfunc"))) if this matters.
  3. Critical-section masking. __disable_irq or BASEPRI raised above SysTick's priority will defer the handler. The maximum critical section duration in your code is the floor on SysTick jitter.
  4. Cache effects (Cortex-M7 only). The first execution of the SysTick handler after a context switch may miss in the I-cache. After warm-up the cost amortises.

If you need sub-microsecond timing precision, SysTick is not the right tool. Use the DWT cycle counter (M3 and later) or a hardware timer with output-compare configured to assert a pin.

RTOS tick — what FreeRTOS and Zephyr actually do

Both FreeRTOS and Zephyr use SysTick as the default kernel tick on Cortex-M.

FreeRTOS configures SysTick at configTICK_RATE_HZ (usually 1000 Hz). The handler is xPortSysTickHandler (or SysTick_Handler via a macro alias). On every tick the kernel decrements task delay counters, checks for ready-to-run tasks of higher priority, and triggers a context switch through PendSV if one is needed. SysTick runs at the lowest priority; PendSV runs at the lowest priority too; the actual context switch happens in PendSV, not in SysTick, so SysTick stays short. This is the canonical "SysTick + PendSV" pattern documented in every FreeRTOS port.

Zephyr does the same thing structurally but with more configuration options. CONFIG_SYS_CLOCK_TICKS_PER_SEC sets the rate (default 100, but commonly 1000 or 10000); CONFIG_TICKLESS_KERNEL=y enables tickless operation — the kernel reprograms SysTick on demand to the next interesting timeout instead of running it at a fixed rate, saving power. Tickless mode is the default on Zephyr and is one of the reasons Zephyr's idle power on a Cortex-M33 is competitive with bare-metal implementations.

RTOSTick sourceTick rate defaultTickless mode
FreeRTOSSysTick1000 HzOptional (configUSE_TICKLESS_IDLE)
ZephyrSysTick100 Hz (10000 Hz on lab boards)Default-on
ThreadXSysTick100 Hz (configurable)Optional
RTX5 / CMSIS-RTOS2SysTick1000 HzConfigurable
ChibiOSSysTick1000 HzOptional

Alternatives when SysTick is not enough

SysTick is excellent for a single kernel tick. It is not enough when you need:

  • Multiple independent periodic timers. Use vendor timer peripherals (TIM, GPT, BCTimer). SysTick offers exactly one period.
  • Sub-microsecond profiling. Use DWT cycle counter on M3+, or a free-running hardware timer.
  • Tick rates > ~10 kHz. SysTick at 10 kHz on a 100 MHz part means an ISR every 10000 cycles — viable but expensive. Vendor timers can autonomous-trigger DMA without per-tick CPU cost.
  • Wake from deep sleep. SysTick stops in most deep-sleep modes; you need an RTC or always-on timer that survives the sleep state.
  • Coordinated multi-domain timing. On TrustZone parts the Secure and Non-secure SysTicks are independent; if you need a single source of truth across both, use a shared peripheral instead.

Cross-references