Lab 3.1 — I²C Scan + Saleae Decode

Course 2 syllabus · Module 3 · Prev: « Lab 2.3 · Next: Lab 3.2 »

Goal

Bring up the I²C bus on the STM32 and prove it works before trusting it with real data. You will wire both Module 3 peripherals — the MCP4725 DAC and the ADS1115 ADC — onto one shared two-wire bus, write a firmware address scanner that walks every 7-bit address and reports which ones ACK, and then put the Saleae Logic 8 on the same wires to decode the START/address/R‑W/ACK/STOP framing byte-for-byte. Being able to bring up an unfamiliar I²C device, confirm its address, and read the bus on a logic analyzer is a daily skill for firmware and DSP embedded engineers — almost every sensor, DAC, ADC, and codec you will ever touch speaks I²C or SPI, and “it doesn’t respond” is the most common bring-up failure.

Equipment & parts

  • STM32 Nucleo-64 (NUCLEO-L476RG) + USB cable to the host (ST-LINK VCP for the console).
  • MCP4725 12-bit I²C DAC breakout.
  • ADS1115 16-bit I²C ADC breakout.
  • Saleae Logic 8 logic analyzer + Logic 2 software.
  • Breadboard, jumper wires, and two ~4.7 kΩ resistors for the SDA/SCL pull-ups (from the kit).
  • Both breakouts and the Nucleo run at 3.3 V for this lab (tie every VDD to the Nucleo’s 3V3 pin so the whole bus is one logic level — 5 V level-shifting is deferred to Lab 3.5).

Wiring & bench setup

The signal chain: the Nucleo is the I²C master, the MCP4725 and ADS1115 hang off the same two-wire bus as slaves, and the Saleae taps SDA/SCL to decode the traffic.

flowchart LR
  MCU["NUCLEO-L476RG<br/>I2C1 master<br/>SCL = PB8 (D15)<br/>SDA = PB9 (D14)"]
  DAC["MCP4725<br/>addr ~0x60"]
  ADC["ADS1115<br/>ADDR→GND = 0x48"]
  SAL["Saleae Logic 8<br/>I2C analyzer"]
  MCU -- "SCL / SDA + 3V3 + GND" --> DAC
  MCU -- "same four rails" --> ADC
  SAL -. "CH0→SDA, CH1→SCL, GND→GND" .-> MCU

flowchart LR
  MCU["NUCLEO-L476RG<br/>I2C1 master<br/>SCL = PB8 (D15)<br/>SDA = PB9 (D14)"]
  DAC["MCP4725<br/>addr ~0x60"]
  ADC["ADS1115<br/>ADDR→GND = 0x48"]
  SAL["Saleae Logic 8<br/>I2C analyzer"]
  MCU -- "SCL / SDA + 3V3 + GND" --> DAC
  MCU -- "same four rails" --> ADC
  SAL -. "CH0→SDA, CH1→SCL, GND→GND" .-> MCU

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

From To Pin/jack
Nucleo 3V3 breadboard + rail 3V3 header pin
Nucleo GND breadboard − rail GND header pin
Nucleo I2C1 SCL breadboard SCL rail D15 (PB8)
Nucleo I2C1 SDA breadboard SDA rail D14 (PB9)
MCP4725 VDD / GND + rail / − rail
MCP4725 SCL / SDA SCL rail / SDA rail
ADS1115 VDD / GND + rail / − rail
ADS1115 SCL / SDA SCL rail / SDA rail
ADS1115 ADDR − rail (⇒ address 0x48)
Saleae CH0 SDA rail CH0 lead
Saleae CH1 SCL rail CH1 lead
Saleae GND − rail any GND lead
 NUCLEO-L476RG              breadboard
 ┌───────────────┐   ┌──────────────────────────────────────────────┐
 │    3V3  ●─────┼───┤ + rail ───● VDD           ● VDD              │
 │    GND  ●─────┼───┤ − rail ───● GND  MCP4725  ● GND   ADS1115    │
 │ D15/PB8 ●─────┼───┤ SCL rail ─● SCL           ● SCL              │
 │ D14/PB9 ●─────┼───┤ SDA rail ─● SDA           ● SDA              │
 └───────────────┘   │                  − rail ──● ADDR  (→ 0x48)   │
                     └──────────────────────────────────────────────┘
  Saleae: CH0 → SDA rail · CH1 → SCL rail · GND → − rail

Setup gotchas: most MCP4725/ADS1115 breakouts carry on-board pull-ups (often 10 kΩ each); two boards in parallel is usually fine, so add the external 4.7 kΩ SDA→3V3 / SCL→3V3 pair only if the bus has none (see Safety). Keep the bus wires short — breadboard capacitance is what rounds the edges you’ll inspect in Part C.

Safety & don’t-break-it

  • Keep the whole bus at 3.3 V for this lab. Power both breakouts from the Nucleo’s 3V3 pin, not 5 V. The STM32’s SDA/SCL are not 5 V tolerant here, and a 5 V idle-high bus would push current into the pins through the pull-ups. (Mixing a 5 V device onto this bus is exactly what Lab 3.5 is for.)
  • You need pull-up resistors, and only one set. I²C SDA and SCL are open-drain: the bus can only pull low, so external pull-ups to 3V3 create the high level. Many breakouts already have on-board pull-ups (often 10 kΩ). Two devices’ pull-ups in parallel is usually fine; adding your own ~4.7 kΩ on top lowers the effective resistance. If the bus won’t idle high or edges look slow, check how many pull-ups are actually on the wire before adding more.
  • Common ground is mandatory. The Nucleo GND, both breakout GNDs, and the Saleae GND must all tie together. An I²C bus without a shared ground reads as garbage or as a dead bus.
  • Don’t hot-swap chips on a powered bus. Insert/remove breakouts with the Nucleo unpowered. Double-check VDD and GND are not swapped on each breakout before applying power — a reversed rail can kill the part.
  • The Saleae is not a scope. Keep its inputs within ~5 V and share ground with the DUT. Set its logic threshold to 3.3 V so it interprets levels correctly.

Project & environment setup

Firmware — create the Module 3 project firmware/m3-mixed/ per the project workflow: STM32CubeMX → New Project → board NUCLEO-L476RG → generate with Toolchain/IDE = CMake into firmware/m3-mixed/ (then restore the repo’s CMakeLists.txt/CMakePresets.json), and open the folder in CLion. All of Module 3 shares this one project — Labs 3.2–3.5 reuse it — so get the .ioc right here:

CubeMX page Setting
Connectivity → I2C1 I2C mode, Standard 100 kHz; pins auto-assign to PB8 = SCL / PB9 = SDA (D15/D14)
Connectivity → USART2 Asynchronous, 115200 8-N-1 — the ST-LINK VCP console the scan prints to
Clock Configuration 80 MHz HCLK per the setup essentials

(Start at 100 kHz standard mode; Lab 3.3 bumps I2C1 to fast-mode 400 kHz.)

Host — no host code this lab; Logic 2 (installed per Toolchain) is the only host-side tool. Just make the capture directory:

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

Where results go:

Artifact Path
Bench note (addresses found, annotated ACK/NACK frames) labs/lab-3-1/notes.md
Logic 2 session of the scan labs/lab-3-1/captures/i2c-scan.sal

Background

I²C is a two-wire, open-drain, multi-drop bus: SDA (data) and SCL (clock), each idling high through a pull-up. The master (the STM32) drives all clocking. A transaction is framed as:

  • START — SDA falls while SCL is high.
  • Address frame — 7 address bits, MSB first, then an R/W̄ bit (0 = write, 1 = read). The addressed slave pulls SDA low for the 9th clock: an ACK. No slave present ⇒ SDA stays high ⇒ NACK.
  • Data bytes — 8 bits each, every byte followed by an ACK/NACK.
  • STOP — SDA rises while SCL is high.

An I²C scan exploits the address-frame ACK: for each 7-bit address \(0x08\)\(0x77\), the master issues START + (address, write) and checks the 9th-clock level. An ACK means “something lives here.” In HAL, HAL_I2C_IsDeviceReady() does exactly this — one address probe with a short timeout.

The wire address on the bus is the 8-bit byte (addr << 1) | R/W̄. This is the classic 7-bit-vs-8-bit confusion: a datasheet “0x60” 7-bit address appears on the Saleae as 0xC0 for a write or 0xC1 for a read. Keep the two representations straight.

Expected addresses on this bus (verify — do not assume):

  • MCP4725 — 7-bit 0x60–0x67 depending on the A0 pin and factory variant; HiLetgo boards are commonly 0x60, some 0x62.
  • ADS1115 — 7-bit set by the ADDR pin: 0x48 (ADDR→GND, default), 0x49 (→VDD), 0x4A (→SDA), 0x4B (→SCL).

Procedure

Part A — Wire the bus (power off).

  1. On the breadboard, create an SDA rail and an SCL rail. Wire the Nucleo PB9 = SDA and PB8 = SCL (I2C1) to those rails. Tie a 3V3 rail and a GND rail from the Nucleo.
  2. Connect MCP4725: VDD→3V3, GND→GND, SDA→SDA rail, SCL→SCL rail. Leave its A0 pin as the board default for now (note which — it sets the address).
  3. Connect ADS1115: VDD→3V3, GND→GND, SDA→SDA rail, SCL→SCL rail, ADDR→GND (address 0x48).
  4. If the bus does not already have pull-ups, add ~4.7 kΩ from SDA→3V3 and SCL→3V3. (Check the breakouts first — see Safety.)
  5. Clip the Saleae onto the bus: CH0→SDA, CH1→SCL, and a Saleae GND→the common GND rail.

Part B — Firmware I²C scan.

  1. In STM32CubeMX, enable I2C1 on PB8/PB9 (Standard-mode 100 kHz to start), and USART2 for the console (printf over the ST-LINK VCP). Generate the CMake project — this is firmware/m3-mixed/ per Project & environment setup; the rest of Module 3 reuses it. Open it in CLion, build, and flash (--target flash-m3-mixed).
  2. Write a scan loop that probes every address and prints the hits:
/* Illustrative only — you write the real firmware.
   Walk the 7-bit address space and report every device that ACKs. */
for (uint8_t addr = 0x08; addr <= 0x77; addr++) {
    /* HAL wants the address left-shifted into the 8-bit field. */
    if (HAL_I2C_IsDeviceReady(&hi2c1, (uint16_t)(addr << 1),
                              1 /*trials*/, 5 /*ms timeout*/) == HAL_OK) {
        printf("Found device at 0x%02X\r\n", addr);
    }
}
printf("Scan complete.\r\n");
  1. Open a serial console on the VCP (115200 8N1). Flash and run. You should see two hits — expect ~0x60 (DAC) and 0x48 (ADC). Record the actual addresses.

Part C — Decode on the Saleae.

  1. In Logic 2, set the digital threshold to 3.3 V, sampling rate a few MS/s, and add an I²C analyzer on CH0 (SDA) / CH1 (SCL).
  2. Capture while the scan runs. Zoom to a single probed address and read the decoded frame: START, the 7-bit address + R/W̄ bit, the ACK/NACK on the 9th clock, and the STOP.
  3. Find an address that NACKs (no device) and one that ACKs (the DAC or ADC). Confirm the ACK bit is SDA held low by the slave on the 9th clock. Confirm the on-wire byte equals (addr<<1) — e.g. a 0x60 device shows 0xC0 for the write frame.

Deliverable & expected results

Capture a Logic 2 session (labs/lab-3-1/captures/i2c-scan.sal) and a short note recording: the two addresses your scan found, one annotated ACK frame and one NACK frame, and the on-wire 8-bit byte for a write to the DAC.

Quantity Predicted Measured
MCP4725 7-bit address 0x60 (verify — may be 0x62)
ADS1115 7-bit address (ADDR→GND) 0x48
On-wire byte, write to 0x60 0xC0
On-wire byte, read from 0x48 0x91
ACK level on 9th clock (device present) SDA pulled low (0)
Number of devices found by scan 2

Analysis & reconciliation

The two firmware hits and the two ACKing addresses on the Saleae must agree — that is the whole point of cross-checking a software scan against a bus decode. If the DAC address is 0x62 rather than 0x60, that’s a legitimate board variant, not a bug; record what your part actually is and use it in Lab 3.2. If a device doesn’t ACK, work the usual list: swapped SDA/SCL, missing common ground, no pull-ups (bus never idles high), or wrong HAL_I2C_IsDeviceReady shift (passing the 7-bit value un-shifted probes the wrong address). If edges look badly rounded on the Saleae, your pull-ups are too weak (resistance too high) for the bus capacitance — you’ll quantify this rise-time effect in Lab 3.5.

Cross-platform ports & language variants

See the syllabus Implementation tracks for the framing; this is the I²C-scan-specific version. This is a software-stack contrast, not a latency one — the scan is instant everywhere; what changes is where the work happens, from bare register poking on the MCU to a kernel driver on the SBC.

STM32 bare-metal (C, and Rust). The whole scan is ~15 lines: a loop over 0x08–0x77 calling HAL_I2C_IsDeviceReady, which drives START + address + checks the 9th-clock ACK by hand on the peripheral. In Rust, the same logic sits behind the embedded-hal I2c trait implemented by stm32l4xx-hal — a portable write/read probe — so the driver code is chip-agnostic and reusable against any HAL that implements the trait.

Raspberry Pi 5 (Linux userspace, shell/Python, and Rust). The entire firmware becomes one command: i2cdetect -y 1, which walks /dev/i2c-1 through the kernel I²C driver and prints the address map — or a few lines of smbus2 in Python. The Pi’s I²C pins are natively 3.3 V, so the identical bus from this lab works unmodified: same MCP4725/ADS1115, same pull-ups, same addresses. In Rust, rppal (or linux-embedded-hal, which re-exposes the kernel bus behind the same embedded-hal I2c trait) gives the portable-driver path — the exact same trait you targeted on the STM32, now over /dev/i2c-1.

Jetson Orin Nano — detailed procedure (embedded Linux)

The same physical bus, scanned through a kernel driver instead of your firmware — and the Saleae can’t tell the difference, which is the point. No GPU role; this is a bus-enumeration task. One-time board config: Jetson setup essentials.

Wiring. The breadboard bus — both chips, rails, pull-ups, and the Saleae taps — stays exactly as built. Only the master changes: unplug the Nucleo’s four wires from the rails and land the Jetson’s header instead (map in the setup essentials):

flowchart LR
  JET["Jetson Orin Nano<br/>I²C master via kernel driver<br/>SDA = pin 3, SCL = pin 5"]
  DAC["MCP4725<br/>addr ~0x60"]
  ADC["ADS1115<br/>ADDR→GND = 0x48"]
  SAL["Saleae Logic 8<br/>I2C analyzer (unchanged)"]
  JET -- "SCL / SDA + 3V3 + GND" --> DAC
  JET -- "same four rails" --> ADC
  SAL -. "CH0→SDA, CH1→SCL, GND→GND" .-> JET

flowchart LR
  JET["Jetson Orin Nano<br/>I²C master via kernel driver<br/>SDA = pin 3, SCL = pin 5"]
  DAC["MCP4725<br/>addr ~0x60"]
  ADC["ADS1115<br/>ADDR→GND = 0x48"]
  SAL["Saleae Logic 8<br/>I2C analyzer (unchanged)"]
  JET -- "SCL / SDA + 3V3 + GND" --> DAC
  JET -- "same four rails" --> ADC
  SAL -. "CH0→SDA, CH1→SCL, GND→GND" .-> JET

From (Jetson header) To (breadboard) Header pin
3.3 V + rail pin 1
GND − rail pin 6
I²C SDA SDA rail pin 3
I²C SCL SCL rail pin 5
 Jetson 40-pin header                breadboard (unchanged from Part A)
 ┌───────────────┐        ┌──────────────────────────────────────────────┐
 │ pin 1  3V3 ●──┼────────┤ + rail ───● VDD           ● VDD              │
 │ pin 3  SDA ●──┼────────┤ SDA rail ─● SDA  MCP4725  ● SDA   ADS1115    │
 │ pin 5  SCL ●──┼────────┤ SCL rail ─● SCL           ● SCL              │
 │ pin 6  GND ●──┼────────┤ − rail ───● GND           ● GND  ● ADDR      │
 └───────────────┘        └──────────────────────────────────────────────┘
  Saleae stays: CH0 → SDA rail · CH1 → SCL rail · GND → − rail

The Jetson header is natively 3.3 V, so the identical bus works unmodified — same chips, same pull-ups, same addresses. (Note the devkit’s own header circuitry adds its pull-ups to the bus; if edges look different from the Nucleo capture, count the pull-ups, exactly as this lab teaches.)

Procedure.

  1. List the I²C controllers and identify the header bus: i2cdetect -l. On the Orin Nano devkit, header pins 3/5 are bus 7.
  2. Scan it: i2cdetect -y -r 7. The printed address map should show your two devices at the same 7-bit addresses the firmware scan found — ~0x60 and 0x48. The entire Part B firmware has become one command against /dev/i2c-7.
  3. Capture the scan on the Saleae (nothing about the Logic 2 setup changes) and compare frames against the STM32 capture: same START/address/R-W̄/ACK anatomy, byte-for-byte. Note the SCL frequency — on the Jetson the bus speed is a kernel/device-tree property, not a per-call API argument like the .ioc setting; the header buses default to 100 kHz.
  4. Re-do the scan from Python (smbus2, a ~10-line loop probing each address with a zero-length write wrapped in try/except — illustrative; write your own) to see the programmatic path a real driver uses, then in Rust with linux-embedded-hal if you’re running the Rust track: the same embedded-hal I2c trait you targeted on the STM32, now backed by /dev/i2c-7.
  5. Save the capture as labs/lab-3-1/captures/jetson-i2c-scan.sal and record the address map in notes.md.

Raspberry Pi 5 differences: pins 3/5 are bus 1i2cdetect -y 1 — and the Pi’s header carries its own strong (1.8 kΩ) on-board bus pull-ups; everything else is identical.

Measure and compare (the interesting column is where the work lives, not speed):

Platform / build Scan implementation Where the work happens Lines of code Measured
STM32 bare-metal, C (HAL) probe loop over addresses firmware drives the peripheral ~15
STM32 bare-metal, Rust (embedded-hal) trait I2c probe firmware, portable driver ~15
Pi 5, i2cdetect / smbus2 kernel I²C driver Linux driver + /dev/i2c-1 ~1
Pi 5, Rust (linux-embedded-hal) same I2c trait as MCU kernel bus behind trait ~10
Jetson, i2cdetect -y -r 7 / smbus2 kernel I²C driver Linux driver + /dev/i2c-7 ~1

Going further

  • Re-run the scan at 400 kHz (fast mode) and compare the SCL period and edge shape on the Saleae to the 100 kHz capture.
  • Model the bus edges in LTspice (the LTspice workflow): the I²C rising edge is just an RC — the pull-up \(R\) charging the bus capacitance \(C_b\) toward 3.3 V (the falling edge is fast; the driver yanks it low). Schematic hardware/lab-3-1.asc: a 3.3 V rail, the effective pull-up (your externals ∥ each breakout’s on-board pair — compute the parallel value first) to the bus node, ~50–100 pF from the node to ground for a breadboard bus, and a PULSE voltage source driving a voltage-controlled switch to ground standing in for the open-drain driver. .tran a few bus clocks and read the rise time (\(\approx 2.2\,RC_b\) for 10–90%); then measure the same rise on the Siglent (the Saleae only shows threshold crossings) and reconcile the two — solving for \(C_b\) from the measured rise tells you what your breadboard actually looks like electrically. The same sim predicts why Lab 3.5’s two-domain bus edges differ.
  • Change the ADS1115 ADDR pin to VDD, SDA, or SCL and confirm the scanned address moves to 0x49 / 0x4A / 0x4B as the datasheet predicts.
  • Add the MCP4725 A0 strap change (if your board exposes it) and watch the DAC address shift — good proof you understand the addressing scheme before you start writing real data in the next lab.