ADC Noise on Hardware
Measuring ENOB on an STM32 ADC
The main page gives the theory. This page is the measurement: how to collect a noise record from a real STM32 ADC, compute SINAD and ENOB, and identify whether you are quantisation-limited, thermal-limited, or jitter-limited.
Collecting the data
Tie ADC1 channel 0 to a low-impedance, well-decoupled voltage reference at mid-scale (e.g., a filtered 1.65 V divider from the 3.3 V rail, buffered with an op-amp). Sample at a fixed rate; use a timer trigger, not software polling, to keep the sampling instants regular.
// NUCLEO-F446RE: TIM3 TRGO -> ADC1, DMA circular, 4096-sample buffer.
#define N_SAMPLES 4096
static uint16_t adc_buffer[N_SAMPLES];
// In main(): configure TIM3 at e.g. 100 kHz, ADC1 triggered by TIM3,
// DMA2_Stream0 in circular mode. Start the timer. The buffer fills
// continuously. Read when needed.For ENOB measurement you need a spectrally pure input: a function generator with a bandpass filter or a low-distortion sine source. For noise-floor measurement a quiet DC input is sufficient.
Computing SINAD and ENOB from captured data
SINAD requires separating the signal power from the noise-plus-distortion power. For a sine-wave input at known frequency \(f_{in}\):
#include "arm_math.h"
#define N_FFT 1024
static float32_t fft_in[N_FFT];
static float32_t fft_out[N_FFT];
static arm_rfft_fast_instance_f32 rfft_inst;
float compute_enob(const uint16_t *adc_data, int n_samples, float v_ref, int bits) {
arm_rfft_fast_init_f32(&rfft_inst, N_FFT);
// Copy first N_FFT samples, convert to voltage, remove DC.
float mean = 0.0f;
for (int i = 0; i < N_FFT; i++) {
fft_in[i] = adc_data[i] * v_ref / (float)((1 << bits) - 1);
mean += fft_in[i];
}
mean /= N_FFT;
for (int i = 0; i < N_FFT; i++) fft_in[i] -= mean;
// Real FFT.
arm_rfft_fast_f32(&rfft_inst, fft_in, fft_out, 0);
// Find the signal bin (largest magnitude).
float sig_power = 0.0f, noise_power = 0.0f;
int peak_bin = 1;
float peak_mag = 0.0f;
// Compute magnitude squared of each bin.
// fft_out format: [real0, real1, imag1, real2, imag2, ...]
for (int k = 1; k < N_FFT / 2; k++) {
float real = fft_out[2 * k];
float imag = fft_out[2 * k + 1];
float mag2 = real*real + imag*imag;
if (mag2 > peak_mag) { peak_mag = mag2; peak_bin = k; }
}
// Signal power from the peak bin + a few neighbours (to capture leakage).
for (int k = peak_bin - 2; k <= peak_bin + 2; k++) {
if (k >= 1 && k < N_FFT / 2) {
float real = fft_out[2*k], imag = fft_out[2*k+1];
sig_power += real*real + imag*imag;
}
}
// Total power in all other bins is noise + distortion.
for (int k = 1; k < N_FFT / 2; k++) {
if (k < peak_bin - 2 || k > peak_bin + 2) {
float real = fft_out[2*k], imag = fft_out[2*k+1];
noise_power += real*real + imag*imag;
}
}
float sinad = 10.0f * log10f(sig_power / noise_power);
float enob = (sinad - 1.76f) / 6.02f;
return enob;
}This is a simplified single-FFT SINAD measurement; a proper characterisation uses Welch averaging (see the PSD estimation embedded page) and a non-coherent input frequency to avoid bin-alignment artifacts.
ESP32-S3
The ESP32-S3 SAR ADC has different noise characteristics from the STM32F4: higher DNL (especially near 0 and full-scale), and an internal bandgap reference of roughly 1.1 V: the usable full-scale input range is set by the programmable attenuator (about 2.5–3.1 V at the highest attenuation, chip- and calibration-dependent), not by the 3.3 V supply. The measurement procedure is the same, quiet mid-scale input, fixed-rate sampling, but the ESP-IDF ADC driver is different:
// ESP32-S3: SINAD/ENOB measurement using oneshot ADC + esp-dsp FFT.
#include "esp_adc/adc_oneshot.h"
#include "esp_adc/adc_cali.h"
#include "esp_adc/adc_cali_scheme.h"
#include "dsps_fft2r.h"
#define N_FFT 1024
static float fft_in[N_FFT * 2]; // esp-dsp needs interleaved real/imag
static float fft_window[N_FFT];
void enob_init_esp32(void) {
// Once, at startup: generate the FFT twiddle table. Calling
// dsps_fft2r_fc32() without this runs on an uninitialised table.
ESP_ERROR_CHECK(dsps_fft2r_init_fc32(NULL, N_FFT));
}
void compute_enob_esp32(adc_oneshot_unit_handle_t adc_handle,
adc_cali_handle_t cali_handle,
float v_ref, int bits) {
// Collect N_FFT samples at a fixed rate via timer ISR (not shown).
// Convert raw readings to calibrated voltage.
float mean = 0.0f;
for (int i = 0; i < N_FFT; i++) {
int raw;
adc_oneshot_read(adc_handle, ADC_CHANNEL_0, &raw);
int voltage_mv;
adc_cali_raw_to_voltage(cali_handle, raw, &voltage_mv);
fft_in[2*i] = voltage_mv / 1000.0f; // real part
fft_in[2*i+1] = 0.0f; // imag part
mean += fft_in[2*i];
}
mean /= N_FFT;
for (int i = 0; i < N_FFT; i++) fft_in[2*i] -= mean;
// esp-dsp real FFT (in-place, window optional).
dsps_fft2r_fc32(fft_in, N_FFT);
// Bit-reverse the output (esp-dsp convention).
dsps_bit_rev_fc32(fft_in, N_FFT);
// Same signal/noise power summation as the STM32 version above.
// (Omitted for brevity, identical algorithm.)
}Espressif publishes no formal ENOB specification for the S3; community measurements commonly land around 8-9 ENOB at the 12-bit setting (vs roughly 10-11 for the STM32F4), mostly due to higher DNL and a noisier internal reference, but the honest number for your board is the one this page’s procedure measures. The calibration API (adc_cali_raw_to_voltage) corrects for gain and offset but not for DNL.
Verifying oversampling gain
The simplest test of the white-noise assumption: collect data at the base sample rate, then decimate in software by averaging blocks of 4, 16, and 64 samples. Compute the variance of each decimated stream. If the noise is white, the variance should decrease as \(1/\text{OSR}\). If it decreases more slowly, the noise is coloured (likely \(1/f\) or cyclostationary) and oversampling will not deliver the theoretical gain.
float verify_oversampling_gain(const uint16_t *data, int n, int osr) {
float sum_block, var_accum = 0.0f;
int n_blocks = n / osr;
float *block_means = malloc(n_blocks * sizeof(float));
for (int b = 0; b < n_blocks; b++) {
sum_block = 0.0f;
for (int i = 0; i < osr; i++) sum_block += data[b * osr + i];
block_means[b] = sum_block / osr;
}
float mean_of_means = 0.0f;
for (int b = 0; b < n_blocks; b++) mean_of_means += block_means[b];
mean_of_means /= n_blocks;
for (int b = 0; b < n_blocks; b++) {
float d = block_means[b] - mean_of_means;
var_accum += d * d;
}
var_accum /= (n_blocks - 1);
free(block_means);
return var_accum;
}If verify_oversampling_gain() returns a variance that scales as \(V_0/\text{OSR}\), the noise is white and oversampling buys bits as expected. If it plateaus, you have a non-white noise source and need to find it before relying on oversampling for resolution.
A 1-bit sigma-delta DAC on one pin
The main page’s modulator section runs in reverse on any MCU: one GPIO or SPI data pin plus an external RC low-pass is a DAC whose resolution you buy with oversampling. The modulator is three lines of integer C; the engineering is in how the bits leave the chip.
// First-order 1-bit sigma-delta modulator, integer state, Q15 input.
// Feedback is +-32767, so the input is saturated to +-32767 first:
// int16_t admits one extra negative code (-32768) that the feedback
// could never cancel, and the accumulator would drift without bound
// on sustained full-scale-negative input. With the clamp, |acc| stays
// bounded by |x| + 32767 <= 2^16 - 2 and the int32 cannot overflow.
static int32_t sd_acc = 0;
static inline uint32_t sd_bit(int16_t x) {
int32_t xi = (x < -32767) ? -32767 : (int32_t)x;
int32_t y = (sd_acc >= 0) ? 32767 : -32767;
sd_acc += xi - y;
return (y > 0);
}
// Pack one input sample into OSR output bits (MSB first).
// For OSR = 64 and 8 kHz audio the bit rate is 512 kbit/s.
void sd_modulate_block(const int16_t *x, uint8_t *bitbuf, int n, int osr) {
int bit_idx = 0;
for (int i = 0; i < n; i++) {
for (int k = 0; k < osr; k++) {
uint32_t b = sd_bit(x[i]);
bitbuf[bit_idx >> 3] = (uint8_t)((bitbuf[bit_idx >> 3] << 1) | b);
bit_idx++;
}
}
}Toggling a GPIO from a timer ISR at 512 kHz is possible on a 180 MHz Cortex-M4 but wasteful (an ISR every ~350 cycles). The right transport on both default platforms is a serial peripheral with DMA: the modulator fills a bit-packed buffer at block rate, and the peripheral shifts it out at the bit rate with zero CPU cost.
- NUCLEO-F446RE: SPI in transmit-only mode, MOSI is the output pin.
HAL_SPI_Transmit_DMA(&hspi2, bitbuf, n_bytes)with the SPI clock prescaled to the bit rate; double-buffer with the half/full-complete callbacks. - ESP32-S3: the I2S peripheral in standard TX mode does the same job (
i2s_channel_writeon a DMA-backed channel), and its clock divider sets the bit rate directly.
After the pin: a simple RC (say 10 kΩ and 33 nF, corner ~480 Hz) or an RC-RC ladder is the entire analog reconstruction for a sub-kilohertz signal band at OSR = 64. The shaped noise sits near the bit rate, far above the corner, which is precisely why the modulator ran at 512 kHz in the first place.
Two honest caveats. First, this is the amplitude-domain twin of what a PDM microphone does in reverse: MEMS PDM mics emit exactly such a 1-bit sigma-delta stream, and the MCU’s job there is the decimation side (a CIC, in a peripheral or in software; see the multirate chapter). Second, the analog world does not honour the ideal NTF: jitter on the bit clock, unequal rise and fall times, and supply ripple all leak error back into the band. The first-order loop shown here at OSR = 64 has an ideal ceiling of about 9 bits (Exercise 1 on the main page computes it), and a real GPIO implementation lands a bit or two below whatever its loop’s ideal is; the 14-ideal-bit territory of a second-order loop at the same OSR demands the second integrator and correspondingly cleaner analog edges.