Lab 2.3 — UART Decode

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

Goal

Get text off the STM32 and onto your host, and understand — bit by bit — how it gets there. You will configure USART2 on the NUCLEO-L476RG (pins PA2/PA3), which is routed through the on-board ST-LINK to a USB Virtual COM Port, send printf-style output, and open it in a host serial terminal. Then you will put the Saleae Async Serial analyzer on the TX line and decode the exact same bytes off the wire — watching the start bit, eight data bits, and stop bit go by, and confirming that each bit is one bit-time wide. This is the debugging channel you will use in every firmware lab from here on, and understanding the frame format (rather than treating “serial” as magic) is what lets you diagnose baud-rate mismatches and framing errors instead of guessing.

Equipment & parts

  • STM32 NUCLEO-L476RG + USB cable (this single cable carries power, debug, and the virtual COM port).
  • Saleae Logic 8 + Logic 2, with one channel free to tap TX (PA2) and a GND lead.
  • Host with STM32CubeMX + CLion (per the project workflow), plus a serial terminal: screen/minicom, or pyserial (python -m serial.tools.miniterm).

Wiring & bench setup

The signal chain: USART2 TX goes two places at once — through the on-board ST-LINK to the host as a virtual COM port, while the Saleae passively taps the same copper and Logic 2 decodes it.

flowchart LR
  MCU["NUCLEO-L476RG<br/>USART2 TX = PA2"]
  ST["ST-LINK VCP<br/>on-board, same USB cable"]
  TERM["Host terminal<br/>/dev/tty.usbmodem*"]
  SAL["Saleae Logic 8<br/>CH0 + GND lead"]
  SW["Logic 2<br/>Async Serial analyzer"]
  MCU -- "on-board route" --> ST
  ST -- "USB" --> TERM
  MCU -- "CH0 taps PA2<br/>GND → Nucleo GND" --> SAL
  SAL -- "USB" --> SW

flowchart LR
  MCU["NUCLEO-L476RG<br/>USART2 TX = PA2"]
  ST["ST-LINK VCP<br/>on-board, same USB cable"]
  TERM["Host terminal<br/>/dev/tty.usbmodem*"]
  SAL["Saleae Logic 8<br/>CH0 + GND lead"]
  SW["Logic 2<br/>Async Serial analyzer"]
  MCU -- "on-board route" --> ST
  ST -- "USB" --> TERM
  MCU -- "CH0 taps PA2<br/>GND → Nucleo GND" --> SAL
  SAL -- "USB" --> SW

Pin map (every lead):

From To Pin/jack
Saleae CH0 flying lead (passive tap) PA2 (USART2 TX) Morpho header CN10 (PA2 — pin 35 on the Nucleo-64 pinout; confirm against UM1724)
Saleae CH1 (optional, Going further) PA3 (USART2 RX) Morpho CN10 (adjacent to PA2 in the UM1724 table)
Saleae GND lead (land it first) Nucleo ground GND (Arduino power header)
USB micro-B host Mac carries power + ST-LINK debug + the VCP

Setup gotcha: the Arduino-header D0/D1 positions are NOT connected to PA3/PA2 on this board — the default solder bridges route USART2 to the ST-LINK instead of the Arduino header. Tap TX on the Morpho header (CN10), located via the UM1724 pinout table. No breadboard needed.

Safety & don’t-break-it

  • 3.3 V logic, share ground. The USART lines idle high at 3.3 V. Clip a Saleae GND to a Nucleo GND pin before touching the signal, and set the Saleae logic threshold to 3.3 V.
  • Tap TX, don’t drive it. Connect the Saleae channel to PA2 (TX) as a passive observer. Never configure two devices to drive the same line — if you also wire something to TX as an output, you create bus contention that can damage a pin.
  • Do not cross TX and RX to the level shifter carelessly. In this lab the TX line stays in the 3.3 V domain (Nucleo ↔︎ ST-LINK, on-board). If you later wire USART to an external 5 V device, use the Coliao level shifter — a 5 V idle-high line into PA3 (RX) violates the non-5 V-tolerant analog constraint on some pins.
  • Match the terminal’s settings to the firmware (baud, 8N1). A mismatch shows up as garbage, not damage — but it wastes time. Decide the baud once and use it everywhere.
  • Don’t call printf from inside a fast ISR (see Lab 2.2) — a blocking UART write in a 10 kHz interrupt overruns. Print from main().

Project & environment setup

Firmware — reuse the Module 2 project (firmware/m2-timing/, created in Lab 2.1). USART2 was pre-enabled by the board defaults; confirm in the .ioc:

CubeMX page Setting
Connectivity → USART2 Mode = Asynchronous; 115200 baud, 8 bits, parity None, 1 stop bit (8-N-1); pins auto-assign to PA2 = TX, PA3 = RX (the ST-LINK VCP route)
Clock Configuration 80 MHz HCLK per the setup essentials — CubeMX computes BRR from the requested baud, but keep the clock consistent across the module

Host — the terminal side runs from the course venv (see Toolchain); pyserial provides miniterm:

source venv/bin/activate
mkdir -p labs/lab-2-3/captures
python -m serial.tools.miniterm /dev/tty.usbmodem* 115200   # or screen/minicom

To keep a transcript, use screen -L (writes screenlog.0 — move it into labs/lab-2-3/captures/) or copy the terminal output by hand.

Where results go:

Artifact Path
Bench note (table below, filled in) labs/lab-2-3/notes.md
Saleae capture with Async Serial decode labs/lab-2-3/captures/uart-115200.sal
Part D captures (baud change / mismatch) labs/lab-2-3/captures/uart-57600.sal, mismatch.sal
Host terminal transcript or screenshot labs/lab-2-3/captures/console.log (or console.png)

Background

An asynchronous serial (UART) line carries one byte at a time as a frame. There is no shared clock — sender and receiver each generate their own clock from an agreed baud rate, and re-synchronize on every frame’s start bit. The line idles high. A standard 8N1 frame is:

  • 1 start bit (line pulled low) — the falling edge tells the receiver “a frame is coming; sample the next bits.”
  • 8 data bits, least-significant-bit first.
  • 0 parity bits (the “N” = none), or 1 parity bit if enabled.
  • 1 stop bit (line returns high).

So one 8N1 byte occupies 10 bit-times on the wire. The fundamental unit is the bit time:

\[t_{\text{bit}} = \frac{1}{\text{baud}}.\]

At a common 115200 baud:

\[t_{\text{bit}} = \frac{1}{115200} \approx 8.68\ \mu\text{s}, \qquad t_{\text{frame}} = 10\,t_{\text{bit}} \approx 86.8\ \mu\text{s}.\]

The receiver samples each data bit near the middle of its bit-time (STM32 USART oversamples by 16 or 8 to find that center). If the two ends disagree on baud by more than a few percent, the sampling point drifts across a full bit over the frame and the last bits are read wrong — a framing error (the stop bit is sampled low instead of high). Framing errors are how a baud mismatch announces itself; the USART sets a FE flag when the stop bit is not high where expected. Parity, when enabled, catches single-bit corruption but is orthogonal to baud.

The STM32 generates its baud by dividing the USART clock; the value in BRR is \(f_{CK}/\text{baud}\) (with oversampling factored in). CubeMX computes it for you from the requested baud, but the point is that the actual on-wire bit time is \(1/\text{baud}\), and that is what the Saleae will confirm.

Procedure

Part A — Configure USART2 and print.

  1. In the .ioc: USART2 is usually already enabled (Mode = Asynchronous) with PA2 = USART2_TX, PA3 = USART2_RX, because the board template wires it to the ST-LINK VCP. Confirm this. Set Baud Rate = 115200, Word Length = 8 bits, Parity = None, Stop Bits = 1 (this is 8N1).

  2. Generate code. Retarget printf to USART2 by providing _write (or use HAL_UART_Transmit directly). Illustrative:

    /* Redirect printf to USART2 (huart2). Provide _write for newlib. */
    int _write(int file, char *ptr, int len)
    {
        HAL_UART_Transmit(&huart2, (uint8_t *)ptr, len, HAL_MAX_DELAY);
        return len;
    }

    and in main():

    /* USER CODE BEGIN WHILE */
    uint32_t n = 0;
    while (1)
    {
        printf("hello %lu\r\n", n++);
        HAL_Delay(500);
    }

    (Illustrative — write your own. Note \r\n so terminals show clean line breaks; HAL_UART_Transmit blocks until the frame(s) are clocked out, which is fine at 2 Hz.)

  3. Build and flash over the ST-LINK.

Part B — See it on the host terminal.

  1. Find the virtual COM port: on macOS it appears as /dev/tty.usbmodemXXXX. Open it at 115200 8N1. Options:
    • python -m serial.tools.miniterm /dev/tty.usbmodemXXXX 115200
    • screen /dev/tty.usbmodemXXXX 115200 (exit with Ctrl-A then k)
    • or any serial-monitor plugin/terminal your IDE offers (set the same 115200 8N1).
  2. You should see hello 0, hello 1, … tick by twice a second. If you get garbage, the baud in the terminal doesn’t match the firmware (see Analysis).

Part C — Decode the wire with the Saleae.

  1. Clip a Saleae channel to PA2 (TX) on the Morpho header (per Wiring & bench setup — the Arduino D0/D1 spots are not connected) and a GND lead to a Nucleo GND. Set the logic threshold to 3.3 V.
  2. Add the Async Serial analyzer in Logic 2: set Baud = 115200, 8 data bits, No parity, 1 stop bit, LSB first, idle level high. (Logic 2 can also auto-detect baud from the shortest pulse — try that and confirm it lands near 115200.)
  3. Capture while the board prints. The analyzer should overlay decoded ASCII (h, e, l, l, o, …) on the waveform. Zoom into one byte and identify the start bit, the eight data bits (LSB first), and the stop bit.

Part D — Measure the bit time and break it.

  1. With a timing measurement, span one bit and read its width → compare to \(t_{\text{bit}}=1/115200\approx8.68\ \mu\text{s}\). Span a full 8N1 frame (start → stop) → ≈ 86.8 µs.
  2. Deliberately induce a framing error: set the terminal (or the Saleae analyzer) to a different baud (e.g. 9600) while the firmware stays at 115200, and observe the garbage / mis-decode. Then set the firmware to a non-standard baud and confirm the Saleae’s measured bit-time changes as \(1/\text{baud}\).
  3. Optionally enable parity = Even in both the .ioc and the Saleae analyzer and watch the extra bit appear before the stop bit.

Deliverable & expected results

labs/lab-2-3/notes.md plus a Logic 2 capture with the Async Serial analyzer decoding your message, and a screenshot of the host terminal output.

Quantity Predicted Measured
Bit time at 115200 baud 8.68 µs
8N1 frame time (10 bits) 86.8 µs
Decoded byte for ASCII ‘h’ (0x68) LSB-first 0 0 0 1 0 1 1 0 between start/stop
Bit time after changing baud to 57600 17.36 µs
Effect of terminal/firmware baud mismatch garbage / framing error

Analysis & reconciliation

Confirm the measured bit-time equals \(1/\text{baud}\) to within a fraction of a percent — the STM32’s BRR divider quantizes the baud slightly, so the actual baud may differ from the nominal by a small amount; if the Saleae’s auto-detected baud is, say, 115108 instead of 115200, that is the divider rounding, and it is well within the ~2–3% tolerance UART allows. Verify the frame really is 10 bit-times (start + 8 + stop) and that the data bits are LSB-first by decoding one known character by hand: ‘h’ = 0x68 = 0110 1000, sent LSB-first as 0,0,0,1,0,1,1,0 between a low start bit and a high stop bit.

Explain the mismatch experiment: when the receiver’s baud disagrees with the sender’s, its mid-bit sampling instants drift; by the stop bit the sample lands in the wrong bit, the stop bit reads low, and the USART flags a framing error (FE). That is why “garbage on the terminal” almost always means wrong baud, not broken hardware — a lesson worth internalizing now, because you will use this console to debug every subsequent lab.

Cross-platform ports & language variants

See the syllabus Implementation tracks for the framing. A UART frame is a UART frame — the 8N1 anatomy you decoded is identical on every platform — so the port is about which serial device the OS hands you and what sits between write() and the wire.

Jetson Orin Nano — detailed procedure (embedded Linux)

The Jetson’s 40-pin header carries a hardware UART on pins 8 (TX) / 10 (RX), exposed to userspace as a /dev/ttyTHS* device (NVIDIA’s “Tegra High-Speed” UART driver). You will transmit from a Linux process, tap TX with the Saleae exactly as you did on the Nucleo, and confirm the same frames — then use the decode to see what the OS added: buffering between your write() and the first start bit. One-time board config: Jetson setup essentials.

Wiring (no breadboard; header map in the setup essentials):

flowchart LR
  JET["Jetson Orin Nano<br/>UART TX = header pin 8<br/>/dev/ttyTHS*"]
  SAL["Saleae Logic 8<br/>CH0 + GND lead"]
  SW["Logic 2<br/>Async Serial analyzer"]
  JET -- "CH0 taps pin 8<br/>GND → pin 6" --> SAL
  SAL -- "USB" --> SW

flowchart LR
  JET["Jetson Orin Nano<br/>UART TX = header pin 8<br/>/dev/ttyTHS*"]
  SAL["Saleae Logic 8<br/>CH0 + GND lead"]
  SW["Logic 2<br/>Async Serial analyzer"]
  JET -- "CH0 taps pin 8<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 (passive tap) UART TX pin 8
Saleae CH1 (optional, loopback step) UART RX pin 10

Same rules as the Nucleo tap: 3.3 V threshold, tap TX as a passive observer, never drive it.

Procedure.

  1. Find the device: ls /dev/ttyTHS* (typically ttyTHS1 on the Orin Nano devkit). If a getty/login service owns it, disable that first (per the setup essentials); confirm your user is in the dialout group or run with sudo once to check.
  2. Transmit the same message the STM32 sent, at the same 115200 8N1 — pyserial from Python (serial.Serial('/dev/ttyTHS1', 115200) and write(b'hello 0\r\n') in a 2 Hz loop — illustrative; write your own), or interactively with picocom/minicom on the device.
  3. In Logic 2, reuse the Async Serial analyzer setup from Part C (115200, 8N1, LSB first). Capture while the Jetson prints: the same start bit / 8 data bits / stop bit anatomy, the same 8.68 µs bit time — the wire doesn’t know an OS is behind it.
  4. Measure the bit time and one full frame; they must match the STM32 numbers exactly (both ends derive baud from a crystal — compare the two boards’ actual measured bauds and note the small divider-rounding difference).
  5. What the OS adds: with the Saleae still attached, timestamp the gap between frames while the Python loop runs. On the Nucleo, HAL_UART_Transmit starts clocking out within microseconds of the call; on Linux your bytes traverse the tty layer’s buffers first, and back-to-back write()s may coalesce into contiguous frames or spread out under load. Run the sender under load (e.g. stress-ng --cpu 4 in another shell) and watch the inter-frame gaps move — the frames themselves stay perfect (the UART hardware owns the bit clock), only when they start is jittered. That split — hardware-owned bit timing vs OS-owned dispatch — is the lesson.
  6. (Optional) Jumper pin 8 → pin 10 and read your own transmission back (cat /dev/ttyTHS1 or a pyserial read loop) — a loopback that proves RX without a second board.
  7. Save labs/lab-2-3/captures/jetson-uart-115200.sal and note the measured bit time and inter-frame-gap observations in notes.md.

Raspberry Pi 5 differences: identical wiring (pins 6/8/10); the device is /dev/ttyAMA0 once the UART is enabled in raspi-config. Everything else — pyserial, the analyzer settings, the load experiment — is unchanged.

Going further

  • Switch HAL_UART_Transmit (blocking) to interrupt or DMA transmit (HAL_UART_Transmit_IT / _DMA) so main() isn’t stalled during the frame — measure on the Saleae that the bytes still go out cleanly while the CPU is free.
  • Add RX: echo received characters back, and decode both TX and RX on two Saleae channels simultaneously to see the full-duplex exchange.
  • Bridge to Module 3: the same logic-analyzer decode workflow applies to I²CLab 3.1 puts the Saleae’s I²C analyzer on the bus to scan device addresses.