Lab 5.2 — Timer-Triggered ADC

Course 2 syllabus · Module 5 · Prev: « Lab 5.1 · Next: Lab 5.3 »

Goal

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.

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.

flowchart LR
  DAC["MCP4725<br/>DC source<br/>(Lab 5.1 wiring)"]
  MCU["NUCLEO-L476RG<br/>TIM2 TRGO → ADC1_IN5<br/>marker = D7 (PA8)"]
  SAL["Saleae Logic 8<br/>Logic 2, 3.3 V threshold"]
  DAC -- "OUT → A0 (PA0)" --> MCU
  MCU -- "marker toggle" --> SAL

flowchart LR
  DAC["MCP4725<br/>DC source<br/>(Lab 5.1 wiring)"]
  MCU["NUCLEO-L476RG<br/>TIM2 TRGO → ADC1_IN5<br/>marker = D7 (PA8)"]
  SAL["Saleae Logic 8<br/>Logic 2, 3.3 V threshold"]
  DAC -- "OUT → A0 (PA0)" --> MCU
  MCU -- "marker toggle" --> SAL

Pin map (every wire):

From To Pin/jack
MCP4725 VDD / GND / SCL / SDA unchanged from Lab 5.1 3V3 / GND / D15 (PB8) / D14 (PB9)
MCP4725 OUT ADC input (loopback jumper) A0 (PA0)
Marker GPIO Saleae CH0 D7 (PA8)
Saleae GND (any harness ground lead) breadboard − rail

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:

CubeMX page Setting
Timers → TIM2 Clock Source Internal; Prescaler = 79, Counter Period (ARR) = 99\(f_s = \dfrac{80\times10^6}{80 \times 100} = 10\ \text{kHz}\). Retarget via \(f_s = f_\text{TIM}/[(\text{PSC}+1)(\text{ARR}+1)]\) with \(f_\text{TIM} = 80\) MHz (ARR = 199 → 5 kHz, ARR = 49 → 20 kHz, per Procedure step 7)
Timers → TIM2 → Trigger Output (TRGO) Trigger Event Selection = Update Event
Analog → ADC1 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

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

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}\):

\[\text{PSC}+1 = 80 \;\Rightarrow\; f_\text{cnt} = \frac{80\times10^6}{80} = 1\ \text{MHz},\qquad \text{ARR}+1 = 100 \;\Rightarrow\; f_s = \frac{10^6}{100} = 10\ \text{kHz}.\]

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.

  1. Start from your Lab 5.1 project. Enable TIM2: set Prescaler = 79, Counter Period (ARR) = 99, and Trigger Event Selection = Update Event (TRGO).
  2. In ADC1, change External Trigger Conversion Source to Timer 2 Trigger Out event, External Trigger Edge = Rising. Keep 12-bit, single channel IN5 (PA0).
  3. 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).

  1. 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 TRGO
HAL_ADC_Start_IT(&hadc1);          // ADC waits for the TIM2 trigger

void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *h) {
    HAL_GPIO_TogglePin(MARK_GPIO_Port, MARK_Pin);  // one edge per conversion
    uint16_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.

  1. Connect a Saleae channel to the marker pin, ground clipped to board GND. Capture a few hundred milliseconds.
  2. 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.
  3. 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.