Skip to content
Home » Embedded Systems » Embedded C » Filtering Noisy ADC Readings: 5 Practical Methods in Embedded C

Filtering Noisy ADC Readings: 5 Practical Methods in Embedded C

Sensors for Embedded Systems
Part 11 of 18 — View Full Path →

Key Takeaways

  • Five practical filters cover ~95 % of embedded sensor cleanup needs: moving average, median, EMA, oversampling, hysteresis.
  • EMA is the embedded sweet spot — one-line update, no buffer, tunable smoothness via a single alpha constant.
  • Median kills outliers (single bad samples don’t shift output). Moving average smooths gradual noise. They are not interchangeable.
  • Oversampling gains real bits — 4× oversample averaged = 1 extra effective bit. Trade CPU/time for resolution.
  • Hysteresis is for thresholds, not continuous readings — two thresholds separated by a band prevent flapping at the decision point.
  • Pick by latency tolerance + RAM/CPU budget + noise character. The comparison table at the end of this post is the cheat sheet.

You did the hardware right — stable reference voltage, low-impedance source, separate analogue ground, good decoupling. The ADC readings are still jittery. That residual noise is unavoidable physics — quantisation, thermal noise, picked-up interference — and the fix is in firmware. This post walks through the five filters that handle 95 % of embedded sensor cleanup, with C code, real trade-offs, and a comparison table you can use to pick the right one for your application.

If you haven’t already, the prerequisite hardware-side reading is ADC in Microcontrollers: How It Works — the filters below assume your reference, grounding, and source impedance are sane.

Why ADC readings are noisy in the first place

Even with a textbook-perfect board, ADC readings carry several layers of noise:

  • Quantisation noise — the LSB always jitters one count. Fundamental to the converter.
  • Reference voltage noise — any wobble on VREF shows up directly in every reading.
  • EMI / coupled interference — switching loads, RF, mains hum all sneak in through the supply or as field-coupled charge on the trace.
  • Sensor-side noise — many real sensors are noisy at the input (thermistors, photodiodes, microphones).
  • Occasional spikes — ultrasonic sensors picking up a stray reflection, IR sensors catching ambient flicker, capacitive sensors near a contact event.

For hardware-side fixes (decoupling, grounding, shielding) see noise in sensor measurements and signal conditioning for sensors. Everything below assumes that work is done; we are now in firmware land.

Filter 1: Simple Moving Average

Keep the last N samples in a circular buffer. The output is their average. Noise reduces by a factor of √N — averaging 16 samples cuts noise by 4×.

#define WINDOW 16

uint16_t buf[WINDOW];     // circular buffer of last WINDOW raw samples
uint8_t  idx = 0;
uint32_t sum = 0;
bool     primed = false;

uint16_t moving_average(uint16_t new_sample) {
    sum -= buf[idx];               // drop the oldest contribution
    buf[idx] = new_sample;         // overwrite oldest slot
    sum += new_sample;             // add the newest
    idx = (idx + 1) % WINDOW;
    if (!primed && idx == 0) primed = true;
    return primed ? (sum / WINDOW) : (sum / (idx ? idx : 1));
}

Pros: Simple to understand, predictable behaviour, very effective for Gaussian noise on a slowly-changing signal.

Cons: Needs N×sizeof(sample) bytes of RAM. Adds N/2 samples of group delay — your readings lag the actual signal. Treats every sample equally (an outlier weighs as much as a good reading).

Use when: The signal changes slowly, noise is roughly Gaussian, you have RAM to spare. Typical N: 8–32. Beyond N=32, the lag usually outweighs the noise reduction (which scales only as √N).

Filter 2: Median Filter

Keep the last N samples (N usually odd: 3, 5, 7), sort them, return the middle one. A single bad sample — a spike or dropout — can never shift the median because it ends up at one end of the sorted list.

#define MED_N 5

uint16_t med_buf[MED_N];
uint8_t  med_idx = 0;

uint16_t median_filter(uint16_t new_sample) {
    med_buf[med_idx] = new_sample;
    med_idx = (med_idx + 1) % MED_N;

    // copy + insertion sort (fast for small N)
    uint16_t sorted[MED_N];
    memcpy(sorted, med_buf, sizeof(sorted));
    for (int i = 1; i < MED_N; i++) {
        uint16_t key = sorted[i];
        int j = i - 1;
        while (j >= 0 && sorted[j] > key) {
            sorted[j + 1] = sorted[j];
            j--;
        }
        sorted[j + 1] = key;
    }
    return sorted[MED_N / 2];
}

Pros: Immune to outliers. A wild reading from a temporary reflection or interference cannot propagate to the output. Preserves signal edges better than averaging.

Cons: Sort cost is O(N log N). Fine for N=5; slow at N=51. Less effective at smoothing Gaussian (continuous) noise than averaging.

Use when: The signal has occasional bad samples — ultrasonic readings with stray reflections, IR sensors with ambient light flicker, capacitive touch with debounce. Typical N: 3, 5, or 7.

Filter 3: Exponential Moving Average (EMA) — the embedded default

One line. No buffer. One tunable knob (alpha). Behaves like a first-order RC low-pass filter in software. This is what most embedded firmware reaches for when memory or speed matters.

// alpha in (0, 1]. Smaller = smoother but laggier.
// Typical alpha for sensor cleanup: 0.05 to 0.30.
static float ema = 0.0f;
const  float alpha = 0.1f;

float ema_filter(uint16_t new_sample) {
    ema = ema + alpha * ((float)new_sample - ema);
    return ema;
}

If you want to avoid floats (common on smaller AVRs), an integer-only version using a power-of-two alpha is fast and exact:

// alpha = 1/16, integer-only
static uint32_t ema_x16 = 0;   // EMA scaled by 16 to preserve precision

uint16_t ema_filter_int(uint16_t new_sample) {
    ema_x16 += (uint32_t)new_sample - (ema_x16 >> 4);
    return ema_x16 >> 4;
}

Pros: One variable of state. Single multiply + add per sample. Tunable smoothness via one constant. Excellent for continuous data.

Cons: Outliers still leak in (weighted by alpha). No clean cut-off frequency — it’s a soft first-order roll-off, not a brick wall.

Use when: Continuous signals where RAM is tight and smoothness matters more than spike rejection. Tune alpha empirically — start at 0.1 and adjust.

Filter 4: Oversampling and Downsampling

Sample faster than you need, average, and report at the slower rate. This actually gains effective resolution: 4× oversample averaged = 1 extra effective bit, 16× = 2 extra bits, 256× = 4 extra bits. The catch is that this only works if there is some genuine noise in the signal (without noise, every sample is the same and averaging adds nothing).

#define OVERSAMPLE 16  // 4 extra effective bits if noise > 1 LSB

uint16_t oversample_read(uint8_t pin) {
    uint32_t accumulator = 0;
    for (int i = 0; i < OVERSAMPLE; i++) {
        accumulator += analogRead(pin);
    }
    return accumulator / OVERSAMPLE;
}

// To gain bits without losing them in the divide, scale up the accumulator:
//   gain N=4^k bits by:  accumulator / (2^k)
// e.g. 16x oversample, return accumulator >> 2 to get 12-bit from a 10-bit ADC

Pros: Real resolution gain. Atmel’s official app note AVR121 documents this technique on AVR. Works particularly well for slow signals where you have time to spare.

Cons: CPU cost is linear in the oversample factor. Sample period × N is the new effective period — you read slower. Bit gain caps at the noise level: if your noise is <½ LSB, oversampling adds nothing.

Use when: You need more effective bits than the hardware gives, the signal changes slowly enough to spare the time, and there is at least ~½ LSB of natural noise to dither against (almost always true in practice).

Filter 5: Hysteresis (for thresholds, not continuous data)

The first four filters smooth continuous readings. Hysteresis is for the opposite case — when you are turning a continuous reading into a yes/no decision (button pressed, level full, motion detected) and the reading hovers near the threshold. With a single threshold, the output flaps on and off. With two thresholds separated by a hysteresis band, the output stays stable.

// Light sensor — turn on a lamp when ambient drops, but don't flap at twilight.
const uint16_t TURN_ON_BELOW   = 200;   // dark
const uint16_t TURN_OFF_ABOVE  = 350;   // clearly light

static bool lamp_on = false;

bool light_decision(uint16_t adc) {
    if (lamp_on  && adc > TURN_OFF_ABOVE) lamp_on = false;
    if (!lamp_on && adc < TURN_ON_BELOW)  lamp_on = true;
    return lamp_on;
}

Pros: One variable of state. Zero latency. Removes flapping at threshold-crossings without slowing the system down.

Cons: Only meaningful for boolean decisions, not continuous output. The band width is a tuning constant you set by experiment.

Use when: Converting an analogue reading to a discrete state (motion, contact, level, threshold alarm). Always combine with one of the smoothing filters above if the underlying analogue reading itself is noisy — hysteresis solves the threshold flap, not the input jitter.

The cheat sheet — which filter when?

FilterLatencyRAMCPU per sampleBest for
Moving averageN/2 samplesN × sample size1 add + 1 sub + 1 divgradual Gaussian noise, slow-changing signals
MedianN/2 samplesN × sample sizeO(N log N) sortimpulse noise, outliers, edge preservation
EMA~1/α samples1 variable1 mult + 1 addthe default — continuous data, tight memory
OversampleN × sample period1 accumulatorN reads + 1 divgaining effective bits beyond hardware resolution
Hysteresis0 (decision)1 boolean2 comparesthreshold decisions (motion, level, contact)

Can I combine filters?

Yes, and you often should. The standard pattern for a noisy sensor with occasional spikes is:

  1. Oversample in hardware/firmware to gain a couple of effective bits and a smoother baseline.
  2. Median filter the oversampled output to kill any remaining spikes.
  3. EMA on the median output for final smoothing.
  4. Hysteresis if you are converting to a threshold decision.

Each stage handles a different kind of noise: oversampling for quantisation, median for impulses, EMA for residual continuous noise, hysteresis for boolean stability. Total RAM cost is still tiny; total CPU is still well under 1 % on a modern MCU.

When you need more than these five

Three cases warrant heavier machinery:

  • Kalman filter — when you have a model of how the underlying quantity should evolve (e.g. accelerometer + gyroscope fusion for orientation). Worth the implementation cost only when you have a usable process model.
  • FIR / IIR digital filters with designed coefficients — when you need a specific frequency response (e.g. 50 Hz notch to kill mains hum). Design via tools like the Octave/MATLAB fir1/butter functions.
  • FFT-based filtering — when you need to separate signals in the frequency domain (audio EQ, vibration analysis). Most MCU work doesn’t need this; when you do, libraries like ARM CMSIS-DSP have efficient implementations.

For 95 % of embedded sensor work, the five filters above are enough. Reach for Kalman / FIR / FFT only when the simpler options demonstrably fail.

Conclusion — pick by noise character, not habit

The mistake most people make is using moving average for everything. It is the default that gets taught in school but it is rarely the best fit. A 3-line decision tree:

  • Continuous, Gaussian-ish noise, tight RAM → EMA
  • Occasional spikes / outliers → median, then EMA
  • Need more bits than the ADC gives → oversample, then EMA
  • Turning into a yes/no decision → one of the above, then hysteresis

And remember: filtering only fixes noise that survives the hardware. If your readings are wandering by 100 LSBs, no filter will save you — go fix the VREF, decoupling, or grounding first (see ADC in Microcontrollers for the hardware checklist).

Related Reading

Frequently Asked Questions

When should I use a median filter instead of a moving average?

Use median when the noise is dominated by occasional bad samples — single spikes, dropouts, reflections — rather than gradual jitter. A median filter discards outliers entirely (a wild reading ends up at one end of the sorted list and never reaches the output). Moving average is the right pick for Gaussian-ish continuous noise. The two address different noise characters.

What alpha value should I pick for an exponential moving average?

Alpha is between 0 and 1. Smaller = smoother but laggier. Start at alpha = 0.1 and adjust empirically — if the output is too jittery, halve alpha; if it lags the signal too much, double it. A useful intuition: with alpha = α, the EMA reaches ~63 % of a step input in 1/α samples. So α = 0.1 means a step takes ~10 samples to follow; α = 0.05 takes 20.

Does oversampling really gain effective bits?

Yes — provided the signal carries at least ~½ LSB of noise (which is almost always the case in real circuits). Averaging N samples reduces noise by a factor of √N, which translates to log4(N) extra effective bits: 4× oversample = 1 bit, 16× = 2 bits, 256× = 4 bits. Without natural noise to dither against, oversampling just averages the same value repeatedly and adds nothing.

Is a Kalman filter overkill for cleaning sensor readings?

Usually yes. Kalman filters shine when you have a model of how the underlying quantity changes (e.g. accelerometer + gyroscope fusion for orientation, where you know rotation follows physics). For “I have one noisy sensor and want a smoother number,” EMA does the job at 1 % of the implementation effort. Use Kalman when fusing multiple sensors with a known process model — not as a generic noise filter.

Can I combine multiple filters?

Yes — the standard chain is: oversample first (gains bits + smoother baseline), then median (kills any remaining spikes), then EMA (final smoothing), then hysteresis (only if you are turning the reading into a yes/no decision). Each stage targets a different kind of noise. Total RAM cost stays small and CPU stays well under 1 % on a modern MCU.

Does filtering add lag to my readings?

Yes, always. Moving average adds ~N/2 samples of group delay; EMA adds ~1/α samples; median adds ~N/2; oversampling adds N sample periods. The trade-off is always smoothness-vs-lag. The right answer is to pick the smallest filter that gives acceptable smoothness — don’t add filtering you don’t need.

Leave a Reply

Your email address will not be published. Required fields are marked *