Smoothing on Hardware
Moving average and exponential smoothing on ESP32-S3 and STM32F4
Smoothing is often the first DSP operation applied to sensor data on a microcontroller — before peak detection, zero-crossing analysis, or any downstream processing. The moving average and exponential moving average (EMA) are trivially cheap to implement and cover the majority of embedded smoothing needs.
For the theory (frequency response, Savitzky-Golay, kernel smoothing, trade-offs), see the main smoothing page.
Exponential moving average (EMA)
The EMA is the simplest recursive smoother and the most commonly used on microcontrollers. As written below it costs two multiplies and one addition (it can be factored to \(y[n] = x[n] + \alpha\,(y[n-1] - x[n])\) for one multiply plus a subtract), with a single state variable:
\[y[n] = \alpha \cdot y[n-1] + (1 - \alpha) \cdot x[n]\]
Note the convention: here \(\alpha\) weights the previous output, so \(\alpha\) near 1 means heavy smoothing, the opposite of the main page’s smoothing-factor \(\alpha\) (which weights the new sample, small \(\alpha\) = heavy smoothing). They relate by \(\alpha_\text{here} = 1 - \alpha_\text{main}\).
typedef struct {
float alpha;
float state;
int initialised;
} EmaFilter;
void ema_init(EmaFilter *f, float alpha) {
f->alpha = alpha;
f->state = 0.0f;
f->initialised = 0;
}
float ema_process(EmaFilter *f, float x) {
if (!f->initialised) {
f->state = x;
f->initialised = 1;
} else {
f->state = f->alpha * f->state + (1.0f - f->alpha) * x;
}
return f->state;
}The time constant is \(\tau = -1 / \ln(\alpha)\) samples. For \(\alpha = 0.9\) at 100 Hz sample rate, \(\tau \approx 9.5\) samples (95 ms). For \(\alpha = 0.99\), \(\tau \approx 100\) samples (1 s).
On targets without hardware FPU (e.g., Cortex-M0), the EMA can be implemented in fixed-point with a power-of-two scaling: \(y[n] = y[n-1] - (y[n-1] \gg K) + (x[n] \gg K)\), where \(\alpha = 1 - 2^{-K}\). This requires only shifts and additions — no multiplications.
Moving average with circular buffer
The \(N\)-point moving average requires storing the last \(N\) samples. A circular buffer avoids shifting the entire array on every sample:
typedef struct {
float *buffer;
float sum; // running sum for O(1) update
int head;
int N;
int count; // samples received so far (for startup)
} MovingAverage;
void ma_init(MovingAverage *f, float *buffer, int N) {
f->buffer = buffer;
f->sum = 0.0f;
f->head = 0;
f->N = N;
f->count = 0;
for (int i = 0; i < N; i++) buffer[i] = 0.0f;
}
float ma_process(MovingAverage *f, float x) {
// Subtract the oldest sample, add the new one
f->sum -= f->buffer[f->head];
f->buffer[f->head] = x;
f->sum += x;
f->head++;
if (f->head >= f->N) f->head = 0;
if (f->count < f->N) f->count++;
return (f->count > 0) ? f->sum / f->count : 0.0f;
}The running-sum trick makes the moving average O(1) per sample regardless of window size — one subtraction, one addition, and one division. This is the same cost as the EMA but requires \(N\) floats of memory.
The running sum accumulates rounding errors over millions of samples. For long-running embedded systems (days or weeks), periodically recompute the sum from scratch: sum = 0; for (i = 0; i < N; i++) sum += buffer[i];. Once per second is sufficient and costs negligible CPU.
STM32F4 (NUCLEO-F446RE): sensor noise reduction
A typical use case: smooth noisy ADC readings before threshold comparison or feature extraction.
#include "arm_math.h"
#define ADC_FS 1000 // 1 kHz ADC sample rate
#define MA_LEN 16 // 16-sample moving average (16 ms window)
static float ma_buf[MA_LEN];
static MovingAverage ma;
static EmaFilter ema;
void sensor_init(void) {
ma_init(&ma, ma_buf, MA_LEN);
ema_init(&ema, 0.995f); // ~200 ms time constant at 1 kHz (tau = -1/ln(0.995) ~= 200 samples)
}
// Called from ADC DMA callback at 1 kHz
void process_adc_sample(uint16_t raw) {
float voltage = (float)raw * 3.3f / 4096.0f;
// Moving average for noise reduction
float smoothed_ma = ma_process(&ma, voltage);
// EMA for slow-varying baseline tracking
float baseline = ema_process(&ema, voltage);
// Use smoothed_ma for threshold detection,
// baseline for drift compensation
}For CMSIS-DSP block processing, arm_mean_f32 computes the mean of a block — equivalent to a non-overlapping moving average:
float32_t block[64];
float32_t mean;
arm_mean_f32(block, 64, &mean);ESP32-S3: temperature / accelerometer smoothing
A common pattern on ESP32-S3: smooth a sensor reading before displaying or transmitting via BLE.
static EmaFilter temp_filter;
static float ma_buf[32];
static MovingAverage accel_filter;
void sensor_task(void *param) {
ema_init(&temp_filter, 0.998f); // slow: ~5 s time constant at 100 Hz (tau = -1/ln(0.998) ~= 500 samples)
ma_init(&accel_filter, ma_buf, 32); // 32-sample window (320 ms at 100 Hz)
while (true) {
float temperature = read_temperature_sensor();
float accel_z = read_accelerometer_z();
float temp_smooth = ema_process(&temp_filter, temperature);
float accel_smooth = ma_process(&accel_filter, accel_z);
// Update BLE characteristics or display
vTaskDelay(pdMS_TO_TICKS(10)); // 100 Hz
}
}Performance budget
Smoothing is negligible on any modern MCU:
| Filter | Operations per sample | Cycles (est.) | Memory |
|---|---|---|---|
| EMA | 2 multiply + 1 add | ~5 | 4 bytes (state) |
| MA (N=16, running sum) | 1 add + 1 sub + 1 div | ~10 | 4N + 8 bytes |
| MA (N=64, running sum) | Same | ~10 | 4N + 8 bytes |
At 1 kHz sample rate on a 180 MHz Cortex-M4F, even a 64-point MA uses about 0.006% of the CPU budget (~10 cycles/sample × 1 kHz ÷ 180 MHz). The smoothing filter itself is never the bottleneck — the sensor read (I2C, SPI, ADC) dominates.
When to use which
| Criterion | EMA | Moving average |
|---|---|---|
| Memory | 4 bytes (fixed) | 4N bytes (scales with window) |
| Startup behaviour | Immediate (uses first sample) | Ramps up over N samples |
| Frequency response | First-order IIR (gradual rolloff) | Sinc-like (nulls at \(f_s/N\)) |
| Step response | Exponential rise | Linear rise over N samples |
| Best for | Baseline tracking, rate smoothing | Noise reduction, pre-filtering |
| Avoid when | Sharp cutoff needed | Memory-constrained and N is large |