Quando você lê um ADC (Conversor Analógico-Digital) no “mundo real”, você não está lendo apenas o sinal útil: você está lendo também ruídos do próprio ADC (quantização, ruído térmico interno, ruído de referência), ruídos de fonte e layout (chaveamento de regulador, retorno de GND, acoplamento de trilhas), e ruídos “de ambiente” (EMI de motores, relés, Wi-Fi, etc.). O resultado clássico é um valor que “dança” alguns LSBs (Least Significant Bits) mesmo com o sinal estável. Filtrar isso em firmware costuma ser o caminho mais barato e rápido, desde que você escolha um filtro compatível com o tipo de ruído e com a dinâmica do seu sinal (se você filtrar demais, mata resposta; se filtrar de menos, sobra ruído).
Um jeito prático de pensar é: primeiro você decide o que quer preservar (variações lentas? degraus rápidos? picos curtíssimos devem sumir?), depois você escolhe um filtro com custo computacional adequado ao MCU e ao seu sampling rate (taxa de amostragem). Em projetos embarcados, isso normalmente vira uma combinação simples e robusta: um filtro de mediana para “estouros” (spikes) + um passa-baixas leve (IIR de 1ª ordem) para suavizar o jitter. A ideia de encapsular a filtragem como um “bloco” bem definido (um filtro como componente) é um padrão recorrente em arquitetura de sistemas embarcados, porque separa aquisição, filtragem e consumo do dado, facilitando testes e evolução do firmware.
O “mínimo que funciona” (pipeline recomendado)
Para a maioria dos sensores analógicos comuns (NTC, shunt com amplificador, potenciômetro, sensores de pressão), um pipeline simples funciona muito bem: você coleta N amostras rápidas, derruba outliers com mediana de 3 ou 5, e em seguida suaviza com um IIR 1-pole. A mediana protege contra picos (por exemplo, interferência curta de comutação), e o IIR dá suavidade com custo baixíssimo e latência controlável.
A seguir eu deixo códigos em C “portáveis”, onde você só precisa plugar sua função de leitura do ADC (no ATmega pode ser adc_read(), no ESP32 pode ser adc1_get_raw()/adc_oneshot_read()).
Filtro de média móvel (FIR simples) — bom para ruído “aleatório”, ruim para degrau rápido
A média móvel é o filtro mais lembrado porque é fácil de entender. Ela reduz ruído branco aproximadamente com ganho de suavização proporcional a √N, mas adiciona latência e “borra” degraus. Se seu sinal muda devagar (ex.: temperatura), ela é ótima.
#include <stdint.h>
typedef struct {
uint32_t acc;
uint16_t *buf;
uint16_t size;
uint16_t idx;
uint8_t primed;
} movavg_t;
void movavg_init(movavg_t *f, uint16_t *buffer, uint16_t size) {
f->acc = 0;
f->buf = buffer;
f->size = size;
f->idx = 0;
f->primed = 0;
for (uint16_t i = 0; i < size; i++) f->buf[i] = 0;
}
uint16_t movavg_update(movavg_t *f, uint16_t x) {
f->acc -= f->buf[f->idx];
f->buf[f->idx] = x;
f->acc += x;
f->idx++;
if (f->idx >= f->size) {
f->idx = 0;
f->primed = 1;
}
// Durante o aquecimento, a média fica "puxada" para zero.
// Você pode tratar isso retornando x até primed=1, se preferir.
uint16_t denom = f->primed ? f->size : (f->idx == 0 ? 1 : f->idx);
return (uint16_t)(f->acc / denom);
}
No ATmega, isso é leve, mas se você usar janelas grandes (ex.: 64, 128) em uma taxa alta, vira custo de RAM e latência. No ESP32, geralmente é tranquilo.
Filtro IIR de 1ª ordem (passa-baixas “exponencial”) — o cavalo de batalha do firmware
Esse filtro é o “suavizador” mais usado em embarcados porque tem custo O(1), pouca RAM e é estável se você escolher o ganho corretamente. Ele implementa, na prática, uma média ponderada: o valor novo puxa o estado aos poucos. Em tempo discreto: y[n] = y[n-1] + α (x[n] - y[n-1]). O parâmetro α controla o compromisso entre suavidade e resposta.
Versão fixed-point (boa para ATmega, sem float)
Aqui eu uso Q15 (15 bits fracionários). alpha_q15 vai de 1 a 32767, equivalente a (0, 1).
#include <stdint.h>
typedef struct {
int32_t y_q15; // estado em Q15
uint16_t alpha_q15; // 0..32767 (~0..1)
uint8_t initialized;
} iir1_q15_t;
void iir1_q15_init(iir1_q15_t *f, uint16_t alpha_q15) {
if (alpha_q15 > 32767) alpha_q15 = 32767;
f->y_q15 = 0;
f->alpha_q15 = alpha_q15;
f->initialized = 0;
}
uint16_t iir1_q15_update(iir1_q15_t *f, uint16_t x) {
int32_t x_q15 = ((int32_t)x) << 15;
if (!f->initialized) {
f->y_q15 = x_q15;
f->initialized = 1;
return x;
}
// y = y + alpha*(x - y)
int32_t err = x_q15 - f->y_q15;
f->y_q15 += ( (int32_t)f->alpha_q15 * err ) >> 15;
// arredondamento simples e saturação para 16 bits
int32_t y = (f->y_q15 + (1 << 14)) >> 15;
if (y < 0) y = 0;
if (y > 65535) y = 65535;
return (uint16_t)y;
}
Como escolher α sem virar matemática demais? Se você amostra a cada Ts e quer uma suavização equivalente a uma constante de tempo aproximada τ, uma aproximação prática é α ≈ Ts / (τ + Ts). Exemplo: amostragem a 1 kHz (Ts=1 ms) e você quer “assentar” em ~100 ms ⇒ α ≈ 0,001/(0,101) ≈ 0,0099, então alpha_q15 ≈ 0,0099 * 32768 ≈ 324. Isso dá um filtro bem suave.
Filtro de mediana — mata spikes sem “atrasar” tanto quanto média
Se o seu problema são picos curtos (ex.: ruído de chaveamento de relé, PWM, comutação de fonte), a mediana é muito eficiente. Para janela 3, o custo é mínimo.
#include <stdint.h>
static inline uint16_t median3_u16(uint16_t a, uint16_t b, uint16_t c) {
// ordena parcialmente sem usar array
if (a > b) { uint16_t t=a; a=b; b=t; }
if (b > c) { uint16_t t=b; b=c; c=t; }
if (a > b) { uint16_t t=a; a=b; b=t; }
return b; // b é a mediana
}
typedef struct {
uint16_t x1, x2;
uint8_t primed;
} median3_t;
void median3_init(median3_t *f) {
f->x1 = f->x2 = 0;
f->primed = 0;
}
uint16_t median3_update(median3_t *f, uint16_t x) {
if (!f->primed) {
f->x2 = f->x1;
f->x1 = x;
if (f->x2 != 0) f->primed = 1;
return x;
}
uint16_t y = median3_u16(f->x2, f->x1, x);
f->x2 = f->x1;
f->x1 = x;
return y;
}
Exemplo completo: mediana + IIR (bom “default” para ATmega e ESP32)
Aqui fica o esqueleto de uma função de “leitura filtrada” que você pode usar em uma task/loop periódico. Você substitui adc_read_raw() por sua leitura real.
#include <stdint.h>
// ---- mocks / stubs: substitua pela sua HAL ----
uint16_t adc_read_raw(void); // ATmega: ADC; ESP32: adc oneshot raw
// ----------------------------------------------
typedef struct {
median3_t med;
iir1_q15_t lp;
} adc_filter_t;
void adc_filter_init(adc_filter_t *f, uint16_t alpha_q15) {
median3_init(&f->med);
iir1_q15_init(&f->lp, alpha_q15);
}
uint16_t adc_read_filtered(adc_filter_t *f) {
uint16_t x = adc_read_raw();
uint16_t x_med = median3_update(&f->med, x);
uint16_t y = iir1_q15_update(&f->lp, x_med);
return y;
}
No ATmega, isso costuma caber confortavelmente e resolve 80% dos casos reais. No ESP32, você pode manter igual ou trocar o IIR para float se quiser ajustar α dinamicamente com mais facilidade, mas não é necessário.
Quando isso não basta (e o que fazer)
Se o seu ruído é “bem comportado” (aleatório, de alta frequência), média móvel e IIR resolvem. Se o ruído é dominado por rede elétrica (50/60 Hz) acoplando no sensor, muitas vezes vale mais a pena atacar na causa (impedância de fonte, RC analógico antes do ADC, referência e aterramento) e depois, no firmware, usar um filtro que tenha rejeição nessa frequência, como um notch discreto ou uma média sincronizada com o período (quando você sabe a fase). E se o ADC está sofrendo com fonte de alta impedância, nenhum filtro compensa totalmente: aí você melhora o circuito (buffer com op-amp, capacitor no sample/hold, tempo de aquisição maior, etc.) antes de “pedir milagre” ao DSP.