Lab 3.3 — MCP4725 Waveform Generation

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

Goal

Make the DAC move: stream a sine lookup table out of the MCP4725 over I²C and watch a real waveform appear on the Siglent scope. The point is not just “a sine on the screen” — it’s to feel, viscerally, that a DAC output is a staircase, that the achievable output frequency is throttled by I²C throughput, and that turning that staircase back into a smooth analog signal needs a reconstruction (anti-imaging) filter. This is where sampling theory stops being abstract. It also commissions the MCP4725 as your bench’s signal source — since the Siglent has no built-in generator, this DAC (and later the STM32’s own DAC/PWM) is what feeds every AC lab in Modules 4–6.

Equipment & parts

  • STM32 Nucleo-64 (NUCLEO-L476RG), USB console.
  • MCP4725 DAC on the I²C bus (address from Lab 3.1).
  • Siglent SDS1104X-E scope + 10× probe.
  • (Optional) Saleae Logic 8 on SDA/SCL to time the actual per-sample write.
  • Breadboard, jumpers, 3.3 V and ground.

Wiring & bench setup

The signal chain: the Nucleo streams Fast Writes to the MCP4725 over the Lab 3.1 bus, and the Siglent watches the staircase on the DAC’s OUT pin.

flowchart LR
  MCU["NUCLEO-L476RG<br/>I2C1 master<br/>SCL = PB8 (D15)<br/>SDA = PB9 (D14)"]
  DAC["MCP4725<br/>12-bit DAC<br/>addr from Lab 3.1"]
  SCOPE["Siglent SDS1104X-E<br/>CH1, 10x probe"]
  SAL["Saleae Logic 8<br/>(optional, T_write)"]
  MCU -- "SCL / SDA + 3V3 + GND" --> DAC
  DAC -- "OUT → probe tip<br/>ground clip → − rail" --> SCOPE
  SAL -. "CH0→SDA, CH1→SCL, GND→GND" .-> DAC

flowchart LR
  MCU["NUCLEO-L476RG<br/>I2C1 master<br/>SCL = PB8 (D15)<br/>SDA = PB9 (D14)"]
  DAC["MCP4725<br/>12-bit DAC<br/>addr from Lab 3.1"]
  SCOPE["Siglent SDS1104X-E<br/>CH1, 10x probe"]
  SAL["Saleae Logic 8<br/>(optional, T_write)"]
  MCU -- "SCL / SDA + 3V3 + GND" --> DAC
  DAC -- "OUT → probe tip<br/>ground clip → − rail" --> SCOPE
  SAL -. "CH0→SDA, CH1→SCL, GND→GND" .-> DAC

Pin map (every wire; the bus is unchanged from Lab 3.1 — the ADS1115 may stay parked on it):

From To Pin/jack
MCP4725 VDD + rail ← Nucleo 3V3 3V3 header pin
MCP4725 GND − rail ← Nucleo GND GND header pin
MCP4725 SCL I2C1 clock D15 (PB8)
MCP4725 SDA I2C1 data D14 (PB9)
MCP4725 OUT Siglent CH1 probe tip 10× passive probe
Probe ground clip breadboard − rail CH1 probe
Saleae CH0 / CH1 (optional) SDA / SCL CH0, CH1 leads
Saleae GND (optional) − rail any GND lead
 NUCLEO-L476RG                       breadboard
 ┌───────────────┐        ┌────────────────────────────────────────┐
 │    3V3  ●─────┼────────┤ + rail ───● VDD                        │
 │    GND  ●─────┼────────┤ − rail ───● GND     MCP4725            │
 │ D15/PB8 ●─────┼─ SCL ──┼───────────● SCL                        │
 │ D14/PB9 ●─────┼─ SDA ──┼───────────● SDA                        │
 └───────────────┘        │           ● OUT ──► CH1 probe tip (10×)│
                          │ − rail ──────────► CH1 ground clip     │
                          └────────────────────────────────────────┘

Setup gotchas: set the probe’s slide switch and the CH1 channel menu to 10× (a mismatch scales every reading by 10). The ground clip lands on the breadboard − rail only — it is earth-referenced. Pull-ups as in Lab 3.1 (on-board on most breakouts; one set total on the bus).

Safety & don’t-break-it

  • Probe the DAC output, not the bus, with the scope. Clip the 10× probe to the DAC OUT pin and the ground clip to the common ground. Don’t put the earth-referenced scope ground on any non-ground node.
  • Output stays within 0–3.3 V. Nothing here exceeds a rail, but remember the DAC can’t source real current — the scope’s 10× probe (high impedance) is a fine load; don’t hang resistive loads on OUT without a buffer.
  • Don’t expect a high-frequency waveform. If you push the update loop too hard it won’t “break,” but the output degrades into a coarse few-point staircase. That degradation is the lesson — read the Background before deciding a low output frequency is a bug.
  • Common ground between Nucleo, DAC, and scope. Keep the bus at 3.3 V.

Project & environment setup

Firmware — reuse the Module 3 project (firmware/m3-mixed/, created in Lab 3.1). One .ioc change for this lab:

CubeMX page Setting
Connectivity → I2C1 I2C mode, Fast 400 kHz for Parts A–B (Part C step 8 drops it back to Standard 100 kHz to watch \(f_s\) fall); PB8/PB9 unchanged
Connectivity → USART2 Asynchronous, 115200 8-N-1 (VCP console) — already configured
Clock Configuration 80 MHz HCLK per the setup essentials

Host — no host code this lab; the measurements come off the Siglent (screenshot/CSV to USB stick) and optionally Logic 2. Make the capture directory:

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

Where results go:

Artifact Path
Bench note (table-size vs. frequency results) labs/lab-3-3/notes.md
Scope screenshot, staircase sine labs/lab-3-3/captures/dac-sine.png
Scope screenshot, zoomed steps labs/lab-3-3/captures/dac-sine-zoom.png
(Optional) Saleae capture of one Fast Write labs/lab-3-3/captures/fast-write-timing.sal

Background

To synthesize a sine, precompute one period as a table of \(M\) codes,

\[D[k] = \operatorname{round}\!\left(2047.5\Big(1+\sin\tfrac{2\pi k}{M}\Big)\right),\quad k=0,\dots,M-1,\]

and write them to the DAC one after another in a tight loop. (Note the scale is \(2047.5\), not \(2048\): a full-amplitude \(2048(1+\sin)\) peaks at code \(4096\), one past the 12-bit maximum \(4095\) — and the Fast-Write packing (code >> 8) & 0x0F would silently wrap that peak sample to \(0\). In practice you also back the amplitude off a little — e.g. centre \(2048\) with amplitude \(\approx 2000\), as in the Procedure — to leave headroom against rail clipping and the reconstruction filter’s overshoot.) The output frequency is set by how fast you can push samples:

\[f_\text{out} = \frac{f_s}{M} = \frac{1}{M\,T_\text{write}},\]

where \(f_s = 1/T_\text{write}\) is the sample (update) rate and \(T_\text{write}\) is the time for one I²C Fast Write.

Throughput is the bottleneck. A Fast Write is a START + address byte + two data bytes + STOP ≈ ~28 SCL clocks (3 bytes × 9 clocks + framing). At fast-mode 400 kHz:

\[T_\text{write} \approx \frac{28}{400\text{ kHz}} \approx 70\ \mu s \;\Rightarrow\; f_s \approx 14\text{ kSa/s (optimistic — ignores gaps/overhead)}.\]

Realistically expect a few kSa/s. With a table of \(M = 32\) points that is

\[f_\text{out} \approx \frac{\sim 10\text{ kSa/s}}{32} \approx \text{a few hundred Hz at best.}\]

So the DAC makes clean low-frequency waveforms; you trade table size (smoothness) against output frequency. At 100 kHz standard mode it’s ~4× slower again.

The staircase and the ZOH. Between writes the DAC holds the last code, so the physical output is a zero-order-hold staircase, not the ideal sample train. The ZOH is equivalent to convolving the samples with a rectangular pulse of width \(T_\text{write}\), giving the frequency response

\[H_\text{ZOH}(f) = T_\text{write}\,\operatorname{sinc}(f\,T_\text{write}) = T_\text{write}\,\frac{\sin(\pi f T_\text{write})}{\pi f T_\text{write}}.\]

Two consequences: (1) a mild \(\operatorname{sinc}\) droop across the band (nulls at multiples of \(f_s\)), and (2) the sampling process replicates the spectrum, so spectral images sit at \(n f_s \pm f_\text{out}\). The ZOH suppresses but does not remove them — the visible staircase edges are those high-frequency images. To recover a smooth sine you follow the DAC with a reconstruction (anti-imaging) low-pass filter with cutoff between \(f_\text{out}\) and \(f_s/2\). You build exactly that filter in Lab 4.4 — Active low-pass filter; this lab is the “before” picture.

Procedure

Part A — Build and stream the table.

  1. In firmware, precompute a 32-point sine table of 12-bit codes (a const uint16_t sine32[32]), centered at 2048 with amplitude ~2000 so it doesn’t clip the rails.
  2. Configure I²C1 for 400 kHz (fast mode). Stream the table in a loop:
/* Illustrative only — you write the real firmware.
   Tightest portable loop: blocking Fast Writes back to back.
   f_out = f_s / 32, where f_s is set by how fast this loop runs. */
static const uint16_t sine32[32] = { /* 2048 + 2000*sin(2*pi*k/32), rounded */ };
for (;;) {
    for (uint8_t k = 0; k < 32; k++) {
        uint8_t buf[2] = { (uint8_t)((sine32[k] >> 8) & 0x0F),
                           (uint8_t)( sine32[k]       & 0xFF) };
        HAL_I2C_Master_Transmit(&hi2c1, (MCP4725_ADDR << 1), buf, 2, 2);
    }
}
  1. Probe OUT with the scope. Trigger on the waveform; you should see a periodic sine-ish trace built from visible steps.

Part B — Measure rate and frequency.

  1. Use the Siglent auto-measure (Freq, Vpp) to read \(f_\text{out}\). From it, back out the update rate: \(f_s = 32\,f_\text{out}\).
  2. (Optional) Put the Saleae on SDA/SCL and measure \(T_\text{write}\) directly — START to STOP of one Fast Write — and compare \(f_s = 1/T_\text{write}\) to the value inferred from the scope.
  3. Zoom the scope timebase until individual steps are visible. Count that there are 32 steps per period. Note the sharp staircase edges.

Part C — Trade table size vs. frequency.

  1. Rebuild with \(M = 16\) and \(M = 64\) tables. Confirm \(f_\text{out}\) roughly doubles / halves as \(M\) halves / doubles, while the update rate \(f_s\) stays about the same. Fewer points ⇒ higher frequency but coarser staircase.
  2. (Optional) Switch I²C back to 100 kHz and watch \(f_s\) (and thus \(f_\text{out}\)) drop ~4×.

Deliverable & expected results

A scope screenshot of the staircase sine (labs/lab-3-3/captures/dac-sine.png), a zoom showing individual steps, and a note relating table size to output frequency. Predicted numbers assume an optimistic \(f_s \approx 10\) kSa/s at 400 kHz — your measured \(f_s\) sets the truth.

Quantity Predicted (fast-mode, est.) Measured
One Fast Write \(T_\text{write}\) ~70 µs (≈28 SCL/400 kHz)
Update rate \(f_s = 1/T_\text{write}\) ~a few–14 kSa/s
\(f_\text{out}\), \(M=32\) \(f_s/32\) ≈ a few hundred Hz
\(f_\text{out}\), \(M=16\) ~2× the \(M=32\) value
Steps visible per period 32 (for \(M=32\))
First ZOH null (at \(f=f_s\)) at \(f_s\) Hz

Analysis & reconciliation

Confirm \(f_\text{out}\cdot M \approx f_s\) across your \(M=16/32/64\) runs — the update rate should be roughly constant because it’s set by I²C, not by the table. If the scope-inferred \(f_s\) is lower than the Saleae-measured \(1/T_\text{write}\), the gap is loop overhead: HAL call overhead, the START/STOP setup, and any per-iteration work between writes. That overhead is exactly why real waveform DACs are driven by timer-triggered DMA, not a blocking loop — note that as motivation for Module 5. Look hard at the staircase: the steps are the spectral images the ZOH couldn’t remove, and the slight amplitude droop toward higher \(f_\text{out}\) is the \(\operatorname{sinc}\) roll-off of \(H_\text{ZOH}\). When you add the Lab 4.4 reconstruction filter, those edges smooth out and the sine becomes clean — that before/after is the payoff.

Cross-platform ports & language variants

See the syllabus Implementation tracks for the framing. This lab is where the SBC port gets interesting: a DC sweep (Lab 3.2) hid the userspace write path’s timing, but a waveform is nothing but timing — \(f_s\) is however fast your loop runs, and every wobble in \(T_\text{write}\) is aperture jitter you can see on the scope.

Jetson Orin Nano — detailed procedure (embedded Linux)

One-time board config: Jetson setup essentials.

Wiring — the Lab 3.1 Jetson hookup (Jetson pins 1/3/5/6 → the bus rails), with the Siglent’s 10× probe on the DAC OUT and its ground clip on the − rail, exactly as in the STM32 half of this lab; keep the Saleae on SDA/SCL for the \(T_\text{write}\) measurement.

flowchart LR
  JET["Jetson Orin Nano<br/>smbus2 / C loop<br/>/dev/i2c-7"]
  DAC["MCP4725<br/>ZOH staircase out"]
  SCOPE["Siglent SDS1104X-E<br/>CH1, 10x probe"]
  SAL["Saleae Logic 8<br/>T_write per sample"]
  JET -- "SCL / SDA + 3V3 + GND" --> DAC
  DAC -- "OUT → probe tip" --> SCOPE
  SAL -. "CH0→SDA, CH1→SCL" .-> DAC

flowchart LR
  JET["Jetson Orin Nano<br/>smbus2 / C loop<br/>/dev/i2c-7"]
  DAC["MCP4725<br/>ZOH staircase out"]
  SCOPE["Siglent SDS1104X-E<br/>CH1, 10x probe"]
  SAL["Saleae Logic 8<br/>T_write per sample"]
  JET -- "SCL / SDA + 3V3 + GND" --> DAC
  DAC -- "OUT → probe tip" --> SCOPE
  SAL -. "CH0→SDA, CH1→SCL" .-> DAC

Procedure.

  1. mkdir -p labs/lab-3-3/edge. Port the 32-point table loop to Python first: the same two Fast-Write bytes per sample through smbus2 on bus 7, written back-to-back in a while True loop (illustrative — write your own; it is the Lab 3.2 helper in a loop).
  2. Probe OUT: a staircase sine appears, exactly as on the Nucleo — but measure \(f_\text{out}\) and back out \(f_s = 32\,f_\text{out}\). Python + kernel round-trips per write put \(f_s\) well below the MCU’s blocking-loop rate.
  3. Look at the period stability. Set the scope to trigger on the waveform and watch the trace: occasional stretched steps are writes the scheduler delayed. On the Saleae, measure many individual \(T_\text{write}\) gaps (START-to-START) and note min/typ/max — on the Nucleo this spread was tiny; here the tail is the OS. This is the same story as Lab 2.2’s jitter, now written into an analog waveform.
  4. Rewrite the loop in C (open("/dev/i2c-7"), ioctl(I2C_SLAVE, addr), 2-byte write() per sample — build in edge/ per the setup essentials) and re-measure: \(f_s\) rises (no interpreter), but the START-to-START spread barely improves — the cost is the syscall + kernel I²C transaction, not Python.
  5. Run the C loop under sudo taskset -c 3 chrt -f 80 ./sinegen and re-measure the spread: the scheduler tail shrinks; the syscall floor stays. Record all three (f_s, spread) pairs.
  6. Save scope shots as labs/lab-3-3/captures/jetson-sine.png and the Saleae session as jetson-write-timing.sal.

What it teaches: the MCU’s blocking loop was slow but steady; the Jetson is faster per instruction but unsteady per write. Neither is the right waveform generator — the right answer is hardware pacing (the MCU’s timer+DMA, previewed in Going further, or a real DAC peripheral), and now you have the numbers to say why.

Build (same DAC, same table) \(f_s\) START-to-START spread Measured
STM32 blocking HAL loop (above) ~kSa/s tight
Jetson, Python smbus2 lower wide tail
Jetson, C write() loop higher than Python tail persists
Jetson, C + chrt -f ≈ C tail shrinks, floor stays

Raspberry Pi 5 differences: bus 1; otherwise identical (both boards’ kernel I²C drivers pace the bus the same way).

Going further

  • Drive the table from a timer + DMA instead of the blocking loop and see how much higher \(f_s\) (and \(f_\text{out}\)) go, and how much steadier the timing becomes on the Saleae.
  • Generate a triangle and a square table and predict, then observe, their harmonic content on the scope’s FFT math — the square’s odd harmonics and the sinc images should be visible.
  • Feed the raw staircase into the Lab 4.4 filter and capture the reconstructed sine; measure the residual image amplitude before and after.