Lab 2.2 — Timer Interrupt Jitter

Course 2 syllabus · Module 2 · Prev: « Lab 2.1 · Next: Lab 2.3 »

Goal

Configure a hardware timer (TIM) on the NUCLEO-L476RG to fire a periodic update interrupt at a known rate (target 10 kHz), toggle a GPIO pin inside the interrupt service routine, and measure the actual period and its jitter on the Saleae. Where Lab 2.1 showed that a software loop runs at “whatever the compiler gives you,” this lab shows the opposite: a hardware timer fires with clock-accurate period, but the response to it — the moment the ISR actually flips the pin — carries latency and jitter from interrupt entry, competing interrupts, and priority. Quantifying that jitter is the core competency behind every real-time sampling system: an ADC that samples on a jittery clock smears its spectrum, so a firmware DSP engineer must be able to prove the sample clock is clean.

Equipment & parts

  • STM32 NUCLEO-L476RG + USB cable.
  • Saleae Logic 8 + Logic 2 (with timing markers / measurement statistics).
  • A spare GPIO pin to toggle from the ISR (e.g. PB5 or PA8), plus optionally a second pin toggled from main() to create contention.
  • Host with STM32CubeMX + CLion (per the project workflow).

Wiring & bench setup

The signal chain: TIM2 fires the update interrupt, the ISR flips PB5, and the Saleae timestamps every edge for Logic 2’s period statistics.

flowchart LR
  MCU["NUCLEO-L476RG<br/>TIM2 ISR toggles PB5 = D4"]
  SAL["Saleae Logic 8<br/>CH0 + GND lead"]
  SW["Logic 2 on the Mac<br/>period statistics"]
  MCU -- "CH0 → PB5<br/>GND → Nucleo GND" --> SAL
  SAL -- "USB" --> SW
  MCU -. "CH1 → optional 2nd pin" .-> SAL

flowchart LR
  MCU["NUCLEO-L476RG<br/>TIM2 ISR toggles PB5 = D4"]
  SAL["Saleae Logic 8<br/>CH0 + GND lead"]
  SW["Logic 2 on the Mac<br/>period statistics"]
  MCU -- "CH0 → PB5<br/>GND → Nucleo GND" --> SAL
  SAL -- "USB" --> SW
  MCU -. "CH1 → optional 2nd pin" .-> SAL

Pin map (every lead; Nucleo pins by Arduino-header label):

From To Pin/jack
Saleae CH0 flying lead ISR toggle pin PB5 D4
Saleae CH1 (optional, Part D step 13) second marker pin, e.g. PA8 D7
Saleae GND lead (land it first) Nucleo ground GND (Arduino power header)

No breadboard needed — clip straight onto the header pins as in Lab 2.1. Part D’s contention source is the on-board user button B1 (PC13): nothing to wire.

Safety & don’t-break-it

  • Same 3.3 V logic rules as Lab 2.1: share ground first, set the Saleae logic threshold to 3.3 V, keep GPIO pins unloaded.
  • A runaway ISR can lock the board. If you enable the timer interrupt but forget to clear the update flag (HAL does this for you inside HAL_TIM_IRQHandler; bare-metal code must clear SR &= ~TIM_SR_UIF), the ISR re-fires forever and main() never runs. If the board appears frozen or the debugger won’t halt cleanly, this is the usual cause. Keep the ST-LINK connected so you can always reflash.
  • Do not do slow work inside the ISR. A printf, a HAL_Delay, or a blocking bus transaction inside a 10 kHz ISR (100 µs budget) will overrun — the next interrupt arrives before the last one finishes. Keep the ISR to a single pin write for this lab.
  • Don’t set the NVIC priority of the timer lower (numerically higher) than something that then starves it, unless you are deliberately creating the contention experiment in Part D — and even then, watch for a lock-up.

Project & environment setup

Firmware — reuse the Module 2 project (firmware/m2-timing/, created in Lab 2.1). Add the timer and toggle pin in the .ioc:

CubeMX page Setting
Timers → TIM2 Clock Source = Internal Clock; pick PSC/ARR from \(f_{\text{update}} = f_{TIM}/\big((\text{PSC}+1)(\text{ARR}+1)\big)\) — with the 80 MHz timer clock, PSC = 79, ARR = 99 gives the 10 kHz target
TIM2 → NVIC Settings enable TIM2 global interrupt; leave the priority at default (Part D changes it deliberately)
Pinout view → PB5 (D4) GPIO_Output; Push-pull, output level Low, Maximum output speed Very High
System Core → NVIC (Part D, optional) enable the EXTI line[15:10] interrupt if you use B1/PC13 as the competing interrupt
Clock Configuration confirm 80 MHz HCLK / APB1 timer clock per the setup essentials — the PSC/ARR values above assume it
Middleware → FREERTOS (RTOS variant only) CMSIS_V2 + a spare-timer HAL timebase, per the FreeRTOS bullet in the setup essentials — needed only for the Same STM32: bare-metal vs RTOS section

Rust variant: RTIC/Embassy toolchain is the one-time install in the syllabus Toolchain; nothing extra here.

Host — Logic 2’s Measurements panel computes the period statistics on its own; optionally export the edge timestamps as CSV and histogram them yourself (numpy + matplotlib in the course venv — you write the script; it’s ~15 lines):

source venv/bin/activate      # only for the optional histogram
mkdir -p labs/lab-2-2/host labs/lab-2-2/captures

Keep this lab’s reconciliation in labs/lab-2-2/host/analysis.ipynb — the notebook convention — and export final figures next to it.

Where results go:

Artifact Path
Bench note (tables below, filled in) labs/lab-2-2/notes.md
Clean / contended captures labs/lab-2-2/captures/clean.sal, contended.sal
RTOS-variant capture labs/lab-2-2/captures/rtos.sal
Exported edge timestamps (optional) labs/lab-2-2/captures/clean-edges.csv
Jitter histogram (optional) labs/lab-2-2/host/jitter-hist.png

Background

A general-purpose timer counts a clock. Starting from the timer input clock \(f_{TIM}\) (derived from the APB bus clock — often 80 MHz on the L476 with the default config), the prescaler PSC divides it and the auto-reload ARR sets the count length. The timer’s update event fires at

\[f_{\text{update}} = \frac{f_{TIM}}{(\text{PSC}+1)\,(\text{ARR}+1)}.\]

For a 10 kHz update from an 80 MHz timer clock, one convenient choice is \(\text{PSC}+1 = 80\) (→ 1 MHz tick) and \(\text{ARR}+1 = 100\):

\[f_{\text{update}} = \frac{80\times10^6}{80 \times 100} = 10\,000\ \text{Hz}, \qquad T = 100\ \mu\text{s}.\]

The timer fires with the accuracy of the crystal/PLL — essentially perfect period. But the pin toggle happens some cycles later, after the CPU takes the exception. That delay is the interrupt latency:

\[t_{\text{latency}} = t_{\text{entry}} + t_{\text{ISR-prologue}} + t_{\text{write}},\]

where \(t_{\text{entry}}\) is the Cortex-M4 exception entry (a fixed ~12 cycles when nothing else is pending) plus any time spent finishing a lower-priority handler or a multi-cycle instruction. If that latency were constant, the pin would still be perfectly periodic — just shifted. Jitter is the variation in latency from one interrupt to the next:

\[J = \max_k T_k - \min_k T_k, \qquad T_k = t_{\text{edge},\,k+1} - t_{\text{edge},\,k},\]

or, more usefully, the standard deviation of the measured period set \(\{T_k\}\). Jitter appears whenever the latency changes: a competing interrupt arrives and delays entry, a longer instruction is mid-execution when the interrupt fires, or a higher-priority ISR preempts. In sampling terms, period jitter is aperture/sample-clock jitter, and it raises the noise floor of any spectrum you later compute.

Procedure

Part A — Configure the timer for a 10 kHz update interrupt.

  1. In the project’s .ioc (continue from the Lab 2.1 firmware/m2-timing/ project, per Project & environment setup), select a basic/general-purpose timer, e.g. TIM2 (32-bit) or TIM6/TIM7 (basic, ideal for a periodic interrupt). Set Clock Source = Internal Clock.

  2. Set Prescaler (PSC) = 79 and Counter Period (ARR) = 99 (the +1 is implicit in the formula above), giving \(f_{\text{update}}=10\text{ kHz}\) from an 80 MHz timer clock. Verify the timer clock in the Clock Configuration tab — if APB1 timer clock is not 80 MHz, recompute PSC/ARR.

  3. In the timer’s NVIC Settings tab, enable the update interrupt (global interrupt). Give it a priority you can change later.

  4. Configure a spare pin (e.g. PB5) as GPIO_Output, push-pull, output speed Very High (crisp edges for clean measurement).

  5. Generate code. In main(), start the timer in interrupt mode:

    HAL_TIM_Base_Start_IT(&htim2);   /* enable timer + its update interrupt */
  6. Implement the period-elapsed callback (HAL routes the ISR here after clearing the flag). Keep it to a single pin write:

    void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
    {
        if (htim->Instance == TIM2)
            GPIOB->BSRR = (GPIOB->ODR & GPIO_ODR_OD5) ? GPIO_BSRR_BR5
                                                       : GPIO_BSRR_BS5;
    }

    (Illustrative — toggling PB5 by reading ODR and writing BSRR. A cleaner pattern toggles with GPIOB->ODR ^= GPIO_ODR_OD5;. Write your own.)

    Note: because you toggle once per interrupt, the pin’s output is a square wave at half the interrupt rate → 5 kHz on the pin for a 10 kHz ISR. Account for that when reading the Saleae.

Part B — Capture and measure period.

  1. Wire the Saleae to PB5 and GND (per Wiring & bench setup); threshold 3.3 V; sample rate at maximum for 1–2 active channels. Capture ~50–100 ms so you have many periods for statistics.
  2. In Logic 2, add a timing measurement across one pin period (should be ~200 µs → 5 kHz). Confirm the interrupt rate is \(2\times\) that (10 kHz).

Part C — Measure jitter.

  1. Use Logic 2’s measurement/statistics feature over the capture: it can report period min/max/mean/standard deviation across all edges (add a Measurements panel and select the channel, or use the analyzer statistics). Record min, max, mean period and compute jitter \(J = T_{\max}-T_{\min}\) and the standard deviation \(\sigma_T\).
  2. Zoom to the tightest time base and eyeball the edge “smear” — with only the timer interrupt running, jitter should be very small (a few timer clock cycles at most, i.e. tens of ns).

Part D — Induce jitter with contention.

  1. Add a second, higher-priority interrupt or a long non-interruptible section in main() that occasionally disables interrupts (e.g. a __disable_irq() / work / __enable_irq() block, or a busy loop with a higher-priority EXTI from the user button B1 / PC13). Each time the higher-priority work delays the timer ISR, the toggle edge slips.
  2. Recapture and re-measure period statistics. Jitter should now be visibly larger. Try reversing the NVIC priorities and watch the effect.
  3. Optionally toggle a second pin at the very start of the ISR and compare its edge to the timer’s known fire instant to isolate entry latency from period jitter.

Deliverable & expected results

labs/lab-2-2/notes.md plus Logic 2 captures for the clean case and the contended case, recording measured period statistics and computed jitter.

Quantity Predicted Measured
Interrupt period \(T\) 100 µs (10 kHz)
Pin square-wave period 200 µs (5 kHz, toggle-per-ISR)
Jitter \(J\), clean (timer only) ≤ a few timer-clock cycles (tens of ns)
\(\sigma_T\), clean small (ns-scale)
Jitter \(J\), with competing interrupt markedly larger (µs-scale possible)

Analysis & reconciliation

Confirm the mean period equals \(1/f_{\text{update}}\) from the PSC/ARR formula — if it is off by a clean ratio, your timer clock isn’t the 80 MHz you assumed (recheck the clock tree, exactly as in Lab 2.1). Then interpret the jitter: in the clean case the timer hardware sets the fire instant with crystal accuracy, so the only variation is CPU exception-entry timing — a few cycles depending on which instruction the core was executing when the interrupt arrived. In the contended case, a higher-priority handler or an interrupts-disabled window delays entry by however long that work takes, and that variation is the µs-scale jitter you measure.

Tie it back to DSP: if this were an ADC sample clock, jitter \(\sigma_T\) translates into phase noise on every sampled sinusoid, raising the spectral noise floor by roughly \(10\log_{10}\) of the timing-error power ratio. This is exactly why Module 5 triggers the ADC from a timer directly (hardware-timed conversion) rather than reading it in an ISR — you remove the software latency from the sample instant entirely.

Cross-platform ports & language variants

See the syllabus Implementation tracks for the framing; this is the timer-ISR-specific version. This lab sits in the hard-deadline / streaming class — a periodic event that must be serviced with bounded latency — and it is the clearest case for why real-time sampling belongs on the MCU, not on a general-purpose CPU.

STM32 bare-metal (C, and Rust). A TIM update interrupt fires with crystal/PLL accuracy and the ISR flips the pin a fixed ~12-cycle exception entry later, so the period jitter is tens of ns (measured with the DWT cycle counter, see setup essentials). In Rust, RTIC is the ideal fit: bind a hardware task directly to the TIM interrupt and the framework gives you a statically-scheduled, priority-ordered handler with the same latency as the C ISR; an Embassy timer future is the async alternative, trading a little more overhead for async/await ergonomics.

Raspberry Pi 5 (Linux userspace). There is no true hardware-timer-ISR-to-userspace path. The closest analog is a periodic thread waking on nanosleep/timerfd and toggling GPIO — but under the stock scheduler its period jitter is ms-scale, since the thread competes with everything else and can be preempted arbitrarily. A PREEMPT_RT kernel plus real-time scheduling drops that to tens of µs, still orders of magnitude worse than the MCU. The lesson is explicit: hard-real-time sampling on the Pi belongs in its hardware PWM/PIO, not on a CPU thread.

Jetson Orin Nano — detailed procedure (embedded Linux)

The Linux counterpart of this lab’s core question — how precisely can software hit a 10 kHz deadline? — with the timer ISR replaced by the closest userspace equivalent: a thread waking on a kernel timer. The GPU is irrelevant to a timing deadline; this is a scheduler-latency story, and you will measure it three ways. One-time board config: Jetson setup essentials.

Wiring — identical to Lab 2.1’s Jetson procedure: Saleae GND → header pin 6 (land it first), CH0 → pin 7 (the course marker pin), threshold 3.3 V.

flowchart LR
  JET["Jetson Orin Nano<br/>timerfd thread @ 10 kHz<br/>toggles header pin 7"]
  SAL["Saleae Logic 8<br/>CH0 + GND lead"]
  SW["Logic 2 on the Mac<br/>period statistics"]
  JET -- "CH0 → pin 7<br/>GND → pin 6" --> SAL
  SAL -- "USB" --> SW

flowchart LR
  JET["Jetson Orin Nano<br/>timerfd thread @ 10 kHz<br/>toggles header pin 7"]
  SAL["Saleae Logic 8<br/>CH0 + GND lead"]
  SW["Logic 2 on the Mac<br/>period statistics"]
  JET -- "CH0 → pin 7<br/>GND → pin 6" --> SAL
  SAL -- "USB" --> SW

Procedure.

  1. mkdir -p labs/lab-2-2/edge for the Jetson-side program; pin the clocks so DVFS doesn’t wander mid-measurement: sudo nvpmodel -m 0 && sudo jetson_clocks.
  2. Baseline the scheduler alone with cyclictest at exactly this lab’s period: sudo cyclictest -t1 -p 90 -i 100 -m -l 100000 (100 µs interval = the 10 kHz deadline). Record min/avg/max wake-latency in µs — this is the board’s best case before your code even runs, the number to reconcile the Saleae statistics against.
  3. Write the toggle program (C, in edge/): create a periodic 100 µs timer with timerfd_create(CLOCK_MONOTONIC, 0) + timerfd_settime, and in the loop read(tfd, …) then flip pin 7 via libgpiod — the read returns the number of expirations, so count anything > 1 as a missed deadline and report the total on exit. (Structure only — write your own; clock_nanosleep with TIMER_ABSTIME is an equivalent base.)
  4. Run 1 — stock scheduling. Run it, capture ~2 s on the Saleae (~10 000 pin periods at the 5 kHz toggle-per-wake rate), and read Logic 2’s period statistics: mean, σ, min, max. Expect the mean to sit exactly on 200 µs (the kernel timer is crystal-driven, like the TIM) but with a long jitter tail — that tail is the scheduler.
  5. Run 2 — real-time class. sudo taskset -c 3 chrt -f 90 ./toggle10k. Recapture, restat: the median is unchanged, σ and the max-period outliers collapse toward the cyclictest baseline.
  6. Record which kernel produced the numbers (uname -v; the stock JetPack kernel is not PREEMPT_RT — NVIDIA’s optional RT kernel shrinks the tail further, per the setup essentials).
  7. Save labs/lab-2-2/captures/jetson-stock.sal and jetson-rt.sal; log missed-deadline counts and the (mean, σ, max) triples in notes.md next to the STM32 rows.

Raspberry Pi 5 differences: identical wiring and code. Set the CPU governor to performance instead of jetson_clocks, and note the Pi world has a long-standing PREEMPT_RT tradition if you want the kernel rung. Everything else — cyclictest, timerfd, chrt — is the same commands.

Measure and compare (fill Measured on each platform):

Platform / build Jitter p50 → p99 Deterministic? Predicted Measured
STM32 bare-metal, C (TIM ISR) tens of ns yes tight
STM32 bare-metal, Rust (RTIC/Embassy) ≈ same as C yes ≈ C
Jetson, timerfd thread, stock scheduling ms-scale tail possible no large p99
Jetson, same + chrt -f 90 + pinned core tens of µs mostly tail ≈ cyclictest max
Pi 5, timerfd thread, stock kernel ms-scale tail no large p99
Pi 5, timerfd thread, PREEMPT_RT tens of µs mostly reduced tail

Same STM32: bare-metal vs RTOS

The runtime axis has a middle rung worth measuring on the MCU itself: run this exact lab under FreeRTOS and compare its jitter against the bare-metal ISR you just built. This is the clearest place to feel what a scheduler costs, and it sets up Lab 7.2.

  • Bare-metal (above): the TIM update ISR toggles the pin directly — tens-of-ns jitter, nothing between the interrupt and the write.
  • FreeRTOS (C): move the toggle into a task. The TIM ISR now only does osSemaphoreRelease(sem) (the FromISR path) and returns; a high-priority task blocks on osSemaphoreAcquire(sem, osWaitForever) and toggles the pin on wake. Setup: enable FreeRTOS (CMSIS_V2) and a spare-timer HAL timebase per the setup essentials, then create the semaphore and task (osSemaphoreNew / osThreadNew, or in the CubeMX FreeRTOS tab). The pin now flips only after the scheduler wakes the task — you have deliberately inserted ISR-to-task latency + one context switch into the path.
  • Rust (RTIC / Embassy): in RTIC, bind a hardware task to the TIM interrupt that pends (or signals) a lower-priority software task which does the toggle; in Embassy, await a Timer in an async task. Same deferred-to-task structure, statically scheduled — RTIC’s priorities are checked at compile time.
  • What you’ll see: the median period is unchanged (the timer still fires with crystal accuracy), but the jitter grows — the deferred toggle now carries the context-switch time (~a few µs on an 80 MHz M4F) plus any delay from the RTOS tick and higher-priority tasks. That gap is the price of the abstraction; measure it with the DWT counter and decide, per deadline, whether it’s affordable. (This is also why Module 5 samples the ADC straight from a timer+DMA — to keep the RTOS off the sample instant entirely.)
Build (same STM32) Pin-toggle jitter ISR→toggle path Predicted Measured
Bare-metal TIM ISR tens of ns ISR writes pin directly tight
FreeRTOS, ISR→semaphore→task + context switch (~µs) ISR gives sem, task wakes & writes larger
Rust RTIC, hw task→sw task ≈ FreeRTOS pend/signal, then write larger

Going further

  • Move the toggle to a hardware-only path: configure the timer to drive the pin via output compare / PWM with no CPU involvement, and measure the jitter of that. It should be essentially zero — the pin flips in silicon on the count match. This is the gold standard the ISR is compared against.
  • Sweep the interrupt rate (1 kHz → 50 kHz) and find where the ISR overhead starts to eat a meaningful fraction of the period — the point where “do it in the ISR” stops being viable and DMA becomes necessary (previews Lab 5.3).
  • Measure the effect of moving the ISR code from flash to RAM, or of enabling/disabling the flash instruction cache/prefetch, on jitter.