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.
Recommended reading
Lyons Ch. 2 — sampling and reconstruction: the ideal digital-to-analog model behind a real DAC. You produce discrete-time samples, and the DAC realizes them as a voltage held between updates (the zero-order hold).
Kuo Ch. 2 — quantization and finite word length: how a continuous value is mapped to a code and the error that introduces. This is the DAC in reverse and the ADC forward; read it once, use it in both.
Lyons Ch. 11 (binary data formats) — how the 12-bit value is packed into the MCP4725’s write bytes.
Course 1: Lesson 33 — Sampling for the sampling/reconstruction framing this sits inside.
The MCP4725 datasheet — the Fast Write command format and the output-buffer/settling section.
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.
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.
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 needsmkdir-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.
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:
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.
Reuse the I²C bring-up from Lab 3.1. Confirm the DAC still answers at its address.
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.
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 */
Put the Fluke across the DAC OUT pin to GND. Expect ≈ \(V_\text{DD}^\text{meas}/2\).
Part B — Sweep the transfer characteristic.
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.
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).
Write code 0 (expect ~0 V — any nonzero reading is offset error) and code 4095 (expect ≈ \(V_\text{DD}^\text{meas} - V_\text{LSB}\)).
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-halI2c::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.
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.
Confirm the DAC answers: i2cdetect -y -r 7 shows it at the Lab 3.1 address.
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.
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.
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.
(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.
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.