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.

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.

Background

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

\[D[k] = \operatorname{round}\!\left(\frac{V_\text{DD}}{2}\Big(1 + \sin\tfrac{2\pi k}{M}\Big)\cdot\frac{4096}{V_\text{DD}}\right) = \operatorname{round}\!\left(2048\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. 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 (captures/lab-3-3-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.

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.