Turn the one-shot conversion of Lab 5.1 into a hardware-timed sample stream at a known, fixed rate \(f_s\). Instead of asking the CPU to poll “convert now” — which is at the mercy of loop timing, interrupts, and cache — you let a timer issue the trigger, so conversions land on a rigid grid with essentially zero jitter. You will drive the ADC from TIM2, toggle a GPIO once per conversion, and confirm the true sample rate on the Saleae. Deterministic, jitter-free sampling is the foundation the entire DSP stack stands on: every frequency you later compute, every filter you design, assumes samples are equally spaced in time. If \(f_s\) wobbles, the math is wrong.
Recommended reading
Kuo — real-time sampling and the sample-clock architecture of an embedded DSP system (the timer-driven acquisition front end).
Lyons Ch. 2 — periodic sampling: the ideal impulse-train sampler and why uniform spacing \(T_s = 1/f_s\) is assumed throughout. Course 1 Lesson 49 has the distributional proof.
Course 1 Lesson 49 — the impulse train / Dirac comb as the model of ideal uniform sampling (the theoretical object your timer approximates).
Equipment & parts
STM32 NUCLEO-L476RG + USB cable.
MCP4725 DAC (3.3 V powered) as the analog source, or the buffered node from Lab 4.4.
Saleae Logic 8 + Logic 2 software.
Jumper wires; common ground.
Wiring & bench setup
The signal chain: the Lab 5.1 DAC→A0 loopback stays as-is; the only new hookup is the Saleae on the per-conversion marker pin — default D7 (PA8), free on the Arduino header with no alternate function in this project.
Breadboard layout is unchanged from the Lab 5.1 sketch — the two Saleae fly-leads (CH0 → D7, GND → − rail) are the only additions. If you use PA5/LD2 as the marker instead, expect the green LED to blink at \(f_s/2\); the Morpho-pin option in Safety below also works — just keep the pin map and firmware define in agreement.
Safety & don’t-break-it
ADC input stays 0 – 3.3 V. As in Lab 5.1, PA0 is not 5 V tolerant. Keep the MCP4725 on the 3.3 V rail so its rail-to-rail output cannot exceed VREF+.
Saleae threshold and ground. Set the Logic 2 input threshold to 3.3 V logic and clip a Saleae ground to the board ground. The trigger-toggle pin swings 0 ↔︎ 3.3 V, well within the analyzer’s range, but a floating ground gives garbage edges.
Don’t route the timer TRGO to a pin you also drive — pick a spare GPIO (e.g. PA5 / LD2 is convenient but blinks the LED; a free Morpho pin is cleaner) for the per-conversion toggle so you’re not fighting an alternate-function assignment.
Project & environment setup
Firmware — reuse firmware/m5-daq/ (created in Lab 5.1). This lab adds the timer trigger to the .ioc:
External Trigger Conversion Source = Timer 2 Trigger Out event, External Trigger Edge = Rising — IN5 / 12-bit / sampling time unchanged from Lab 5.1
System Core → GPIO
PA8 (D7) → GPIO_Output — the per-conversion marker pin
System Core → NVIC
ADC1 and ADC2 global interrupt enabled (delivers the EOC callback)
Middleware → FREERTOS
RTOS variant only (section below): CMSIS_V2 + HAL timebase moved to a spare timer, per the FreeRTOS bullet in the setup essentials
Clock Configuration
80 MHz HCLK per the setup essentials — TIM2’s kernel clock is the APB1 timer clock, 80 MHz here (the ×2 rule in Analysis)
Host — nothing to script; Logic 2 does the measuring. Just make the capture directory:
mkdir-p labs/lab-5-2/captures
Where results go:
Artifact
Path
Bench note (PSC/ARR vs measured-\(f_s\) table)
labs/lab-5-2/notes.md
Saleae capture of the marker pin
labs/lab-5-2/captures/fs-marker.sal
(RTOS variant) latency/jitter capture
labs/lab-5-2/captures/rtos-marker.sal
Background
A general-purpose timer counts its input clock \(f_\text{TIM}\), divides by the prescaler\((\text{PSC}+1)\), and rolls over every auto-reload\((\text{ARR}+1)\) counts, generating an update event at
Configuring the timer’s TRGO (trigger output) to fire on update and pointing the ADC’s external trigger at TIM2_TRGO means each rollover launches exactly one conversion. The sample period is \(T_s = 1/f_s\).
Worked example, targeting \(f_s = 10\ \text{kHz}\) from \(f_\text{TIM} = 80\ \text{MHz}\):
So PSC = 79, ARR = 99. The reason this beats software polling is jitter: a polled loop’s actual sampling instant drifts with whatever else the CPU is doing, and non-uniform spacing smears the spectrum. A timer trigger is a hardware event — the conversion start is locked to the counter, so the only error is the (tiny, bounded) timer clock accuracy, not software timing.
Procedure
Part A — Timer + ADC trigger in CubeMX.
Start from your Lab 5.1 project. Enable TIM2: set Prescaler = 79, Counter Period (ARR) = 99, and Trigger Event Selection = Update Event (TRGO).
In ADC1, change External Trigger Conversion Source to Timer 2 Trigger Out event, External Trigger Edge = Rising. Keep 12-bit, single channel IN5 (PA0).
Enable EOC (end-of-conversion) interrupt for ADC1. Configure a spare GPIO output for the per-conversion toggle (D7 = PA8 per Wiring & bench setup). Generate code.
Part B — Toggle a pin per conversion (illustrative firmware).
Start the timer base and the ADC in interrupt mode; toggle the marker pin in the conversion-complete callback:
// Illustrative only.HAL_TIM_Base_Start(&htim2);// timer now free-runs, issuing TRGOHAL_ADC_Start_IT(&hadc1);// ADC waits for the TIM2 triggervoid HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *h){ HAL_GPIO_TogglePin(MARK_GPIO_Port, MARK_Pin);// one edge per conversionuint16_t code = HAL_ADC_GetValue(h);// grab the sample// (store/print in later labs)}
Because the marker toggles, its output frequency is \(f_s/2\) (a full high+low cycle spans two conversions) — account for that when you read the Saleae.
Part C — Measure the true sample rate.
Connect a Saleae channel to the marker pin, ground clipped to board GND. Capture a few hundred milliseconds.
In Logic 2, measure the marker’s period. The conversion rate is \(f_s = 2\times\) (marker frequency), or equivalently \(f_s = 1/(\tfrac12 T_\text{marker})\). Alternatively, add the frequency measurement and double it.
Change ARR to retarget \(f_s\) (e.g. ARR = 199 → 5 kHz; ARR = 49 → 20 kHz) and re-measure to confirm the formula tracks.
Deliverable & expected results
A Saleae capture (labs/lab-5-2/captures/fs-marker.sal) plus a note recording predicted vs. measured \(f_s\) for two or three timer settings.
PSC
ARR
Predicted \(f_s\)
Marker freq (pred.)
Measured \(f_s\)
79
99
10.000 kHz
5.000 kHz
…
79
199
5.000 kHz
2.500 kHz
…
79
49
20.000 kHz
10.000 kHz
…
Analysis & reconciliation
Predicted \(f_s = f_\text{TIM}/[(\text{PSC}+1)(\text{ARR}+1)]\); confirm your \(f_\text{TIM}\) from the CubeMX clock tree (the timer’s kernel clock may be the APB1 timer clock, which can differ from the raw APB1 bus clock by the ×2 timer-clock rule — check it). Expect measured \(f_s\) within the Saleae’s timing resolution and the STM32 oscillator tolerance (HSI ~1%; tighter if you’re on an external/PLL-locked source). If you’re off by exactly ×2, you’ve either forgotten the toggle-halves-the-rate factor or hit the APB timer-clock doubler. If \(f_s\) is a bit low and unstable, you may be missing conversions because \(t_\text{conv}\) (sampling + SAR time from Lab 5.1) exceeds \(T_s\) — the ADC can’t finish before the next trigger; lengthen ARR or shorten the sampling time.
Bare-metal vs RTOS on the STM32
The runtime axis has a middle rung worth measuring on the MCU itself: run this exact acquisition under FreeRTOS and compare its per-sample behaviour against the bare-metal EOC callback you just built.
Bare-metal (above): the timer fires TRGO, the conversion completes, and the EOC interrupt delivers each sample — you read HAL_ADC_GetValue and do the work inline in the callback, tens of ns after the conversion lands.
FreeRTOS (C): the EOC ISR now does nothing but hand the sample off — osMessageQueuePut(q, &code, 0, 0) (or osSemaphoreRelease(sem)) on the ...FromISR signal path — and returns fast; a consumer task blocks on osMessageQueueGet/osSemaphoreAcquire, reads the sample, and does the work in thread context. Setup: enable FreeRTOS (CubeMX FREERTOS → CMSIS_V2) and move the HAL timebase to a spare timer per the setup essentials, then create the queue/semaphore and task (osMessageQueueNew/osSemaphoreNew + osThreadNew). You have deliberately inserted sample-to-task latency + one context switch per sample into the path.
Rust (RTIC / Embassy): in RTIC, bind a hardware task to the ADC IRQ that spawns (or signals) a lower-priority software task which consumes the sample — statically scheduled, priorities checked at compile time; in Embassy, await an async ADC read in a task. Same deferred-to-task structure.
What you’ll see: at low \(f_s\) this is fine — the scheduler has ample slack between conversions. But as \(f_s\) climbs, the per-sample context-switch overhead (~a few µs on an 80 MHz M4F) becomes the limiter: the max sustainable rate drops and jitter grows. That ceiling is exactly why the next lab moves to DMA (Lab 5.3) — to get the CPU and the scheduler off the per-sample path entirely.
Build (same STM32)
Sample→task latency
Max sustainable \(f_s\)
Jitter
Predicted
Measured
Bare-metal EOC callback
inline, tens of ns
high
tight
tight
…
FreeRTOS, ISR→queue/sem→task
+ context switch (~µs)
lower
larger
reduced
…
Rust RTIC, hw task→sw task
≈ FreeRTOS
≈ FreeRTOS
larger
≈ FreeRTOS
…
NoteWhy there is no Jetson/Pi port of this lab
A hardware-timer-triggered conversion — silicon starting the sample with no software in the path — has no userspace equivalent on the Jetson or Pi; the Lab 2.2 Jetson procedure measured exactly how far a scheduled thread falls short of it. The nearest embedded-Linux analogs are the ADS1115’s continuous-mode + data-ready-interrupt path (built in the Lab 3.4 Jetson procedure) and the hardware-clocked audio codecs of Modules 8–9, where the sample clock lives in the audio hardware, not in Linux.
Going further
Jitter comparison. Re-run the old software-polled loop from Lab 5.1 in a busy while(1) and capture its marker on the Saleae; compare the edge-to-edge jitter against the timer-triggered version. This is the concrete payoff of hardware timing (and connects to the jitter work in Lab 2.2).
Scope the actual sampling instants by also probing the ADC input and the marker together — see where in the waveform each sample is taken.
This per-conversion interrupt still costs one ISR per sample. At high \(f_s\) that CPU tax becomes unacceptable — which is exactly the problem DMA solves in Lab 5.3.