STM32 Timer Input Capture: Measuring Frequency and Duty Cycle at Register Level

2026-06-01 · Davide Carrese
STM32 · Timers · Firmware Debugging

Measuring an external signal's frequency and duty cycle is one of the most common embedded tasks — RPM sensors, PWM feedback lines, encoder pulses, and protocol timing debug all rely on accurate period capture. The STM32 timer input capture mode lets you timestamp edges with timer-resolution accuracy. This article walks through the register-level configuration, covers the dual-channel method for simultaneous frequency and duty-cycle measurement, and addresses the overflow handling that every production firmware needs.

How input capture works

Every STM32 general-purpose timer (TIM2–TIM5, TIM9–TIM17 on F4; TIM2–TIM8, TIM15–TIM17 on G4) has capture/compare channels. In input capture mode, the channel detects a programmed edge on its input pin (TI1, TI2) and latches the free-running counter value into the capture register (CCR). The core sequence:

  1. The timer counter TIMx_CNT runs freely, typically at the timer clock frequency divided by the PSC prescaler.
  2. An edge on the input pin triggers a capture: TIMx_CCRx receives the current CNT value.
  3. The CCxIF flag in TIMx_SR asserts; an interrupt or DMA request can be generated.
  4. By reading successive captures and computing the difference, you derive the signal period and pulse width.

The input path includes programmable filtering, edge polarity selection, and optional prescaling (every 2nd, 4th, or 8th edge). These are configured in TIMx_CCMR1 (for channels 1, 2) or TIMx_CCMR2 (for channels 3, 4).

Single-channel measurement: frequency only

With one channel configured on the rising edge, each capture gives a timestamp of consecutive rising edges. The difference between two successive captures is the signal period in timer ticks. The frequency is:

f_signal = timer_clock / (PSC + 1) / period_ticks

This works well for stable signals but cannot measure duty cycle. The timer must be configured so that its counter does not overflow between two edges — otherwise the period calculation is garbage.

Register configuration: TIM2 channel 1, rising edge, STM32F401

// TIM2 clock: APB1 timer clock = 84 MHz (if APB1 prescaler != 1, timer clock = 2 * APB1)
// PSC = 83 → counter clock = 84 MHz / 84 = 1 MHz (1 μs per tick)
// ARR = 0xFFFF → 16-bit period, overflow at ~65.5 ms

TIM2->PSC = 83;                      // Prescaler: 84 MHz → 1 MHz
TIM2->ARR = 0xFFFF;                  // Auto-reload max (16-bit)

// CCMR1: CC1 channel configured as input, mapped on TI1, no prescaler, no filter
TIM2->CCMR1 = TIM_CCMR1_CC1S_0;     // CC1S = 01: channel as input, IC1 mapped on TI1

// CCER: CC1 active on rising edge, enable capture
TIM2->CCER = TIM_CCER_CC1E;          // CC1E=1, CC1P=0 (rising edge)

// Enable CC1 interrupt
TIM2->DIER = TIM_DIER_CC1IE;

// Enable counter
TIM2->CR1 = TIM_CR1_CEN;

In the interrupt handler, read TIM2->CCR1 and compute the delta with the previous capture:

uint32_t prev_capture = 0;
uint32_t period_ticks = 0;

void TIM2_IRQHandler(void) {
    if (TIM2->SR & TIM_SR_CC1IF) {
        TIM2->SR = ~TIM_SR_CC1IF;
        uint32_t now = TIM2->CCR1;
        period_ticks = (now >= prev_capture)
                       ? (now - prev_capture)
                       : (0xFFFF - prev_capture + now + 1);
        prev_capture = now;
    }
}

Dual-channel measurement: frequency and duty cycle

To measure both frequency and duty cycle, you need two captures per period: one on the rising edge (timestamp A), one on the falling edge (timestamp B). The high time = B − A, period = next_rising − A. The STM32 timer architecture supports this directly through two channels on the same input:

This is configured via TI1FP1 (rising) and TI1FP2 (falling) in the CCMR1 register. Both channels share the same input pin, but each has its own edge polarity in CCER.

Register configuration: TIM2 channels 1 + 2, same TI1 input

// Same PSC and ARR as above
TIM2->PSC = 83;                      // 1 μs per tick
TIM2->ARR = 0xFFFF;

// CCMR1: IC1 on TI1 (rising), IC2 on TI1 (falling) — TI1FP1 and TI1FP2
TIM2->CCMR1 = TIM_CCMR1_CC1S_0      // CC1S=01: IC1 on TI1
            | TIM_CCMR1_CC2S_0;      // CC2S=01: IC2 on TI1

// CCER: CC1 rising, CC2 falling
TIM2->CCER = TIM_CCER_CC1E           // CC1 rising, enable
           | TIM_CCER_CC2E           // CC2 enable
           | TIM_CCER_CC2P;          // CC2 falling edge

// Enable both capture interrupts
TIM2->DIER = TIM_DIER_CC1IE | TIM_DIER_CC2IE;

TIM2->CR1 = TIM_CR1_CEN;

ISR with duty-cycle computation

typedef struct {
    uint32_t period_ticks;
    uint32_t high_ticks;
    float    frequency_hz;
    float    duty_cycle_pct;
} input_capture_result_t;

static volatile input_capture_result_t result = {0};
static uint32_t rising_a = 0, rising_b = 0;
static uint32_t falling = 0;
static uint8_t edge_seq = 0;  // 0 = wait rising, 1 = wait falling

void TIM2_IRQHandler(void) {
    if (TIM2->SR & TIM_SR_CC1IF) {          // Rising edge
        TIM2->SR = ~TIM_SR_CC1IF;
        rising_b = TIM2->CCR1;
        if (edge_seq == 0) {
            rising_a = rising_b;
            edge_seq = 1;                     // Now wait for falling
        } else {
            // Complete period: rising_a → falling → rising_b
            uint32_t period;
            if (rising_b >= rising_a)
                period = rising_b - rising_a;
            else
                period = (0xFFFF - rising_a) + rising_b + 1;

            uint32_t high;
            if (falling >= rising_a)
                high = falling - rising_a;
            else
                high = (0xFFFF - rising_a) + falling + 1;

            result.period_ticks = period;
            result.high_ticks   = high;
            result.frequency_hz = 1000000.0f / period;  // 1 MHz tick
            result.duty_cycle_pct = (float)high / period * 100.0f;

            rising_a = rising_b;
            edge_seq = 1;                     // Continue tracking
        }
    }
    if (TIM2->SR & TIM_SR_CC2IF) {          // Falling edge
        TIM2->SR = ~TIM_SR_CC2IF;
        falling = TIM2->CCR2;
    }
}

This gives you a running measurement updated once per period. The 16-bit timer limits measurable periods to ~65.5 ms at 1 MHz tick rate. For slower signals, reduce PSC or use a 32-bit timer like TIM2 or TIM5 on F4 series.

Handling timer overflow (16-bit rollover)

The 16-bit general-purpose timers (TIM3, TIM4 on F4) overflow every 65536 ticks. If the signal period exceeds this, the delta calculation breaks. The standard solution is to count overflows in the update interrupt and extend the captured value to 32 bits:

static volatile uint32_t overflow_count = 0;

void TIM3_IRQHandler(void) {
    if (TIM3->SR & TIM_SR_UIF) {            // Update (overflow)
        TIM3->SR = ~TIM_SR_UIF;
        overflow_count++;
    }
}

// In the capture ISR, extend to 32 bits:
uint32_t now_ext = (overflow_count << 16) | TIM3->CCR1;

This technique works up to (2^32 − 1) / timer_clock seconds — at 1 MHz that is about 4295 seconds. For submicrosecond precision, use a 32-bit timer (TIM2 or TIM5) directly, which eliminates the overflow tracking overhead.

Input filtering and noise rejection

Real signals glitch. The STM32 timer input has a programmable digital filter on the TIx input, configured in the ICxF bits of CCMR1/CCMR2. The filter samples the input at f_DTS (usually the timer clock) and asserts a validated edge only after N consecutive samples match:

For a typical PWM feedback line on a motor controller board, I use ICF = 4 or 5 (N = 6–8 at f_DTS) which rejects contact bounce and switching noise without introducing more than a few microseconds of delay.

// CCMR1 with IC1 filter = 6 samples at f_DTS
TIM2->CCMR1 = TIM_CCMR1_CC1S_0
            | TIM_CCMR1_IC1F_2      // IC1F = 100 → N=8 samples
            | TIM_CCMR1_CC2S_0
            | TIM_CCMR1_IC2F_2;     // IC2F = 100 → same filter on IC2

Practical example: measuring a servo PWM signal on STM32F401

A standard RC servo PWM has a 50 Hz base frequency (20 ms period) with a pulse width of 1–2 ms. With PSC = 83 and 84 MHz timer clock, the tick is 1 MHz:

The ISR from the dual-channel example above produces:

Period:   20000 ticks → 50.00 Hz
High:     1500 ticks  → 7.50% duty → 1.50 ms pulse

I verify this on my bench by feeding the PWM from a second timer output back to the capture input. This loopback test catches configuration bugs before connecting real hardware.

Common pitfalls

Wrong timer clock frequency

The timer clock is not always the same as SYSCLK. On STM32F4, if APB1 prescaler is not 1, timers on APB1 (TIM2–TIM7) run at 2 × APB1 clock. A common bug: assuming TIM2 runs at 42 MHz when APB1 is 42 MHz with prescaler > 1 — it actually runs at 84 MHz. Always read RCC->CFGR and compute the actual timer clock before setting PSC.

Channel remapping (TI1 vs TI2)

By default, channel 1 captures from TI1 (TIMx_CH1 pin) and channel 2 from TI2. The dual-channel trick (IC2 on TI1) works through the TI1FP2 routing, but only if CCMR1 CC2S = 01. If you set CC2S = 10 or 11, channel 2 switches to TI2 or TRC (internal trigger). This is the most common register-level mistake in dual input capture.

16-bit overflow on slow signals

A 1 Hz signal at 1 MHz tick rate needs 1000000 ticks — impossible with a 16-bit timer without overflow tracking. Either increase PSC to lower the tick rate (trading resolution for range) or use a 32-bit timer. On F4, TIM2 and TIM5 are 32-bit. On G4, only TIM2 is 32-bit (TIM5 is gone).

Missing edge sequence

The dual-channel ISR above assumes a rising edge arrives first. If the signal starts low, the first edge is rising — fine. If the board is connected mid-cycle and the signal is high, the first edge seen is falling, and the state machine computes garbage. Add a startup synchronisation: ignore the first 2–3 captures, or initialise edge_seq after confirming both channels have fired at least once.

Practical checklist

How I would approach this on a client project

On a production firmware project, I never embed the input capture logic directly in the timer ISR with global variables. Instead I write a dedicated measurement module with a state machine, a result queue, and a configuration structure:

typedef struct {
    TIM_TypeDef *tim;
    uint8_t      channel;      // 1 or 2 for single, 3 for dual
    uint32_t     psc;
    uint16_t     arr;
    uint8_t      ic_filter;    // ICF value
    uint8_t      edge;         // RISING, FALLING, or BOTH (dual)
} input_capture_config_t;

typedef struct {
    float frequency_hz;
    float duty_cycle_pct;
    uint32_t timestamp_ms;
    bool     valid;
} measurement_t;

int8_t ic_start(const input_capture_config_t *cfg);
int8_t ic_read(measurement_t *out, uint32_t timeout_ms);

The module uses a ring buffer of measurements so the application can poll at its own pace without losing data. The ISR is minimal — it writes captures to the ring and advances the edge state — while the business logic (frequency and duty computation) runs in the application context. This separation makes the module reusable across projects and testable with synthetic captures.

The configuration structure lives with the board's bsp_timers.h file. When the client changes the signal source (different encoder, different PWM frequency), they edit the configuration table, not the ISR logic — no regressions, no copy-paste mistakes.

Sources and further reading

Comments

Have comments? Send me an email.