Go the other direction: measure a real voltage with the ADS1115 16-bit ADC and get a trustworthy number out of it. You will take single-ended reads on AIN0, learn to choose the programmable-gain amplifier (PGA) / full-scale range for your signal, set the data rate (SPS), and — the satisfying part — read the MCP4725 output with the ADS1115 to close a DAC → ADC loop and check the two converters against each other and against the Fluke. Owning full-scale range, LSB size, and the input-range constraints of a real ADC is exactly the mixed-signal judgment a DSP/firmware engineer is hired for; get it wrong and you either clip your signal or throw away resolution.
Recommended reading
Zölzer Ch. 2–3 — quantization and AD conversion: the ideal analog-to-digital model is sample, then quantize to the nearest level. The ADS1115 is that model made of silicon (a delta-sigma modulator + decimator).
Lyons Ch. 2 (periodic sampling) — the practitioner’s picture of the same thing: what the sample-rate choice actually buys you, and what it costs.
Kuo Ch. 2 — quantization and finite word length, again, now on the input side: LSB, quantization noise, and how full-scale range trades against resolution.
The ADS1115 datasheet — the Config register (MUX, PGA, MODE, DR bits), the Conversion register format, and the absolute input voltage limits. Read the Config-register section in full; you’ll be setting those bits by hand.
Equipment & parts
STM32 Nucleo-64 (NUCLEO-L476RG), USB console.
ADS1115 ADC breakout on the I²C bus (address from Lab 3.1; default 0x48).
MCP4725 DAC (still on the bus) — the signal source for the loop-back test.
Fluke 117 DMM (the reference the ADC is checked against).
Breadboard, jumpers, 3.3 V and ground.
Wiring & bench setup
The signal chain: both converters sit on the shared Lab 3.1 bus; the voltage under test (DAC OUT, or a divider for Part A) feeds AIN0, and the Fluke reads the same node as the reference.
Setup gotchas: for the Part A divider option, two equal kit resistors (e.g. 10 kΩ + 10 kΩ) from + rail to − rail with the midpoint → AIN0 gives ≈ 1.65 V. Pull-ups as in Lab 3.1 (one set on the bus, usually on-board). Whatever the source, the AIN0 node must stay inside 0–3.3 V (see Safety).
Safety & don’t-break-it
Inputs must stay within GND − 0.3 V to VDD + 0.3 V. With the ADS1115 at 3.3 V, no analog input pin may go below ≈ −0.3 V or above ≈ 3.6 V — even momentarily. Feeding a 5 V signal into an AIN pin will damage it; that’s precisely why the DAC source here is a 3.3 V device.
The PGA range is separate from the supply rail — and it does not extend it. You may select a full-scale range up to ±6.144 V, but you still cannot drive a pin past VDD+0.3 V. A signal must satisfy both limits: inside the chosen PGA FSR and inside the absolute GND..VDD window. Choosing ±6.144 V does not make it safe to apply 5 V.
Single-ended reads are AINx referenced to GND, so they can only read positive voltages (0 up to FSR/VDD limit). A negative input on a single-ended channel just reads zero (and, if it goes below −0.3 V, damages the part).
Common ground with the DAC and the Fluke is mandatory or the ADC reads a floating reference. Keep the bus at 3.3 V.
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 left over from Lab 3.3 also fine — both parts support it); PB8/PB9
Connectivity → USART2
Asynchronous, 115200 8-N-1 — the VCP console the readings printf to
Host — the loop-back scatter plot runs on the Mac, in the course venv (see Toolchain):
source venv/bin/activate # numpy + matplotlib are all this lab needsmkdir-p labs/lab-3-4/host labs/lab-3-4/captures
Put your scatter/fit script in labs/lab-3-4/host/ (numpy loads the three-column table and fits gain/offset, matplotlib draws ADC-vs-Fluke against \(y=x\) — you write the script; it’s ~15 lines).
Keep this lab’s reconciliation in labs/lab-3-4/host/analysis.ipynb — the notebook convention — and export final figures next to it.
Hand-recorded loop-back data (CSV: dac_code,V_pred,V_adc,V_fluke)
labs/lab-3-4/captures/loopback.csv
ADC-vs-Fluke scatter + \(y=x\) line
labs/lab-3-4/host/adc-vs-fluke.png
(Optional) serial log of repeated reads for the noise check
labs/lab-3-4/captures/noise-reads.log
Background
The ADS1115 is a 16-bit converter that reports a signed 16-bit code. In single-ended mode it uses 15 bits of magnitude (the sign bit is there for the differential mode; single-ended negative inputs read as 0). The PGA sets the full-scale range (FSR) — the input voltage that maps to full code. The available FSRs are ±6.144, ±4.096, ±2.048, ±1.024, ±0.512, ±0.256 V.
For a chosen FSR, the step size (LSB) is the range divided by the code count. Using the full ±FSR over the signed 16-bit range:
Choosing the PGA is a resolution/headroom trade. Pick the smallest FSR that still contains your whole signal: it gives the smallest LSB (best resolution, least quantization noise) without clipping. For a signal that ranges 0–3.3 V you must use ±4.096 V (the ±2.048 V range would clip everything above 2.048 V). Undershoot the range and you clip; overshoot it and you waste bits.
The data rate (DR bits) sets conversions per second: 8 to 860 SPS. Faster is more bandwidth but noisier (less delta-sigma averaging); slower is quieter. For DC voltage reads use a low-to-moderate rate; the ADS1115 is a slow, precise ADC — not a waveform digitizer (that job is the STM32’s own fast ADC in Module 5).
Procedure
Part A — Single read on AIN0.
Wire a known DC voltage into AIN0 (per Wiring & bench setup): start with the DAC output (Lab 3.2) set to a mid value, or a resistor divider off 3V3. Keep it well inside 0–3.3 V.
In firmware, write the ADS1115 Config register: MUX = AIN0-vs-GND (single-ended), PGA = ±4.096 V, MODE = single-shot, DR = e.g. 128 SPS, then start a conversion; poll the OS/ready bit and read the Conversion register.
/* Illustrative only — you write the real firmware. Single-shot single-ended read of AIN0 at FSR = ±4.096 V. Config bits per datasheet: OS=1(start), MUX=100(AIN0/GND), PGA=001(4.096V), MODE=1(single-shot), DR=100(128SPS), COMP off. */#define ADS1115_ADDR 0x48#define REG_CONVERSION 0x00#define REG_CONFIG 0x01int16_t ads1115_read_ain0(void){uint8_t cfg[3]={ REG_CONFIG,0xC3,0x83};/* MSB, LSB of config */ HAL_I2C_Master_Transmit(&hi2c1,(ADS1115_ADDR <<1), cfg,3,10); HAL_Delay(9);/* wait > 1/DR for 128 SPS */uint8_t reg = REG_CONVERSION, rx[2]; HAL_I2C_Master_Transmit(&hi2c1,(ADS1115_ADDR <<1),®,1,10); HAL_I2C_Master_Receive (&hi2c1,(ADS1115_ADDR <<1), rx,2,10);return(int16_t)((rx[0]<<8)| rx[1]);/* signed 16-bit code */}/* V_in = code * 4.096 / 32768 (volts) */
Convert the code to volts with \(V_\text{in} = \text{code}\times 4.096/32768\) and printf it. Cross-check with the Fluke on the same node.
Part B — Verify the LSB and the range choice.
Read a small voltage (~0.1 V) at ±4.096 V and again at ±2.048 V FSR; confirm the second gives ~2× the code (finer LSB) for the same input.
Deliberately apply ~2.5 V while set to ±2.048 V and watch the code clip at full scale — the concrete meaning of “signal exceeds FSR.” Return to ±4.096 V. (Never exceed the 3.3 V absolute pin limit while doing this.)
Part C — Close the DAC → ADC loop.
Wire MCP4725 OUT → ADS1115 AIN0 directly (both 3.3 V devices — safe). Set FSR = ±4.096 V.
For each DAC code in the table, write the DAC, read the ADC, and also read the node with the Fluke. You now have three numbers per point: intended DAC volts, ADC-measured volts, Fluke volts.
Deliverable & expected results
A labs/lab-3-4/notes.md note with the completed loop-back table and a scatter of ADC-reported vs. Fluke voltage (should lie on \(y=x\)). Predicted ADC volts assume an ideal DAC at VDD = 3.30 V and FSR = ±4.096 V — use your measured DAC output as the true input.
DAC code
DAC out (pred., VDD=3.30 V)
ADC read (pred.)
ADC measured
Fluke measured
512
0.413 V
0.413 V
…
…
1024
0.825 V
0.825 V
…
…
2048
1.650 V
1.650 V
…
…
3072
2.475 V
2.475 V
…
…
4000
3.223 V
3.223 V
…
…
Derived quantity
Predicted
Measured
\(V_\text{LSB}\) at ±4.096 V
125 µV
…
\(V_\text{LSB}\) at ±2.048 V
62.5 µV
…
Code for 1.650 V at ±4.096 V
\(\text{round}(1.650\cdot32768/4.096)=13200\)
…
Clip code at ±2.048 V, 2.5 V in
32767 (full scale)
…
Analysis & reconciliation
The three columns of the loop-back table are three independent estimates of the same node voltage; agreement to a few millivolts is the goal. A consistent slope error between ADC volts and Fluke volts is the ADS1115’s PGA gain tolerance (and/or your assumed FSR vs. the part’s true reference); a fixed offset is the ADC’s offset error plus any thermocouple/wiring offset. If the ADC reads systematically low versus the Fluke while the DAC formula matches the Fluke, trust the Fluke and characterize the ADC’s gain/offset — that’s a real calibration you’d do in production. Watch the noise: repeat one read many times and look at the code spread; higher SPS should visibly widen it. Reconcile everything back to the LSB: a 1-code wobble is 125 µV and is below what the Fluke can resolve, so don’t chase sub-LSB “disagreements.”
Cross-platform ports & language variants
See the syllabus Implementation tracks for the framing; this is the ADC-read-specific version. It’s a mixed-signal I/O case, and the config-register-over-I²C pattern — write MUX/PGA/MODE/DR bits, wait, read the conversion register — is identical across every target; only the stack issuing it changes.
STM32 bare-metal (C, and Rust). The read is a config-register write, a poll or delay of \(1/\text{DR}\), then a two-byte read of the conversion register. In Rust, the mature ads1x1x crate wraps exactly this over the embedded-halI2c trait, giving a typed read() with the PGA/data-rate as enum settings instead of hand-packed bits.
Raspberry Pi 5 (Linux userspace) and Jetson. The ADS1115 is the canonical Pi ADC — arguably more native here than on the STM32, because neither SBC has an on-chip ADC, so this external delta-sigma part is how a CPU-only board gets analog in at all. Drive it with the Adafruit CircuitPython ADS1x15 library or smbus in Python, or the same ads1x1x crate over linux-embedded-hal in Rust — the identical config-register sequence, now over /dev/i2c. This is the concrete lesson in how an SBC with no ADC acquires a voltage.
Jetson Orin Nano — detailed procedure (embedded Linux)
This is the port with the most practical payoff in the whole module: the ADS1115 is how the Jetson gets analog input at all (no on-chip ADC), and this exact hookup is the acquisition front end the Module 6 Jetson variants reuse for live signals. One-time board config: Jetson setup essentials.
Wiring — the Lab 3.1 Jetson hookup (Jetson pins 1/3/5/6 → the bus rails) with both converters on the bus and the loop-back jumper in place:
The AIN0 node’s absolute limits are unchanged — the ADS1115 is still a 3.3 V-powered part; nothing about the Jetson relaxes the 0–3.3 V window.
Procedure.
Confirm both devices: i2cdetect -y -r 7 shows ~0x60 and 0x48.
Do a single-shot read from Python: either hand-pack the identical config register the firmware used (smbus2: write [0xC3, 0x83] to register 1, wait > 1/DR, read two bytes from register 0, sign-extend — the same bytes, now through the kernel) or use the Adafruit ADS1x15 library for the typed version. Convert with the same \(V_\text{in} = \text{code}\cdot 4.096/32768\) and cross-check against the Fluke.
Re-run Part C’s full DAC → ADC loop entirely on the Jetson: the Lab 3.2 smbus2 DAC helper writes each code, the ADS1115 read follows, the Fluke arbitrates. Fill a Jetson column in the loop-back table — gain/offset fits should match the STM32 run to within the converters’ tolerance, because the converters haven’t changed.
Sample-rate reality check: loop timed single-shot reads and measure the achieved reads/second at DR = 860 SPS. It lands below 860 — each read pays a config write + conversion wait + register read over a 100 kHz bus, plus syscall overhead. Compute the bus-time budget by hand (bytes × 9 clocks / 100 kHz) and reconcile.
(Optional but very embedded-Linux) Switch the config to continuous conversion mode and route the ALERT/RDY pin to header pin 7 as a data-ready interrupt: catch rising edges with gpiomon/libgpiod edge events and read only when signaled — polling replaced by event-driven acquisition, the Linux cousin of Module 5’s timer-triggered ADC.
Log a noise run (many reads of a fixed DC input) to labs/lab-3-4/captures/jetson-noise-reads.log and compare the code spread with the STM32 run at the same DR — it should match; the delta-sigma sets the noise, not the master.
Raspberry Pi 5 differences: bus 1 (SMBus(1) / i2cdetect -y 1); the Adafruit library and the ads1x1x Rust crate work identically. Same absolute-voltage rules.
Measure and compare (fill Measured on each platform):
Platform / build
Read path
Single-shot latency
Latency jitter
Measured
STM32 bare-metal, C (HAL)
config write + poll + read
low
low
…
STM32 bare-metal, Rust (ads1x1x)
typed read()
≈ C
≈ C
…
Jetson, Python (smbus2/Adafruit)
kernel /dev/i2c-7
higher
scheduler tail
…
Jetson, continuous + RDY→GPIO event
event-driven read
bounded by DR
reduced
…
Pi 5, Python (Adafruit/smbus)
kernel /dev/i2c-1
higher
scheduler tail
…
Pi 5 / Jetson, Rust (ads1x1x)
same crate, kernel bus
higher
scheduler tail
…
Going further
Sweep the data rate 8 → 860 SPS on a fixed DC input and plot the code standard deviation vs. SPS — you’ll see the delta-sigma noise/bandwidth trade directly.
Switch to a differential read (AIN0−AIN1) across a small sensor or divider and confirm the sign bit now carries meaning.
Use the DAC→ADC loop to build a quick self-calibration: fit ADC gain/offset against the Fluke-verified DAC and apply the correction in firmware.
Feed a slow DAC ramp (Lab 3.3 idea, but a ramp) into the ADC and log it — a first taste of the acquisition pipeline you’ll build properly on the STM32 ADC in Module 5.