跳转至

02 基础滤波算法:限幅、平均、滞后与消抖

这一章的算法大多很短,但工程价值极高。它们是 MCU 测量程序里的螺丝刀和扳手——形式简单,不代表可以随便用。

先看选型表

遇到测量问题时,先判断问题类型,再选工具:

问题 优先考虑 不适合
偶发尖峰,幅度远超正常波动 限幅、中值 平均(会被拉偏)
随机抖动,没有明显尖峰 平均、滑动平均、一阶滞后 中值(对连续抖动效果差)
显示稳定但不能太慢 一阶滞后、短窗口滑动平均 长窗口平均(延迟太大)
按键/开关信号跳变 消抖 任何连续信号滤波器
阈值附近反复开关 迟滞 单阈值判断

这个表不是绝对规则,而是一个起点。读完本章后,你应该能理解为什么这些搭配成立、什么时候会失效。

线性与非线性的区分

本章算法要分成两类,这决定了你用什么工具分析它们:

线性时不变(LTI)滤波器:平均滤波、滑动平均、一阶滞后。它们满足叠加性——输入放大多少,输出也放大多少;两个输入相加,输出也相加。这类滤波器可以写出频率响应、截止频率、群延迟。

非线性规则:限幅、中值、消抖、迟滞。它们不满足叠加性,不能用固定截止频率描述。分析它们要看阈值逻辑、状态机、异常值处理能力。

这一区分很重要。专业不是把所有算法都套同一套公式,而是知道什么场景该用什么分析工具。

限幅滤波

ADC 偶尔会输出一个离谱的值——可能是电源开关噪声、接触不良、或者 ADC 内部错误。这种尖峰通常只持续 1~3 个采样点,但幅度可能是正常值的几倍甚至几十倍。限幅滤波的思路是:真实物理量不可能在一个采样周期内变化太多。 如果新值和上一次有效值相差超过合理范围,就认为新值可疑。

两种策略

限幅有两种做法,不要混为一谈:

策略一:拒绝(reject)。超过阈值就丢弃新值,继续用上一次的输出。

如果 |x[n] - y[n-1]| > D,则 y[n] = y[n-1](保持不变)
否则 y[n] = x[n]

策略二:夹住(clamp)。超过阈值就限制变化幅度,也叫速率限制(rate limiter)。

如果 x[n] > y[n-1] + D,则 y[n] = y[n-1] + D
如果 x[n] < y[n-1] - D,则 y[n] = y[n-1] - D
否则 y[n] = x[n]

两种策略的区别:拒绝策略会让输出在尖峰期间保持不动;夹住策略会让输出以最大允许速率跟踪输入。后者更适合需要连续跟踪的场景,前者更适合输入可以短暂缺失的场景。

MCU C 实现

#include <stdint.h>

/* 夹住策略:限制单次最大变化幅度 */
float limit_filter_clamp(float x, float last, float max_step)
{
    if (max_step < 0.0f) max_step = 0.0f; /* 负步长无意义 */
    if (x > last + max_step) return last + max_step;
    if (x < last - max_step) return last - max_step;
    return x;
}

/* 拒绝策略:超过阈值就保持上次输出 */
float limit_filter_reject(float x, float last, float max_step)
{
    if (max_step < 0.0f) max_step = 0.0f; /* 负步长无意义 */
    if (x > last + max_step) return last;
    if (x < last - max_step) return last;
    return x;
}

这段代码要注意:两个函数的区别只有一行——夹住返回 last ± max_step,拒绝返回 last。选择哪种取决于你的应用场景。

参数选择。 D(单次最大变化量)要根据物理变化速度设置。例如温度测量:传感器每秒最多变化 2°C,采样周期 0.1 秒,则单次合理变化约 0.2°C,再留一些余量可以设 D = 0.3°C。常见误区是把 D 设得太小,这样真实快速变化也会被当成干扰,输出像被绑住一样跟不上。

代价和边界。 限幅不能处理连续多个异常值——如果尖峰持续多个采样点,夹住策略会以最大速率偏离真实值。D 的选择依赖对物理变化速度的了解,如果不确定,宁可设大一些,先保证不卡住正常信号。限幅是非线性的,没有频率响应可言。

验证方法。 在正常数据中人为插入几个尖峰,观察限幅后输出是否被夹住或拒绝。同时检查正常快速变化时输出是否跟得上。

什么时候不该用。 噪声是连续小幅抖动(不是偶发尖峰)时,限幅无能为力。信号本身可能快速变化时(比如电机启动时的电流冲击),限幅会把它当成异常。

中值滤波

中值滤波专门对付孤立尖峰。它取一组数据排序后的中间值,而不是平均值。少数极端值不会影响中间位置。

直觉解释

以 3 点中值为例,三个数排序后为:

a <= b <= c

此时中值就是 b。如果其中一个点是尖峰,例如 {100, 101, 800},排序后中间仍接近正常值 101,不会像平均值那样被 800 拉偏。

边界条件

中值滤波不是万能的。它的有效性依赖一个条件:窗口内异常值数量不能超过窗口长度的一半。

以 3 点中值为例,如果 3 个点里有 2 个是尖峰,中值就是尖峰之一。5 点中值能容忍 2 个异常值,7 点能容忍 3 个。

所以中值滤波适合"偶尔出现"的尖峰,不适合"密集出现"的异常。

MCU C 实现

#include <stdint.h>

/* 3 点中值:通过三次比较交换排序 */
float median3(float a, float b, float c)
{
    if (a > b) { float t = a; a = b; b = t; }
    if (b > c) { float t = b; b = c; c = t; }
    if (a > b) { float t = a; a = b; b = t; }
    return b;
}

这段代码要注意:三次比较交换是 3 点排序的最少比较次数。窗口更大时(5 点、7 点),排序逻辑会更复杂,但原理相同。

参数选择。 窗口长度通常取奇数,例如 3、5、7。窗口越大,抗尖峰能力越强,但延迟也越大。3 点窗口最多容忍 1 个异常值,5 点最多容忍 2 个,7 点最多容忍 3 个。如果尖峰通常只持续 1 个采样点,3 点中值就够了;持续 2 个采样点需要 5 点;持续 3 个采样点时 5 点可能失效,应选 7 点或更大。

代价和边界。 中值滤波是非线性的,它没有像滑动平均那样简单固定的频率响应,也不能严谨地说"截止频率是多少"。它不适合只含普通小幅白噪声的场景——它能去尖峰,但平滑连续随机抖动不如平均类算法自然。窗口越大,排序计算量越大。

验证方法。 在正常数据中插入单个尖峰(幅度为正常值的 5~10 倍),观察中值输出是否不受影响。然后插入多个连续尖峰,观察窗口多大时开始失效。

什么时候不该用。 噪声是连续小幅抖动时,中值不如平均类算法。异常值出现频率很高、超过窗口内一半位置时,中值也会被污染。

算术平均与滑动平均

先记住: 平均滤波用窗口长度 \(N\) 换噪声降低,但代价是延迟和更低截止频率。平均 4 次,随机噪声标准差约减半;平均 16 次,标准差约变成四分之一。

这两个算法本质相同,都是 N 点矩形窗 FIR 低通滤波器。区别只在实现方式:算术平均每次重新求和,滑动平均用递推方式更新。

噪声为什么会降低

设真实值为 s,噪声为 e[i],采样值为:

\[ x[i] = s + e[i] \]

N 点平均为:

\[ \begin{aligned} y &= \frac{x[0] + x[1] + \cdots + x[N-1]}{N} \\ &= s + \frac{e[0] + e[1] + \cdots + e[N-1]}{N} \end{aligned} \]

如果噪声相互独立、方差为 \(\sigma^2\),那么 N 点平均后的噪声方差变为:

\[ \sigma_y^2 = \frac{\sigma^2}{N} \]

标准差变为:

\[ \sigma_y = \frac{\sigma}{\sqrt{N}} \]

平均 4 次,随机噪声标准差约减半;平均 16 次,标准差约变成四分之一。 它不是无限免费变好,因为窗口越大,延迟越大,截止频率越低。

平均滤波为什么是 FIR

对连续运行的 N 点平均,可以写成:

\[ y[n] = \frac{1}{N}\left(x[n] + x[n-1] + \cdots + x[n-N+1]\right) \]

它的冲激响应是:

\[ h[0] = h[1] = \cdots = h[N-1] = \frac{1}{N} \]

因为冲激响应有限,所以它天然稳定;因为系数左右对称,所以它具有线性相位。

N 点平均的幅频响应为:

\[ \left|H(e^{j\omega})\right| = \left| \frac{\sin(N\omega/2)}{N\sin(\omega/2)} \right| \]

先抓住三件事:

  1. 低频处 \(|H|\) 接近 1,所以慢变化能通过。
  2. 高频处通常被衰减,所以随机抖动会变小。
  3. \(\sin(N\omega/2) = 0\) 时,幅度为 0,这些位置叫零点。

零点对应频率为:

\[ f_{\text{zero}} = \frac{m f_s}{N}, \quad m = 1, 2, \ldots, N-1 \]

第一个零点是 \(f_s/N\)。如果采样率 \(f_s = 1000\;\text{Hz}\),窗口 \(N = 20\),第一个零点就是 \(50\;\text{Hz}\)。这意味着 50 Hz 正弦干扰在理想情况下会被完全压掉。这不是玄学,是矩形窗平均的零点决定的。

-3 dB 截止频率没有初等函数形式的精确闭式解。它需要求解 \(\text{sinc}(N\omega_c/2) = 1/\sqrt{2}\),其中 \(\text{sinc}(x) = \sin(x)/x\)。数值求解可得 \(N\omega_c/2 \approx 1.393\),即 \(\sin(1.393)/1.393 \approx 1/\sqrt{2}\),因此:

\[ f_c \approx \frac{1.393}{\pi} \cdot \frac{f_s}{N} \approx 0.443 \frac{f_s}{N} \]

工程上当 \(N \ge 4\) 时这个近似足够准确。

群延迟为:

\[ \text{delay} = \frac{N - 1}{2 f_s} \]

例如 \(f_s = 1000\;\text{Hz}\)

N 近似 -3 dB 截止频率 第一零点 群延迟
4 110.8 Hz 250 Hz 1.5 ms
8 55.4 Hz 125 Hz 3.5 ms
16 27.7 Hz 62.5 Hz 7.5 ms
32 13.8 Hz 31.25 Hz 15.5 ms

所以平均滤波的核心取舍不是"平均几次更稳",而是:

\[ N \uparrow \quad \Rightarrow \quad \text{噪声更小,截止频率更低,延迟更大} \]

窗口长度、截止频率和延迟

如果你希望 N 点平均的 -3 dB 截止频率大约是 \(f_c\),采样率为 \(f_s\),可以反推:

\[ N \approx 0.443 \frac{f_s}{f_c} \]

例如希望在 \(f_s = 200\;\text{Hz}\) 采样时得到约 \(5\;\text{Hz}\) 截止频率:

\[ N \approx 0.443 \times \frac{200}{5} = 17.72 \]

可以选 \(N = 18\)。如果为了环形缓冲区优化选 \(N = 16\),要知道此时截止频率会变成:

\[ f_c \approx 0.443 \times \frac{200}{16} = 5.54\;\text{Hz} \]

这就是参数设计,而不是凭感觉写一个 for 循环平均 10 次。

MCU 上怎么实现

算术平均:每次重新求和,适合窗口较小或不连续运行的场景。

#include <stdint.h>

float mean_filter(const float *buf, uint16_t n)
{
    if (n == 0) return 0.0f; /* 无样本时返回 0,调用方应避免此情况 */
    float sum = 0.0f;
    for (uint16_t i = 0; i < n; i++) {
        sum += buf[i];
    }
    return sum / (float)n;
}

这段代码要注意:计算量和 N 成正比,窗口大时每次调用都要遍历整个数组。

滑动平均:用"加新值、减旧值"的方法递推更新,计算量从 \(O(N)\) 降到 \(O(1)\)

\[ \text{sum}[n] = \text{sum}[n-1] + x[n] - x[n-N] \]
\[ y[n] = \frac{\text{sum}[n]}{N} \]

注意:在窗口尚未填满的启动阶段,如果分母使用当前已有样本数,滤波器是随时间变化的,严格说还不是固定的 LTI 系统。频率响应、截止频率、群延迟这些结论,是在窗口填满并使用固定 N 后成立的。

#include <stdint.h>

#define MOVING_AVG_N 16

typedef struct {
    float buf[MOVING_AVG_N];   /* 环形缓冲区 */
    uint8_t idx;               /* 下一次要覆盖的位置 */
    uint16_t count;            /* 启动阶段已填入的样本数 */
    float sum;                 /* 当前窗口内样本总和 */
    uint32_t tick;             /* 调用计数器,用于定期修正浮点漂移 */
} MovingAvg;

/* 初始化:必须在首次调用前执行 */
void moving_avg_init(MovingAvg *f)
{
    for (uint8_t i = 0; i < MOVING_AVG_N; i++) {
        f->buf[i] = 0.0f;
    }
    f->idx = 0;
    f->count = 0;
    f->sum = 0.0f;
    f->tick = 0;
}

float moving_avg_update(MovingAvg *f, float x)
{
    /* 先减掉即将被覆盖的旧样本 */
    f->sum -= f->buf[f->idx];

    /* 写入新样本,并把新样本加入总和 */
    f->buf[f->idx] = x;
    f->sum += x;

    /* 环形缓冲区索引回绕 */
    f->idx++;
    if (f->idx >= MOVING_AVG_N) f->idx = 0;

    /* 启动阶段还没填满窗口时,分母用实际样本数 */
    if (f->count < MOVING_AVG_N) f->count++;

    /* 每 1024 次调用重新求和一次,修正浮点累积漂移 */
    f->tick++;
    if ((f->tick & 0x3FF) == 0) {
        f->sum = 0.0f;
        for (uint16_t i = 0; i < f->count; i++) {
            f->sum += f->buf[i];
        }
    }

    return f->sum / f->count;
}

这段代码要注意两点:

  • moving_avg_init 必须在首次调用前执行,否则环形缓冲区和 sum 是随机值。
  • 启动阶段 count 逐渐增加,分母不是固定 N,此时频率响应特性尚未建立。

代价和边界。 平均滤波对突发尖峰无能为力——一个极端值会把平均值拉偏,恢复需要 N 个采样周期。窗口越大,RAM 占用越多。算术平均计算量和 N 成正比,滑动平均是常数。

验证方法。 测稳态抖动(滤波后输出在稳态时的最大最小值之差)、测响应时间(输入阶跃后输出达到 95% 所需的时间)、测尖峰恢复(插入一个 10 倍尖峰,观察输出偏离多少、多久恢复)。

什么时候不该用。 信号中有明显尖峰干扰时,应先限幅或中值处理。需要快速响应阶跃变化时,长窗口平均延迟太大。RAM 非常紧张时,连几个 float 都放不下。

一阶滞后滤波

先记住: 一阶滞后是 MCU 里最划算的低通滤波器。它只保存上一次输出,不需要缓冲区,代码最短,计算量最小。这里的 \(\alpha\) 读作 alpha,是一阶滞后的平滑系数,取值通常在 0 到 1 之间。它表示每次更新时,新输入参与修正输出的比例——\(\alpha\) 越小越平滑但越慢,\(\alpha\) 越大越快但噪声越明显。

直觉解释

新输出等于"旧输出的一部分"加"新输入的一部分":

\[ y[n] = (1-\alpha)y[n-1] + \alpha x[n] \]

把它整理一下:

\[ y[n] = y[n-1] + \alpha\left(x[n] - y[n-1]\right) \]

第二种写法更适合写代码。\(x[n] - y[n-1]\) 是当前输入和旧输出之间的误差,\(\alpha\) 决定这次修正误差的多少。

作为一阶 IIR

\(a = 1 - \alpha\),公式可写成:

\[ y[n] = \alpha x[n] + a y[n-1] \]

它的系统函数是:

\[ H(z) = \frac{\alpha}{1 - a z^{-1}} \]

因为输出 \(y[n]\) 依赖上一时刻输出 \(y[n-1]\),所以它是 IIR,也就是无限冲激响应滤波器。它的冲激响应不是有限长度,而是按指数衰减:

\[ h[n] = \alpha(1-\alpha)^n,\quad n \ge 0 \]

这也解释了"指数平滑"这个名字。

一阶滞后的频率特性

\(z\) 换成 \(e^{j\omega}\),一阶滞后的幅频响应为:

\[ \left|H(e^{j\omega})\right| = \frac{\alpha}{\sqrt{1 + a^2 - 2a\cos(\omega)}} \]

其中 \(a = 1 - \alpha\)\(\omega = 2\pi f / f_s\)

它的 -3 dB 截止频率满足:

\[ \cos(\omega_c) = 1 - \frac{\alpha^2}{2(1-\alpha)} \]

因此:

\[ f_c = \frac{f_s}{2\pi} \arccos\left( 1 - \frac{\alpha^2}{2(1-\alpha)} \right) \]

注意:这个精确截止点只在衰减能达到 -3 dB 时存在。当 \(\alpha\) 较大时,Nyquist 频率处的衰减可能不到 -3 dB,此时不存在严格意义上的截止频率。具体来说,Nyquist 频率处(\(\omega = \pi\))的幅度为 \(|H(e^{j\pi})| = \alpha / (2 - \alpha)\),令其等于 \(1/\sqrt{2}\) 解得 \(\alpha \approx 0.828\)。所以当 \(\alpha > 0.828\) 时严格 -3 dB 截止点不存在。工程常用 \(\alpha < 0.5\) 基本不会遇到这个问题。

\(f_c\) 远小于 \(f_s\) 时,可以用更简单的近似:

\[ f_c \approx \frac{\alpha f_s}{2\pi} \]

这个近似在 \(\alpha < 0.3\) 时误差约 2% 以内;\(\alpha\) 再大误差会显著增加。如果 \(\alpha\) 较大,应使用上面的精确公式或下面的指数映射公式。

反过来,如果已知目标截止频率 \(f_c\),可以用线性近似:

\[ \alpha \approx 2\pi\frac{f_c}{f_s} \]

但这个线性近似只在 \(f_c / f_s\) 很小时(比如 < 0.01)误差才小。当 \(f_c / f_s = 0.05\) 时,线性近似得 \(\alpha \approx 0.314\),而指数映射得 \(\alpha \approx 0.270\),差了约 16%。实际参数设计时优先用下面的指数映射公式。

更稳妥的工程设计常用连续一阶低通在零阶保持输入下的离散化(也叫 step response matching 或 matched-z):

\[ \alpha = 1 - \exp\left(-2\pi\frac{f_c}{f_s}\right) \]

这不是双线性变换(bilinear transform),双线性变换会有频率压缩效应(warping),映射公式不同。当 \(f_c/f_s\) 很小时两者结果接近;\(f_c/f_s\) 较大时指数映射更准确。

延迟与阶跃响应

一阶滞后没有滑动平均那样固定的线性相位群延迟,它的延迟随频率变化。低频附近(远低于截止频率)的等效群延迟约为:

\[ \text{delay}_{\text{samples}} \approx \frac{1-\alpha}{\alpha} \]

例如 \(\alpha = 0.1\),低频等效延迟约 9 个采样周期。在截止频率附近及更高频率,群延迟会明显减小,不能直接用这个近似。

对单位阶跃输入(假设初始输出为零,阶跃从 \(n=0\) 开始),输出为:

\[ y[n] = 1 - (1-\alpha)^{n+1} \]

所以 \(\alpha\) 越小,指数项衰减越慢,输出越稳也越慢。注意:代码中初始化为第一个输入值(而非零),所以实际阶跃响应会从初始值开始收敛,公式形式略有不同,但收敛速度和时间常数的规律不变。

工程上常用"达到最终值 90% 或 95% 所需的时间"来衡量响应速度。对于一阶系统,时间常数 \(\tau\) 和系数 \(\alpha\) 的关系是:

\[ \tau = -\frac{T_s}{\ln(1 - \alpha)}, \quad \text{达到 95\%} \approx 3\tau \]

例如采样率 100 Hz(\(T_s = 10\;\text{ms}\)),\(\alpha = 0.1\)

\[ \tau = \frac{-0.01}{\ln(0.9)} \approx 0.095\;\text{s}, \quad 95\% \text{ 响应} \approx 0.285\;\text{s} \]

参数怎么选

\(\alpha\) 越大,截止频率越高,响应越快,抖动越明显;\(\alpha\) 越小,截止频率越低,越平滑,延迟越大。常用范围是 0.01 到 0.5,但专业设计时应优先从采样率和目标截止频率反推,而不是凭感觉选。

反推步骤:

  1. 确定采样率 \(f_s\) 和目标截止频率 \(f_c\)
  2. 计算 \(\alpha = 1 - \exp(-2\pi f_c / f_s)\)
  3. 验证响应时间是否满足要求(\(3\tau\) 是否在允许范围内)。

MCU C 实现

#include <stdint.h>

typedef struct {
    float y;       /* 上一次输出 */
    uint8_t init;  /* 是否完成初始化 */
} Lowpass1st;

void lowpass_1st_init(Lowpass1st *f)
{
    f->y = 0.0f;
    f->init = 0;
}

float lowpass_1st_update(Lowpass1st *f, float x, float alpha)
{
    if (!f->init) {
        /* 第一次调用,用输入值初始化,避免启动瞬态 */
        f->y = x;
        f->init = 1;
        return x;
    }

    /* 钳位 alpha 到合法范围 */
    if (alpha < 0.0f) alpha = 0.0f;
    if (alpha > 1.0f) alpha = 1.0f;

    /* x - y 是当前输入和上次输出的差值 */
    /* alpha 只取其中一部分来更新输出 */
    f->y = f->y + alpha * (x - f->y);
    return f->y;
}

这段代码要注意三点:

  • init 用来避免启动瞬态——第一次调用时直接用输入值初始化,而不是从 0 开始。
  • alpha 应限制在 0 到 1 之间。超出范围时滤波器行为不可预测。
  • 无 FPU 的 MCU 可以改成定点实现:把 alpha 放大为 2 的幂次倍再移位。

代价和边界。 一阶滞后对尖峰干扰没有特殊抵抗力——一个极端值会通过 \(\alpha\) 的比例进入输出,恢复时间约 \(1/\alpha\) 个采样周期。它只有 -20 dB/十倍频的衰减速度,对高频噪声的抑制能力有限。\(\alpha\) 太大(接近 1)时,滤波器几乎不起作用;\(\alpha\) 太小时,响应极慢。

验证方法。 测截止频率(用正弦波扫频,观察输出幅度降到 0.707 时的频率)、测阶跃响应(输入阶跃后记录输出达到 63%、95% 的时间)、和滑动平均对比(相同截止频率下,一阶滞后延迟更小但高频衰减更慢)。

什么时候不该用。 需要快速响应阶跃变化且不允许过冲时(一阶滞后没有过冲,但响应慢)。信号中有明显尖峰时,应先限幅或中值处理。需要更陡峭的截止特性时,考虑二阶 IIR 或 FIR。

消抖滤波

机械触点(按键、继电器、干簧管)闭合时不会一步到位,而是会在几毫秒内反复跳变。一个按键的典型抖动时间是 5~20 ms,抖动次数 3~10 次。如果直接读取触点状态,一次按键可能被误判为多次。消抖本质上是一个"连续计数"问题:输入状态必须连续稳定一段时间,才承认状态改变。

状态规则

设当前原始输入为 raw,上一次原始输入为 last_raw

  • 如果两次相同,稳定计数 cnt 加 1。
  • 如果两次不同,说明状态又变了,计数清零。
  • 只有 cnt >= need 时,才更新稳定输出。
如果 raw == last_raw:
    cnt = min(cnt + 1, need)
否则:
    last_raw = raw
    cnt = 0

如果 cnt >= need:
    stable = raw

MCU C 实现

#include <stdint.h>

typedef struct {
    uint8_t stable;    /* 已确认的稳定状态 */
    uint8_t last_raw;  /* 上一次读到的原始状态 */
    uint16_t cnt;      /* 原始状态连续不变的次数 */
} Debounce;

/* 初始化:必须在首次调用前执行,否则结构体成员是随机值 */
void debounce_init(Debounce *d, uint8_t initial_state)
{
    d->stable = initial_state;
    d->last_raw = initial_state;
    d->cnt = 0;
}

uint8_t debounce_update(Debounce *d, uint8_t raw, uint16_t need)
{
    if (raw == d->last_raw) {
        /* 原始输入没有变化,稳定计数增加 */
        if (d->cnt < need) d->cnt++;
    } else {
        /* 原始输入发生变化,重新开始计数 */
        d->last_raw = raw;
        d->cnt = 0;
    }

    /* 连续稳定时间达到要求后,才承认新状态 */
    if (d->cnt >= need) d->stable = raw;

    return d->stable;
}

这段代码要注意:debounce_init 必须在首次调用前执行,否则 stablelast_rawcnt 是随机值,输出不可预测。initial_state 通常设为按键的初始状态(未按下时为 0 或 1)。

参数选择。 need 的值取决于采样周期和抖动时间。如果每 1 ms 调用一次,need = 20 表示需要稳定约 20 ms。典型按键抖动 5~20 ms,采样周期 1 ms 时,need 可以设 10~30。

代价和边界。 消抖引入延迟——状态变化至少需要 need 个采样周期才能被确认。消抖是非线性的,不能用频率响应分析。它只适用于数字量(0/1),不适用于连续模拟信号。

验证方法。 用示波器观察原始触点波形和消抖后输出,确认抖动被消除、真实状态切换的延迟在可接受范围内。

什么时候不该用。 输入是连续变化的模拟信号时。抖动时间很短、不需要消抖时(比如数字传感器输出)。

软件迟滞滤波

如果只有一个阈值,输入在阈值附近抖动时,输出会反复开关。这在液位报警、温控开关、电池欠压保护等场景中很常见。迟滞用两个阈值解决这个问题:上升时用高阈值,下降时用低阈值。

状态规则

普通单阈值判断只有一个边界:

x >= T -> 1
x <  T -> 0

迟滞判断有两个边界:

当前为 0 时,只有 x >= high_th 才变成 1
当前为 1 时,只有 x <= low_th  才变成 0

中间区域不是"不知道",而是"保持原状态"。

MCU C 实现

#include <stdint.h>

uint8_t hysteresis_update(float x, uint8_t state,
                          float low_th, float high_th)
{
    /* 保证阈值顺序正确 */
    if (low_th > high_th) {
        float tmp = low_th;
        low_th = high_th;
        high_th = tmp;
    }

    /* 原来是关闭状态,必须越过高阈值才打开 */
    if (!state && x >= high_th) state = 1;

    /* 原来是打开状态,必须跌破低阈值才关闭 */
    if ( state && x <= low_th)  state = 0;

    /* 在 low_th 和 high_th 之间时,状态不变 */
    return state;
}

这段代码要注意:high_th 必须大于 low_th,否则回差带为负,逻辑会出错。两个阈值之间的区域叫回差带,输入在此范围内时输出保持原状态。

参数选择。 回差带宽度(high_th - low_th)要大于输入在阈值附近的正常抖动幅度。例如温度控制,传感器在阈值附近抖动 ±0.5°C,回差带可以设 2°C(high_th = 72low_th = 70)。回差带太小,起不到防抖作用;回差带太大,控制精度下降。

代价和边界。 迟滞是非线性的,不能用频率响应分析。回差带内的真实变化会被忽略——如果需要精确控制,迟滞可能不合适。它只适用于数字量输出(开/关),不适用于连续控制。

验证方法。 让输入在阈值附近缓慢变化,观察输出是否稳定。如果输出仍然反复开关,说明回差带太小。

什么时候不该用。 需要连续输出而不是开关控制时。输入抖动幅度很小、单阈值就够用时。

小结:基础算法怎么选

场景 优先算法 主要代价
偶发尖峰 限幅、中值 可能误伤真实快速变化(限幅);窗口内异常过半时失效(中值)
随机抖动 平均、滑动平均、一阶滞后 延迟增加;窗口越大/α 越小,响应越慢
按键/开关抖动 消抖 状态确认延迟(need 个采样周期)
阈值附近抖动 迟滞 回差带内不响应

这些基础算法不是互相替代的关系,而是互相配合。实际项目中,常见做法是先限幅去尖峰,再平均或一阶滞后平滑抖动。组合使用时,顺序很重要——先剔除异常值,再做平滑。

学完第一阶段,试着不翻书独立回答下面这些问题:

  • ADC 数值偶尔跳很大,先试什么?
  • 数值随机抖动但没有明显尖峰,用什么?
  • 按键为什么不能直接读一次就判断?
  • 阈值控制为什么要有回差?
  • 平滑越强,为什么响应越慢?

回答不上来也没关系,回到对应章节再看一遍。算法理解不是一次到位的,学到后面再回头看,往往会有新的理解。