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.
Recommended reading
Kuo Ch. 1 — the embedded DSP system and its host/console link. Light.
STM32L476 reference manual (RM0351) — the USART chapter: frame format (start/data/parity/stop), and the baud-rate register (BRR) / oversampling. Read the frame-format figure.
NUCLEO-L476RG user manual (UM1724) — the section confirming USART2 (PA2 TX / PA3 RX) is connected to the ST-LINK VCP by default.
Saleae Logic 2 docs — “Async Serial analyzer”: setting baud, bits, parity, stop bits, and bit order.
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/activatemkdir-p labs/lab-2-3/capturespython-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.
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:
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.
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).
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.)
Build and flash over the ST-LINK.
Part B — See it on the host terminal.
Find the virtual COM port: on macOS it appears as /dev/tty.usbmodemXXXX. Open it at 115200 8N1. Options:
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).
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.
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.
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.)
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.
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.
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}\).
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.
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.
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.
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.
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.
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).
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.
(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.
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²C — Lab 3.1 puts the Saleae’s I²C analyzer on the bus to scan device addresses.