Lab 3.2 — MCP4725 DAC Voltage Output

Course 2 syllabus · Module 3 · Prev: « Lab 3.1 · Next: Lab 3.3 »

Goal

Turn digital codes into a real analog voltage and measure the result. You will drive the MCP4725 12-bit DAC over I²C across its full code range 0–4095, measure the output with the Fluke 117, and build the code→voltage transfer characteristic by hand and on the bench. Along the way you nail down the vocabulary every mixed-signal engineer must own cold: LSB size, the linear code-to-voltage map, quantization error, offset/gain/linearity, and why the reference voltage is the thing that actually sets your accuracy. This DAC is also your bench’s missing signal source — the Siglent has no built-in generator — so getting its DC transfer function trustworthy here pays off in every AC lab downstream.

Equipment & parts

  • STM32 Nucleo-64 (NUCLEO-L476RG), powered/consoled over USB.
  • MCP4725 DAC breakout on the I²C bus from Lab 3.1 (use the address you actually found).
  • Fluke 117 DMM in DC-volts mode + leads.
  • Breadboard, jumpers, common 3.3 V supply and ground.
  • (Optional) Saleae Logic 8 to confirm the write frames.

Wiring & bench setup

The signal chain: the Nucleo is the I²C master, the MCP4725 the only slave, and the Fluke reads the analog result.

flowchart LR
  MCU["NUCLEO-L476RG<br/>I2C1 master<br/>SCL = PB8 (D15)<br/>SDA = PB9 (D14)"]
  DAC["MCP4725<br/>12-bit DAC<br/>addr 0x60/0x62"]
  DMM["Fluke 117<br/>DC volts"]
  SAL["Saleae Logic 8<br/>(optional)"]
  MCU -- "SCL / SDA + 3V3 + GND" --> DAC
  DAC -- "OUT → red lead<br/>GND → black lead" --> DMM
  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 0x60/0x62"]
  DMM["Fluke 117<br/>DC volts"]
  SAL["Saleae Logic 8<br/>(optional)"]
  MCU -- "SCL / SDA + 3V3 + GND" --> DAC
  DAC -- "OUT → red lead<br/>GND → black lead" --> DMM
  SAL -. "CH0→SDA, CH1→SCL, GND→GND" .-> DAC

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

From (MCP4725 breakout) To Nucleo pin
VDD breadboard + rail ← Nucleo 3.3 V 3V3
GND breadboard − rail ← Nucleo ground GND
SCL I2C1 clock D15 (PB8)
SDA I2C1 data D14 (PB9)
OUT Fluke red lead (VΩ jack)
A0 (if present) − rail for addr 0x60 (or follow your breakout’s marking — use the address Lab 3.1 actually found)

Fluke black lead (COM) → breadboard − rail. Saleae (optional): CH0 → SDA, CH1 → SCL (the assignment fixed in Lab 3.1 — keep it identical in every Module 3 capture so saved Logic 2 configs reload cleanly), any Saleae GND → − rail.

 NUCLEO-L476RG                       breadboard
 ┌───────────────┐        ┌───────────────────────────────────┐
 │    3V3  ●─────┼────────┤ + rail ───● VDD                   │
 │    GND  ●─────┼────────┤ − rail ───● GND     MCP4725       │
 │ D15/PB8 ●─────┼─ SCL ──┼───────────● SCL     breakout      │
 │ D14/PB9 ●─────┼─ SDA ──┼───────────● SDA                   │
 └───────────────┘        │           ● OUT ──► Fluke red (V⎓)│
                          │ − rail ──────────► Fluke black    │
                          └───────────────────────────────────┘

Most MCP4725 breakouts carry on-board I²C pull-ups; if yours is bare, add 4.7 kΩ from SCL→3V3 and SDA→3V3 (the bus needs exactly one set of pull-ups — this is the same bus you validated in Lab 3.1).

Safety & don’t-break-it

  • The DAC output can only reach its own supply rail. With VDD = 3V3, full-scale (code 4095) is ≈ 3.3 V, never 5 V — do not expect otherwise, and do not feed this output into anything that needs more headroom without buffering (Lab 4.1).
  • Know your VDD precisely. The MCP4725 uses VDD as its reference. Whatever error is on your 3.3 V rail is directly the error on every output voltage. Measure the actual VDD with the Fluke first and use that number in every prediction — don’t assume exactly 3.300 V.
  • Don’t short the output or draw real current from it. The DAC output buffer is not a power source; loading it (or shorting to ground/rail) distorts the reading and can stress the part. The Fluke’s voltmeter input is high-impedance — that’s the correct load.
  • Keep the bus at 3.3 V (as in Lab 3.1). Common ground between the Nucleo, the DAC, and the Fluke.

Project & environment setup

Firmware — reuse the Module 3 project (firmware/m3-mixed/, created in Lab 3.1). Nothing new to configure; confirm the .ioc has:

CubeMX page Setting
Connectivity → I2C1 I2C mode, Standard 100 kHz (Fast 400 kHz also fine); pins auto-assign to PB8/PB9
Connectivity → USART2 Asynchronous, 115200 8-N-1 — the ST-LINK VCP console you’ll type codes into
Clock Configuration 80 MHz HCLK per the setup essentials (not timing-critical here, but keep every lab consistent)

Host — the transfer-characteristic plot and line fit run on the Mac, in the course venv (see Toolchain):

source venv/bin/activate        # numpy + matplotlib are all this lab needs
mkdir -p labs/lab-3-2/host labs/lab-3-2/captures

Put your plotting/fit script in labs/lab-3-2/host/ (numpy for the code→voltage arrays and the straight-line fit, matplotlib for the plot — you write the script; it’s ~15 lines).

Keep this lab’s reconciliation in labs/lab-3-2/host/analysis.ipynb — the notebook convention — and export final figures next to it.

Where results go:

Artifact Path
Bench note (tables below, filled in) labs/lab-3-2/notes.md
Hand-recorded Fluke readings (CSV: code,V_predicted,V_measured) labs/lab-3-2/captures/sweep.csv
Code-vs-voltage plot + fit labs/lab-3-2/host/transfer.png
(Optional) Saleae capture of the Fast Write frames labs/lab-3-2/captures/fast-write.sal

Background

An ideal \(N\)-bit DAC with reference (here supply) \(V_\text{DD}\) maps an integer code \(D \in \{0,1,\dots,2^N-1\}\) to an output

\[V_\text{out} = \frac{D}{2^N}\,V_\text{DD}, \qquad N = 12,\ 2^N = 4096.\]

The least-significant-bit (LSB) size — the voltage step per code — is

\[V_\text{LSB} = \frac{V_\text{DD}}{2^N} = \frac{V_\text{DD}}{4096}.\]

For \(V_\text{DD} = 3.30\text{ V}\): \(V_\text{LSB} = 3.30/4096 \approx 0.806\text{ mV}\). Code 0 gives 0 V; code 4095 gives \(\tfrac{4095}{4096}V_\text{DD} \approx V_\text{DD} - V_\text{LSB} \approx 3.299\text{ V}\) (one LSB below the rail — a full-scale DAC output never quite reaches the reference).

Quantization is the fact that only these discrete steps exist. If you want an arbitrary voltage \(V\), the best code is \(D = \operatorname{round}\!\big(V \cdot 4096 / V_\text{DD}\big)\), and the residual quantization error is bounded by half a step:

\[|V - V_\text{out}| \le \tfrac{1}{2}V_\text{LSB} \approx 0.40\text{ mV}.\]

Real DACs also have offset error (output at code 0 isn’t exactly 0), gain error (the slope isn’t exactly \(V_\text{DD}/4096\)), and integral/differential nonlinearity (INL/DNL — deviation of the actual curve from the ideal straight line, and non-uniform step sizes). The MCP4725 is monotonic and quite linear, so on the bench you’ll mostly see a tiny offset and a gain term that tracks your true VDD.

Procedure

Part A — Write a single code.

  1. Reuse the I²C bring-up from Lab 3.1. Confirm the DAC still answers at its address.
  2. Measure the actual VDD at the DAC’s VDD pin with the Fluke (DC volts). Record it — call it \(V_\text{DD}^\text{meas}\); use it in every prediction below.
  3. Send a mid-scale code (2048) using the Fast Write command and read the output:
/* Illustrative only — you write the real firmware.
   MCP4725 "fast write": one control byte carrying the top nibble,
   then the low byte. Powers the output buffer normally (PD = 00). */
HAL_StatusTypeDef mcp4725_write(uint16_t code) {   /* code in 0..4095 */
    uint8_t buf[2];
    buf[0] = (uint8_t)((code >> 8) & 0x0F);        /* PD=00 | D11..D8 */
    buf[1] = (uint8_t)(code & 0xFF);               /* D7..D0          */
    return HAL_I2C_Master_Transmit(&hi2c1,
             (uint16_t)(MCP4725_ADDR << 1), buf, 2, 10);
}
/* ... */
mcp4725_write(2048);   /* expect ~VDD/2 on the output pin */
  1. Put the Fluke across the DAC OUT pin to GND. Expect ≈ \(V_\text{DD}^\text{meas}/2\).

Part B — Sweep the transfer characteristic.

  1. Write each of the codes in the table below (a loop that writes, waits ~50 ms for the meter to settle, and holds is fine, or step them by hand from the console). For each, record the steady Fluke reading.
  2. Compute the predicted voltage \(V = D\,V_\text{DD}^\text{meas}/4096\) for each code first, then compare.

Part C — Endpoints and steps (optional but instructive).

  1. Write code 0 (expect ~0 V — any nonzero reading is offset error) and code 4095 (expect ≈ \(V_\text{DD}^\text{meas} - V_\text{LSB}\)).
  2. Write two adjacent codes near mid-scale (e.g. 2048 then 2049) and see whether the ~0.8 mV LSB step is even resolvable on the Fluke — it’s near the meter’s resolution, which is itself a good lesson about instrument limits.

Deliverable & expected results

A labs/lab-3-2/notes.md note with the measured VDD, the completed table, and a code-vs-voltage plot (host-side) showing the near-perfect straight line. Predicted values below use \(V_\text{DD} = 3.30\text{ V}\)recompute yours with the VDD you actually measured.

Code \(D\) Predicted \(V=D\cdot3.30/4096\) Measured
0 0.000 V
512 0.413 V
1024 0.825 V
2048 1.650 V
3072 2.475 V
4095 3.299 V
Derived quantity Predicted (at VDD = 3.30 V) Measured
\(V_\text{LSB} = V_\text{DD}/4096\) 0.806 mV
Max quantization error \(\tfrac12 V_\text{LSB}\) ±0.40 mV
Offset (output at code 0) ~0 V
Full-scale output (code 4095) 3.299 V

Analysis & reconciliation

Fit a line \(V_\text{out} = m\,D + b\) to your six points. The slope \(m\) should match \(V_\text{DD}^\text{meas}/4096\) (that’s gain), and the intercept \(b\) is the offset. Any consistent scaling of all readings almost always traces back to your true VDD, not the DAC — this is why Step 2 measures it. Small per-point wiggles off the straight line are INL, at or below the LSB level and hard to see on a handheld DMM. If the readings sag under measurement, suspect loading; the Fluke shouldn’t cause it, but any parallel path (a scope probe, a resistor to ground) will. Reconcile the numbers three ways where you can: hand formula → the value the firmware intended → the Fluke reading.

Cross-platform ports & language variants

See the syllabus Implementation tracks for the framing; this is the DAC-write-specific version. It’s a mixed-signal I/O case: the code→voltage transfer function is the DAC’s, so the port is really about which stack issues the two-byte I²C write.

STM32 bare-metal (C, and Rust). The mcp4725_write fast-write is a single HAL_I2C_Master_Transmit of two bytes; in Rust the same two bytes go out through the embedded-hal I2c::write, so a portable Mcp4725 driver reads identically to the C.

Raspberry Pi 5 (Linux userspace) and Jetson. Here the DAC is genuinely useful, because neither SBC has an on-board DAC — the MCP4725 is how a Pi or Jetson gets a real analog output at all. The transfer function is unchanged; the one caveat is that the userspace write path adds latency and jitter versus the bare HAL write, so the update instant is less precise than on the MCU — but for a DC transfer sweep that doesn’t matter (the sharper aperture-jitter consequences show up in Lab 3.3, not here).

Jetson Orin Nano — detailed procedure (embedded Linux)

Re-run the whole transfer-characteristic sweep with the Jetson as the I²C master. Same DAC, same Fluke, same math — a direct measurement of whether the converter or the master sets the accuracy (spoiler: the converter and its reference; the master is just a byte pipe). One-time board config: Jetson setup essentials.

Wiring — the Lab 3.1 Jetson hookup with the Fluke added on OUT:

flowchart LR
  JET["Jetson Orin Nano<br/>SDA = pin 3, SCL = pin 5<br/>/dev/i2c-7"]
  DAC["MCP4725<br/>12-bit DAC"]
  DMM["Fluke 117<br/>DC volts"]
  JET -- "SCL / SDA + 3V3 + GND" --> DAC
  DAC -- "OUT → red lead<br/>GND → black lead" --> DMM

flowchart LR
  JET["Jetson Orin Nano<br/>SDA = pin 3, SCL = pin 5<br/>/dev/i2c-7"]
  DAC["MCP4725<br/>12-bit DAC"]
  DMM["Fluke 117<br/>DC volts"]
  JET -- "SCL / SDA + 3V3 + GND" --> DAC
  DAC -- "OUT → red lead<br/>GND → black lead" --> DMM

From To Header pin
Jetson 3.3 V → breadboard + rail → MCP4725 VDD + rail pin 1
Jetson GND → breadboard − rail → MCP4725 GND, Fluke black lead − rail pin 6
Jetson SDA → MCP4725 SDA SDA rail pin 3
Jetson SCL → MCP4725 SCL SCL rail pin 5
MCP4725 OUT → Fluke red lead (VΩ jack)

Procedure.

  1. Confirm the DAC answers: i2cdetect -y -r 7 shows it at the Lab 3.1 address.
  2. Measure the actual VDD at the DAC’s VDD pin with the Fluke — this is now the Jetson’s 3.3 V rail, not the Nucleo’s, and it will differ slightly. Use this value in every prediction; it’s also the first reconciliation check if all readings scale by a constant.
  3. Write codes from Python with smbus2 — the Fast Write is the same two bytes the firmware sent ([(code >> 8) & 0x0F, code & 0xFF] via SMBus(7).write_i2c_block_data(addr, first, [second])-style calls; illustrative — write your own helper, ~10 lines). Step through the same code table as Part B (0, 512, 1024, 2048, 3072, 4095), recording the steady Fluke reading for each.
  4. Fill a second Measured column for the Jetson sweep in notes.md, and fit the same line: slope → gain vs the Jetson rail, intercept → offset. The slope should equal (Jetson VDD)/4096 — the DAC’s transfer function does not care who its master is.
  5. (Optional, Rust track) The same sweep through linux-embedded-hal’s I2c trait — the identical Mcp4725 driver code from the STM32 Rust build, recompiled for Linux, is the portability payoff made concrete.
  6. Save the sweep CSV as labs/lab-3-2/captures/sweep-jetson.csv alongside the STM32 one.

Raspberry Pi 5 differences: bus 1 instead of 7 (SMBus(1)), and in Rust rppal’s I2c works directly. Wiring and everything else identical.

Measure and compare (fill Measured on each platform):

Platform / build Write path Update-instant jitter Predicted Measured
STM32 bare-metal, C (HAL) direct peripheral write low tight
STM32 bare-metal, Rust (embedded-hal) I2c::write ≈ C ≈ C
Pi 5 / Jetson, Python or Rust kernel /dev/i2c higher (userspace) looser instant

Going further

  • Repeat the whole sweep at VDD = 5 V (powering the DAC from 5 V through the level shifter of Lab 3.5) and confirm every output scales by 5.0/3.3 — direct proof that VDD is the reference.
  • Write to the MCP4725’s EEPROM (the general-write command) so the DAC powers up at a chosen voltage, then power-cycle and confirm.
  • Feed this DAC output into the ADS1115 (Lab 3.4) to close a DAC→ADC loop and cross-check both converters against each other.