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 theory section below — oversampling and sigma–delta noise shaping: why oversampling by \(L\) buys \(3\) dB per octave, why a first-order noise-shaping loop buys \(9\) and a second-order loop \(15\), and how a 1-bit modulator at \(64 f_S\) becomes a 16–20-bit converter — the mechanism inside the ADS1115. Zölzer Ch. 3 derives the MASH cascade and surveys converter architectures (SAR, flash, R-2R, sigma–delta), the vocabulary of every converter datasheet.
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).
Theory — Oversampling Converters and Sigma–Delta Noise Shaping
This is the converter half of the multirate theory in Lab 5.4’s theory section: the same decimation and interpolation machinery, run in reverse order at the analog boundary, where it buys resolution instead of rate. The ADS1115 is a delta-sigma part, so everything below is the mechanism inside the chip on the breadboard.
Oversampling converters
Now run multirate thinking at the analog boundary. Plain Nyquist-rate conversion samples at \(f_S = 2 f_B\) with a \(w\)-bit converter and demands a brutal analog brick-wall anti-aliasing filter before the ADC. Oversampling by \(L\) instead spreads the fixed quantization-noise power \(Q^2/12\) over an \(L\)-times wider band, so the noise in the signal band drops:
\[
\mathrm{SNR} = 6.02\,w + 10\log_{10} L \quad \text{[dB]},
\]
i.e. \(3\) dB — half a bit — per doubling of \(L\). Just as valuable: the analog anti-alias filter’s transition band stretches from \(f_B\) out to nearly \(L f_S\), so it can be gentle and cheap, with a sharp digital lowpass and \(\downarrow L\) decimation (the multirate half of the theory) doing the real work. DA conversion mirrors the chain: \(\uparrow L\) zero-stuffing, a digital image-killing filter, the DAC running at \(L f_S\), and a relaxed analog reconstruction filter.
Noise shaping and sigma–delta
Three dB per octave is slow. A sigma–delta (delta–sigma) modulator wraps a feedback loop around a coarse quantizer — an integrator ahead of the quantizer, the quantized output fed back — so that
the signal passes through, but the quantization error \(E\) is shaped away from DC. The first-order discrete model is \(y[n] = x[n-1] + e[n] - e[n-1]\): error weighted by \(|1 - e^{-j\Omega}|^2 = 4\sin^2(\Omega/2)\), small in-band, large near \(f_S/2\) where the decimation filter removes it. In-band noise power becomes
— \(9\) dB (\(1.5\) bits) per doubling of \(L\). A second-order loop shapes by \((1 - z^{-1})^2\) and gains \(15\) dB (\(2.5\) bits) per doubling; multistage (MASH) cascades of first-order loops reach third-order shaping while staying unconditionally stable. This is how a 1-bit quantizer clocked at \(64 f_S\) delivers 16–20-bit audio: trade amplitude resolution for rate, then filter the shaped noise away with exactly the decimation machinery of Lab 5.4. The \(3/9/15\) dB-per-octave ladder (plain oversampling / first / second order) is the single most useful number set in converter selection.
NoteConnection
DSP and this course. The converters on this course’s bench — the MCP4725 DAC and ADS1115 ADC of Labs 3.2–3.5 and the STM32’s on-chip ADC — are oversampling converters: the ADS1115 is a delta-sigma part whose data-rate register trades \(L\) for effective bits exactly per the \(3/9/15\) dB ladder, and the STM32’s hardware-oversampling registers literally implement \(\downarrow L\) accumulation. The anti-alias and reconstruction filters flanking them are Lab 4.4’s Sallen–Key stages — gentle analog filters that oversampling makes sufficient — and Lab 5.4’s aliasing validation is what happens when the decimation recipe’s “filter first” step is skipped.
Worked by hand
P3 (why noise shaping). To gain \(8\) bits (\(48\) dB) beyond a converter’s native resolution: plain oversampling at \(3\) dB/octave needs \(16\) octaves, \(L = 2^{16} = 65536\) — hopeless. First-order shaping at \(9\) dB/octave needs \(48/9 \approx 5.3\) octaves, \(L \approx 40\); second-order at \(15\) dB/octave needs \(3.2\) octaves, \(L \approx 9\). Noise shaping is what makes oversampled converters practical.
Theory exercises
Theory exercise 3[Hand] — (Zölzer 3.1 practice.) (a) A 12-bit SAR ADC is oversampled by \(L = 16\): compute the SNR from \(\mathrm{SNR} = 6.02\,w + 10\log_{10} L\) and the number of effective bits. (b) For a 1-bit first-order sigma–delta modulator (levels \(\pm 1\), so \(Q = 2\)) at \(L = 64\), compute the in-band noise power \(N_B^2\) from the formula in this section and the SNR for a full-scale sine (\(\sigma_X^2 = 1/2\)).
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.