Lab 2.1 — Saleae GPIO Timing

Course 2 syllabus · Module 2 · Prev: « Lab 1.3 · Next: Lab 2.2 »

Goal

Bring up your first STM32 firmware project end-to-end — generate the NUCLEO-L476RG project with CubeMX as a CMake project, work it in CLion, blink/toggle a GPIO pin in a tight loop, flash it over the on-board ST-LINK, and then measure what you actually built with the Saleae Logic 8. The skill here is the loop that every firmware role runs on: write code, put a probe on the pin, and confirm the hardware does what the C says. You will discover that a “toggle as fast as possible” loop does not run at the CPU clock — the GPIO output-speed setting and the compiler’s optimization level both change the measured toggle rate by large factors. Learning to see that on a logic analyzer, on day one, is the foundation for every timing-critical lab that follows.

Equipment & parts

  • STM32 NUCLEO-L476RG board + USB-A/micro-B cable to the host Mac.
  • Saleae Logic 8 + its flying-lead harness (Logic 2 software installed).
  • A jumper wire or two, and a breadboard (only to give the Saleae ground and signal leads something to clip to, if you prefer not to probe the header directly).
  • (Optional) Siglent SDS1104X-E scope + 10× probe — needed only for Part D’s edge-slope / rise-time observation, which a digital logic analyzer cannot resolve (see Part D).
  • Host running STM32CubeMX + CLion (or STM32CubeIDE — see the project workflow) and Logic 2 (free).

Wiring & bench setup

The signal chain: firmware toggles a GPIO pin, the Saleae samples it, and Logic 2 on the Mac measures the edges.

flowchart LR
  MCU["NUCLEO-L476RG<br/>toggle pin PA5 = D13/LD2<br/>or spare PA8 = D7"]
  SAL["Saleae Logic 8<br/>CH0 + GND lead"]
  SW["Logic 2 on the Mac<br/>timing measurements"]
  SCOPE["Siglent SDS1104X-E<br/>Part D rise time only"]
  MCU -- "CH0 → toggle pin<br/>GND → Nucleo GND" --> SAL
  SAL -- "USB" --> SW
  MCU -. "CH1, 10× probe" .-> SCOPE

flowchart LR
  MCU["NUCLEO-L476RG<br/>toggle pin PA5 = D13/LD2<br/>or spare PA8 = D7"]
  SAL["Saleae Logic 8<br/>CH0 + GND lead"]
  SW["Logic 2 on the Mac<br/>timing measurements"]
  SCOPE["Siglent SDS1104X-E<br/>Part D rise time only"]
  MCU -- "CH0 → toggle pin<br/>GND → Nucleo GND" --> SAL
  SAL -- "USB" --> SW
  MCU -. "CH1, 10× probe" .-> SCOPE

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

From To Pin/jack
Saleae CH0 flying lead the pin your loop toggles D13 (PA5/LD2) or spare D7 (PA8)
Saleae GND lead (land it first) Nucleo ground GND (Arduino power header)
(Part D) Siglent CH1 10× probe tip — probe switch and channel menu at 10× same toggle pin D13 or D7
(Part D) Siglent probe ground clip Nucleo ground GND

No breadboard is required: clip the grabbers straight onto the Arduino-header pins, or run male–male jumpers from the pins to a breadboard and clip there (gentler on the leads). Setup gotcha: PA5 also drives LD2, whose trace adds a little capacitance — the spare D7 = PA8 gives you an unloaded pin, which is why step 2 suggests it.

Safety & don’t-break-it

  • Everything here is 3.3 V logic. The Nucleo drives its GPIO at 3.3 V. The Saleae inputs are fine with this, but you must share ground: connect one Saleae GND lead to a Nucleo GND pin before clipping a signal lead. A probe with no common ground reads garbage and can inject noise.
  • Do not source real load current from a GPIO. You are measuring a voltage edge with a high-impedance logic analyzer, not driving an LED array. Keep the pin unloaded (or driving only the on-board LD2 LED). An STM32 GPIO can source/sink only ~8–20 mA per pin; shorting a driven pin to ground or to 3.3 V can damage it.
  • Never let a probe lead touch the 5 V (E5V / VIN) pins and then a 3.3 V logic input on the same reference. The Saleae tolerates ~5 V but the point of this lab is the 3.3 V domain — keep leads on the pins you intend.
  • Confirm pin 1 / pin identity on the Morpho header before probing. Use the UM1724 pinout. Probing the wrong pin wastes an afternoon; probing a power pin as if it were a GPIO does not.
  • Insert/remove the USB cable gently; the micro-B connector on ST-LINK is the board’s most fragile part.

Project & environment setup

Firmware — create the Module 2 project (firmware/m2-timing/; Labs 2.2 and 2.3 reuse it) per the project workflow: in STM32CubeMX, New Project → board selector → NUCLEO-L476RG → answer Yes to “initialize all peripherals with their default mode” (this pre-wires LD2/PA5, the B1 button, and USART2 — all used later in the module). Then in the .ioc:

CubeMX page Setting
Pinout view → PA5 already GPIO_Output (LD2); Push-pull, output level Low, Maximum output speed = Low to start (Part D raises it)
Pinout view → PA8 (D7, optional spare) click the pin → GPIO_Output; Push-pull, Low, speed Low — a probe pin not loaded by the LED
Clock Configuration 80 MHz HCLK per the setup essentials — do this now; the cycles-per-half arithmetic below assumes it

Generate with Toolchain/IDE = CMake into firmware/m2-timing/, restore the repo’s CMakeLists.txt/CMakePresets.json, and open the folder in CLion. The optimization level (Part D) is a build setting, not an .ioc one: it’s the CMake preset — debug = -Og -g3, release = -O2 -g, and a one-off -O0 profile per the project workflow.

Rust variant: toolchain for the cross-platform section is a one-time install per the syllabus Toolchain; nothing extra here.

Host — no Python needed in this lab; the results are Logic 2 exports:

mkdir -p labs/lab-2-1/captures

Where results go:

Artifact Path
Bench note (table below, filled in) labs/lab-2-1/notes.md
Logic 2 capture per configuration labs/lab-2-1/captures/bsrr-O2.sal, bsrr-O0.sal, hal-O0.sal
(Part D) scope rise-time screenshots labs/lab-2-1/captures/rise-ospeed-low.png, rise-ospeed-vhigh.png

Background

A GPIO output pin is a voltage that firmware flips between 0 V (logic low) and \(V_{DD}=3.3\text{ V}\) (logic high). The fastest possible software toggle is a loop that writes high, then low, forever. Naively you might expect the pin to toggle near the CPU clock \(f_{CPU}\) (up to 80 MHz on the L476), giving a square wave at \(f_{CPU}/2\). It does not, for three reasons:

  1. Instruction count per toggle. Each half-period costs several instructions — read the loop condition, compute the write, store to the peripheral register. Using HAL’s HAL_GPIO_TogglePin() costs far more (a function call, a read-modify-write, branch logic) than a direct write to the BSRR register.
  2. The GPIO output-speed setting (OSPEEDR). Each pin has a slew-rate control — Low / Medium / High / Very-High speed. This sets how fast the output driver transitions, i.e. the edge rise/fall time, and at high toggle rates it limits how clean (and how fast) the pin can actually swing. It does not make the loop run faster, but it changes the edges you measure.
  3. Compiler optimization level. At -O0 (debug) the compiler emits literal, un-optimized code and the loop is slow. At -O2/-Ofast it keeps the peripheral write but strips overhead, and the toggle rate jumps several-fold. This is the single most surprising number in the lab.

For a square wave, the measured period \(T\) and frequency \(f\) relate as

\[f = \frac{1}{T}, \qquad T = t_{\text{high}} + t_{\text{low}}.\]

If a single loop iteration writes high then low, one full period covers both writes, and the toggle rate (edges per second) is \(2f\). The pulse width you measure (\(t_{\text{high}}\) or \(t_{\text{low}}\)) times the instructions-per-half count, divided by \(f_{CPU}\), tells you the effective cycles per edge:

\[\text{cycles per half-period} \approx t_{\text{high}} \cdot f_{CPU}.\]

That number is your window into what the compiler actually generated.

Procedure

Part A — Create and flash the project.

  1. In STM32CubeMX: New Project → board selector → NUCLEO-L476RG, accept “initialize all peripherals to default,” and generate with Toolchain/IDE = CMake into firmware/m2-timing/ (per Project & environment setup — this project serves all of Module 2). This produces a .ioc with LD2 on PA5 already configured as GPIO_Output and the clock tree set up. Open the folder in CLion.

  2. Open the .ioc. Confirm PA5 is GPIO_Output. Optionally add a spare pin (e.g. PB5 or PA8 on the Morpho header) as GPIO_Output so you can probe a pin that is not also driving the LED — the LED and its wiring add a little capacitance you may want to avoid. Set the pin’s GPIO output level = Low, mode = Push-pull, and note the Maximum output speed field (start at Low).

  3. In the generated main.c, inside the while(1) loop, add a tight toggle. Illustrative HAL version:

    /* USER CODE BEGIN WHILE */
    while (1)
    {
        HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5);   /* toggle LD2 / PA5 */
    }
    /* USER CODE END WHILE */

    And the faster LL / direct-register version (write to BSRR — atomic set/reset, no read-modify-write):

    while (1)
    {
        GPIOA->BSRR = GPIO_BSRR_BS5;   /* set PA5 high  */
        GPIOA->BSRR = GPIO_BSRR_BR5;   /* set PA5 low   */
    }

    (Write your own real loop; these show the two approaches you will compare.)

  4. Build and flash — cmake --preset release && cmake --build --preset release --target flash-m2-timing (or the same targets from CLion’s build panel). The ST-LINK programs the board over the USB cable. LD2 should appear solid (it is toggling far too fast to see flicker).

Part B — Capture with the Saleae.

  1. Wire the Saleae per Wiring & bench setup: one GND flying lead to a Nucleo GND pin; one channel lead (say CH0) to the pin you are toggling (PA5 on the Morpho header, or your spare pin).
  2. In Logic 2, click the channel’s gear and set the logic threshold to 3.3 V (choose the “1.2/1.8/3.3 V” logic-level preset, not 5 V). This tells Logic where the high/low decision point sits for CMOS 3.3 V.
  3. Set the sample rate high — with only one or two channels active the Logic 8 can sample at up to 100 MS/s. You need at least ~10× the edge rate to resolve a fast toggle; start at the maximum and a short capture duration (e.g. 10 ms).
  4. Click Start. You should see a dense square wave. Zoom in until individual pulses are clear.

Part C — Measure period and pulse width.

  1. Drag a timing measurement across one full period (rising edge to the next rising edge) → this is \(T\). Logic 2 shows period and frequency directly. Also measure one high pulse width \(t_{\text{high}}\).
  2. Note \(f = 1/T\) and record it in the table.

Part D — Vary output speed and optimization.

  1. In the .ioc, change the pin’s Maximum output speed from LowVery High, regenerate code, rebuild, reflash, recapture. On the Saleae you can see whether the top toggle frequency changes — but the edge slope / rise time is an analog, sub-10-ns feature that a digital logic analyzer cannot resolve (the Logic 8 only reports high/low at 10 ns granularity). To actually see the OSPEEDR effect on rise time, probe the pin with the Siglent scope (10× probe, fast timebase) and read the 10–90% rise time. That is why the rise-time row below is a scope measurement.
  2. Change the optimization level by switching CMake preset: build once at -O0 (a one-off profile passing -DCMAKE_C_FLAGS_DEBUG="-O0 -g3", per the project workflow) and once with the release preset (-O2). Reflash and recapture each. Record the toggle frequency for both.
  3. Repeat the whole capture for the HAL TogglePin loop vs. the direct BSRR loop.

Deliverable & expected results

A short bench note (labs/lab-2-1/notes.md) plus one Logic 2 .sal capture per configuration, recording measured period/frequency and the effect of each knob.

Compute predictions from \(f_{CPU}=80\text{ MHz}\) and an estimated cycles-per-half. A direct BSRR write is ~1–2 cycles plus loop overhead; at -O2 a two-instruction store loop lands in the low-MHz toggle range. HAL TogglePin at -O0 can be 10–50× slower.

Quantity Predicted Measured
Toggle freq — BSRR loop, -O2 ~ several MHz (compute from cycles/half)
Toggle freq — BSRR loop, -O0 markedly lower than -O2
Toggle freq — HAL TogglePin, -O0 far lower again (function-call overhead)
High pulse width \(t_{\text{high}}\) (BSRR, -O2) \(T/2\)
Rise time at OSPEEDR = Low vs. Very High (scope, not Saleae) shorter at Very High

Analysis & reconciliation

From the measured \(t_{\text{high}}\), back out the cycles per half-period: \(N = t_{\text{high}}\cdot f_{CPU}\). Compare \(N\) across the four builds and explain the gaps — the HAL path pays for a function call and a read-modify-write of ODR; -O0 keeps every load/store the C implies; -O2 folds them away. This is your first concrete lesson that C source is not the machine timing — the toolchain sits in between, and only the logic analyzer tells the truth. Note whether the clock is actually 80 MHz (check the CubeMX clock tree; the default may leave you at a slower HSI/PLL setting — if your numbers are off by a clean factor, suspect the clock config).

Expect the OSPEEDR change to affect edge slope, not loop rate: at Low speed a very fast toggle may not fully reach the rails (rounded, slewed edges); at Very High the edges are crisp but couple more noise. This previews why you will pick output speed deliberately in the I²C/SPI labs.

Cross-platform ports & language variants

See the syllabus Implementation tracks for the framing; this is the GPIO-toggle-specific version. This is the purest register-access / determinism case in the course — one store to a memory-mapped register — and it is exactly where a bare-metal MCU pulls furthest ahead of a Linux SBC.

STM32 bare-metal (C, and Rust). A single GPIOx->BSRR write flips the pin in one or two cycles, so a two-store loop toggles at MHz rates with no OS between the C and the silicon — the rate is a fixed function of the instruction stream and the clock. In Rust (#![no_std]) the stm32l4xx-hal/PAC set_high()/set_low() compile down to the same BSRR write (zero-cost abstractions), or you can write gpioa.bsrr.write(|w| w.bs5().set_bit()) directly; at -O/release the Rust toggle matches the C BSRR speed edge-for-edge. Time it with the DWT cycle counter (setup essentials), not just the Saleae.

Raspberry Pi 5 (Linux userspace, C or Python, and Rust). The same intent — flip a pin — is now mediated by the kernel. A libgpiod/sysfs toggle runs at only ~100 kHz and is jittery, because every edge pays a syscall and can be descheduled mid-loop. Poking the GPIO registers through an /dev/mem-mmap’d window recovers much of the raw speed — the Linux analog of BSRR-vs-HAL, the same “get under the abstraction” lesson. In Rust, rppal uses /dev/gpiomem (fast, mmap path) while a gpiod-style crate takes the syscall path (slow); the two bracket the trade directly.

Jetson Orin Nano — detailed procedure (embedded Linux)

The same experiment as the STM32 procedure — toggle a pin as fast as software allows, then measure what actually happened — run on the Jetson so the OS’s cost is a number, not a slogan. One-time board config is in the Jetson setup essentials; the ladder here is Python → C libgpiodSCHED_FIFO, each rung peeling away one layer of overhead (there is no GPU angle — this is CPU/OS-bound I/O, nothing to offload).

Wiring. No breadboard; clip straight onto the 40-pin header (silkscreen pin 1 first — see the header map in the setup essentials):

flowchart LR
  JET["Jetson Orin Nano<br/>40-pin header<br/>toggle pin = physical pin 7"]
  SAL["Saleae Logic 8<br/>CH0 + GND lead"]
  SW["Logic 2 on the Mac<br/>timing measurements"]
  JET -- "CH0 → pin 7<br/>GND → pin 6" --> SAL
  SAL -- "USB" --> SW

flowchart LR
  JET["Jetson Orin Nano<br/>40-pin header<br/>toggle pin = physical pin 7"]
  SAL["Saleae Logic 8<br/>CH0 + GND lead"]
  SW["Logic 2 on the Mac<br/>timing measurements"]
  JET -- "CH0 → pin 7<br/>GND → pin 6" --> SAL
  SAL -- "USB" --> SW

From To Header pin
Saleae GND lead (land it first) Jetson ground pin 6
Saleae CH0 flying lead course marker/toggle pin pin 7

Same 3.3 V threshold in Logic 2 as the STM32 captures; the header is a 3.3 V domain.

Procedure.

  1. mkdir -p labs/lab-2-1/edge — the Jetson-side code for this lab lives there (build/run it on the board per the setup essentials — CLion remote toolchain or plain SSH).

  2. Identify pin 7’s GPIO line: gpioinfo | less (find the line the header pin maps to), or skip the lookup entirely by using Jetson.GPIO with BOARD numbering, where the pin is the number 7.

  3. Rung 1 — Python. A free-running toggle loop with Jetson.GPIO (illustrative — write your own):

    import Jetson.GPIO as GPIO
    GPIO.setmode(GPIO.BOARD)
    GPIO.setup(7, GPIO.OUT)
    while True:
        GPIO.output(7, 1); GPIO.output(7, 0)

    Capture ~100 ms on the Saleae. Measure the toggle frequency and, in Logic 2’s statistics, the period min/max — note both the rate (kHz at best) and the spread.

  4. Rung 2 — C + libgpiod. The same loop in C (gpiod_chip_open → request the line as output → gpiod_line_set_value high/low in a while(1)), compiled -O2. Run it, recapture, remeasure. Faster than Python, but still one syscall per edge — this is the Linux counterpart of the HAL-vs-BSRR gap in Part D.

  5. Rung 3 — pin the scheduler down. Re-run the C loop under real-time priority and on a fixed core: sudo taskset -c 3 chrt -f 80 ./toggle. The median period barely moves (the syscall cost stays), but the outlier tail — edges delayed when the thread was preempted — shrinks. That tail is the scheduler, and you have just measured it.

  6. Save captures as labs/lab-2-1/captures/jetson-python.sal, jetson-gpiod.sal, jetson-gpiod-rt.sal, and note the three (frequency, jitter) pairs in notes.md alongside the STM32 rows.

Raspberry Pi 5 differences: identical wiring (pins 6/7) and identical libgpiod code; the Python library is gpiozero/RPi.GPIO-style instead of Jetson.GPIO. The Pi adds a fourth rung the Jetson procedure skips: Rust rppal (or C via /dev/gpiomem mmap) writes the GPIO registers from userspace with no syscall per edge — MHz-range toggles, the closest Linux gets to BSRR. If you run it, add the row to the table.

Measure and compare (fill Measured on each platform):

Platform / build Toggle frequency Edge-to-edge jitter Predicted Measured
STM32 bare-metal, C (BSRR) MHz-range ~0 (deterministic) tight, constant
STM32 bare-metal, Rust (PAC/HAL) ≈ same as C ~0 ≈ C
Jetson, Python Jetson.GPIO lowest large slow, jittery
Jetson, C libgpiod ~100 kHz-scale large tail (syscall/schedule) faster, still jittery
Jetson, C libgpiod + chrt -f ≈ same rate tail shrinks reduced p99
Pi 5, rppal / /dev/gpiomem mmap ≫ syscall path reduced fastest Linux rung

Going further

  • Add SWV / ITM trace or toggle a second pin around a block of code to time an arbitrary function — the same “probe the pin” technique, applied to real work.
  • Put the direct-register write inside a for loop with a known iteration count and confirm the loop overhead cycle-by-cycle against the ARM Cortex-M4 instruction timings.
  • Compare HAL_GPIO_WritePin set/reset vs. BSRR vs. ODR read-modify-write — three ways to set a pin, three different timings.
  • Bridge to Lab 2.2 — Timer interrupt jitter: a software loop’s rate is whatever the compiler gives you; a hardware timer’s rate is exactly what you program. That determinism is why real DSP sampling is timer-driven.