Lab 3.4 — ADS1115 ADC Voltage Reading

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

Goal

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.

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.

flowchart LR
  MCU["NUCLEO-L476RG<br/>I2C1 master<br/>SCL = PB8 (D15)<br/>SDA = PB9 (D14)"]
  DAC["MCP4725<br/>source under test"]
  ADC["ADS1115<br/>ADDR→GND = 0x48"]
  DMM["Fluke 117<br/>DC volts"]
  MCU -- "SCL / SDA + 3V3 + GND" --> DAC
  MCU -- "same rails" --> ADC
  DAC -- "OUT → AIN0" --> ADC
  DMM -. "red → AIN0 node<br/>black → − rail" .-> ADC

flowchart LR
  MCU["NUCLEO-L476RG<br/>I2C1 master<br/>SCL = PB8 (D15)<br/>SDA = PB9 (D14)"]
  DAC["MCP4725<br/>source under test"]
  ADC["ADS1115<br/>ADDR→GND = 0x48"]
  DMM["Fluke 117<br/>DC volts"]
  MCU -- "SCL / SDA + 3V3 + GND" --> DAC
  MCU -- "same rails" --> ADC
  DAC -- "OUT → AIN0" --> ADC
  DMM -. "red → AIN0 node<br/>black → − rail" .-> ADC

Pin map (every wire; bus wiring carried over from Lab 3.1):

From To Pin/jack
Nucleo 3V3 / GND breadboard + rail / − rail 3V3, GND header pins
Nucleo I2C1 SCL / SDA SCL rail / SDA rail D15 (PB8) / D14 (PB9)
MCP4725 VDD, GND, SCL, SDA + rail, − rail, SCL rail, SDA rail
ADS1115 VDD, GND, SCL, SDA + rail, − rail, SCL rail, SDA rail
ADS1115 ADDR − rail (⇒ address 0x48)
MCP4725 OUT ADS1115 AIN0 (Part A alt.: divider midpoint → AIN0) jumper
Fluke red lead the AIN0 node VΩ jack
Fluke black lead − rail COM jack
 NUCLEO-L476RG              breadboard
 ┌───────────────┐   ┌──────────────────────────────────────────────┐
 │    3V3  ●─────┼───┤ + rail ───● VDD           ● VDD              │
 │    GND  ●─────┼───┤ − rail ───● GND  MCP4725  ● GND   ADS1115    │
 │ D15/PB8 ●─────┼───┤ SCL rail ─● SCL           ● SCL              │
 │ D14/PB9 ●─────┼───┤ SDA rail ─● SDA           ● SDA              │
 └───────────────┘   │           ● OUT ──────────● AIN0             │
                     │                  − rail ──● ADDR  (→ 0x48)   │
                     └──────────────────────────────────────────────┘
  Fluke: red → AIN0 node (VΩ) · black → − rail (COM)

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
Clock Configuration 80 MHz HCLK per the setup essentials

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 needs
mkdir -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.

Where results go:

Artifact Path
Bench note (loop-back table, FSR/clip observations) labs/lab-3-4/notes.md
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:

\[V_\text{LSB} = \frac{2\cdot\text{FSR}}{2^{16}} = \frac{\text{FSR}}{2^{15}}.\]

Two worked cases (memorize the second — it’s the ADS1115’s headline number):

\[\text{FSR} = \pm4.096\text{ V}:\quad V_\text{LSB} = \frac{4.096}{32768} = 125\ \mu\text{V},\] \[\text{FSR} = \pm2.048\text{ V}:\quad V_\text{LSB} = \frac{2.048}{32768} = 62.5\ \mu\text{V}.\]

The code-to-voltage conversion for a single-ended read is

\[V_\text{in} = \text{code}\times V_\text{LSB} = \text{code}\times\frac{\text{FSR}}{2^{15}}.\]

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.

  1. 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.
  2. 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     0x01

int16_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), &reg, 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) */
  1. 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.

  1. 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.
  2. 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.

  1. Wire MCP4725 OUT → ADS1115 AIN0 directly (both 3.3 V devices — safe). Set FSR = ±4.096 V.
  2. 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-hal I2c 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:

flowchart LR
  JET["Jetson Orin Nano<br/>/dev/i2c-7 master<br/>SDA = pin 3, SCL = pin 5"]
  DAC["MCP4725<br/>source under test"]
  ADC["ADS1115<br/>0x48"]
  DMM["Fluke 117<br/>reference"]
  JET -- "SCL / SDA + 3V3 + GND" --> DAC
  JET -- "same rails" --> ADC
  DAC -- "OUT → AIN0" --> ADC
  DMM -. "red → AIN0 node" .-> ADC

flowchart LR
  JET["Jetson Orin Nano<br/>/dev/i2c-7 master<br/>SDA = pin 3, SCL = pin 5"]
  DAC["MCP4725<br/>source under test"]
  ADC["ADS1115<br/>0x48"]
  DMM["Fluke 117<br/>reference"]
  JET -- "SCL / SDA + 3V3 + GND" --> DAC
  JET -- "same rails" --> ADC
  DAC -- "OUT → AIN0" --> ADC
  DMM -. "red → AIN0 node" .-> ADC

From To Header pin
Jetson 3.3 V / GND + rail / − rail pin 1 / pin 6
Jetson SDA / SCL SDA rail / SCL rail pin 3 / pin 5
MCP4725 OUT ADS1115 AIN0 (loop-back jumper)
ADS1115 ALERT/RDY (optional, step 5) spare GPIO pin 7
Fluke red / black AIN0 node / − rail

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.

  1. Confirm both devices: i2cdetect -y -r 7 shows ~0x60 and 0x48.
  2. 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.
  3. 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.
  4. 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.
  5. (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.
  6. 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.