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.
Recommended reading
Zölzer Ch. 3 — AD/DA conversion: reconstruction and the zero-order hold. A DAC that holds each sample until the next is a ZOH, and the ZOH has a known \(\operatorname{sinc}\)-shaped frequency response that droops the passband and passes spectral images. This is the reading this lab is built on.
Lyons Ch. 2 (periodic sampling) — the practitioner’s picture of samples, replicated spectra, and why images appear at multiples of the update rate.
Course 1: Lesson 33 — Sampling (the sampling theorem and reconstruction; Lesson 49 has the rigorous Dirac-comb version).
The MCP4725 datasheet — Fast Write timing and output settling.
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.
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
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:
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:
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.
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.
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. */staticconstuint16_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);}}
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.
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}\).
(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.
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.
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.
(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)
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.
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).
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.
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.
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.
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.
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.