Random Processes on Hardware

Characterising noise sources on a microcontroller

The process classes from the main page are theory. On a real microcontroller you face a mixture of them (thermal noise from the sensor and amplifier, quantization noise from the ADC, interference from the switching power supply) and you need to tell them apart from data. This page shows how to collect a noise record, compute its statistical signature on the metal, and identify what you are looking at.

The code targets the two default platforms (ESP32-S3 and NUCLEO-F446RE, per ADR-005) but the principles transfer to any MCU with an ADC.


What you are measuring

When you sample an ADC input (even one tied to a quiet voltage reference, or shorted to ground) you do not read a constant value. The LSBs flicker. That flicker is the noise floor of your system, and it contains at least three distinct components:

Source Process class Spectrum What determines it
Thermal noise Gaussian, white Flat Resistance, temperature, bandwidth
Quantization noise Uniform, white (ideally) Flat, at \(Q^2/12\) ADC bit depth, input range
Interference Cyclostationary or deterministic Peaked at clock harmonics Board layout, decoupling, switching regulators

A 12-bit ADC with a 3.3 V reference has \(Q = 3.3 / 4096 \approx 0.81\) mV. The quantization noise floor is \(Q/\sqrt{12} \approx 0.23\) mV RMS, or about 0.3 LSB. If you measure an RMS noise of 2 LSB, the dominant source is not quantization; it is thermal noise in the analog front end or interference. Identifying which one determines what you do about it.


Collecting a noise record

The simplest noise measurement: tie an ADC input to a quiet reference (the internal bandgap, or a filtered mid-rail divider) and record a block of samples at a fixed sample rate. Do NOT use the same power rail the ADC is referencing; that couples supply noise into the measurement.

// ESP32-S3: collect a noise record from ADC1 channel 0
// Assumes ADC is already calibrated and configured for continuous sampling.

#define NOISE_BLOCK 2048

static int16_t noise_buffer[NOISE_BLOCK];

void collect_noise_record(void) {
    for (int i = 0; i < NOISE_BLOCK; i++) {
        noise_buffer[i] = adc1_get_raw(ADC_CHANNEL_0);
        // Pace samples at a fixed rate: use a timer ISR in production,
        // a simple ets_delay_us(100) for a quick bench test at ~10 kS/s.
    }
}

On the NUCLEO-F446RE the same pattern uses the ADC with DMA:

// NUCLEO-F446RE: ADC1 timer-triggered conversion with DMA into a buffer.
// TIM3 TRGO triggers ADC1 at a fixed rate; DMA circular mode fills the buffer.
// (CubeMX generates the handle initialisation; the calls below show the
// wiring that matters: timer-paced conversions, not free-running.)

#define NOISE_BLOCK 2048

static uint16_t noise_buffer[NOISE_BLOCK];

extern ADC_HandleTypeDef hadc1;   // ExternalTrigConv = ADC_EXTERNALTRIGCONV_T3_TRGO
extern TIM_HandleTypeDef htim3;   // TRGO on update event, ARR set for f_s

void noise_capture_start(uint32_t fs_hz) {
    // Pace TIM3 update events at fs_hz (APB1 timer clock = 90 MHz on F446).
    uint32_t arr = (90000000u / fs_hz) - 1u;
    __HAL_TIM_SET_AUTORELOAD(&htim3, arr);
    HAL_TIM_Base_Start(&htim3);

    // Each TRGO pulse starts one conversion; DMA writes it to the buffer.
    HAL_ADC_Start_DMA(&hadc1, (uint32_t *)noise_buffer, NOISE_BLOCK);
}

// HAL_ADC_ConvCpltCallback() fires when the buffer is full; process it
// there (or poll a flag it sets) before the circular DMA overwrites it.

The key requirement is fixed sample timing. If your samples are not evenly spaced, the computed statistics and ACF are not interpretable as a sampled random process.


Computing statistics on the metal

The characterisation from random_processes.py maps directly to integer arithmetic on an MCU. Here is a minimal C implementation that computes the mean, variance, and lag-1 autocorrelation from a buffer of 16-bit ADC readings:

#include <stdint.h>
#include <math.h>

typedef struct {
    float mean;
    float variance;
    float std_dev;
    float acf_lag1;       // normalised, -1 to 1
    float excess_kurtosis;
} NoiseStats;

NoiseStats characterize_int16(const int16_t *x, int n) {
    NoiseStats s = {0};

    // --- Mean ---
    double sum = 0.0;
    for (int i = 0; i < n; i++) sum += x[i];
    s.mean = (float)(sum / n);

    // --- Variance ---
    double sum_sq = 0.0;
    for (int i = 0; i < n; i++) {
        double d = x[i] - s.mean;
        sum_sq += d * d;
    }
    s.variance = (float)(sum_sq / (n - 1));  // sample variance
    s.std_dev = sqrtf(s.variance);

    // --- Excess kurtosis (Fisher) ---
    double sum_d4 = 0.0;
    for (int i = 0; i < n; i++) {
        double d = x[i] - s.mean;
        sum_d4 += d * d * d * d;
    }
    double m4 = sum_d4 / n;
    double m2 = sum_sq / n;
    if (m2 > 1e-12f) {
        s.excess_kurtosis = (float)(m4 / (m2 * m2) - 3.0);
    }

    // --- Normalised lag-1 autocorrelation ---
    double num = 0.0, den = 0.0;
    for (int i = 0; i < n - 1; i++) {
        double d0 = x[i] - s.mean;
        double d1 = x[i+1] - s.mean;
        num += d0 * d1;
        den += d0 * d0;
    }
    if (den > 1e-12f) {
        s.acf_lag1 = (float)(num / den);
    }

    return s;
}

Fixed-point note: The code above uses float and double for clarity. On an FPU-equipped part (Cortex-M4F, ESP32-S3) this runs in hardware at the sample rate. On parts without an FPU, convert to integer accumulation and size the accumulator from the worst case: a squared deviation of a full-range 16-bit sample is \(\approx 2^{32}\), so an int64_t sum is safe for any realistic block (\(2^{63}/2^{32} \approx 2 \times 10^9\) samples), while an int32_t sum is safe only when you can bound the deviations: for noise of at most \(d_\text{max}\) counts, \(N_\text{max} = 2^{31}/d_\text{max}^2\) (e.g. 8-count noise: \(N_\text{max} \approx 33\) million samples; full-range swings: only a handful). Derive the bound before trusting int32_t.


Interpreting the numbers

Once you have NoiseStats for your noise record, the same decision logic from the main page applies, translated into practical MCU terms:

void identify_noise_source(const NoiseStats *s) {
    // 1. Gaussian check: excess kurtosis near zero, symmetric distribution.
    if (fabsf(s->excess_kurtosis) < 0.5f) {
        // Likely thermal or quantization: both are symmetric and flat-topped.
        if (fabsf(s->acf_lag1) < 0.05f) {
            // White. If std_dev ≈ Q/sqrt(12) → quantization-limited.
            // If std_dev >> Q/sqrt(12) → thermal-limited (or interference).
        } else {
            // Coloured. AR process or cyclostationary.
        }
    } else if (s->excess_kurtosis > 2.0f) {
        // Heavy-tailed: impulse noise or a deterministic glitch.
    }
}

A practical conversion for a 12-bit ADC with 3.3 V reference: the quantization-limited RMS is \(Q/\sqrt{12} = (3.3/4096)/\sqrt{12} \approx 0.23\) mV. In ADC counts that is about 0.29 LSB. If your measured RMS is below 1 LSB, your system is quantization-limited: adding more ADC bits helps. If it is above 3 LSB, you are thermal- or interference-limited: more bits do nothing; fix the analog front end first.


The noise floor of a real ESP32-S3 ADC

Community measurements and Espressif’s own ADC documentation report that the ESP32-S3 SAR ADC is noticeably noisier than its 12-bit resolution suggests: on the order of a few LSB RMS with a quiet mid-scale input (Espressif does not publish a formal noise specification; measure your own board with the procedure below rather than trusting any quoted figure, including this one). The excess over the ideal quantization limit (0.29 LSB) comes from:

  • Thermal noise in the sampling capacitor (\(kT/C\) noise): SAR sampling capacitors are typically a few picofarads (the exact ESP32-S3 value is not published); for an illustrative 5 pF, the RMS noise is about \(\sqrt{k_B T / C} \approx 29\ \mu\text{V}\) at the capacitor, amplified by the ADC front end.
  • Supply noise coupling: the ADC’s internal reference is derived from the supply rail; any ripple on the 3.3 V line appears in the conversion result.
  • Digital switching noise: the ESP32’s WiFi/BLE radio and CPU core inject current spikes into the substrate that couple to the ADC input.

The ESP-IDF provides esp_adc_cal_characterize() for calibration, but calibration fixes accuracy (gain/offset), not precision (noise). To characterise the noise floor, collect a block of 2048 samples from a quiet input and run characterize_int16() on the result.


Telling thermal from interference

Thermal noise and interference both increase the measured RMS, but they have different ACF signatures. Thermal noise is white: the lag-1 ACF is near zero. Interference at a fixed frequency (e.g., 50 Hz mains hum, 100 kHz switching regulator ripple) has a structured ACF with a periodic component.

A CPU-light test on the MCU: compute the lag-1 ACF of consecutive blocks. If it is consistently near zero, the noise is white and you likely have a thermal limit. If it varies over time (higher when a switching regulator is under load, lower when idle), you have interference, and you can find the source by toggling peripherals.

// Quick periodicity check: compute ACF at the expected interference period.
// For 100 kHz switching noise sampled at 200 kS/s, the period is 2 samples.
float check_interference(const int16_t *x, int n, int suspect_period) {
    double num = 0.0, den = 0.0;
    float mean = 0.0;
    for (int i = 0; i < n; i++) mean += x[i];
    mean /= n;
    for (int i = 0; i < n - suspect_period; i++) {
        double d0 = x[i] - mean;
        double d1 = x[i + suspect_period] - mean;
        num += d0 * d1;
        den += d0 * d0;
    }
    return (den > 1e-12f) ? (float)(num / den) : 0.0f;
}

A significant ACF at the suspected interference period (say > 0.2) confirms that a deterministic signal is present. The remedy is not more averaging; it is better decoupling, shielding, or a notch filter at the interference frequency.


Practical takeaways

  1. Measure before you fix. Collect a noise record first. Know whether you are quantization-limited (add bits, oversample), thermal-limited (reduce resistance, cool it, or accept it), or interference-limited (fix the board).
  2. The ACF is your most informative single diagnostic. A flat ACF means white noise (thermal or quantization). A structured ACF means something deterministic is present.
  3. Excess kurtosis above 1.0 means tails heavier than Gaussian. Check for impulse sources: a switching regulator that fires sporadically, a GPIO with an unterminated pull-up coupling into the analog plane.
  4. The quantization floor is a hard limit, but you can trade bandwidth for bits. Oversampling by 4× and averaging buys 1 bit of effective resolution, provided the noise is white. If the noise is coloured or cyclostationary, oversampling does not help: the correlated noise averages more slowly than \(1/\sqrt{K}\).

The companion topic on ADC noise takes the oversampling and noise-shaping ideas further with a full treatment of the conversion pipeline.

References