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.
Recommended reading
Kuo Ch. 1 — real-time systems, interrupts, and why a deterministic sampling instant matters for DSP. Focus on the sampling-clock and real-time-constraint discussion.
STM32L476 reference manual (RM0351) — the general-purpose timer chapter: prescaler (PSC), auto-reload (ARR), and the update event (UEV) / update interrupt (UIE). Read how PSC and ARR set the period.
PM0214 (Cortex-M4 programming manual) — NVIC priorities and interrupt entry/exit (the “12-cycle” exception entry). Skim.
Course 1: Lesson 49 — distributions, the Dirac comb, and sampling for why non-uniform sampling corrupts the spectrum (the Dirac comb / sampling theorem; sampling-clock jitter → phase noise). Optional here, essential in Module 5.
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 histogrammkdir-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
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:
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:
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.
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.
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.
In the timer’s NVIC Settings tab, enable the update interrupt (global interrupt). Give it a priority you can change later.
Configure a spare pin (e.g. PB5) as GPIO_Output, push-pull, output speed Very High (crisp edges for clean measurement).
Generate code. In main(), start the timer in interrupt mode:
HAL_TIM_Base_Start_IT(&htim2);/* enable timer + its update interrupt */
Implement the period-elapsed callback (HAL routes the ISR here after clearing the flag). Keep it to a single pin write:
(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.
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.
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.
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\).
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.
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.
Recapture and re-measure period statistics. Jitter should now be visibly larger. Try reversing the NVIC priorities and watch the effect.
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.
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.
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.
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.)
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.
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.
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).
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.