Lab 5.1 — STM32 ADC Single Sample

Course 2 syllabus · Module 5 · Prev: « Lab 4.4 · Next: Lab 5.2 »

Goal

Take the first real step from analog into the digital domain: configure the STM32’s on-chip 12-bit ADC, trigger one conversion under software control, and turn the raw integer code back into a voltage. You will feed the ADC a known DC level from the MCP4725 DAC (built in Lab 3.3) and compare the STM32’s reading against the Fluke 117 as ground truth. This is the “hello world” of data acquisition — every later DSP lab is just this conversion, repeated deterministically and processed. Getting the reference, the full-scale range, and the code→volts mapping exactly right here is what makes every downstream measurement trustworthy.

Equipment & parts

  • STM32 NUCLEO-L476RG + USB cable (ST-LINK debug + virtual COM port).
  • MCP4725 DAC breakout on a breadboard, wired and working from Lab 3.2/3.3.
  • Fluke 117 DMM + leads.
  • Jumper wires; common ground rail.
  • STM32CubeMX + CLion on the host (per the project workflow).

Wiring & bench setup

The signal chain: the Nucleo drives the MCP4725 over I²C, and the DAC’s output loops straight back into the Nucleo’s own ADC pin — with the Fluke tapping the same node as ground truth.

flowchart LR
  MCU["NUCLEO-L476RG<br/>I2C1 master<br/>SCL = PB8 (D15)<br/>SDA = PB9 (D14)"]
  DAC["MCP4725<br/>VDD = 3.3 V"]
  ADC["NUCLEO A0 (PA0)<br/>ADC1_IN5"]
  DMM["Fluke 117<br/>DC volts"]
  MCU -- "SCL / SDA + 3V3 + GND" --> DAC
  DAC -- "OUT (loopback jumper)" --> ADC
  DAC -. "OUT → red lead<br/>GND → black lead" .-> DMM

flowchart LR
  MCU["NUCLEO-L476RG<br/>I2C1 master<br/>SCL = PB8 (D15)<br/>SDA = PB9 (D14)"]
  DAC["MCP4725<br/>VDD = 3.3 V"]
  ADC["NUCLEO A0 (PA0)<br/>ADC1_IN5"]
  DMM["Fluke 117<br/>DC volts"]
  MCU -- "SCL / SDA + 3V3 + GND" --> DAC
  DAC -- "OUT (loopback jumper)" --> ADC
  DAC -. "OUT → red lead<br/>GND → black lead" .-> DMM

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

From To Pin/jack
MCP4725 VDD breadboard + rail ← Nucleo 3.3 V 3V3
MCP4725 GND breadboard − rail ← Nucleo ground GND
MCP4725 SCL I2C1 clock D15 (PB8)
MCP4725 SDA I2C1 data D14 (PB9)
MCP4725 OUT ADC input — the loopback jumper A0 (PA0)
Fluke red lead (VΩ jack) DAC OUT node on the breadboard
Fluke black lead (COM) breadboard − rail
 NUCLEO-L476RG                       breadboard
 ┌───────────────┐        ┌───────────────────────────────────┐
 │    3V3  ●─────┼────────┤ + rail ───● VDD                   │
 │    GND  ●─────┼────────┤ − rail ───● GND     MCP4725       │
 │ D15/PB8 ●─────┼─ SCL ──┼───────────● SCL     breakout      │
 │ D14/PB9 ●─────┼─ SDA ──┼───────────● SDA                   │
 │ A0/PA0  ●◄────┼─ loop ─┼───────────● OUT ──► Fluke red (V⎓)│
 └───────────────┘        │ − rail ──────────► Fluke black    │
                          └───────────────────────────────────┘

Most MCP4725 breakouts carry on-board I²C pull-ups (same bus you validated in Lab 3.1); if yours is bare, add 4.7 kΩ SCL→3V3 and SDA→3V3. Land the OUT → A0 loopback jumper last, after the DAC is at mid-scale (see Safety below).

Safety & don’t-break-it

  • The analog pins are a 3.3 V world — they are not 5 V tolerant. VDDA/VREF+ on the Nucleo is 3.3 V. Any voltage on an ADC input above ~3.3 V (strictly, above VDDA + 0.3 V) forward-biases the input protection diode and can destroy the pin or the whole chip. Everything you connect to A0 must be guaranteed to stay within 0 – 3.3 V.
  • Power the MCP4725 from the Nucleo’s 3.3 V rail, not 5 V. The MCP4725 output is rail-to-rail: on a 5 V supply its full-scale output is ~5 V, which would over-drive the ADC. Running it from 3.3 V makes its maximum possible output 3.3 V — physically incapable of exceeding VREF+. Verify with the Fluke that the DAC’s full-scale (code 4095) output is ≈ 3.3 V before you connect it to the ADC pin.
  • Share grounds. MCP4725 GND, Nucleo GND, and the Fluke’s COM must all be common, or your reading is meaningless.
  • Never let the DAC output float into the pin while the code is uninitialized — set the DAC to a known mid-scale (~1.65 V) before wiring it to A0.

Project & environment setup

Firmware — create the Module 5 project (firmware/m5-daq/; CubeMX → board NUCLEO-L476RG → Toolchain/IDE = CMake, per the project workflow). Labs 5.2–5.4 build on this same project — they only add to this .ioc. Configure:

CubeMX page Setting
Analog → ADC1 IN5 Single-ended (PA0 = A0); Resolution 12 bits; Continuous Conversion Disabled; Scan Disabled; External Trigger = software; channel Sampling Time 47.5 Cycles (Procedure step 2)
Analog → ADC1 → Clock Prescaler Synchronous clock mode divided by 4 (Procedure step 3 — keeps \(f_\text{ADC}\) in spec at 80 MHz)
Connectivity → I2C1 I2C mode, Standard 100 kHz, PB8/PB9 — drives the MCP4725 source (same settings as firmware/m3-mixed/)
Connectivity → USART2 Asynchronous, 115200 8-N-1 — the ST-LINK VCP that prints code/volts
Clock Configuration 80 MHz HCLK per the setup essentials

Host — only serial logging, in the course venv (see Toolchain):

source venv/bin/activate        # pyserial is all this lab needs
mkdir -p labs/lab-5-1/host labs/lab-5-1/captures

Put a small logger in labs/lab-5-1/host/log_vcp.py (pyserial: open /dev/tty.usbmodem* at 115200 and append each printed line to labs/lab-5-1/captures/sweep.log — you write the script; it’s ~10 lines). Copy-pasting from a serial terminal into the log file is fine too.

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

Where results go:

Artifact Path
Bench note (sweep table + worst-case error) labs/lab-5-1/notes.md
VCP printout of the sweep (code, volts per DAC code) labs/lab-5-1/captures/sweep.log
(Optional) STM32-vs-Fluke plot labs/lab-5-1/host/adc-vs-fluke.png

Background

The STM32L476’s ADC is a 12-bit successive-approximation converter referenced to VREF+ = VDDA = 3.3 V. A conversion produces an integer code in \([0, 4095]\). Treating the full-scale span as \(V_\text{REF}\) across \(2^{12}\) quantization levels, the reconstructed voltage is

\[V = \frac{\text{code}}{2^{12}}\, V_\text{REF} = \frac{\text{code}}{4096}\times 3.3\ \text{V}.\]

The quantization step (1 LSB) is

\[\Delta = \frac{V_\text{REF}}{2^{12}} = \frac{3.3}{4096} \approx 0.806\ \text{mV},\]

so no single reading can be trusted below roughly a millivolt — that is the noise floor the digital side imposes before any circuit noise.

A conversion is not instantaneous. Its time is the sampling (acquisition) time plus the conversion time:

\[t_\text{conv} = \frac{t_\text{smp} + t_\text{SAR}}{f_\text{ADC}},\qquad t_\text{SAR} = 12.5\ \text{cycles (12-bit)}.\]

During \(t_\text{smp}\) the internal sample-and-hold capacitor charges through the source resistance \(R_\text{src}\) and the ADC’s sampling resistance. If \(t_\text{smp}\) is too short for a high-impedance source, the cap never fully settles and the reading reads low. The MCP4725 is a low-impedance (buffered, rail-to-rail) source, so a modest sampling time is fine — but choose a generous SamplingTime (e.g. 47.5 or 92.5 cycles) for margin and note how it trades against throughput.

Procedure

Part A — Configure the ADC in CubeMX.

  1. New CubeMX project for NUCLEO-L476RG, generated as CMake into firmware/m5-daq/ per Project & environment setup, opened in CLion. In the .ioc, enable ADC1 and tick IN5 (pin PA0, the Arduino A0 header). Leave it single-ended.
  2. ADC settings: Resolution 12 bits, Continuous Conversion Mode = Disabled (we want one conversion per request), Scan = Disabled, External Trigger = Software. Set the channel Sampling Time to 47.5 Cycles to start.
  3. Set the ADC clock so \(f_\text{ADC}\) is within spec (e.g. sync clock / 4). Enable USART2 for the VCP so you can print. Generate code.

Part B — Read one sample (illustrative firmware).

  1. A single polled conversion in HAL looks like:
// Illustrative only — you write and debug the real project.
HAL_ADC_Start(&hadc1);
if (HAL_ADC_PollForConversion(&hadc1, 10) == HAL_OK) {   // 10 ms timeout
    uint16_t code  = HAL_ADC_GetValue(&hadc1);            // 0..4095
    float    volts = ((float)code / 4096.0f) * 3.3f;      // code -> volts
    printf("code=%4u  V=%.4f\r\n", code, volts);
}
HAL_ADC_Stop(&hadc1);

The LL equivalent is the same three ideas — LL_ADC_REG_StartConversion(), poll LL_ADC_IsActiveFlag_EOC(), LL_ADC_REG_ReadConversionData12() — with no HAL overhead.

Part C — Present a known DC level and read it.

  1. With the MCP4725 powered from 3.3 V and its output wired to PA0 (per Wiring & bench setup), set the DAC to a known code. Start with mid-scale (code 2048 → predicted ≈ 1.65 V).
  2. Measure the DAC output node with the Fluke (COM + VΩ, DC volts). Record the true voltage \(V_\text{Fluke}\).
  3. Trigger a conversion; read code and volts over the VCP.
  4. Repeat for several DAC codes spanning the range — e.g. 0, 1024, 2048, 3072, 4095 — recording DAC code, \(V_\text{Fluke}\), ADC code, and computed volts for each.

Deliverable & expected results

A bench note (labs/lab-5-1/notes.md) with the code→volts table across the sweep, and a one-line statement of your worst-case STM32-vs-Fluke error in millivolts and in LSBs.

DAC code Predicted DAC out ADC code (pred.) STM32 volts Fluke volts
0 0.000 V ~0
1024 0.825 V ~1024
2048 1.650 V ~2048
3072 2.475 V ~3072
4095 3.300 V ~4095

(Predicted ADC code = \(\text{round}(V_\text{Fluke}/3.3 \times 4096)\). Since the ADC and the DAC share the same 3.3 V reference, the ADC code should track the DAC code almost exactly; e.g. 1.65 V → 2048, with the 4096-vs-4095 convention shifting it by at most 1 LSB — see Analysis.)

Analysis & reconciliation

Compute each volts by hand from the code and compare to the Fluke. Expect agreement to within a few LSBs (a handful of mV). Sources of the gap, in rough order: (1) VREF+ is not exactly 3.300 V — measure the actual VDDA with the Fluke and re-scale; this is usually the dominant error. (2) The 4096 vs. 4095 convention: dividing by 4096 maps full-scale code 4095 to 3.2992 V, not 3.3000 V — a fixed 1-LSB tilt at the top. (3) ADC offset and gain error (datasheet, in LSBs). (4) Insufficient sampling time if you shortened it — re-run at 92.5 cycles and see if the low-end codes rise. A single conversion also carries the full quantization + input noise; averaging is deferred to later labs.

NoteWhy there is no Jetson/Pi port of this lab

The Jetson Orin Nano and Pi 5 have no on-chip ADC — there is no register to configure and no A0 pin; that absence is exactly why Lab 3.4 exists, whose Jetson procedure (ADS1115 over /dev/i2c-7) is this lab’s counterpart on embedded Linux. Module 5 stays on the MCU because its subject — the on-chip converter, its reference, its sampling time — only exists there.

Going further

  • Two-point calibration. Fit volts = a·code + b from your measured (code, Fluke) pairs by least squares (Course 1 Lesson 6) and report the residual. This calibration line is what you would ship.
  • Internal channels. The L476 exposes an internal VREFINT and a temperature sensor channel — read VREFINT to compute the true VDDA at runtime and auto-correct the scale factor.
  • Oversample. Average 256 conversions and watch the effective resolution improve by ~4 bits (½ bit per 4× averaging) — the bridge to the noise-floor work in Lab 6.4.